From 8a8ad0e818707b2abec19c540501e81b6d1c3ba4 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Fri, 19 Dec 2025 11:12:42 +0200 Subject: [PATCH 01/15] feat: add jsonchema pydantic converter --- src/uipath_langchain/agent/react/agent.py | 2 +- src/uipath_langchain/agent/react/init_node.py | 16 +- .../react/jsonschema_pydantic_converter.py | 75 +++ src/uipath_langchain/agent/react/types.py | 5 + src/uipath_langchain/agent/react/utils.py | 268 +++++++- .../agent/tools/escalation_tool.py | 3 +- .../agent/tools/integration_tool.py | 2 +- .../agent/tools/internal_tools/__init__.py | 7 + .../internal_tools/analyze_files_tool.py | 54 ++ .../internal_tools/internal_tool_factory.py | 66 ++ .../agent/tools/process_tool.py | 3 +- .../agent/tools/tool_factory.py | 5 + tests/agent/react/test_utils.py | 636 +++++++++++++++++- 13 files changed, 1132 insertions(+), 10 deletions(-) create mode 100644 src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py create mode 100644 src/uipath_langchain/agent/tools/internal_tools/__init__.py create mode 100644 src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py create mode 100644 src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 10d0b60a..5f63a033 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -76,7 +76,7 @@ def create_agent( flow_control_tools: list[BaseTool] = create_flow_control_tools(output_schema) llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools] - init_node = create_init_node(messages) + init_node = create_init_node(messages, input_schema) tool_nodes = create_tool_node(agent_tools) tool_nodes_with_guardrails = create_tools_guardrails_subgraph( tool_nodes, guardrails diff --git a/src/uipath_langchain/agent/react/init_node.py b/src/uipath_langchain/agent/react/init_node.py index 1a1b14cf..026f1f35 100644 --- a/src/uipath_langchain/agent/react/init_node.py +++ b/src/uipath_langchain/agent/react/init_node.py @@ -3,11 +3,17 @@ from typing import Any, Callable, Sequence from langchain_core.messages import HumanMessage, SystemMessage +from pydantic import BaseModel + +from .utils import ( + get_job_attachments, +) def create_init_node( messages: Sequence[SystemMessage | HumanMessage] | Callable[[Any], Sequence[SystemMessage | HumanMessage]], + input_schema: type[BaseModel], ): def graph_state_init(state: Any): if callable(messages): @@ -15,6 +21,14 @@ def graph_state_init(state: Any): else: resolved_messages = messages - return {"messages": list(resolved_messages)} + job_attachments = get_job_attachments(input_schema, state) + job_attachments_dict = { + att.id: att for att in job_attachments if att.id is not None + } + + return { + "messages": list(resolved_messages), + "job_attachments": job_attachments_dict, + } return graph_state_init diff --git a/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py b/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py new file mode 100644 index 00000000..8614e57b --- /dev/null +++ b/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py @@ -0,0 +1,75 @@ +import inspect +import sys +from types import ModuleType +from typing import Any, Type, get_args, get_origin + +from jsonschema_pydantic_converter import transform_with_modules +from pydantic import BaseModel + +# Shared pseudo-module for all dynamically created types +# This allows get_type_hints() to resolve forward references +_DYNAMIC_MODULE_NAME = "jsonschema_pydantic_converter._dynamic" + + +def _get_or_create_dynamic_module() -> ModuleType: + """Get or create the shared pseudo-module for dynamic types.""" + if _DYNAMIC_MODULE_NAME not in sys.modules: + pseudo_module = ModuleType(_DYNAMIC_MODULE_NAME) + pseudo_module.__doc__ = ( + "Shared module for dynamically generated Pydantic models from JSON schemas" + ) + sys.modules[_DYNAMIC_MODULE_NAME] = pseudo_module + return sys.modules[_DYNAMIC_MODULE_NAME] + + +def create_model( + schema: dict[str, Any], +) -> Type[BaseModel]: + model, namespace = transform_with_modules(schema) + corrected_namespace: dict[str, Any] = {} + + def collect_types(annotation: Any) -> None: + """Recursively collect all BaseModel types from an annotation.""" + # Unwrap generic types like List, Optional, etc. + origin = get_origin(annotation) + if origin is not None: + for arg in get_args(annotation): + collect_types(arg) + + elif inspect.isclass(annotation) and issubclass(annotation, BaseModel): + # Find the original name for this type from the namespace + for type_name, type_def in namespace.items(): + # Match by class name since rebuild may create new instances + if ( + hasattr(annotation, "__name__") + and hasattr(type_def, "__name__") + and annotation.__name__ == type_def.__name__ + ): + # Store the actual annotation type, not the old namespace one + corrected_namespace[type_name] = annotation + break + + # Collect all types from field annotations + for field_info in model.model_fields.values(): + collect_types(field_info.annotation) + + # Get the shared pseudo-module and populate it with this schema's types + # This ensures that forward references can be resolved by get_type_hints() + # when the model is used with external libraries (e.g., LangGraph) + pseudo_module = _get_or_create_dynamic_module() + + # Populate the pseudo-module with all types from the namespace + # Use the original names so forward references resolve correctly + for type_name, type_def in corrected_namespace.items(): + setattr(pseudo_module, type_name, type_def) + + setattr(pseudo_module, model.__name__, model) + + # Update the model's __module__ to point to the shared pseudo-module + model.__module__ = _DYNAMIC_MODULE_NAME + + # Update the __module__ of all generated types in the namespace + for type_def in corrected_namespace.values(): + if inspect.isclass(type_def) and issubclass(type_def, BaseModel): + type_def.__module__ = _DYNAMIC_MODULE_NAME + return model diff --git a/src/uipath_langchain/agent/react/types.py b/src/uipath_langchain/agent/react/types.py index bbf017da..6c5fc1f9 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -1,9 +1,13 @@ +import uuid from enum import StrEnum from typing import Annotated, Any, Optional from langchain_core.messages import AnyMessage from langgraph.graph.message import add_messages from pydantic import BaseModel, Field +from uipath.platform.attachments import Attachment + +from uipath_langchain.agent.react.utils import add_job_attachments class AgentTerminationSource(StrEnum): @@ -22,6 +26,7 @@ class AgentGraphState(BaseModel): """Agent Graph state for standard loop execution.""" messages: Annotated[list[AnyMessage], add_messages] = [] + job_attachments: Annotated[dict[uuid.UUID, Attachment], add_job_attachments] = {} termination: AgentTermination | None = None diff --git a/src/uipath_langchain/agent/react/utils.py b/src/uipath_langchain/agent/react/utils.py index d0ed3f71..b9b44d63 100644 --- a/src/uipath_langchain/agent/react/utils.py +++ b/src/uipath_langchain/agent/react/utils.py @@ -1,11 +1,16 @@ """ReAct Agent loop utilities.""" -from typing import Any, Sequence +import sys +import uuid +from typing import Any, ForwardRef, Sequence, Union, get_args, get_origin +from jsonpath_ng import parse # type: ignore[import-untyped] from langchain_core.messages import AIMessage, BaseMessage from pydantic import BaseModel from uipath.agent.react import END_EXECUTION_TOOL -from uipath.utils.dynamic_schema import jsonschema_to_pydantic +from uipath.platform.attachments import Attachment + +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model def resolve_input_model( @@ -13,7 +18,7 @@ def resolve_input_model( ) -> type[BaseModel]: """Resolve the input model from the input schema.""" if input_schema: - return jsonschema_to_pydantic(input_schema) + return create_model(input_schema) return BaseModel @@ -23,7 +28,7 @@ def resolve_output_model( ) -> type[BaseModel]: """Fallback to default end_execution tool schema when no agent output schema is provided.""" if output_schema: - return jsonschema_to_pydantic(output_schema) + return create_model(output_schema) return END_EXECUTION_TOOL.args_schema @@ -47,3 +52,258 @@ def count_consecutive_thinking_messages(messages: Sequence[BaseMessage]) -> int: count += 1 return count + + +def add_job_attachments( + left: dict[uuid.UUID, Attachment], right: dict[uuid.UUID, Attachment] +) -> dict[uuid.UUID, Attachment]: + """Merge attachment dictionaries, with right values taking precedence. + + This reducer function merges two dictionaries of attachments by UUID. + If the same UUID exists in both dictionaries, the value from 'right' takes precedence. + + Args: + left: Existing dictionary of attachments keyed by UUID + right: New dictionary of attachments to merge + + Returns: + Merged dictionary with right values overriding left values for duplicate keys + """ + if not right: + return left + + if not left: + return right + + return {**left, **right} + + +def get_job_attachments( + schema: type[BaseModel], + data: dict[str, Any], +) -> list[Attachment]: + """Extract job attachments from data based on schema and convert to Attachment objects. + + Args: + schema: The Pydantic model class defining the data structure + data: The data object (dict or Pydantic model) to extract attachments from + + Returns: + List of Attachment objects + """ + job_attachment_paths = _get_job_attachment_paths(schema) + job_attachments = _extract_values_by_paths(data, job_attachment_paths) + + result = [] + for attachment in job_attachments: + if isinstance(attachment, BaseModel): + # Convert Pydantic model to dict and create Attachment + attachment_dict = attachment.model_dump(by_alias=True) + result.append(Attachment.model_validate(attachment_dict)) + elif isinstance(attachment, dict): + # Already a dict, create Attachment directly + result.append(Attachment.model_validate(attachment)) + else: + # Try to convert to Attachment as-is + result.append(Attachment.model_validate(attachment)) + + return result + + +def _get_target_type(model: type[BaseModel], type_name: str) -> Any: + """Get the target type from the model's module. + + Args: + model: A Pydantic model class + type_name: The name of the type to search for + + Returns: + The target type if found, None otherwise + """ + model_module = sys.modules.get(model.__module__) + if model_module and hasattr(model_module, type_name): + return getattr(model_module, type_name) + return None + + +def _create_type_matcher(type_name: str, target_type: Any) -> Any: + """Create a function that checks if an annotation matches the target type. + + Args: + type_name: The name of the type to match + target_type: The actual type object (can be None) + + Returns: + A function that takes an annotation and returns True if it matches + """ + + def matches_type(annotation: Any) -> bool: + """Check if an annotation matches the target type name.""" + if isinstance(annotation, ForwardRef): + return annotation.__forward_arg__ == type_name + if isinstance(annotation, str): + return annotation == type_name + if hasattr(annotation, "__name__") and annotation.__name__ == type_name: + return True + if target_type is not None and annotation is target_type: + return True + return False + + return matches_type + + +def _unwrap_optional(annotation: Any) -> Any: + """Unwrap Optional/Union types to get the underlying type. + + Args: + annotation: The type annotation to unwrap + + Returns: + The unwrapped type, or the original if not Optional/Union + """ + origin = get_origin(annotation) + if origin is Union: + args = get_args(annotation) + non_none_args = [arg for arg in args if arg is not type(None)] + if non_none_args: + return non_none_args[0] + return annotation + + +def _is_pydantic_model(annotation: Any) -> bool: + """Check if annotation is a Pydantic model. + + Args: + annotation: The type annotation to check + + Returns: + True if the annotation is a Pydantic model class + """ + try: + return isinstance(annotation, type) and issubclass(annotation, BaseModel) + except TypeError: + return False + + +def _get_job_attachment_paths(model: type[BaseModel]) -> list[str]: + return _get_json_paths_by_type(model, "Job_attachment") + + +def _get_json_paths_by_type(model: type[BaseModel], type_name: str) -> list[str]: + """Get JSONPath expressions for all fields that reference a specific type. + + This function recursively traverses nested Pydantic models to find all paths + that lead to fields of the specified type. + + Args: + model: A Pydantic model class + type_name: The name of the type to search for (e.g., "Job_attachment") + + Returns: + List of JSONPath expressions using standard JSONPath syntax. + For array fields, uses [*] to indicate all array elements. + + Example: + >>> schema = { + ... "type": "object", + ... "properties": { + ... "attachment": {"$ref": "#/definitions/job-attachment"}, + ... "attachments": { + ... "type": "array", + ... "items": {"$ref": "#/definitions/job-attachment"} + ... } + ... }, + ... "definitions": { + ... "job-attachment": {"type": "object", "properties": {"id": {"type": "string"}}} + ... } + ... } + >>> model = transform(schema) + >>> get_json_paths_by_type(model, "Job_attachment") + ['$.attachment', '$.attachments[*]'] + """ + + def _recursive_search( + current_model: type[BaseModel], current_path: str + ) -> list[str]: + """Recursively search for fields of the target type.""" + json_paths = [] + + # Get the target type and create a matcher function + target_type = _get_target_type(current_model, type_name) + matches_type = _create_type_matcher(type_name, target_type) + + for field_name, field_info in current_model.model_fields.items(): + annotation = field_info.annotation + + # Build the path for this field + if current_path: + field_path = f"{current_path}.{field_name}" + else: + field_path = f"$.{field_name}" + + # Unwrap Optional/Union types + annotation = _unwrap_optional(annotation) + origin = get_origin(annotation) + + # Check if this field matches the target type + if matches_type(annotation): + json_paths.append(field_path) + continue + + # Check if this is a list of the target type or nested models + if origin is list: + args = get_args(annotation) + if args: + list_item_type = args[0] + if matches_type(list_item_type): + json_paths.append(f"{field_path}[*]") + continue + # Check if it's a list of nested models + if _is_pydantic_model(list_item_type): + nested_paths = _recursive_search( + list_item_type, f"{field_path}[*]" + ) + json_paths.extend(nested_paths) + continue + + # Check if this field is a nested Pydantic model that we should traverse + if _is_pydantic_model(annotation): + nested_paths = _recursive_search(annotation, field_path) + json_paths.extend(nested_paths) + + return json_paths + + return _recursive_search(model, "") + + +def _extract_values_by_paths( + obj: dict[str, Any] | BaseModel, json_paths: list[str] +) -> list[Any]: + """Extract values from an object using JSONPath expressions. + + Args: + obj: The object (dict or Pydantic model) to extract values from + json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"]) + + Returns: + List of all extracted values (flattened) + + Example: + >>> obj = { + ... "attachment": {"id": "123"}, + ... "attachments": [{"id": "456"}, {"id": "789"}] + ... } + >>> paths = ['$.attachment', '$.attachments[*]'] + >>> extract_values_by_paths(obj, paths) + [{'id': '123'}, {'id': '456'}, {'id': '789'}] + """ + # Convert Pydantic model to dict if needed + data = obj.model_dump() if isinstance(obj, BaseModel) else obj + + results = [] + for json_path in json_paths: + expr = parse(json_path) + matches = expr.find(data) + results.extend([match.value for match in matches]) + + return results diff --git a/src/uipath_langchain/agent/tools/escalation_tool.py b/src/uipath_langchain/agent/tools/escalation_tool.py index 90e6d18e..e95506d4 100644 --- a/src/uipath_langchain/agent/tools/escalation_tool.py +++ b/src/uipath_langchain/agent/tools/escalation_tool.py @@ -3,7 +3,6 @@ from enum import Enum from typing import Any -from jsonschema_pydantic_converter import transform as create_model from langchain.tools import ToolRuntime from langchain_core.messages import ToolMessage from langchain_core.tools import StructuredTool @@ -16,6 +15,8 @@ from uipath.eval.mocks import mockable from uipath.platform.common import CreateEscalation +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model + from ..react.types import AgentGraphNode, AgentTerminationSource from .utils import sanitize_tool_name diff --git a/src/uipath_langchain/agent/tools/integration_tool.py b/src/uipath_langchain/agent/tools/integration_tool.py index 468d256f..281873fe 100644 --- a/src/uipath_langchain/agent/tools/integration_tool.py +++ b/src/uipath_langchain/agent/tools/integration_tool.py @@ -3,13 +3,13 @@ import copy from typing import Any -from jsonschema_pydantic_converter import transform as create_model from langchain_core.tools import StructuredTool from uipath.agent.models.agent import AgentIntegrationToolResourceConfig from uipath.eval.mocks import mockable from uipath.platform import UiPath from uipath.platform.connections import ActivityMetadata, ActivityParameterLocationInfo +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model from uipath_langchain.agent.tools.tool_node import ToolWrapperMixin from uipath_langchain.agent.wrappers.static_args_wrapper import get_static_args_wrapper diff --git a/src/uipath_langchain/agent/tools/internal_tools/__init__.py b/src/uipath_langchain/agent/tools/internal_tools/__init__.py new file mode 100644 index 00000000..851ee313 --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/__init__.py @@ -0,0 +1,7 @@ +"""Internal Tool creation and management for LowCode agents.""" + +from .internal_tool_factory import create_internal_tool + +__all__ = [ + "create_internal_tool" +] diff --git a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py new file mode 100644 index 00000000..d6889bac --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py @@ -0,0 +1,54 @@ +from typing import Any + +from langchain.tools import ToolRuntime +from langchain_core.tools import StructuredTool +from uipath.agent.models.agent import ( + AgentInternalToolResourceConfig, +) +from uipath.eval.mocks import mockable + +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model +from uipath_langchain.agent.tools.structured_tool_with_output_type import ( + StructuredToolWithOutputType, +) +from uipath_langchain.agent.tools.utils import sanitize_tool_name + + +def create_analyze_file_tool( + resource: AgentInternalToolResourceConfig, +) -> StructuredTool: + """ + Creates an internal tool based on the resource configuration. + + Routes to the appropriate handler based on the tool_type specified in + the resource properties. + + Args: + resource: Internal tool resource configuration + + Returns: + A structured tool that can be used by LangChain agents + + Raises: + ValueError: If schema creation fails or tool_type is not supported + """ + tool_name = sanitize_tool_name(resource.name) + input_model = create_model(resource.input_schema) + output_model = create_model(resource.output_schema) + + @mockable( + name=resource.name, + description=resource.description, + input_schema=input_model.model_json_schema(), + output_schema=output_model.model_json_schema(), + ) + async def tool_fn(runtime: ToolRuntime, **kwargs: Any): + return "Tool result message." + + return StructuredToolWithOutputType( + name=tool_name, + description=resource.description, + args_schema=input_model, + coroutine=tool_fn, + output_type=output_model, + ) diff --git a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py new file mode 100644 index 00000000..8909e957 --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py @@ -0,0 +1,66 @@ +"""Factory for creating internal agent tools. + +This module provides a factory pattern for creating internal tools used by agents. +Internal tools are built-in tools that provide core functionality for agents, such as +file analysis, data processing, or other utilities that don't require external integrations. + +Supported Internal Tools: + - ANALYZE_FILES: Tool for analyzing file contents and extracting information + +Example: + >>> from uipath.agent.models.agent import AgentInternalToolResourceConfig + >>> resource = AgentInternalToolResourceConfig(...) + >>> tool = create_internal_tool(resource) + >>> # Use the tool in your agent workflow +""" + +from typing import Callable + +from langchain_core.tools import StructuredTool +from uipath.agent.models.agent import ( + AgentInternalToolResourceConfig, + AgentInternalToolType, +) + +from .analyze_files_tool import create_analyze_file_tool + +_INTERNAL_TOOL_HANDLERS: dict[ + AgentInternalToolType, Callable[[AgentInternalToolResourceConfig], StructuredTool] +] = { + AgentInternalToolType.ANALYZE_FILES: create_analyze_file_tool, +} + + +def create_internal_tool(resource: AgentInternalToolResourceConfig) -> StructuredTool: + """Create an internal tool based on the resource configuration. + + Args: + resource: Internal tool resource configuration containing the tool type and + properties needed for tool creation. + + Returns: + A LangChain StructuredTool instance configured for the specified internal tool. + + Raises: + ValueError: If the tool type is not supported (no handler exists for it). + + Example: + >>> resource = AgentInternalToolResourceConfig( + ... properties=AgentInternalToolProperties( + ... tool_type=AgentInternalToolType.ANALYZE_FILES + ... ) + ... ) + >>> tool = create_internal_tool(resource) + >>> result = tool.invoke({"file_content": "..."}) + """ + tool_type = resource.properties.tool_type + + # Get the appropriate handler for this tool type + handler = _INTERNAL_TOOL_HANDLERS.get(tool_type) + if handler is None: + raise ValueError( + f"Unsupported internal tool type: {tool_type}. " + f"Supported types: {list[AgentInternalToolType](_INTERNAL_TOOL_HANDLERS.keys())}" + ) + + return handler(resource) diff --git a/src/uipath_langchain/agent/tools/process_tool.py b/src/uipath_langchain/agent/tools/process_tool.py index 885b3cc4..818d19d8 100644 --- a/src/uipath_langchain/agent/tools/process_tool.py +++ b/src/uipath_langchain/agent/tools/process_tool.py @@ -2,13 +2,14 @@ from typing import Any -from jsonschema_pydantic_converter import transform as create_model from langchain_core.tools import StructuredTool from langgraph.types import interrupt from uipath.agent.models.agent import AgentProcessToolResourceConfig from uipath.eval.mocks import mockable from uipath.platform.common import InvokeProcess +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model + from .structured_tool_with_output_type import StructuredToolWithOutputType from .utils import sanitize_tool_name diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index d7209be1..91764e5b 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -5,6 +5,7 @@ AgentContextResourceConfig, AgentEscalationResourceConfig, AgentIntegrationToolResourceConfig, + AgentInternalToolResourceConfig, AgentProcessToolResourceConfig, BaseAgentResourceConfig, LowCodeAgentDefinition, @@ -13,6 +14,7 @@ from .context_tool import create_context_tool from .escalation_tool import create_escalation_tool from .integration_tool import create_integration_tool +from .internal_tools import create_internal_tool from .process_tool import create_process_tool @@ -42,4 +44,7 @@ async def _build_tool_for_resource( elif isinstance(resource, AgentIntegrationToolResourceConfig): return create_integration_tool(resource) + elif isinstance(resource, AgentInternalToolResourceConfig): + return create_internal_tool(resource) + return None diff --git a/tests/agent/react/test_utils.py b/tests/agent/react/test_utils.py index 699c1541..855ae910 100644 --- a/tests/agent/react/test_utils.py +++ b/tests/agent/react/test_utils.py @@ -1,8 +1,17 @@ """Tests for ReAct agent utilities.""" +import uuid + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from pydantic import BaseModel +from uipath.platform.attachments import Attachment -from uipath_langchain.agent.react.utils import count_consecutive_thinking_messages +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model +from uipath_langchain.agent.react.utils import ( + add_job_attachments, + count_consecutive_thinking_messages, + get_job_attachments, +) class TestCountSuccessiveCompletions: @@ -133,3 +142,628 @@ def test_only_ai_messages_all_text(self): AIMessage(content="thought 3"), ] assert count_consecutive_thinking_messages(messages) == 3 + + +class TestGetJobAttachments: + """Test job attachment extraction from data based on schema.""" + + def test_no_attachments_in_schema(self): + """Should return empty list when schema has no job-attachment fields.""" + schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "value": {"type": "number"}}, + } + model = create_model(schema) + data = {"name": "test", "value": 42} + + result = get_job_attachments(model, data) + + assert result == [] + + def test_no_attachments_in_data(self): + """Should return empty list when data has no attachment values.""" + schema = { + "type": "object", + "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + } + }, + } + model = create_model(schema) + data = {} + + result = get_job_attachments(model, data) + + assert result == [] + + def test_single_direct_attachment(self): + """Should extract single direct attachment field.""" + schema = { + "type": "object", + "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["ID"], + } + }, + } + model = create_model(schema) + test_uuid = "550e8400-e29b-41d4-a716-446655440000" + data = { + "attachment": { + "ID": test_uuid, + "FullName": "document.pdf", + "MimeType": "application/pdf", + } + } + + result = get_job_attachments(model, data) + + assert len(result) == 1 + assert str(result[0].id) == test_uuid + assert result[0].full_name == "document.pdf" + assert result[0].mime_type == "application/pdf" + + def test_multiple_attachments_in_array(self): + """Should extract all attachments from array field.""" + schema = { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": {"$ref": "#/definitions/job-attachment"}, + } + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + uuid1 = "550e8400-e29b-41d4-a716-446655440001" + uuid2 = "550e8400-e29b-41d4-a716-446655440002" + uuid3 = "550e8400-e29b-41d4-a716-446655440003" + data = { + "attachments": [ + {"ID": uuid1, "FullName": "file1.pdf", "MimeType": "application/pdf"}, + { + "ID": uuid2, + "FullName": "file2.docx", + "MimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + { + "ID": uuid3, + "FullName": "file3.xlsx", + "MimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + ] + } + + result = get_job_attachments(model, data) + + assert len(result) == 3 + assert str(result[0].id) == uuid1 + assert result[0].full_name == "file1.pdf" + assert str(result[1].id) == uuid2 + assert result[1].full_name == "file2.docx" + assert str(result[2].id) == uuid3 + assert result[2].full_name == "file3.xlsx" + + def test_mixed_direct_and_array_attachments(self): + """Should extract attachments from both direct and array fields.""" + schema = { + "type": "object", + "properties": { + "primary_attachment": {"$ref": "#/definitions/job-attachment"}, + "additional_attachments": { + "type": "array", + "items": {"$ref": "#/definitions/job-attachment"}, + }, + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["ID"], + } + }, + } + model = create_model(schema) + uuid_primary = "550e8400-e29b-41d4-a716-446655440010" + uuid1 = "550e8400-e29b-41d4-a716-446655440011" + uuid2 = "550e8400-e29b-41d4-a716-446655440012" + data = { + "primary_attachment": { + "ID": uuid_primary, + "FullName": "main.pdf", + "MimeType": "application/pdf", + }, + "additional_attachments": [ + {"ID": uuid1, "FullName": "extra1.pdf", "MimeType": "application/pdf"}, + {"ID": uuid2, "FullName": "extra2.pdf", "MimeType": "application/pdf"}, + ], + } + + result = get_job_attachments(model, data) + + assert len(result) == 3 + # Check that all attachments are extracted (order may vary based on schema field order) + ids = {str(att.id) for att in result} + assert ids == {uuid_primary, uuid1, uuid2} + + def test_empty_array_attachments(self): + """Should handle empty attachment arrays gracefully.""" + schema = { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": {"$ref": "#/definitions/job-attachment"}, + } + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + data = {"attachments": []} + + result = get_job_attachments(model, data) + + assert result == [] + + def test_optional_attachment_field(self): + """Should handle optional attachment fields that are not present.""" + schema = { + "type": "object", + "properties": { + "attachment": {"$ref": "#/definitions/job-attachment"}, + "other_field": {"type": "string"}, + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + data = {"other_field": "value"} + + result = get_job_attachments(model, data) + + assert result == [] + + def test_pydantic_model_input(self): + """Should handle Pydantic model instances as input data.""" + schema = { + "type": "object", + "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + + # Create a Pydantic model instance + class TestModel(BaseModel): + attachment: dict + + test_uuid = "550e8400-e29b-41d4-a716-446655440099" + data_model = TestModel( + attachment={ + "ID": test_uuid, + "FullName": "test.pdf", + "MimeType": "application/pdf", + } + ) + + result = get_job_attachments(model, data_model) + + assert len(result) == 1 + assert str(result[0].id) == test_uuid + assert result[0].full_name == "test.pdf" + + def test_attachment_with_additional_fields(self): + """Should extract attachments with additional optional fields.""" + schema = { + "type": "object", + "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + "size": {"type": "integer"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + test_uuid = "550e8400-e29b-41d4-a716-446655440100" + data = { + "attachment": { + "ID": test_uuid, + "FullName": "document.pdf", + "MimeType": "application/pdf", + "size": 1024, + } + } + + result = get_job_attachments(model, data) + + assert len(result) == 1 + assert str(result[0].id) == test_uuid + assert result[0].full_name == "document.pdf" + assert result[0].mime_type == "application/pdf" + + def test_nested_structure_with_attachments(self): + """Should extract attachments from nested structures.""" + schema = { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "attachment": {"$ref": "#/definitions/job-attachment"} + }, + } + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["ID"], + } + }, + } + model = create_model(schema) + test_uuid = "550e8400-e29b-41d4-a716-446655440200" + data = { + "result": { + "attachment": { + "ID": test_uuid, + "FullName": "nested.pdf", + "MimeType": "application/pdf", + } + } + } + + result = get_job_attachments(model, data) + + # Implementation now traverses nested objects + assert len(result) == 1 + assert str(result[0].id) == test_uuid + assert result[0].full_name == "nested.pdf" + assert result[0].mime_type == "application/pdf" + + def test_deeply_nested_and_array_structures(self): + """Should extract attachments from deeply nested structures and arrays of nested objects.""" + schema = { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/job-attachment" + }, + } + }, + }, + } + }, + } + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["ID"], + } + }, + } + model = create_model(schema) + uuid1 = "550e8400-e29b-41d4-a716-446655440301" + uuid2 = "550e8400-e29b-41d4-a716-446655440302" + uuid3 = "550e8400-e29b-41d4-a716-446655440303" + data = { + "data": { + "items": [ + { + "files": [ + { + "ID": uuid1, + "FullName": "file1.pdf", + "MimeType": "application/pdf", + }, + { + "ID": uuid2, + "FullName": "file2.pdf", + "MimeType": "application/pdf", + }, + ] + }, + { + "files": [ + { + "ID": uuid3, + "FullName": "file3.pdf", + "MimeType": "application/pdf", + } + ] + }, + ] + } + } + + result = get_job_attachments(model, data) + + # Should extract all attachments from deeply nested arrays + assert len(result) == 3 + ids = {str(att.id) for att in result} + assert ids == {uuid1, uuid2, uuid3} + + +class TestAddJobAttachments: + """Test attachment dictionary merging.""" + + def test_both_empty_dictionaries(self): + """Should return empty dict when both inputs are empty.""" + left = {} + right = {} + + result = add_job_attachments(left, right) + + assert result == {} + + def test_left_empty_right_has_attachments(self): + """Should return right dict when left is empty.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + right = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ) + } + + result = add_job_attachments({}, right) + + assert result == right + assert len(result) == 1 + assert result[uuid1].full_name == "file1.pdf" + + def test_left_has_attachments_right_empty(self): + """Should return left dict when right is empty.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ) + } + + result = add_job_attachments(left, {}) + + assert result == left + assert len(result) == 1 + assert result[uuid1].full_name == "file1.pdf" + + def test_no_overlapping_uuids(self): + """Should merge dicts with no overlapping keys.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") + + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ) + } + right = { + uuid2: Attachment.model_validate( + { + "ID": str(uuid2), + "FullName": "file2.pdf", + "MimeType": "application/pdf", + } + ) + } + + result = add_job_attachments(left, right) + + assert len(result) == 2 + assert uuid1 in result + assert uuid2 in result + assert result[uuid1].full_name == "file1.pdf" + assert result[uuid2].full_name == "file2.pdf" + + def test_overlapping_uuid_right_takes_precedence(self): + """Should use right value when same UUID exists in both dicts.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "old_file.pdf", + "MimeType": "application/pdf", + } + ) + } + right = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "new_file.pdf", + "MimeType": "application/pdf", + } + ) + } + + result = add_job_attachments(left, right) + + assert len(result) == 1 + assert result[uuid1].full_name == "new_file.pdf" # Right takes precedence + + def test_mixed_overlapping_and_unique(self): + """Should correctly merge dicts with both overlapping and unique keys.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") + uuid3 = uuid.UUID("550e8400-e29b-41d4-a716-446655440003") + + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1_old.pdf", + "MimeType": "application/pdf", + } + ), + uuid2: Attachment.model_validate( + { + "ID": str(uuid2), + "FullName": "file2.pdf", + "MimeType": "application/pdf", + } + ), + } + right = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1_new.pdf", + "MimeType": "application/pdf", + } + ), + uuid3: Attachment.model_validate( + { + "ID": str(uuid3), + "FullName": "file3.pdf", + "MimeType": "application/pdf", + } + ), + } + + result = add_job_attachments(left, right) + + assert len(result) == 3 + assert result[uuid1].full_name == "file1_new.pdf" # Right overrides + assert result[uuid2].full_name == "file2.pdf" # From left only + assert result[uuid3].full_name == "file3.pdf" # From right only + + def test_multiple_attachments_same_operation(self): + """Should handle merging multiple attachments at once.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") + uuid3 = uuid.UUID("550e8400-e29b-41d4-a716-446655440003") + uuid4 = uuid.UUID("550e8400-e29b-41d4-a716-446655440004") + + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ), + uuid2: Attachment.model_validate( + { + "ID": str(uuid2), + "FullName": "file2.pdf", + "MimeType": "application/pdf", + } + ), + } + right = { + uuid3: Attachment.model_validate( + { + "ID": str(uuid3), + "FullName": "file3.pdf", + "MimeType": "application/pdf", + } + ), + uuid4: Attachment.model_validate( + { + "ID": str(uuid4), + "FullName": "file4.pdf", + "MimeType": "application/pdf", + } + ), + } + + result = add_job_attachments(left, right) + + assert len(result) == 4 + assert all(uid in result for uid in [uuid1, uuid2, uuid3, uuid4]) From fb8cda85d42b8a544ca702475d29c1289ffed585 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Fri, 19 Dec 2025 11:54:04 +0200 Subject: [PATCH 02/15] fix: tool args --- .../agent/tools/internal_tools/analyze_files_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py index d6889bac..c84541b1 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py @@ -42,7 +42,7 @@ def create_analyze_file_tool( input_schema=input_model.model_json_schema(), output_schema=output_model.model_json_schema(), ) - async def tool_fn(runtime: ToolRuntime, **kwargs: Any): + async def tool_fn(**kwargs: Any): return "Tool result message." return StructuredToolWithOutputType( From 39ce27117f93ec11fdd1ca9472d5622510127760 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Fri, 19 Dec 2025 18:53:13 +0200 Subject: [PATCH 03/15] fix: refactor code --- src/uipath_langchain/agent/react/init_node.py | 4 +- .../agent/react/job_attachments.py | 307 +++++++++ .../react/jsonschema_pydantic_converter.py | 1 + src/uipath_langchain/agent/react/types.py | 2 +- src/uipath_langchain/agent/react/utils.py | 235 +------ .../internal_tools/analyze_files_tool.py | 16 +- .../agent/wrappers/__init__.py | 3 +- .../agent/wrappers/job_attachment_wrapper.py | 66 ++ tests/agent/react/test_job_attachments.py | 635 ++++++++++++++++++ tests/agent/react/test_utils.py | 632 ----------------- tests/agent/wrappers/__init__.py | 1 + .../wrappers/test_job_attachment_wrapper.py | 546 +++++++++++++++ 12 files changed, 1575 insertions(+), 873 deletions(-) create mode 100644 src/uipath_langchain/agent/react/job_attachments.py create mode 100644 src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py create mode 100644 tests/agent/react/test_job_attachments.py create mode 100644 tests/agent/wrappers/__init__.py create mode 100644 tests/agent/wrappers/test_job_attachment_wrapper.py diff --git a/src/uipath_langchain/agent/react/init_node.py b/src/uipath_langchain/agent/react/init_node.py index 026f1f35..e042dc4e 100644 --- a/src/uipath_langchain/agent/react/init_node.py +++ b/src/uipath_langchain/agent/react/init_node.py @@ -5,7 +5,7 @@ from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel -from .utils import ( +from .job_attachments import ( get_job_attachments, ) @@ -23,7 +23,7 @@ def graph_state_init(state: Any): job_attachments = get_job_attachments(input_schema, state) job_attachments_dict = { - att.id: att for att in job_attachments if att.id is not None + str(att.id): att for att in job_attachments if att.id is not None } return { diff --git a/src/uipath_langchain/agent/react/job_attachments.py b/src/uipath_langchain/agent/react/job_attachments.py new file mode 100644 index 00000000..edd211f1 --- /dev/null +++ b/src/uipath_langchain/agent/react/job_attachments.py @@ -0,0 +1,307 @@ +"""Job attachment utilities for ReAct Agent.""" + +import copy +import sys +import uuid +from typing import Any, ForwardRef, Union, get_args, get_origin + +from jsonpath_ng import parse # type: ignore[import-untyped] +from pydantic import BaseModel +from uipath.platform.attachments import Attachment + + +def get_job_attachments( + schema: type[BaseModel], + data: dict[str, Any], +) -> list[Attachment]: + """Extract job attachments from data based on schema and convert to Attachment objects. + + Args: + schema: The Pydantic model class defining the data structure + data: The data object (dict or Pydantic model) to extract attachments from + + Returns: + List of Attachment objects + """ + job_attachment_paths = get_job_attachment_paths(schema) + job_attachments = _extract_values_by_paths(data, job_attachment_paths) + + result = [] + for attachment in job_attachments: + if isinstance(attachment, BaseModel): + attachment_dict = attachment.model_dump(by_alias=True) + result.append(Attachment.model_validate(attachment_dict)) + elif isinstance(attachment, dict): + result.append(Attachment.model_validate(attachment)) + else: + result.append(Attachment.model_validate(attachment)) + + return result + + +def get_job_attachment_paths(model: type[BaseModel]) -> list[str]: + """Get JSONPath expressions for all job attachment fields in a Pydantic model. + + Args: + model: The Pydantic model class to analyze + + Returns: + List of JSONPath expressions pointing to job attachment fields + """ + return _get_json_paths_by_type(model, "Job_attachment") + + +def replace_job_attachment_ids( + json_paths: list[str], + tool_args: dict[str, Any], + state: dict[str, Attachment], + errors: list[str], +) -> dict[str, Any]: + """Replace job attachment IDs in tool_args with full attachment objects from state. + + For each JSON path, this function finds matching objects in tool_args and + replaces them with corresponding attachment objects from state. The matching + is done by looking up the object's 'ID' field in the state dictionary. + + If an ID is not a valid UUID or is not present in state, an error message + is added to the errors list. + + Args: + json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"]) + tool_args: The dictionary containing tool arguments to modify + state: Dictionary mapping attachment UUID strings to Attachment objects + errors: List to collect error messages for invalid or missing IDs + + Returns: + Modified copy of tool_args with attachment IDs replaced by full objects + + Example: + >>> state = { + ... "123e4567-e89b-12d3-a456-426614174000": Attachment(id="123e4567-e89b-12d3-a456-426614174000", name="file1.pdf"), + ... "223e4567-e89b-12d3-a456-426614174001": Attachment(id="223e4567-e89b-12d3-a456-426614174001", name="file2.pdf") + ... } + >>> tool_args = { + ... "attachment": {"ID": "123"}, + ... "other_field": "value" + ... } + >>> paths = ['$.attachment'] + >>> errors = [] + >>> replace_job_attachment_ids(paths, tool_args, state, errors) + {'attachment': {'ID': '123', 'name': 'file1.pdf', ...}, 'other_field': 'value'} + """ + result = copy.deepcopy(tool_args) + + for json_path in json_paths: + expr = parse(json_path) + matches = expr.find(result) + + for match in matches: + current_value = match.value + + if isinstance(current_value, dict) and "ID" in current_value: + attachment_id_str = str(current_value["ID"]) + + try: + uuid.UUID(attachment_id_str) + except (ValueError, AttributeError): + errors.append( + _create_job_attachment_error_message(attachment_id_str) + ) + continue + + if attachment_id_str in state: + replacement_value = state[attachment_id_str] + match.full_path.update( + result, replacement_value.model_dump(by_alias=True, mode="json") + ) + else: + errors.append( + _create_job_attachment_error_message(attachment_id_str) + ) + + return result + + +def _create_job_attachment_error_message(attachment_id_str: str) -> str: + return ( + f"Could not find JobAttachment with ID='{attachment_id_str}'. " + f"Try again invoking the tool and please make sure that you pass " + f"valid JobAttachment IDs associated with existing JobAttachments in the current context." + ) + + +def _get_json_paths_by_type(model: type[BaseModel], type_name: str) -> list[str]: + """Get JSONPath expressions for all fields that reference a specific type. + + This function recursively traverses nested Pydantic models to find all paths + that lead to fields of the specified type. + + Args: + model: A Pydantic model class + type_name: The name of the type to search for (e.g., "Job_attachment") + + Returns: + List of JSONPath expressions using standard JSONPath syntax. + For array fields, uses [*] to indicate all array elements. + + Example: + >>> schema = { + ... "type": "object", + ... "properties": { + ... "attachment": {"$ref": "#/definitions/job-attachment"}, + ... "attachments": { + ... "type": "array", + ... "items": {"$ref": "#/definitions/job-attachment"} + ... } + ... }, + ... "definitions": { + ... "job-attachment": {"type": "object", "properties": {"id": {"type": "string"}}} + ... } + ... } + >>> model = transform(schema) + >>> _get_json_paths_by_type(model, "Job_attachment") + ['$.attachment', '$.attachments[*]'] + """ + + def _recursive_search( + current_model: type[BaseModel], current_path: str + ) -> list[str]: + """Recursively search for fields of the target type.""" + json_paths = [] + + target_type = _get_target_type(current_model, type_name) + matches_type = _create_type_matcher(type_name, target_type) + + for field_name, field_info in current_model.model_fields.items(): + annotation = field_info.annotation + + if current_path: + field_path = f"{current_path}.{field_name}" + else: + field_path = f"$.{field_name}" + + annotation = _unwrap_optional(annotation) + origin = get_origin(annotation) + + if matches_type(annotation): + json_paths.append(field_path) + continue + + if origin is list: + args = get_args(annotation) + if args: + list_item_type = args[0] + if matches_type(list_item_type): + json_paths.append(f"{field_path}[*]") + continue + + if _is_pydantic_model(list_item_type): + nested_paths = _recursive_search( + list_item_type, f"{field_path}[*]" + ) + json_paths.extend(nested_paths) + continue + + if _is_pydantic_model(annotation): + nested_paths = _recursive_search(annotation, field_path) + json_paths.extend(nested_paths) + + return json_paths + + return _recursive_search(model, "") + + +def _extract_values_by_paths( + obj: dict[str, Any] | BaseModel, json_paths: list[str] +) -> list[Any]: + """Extract values from an object using JSONPath expressions. + + Args: + obj: The object (dict or Pydantic model) to extract values from + json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"]) + + Returns: + List of all extracted values (flattened) + + Example: + >>> obj = { + ... "attachment": {"id": "123"}, + ... "attachments": [{"id": "456"}, {"id": "789"}] + ... } + >>> paths = ['$.attachment', '$.attachments[*]'] + >>> _extract_values_by_paths(obj, paths) + [{'id': '123'}, {'id': '456'}, {'id': '789'}] + """ + data = obj.model_dump() if isinstance(obj, BaseModel) else obj + + results = [] + for json_path in json_paths: + expr = parse(json_path) + matches = expr.find(data) + results.extend([match.value for match in matches]) + + return results + + +def _get_target_type(model: type[BaseModel], type_name: str) -> Any: + """Get the target type from the model's module. + + Args: + model: A Pydantic model class + type_name: The name of the type to search for + + Returns: + The target type if found, None otherwise + """ + model_module = sys.modules.get(model.__module__) + if model_module and hasattr(model_module, type_name): + return getattr(model_module, type_name) + return None + + +def _create_type_matcher(type_name: str, target_type: Any) -> Any: + """Create a function that checks if an annotation matches the target type. + + Args: + type_name: The name of the type to match + target_type: The actual type object (can be None) + + Returns: + A function that takes an annotation and returns True if it matches + """ + + def matches_type(annotation: Any) -> bool: + """Check if an annotation matches the target type name.""" + if isinstance(annotation, ForwardRef): + return annotation.__forward_arg__ == type_name + if isinstance(annotation, str): + return annotation == type_name + if hasattr(annotation, "__name__") and annotation.__name__ == type_name: + return True + if target_type is not None and annotation is target_type: + return True + return False + + return matches_type + + +def _unwrap_optional(annotation: Any) -> Any: + """Unwrap Optional/Union types to get the underlying type. + + Args: + annotation: The type annotation to unwrap + + Returns: + The unwrapped type, or the original if not Optional/Union + """ + origin = get_origin(annotation) + if origin is Union: + args = get_args(annotation) + non_none_args = [arg for arg in args if arg is not type(None)] + if non_none_args: + return non_none_args[0] + return annotation + + +def _is_pydantic_model(annotation: Any) -> bool: + return isinstance(annotation, type) and issubclass(annotation, BaseModel) diff --git a/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py b/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py index 8614e57b..7e5c8283 100644 --- a/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py +++ b/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py @@ -46,6 +46,7 @@ def collect_types(annotation: Any) -> None: and annotation.__name__ == type_def.__name__ ): # Store the actual annotation type, not the old namespace one + annotation.__name__ = type_name corrected_namespace[type_name] = annotation break diff --git a/src/uipath_langchain/agent/react/types.py b/src/uipath_langchain/agent/react/types.py index 6c5fc1f9..0327afb2 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -26,7 +26,7 @@ class AgentGraphState(BaseModel): """Agent Graph state for standard loop execution.""" messages: Annotated[list[AnyMessage], add_messages] = [] - job_attachments: Annotated[dict[uuid.UUID, Attachment], add_job_attachments] = {} + job_attachments: Annotated[dict[str, Attachment], add_job_attachments] = {} termination: AgentTermination | None = None diff --git a/src/uipath_langchain/agent/react/utils.py b/src/uipath_langchain/agent/react/utils.py index b9b44d63..5a349bf8 100644 --- a/src/uipath_langchain/agent/react/utils.py +++ b/src/uipath_langchain/agent/react/utils.py @@ -1,10 +1,8 @@ """ReAct Agent loop utilities.""" -import sys import uuid -from typing import Any, ForwardRef, Sequence, Union, get_args, get_origin +from typing import Any, Sequence -from jsonpath_ng import parse # type: ignore[import-untyped] from langchain_core.messages import AIMessage, BaseMessage from pydantic import BaseModel from uipath.agent.react import END_EXECUTION_TOOL @@ -76,234 +74,3 @@ def add_job_attachments( return right return {**left, **right} - - -def get_job_attachments( - schema: type[BaseModel], - data: dict[str, Any], -) -> list[Attachment]: - """Extract job attachments from data based on schema and convert to Attachment objects. - - Args: - schema: The Pydantic model class defining the data structure - data: The data object (dict or Pydantic model) to extract attachments from - - Returns: - List of Attachment objects - """ - job_attachment_paths = _get_job_attachment_paths(schema) - job_attachments = _extract_values_by_paths(data, job_attachment_paths) - - result = [] - for attachment in job_attachments: - if isinstance(attachment, BaseModel): - # Convert Pydantic model to dict and create Attachment - attachment_dict = attachment.model_dump(by_alias=True) - result.append(Attachment.model_validate(attachment_dict)) - elif isinstance(attachment, dict): - # Already a dict, create Attachment directly - result.append(Attachment.model_validate(attachment)) - else: - # Try to convert to Attachment as-is - result.append(Attachment.model_validate(attachment)) - - return result - - -def _get_target_type(model: type[BaseModel], type_name: str) -> Any: - """Get the target type from the model's module. - - Args: - model: A Pydantic model class - type_name: The name of the type to search for - - Returns: - The target type if found, None otherwise - """ - model_module = sys.modules.get(model.__module__) - if model_module and hasattr(model_module, type_name): - return getattr(model_module, type_name) - return None - - -def _create_type_matcher(type_name: str, target_type: Any) -> Any: - """Create a function that checks if an annotation matches the target type. - - Args: - type_name: The name of the type to match - target_type: The actual type object (can be None) - - Returns: - A function that takes an annotation and returns True if it matches - """ - - def matches_type(annotation: Any) -> bool: - """Check if an annotation matches the target type name.""" - if isinstance(annotation, ForwardRef): - return annotation.__forward_arg__ == type_name - if isinstance(annotation, str): - return annotation == type_name - if hasattr(annotation, "__name__") and annotation.__name__ == type_name: - return True - if target_type is not None and annotation is target_type: - return True - return False - - return matches_type - - -def _unwrap_optional(annotation: Any) -> Any: - """Unwrap Optional/Union types to get the underlying type. - - Args: - annotation: The type annotation to unwrap - - Returns: - The unwrapped type, or the original if not Optional/Union - """ - origin = get_origin(annotation) - if origin is Union: - args = get_args(annotation) - non_none_args = [arg for arg in args if arg is not type(None)] - if non_none_args: - return non_none_args[0] - return annotation - - -def _is_pydantic_model(annotation: Any) -> bool: - """Check if annotation is a Pydantic model. - - Args: - annotation: The type annotation to check - - Returns: - True if the annotation is a Pydantic model class - """ - try: - return isinstance(annotation, type) and issubclass(annotation, BaseModel) - except TypeError: - return False - - -def _get_job_attachment_paths(model: type[BaseModel]) -> list[str]: - return _get_json_paths_by_type(model, "Job_attachment") - - -def _get_json_paths_by_type(model: type[BaseModel], type_name: str) -> list[str]: - """Get JSONPath expressions for all fields that reference a specific type. - - This function recursively traverses nested Pydantic models to find all paths - that lead to fields of the specified type. - - Args: - model: A Pydantic model class - type_name: The name of the type to search for (e.g., "Job_attachment") - - Returns: - List of JSONPath expressions using standard JSONPath syntax. - For array fields, uses [*] to indicate all array elements. - - Example: - >>> schema = { - ... "type": "object", - ... "properties": { - ... "attachment": {"$ref": "#/definitions/job-attachment"}, - ... "attachments": { - ... "type": "array", - ... "items": {"$ref": "#/definitions/job-attachment"} - ... } - ... }, - ... "definitions": { - ... "job-attachment": {"type": "object", "properties": {"id": {"type": "string"}}} - ... } - ... } - >>> model = transform(schema) - >>> get_json_paths_by_type(model, "Job_attachment") - ['$.attachment', '$.attachments[*]'] - """ - - def _recursive_search( - current_model: type[BaseModel], current_path: str - ) -> list[str]: - """Recursively search for fields of the target type.""" - json_paths = [] - - # Get the target type and create a matcher function - target_type = _get_target_type(current_model, type_name) - matches_type = _create_type_matcher(type_name, target_type) - - for field_name, field_info in current_model.model_fields.items(): - annotation = field_info.annotation - - # Build the path for this field - if current_path: - field_path = f"{current_path}.{field_name}" - else: - field_path = f"$.{field_name}" - - # Unwrap Optional/Union types - annotation = _unwrap_optional(annotation) - origin = get_origin(annotation) - - # Check if this field matches the target type - if matches_type(annotation): - json_paths.append(field_path) - continue - - # Check if this is a list of the target type or nested models - if origin is list: - args = get_args(annotation) - if args: - list_item_type = args[0] - if matches_type(list_item_type): - json_paths.append(f"{field_path}[*]") - continue - # Check if it's a list of nested models - if _is_pydantic_model(list_item_type): - nested_paths = _recursive_search( - list_item_type, f"{field_path}[*]" - ) - json_paths.extend(nested_paths) - continue - - # Check if this field is a nested Pydantic model that we should traverse - if _is_pydantic_model(annotation): - nested_paths = _recursive_search(annotation, field_path) - json_paths.extend(nested_paths) - - return json_paths - - return _recursive_search(model, "") - - -def _extract_values_by_paths( - obj: dict[str, Any] | BaseModel, json_paths: list[str] -) -> list[Any]: - """Extract values from an object using JSONPath expressions. - - Args: - obj: The object (dict or Pydantic model) to extract values from - json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"]) - - Returns: - List of all extracted values (flattened) - - Example: - >>> obj = { - ... "attachment": {"id": "123"}, - ... "attachments": [{"id": "456"}, {"id": "789"}] - ... } - >>> paths = ['$.attachment', '$.attachments[*]'] - >>> extract_values_by_paths(obj, paths) - [{'id': '123'}, {'id': '456'}, {'id': '789'}] - """ - # Convert Pydantic model to dict if needed - data = obj.model_dump() if isinstance(obj, BaseModel) else obj - - results = [] - for json_path in json_paths: - expr = parse(json_path) - matches = expr.find(data) - results.extend([match.value for match in matches]) - - return results diff --git a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py index c84541b1..f3b18d9a 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py @@ -1,6 +1,5 @@ from typing import Any -from langchain.tools import ToolRuntime from langchain_core.tools import StructuredTool from uipath.agent.models.agent import ( AgentInternalToolResourceConfig, @@ -11,7 +10,15 @@ from uipath_langchain.agent.tools.structured_tool_with_output_type import ( StructuredToolWithOutputType, ) +from uipath_langchain.agent.tools.tool_node import ToolWrapperMixin from uipath_langchain.agent.tools.utils import sanitize_tool_name +from uipath_langchain.agent.wrappers.job_attachment_wrapper import ( + get_job_attachment_wrapper, +) + + +class AnalyzeFileTool(StructuredToolWithOutputType, ToolWrapperMixin): + pass def create_analyze_file_tool( @@ -43,12 +50,15 @@ def create_analyze_file_tool( output_schema=output_model.model_json_schema(), ) async def tool_fn(**kwargs: Any): - return "Tool result message." + return "The event name is 'Toamna' by Tudor Gheorghe" - return StructuredToolWithOutputType( + wrapper = get_job_attachment_wrapper(resource) + tool = AnalyzeFileTool( name=tool_name, description=resource.description, args_schema=input_model, coroutine=tool_fn, output_type=output_model, ) + tool.set_tool_wrappers(awrapper=wrapper) + return tool diff --git a/src/uipath_langchain/agent/wrappers/__init__.py b/src/uipath_langchain/agent/wrappers/__init__.py index be4f850b..09f28086 100644 --- a/src/uipath_langchain/agent/wrappers/__init__.py +++ b/src/uipath_langchain/agent/wrappers/__init__.py @@ -1,5 +1,6 @@ """Wrappers to add behavior to tools while keeping them graph agnostic.""" +from .job_attachment_wrapper import get_job_attachment_wrapper from .static_args_wrapper import get_static_args_wrapper -__all__ = ["get_static_args_wrapper"] +__all__ = ["get_static_args_wrapper", "get_job_attachment_wrapper"] diff --git a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py new file mode 100644 index 00000000..104eb331 --- /dev/null +++ b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py @@ -0,0 +1,66 @@ +from typing import Any, Type, cast + +from langchain_core.messages.tool import ToolCall +from langchain_core.tools import BaseTool +from langgraph.types import Command +from pydantic import BaseModel +from uipath.agent.models.agent import BaseAgentToolResourceConfig + +from uipath_langchain.agent.react.job_attachments import ( + get_job_attachment_paths, + replace_job_attachment_ids, +) +from uipath_langchain.agent.react.types import AgentGraphState +from uipath_langchain.agent.tools.tool_node import AsyncToolWrapperType + + +def get_job_attachment_wrapper( + resource: BaseAgentToolResourceConfig, +) -> AsyncToolWrapperType: + """Create a tool wrapper that validates and replaces job attachment IDs with full attachment objects. + + This wrapper extracts job attachment paths from the tool's schema, validates that all + referenced attachments exist in the agent state, and replaces attachment IDs with complete + attachment objects before invoking the tool. + + Args: + resource: The agent tool resource configuration + + Returns: + An async tool wrapper function that handles job attachment validation and replacement + """ + + async def job_attachment_wrapper( + tool: BaseTool, + call: ToolCall, + state: AgentGraphState, + ) -> dict[str, Any] | Command[Any] | None: + """Validate and replace job attachments in tool arguments before invocation. + + Args: + tool: The tool to wrap + call: The tool call containing arguments + state: The agent graph state containing job attachments + + Returns: + Tool invocation result, or error dict if attachment validation fails + """ + input_args = call["args"] + new_input_args = input_args + + if isinstance(tool.args_schema, type) and issubclass( + tool.args_schema, BaseModel + ): + schema = cast(Type[BaseModel], tool.args_schema) + errors: list[str] = [] + paths = get_job_attachment_paths(schema) + new_input_args = replace_job_attachment_ids( + paths, input_args, state.job_attachments, errors + ) + + if errors: + return {"error": "\n".join(errors)} + + return await tool.ainvoke(new_input_args) + + return job_attachment_wrapper diff --git a/tests/agent/react/test_job_attachments.py b/tests/agent/react/test_job_attachments.py new file mode 100644 index 00000000..dd278418 --- /dev/null +++ b/tests/agent/react/test_job_attachments.py @@ -0,0 +1,635 @@ +import uuid + +from pydantic import BaseModel +from uipath.platform.attachments import Attachment + +from uipath_langchain.agent.react.job_attachments import get_job_attachments +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model +from uipath_langchain.agent.react.utils import ( + add_job_attachments, +) + + +class TestGetJobAttachments: + """Test job attachment extraction from data based on schema.""" + + def test_no_attachments_in_schema(self): + """Should return empty list when schema has no job-attachment fields.""" + schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "value": {"type": "number"}}, + } + model = create_model(schema) + data = {"name": "test", "value": 42} + + result = get_job_attachments(model, data) + + assert result == [] + + def test_no_attachments_in_data(self): + """Should return empty list when data has no attachment values.""" + schema = { + "type": "object", + "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + } + }, + } + model = create_model(schema) + data = {} + + result = get_job_attachments(model, data) + + assert result == [] + + def test_single_direct_attachment(self): + """Should extract single direct attachment field.""" + schema = { + "type": "object", + "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["ID"], + } + }, + } + model = create_model(schema) + test_uuid = "550e8400-e29b-41d4-a716-446655440000" + data = { + "attachment": { + "ID": test_uuid, + "FullName": "document.pdf", + "MimeType": "application/pdf", + } + } + + result = get_job_attachments(model, data) + + assert len(result) == 1 + assert str(result[0].id) == test_uuid + assert result[0].full_name == "document.pdf" + assert result[0].mime_type == "application/pdf" + + def test_multiple_attachments_in_array(self): + """Should extract all attachments from array field.""" + schema = { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": {"$ref": "#/definitions/job-attachment"}, + } + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + uuid1 = "550e8400-e29b-41d4-a716-446655440001" + uuid2 = "550e8400-e29b-41d4-a716-446655440002" + uuid3 = "550e8400-e29b-41d4-a716-446655440003" + data = { + "attachments": [ + {"ID": uuid1, "FullName": "file1.pdf", "MimeType": "application/pdf"}, + { + "ID": uuid2, + "FullName": "file2.docx", + "MimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + { + "ID": uuid3, + "FullName": "file3.xlsx", + "MimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + ] + } + + result = get_job_attachments(model, data) + + assert len(result) == 3 + assert str(result[0].id) == uuid1 + assert result[0].full_name == "file1.pdf" + assert str(result[1].id) == uuid2 + assert result[1].full_name == "file2.docx" + assert str(result[2].id) == uuid3 + assert result[2].full_name == "file3.xlsx" + + def test_mixed_direct_and_array_attachments(self): + """Should extract attachments from both direct and array fields.""" + schema = { + "type": "object", + "properties": { + "primary_attachment": {"$ref": "#/definitions/job-attachment"}, + "additional_attachments": { + "type": "array", + "items": {"$ref": "#/definitions/job-attachment"}, + }, + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["ID"], + } + }, + } + model = create_model(schema) + uuid_primary = "550e8400-e29b-41d4-a716-446655440010" + uuid1 = "550e8400-e29b-41d4-a716-446655440011" + uuid2 = "550e8400-e29b-41d4-a716-446655440012" + data = { + "primary_attachment": { + "ID": uuid_primary, + "FullName": "main.pdf", + "MimeType": "application/pdf", + }, + "additional_attachments": [ + {"ID": uuid1, "FullName": "extra1.pdf", "MimeType": "application/pdf"}, + {"ID": uuid2, "FullName": "extra2.pdf", "MimeType": "application/pdf"}, + ], + } + + result = get_job_attachments(model, data) + + assert len(result) == 3 + # Check that all attachments are extracted (order may vary based on schema field order) + ids = {str(att.id) for att in result} + assert ids == {uuid_primary, uuid1, uuid2} + + def test_empty_array_attachments(self): + """Should handle empty attachment arrays gracefully.""" + schema = { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": {"$ref": "#/definitions/job-attachment"}, + } + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + data = {"attachments": []} + + result = get_job_attachments(model, data) + + assert result == [] + + def test_optional_attachment_field(self): + """Should handle optional attachment fields that are not present.""" + schema = { + "type": "object", + "properties": { + "attachment": {"$ref": "#/definitions/job-attachment"}, + "other_field": {"type": "string"}, + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + data = {"other_field": "value"} + + result = get_job_attachments(model, data) + + assert result == [] + + def test_pydantic_model_input(self): + """Should handle Pydantic model instances as input data.""" + schema = { + "type": "object", + "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + + # Create a Pydantic model instance + class TestModel(BaseModel): + attachment: dict + + test_uuid = "550e8400-e29b-41d4-a716-446655440099" + data_model = TestModel( + attachment={ + "ID": test_uuid, + "FullName": "test.pdf", + "MimeType": "application/pdf", + } + ) + + result = get_job_attachments(model, data_model) + + assert len(result) == 1 + assert str(result[0].id) == test_uuid + assert result[0].full_name == "test.pdf" + + def test_attachment_with_additional_fields(self): + """Should extract attachments with additional optional fields.""" + schema = { + "type": "object", + "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + "size": {"type": "integer"}, + }, + "required": ["FullName", "MimeType"], + } + }, + } + model = create_model(schema) + test_uuid = "550e8400-e29b-41d4-a716-446655440100" + data = { + "attachment": { + "ID": test_uuid, + "FullName": "document.pdf", + "MimeType": "application/pdf", + "size": 1024, + } + } + + result = get_job_attachments(model, data) + + assert len(result) == 1 + assert str(result[0].id) == test_uuid + assert result[0].full_name == "document.pdf" + assert result[0].mime_type == "application/pdf" + + def test_nested_structure_with_attachments(self): + """Should extract attachments from nested structures.""" + schema = { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "attachment": {"$ref": "#/definitions/job-attachment"} + }, + } + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["ID"], + } + }, + } + model = create_model(schema) + test_uuid = "550e8400-e29b-41d4-a716-446655440200" + data = { + "result": { + "attachment": { + "ID": test_uuid, + "FullName": "nested.pdf", + "MimeType": "application/pdf", + } + } + } + + result = get_job_attachments(model, data) + + # Implementation now traverses nested objects + assert len(result) == 1 + assert str(result[0].id) == test_uuid + assert result[0].full_name == "nested.pdf" + assert result[0].mime_type == "application/pdf" + + def test_deeply_nested_and_array_structures(self): + """Should extract attachments from deeply nested structures and arrays of nested objects.""" + schema = { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/job-attachment" + }, + } + }, + }, + } + }, + } + }, + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + }, + "required": ["ID"], + } + }, + } + model = create_model(schema) + uuid1 = "550e8400-e29b-41d4-a716-446655440301" + uuid2 = "550e8400-e29b-41d4-a716-446655440302" + uuid3 = "550e8400-e29b-41d4-a716-446655440303" + data = { + "data": { + "items": [ + { + "files": [ + { + "ID": uuid1, + "FullName": "file1.pdf", + "MimeType": "application/pdf", + }, + { + "ID": uuid2, + "FullName": "file2.pdf", + "MimeType": "application/pdf", + }, + ] + }, + { + "files": [ + { + "ID": uuid3, + "FullName": "file3.pdf", + "MimeType": "application/pdf", + } + ] + }, + ] + } + } + + result = get_job_attachments(model, data) + + # Should extract all attachments from deeply nested arrays + assert len(result) == 3 + ids = {str(att.id) for att in result} + assert ids == {uuid1, uuid2, uuid3} + + +class TestAddJobAttachments: + """Test attachment dictionary merging.""" + + def test_both_empty_dictionaries(self): + """Should return empty dict when both inputs are empty.""" + left = {} + right = {} + + result = add_job_attachments(left, right) + + assert result == {} + + def test_left_empty_right_has_attachments(self): + """Should return right dict when left is empty.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + right = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ) + } + + result = add_job_attachments({}, right) + + assert result == right + assert len(result) == 1 + assert result[uuid1].full_name == "file1.pdf" + + def test_left_has_attachments_right_empty(self): + """Should return left dict when right is empty.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ) + } + + result = add_job_attachments(left, {}) + + assert result == left + assert len(result) == 1 + assert result[uuid1].full_name == "file1.pdf" + + def test_no_overlapping_uuids(self): + """Should merge dicts with no overlapping keys.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") + + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ) + } + right = { + uuid2: Attachment.model_validate( + { + "ID": str(uuid2), + "FullName": "file2.pdf", + "MimeType": "application/pdf", + } + ) + } + + result = add_job_attachments(left, right) + + assert len(result) == 2 + assert uuid1 in result + assert uuid2 in result + assert result[uuid1].full_name == "file1.pdf" + assert result[uuid2].full_name == "file2.pdf" + + def test_overlapping_uuid_right_takes_precedence(self): + """Should use right value when same UUID exists in both dicts.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "old_file.pdf", + "MimeType": "application/pdf", + } + ) + } + right = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "new_file.pdf", + "MimeType": "application/pdf", + } + ) + } + + result = add_job_attachments(left, right) + + assert len(result) == 1 + assert result[uuid1].full_name == "new_file.pdf" # Right takes precedence + + def test_mixed_overlapping_and_unique(self): + """Should correctly merge dicts with both overlapping and unique keys.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") + uuid3 = uuid.UUID("550e8400-e29b-41d4-a716-446655440003") + + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1_old.pdf", + "MimeType": "application/pdf", + } + ), + uuid2: Attachment.model_validate( + { + "ID": str(uuid2), + "FullName": "file2.pdf", + "MimeType": "application/pdf", + } + ), + } + right = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1_new.pdf", + "MimeType": "application/pdf", + } + ), + uuid3: Attachment.model_validate( + { + "ID": str(uuid3), + "FullName": "file3.pdf", + "MimeType": "application/pdf", + } + ), + } + + result = add_job_attachments(left, right) + + assert len(result) == 3 + assert result[uuid1].full_name == "file1_new.pdf" # Right overrides + assert result[uuid2].full_name == "file2.pdf" # From left only + assert result[uuid3].full_name == "file3.pdf" # From right only + + def test_multiple_attachments_same_operation(self): + """Should handle merging multiple attachments at once.""" + uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") + uuid3 = uuid.UUID("550e8400-e29b-41d4-a716-446655440003") + uuid4 = uuid.UUID("550e8400-e29b-41d4-a716-446655440004") + + left = { + uuid1: Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ), + uuid2: Attachment.model_validate( + { + "ID": str(uuid2), + "FullName": "file2.pdf", + "MimeType": "application/pdf", + } + ), + } + right = { + uuid3: Attachment.model_validate( + { + "ID": str(uuid3), + "FullName": "file3.pdf", + "MimeType": "application/pdf", + } + ), + uuid4: Attachment.model_validate( + { + "ID": str(uuid4), + "FullName": "file4.pdf", + "MimeType": "application/pdf", + } + ), + } + + result = add_job_attachments(left, right) + + assert len(result) == 4 + assert all(uid in result for uid in [uuid1, uuid2, uuid3, uuid4]) diff --git a/tests/agent/react/test_utils.py b/tests/agent/react/test_utils.py index 855ae910..84d03dbd 100644 --- a/tests/agent/react/test_utils.py +++ b/tests/agent/react/test_utils.py @@ -1,16 +1,9 @@ """Tests for ReAct agent utilities.""" -import uuid - from langchain_core.messages import AIMessage, HumanMessage, ToolMessage -from pydantic import BaseModel -from uipath.platform.attachments import Attachment -from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model from uipath_langchain.agent.react.utils import ( - add_job_attachments, count_consecutive_thinking_messages, - get_job_attachments, ) @@ -142,628 +135,3 @@ def test_only_ai_messages_all_text(self): AIMessage(content="thought 3"), ] assert count_consecutive_thinking_messages(messages) == 3 - - -class TestGetJobAttachments: - """Test job attachment extraction from data based on schema.""" - - def test_no_attachments_in_schema(self): - """Should return empty list when schema has no job-attachment fields.""" - schema = { - "type": "object", - "properties": {"name": {"type": "string"}, "value": {"type": "number"}}, - } - model = create_model(schema) - data = {"name": "test", "value": 42} - - result = get_job_attachments(model, data) - - assert result == [] - - def test_no_attachments_in_data(self): - """Should return empty list when data has no attachment values.""" - schema = { - "type": "object", - "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "id": {"type": "string"}, - "name": {"type": "string"}, - }, - } - }, - } - model = create_model(schema) - data = {} - - result = get_job_attachments(model, data) - - assert result == [] - - def test_single_direct_attachment(self): - """Should extract single direct attachment field.""" - schema = { - "type": "object", - "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "ID": {"type": "string"}, - "FullName": {"type": "string"}, - "MimeType": {"type": "string"}, - }, - "required": ["ID"], - } - }, - } - model = create_model(schema) - test_uuid = "550e8400-e29b-41d4-a716-446655440000" - data = { - "attachment": { - "ID": test_uuid, - "FullName": "document.pdf", - "MimeType": "application/pdf", - } - } - - result = get_job_attachments(model, data) - - assert len(result) == 1 - assert str(result[0].id) == test_uuid - assert result[0].full_name == "document.pdf" - assert result[0].mime_type == "application/pdf" - - def test_multiple_attachments_in_array(self): - """Should extract all attachments from array field.""" - schema = { - "type": "object", - "properties": { - "attachments": { - "type": "array", - "items": {"$ref": "#/definitions/job-attachment"}, - } - }, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "ID": {"type": "string"}, - "FullName": {"type": "string"}, - "MimeType": {"type": "string"}, - }, - "required": ["FullName", "MimeType"], - } - }, - } - model = create_model(schema) - uuid1 = "550e8400-e29b-41d4-a716-446655440001" - uuid2 = "550e8400-e29b-41d4-a716-446655440002" - uuid3 = "550e8400-e29b-41d4-a716-446655440003" - data = { - "attachments": [ - {"ID": uuid1, "FullName": "file1.pdf", "MimeType": "application/pdf"}, - { - "ID": uuid2, - "FullName": "file2.docx", - "MimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - }, - { - "ID": uuid3, - "FullName": "file3.xlsx", - "MimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - }, - ] - } - - result = get_job_attachments(model, data) - - assert len(result) == 3 - assert str(result[0].id) == uuid1 - assert result[0].full_name == "file1.pdf" - assert str(result[1].id) == uuid2 - assert result[1].full_name == "file2.docx" - assert str(result[2].id) == uuid3 - assert result[2].full_name == "file3.xlsx" - - def test_mixed_direct_and_array_attachments(self): - """Should extract attachments from both direct and array fields.""" - schema = { - "type": "object", - "properties": { - "primary_attachment": {"$ref": "#/definitions/job-attachment"}, - "additional_attachments": { - "type": "array", - "items": {"$ref": "#/definitions/job-attachment"}, - }, - }, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "ID": {"type": "string"}, - "FullName": {"type": "string"}, - "MimeType": {"type": "string"}, - }, - "required": ["ID"], - } - }, - } - model = create_model(schema) - uuid_primary = "550e8400-e29b-41d4-a716-446655440010" - uuid1 = "550e8400-e29b-41d4-a716-446655440011" - uuid2 = "550e8400-e29b-41d4-a716-446655440012" - data = { - "primary_attachment": { - "ID": uuid_primary, - "FullName": "main.pdf", - "MimeType": "application/pdf", - }, - "additional_attachments": [ - {"ID": uuid1, "FullName": "extra1.pdf", "MimeType": "application/pdf"}, - {"ID": uuid2, "FullName": "extra2.pdf", "MimeType": "application/pdf"}, - ], - } - - result = get_job_attachments(model, data) - - assert len(result) == 3 - # Check that all attachments are extracted (order may vary based on schema field order) - ids = {str(att.id) for att in result} - assert ids == {uuid_primary, uuid1, uuid2} - - def test_empty_array_attachments(self): - """Should handle empty attachment arrays gracefully.""" - schema = { - "type": "object", - "properties": { - "attachments": { - "type": "array", - "items": {"$ref": "#/definitions/job-attachment"}, - } - }, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "ID": {"type": "string"}, - "FullName": {"type": "string"}, - "MimeType": {"type": "string"}, - }, - "required": ["FullName", "MimeType"], - } - }, - } - model = create_model(schema) - data = {"attachments": []} - - result = get_job_attachments(model, data) - - assert result == [] - - def test_optional_attachment_field(self): - """Should handle optional attachment fields that are not present.""" - schema = { - "type": "object", - "properties": { - "attachment": {"$ref": "#/definitions/job-attachment"}, - "other_field": {"type": "string"}, - }, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "ID": {"type": "string"}, - "FullName": {"type": "string"}, - "MimeType": {"type": "string"}, - }, - "required": ["FullName", "MimeType"], - } - }, - } - model = create_model(schema) - data = {"other_field": "value"} - - result = get_job_attachments(model, data) - - assert result == [] - - def test_pydantic_model_input(self): - """Should handle Pydantic model instances as input data.""" - schema = { - "type": "object", - "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "ID": {"type": "string"}, - "FullName": {"type": "string"}, - "MimeType": {"type": "string"}, - }, - "required": ["FullName", "MimeType"], - } - }, - } - model = create_model(schema) - - # Create a Pydantic model instance - class TestModel(BaseModel): - attachment: dict - - test_uuid = "550e8400-e29b-41d4-a716-446655440099" - data_model = TestModel( - attachment={ - "ID": test_uuid, - "FullName": "test.pdf", - "MimeType": "application/pdf", - } - ) - - result = get_job_attachments(model, data_model) - - assert len(result) == 1 - assert str(result[0].id) == test_uuid - assert result[0].full_name == "test.pdf" - - def test_attachment_with_additional_fields(self): - """Should extract attachments with additional optional fields.""" - schema = { - "type": "object", - "properties": {"attachment": {"$ref": "#/definitions/job-attachment"}}, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "ID": {"type": "string"}, - "FullName": {"type": "string"}, - "MimeType": {"type": "string"}, - "size": {"type": "integer"}, - }, - "required": ["FullName", "MimeType"], - } - }, - } - model = create_model(schema) - test_uuid = "550e8400-e29b-41d4-a716-446655440100" - data = { - "attachment": { - "ID": test_uuid, - "FullName": "document.pdf", - "MimeType": "application/pdf", - "size": 1024, - } - } - - result = get_job_attachments(model, data) - - assert len(result) == 1 - assert str(result[0].id) == test_uuid - assert result[0].full_name == "document.pdf" - assert result[0].mime_type == "application/pdf" - - def test_nested_structure_with_attachments(self): - """Should extract attachments from nested structures.""" - schema = { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "attachment": {"$ref": "#/definitions/job-attachment"} - }, - } - }, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "ID": {"type": "string"}, - "FullName": {"type": "string"}, - "MimeType": {"type": "string"}, - }, - "required": ["ID"], - } - }, - } - model = create_model(schema) - test_uuid = "550e8400-e29b-41d4-a716-446655440200" - data = { - "result": { - "attachment": { - "ID": test_uuid, - "FullName": "nested.pdf", - "MimeType": "application/pdf", - } - } - } - - result = get_job_attachments(model, data) - - # Implementation now traverses nested objects - assert len(result) == 1 - assert str(result[0].id) == test_uuid - assert result[0].full_name == "nested.pdf" - assert result[0].mime_type == "application/pdf" - - def test_deeply_nested_and_array_structures(self): - """Should extract attachments from deeply nested structures and arrays of nested objects.""" - schema = { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { - "$ref": "#/definitions/job-attachment" - }, - } - }, - }, - } - }, - } - }, - "definitions": { - "job-attachment": { - "type": "object", - "properties": { - "ID": {"type": "string"}, - "FullName": {"type": "string"}, - "MimeType": {"type": "string"}, - }, - "required": ["ID"], - } - }, - } - model = create_model(schema) - uuid1 = "550e8400-e29b-41d4-a716-446655440301" - uuid2 = "550e8400-e29b-41d4-a716-446655440302" - uuid3 = "550e8400-e29b-41d4-a716-446655440303" - data = { - "data": { - "items": [ - { - "files": [ - { - "ID": uuid1, - "FullName": "file1.pdf", - "MimeType": "application/pdf", - }, - { - "ID": uuid2, - "FullName": "file2.pdf", - "MimeType": "application/pdf", - }, - ] - }, - { - "files": [ - { - "ID": uuid3, - "FullName": "file3.pdf", - "MimeType": "application/pdf", - } - ] - }, - ] - } - } - - result = get_job_attachments(model, data) - - # Should extract all attachments from deeply nested arrays - assert len(result) == 3 - ids = {str(att.id) for att in result} - assert ids == {uuid1, uuid2, uuid3} - - -class TestAddJobAttachments: - """Test attachment dictionary merging.""" - - def test_both_empty_dictionaries(self): - """Should return empty dict when both inputs are empty.""" - left = {} - right = {} - - result = add_job_attachments(left, right) - - assert result == {} - - def test_left_empty_right_has_attachments(self): - """Should return right dict when left is empty.""" - uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") - right = { - uuid1: Attachment.model_validate( - { - "ID": str(uuid1), - "FullName": "file1.pdf", - "MimeType": "application/pdf", - } - ) - } - - result = add_job_attachments({}, right) - - assert result == right - assert len(result) == 1 - assert result[uuid1].full_name == "file1.pdf" - - def test_left_has_attachments_right_empty(self): - """Should return left dict when right is empty.""" - uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") - left = { - uuid1: Attachment.model_validate( - { - "ID": str(uuid1), - "FullName": "file1.pdf", - "MimeType": "application/pdf", - } - ) - } - - result = add_job_attachments(left, {}) - - assert result == left - assert len(result) == 1 - assert result[uuid1].full_name == "file1.pdf" - - def test_no_overlapping_uuids(self): - """Should merge dicts with no overlapping keys.""" - uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") - uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") - - left = { - uuid1: Attachment.model_validate( - { - "ID": str(uuid1), - "FullName": "file1.pdf", - "MimeType": "application/pdf", - } - ) - } - right = { - uuid2: Attachment.model_validate( - { - "ID": str(uuid2), - "FullName": "file2.pdf", - "MimeType": "application/pdf", - } - ) - } - - result = add_job_attachments(left, right) - - assert len(result) == 2 - assert uuid1 in result - assert uuid2 in result - assert result[uuid1].full_name == "file1.pdf" - assert result[uuid2].full_name == "file2.pdf" - - def test_overlapping_uuid_right_takes_precedence(self): - """Should use right value when same UUID exists in both dicts.""" - uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") - - left = { - uuid1: Attachment.model_validate( - { - "ID": str(uuid1), - "FullName": "old_file.pdf", - "MimeType": "application/pdf", - } - ) - } - right = { - uuid1: Attachment.model_validate( - { - "ID": str(uuid1), - "FullName": "new_file.pdf", - "MimeType": "application/pdf", - } - ) - } - - result = add_job_attachments(left, right) - - assert len(result) == 1 - assert result[uuid1].full_name == "new_file.pdf" # Right takes precedence - - def test_mixed_overlapping_and_unique(self): - """Should correctly merge dicts with both overlapping and unique keys.""" - uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") - uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") - uuid3 = uuid.UUID("550e8400-e29b-41d4-a716-446655440003") - - left = { - uuid1: Attachment.model_validate( - { - "ID": str(uuid1), - "FullName": "file1_old.pdf", - "MimeType": "application/pdf", - } - ), - uuid2: Attachment.model_validate( - { - "ID": str(uuid2), - "FullName": "file2.pdf", - "MimeType": "application/pdf", - } - ), - } - right = { - uuid1: Attachment.model_validate( - { - "ID": str(uuid1), - "FullName": "file1_new.pdf", - "MimeType": "application/pdf", - } - ), - uuid3: Attachment.model_validate( - { - "ID": str(uuid3), - "FullName": "file3.pdf", - "MimeType": "application/pdf", - } - ), - } - - result = add_job_attachments(left, right) - - assert len(result) == 3 - assert result[uuid1].full_name == "file1_new.pdf" # Right overrides - assert result[uuid2].full_name == "file2.pdf" # From left only - assert result[uuid3].full_name == "file3.pdf" # From right only - - def test_multiple_attachments_same_operation(self): - """Should handle merging multiple attachments at once.""" - uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") - uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") - uuid3 = uuid.UUID("550e8400-e29b-41d4-a716-446655440003") - uuid4 = uuid.UUID("550e8400-e29b-41d4-a716-446655440004") - - left = { - uuid1: Attachment.model_validate( - { - "ID": str(uuid1), - "FullName": "file1.pdf", - "MimeType": "application/pdf", - } - ), - uuid2: Attachment.model_validate( - { - "ID": str(uuid2), - "FullName": "file2.pdf", - "MimeType": "application/pdf", - } - ), - } - right = { - uuid3: Attachment.model_validate( - { - "ID": str(uuid3), - "FullName": "file3.pdf", - "MimeType": "application/pdf", - } - ), - uuid4: Attachment.model_validate( - { - "ID": str(uuid4), - "FullName": "file4.pdf", - "MimeType": "application/pdf", - } - ), - } - - result = add_job_attachments(left, right) - - assert len(result) == 4 - assert all(uid in result for uid in [uuid1, uuid2, uuid3, uuid4]) diff --git a/tests/agent/wrappers/__init__.py b/tests/agent/wrappers/__init__.py new file mode 100644 index 00000000..9ad6f3fc --- /dev/null +++ b/tests/agent/wrappers/__init__.py @@ -0,0 +1 @@ +"""Tests for agent wrappers.""" diff --git a/tests/agent/wrappers/test_job_attachment_wrapper.py b/tests/agent/wrappers/test_job_attachment_wrapper.py new file mode 100644 index 00000000..01a3f2f0 --- /dev/null +++ b/tests/agent/wrappers/test_job_attachment_wrapper.py @@ -0,0 +1,546 @@ +"""Tests for job_attachment_wrapper module.""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from langchain_core.tools import BaseTool +from pydantic import BaseModel, Field +from uipath.agent.models.agent import BaseAgentToolResourceConfig +from uipath.platform.attachments import Attachment + +from uipath_langchain.agent.react.types import AgentGraphState +from uipath_langchain.agent.wrappers.job_attachment_wrapper import ( + get_job_attachment_wrapper, +) + + +class MockAttachmentSchema(BaseModel): + """Mock schema with job attachment field.""" + + attachment_id: uuid.UUID = Field(description="Job attachment ID") + name: str + + +class TestGetJobAttachmentWrapper: + """Test cases for get_job_attachment_wrapper function.""" + + @pytest.fixture + def mock_resource(self): + """Create a mock resource config.""" + return MagicMock(spec=BaseAgentToolResourceConfig) + + @pytest.fixture + def mock_tool(self): + """Create a mock tool.""" + tool = MagicMock(spec=BaseTool) + tool.ainvoke = AsyncMock(return_value={"result": "success"}) + return tool + + @pytest.fixture + def mock_tool_call(self): + """Create a mock tool call.""" + return { + "name": "test_tool", + "args": {"attachment_id": str(uuid.uuid4()), "name": "test"}, + "id": "call_123", + } + + @pytest.fixture + def mock_state(self): + """Create a mock agent graph state.""" + state = MagicMock(spec=AgentGraphState) + state.job_attachments = {} + state.messages = [] + return state + + @pytest.fixture + def mock_attachment(self): + """Create a mock attachment.""" + attachment_id = uuid.uuid4() + attachment = MagicMock(spec=Attachment) + attachment.id = attachment_id + attachment.model_dump = MagicMock( + return_value={"ID": str(attachment_id), "name": "test.pdf", "size": 1024} + ) + return attachment + + @pytest.mark.asyncio + async def test_tool_without_args_schema( + self, mock_resource, mock_tool, mock_tool_call, mock_state + ): + """Test that tool is invoked normally when args_schema is None.""" + mock_tool.args_schema = None + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, mock_tool_call, mock_state) + + assert result == {"result": "success"} + mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"]) + + @pytest.mark.asyncio + async def test_tool_with_dict_args_schema( + self, mock_resource, mock_tool, mock_tool_call, mock_state + ): + """Test that tool is invoked normally when args_schema is a dict.""" + mock_tool.args_schema = {"type": "object", "properties": {}} + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, mock_tool_call, mock_state) + + assert result == {"result": "success"} + mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"]) + + @pytest.mark.asyncio + async def test_tool_with_non_basemodel_schema( + self, mock_resource, mock_tool, mock_tool_call, mock_state + ): + """Test that tool is invoked normally when args_schema is not a BaseModel.""" + mock_tool.args_schema = str + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, mock_tool_call, mock_state) + + assert result == {"result": "success"} + mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"]) + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + async def test_tool_with_no_attachment_paths( + self, + mock_get_paths, + mock_resource, + mock_tool, + mock_tool_call, + mock_state, + ): + """Test that tool is invoked normally when no attachment paths are found.""" + mock_tool.args_schema = MockAttachmentSchema + mock_get_paths.return_value = [] + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, mock_tool_call, mock_state) + + assert result == {"result": "success"} + mock_get_paths.assert_called_once_with(MockAttachmentSchema) + mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"]) + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + async def test_tool_with_valid_attachments( + self, + mock_get_paths, + mock_resource, + mock_tool, + mock_attachment, + mock_state, + ): + """Test that tool is invoked with replaced values when all attachments are valid.""" + mock_tool.args_schema = MockAttachmentSchema + mock_get_paths.return_value = ["$.attachment"] + + # Setup state with valid attachment (string keys) + mock_state.job_attachments = {str(mock_attachment.id): mock_attachment} + + # Setup tool call with attachment ID + tool_call = { + "name": "test_tool", + "args": {"attachment": {"ID": str(mock_attachment.id)}, "name": "test"}, + "id": "call_123", + } + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, tool_call, mock_state) + + assert result == {"result": "success"} + # Verify that tool.ainvoke was called (with replaced attachment) + mock_tool.ainvoke.assert_awaited_once() + called_args = mock_tool.ainvoke.call_args[0][0] + assert called_args["name"] == "test" + assert "attachment" in called_args + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + async def test_tool_with_missing_attachment( + self, + mock_get_paths, + mock_resource, + mock_tool, + mock_state, + ): + """Test that error is returned when attachment is missing from state.""" + mock_tool.args_schema = MockAttachmentSchema + mock_get_paths.return_value = ["$.attachment"] + + attachment_id = uuid.uuid4() + tool_call = { + "name": "test_tool", + "args": {"attachment": {"ID": str(attachment_id)}, "name": "test"}, + "id": "call_123", + } + + # Empty state - attachment not found + mock_state.job_attachments = {} + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, tool_call, mock_state) + + assert isinstance(result, dict) + assert "error" in result + assert str(attachment_id) in result["error"] + assert "Could not find JobAttachment" in result["error"] + mock_tool.ainvoke.assert_not_awaited() + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + async def test_tool_with_multiple_missing_attachments( + self, + mock_get_paths, + mock_resource, + mock_tool, + mock_state, + ): + """Test that all missing attachments are reported in error.""" + mock_tool.args_schema = MockAttachmentSchema + mock_get_paths.return_value = ["$.attachments[*]"] + + attachment_id_1 = uuid.uuid4() + attachment_id_2 = uuid.uuid4() + + tool_call = { + "name": "test_tool", + "args": { + "attachments": [ + {"ID": str(attachment_id_1)}, + {"ID": str(attachment_id_2)}, + ], + "name": "test", + }, + "id": "call_123", + } + + # Empty state - both attachments not found + mock_state.job_attachments = {} + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, tool_call, mock_state) + + assert isinstance(result, dict) + assert "error" in result + + # Check that both attachment IDs are in the error message + assert str(attachment_id_1) in result["error"] + assert str(attachment_id_2) in result["error"] + + # Check that errors are newline-separated + error_lines = result["error"].split("\n") + assert len(error_lines) == 2 + + mock_tool.ainvoke.assert_not_awaited() + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + async def test_tool_with_invalid_uuid( + self, + mock_get_paths, + mock_resource, + mock_tool, + mock_state, + ): + """Test that error is returned when attachment ID is not a valid UUID.""" + mock_tool.args_schema = MockAttachmentSchema + mock_get_paths.return_value = ["$.attachment"] + + invalid_id = "not-a-valid-uuid" + tool_call = { + "name": "test_tool", + "args": {"attachment": {"ID": invalid_id}, "name": "test"}, + "id": "call_123", + } + + mock_state.job_attachments = {} + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, tool_call, mock_state) + + assert isinstance(result, dict) + assert "error" in result + assert invalid_id in result["error"] + mock_tool.ainvoke.assert_not_awaited() + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + async def test_tool_with_partial_valid_attachments( + self, + mock_get_paths, + mock_resource, + mock_tool, + mock_attachment, + mock_state, + ): + """Test that error is returned when some attachments are valid and others are not.""" + mock_tool.args_schema = MockAttachmentSchema + mock_get_paths.return_value = ["$.attachments[*]"] + + # One valid, one invalid (string keys) + mock_state.job_attachments = {str(mock_attachment.id): mock_attachment} + invalid_id = uuid.uuid4() + + tool_call = { + "name": "test_tool", + "args": { + "attachments": [ + {"ID": str(mock_attachment.id)}, + {"ID": str(invalid_id)}, + ], + "name": "test", + }, + "id": "call_123", + } + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, tool_call, mock_state) + + assert isinstance(result, dict) + assert "error" in result + assert str(invalid_id) in result["error"] + assert str(mock_attachment.id) not in result["error"] + mock_tool.ainvoke.assert_not_awaited() + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + async def test_tool_with_complex_nested_structure( + self, + mock_get_paths, + mock_resource, + mock_tool, + mock_state, + ): + """Test attachment validation with complex nested object structures and deep paths.""" + mock_tool.args_schema = MockAttachmentSchema + + # Setup multiple attachments with different IDs + attachment1_id = uuid.uuid4() + attachment2_id = uuid.uuid4() + attachment3_id = uuid.uuid4() + missing_attachment_id = uuid.uuid4() + + attachment1 = MagicMock(spec=Attachment) + attachment1.id = attachment1_id + attachment1.model_dump = MagicMock( + return_value={ + "ID": str(attachment1_id), + "name": "document1.pdf", + "size": 1024, + } + ) + + attachment2 = MagicMock(spec=Attachment) + attachment2.id = attachment2_id + attachment2.model_dump = MagicMock( + return_value={ + "ID": str(attachment2_id), + "name": "document2.pdf", + "size": 2048, + } + ) + + attachment3 = MagicMock(spec=Attachment) + attachment3.id = attachment3_id + attachment3.model_dump = MagicMock( + return_value={ + "ID": str(attachment3_id), + "name": "document3.pdf", + "size": 3072, + } + ) + + # Setup state with available attachments (string keys) + mock_state.job_attachments = { + str(attachment1_id): attachment1, + str(attachment2_id): attachment2, + str(attachment3_id): attachment3, + } + + # Define complex nested paths + mock_get_paths.return_value = [ + "$.request.metadata.primary_attachment", + "$.request.documents[*]", + "$.workflow.steps[*].input_files[*]", + "$.backup.archive.files[*]", + ] + + # Create complex nested tool call structure + tool_call = { + "name": "complex_tool", + "args": { + "request": { + "metadata": { + "primary_attachment": {"ID": str(attachment1_id)}, + "description": "Main request", + }, + "documents": [ + {"ID": str(attachment2_id)}, + {"ID": str(missing_attachment_id)}, # This one is missing + ], + }, + "workflow": { + "name": "process_docs", + "steps": [ + { + "name": "step1", + "input_files": [{"ID": str(attachment3_id)}], + }, + { + "name": "step2", + "input_files": [ + {"ID": str(attachment1_id)}, + ], + }, + ], + }, + "backup": { + "archive": { + "files": [ + {"ID": str(attachment2_id)}, + ] + } + }, + "other_field": "some_value", + }, + "id": "call_complex_123", + } + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, tool_call, mock_state) + + # Should return error for the missing attachment + assert isinstance(result, dict) + assert "error" in result + assert str(missing_attachment_id) in result["error"] + assert "Could not find JobAttachment" in result["error"] + + # Valid attachments should not be in error message + assert str(attachment1_id) not in result["error"] + assert str(attachment2_id) not in result["error"] + assert str(attachment3_id) not in result["error"] + + # Tool should not be invoked due to error + mock_tool.ainvoke.assert_not_awaited() + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + async def test_tool_with_complex_nested_structure_all_valid( + self, + mock_get_paths, + mock_resource, + mock_tool, + mock_state, + ): + """Test successful replacement with complex nested structure when all attachments are valid.""" + mock_tool.args_schema = MockAttachmentSchema + + # Setup multiple attachments + attachment1_id = uuid.uuid4() + attachment2_id = uuid.uuid4() + attachment3_id = uuid.uuid4() + + attachment1 = MagicMock(spec=Attachment) + attachment1.id = attachment1_id + attachment1.model_dump = MagicMock( + return_value={ + "ID": str(attachment1_id), + "name": "document1.pdf", + "size": 1024, + } + ) + + attachment2 = MagicMock(spec=Attachment) + attachment2.id = attachment2_id + attachment2.model_dump = MagicMock( + return_value={ + "ID": str(attachment2_id), + "name": "document2.pdf", + "size": 2048, + } + ) + + attachment3 = MagicMock(spec=Attachment) + attachment3.id = attachment3_id + attachment3.model_dump = MagicMock( + return_value={ + "ID": str(attachment3_id), + "name": "document3.pdf", + "size": 3072, + } + ) + + # Setup state with all attachments (string keys) + mock_state.job_attachments = { + str(attachment1_id): attachment1, + str(attachment2_id): attachment2, + str(attachment3_id): attachment3, + } + + # Define complex nested paths + mock_get_paths.return_value = [ + "$.request.metadata.primary_attachment", + "$.request.documents[*]", + "$.workflow.steps[*].input_files[*]", + ] + + # Create complex nested tool call structure with all valid attachments + tool_call = { + "name": "complex_tool", + "args": { + "request": { + "metadata": { + "primary_attachment": {"ID": str(attachment1_id)}, + "description": "Main request", + }, + "documents": [ + {"ID": str(attachment2_id)}, + {"ID": str(attachment3_id)}, + ], + }, + "workflow": { + "name": "process_docs", + "steps": [ + { + "name": "step1", + "input_files": [{"ID": str(attachment1_id)}], + }, + { + "name": "step2", + "input_files": [{"ID": str(attachment2_id)}], + }, + ], + }, + "other_field": "some_value", + }, + "id": "call_complex_456", + } + + wrapper = get_job_attachment_wrapper(mock_resource) + result = await wrapper(mock_tool, tool_call, mock_state) + + # Should succeed without errors + assert result == {"result": "success"} + + # Tool should be invoked with replaced attachments + mock_tool.ainvoke.assert_awaited_once() + called_args = mock_tool.ainvoke.call_args[0][0] + + # Verify structure is preserved + assert "request" in called_args + assert "metadata" in called_args["request"] + assert "documents" in called_args["request"] + assert "workflow" in called_args + assert "steps" in called_args["workflow"] + assert called_args["other_field"] == "some_value" + + # Verify attachments were replaced (they should now be full objects) + primary_attachment = called_args["request"]["metadata"]["primary_attachment"] + assert isinstance(primary_attachment, dict) + assert "name" in primary_attachment or "ID" in primary_attachment + From a2fb69cbebabdf014b4a710c73c9654364eca1d3 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Mon, 22 Dec 2025 11:41:06 +0200 Subject: [PATCH 04/15] fix: update uipath and jsonschema-pydantic-converter versions --- pyproject.toml | 25 +++++++------------------ uv.lock | 8 ++++---- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 18caa61a..d8a00251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "python-dotenv>=1.0.1", "httpx>=0.27.0", "openinference-instrumentation-langchain>=0.1.56", - "jsonschema-pydantic-converter>=0.1.5", + "jsonschema-pydantic-converter>=0.1.6", "jsonpath-ng>=1.7.0", "mcp==1.24.0", "langchain-mcp-adapters==0.2.1", @@ -31,18 +31,12 @@ classifiers = [ ] maintainers = [ { name = "Marius Cosareanu", email = "marius.cosareanu@uipath.com" }, - { name = "Cristian Pufu", email = "cristian.pufu@uipath.com" } + { name = "Cristian Pufu", email = "cristian.pufu@uipath.com" }, ] [project.optional-dependencies] -vertex = [ - "langchain-google-genai>=2.0.0", - "google-generativeai>=0.8.0", -] -bedrock = [ - "langchain-aws>=0.2.35", - "boto3-stubs>=1.41.4", -] +vertex = ["langchain-google-genai>=2.0.0", "google-generativeai>=0.8.0"] +bedrock = ["langchain-aws>=0.2.35", "boto3-stubs>=1.41.4"] [project.entry-points."uipath.middlewares"] register = "uipath_langchain.middlewares:register_middleware" @@ -69,7 +63,7 @@ dev = [ "pytest-asyncio>=1.0.0", "pre-commit>=4.1.0", "numpy>=1.24.0", - "pytest_httpx>=0.35.0" + "pytest_httpx>=0.35.0", ] [tool.hatch.build.targets.wheel] @@ -95,13 +89,8 @@ skip-magic-trailing-comma = false line-ending = "auto" [tool.mypy] -plugins = [ - "pydantic.mypy" -] -exclude = [ - "samples/.*", - "testcases/.*" -] +plugins = ["pydantic.mypy"] +exclude = ["samples/.*", "testcases/.*"] follow_imports = "silent" warn_redundant_casts = true diff --git a/uv.lock b/uv.lock index 9859eac7..34a30ad7 100644 --- a/uv.lock +++ b/uv.lock @@ -1199,14 +1199,14 @@ wheels = [ [[package]] name = "jsonschema-pydantic-converter" -version = "0.1.5" +version = "0.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/a1/b6a670843a889fd5442d75bcca3e8351b7d47351dfadd95bd453e3e36554/jsonschema_pydantic_converter-0.1.5.tar.gz", hash = "sha256:5b0802e872958f3fa57ed1dc6c95bebb43eff3f2e3bf997b7a7a47652f9e69ee", size = 57905, upload-time = "2025-11-25T06:56:39.022Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/27/3c6cd4e59cb9a2e91979ec5eb8408a2bfca0a40e0055ee4603e59ae1aa3d/jsonschema_pydantic_converter-0.1.6.tar.gz", hash = "sha256:15bde9fe9ea4a720b082ba334391bae90a21432cafbf9b6a80dc804823201e0d", size = 58756, upload-time = "2025-12-18T16:28:55.322Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/7a/660cbe4876fa5f3ed52acf0978659d3b2b6c4e766080177f69889cc5a4ee/jsonschema_pydantic_converter-0.1.5-py3-none-any.whl", hash = "sha256:dd35a42f968251f1e5d215e2272317a99b27719d6fc348fac3560257ef9f7b3f", size = 17450, upload-time = "2025-11-25T06:56:37.9Z" }, + { url = "https://files.pythonhosted.org/packages/c4/36/52c517c8d3f5196ad5096b559f97c5bd489b80d48a8af95c1af327cbda20/jsonschema_pydantic_converter-0.1.6-py3-none-any.whl", hash = "sha256:49011eb29a119fa12cf28295116ae31ba50eb7e94abfc0767949ab5cb970a5b4", size = 18041, upload-time = "2025-12-18T16:28:53.97Z" }, ] [[package]] @@ -3310,7 +3310,7 @@ requires-dist = [ { name = "google-generativeai", marker = "extra == 'vertex'", specifier = ">=0.8.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "jsonpath-ng", specifier = ">=1.7.0" }, - { name = "jsonschema-pydantic-converter", specifier = ">=0.1.5" }, + { name = "jsonschema-pydantic-converter", specifier = ">=0.1.6" }, { name = "langchain", specifier = ">=1.0.0,<2.0.0" }, { name = "langchain-aws", marker = "extra == 'bedrock'", specifier = ">=0.2.35" }, { name = "langchain-core", specifier = ">=1.0.0,<2.0.0" }, From 62ca99d52a1bcf9230b7a784c2422dd38ef1a563 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Mon, 22 Dec 2025 12:02:15 +0200 Subject: [PATCH 05/15] fix: refactored code --- .../agent/react/job_attachments.py | 225 +++++++++--------- .../internal_tools/analyze_files_tool.py | 15 -- .../internal_tools/internal_tool_factory.py | 16 -- .../agent/wrappers/job_attachment_wrapper.py | 6 +- 4 files changed, 116 insertions(+), 146 deletions(-) diff --git a/src/uipath_langchain/agent/react/job_attachments.py b/src/uipath_langchain/agent/react/job_attachments.py index edd211f1..e475d82f 100644 --- a/src/uipath_langchain/agent/react/job_attachments.py +++ b/src/uipath_langchain/agent/react/job_attachments.py @@ -51,85 +51,6 @@ def get_job_attachment_paths(model: type[BaseModel]) -> list[str]: return _get_json_paths_by_type(model, "Job_attachment") -def replace_job_attachment_ids( - json_paths: list[str], - tool_args: dict[str, Any], - state: dict[str, Attachment], - errors: list[str], -) -> dict[str, Any]: - """Replace job attachment IDs in tool_args with full attachment objects from state. - - For each JSON path, this function finds matching objects in tool_args and - replaces them with corresponding attachment objects from state. The matching - is done by looking up the object's 'ID' field in the state dictionary. - - If an ID is not a valid UUID or is not present in state, an error message - is added to the errors list. - - Args: - json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"]) - tool_args: The dictionary containing tool arguments to modify - state: Dictionary mapping attachment UUID strings to Attachment objects - errors: List to collect error messages for invalid or missing IDs - - Returns: - Modified copy of tool_args with attachment IDs replaced by full objects - - Example: - >>> state = { - ... "123e4567-e89b-12d3-a456-426614174000": Attachment(id="123e4567-e89b-12d3-a456-426614174000", name="file1.pdf"), - ... "223e4567-e89b-12d3-a456-426614174001": Attachment(id="223e4567-e89b-12d3-a456-426614174001", name="file2.pdf") - ... } - >>> tool_args = { - ... "attachment": {"ID": "123"}, - ... "other_field": "value" - ... } - >>> paths = ['$.attachment'] - >>> errors = [] - >>> replace_job_attachment_ids(paths, tool_args, state, errors) - {'attachment': {'ID': '123', 'name': 'file1.pdf', ...}, 'other_field': 'value'} - """ - result = copy.deepcopy(tool_args) - - for json_path in json_paths: - expr = parse(json_path) - matches = expr.find(result) - - for match in matches: - current_value = match.value - - if isinstance(current_value, dict) and "ID" in current_value: - attachment_id_str = str(current_value["ID"]) - - try: - uuid.UUID(attachment_id_str) - except (ValueError, AttributeError): - errors.append( - _create_job_attachment_error_message(attachment_id_str) - ) - continue - - if attachment_id_str in state: - replacement_value = state[attachment_id_str] - match.full_path.update( - result, replacement_value.model_dump(by_alias=True, mode="json") - ) - else: - errors.append( - _create_job_attachment_error_message(attachment_id_str) - ) - - return result - - -def _create_job_attachment_error_message(attachment_id_str: str) -> str: - return ( - f"Could not find JobAttachment with ID='{attachment_id_str}'. " - f"Try again invoking the tool and please make sure that you pass " - f"valid JobAttachment IDs associated with existing JobAttachments in the current context." - ) - - def _get_json_paths_by_type(model: type[BaseModel], type_name: str) -> list[str]: """Get JSONPath expressions for all fields that reference a specific type. @@ -211,38 +132,6 @@ def _recursive_search( return _recursive_search(model, "") -def _extract_values_by_paths( - obj: dict[str, Any] | BaseModel, json_paths: list[str] -) -> list[Any]: - """Extract values from an object using JSONPath expressions. - - Args: - obj: The object (dict or Pydantic model) to extract values from - json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"]) - - Returns: - List of all extracted values (flattened) - - Example: - >>> obj = { - ... "attachment": {"id": "123"}, - ... "attachments": [{"id": "456"}, {"id": "789"}] - ... } - >>> paths = ['$.attachment', '$.attachments[*]'] - >>> _extract_values_by_paths(obj, paths) - [{'id': '123'}, {'id': '456'}, {'id': '789'}] - """ - data = obj.model_dump() if isinstance(obj, BaseModel) else obj - - results = [] - for json_path in json_paths: - expr = parse(json_path) - matches = expr.find(data) - results.extend([match.value for match in matches]) - - return results - - def _get_target_type(model: type[BaseModel], type_name: str) -> Any: """Get the target type from the model's module. @@ -284,7 +173,6 @@ def matches_type(annotation: Any) -> bool: return matches_type - def _unwrap_optional(annotation: Any) -> Any: """Unwrap Optional/Union types to get the underlying type. @@ -305,3 +193,116 @@ def _unwrap_optional(annotation: Any) -> Any: def _is_pydantic_model(annotation: Any) -> bool: return isinstance(annotation, type) and issubclass(annotation, BaseModel) + +def replace_job_attachment_ids( + json_paths: list[str], + tool_args: dict[str, Any], + state: dict[str, Attachment], + errors: list[str], +) -> dict[str, Any]: + """Replace job attachment IDs in tool_args with full attachment objects from state. + + For each JSON path, this function finds matching objects in tool_args and + replaces them with corresponding attachment objects from state. The matching + is done by looking up the object's 'ID' field in the state dictionary. + + If an ID is not a valid UUID or is not present in state, an error message + is added to the errors list. + + Args: + json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"]) + tool_args: The dictionary containing tool arguments to modify + state: Dictionary mapping attachment UUID strings to Attachment objects + errors: List to collect error messages for invalid or missing IDs + + Returns: + Modified copy of tool_args with attachment IDs replaced by full objects + + Example: + >>> state = { + ... "123e4567-e89b-12d3-a456-426614174000": Attachment(id="123e4567-e89b-12d3-a456-426614174000", name="file1.pdf"), + ... "223e4567-e89b-12d3-a456-426614174001": Attachment(id="223e4567-e89b-12d3-a456-426614174001", name="file2.pdf") + ... } + >>> tool_args = { + ... "attachment": {"ID": "123"}, + ... "other_field": "value" + ... } + >>> paths = ['$.attachment'] + >>> errors = [] + >>> replace_job_attachment_ids(paths, tool_args, state, errors) + {'attachment': {'ID': '123', 'name': 'file1.pdf', ...}, 'other_field': 'value'} + """ + result = copy.deepcopy(tool_args) + + for json_path in json_paths: + expr = parse(json_path) + matches = expr.find(result) + + for match in matches: + current_value = match.value + + if isinstance(current_value, dict) and "ID" in current_value: + attachment_id_str = str(current_value["ID"]) + + try: + uuid.UUID(attachment_id_str) + except (ValueError, AttributeError): + errors.append( + _create_job_attachment_error_message(attachment_id_str) + ) + continue + + if attachment_id_str in state: + replacement_value = state[attachment_id_str] + match.full_path.update( + result, replacement_value.model_dump(by_alias=True, mode="json") + ) + else: + errors.append( + _create_job_attachment_error_message(attachment_id_str) + ) + + return result + + +def _create_job_attachment_error_message(attachment_id_str: str) -> str: + return ( + f"Could not find JobAttachment with ID='{attachment_id_str}'. " + f"Try again invoking the tool and please make sure that you pass " + f"valid JobAttachment IDs associated with existing JobAttachments in the current context." + ) + + +def _extract_values_by_paths( + obj: dict[str, Any] | BaseModel, json_paths: list[str] +) -> list[Any]: + """Extract values from an object using JSONPath expressions. + + Args: + obj: The object (dict or Pydantic model) to extract values from + json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"]) + + Returns: + List of all extracted values (flattened) + + Example: + >>> obj = { + ... "attachment": {"id": "123"}, + ... "attachments": [{"id": "456"}, {"id": "789"}] + ... } + >>> paths = ['$.attachment', '$.attachments[*]'] + >>> _extract_values_by_paths(obj, paths) + [{'id': '123'}, {'id': '456'}, {'id': '789'}] + """ + data = obj.model_dump() if isinstance(obj, BaseModel) else obj + + results = [] + for json_path in json_paths: + expr = parse(json_path) + matches = expr.find(data) + results.extend([match.value for match in matches]) + + return results + + + diff --git a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py index f3b18d9a..9b4ee9ea 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py @@ -24,21 +24,6 @@ class AnalyzeFileTool(StructuredToolWithOutputType, ToolWrapperMixin): def create_analyze_file_tool( resource: AgentInternalToolResourceConfig, ) -> StructuredTool: - """ - Creates an internal tool based on the resource configuration. - - Routes to the appropriate handler based on the tool_type specified in - the resource properties. - - Args: - resource: Internal tool resource configuration - - Returns: - A structured tool that can be used by LangChain agents - - Raises: - ValueError: If schema creation fails or tool_type is not supported - """ tool_name = sanitize_tool_name(resource.name) input_model = create_model(resource.input_schema) output_model = create_model(resource.output_schema) diff --git a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py index 8909e957..9c83fa92 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py +++ b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py @@ -34,28 +34,12 @@ def create_internal_tool(resource: AgentInternalToolResourceConfig) -> StructuredTool: """Create an internal tool based on the resource configuration. - Args: - resource: Internal tool resource configuration containing the tool type and - properties needed for tool creation. - - Returns: - A LangChain StructuredTool instance configured for the specified internal tool. - Raises: ValueError: If the tool type is not supported (no handler exists for it). - Example: - >>> resource = AgentInternalToolResourceConfig( - ... properties=AgentInternalToolProperties( - ... tool_type=AgentInternalToolType.ANALYZE_FILES - ... ) - ... ) - >>> tool = create_internal_tool(resource) - >>> result = tool.invoke({"file_content": "..."}) """ tool_type = resource.properties.tool_type - # Get the appropriate handler for this tool type handler = _INTERNAL_TOOL_HANDLERS.get(tool_type) if handler is None: raise ValueError( diff --git a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py index 104eb331..70505df7 100644 --- a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py +++ b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py @@ -46,7 +46,7 @@ async def job_attachment_wrapper( Tool invocation result, or error dict if attachment validation fails """ input_args = call["args"] - new_input_args = input_args + modified_input_args = input_args if isinstance(tool.args_schema, type) and issubclass( tool.args_schema, BaseModel @@ -54,13 +54,13 @@ async def job_attachment_wrapper( schema = cast(Type[BaseModel], tool.args_schema) errors: list[str] = [] paths = get_job_attachment_paths(schema) - new_input_args = replace_job_attachment_ids( + modified_input_args = replace_job_attachment_ids( paths, input_args, state.job_attachments, errors ) if errors: return {"error": "\n".join(errors)} - return await tool.ainvoke(new_input_args) + return await tool.ainvoke(modified_input_args) return job_attachment_wrapper From 8738ab48ad0e18104961fe22cbd510599934c7fd Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Mon, 22 Dec 2025 12:35:37 +0200 Subject: [PATCH 06/15] fix: linting issues --- src/uipath_langchain/agent/react/agent.py | 4 +++- .../agent/wrappers/job_attachment_wrapper.py | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 5f63a033..270083ca 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -76,7 +76,9 @@ def create_agent( flow_control_tools: list[BaseTool] = create_flow_control_tools(output_schema) llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools] - init_node = create_init_node(messages, input_schema) + init_node = create_init_node( + messages, input_schema if input_schema is not None else BaseModel + ) tool_nodes = create_tool_node(agent_tools) tool_nodes_with_guardrails = create_tools_guardrails_subgraph( tool_nodes, guardrails diff --git a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py index 70505df7..1fbf714c 100644 --- a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py +++ b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py @@ -1,4 +1,4 @@ -from typing import Any, Type, cast +from typing import Any from langchain_core.messages.tool import ToolCall from langchain_core.tools import BaseTool @@ -51,9 +51,8 @@ async def job_attachment_wrapper( if isinstance(tool.args_schema, type) and issubclass( tool.args_schema, BaseModel ): - schema = cast(Type[BaseModel], tool.args_schema) errors: list[str] = [] - paths = get_job_attachment_paths(schema) + paths = get_job_attachment_paths(tool.args_schema) modified_input_args = replace_job_attachment_ids( paths, input_args, state.job_attachments, errors ) From c4d7c54a905c43b38f3cde068e1b31ef3797df23 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Mon, 22 Dec 2025 13:57:03 +0200 Subject: [PATCH 07/15] fix: linting issues --- .../agent/react/job_attachments.py | 2 +- src/uipath_langchain/agent/react/utils.py | 9 +- tests/agent/react/test_job_attachments.py | 61 ++--- .../wrappers/test_job_attachment_wrapper.py | 250 ++++++++++-------- 4 files changed, 180 insertions(+), 142 deletions(-) diff --git a/src/uipath_langchain/agent/react/job_attachments.py b/src/uipath_langchain/agent/react/job_attachments.py index e475d82f..3bc7f89a 100644 --- a/src/uipath_langchain/agent/react/job_attachments.py +++ b/src/uipath_langchain/agent/react/job_attachments.py @@ -12,7 +12,7 @@ def get_job_attachments( schema: type[BaseModel], - data: dict[str, Any], + data: dict[str, Any] | BaseModel, ) -> list[Attachment]: """Extract job attachments from data based on schema and convert to Attachment objects. diff --git a/src/uipath_langchain/agent/react/utils.py b/src/uipath_langchain/agent/react/utils.py index 5a349bf8..94244c87 100644 --- a/src/uipath_langchain/agent/react/utils.py +++ b/src/uipath_langchain/agent/react/utils.py @@ -1,6 +1,5 @@ """ReAct Agent loop utilities.""" -import uuid from typing import Any, Sequence from langchain_core.messages import AIMessage, BaseMessage @@ -53,15 +52,15 @@ def count_consecutive_thinking_messages(messages: Sequence[BaseMessage]) -> int: def add_job_attachments( - left: dict[uuid.UUID, Attachment], right: dict[uuid.UUID, Attachment] -) -> dict[uuid.UUID, Attachment]: + left: dict[str, Attachment], right: dict[str, Attachment] +) -> dict[str, Attachment]: """Merge attachment dictionaries, with right values taking precedence. - This reducer function merges two dictionaries of attachments by UUID. + This reducer function merges two dictionaries of attachments by UUID string. If the same UUID exists in both dictionaries, the value from 'right' takes precedence. Args: - left: Existing dictionary of attachments keyed by UUID + left: Existing dictionary of attachments keyed by UUID string right: New dictionary of attachments to merge Returns: diff --git a/tests/agent/react/test_job_attachments.py b/tests/agent/react/test_job_attachments.py index dd278418..d995f5f7 100644 --- a/tests/agent/react/test_job_attachments.py +++ b/tests/agent/react/test_job_attachments.py @@ -1,4 +1,5 @@ import uuid +from typing import Any from pydantic import BaseModel from uipath.platform.attachments import Attachment @@ -42,7 +43,7 @@ def test_no_attachments_in_data(self): }, } model = create_model(schema) - data = {} + data: dict[str, Any] = {} result = get_job_attachments(model, data) @@ -203,7 +204,7 @@ def test_empty_array_attachments(self): }, } model = create_model(schema) - data = {"attachments": []} + data: dict[str, Any] = {"attachments": []} result = get_job_attachments(model, data) @@ -257,7 +258,7 @@ def test_pydantic_model_input(self): # Create a Pydantic model instance class TestModel(BaseModel): - attachment: dict + attachment: dict[str, Any] test_uuid = "550e8400-e29b-41d4-a716-446655440099" data_model = TestModel( @@ -438,8 +439,8 @@ class TestAddJobAttachments: def test_both_empty_dictionaries(self): """Should return empty dict when both inputs are empty.""" - left = {} - right = {} + left: dict[str, Attachment] = {} + right: dict[str, Attachment] = {} result = add_job_attachments(left, right) @@ -449,7 +450,7 @@ def test_left_empty_right_has_attachments(self): """Should return right dict when left is empty.""" uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") right = { - uuid1: Attachment.model_validate( + str(uuid1): Attachment.model_validate( { "ID": str(uuid1), "FullName": "file1.pdf", @@ -462,13 +463,13 @@ def test_left_empty_right_has_attachments(self): assert result == right assert len(result) == 1 - assert result[uuid1].full_name == "file1.pdf" + assert result[str(uuid1)].full_name == "file1.pdf" def test_left_has_attachments_right_empty(self): """Should return left dict when right is empty.""" uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") left = { - uuid1: Attachment.model_validate( + str(uuid1): Attachment.model_validate( { "ID": str(uuid1), "FullName": "file1.pdf", @@ -481,7 +482,7 @@ def test_left_has_attachments_right_empty(self): assert result == left assert len(result) == 1 - assert result[uuid1].full_name == "file1.pdf" + assert result[str(uuid1)].full_name == "file1.pdf" def test_no_overlapping_uuids(self): """Should merge dicts with no overlapping keys.""" @@ -489,7 +490,7 @@ def test_no_overlapping_uuids(self): uuid2 = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") left = { - uuid1: Attachment.model_validate( + str(uuid1): Attachment.model_validate( { "ID": str(uuid1), "FullName": "file1.pdf", @@ -498,7 +499,7 @@ def test_no_overlapping_uuids(self): ) } right = { - uuid2: Attachment.model_validate( + str(uuid2): Attachment.model_validate( { "ID": str(uuid2), "FullName": "file2.pdf", @@ -510,17 +511,17 @@ def test_no_overlapping_uuids(self): result = add_job_attachments(left, right) assert len(result) == 2 - assert uuid1 in result - assert uuid2 in result - assert result[uuid1].full_name == "file1.pdf" - assert result[uuid2].full_name == "file2.pdf" + assert str(uuid1) in result + assert str(uuid2) in result + assert result[str(uuid1)].full_name == "file1.pdf" + assert result[str(uuid2)].full_name == "file2.pdf" def test_overlapping_uuid_right_takes_precedence(self): """Should use right value when same UUID exists in both dicts.""" uuid1 = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") left = { - uuid1: Attachment.model_validate( + str(uuid1): Attachment.model_validate( { "ID": str(uuid1), "FullName": "old_file.pdf", @@ -529,7 +530,7 @@ def test_overlapping_uuid_right_takes_precedence(self): ) } right = { - uuid1: Attachment.model_validate( + str(uuid1): Attachment.model_validate( { "ID": str(uuid1), "FullName": "new_file.pdf", @@ -541,7 +542,7 @@ def test_overlapping_uuid_right_takes_precedence(self): result = add_job_attachments(left, right) assert len(result) == 1 - assert result[uuid1].full_name == "new_file.pdf" # Right takes precedence + assert result[str(uuid1)].full_name == "new_file.pdf" # Right takes precedence def test_mixed_overlapping_and_unique(self): """Should correctly merge dicts with both overlapping and unique keys.""" @@ -550,14 +551,14 @@ def test_mixed_overlapping_and_unique(self): uuid3 = uuid.UUID("550e8400-e29b-41d4-a716-446655440003") left = { - uuid1: Attachment.model_validate( + str(uuid1): Attachment.model_validate( { "ID": str(uuid1), "FullName": "file1_old.pdf", "MimeType": "application/pdf", } ), - uuid2: Attachment.model_validate( + str(uuid2): Attachment.model_validate( { "ID": str(uuid2), "FullName": "file2.pdf", @@ -566,14 +567,14 @@ def test_mixed_overlapping_and_unique(self): ), } right = { - uuid1: Attachment.model_validate( + str(uuid1): Attachment.model_validate( { "ID": str(uuid1), "FullName": "file1_new.pdf", "MimeType": "application/pdf", } ), - uuid3: Attachment.model_validate( + str(uuid3): Attachment.model_validate( { "ID": str(uuid3), "FullName": "file3.pdf", @@ -585,9 +586,9 @@ def test_mixed_overlapping_and_unique(self): result = add_job_attachments(left, right) assert len(result) == 3 - assert result[uuid1].full_name == "file1_new.pdf" # Right overrides - assert result[uuid2].full_name == "file2.pdf" # From left only - assert result[uuid3].full_name == "file3.pdf" # From right only + assert result[str(uuid1)].full_name == "file1_new.pdf" # Right overrides + assert result[str(uuid2)].full_name == "file2.pdf" # From left only + assert result[str(uuid3)].full_name == "file3.pdf" # From right only def test_multiple_attachments_same_operation(self): """Should handle merging multiple attachments at once.""" @@ -597,14 +598,14 @@ def test_multiple_attachments_same_operation(self): uuid4 = uuid.UUID("550e8400-e29b-41d4-a716-446655440004") left = { - uuid1: Attachment.model_validate( + str(uuid1): Attachment.model_validate( { "ID": str(uuid1), "FullName": "file1.pdf", "MimeType": "application/pdf", } ), - uuid2: Attachment.model_validate( + str(uuid2): Attachment.model_validate( { "ID": str(uuid2), "FullName": "file2.pdf", @@ -613,14 +614,14 @@ def test_multiple_attachments_same_operation(self): ), } right = { - uuid3: Attachment.model_validate( + str(uuid3): Attachment.model_validate( { "ID": str(uuid3), "FullName": "file3.pdf", "MimeType": "application/pdf", } ), - uuid4: Attachment.model_validate( + str(uuid4): Attachment.model_validate( { "ID": str(uuid4), "FullName": "file4.pdf", @@ -632,4 +633,4 @@ def test_multiple_attachments_same_operation(self): result = add_job_attachments(left, right) assert len(result) == 4 - assert all(uid in result for uid in [uuid1, uuid2, uuid3, uuid4]) + assert all(str(uid) in result for uid in [uuid1, uuid2, uuid3, uuid4]) diff --git a/tests/agent/wrappers/test_job_attachment_wrapper.py b/tests/agent/wrappers/test_job_attachment_wrapper.py index 01a3f2f0..6c4c171e 100644 --- a/tests/agent/wrappers/test_job_attachment_wrapper.py +++ b/tests/agent/wrappers/test_job_attachment_wrapper.py @@ -1,9 +1,11 @@ """Tests for job_attachment_wrapper module.""" import uuid +from typing import cast from unittest.mock import AsyncMock, MagicMock, patch import pytest +from langchain_core.messages.tool import ToolCall from langchain_core.tools import BaseTool from pydantic import BaseModel, Field from uipath.agent.models.agent import BaseAgentToolResourceConfig @@ -105,7 +107,9 @@ async def test_tool_with_non_basemodel_schema( mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"]) @pytest.mark.asyncio - @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths" + ) async def test_tool_with_no_attachment_paths( self, mock_get_paths, @@ -126,7 +130,9 @@ async def test_tool_with_no_attachment_paths( mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"]) @pytest.mark.asyncio - @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths" + ) async def test_tool_with_valid_attachments( self, mock_get_paths, @@ -143,11 +149,14 @@ async def test_tool_with_valid_attachments( mock_state.job_attachments = {str(mock_attachment.id): mock_attachment} # Setup tool call with attachment ID - tool_call = { - "name": "test_tool", - "args": {"attachment": {"ID": str(mock_attachment.id)}, "name": "test"}, - "id": "call_123", - } + tool_call = cast( + ToolCall, + { + "name": "test_tool", + "args": {"attachment": {"ID": str(mock_attachment.id)}, "name": "test"}, + "id": "call_123", + }, + ) wrapper = get_job_attachment_wrapper(mock_resource) result = await wrapper(mock_tool, tool_call, mock_state) @@ -160,7 +169,9 @@ async def test_tool_with_valid_attachments( assert "attachment" in called_args @pytest.mark.asyncio - @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths" + ) async def test_tool_with_missing_attachment( self, mock_get_paths, @@ -173,11 +184,14 @@ async def test_tool_with_missing_attachment( mock_get_paths.return_value = ["$.attachment"] attachment_id = uuid.uuid4() - tool_call = { - "name": "test_tool", - "args": {"attachment": {"ID": str(attachment_id)}, "name": "test"}, - "id": "call_123", - } + tool_call = cast( + ToolCall, + { + "name": "test_tool", + "args": {"attachment": {"ID": str(attachment_id)}, "name": "test"}, + "id": "call_123", + }, + ) # Empty state - attachment not found mock_state.job_attachments = {} @@ -192,7 +206,9 @@ async def test_tool_with_missing_attachment( mock_tool.ainvoke.assert_not_awaited() @pytest.mark.asyncio - @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths" + ) async def test_tool_with_multiple_missing_attachments( self, mock_get_paths, @@ -207,17 +223,20 @@ async def test_tool_with_multiple_missing_attachments( attachment_id_1 = uuid.uuid4() attachment_id_2 = uuid.uuid4() - tool_call = { - "name": "test_tool", - "args": { - "attachments": [ - {"ID": str(attachment_id_1)}, - {"ID": str(attachment_id_2)}, - ], - "name": "test", + tool_call = cast( + ToolCall, + { + "name": "test_tool", + "args": { + "attachments": [ + {"ID": str(attachment_id_1)}, + {"ID": str(attachment_id_2)}, + ], + "name": "test", + }, + "id": "call_123", }, - "id": "call_123", - } + ) # Empty state - both attachments not found mock_state.job_attachments = {} @@ -239,7 +258,9 @@ async def test_tool_with_multiple_missing_attachments( mock_tool.ainvoke.assert_not_awaited() @pytest.mark.asyncio - @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths" + ) async def test_tool_with_invalid_uuid( self, mock_get_paths, @@ -252,11 +273,14 @@ async def test_tool_with_invalid_uuid( mock_get_paths.return_value = ["$.attachment"] invalid_id = "not-a-valid-uuid" - tool_call = { - "name": "test_tool", - "args": {"attachment": {"ID": invalid_id}, "name": "test"}, - "id": "call_123", - } + tool_call = cast( + ToolCall, + { + "name": "test_tool", + "args": {"attachment": {"ID": invalid_id}, "name": "test"}, + "id": "call_123", + }, + ) mock_state.job_attachments = {} @@ -269,7 +293,9 @@ async def test_tool_with_invalid_uuid( mock_tool.ainvoke.assert_not_awaited() @pytest.mark.asyncio - @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths" + ) async def test_tool_with_partial_valid_attachments( self, mock_get_paths, @@ -286,17 +312,20 @@ async def test_tool_with_partial_valid_attachments( mock_state.job_attachments = {str(mock_attachment.id): mock_attachment} invalid_id = uuid.uuid4() - tool_call = { - "name": "test_tool", - "args": { - "attachments": [ - {"ID": str(mock_attachment.id)}, - {"ID": str(invalid_id)}, - ], - "name": "test", + tool_call = cast( + ToolCall, + { + "name": "test_tool", + "args": { + "attachments": [ + {"ID": str(mock_attachment.id)}, + {"ID": str(invalid_id)}, + ], + "name": "test", + }, + "id": "call_123", }, - "id": "call_123", - } + ) wrapper = get_job_attachment_wrapper(mock_resource) result = await wrapper(mock_tool, tool_call, mock_state) @@ -308,7 +337,9 @@ async def test_tool_with_partial_valid_attachments( mock_tool.ainvoke.assert_not_awaited() @pytest.mark.asyncio - @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths" + ) async def test_tool_with_complex_nested_structure( self, mock_get_paths, @@ -371,45 +402,48 @@ async def test_tool_with_complex_nested_structure( ] # Create complex nested tool call structure - tool_call = { - "name": "complex_tool", - "args": { - "request": { - "metadata": { - "primary_attachment": {"ID": str(attachment1_id)}, - "description": "Main request", - }, - "documents": [ - {"ID": str(attachment2_id)}, - {"ID": str(missing_attachment_id)}, # This one is missing - ], - }, - "workflow": { - "name": "process_docs", - "steps": [ - { - "name": "step1", - "input_files": [{"ID": str(attachment3_id)}], - }, - { - "name": "step2", - "input_files": [ - {"ID": str(attachment1_id)}, - ], + tool_call = cast( + ToolCall, + { + "name": "complex_tool", + "args": { + "request": { + "metadata": { + "primary_attachment": {"ID": str(attachment1_id)}, + "description": "Main request", }, - ], - }, - "backup": { - "archive": { - "files": [ + "documents": [ {"ID": str(attachment2_id)}, - ] - } + {"ID": str(missing_attachment_id)}, # This one is missing + ], + }, + "workflow": { + "name": "process_docs", + "steps": [ + { + "name": "step1", + "input_files": [{"ID": str(attachment3_id)}], + }, + { + "name": "step2", + "input_files": [ + {"ID": str(attachment1_id)}, + ], + }, + ], + }, + "backup": { + "archive": { + "files": [ + {"ID": str(attachment2_id)}, + ] + } + }, + "other_field": "some_value", }, - "other_field": "some_value", + "id": "call_complex_123", }, - "id": "call_complex_123", - } + ) wrapper = get_job_attachment_wrapper(mock_resource) result = await wrapper(mock_tool, tool_call, mock_state) @@ -429,7 +463,9 @@ async def test_tool_with_complex_nested_structure( mock_tool.ainvoke.assert_not_awaited() @pytest.mark.asyncio - @patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths") + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_paths" + ) async def test_tool_with_complex_nested_structure_all_valid( self, mock_get_paths, @@ -490,36 +526,39 @@ async def test_tool_with_complex_nested_structure_all_valid( ] # Create complex nested tool call structure with all valid attachments - tool_call = { - "name": "complex_tool", - "args": { - "request": { - "metadata": { - "primary_attachment": {"ID": str(attachment1_id)}, - "description": "Main request", - }, - "documents": [ - {"ID": str(attachment2_id)}, - {"ID": str(attachment3_id)}, - ], - }, - "workflow": { - "name": "process_docs", - "steps": [ - { - "name": "step1", - "input_files": [{"ID": str(attachment1_id)}], - }, - { - "name": "step2", - "input_files": [{"ID": str(attachment2_id)}], + tool_call = cast( + ToolCall, + { + "name": "complex_tool", + "args": { + "request": { + "metadata": { + "primary_attachment": {"ID": str(attachment1_id)}, + "description": "Main request", }, - ], + "documents": [ + {"ID": str(attachment2_id)}, + {"ID": str(attachment3_id)}, + ], + }, + "workflow": { + "name": "process_docs", + "steps": [ + { + "name": "step1", + "input_files": [{"ID": str(attachment1_id)}], + }, + { + "name": "step2", + "input_files": [{"ID": str(attachment2_id)}], + }, + ], + }, + "other_field": "some_value", }, - "other_field": "some_value", + "id": "call_complex_456", }, - "id": "call_complex_456", - } + ) wrapper = get_job_attachment_wrapper(mock_resource) result = await wrapper(mock_tool, tool_call, mock_state) @@ -543,4 +582,3 @@ async def test_tool_with_complex_nested_structure_all_valid( primary_attachment = called_args["request"]["metadata"]["primary_attachment"] assert isinstance(primary_attachment, dict) assert "name" in primary_attachment or "ID" in primary_attachment - From 9ca88f03215c18ec87d9abed3fe94362d3140540 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Mon, 22 Dec 2025 13:59:01 +0200 Subject: [PATCH 08/15] fix: linting issues --- src/uipath_langchain/agent/react/types.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/uipath_langchain/agent/react/types.py b/src/uipath_langchain/agent/react/types.py index 0327afb2..e0b68b38 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -1,4 +1,3 @@ -import uuid from enum import StrEnum from typing import Annotated, Any, Optional From a3165a9288734acbaa134266ea5a10882769db33 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Mon, 22 Dec 2025 14:00:51 +0200 Subject: [PATCH 09/15] fix: ruff format issues --- src/uipath_langchain/agent/react/job_attachments.py | 5 ++--- src/uipath_langchain/agent/tools/internal_tools/__init__.py | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/uipath_langchain/agent/react/job_attachments.py b/src/uipath_langchain/agent/react/job_attachments.py index 3bc7f89a..91e03065 100644 --- a/src/uipath_langchain/agent/react/job_attachments.py +++ b/src/uipath_langchain/agent/react/job_attachments.py @@ -173,6 +173,7 @@ def matches_type(annotation: Any) -> bool: return matches_type + def _unwrap_optional(annotation: Any) -> Any: """Unwrap Optional/Union types to get the underlying type. @@ -194,6 +195,7 @@ def _unwrap_optional(annotation: Any) -> Any: def _is_pydantic_model(annotation: Any) -> bool: return isinstance(annotation, type) and issubclass(annotation, BaseModel) + def replace_job_attachment_ids( json_paths: list[str], tool_args: dict[str, Any], @@ -303,6 +305,3 @@ def _extract_values_by_paths( results.extend([match.value for match in matches]) return results - - - diff --git a/src/uipath_langchain/agent/tools/internal_tools/__init__.py b/src/uipath_langchain/agent/tools/internal_tools/__init__.py index 851ee313..5298b109 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/__init__.py +++ b/src/uipath_langchain/agent/tools/internal_tools/__init__.py @@ -2,6 +2,4 @@ from .internal_tool_factory import create_internal_tool -__all__ = [ - "create_internal_tool" -] +__all__ = ["create_internal_tool"] From c9e633af28016cae5fabbf381745aa3b050aa957 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Mon, 22 Dec 2025 18:22:50 +0200 Subject: [PATCH 10/15] fix: address PR comments --- src/uipath_langchain/agent/react/agent.py | 4 +--- src/uipath_langchain/agent/react/init_node.py | 5 +++-- src/uipath_langchain/agent/react/job_attachments.py | 10 ++-------- tests/agent/react/test_job_attachments.py | 8 ++++++++ 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 270083ca..5f63a033 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -76,9 +76,7 @@ def create_agent( flow_control_tools: list[BaseTool] = create_flow_control_tools(output_schema) llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools] - init_node = create_init_node( - messages, input_schema if input_schema is not None else BaseModel - ) + init_node = create_init_node(messages, input_schema) tool_nodes = create_tool_node(agent_tools) tool_nodes_with_guardrails = create_tools_guardrails_subgraph( tool_nodes, guardrails diff --git a/src/uipath_langchain/agent/react/init_node.py b/src/uipath_langchain/agent/react/init_node.py index e042dc4e..4f521650 100644 --- a/src/uipath_langchain/agent/react/init_node.py +++ b/src/uipath_langchain/agent/react/init_node.py @@ -13,7 +13,7 @@ def create_init_node( messages: Sequence[SystemMessage | HumanMessage] | Callable[[Any], Sequence[SystemMessage | HumanMessage]], - input_schema: type[BaseModel], + input_schema: type[BaseModel] | None, ): def graph_state_init(state: Any): if callable(messages): @@ -21,7 +21,8 @@ def graph_state_init(state: Any): else: resolved_messages = messages - job_attachments = get_job_attachments(input_schema, state) + schema = input_schema if input_schema is not None else BaseModel + job_attachments = get_job_attachments(schema, state) job_attachments_dict = { str(att.id): att for att in job_attachments if att.id is not None } diff --git a/src/uipath_langchain/agent/react/job_attachments.py b/src/uipath_langchain/agent/react/job_attachments.py index 91e03065..62e759f4 100644 --- a/src/uipath_langchain/agent/react/job_attachments.py +++ b/src/uipath_langchain/agent/react/job_attachments.py @@ -28,13 +28,7 @@ def get_job_attachments( result = [] for attachment in job_attachments: - if isinstance(attachment, BaseModel): - attachment_dict = attachment.model_dump(by_alias=True) - result.append(Attachment.model_validate(attachment_dict)) - elif isinstance(attachment, dict): - result.append(Attachment.model_validate(attachment)) - else: - result.append(Attachment.model_validate(attachment)) + result.append(Attachment.model_validate(attachment, from_attributes=True)) return result @@ -270,7 +264,7 @@ def replace_job_attachment_ids( def _create_job_attachment_error_message(attachment_id_str: str) -> str: return ( f"Could not find JobAttachment with ID='{attachment_id_str}'. " - f"Try again invoking the tool and please make sure that you pass " + f"Try invoking the tool again and please make sure that you pass " f"valid JobAttachment IDs associated with existing JobAttachments in the current context." ) diff --git a/tests/agent/react/test_job_attachments.py b/tests/agent/react/test_job_attachments.py index d995f5f7..c73ecee3 100644 --- a/tests/agent/react/test_job_attachments.py +++ b/tests/agent/react/test_job_attachments.py @@ -14,6 +14,14 @@ class TestGetJobAttachments: """Test job attachment extraction from data based on schema.""" + def test_base_model_schema(self): + """Should return empty list when schema is BaseModel (no fields).""" + data = {"name": "test", "value": 42} + + result = get_job_attachments(BaseModel, data) + + assert result == [] + def test_no_attachments_in_schema(self): """Should return empty list when schema has no job-attachment fields.""" schema = { From 23a85488c79047daa95f7e85f276593d0970fc3c Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Tue, 23 Dec 2025 11:38:48 +0200 Subject: [PATCH 11/15] fix: pr comments --- .../agent/react/job_attachments.py | 186 +----------------- .../agent/react/json_utils.py | 183 +++++++++++++++++ 2 files changed, 188 insertions(+), 181 deletions(-) create mode 100644 src/uipath_langchain/agent/react/json_utils.py diff --git a/src/uipath_langchain/agent/react/job_attachments.py b/src/uipath_langchain/agent/react/job_attachments.py index 62e759f4..9119b4b9 100644 --- a/src/uipath_langchain/agent/react/job_attachments.py +++ b/src/uipath_langchain/agent/react/job_attachments.py @@ -1,14 +1,15 @@ """Job attachment utilities for ReAct Agent.""" import copy -import sys import uuid -from typing import Any, ForwardRef, Union, get_args, get_origin +from typing import Any from jsonpath_ng import parse # type: ignore[import-untyped] from pydantic import BaseModel from uipath.platform.attachments import Attachment +from .json_utils import extract_values_by_paths, get_json_paths_by_type + def get_job_attachments( schema: type[BaseModel], @@ -24,7 +25,7 @@ def get_job_attachments( List of Attachment objects """ job_attachment_paths = get_job_attachment_paths(schema) - job_attachments = _extract_values_by_paths(data, job_attachment_paths) + job_attachments = extract_values_by_paths(data, job_attachment_paths) result = [] for attachment in job_attachments: @@ -42,152 +43,7 @@ def get_job_attachment_paths(model: type[BaseModel]) -> list[str]: Returns: List of JSONPath expressions pointing to job attachment fields """ - return _get_json_paths_by_type(model, "Job_attachment") - - -def _get_json_paths_by_type(model: type[BaseModel], type_name: str) -> list[str]: - """Get JSONPath expressions for all fields that reference a specific type. - - This function recursively traverses nested Pydantic models to find all paths - that lead to fields of the specified type. - - Args: - model: A Pydantic model class - type_name: The name of the type to search for (e.g., "Job_attachment") - - Returns: - List of JSONPath expressions using standard JSONPath syntax. - For array fields, uses [*] to indicate all array elements. - - Example: - >>> schema = { - ... "type": "object", - ... "properties": { - ... "attachment": {"$ref": "#/definitions/job-attachment"}, - ... "attachments": { - ... "type": "array", - ... "items": {"$ref": "#/definitions/job-attachment"} - ... } - ... }, - ... "definitions": { - ... "job-attachment": {"type": "object", "properties": {"id": {"type": "string"}}} - ... } - ... } - >>> model = transform(schema) - >>> _get_json_paths_by_type(model, "Job_attachment") - ['$.attachment', '$.attachments[*]'] - """ - - def _recursive_search( - current_model: type[BaseModel], current_path: str - ) -> list[str]: - """Recursively search for fields of the target type.""" - json_paths = [] - - target_type = _get_target_type(current_model, type_name) - matches_type = _create_type_matcher(type_name, target_type) - - for field_name, field_info in current_model.model_fields.items(): - annotation = field_info.annotation - - if current_path: - field_path = f"{current_path}.{field_name}" - else: - field_path = f"$.{field_name}" - - annotation = _unwrap_optional(annotation) - origin = get_origin(annotation) - - if matches_type(annotation): - json_paths.append(field_path) - continue - - if origin is list: - args = get_args(annotation) - if args: - list_item_type = args[0] - if matches_type(list_item_type): - json_paths.append(f"{field_path}[*]") - continue - - if _is_pydantic_model(list_item_type): - nested_paths = _recursive_search( - list_item_type, f"{field_path}[*]" - ) - json_paths.extend(nested_paths) - continue - - if _is_pydantic_model(annotation): - nested_paths = _recursive_search(annotation, field_path) - json_paths.extend(nested_paths) - - return json_paths - - return _recursive_search(model, "") - - -def _get_target_type(model: type[BaseModel], type_name: str) -> Any: - """Get the target type from the model's module. - - Args: - model: A Pydantic model class - type_name: The name of the type to search for - - Returns: - The target type if found, None otherwise - """ - model_module = sys.modules.get(model.__module__) - if model_module and hasattr(model_module, type_name): - return getattr(model_module, type_name) - return None - - -def _create_type_matcher(type_name: str, target_type: Any) -> Any: - """Create a function that checks if an annotation matches the target type. - - Args: - type_name: The name of the type to match - target_type: The actual type object (can be None) - - Returns: - A function that takes an annotation and returns True if it matches - """ - - def matches_type(annotation: Any) -> bool: - """Check if an annotation matches the target type name.""" - if isinstance(annotation, ForwardRef): - return annotation.__forward_arg__ == type_name - if isinstance(annotation, str): - return annotation == type_name - if hasattr(annotation, "__name__") and annotation.__name__ == type_name: - return True - if target_type is not None and annotation is target_type: - return True - return False - - return matches_type - - -def _unwrap_optional(annotation: Any) -> Any: - """Unwrap Optional/Union types to get the underlying type. - - Args: - annotation: The type annotation to unwrap - - Returns: - The unwrapped type, or the original if not Optional/Union - """ - origin = get_origin(annotation) - if origin is Union: - args = get_args(annotation) - non_none_args = [arg for arg in args if arg is not type(None)] - if non_none_args: - return non_none_args[0] - return annotation - - -def _is_pydantic_model(annotation: Any) -> bool: - return isinstance(annotation, type) and issubclass(annotation, BaseModel) + return get_json_paths_by_type(model, "Job_attachment") def replace_job_attachment_ids( @@ -267,35 +123,3 @@ def _create_job_attachment_error_message(attachment_id_str: str) -> str: f"Try invoking the tool again and please make sure that you pass " f"valid JobAttachment IDs associated with existing JobAttachments in the current context." ) - - -def _extract_values_by_paths( - obj: dict[str, Any] | BaseModel, json_paths: list[str] -) -> list[Any]: - """Extract values from an object using JSONPath expressions. - - Args: - obj: The object (dict or Pydantic model) to extract values from - json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"]) - - Returns: - List of all extracted values (flattened) - - Example: - >>> obj = { - ... "attachment": {"id": "123"}, - ... "attachments": [{"id": "456"}, {"id": "789"}] - ... } - >>> paths = ['$.attachment', '$.attachments[*]'] - >>> _extract_values_by_paths(obj, paths) - [{'id': '123'}, {'id': '456'}, {'id': '789'}] - """ - data = obj.model_dump() if isinstance(obj, BaseModel) else obj - - results = [] - for json_path in json_paths: - expr = parse(json_path) - matches = expr.find(data) - results.extend([match.value for match in matches]) - - return results diff --git a/src/uipath_langchain/agent/react/json_utils.py b/src/uipath_langchain/agent/react/json_utils.py new file mode 100644 index 00000000..d33357a3 --- /dev/null +++ b/src/uipath_langchain/agent/react/json_utils.py @@ -0,0 +1,183 @@ +import sys +from typing import Any, ForwardRef, Union, get_args, get_origin + +from jsonpath_ng import parse # type: ignore[import-untyped] +from pydantic import BaseModel + + +def get_json_paths_by_type(model: type[BaseModel], type_name: str) -> list[str]: + """Get JSONPath expressions for all fields that reference a specific type. + + This function recursively traverses nested Pydantic models to find all paths + that lead to fields of the specified type. + + Args: + model: A Pydantic model class + type_name: The name of the type to search for (e.g., "Job_attachment") + + Returns: + List of JSONPath expressions using standard JSONPath syntax. + For array fields, uses [*] to indicate all array elements. + + Example: + >>> schema = { + ... "type": "object", + ... "properties": { + ... "attachment": {"$ref": "#/definitions/job-attachment"}, + ... "attachments": { + ... "type": "array", + ... "items": {"$ref": "#/definitions/job-attachment"} + ... } + ... }, + ... "definitions": { + ... "job-attachment": {"type": "object", "properties": {"id": {"type": "string"}}} + ... } + ... } + >>> model = transform(schema) + >>> _get_json_paths_by_type(model, "Job_attachment") + ['$.attachment', '$.attachments[*]'] + """ + + def _recursive_search( + current_model: type[BaseModel], current_path: str + ) -> list[str]: + """Recursively search for fields of the target type.""" + json_paths = [] + + target_type = _get_target_type(current_model, type_name) + matches_type = _create_type_matcher(type_name, target_type) + + for field_name, field_info in current_model.model_fields.items(): + annotation = field_info.annotation + + if current_path: + field_path = f"{current_path}.{field_name}" + else: + field_path = f"$.{field_name}" + + annotation = _unwrap_optional(annotation) + origin = get_origin(annotation) + + if matches_type(annotation): + json_paths.append(field_path) + continue + + if origin is list: + args = get_args(annotation) + if args: + list_item_type = args[0] + if matches_type(list_item_type): + json_paths.append(f"{field_path}[*]") + continue + + if _is_pydantic_model(list_item_type): + nested_paths = _recursive_search( + list_item_type, f"{field_path}[*]" + ) + json_paths.extend(nested_paths) + continue + + if _is_pydantic_model(annotation): + nested_paths = _recursive_search(annotation, field_path) + json_paths.extend(nested_paths) + + return json_paths + + return _recursive_search(model, "") + + +def extract_values_by_paths( + obj: dict[str, Any] | BaseModel, json_paths: list[str] +) -> list[Any]: + """Extract values from an object using JSONPath expressions. + + Args: + obj: The object (dict or Pydantic model) to extract values from + json_paths: List of JSONPath expressions. **Paths are assumed to be disjoint** + (non-overlapping). If paths overlap, duplicate values will be returned. + + Returns: + List of all extracted values (flattened) + + Example: + >>> obj = { + ... "attachment": {"id": "123"}, + ... "attachments": [{"id": "456"}, {"id": "789"}] + ... } + >>> paths = ['$.attachment', '$.attachments[*]'] + >>> _extract_values_by_paths(obj, paths) + [{'id': '123'}, {'id': '456'}, {'id': '789'}] + """ + data = obj.model_dump() if isinstance(obj, BaseModel) else obj + + results = [] + for json_path in json_paths: + expr = parse(json_path) + matches = expr.find(data) + results.extend([match.value for match in matches]) + + return results + + +def _get_target_type(model: type[BaseModel], type_name: str) -> Any: + """Get the target type from the model's module. + + Args: + model: A Pydantic model class + type_name: The name of the type to search for + + Returns: + The target type if found, None otherwise + """ + model_module = sys.modules.get(model.__module__) + if model_module and hasattr(model_module, type_name): + return getattr(model_module, type_name) + return None + + +def _create_type_matcher(type_name: str, target_type: Any) -> Any: + """Create a function that checks if an annotation matches the target type. + + Args: + type_name: The name of the type to match + target_type: The actual type object (can be None) + + Returns: + A function that takes an annotation and returns True if it matches + """ + + def matches_type(annotation: Any) -> bool: + """Check if an annotation matches the target type name.""" + if isinstance(annotation, ForwardRef): + return annotation.__forward_arg__ == type_name + if isinstance(annotation, str): + return annotation == type_name + if hasattr(annotation, "__name__") and annotation.__name__ == type_name: + return True + if target_type is not None and annotation is target_type: + return True + return False + + return matches_type + + +def _unwrap_optional(annotation: Any) -> Any: + """Unwrap Optional/Union types to get the underlying type. + + Args: + annotation: The type annotation to unwrap + + Returns: + The unwrapped type, or the original if not Optional/Union + """ + origin = get_origin(annotation) + if origin is Union: + args = get_args(annotation) + non_none_args = [arg for arg in args if arg is not type(None)] + if non_none_args: + return non_none_args[0] + return annotation + + +def _is_pydantic_model(annotation: Any) -> bool: + return isinstance(annotation, type) and issubclass(annotation, BaseModel) From 5fe66f5d91b477f86497aad0b046f63e5beacb48 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Tue, 23 Dec 2025 11:57:48 +0200 Subject: [PATCH 12/15] fix: increment package version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d8a00251..1a0c83ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.1.43" +version = "0.1.44" 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" diff --git a/uv.lock b/uv.lock index 34a30ad7..a501c870 100644 --- a/uv.lock +++ b/uv.lock @@ -3260,7 +3260,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.1.43" +version = "0.1.44" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From f41e4187813857e50f1cb4f491192e2542034c71 Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Tue, 23 Dec 2025 16:08:11 +0200 Subject: [PATCH 13/15] feat: resolve job attachments and call llm with files --- pyproject.toml | 2 +- .../internal_tools/analyze_files_tool.py | 69 ++++++++++++++++++- .../internal_tools/internal_tool_factory.py | 10 ++- .../agent/tools/tool_factory.py | 11 +-- .../agent/wrappers/job_attachment_wrapper.py | 5 +- uv.lock | 8 +-- 6 files changed, 86 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1a0c83ab..9e7b3eae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Python SDK that enables developers to build and deploy LangGraph readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath>=2.2.41, <2.3.0", + "uipath>=2.2.44, <2.3.0", "langgraph>=1.0.0, <2.0.0", "langchain-core>=1.0.0, <2.0.0", "aiosqlite==0.21.0", diff --git a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py index 9b4ee9ea..06e4a5bf 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py @@ -1,12 +1,17 @@ +import uuid from typing import Any +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage from langchain_core.tools import StructuredTool from uipath.agent.models.agent import ( AgentInternalToolResourceConfig, ) from uipath.eval.mocks import mockable +from uipath.platform import UiPath from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model +from uipath_langchain.agent.react.llm_with_files import FileInfo, llm_call_with_files from uipath_langchain.agent.tools.structured_tool_with_output_type import ( StructuredToolWithOutputType, ) @@ -16,13 +21,19 @@ get_job_attachment_wrapper, ) +ANALYZE_FILES_SYSTEM_MESSAGE = ( + "Process the provided files to complete the given task. " + "Analyze the files contents thoroughly to deliver an accurate response " + "based on the extracted information." +) + class AnalyzeFileTool(StructuredToolWithOutputType, ToolWrapperMixin): pass def create_analyze_file_tool( - resource: AgentInternalToolResourceConfig, + resource: AgentInternalToolResourceConfig, llm: BaseChatModel ) -> StructuredTool: tool_name = sanitize_tool_name(resource.name) input_model = create_model(resource.input_schema) @@ -35,9 +46,23 @@ def create_analyze_file_tool( output_schema=output_model.model_json_schema(), ) async def tool_fn(**kwargs: Any): - return "The event name is 'Toamna' by Tudor Gheorghe" + if "analysisTask" not in kwargs: + raise ValueError("Argument 'analysisTask' is not available") + if "attachments" not in kwargs: + raise ValueError("Argument 'attachments' is not available") + + attachments = kwargs["attachments"] + analysisTask = kwargs["analysisTask"] + + files = await _resolve_job_attachment_arguments(attachments) + messages: list[AnyMessage] = [ + SystemMessage(content=ANALYZE_FILES_SYSTEM_MESSAGE), + HumanMessage(content=analysisTask), + ] + result = await llm_call_with_files(messages, files, llm) + return result - wrapper = get_job_attachment_wrapper(resource) + wrapper = get_job_attachment_wrapper() tool = AnalyzeFileTool( name=tool_name, description=resource.description, @@ -47,3 +72,41 @@ async def tool_fn(**kwargs: Any): ) tool.set_tool_wrappers(awrapper=wrapper) return tool + + +async def _resolve_job_attachment_arguments( + attachments: list[Any], +) -> list[FileInfo]: + """Resolve job attachments to FileInfo objects. + + Args: + attachments: List of job attachment objects (dynamically typed from schema) + + Returns: + List of FileInfo objects with blob URIs for each attachment + """ + client = UiPath() + file_infos: list[FileInfo] = [] + + for attachment in attachments: + # Access using Pydantic field aliases (ID, FullName, MimeType) + # These are dynamically created from the JSON schema + attachment_id_value = getattr(attachment, "ID", None) + if attachment_id_value is None: + continue + + attachment_id = uuid.UUID(attachment_id_value) + mime_type = getattr(attachment, "MimeType", "") + + blob_info = await client.attachments.get_blob_file_access_uri_async( + key=attachment_id + ) + + file_info = FileInfo( + url=blob_info.uri, + name=blob_info.name, + mime_type=mime_type, + ) + file_infos.append(file_info) + + return file_infos diff --git a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py index 9c83fa92..7bf6cf4b 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py +++ b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py @@ -16,6 +16,7 @@ from typing import Callable +from langchain_core.language_models import BaseChatModel from langchain_core.tools import StructuredTool from uipath.agent.models.agent import ( AgentInternalToolResourceConfig, @@ -25,13 +26,16 @@ from .analyze_files_tool import create_analyze_file_tool _INTERNAL_TOOL_HANDLERS: dict[ - AgentInternalToolType, Callable[[AgentInternalToolResourceConfig], StructuredTool] + AgentInternalToolType, + Callable[[AgentInternalToolResourceConfig, BaseChatModel], StructuredTool], ] = { AgentInternalToolType.ANALYZE_FILES: create_analyze_file_tool, } -def create_internal_tool(resource: AgentInternalToolResourceConfig) -> StructuredTool: +def create_internal_tool( + resource: AgentInternalToolResourceConfig, llm: BaseChatModel +) -> StructuredTool: """Create an internal tool based on the resource configuration. Raises: @@ -47,4 +51,4 @@ def create_internal_tool(resource: AgentInternalToolResourceConfig) -> Structure f"Supported types: {list[AgentInternalToolType](_INTERNAL_TOOL_HANDLERS.keys())}" ) - return handler(resource) + return handler(resource, llm) diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index 91764e5b..d215ec39 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -1,5 +1,6 @@ """Factory functions for creating tools from agent resources.""" +from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool, StructuredTool from uipath.agent.models.agent import ( AgentContextResourceConfig, @@ -18,11 +19,13 @@ from .process_tool import create_process_tool -async def create_tools_from_resources(agent: LowCodeAgentDefinition) -> list[BaseTool]: +async def create_tools_from_resources( + agent: LowCodeAgentDefinition, llm: BaseChatModel +) -> list[BaseTool]: tools: list[BaseTool] = [] for resource in agent.resources: - tool = await _build_tool_for_resource(resource) + tool = await _build_tool_for_resource(resource, llm) if tool is not None: tools.append(tool) @@ -30,7 +33,7 @@ async def create_tools_from_resources(agent: LowCodeAgentDefinition) -> list[Bas async def _build_tool_for_resource( - resource: BaseAgentResourceConfig, + resource: BaseAgentResourceConfig, llm: BaseChatModel ) -> StructuredTool | None: if isinstance(resource, AgentProcessToolResourceConfig): return create_process_tool(resource) @@ -45,6 +48,6 @@ async def _build_tool_for_resource( return create_integration_tool(resource) elif isinstance(resource, AgentInternalToolResourceConfig): - return create_internal_tool(resource) + return create_internal_tool(resource, llm) return None diff --git a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py index 1fbf714c..8d86f30c 100644 --- a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py +++ b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py @@ -4,7 +4,6 @@ from langchain_core.tools import BaseTool from langgraph.types import Command from pydantic import BaseModel -from uipath.agent.models.agent import BaseAgentToolResourceConfig from uipath_langchain.agent.react.job_attachments import ( get_job_attachment_paths, @@ -14,9 +13,7 @@ from uipath_langchain.agent.tools.tool_node import AsyncToolWrapperType -def get_job_attachment_wrapper( - resource: BaseAgentToolResourceConfig, -) -> AsyncToolWrapperType: +def get_job_attachment_wrapper() -> AsyncToolWrapperType: """Create a tool wrapper that validates and replaces job attachment IDs with full attachment objects. This wrapper extracts job attachment paths from the tool's schema, validates that all diff --git a/uv.lock b/uv.lock index a501c870..d68e3d19 100644 --- a/uv.lock +++ b/uv.lock @@ -3219,7 +3219,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.2.41" +version = "2.2.44" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -3239,9 +3239,9 @@ dependencies = [ { name = "uipath-core" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/7e/a4ee26cd1445945a71f1684b27e2e9f9de23919586101dbb55863eb86143/uipath-2.2.41.tar.gz", hash = "sha256:4e31fa0e7ac3328ec046b78fc844ab7cbf05c2bd9f27908c74a5fb7769abe3eb", size = 3430090, upload-time = "2025-12-22T15:07:44.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/79/2b261df31c2265c724b94a389586250075be619c40a47982fe0683de83eb/uipath-2.2.44.tar.gz", hash = "sha256:170accf02e5d8f5c96e2a501d1a8179810d9d37296b67675d39be080de46b252", size = 3431301, upload-time = "2025-12-23T13:38:07.597Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/af/2d72b618c12682b046eaa9933838484d9ddcc768e83cf2bd01a71827b564/uipath-2.2.41-py3-none-any.whl", hash = "sha256:0db7087daa7dc829b44edbdd9930ab17c3490c04171cde09d3d1fa80eb4dc1c2", size = 397396, upload-time = "2025-12-22T15:07:43.409Z" }, + { url = "https://files.pythonhosted.org/packages/85/6a/fbcd2389d0db64c0f998a73347d7ae0da7ea96525ec75f7ad31f7e8108a4/uipath-2.2.44-py3-none-any.whl", hash = "sha256:145cc0c84ccd44bac5f82ff330799556a59cdcc8854be8b2ca75497510770d98", size = 398294, upload-time = "2025-12-23T13:38:05.615Z" }, ] [[package]] @@ -3323,7 +3323,7 @@ requires-dist = [ { name = "openinference-instrumentation-langchain", specifier = ">=0.1.56" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "uipath", specifier = ">=2.2.41,<2.3.0" }, + { name = "uipath", specifier = ">=2.2.44,<2.3.0" }, ] provides-extras = ["vertex", "bedrock"] From 48551dfe2c7a2eb4b36e10d394551a72d800dfee Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Tue, 23 Dec 2025 16:28:40 +0200 Subject: [PATCH 14/15] fix: add unit tests --- tests/agent/tools/internal_tools/__init__.py | 1 + .../internal_tools/test_analyze_files_tool.py | 423 ++++++++++++++++++ .../wrappers/test_job_attachment_wrapper.py | 42 +- 3 files changed, 438 insertions(+), 28 deletions(-) create mode 100644 tests/agent/tools/internal_tools/__init__.py create mode 100644 tests/agent/tools/internal_tools/test_analyze_files_tool.py diff --git a/tests/agent/tools/internal_tools/__init__.py b/tests/agent/tools/internal_tools/__init__.py new file mode 100644 index 00000000..8343e492 --- /dev/null +++ b/tests/agent/tools/internal_tools/__init__.py @@ -0,0 +1 @@ +"""Tests for internal tools.""" diff --git a/tests/agent/tools/internal_tools/test_analyze_files_tool.py b/tests/agent/tools/internal_tools/test_analyze_files_tool.py new file mode 100644 index 00000000..7038e168 --- /dev/null +++ b/tests/agent/tools/internal_tools/test_analyze_files_tool.py @@ -0,0 +1,423 @@ +"""Tests for analyze_files_tool.py module.""" + +import uuid +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from langchain_core.messages import AIMessage +from pydantic import BaseModel, ConfigDict, Field +from uipath.agent.models.agent import ( + AgentInternalToolProperties, + AgentInternalToolResourceConfig, + AgentInternalToolType, +) + +from uipath_langchain.agent.react.llm_with_files import FileInfo +from uipath_langchain.agent.tools.internal_tools.analyze_files_tool import ( + ANALYZE_FILES_SYSTEM_MESSAGE, + _resolve_job_attachment_arguments, + create_analyze_file_tool, +) + + +class MockAttachment(BaseModel): + """Mock attachment model for testing.""" + + model_config = ConfigDict(populate_by_name=True) + + ID: str = Field(alias="ID") + FullName: str = Field(alias="FullName") + MimeType: str = Field(alias="MimeType") + + +class MockBlobInfo(BaseModel): + """Mock blob info model for testing.""" + + uri: str + name: str + + +class TestCreateAnalyzeFileTool: + """Test cases for create_analyze_file_tool function.""" + + @pytest.fixture + def mock_llm(self): + """Fixture for mock LLM.""" + llm = AsyncMock() + llm.ainvoke = AsyncMock(return_value=AIMessage(content="Analyzed result")) + return llm + + @pytest.fixture + def resource_config(self): + """Fixture for resource configuration.""" + input_schema = { + "type": "object", + "properties": { + "analysisTask": {"type": "string"}, + "attachments": {"type": "array", "items": {"type": "object"}}, + }, + "required": ["analysisTask", "attachments"], + } + output_schema = {"type": "object", "properties": {"result": {"type": "string"}}} + + properties = AgentInternalToolProperties( + tool_type=AgentInternalToolType.ANALYZE_FILES + ) + + return AgentInternalToolResourceConfig( + name="analyze_files", + description="Analyze files with AI", + input_schema=input_schema, + output_schema=output_schema, + properties=properties, + ) + + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool.get_job_attachment_wrapper" + ) + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool.llm_call_with_files" + ) + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool._resolve_job_attachment_arguments" + ) + async def test_create_analyze_file_tool_success( + self, + mock_resolve_attachments, + mock_llm_call, + mock_get_wrapper, + resource_config, + mock_llm, + ): + """Test successful creation and execution of analyze file tool.""" + # Setup mocks + mock_resolve_attachments.return_value = [ + FileInfo( + url="https://example.com/file.pdf", + name="test.pdf", + mime_type="application/pdf", + ) + ] + mock_llm_call.return_value = "Analysis complete" + mock_wrapper = Mock() + mock_get_wrapper.return_value = mock_wrapper + + # Create tool + tool = create_analyze_file_tool(resource_config, mock_llm) + + # Verify tool creation + assert tool.name == "analyze_files" + assert tool.description == "Analyze files with AI" + assert hasattr(tool, "coroutine") + + # Test tool execution + mock_attachment = MockAttachment( + ID=str(uuid.uuid4()), FullName="test.pdf", MimeType="application/pdf" + ) + + result = await tool.coroutine( + analysisTask="Summarize the document", attachments=[mock_attachment] + ) + + # Verify calls + assert result == "Analysis complete" + mock_resolve_attachments.assert_called_once() + mock_llm_call.assert_called_once() + + # Verify LLM call arguments + call_args = mock_llm_call.call_args + messages, files, llm = call_args[0] + assert len(messages) == 2 + assert messages[0].content == ANALYZE_FILES_SYSTEM_MESSAGE + assert messages[1].content == "Summarize the document" + assert len(files) == 1 + assert files[0].url == "https://example.com/file.pdf" + + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool.get_job_attachment_wrapper" + ) + async def test_create_analyze_file_tool_missing_analysis_task( + self, mock_get_wrapper, resource_config, mock_llm + ): + """Test tool execution fails when analysisTask is missing.""" + mock_wrapper = Mock() + mock_get_wrapper.return_value = mock_wrapper + + tool = create_analyze_file_tool(resource_config, mock_llm) + + mock_attachment = MockAttachment( + ID=str(uuid.uuid4()), FullName="test.pdf", MimeType="application/pdf" + ) + + with pytest.raises( + ValueError, match="Argument 'analysisTask' is not available" + ): + await tool.coroutine(attachments=[mock_attachment]) + + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool.get_job_attachment_wrapper" + ) + async def test_create_analyze_file_tool_missing_attachments( + self, mock_get_wrapper, resource_config, mock_llm + ): + """Test tool execution fails when attachments are missing.""" + mock_wrapper = Mock() + mock_get_wrapper.return_value = mock_wrapper + + tool = create_analyze_file_tool(resource_config, mock_llm) + + with pytest.raises(ValueError, match="Argument 'attachments' is not available"): + await tool.coroutine(analysisTask="Summarize the document") + + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool.get_job_attachment_wrapper" + ) + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool.llm_call_with_files" + ) + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool._resolve_job_attachment_arguments" + ) + async def test_create_analyze_file_tool_with_multiple_attachments( + self, + mock_resolve_attachments, + mock_llm_call, + mock_get_wrapper, + resource_config, + mock_llm, + ): + """Test tool execution with multiple attachments.""" + mock_resolve_attachments.return_value = [ + FileInfo( + url="https://example.com/file1.pdf", + name="doc1.pdf", + mime_type="application/pdf", + ), + FileInfo( + url="https://example.com/file2.docx", + name="doc2.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ] + mock_llm_call.return_value = "Multiple files analyzed" + mock_wrapper = Mock() + mock_get_wrapper.return_value = mock_wrapper + + tool = create_analyze_file_tool(resource_config, mock_llm) + + mock_attachments = [ + MockAttachment( + ID=str(uuid.uuid4()), FullName="doc1.pdf", MimeType="application/pdf" + ), + MockAttachment( + ID=str(uuid.uuid4()), + FullName="doc2.docx", + MimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ] + + result = await tool.coroutine( + analysisTask="Compare these documents", attachments=mock_attachments + ) + + assert result == "Multiple files analyzed" + mock_resolve_attachments.assert_called_once() + + # Verify LLM received both files + call_args = mock_llm_call.call_args + files = call_args[0][1] + assert len(files) == 2 + + +class TestResolveJobAttachmentArguments: + """Test cases for _resolve_job_attachment_arguments function.""" + + @pytest.fixture + def mock_uipath_client(self): + """Fixture for mock UiPath client.""" + with patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool.UiPath" + ) as mock_client_class: + mock_client = Mock() + mock_client_class.return_value = mock_client + yield mock_client + + async def test_resolve_single_attachment(self, mock_uipath_client): + """Test resolving a single attachment.""" + attachment_id = uuid.uuid4() + mock_attachment = MockAttachment( + ID=str(attachment_id), + FullName="document.pdf", + MimeType="application/pdf", + ) + + mock_blob_info = MockBlobInfo( + uri="https://blob.storage.com/files/document.pdf", + name="document.pdf", + ) + + mock_uipath_client.attachments.get_blob_file_access_uri_async = AsyncMock( + return_value=mock_blob_info + ) + + result = await _resolve_job_attachment_arguments([mock_attachment]) + + assert len(result) == 1 + assert result[0].url == "https://blob.storage.com/files/document.pdf" + assert result[0].name == "document.pdf" + assert result[0].mime_type == "application/pdf" + + mock_uipath_client.attachments.get_blob_file_access_uri_async.assert_called_once_with( + key=attachment_id + ) + + async def test_resolve_multiple_attachments(self, mock_uipath_client): + """Test resolving multiple attachments.""" + attachment_id_1 = uuid.uuid4() + attachment_id_2 = uuid.uuid4() + + mock_attachments = [ + MockAttachment( + ID=str(attachment_id_1), + FullName="doc1.pdf", + MimeType="application/pdf", + ), + MockAttachment( + ID=str(attachment_id_2), + FullName="image.png", + MimeType="image/png", + ), + ] + + mock_blob_infos = [ + MockBlobInfo( + uri="https://blob.storage.com/files/doc1.pdf", name="doc1.pdf" + ), + MockBlobInfo( + uri="https://blob.storage.com/files/image.png", name="image.png" + ), + ] + + mock_uipath_client.attachments.get_blob_file_access_uri_async = AsyncMock( + side_effect=mock_blob_infos + ) + + result = await _resolve_job_attachment_arguments(mock_attachments) + + assert len(result) == 2 + assert result[0].url == "https://blob.storage.com/files/doc1.pdf" + assert result[0].name == "doc1.pdf" + assert result[0].mime_type == "application/pdf" + assert result[1].url == "https://blob.storage.com/files/image.png" + assert result[1].name == "image.png" + assert result[1].mime_type == "image/png" + + assert ( + mock_uipath_client.attachments.get_blob_file_access_uri_async.call_count + == 2 + ) + + async def test_resolve_attachment_without_id_skips(self, mock_uipath_client): + """Test that attachments without ID are skipped.""" + + class AttachmentWithoutID(BaseModel): + FullName: str + MimeType: str + + mock_attachments = [ + AttachmentWithoutID(FullName="doc.pdf", MimeType="application/pdf"), + ] + + mock_uipath_client.attachments.get_blob_file_access_uri_async = AsyncMock() + + result = await _resolve_job_attachment_arguments(mock_attachments) + + assert len(result) == 0 + mock_uipath_client.attachments.get_blob_file_access_uri_async.assert_not_called() + + async def test_resolve_empty_attachments_list(self, mock_uipath_client): + """Test resolving an empty list of attachments.""" + result = await _resolve_job_attachment_arguments([]) + + assert len(result) == 0 + + async def test_resolve_attachment_with_missing_mime_type(self, mock_uipath_client): + """Test resolving attachment with missing MimeType defaults to empty string.""" + attachment_id = uuid.uuid4() + + class AttachmentWithoutMimeType(BaseModel): + ID: str + FullName: str + + mock_attachment = AttachmentWithoutMimeType( + ID=str(attachment_id), + FullName="document.pdf", + ) + + mock_blob_info = MockBlobInfo( + uri="https://blob.storage.com/files/document.pdf", + name="document.pdf", + ) + + mock_uipath_client.attachments.get_blob_file_access_uri_async = AsyncMock( + return_value=mock_blob_info + ) + + result = await _resolve_job_attachment_arguments([mock_attachment]) + + assert len(result) == 1 + assert result[0].mime_type == "" + + async def test_resolve_attachment_with_invalid_uuid_raises( + self, mock_uipath_client + ): + """Test that invalid UUID in ID field raises ValueError.""" + + class AttachmentWithInvalidID(BaseModel): + ID: str + FullName: str + MimeType: str + + mock_attachment = AttachmentWithInvalidID( + ID="not-a-valid-uuid", + FullName="document.pdf", + MimeType="application/pdf", + ) + + with pytest.raises(ValueError): + await _resolve_job_attachment_arguments([mock_attachment]) + + async def test_resolve_attachments_mixed_valid_and_invalid( + self, mock_uipath_client + ): + """Test resolving mix of valid attachments and attachments without IDs.""" + attachment_id = uuid.uuid4() + + class AttachmentWithoutID(BaseModel): + FullName: str + MimeType: str + + mock_attachments = [ + MockAttachment( + ID=str(attachment_id), + FullName="doc1.pdf", + MimeType="application/pdf", + ), + AttachmentWithoutID(FullName="doc2.pdf", MimeType="application/pdf"), + ] + + mock_blob_info = MockBlobInfo( + uri="https://blob.storage.com/files/doc1.pdf", + name="doc1.pdf", + ) + + mock_uipath_client.attachments.get_blob_file_access_uri_async = AsyncMock( + return_value=mock_blob_info + ) + + result = await _resolve_job_attachment_arguments(mock_attachments) + + # Only the valid attachment should be resolved + assert len(result) == 1 + assert result[0].url == "https://blob.storage.com/files/doc1.pdf" + mock_uipath_client.attachments.get_blob_file_access_uri_async.assert_called_once() diff --git a/tests/agent/wrappers/test_job_attachment_wrapper.py b/tests/agent/wrappers/test_job_attachment_wrapper.py index 6c4c171e..d181462b 100644 --- a/tests/agent/wrappers/test_job_attachment_wrapper.py +++ b/tests/agent/wrappers/test_job_attachment_wrapper.py @@ -8,7 +8,6 @@ from langchain_core.messages.tool import ToolCall from langchain_core.tools import BaseTool from pydantic import BaseModel, Field -from uipath.agent.models.agent import BaseAgentToolResourceConfig from uipath.platform.attachments import Attachment from uipath_langchain.agent.react.types import AgentGraphState @@ -27,11 +26,6 @@ class MockAttachmentSchema(BaseModel): class TestGetJobAttachmentWrapper: """Test cases for get_job_attachment_wrapper function.""" - @pytest.fixture - def mock_resource(self): - """Create a mock resource config.""" - return MagicMock(spec=BaseAgentToolResourceConfig) - @pytest.fixture def mock_tool(self): """Create a mock tool.""" @@ -69,12 +63,12 @@ def mock_attachment(self): @pytest.mark.asyncio async def test_tool_without_args_schema( - self, mock_resource, mock_tool, mock_tool_call, mock_state + self, mock_tool, mock_tool_call, mock_state ): """Test that tool is invoked normally when args_schema is None.""" mock_tool.args_schema = None - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, mock_tool_call, mock_state) assert result == {"result": "success"} @@ -82,12 +76,12 @@ async def test_tool_without_args_schema( @pytest.mark.asyncio async def test_tool_with_dict_args_schema( - self, mock_resource, mock_tool, mock_tool_call, mock_state + self, mock_tool, mock_tool_call, mock_state ): """Test that tool is invoked normally when args_schema is a dict.""" mock_tool.args_schema = {"type": "object", "properties": {}} - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, mock_tool_call, mock_state) assert result == {"result": "success"} @@ -95,12 +89,12 @@ async def test_tool_with_dict_args_schema( @pytest.mark.asyncio async def test_tool_with_non_basemodel_schema( - self, mock_resource, mock_tool, mock_tool_call, mock_state + self, mock_tool, mock_tool_call, mock_state ): """Test that tool is invoked normally when args_schema is not a BaseModel.""" mock_tool.args_schema = str - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, mock_tool_call, mock_state) assert result == {"result": "success"} @@ -113,7 +107,6 @@ async def test_tool_with_non_basemodel_schema( async def test_tool_with_no_attachment_paths( self, mock_get_paths, - mock_resource, mock_tool, mock_tool_call, mock_state, @@ -122,7 +115,7 @@ async def test_tool_with_no_attachment_paths( mock_tool.args_schema = MockAttachmentSchema mock_get_paths.return_value = [] - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, mock_tool_call, mock_state) assert result == {"result": "success"} @@ -136,7 +129,6 @@ async def test_tool_with_no_attachment_paths( async def test_tool_with_valid_attachments( self, mock_get_paths, - mock_resource, mock_tool, mock_attachment, mock_state, @@ -158,7 +150,7 @@ async def test_tool_with_valid_attachments( }, ) - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, tool_call, mock_state) assert result == {"result": "success"} @@ -175,7 +167,6 @@ async def test_tool_with_valid_attachments( async def test_tool_with_missing_attachment( self, mock_get_paths, - mock_resource, mock_tool, mock_state, ): @@ -196,7 +187,7 @@ async def test_tool_with_missing_attachment( # Empty state - attachment not found mock_state.job_attachments = {} - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, tool_call, mock_state) assert isinstance(result, dict) @@ -212,7 +203,6 @@ async def test_tool_with_missing_attachment( async def test_tool_with_multiple_missing_attachments( self, mock_get_paths, - mock_resource, mock_tool, mock_state, ): @@ -241,7 +231,7 @@ async def test_tool_with_multiple_missing_attachments( # Empty state - both attachments not found mock_state.job_attachments = {} - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, tool_call, mock_state) assert isinstance(result, dict) @@ -264,7 +254,6 @@ async def test_tool_with_multiple_missing_attachments( async def test_tool_with_invalid_uuid( self, mock_get_paths, - mock_resource, mock_tool, mock_state, ): @@ -284,7 +273,7 @@ async def test_tool_with_invalid_uuid( mock_state.job_attachments = {} - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, tool_call, mock_state) assert isinstance(result, dict) @@ -299,7 +288,6 @@ async def test_tool_with_invalid_uuid( async def test_tool_with_partial_valid_attachments( self, mock_get_paths, - mock_resource, mock_tool, mock_attachment, mock_state, @@ -327,7 +315,7 @@ async def test_tool_with_partial_valid_attachments( }, ) - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, tool_call, mock_state) assert isinstance(result, dict) @@ -343,7 +331,6 @@ async def test_tool_with_partial_valid_attachments( async def test_tool_with_complex_nested_structure( self, mock_get_paths, - mock_resource, mock_tool, mock_state, ): @@ -445,7 +432,7 @@ async def test_tool_with_complex_nested_structure( }, ) - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, tool_call, mock_state) # Should return error for the missing attachment @@ -469,7 +456,6 @@ async def test_tool_with_complex_nested_structure( async def test_tool_with_complex_nested_structure_all_valid( self, mock_get_paths, - mock_resource, mock_tool, mock_state, ): @@ -560,7 +546,7 @@ async def test_tool_with_complex_nested_structure_all_valid( }, ) - wrapper = get_job_attachment_wrapper(mock_resource) + wrapper = get_job_attachment_wrapper() result = await wrapper(mock_tool, tool_call, mock_state) # Should succeed without errors From daa8b8a7b73918a1e91369e2e7f73e2e27a7f00a Mon Sep 17 00:00:00 2001 From: "cristian.groza" Date: Tue, 23 Dec 2025 16:34:21 +0200 Subject: [PATCH 15/15] fix: linting errors --- tests/agent/tools/internal_tools/test_analyze_files_tool.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/agent/tools/internal_tools/test_analyze_files_tool.py b/tests/agent/tools/internal_tools/test_analyze_files_tool.py index 7038e168..25174dcd 100644 --- a/tests/agent/tools/internal_tools/test_analyze_files_tool.py +++ b/tests/agent/tools/internal_tools/test_analyze_files_tool.py @@ -115,6 +115,7 @@ async def test_create_analyze_file_tool_success( ID=str(uuid.uuid4()), FullName="test.pdf", MimeType="application/pdf" ) + assert tool.coroutine is not None result = await tool.coroutine( analysisTask="Summarize the document", attachments=[mock_attachment] ) @@ -149,6 +150,7 @@ async def test_create_analyze_file_tool_missing_analysis_task( ID=str(uuid.uuid4()), FullName="test.pdf", MimeType="application/pdf" ) + assert tool.coroutine is not None with pytest.raises( ValueError, match="Argument 'analysisTask' is not available" ): @@ -166,6 +168,7 @@ async def test_create_analyze_file_tool_missing_attachments( tool = create_analyze_file_tool(resource_config, mock_llm) + assert tool.coroutine is not None with pytest.raises(ValueError, match="Argument 'attachments' is not available"): await tool.coroutine(analysisTask="Summarize the document") @@ -216,6 +219,7 @@ async def test_create_analyze_file_tool_with_multiple_attachments( ), ] + assert tool.coroutine is not None result = await tool.coroutine( analysisTask="Compare these documents", attachments=mock_attachments )