diff --git a/pyproject.toml b/pyproject.toml index 18caa61a..9e7b3eae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [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" 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", @@ -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/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..4f521650 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 .job_attachments import ( + get_job_attachments, +) def create_init_node( messages: Sequence[SystemMessage | HumanMessage] | Callable[[Any], Sequence[SystemMessage | HumanMessage]], + input_schema: type[BaseModel] | None, ): def graph_state_init(state: Any): if callable(messages): @@ -15,6 +21,15 @@ def graph_state_init(state: Any): else: resolved_messages = messages - return {"messages": list(resolved_messages)} + 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 + } + + return { + "messages": list(resolved_messages), + "job_attachments": job_attachments_dict, + } return graph_state_init 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..9119b4b9 --- /dev/null +++ b/src/uipath_langchain/agent/react/job_attachments.py @@ -0,0 +1,125 @@ +"""Job attachment utilities for ReAct Agent.""" + +import copy +import uuid +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], + data: dict[str, Any] | BaseModel, +) -> 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: + result.append(Attachment.model_validate(attachment, from_attributes=True)) + + 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 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/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) 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..7e5c8283 --- /dev/null +++ b/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py @@ -0,0 +1,76 @@ +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 + annotation.__name__ = type_name + 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..e0b68b38 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -4,6 +4,9 @@ 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 +25,7 @@ class AgentGraphState(BaseModel): """Agent Graph state for standard loop execution.""" messages: Annotated[list[AnyMessage], add_messages] = [] + 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 d0ed3f71..94244c87 100644 --- a/src/uipath_langchain/agent/react/utils.py +++ b/src/uipath_langchain/agent/react/utils.py @@ -5,7 +5,9 @@ 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 +15,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 +25,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 +49,27 @@ def count_consecutive_thinking_messages(messages: Sequence[BaseMessage]) -> int: count += 1 return count + + +def add_job_attachments( + 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 string. + If the same UUID exists in both dictionaries, the value from 'right' takes precedence. + + Args: + left: Existing dictionary of attachments keyed by UUID string + 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} 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..5298b109 --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/__init__.py @@ -0,0 +1,5 @@ +"""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..06e4a5bf --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py @@ -0,0 +1,112 @@ +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, +) +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, +) + +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, llm: BaseChatModel +) -> StructuredTool: + 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(**kwargs: Any): + 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() + 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 + + +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 new file mode 100644 index 00000000..7bf6cf4b --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py @@ -0,0 +1,54 @@ +"""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.language_models import BaseChatModel +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, BaseChatModel], StructuredTool], +] = { + AgentInternalToolType.ANALYZE_FILES: create_analyze_file_tool, +} + + +def create_internal_tool( + resource: AgentInternalToolResourceConfig, llm: BaseChatModel +) -> StructuredTool: + """Create an internal tool based on the resource configuration. + + Raises: + ValueError: If the tool type is not supported (no handler exists for it). + + """ + tool_type = resource.properties.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, llm) 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..d215ec39 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -1,10 +1,12 @@ """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, AgentEscalationResourceConfig, AgentIntegrationToolResourceConfig, + AgentInternalToolResourceConfig, AgentProcessToolResourceConfig, BaseAgentResourceConfig, LowCodeAgentDefinition, @@ -13,14 +15,17 @@ 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 -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) @@ -28,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) @@ -42,4 +47,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, llm) + return None 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..8d86f30c --- /dev/null +++ b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py @@ -0,0 +1,62 @@ +from typing import Any + +from langchain_core.messages.tool import ToolCall +from langchain_core.tools import BaseTool +from langgraph.types import Command +from pydantic import BaseModel + +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() -> 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"] + modified_input_args = input_args + + if isinstance(tool.args_schema, type) and issubclass( + tool.args_schema, BaseModel + ): + errors: list[str] = [] + paths = get_job_attachment_paths(tool.args_schema) + 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(modified_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..c73ecee3 --- /dev/null +++ b/tests/agent/react/test_job_attachments.py @@ -0,0 +1,644 @@ +import uuid +from typing import Any + +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_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 = { + "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: dict[str, Any] = {} + + 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: dict[str, Any] = {"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[str, Any] + + 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: dict[str, Attachment] = {} + right: dict[str, Attachment] = {} + + 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 = { + str(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[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 = { + str(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[str(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 = { + str(uuid1): Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ) + } + right = { + str(uuid2): Attachment.model_validate( + { + "ID": str(uuid2), + "FullName": "file2.pdf", + "MimeType": "application/pdf", + } + ) + } + + result = add_job_attachments(left, right) + + assert len(result) == 2 + 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 = { + str(uuid1): Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "old_file.pdf", + "MimeType": "application/pdf", + } + ) + } + right = { + str(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[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.""" + 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 = { + str(uuid1): Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1_old.pdf", + "MimeType": "application/pdf", + } + ), + str(uuid2): Attachment.model_validate( + { + "ID": str(uuid2), + "FullName": "file2.pdf", + "MimeType": "application/pdf", + } + ), + } + right = { + str(uuid1): Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1_new.pdf", + "MimeType": "application/pdf", + } + ), + str(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[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.""" + 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 = { + str(uuid1): Attachment.model_validate( + { + "ID": str(uuid1), + "FullName": "file1.pdf", + "MimeType": "application/pdf", + } + ), + str(uuid2): Attachment.model_validate( + { + "ID": str(uuid2), + "FullName": "file2.pdf", + "MimeType": "application/pdf", + } + ), + } + right = { + str(uuid3): Attachment.model_validate( + { + "ID": str(uuid3), + "FullName": "file3.pdf", + "MimeType": "application/pdf", + } + ), + str(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(str(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 699c1541..84d03dbd 100644 --- a/tests/agent/react/test_utils.py +++ b/tests/agent/react/test_utils.py @@ -2,7 +2,9 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage -from uipath_langchain.agent.react.utils import count_consecutive_thinking_messages +from uipath_langchain.agent.react.utils import ( + count_consecutive_thinking_messages, +) class TestCountSuccessiveCompletions: 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..25174dcd --- /dev/null +++ b/tests/agent/tools/internal_tools/test_analyze_files_tool.py @@ -0,0 +1,427 @@ +"""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" + ) + + assert tool.coroutine is not None + 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" + ) + + assert tool.coroutine is not None + 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) + + assert tool.coroutine is not None + 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", + ), + ] + + assert tool.coroutine is not None + 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/__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..d181462b --- /dev/null +++ b/tests/agent/wrappers/test_job_attachment_wrapper.py @@ -0,0 +1,570 @@ +"""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.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_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_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() + 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_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() + 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_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() + 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_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() + 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_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 = cast( + ToolCall, + { + "name": "test_tool", + "args": {"attachment": {"ID": str(mock_attachment.id)}, "name": "test"}, + "id": "call_123", + }, + ) + + wrapper = get_job_attachment_wrapper() + 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_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 = 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 = {} + + wrapper = get_job_attachment_wrapper() + 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_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 = cast( + ToolCall, + { + "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() + 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_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 = cast( + ToolCall, + { + "name": "test_tool", + "args": {"attachment": {"ID": invalid_id}, "name": "test"}, + "id": "call_123", + }, + ) + + mock_state.job_attachments = {} + + wrapper = get_job_attachment_wrapper() + 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_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 = cast( + ToolCall, + { + "name": "test_tool", + "args": { + "attachments": [ + {"ID": str(mock_attachment.id)}, + {"ID": str(invalid_id)}, + ], + "name": "test", + }, + "id": "call_123", + }, + ) + + wrapper = get_job_attachment_wrapper() + 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_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 = cast( + ToolCall, + { + "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() + 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_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 = 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", + }, + "id": "call_complex_456", + }, + ) + + wrapper = get_job_attachment_wrapper() + 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 diff --git a/uv.lock b/uv.lock index 9859eac7..d68e3d19 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]] @@ -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]] @@ -3260,7 +3260,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.1.43" +version = "0.1.44" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -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" }, @@ -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"]