diff --git a/pyproject.toml b/pyproject.toml index 482f23b21..ac9f6eb91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-langchain" -version = "0.14.2" +version = "0.14.3" 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.13.5, <2.14.0", + "uipath>=2.13.7, <2.14.0", "uipath-core>=0.5.29, <0.6.0", "uipath-platform>=0.2.4, <0.3.0", "uipath-runtime>=0.12.1, <0.13.0", diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 44f09052c..f485c2c0e 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -15,6 +15,9 @@ from ...runtime._citations import cas_deep_rag_citation_wrapper from ..guardrails.actions import GuardrailAction from ..tools.structured_tool_with_output_type import StructuredToolWithOutputType +from .conversational_output_node import ( + create_conversational_output_node, +) from .guardrails.guardrails_subgraph import ( create_agent_init_guardrails_subgraph, create_agent_terminate_guardrails_subgraph, @@ -42,7 +45,10 @@ AgentGraphState, MemoryConfig, ) -from .utils import create_state_with_input +from .utils import ( + create_state_with_input, + has_custom_conversational_output_fields, +) InputT = TypeVar("InputT", bound=BaseModel) OutputT = TypeVar("OutputT", bound=BaseModel) @@ -124,7 +130,14 @@ def create_agent( tool_nodes_with_guardrails ) - terminate_node = create_terminate_node(output_schema, config.is_conversational) + with_conversational_output_node = ( + config.is_conversational + and has_custom_conversational_output_fields(output_schema) + ) + + terminate_node = create_terminate_node( + output_schema, config.is_conversational, with_conversational_output_node + ) CompleteAgentGraphState = create_state_with_input( input_schema if input_schema is not None else BaseModel @@ -150,6 +163,16 @@ def create_agent( ) builder.add_node(AgentGraphNode.TERMINATE, terminate_with_guardrails_subgraph) + if with_conversational_output_node and output_schema is not None: + builder.add_node( + AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT, + create_conversational_output_node(model, output_schema), + ) + builder.add_edge( + AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT, + AgentGraphNode.TERMINATE, + ) + if memory: memory_recall = create_memory_recall_node(memory, input_schema=input_schema) builder.add_node(AgentGraphNode.MEMORY_RECALL, memory_recall) @@ -182,7 +205,12 @@ def create_agent( *tool_node_names, AgentGraphNode.TERMINATE, ] - route_agent = create_route_agent_conversational(valid_targets=target_node_names) + if with_conversational_output_node: + target_node_names.append(AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT) + route_agent = create_route_agent_conversational( + valid_targets=target_node_names, + with_generate_output_node=with_conversational_output_node, + ) else: target_node_names = [ AgentGraphNode.AGENT, diff --git a/src/uipath_langchain/agent/react/constants.py b/src/uipath_langchain/agent/react/constants.py index aec74daa6..d28d7b789 100644 --- a/src/uipath_langchain/agent/react/constants.py +++ b/src/uipath_langchain/agent/react/constants.py @@ -1,2 +1,4 @@ DEFAULT_MAX_CONSECUTIVE_THINKING_MESSAGES = 0 DEFAULT_MAX_LLM_MESSAGES = 25 + +UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD = "uipath__agent_response_messages" diff --git a/src/uipath_langchain/agent/react/conversational_output_node.py b/src/uipath_langchain/agent/react/conversational_output_node.py new file mode 100644 index 000000000..54e9308e8 --- /dev/null +++ b/src/uipath_langchain/agent/react/conversational_output_node.py @@ -0,0 +1,101 @@ +"""GENERATE_CONVERSATIONAL_OUTPUT node for the Agent graph. + +This intermediate node runs after AGENT for conversational agents whose +output schema declares custom fields beyond `uipath__agent_response_messages`. +It performs a focused LLM call with only the `set_conversational_output` +tool bound and `tool_choice="any"` to extract the structured output for the turn. +""" + +from typing import TypeVar + +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.runnables.config import var_child_runnable_config +from pydantic import BaseModel +from uipath.agent.react.conversational_prompts import ( + get_generate_output_prompt, +) +from uipath.llm_client import UiPathAPIError, UiPathError +from uipath.llm_client.utils.exceptions import as_uipath_error +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.chat.handlers import get_payload_handler + +from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode +from ..exceptions.licensing import raise_for_provider_http_error +from ..exceptions.llm import raise_for_llm_client_error +from ..tools.utils import config_without_streaming +from .tools.tools import create_set_conversational_output_tool +from .types import AgentGraphState + +StateT = TypeVar("StateT", bound=AgentGraphState) + + +def create_conversational_output_node( + model: BaseChatModel, + agent_output_schema: type[BaseModel], +): + """Build the focused structured-output extraction node. + + Args: + model: The chat model to invoke for the extraction call. Reused from + the AGENT loop; rebinding is stateless. + agent_output_schema: The agent's declared output schema. Used to + construct the `set_conversational_output` tool with the + LLM-fillable fields (`uipath__agent_response_messages` stripped). + """ + set_conversational_output_tool = create_set_conversational_output_tool( + agent_output_schema + ) + # Disable streaming on this internal LLM call + non_streaming_model = model.model_copy(update={"disable_streaming": True}) + payload_handler = get_payload_handler(non_streaming_model) + binding_kwargs = payload_handler.get_tool_binding_kwargs( + tools=[set_conversational_output_tool], + tool_choice="any", + parallel_tool_calls=False, + ) + llm = non_streaming_model.bind_tools( + [set_conversational_output_tool], **binding_kwargs + ) + output_prompt = get_generate_output_prompt() + + async def conversational_output_node(state: StateT): + # The appended HumanMessage stays local to this LLM call — only the + # response is returned to state, so the framework instruction never + # enters the persisted conversation history. + messages = [*state.messages, HumanMessage(content=output_prompt)] + config = config_without_streaming(var_child_runnable_config.get(None)) + + try: + response = await llm.ainvoke(messages, config=config) + except UiPathAPIError as e: + # New LLM clients surface provider HTTP errors as a normalized UiPathAPIError directly. + raise_for_provider_http_error(e) + except UiPathError as e: + raise_for_llm_client_error(e) + raise + except Exception as e: + # Legacy in-repo clients (use_new_llm_clients=False) raise raw provider SDK exceptions. + # Normalize via as_uipath_error and apply the same mapping when the error is HTTP-shaped; non-HTTP errors propagate. + uipath_error = as_uipath_error(e) + if isinstance(uipath_error, UiPathAPIError): + raise_for_provider_http_error(uipath_error) + raise + + if not isinstance(response, AIMessage): + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.LLM_INVALID_RESPONSE, + title=f"Structured-output LLM returned {type(response).__name__} invalid response.", + detail=( + "The language model returned an unexpected response type. " + "If you are using a BYOM configuration, verify your model deployment.", + ), + category=UiPathErrorCategory.SYSTEM, + ) + + payload_handler.check_stop_reason(response) + + return {"messages": [response]} + + return conversational_output_node diff --git a/src/uipath_langchain/agent/react/router_conversational.py b/src/uipath_langchain/agent/react/router_conversational.py index ea3fdf611..54ef48937 100644 --- a/src/uipath_langchain/agent/react/router_conversational.py +++ b/src/uipath_langchain/agent/react/router_conversational.py @@ -21,31 +21,47 @@ logger = logging.getLogger(__name__) -def create_route_agent_conversational(valid_targets: Container[str] | None = None): +def create_route_agent_conversational( + valid_targets: Container[str] | None = None, + with_generate_output_node: bool = False, +): """Create a routing function for conversational agents. It routes between agent and tool calls until - the agent response has no tool calls, then it routes to the USER_MESSAGE_WAIT node which does an interrupt. + the agent response has no tool calls, then it routes to either the + GENERATE_CONVERSATIONAL_OUTPUT node (when the agent declares custom output + fields) or directly to TERMINATE. Args: - valid_targets: Allowed routing destinations + valid_targets: Allowed routing destinations. + with_generate_output_node: When True, route AGENT-without-tool-calls to the + GENERATE_CONVERSATIONAL_OUTPUT node so the structured output can + be extracted before TERMINATE. When False, route straight to + TERMINATE. Returns: Routing function for LangGraph conditional edges """ def route_agent_conversational( state: AgentGraphState, - ) -> str | Literal[AgentGraphNode.TERMINATE] | Literal[AgentGraphNode.AGENT]: - """Route after agent + ) -> ( + str + | Literal[AgentGraphNode.TERMINATE] + | Literal[AgentGraphNode.AGENT] + | Literal[AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT] + ): + """Route after agent. Routing logic: - 3. If tool calls, route to specific tool nodes (return list of tool names) - 4. If no tool calls, route to user message wait node - - Returns: - - str: Tool node name for sequential execution - - AgentGraphNode.USER_MESSAGE_WAIT: When there are no tool calls + - If the latest AIMessage has tool calls + - If pending tools, route to the next pending tool node. + - Otherwise: route to AGENT as all tool calls completed. + - Otherwise: + - If schema declares custom output fields: route to + GENERATE_CONVERSATIONAL_OUTPUT to generate the output fields. + - Otherwise: route straight to TERMINATE. Raises: - AgentNodeRoutingException: When encountering unexpected state (empty messages, non-AIMessage, or excessive completions) + AgentRuntimeError: ROUTING_ERROR when state has no AIMessage, or + when a routed tool name is not in `valid_targets`. """ last_message = find_latest_ai_message(state.messages) if last_message is None: @@ -77,6 +93,10 @@ def route_agent_conversational( return current_tool_name else: - return AgentGraphNode.TERMINATE + return ( + AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT + if with_generate_output_node + else AgentGraphNode.TERMINATE + ) return route_agent_conversational diff --git a/src/uipath_langchain/agent/react/terminate_node.py b/src/uipath_langchain/agent/react/terminate_node.py index 092bf72a1..68222b7aa 100644 --- a/src/uipath_langchain/agent/react/terminate_node.py +++ b/src/uipath_langchain/agent/react/terminate_node.py @@ -6,12 +6,17 @@ from langchain_core.messages import AIMessage from pydantic import BaseModel, ValidationError -from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL +from uipath.agent.react import ( + END_EXECUTION_TOOL, + RAISE_ERROR_TOOL, + SET_CONVERSATIONAL_OUTPUT_TOOL, +) from uipath.core.chat import UiPathConversationMessageData from uipath.runtime.errors import UiPathErrorCategory from ...runtime.messages import UiPathChatMessagesMapper from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode +from .constants import UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD from .types import AgentGraphState @@ -48,9 +53,12 @@ def _handle_raise_error(args: dict[str, Any]) -> NoReturn: def _handle_end_conversational( - state: AgentGraphState, response_schema: type[BaseModel] | None + state: AgentGraphState, + response_schema: type[BaseModel] | None, + with_conversational_output_node: bool = False, ) -> dict[str, Any]: - """Handle conversational agent termination by returning converted messages.""" + """Handle conversational agent termination by returning converted messages with optional structured output fields.""" + if state.inner_state.initial_message_count is None: raise AgentRuntimeError( code=AgentRuntimeErrorCode.STATE_ERROR, @@ -68,34 +76,86 @@ def _handle_end_conversational( ) initial_count = state.inner_state.initial_message_count - new_messages = state.messages[initial_count:] + + custom_output_fields: dict[str, Any] = {} + if with_conversational_output_node: + # with_conversational_output_node means that a node before TERMINATE + # produces an AIMessage carrying a `set_conversational_output` tool call. + # Extract the custom-field args from that call, and strip its AIMessage from message-history. + last_ai_message = state.messages[-1] if state.messages else None + + if not isinstance(last_ai_message, AIMessage): + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.STATE_ERROR, + title="Expected last message to be an AIMessage for conversational agent termination.", + detail=( + "Last message in state is expected to be an AIMessage containing a `set_conversational_output` tool call." + ), + category=UiPathErrorCategory.SYSTEM, + ) + + set_output_call = next( + ( + tc + for tc in (last_ai_message.tool_calls or []) + if tc["name"] == SET_CONVERSATIONAL_OUTPUT_TOOL.name + ), + None, + ) + if set_output_call is None: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.STATE_ERROR, + title="No set_conversational_output tool call found.", + detail=( + "The conversational output node was expected to produce a set_conversational_output tool call " + "in the last AIMessage, but none was found." + ), + category=UiPathErrorCategory.SYSTEM, + ) + + custom_output_fields = dict(set_output_call["args"]) + new_conversation_messages = state.messages[initial_count:-1] + else: + new_conversation_messages = state.messages[initial_count:] converted_messages: list[UiPathConversationMessageData] = [] # For the agent-output messages, don't include tool-results. Just include agent's LLM outputs and tool-calls + inputs. # This is primarily since evaluations don't check for tool-results; this output represents the agent's actual choices rather than tool-results. - if new_messages: + if new_conversation_messages: converted_messages = ( UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( - messages=new_messages, include_tool_results=False + messages=new_conversation_messages, include_tool_results=False ) ) - output = { - "uipath__agent_response_messages": [ + output: dict[str, Any] = { + **custom_output_fields, + UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD: [ msg.model_dump(by_alias=True) for msg in converted_messages - ] + ], } - validated = response_schema.model_validate(output) + try: + validated = response_schema.model_validate(output) + except ValidationError as e: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.OUTPUT_VALIDATION_ERROR, + title="Conversational agent output did not match the expected schema", + detail=( + "The conversational agent's output does not satisfy the configured output schema. " + f"Verify the output, and adjust the schema or the prompt. Details:\n{e}" + ), + category=UiPathErrorCategory.USER, + ) from e # Dump with exclude_none to prevent UiPathConversation... fields with None values from being outputted (e.g. UiPathConversationContentPartData.isTranscript). - # May need to revisit if other output fields are added for conversational agents, where we want nulls outputted. return validated.model_dump(by_alias=True, exclude_none=True) def create_terminate_node( response_schema: type[BaseModel] | None = None, is_conversational: bool = False, + with_conversational_output_node: bool = False, ): """Handles Agent Graph termination for multiple sources and output or error propagation to Orchestrator. @@ -107,7 +167,9 @@ def create_terminate_node( def terminate_node(state: AgentGraphState): if is_conversational: - return _handle_end_conversational(state, response_schema) + return _handle_end_conversational( + state, response_schema, with_conversational_output_node + ) else: last_message = state.messages[-1] if not isinstance(last_message, AIMessage): diff --git a/src/uipath_langchain/agent/react/tools/tools.py b/src/uipath_langchain/agent/react/tools/tools.py index 710cdd9fe..cdb722124 100644 --- a/src/uipath_langchain/agent/react/tools/tools.py +++ b/src/uipath_langchain/agent/react/tools/tools.py @@ -7,8 +7,11 @@ from uipath.agent.react import ( END_EXECUTION_TOOL, RAISE_ERROR_TOOL, + SET_CONVERSATIONAL_OUTPUT_TOOL, ) +from ..utils import build_conversational_output_args_schema + def create_end_execution_tool( agent_output_schema: type[BaseModel] | None = None, @@ -41,6 +44,24 @@ async def raise_error_fn(**kwargs: Any) -> dict[str, Any]: ) +def create_set_conversational_output_tool( + agent_output_schema: type[BaseModel], +) -> StructuredTool: + """Called by conversational-agents at the end of the loop when custom-output + fields are declared. Never executed — args are extracted and intercepted during termination.""" + input_schema = build_conversational_output_args_schema(agent_output_schema) + + async def set_conversational_output_fn(**kwargs: Any) -> dict[str, Any]: + return kwargs + + return StructuredTool( + name=SET_CONVERSATIONAL_OUTPUT_TOOL.name, + description=SET_CONVERSATIONAL_OUTPUT_TOOL.description, + args_schema=input_schema, + coroutine=set_conversational_output_fn, + ) + + def create_flow_control_tools( agent_output_schema: type[BaseModel] | None = None, ) -> list[BaseTool]: diff --git a/src/uipath_langchain/agent/react/types.py b/src/uipath_langchain/agent/react/types.py index 2801bc51c..4dda11f6d 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -4,7 +4,11 @@ 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 +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 ( @@ -12,7 +16,11 @@ merge_objects, ) -FLOW_CONTROL_TOOLS = [END_EXECUTION_TOOL.name, RAISE_ERROR_TOOL.name] +FLOW_CONTROL_TOOLS = [ + END_EXECUTION_TOOL.name, + RAISE_ERROR_TOOL.name, + SET_CONVERSATIONAL_OUTPUT_TOOL.name, +] class InnerAgentGraphState(BaseModel): @@ -56,6 +64,7 @@ class AgentGraphNode(StrEnum): AGENT = "agent" LLM = "llm" TOOLS = "tools" + GENERATE_CONVERSATIONAL_OUTPUT = "generate-conversational-output" TERMINATE = "terminate" GUARDED_TERMINATE = "guarded-terminate" MEMORY_RECALL = "memory_recall" diff --git a/src/uipath_langchain/agent/react/utils.py b/src/uipath_langchain/agent/react/utils.py index 26552b0cc..e2fa6d2c7 100644 --- a/src/uipath_langchain/agent/react/utils.py +++ b/src/uipath_langchain/agent/react/utils.py @@ -4,6 +4,7 @@ from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, ToolMessage from pydantic import BaseModel +from pydantic import create_model as pydantic_create_model from uipath.agent.react import END_EXECUTION_TOOL from uipath.runtime.errors import UiPathErrorCategory @@ -11,6 +12,9 @@ AgentRuntimeError, AgentRuntimeErrorCode, ) +from uipath_langchain.agent.react.constants import ( + UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD, +) from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model from uipath_langchain.agent.react.types import ( AgentGraphState, @@ -38,6 +42,43 @@ def resolve_output_model( return END_EXECUTION_TOOL.args_schema +def has_custom_conversational_output_fields( + output_schema: type[BaseModel] | None, +) -> bool: + """Return True iff the schema declares fields beyond `uipath__agent_response_messages`. + + Used to decide whether a conversational agent needs the + `GENERATE_CONVERSATIONAL_OUTPUT` node inserted between AGENT and TERMINATE. + """ + if output_schema is None: + return False + return any( + name != UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD + for name in output_schema.model_fields + ) + + +def build_conversational_output_args_schema( + schema: type[BaseModel], +) -> type[BaseModel]: + """Strip `uipath__agent_response_messages` from the schema. + + That field is populated from message history at termination — not by the + LLM. Stripping it produces the args schema the LLM should actually fill + via `set_conversational_output`. + """ + custom_fields: dict[str, Any] = { + name: (field.annotation, field) + for name, field in schema.model_fields.items() + if name != UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD + } + return pydantic_create_model( + f"{schema.__name__}SetConversationalOutputArgs", + __base__=BaseModel, + **custom_fields, + ) + + def extract_input_data_from_state( state: BaseModel | dict[str, Any], input_model: type[BaseModel], diff --git a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py index 05703d5a2..e42b1b340 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py @@ -15,7 +15,6 @@ ) from langchain_core.runnables.config import RunnableConfig, var_child_runnable_config from langchain_core.tools import StructuredTool -from langgraph.constants import TAG_NOSTREAM from opentelemetry import trace as otel_trace from uipath.agent.models.agent import ( AgentInternalToolResourceConfig, @@ -48,7 +47,10 @@ from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( StructuredToolWithArgumentProperties, ) -from uipath_langchain.agent.tools.utils import sanitize_tool_name +from uipath_langchain.agent.tools.utils import ( + config_without_streaming, + sanitize_tool_name, +) from uipath_langchain.chat.helpers import ( append_content_blocks_to_message, extract_text_content, @@ -139,15 +141,6 @@ def _llm_call_attachments_payload(files: list[FileInfo]) -> str | None: return json.dumps([att.model_dump(by_alias=True) for att in attachments]) -def _config_without_streaming(config: RunnableConfig | None) -> RunnableConfig: - """Tag config with TAG_NOSTREAM so LangGraph's StreamMessagesHandler skips - this LLM call — prevents its response from leaking into the conversation - stream as a visible content part.""" - new_config = cast(RunnableConfig, dict(config) if config else {}) - new_config["tags"] = [*(new_config.get("tags") or []), TAG_NOSTREAM] - return new_config - - def _config_with_llm_call_attachments( config: RunnableConfig | None, files: list[FileInfo] ) -> RunnableConfig | None: @@ -256,8 +249,8 @@ def create_analyze_file_tool( input_model = create_model(resource.input_schema) output_model = create_model(resource.output_schema) - # Disable streaming so for conversational loops, the internal LLM call doesn't leak - # AIMessageChunk events into the graph stream. + # Explicitly disable streaming - for conversational, no streaming is needed as this + # internal tool-call does not produce streamed conversation events. non_streaming_llm = llm.model_copy(update={"disable_streaming": True}) @mockable( @@ -337,7 +330,7 @@ async def tool_fn(**kwargs: Any): cast(AnyMessage, human_message_with_files), ] config = var_child_runnable_config.get(None) - config = _config_without_streaming(config) + config = config_without_streaming(config) config = _config_with_llm_call_attachments(config, files) result = await non_streaming_llm.ainvoke(messages, config=config) diff --git a/src/uipath_langchain/agent/tools/utils.py b/src/uipath_langchain/agent/tools/utils.py index da6609611..e26c87c21 100644 --- a/src/uipath_langchain/agent/tools/utils.py +++ b/src/uipath_langchain/agent/tools/utils.py @@ -1,8 +1,10 @@ """Tool-related utility functions.""" import re -from typing import Any +from typing import Any, cast +from langchain_core.runnables.config import RunnableConfig +from langgraph.constants import TAG_NOSTREAM from uipath.agent.models.agent import TaskTitle, TextBuilderTaskTitle from uipath.agent.utils.text_tokens import build_string_from_tokens @@ -45,6 +47,19 @@ def sanitize_dict_for_serialization(args: dict[str, Any]) -> dict[str, Any]: return converted_args +def config_without_streaming(config: RunnableConfig | None) -> RunnableConfig: + """Return a RunnableConfig with `TAG_NOSTREAM` appended to its tags, so + LangGraph's StreamMessagesHandler skips this LLM call. + + Used for internal LLM calls (e.g. structured-output extraction, + attachment analysis) to prevent their responses from leaking into + the conversation stream for conversational agents. + """ + new_config = cast(RunnableConfig, dict(config) if config else {}) + new_config["tags"] = [*(new_config.get("tags") or []), TAG_NOSTREAM] + return new_config + + def resolve_task_title( task_title: TaskTitle | str | None, agent_input: dict[str, Any], diff --git a/tests/agent/react/test_conversational_output_node.py b/tests/agent/react/test_conversational_output_node.py new file mode 100644 index 000000000..735c89453 --- /dev/null +++ b/tests/agent/react/test_conversational_output_node.py @@ -0,0 +1,143 @@ +"""Tests for the GENERATE_CONVERSATIONAL_OUTPUT node.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langgraph.constants import TAG_NOSTREAM +from pydantic import BaseModel, Field +from uipath.agent.react import SET_CONVERSATIONAL_OUTPUT_TOOL + +from uipath_langchain.agent.react.conversational_output_node import ( + create_conversational_output_node, +) +from uipath_langchain.agent.react.types import AgentGraphState, InnerAgentGraphState + + +class _OutputSchema(BaseModel): + uipath__agent_response_messages: list[Any] = Field(default_factory=list) + handoff_target: str = "none" + ready_for_handoff: bool = False + + +def _make_state(messages: list[Any]) -> AgentGraphState: + return AgentGraphState( + messages=messages, + inner_state=InnerAgentGraphState(initial_message_count=1), + ) + + +def _make_mock_model() -> tuple[MagicMock, MagicMock, MagicMock]: + """Return a chat model whose + `model_copy(...).bind_tools(...).ainvoke(...)` chain records the messages + and returns a canned AIMessage with the set_conversational_output tool + call. + + The TAG_NOSTREAM tag is supplied at call time via the `config=` kwarg + (mirroring the analyze-files-tool pattern), so `bind_tools` returns the + runnable that's invoked directly. + + Returns (model, non_streaming, bound). + """ + response = AIMessage( + content="", + tool_calls=[ + { + "name": SET_CONVERSATIONAL_OUTPUT_TOOL.name, + "args": {"handoff_target": "billing", "ready_for_handoff": True}, + "id": "call_1", + } + ], + ) + + bound = MagicMock() + bound.ainvoke = AsyncMock(return_value=response) + + non_streaming = MagicMock() + non_streaming.bind_tools = MagicMock(return_value=bound) + + model = MagicMock() + model.model_copy = MagicMock(return_value=non_streaming) + return model, non_streaming, bound + + +class TestCreateConversationalOutputNode: + @pytest.mark.asyncio + async def test_returns_response_with_tool_call(self): + model, _non_streaming, _bound = _make_mock_model() + node = create_conversational_output_node(model, _OutputSchema) + + state = _make_state([SystemMessage(content="sys"), HumanMessage(content="hi")]) + result = await node(state) + + assert len(result["messages"]) == 1 + ai = result["messages"][0] + assert isinstance(ai, AIMessage) + assert ai.tool_calls[0]["name"] == SET_CONVERSATIONAL_OUTPUT_TOOL.name + assert ai.tool_calls[0]["args"]["handoff_target"] == "billing" + + @pytest.mark.asyncio + async def test_appends_human_instruction_for_llm_call(self): + """The framework instruction is appended as a HumanMessage for the LLM + call, but never returned to state.""" + model, _non_streaming, bound = _make_mock_model() + node = create_conversational_output_node(model, _OutputSchema) + + agent_reply = AIMessage(content="here's my reply") + state = _make_state( + [ + SystemMessage(content="sys"), + HumanMessage(content="hi"), + agent_reply, + ] + ) + result = await node(state) + + # The LLM was invoked with state.messages PLUS one extra HumanMessage. + bound.ainvoke.assert_awaited_once() + invoked_messages = bound.ainvoke.await_args.args[0] + assert len(invoked_messages) == len(state.messages) + 1 + assert isinstance(invoked_messages[-1], HumanMessage) + assert "set_conversational_output" in invoked_messages[-1].content + + # The instruction was NOT persisted into the returned messages. + assert len(result["messages"]) == 1 + + @pytest.mark.asyncio + async def test_binds_only_set_conversational_output_tool(self): + model, non_streaming, _bound = _make_mock_model() + create_conversational_output_node(model, _OutputSchema) + + non_streaming.bind_tools.assert_called_once() + tools_arg = non_streaming.bind_tools.call_args.args[0] + assert len(tools_arg) == 1 + assert tools_arg[0].name == SET_CONVERSATIONAL_OUTPUT_TOOL.name + + @pytest.mark.asyncio + async def test_ainvoke_config_includes_tag_nostream(self): + """TAG_NOSTREAM is added to the per-call config (mirroring the + analyze-files-tool pattern) — not via .with_config at construction.""" + model, _non_streaming, bound = _make_mock_model() + node = create_conversational_output_node(model, _OutputSchema) + + state = _make_state([SystemMessage(content="sys"), HumanMessage(content="hi")]) + await node(state) + + bound.ainvoke.assert_awaited_once() + config_arg = bound.ainvoke.await_args.kwargs.get("config") + assert config_arg is not None + assert TAG_NOSTREAM in config_arg.get("tags", []) + + @pytest.mark.asyncio + async def test_disables_streaming_on_internal_llm(self): + """The node copies the model with `disable_streaming=True` so the + underlying provider call doesn't stream — same pattern used by + analyze-files.""" + model, _non_streaming, _bound = _make_mock_model() + create_conversational_output_node(model, _OutputSchema) + + model.model_copy.assert_called_once() + update_arg = model.model_copy.call_args.kwargs.get("update") + assert update_arg is not None + assert update_arg.get("disable_streaming") is True diff --git a/tests/agent/react/test_create_agent.py b/tests/agent/react/test_create_agent.py index fb611c8d6..7318dc7da 100644 --- a/tests/agent/react/test_create_agent.py +++ b/tests/agent/react/test_create_agent.py @@ -9,6 +9,7 @@ from langchain_core.runnables.graph import Edge from langchain_core.tools import BaseTool from langgraph.graph import StateGraph +from pydantic import BaseModel, Field from uipath_langchain.agent.react.agent import create_agent from uipath_langchain.agent.react.init_node import create_init_node @@ -156,6 +157,7 @@ def test_autonomous_agent_with_tools( mock_create_terminate_node.assert_called_once_with( None, # output schema False, # is_conversational + False, # with_conversational_output_node ) @_patch("create_route_agent_conversational") @@ -252,6 +254,7 @@ def test_conversational_agent_with_tools( mock_create_terminate_node.assert_called_once_with( None, # output schema True, # is_conversational + False, # with_conversational_output_node (no custom output schema) ) @_patch("create_route_agent_conversational") @@ -293,3 +296,74 @@ def test_conversational_agent_without_tools( mock_route_agent.assert_not_called() mock_route_agent_conversational.assert_called_once() mock_create_flow_control_tools.assert_not_called() + + +class _ConversationalOutputSchemaWithCustomFields(BaseModel): + """A conversational agent's `outputSchema` that exercises the new node.""" + + uipath__agent_response_messages: list[Any] = Field(default_factory=list) + handoff_target: str = "none" + ready_for_handoff: bool = False + + +class _ConversationalOutputSchemaNoCustomFields(BaseModel): + uipath__agent_response_messages: list[Any] = Field(default_factory=list) + + +class TestCreateAgentGenerateConversationalOutput: + """Topology assertions for the GENERATE_CONVERSATIONAL_OUTPUT node.""" + + @pytest.fixture + def mock_model(self): + return _make_mock_model() + + @pytest.fixture + def mock_tool_a(self): + return _make_mock_tool(mock_tool_a_name) + + @pytest.fixture + def messages(self): + return [SystemMessage(content="You are a helpful assistant.")] + + def test_node_added_when_conversational_with_custom_output( + self, mock_model, mock_tool_a, messages + ): + result: StateGraph[Any] = create_agent( + mock_model, + [mock_tool_a], + messages, + output_schema=_ConversationalOutputSchemaWithCustomFields, + config=AgentGraphConfig(is_conversational=True), + ) + graph = result.compile().get_graph() + assert AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT in graph.nodes + assert Edge( + AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT, + AgentGraphNode.TERMINATE, + ) in set(graph.edges) + + def test_node_absent_when_conversational_without_custom_output( + self, mock_model, mock_tool_a, messages + ): + result: StateGraph[Any] = create_agent( + mock_model, + [mock_tool_a], + messages, + output_schema=_ConversationalOutputSchemaNoCustomFields, + config=AgentGraphConfig(is_conversational=True), + ) + graph = result.compile().get_graph() + assert AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT not in graph.nodes + + def test_node_absent_for_autonomous_agents(self, mock_model, mock_tool_a, messages): + """Even when output_schema has custom fields, autonomous agents do not + get the new node — it is conversational-only.""" + result: StateGraph[Any] = create_agent( + mock_model, + [mock_tool_a], + messages, + output_schema=_ConversationalOutputSchemaWithCustomFields, + config=AgentGraphConfig(is_conversational=False), + ) + graph = result.compile().get_graph() + assert AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT not in graph.nodes diff --git a/tests/agent/react/test_flow_control_tools.py b/tests/agent/react/test_flow_control_tools.py new file mode 100644 index 000000000..5088dd344 --- /dev/null +++ b/tests/agent/react/test_flow_control_tools.py @@ -0,0 +1,60 @@ +"""Tests for flow-control tool factories in agent.react.tools.tools.""" + +from typing import Any + +from pydantic import BaseModel, Field +from uipath.agent.react import SET_CONVERSATIONAL_OUTPUT_TOOL + +from uipath_langchain.agent.react.tools.tools import ( + create_set_conversational_output_tool, +) + + +class _MergedOutputSchema(BaseModel): + """Represents the schema as it reaches the LangChain tool factory. + + The user's raw agent.json outputSchema contains only the custom fields + (`handoff_target`, etc.); `uipath__agent_response_messages` is merged in + by `uipath-agents-python`'s factory before the schema arrives here. The + factory's job is to strip that field back out for the tool's args_schema. + """ + + uipath__agent_response_messages: list[Any] = Field(default_factory=list) + handoff_target: str = "none" + ready_for_handoff: bool = False + urgency: str | None = None + + +class TestCreateSetConversationalOutputTool: + def test_tool_name_matches_registry(self): + tool = create_set_conversational_output_tool(_MergedOutputSchema) + assert tool.name == SET_CONVERSATIONAL_OUTPUT_TOOL.name + + def test_tool_description_matches_registry(self): + tool = create_set_conversational_output_tool(_MergedOutputSchema) + assert tool.description == SET_CONVERSATIONAL_OUTPUT_TOOL.description + + def test_args_schema_strips_response_messages_field(self): + tool = create_set_conversational_output_tool(_MergedOutputSchema) + args_schema = tool.args_schema + assert isinstance(args_schema, type) and issubclass(args_schema, BaseModel) + + fields = args_schema.model_fields + assert "uipath__agent_response_messages" not in fields + assert "handoff_target" in fields + assert "ready_for_handoff" in fields + assert "urgency" in fields + + def test_args_schema_accepts_partial_payload(self): + """Validates the "N/A"-style placeholder workflow — the schema must + accept partial payloads where optional fields are omitted.""" + tool = create_set_conversational_output_tool(_MergedOutputSchema) + args_schema = tool.args_schema + assert isinstance(args_schema, type) and issubclass(args_schema, BaseModel) + + dumped = args_schema.model_validate( + {"handoff_target": "billing", "ready_for_handoff": True} + ).model_dump() + assert dumped["handoff_target"] == "billing" + assert dumped["ready_for_handoff"] is True + assert dumped["urgency"] is None diff --git a/tests/agent/react/test_router_conversational.py b/tests/agent/react/test_router_conversational.py index 3a511a9ae..629986ea9 100644 --- a/tests/agent/react/test_router_conversational.py +++ b/tests/agent/react/test_router_conversational.py @@ -295,3 +295,66 @@ def test_default_valid_targets_skips_guard(self): state = AgentGraphState(messages=[HumanMessage(content="query"), ai_message]) assert route_func(state) == "unwired_tool" + + +class TestRouteAgentConversationalCustomOutput: + """Routing for conversational agents with custom output fields. + + When the schema declares fields beyond `uipath__agent_response_messages`, + a GENERATE_CONVERSATIONAL_OUTPUT node sits between AGENT and TERMINATE. + """ + + def test_routes_to_generate_conversational_output_when_custom_output(self): + """No tool calls + has_custom_output=True → GENERATE_CONVERSATIONAL_OUTPUT.""" + route_func = create_route_agent_conversational( + valid_targets=[ + AgentGraphNode.TERMINATE, + AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT, + ], + with_generate_output_node=True, + ) + ai_message = AIMessage(content="here is my reply", tool_calls=[]) + state = AgentGraphState(messages=[HumanMessage(content="hi"), ai_message]) + + assert route_func(state) == AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT + + def test_routes_to_terminate_when_no_custom_output(self): + """No tool calls + has_custom_output=False → TERMINATE (existing path).""" + route_func = create_route_agent_conversational( + valid_targets=[AgentGraphNode.TERMINATE], + with_generate_output_node=False, + ) + ai_message = AIMessage(content="here is my reply", tool_calls=[]) + state = AgentGraphState(messages=[HumanMessage(content="hi"), ai_message]) + + assert route_func(state) == AgentGraphNode.TERMINATE + + def test_has_custom_output_does_not_affect_tool_routing(self): + """has_custom_output=True must not change tool-routing behavior — the + new branch only fires when the AIMessage has no tool calls.""" + route_func = create_route_agent_conversational( + valid_targets=[ + "real_tool", + AgentGraphNode.TERMINATE, + AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT, + ], + with_generate_output_node=True, + ) + ai_message = AIMessage( + content="calling tool", + tool_calls=[{"name": "real_tool", "args": {}, "id": "call_1"}], + ) + state = AgentGraphState(messages=[HumanMessage(content="query"), ai_message]) + + assert route_func(state) == "real_tool" + + def test_has_custom_output_default_false_preserves_legacy_routing(self): + """The new parameter defaults to False so existing callers that don't + opt in keep routing to TERMINATE on AGENT-without-tool-calls.""" + route_func = create_route_agent_conversational( + valid_targets=[AgentGraphNode.TERMINATE] + ) + ai_message = AIMessage(content="reply", tool_calls=[]) + state = AgentGraphState(messages=[HumanMessage(content="hi"), ai_message]) + + assert route_func(state) == AgentGraphNode.TERMINATE diff --git a/tests/agent/react/test_terminate_node.py b/tests/agent/react/test_terminate_node.py index 59cbb5f7a..c2fbfca70 100644 --- a/tests/agent/react/test_terminate_node.py +++ b/tests/agent/react/test_terminate_node.py @@ -5,7 +5,11 @@ import pytest from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from pydantic import BaseModel -from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL +from uipath.agent.react import ( + END_EXECUTION_TOOL, + RAISE_ERROR_TOOL, + SET_CONVERSATIONAL_OUTPUT_TOOL, +) from uipath.core.chat import UiPathConversationMessageData from uipath.runtime.errors import UiPathErrorCategory @@ -173,38 +177,116 @@ class ResponseSchema(BaseModel): assert messages[0]["toolCalls"][0]["name"] == "test_tool" assert messages[0]["toolCalls"][0]["input"] == {"param": "value"} - def test_conversational_ignores_end_execution_tool(self): - """Conversational mode should ignore END_EXECUTION tool calls.""" + def test_conversational_extracts_custom_output_fields(self): + """When the response schema has custom fields, the terminate node + reads them from the last AIMessage's set_conversational_output tool + call and merges them with the response_messages.""" class ResponseSchema(BaseModel): uipath__agent_response_messages: list[UiPathConversationMessageData] + handoff_target: str + ready_for_handoff: bool terminate_node = create_terminate_node( - response_schema=ResponseSchema, is_conversational=True + response_schema=ResponseSchema, + is_conversational=True, + with_conversational_output_node=True, ) - ai_message = AIMessage( - content="Done", + + agent_reply = AIMessage(content="Sure, I'll route you to billing.") + set_output_msg = AIMessage( + content="", tool_calls=[ { - "name": END_EXECUTION_TOOL.name, - "args": {"result": "completed"}, + "name": SET_CONVERSATIONAL_OUTPUT_TOOL.name, + "args": { + "handoff_target": "billing", + "ready_for_handoff": True, + }, "id": "call_1", } ], ) state = MockAgentGraphState( - messages=[HumanMessage(content="Initial"), ai_message], + messages=[ + HumanMessage(content="I have a billing issue"), + agent_reply, + set_output_msg, + ], inner_state=MockInnerState(initial_message_count=1), ) - # Should process normally, not treat as special result = terminate_node(state) - assert "uipath__agent_response_messages" in result + assert result["handoff_target"] == "billing" + assert result["ready_for_handoff"] is True + # The conversational reply is preserved; the set_conversational_output + # AIMessage is dropped by the flow-control filter in the converter. messages = result["uipath__agent_response_messages"] assert len(messages) == 1 - assert messages[0]["role"] == "assistant" - assert "Done" in str(messages[0]["contentParts"][0]["data"]) + assert "Sure, I'll route you to billing." in str( + messages[0]["contentParts"][0]["data"] + ) + + def test_conversational_missing_set_output_call_falls_through_to_schema_validation( + self, + ) -> None: + """When the schema has required custom fields but the last AIMessage + doesn't carry a set_conversational_output tool call, terminate does + NOT raise a routing error — it best-effort passes an empty custom + dict through, and the surrounding schema validation raises a clear + OUTPUT_VALIDATION_ERROR referencing the missing required field.""" + + class ResponseSchema(BaseModel): + uipath__agent_response_messages: list[UiPathConversationMessageData] + handoff_target: str + + terminate_node = create_terminate_node( + response_schema=ResponseSchema, is_conversational=True + ) + + # Last AIMessage has no tool calls — feature could be off, or the + # extraction model didn't call the tool. + agent_reply = AIMessage(content="Some reply") + state = MockAgentGraphState( + messages=[HumanMessage(content="hi"), agent_reply], + inner_state=MockInnerState(initial_message_count=1), + ) + + with pytest.raises(AgentRuntimeError) as exc_info: + terminate_node(state) + + # The error is a schema-validation error surfaced by Pydantic, not + # a routing error — the message references the missing required + # field on the response schema, not `set_conversational_output`. + assert exc_info.value.error_info.code == AgentRuntimeError.full_code( + AgentRuntimeErrorCode.OUTPUT_VALIDATION_ERROR + ) + assert "handoff_target" in exc_info.value.error_info.detail + + def test_conversational_no_custom_fields_no_set_output_call_succeeds(self): + """When the schema has no custom fields and no set_conversational_output + tool call was made, terminate succeeds without extracting anything.""" + + class ResponseSchema(BaseModel): + uipath__agent_response_messages: list[UiPathConversationMessageData] + + terminate_node = create_terminate_node( + response_schema=ResponseSchema, is_conversational=True + ) + agent_reply = AIMessage(content="Some reply") + state = MockAgentGraphState( + messages=[HumanMessage(content="hi"), agent_reply], + inner_state=MockInnerState(initial_message_count=1), + ) + + result = terminate_node(state) + + # The final reply is preserved — no last-message slicing since no + # set_conversational_output call was found. + messages = result["uipath__agent_response_messages"] + assert len(messages) == 1 + assert "Some reply" in str(messages[0]["contentParts"][0]["data"]) class TestTerminateNodeNonConversational: diff --git a/tests/agent/react/test_utils.py b/tests/agent/react/test_utils.py index 44ae796ce..c3142ab59 100644 --- a/tests/agent/react/test_utils.py +++ b/tests/agent/react/test_utils.py @@ -1,16 +1,21 @@ """Tests for ReAct agent utilities.""" +from typing import Any + import pytest from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage +from pydantic import BaseModel, Field from uipath_langchain.agent.exceptions import ( AgentRuntimeError, AgentRuntimeErrorCode, ) from uipath_langchain.agent.react.utils import ( + build_conversational_output_args_schema, count_consecutive_thinking_messages, extract_current_tool_call_index, find_latest_ai_message, + has_custom_conversational_output_fields, ) @@ -401,3 +406,74 @@ def test_unexpected_message_type(self): assert exc_info.value.error_info.code == AgentRuntimeError.full_code( AgentRuntimeErrorCode.STATE_ERROR ) + + +class TestHasCustomConversationalOutputFields: + """Tests for has_custom_conversational_output_fields helper.""" + + def test_returns_false_for_none_schema(self): + assert has_custom_conversational_output_fields(None) is False + + def test_returns_false_when_only_response_messages_field(self): + class ResponseOnly(BaseModel): + uipath__agent_response_messages: list[Any] = Field(default_factory=list) + + assert has_custom_conversational_output_fields(ResponseOnly) is False + + def test_returns_true_when_extra_field_exists(self): + class WithExtras(BaseModel): + uipath__agent_response_messages: list[Any] = Field(default_factory=list) + handoff_target: str = "none" + + assert has_custom_conversational_output_fields(WithExtras) is True + + def test_returns_true_when_no_response_messages_field(self): + """Defensive: a schema with only custom fields still requires the new + node (so set_conversational_output can populate them).""" + + class CustomOnly(BaseModel): + web_searched: str = "no" + + assert has_custom_conversational_output_fields(CustomOnly) is True + + +class TestBuildConversationalOutputArgsSchema: + """Tests for build_conversational_output_args_schema helper.""" + + def test_strips_response_messages_field(self): + class FullSchema(BaseModel): + uipath__agent_response_messages: list[Any] = Field(default_factory=list) + handoff_target: str = "none" + ready_for_handoff: bool = False + + args_schema = build_conversational_output_args_schema(FullSchema) + assert "uipath__agent_response_messages" not in args_schema.model_fields + assert "handoff_target" in args_schema.model_fields + assert "ready_for_handoff" in args_schema.model_fields + + def test_preserves_field_types_and_defaults(self): + class FullSchema(BaseModel): + uipath__agent_response_messages: list[Any] = Field(default_factory=list) + urgency: str = "low" + + args_schema = build_conversational_output_args_schema(FullSchema) + instance = args_schema.model_validate({"urgency": "high"}) + assert instance.model_dump()["urgency"] == "high" + + def test_no_response_messages_field_passes_through(self): + """When the field isn't present, the result mirrors the input schema.""" + + class CustomOnly(BaseModel): + web_searched: str = "no" + + args_schema = build_conversational_output_args_schema(CustomOnly) + assert "web_searched" in args_schema.model_fields + + def test_generated_schema_name_is_distinct(self): + class FullSchema(BaseModel): + uipath__agent_response_messages: list[Any] = Field(default_factory=list) + web_searched: str = "no" + + args_schema = build_conversational_output_args_schema(FullSchema) + assert args_schema.__name__ != FullSchema.__name__ + assert "SetConversationalOutputArgs" in args_schema.__name__ diff --git a/tests/agent/tools/internal_tools/test_analyze_files_tool.py b/tests/agent/tools/internal_tools/test_analyze_files_tool.py index 0d48501de..bd61cb4b3 100644 --- a/tests/agent/tools/internal_tools/test_analyze_files_tool.py +++ b/tests/agent/tools/internal_tools/test_analyze_files_tool.py @@ -27,7 +27,6 @@ ANALYZE_FILES_SYSTEM_MESSAGE, LLM_CALL_ATTACHMENTS_METADATA_KEY, _config_with_llm_call_attachments, - _config_without_streaming, _is_pii_scope_for_files, _resolve_job_attachment_arguments, create_analyze_file_tool, @@ -1055,31 +1054,6 @@ def test_is_case_sensitive(self) -> None: ) -class TestConfigWithoutStreaming: - """Tests for _config_without_streaming — ensures TAG_NOSTREAM is injected.""" - - def test_adds_nostream_tag_to_empty_config(self) -> None: - result = _config_without_streaming(None) - assert TAG_NOSTREAM in result["tags"] - - def test_adds_nostream_tag_to_existing_config(self) -> None: - config: RunnableConfig = {"tags": ["existing_tag"]} - result = _config_without_streaming(config) - assert "existing_tag" in result["tags"] - assert TAG_NOSTREAM in result["tags"] - - def test_preserves_other_config_keys(self) -> None: - config: RunnableConfig = {"metadata": {"key": "value"}, "tags": ["t"]} - result = _config_without_streaming(config) - assert result["metadata"] == {"key": "value"} - assert TAG_NOSTREAM in result["tags"] - - def test_handles_config_without_tags(self) -> None: - config: RunnableConfig = {"metadata": {"key": "value"}} - result = _config_without_streaming(config) - assert result["tags"] == [TAG_NOSTREAM] - - class TestConfigWithLlmCallAttachments: """The attachments payload travels to the llmCall span via langchain config metadata.""" diff --git a/tests/agent/tools/test_utils.py b/tests/agent/tools/test_utils.py index 02418513a..f98153f24 100644 --- a/tests/agent/tools/test_utils.py +++ b/tests/agent/tools/test_utils.py @@ -1,8 +1,11 @@ """Tests for tools/utils.py module.""" +from langchain_core.runnables.config import RunnableConfig +from langgraph.constants import TAG_NOSTREAM from pydantic import BaseModel from uipath_langchain.agent.tools.utils import ( + config_without_streaming, sanitize_dict_for_serialization, sanitize_tool_name, ) @@ -122,3 +125,29 @@ def __init__(self): result = sanitize_dict_for_serialization(input_dict) assert result["obj"] is obj + + +class TestConfigWithoutStreaming: + """Ensures TAG_NOSTREAM is injected into the runnable config so LangGraph + skips the LLM call's tokens in the messages stream.""" + + def test_adds_nostream_tag_to_empty_config(self) -> None: + result = config_without_streaming(None) + assert TAG_NOSTREAM in result["tags"] + + def test_adds_nostream_tag_to_existing_config(self) -> None: + config: RunnableConfig = {"tags": ["existing_tag"]} + result = config_without_streaming(config) + assert "existing_tag" in result["tags"] + assert TAG_NOSTREAM in result["tags"] + + def test_preserves_other_config_keys(self) -> None: + config: RunnableConfig = {"metadata": {"key": "value"}, "tags": ["t"]} + result = config_without_streaming(config) + assert result["metadata"] == {"key": "value"} + assert TAG_NOSTREAM in result["tags"] + + def test_handles_config_without_tags(self) -> None: + config: RunnableConfig = {"metadata": {"key": "value"}} + result = config_without_streaming(config) + assert result["tags"] == [TAG_NOSTREAM] diff --git a/uv.lock b/uv.lock index 6c477a85b..96b1775fa 100644 --- a/uv.lock +++ b/uv.lock @@ -4432,7 +4432,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.5" +version = "2.13.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -4456,28 +4456,28 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/e4/3fb853b93cdb79ede574566de4222630542e8da82aaefc51a2932a584ef0/uipath-2.13.5.tar.gz", hash = "sha256:fa7b5c37b4beb10b162778b59b449ef7158bd05420f75a2a5bfb084832d29035", size = 4497874, upload-time = "2026-07-07T17:14:45.421295Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/76/f57747721f7e6386bf277f34e4ccc5d34f1e58a8c31080a4ed449df8b614/uipath-2.13.7.tar.gz", hash = "sha256:379af967a9a302b3521938030ae41bf53c21a34a68aa6354416caf2b76cecb2f", size = 4500489, upload-time = "2026-07-08T15:57:51.3Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/7c/51400a789db89ee040b07c5a877297e1b9faca6add00988ce31f81ce1266/uipath-2.13.5-py3-none-any.whl", hash = "sha256:6705b6f9fcd50e0c3d8228312d47969ef382f7895b927ca2f3c6fd16c57267a1", size = 418209, upload-time = "2026-07-07T17:14:43.292295Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1b/9c4f095cb4e87eaded00ea9eb022a4ea51f663b961124e6dccb27bd7fc82/uipath-2.13.7-py3-none-any.whl", hash = "sha256:145881ae320b9d936252766f1cb575ed40601181b1f313b5816fc43653d92ad5", size = 418876, upload-time = "2026-07-08T15:57:49.254Z" }, ] [[package]] name = "uipath-core" -version = "0.5.29" +version = "0.5.30" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/bf/83e261295dd5d5137d2a17d455ee43d090decdb683654042f0f97bae74a5/uipath_core-0.5.29.tar.gz", hash = "sha256:f931d6ad866c3b70dc93e25e3d1cc9e484deb7061d61efe41f6ae094e5acafb2", size = 130862, upload-time = "2026-07-07T10:27:13.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/cc/a5acd1d4bbf3c8252a2c8ee3a57b584a790b8bfd86661cf2c236084875ac/uipath_core-0.5.30.tar.gz", hash = "sha256:746494d97a07fc557baff6dca341d53f28ffdd09109b666c19892092816c3335", size = 130833, upload-time = "2026-07-07T14:38:57.377Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/34/0b58685eacae7cfff0a2281348f33ea1f2559d91f3702f16c2b15ac4f504/uipath_core-0.5.29-py3-none-any.whl", hash = "sha256:fac1521f3963ff5787b63dc02d2e26417f298eef4bbc40a2851f857f24aa37cd", size = 55049, upload-time = "2026-07-07T10:27:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/a90a150141449a540be6b607dd01b6aa08823b3332531c54813ad627cfb6/uipath_core-0.5.30-py3-none-any.whl", hash = "sha256:b8c4f48a06a1c49504351bbfa42282e225af1a30467e93d4e2e2ea0e3a2f06f9", size = 55038, upload-time = "2026-07-07T14:38:55.979Z" }, ] [[package]] name = "uipath-langchain" -version = "0.14.2" +version = "0.14.3" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4555,7 +4555,7 @@ requires-dist = [ { name = "pillow", specifier = ">=12.1.1" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "uipath", specifier = ">=2.13.5,<2.14.0" }, + { name = "uipath", specifier = ">=2.13.7,<2.14.0" }, { name = "uipath-core", specifier = ">=0.5.29,<0.6.0" }, { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.16.1,<1.17.0" }, { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.16.1,<1.17.0" }, @@ -4659,23 +4659,23 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/6b/feaeeaa028ee94fedcd6ebd40d2068097c3bfcacf683c798e40b35293ee5/uipath_platform-0.2.4.tar.gz", hash = "sha256:d79c7c99d74c484006a4684efc62dbd2572ba7053447e9882c624f3b272e2367", size = 421692, upload-time = "2026-07-07T17:13:56.832163Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/6b/feaeeaa028ee94fedcd6ebd40d2068097c3bfcacf683c798e40b35293ee5/uipath_platform-0.2.4.tar.gz", hash = "sha256:d79c7c99d74c484006a4684efc62dbd2572ba7053447e9882c624f3b272e2367", size = 421692, upload-time = "2026-07-07T17:13:56.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/fc/fd8a88e50f99579483b8ead68912884d50505ecac8046491c8c1c1318e09/uipath_platform-0.2.4-py3-none-any.whl", hash = "sha256:a0736a60723e7c48a6efdbc42a64a933197205ca7c555a4f89988a50eb88a5c9", size = 277697, upload-time = "2026-07-07T17:13:55.444420Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/fd8a88e50f99579483b8ead68912884d50505ecac8046491c8c1c1318e09/uipath_platform-0.2.4-py3-none-any.whl", hash = "sha256:a0736a60723e7c48a6efdbc42a64a933197205ca7c555a4f89988a50eb88a5c9", size = 277697, upload-time = "2026-07-07T17:13:55.444Z" }, ] [[package]] name = "uipath-runtime" -version = "0.12.1" +version = "0.12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, { name = "uipath-core" }, { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/0b/6d6bfd912c2ad6978f58646becf3647430ccb087008a8c78dbc33b0b101e/uipath_runtime-0.12.1.tar.gz", hash = "sha256:a63ce739ea53da8479bb0314477f89d42724045ecbd7db3f2a3f0a09ab17a755", size = 235372, upload-time = "2026-07-06T11:14:51.947Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/eb/1638b8307c9305527a449664c95a03a2af1bfaf1bd150819fb882ef71415/uipath_runtime-0.12.2.tar.gz", hash = "sha256:0d1f56f41add0d932bbe0c975d473ec27a86c58e14e9aa745d6501356c51284a", size = 235789, upload-time = "2026-07-07T11:02:12.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/4c/433de72c4c5869ee26a63f68361b5c62d04bb03e9c7db9a69c0d3db7b458/uipath_runtime-0.12.1-py3-none-any.whl", hash = "sha256:88b014e0c5a50a5b41b2e532bd628eebc91d70ad42b6d3389fdc61b7ae78e0a6", size = 92004, upload-time = "2026-07-06T11:14:50.363Z" }, + { url = "https://files.pythonhosted.org/packages/09/7a/9042e41a71726386d6f7673f843decd5405daff0787bfe127a8ac15abaa4/uipath_runtime-0.12.2-py3-none-any.whl", hash = "sha256:8faa88ac0af208b48f08abfff11530ae0279f1e88a12e9d2935f7f74111ebde1", size = 92087, upload-time = "2026-07-07T11:02:11.221Z" }, ] [[package]]