diff --git a/pyproject.toml b/pyproject.toml index 3336af22d..9907182a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-langchain" -version = "0.11.4" +version = "0.11.5" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath>=2.10.69, <2.11.0", + "uipath>=2.10.70, <2.11.0", "uipath-core>=0.5.15, <0.6.0", "uipath-platform>=0.1.45, <0.2.0", "uipath-runtime>=0.10.0, <0.11.0", diff --git a/src/uipath_langchain/agent/tools/static_args.py b/src/uipath_langchain/agent/tools/static_args.py index 66ed77698..6d7b67b09 100644 --- a/src/uipath_langchain/agent/tools/static_args.py +++ b/src/uipath_langchain/agent/tools/static_args.py @@ -2,6 +2,7 @@ import copy import logging +import re from typing import Any, Iterator, Mapping, Sequence, TypeVar from jsonpath_ng import parse # type: ignore[import-untyped] @@ -12,6 +13,7 @@ from uipath.agent.models.agent import ( AgentToolArgumentArgumentProperties, AgentToolArgumentProperties, + AgentToolArrayBuilderArgumentProperties, AgentToolStaticArgumentProperties, AgentToolTextBuilderArgumentProperties, ) @@ -37,9 +39,15 @@ class ToolStaticArgument(BaseModel): """Tool static argument model.""" value: Any + display_value: Any is_sensitive: bool +_INDEX_AND_REST_REGEX = re.compile(r"^\[(\d+)\](.*)$") + +_SENSITIVE_ITEM_PLACEHOLDER = "" + + def _resolve_argument_properties( argument_properties: Mapping[str, AgentToolArgumentProperties], agent_input: dict[str, Any], @@ -48,12 +56,15 @@ def _resolve_argument_properties( def resolve_to_static( props: AgentToolArgumentProperties, + json_path: str, ) -> ToolStaticArgument | None: - """Resolves argument and textBuilder variants to static.""" + """Resolves argument, textBuilder, and arrayBuilder variants to static.""" match props: case AgentToolStaticArgumentProperties(): return ToolStaticArgument( - value=props.value, is_sensitive=props.is_sensitive + value=props.value, + display_value=props.value, + is_sensitive=props.is_sensitive, ) case AgentToolArgumentArgumentProperties(): agent_argument = parse(props.argument_path).find(agent_input) @@ -62,30 +73,86 @@ def resolve_to_static( else: argument_value = agent_argument[0].value return ToolStaticArgument( - value=argument_value, is_sensitive=props.is_sensitive + value=argument_value, + display_value=argument_value, + is_sensitive=props.is_sensitive, ) case AgentToolTextBuilderArgumentProperties(): text_value = build_string_from_tokens(props.tokens, agent_input) return ToolStaticArgument( - value=text_value, is_sensitive=props.is_sensitive + value=text_value, + display_value=text_value, + is_sensitive=props.is_sensitive, ) + case AgentToolArrayBuilderArgumentProperties(): + return resolve_arraybuilder(json_path, argument_properties) case _: raise ValueError(f"Unsupported argument property type: {type(props)}") + def resolve_arraybuilder( + base_path: str, + argument_properties: Mapping[str, AgentToolArgumentProperties], + ) -> ToolStaticArgument: + """Build an array value from arrayBuilder indexed children. + + Only direct indexed children ``base_path[N]`` are considered; entries + with other nested properties are out of scope and silently skipped.""" + + base_with_bracket = base_path + "[" + direct_children: dict[int, AgentToolArgumentProperties] = {} + max_index = -1 + + for path, props in argument_properties.items(): + if not path.startswith(base_with_bracket): + continue + match = _INDEX_AND_REST_REGEX.match(path[len(base_path) :]) + if not match or match.group(2) != "": + continue + idx = int(match.group(1)) + direct_children[idx] = props + if idx > max_index: + max_index = idx + + runtime_items: list[Any] = [] + display_items: list[Any] = [] + for i in range(max_index + 1): + item_props = direct_children.get(i) + if item_props is None: + runtime_items.append(None) + display_items.append(None) + continue + resolved = resolve_to_static(item_props, f"{base_path}[{i}]") + if resolved is None: + runtime_items.append(None) + display_items.append(None) + else: + runtime_items.append(resolved.value) + display_value = ( + _SENSITIVE_ITEM_PLACEHOLDER + if resolved.is_sensitive + else resolved.display_value + ) + display_items.append(display_value) + + return ToolStaticArgument( + value=runtime_items, display_value=display_items, is_sensitive=False + ) + def deduplicate_argument_properties( properties: Mapping[str, AgentToolArgumentProperties], ) -> Iterator[tuple[str, AgentToolArgumentProperties]]: """Skips more specific argument properties. In effect, prioritizes parent paths over child paths.""" - sorted_paths = sorted(properties.keys()) - for i, json_path in enumerate(sorted_paths): - if i > 0 and json_path.startswith(sorted_paths[i - 1]): + last_yielded: str | None = None + for json_path in sorted(properties.keys()): + if last_yielded is not None and json_path.startswith(last_yielded): continue yield json_path, properties[json_path] + last_yielded = json_path static_args: dict[str, ToolStaticArgument] = {} for json_path, props in deduplicate_argument_properties(argument_properties): - static_arg = resolve_to_static(props) + static_arg = resolve_to_static(props, json_path) if static_arg is not None: static_args[json_path] = static_arg return static_args @@ -119,7 +186,7 @@ def _apply_static_arguments_to_schema( apply_static_value_to_schema( modified_json_schema, json_path, - static_arg.value, + static_arg.display_value, static_arg.is_sensitive, ) except SchemaModificationError as e: diff --git a/tests/agent/tools/test_static_args.py b/tests/agent/tools/test_static_args.py index b5ecf0900..a7eada029 100644 --- a/tests/agent/tools/test_static_args.py +++ b/tests/agent/tools/test_static_args.py @@ -7,7 +7,11 @@ from uipath.agent.models.agent import ( AgentToolArgumentArgumentProperties, AgentToolArgumentProperties, + AgentToolArrayBuilderArgumentProperties, AgentToolStaticArgumentProperties, + AgentToolTextBuilderArgumentProperties, + TextToken, + TextTokenType, ) from uipath_langchain.agent.tools.static_args import ( @@ -579,3 +583,244 @@ class ResourceWithProps(ArgumentPropertiesMixin): assert result == {"$['existing_param']": "exists"} assert "$['missing_param']" not in result + + +class ListInput(BaseModel): + """Input model with an array field for arrayBuilder tests.""" + + items: list[str] + api_key: str = "" + + +class TestArrayBuilder: + """Tests for AgentToolArrayBuilderArgumentProperties resolution.""" + + def test_array_builder_resolves_indexed_static_children_to_list(self): + """ArrayBuilder with indexed static children produces an ordered list.""" + + class ResourceWithProps(ArgumentPropertiesMixin): + argument_properties = { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + "$['items'][0]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="alpha" + ), + "$['items'][1]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="beta" + ), + } + + result = resolve_static_args(ResourceWithProps(), {}) + + assert result == {"$['items']": ["alpha", "beta"]} + + def test_array_builder_with_sparse_indices_fills_missing_with_none(self): + """ArrayBuilder leaves gaps as None for indices without props.""" + + class ResourceWithProps(ArgumentPropertiesMixin): + argument_properties = { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + "$['items'][0]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="first" + ), + "$['items'][2]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="third" + ), + } + + result = resolve_static_args(ResourceWithProps(), {}) + + assert result == {"$['items']": ["first", None, "third"]} + + def test_array_builder_with_no_indexed_children_produces_empty_list(self): + """ArrayBuilder with no children produces an empty list.""" + + class ResourceWithProps(ArgumentPropertiesMixin): + argument_properties = { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + } + + result = resolve_static_args(ResourceWithProps(), {}) + + assert result == {"$['items']": []} + + def test_array_builder_ignores_nested_property_children(self): + """Children with nested paths like [0]['name'] are out of scope and skipped.""" + + class ResourceWithProps(ArgumentPropertiesMixin): + argument_properties = { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + "$['items'][0]['name']": AgentToolStaticArgumentProperties( + is_sensitive=False, value="ignored" + ), + "$['items'][1]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="kept" + ), + } + + result = resolve_static_args(ResourceWithProps(), {}) + + # Only index 1 is recognized as a direct child; index 0 is skipped. + assert result == {"$['items']": [None, "kept"]} + + def test_array_builder_resolves_argument_variant_child_from_input(self): + """ArrayBuilder children of variant 'argument' resolve from agent input.""" + + class ResourceWithProps(ArgumentPropertiesMixin): + argument_properties = { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + "$['items'][0]": AgentToolArgumentArgumentProperties( + is_sensitive=False, argument_path="userId" + ), + "$['items'][1]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="literal" + ), + } + + result = resolve_static_args(ResourceWithProps(), {"userId": "u-42"}) + + assert result == {"$['items']": ["u-42", "literal"]} + + def test_array_builder_resolves_text_builder_child_from_tokens(self): + """ArrayBuilder children of variant 'textBuilder' resolve via tokens.""" + tokens = [ + TextToken(type=TextTokenType.SIMPLE_TEXT, raw_string="Hello, "), + TextToken(type=TextTokenType.VARIABLE, raw_string="input.name"), + ] + + class ResourceWithProps(ArgumentPropertiesMixin): + argument_properties = { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + "$['items'][0]": AgentToolTextBuilderArgumentProperties( + is_sensitive=False, tokens=tokens + ), + } + + result = resolve_static_args(ResourceWithProps(), {"name": "world"}) + + assert result == {"$['items']": ["Hello, world"]} + + def test_array_builder_argument_child_resolving_to_missing_becomes_none(self): + """A child argument that doesn't resolve in agent input becomes None in the list.""" + + class ResourceWithProps(ArgumentPropertiesMixin): + argument_properties = { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + "$['items'][0]": AgentToolArgumentArgumentProperties( + is_sensitive=False, argument_path="present" + ), + "$['items'][1]": AgentToolArgumentArgumentProperties( + is_sensitive=False, argument_path="missing" + ), + } + + result = resolve_static_args(ResourceWithProps(), {"present": "yes"}) + + assert result == {"$['items']": ["yes", None]} + + def test_array_builder_sensitive_child_runtime_value_preserved(self): + """Runtime value for the arrayBuilder keeps real values even when child is sensitive.""" + tool = _create_tool( + "test_tool", + { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + "$['items'][0]": AgentToolStaticArgumentProperties( + is_sensitive=True, value="super-secret" + ), + "$['items'][1]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="public" + ), + }, + args_schema=ListInput, + ) + handler = StaticArgsHandler() + handler.initialize([tool], EmptyInput(), EmptyInput) + + call = _make_tool_call("test_tool") + handler.apply_to_response([call]) + + assert call["args"]["items"] == ["super-secret", "public"] + + def test_array_builder_sensitive_child_schema_uses_hidden_placeholder(self): + """Schema modification uses display_value, so sensitive items appear as ''.""" + tool = _create_tool( + "test_tool", + { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + "$['items'][0]": AgentToolStaticArgumentProperties( + is_sensitive=True, value="super-secret" + ), + "$['items'][1]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="public" + ), + }, + args_schema=ListInput, + ) + handler = StaticArgsHandler() + processed_tools = handler.initialize([tool], EmptyInput(), EmptyInput) + + modified_tool = processed_tools[0] + assert isinstance(modified_tool.args_schema, type) and issubclass( + modified_tool.args_schema, BaseModel + ) + schema = modified_tool.args_schema.model_json_schema() + + ref = schema["properties"]["items"]["$ref"] + def_name = ref.rsplit("/", 1)[-1] + items_def = schema["$defs"][def_name] + assert items_def["type"] == "string" + + assert items_def["enum"] == ['["", "public"]'] + assert "super-secret" not in items_def["enum"][0] + + +class TestDeduplicationOfArgumentProperties: + """Tests for transitive parent tracking in deduplication.""" + + def test_dedup_skips_all_descendants_of_yielded_parent(self): + """All paths under a previously yielded parent are skipped, even across + non-contiguous siblings — i.e. last_yielded tracks the parent, not just + the immediately previous sorted path.""" + + class ResourceWithProps(ArgumentPropertiesMixin): + argument_properties = { + "$['users']": AgentToolStaticArgumentProperties( + is_sensitive=False, + value={"age": 30, "name": "alice"}, + ), + "$['users']['age']": AgentToolStaticArgumentProperties( + is_sensitive=False, value=99 + ), + "$['users']['name']": AgentToolStaticArgumentProperties( + is_sensitive=False, value="overwritten" + ), + "$['v']": AgentToolStaticArgumentProperties( + is_sensitive=False, value="1.0" + ), + } + + result = resolve_static_args(ResourceWithProps(), {}) + + assert result == { + "$['users']": {"age": 30, "name": "alice"}, + "$['v']": "1.0", + } + + def test_dedup_array_builder_swallows_indexed_children_paths(self): + """Only the arrayBuilder variant is kept, with the built list.""" + + class ResourceWithProps(ArgumentPropertiesMixin): + argument_properties = { + "$['items']": AgentToolArrayBuilderArgumentProperties(), + "$['items'][0]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="a" + ), + "$['items'][1]": AgentToolStaticArgumentProperties( + is_sensitive=False, value="b" + ), + } + + result = resolve_static_args(ResourceWithProps(), {}) + + assert result == {"$['items']": ["a", "b"]} + assert "$['items'][0]" not in result + assert "$['items'][1]" not in result diff --git a/uv.lock b/uv.lock index 7d45de6e0..e76ef7dc9 100644 --- a/uv.lock +++ b/uv.lock @@ -4344,7 +4344,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.69" +version = "2.10.70" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -4367,9 +4367,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/1c/bfb47ee7ad8822ef9f86f270a02e98df048cce96247de2f8ff001c53b5da/uipath-2.10.69.tar.gz", hash = "sha256:8976b16bff085afedc922d5ba05734dc091e6db219ef1c09251a61335e243159", size = 2942472, upload-time = "2026-05-21T11:45:06.074Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/61/60d35aadbb5baf1bb42ca7d8441a8d8b0cc924b261fac1c4c20ee6db5232/uipath-2.10.70.tar.gz", hash = "sha256:4b8e132cd8e7bf9e66830af08ef87940a203f2ad8601b4f167a8c9d5536c0544", size = 2942527, upload-time = "2026-05-21T13:41:15.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/79/8548bc3bd1b2b2e4ceed9beb96dd08df5797a968276b6ef36a0741ed8e8e/uipath-2.10.69-py3-none-any.whl", hash = "sha256:c076aa7ca394c208cbf52d4e72e06c1da2fd150d343ee9c3358672e4bc9b7877", size = 390772, upload-time = "2026-05-21T11:45:03.813Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/eee2ff7f2cf2989cdf8335a43c4627c4ad99ba275fb4fd7fe971443c4e90/uipath-2.10.70-py3-none-any.whl", hash = "sha256:79d18858c076bf5d0dba003c0f982769fac9d543d9d9c47d9b82f54c3e7ccc05", size = 390821, upload-time = "2026-05-21T13:41:13.05Z" }, ] [[package]] @@ -4388,7 +4388,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.11.4" +version = "0.11.5" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4463,7 +4463,7 @@ requires-dist = [ { name = "pillow", specifier = ">=12.1.1" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "uipath", specifier = ">=2.10.69,<2.11.0" }, + { name = "uipath", specifier = ">=2.10.70,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.15,<0.6.0" }, { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.11.0,<1.12.0" }, { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.11.0,<1.12.0" },