Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/uipath_langchain/agent/tools/a2a/a2a_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from uipath_langchain.agent.tools.base_uipath_structured_tool import (
BaseUiPathStructuredTool,
)
from uipath_langchain.agent.tools.static_args import wrap_tools_with_static_args
from uipath_langchain.agent.tools.tool_node import (
ToolWrapperMixin,
ToolWrapperReturnType,
Expand Down Expand Up @@ -346,7 +347,7 @@ def create_a2a_tools_and_clients(
tools.append(tool)
clients.append(a2a_client)

return tools, clients
return wrap_tools_with_static_args(tools), clients


@asynccontextmanager
Expand Down
3 changes: 2 additions & 1 deletion src/uipath_langchain/agent/tools/mcp/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
StructuredToolWithArgumentProperties,
)

from ..static_args import wrap_tools_with_static_args
from ..utils import sanitize_tool_name
from .mcp_client import McpClient, SessionInfoFactory

Expand Down Expand Up @@ -236,4 +237,4 @@ async def create_mcp_tools_and_clients(
f"Created {len(resource_tools)} tools for MCP resource '{resource.name}'"
)

return tools, clients
return wrap_tools_with_static_args(tools), clients
37 changes: 37 additions & 0 deletions src/uipath_langchain/agent/tools/schema_editing.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,40 @@ def _index_of_non_null_schema(

_inline_ref_if_present(schema, container["anyOf"], target_index)
return container["anyOf"][target_index]


def remove_fields_from_schema(
schema: dict[str, Any],
json_paths: list[str],
) -> set[str]:
"""Remove the named fields at ``json_paths`` from the schema (in place).

For each path, navigate to the parent object and drop the named field from
its ``properties`` and ``required``. Used to hide statically-configured
parameters from the model-facing tool schema. Paths that do not resolve to a
named object field (array elements, or fields absent from the schema) are
skipped. Returns the set of paths that were actually removed.
"""
removed: set[str] = set()
for json_path in json_paths:
try:
segments = parse_jsonpath_segments(json_path)
except Exception:
# A malformed path is simply not removable; leave the field in place.
continue
if not segments or segments[-1] == "*":
continue
Comment on lines +315 to +323
field_name = segments[-1]
try:
parent = _navigate_schema_inlining_refs(schema, segments[:-1])
except SchemaNavigationError:
continue
properties = parent.get("properties")
if not isinstance(properties, dict) or field_name not in properties:
continue
del properties[field_name]
required = parent.get("required")
if isinstance(required, list) and field_name in required:
required.remove(field_name)
removed.add(json_path)
return removed
126 changes: 126 additions & 0 deletions src/uipath_langchain/agent/tools/static_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
InvalidStaticArgError,
SchemaNavigationError,
apply_static_value_to_schema,
parse_jsonpath_segments,
remove_fields_from_schema,
)

from .utils import sanitize_dict_for_serialization
Expand Down Expand Up @@ -342,3 +344,127 @@
static_values = self._sanitized_static_values.get(tool_call["name"])
if static_values:
tool_call["args"] = apply_static_args(static_values, tool_call["args"])


def wrap_tool_with_static_args(tool: BaseTool) -> BaseTool:

Check failure on line 349 in src/uipath_langchain/agent/tools/static_args.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ7y_4r_DZRFUhWhNTaB&open=AZ7y_4r_DZRFUhWhNTaB&pullRequest=921
"""Wrap a tool so its *static* parameters are hidden from the model and
injected just before the underlying tool runs.

The returned tool exposes only the non-static parameters in its
``args_schema``; on invocation the configured static values are merged back
into the arguments before the original tool's ``coroutine`` / ``func`` runs.
The hidden static values are appended to the tool ``description`` (sensitive
values redacted) so the model can still distinguish otherwise-identical tools
by their configured values.

This is a per-tool, agent-state-independent counterpart to
``StaticArgsHandler``: static values are constants
(``AgentToolStaticArgumentProperties``), so no agent input is needed to
resolve them. Tools without static ``argument_properties`` are returned
unchanged, and the original tool is never mutated.
"""
properties: dict[str, AgentToolArgumentProperties] | None = getattr(
tool, "argument_properties", None
)
if not isinstance(tool, StructuredTool) or not properties:
return tool

static_properties = {
json_path: props
for json_path, props in properties.items()
if isinstance(props, AgentToolStaticArgumentProperties)
}
if not static_properties:
return tool

static_values = {
json_path: static_arg.value
for json_path, static_arg in _resolve_argument_properties(
static_properties, {}
).items()
}

args_schema = tool.args_schema
if isinstance(args_schema, dict):
schema = copy.deepcopy(args_schema)
elif args_schema is not None and issubclass(args_schema, BaseModel):
schema = args_schema.model_json_schema()
else:
return tool

removed = remove_fields_from_schema(schema, list(static_values))
if not removed:
return tool

inject_values = {
json_path: value
for json_path, value in static_values.items()
if json_path in removed
}

# Surface the now-hidden static values in the tool description so the model
# can still tell otherwise-identical tools apart (e.g. one send-email tool
# configured for Bob and another for Alice) without the user having to encode
# it in the tool name. Sensitive values are redacted.
preconfigured_lines: list[str] = []
for json_path in sorted(removed):
static_prop = static_properties[json_path]
segments = parse_jsonpath_segments(json_path)
name = ".".join(segments) if segments else json_path
if static_prop.is_sensitive:
preconfigured_lines.append(f"- {name}: <sensitive>")
else:
value_text = str(static_prop.value)
if len(value_text) > 200:
value_text = value_text[:200] + "..."
preconfigured_lines.append(f"- {name}: {value_text}")

description = tool.description
if preconfigured_lines:
description = (
f"{tool.description}\n\n"
"Pre-configured parameters (set automatically, do not provide):\n"
+ "\n".join(preconfigured_lines)
)

update: dict[str, Any] = {
"args_schema": create_model(schema),
"description": description,
# Drop the static argument properties now baked into the wrapper so the
# StaticArgsHandler still running in the ReAct llm_node does not also
# process them; non-static variants are left for it (back-compat).
"argument_properties": {
json_path: props
for json_path, props in properties.items()
if json_path not in removed
},
}

coroutine = tool.coroutine
if coroutine is not None:
_coroutine = coroutine

async def _acall(**kwargs: Any) -> Any:
return await _coroutine(**apply_static_args(inject_values, kwargs))

update["coroutine"] = _acall

func = tool.func
if func is not None:
_func = func

def _call(**kwargs: Any) -> Any:
return _func(**apply_static_args(inject_values, kwargs))

update["func"] = _call
Comment on lines +443 to +459

return tool.model_copy(update=update)


def wrap_tools_with_static_args(tools: Sequence[BaseTool]) -> list[BaseTool]:
"""Hide and inject each tool's static parameters.

See :func:`wrap_tool_with_static_args`. Tools without static parameters pass
through unchanged.
"""
return [wrap_tool_with_static_args(tool) for tool in tools]
5 changes: 4 additions & 1 deletion src/uipath_langchain/agent/tools/tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .internal_tools import create_internal_tool
from .ixp_escalation_tool import create_ixp_escalation_tool
from .process_tool import create_process_tool
from .static_args import wrap_tools_with_static_args

logger = getLogger(__name__)

Expand Down Expand Up @@ -92,7 +93,9 @@ async def create_tools_from_resources(
tool.metadata = {}
tool.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True

return tools
# Hide statically-configured tool parameters from the model and inject them
# at call time (e.g. a fixed Integration Service search `provider`).
return wrap_tools_with_static_args(tools)


async def _build_tool_for_resource(
Expand Down
Loading
Loading