diff --git a/packages/uipath-agent-framework/pyproject.toml b/packages/uipath-agent-framework/pyproject.toml index fa0034a..1a8d6d4 100644 --- a/packages/uipath-agent-framework/pyproject.toml +++ b/packages/uipath-agent-framework/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-agent-framework" -version = "0.0.7" +version = "0.0.8" 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" diff --git a/packages/uipath-agent-framework/samples/README.md b/packages/uipath-agent-framework/samples/README.md index 4c8f8b3..e164ae2 100644 --- a/packages/uipath-agent-framework/samples/README.md +++ b/packages/uipath-agent-framework/samples/README.md @@ -7,7 +7,11 @@ Sample agents built with [Agent Framework](https://github.com/microsoft/agent-fr | Sample | Description | |--------|-------------| | [quickstart-workflow](./quickstart-workflow/) | Single workflow agent with tool calling: fetches live weather data for any location | -| [group-chat](./group-chat/) | Group chat orchestration: researcher, critic, and writer discuss a topic with an orchestrator picking speakers | +| [structured-output](./structured-output/) | Structured output workflow: extracts city information and returns it as a typed Pydantic model | +| [sequential-structured-output](./sequential-structured-output/) | Sequential pipeline with structured output: researcher and editor agents produce a typed Pydantic city profile | +| [hitl-workflow](./hitl-workflow/) | Human-in-the-loop workflow: customer support with approval-gated billing and refund operations | +| [sequential](./sequential/) | Sequential pipeline: writer, reviewer, and editor agents process a task one after another | | [concurrent](./concurrent/) | Concurrent orchestration: sentiment, topic extraction, and summarization agents analyze text in parallel | | [handoff](./handoff/) | Handoff orchestration: customer support agents transfer control to specialists with explicit routing rules | -| [hitl-workflow](./hitl-workflow/) | Human-in-the-loop workflow: customer support with approval-gated billing and refund operations | +| [group-chat](./group-chat/) | Group chat orchestration: researcher, critic, and writer discuss a topic with an orchestrator picking speakers | +| [magentic](./magentic/) | Magentic-One orchestration: a manager dynamically coordinates researcher and analyst agents based on task progress | diff --git a/packages/uipath-agent-framework/samples/hitl-workflow/README.md b/packages/uipath-agent-framework/samples/hitl-workflow/README.md index b49aa55..beb6829 100644 --- a/packages/uipath-agent-framework/samples/hitl-workflow/README.md +++ b/packages/uipath-agent-framework/samples/hitl-workflow/README.md @@ -57,3 +57,4 @@ uipath run agent '{"messages": [{"contentParts": [{"data": {"inline": "Transfer ``` uipath dev web ``` + diff --git a/packages/uipath-agent-framework/samples/magentic/agent_framework.json b/packages/uipath-agent-framework/samples/magentic/agent_framework.json new file mode 100644 index 0000000..61974ae --- /dev/null +++ b/packages/uipath-agent-framework/samples/magentic/agent_framework.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-agent-framework/samples/sequential-structured-output/README.md b/packages/uipath-agent-framework/samples/sequential-structured-output/README.md new file mode 100644 index 0000000..a7dab90 --- /dev/null +++ b/packages/uipath-agent-framework/samples/sequential-structured-output/README.md @@ -0,0 +1,46 @@ +# Sequential + Structured Output + +A sequential pipeline that combines multi-agent processing with structured output. A researcher gathers facts about a city, then an editor organizes them into a well-defined Pydantic model (`CityInfo`). The final output is a typed JSON object — not free-form text. + +## Agent Graph + +```mermaid +flowchart TB + __start__(__start__) + __end__(__end__) + input-conversation(input-conversation) + researcher(researcher) + editor(editor) + end_(end) + __start__ --> |input|input-conversation + input-conversation --> researcher + researcher --> editor + editor --> end_ + end_ --> |output|__end__ +``` + +Internally, the sequential orchestration chains: +- **researcher** — gathers key facts about the city (country, population, landmarks, cultural significance) +- **editor** — organizes the research into a structured `CityInfo` schema with `response_format` + +Each agent sees the full conversation history from previous agents. The last agent's `response_format` determines the output schema. + +## Prerequisites + +Authenticate with UiPath to configure your `.env` file: + +```bash +uipath auth +``` + +## Run + +``` +uipath run agent '{"messages": [{"contentParts": [{"data": {"inline": "Tell me about Tokyo"}}], "role": "user"}]}' +``` + +## Debug + +``` +uipath dev web +``` diff --git a/packages/uipath-agent-framework/samples/sequential-structured-output/agent.mermaid b/packages/uipath-agent-framework/samples/sequential-structured-output/agent.mermaid new file mode 100644 index 0000000..d284420 --- /dev/null +++ b/packages/uipath-agent-framework/samples/sequential-structured-output/agent.mermaid @@ -0,0 +1,12 @@ +flowchart TB + __start__(__start__) + __end__(__end__) + input-conversation(input-conversation) + researcher(researcher) + editor(editor) + end(end) + __start__ --> |input|input-conversation + input-conversation --> researcher + researcher --> editor + editor --> end + end --> |output|__end__ diff --git a/packages/uipath-agent-framework/samples/sequential-structured-output/agent_framework.json b/packages/uipath-agent-framework/samples/sequential-structured-output/agent_framework.json new file mode 100644 index 0000000..61974ae --- /dev/null +++ b/packages/uipath-agent-framework/samples/sequential-structured-output/agent_framework.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-agent-framework/samples/sequential-structured-output/main.py b/packages/uipath-agent-framework/samples/sequential-structured-output/main.py new file mode 100644 index 0000000..214ffa4 --- /dev/null +++ b/packages/uipath-agent-framework/samples/sequential-structured-output/main.py @@ -0,0 +1,44 @@ +from agent_framework.orchestrations import SequentialBuilder +from pydantic import BaseModel + +from uipath_agent_framework.chat import UiPathOpenAIChatClient + + +class CityInfo(BaseModel): + """Structured output for city information.""" + + city: str + country: str + description: str + population_estimate: str + famous_for: list[str] + + +client = UiPathOpenAIChatClient(model="gpt-5-mini-2025-08-07") + +researcher = client.as_agent( + name="researcher", + description="Researches factual information about a city.", + instructions=( + "You are a thorough researcher. Given a city name, gather key facts " + "including its country, population, notable landmarks, cultural " + "significance, and what it is famous for. Present your findings clearly." + ), +) + +editor = client.as_agent( + name="editor", + description="Edits research into a structured city profile.", + instructions=( + "You are a precise editor. Take the researcher's findings and organize " + "them into a well-structured city profile. Ensure all facts are accurate " + "and the description is concise and informative." + ), + default_options={"response_format": CityInfo}, +) + +workflow = SequentialBuilder( + participants=[researcher, editor], +).build() + +agent = workflow.as_agent(name="sequential_structured_output_workflow") diff --git a/packages/uipath-agent-framework/samples/sequential-structured-output/pyproject.toml b/packages/uipath-agent-framework/samples/sequential-structured-output/pyproject.toml new file mode 100644 index 0000000..20865d8 --- /dev/null +++ b/packages/uipath-agent-framework/samples/sequential-structured-output/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "sequential-structured-output" +version = "0.0.1" +description = "Sequential + structured output: agents process a task in a pipeline, the last agent returns data in a well-defined Pydantic schema" +authors = [{ name = "John Doe" }] +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "uipath", + "uipath-agent-framework", + "agent-framework-core>=1.0.0rc1", + "agent-framework-orchestrations>=1.0.0b260219", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] + +[tool.uv] +prerelease = "allow" + +[tool.uv.sources] +uipath-dev = { path = "../../../../../uipath-dev-python", editable = true } +uipath-agent-framework = { path = "../../", editable = true } diff --git a/packages/uipath-agent-framework/samples/sequential-structured-output/uipath.json b/packages/uipath-agent-framework/samples/sequential-structured-output/uipath.json new file mode 100644 index 0000000..43c01b6 --- /dev/null +++ b/packages/uipath-agent-framework/samples/sequential-structured-output/uipath.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "runtimeOptions": { + "isConversational": false + }, + "packOptions": { + "fileExtensionsIncluded": [], + "filesIncluded": [], + "filesExcluded": [], + "directoriesExcluded": [], + "includeUvLock": true + }, + "functions": {} +} \ No newline at end of file diff --git a/packages/uipath-agent-framework/samples/sequential/agent.mermaid b/packages/uipath-agent-framework/samples/sequential/agent.mermaid new file mode 100644 index 0000000..7a6272e --- /dev/null +++ b/packages/uipath-agent-framework/samples/sequential/agent.mermaid @@ -0,0 +1,14 @@ +flowchart TB + __start__(__start__) + __end__(__end__) + input-conversation(input-conversation) + writer(writer) + reviewer(reviewer) + editor(editor) + end(end) + __start__ --> |input|input-conversation + input-conversation --> writer + writer --> reviewer + reviewer --> editor + editor --> end + end --> |output|__end__ diff --git a/packages/uipath-agent-framework/samples/sequential/agent_framework.json b/packages/uipath-agent-framework/samples/sequential/agent_framework.json new file mode 100644 index 0000000..61974ae --- /dev/null +++ b/packages/uipath-agent-framework/samples/sequential/agent_framework.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-agent-framework/samples/sequential/uipath.json b/packages/uipath-agent-framework/samples/sequential/uipath.json new file mode 100644 index 0000000..43c01b6 --- /dev/null +++ b/packages/uipath-agent-framework/samples/sequential/uipath.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "runtimeOptions": { + "isConversational": false + }, + "packOptions": { + "fileExtensionsIncluded": [], + "filesIncluded": [], + "filesExcluded": [], + "directoriesExcluded": [], + "includeUvLock": true + }, + "functions": {} +} \ No newline at end of file diff --git a/packages/uipath-agent-framework/samples/structured-output/agent.mermaid b/packages/uipath-agent-framework/samples/structured-output/agent.mermaid new file mode 100644 index 0000000..7add725 --- /dev/null +++ b/packages/uipath-agent-framework/samples/structured-output/agent.mermaid @@ -0,0 +1,6 @@ +flowchart TB + __start__(__start__) + __end__(__end__) + city_agent(city_agent) + __start__ --> |input|city_agent + city_agent --> |output|__end__ diff --git a/packages/uipath-agent-framework/samples/structured-output/agent_framework.json b/packages/uipath-agent-framework/samples/structured-output/agent_framework.json new file mode 100644 index 0000000..61974ae --- /dev/null +++ b/packages/uipath-agent-framework/samples/structured-output/agent_framework.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-agent-framework/samples/structured-output/main.py b/packages/uipath-agent-framework/samples/structured-output/main.py index efccfd7..1910637 100644 --- a/packages/uipath-agent-framework/samples/structured-output/main.py +++ b/packages/uipath-agent-framework/samples/structured-output/main.py @@ -22,7 +22,7 @@ class CityInfo(BaseModel): "Given a city name, provide the city name, country, a brief description, " "an estimated population, and a list of things the city is famous for." ), - response_format=CityInfo, + default_options={"response_format": CityInfo}, ) workflow = WorkflowBuilder(start_executor=city_agent).build() diff --git a/packages/uipath-agent-framework/samples/structured-output/uipath.json b/packages/uipath-agent-framework/samples/structured-output/uipath.json new file mode 100644 index 0000000..43c01b6 --- /dev/null +++ b/packages/uipath-agent-framework/samples/structured-output/uipath.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "runtimeOptions": { + "isConversational": false + }, + "packOptions": { + "fileExtensionsIncluded": [], + "filesIncluded": [], + "filesExcluded": [], + "directoriesExcluded": [], + "includeUvLock": true + }, + "functions": {} +} \ No newline at end of file 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 9595c84..d4f8a12 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 @@ -1,10 +1,12 @@ """Runtime class for executing Agent Framework agents within the UiPath framework.""" import json +import logging from typing import Any, AsyncGenerator from uuid import uuid4 from agent_framework import ( + Agent, AgentExecutor, AgentExecutorResponse, AgentResponse, @@ -45,6 +47,8 @@ from .resumable_storage import ScopedCheckpointStorage, SqliteResumableStorage from .schema import get_agent_graph, get_entrypoints_schema +logger = logging.getLogger(__name__) + class UiPathAgentFrameworkRuntime: """A runtime class for executing Agent Framework agents within the UiPath framework.""" @@ -610,6 +614,22 @@ async def _stream_workflow( # (COMPLETED) is only in executor_completed. executor_tool_phases: dict[str, set[UiPathRuntimeStatePhase]] = {} + # Determine which executors are output executors so we can emit + # intermediate message events from non-output agent executors on + # completion. This enables per-agent streaming for orchestrations + # like SequentialBuilder where output_executors=[end] and + # intermediate agent outputs are filtered from "output" events. + output_executor_ids: set[str] = set() + try: + for ex in workflow.get_output_executors(): + output_executor_ids.add(ex.id) + except Exception: + pass + # Track executors that already emitted message events so we don't + # duplicate when the same data appears in both executor_completed + # and "output" events. + executors_with_messages: set[str] = set() + # Emit an early STARTED event for the start executor so the graph # visualization shows it immediately rather than after it finishes. # The framework's _run_workflow_with_tracing awaits the entire start @@ -675,6 +695,33 @@ async def _stream_workflow( tool_event.node_name ) yield tool_event + + # For non-output AgentExecutor instances, extract + # message events from executor_completed data. + # This provides intermediate streaming for + # orchestrations (e.g. sequential) where agent + # output events are filtered by output_executors. + # Only AgentExecutors produce meaningful chat + # messages; framework-internal executors like + # input-conversation would echo user input. + executor = workflow.executors.get(event.executor_id) + if ( + isinstance(executor, AgentExecutor) + and event.executor_id not in output_executor_ids + and event.executor_id not in executors_with_messages + ): + completed_msg_events = self._extract_workflow_messages( + self._filter_completed_data(event.data) + ) + if completed_msg_events: + # Close prior message so each agent gets a + # separate bubble in the UI. + for close_evt in self.chat.close_message(): + yield UiPathRuntimeMessageEvent(payload=close_evt) + for msg_event in completed_msg_events: + yield UiPathRuntimeMessageEvent(payload=msg_event) + executors_with_messages.add(event.executor_id) + yield UiPathRuntimeStateEvent( payload=self._serialize_event_data( self._filter_completed_data(event.data) @@ -698,8 +745,15 @@ async def _stream_workflow( elif tool_event.phase == UiPathRuntimeStatePhase.COMPLETED: self._pending_tool_nodes.discard(tool_event.node_name) yield tool_event - for msg_event in self._extract_workflow_messages(event.data): - yield UiPathRuntimeMessageEvent(payload=msg_event) + + # When intermediate agents already emitted message + # events via executor_completed, skip the final + # orchestration output to avoid duplicating text. + if not executors_with_messages: + for msg_event in self._extract_workflow_messages( + event.data, assistant_only=True + ): + yield UiPathRuntimeMessageEvent(payload=msg_event) # Detect workflow suspension via state if ( @@ -895,9 +949,31 @@ def _extract_contents(data: Any) -> list[Any]: contents.extend(UiPathAgentFrameworkRuntime._extract_contents(item)) return contents - def _extract_workflow_messages(self, data: Any) -> list[Any]: - """Extract UiPath conversation message events from workflow output data.""" + def _extract_workflow_messages( + self, data: Any, *, assistant_only: bool = False + ) -> list[Any]: + """Extract UiPath conversation message events from workflow output data. + + Args: + data: Workflow output data (AgentResponse, Message, list[Message], etc.) + assistant_only: When True, only extract content from assistant-role + messages. Used for orchestration outputs (e.g. sequential + workflow full-conversation lists) to avoid echoing the user's + input back as AI output. + """ events: list[Any] = [] + + if assistant_only and isinstance(data, list): + for item in data: + if isinstance(item, Message) and item.role != "assistant": + continue + for content in self._extract_contents(item): + if isinstance(content, Content): + if content.type == "function_approval_request": + continue + events.extend(self.chat.map_streaming_content(content)) + return events + for content in self._extract_contents(data): if isinstance(content, Content): # Skip HITL approval requests — handled by the suspension mechanism @@ -913,44 +989,140 @@ def _extract_workflow_output(self, result: WorkflowRunResult) -> Any: if not outputs: return "" - texts: list[str] = [] + # Check for AgentResponse.value (non-streaming path). for data in outputs: - text = self._extract_text_from_data(data) - if text: - texts.append(text) - - if texts: - return "\n\n".join(texts) + response = self._get_agent_response(data) + if response is not None and response.value is not None: + value = response.value + if isinstance(value, BaseModel): + return value.model_dump() + if isinstance(value, dict): + return value + + # Concatenate text from all outputs without separator — + # streaming tokens are individual chunks, not separate paragraphs. + combined = "".join(self._extract_text_from_data(data) for data in outputs) + + if combined: + # Try parsing as structured output via response_format. + parsed = self._try_parse_structured_output(combined) + if parsed is not None: + return parsed + return combined try: return json.loads(serialize_json(outputs[-1])) except Exception: return str(outputs[-1]) + @staticmethod + def _get_agent_response(data: Any) -> AgentResponse | None: + """Unwrap an AgentResponse from workflow output data.""" + if isinstance(data, AgentExecutorResponse): + return data.agent_response + if isinstance(data, AgentResponse): + return data + return None + + def _try_parse_structured_output(self, text: str) -> dict[str, Any] | None: + """Try to parse concatenated text using the output executor's response_format.""" + response_format = self._get_output_response_format() + if response_format is None: + return None + try: + parsed = response_format.model_validate_json(text) + return parsed.model_dump() + except Exception: + return None + + def _get_output_response_format(self) -> type[BaseModel] | None: + """Get the response_format from the workflow's output executors. + + For orchestrations (e.g. SequentialBuilder) where output executors are + framework-internal adapters, falls back to scanning all workflow + executors and returns the response_format from the last AgentExecutor. + """ + try: + output_executors = self.agent.workflow.get_output_executors() + except Exception: + return None + for executor in output_executors: + if not isinstance(executor, AgentExecutor): + continue + inner_agent = executor._agent + if not isinstance(inner_agent, Agent): + continue + response_format = inner_agent.default_options.get("response_format") + if ( + response_format is not None + and isinstance(response_format, type) + and issubclass(response_format, BaseModel) + ): + return response_format + + # Fallback: scan all workflow executors for the last AgentExecutor + # with a response_format. Needed for orchestrations like sequential + # where the output executor is an internal adapter (e.g. _EndWithConversation). + try: + all_executors = list(self.agent.workflow.executors.values()) + except Exception: + return None + result: type[BaseModel] | None = None + for executor in all_executors: + if not isinstance(executor, AgentExecutor): + continue + inner_agent = executor._agent + if not isinstance(inner_agent, Agent): + continue + response_format = inner_agent.default_options.get("response_format") + if ( + response_format is not None + and isinstance(response_format, type) + and issubclass(response_format, BaseModel) + ): + result = response_format + return result + @staticmethod def _extract_text_from_data(data: Any) -> str: - """Extract text from any workflow data type.""" + """Extract text from any workflow data type. + + For list[Message] data (e.g. sequential workflow full-conversation + output), only the last assistant message is used. The full + conversation includes intermediate agent turns but the workflow + result should be the final agent's output, not the concatenation + of every participant. + """ if isinstance(data, (AgentResponseUpdate, AgentResponse)): return data.text or "" if isinstance(data, Message): + if data.role != "assistant": + return "" return "".join( c.text for c in (data.contents or []) if hasattr(c, "text") and c.text ) if isinstance(data, str): return data if isinstance(data, list): - parts: list[str] = [] + # Collect assistant message texts, then return only the last + # one. For single-agent workflows there is typically only one + # assistant message so this is equivalent to the old behavior. + # For multi-agent conversations (sequential, group-chat) the + # last assistant message is the final agent's output. + last_text: str = "" for item in data: if isinstance(item, Message): + if item.role != "assistant": + continue text = "".join( c.text for c in (item.contents or []) if hasattr(c, "text") and c.text ) if text: - parts.append(text) + last_text = text elif isinstance(item, str): - parts.append(item) + last_text = item elif isinstance(item, list): for inner in item: if isinstance(inner, Message) and inner.role == "assistant": @@ -960,8 +1132,8 @@ def _extract_text_from_data(data: Any) -> str: if hasattr(c, "text") and c.text ) if text: - parts.append(text) - return "\n\n".join(parts) + last_text = text + return last_text return "" def _prepare_input(self, input: dict[str, Any] | None) -> str: diff --git a/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/schema.py b/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/schema.py index 642e1b4..f964762 100644 --- a/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/schema.py +++ b/packages/uipath-agent-framework/src/uipath_agent_framework/runtime/schema.py @@ -4,6 +4,7 @@ from typing import Any from agent_framework import ( + Agent, AgentExecutor, BaseAgent, Edge, @@ -12,6 +13,7 @@ Workflow, WorkflowAgent, ) +from pydantic import BaseModel from uipath.runtime.schema import ( UiPathRuntimeEdge, UiPathRuntimeGraph, @@ -25,13 +27,81 @@ def get_entrypoints_schema(agent: BaseAgent) -> dict[str, Any]: Agent Framework agents are conversational — they always take messages as input and return conversation messages as output. Uses the standard UiPath conversation message format (matching Google ADK pattern). + + If the workflow's output executor has a response_format (Pydantic model), + the output schema reflects that structured type instead of default messages. """ + output_schema = _extract_output_schema(agent) or _default_messages_schema() return { "input": _default_messages_schema(), - "output": _default_messages_schema(), + "output": output_schema, } +def _extract_output_schema(agent: BaseAgent) -> dict[str, Any] | None: + """Extract structured output schema from a WorkflowAgent's output executors. + + Checks if the output executor's inner agent has a response_format set to a + Pydantic BaseModel. If so, returns its JSON schema as the output schema. + + For orchestrations (e.g. SequentialBuilder) where output executors are + framework-internal adapters, falls back to scanning all workflow executors + and returns the schema from the last AgentExecutor with a response_format. + """ + if not isinstance(agent, WorkflowAgent): + return None + + try: + output_executors = agent.workflow.get_output_executors() + except Exception: + return None + + for executor in output_executors: + if not isinstance(executor, AgentExecutor): + continue + inner_agent = executor._agent + if not isinstance(inner_agent, Agent): + continue + response_format = inner_agent.default_options.get("response_format") + if response_format is None: + continue + try: + if isinstance(response_format, type) and issubclass( + response_format, BaseModel + ): + return response_format.model_json_schema() + except Exception: + continue + + # Fallback: scan all workflow executors for the last AgentExecutor + # with a response_format. Needed for orchestrations like sequential + # where the output executor is an internal adapter. + try: + all_executors = list(agent.workflow.executors.values()) + except Exception: + return None + + result: dict[str, Any] | None = None + for executor in all_executors: + if not isinstance(executor, AgentExecutor): + continue + inner_agent = executor._agent + if not isinstance(inner_agent, Agent): + continue + response_format = inner_agent.default_options.get("response_format") + if response_format is None: + continue + try: + if isinstance(response_format, type) and issubclass( + response_format, BaseModel + ): + result = response_format.model_json_schema() + except Exception: + continue + + return result + + def _conversation_message_item_schema() -> dict[str, Any]: """Minimal message schema: role and contentParts required, contentParts items only need data.inline.""" return { @@ -152,12 +222,12 @@ def _build_workflow_graph(workflow: Workflow) -> UiPathRuntimeGraph: ) # AgentExecutors wrap a BaseAgent that may have tools - if isinstance(executor, AgentExecutor): - inner_agent: BaseAgent | None = getattr(executor, "_agent", None) - if inner_agent is not None: - _add_executor_tool_nodes( - exec_id, inner_agent, nodes, edges, executor_ids - ) + if isinstance(executor, AgentExecutor) and isinstance( + executor._agent, BaseAgent + ): + _add_executor_tool_nodes( + exec_id, executor._agent, nodes, edges, executor_ids + ) # Connect __start__ → start executor edges.append(UiPathRuntimeEdge(source="__start__", target=start_id, label="input")) diff --git a/packages/uipath-agent-framework/tests/conftest.py b/packages/uipath-agent-framework/tests/conftest.py index caade0d..ed08856 100644 --- a/packages/uipath-agent-framework/tests/conftest.py +++ b/packages/uipath-agent-framework/tests/conftest.py @@ -94,6 +94,42 @@ async def make_streaming_response(text: str): ) +async def make_chunked_streaming_response(text: str, chunk_size: int = 4): + """Create streaming chunks that simulate token-by-token LLM output.""" + for i in range(0, len(text), chunk_size): + token = text[i : i + chunk_size] + yield ChatCompletionChunk( + id="test-chunk", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta( + role="assistant" if i == 0 else None, + content=token, + ), + 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: diff --git a/packages/uipath-agent-framework/tests/test_schema.py b/packages/uipath-agent-framework/tests/test_schema.py index 0c95d2b..4aa5d73 100644 --- a/packages/uipath-agent-framework/tests/test_schema.py +++ b/packages/uipath-agent-framework/tests/test_schema.py @@ -1,9 +1,19 @@ """Tests for schema extraction utilities.""" -from unittest.mock import MagicMock +import asyncio +import json +import os +from unittest.mock import AsyncMock, MagicMock -from agent_framework import BaseAgent +from agent_framework import BaseAgent, WorkflowBuilder +from agent_framework.openai import OpenAIChatClient +from agent_framework.orchestrations import SequentialBuilder +from conftest import make_chunked_streaming_response, make_mock_response +from pydantic import BaseModel +from uipath.runtime import UiPathRuntimeResult +from uipath.runtime.result import UiPathRuntimeStatus +from uipath_agent_framework.runtime.runtime import UiPathAgentFrameworkRuntime from uipath_agent_framework.runtime.schema import ( get_entrypoints_schema, ) @@ -75,3 +85,372 @@ def test_input_output_schema_match(self): schema = get_entrypoints_schema(agent) assert schema["input"] == schema["output"] + + +class CityInfo(BaseModel): + """Structured output for city information.""" + + city: str + country: str + description: str + population_estimate: str + famous_for: list[str] + + +class TestStructuredOutputSchema: + """E2E tests for structured output schema inference via the runtime.""" + + def _make_structured_output_agent(self): + """Create the structured-output sample workflow agent.""" + os.environ.setdefault("UIPATH_URL", "https://fake.uipath.com") + os.environ.setdefault("UIPATH_ACCESS_TOKEN", "fake") + from uipath_agent_framework.chat import UiPathOpenAIChatClient + + client = UiPathOpenAIChatClient(model="gpt-5-mini-2025-08-07") + city_agent = client.as_agent( + name="city_agent", + instructions="You are a helpful agent that describes cities.", + default_options={"response_format": CityInfo}, + ) + workflow = WorkflowBuilder(start_executor=city_agent).build() + return workflow.as_agent(name="structured_output_workflow") + + def test_runtime_get_schema_has_structured_output(self): + """Runtime.get_schema() returns the CityInfo schema as output.""" + agent = self._make_structured_output_agent() + runtime = UiPathAgentFrameworkRuntime(agent=agent, entrypoint="agent") + + schema = asyncio.run(runtime.get_schema()) + + # Output should be CityInfo, not default messages + assert schema.output["type"] == "object" + assert "city" in schema.output["properties"] + assert "country" in schema.output["properties"] + assert "description" in schema.output["properties"] + assert "population_estimate" in schema.output["properties"] + assert "famous_for" in schema.output["properties"] + assert schema.output["properties"]["famous_for"]["type"] == "array" + assert "messages" not in schema.output.get("properties", {}) + + def test_runtime_get_schema_input_still_messages(self): + """Runtime.get_schema() input remains the default messages schema.""" + agent = self._make_structured_output_agent() + runtime = UiPathAgentFrameworkRuntime(agent=agent, entrypoint="agent") + + schema = asyncio.run(runtime.get_schema()) + + assert "messages" in schema.input["properties"] + assert schema.input["properties"]["messages"]["type"] == "array" + + def test_runtime_get_schema_without_response_format(self): + """Runtime.get_schema() falls back to messages output without response_format.""" + os.environ.setdefault("UIPATH_URL", "https://fake.uipath.com") + os.environ.setdefault("UIPATH_ACCESS_TOKEN", "fake") + from uipath_agent_framework.chat import UiPathOpenAIChatClient + + client = UiPathOpenAIChatClient(model="gpt-5-mini-2025-08-07") + agent = client.as_agent(name="basic_agent", instructions="test") + workflow = WorkflowBuilder(start_executor=agent).build() + wa = workflow.as_agent(name="basic_workflow") + runtime = UiPathAgentFrameworkRuntime(agent=wa, entrypoint="agent") + + schema = asyncio.run(runtime.get_schema()) + + assert "messages" in schema.output["properties"] + assert schema.input == schema.output + + +CITY_INFO_JSON = json.dumps( + { + "city": "Tokyo", + "country": "Japan", + "description": "Capital of Japan", + "population_estimate": "14 million", + "famous_for": ["sushi", "technology", "cherry blossoms"], + } +) + + +class TestStructuredOutputResult: + """E2E tests for structured output runtime result.""" + + def test_execute_returns_structured_output_dict(self): + """Runtime.execute() returns structured dict, not messages wrapper.""" + mock_openai = AsyncMock() + mock_openai.chat.completions.create.return_value = make_mock_response( + CITY_INFO_JSON + ) + + client = OpenAIChatClient(model_id="mock-model", async_client=mock_openai) + city_agent = client.as_agent( + name="city_agent", + instructions="Describe cities in structured format.", + default_options={"response_format": CityInfo}, + ) + workflow = WorkflowBuilder(start_executor=city_agent).build() + agent = workflow.as_agent(name="structured_output_workflow") + + runtime = UiPathAgentFrameworkRuntime(agent=agent, entrypoint="agent") + runtime.chat = MagicMock() + runtime.chat.map_messages_to_input.return_value = "Tell me about Tokyo" + + result = asyncio.run( + runtime.execute( + input={ + "messages": [ + { + "role": "user", + "contentParts": [ + {"data": {"inline": "Tell me about Tokyo"}} + ], + } + ] + } + ) + ) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert isinstance(result.output, dict) + assert result.output["city"] == "Tokyo" + assert result.output["country"] == "Japan" + assert result.output["famous_for"] == ["sushi", "technology", "cherry blossoms"] + assert "messages" not in result.output + + def test_execute_without_response_format_returns_messages(self): + """Runtime.execute() wraps plain text output in messages.""" + mock_openai = AsyncMock() + mock_openai.chat.completions.create.return_value = make_mock_response( + "Tokyo is the capital of Japan." + ) + + client = OpenAIChatClient(model_id="mock-model", async_client=mock_openai) + agent = client.as_agent( + name="basic_agent", + instructions="Describe cities.", + ) + workflow = WorkflowBuilder(start_executor=agent).build() + wa = workflow.as_agent(name="basic_workflow") + + runtime = UiPathAgentFrameworkRuntime(agent=wa, entrypoint="agent") + runtime.chat = MagicMock() + runtime.chat.map_messages_to_input.return_value = "Tell me about Tokyo" + + result = asyncio.run( + runtime.execute( + input={ + "messages": [ + { + "role": "user", + "contentParts": [ + {"data": {"inline": "Tell me about Tokyo"}} + ], + } + ] + } + ) + ) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert isinstance(result.output, dict) and "messages" in result.output + + def test_streaming_returns_structured_output_dict(self): + """Runtime.stream() returns structured dict from streaming tokens. + + Matches production behavior where get_outputs() returns many + AgentResponseUpdate tokens instead of a single AgentResponse. + """ + mock_openai = AsyncMock() + mock_openai.chat.completions.create.side_effect = lambda *args, **kwargs: ( + make_chunked_streaming_response(CITY_INFO_JSON) + ) + + client = OpenAIChatClient(model_id="mock-model", async_client=mock_openai) + city_agent = client.as_agent( + name="city_agent", + instructions="Describe cities in structured format.", + default_options={"response_format": CityInfo}, + ) + workflow = WorkflowBuilder(start_executor=city_agent).build() + agent = workflow.as_agent(name="structured_output_workflow") + + runtime = UiPathAgentFrameworkRuntime(agent=agent, entrypoint="agent") + runtime.chat = MagicMock() + runtime.chat.map_messages_to_input.return_value = "Tell me about Tokyo" + runtime.chat.map_streaming_content.return_value = [] + runtime.chat.close_message.return_value = [] + + async def run_stream(): + result = None + async for event in runtime.stream( + input={ + "messages": [ + { + "role": "user", + "contentParts": [ + {"data": {"inline": "Tell me about Tokyo"}} + ], + } + ] + } + ): + if isinstance(event, UiPathRuntimeResult): + result = event + return result + + result = asyncio.run(run_stream()) + + assert result is not None + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert isinstance(result.output, dict) + assert result.output["city"] == "Tokyo" + assert result.output["country"] == "Japan" + assert result.output["famous_for"] == ["sushi", "technology", "cherry blossoms"] + assert "messages" not in result.output + + +class TestSequentialStructuredOutput: + """E2E tests for sequential workflow with structured output on the last agent.""" + + def _make_sequential_agent(self, mock_openai: AsyncMock): + """Create a sequential workflow where the last agent has response_format.""" + client = OpenAIChatClient(model_id="mock-model", async_client=mock_openai) + + writer = client.as_agent( + name="writer", + instructions="Write about cities.", + ) + reviewer = client.as_agent( + name="reviewer", + instructions="Review the writing.", + ) + editor = client.as_agent( + name="editor", + instructions="Edit into structured format.", + default_options={"response_format": CityInfo}, + ) + + workflow = SequentialBuilder( + participants=[writer, reviewer, editor], + ).build() + return workflow.as_agent(name="sequential_structured") + + def test_schema_has_structured_output(self): + """get_schema() on a sequential workflow returns the CityInfo schema as output.""" + mock_openai = AsyncMock() + agent = self._make_sequential_agent(mock_openai) + runtime = UiPathAgentFrameworkRuntime(agent=agent, entrypoint="agent") + + schema = asyncio.run(runtime.get_schema()) + + assert schema.output["type"] == "object" + assert "city" in schema.output["properties"] + assert "country" in schema.output["properties"] + assert "famous_for" in schema.output["properties"] + assert "messages" not in schema.output.get("properties", {}) + + def test_streaming_returns_structured_output_dict(self): + """Sequential workflow streaming returns structured dict when the last + agent has response_format, not messages wrapper. + + Simulates a real e2e flow: three agents run in sequence, the last one + produces structured JSON. The runtime should parse and return a dict. + """ + mock_openai = AsyncMock() + + call_idx = 0 + texts = [ + "Tokyo is an amazing, vibrant city in Japan!", + "Great draft. Consider adding population and famous landmarks.", + CITY_INFO_JSON, + ] + + def mock_create(*args, **kwargs): + nonlocal call_idx + text = texts[call_idx] + call_idx += 1 + return make_chunked_streaming_response(text) + + mock_openai.chat.completions.create.side_effect = mock_create + + agent = self._make_sequential_agent(mock_openai) + + runtime = UiPathAgentFrameworkRuntime(agent=agent, entrypoint="agent") + runtime.chat = MagicMock() + runtime.chat.map_messages_to_input.return_value = "Tell me about Tokyo" + runtime.chat.map_streaming_content.return_value = [] + runtime.chat.close_message.return_value = [] + + async def run_stream(): + result = None + async for event in runtime.stream( + input={ + "messages": [ + { + "role": "user", + "contentParts": [ + {"data": {"inline": "Tell me about Tokyo"}} + ], + } + ] + } + ): + if isinstance(event, UiPathRuntimeResult): + result = event + return result + + result = asyncio.run(run_stream()) + + assert result is not None + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert isinstance(result.output, dict) + assert result.output["city"] == "Tokyo" + assert result.output["country"] == "Japan" + assert result.output["famous_for"] == ["sushi", "technology", "cherry blossoms"] + assert "messages" not in result.output + + def test_execute_returns_structured_output_dict(self): + """Sequential workflow execute() also returns structured dict.""" + mock_openai = AsyncMock() + + call_idx = 0 + texts = [ + "Tokyo is an amazing city!", + "Good draft, add more details.", + CITY_INFO_JSON, + ] + + def mock_create(*args, **kwargs): + nonlocal call_idx + text = texts[call_idx] + call_idx += 1 + return make_mock_response(text) + + mock_openai.chat.completions.create.side_effect = mock_create + + agent = self._make_sequential_agent(mock_openai) + + runtime = UiPathAgentFrameworkRuntime(agent=agent, entrypoint="agent") + runtime.chat = MagicMock() + runtime.chat.map_messages_to_input.return_value = "Tell me about Tokyo" + + result = asyncio.run( + runtime.execute( + input={ + "messages": [ + { + "role": "user", + "contentParts": [ + {"data": {"inline": "Tell me about Tokyo"}} + ], + } + ] + } + ) + ) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert isinstance(result.output, dict) + assert result.output["city"] == "Tokyo" + assert result.output["country"] == "Japan" + assert result.output["famous_for"] == ["sushi", "technology", "cherry blossoms"] + assert "messages" not in result.output diff --git a/packages/uipath-agent-framework/uv.lock b/packages/uipath-agent-framework/uv.lock index 4a75135..ba286ca 100644 --- a/packages/uipath-agent-framework/uv.lock +++ b/packages/uipath-agent-framework/uv.lock @@ -2460,7 +2460,7 @@ wheels = [ [[package]] name = "uipath-agent-framework" -version = "0.0.7" +version = "0.0.8" source = { editable = "." } dependencies = [ { name = "agent-framework-core" },