From 273dbeb3a45ff564c64bf490d95eede33690230a Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Sat, 21 Feb 2026 08:20:54 +0200 Subject: [PATCH] fix: add chat hitl for agent framework --- .../uipath-agent-framework/pyproject.toml | 8 +- .../samples/concurrent/pyproject.toml | 4 +- .../samples/group-chat/pyproject.toml | 4 +- .../samples/handoff/pyproject.toml | 4 +- .../samples/hitl-workflow/README.md | 20 +- .../samples/hitl-workflow/main.py | 67 +- .../samples/hitl-workflow/pyproject.toml | 3 +- .../quickstart-workflow/pyproject.toml | 2 +- .../uipath_agent_framework/chat/__init__.py | 5 - .../src/uipath_agent_framework/chat/openai.py | 3 +- .../chat/{hitl.py => tools.py} | 10 +- .../runtime/breakpoints.py | 7 +- .../runtime/resumable_storage.py | 2 +- .../uipath_agent_framework/runtime/runtime.py | 149 ++++- .../uipath-agent-framework/tests/conftest.py | 182 ++++++ ...breakpoints.py => test_breakpoints_e2e.py} | 231 +------ .../tests/test_hitl_e2e.py | 574 ++++++++++++++++++ packages/uipath-agent-framework/uv.lock | 26 +- 18 files changed, 1029 insertions(+), 272 deletions(-) rename packages/uipath-agent-framework/src/uipath_agent_framework/chat/{hitl.py => tools.py} (83%) create mode 100644 packages/uipath-agent-framework/tests/conftest.py rename packages/uipath-agent-framework/tests/{test_group_chat_breakpoints.py => test_breakpoints_e2e.py} (84%) create mode 100644 packages/uipath-agent-framework/tests/test_hitl_e2e.py diff --git a/packages/uipath-agent-framework/pyproject.toml b/packages/uipath-agent-framework/pyproject.toml index 3332064..e25bf84 100644 --- a/packages/uipath-agent-framework/pyproject.toml +++ b/packages/uipath-agent-framework/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath-agent-framework" -version = "0.0.5" +version = "0.0.6" description = "Python SDK that enables developers to build and deploy Microsoft Agent Framework agents to the UiPath Cloud Platform" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "agent-framework-core>=1.0.0b260212", - "agent-framework-orchestrations>=1.0.0b260212", + "agent-framework-core>=1.0.0rc1", + "agent-framework-orchestrations>=1.0.0b260219", "aiosqlite>=0.20.0", "openinference-instrumentation-agent-framework>=0.1.0", "uipath>=2.8.41, <2.9.0", @@ -24,7 +24,7 @@ maintainers = [ ] [project.optional-dependencies] -anthropic = ["anthropic>=0.43.0", "agent-framework-anthropic>=1.0.0b260212"] +anthropic = ["anthropic>=0.43.0", "agent-framework-anthropic>=1.0.0b260219"] [project.entry-points."uipath.middlewares"] register = "uipath_agent_framework.middlewares:register_middleware" diff --git a/packages/uipath-agent-framework/samples/concurrent/pyproject.toml b/packages/uipath-agent-framework/samples/concurrent/pyproject.toml index 44ae5b2..d8f058a 100644 --- a/packages/uipath-agent-framework/samples/concurrent/pyproject.toml +++ b/packages/uipath-agent-framework/samples/concurrent/pyproject.toml @@ -8,8 +8,8 @@ requires-python = ">=3.11" dependencies = [ "uipath", "uipath-agent-framework", - "agent-framework-core>=1.0.0b260212", - "agent-framework-orchestrations>=1.0.0b260212", + "agent-framework-core>=1.0.0rc1", + "agent-framework-orchestrations>=1.0.0b260219", ] [dependency-groups] diff --git a/packages/uipath-agent-framework/samples/group-chat/pyproject.toml b/packages/uipath-agent-framework/samples/group-chat/pyproject.toml index c61a2cd..aa62429 100644 --- a/packages/uipath-agent-framework/samples/group-chat/pyproject.toml +++ b/packages/uipath-agent-framework/samples/group-chat/pyproject.toml @@ -8,8 +8,8 @@ requires-python = ">=3.11" dependencies = [ "uipath", "uipath-agent-framework", - "agent-framework-core>=1.0.0b260212", - "agent-framework-orchestrations>=1.0.0b260212", + "agent-framework-core>=1.0.0rc1", + "agent-framework-orchestrations>=1.0.0b260219", ] [dependency-groups] diff --git a/packages/uipath-agent-framework/samples/handoff/pyproject.toml b/packages/uipath-agent-framework/samples/handoff/pyproject.toml index 81aa0f6..e301b92 100644 --- a/packages/uipath-agent-framework/samples/handoff/pyproject.toml +++ b/packages/uipath-agent-framework/samples/handoff/pyproject.toml @@ -8,8 +8,8 @@ requires-python = ">=3.11" dependencies = [ "uipath", "uipath-agent-framework", - "agent-framework-core>=1.0.0b260212", - "agent-framework-orchestrations>=1.0.0b260212", + "agent-framework-core>=1.0.0rc1", + "agent-framework-orchestrations>=1.0.0b260219", ] [dependency-groups] diff --git a/packages/uipath-agent-framework/samples/hitl-workflow/README.md b/packages/uipath-agent-framework/samples/hitl-workflow/README.md index 67f7e32..b49aa55 100644 --- a/packages/uipath-agent-framework/samples/hitl-workflow/README.md +++ b/packages/uipath-agent-framework/samples/hitl-workflow/README.md @@ -1,6 +1,6 @@ # HITL Workflow -A customer support workflow with human-in-the-loop approval for sensitive operations. A triage agent routes requests to billing or returns specialists. Both `transfer_funds` and `issue_refund` tools require human approval before executing. +A customer support workflow with human-in-the-loop approval for sensitive operations. A triage agent routes requests to specialists: billing handles fund transfers, returns handles refunds. The `transfer_funds`, `get_customer_order`, and `issue_refund` tools all require human approval before executing. ## Agent Graph @@ -8,16 +8,24 @@ A customer support workflow with human-in-the-loop approval for sensitive operat flowchart TB __start__(__start__) triage(triage) + orders_agent(orders_agent) billing_agent(billing_agent) returns_agent(returns_agent) __end__(__end__) __start__ --> |input|triage + triage --> orders_agent triage --> billing_agent triage --> returns_agent + orders_agent --> billing_agent + orders_agent --> returns_agent + orders_agent --> triage + billing_agent --> orders_agent billing_agent --> returns_agent billing_agent --> triage + returns_agent --> orders_agent returns_agent --> billing_agent returns_agent --> triage + orders_agent --> |output|__end__ billing_agent --> |output|__end__ returns_agent --> |output|__end__ ``` @@ -32,8 +40,16 @@ uipath auth ## Run +Try a refund request (triggers `get_customer_order` + `issue_refund`, both require approval): + +``` +uipath run agent '{"messages": [{"contentParts": [{"data": {"inline": "I need a refund for order #12345, the item was defective"}}], "role": "user"}]}' +``` + +Or a fund transfer (triggers `transfer_funds`, requires approval): + ``` -uipath run agent '{"messages": [{"contentParts": [{"data": {"inline": "I need a refund for order #12345"}}], "role": "user"}]}' +uipath run agent '{"messages": [{"contentParts": [{"data": {"inline": "Transfer $500 from account ACC-001 to account ACC-002"}}], "role": "user"}]}' ``` ## Debug diff --git a/packages/uipath-agent-framework/samples/hitl-workflow/main.py b/packages/uipath-agent-framework/samples/hitl-workflow/main.py index cd22c4f..2dc1d91 100644 --- a/packages/uipath-agent-framework/samples/hitl-workflow/main.py +++ b/packages/uipath-agent-framework/samples/hitl-workflow/main.py @@ -1,6 +1,7 @@ from agent_framework.orchestrations import HandoffBuilder -from uipath_agent_framework.chat import UiPathOpenAIChatClient, requires_approval +from uipath_agent_framework.chat import UiPathOpenAIChatClient +from uipath_agent_framework.chat.tools import requires_approval @requires_approval @@ -18,6 +19,26 @@ def transfer_funds(from_account: str, to_account: str, amount: float) -> str: return f"Transferred ${amount:.2f} from {from_account} to {to_account}" +@requires_approval +def get_customer_order(order_id: str) -> str: + """Retrieve customer info and order details by order ID. Requires human approval. + + Args: + order_id: The order ID to look up + + Returns: + Customer and order information + """ + return ( + f"Order {order_id}:\n" + f" Customer: Jane Doe (jane.doe@example.com)\n" + f" Product: Wireless Headphones (SKU-4521)\n" + f" Amount: $79.99\n" + f" Status: Delivered\n" + f" Date: 2026-02-15" + ) + + @requires_approval def issue_refund(order_id: str, amount: float, reason: str) -> str: """Issue a refund for an order. Requires human approval. @@ -39,20 +60,34 @@ def issue_refund(order_id: str, amount: float, reason: str) -> str: name="triage", description="Routes customer requests to the right specialist.", instructions=( - "You are a customer support triage agent. Determine what the " - "customer needs help with and hand off to the right agent:\n" + "You are a customer support triage agent. " + "Route the customer to the right agent immediately without asking questions:\n" + "- Order lookups and customer info -> orders_agent\n" "- Billing issues (payments, transfers) -> billing_agent\n" "- Returns and refunds -> returns_agent\n" + "Hand off on the first message. Do not ask clarifying questions." + ), +) + +orders = client.as_agent( + name="orders_agent", + description="Looks up customer info and order details.", + instructions=( + "You are an order lookup specialist. " + "When a customer asks about an order, immediately call get_customer_order " + "with the order ID they provided. Do not ask for additional information." ), + tools=[get_customer_order], ) billing = client.as_agent( name="billing_agent", description="Handles billing, payments, and fund transfers.", instructions=( - "You are a billing specialist. Help customers with payments " - "and transfers. Use the transfer_funds tool when needed — " - "it will require human approval before executing." + "You are a billing specialist. " + "When a customer requests a payment or transfer, immediately call " + "transfer_funds with the details provided. Do not ask for confirmation " + "or additional information — the tool has built-in human approval." ), tools=[transfer_funds], ) @@ -61,22 +96,26 @@ def issue_refund(order_id: str, amount: float, reason: str) -> str: name="returns_agent", description="Handles product returns and refund requests.", instructions=( - "You are a returns specialist. Help customers process returns " - "and issue refunds. Use the issue_refund tool — it will " - "require human approval before executing." + "You are a returns specialist. " + "When a customer requests a refund, first call get_customer_order to look up " + "the order details, then immediately call issue_refund with the order ID, " + "the full order amount, and the reason the customer gave. " + "Do not ask the customer for information you can look up. " + "The tools have built-in human approval so just call them directly." ), - tools=[issue_refund], + tools=[get_customer_order, issue_refund], ) workflow = ( HandoffBuilder( name="customer_support", - participants=[triage, billing, returns], + participants=[triage, orders, billing, returns], ) .with_start_agent(triage) - .add_handoff(triage, [billing, returns]) - .add_handoff(billing, [returns, triage]) - .add_handoff(returns, [billing, triage]) + .add_handoff(triage, [orders, billing, returns]) + .add_handoff(orders, [billing, returns, triage]) + .add_handoff(billing, [orders, returns, triage]) + .add_handoff(returns, [orders, billing, triage]) .build() ) diff --git a/packages/uipath-agent-framework/samples/hitl-workflow/pyproject.toml b/packages/uipath-agent-framework/samples/hitl-workflow/pyproject.toml index 667ad56..7b9e2d2 100644 --- a/packages/uipath-agent-framework/samples/hitl-workflow/pyproject.toml +++ b/packages/uipath-agent-framework/samples/hitl-workflow/pyproject.toml @@ -8,7 +8,7 @@ requires-python = ">=3.11" dependencies = [ "uipath", "uipath-agent-framework", - "agent-framework-core>=1.0.0b260212", + "agent-framework-core>=1.0.0rc1", ] [dependency-groups] @@ -18,3 +18,4 @@ dev = [ [tool.uv] prerelease = "allow" + diff --git a/packages/uipath-agent-framework/samples/quickstart-workflow/pyproject.toml b/packages/uipath-agent-framework/samples/quickstart-workflow/pyproject.toml index a42ca93..cb9e439 100644 --- a/packages/uipath-agent-framework/samples/quickstart-workflow/pyproject.toml +++ b/packages/uipath-agent-framework/samples/quickstart-workflow/pyproject.toml @@ -8,7 +8,7 @@ requires-python = ">=3.11" dependencies = [ "uipath", "uipath-agent-framework", - "agent-framework-core>=1.0.0b260212", + "agent-framework-core>=1.0.0rc1", ] [dependency-groups] diff --git a/packages/uipath-agent-framework/src/uipath_agent_framework/chat/__init__.py b/packages/uipath-agent-framework/src/uipath_agent_framework/chat/__init__.py index d088450..ae2cdbd 100644 --- a/packages/uipath-agent-framework/src/uipath_agent_framework/chat/__init__.py +++ b/packages/uipath-agent-framework/src/uipath_agent_framework/chat/__init__.py @@ -18,15 +18,10 @@ def __getattr__(name): from .anthropic import UiPathAnthropicClient return UiPathAnthropicClient - if name == "requires_approval": - from .hitl import requires_approval - - return requires_approval raise AttributeError(f"module {__name__!r} has no attribute {name!r}") __all__ = [ "UiPathOpenAIChatClient", "UiPathAnthropicClient", - "requires_approval", ] diff --git a/packages/uipath-agent-framework/src/uipath_agent_framework/chat/openai.py b/packages/uipath-agent-framework/src/uipath_agent_framework/chat/openai.py index 98c27d5..6a4ca6c 100644 --- a/packages/uipath-agent-framework/src/uipath_agent_framework/chat/openai.py +++ b/packages/uipath-agent-framework/src/uipath_agent_framework/chat/openai.py @@ -8,7 +8,6 @@ from __future__ import annotations import logging -from collections.abc import Sequence from typing import Any import httpx @@ -103,7 +102,7 @@ def __init__(self, model: str = "gpt-4o-mini", **kwargs: Any): **kwargs, ) - def _prepare_tools_for_openai(self, tools: Sequence[Any]) -> dict[str, Any]: + def _prepare_tools_for_openai(self, tools: Any) -> dict[str, Any]: """Prepare tools for the OpenAI Chat Completions API. Extends the base implementation to convert plain callables to diff --git a/packages/uipath-agent-framework/src/uipath_agent_framework/chat/hitl.py b/packages/uipath-agent-framework/src/uipath_agent_framework/chat/tools.py similarity index 83% rename from packages/uipath-agent-framework/src/uipath_agent_framework/chat/hitl.py rename to packages/uipath-agent-framework/src/uipath_agent_framework/chat/tools.py index 944907f..73c7c33 100644 --- a/packages/uipath-agent-framework/src/uipath_agent_framework/chat/hitl.py +++ b/packages/uipath-agent-framework/src/uipath_agent_framework/chat/tools.py @@ -5,7 +5,7 @@ Example:: - from uipath_agent_framework.chat import requires_approval + from uipath_agent_framework.chat.tools import requires_approval @requires_approval def transfer_funds(from_account: str, to_account: str, amount: float) -> str: @@ -22,18 +22,18 @@ def transfer_funds(from_account: str, to_account: str, amount: float) -> str: @overload -def requires_approval(func: Callable[..., Any]) -> FunctionTool[Any]: ... +def requires_approval(func: Callable[..., Any]) -> FunctionTool: ... @overload def requires_approval( func: None = None, -) -> Callable[[Callable[..., Any]], FunctionTool[Any]]: ... +) -> Callable[[Callable[..., Any]], FunctionTool]: ... def requires_approval( func: Callable[..., Any] | None = None, -) -> FunctionTool[Any] | Callable[[Callable[..., Any]], FunctionTool[Any]]: +) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]: """Decorator that marks a tool function as requiring human approval. When the agent calls a tool decorated with ``@requires_approval``, @@ -61,7 +61,7 @@ def my_tool(arg: str) -> str: ... if func is not None: return tool(func, approval_mode="always_require") - def decorator(fn: Callable[..., Any]) -> FunctionTool[Any]: + def decorator(fn: Callable[..., Any]) -> FunctionTool: return tool(fn, approval_mode="always_require") return decorator diff --git a/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/breakpoints.py b/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/breakpoints.py index f5b6306..c7eae86 100644 --- a/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/breakpoints.py +++ b/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/breakpoints.py @@ -18,7 +18,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from typing import Any from uuid import uuid4 @@ -123,7 +123,10 @@ async def process( input_value: Any = None if context.arguments is not None: try: - input_value = context.arguments.model_dump() + if isinstance(context.arguments, Mapping): + input_value = dict(context.arguments) + else: + input_value = context.arguments.model_dump() except Exception: input_value = str(context.arguments) diff --git a/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/resumable_storage.py b/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/resumable_storage.py index 24a87d6..55b09d5 100644 --- a/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/resumable_storage.py +++ b/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/resumable_storage.py @@ -407,7 +407,7 @@ async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None: conn = await self._storage._get_conn() async with self._storage._lock: cursor = await conn.execute( - "SELECT checkpoint_data FROM checkpoints WHERE workflow_name = ? ORDER BY timestamp DESC LIMIT 1", + "SELECT checkpoint_data FROM checkpoints WHERE workflow_name = ? ORDER BY timestamp DESC, rowid DESC LIMIT 1", (workflow_name,), ) row = await cursor.fetchone() diff --git a/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/runtime.py b/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/runtime.py index 84a8dd7..a2d373d 100644 --- a/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/runtime.py +++ b/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/runtime.py @@ -13,8 +13,10 @@ Message, WorkflowAgent, WorkflowRunResult, + WorkflowRunState, ) from pydantic import BaseModel +from uipath.core.chat import UiPathConversationToolCallConfirmationValue from uipath.core.serialization import serialize_json from uipath.runtime import ( UiPathExecuteOptions, @@ -169,6 +171,93 @@ def _apply_session_to_executors(self, session: AgentSession) -> None: if isinstance(executor, AgentExecutor): executor._session = session + # ------------------------------------------------------------------ + # HITL helpers (tool approval flow) + # ------------------------------------------------------------------ + + async def _save_hitl_state(self, request_info_map: dict[str, Any]) -> None: + """Persist original function_approval_request Content objects for resume.""" + if not self._resumable_storage: + return + serialized = {} + for request_id, content in request_info_map.items(): + if ( + isinstance(content, Content) + and content.type == "function_approval_request" + ): + serialized[request_id] = content.to_dict() + if serialized: + await self._resumable_storage.set_value( + self.runtime_id, "hitl", "state", serialized + ) + + async def _load_hitl_state(self) -> dict[str, Content] | None: + """Load stored HITL state for converting resume responses.""" + if not self._resumable_storage: + return None + state = await self._resumable_storage.get_value( + self.runtime_id, "hitl", "state" + ) + if state and isinstance(state, dict): + return {rid: Content.from_dict(data) for rid, data in state.items()} + return None + + @staticmethod + def _convert_to_hitl_output(request_info_map: dict[str, Any]) -> dict[str, Any]: + """Convert request_info Content objects to UiPathConversationToolCallConfirmationValue.""" + output = {} + for request_id, content in request_info_map.items(): + if ( + isinstance(content, Content) + and content.type == "function_approval_request" + ): + fc = content.function_call # nested Content(function_call) + if fc is None: + continue + args = fc.arguments + if isinstance(args, str): + try: + args = json.loads(args) + except (json.JSONDecodeError, TypeError): + args = {} + elif not isinstance(args, dict): + args = {} + output[request_id] = UiPathConversationToolCallConfirmationValue( + tool_call_id=fc.call_id or "", + tool_name=fc.name or "", + input_schema={}, + input_value=args, + ) + else: + output[request_id] = content # pass through unknown types + return output + + async def _convert_resume_responses(self, input: dict[str, Any]) -> dict[str, Any]: + """Convert CAS approval responses to framework Content objects.""" + hitl_state = await self._load_hitl_state() + if not hitl_state: + return input # no stored state, pass through + + converted = {} + for request_id, response_data in input.items(): + original = hitl_state.get(request_id) + if ( + original + and isinstance(original, Content) + and original.type == "function_approval_request" + ): + # Extract approval from CAS response format: + # {"type": "uipath_cas_tool_call_confirmation", "value": {"approved": bool}} + approved = False + if isinstance(response_data, dict): + value = response_data.get("value", response_data) + if isinstance(value, dict): + approved = value.get("approved", False) + converted[request_id] = original.to_function_approval_response(approved) + else: + converted[request_id] = response_data + return converted + async def execute( self, input: dict[str, Any] | None = None, @@ -183,7 +272,7 @@ async def execute( if is_resuming and input is not None: # HITL resume: checkpoint restores executor state (including session) - self._resume_responses = input + self._resume_responses = await self._convert_resume_responses(input) # Inject breakpoints (no skip needed for HITL resume) if options and options.breakpoints: @@ -243,6 +332,23 @@ async def execute( checkpoint_storage=self._checkpoint_storage, ) + # Check for HITL suspension (framework's request_info mechanism) + request_info_events = result.get_request_info_events() + hitl_requests = { + e.request_id: e.data + for e in request_info_events + if isinstance(e.data, Content) + and e.data.type == "function_approval_request" + } + if hitl_requests: + await self._save_hitl_state(hitl_requests) + if session is not None: + await self._save_session(session) + return UiPathRuntimeResult( + output=self._convert_to_hitl_output(hitl_requests), + status=UiPathRuntimeStatus.SUSPENDED, + ) + if session is not None: await self._save_session(session) output = self._extract_workflow_output(result) @@ -291,7 +397,7 @@ async def stream( if is_resuming and input is not None: # HITL resume: input contains response data - self._resume_responses = input + self._resume_responses = await self._convert_resume_responses(input) user_input = self._prepare_input(None) # Inject breakpoints (no skip needed for HITL resume) @@ -412,7 +518,8 @@ async def _stream_workflow( try: async for event in response_stream: if event.type == "request_info": - request_info_map[event.request_id] = event.data + data = event.data + request_info_map[event.request_id] = data elif event.type == "executor_invoked": # Skip the duplicate for the start executor we already emitted if ( @@ -460,7 +567,7 @@ async def _stream_workflow( # Detect workflow suspension via state if ( event.type == "status" - and str(event.state) == "IDLE_WITH_PENDING_REQUESTS" + and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS ): is_suspended = True except AgentInterruptException as e: @@ -515,9 +622,19 @@ async def _stream_workflow( if session is not None: await self._save_session(session) - if is_suspended and request_info_map: + # Filter to only include HITL approval requests in SUSPENDED output. + # Other request_info events (e.g. HandoffAgentUserRequest) are part of + # the workflow's multi-turn mechanism and handled separately. + hitl_requests = { + rid: data + for rid, data in request_info_map.items() + if isinstance(data, Content) and data.type == "function_approval_request" + } + if is_suspended and hitl_requests: + hitl_output = self._convert_to_hitl_output(hitl_requests) + await self._save_hitl_state(hitl_requests) yield UiPathRuntimeResult( - output=request_info_map, + output=hitl_output, status=UiPathRuntimeStatus.SUSPENDED, ) else: @@ -603,11 +720,10 @@ def _extract_tool_state_events( ) return tool_events - def _extract_workflow_messages(self, data: Any) -> list[Any]: - """Extract UiPath conversation message events from workflow output data.""" - events: list[Any] = [] + @staticmethod + def _extract_contents(data: Any) -> list[Any]: + """Extract Content objects from any workflow data type.""" contents: list[Any] = [] - if isinstance(data, AgentResponseUpdate): contents = list(data.contents or []) elif isinstance(data, AgentResponse): @@ -617,13 +733,18 @@ def _extract_workflow_messages(self, data: Any) -> list[Any]: contents = list(data.contents or []) elif isinstance(data, list): for item in data: - events.extend(self._extract_workflow_messages(item)) - return events + contents.extend(UiPathAgentFrameworkRuntime._extract_contents(item)) + return contents - for content in contents: + def _extract_workflow_messages(self, data: Any) -> list[Any]: + """Extract UiPath conversation message events from workflow output data.""" + events: list[Any] = [] + for content in self._extract_contents(data): if isinstance(content, Content): + # Skip HITL approval requests — handled by the suspension mechanism + if content.type == "function_approval_request": + continue events.extend(self.chat.map_streaming_content(content)) - return events def _extract_workflow_output(self, result: WorkflowRunResult) -> Any: diff --git a/packages/uipath-agent-framework/tests/conftest.py b/packages/uipath-agent-framework/tests/conftest.py new file mode 100644 index 0000000..caade0d --- /dev/null +++ b/packages/uipath-agent-framework/tests/conftest.py @@ -0,0 +1,182 @@ +"""Shared test helpers for agent framework e2e tests.""" + +from __future__ import annotations + +from typing import Any + +from openai.types import CompletionUsage +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_chunk import ( + ChatCompletionChunk, + ChoiceDelta, + ChoiceDeltaToolCall, + ChoiceDeltaToolCallFunction, +) +from openai.types.chat.chat_completion_chunk import ( + Choice as ChunkChoice, +) +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.chat.chat_completion_message_tool_call import ( + ChatCompletionMessageToolCall, + Function, +) + + +def extract_system_text(messages: list[dict[str, Any]]) -> str: + """Extract system/developer message text from OpenAI-format messages. + + The OpenAI client sends content as a list of content parts: + [{"type": "text", "text": "..."}] + """ + for msg in messages: + if not isinstance(msg, dict): + continue + if msg.get("role") not in ("system", "developer"): + continue + content = msg.get("content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(part.get("text", "")) + return " ".join(parts) + return "" + + +def make_chat_completion(text: str) -> ChatCompletion: + """Create a mock OpenAI ChatCompletion response.""" + return ChatCompletion( + id="test-completion", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content=text), + finish_reason="stop", + ) + ], + created=0, + model="mock-model", + object="chat.completion", + usage=CompletionUsage(prompt_tokens=10, completion_tokens=10, total_tokens=20), + ) + + +async def make_streaming_response(text: str): + """Create an async iterable of ChatCompletionChunks for streaming.""" + yield ChatCompletionChunk( + id="test-chunk", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(role="assistant", content=text), + finish_reason=None, + ) + ], + created=0, + model="mock-model", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk( + id="test-chunk", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(), + finish_reason="stop", + ) + ], + created=0, + model="mock-model", + object="chat.completion.chunk", + usage=CompletionUsage(prompt_tokens=10, completion_tokens=10, total_tokens=20), + ) + + +def make_mock_response(text: str, stream: bool = False): + """Return either a ChatCompletion or a streaming async iterable.""" + if stream: + return make_streaming_response(text) + return make_chat_completion(text) + + +def make_tool_call_completion(tool_name: str, arguments: str = "{}") -> ChatCompletion: + """Create a mock ChatCompletion with a tool call.""" + return ChatCompletion( + id="test-completion", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role="assistant", + content=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id=f"call_{tool_name}", + function=Function(name=tool_name, arguments=arguments), + type="function", + ) + ], + ), + finish_reason="tool_calls", + ) + ], + created=0, + model="mock-model", + object="chat.completion", + usage=CompletionUsage(prompt_tokens=10, completion_tokens=10, total_tokens=20), + ) + + +async def make_streaming_tool_call(tool_name: str, arguments: str = "{}"): + """Create streaming chunks for a tool call response.""" + yield ChatCompletionChunk( + id="test-chunk", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta( + role="assistant", + tool_calls=[ + ChoiceDeltaToolCall( + index=0, + id=f"call_{tool_name}", + function=ChoiceDeltaToolCallFunction( + name=tool_name, + arguments=arguments, + ), + type="function", + ) + ], + ), + finish_reason=None, + ) + ], + created=0, + model="mock-model", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk( + id="test-chunk", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(), + finish_reason="tool_calls", + ) + ], + created=0, + model="mock-model", + object="chat.completion.chunk", + usage=CompletionUsage(prompt_tokens=10, completion_tokens=10, total_tokens=20), + ) + + +def make_tool_call_response( + tool_name: str, arguments: str = "{}", stream: bool = False +): + """Return either a tool call ChatCompletion or streaming chunks.""" + if stream: + return make_streaming_tool_call(tool_name, arguments) + return make_tool_call_completion(tool_name, arguments) diff --git a/packages/uipath-agent-framework/tests/test_group_chat_breakpoints.py b/packages/uipath-agent-framework/tests/test_breakpoints_e2e.py similarity index 84% rename from packages/uipath-agent-framework/tests/test_group_chat_breakpoints.py rename to packages/uipath-agent-framework/tests/test_breakpoints_e2e.py index 610472f..b5845e7 100644 --- a/packages/uipath-agent-framework/tests/test_group_chat_breakpoints.py +++ b/packages/uipath-agent-framework/tests/test_breakpoints_e2e.py @@ -27,21 +27,10 @@ GroupChatBuilder, HandoffBuilder, ) -from openai.types import CompletionUsage -from openai.types.chat.chat_completion import ChatCompletion, Choice -from openai.types.chat.chat_completion_chunk import ( - ChatCompletionChunk, - ChoiceDelta, - ChoiceDeltaToolCall, - ChoiceDeltaToolCallFunction, -) -from openai.types.chat.chat_completion_chunk import ( - Choice as ChunkChoice, -) -from openai.types.chat.chat_completion_message import ChatCompletionMessage -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, +from conftest import ( + extract_system_text, + make_mock_response, + make_tool_call_response, ) from uipath.runtime.debug import ( UiPathDebugProtocol, @@ -60,168 +49,6 @@ MAX_RESUME_CALLS = 50 -def _extract_system_text(messages: list[dict[str, Any]]) -> str: - """Extract system/developer message text from OpenAI-format messages. - - The OpenAI client sends content as a list of content parts: - [{"type": "text", "text": "..."}] - """ - for msg in messages: - if not isinstance(msg, dict): - continue - if msg.get("role") not in ("system", "developer"): - continue - content = msg.get("content", "") - if isinstance(content, str): - return content - if isinstance(content, list): - parts = [] - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - parts.append(part.get("text", "")) - return " ".join(parts) - return "" - - -def _make_chat_completion(text: str) -> ChatCompletion: - """Create a mock OpenAI ChatCompletion response.""" - return ChatCompletion( - id="test-completion", - choices=[ - Choice( - index=0, - message=ChatCompletionMessage(role="assistant", content=text), - finish_reason="stop", - ) - ], - created=0, - model="mock-model", - object="chat.completion", - usage=CompletionUsage(prompt_tokens=10, completion_tokens=10, total_tokens=20), - ) - - -async def _make_streaming_response(text: str): - """Create an async iterable of ChatCompletionChunks for streaming.""" - # Yield the content in a single chunk - yield ChatCompletionChunk( - id="test-chunk", - choices=[ - ChunkChoice( - index=0, - delta=ChoiceDelta(role="assistant", content=text), - finish_reason=None, - ) - ], - created=0, - model="mock-model", - object="chat.completion.chunk", - ) - # Yield the stop chunk - yield ChatCompletionChunk( - id="test-chunk", - choices=[ - ChunkChoice( - index=0, - delta=ChoiceDelta(), - finish_reason="stop", - ) - ], - created=0, - model="mock-model", - object="chat.completion.chunk", - usage=CompletionUsage(prompt_tokens=10, completion_tokens=10, total_tokens=20), - ) - - -def _make_mock_response(text: str, stream: bool = False): - """Return either a ChatCompletion or a streaming async iterable.""" - if stream: - return _make_streaming_response(text) - return _make_chat_completion(text) - - -def _make_tool_call_completion(tool_name: str, arguments: str = "{}") -> ChatCompletion: - """Create a mock ChatCompletion with a tool call.""" - return ChatCompletion( - id="test-completion", - choices=[ - Choice( - index=0, - message=ChatCompletionMessage( - role="assistant", - content=None, - tool_calls=[ - ChatCompletionMessageToolCall( - id=f"call_{tool_name}", - function=Function(name=tool_name, arguments=arguments), - type="function", - ) - ], - ), - finish_reason="tool_calls", - ) - ], - created=0, - model="mock-model", - object="chat.completion", - usage=CompletionUsage(prompt_tokens=10, completion_tokens=10, total_tokens=20), - ) - - -async def _make_streaming_tool_call(tool_name: str, arguments: str = "{}"): - """Create streaming chunks for a tool call response.""" - yield ChatCompletionChunk( - id="test-chunk", - choices=[ - ChunkChoice( - index=0, - delta=ChoiceDelta( - role="assistant", - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - id=f"call_{tool_name}", - function=ChoiceDeltaToolCallFunction( - name=tool_name, - arguments=arguments, - ), - type="function", - ) - ], - ), - finish_reason=None, - ) - ], - created=0, - model="mock-model", - object="chat.completion.chunk", - ) - yield ChatCompletionChunk( - id="test-chunk", - choices=[ - ChunkChoice( - index=0, - delta=ChoiceDelta(), - finish_reason="tool_calls", - ) - ], - created=0, - model="mock-model", - object="chat.completion.chunk", - usage=CompletionUsage(prompt_tokens=10, completion_tokens=10, total_tokens=20), - ) - - -def _make_tool_call_response( - tool_name: str, arguments: str = "{}", stream: bool = False -): - """Return either a tool call ChatCompletion or streaming chunks.""" - if stream: - return _make_streaming_tool_call(tool_name, arguments) - return _make_tool_call_completion(tool_name, arguments) - - def _make_debug_bridge(**overrides: Any) -> UiPathDebugProtocol: """Create a mock debug bridge with sensible defaults.""" bridge: Mock = Mock(spec=UiPathDebugProtocol) @@ -276,7 +103,7 @@ async def mock_chat_completions_create(**kwargs: Any): """Mock LLM: orchestrator returns structured JSON, participants return text.""" messages = kwargs.get("messages", []) is_stream = kwargs.get("stream", False) - system_msg = _extract_system_text(messages) + system_msg = extract_system_text(messages) if ( "coordinate" in system_msg.lower() @@ -319,7 +146,7 @@ async def mock_chat_completions_create(**kwargs: Any): response_text = "OK" llm_call_log.append({"agent": "unknown", "response": response_text}) - return _make_mock_response(response_text, stream=is_stream) + return make_mock_response(response_text, stream=is_stream) # --- Build agents exactly like the group-chat sample --- mock_openai = AsyncMock() @@ -465,7 +292,7 @@ async def test_group_chat_single_breakpoint_completes(self): async def mock_create(**kwargs: Any): messages = kwargs.get("messages", []) is_stream = kwargs.get("stream", False) - system_msg = _extract_system_text(messages) + system_msg = extract_system_text(messages) if ( "coordinate" in system_msg.lower() @@ -481,8 +308,8 @@ async def mock_create(**kwargs: Any): "final_message": None, } ) - return _make_mock_response(text, stream=is_stream) - return _make_mock_response("Some response text.", stream=is_stream) + return make_mock_response(text, stream=is_stream) + return make_mock_response("Some response text.", stream=is_stream) mock_openai = AsyncMock() mock_openai.chat.completions.create = mock_create @@ -586,7 +413,7 @@ async def test_group_chat_no_breakpoints_completes(self): async def mock_create(**kwargs: Any): messages = kwargs.get("messages", []) is_stream = kwargs.get("stream", False) - system_msg = _extract_system_text(messages) + system_msg = extract_system_text(messages) if ( "coordinate" in system_msg.lower() @@ -602,8 +429,8 @@ async def mock_create(**kwargs: Any): "final_message": None, } ) - return _make_mock_response(text, stream=is_stream) - return _make_mock_response("Some response text.", stream=is_stream) + return make_mock_response(text, stream=is_stream) + return make_mock_response("Some response text.", stream=is_stream) mock_openai = AsyncMock() mock_openai.chat.completions.create = mock_create @@ -694,7 +521,7 @@ async def test_quickstart_breakall_completes_without_loop(self): async def mock_create(**kwargs: Any): is_stream = kwargs.get("stream", False) llm_call_log.append("weather_agent") - return _make_mock_response( + return make_mock_response( "The weather in New York is 72°F and sunny.", stream=is_stream, ) @@ -789,27 +616,27 @@ async def test_concurrent_breakall_completes_without_loop(self): async def mock_create(**kwargs: Any): messages = kwargs.get("messages", []) is_stream = kwargs.get("stream", False) - system_msg = _extract_system_text(messages) + system_msg = extract_system_text(messages) if "sentiment" in system_msg.lower(): llm_call_log.append("sentiment") - return _make_mock_response( + return make_mock_response( "Sentiment: positive (0.85)", stream=is_stream ) elif "topic" in system_msg.lower() or "entit" in system_msg.lower(): llm_call_log.append("topic") - return _make_mock_response( + return make_mock_response( "Topics: AI, safety, alignment", stream=is_stream ) elif "summar" in system_msg.lower(): llm_call_log.append("summarizer") - return _make_mock_response( + return make_mock_response( "Summary: A discussion about AI safety.", stream=is_stream, ) else: llm_call_log.append("unknown") - return _make_mock_response("OK", stream=is_stream) + return make_mock_response("OK", stream=is_stream) mock_openai = AsyncMock() mock_openai.chat.completions.create = mock_create @@ -918,36 +745,36 @@ async def test_handoff_breakall_completes_without_loop(self): async def mock_create(**kwargs: Any): messages = kwargs.get("messages", []) is_stream = kwargs.get("stream", False) - system_msg = _extract_system_text(messages) + system_msg = extract_system_text(messages) if "route" in system_msg.lower() or "triage" in system_msg.lower(): llm_call_log.append("triage") # Triage hands off to billing_agent via tool call - return _make_tool_call_response( + return make_tool_call_response( "handoff_to_billing_agent", stream=is_stream ) elif "billing" in system_msg.lower(): llm_call_log.append("billing_agent") - return _make_mock_response( + return make_mock_response( "I've resolved your billing issue. Your account " "has been credited $50.", stream=is_stream, ) elif "tech" in system_msg.lower(): llm_call_log.append("tech_agent") - return _make_mock_response( + return make_mock_response( "I can help with your technical issue.", stream=is_stream, ) elif "return" in system_msg.lower() or "refund" in system_msg.lower(): llm_call_log.append("returns_agent") - return _make_mock_response( + return make_mock_response( "I'll process your return right away.", stream=is_stream, ) else: llm_call_log.append("unknown") - return _make_mock_response("How can I help you?", stream=is_stream) + return make_mock_response("How can I help you?", stream=is_stream) mock_openai = AsyncMock() mock_openai.chat.completions.create = mock_create @@ -1087,28 +914,28 @@ async def test_hitl_breakall_completes_without_loop(self): async def mock_create(**kwargs: Any): messages = kwargs.get("messages", []) is_stream = kwargs.get("stream", False) - system_msg = _extract_system_text(messages) + system_msg = extract_system_text(messages) if "route" in system_msg.lower() or "triage" in system_msg.lower(): llm_call_log.append("triage") - return _make_tool_call_response( + return make_tool_call_response( "handoff_to_billing_agent", stream=is_stream ) elif "billing" in system_msg.lower(): llm_call_log.append("billing_agent") - return _make_mock_response( + return make_mock_response( "Your billing issue has been resolved.", stream=is_stream, ) elif "return" in system_msg.lower() or "refund" in system_msg.lower(): llm_call_log.append("returns_agent") - return _make_mock_response( + return make_mock_response( "Your refund has been processed.", stream=is_stream, ) else: llm_call_log.append("unknown") - return _make_mock_response("How can I help you?", stream=is_stream) + return make_mock_response("How can I help you?", stream=is_stream) mock_openai = AsyncMock() mock_openai.chat.completions.create = mock_create diff --git a/packages/uipath-agent-framework/tests/test_hitl_e2e.py b/packages/uipath-agent-framework/tests/test_hitl_e2e.py new file mode 100644 index 0000000..775f983 --- /dev/null +++ b/packages/uipath-agent-framework/tests/test_hitl_e2e.py @@ -0,0 +1,574 @@ +"""End-to-end tests for the HITL tool approval flow. + +Tests the full runtime stack: + UiPathChatRuntime -> UiPathResumableRuntime -> UiPathAgentFrameworkRuntime + +Only LLM calls are mocked. The chat bridge is a mock implementing UiPathChatProtocol. +All other components (builders, checkpoint storage, trigger management) use real code. + +Covers: +- Tool approval (approved) completes successfully +- Tool approval (rejected) completes successfully +- Multiple tools across agents +- Streaming tool approval +- Interrupt payload format validation +""" + +import asyncio +import os +import tempfile +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agent_framework.openai import OpenAIChatClient +from agent_framework.orchestrations import HandoffBuilder +from conftest import ( + extract_system_text, + make_mock_response, + make_tool_call_response, +) +from uipath.core.chat import UiPathConversationToolCallConfirmationValue +from uipath.platform.resume_triggers import UiPathResumeTriggerHandler +from uipath.runtime import UiPathResumableRuntime +from uipath.runtime.chat.runtime import UiPathChatRuntime +from uipath.runtime.events import UiPathRuntimeEvent +from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.resumable.trigger import ( + UiPathResumeTrigger, + UiPathResumeTriggerType, +) + +from uipath_agent_framework.chat.tools import requires_approval +from uipath_agent_framework.runtime.resumable_storage import ( + ScopedCheckpointStorage, + SqliteResumableStorage, +) +from uipath_agent_framework.runtime.runtime import UiPathAgentFrameworkRuntime + +# --------------------------------------------------------------------------- +# Mock chat bridge +# --------------------------------------------------------------------------- + + +class MockChatBridge: + """Mock UiPathChatProtocol for testing HITL flows. + + Captures emitted interrupt events and auto-responds with approval/rejection. + """ + + def __init__(self, auto_approve: bool = True): + self.auto_approve = auto_approve + self.interrupts: list[UiPathResumeTrigger] = [] + self.messages: list[Any] = [] + + async def connect(self) -> None: + pass + + async def disconnect(self) -> None: + pass + + async def emit_message_event(self, message_event: Any) -> None: + self.messages.append(message_event) + + async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger) -> None: + self.interrupts.append(resume_trigger) + + async def emit_exchange_end_event(self) -> None: + pass + + async def wait_for_resume(self) -> dict[str, Any]: + """Return CAS-format approval/rejection response.""" + return { + "type": "uipath_cas_tool_call_confirmation", + "value": {"approved": self.auto_approve}, + } + + +# --------------------------------------------------------------------------- +# Runtime stack helper +# --------------------------------------------------------------------------- + + +async def _create_hitl_runtime_stack( + agent: Any, + runtime_id: str, + tmp_path: str, + auto_approve: bool = True, +) -> tuple[UiPathChatRuntime, MockChatBridge, SqliteResumableStorage]: + """Create the full runtime stack: ChatRuntime -> ResumableRuntime -> AgentFrameworkRuntime.""" + storage = SqliteResumableStorage(tmp_path) + await storage.setup() + assert storage.checkpoint_storage is not None + + scoped_cs = ScopedCheckpointStorage(storage.checkpoint_storage, runtime_id) + + base_runtime = UiPathAgentFrameworkRuntime( + agent=agent, + runtime_id=runtime_id, + checkpoint_storage=scoped_cs, + resumable_storage=storage, + ) + base_runtime.chat = MagicMock() + base_runtime.chat.map_messages_to_input.return_value = "I need help with billing" + base_runtime.chat.map_streaming_content.return_value = [] + base_runtime.chat.close_message.return_value = [] + + resumable_runtime = UiPathResumableRuntime( + delegate=base_runtime, + storage=storage, + trigger_manager=UiPathResumeTriggerHandler(), + runtime_id=runtime_id, + ) + + chat_bridge = MockChatBridge(auto_approve=auto_approve) + chat_runtime = UiPathChatRuntime( + delegate=resumable_runtime, chat_bridge=chat_bridge + ) + + return chat_runtime, chat_bridge, storage + + +# --------------------------------------------------------------------------- +# Agent builder helper +# --------------------------------------------------------------------------- + + +def _build_hitl_agents(mock_openai: AsyncMock) -> Any: + """Build the HITL handoff workflow with real @requires_approval tools. + + Tool functions are defined inside this function so each call creates + fresh FunctionTool instances, avoiding cross-test state pollution. + """ + + @requires_approval + def transfer_funds(from_account: str, to_account: str, amount: float) -> str: + """Transfer funds between accounts. Requires human approval.""" + return f"Transferred ${amount:.2f} from {from_account} to {to_account}" + + @requires_approval + def get_customer_order(order_id: str) -> str: + """Retrieve customer order details. Requires human approval.""" + return f"Order {order_id}: Widget x2, $49.99" + + @requires_approval + def issue_refund(order_id: str, amount: float, reason: str) -> str: + """Issue a refund for an order. Requires human approval.""" + return f"Refund of ${amount:.2f} issued for order {order_id}: {reason}" + + client = OpenAIChatClient(model_id="mock-model", async_client=mock_openai) + + # IMPORTANT: Agent instructions must use unique keywords for LLM mock routing. + # The triage instructions must NOT contain keywords used by other agents + # (e.g., "billing", "order", "returns") or the mock will misroute calls. + triage = client.as_agent( + name="triage", + description="Routes customer requests to the right specialist.", + instructions=( + "You are a triage agent. Determine the customer issue and " + "hand off to the appropriate specialist using handoff tools." + ), + ) + + billing = client.as_agent( + name="billing_agent", + description="Handles billing and fund transfers.", + instructions=("You are a billing specialist. Use transfer_funds when needed."), + tools=[transfer_funds], + ) + + orders = client.as_agent( + name="orders_agent", + description="Looks up customer orders.", + instructions=( + "You are an order lookup specialist. Use get_customer_order to look up orders." + ), + tools=[get_customer_order], + ) + + returns = client.as_agent( + name="returns_agent", + description="Handles returns and refunds.", + instructions=("You are a refund specialist. Use issue_refund when needed."), + tools=[issue_refund], + ) + + workflow = ( + HandoffBuilder( + name="customer_support", + participants=[triage, billing, orders, returns], + ) + .with_start_agent(triage) + .add_handoff(triage, [billing, orders, returns]) + .add_handoff(billing, [triage]) + .add_handoff(orders, [triage]) + .add_handoff(returns, [triage]) + .build() + ) + + return workflow.as_agent(name="customer_support") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio(loop_scope="class") +class TestHitlToolApprovalE2E: + """End-to-end tests for HITL tool approval flow through the full runtime stack. + + Uses class-scoped event loop to avoid cross-test interference from + the agent_framework's internal async checkpoint operations. + """ + + @pytest.fixture(autouse=True) + async def _settle_framework(self): + """Allow framework background tasks to complete between tests.""" + yield + await asyncio.sleep(0.2) + + async def test_tool_approval_approved_completes(self): + """Tool approval with auto_approve=True should complete successfully.""" + call_count: dict[str, int] = {} + + async def mock_create(**kwargs: Any): + messages = kwargs.get("messages", []) + is_stream = kwargs.get("stream", False) + system_msg = extract_system_text(messages) + + if "billing" in system_msg.lower(): + count = call_count.get("billing", 0) + call_count["billing"] = count + 1 + if count == 0: + return make_tool_call_response( + "transfer_funds", + arguments='{"from_account": "A", "to_account": "B", "amount": 100.0}', + stream=is_stream, + ) + else: + return make_mock_response("Transfer complete.", stream=is_stream) + elif "triage" in system_msg.lower(): + return make_tool_call_response( + "handoff_to_billing_agent", stream=is_stream + ) + else: + return make_mock_response("OK", stream=is_stream) + + mock_openai = AsyncMock() + mock_openai.chat.completions.create = mock_create + + agent = _build_hitl_agents(mock_openai) + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db") + os.close(tmp_fd) + try: + chat_runtime, chat_bridge, storage = await _create_hitl_runtime_stack( + agent, "test-hitl-approve", tmp_path, auto_approve=True + ) + + result = await chat_runtime.execute({"messages": []}) + + # Interrupt was emitted + assert len(chat_bridge.interrupts) == 1, ( + f"Expected 1 interrupt, got {len(chat_bridge.interrupts)}" + ) + + # Trigger has correct type and API resume data + trigger = chat_bridge.interrupts[0] + assert trigger.trigger_type == UiPathResumeTriggerType.API + assert trigger.api_resume is not None + request = trigger.api_resume.request + assert isinstance(request, dict) + assert request["toolName"] == "transfer_funds" + assert request["inputValue"]["from_account"] == "A" + assert request["inputValue"]["amount"] == 100.0 + + # Completed successfully (tool was executed after approval) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, ( + f"Expected SUCCESSFUL, got {result.status}" + ) + finally: + await storage.dispose() + os.unlink(tmp_path) + + async def test_tool_approval_rejected_completes(self): + """Tool approval with auto_approve=False should complete (tool not executed).""" + call_count: dict[str, int] = {} + + async def mock_create(**kwargs: Any): + messages = kwargs.get("messages", []) + is_stream = kwargs.get("stream", False) + system_msg = extract_system_text(messages) + + if "billing" in system_msg.lower(): + count = call_count.get("billing", 0) + call_count["billing"] = count + 1 + if count == 0: + return make_tool_call_response( + "transfer_funds", + arguments='{"from_account": "A", "to_account": "B", "amount": 50.0}', + stream=is_stream, + ) + else: + # After rejection, agent responds with text + return make_mock_response( + "The transfer was not approved.", stream=is_stream + ) + elif "triage" in system_msg.lower(): + return make_tool_call_response( + "handoff_to_billing_agent", stream=is_stream + ) + else: + return make_mock_response("OK", stream=is_stream) + + mock_openai = AsyncMock() + mock_openai.chat.completions.create = mock_create + + agent = _build_hitl_agents(mock_openai) + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db") + os.close(tmp_fd) + try: + chat_runtime, chat_bridge, storage = await _create_hitl_runtime_stack( + agent, "test-hitl-reject", tmp_path, auto_approve=False + ) + + result = await chat_runtime.execute({"messages": []}) + + # Interrupt was emitted + assert len(chat_bridge.interrupts) == 1 + + # Trigger type is API + assert chat_bridge.interrupts[0].trigger_type == UiPathResumeTriggerType.API + + # Completed (framework handles rejection gracefully) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, ( + f"Expected SUCCESSFUL, got {result.status}" + ) + finally: + await storage.dispose() + os.unlink(tmp_path) + + async def test_streaming_tool_approval(self): + """Streaming HITL flow should emit events and complete successfully.""" + call_count: dict[str, int] = {} + + async def mock_create(**kwargs: Any): + messages = kwargs.get("messages", []) + is_stream = kwargs.get("stream", False) + system_msg = extract_system_text(messages) + + if "billing" in system_msg.lower(): + count = call_count.get("billing", 0) + call_count["billing"] = count + 1 + if count == 0: + return make_tool_call_response( + "transfer_funds", + arguments='{"from_account": "X", "to_account": "Y", "amount": 200.0}', + stream=is_stream, + ) + else: + return make_mock_response("Transfer done.", stream=is_stream) + elif "triage" in system_msg.lower(): + return make_tool_call_response( + "handoff_to_billing_agent", stream=is_stream + ) + else: + return make_mock_response("OK", stream=is_stream) + + mock_openai = AsyncMock() + mock_openai.chat.completions.create = mock_create + + agent = _build_hitl_agents(mock_openai) + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db") + os.close(tmp_fd) + try: + chat_runtime, chat_bridge, storage = await _create_hitl_runtime_stack( + agent, "test-hitl-stream", tmp_path, auto_approve=True + ) + + events: list[UiPathRuntimeEvent] = [] + async for event in chat_runtime.stream({"messages": []}): + events.append(event) + + # Should have collected some events + assert len(events) > 0, "Should have emitted at least one event" + + # Interrupt was captured by bridge + assert len(chat_bridge.interrupts) >= 1 + assert chat_bridge.interrupts[0].trigger_type == UiPathResumeTriggerType.API + + # Last event should be a successful result + results = [e for e in events if isinstance(e, UiPathRuntimeResult)] + assert len(results) >= 1 + assert results[-1].status == UiPathRuntimeStatus.SUCCESSFUL + finally: + await storage.dispose() + os.unlink(tmp_path) + + async def test_interrupt_payload_format(self): + """Verify the exact shape of the interrupt trigger payload.""" + call_count: dict[str, int] = {} + + async def mock_create(**kwargs: Any): + messages = kwargs.get("messages", []) + is_stream = kwargs.get("stream", False) + system_msg = extract_system_text(messages) + + if "billing" in system_msg.lower(): + count = call_count.get("billing", 0) + call_count["billing"] = count + 1 + if count == 0: + return make_tool_call_response( + "transfer_funds", + arguments='{"from_account": "ACC1", "to_account": "ACC2", "amount": 99.99}', + stream=is_stream, + ) + else: + return make_mock_response("Done.", stream=is_stream) + elif "triage" in system_msg.lower(): + return make_tool_call_response( + "handoff_to_billing_agent", stream=is_stream + ) + else: + return make_mock_response("OK", stream=is_stream) + + mock_openai = AsyncMock() + mock_openai.chat.completions.create = mock_create + + agent = _build_hitl_agents(mock_openai) + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db") + os.close(tmp_fd) + try: + chat_runtime, chat_bridge, storage = await _create_hitl_runtime_stack( + agent, "test-hitl-format", tmp_path, auto_approve=True + ) + + await chat_runtime.execute({"messages": []}) + + assert len(chat_bridge.interrupts) >= 1 + trigger = chat_bridge.interrupts[0] + + # Verify trigger structure uses correct enum type + assert trigger.trigger_type == UiPathResumeTriggerType.API + assert trigger.api_resume is not None, "Should have api_resume" + request = trigger.api_resume.request + assert isinstance(request, dict), ( + f"request should be dict, got {type(request)}" + ) + + # Verify expected keys (camelCase from Pydantic alias serialization) + assert "toolCallId" in request, f"Missing toolCallId in {request.keys()}" + assert "toolName" in request, f"Missing toolName in {request.keys()}" + assert "inputSchema" in request, f"Missing inputSchema in {request.keys()}" + assert "inputValue" in request, f"Missing inputValue in {request.keys()}" + + # Verify values + assert request["toolName"] == "transfer_funds" + assert request["inputValue"]["from_account"] == "ACC1" + assert request["inputValue"]["to_account"] == "ACC2" + assert request["inputValue"]["amount"] == 99.99 + + # Verify the request can be reconstructed as UiPathConversationToolCallConfirmationValue + confirmation = UiPathConversationToolCallConfirmationValue(**request) + assert confirmation.tool_name == "transfer_funds" + assert confirmation.input_value is not None + assert confirmation.input_value["amount"] == 99.99 + finally: + await storage.dispose() + os.unlink(tmp_path) + + async def test_multiple_tools_across_agents(self): + """Multiple tools across different agents should each trigger an interrupt. + + Flow: triage -> orders -> get_customer_order (HITL) -> orders hands off + to triage -> triage -> billing -> transfer_funds (HITL) -> billing responds. + """ + call_count: dict[str, int] = {} + + async def mock_create(**kwargs: Any): + messages = kwargs.get("messages", []) + is_stream = kwargs.get("stream", False) + system_msg = extract_system_text(messages) + + if "order" in system_msg.lower(): + count = call_count.get("orders", 0) + call_count["orders"] = count + 1 + if count == 0: + return make_tool_call_response( + "get_customer_order", + arguments='{"order_id": "ORD-123"}', + stream=is_stream, + ) + else: + # After tool approval, hand back to triage + return make_tool_call_response( + "handoff_to_triage", stream=is_stream + ) + elif "billing" in system_msg.lower(): + count = call_count.get("billing", 0) + call_count["billing"] = count + 1 + if count == 0: + return make_tool_call_response( + "transfer_funds", + arguments='{"from_account": "A", "to_account": "B", "amount": 50.0}', + stream=is_stream, + ) + else: + return make_mock_response("All done.", stream=is_stream) + elif "triage" in system_msg.lower(): + count = call_count.get("triage", 0) + call_count["triage"] = count + 1 + if count == 0: + # First triage call: route to orders + return make_tool_call_response( + "handoff_to_orders_agent", stream=is_stream + ) + else: + # Second triage call (after orders hands back): route to billing + return make_tool_call_response( + "handoff_to_billing_agent", stream=is_stream + ) + else: + return make_mock_response("OK", stream=is_stream) + + mock_openai = AsyncMock() + mock_openai.chat.completions.create = mock_create + + agent = _build_hitl_agents(mock_openai) + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db") + os.close(tmp_fd) + try: + chat_runtime, chat_bridge, storage = await _create_hitl_runtime_stack( + agent, "test-hitl-multi", tmp_path, auto_approve=True + ) + + result = await chat_runtime.execute({"messages": []}) + + # Two interrupts: one for get_customer_order, one for transfer_funds + assert len(chat_bridge.interrupts) == 2, ( + f"Expected 2 interrupts, got {len(chat_bridge.interrupts)}. " + f"Tool names: {[t.api_resume.request.get('toolName') if t.api_resume else None for t in chat_bridge.interrupts]}" + ) + + # Both triggers should be API type + for trigger in chat_bridge.interrupts: + assert trigger.trigger_type == UiPathResumeTriggerType.API + + tool_names = [ + t.api_resume.request["toolName"] + for t in chat_bridge.interrupts + if t.api_resume + ] + assert "get_customer_order" in tool_names + assert "transfer_funds" in tool_names + + # Final result should be successful + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + finally: + await storage.dispose() + os.unlink(tmp_path) diff --git a/packages/uipath-agent-framework/uv.lock b/packages/uipath-agent-framework/uv.lock index b18a2cb..dac2194 100644 --- a/packages/uipath-agent-framework/uv.lock +++ b/packages/uipath-agent-framework/uv.lock @@ -7,20 +7,20 @@ prerelease-mode = "allow" [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260212" +version = "1.0.0b260219" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "agent-framework-core" }, { name = "anthropic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/fb/db7157cd342ed079a467a4080f3d5aadd403b798e25b6c52b638c317f44c/agent_framework_anthropic-1.0.0b260212.tar.gz", hash = "sha256:46adac3ff7cfedbf97d76fafa9c6a461b4ad1b53c075336d300800a283289f21", size = 12977, upload-time = "2026-02-13T00:27:13.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/c7/2d181e1ade76be55801f167fe2827bde794460e80fb2ea3345168914b8e6/agent_framework_anthropic-1.0.0b260219.tar.gz", hash = "sha256:a1d0316bd2d0cfd9e1b79096ba461c46e16643283a57599425f2f77e185f12c1", size = 13311, upload-time = "2026-02-20T02:59:06.255Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/1f/0c016c683b922919c5aabb8591fd04cffec7432a224169b029049e90967c/agent_framework_anthropic-1.0.0b260212-py3-none-any.whl", hash = "sha256:8c2a8bb5474b7984994b7e6cf0318f06338765f406c50a3d7336f38ed44c9444", size = 13025, upload-time = "2026-02-13T00:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cf/69c0107bb54413f2620e785e88136e10844acffdbcb7b2b345e151db5dda/agent_framework_anthropic-1.0.0b260219-py3-none-any.whl", hash = "sha256:3041f06a85d40bfb647b37f703ca261a0f97b06c2f39e4799fcefe9a1e5df78c", size = 13291, upload-time = "2026-02-20T02:58:59.664Z" }, ] [[package]] name = "agent-framework-core" -version = "1.0.0b260212" +version = "1.0.0rc1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-ai-projects" }, @@ -35,21 +35,21 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/75/33444ede8faa90ebc0c81a7c362c401c9a29c224d8e4ef3653b018ad32f3/agent_framework_core-1.0.0b260212.tar.gz", hash = "sha256:4490e05f48dff6fe616331e46b4b4432e54a81075475c7087dce91b8c462c8d0", size = 259769, upload-time = "2026-02-13T00:27:18.66Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/25/f43b9253e5534d812bb80b2a1a0ceb21c63f60df26c1f77104ab9756b493/agent_framework_core-1.0.0rc1.tar.gz", hash = "sha256:3684b219675f161a67b396d07c862b90ae9978bf56c7863fd1e7bdd189646e98", size = 262786, upload-time = "2026-02-20T02:59:27.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/b6/6c32def3967496e3688a69fda5bb55066688721c2839bc220de8ca3b306b/agent_framework_core-1.0.0b260212-py3-none-any.whl", hash = "sha256:18fc00c35911bd8a8c49055eda5204dc6dde761cf2379d32b16a91aaf6635dc0", size = 301370, upload-time = "2026-02-13T00:27:32.307Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/c3803d1bc94c955118ded70ac502f92186b3a61b1f3404c47175f8f80adc/agent_framework_core-1.0.0rc1-py3-none-any.whl", hash = "sha256:d3ee2f752f2fc7e41e23d998df74638302b251659deaf36041b37173fd65a6c4", size = 306254, upload-time = "2026-02-20T02:58:54.035Z" }, ] [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260212" +version = "1.0.0b260219" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "agent-framework-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/bf/9a1f62bf243157f5ce20e26b1549bdc563334521a1c3155b7eb4093f5b47/agent_framework_orchestrations-1.0.0b260212.tar.gz", hash = "sha256:31c215af8fe7cf954c17306113137df945aabae35e9f3c89052b6983e39516a8", size = 53522, upload-time = "2026-02-13T00:37:58.163Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/51/60eb73412adc6e85bbf91208314da55189697bf80da7adace999a93ea3c1/agent_framework_orchestrations-1.0.0b260219.tar.gz", hash = "sha256:539b550e8502eeccf6b8da8513c7d397d490c5e5b54a102a0d3c4e7c1dd7b14e", size = 53820, upload-time = "2026-02-20T02:59:23.984Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/87/7af9ea63943555dd133a9aaf3d056de4763cc2e91986763167a2f4dff6e0/agent_framework_orchestrations-1.0.0b260212-py3-none-any.whl", hash = "sha256:c10ed851a33ce46de8ee50f20a08b1db2604ef0a14739a23b42ba1279ddd52d1", size = 59721, upload-time = "2026-02-13T00:37:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/95/3c/b3de2062b2b6269d9a6b66ea76b43dd83124e4e495b6f3eff363da41a750/agent_framework_orchestrations-1.0.0b260219-py3-none-any.whl", hash = "sha256:2802af8c6393854e03900e0555e3c0e09468a549541fc861de9003ce719ec073", size = 59867, upload-time = "2026-02-20T02:58:50.084Z" }, ] [[package]] @@ -2460,7 +2460,7 @@ wheels = [ [[package]] name = "uipath-agent-framework" -version = "0.0.5" +version = "0.0.6" source = { editable = "." } dependencies = [ { name = "agent-framework-core" }, @@ -2490,9 +2490,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-framework-anthropic", marker = "extra == 'anthropic'", specifier = ">=1.0.0b260212" }, - { name = "agent-framework-core", specifier = ">=1.0.0b260212" }, - { name = "agent-framework-orchestrations", specifier = ">=1.0.0b260212" }, + { name = "agent-framework-anthropic", marker = "extra == 'anthropic'", specifier = ">=1.0.0b260219" }, + { name = "agent-framework-core", specifier = ">=1.0.0rc1" }, + { name = "agent-framework-orchestrations", specifier = ">=1.0.0b260219" }, { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.43.0" }, { name = "openinference-instrumentation-agent-framework", specifier = ">=0.1.0" },