diff --git a/pyproject.toml b/pyproject.toml index a92267ba7..cf9931c73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.16.1" +version = "0.16.2" 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" @@ -163,6 +163,7 @@ uipath = false uipath-core = false uipath-platform = false uipath-runtime = false +uipath-dev = false uipath-langchain-client = false uipath-llm-client = false jsonschema-pydantic-converter = false diff --git a/src/uipath_langchain/_conversation/__init__.py b/src/uipath_langchain/_conversation/__init__.py new file mode 100644 index 000000000..1269b8e91 --- /dev/null +++ b/src/uipath_langchain/_conversation/__init__.py @@ -0,0 +1 @@ +"""Shared conversational message components.""" diff --git a/src/uipath_langchain/_conversation/types.py b/src/uipath_langchain/_conversation/types.py new file mode 100644 index 000000000..e6d909519 --- /dev/null +++ b/src/uipath_langchain/_conversation/types.py @@ -0,0 +1,8 @@ +"""Shared conversational contracts.""" + +from typing import Any, TypedDict + + +class ClientSideToolInfo(TypedDict): + input_schema: dict[str, Any] | None + output_schema: dict[str, Any] | None diff --git a/src/uipath_langchain/agent/advanced/agent.py b/src/uipath_langchain/agent/advanced/agent.py index 6c5b57b13..e8c1629f8 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -20,13 +20,18 @@ from langchain_core.tools import BaseTool from langgraph.graph import END, START from langgraph.graph.state import CompiledStateGraph, StateGraph -from pydantic import BaseModel, create_model +from pydantic import BaseModel, ConfigDict, create_model from uipath.core.chat import UiPathConversationMessageData from uipath_langchain._utils import get_unique_model_field_name from uipath_langchain.agent.react.job_attachments import get_job_attachment_paths +from uipath_langchain.runtime.messages import UiPathChatMessagesMapper -from .types import AdvancedAgentGraphState, ConversationalAdvancedAgentGraphState +from .types import ( + AdvancedAgentGraphState, + ConversationalAdvancedAgentGraphState, + _ConversationalAdvancedAgentGraphInput, +) from .utils import ( MEMORY_INDEX_VIRTUAL_PATH, create_state_with_input, @@ -213,45 +218,132 @@ def transform_output(state: BaseModel) -> dict[str, Any]: def create_conversational_advanced_agent_graph( model: BaseChatModel, tools: Sequence[BaseTool], - system_prompt: str, + system_prompt: str | Callable[[dict[str, Any]], str], backend: BackendProtocol | BackendFactory | None, + input_schema: type[BaseModel] | None = None, ) -> StateGraph[Any, Any, Any, Any]: """Wrap the advanced agent in a parent graph that speaks the conversational contract. Conversational agents receive the full conversation history in the ``messages`` input each exchange and must output the newly produced messages as ``uipath__agent_response_messages``. The deepagent already - operates on ``messages``, so the wrapper only records the incoming history - size and maps the new messages to the conversational output field. + operates on ``messages``. Callable system prompts are resolved once from + the exchange input and applied to every main-agent model request in that + invocation. """ - # deferred: avoids a circular import (runtime.messages imports agent modules) - from uipath_langchain.runtime.messages import UiPathChatMessagesMapper - memory_sources = ( [MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else [] ) + if callable(system_prompt): + build_system_prompt = system_prompt + static_system_prompt = None + else: + build_system_prompt = None + static_system_prompt = system_prompt + initial_message_count_key = get_unique_model_field_name( + "initial_message_count", + _ConversationalAdvancedAgentGraphInput, + input_schema, + ) + runtime_system_prompt_key = ( + get_unique_model_field_name( + "uipath__system_prompt", + _ConversationalAdvancedAgentGraphInput, + input_schema, + ) + if build_system_prompt is not None + else None + ) inner_graph = create_advanced_agent( model=model, tools=tools, - system_prompt=system_prompt, + system_prompt=static_system_prompt, backend=backend, memory=memory_sources, + middleware=( + [_RuntimeSystemPromptMiddleware(runtime_system_prompt_key)] + if runtime_system_prompt_key is not None + else [] + ), ) class ConversationalAdvancedAgentOutput(BaseModel): uipath__agent_response_messages: list[UiPathConversationMessageData] = [] - def capture_exchange_start( - state: ConversationalAdvancedAgentGraphState, - ) -> dict[str, Any]: - return {"initial_message_count": len(state.messages)} + graph_input: type[BaseModel] = _ConversationalAdvancedAgentGraphInput + wrapper_input = graph_input + if input_schema is not None: + wrapper_input = type( + "CompleteConversationalAdvancedAgentInput", + (_ConversationalAdvancedAgentGraphInput, input_schema), + { + "model_config": ConfigDict( + validate_by_alias=True, + validate_by_name=True, + ) + }, + ) + wrapper_input.model_rebuild() + graph_input = ( + input_schema if "messages" in input_schema.model_fields else wrapper_input + ) + initial_count_field: dict[str, Any] = { + initial_message_count_key: (int | None, None) + } + base_wrapper_state = cast( + type[BaseModel], + create_model( + "ConversationalAdvancedAgentGraphState", + __base__=wrapper_input, + **initial_count_field, + ), + ) + if runtime_system_prompt_key is not None: + runtime_state_field: dict[str, Any] = { + runtime_system_prompt_key: (str | None, None) + } + wrapper_state = cast( + type[BaseModel], + create_model( + "RuntimeConversationalAdvancedAgentGraphState", + __base__=base_wrapper_state, + **runtime_state_field, + ), + ) + else: + wrapper_state = base_wrapper_state + + internal_fields = set(_ConversationalAdvancedAgentGraphInput.model_fields) + internal_fields.add(initial_message_count_key) + if runtime_system_prompt_key is not None: + internal_fields.add(runtime_system_prompt_key) - def transform_output( - state: ConversationalAdvancedAgentGraphState, - ) -> dict[str, Any]: - initial_count = state.initial_message_count or 0 - new_messages = state.messages[initial_count:] + def capture_exchange_start(state: BaseModel) -> dict[str, Any]: + conversation_state = cast(ConversationalAdvancedAgentGraphState, state) + update: dict[str, Any] = { + initial_message_count_key: len(conversation_state.messages) + } + if build_system_prompt is not None and runtime_system_prompt_key is not None: + input_args = ( + input_schema.model_construct( + **{ + field_name: getattr(state, field_name) + for field_name in input_schema.model_fields + if field_name not in internal_fields + } + ).model_dump(by_alias=True, exclude_unset=True) + if input_schema is not None + else {} + ) + update[runtime_system_prompt_key] = build_system_prompt(input_args) + return update + + def transform_output(state: BaseModel) -> dict[str, Any]: + initial_count = getattr(state, initial_message_count_key) or 0 + new_messages = cast(ConversationalAdvancedAgentGraphState, state).messages[ + initial_count: + ] converted = ( UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( messages=new_messages, include_tool_results=False @@ -262,7 +354,8 @@ def transform_output( return {"uipath__agent_response_messages": converted} wrapper: StateGraph[Any, Any, Any, Any] = StateGraph( - ConversationalAdvancedAgentGraphState, + wrapper_state, + input_schema=graph_input, output_schema=ConversationalAdvancedAgentOutput, ) wrapper.add_node("capture_exchange_start", capture_exchange_start) diff --git a/src/uipath_langchain/agent/advanced/types.py b/src/uipath_langchain/agent/advanced/types.py index 929981fc9..b5b9ce100 100644 --- a/src/uipath_langchain/agent/advanced/types.py +++ b/src/uipath_langchain/agent/advanced/types.py @@ -4,7 +4,7 @@ from langchain_core.messages import AnyMessage from langgraph.graph.message import add_messages -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class AdvancedAgentGraphState(BaseModel): @@ -14,8 +14,13 @@ class AdvancedAgentGraphState(BaseModel): structured_response: dict[str, Any] = {} -class ConversationalAdvancedAgentGraphState(BaseModel): - """Graph state for the conversational advanced agent wrapper.""" +class _ConversationalAdvancedAgentGraphInput(BaseModel): + model_config = ConfigDict(validate_by_alias=True, validate_by_name=True) messages: Annotated[list[AnyMessage], add_messages] = [] + + +class ConversationalAdvancedAgentGraphState(_ConversationalAdvancedAgentGraphInput): + """Graph state for the conversational advanced agent wrapper.""" + initial_message_count: int | None = None diff --git a/src/uipath_langchain/agent/job_attachments.py b/src/uipath_langchain/agent/job_attachments.py new file mode 100644 index 000000000..c47550347 --- /dev/null +++ b/src/uipath_langchain/agent/job_attachments.py @@ -0,0 +1,271 @@ +"""Job attachment utilities for ReAct Agent.""" + +import copy +import uuid +from typing import Any, Sequence + +from jsonpath_ng import parse # type: ignore[import-untyped] +from langchain_core.messages import BaseMessage, HumanMessage +from pydantic import BaseModel, ValidationError +from uipath.platform.attachments import Attachment +from uipath.platform.errors import EnrichedException +from uipath.runtime.errors import UiPathErrorCategory + +from .exceptions import AgentRuntimeError, AgentRuntimeErrorCode, raise_for_enriched +from .json_utils import extract_values_by_paths, get_json_paths_by_type + +_JOB_ATTACHMENT_ERRORS: dict[ + tuple[int, str | None], tuple[str, UiPathErrorCategory] +] = { + (404, None): ( + "Attachment '{attachment_name}' ({attachment_id}) was not found.", + UiPathErrorCategory.SYSTEM, + ), + (403, "1108"): ( + "You don't have permissions to access attachment " + "'{attachment_name}' ({attachment_id}).", + UiPathErrorCategory.DEPLOYMENT, + ), +} + + +def raise_for_job_attachment_error( + e: EnrichedException, + *, + title: str, + attachment_name: str | None, + attachment_id: uuid.UUID, +) -> None: + """Raise a structured error for known job attachment failures.""" + raise_for_enriched( + e, + _JOB_ATTACHMENT_ERRORS, + title=title, + attachment_name=attachment_name or "", + attachment_id=str(attachment_id), + ) + + +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. + + Raises: + AgentRuntimeError: If a tool-output attachment fails validation (e.g. its + ID is not a valid UUID). This is unrecoverable invalid data and is + surfaced as a SYSTEM failure rather than silently skipped. + """ + job_attachment_paths = get_job_attachment_paths(schema) + job_attachments = extract_values_by_paths(data, job_attachment_paths) + + result = [] + for att in job_attachments: + if not att: + continue + # Tool arguments are coerced into a generated input model, so an + # extracted attachment (and its nested fields, e.g. Metadata) may be a + # Pydantic model instance rather than plain data. model_validate with + # from_attributes does not recursively coerce nested models to dicts, so + # a valid Metadata map arriving as a sub-model would be rejected as "not + # a dictionary". Materialize the model to plain data first. + if isinstance(att, BaseModel): + att = att.model_dump(by_alias=True) + try: + attachment = Attachment.model_validate(att, from_attributes=True) + except ValidationError as e: + id_error = _attachment_id_uuid_error(e) + if id_error: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.INVALID_ATTACHMENT_ID, + title="Invalid attachment id", + detail=( + f"A tool returned a job attachment with id {id_error.get('input')!r}, " + f"which is not a valid UUID. The agent cannot proceed with an " + f"invalid attachment." + ), + category=UiPathErrorCategory.SYSTEM, + ) from e + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.OUTPUT_VALIDATION_ERROR, + title="Invalid job attachment", + detail=( + f"A tool returned a job attachment that does not match the " + f"expected shape — {_describe_validation_errors(e)}. " + f"Verify the tool's output provides valid attachment fields; the " + f"agent cannot proceed with an invalid attachment." + ), + category=UiPathErrorCategory.SYSTEM, + ) from e + result.append(attachment) + + return result + + +def _attachment_id_uuid_error(exc: ValidationError) -> Any | None: + id_field = Attachment.model_fields["id"] + id_field_names = ("id", id_field.validation_alias, id_field.alias) + for err in exc.errors(): + if err.get("type") not in ("uuid_parsing", "uuid_type"): + continue + if any( + err.get("loc") == (name,) + for name in id_field_names + if isinstance(name, str) + ): + return err + return None + + +def _describe_validation_errors(exc: ValidationError) -> str: + """Render a pydantic ValidationError as a short, human-readable field list. + + Reports each failing field path and reason (e.g. ``'MimeType': Field required``) + without echoing the offending input values, so the message is actionable and + safe to surface. + """ + issues = [] + for err in exc.errors(): + field = ".".join(str(part) for part in err.get("loc", ())) or "attachment" + issues.append(f"'{field}': {err.get('msg', 'invalid value')}") + return "; ".join(issues) + + +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." + ) + + +def parse_attachments_from_conversation_messages( + messages: Sequence[BaseMessage], +) -> dict[str, Attachment]: + """Parse attachments from HumanMessage additional_kwargs. + + Extracts attachment information from HumanMessages where additional_kwargs + contains an 'attachments' list with attachment details. + + Args: + messages: Sequence of messages to parse + + Returns: + Dictionary mapping attachment ID to Attachment objects + """ + attachments: dict[str, Attachment] = {} + + for message in messages: + if not isinstance(message, HumanMessage): + continue + + kwargs = getattr(message, "additional_kwargs", None) + if not kwargs: + continue + + # Handle attachments list in additional_kwargs + attachment_list = kwargs.get("attachments", []) + for att in attachment_list: + id = att.get("id") + full_name = att.get("full_name") + mime_type = att.get("mime_type") + + if id and full_name: + attachments[str(id)] = Attachment( + id=id, + full_name=full_name, + mime_type=mime_type, + ) + + return attachments diff --git a/src/uipath_langchain/agent/json_utils.py b/src/uipath_langchain/agent/json_utils.py new file mode 100644 index 000000000..fe1570771 --- /dev/null +++ b/src/uipath_langchain/agent/json_utils.py @@ -0,0 +1,275 @@ +import ast +import json +import sys +import types +from typing import Any, ForwardRef, Union, get_args, get_origin + +from jsonpath_ng import parse # type: ignore[import-untyped] +from pydantic import BaseModel, RootModel + + +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 + + json_key = _json_key(field_name, field_info) + if current_path: + field_path = f"{current_path}.{json_key}" + else: + field_path = f"$.{json_key}" + + annotation = _unwrap_optional(annotation) + origin = get_origin(annotation) + + if matches_type(annotation): + json_paths.append(field_path) + continue + + if origin is list: + inner_type, suffix = _unwrap_lists(annotation) + inner_path = f"{field_path}{suffix}" + if matches_type(inner_type): + json_paths.append(inner_path) + continue + if _is_pydantic_model(inner_type): + nested_paths = _recursive_search(inner_type, inner_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 + + # RootModel serializes without the "root" wrapper — e.g. RootModel[list[X]] + # dumps as [...], not {"root": [...]}. Iterating model_fields directly would + # produce wrong paths like "$.root.field". Instead we peel off the RootModel + # envelope (and any Optional/list layers) so _recursive_search only ever sees + # a plain BaseModel with correct JSONPath prefixes (e.g. "$[*].field"). + if issubclass(model, RootModel): + inner = _unwrap_optional(model.model_fields["root"].annotation) + inner, suffix = _unwrap_lists(inner) + # Primitive or non-model root types can't contain nested typed fields. + if not _is_pydantic_model(inner): + return [] + return _recursive_search(inner, f"${suffix}" if suffix else "") + + 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: + """Whether ``annotation`` refers to ``type_name``, by name or identity.""" + if isinstance(annotation, ForwardRef): + return annotation.__forward_arg__ == type_name + if isinstance(annotation, str): + return annotation == type_name + # prefer the per-class marker: identity/target_type break when several + # dynamic models are built (they share the same module). + return ( + getattr(annotation, "__uipath_marker_name__", None) == type_name + or getattr(annotation, "__name__", None) == type_name + or (target_type is not None and annotation is target_type) + ) + + 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 or origin is types.UnionType: + 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 _unwrap_lists(annotation: Any) -> tuple[Any, str]: + """Unwrap nested list types, returning (inner_type, jsonpath_suffix). + + Each list layer adds a "[*]" wildcard so the resulting suffix maps directly + to JSONPath: list[list[X]] → (X, "[*][*]"). + """ + suffix = "" + while get_origin(annotation) is list: + args = get_args(annotation) + if not args: + break + annotation = args[0] + suffix += "[*]" + return annotation, suffix + + +def _json_key(field_name: str, field_info: Any) -> str: + """Get the JSON property name for a field, accounting for aliases.""" + return field_info.alias or field_name + + +def _is_pydantic_model(annotation: Any) -> bool: + return isinstance(annotation, type) and issubclass(annotation, BaseModel) + + +def _coerce_field(key: str, value: Any, schema: type[BaseModel] | None) -> Any: + """Coerce a single field value, skipping str-typed fields when schema is available.""" + if schema is None: + return coerce_json_strings(value) + + field_info = schema.model_fields.get(key) + if field_info is None: + return coerce_json_strings(value) + + annotation = _unwrap_optional(field_info.annotation) + + if annotation is str: + return value + + if _is_pydantic_model(annotation): + return coerce_json_strings(value, annotation) + + if get_origin(annotation) is list: + item_args = get_args(annotation) + item_schema = None + if item_args and _is_pydantic_model(item_args[0]): + item_schema = item_args[0] + if isinstance(value, list): + return [coerce_json_strings(item, item_schema) for item in value] + + return coerce_json_strings(value) + + +def coerce_json_strings(data: Any, schema: type[BaseModel] | None = None) -> Any: + """Parse stringified dicts/lists back into Python objects. + + LLMs sometimes serialize nested objects as strings instead of dicts, + either as JSON (double quotes) or Python repr (single quotes). + When a schema is provided, str-typed fields are left untouched. + """ + if isinstance(data, dict): + return {k: _coerce_field(k, v, schema) for k, v in data.items()} + if isinstance(data, list): + return [coerce_json_strings(item) for item in data] + if isinstance(data, str): + try: + parsed = json.loads(data) + if isinstance(parsed, (dict, list)): + return parsed + except (json.JSONDecodeError, TypeError): + pass + # LLMs sometimes emit Python repr (single quotes) instead of JSON + try: + parsed = ast.literal_eval(data) + if isinstance(parsed, (dict, list)): + return parsed + except (ValueError, SyntaxError): + pass + return data diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index cee6c231f..b91e5b77f 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -9,7 +9,7 @@ from uipath.platform.context_grounding import DeepRagContent from uipath.platform.guardrails import BaseGuardrail -from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo +from uipath_langchain._conversation.types import ClientSideToolInfo from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL from ...runtime._citations import cas_deep_rag_citation_wrapper diff --git a/src/uipath_langchain/agent/react/init_node.py b/src/uipath_langchain/agent/react/init_node.py index 7b9f5b06a..529978189 100644 --- a/src/uipath_langchain/agent/react/init_node.py +++ b/src/uipath_langchain/agent/react/init_node.py @@ -6,9 +6,9 @@ from langgraph.types import Overwrite from pydantic import BaseModel +from uipath_langchain._conversation.types import ClientSideToolInfo from uipath_langchain.agent.tools.client_side_tool import ( UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY, - ClientSideToolInfo, apply_tool_filter, available_client_side_tools, ) diff --git a/src/uipath_langchain/agent/react/job_attachments.py b/src/uipath_langchain/agent/react/job_attachments.py index 117d9a870..3c21b4522 100644 --- a/src/uipath_langchain/agent/react/job_attachments.py +++ b/src/uipath_langchain/agent/react/job_attachments.py @@ -1,271 +1,23 @@ -"""Job attachment utilities for ReAct Agent.""" - -import copy -import uuid -from typing import Any, Sequence - -from jsonpath_ng import parse # type: ignore[import-untyped] -from langchain_core.messages import BaseMessage, HumanMessage -from pydantic import BaseModel, ValidationError -from uipath.platform.attachments import Attachment -from uipath.platform.errors import EnrichedException -from uipath.runtime.errors import UiPathErrorCategory - -from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode, raise_for_enriched -from .json_utils import extract_values_by_paths, get_json_paths_by_type - -_JOB_ATTACHMENT_ERRORS: dict[ - tuple[int, str | None], tuple[str, UiPathErrorCategory] -] = { - (404, None): ( - "Attachment '{attachment_name}' ({attachment_id}) was not found.", - UiPathErrorCategory.SYSTEM, - ), - (403, "1108"): ( - "You don't have permissions to access attachment " - "'{attachment_name}' ({attachment_id}).", - UiPathErrorCategory.DEPLOYMENT, - ), -} - - -def raise_for_job_attachment_error( - e: EnrichedException, - *, - title: str, - attachment_name: str | None, - attachment_id: uuid.UUID, -) -> None: - """Raise a structured error for known job attachment failures.""" - raise_for_enriched( - e, - _JOB_ATTACHMENT_ERRORS, - title=title, - attachment_name=attachment_name or "", - attachment_id=str(attachment_id), - ) - - -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. - - Raises: - AgentRuntimeError: If a tool-output attachment fails validation (e.g. its - ID is not a valid UUID). This is unrecoverable invalid data and is - surfaced as a SYSTEM failure rather than silently skipped. - """ - job_attachment_paths = get_job_attachment_paths(schema) - job_attachments = extract_values_by_paths(data, job_attachment_paths) - - result = [] - for att in job_attachments: - if not att: - continue - # Tool arguments are coerced into a generated input model, so an - # extracted attachment (and its nested fields, e.g. Metadata) may be a - # Pydantic model instance rather than plain data. model_validate with - # from_attributes does not recursively coerce nested models to dicts, so - # a valid Metadata map arriving as a sub-model would be rejected as "not - # a dictionary". Materialize the model to plain data first. - if isinstance(att, BaseModel): - att = att.model_dump(by_alias=True) - try: - attachment = Attachment.model_validate(att, from_attributes=True) - except ValidationError as e: - id_error = _attachment_id_uuid_error(e) - if id_error: - raise AgentRuntimeError( - code=AgentRuntimeErrorCode.INVALID_ATTACHMENT_ID, - title="Invalid attachment id", - detail=( - f"A tool returned a job attachment with id {id_error.get('input')!r}, " - f"which is not a valid UUID. The agent cannot proceed with an " - f"invalid attachment." - ), - category=UiPathErrorCategory.SYSTEM, - ) from e - raise AgentRuntimeError( - code=AgentRuntimeErrorCode.OUTPUT_VALIDATION_ERROR, - title="Invalid job attachment", - detail=( - f"A tool returned a job attachment that does not match the " - f"expected shape — {_describe_validation_errors(e)}. " - f"Verify the tool's output provides valid attachment fields; the " - f"agent cannot proceed with an invalid attachment." - ), - category=UiPathErrorCategory.SYSTEM, - ) from e - result.append(attachment) - - return result - - -def _attachment_id_uuid_error(exc: ValidationError) -> Any | None: - id_field = Attachment.model_fields["id"] - id_field_names = ("id", id_field.validation_alias, id_field.alias) - for err in exc.errors(): - if err.get("type") not in ("uuid_parsing", "uuid_type"): - continue - if any( - err.get("loc") == (name,) - for name in id_field_names - if isinstance(name, str) - ): - return err - return None - - -def _describe_validation_errors(exc: ValidationError) -> str: - """Render a pydantic ValidationError as a short, human-readable field list. - - Reports each failing field path and reason (e.g. ``'MimeType': Field required``) - without echoing the offending input values, so the message is actionable and - safe to surface. - """ - issues = [] - for err in exc.errors(): - field = ".".join(str(part) for part in err.get("loc", ())) or "attachment" - issues.append(f"'{field}': {err.get('msg', 'invalid value')}") - return "; ".join(issues) - - -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." - ) - - -def parse_attachments_from_conversation_messages( - messages: Sequence[BaseMessage], -) -> dict[str, Attachment]: - """Parse attachments from HumanMessage additional_kwargs. - - Extracts attachment information from HumanMessages where additional_kwargs - contains an 'attachments' list with attachment details. - - Args: - messages: Sequence of messages to parse - - Returns: - Dictionary mapping attachment ID to Attachment objects - """ - attachments: dict[str, Attachment] = {} - - for message in messages: - if not isinstance(message, HumanMessage): - continue - - kwargs = getattr(message, "additional_kwargs", None) - if not kwargs: - continue - - # Handle attachments list in additional_kwargs - attachment_list = kwargs.get("attachments", []) - for att in attachment_list: - id = att.get("id") - full_name = att.get("full_name") - mime_type = att.get("mime_type") - - if id and full_name: - attachments[str(id)] = Attachment( - id=id, - full_name=full_name, - mime_type=mime_type, - ) - - return attachments +"""Compatibility exports for job attachment utilities.""" + +from ..job_attachments import ( + _attachment_id_uuid_error, + _create_job_attachment_error_message, + _describe_validation_errors, + get_job_attachment_paths, + get_job_attachments, + parse_attachments_from_conversation_messages, + raise_for_job_attachment_error, + replace_job_attachment_ids, +) + +__all__ = [ + "_attachment_id_uuid_error", + "_create_job_attachment_error_message", + "_describe_validation_errors", + "get_job_attachment_paths", + "get_job_attachments", + "parse_attachments_from_conversation_messages", + "raise_for_job_attachment_error", + "replace_job_attachment_ids", +] diff --git a/src/uipath_langchain/agent/react/json_utils.py b/src/uipath_langchain/agent/react/json_utils.py index fe1570771..d182c8995 100644 --- a/src/uipath_langchain/agent/react/json_utils.py +++ b/src/uipath_langchain/agent/react/json_utils.py @@ -1,275 +1,27 @@ -import ast -import json -import sys -import types -from typing import Any, ForwardRef, Union, get_args, get_origin - -from jsonpath_ng import parse # type: ignore[import-untyped] -from pydantic import BaseModel, RootModel - - -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 - - json_key = _json_key(field_name, field_info) - if current_path: - field_path = f"{current_path}.{json_key}" - else: - field_path = f"$.{json_key}" - - annotation = _unwrap_optional(annotation) - origin = get_origin(annotation) - - if matches_type(annotation): - json_paths.append(field_path) - continue - - if origin is list: - inner_type, suffix = _unwrap_lists(annotation) - inner_path = f"{field_path}{suffix}" - if matches_type(inner_type): - json_paths.append(inner_path) - continue - if _is_pydantic_model(inner_type): - nested_paths = _recursive_search(inner_type, inner_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 - - # RootModel serializes without the "root" wrapper — e.g. RootModel[list[X]] - # dumps as [...], not {"root": [...]}. Iterating model_fields directly would - # produce wrong paths like "$.root.field". Instead we peel off the RootModel - # envelope (and any Optional/list layers) so _recursive_search only ever sees - # a plain BaseModel with correct JSONPath prefixes (e.g. "$[*].field"). - if issubclass(model, RootModel): - inner = _unwrap_optional(model.model_fields["root"].annotation) - inner, suffix = _unwrap_lists(inner) - # Primitive or non-model root types can't contain nested typed fields. - if not _is_pydantic_model(inner): - return [] - return _recursive_search(inner, f"${suffix}" if suffix else "") - - 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: - """Whether ``annotation`` refers to ``type_name``, by name or identity.""" - if isinstance(annotation, ForwardRef): - return annotation.__forward_arg__ == type_name - if isinstance(annotation, str): - return annotation == type_name - # prefer the per-class marker: identity/target_type break when several - # dynamic models are built (they share the same module). - return ( - getattr(annotation, "__uipath_marker_name__", None) == type_name - or getattr(annotation, "__name__", None) == type_name - or (target_type is not None and annotation is target_type) - ) - - 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 or origin is types.UnionType: - 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 _unwrap_lists(annotation: Any) -> tuple[Any, str]: - """Unwrap nested list types, returning (inner_type, jsonpath_suffix). - - Each list layer adds a "[*]" wildcard so the resulting suffix maps directly - to JSONPath: list[list[X]] → (X, "[*][*]"). - """ - suffix = "" - while get_origin(annotation) is list: - args = get_args(annotation) - if not args: - break - annotation = args[0] - suffix += "[*]" - return annotation, suffix - - -def _json_key(field_name: str, field_info: Any) -> str: - """Get the JSON property name for a field, accounting for aliases.""" - return field_info.alias or field_name - - -def _is_pydantic_model(annotation: Any) -> bool: - return isinstance(annotation, type) and issubclass(annotation, BaseModel) - - -def _coerce_field(key: str, value: Any, schema: type[BaseModel] | None) -> Any: - """Coerce a single field value, skipping str-typed fields when schema is available.""" - if schema is None: - return coerce_json_strings(value) - - field_info = schema.model_fields.get(key) - if field_info is None: - return coerce_json_strings(value) - - annotation = _unwrap_optional(field_info.annotation) - - if annotation is str: - return value - - if _is_pydantic_model(annotation): - return coerce_json_strings(value, annotation) - - if get_origin(annotation) is list: - item_args = get_args(annotation) - item_schema = None - if item_args and _is_pydantic_model(item_args[0]): - item_schema = item_args[0] - if isinstance(value, list): - return [coerce_json_strings(item, item_schema) for item in value] - - return coerce_json_strings(value) - - -def coerce_json_strings(data: Any, schema: type[BaseModel] | None = None) -> Any: - """Parse stringified dicts/lists back into Python objects. - - LLMs sometimes serialize nested objects as strings instead of dicts, - either as JSON (double quotes) or Python repr (single quotes). - When a schema is provided, str-typed fields are left untouched. - """ - if isinstance(data, dict): - return {k: _coerce_field(k, v, schema) for k, v in data.items()} - if isinstance(data, list): - return [coerce_json_strings(item) for item in data] - if isinstance(data, str): - try: - parsed = json.loads(data) - if isinstance(parsed, (dict, list)): - return parsed - except (json.JSONDecodeError, TypeError): - pass - # LLMs sometimes emit Python repr (single quotes) instead of JSON - try: - parsed = ast.literal_eval(data) - if isinstance(parsed, (dict, list)): - return parsed - except (ValueError, SyntaxError): - pass - return data +"""Compatibility exports for agent JSON utilities.""" + +from ..json_utils import ( + _coerce_field, + _create_type_matcher, + _get_target_type, + _is_pydantic_model, + _json_key, + _unwrap_lists, + _unwrap_optional, + coerce_json_strings, + extract_values_by_paths, + get_json_paths_by_type, +) + +__all__ = [ + "_coerce_field", + "_create_type_matcher", + "_get_target_type", + "_is_pydantic_model", + "_json_key", + "_unwrap_lists", + "_unwrap_optional", + "coerce_json_strings", + "extract_values_by_paths", + "get_json_paths_by_type", +] diff --git a/src/uipath_langchain/agent/react/reducers.py b/src/uipath_langchain/agent/react/reducers.py index 9894e9e71..a2aef62cf 100644 --- a/src/uipath_langchain/agent/react/reducers.py +++ b/src/uipath_langchain/agent/react/reducers.py @@ -1,105 +1,5 @@ -"""Dict-like object reducers for merging state with field-specific reducers.""" +"""Compatibility exports for agent state reducers.""" -from typing import Any, Hashable, TypeVar +from ..reducers import merge_dicts, merge_objects -from pydantic import BaseModel -from uipath.runtime.errors import UiPathErrorCategory - -from uipath_langchain.agent.exceptions import ( - AgentRuntimeError, - AgentRuntimeErrorCode, -) - -K = TypeVar("K", bound=Hashable) - - -def merge_dicts(left: dict[K, Any], right: dict[K, Any]) -> dict[K, Any]: - """Generic dict merger with right values taking precedence. - - This reducer function merges two dictionaries. - If the same key exists in both dictionaries, the value from 'right' takes precedence. - - Args: - left: Existing dictionary - right: New dictionary 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 merge_objects(left: Any, right: Any) -> Any: - """Merge a Pydantic model with another model or dict, with right values taking precedence. - - Applies field-specific reducers from annotation metadata when merging values. - - Args: - left: Existing Pydantic BaseModel instance - right: New Pydantic BaseModel instance or dict to merge - - Returns: - New Pydantic model instance with merged values - - Raises: - AgentRuntimeError: If left is not a Pydantic BaseModel or right is not a BaseModel or dict - """ - if not right: - return left - - if not left: - return right - - # validate input types - if not isinstance(left, BaseModel): - raise AgentRuntimeError( - code=AgentRuntimeErrorCode.STATE_ERROR, - title="Left object must be a Pydantic BaseModel.", - detail=f"Got {type(left).__name__} instead of BaseModel during state merge.", - category=UiPathErrorCategory.SYSTEM, - ) - - if not isinstance(right, (BaseModel, dict)): - raise AgentRuntimeError( - code=AgentRuntimeErrorCode.STATE_ERROR, - title="Right object must be a Pydantic BaseModel or dict.", - detail=f"Got {type(right).__name__} instead of BaseModel or dict during state merge.", - category=UiPathErrorCategory.SYSTEM, - ) - - model_fields = type(left).model_fields - merged_values = {} - - for field_name in model_fields: - merged_values[field_name] = getattr(left, field_name) - - for field_name in model_fields: - if isinstance(right, BaseModel): - if hasattr(right, field_name): - right_value = getattr(right, field_name) - else: - continue # field not present in right - else: - # right is dict - if field_name not in right: - continue # field not present in right - right_value = right[field_name] - - field_info = model_fields[field_name] - left_value = merged_values[field_name] - - # apply reducer if defined - if field_info.metadata and callable(field_info.metadata[0]): - reducer_func = field_info.metadata[0] - merged_values[field_name] = reducer_func(left_value, right_value) - else: - merged_values[field_name] = right_value - - # return new model instance with merged values - return type(left)(**merged_values) +__all__ = ["merge_dicts", "merge_objects"] diff --git a/src/uipath_langchain/agent/react/types.py b/src/uipath_langchain/agent/react/types.py index 9a890e8c5..b8f8eeed8 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -1,135 +1,23 @@ -from enum import StrEnum -from typing import Annotated, Any, Hashable, Literal, Optional - -from langchain_core.messages import AnyMessage -from langgraph.graph.message import add_messages -from pydantic import BaseModel, Field, model_validator -from uipath.agent.react import ( - END_EXECUTION_TOOL, - RAISE_ERROR_TOOL, - SET_CONVERSATIONAL_OUTPUT_TOOL, -) -from uipath.platform.attachments import Attachment - -from uipath_langchain.agent.react.reducers import ( - merge_dicts, - merge_objects, +"""Compatibility exports for agent graph types.""" + +from ..types import ( + FLOW_CONTROL_TOOLS, + AgentGraphConfig, + AgentGraphNode, + AgentGraphState, + AgentGuardrailsGraphState, + InnerAgentGraphState, + InnerAgentGuardrailsGraphState, + MemoryConfig, ) -FLOW_CONTROL_TOOLS = [ - END_EXECUTION_TOOL.name, - RAISE_ERROR_TOOL.name, - SET_CONVERSATIONAL_OUTPUT_TOOL.name, +__all__ = [ + "FLOW_CONTROL_TOOLS", + "AgentGraphConfig", + "AgentGraphNode", + "AgentGraphState", + "AgentGuardrailsGraphState", + "InnerAgentGraphState", + "InnerAgentGuardrailsGraphState", + "MemoryConfig", ] - - -class InnerAgentGraphState(BaseModel): - job_attachments: Annotated[dict[str, Attachment], merge_dicts] = {} - initial_message_count: int | None = None - tools_storage: Annotated[dict[Hashable, Any], merge_dicts] = {} - memory_injection: str = "" - conversational_output: dict[str, Any] | None = None - - -class InnerAgentGuardrailsGraphState(InnerAgentGraphState): - """Extended inner state for guardrails subgraph.""" - - guardrail_validation_result: Optional[bool] = None - guardrail_validation_details: Optional[str] = None - guardrail_span_id: Optional[str] = None - agent_result: Optional[dict[str, Any]] = None - hitl_task_info: Optional[Any] = {} - escalation_review_data: Optional[dict[str, Any]] = None - - -class AgentGraphState(BaseModel): - """Agent Graph state for standard loop execution.""" - - messages: Annotated[list[AnyMessage], add_messages] = [] - inner_state: Annotated[InnerAgentGraphState, merge_objects] = Field( - default_factory=InnerAgentGraphState - ) - - -class AgentGuardrailsGraphState(AgentGraphState): - """Agent Guardrails Graph state for guardrail subgraph.""" - - inner_state: Annotated[InnerAgentGuardrailsGraphState, merge_objects] = Field( - default_factory=InnerAgentGuardrailsGraphState - ) - - -class AgentGraphNode(StrEnum): - INIT = "init" - GUARDED_INIT = "guarded-init" - AGENT = "agent" - LLM = "llm" - TOOLS = "tools" - GENERATE_CONVERSATIONAL_OUTPUT = "generate-conversational-output" - TERMINATE = "terminate" - GUARDED_TERMINATE = "guarded-terminate" - MEMORY_RECALL = "memory_recall" - - -class MemoryConfig(BaseModel): - """Configuration for Agent Episodic Memory. - - When passed to ``create_agent()``, a MEMORY_RECALL node is added before - INIT that queries the memory service and stores the server-generated - systemPromptInjection in ``inner_state.memory_injection``. - """ - - memory_space_id: str = Field(description="GUID of the memory space to query.") - memory_space_name: str = Field( - default="", description="Name of the memory space (for tracing)." - ) - folder_key: str | None = Field( - default=None, description="Folder key for the memory resource." - ) - folder_path: str | None = Field( - default=None, - description="Folder path for the memory resource. Resolved to folder_key at runtime if folder_key is not set.", - ) - # Defaults match FE episodic memory settings (agentEditor.ts:324-328) - result_count: int = Field(default=3, ge=1, le=10) - threshold: float = Field(default=0.0, ge=0.0, le=1.0) - field_weights: dict[str, float] = Field( - description=( - "Per-field search weights. Keys are input field names, values are " - "weights between 0.0 and 1.0. At least one field must be specified." - ), - ) - - @model_validator(mode="after") - def _validate_field_weights(self) -> "MemoryConfig": - if not self.field_weights: - raise ValueError("field_weights must contain at least one field") - return self - - -class AgentGraphConfig(BaseModel): - llm_messages_limit: int = Field( - default=25, - ge=1, - description="Maximum number of LLM calls allowed per agent execution", - ) - thinking_messages_limit: int = Field( - default=0, - ge=0, - description="Max consecutive thinking messages before enforcing tool calling. 0 = force tool calling every time.", - ) - is_conversational: bool = Field( - default=False, description="If set, creates a graph for conversational agents" - ) - tool_choice: Literal["auto", "any"] = Field( - default="auto", - description="The tool choice to use for the LLM. 'auto' means the LLM will choose the tool, 'any' means the LLM will return multiple tool calls in a single response.", - ) - parallel_tool_calls: bool = Field( - default=True, - description="Allow the LLM to return multiple tool calls in a single response.", - ) - strict_mode: bool = Field( - default=False, - description="If set, the LLM will guarantee schema validation of the tool calls.", - ) diff --git a/src/uipath_langchain/agent/reducers.py b/src/uipath_langchain/agent/reducers.py new file mode 100644 index 000000000..9894e9e71 --- /dev/null +++ b/src/uipath_langchain/agent/reducers.py @@ -0,0 +1,105 @@ +"""Dict-like object reducers for merging state with field-specific reducers.""" + +from typing import Any, Hashable, TypeVar + +from pydantic import BaseModel +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.agent.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, +) + +K = TypeVar("K", bound=Hashable) + + +def merge_dicts(left: dict[K, Any], right: dict[K, Any]) -> dict[K, Any]: + """Generic dict merger with right values taking precedence. + + This reducer function merges two dictionaries. + If the same key exists in both dictionaries, the value from 'right' takes precedence. + + Args: + left: Existing dictionary + right: New dictionary 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 merge_objects(left: Any, right: Any) -> Any: + """Merge a Pydantic model with another model or dict, with right values taking precedence. + + Applies field-specific reducers from annotation metadata when merging values. + + Args: + left: Existing Pydantic BaseModel instance + right: New Pydantic BaseModel instance or dict to merge + + Returns: + New Pydantic model instance with merged values + + Raises: + AgentRuntimeError: If left is not a Pydantic BaseModel or right is not a BaseModel or dict + """ + if not right: + return left + + if not left: + return right + + # validate input types + if not isinstance(left, BaseModel): + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.STATE_ERROR, + title="Left object must be a Pydantic BaseModel.", + detail=f"Got {type(left).__name__} instead of BaseModel during state merge.", + category=UiPathErrorCategory.SYSTEM, + ) + + if not isinstance(right, (BaseModel, dict)): + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.STATE_ERROR, + title="Right object must be a Pydantic BaseModel or dict.", + detail=f"Got {type(right).__name__} instead of BaseModel or dict during state merge.", + category=UiPathErrorCategory.SYSTEM, + ) + + model_fields = type(left).model_fields + merged_values = {} + + for field_name in model_fields: + merged_values[field_name] = getattr(left, field_name) + + for field_name in model_fields: + if isinstance(right, BaseModel): + if hasattr(right, field_name): + right_value = getattr(right, field_name) + else: + continue # field not present in right + else: + # right is dict + if field_name not in right: + continue # field not present in right + right_value = right[field_name] + + field_info = model_fields[field_name] + left_value = merged_values[field_name] + + # apply reducer if defined + if field_info.metadata and callable(field_info.metadata[0]): + reducer_func = field_info.metadata[0] + merged_values[field_name] = reducer_func(left_value, right_value) + else: + merged_values[field_name] = right_value + + # return new model instance with merged values + return type(left)(**merged_values) diff --git a/src/uipath_langchain/agent/tool_types.py b/src/uipath_langchain/agent/tool_types.py new file mode 100644 index 000000000..930360581 --- /dev/null +++ b/src/uipath_langchain/agent/tool_types.py @@ -0,0 +1,21 @@ +"""Shared tool wrapper types.""" + +from typing import Any, Awaitable, Callable + +from langchain_core.messages.tool import ToolCall +from langchain_core.tools import BaseTool +from langgraph.types import Command + +ToolWrapperReturnType = dict[str, Any] | Command[Any] | None + +ToolWrapperWithoutState = Callable[[BaseTool, ToolCall], ToolWrapperReturnType] +ToolWrapperWithState = Callable[[BaseTool, ToolCall, Any], ToolWrapperReturnType] +ToolWrapperType = ToolWrapperWithoutState | ToolWrapperWithState + +AsyncToolWrapperWithoutState = Callable[ + [BaseTool, ToolCall], Awaitable[ToolWrapperReturnType] +] +AsyncToolWrapperWithState = Callable[ + [BaseTool, ToolCall, Any], Awaitable[ToolWrapperReturnType] +] +AsyncToolWrapperType = AsyncToolWrapperWithoutState | AsyncToolWrapperWithState diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py index 6471c7d7e..1ab877975 100644 --- a/src/uipath_langchain/agent/tools/client_side_tool.py +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -2,13 +2,16 @@ import json from contextvars import ContextVar -from typing import Annotated, Any, TypedDict +from typing import Annotated, Any from langchain_core.messages import ToolMessage from langchain_core.tools import InjectedToolCallId, StructuredTool from uipath.agent.models.agent import AgentClientSideToolResourceConfig from uipath.eval.mocks import mockable +from uipath_langchain._conversation.types import ( + ClientSideToolInfo as ClientSideToolInfo, +) from uipath_langchain._utils.durable_interrupt import durable_interrupt from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( create_model as create_model_from_schema, @@ -26,11 +29,6 @@ UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY = "uipath__client_side_tools" -class ClientSideToolInfo(TypedDict): - input_schema: dict[str, Any] | None - output_schema: dict[str, Any] | None - - def apply_tool_filter( declared_tools: list[str | dict[str, Any]], agent_tools: dict[str, ClientSideToolInfo], diff --git a/src/uipath_langchain/agent/tools/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index f28a3b28f..86c3b89c0 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -22,27 +22,33 @@ extract_current_tool_call_index, find_latest_ai_message, ) +from uipath_langchain.agent.tool_types import ( + AsyncToolWrapperType as AsyncToolWrapperType, +) +from uipath_langchain.agent.tool_types import ( + AsyncToolWrapperWithoutState as AsyncToolWrapperWithoutState, +) +from uipath_langchain.agent.tool_types import ( + AsyncToolWrapperWithState as AsyncToolWrapperWithState, +) +from uipath_langchain.agent.tool_types import ( + ToolWrapperReturnType as ToolWrapperReturnType, +) +from uipath_langchain.agent.tool_types import ( + ToolWrapperType as ToolWrapperType, +) +from uipath_langchain.agent.tool_types import ( + ToolWrapperWithoutState as ToolWrapperWithoutState, +) +from uipath_langchain.agent.tool_types import ( + ToolWrapperWithState as ToolWrapperWithState, +) from uipath_langchain.chat.hitl import ( IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, REQUIRE_CONVERSATIONAL_CONFIRMATION, request_conversational_tool_confirmation, ) -# the type safety can be improved with generics -ToolWrapperReturnType = dict[str, Any] | Command[Any] | None - -ToolWrapperWithoutState = Callable[[BaseTool, ToolCall], ToolWrapperReturnType] -ToolWrapperWithState = Callable[[BaseTool, ToolCall, Any], ToolWrapperReturnType] -ToolWrapperType = ToolWrapperWithoutState | ToolWrapperWithState - -AsyncToolWrapperWithoutState = Callable[ - [BaseTool, ToolCall], Awaitable[ToolWrapperReturnType] -] -AsyncToolWrapperWithState = Callable[ - [BaseTool, ToolCall, Any], Awaitable[ToolWrapperReturnType] -] -AsyncToolWrapperType = AsyncToolWrapperWithoutState | AsyncToolWrapperWithState - OutputType = dict[Literal["messages"], list[ToolMessage]] | Command[Any] | None diff --git a/src/uipath_langchain/agent/types.py b/src/uipath_langchain/agent/types.py new file mode 100644 index 000000000..3390a8273 --- /dev/null +++ b/src/uipath_langchain/agent/types.py @@ -0,0 +1,135 @@ +from enum import StrEnum +from typing import Annotated, Any, Hashable, Literal, Optional + +from langchain_core.messages import AnyMessage +from langgraph.graph.message import add_messages +from pydantic import BaseModel, Field, model_validator +from uipath.agent.react import ( + END_EXECUTION_TOOL, + RAISE_ERROR_TOOL, + SET_CONVERSATIONAL_OUTPUT_TOOL, +) +from uipath.platform.attachments import Attachment + +from uipath_langchain.agent.reducers import ( + merge_dicts, + merge_objects, +) + +FLOW_CONTROL_TOOLS = [ + END_EXECUTION_TOOL.name, + RAISE_ERROR_TOOL.name, + SET_CONVERSATIONAL_OUTPUT_TOOL.name, +] + + +class InnerAgentGraphState(BaseModel): + job_attachments: Annotated[dict[str, Attachment], merge_dicts] = {} + initial_message_count: int | None = None + tools_storage: Annotated[dict[Hashable, Any], merge_dicts] = {} + memory_injection: str = "" + conversational_output: dict[str, Any] | None = None + + +class InnerAgentGuardrailsGraphState(InnerAgentGraphState): + """Extended inner state for guardrails subgraph.""" + + guardrail_validation_result: Optional[bool] = None + guardrail_validation_details: Optional[str] = None + guardrail_span_id: Optional[str] = None + agent_result: Optional[dict[str, Any]] = None + hitl_task_info: Optional[Any] = {} + escalation_review_data: Optional[dict[str, Any]] = None + + +class AgentGraphState(BaseModel): + """Agent Graph state for standard loop execution.""" + + messages: Annotated[list[AnyMessage], add_messages] = [] + inner_state: Annotated[InnerAgentGraphState, merge_objects] = Field( + default_factory=InnerAgentGraphState + ) + + +class AgentGuardrailsGraphState(AgentGraphState): + """Agent Guardrails Graph state for guardrail subgraph.""" + + inner_state: Annotated[InnerAgentGuardrailsGraphState, merge_objects] = Field( + default_factory=InnerAgentGuardrailsGraphState + ) + + +class AgentGraphNode(StrEnum): + INIT = "init" + GUARDED_INIT = "guarded-init" + AGENT = "agent" + LLM = "llm" + TOOLS = "tools" + GENERATE_CONVERSATIONAL_OUTPUT = "generate-conversational-output" + TERMINATE = "terminate" + GUARDED_TERMINATE = "guarded-terminate" + MEMORY_RECALL = "memory_recall" + + +class MemoryConfig(BaseModel): + """Configuration for Agent Episodic Memory. + + When passed to ``create_agent()``, a MEMORY_RECALL node is added before + INIT that queries the memory service and stores the server-generated + systemPromptInjection in ``inner_state.memory_injection``. + """ + + memory_space_id: str = Field(description="GUID of the memory space to query.") + memory_space_name: str = Field( + default="", description="Name of the memory space (for tracing)." + ) + folder_key: str | None = Field( + default=None, description="Folder key for the memory resource." + ) + folder_path: str | None = Field( + default=None, + description="Folder path for the memory resource. Resolved to folder_key at runtime if folder_key is not set.", + ) + # Defaults match FE episodic memory settings (agentEditor.ts:324-328) + result_count: int = Field(default=3, ge=1, le=10) + threshold: float = Field(default=0.0, ge=0.0, le=1.0) + field_weights: dict[str, float] = Field( + description=( + "Per-field search weights. Keys are input field names, values are " + "weights between 0.0 and 1.0. At least one field must be specified." + ), + ) + + @model_validator(mode="after") + def _validate_field_weights(self) -> "MemoryConfig": + if not self.field_weights: + raise ValueError("field_weights must contain at least one field") + return self + + +class AgentGraphConfig(BaseModel): + llm_messages_limit: int = Field( + default=25, + ge=1, + description="Maximum number of LLM calls allowed per agent execution", + ) + thinking_messages_limit: int = Field( + default=0, + ge=0, + description="Max consecutive thinking messages before enforcing tool calling. 0 = force tool calling every time.", + ) + is_conversational: bool = Field( + default=False, description="If set, creates a graph for conversational agents" + ) + tool_choice: Literal["auto", "any"] = Field( + default="auto", + description="The tool choice to use for the LLM. 'auto' means the LLM will choose the tool, 'any' means the LLM will return multiple tool calls in a single response.", + ) + parallel_tool_calls: bool = Field( + default=True, + description="Allow the LLM to return multiple tool calls in a single response.", + ) + strict_mode: bool = Field( + default=False, + description="If set, the LLM will guarantee schema validation of the tool calls.", + ) diff --git a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py index 476fa41c1..e32231e80 100644 --- a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py +++ b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py @@ -6,14 +6,14 @@ from langgraph.types import Command from pydantic import BaseModel -from uipath_langchain.agent.react.job_attachments import ( +from uipath_langchain.agent.job_attachments import ( get_job_attachment_paths, get_job_attachments, replace_job_attachment_ids, ) -from uipath_langchain.agent.react.json_utils import coerce_json_strings -from uipath_langchain.agent.react.types import AgentGraphState -from uipath_langchain.agent.tools.tool_node import AsyncToolWrapperWithState +from uipath_langchain.agent.json_utils import coerce_json_strings +from uipath_langchain.agent.tool_types import AsyncToolWrapperWithState +from uipath_langchain.agent.types import AgentGraphState def _parse(content: str) -> Any: diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index b6dab7a3b..e047d4555 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -40,7 +40,7 @@ ) from uipath.runtime import UiPathRuntimeStorageProtocol -from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo +from uipath_langchain._conversation.types import ClientSideToolInfo from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL from ._citations import ( @@ -188,11 +188,6 @@ def _map_messages_internal( ) ) elif isinstance(data, UiPathExternalValue): - if uipath_message.role == "assistant": - # Workspace files persisted by the advanced runtime - # (hydrated into the file backend before the graph - # runs); they are not attachments for the LLM. - continue attachment_id = self.parse_attachment_id_from_content_part_uri( data.uri ) @@ -707,15 +702,12 @@ def map_to_content_part_end_event( ), ) - # Static methods for mapping langchain messages to uipath message types - @staticmethod def map_langchain_messages_to_uipath_message_data_list( messages: list[AnyMessage], include_tool_results: bool = True ) -> list[UiPathConversationMessageData]: """Convert LangChain messages to UiPathConversationMessageData format. include_tool_results controls whether to include tool call results from ToolMessage instances in the output agent-messages.""" - # Build map of tool_call_id -> ToolMessage lookup, if tool-results should be included tool_messages_map = ( UiPathChatMessagesMapper._build_langchain_tool_messages_map(messages) if include_tool_results @@ -744,31 +736,29 @@ def map_langchain_messages_to_uipath_message_data_list( def _build_langchain_tool_messages_map( messages: list[AnyMessage], ) -> dict[str, ToolMessage]: - """Create mapping of tool_call_id -> ToolMessage for efficient lookup.""" - tool_map: dict[str, ToolMessage] = {} - for msg in messages: - if isinstance(msg, ToolMessage) and msg.tool_call_id: - tool_map[msg.tool_call_id] = msg - return tool_map + """Create mapping of tool_call_id to ToolMessage for efficient lookup.""" + return { + message.tool_call_id: message + for message in messages + if isinstance(message, ToolMessage) and message.tool_call_id + } @staticmethod def _parse_langchain_tool_result(content: Any) -> Any: - """Attempt to parse JSON result back to dict (reverse of json.dumps).""" + """Attempt to parse a JSON result back to its original value.""" if not content or not isinstance(content, str): return content try: return json.loads(content) except (json.JSONDecodeError, TypeError): - # Not valid JSON, return as string return content @staticmethod def _map_langchain_human_message_to_uipath_message_data( message: HumanMessage, ) -> UiPathConversationMessageData: - """Convert HumanMessage to UiPathConversationMessageData.""" - + """Convert a HumanMessage to UiPathConversationMessageData.""" text_content = UiPathChatMessagesMapper._extract_text(message.content) content_parts: list[UiPathConversationContentPartData] = [] if text_content: @@ -788,8 +778,7 @@ def _map_langchain_human_message_to_uipath_message_data( def _map_langchain_ai_message_to_uipath_message_data( message: AIMessage, tool_message_map: dict[str, ToolMessage] | None ) -> UiPathConversationMessageData: - """Convert AIMessage to UiPathConversationMessageData with embedded tool-calls. When tool_message_map is passed in, tool results are matched by tool-call ID and included.""" - + """Convert an AIMessage to UiPathConversationMessageData.""" content_parts: list[UiPathConversationContentPartData] = [] text_content = UiPathChatMessagesMapper._extract_text(message.content) if text_content: @@ -802,30 +791,23 @@ def _map_langchain_ai_message_to_uipath_message_data( ) ) - # Convert tool_calls uipath_tool_calls: list[UiPathConversationToolCallData] = [] - if message.tool_calls: - for tool_call in message.tool_calls: - uipath_tool_call = UiPathConversationToolCallData( - name=tool_call["name"], input=tool_call.get("args", {}) - ) + for tool_call in message.tool_calls: + uipath_tool_call = UiPathConversationToolCallData( + name=tool_call["name"], input=tool_call.get("args", {}) + ) - if tool_message_map and tool_call["id"]: - # Find corresponding ToolMessage and build tool-call result if found - tool_message = tool_message_map.get(tool_call["id"]) - result = None - if tool_message: - # Parse JSON result back to dict - output = UiPathChatMessagesMapper._parse_langchain_tool_result( + if tool_message_map and tool_call["id"]: + tool_message = tool_message_map.get(tool_call["id"]) + if tool_message: + uipath_tool_call.result = UiPathConversationToolCallResult( + output=UiPathChatMessagesMapper._parse_langchain_tool_result( tool_message.content - ) - result = UiPathConversationToolCallResult( - output=output, - is_error=tool_message.status == "error", - ) - uipath_tool_call.result = result + ), + is_error=tool_message.status == "error", + ) - uipath_tool_calls.append(uipath_tool_call) + uipath_tool_calls.append(uipath_tool_call) return UiPathConversationMessageData( role="assistant", diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 04657b1e7..0fb597ba4 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -32,7 +32,7 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema -from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo +from uipath_langchain._conversation.types import ClientSideToolInfo from uipath_langchain.chat.hitl import ( IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, get_confirmation_schema, diff --git a/tests/agent/advanced/test_conversational_advanced_agent_graph.py b/tests/agent/advanced/test_conversational_advanced_agent_graph.py index 7817fe8a3..4bb30b349 100644 --- a/tests/agent/advanced/test_conversational_advanced_agent_graph.py +++ b/tests/agent/advanced/test_conversational_advanced_agent_graph.py @@ -1,14 +1,18 @@ """Tests for the conversational advanced agent wrapper builder.""" -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock, patch import pytest +from langchain.agents.middleware import ModelRequest, ModelResponse from langchain_core.language_models import BaseChatModel -from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_core.runnables import RunnableLambda from langgraph.graph import END, START, StateGraph +from pydantic import BaseModel, Field from uipath_langchain.agent.advanced.agent import ( + _RuntimeSystemPromptMiddleware, create_conversational_advanced_agent_graph, ) from uipath_langchain.agent.advanced.types import ( @@ -16,6 +20,27 @@ ) +class _Input(BaseModel): + messages: list[Any] = [] + tenant: str = "" + uipath__user_settings: dict[str, Any] = {} + + +class _InputWithoutMessages(BaseModel): + tenant: str + + +class _AliasedInput(BaseModel): + messages: list[Any] = [] + tenant_name: str = Field(alias="tenantName") + + +class _CollidingInput(BaseModel): + messages: list[Any] = [] + initial_message_count: str + uipath__system_prompt: str + + def _mock_model() -> MagicMock: model = MagicMock(spec=BaseChatModel) model.profile = None @@ -48,6 +73,231 @@ def test_wrapper_graph_has_conversational_nodes() -> None: } <= set(graph.nodes) +def test_callable_system_prompt_enables_runtime_middleware() -> None: + with patch( + "uipath_langchain.agent.advanced.agent._create_deep_agent", + return_value=MagicMock(), + ) as create_deep_agent: + create_conversational_advanced_agent_graph( + model=_mock_model(), + tools=[], + system_prompt=lambda args: f"system:{args}", + backend=None, + input_schema=_Input, + ) + + call_kwargs = create_deep_agent.call_args.kwargs + assert call_kwargs["system_prompt"] is None + assert len(call_kwargs["middleware"]) == 1 + middleware = call_kwargs["middleware"][0] + assert isinstance(middleware, _RuntimeSystemPromptMiddleware) + assert middleware.state_key == "uipath__system_prompt" + + +def test_static_system_prompt_keeps_existing_deep_agent_configuration() -> None: + with patch( + "uipath_langchain.agent.advanced.agent._create_deep_agent", + return_value=MagicMock(), + ) as create_deep_agent: + create_conversational_advanced_agent_graph( + model=_mock_model(), + tools=[], + system_prompt="system", + backend=None, + ) + + call_kwargs = create_deep_agent.call_args.kwargs + assert call_kwargs["system_prompt"] == "system" + assert call_kwargs["middleware"] == [] + + +@pytest.mark.asyncio +async def test_resolves_system_prompt_from_exchange_input() -> None: + calls: list[dict[str, Any]] = [] + + def build_system_prompt(args: dict[str, Any]) -> str: + calls.append(args) + return f"system:{args['tenant']}:{args['uipath__user_settings']['name']}" + + graph = create_conversational_advanced_agent_graph( + model=_mock_model(), + tools=[], + system_prompt=build_system_prompt, + backend=None, + input_schema=_Input, + ) + state = graph.state_schema( + messages=[HumanMessage(content="hi")], + tenant="finance", + uipath__user_settings={"name": "Ada"}, + ) + + capture_exchange_start = cast(Any, graph.nodes["capture_exchange_start"].runnable) + update = await capture_exchange_start.ainvoke(state) + + assert calls == [ + { + "tenant": "finance", + "uipath__user_settings": {"name": "Ada"}, + } + ] + assert update == { + "initial_message_count": 1, + "uipath__system_prompt": "system:finance:Ada", + } + + +@pytest.mark.asyncio +async def test_compiled_graph_serializes_input_aliases_for_prompt() -> None: + calls: list[dict[str, Any]] = [] + + def build_system_prompt(args: dict[str, Any]) -> str: + calls.append(args) + return "system" + + with patch( + "uipath_langchain.agent.advanced.agent.create_advanced_agent", + return_value=_fake_inner_agent(), + ): + graph = create_conversational_advanced_agent_graph( + model=_mock_model(), + tools=[], + system_prompt=build_system_prompt, + backend=None, + input_schema=_AliasedInput, + ).compile() + result = await graph.ainvoke( + { + "messages": [HumanMessage(content="hi", id="u1")], + "tenant_name": "finance", + } + ) + + assert calls == [{"tenantName": "finance"}] + assert len(result["uipath__agent_response_messages"]) == 1 + + +@pytest.mark.asyncio +async def test_custom_input_schema_does_not_drop_conversation_messages() -> None: + with patch( + "uipath_langchain.agent.advanced.agent.create_advanced_agent", + return_value=_fake_inner_agent(), + ): + graph = create_conversational_advanced_agent_graph( + model=_mock_model(), + tools=[], + system_prompt=lambda args: f"system:{args['tenant']}", + backend=None, + input_schema=_InputWithoutMessages, + ).compile() + result = await graph.ainvoke( + { + "messages": [HumanMessage(content="hi", id="u1")], + "tenant": "finance", + } + ) + + assert len(result["uipath__agent_response_messages"]) == 1 + + +@pytest.mark.asyncio +async def test_internal_state_fields_do_not_collide_with_input_fields() -> None: + calls: list[dict[str, Any]] = [] + + def build_system_prompt(args: dict[str, Any]) -> str: + calls.append(args) + return "resolved" + + graph = create_conversational_advanced_agent_graph( + model=_mock_model(), + tools=[], + system_prompt=build_system_prompt, + backend=None, + input_schema=_CollidingInput, + ) + state = graph.state_schema( + messages=[HumanMessage(content="hi")], + initial_message_count="custom count", + uipath__system_prompt="custom prompt", + ) + + capture_exchange_start = cast(Any, graph.nodes["capture_exchange_start"].runnable) + update = await capture_exchange_start.ainvoke(state) + + assert calls == [ + { + "initial_message_count": "custom count", + "uipath__system_prompt": "custom prompt", + } + ] + assert update == { + "initial_message_count_1": 1, + "uipath__system_prompt_1": "resolved", + } + + +@pytest.mark.asyncio +async def test_runtime_prompt_crosses_into_deep_agent() -> None: + resolver_calls: list[dict[str, Any]] = [] + captured: list[ModelRequest[Any]] = [] + + def build_system_prompt(args: dict[str, Any]) -> str: + resolver_calls.append(args) + return f"system:{args['tenant']}" + + def create_inner_graph(**kwargs: Any) -> Any: + middleware = kwargs["middleware"][0] + + def respond(state: BaseModel) -> dict[str, Any]: + state_data = state.model_dump() + state_data["messages"] = cast(Any, state).messages + request = ModelRequest( + model=_mock_model(), + messages=state_data["messages"], + system_message=SystemMessage(content="deepagents prompt"), + state=cast(Any, state_data), + ) + + def handler(prepared: ModelRequest[Any]) -> ModelResponse[Any]: + captured.append(prepared) + return ModelResponse(result=[]) + + middleware.wrap_model_call(request, handler) + return {"messages": [AIMessage(content="done", id="ai-1")]} + + return RunnableLambda(respond) + + with patch( + "uipath_langchain.agent.advanced.agent._create_deep_agent", + side_effect=create_inner_graph, + ): + graph = create_conversational_advanced_agent_graph( + model=_mock_model(), + tools=[], + system_prompt=build_system_prompt, + backend=None, + input_schema=_Input, + ).compile() + result = await graph.ainvoke( + { + "messages": [HumanMessage(content="hi", id="u1")], + "tenant": "finance", + "uipath__user_settings": {"name": "Ada"}, + } + ) + + assert resolver_calls == [ + { + "tenant": "finance", + "uipath__user_settings": {"name": "Ada"}, + } + ] + assert len(captured) == 1 + assert captured[0].system_message is not None + assert captured[0].system_message.text == "system:finance\n\ndeepagents prompt" + assert len(result["uipath__agent_response_messages"]) == 1 + + @pytest.mark.asyncio async def test_outputs_only_new_messages_as_response_messages() -> None: with patch( diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index 11db32f9c..e7c91ea4f 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -915,7 +915,7 @@ def test_map_messages_external_value_produces_attachment_content(self): content_part_id="part-file", mime_type="application/pdf", data=UiPathExternalValue( - uri="urn:uipath:cas:file:orchestrator:a940a416-b97b-4146-3089-08de5f4d0a87" + uri="urn:uipath:cas:file:orchestrator:00000000-0000-0000-0000-000000000000" ), name="test.pdf", citations=[], @@ -936,16 +936,54 @@ def test_map_messages_external_value_produces_attachment_content(self): assert len(msg.content_blocks) == 2 assert msg.content_blocks[0]["text"] == "Check this file" # type: ignore[typeddict-item] assert "" in msg.content_blocks[1]["text"] # type: ignore[typeddict-item] - assert "a940a416-b97b-4146-3089-08de5f4d0a87" in msg.content_blocks[1]["text"] # type: ignore[typeddict-item] + assert "00000000-0000-0000-0000-000000000000" in msg.content_blocks[1]["text"] # type: ignore[typeddict-item] assert "attachments" in msg.additional_kwargs assert msg.additional_kwargs["attachments"] == [ { - "id": "a940a416-b97b-4146-3089-08de5f4d0a87", + "id": "00000000-0000-0000-0000-000000000000", "full_name": "test.pdf", "mime_type": "application/pdf", } ] + def test_map_messages_preserves_assistant_external_value(self): + mapper = UiPathChatMessagesMapper("test-runtime", None) + uipath_msg = UiPathConversationMessage( + message_id="msg-1", + role="assistant", + created_at=TEST_TIMESTAMP, + updated_at=TEST_TIMESTAMP, + content_parts=[ + UiPathConversationContentPart( + content_part_id="part-file", + mime_type="application/pdf", + data=UiPathExternalValue( + uri="urn:uipath:cas:file:orchestrator:00000000-0000-0000-0000-000000000000" + ), + name="result.pdf", + citations=[], + created_at=TEST_TIMESTAMP, + updated_at=TEST_TIMESTAMP, + ) + ], + tool_calls=[], + interrupts=[], + ) + + result = mapper.map_messages([uipath_msg]) + + assert len(result) == 1 + message = result[0] + assert isinstance(message, AIMessage) + assert "" in message.content + assert message.additional_kwargs["attachments"] == [ + { + "id": "00000000-0000-0000-0000-000000000000", + "full_name": "result.pdf", + "mime_type": "application/pdf", + } + ] + def test_map_messages_external_value_with_empty_uri_skips_attachment(self): """Should skip attachment when external value has an empty URI.""" mapper = UiPathChatMessagesMapper("test-runtime", None) @@ -1888,7 +1926,7 @@ def test_extracts_text_from_content_blocks(self): class TestMapLangChainAIMessageCitations: - """Tests for citation extraction in _map_langchain_ai_message_to_uipath_message_data.""" + """Tests for citation conversion in assistant messages.""" def test_ai_message_with_citation_tags_populates_citations(self): """AIMessage with inline citation tags should have citations populated and text cleaned.""" diff --git a/tests/runtime/test_chat_message_mapper_workspace.py b/tests/runtime/test_chat_message_mapper_workspace.py deleted file mode 100644 index 065f37af1..000000000 --- a/tests/runtime/test_chat_message_mapper_workspace.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Assistant-message workspace file content-parts must be hidden from the LLM.""" - -from langchain_core.messages import AIMessage, HumanMessage -from uipath.core.chat import ( - UiPathConversationContentPart, - UiPathConversationMessage, - UiPathExternalValue, - UiPathInlineValue, -) - -from uipath_langchain.runtime.messages import UiPathChatMessagesMapper - -CAS_URI = "urn:uipath:cas:file:orchestrator:a940a416-b97b-4146-3089-08de5f4d0a87" - - -def _file_part(part_id: str, name: str) -> UiPathConversationContentPart: - return UiPathConversationContentPart( - content_part_id=part_id, - mime_type="text/markdown", - data=UiPathExternalValue(uri=CAS_URI), - name=name, - citations=[], - ) - - -def test_assistant_file_parts_are_skipped() -> None: - mapper = UiPathChatMessagesMapper("test-runtime", None) - message = UiPathConversationMessage( - message_id="a1", - role="assistant", - content_parts=[ - UiPathConversationContentPart( - content_part_id="p1", - mime_type="text/plain", - data=UiPathInlineValue(inline="done, see the plan"), - citations=[], - ), - _file_part("p2", "plan/todo.md"), - ], - tool_calls=[], - ) - - result = mapper.map_messages([message]) - - assert len(result) == 1 - ai = result[0] - assert isinstance(ai, AIMessage) - assert "" not in ai.content - assert "attachments" not in ai.additional_kwargs - assert "done, see the plan" in ai.content - - -def test_user_file_parts_still_produce_attachments() -> None: - mapper = UiPathChatMessagesMapper("test-runtime", None) - message = UiPathConversationMessage( - message_id="u1", - role="user", - content_parts=[_file_part("p1", "report.pdf")], - tool_calls=[], - ) - - result = mapper.map_messages([message]) - - assert len(result) == 1 - user = result[0] - assert isinstance(user, HumanMessage) - assert user.additional_kwargs["attachments"][0]["full_name"] == "report.pdf" diff --git a/uv.lock b/uv.lock index d53cf2873..a58363347 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P2D" [options.exclude-newer-package] +uipath-dev = false uipath-runtime = false uipath = false uipath-platform = false @@ -4498,7 +4499,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.16.1" +version = "0.16.2" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },