diff --git a/src/uipath_langchain/agent/tools/integration_tool.py b/src/uipath_langchain/agent/tools/integration_tool.py index 05024e3a..ac86bb6f 100644 --- a/src/uipath_langchain/agent/tools/integration_tool.py +++ b/src/uipath_langchain/agent/tools/integration_tool.py @@ -27,6 +27,7 @@ ToolWrapperReturnType, ) +from .schema_editing import strip_matching_enums from .structured_tool_with_argument_properties import ( StructuredToolWithArgumentProperties, ) @@ -62,7 +63,7 @@ def convert_integration_parameters_to_argument_properties( ) elif param.field_variant == "argument": value_str = str(param.value) if param.value is not None else "" - match = re.fullmatch(r"\{\{(.+?)\}\}", value_str) + match = _TEMPLATE_PATTERN.match(value_str) if not match: raise AgentStartupError( code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, @@ -81,7 +82,7 @@ def convert_integration_parameters_to_argument_properties( return result -_TEMPLATE_PATTERN = re.compile(r"^\{\{.*\}\}$") +_TEMPLATE_PATTERN = re.compile(r"^\{\{(.+?)\}\}$") def _param_name_to_segments(param_name: str) -> list[str]: @@ -133,52 +134,6 @@ def _is_param_name_to_jsonpath(param_name: str) -> str: return "$" + "".join(parts) -def _resolve_schema_ref( - root_schema: dict[str, Any], node: dict[str, Any] -) -> dict[str, Any]: - """Resolve a $ref pointer in a JSON schema node. - - Returns the referenced definition if $ref is present, - otherwise returns the node unchanged. - """ - ref = node.get("$ref") - if ref is None: - return node - parts = ref.lstrip("#/").split("/") - current = root_schema - for part in parts: - current = current[part] - return current - - -def _navigate_schema_to_field( - root_schema: dict[str, Any], segments: list[str] -) -> dict[str, Any] | None: - """Navigate a JSON schema to a leaf field using parsed path segments. - - Args: - root_schema: The root schema (needed for $ref resolution). - segments: Path segments from _parse_is_param_name. - - Returns: - The schema dict of the leaf field, or None if the path doesn't exist. - """ - current = root_schema - for seg in segments: - current = _resolve_schema_ref(root_schema, current) - if seg == "*": - items = current.get("items") - if items is None: - return None - current = items - else: - props = current.get("properties") - if props is None or seg not in props: - return None - current = props[seg] - return _resolve_schema_ref(root_schema, current) - - def strip_template_enums_from_schema( schema: dict[str, Any], parameters: list[AgentIntegrationToolParameter], @@ -206,21 +161,7 @@ def strip_template_enums_from_schema( continue segments = _param_name_to_segments(param.name) - field_schema = _navigate_schema_to_field(schema, segments) - if field_schema is None: - continue - - enum = field_schema.get("enum") - if enum is None: - continue - - cleaned = [ - v for v in enum if not (isinstance(v, str) and _TEMPLATE_PATTERN.match(v)) - ] - if not cleaned: - del field_schema["enum"] - else: - field_schema["enum"] = cleaned + strip_matching_enums(schema, segments, _TEMPLATE_PATTERN) return schema @@ -392,7 +333,7 @@ async def integration_tool_wrapper( call: ToolCall, state: AgentGraphState, ) -> ToolWrapperReturnType: - call["args"] = handle_static_args(resource, state, call["args"]) + call["args"] = handle_static_args(tool, state, call["args"]) return await tool.ainvoke(call) tool = StructuredToolWithArgumentProperties( diff --git a/src/uipath_langchain/agent/tools/schema_editing.py b/src/uipath_langchain/agent/tools/schema_editing.py index f171ced4..88b19897 100644 --- a/src/uipath_langchain/agent/tools/schema_editing.py +++ b/src/uipath_langchain/agent/tools/schema_editing.py @@ -2,6 +2,7 @@ import copy import json +import re from typing import Any from jsonpath_ng import ( # type: ignore[import-untyped] @@ -108,6 +109,37 @@ def _navigate_schema_inlining_refs( return current +def strip_matching_enums( + schema: dict[str, Any], + path_segments: list[str], + pattern: re.Pattern[str], +) -> None: + """Navigate to a field in schema and remove enum values matching the pattern. + + If the path doesn't exist in the schema, this is a no-op. + After removing matching values, if the enum becomes empty, the enum key is deleted. + + Args: + schema: The root JSON schema (modified in place). + path_segments: Path segments to navigate to the field. + pattern: Compiled regex pattern to match enum values against. + """ + try: + field_schema = _navigate_schema_inlining_refs(schema, path_segments) + except SchemaModificationError: + return + + enum = field_schema.get("enum") + if enum is None: + return + + cleaned = [v for v in enum if not (isinstance(v, str) and pattern.match(v))] + if not cleaned: + del field_schema["enum"] + else: + field_schema["enum"] = cleaned + + def _apply_sensitive_schema_modification( schema: dict[str, Any], path_parts: list[str], diff --git a/src/uipath_langchain/agent/tools/static_args.py b/src/uipath_langchain/agent/tools/static_args.py index 923ad52e..7aa71cc0 100644 --- a/src/uipath_langchain/agent/tools/static_args.py +++ b/src/uipath_langchain/agent/tools/static_args.py @@ -8,13 +8,10 @@ from langchain_core.tools import StructuredTool from pydantic import BaseModel from uipath.agent.models.agent import ( - AgentIntegrationToolParameter, - AgentIntegrationToolResourceConfig, AgentToolArgumentArgumentProperties, AgentToolArgumentProperties, AgentToolStaticArgumentProperties, AgentToolTextBuilderArgumentProperties, - BaseAgentResourceConfig, ) from uipath.agent.utils.text_tokens import build_string_from_tokens @@ -137,24 +134,20 @@ def apply_static_argument_properties_to_schema( def resolve_static_args( - resource: BaseAgentResourceConfig, + resource: Any, agent_input: dict[str, Any], ) -> dict[str, Any]: """Resolves static arguments for a given resource with a given input. Args: - resource: The agent resource configuration. - input: The input arguments passed to the agent. + resource: The agent resource configuration or tool with argument_properties. + agent_input: The input arguments passed to the agent. Returns: A dictionary of expanded arguments to be used in the tool call. """ - if isinstance(resource, AgentIntegrationToolResourceConfig): - return resolve_integration_static_args( - resource.properties.parameters, agent_input - ) - elif hasattr(resource, "argument_properties"): + if hasattr(resource, "argument_properties"): # TODO: MCP tools don't inherit from BaseAgentResourceConfig; will need to handle separately static_arguments = _resolve_argument_properties_to_static_arguments( resource.argument_properties, agent_input @@ -167,47 +160,6 @@ def resolve_static_args( return {} # to be implemented for other resource types in the future -def resolve_integration_static_args( - parameters: list[AgentIntegrationToolParameter], - agent_input: dict[str, Any], -) -> dict[str, Any]: - """Resolves static arguments for an integration tool resource. - - Args: - resource: The AgentIntegrationToolResourceConfig instance. - input: The input arguments passed to the agent. - - Returns: - A dictionary of expanded static arguments for the integration tool. - """ - - static_args: dict[str, Any] = {} - for param in parameters: - value = None - - # static parameter, use the defined static value - if param.field_variant == "static": - value = param.value - # argument parameter, extract value from agent input - elif param.field_variant == "argument": - if ( - not isinstance(param.value, str) - or not param.value.startswith("{{") - or not param.value.endswith("}}") - ): - raise ValueError( - f"Parameter value must be in the format '{{argument_name}}' when field_variant is 'argument', got {param.value}" - ) - arg_name = param.value[2:-2].strip() - # currently only support top-level arguments - value = agent_input.get(arg_name) - - if value is not None: - static_args[param.name] = value - - return static_args - - def apply_static_args( static_args: dict[str, Any], kwargs: dict[str, Any], @@ -244,12 +196,12 @@ def apply_static_args( def handle_static_args( - resource: BaseAgentResourceConfig, state: BaseModel, input_args: dict[str, Any] + resource: Any, state: BaseModel, input_args: dict[str, Any] ) -> dict[str, Any]: """Resolves and applies static arguments for a tool call. Args: - resource: The agent resource configuration. - runtime: The tool runtime providing the current state. + resource: The agent resource configuration or tool with argument_properties. + state: The current agent state. input_args: The original input arguments to the tool. Returns: A dictionary of input arguments with static arguments applied. diff --git a/tests/agent/tools/test_integration_tool.py b/tests/agent/tools/test_integration_tool.py index dce652fd..cc1fa0fe 100644 --- a/tests/agent/tools/test_integration_tool.py +++ b/tests/agent/tools/test_integration_tool.py @@ -461,6 +461,31 @@ def test_argument_parameter_with_invalid_template_raises(self): with pytest.raises(AgentStartupError): convert_integration_parameters_to_argument_properties(params) + @pytest.mark.parametrize( + "invalid_value", + [ + "{missing_closing", + "missing_opening}", + "{{missing_closing}", + "{missing_opening}}", + "no_braces_at_all", + ], + ) + def test_argument_parameter_with_malformed_braces_raises(self, invalid_value): + """Various malformed brace patterns raise AgentStartupError.""" + params = [ + AgentIntegrationToolParameter( + name="test_param", + type="string", + value=invalid_value, + field_location="body", + field_variant="argument", + ), + ] + + with pytest.raises(AgentStartupError): + convert_integration_parameters_to_argument_properties(params) + class TestStripTemplateEnumsFromSchema: """Test cases for strip_template_enums_from_schema function.""" @@ -783,7 +808,8 @@ def test_handles_ref_resolution(self): result = strip_template_enums_from_schema(schema, parameters) - config_props = result["definitions"]["Config"]["properties"] + # The $ref is inlined and modified on the inlined copy + config_props = result["properties"]["config"]["properties"] assert config_props["mode"]["enum"] == ["auto"] def test_skips_argument_param_when_schema_path_not_found(self): diff --git a/tests/agent/tools/test_static_args.py b/tests/agent/tools/test_static_args.py index bd3f7e9a..8f0ea10f 100644 --- a/tests/agent/tools/test_static_args.py +++ b/tests/agent/tools/test_static_args.py @@ -6,19 +6,15 @@ import pytest from pydantic import BaseModel, Field from uipath.agent.models.agent import ( - AgentIntegrationToolParameter, - AgentIntegrationToolProperties, - AgentIntegrationToolResourceConfig, + AgentToolArgumentArgumentProperties, AgentToolArgumentProperties, AgentToolStaticArgumentProperties, BaseAgentResourceConfig, ) -from uipath.platform.connections import Connection from uipath_langchain.agent.tools.static_args import ( apply_static_args, apply_static_argument_properties_to_schema, - resolve_integration_static_args, resolve_static_args, ) from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( @@ -29,238 +25,126 @@ class TestResolveStaticArgs: """Test cases for resolve_static_args function.""" - @pytest.fixture - def connection(self): - """Common connection object used by tests.""" - return Connection( - id="test-connection-id", name="Test Connection", element_instance_id=12345 - ) - - @pytest.fixture - def integration_properties_factory(self, connection): - """Factory for creating integration tool properties.""" - - def _create_properties(parameters=None): - return AgentIntegrationToolProperties( - method="POST", - tool_path="/api/test", - object_name="test_object", - tool_display_name="Test Tool", - tool_description="Test tool description", - connection=connection, - parameters=parameters or [], - ) - - return _create_properties + def test_resolve_static_args_with_argument_properties(self): + """Test resolve_static_args with an object that has argument_properties.""" - @pytest.fixture - def integration_resource_factory(self, integration_properties_factory): - """Factory for creating integration resource config.""" - - def _create_resource(parameters=None): - properties = integration_properties_factory(parameters) - return AgentIntegrationToolResourceConfig( - name="test_tool", - description="Test tool", - properties=properties, - input_schema={}, - ) - - return _create_resource - - def test_resolve_static_args_with_integration_resource( - self, integration_resource_factory - ): - """Test resolve_static_args with AgentIntegrationToolResourceConfig.""" - parameters = [ - AgentIntegrationToolParameter( - name="static_param", - type="string", - field_variant="static", - field_location="body", - value="static_value", - ) - ] - resource = integration_resource_factory(parameters) - agent_input = {"input_arg": "input_value"} + class ResourceWithProps: + argument_properties = { + "$['host']": AgentToolStaticArgumentProperties( + is_sensitive=False, value="api.example.com" + ), + } - result = resolve_static_args(resource, agent_input) + result = resolve_static_args(ResourceWithProps(), {"unused": "input"}) - assert result == {"static_param": "static_value"} + assert result == {"$['host']": "api.example.com"} - def test_resolve_static_args_with_unknown_resource_type(self): - """Test resolve_static_args with unknown resource type returns empty dict.""" - mock_resource = MagicMock(spec=BaseAgentResourceConfig) - agent_input = {"input_arg": "input_value"} + def test_resolve_static_args_with_static_values_of_different_types(self): + """Test resolve_static_args resolves string, integer, and object static values.""" - result = resolve_static_args(mock_resource, agent_input) + class ResourceWithProps: + argument_properties = { + "$['connection_id']": AgentToolStaticArgumentProperties( + is_sensitive=False, value="12345" + ), + "$['timeout']": AgentToolStaticArgumentProperties( + is_sensitive=False, value=30 + ), + "$['config']": AgentToolStaticArgumentProperties( + is_sensitive=False, value={"enabled": True, "retries": 3} + ), + } - assert result == {} + result = resolve_static_args(ResourceWithProps(), {"unused": "input"}) + assert result == { + "$['connection_id']": "12345", + "$['timeout']": 30, + "$['config']": {"enabled": True, "retries": 3}, + } -class TestResolveIntegrationStaticArgs: - """Test cases for resolve_integration_static_args function.""" - - def test_resolve_with_static_values(self): - """Test resolving parameters with static values.""" - parameters = [ - AgentIntegrationToolParameter( - name="connection_id", - type="string", - field_variant="static", - field_location="body", - value="12345", - ), - AgentIntegrationToolParameter( - name="timeout", - type="integer", - field_variant="static", - field_location="body", - value=30, - ), - AgentIntegrationToolParameter( - name="config", - type="object", - field_variant="static", - field_location="body", - value={"enabled": True, "retries": 3}, - ), - ] - agent_input = {"user_input": "test"} - - result = resolve_integration_static_args(parameters, agent_input) + def test_resolve_static_args_with_argument_properties_extracts_from_agent_input( + self, + ): + """Test resolve_static_args resolves AgentToolArgumentArgumentProperties from agent_input.""" - expected = { - "connection_id": "12345", - "timeout": 30, - "config": {"enabled": True, "retries": 3}, - } - assert result == expected + class ResourceWithProps: + argument_properties = { + "$['user_id']": AgentToolArgumentArgumentProperties( + is_sensitive=False, argument_path="userId" + ), + "$['query']": AgentToolArgumentArgumentProperties( + is_sensitive=False, argument_path="searchQuery" + ), + } - def test_resolve_with_input_arg_values(self): - """Test resolving parameters with input argument values.""" - parameters = [ - AgentIntegrationToolParameter( - name="user_id", - type="string", - field_variant="argument", - field_location="body", - value="{{userId}}", - ), - AgentIntegrationToolParameter( - name="query", - type="string", - field_variant="argument", - field_location="body", - value="{{searchQuery}}", - ), - ] agent_input = { "userId": "user123", "searchQuery": "test search", "unused_arg": "not_used", } - result = resolve_integration_static_args(parameters, agent_input) + result = resolve_static_args(ResourceWithProps(), agent_input) - expected = {"user_id": "user123", "query": "test search"} - assert result == expected + assert result == { + "$['user_id']": "user123", + "$['query']": "test search", + } + + def test_resolve_static_args_with_mixed_static_and_argument_properties(self): + """Test resolve_static_args with both static and argument properties.""" + + class ResourceWithProps: + argument_properties = { + "$['api_key']": AgentToolStaticArgumentProperties( + is_sensitive=False, value="secret_key" + ), + "$['user_id']": AgentToolArgumentArgumentProperties( + is_sensitive=False, argument_path="userId" + ), + "$['version']": AgentToolStaticArgumentProperties( + is_sensitive=False, value="v1" + ), + } - def test_resolve_with_mixed_static_and_argument_values(self): - """Test resolving parameters with both static and argument values.""" - parameters = [ - AgentIntegrationToolParameter( - name="api_key", - type="string", - field_variant="static", - field_location="body", - value="secret_key", - ), - AgentIntegrationToolParameter( - name="user_id", - type="string", - field_variant="argument", - field_location="body", - value="{{userId}}", - ), - AgentIntegrationToolParameter( - name="version", - type="string", - field_variant="static", - field_location="body", - value="v1", - ), - ] agent_input = {"userId": "user456"} - result = resolve_integration_static_args(parameters, agent_input) + result = resolve_static_args(ResourceWithProps(), agent_input) - expected = {"api_key": "secret_key", "user_id": "user456", "version": "v1"} - assert result == expected + assert result == { + "$['api_key']": "secret_key", + "$['user_id']": "user456", + "$['version']": "v1", + } + + def test_resolve_static_args_skips_missing_argument_values(self): + """Test that argument properties referencing missing agent_input keys are skipped.""" + + class ResourceWithProps: + argument_properties = { + "$['existing_param']": AgentToolArgumentArgumentProperties( + is_sensitive=False, argument_path="existingArg" + ), + "$['missing_param']": AgentToolArgumentArgumentProperties( + is_sensitive=False, argument_path="missingArg" + ), + } - def test_resolve_skips_none_values(self): - """Test that None values are skipped in the result.""" - parameters = [ - AgentIntegrationToolParameter( - name="existing_param", - type="string", - field_variant="argument", - field_location="body", - value="{{existingArg}}", - ), - AgentIntegrationToolParameter( - name="missing_param", - type="string", - field_variant="argument", - field_location="body", - value="{{missingArg}}", - ), - ] agent_input = {"existingArg": "exists"} - result = resolve_integration_static_args(parameters, agent_input) - - assert result == {"existing_param": "exists"} - assert "missing_param" not in result - - def test_resolve_with_invalid_argument_format_raises_error(self): - """Test that invalid argument format raises ValueError.""" - parameters = [ - AgentIntegrationToolParameter( - name="invalid_param", - type="string", - field_variant="argument", - field_location="body", - value="invalid_format", - ) - ] - - with pytest.raises(ValueError, match="Parameter value must be in the format"): - resolve_integration_static_args(parameters, {}) - - def test_resolve_with_malformed_argument_braces(self): - """Test various malformed argument brace patterns.""" - test_cases = [ - "{missing_closing", - "missing_opening}", - "{{missing_closing}", - "{missing_opening}}", - "no_braces_at_all", - ] - - for invalid_value in test_cases: - parameters = [ - AgentIntegrationToolParameter( - name="test_param", - type="string", - field_variant="argument", - field_location="body", - value=invalid_value, - ) - ] + result = resolve_static_args(ResourceWithProps(), agent_input) - with pytest.raises(ValueError): - resolve_integration_static_args(parameters, {}) + assert result == {"$['existing_param']": "exists"} + assert "$['missing_param']" not in result + + def test_resolve_static_args_with_unknown_resource_type(self): + """Test resolve_static_args with unknown resource type returns empty dict.""" + mock_resource = MagicMock(spec=BaseAgentResourceConfig) + agent_input = {"input_arg": "input_value"} + + result = resolve_static_args(mock_resource, agent_input) + + assert result == {} class TestApplyStaticArgs: