From 5a5133cb2bec806e4a06e887702d76bc1e891a16 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 16 Apr 2026 15:23:08 +0900 Subject: [PATCH 01/16] Fix orchestration outputs so as_agent() returns the final answer only. Align other orchestration outputs --- .../core/agent_framework/_workflows/_agent.py | 17 +- .../_workflows/_agent_executor.py | 12 ++ .../tests/workflow/test_agent_executor.py | 58 +++++++ .../_base_group_chat_orchestrator.py | 20 ++- .../_concurrent.py | 51 +++--- .../_group_chat.py | 19 ++- .../_handoff.py | 11 +- .../_magentic.py | 36 ++-- .../_orchestration_request_info.py | 10 +- .../_sequential.py | 119 +++++++------ .../orchestrations/tests/test_concurrent.py | 50 +++--- .../orchestrations/tests/test_group_chat.py | 160 ++++++++---------- .../orchestrations/tests/test_handoff.py | 32 ++-- .../orchestrations/tests/test_magentic.py | 43 +++-- .../orchestrations/tests/test_sequential.py | 151 +++++++++++++---- .../agents/sequential_workflow_as_agent.py | 41 ++--- 16 files changed, 492 insertions(+), 338 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 2fd3f35213..0ada82f4e1 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -300,7 +300,9 @@ async def _run_impl( function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ): - if event.type == "output" or event.type == "request_info": + if event.type in ("output", "request_info") or ( + event.type == "data" and isinstance(event.data, (AgentResponse, AgentResponseUpdate)) + ): output_events.append(event) result = self._convert_workflow_events_to_agent_response(response_id, output_events) @@ -618,16 +620,17 @@ def _convert_workflow_event_to_agent_response_updates( ) -> list[AgentResponseUpdate]: """Convert a workflow event to a list of AgentResponseUpdate objects. - Events with type='output' and type='request_info' are processed. - Other workflow events are ignored as they are workflow-internal. - - For 'output' events, AgentExecutor yields AgentResponseUpdate for streaming updates - via ctx.yield_output(). This method converts those to agent response updates. + Processes `output` and `request_info` events, plus `data` events carrying + `AgentResponse` or `AgentResponseUpdate` (emitted by orchestrations to surface + intermediate participants when `intermediate_outputs=True`). Other event types + are workflow-internal and ignored. Returns: A list of AgentResponseUpdate objects. Empty list if the event is not relevant. """ - if event.type == "output": + if event.type == "output" or ( + event.type == "data" and isinstance(event.data, (AgentResponse, AgentResponseUpdate)) + ): # Convert workflow output to agent response updates. # Handle different data types appropriately. data = event.data diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 2bcc6d355e..3efc3f507b 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -15,6 +15,7 @@ from .._types import AgentResponse, AgentResponseUpdate, Message, ResponseStream from ._agent_utils import resolve_agent_id from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY +from ._events import WorkflowEvent from ._executor import Executor, handler from ._message_utils import normalize_messages_input from ._request_info_mixin import response_handler @@ -85,6 +86,7 @@ def __init__( id: str | None = None, context_mode: Literal["full", "last_agent", "custom"] | None = None, context_filter: Callable[[list[Message]], list[Message]] | None = None, + emit_intermediate_data: bool = False, ): """Initialize the executor with a unique identifier. @@ -102,6 +104,10 @@ def __init__( as context for the agent run. context_filter: An optional function for filtering conversation context when context_mode is set to "custom". + emit_intermediate_data: When True, additionally emits `data` events (via + `WorkflowEvent.emit`) carrying each AgentResponse / AgentResponseUpdate alongside + the existing `output` events. Orchestrations use this to surface intermediate + participants while reserving `output` events for the workflow's final answer. """ # Prefer provided id; else use agent.name if present; else generate deterministic prefix exec_id = id or resolve_agent_id(agent) @@ -127,6 +133,8 @@ def __init__( if self._context_mode == "custom" and not self._context_filter: raise ValueError("context_filter must be provided when context_mode is set to 'custom'.") + self._emit_intermediate_data = emit_intermediate_data + @property def agent(self) -> SupportsAgentRun: """Get the underlying agent wrapped by this executor.""" @@ -355,6 +363,8 @@ async def _run_agent(self, ctx: WorkflowContext[Never, AgentResponse]) -> AgentR client_kwargs=client_kwargs, ) await ctx.yield_output(response) + if self._emit_intermediate_data: + await ctx.add_event(WorkflowEvent.emit(self.id, response)) # Handle any user input requests if response.user_input_requests: @@ -398,6 +408,8 @@ async def _run_agent_streaming(self, ctx: WorkflowContext[Never, AgentResponseUp async for update in stream: updates.append(update) await ctx.yield_output(update) + if self._emit_intermediate_data: + await ctx.add_event(WorkflowEvent.emit(self.id, update)) if update.user_input_requests: streamed_user_input_requests.extend(update.user_input_requests) diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 5ffd60aa55..d7a3f667e8 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -699,3 +699,61 @@ async def test_resolve_executor_kwargs_empty_per_executor_does_not_fallback_to_g resolved = {"exec_a": {}, GLOBAL_KWARGS_KEY: {"global_key": "global_val"}} result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage] assert result == {} + + +async def test_emit_intermediate_data_emits_data_events_non_streaming() -> None: + """When emit_intermediate_data=True, AgentExecutor emits a data event with the AgentResponse.""" + agent = _CountingAgent(id="agent_a", name="AgentA") + executor = AgentExecutor(agent, id="exec_a", emit_intermediate_data=True) + workflow = WorkflowBuilder(start_executor=executor).build() + + output_events: list[WorkflowEvent[Any]] = [] + data_events: list[WorkflowEvent[Any]] = [] + for event in await workflow.run("hello"): + if event.type == "output": + output_events.append(event) + elif event.type == "data": + data_events.append(event) + + # Output event still emitted (existing behavior unchanged) + assert len(output_events) == 1 + assert isinstance(output_events[0].data, AgentResponse) + # Plus a parallel data event with the same AgentResponse payload + assert len(data_events) == 1 + assert data_events[0].executor_id == "exec_a" + assert isinstance(data_events[0].data, AgentResponse) + assert data_events[0].data.messages[0].text == output_events[0].data.messages[0].text + + +async def test_emit_intermediate_data_emits_data_events_streaming() -> None: + """When emit_intermediate_data=True and streaming, data events accompany each AgentResponseUpdate.""" + agent = _CountingAgent(id="agent_a", name="AgentA") + executor = AgentExecutor(agent, id="exec_a", emit_intermediate_data=True) + workflow = WorkflowBuilder(start_executor=executor).build() + + output_updates: list[WorkflowEvent[Any]] = [] + data_updates: list[WorkflowEvent[Any]] = [] + async for event in workflow.run("hello", stream=True): + if event.type == "output": + output_updates.append(event) + elif event.type == "data": + data_updates.append(event) + + assert output_updates and all(isinstance(e.data, AgentResponseUpdate) for e in output_updates) + assert len(data_updates) == len(output_updates) + assert all(isinstance(e.data, AgentResponseUpdate) for e in data_updates) + assert all(e.executor_id == "exec_a" for e in data_updates) + + +async def test_emit_intermediate_data_default_false_no_data_events() -> None: + """When emit_intermediate_data is not set, no extra data events are emitted (default behavior).""" + agent = _CountingAgent(id="agent_a", name="AgentA") + executor = AgentExecutor(agent, id="exec_a") # default: emit_intermediate_data=False + workflow = WorkflowBuilder(start_executor=executor).build() + + data_events: list[WorkflowEvent[Any]] = [] + for event in await workflow.run("hello"): + if event.type == "data": + data_events.append(event) + + assert data_events == [] diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py index 86c85cc079..2cca41d9ee 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import Any, ClassVar, TypeAlias -from agent_framework._types import Message +from agent_framework._types import AgentResponse, Message from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._events import WorkflowEvent from agent_framework._workflows._executor import Executor, handler @@ -351,8 +351,8 @@ async def _check_termination(self) -> bool: result = await result return result - async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: - """Check termination conditions and yield completion if met. + async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, AgentResponse]) -> bool: + """Check termination conditions and yield the completion message if met. Args: ctx: Workflow context for yielding output @@ -362,8 +362,9 @@ async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[Mess """ terminate = await self._check_termination() if terminate: - self._append_messages([self._create_completion_message(self.TERMINATION_CONDITION_MET_MESSAGE)]) - await ctx.yield_output(self._full_conversation) + completion_message = self._create_completion_message(self.TERMINATION_CONDITION_MET_MESSAGE) + self._append_messages([completion_message]) + await ctx.yield_output(AgentResponse(messages=[completion_message])) return True return False @@ -490,8 +491,8 @@ def _check_round_limit(self) -> bool: return False - async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: - """Check round limit and yield completion if reached. + async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, AgentResponse]) -> bool: + """Check round limit and yield the max-rounds completion message if reached. Args: ctx: Workflow context for yielding output @@ -501,8 +502,9 @@ async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, list[Me """ reach_max_rounds = self._check_round_limit() if reach_max_rounds: - self._append_messages([self._create_completion_message(self.MAX_ROUNDS_MET_MESSAGE)]) - await ctx.yield_output(self._full_conversation) + completion_message = self._create_completion_message(self.MAX_ROUNDS_MET_MESSAGE) + self._append_messages([completion_message]) + await ctx.yield_output(AgentResponse(messages=[completion_message])) return True return False diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py index d73b7e322b..f3f8c5e5e2 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence from typing import Any -from agent_framework import Message, SupportsAgentRun +from agent_framework import AgentResponse, Message, SupportsAgentRun from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage @@ -71,18 +71,19 @@ async def from_messages( class _AggregateAgentConversations(Executor): - """Aggregates agent responses and completes with combined ChatMessages. + """Aggregates agent responses and completes with a single AgentResponse. - Emits a list[Message] shaped as: - [ single_user_prompt?, agent1_final_assistant, agent2_final_assistant, ... ] + Emits an `AgentResponse` whose `messages` are the final assistant message from each + participant (one message per agent), in the order participants completed. The + user prompt is intentionally not included — that is part of the input, not the answer. - - Extracts a single user prompt (first user message seen across results). - - For each result, selects the final assistant message (prefers agent_response.messages). - - Avoids duplicating the same user message per agent. + For each participant the final assistant message is sourced from + `r.agent_response.messages`, falling back to scanning `r.full_conversation` for + pathological executors that did not populate the response. """ @handler - async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, list[Message]]) -> None: + async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, AgentResponse]) -> None: if not results: logger.error("Concurrent aggregator received empty results list") raise ValueError("Aggregation failed: no results provided") @@ -91,12 +92,10 @@ def _is_role(msg: Any, role: str) -> bool: r = getattr(msg, "role", None) if r is None: return False - # Normalize both r and role to lowercase strings for comparison r_str = str(r).lower() if isinstance(r, str) or hasattr(r, "__str__") else r role_str = str(role).lower() return r_str == role_str - prompt_message: Message | None = None assistant_replies: list[Message] = [] for r in results: @@ -107,10 +106,6 @@ def _is_role(msg: Any, role: str) -> bool: f"{len(resp_messages)} response msgs, {len(r.full_conversation)} conversation msgs" ) - # Capture a single user prompt (first encountered across any conversation) - if prompt_message is None: - prompt_message = next((m for m in r.full_conversation if _is_role(m, "user")), None) - # Pick the final assistant message from the response; fallback to conversation search final_assistant = next((m for m in reversed(resp_messages) if _is_role(m, "assistant")), None) if final_assistant is None: @@ -127,14 +122,7 @@ def _is_role(msg: Any, role: str) -> bool: logger.error(f"Aggregation failed: no assistant replies found across {len(results)} results") raise RuntimeError("Aggregation failed: no assistant replies found") - output: list[Message] = [] - if prompt_message is not None: - output.append(prompt_message) - else: - logger.warning("No user prompt found in any conversation; emitting assistants only") - output.extend(assistant_replies) - - await ctx.yield_output(output) + await ctx.yield_output(AgentResponse(messages=assistant_replies)) class _CallbackAggregator(Executor): @@ -190,7 +178,8 @@ class ConcurrentBuilder: from agent_framework_orchestrations import ConcurrentBuilder - # Minimal: use default aggregator (returns list[Message]) + # Minimal: use default aggregator (yields one AgentResponse with one assistant + # message per participant) workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3]).build() @@ -351,7 +340,13 @@ def with_request_info( return self def _resolve_participants(self) -> list[Executor]: - """Resolve participant instances into Executor objects.""" + """Resolve participant instances into Executor objects. + + When `intermediate_outputs=True`, every wrapped agent is constructed with + `emit_intermediate_data=True` so its individual response surfaces as a `data` + event without polluting the single `output` event reserved for the aggregator's + final answer. + """ if not self._participants: raise ValueError("No participants provided. Pass participants to the constructor.") @@ -366,9 +361,9 @@ def _resolve_participants(self) -> list[Executor]: not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(p)) + executors.append(AgentApprovalExecutor(p, emit_intermediate_data=self._intermediate_outputs)) else: - executors.append(AgentExecutor(p)) + executors.append(AgentExecutor(p, emit_intermediate_data=self._intermediate_outputs)) else: raise TypeError(f"Participants must be SupportsAgentRun or Executor instances. Got {type(p).__name__}.") @@ -383,7 +378,7 @@ def build(self) -> Workflow: - If request info is enabled, the orchestration emits a request info event with outputs from all participants before sending the outputs to the aggregator - Aggregator yields output and the workflow becomes idle. The output is either: - - list[Message] (default aggregator: one user + one assistant per agent) + - AgentResponse (default aggregator: one assistant message per participant) - custom payload from the provided aggregator Returns: @@ -408,7 +403,7 @@ def build(self) -> Workflow: builder = WorkflowBuilder( start_executor=dispatcher, checkpoint_storage=self._checkpoint_storage, - output_executors=[aggregator] if not self._intermediate_outputs else None, + output_executors=[aggregator], ) # Fan-out for parallel execution builder.add_fan_out_edges(dispatcher, participants) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index 4f1c2f832a..3b9f19555c 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -29,7 +29,7 @@ from dataclasses import dataclass from typing import Any, ClassVar, cast -from agent_framework import Agent, AgentSession, Message, SupportsAgentRun +from agent_framework import Agent, AgentResponse, AgentSession, Message, SupportsAgentRun from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage @@ -522,9 +522,9 @@ async def _invoke_agent_helper(conversation: list[Message]) -> AgentOrchestratio async def _check_agent_terminate_and_yield( self, agent_orchestration_output: AgentOrchestrationOutput, - ctx: WorkflowContext[Never, list[Message]], + ctx: WorkflowContext[Never, AgentResponse], ) -> bool: - """Check if the agent requested termination and yield completion if so. + """Yield the orchestrator's completion `AgentResponse` if termination was requested. Args: agent_orchestration_output: Output from the orchestrator agent @@ -536,8 +536,9 @@ async def _check_agent_terminate_and_yield( final_message = ( agent_orchestration_output.final_message or "The conversation has been terminated by the agent." ) - self._append_messages([self._create_completion_message(final_message)]) - await ctx.yield_output(self._full_conversation) + completion_message = self._create_completion_message(final_message) + self._append_messages([completion_message]) + await ctx.yield_output(AgentResponse(messages=[completion_message])) return True return False @@ -963,9 +964,11 @@ def _resolve_participants(self) -> list[Executor]: not self._request_info_filter or resolve_agent_id(participant) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(participant)) + executors.append( + AgentApprovalExecutor(participant, emit_intermediate_data=self._intermediate_outputs) + ) else: - executors.append(AgentExecutor(participant)) + executors.append(AgentExecutor(participant, emit_intermediate_data=self._intermediate_outputs)) else: raise TypeError( f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}." @@ -991,7 +994,7 @@ def build(self) -> Workflow: workflow_builder = WorkflowBuilder( start_executor=orchestrator, checkpoint_storage=self._checkpoint_storage, - output_executors=[orchestrator] if not self._intermediate_outputs else None, + output_executors=[orchestrator], ) for participant in participants: # Orchestrator and participant bi-directional edges diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index c3e156096c..6c06c0bd84 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -447,10 +447,10 @@ async def handle_response( response: The user's response messages ctx: The workflow context - If the response is empty, it indicates termination of the handoff workflow. + If the response is empty, the handoff workflow terminates. Per-agent responses + already surfaced as `output` events; no terminal yield is needed. """ if not response: - await ctx.yield_output(self._full_conversation) return # Broadcast the user response to all other agents @@ -536,11 +536,8 @@ async def _check_terminate_and_yield(self, ctx: WorkflowContext[Any, Any]) -> bo if inspect.isawaitable(terminated): terminated = await terminated - if terminated: - await ctx.yield_output(self._full_conversation) - return True - - return False + # Per-agent responses already surfaced as `output` events; no terminal yield needed. + return bool(terminated) @override async def on_checkpoint_save(self) -> dict[str, Any]: diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index 80031cd726..0ce14444c7 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -1192,23 +1192,23 @@ async def _run_outer_loop( # Start inner loop await self._run_inner_loop(ctx) - async def _prepare_final_answer(self, ctx: WorkflowContext[Never, list[Message]]) -> None: - """Prepare the final answer using the manager.""" + async def _prepare_final_answer(self, ctx: WorkflowContext[Never, AgentResponse]) -> None: + """Yield the manager's synthesized final answer as the workflow's `AgentResponse`.""" if self._magentic_context is None: raise RuntimeError("Context not initialized") logger.info("Magentic Orchestrator: Preparing final answer") final_answer = await self._manager.prepare_final_answer(self._magentic_context.clone(deep=True)) - # Emit a completed event for the workflow - await ctx.yield_output([final_answer]) + await ctx.yield_output(AgentResponse(messages=[final_answer])) self._terminated = True - async def _check_within_limits_or_complete(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: + async def _check_within_limits_or_complete(self, ctx: WorkflowContext[Never, AgentResponse]) -> bool: """Check if orchestrator is within operational limits. - If limits are exceeded, yield a termination message and mark the workflow as terminated. + If limits are exceeded, yield a termination AgentResponse and mark the workflow + as terminated. Args: ctx: The workflow context. @@ -1229,15 +1229,12 @@ async def _check_within_limits_or_complete(self, ctx: WorkflowContext[Never, lis limit_type = "round" if hit_round_limit else "reset" logger.error(f"Magentic Orchestrator: Max {limit_type} count reached") - # Yield the full conversation with an indication of termination due to limits - await ctx.yield_output([ - *self._magentic_context.chat_history, - Message( - role="assistant", - contents=[f"Workflow terminated due to reaching maximum {limit_type} count."], - author_name=MAGENTIC_MANAGER_NAME, - ), - ]) + termination_message = Message( + role="assistant", + contents=[f"Workflow terminated due to reaching maximum {limit_type} count."], + author_name=MAGENTIC_MANAGER_NAME, + ) + await ctx.yield_output(AgentResponse(messages=[termination_message])) self._terminated = True return False @@ -1316,7 +1313,7 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: class MagenticAgentExecutor(AgentExecutor): """Specialized AgentExecutor for Magentic agent participants.""" - def __init__(self, agent: SupportsAgentRun) -> None: + def __init__(self, agent: SupportsAgentRun, *, emit_intermediate_data: bool = False) -> None: """Initialize a Magentic Agent Executor. This executor wraps an SupportsAgentRun instance to be used as a participant @@ -1324,13 +1321,14 @@ def __init__(self, agent: SupportsAgentRun) -> None: Args: agent: The agent instance to wrap. + emit_intermediate_data: Forwarded to the base AgentExecutor. Notes: Magentic pattern requires a reset operation upon replanning. This executor extends the base AgentExecutor to handle resets appropriately. In order to handle resets, the agent threads and other states are reset when requested by the orchestrator. And because of this, MagenticAgentExecutor does not support custom threads. """ - super().__init__(agent) + super().__init__(agent, emit_intermediate_data=emit_intermediate_data) @handler async def handle_magentic_reset(self, signal: MagenticResetSignal, ctx: WorkflowContext) -> None: @@ -1741,7 +1739,7 @@ def _resolve_participants(self) -> list[Executor]: if isinstance(participant, Executor): executors.append(participant) elif isinstance(participant, SupportsAgentRun): - executors.append(MagenticAgentExecutor(participant)) + executors.append(MagenticAgentExecutor(participant, emit_intermediate_data=self._intermediate_outputs)) else: raise TypeError( f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}." @@ -1760,7 +1758,7 @@ def build(self) -> Workflow: workflow_builder = WorkflowBuilder( start_executor=orchestrator, checkpoint_storage=self._checkpoint_storage, - output_executors=[orchestrator] if not self._intermediate_outputs else None, + output_executors=[orchestrator], ) for participant in participants: # Orchestrator and participant bi-directional edges diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py index e78d1bef14..30601e587c 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py @@ -122,21 +122,29 @@ def __init__( self, agent: SupportsAgentRun, context_mode: Literal["full", "last_agent", "custom"] | None = None, + *, + emit_intermediate_data: bool = False, ) -> None: """Initialize the AgentApprovalExecutor. Args: agent: The agent protocol to use for generating responses. context_mode: The mode for providing context to the agent. + emit_intermediate_data: Forwarded to the inner AgentExecutor. """ self._context_mode: Literal["full", "last_agent", "custom"] | None = context_mode self._description = agent.description + self._emit_intermediate_data = emit_intermediate_data super().__init__(workflow=self._build_workflow(agent), id=resolve_agent_id(agent), propagate_request=True) def _build_workflow(self, agent: SupportsAgentRun) -> Workflow: """Build the internal workflow for the AgentApprovalExecutor.""" - agent_executor = AgentExecutor(agent, context_mode=self._context_mode) + agent_executor = AgentExecutor( + agent, + context_mode=self._context_mode, + emit_intermediate_data=self._emit_intermediate_data, + ) request_info_executor = AgentRequestInfoExecutor(id="agent_request_info_executor") return ( diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index bda7f194ab..6de6e7ff48 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -2,39 +2,17 @@ """Sequential builder for agent/executor workflows with shared conversation context. -This module provides a high-level, agent-focused API to assemble a sequential -workflow where: -- Participants are provided as SupportsAgentRun or Executor instances via `participants=[...]` -- A shared conversation context (list[Message]) is passed along the chain -- Agents append their assistant messages to the context -- Custom executors can transform or summarize and return a refined context -- The workflow finishes with the final context produced by the last participant - -Typical wiring: - input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> - ... -> participantN -> _EndWithConversation - -Notes: -- Participants can mix SupportsAgentRun and Executor objects -- Agents are auto-wrapped by WorkflowBuilder as AgentExecutor (unless already wrapped) -- AgentExecutor produces AgentExecutorResponse; _ResponseToConversation converts this to list[Message] -- Non-agent executors must define a handler that consumes `list[Message]` and sends back - the updated `list[Message]` via their workflow context - -Why include the small internal adapter executors? -- Input normalization ("input-conversation"): ensures the workflow always starts with a - `list[Message]` regardless of whether callers pass a `str`, a single `Message`, - or a list. This keeps the first hop strongly typed and avoids boilerplate in participants. -- Agent response adaptation ("to-conversation:"): agents (via AgentExecutor) - emit `AgentExecutorResponse`. The adapter converts that to a `list[Message]` - using `full_conversation` so original prompts aren't lost when chaining. -- Result output ("end"): yields the final conversation list and the workflow becomes idle - giving a consistent terminal payload shape for both agents and custom executors. - -These adapters are first-class executors by design so they are type-checked at edges, -observable (ExecutorInvoke/Completed events), and easily testable/reusable. Their IDs are -deterministic and self-describing (for example, "to-conversation:writer") to reduce event-log -confusion and to mirror how the concurrent builder uses explicit dispatcher/aggregator nodes. +Participants (SupportsAgentRun or Executor instances) run in order, sharing a +conversation along the chain. Agents append their assistant messages; custom executors +transform and return a refined `list[Message]`. + +Wiring: input -> _InputToConversation -> participant1 -> ... -> participantN -> _EndWithConversation + +The workflow's final `output` event is either the last agent's `AgentResponse` (when the +terminator is an agent) or the custom executor's `list[Message]`. With +`intermediate_outputs=True`, intermediate agents emit `data` events (via +`AgentExecutor.emit_intermediate_data`) so consumers can observe them separately from the +terminal answer. """ import logging @@ -79,7 +57,20 @@ async def from_messages(self, messages: list[str | Message], ctx: WorkflowContex class _EndWithConversation(Executor): - """Terminates the workflow by emitting the final conversation context.""" + """Graph terminator for the sequential workflow. + + For custom-executor terminators, this emits the final `list[Message]` as an `output` + event (the executor's own contract). For agent terminators it is a passive sink: the + last `AgentExecutor` is itself registered as the workflow's output executor in + `SequentialBuilder.build()`, so its `yield_output` calls — a single `AgentResponse` + non-streaming, or per-chunk `AgentResponseUpdate` events streaming — become the + workflow's outputs directly. + + Intermediate participants emit observation `data` events (via + `AgentExecutor.emit_intermediate_data`) when `intermediate_outputs=True`; they never + emit `output` events because output_executors is restricted to the terminator + executor (the last agent or this node). + """ @handler async def end_with_messages( @@ -87,23 +78,17 @@ async def end_with_messages( conversation: list[Message], ctx: WorkflowContext[Any, list[Message]], ) -> None: - """Handler for ending with a list of Message. - - This is used when the last participant is a custom executor. - """ + """Yield the final conversation when the last participant is a custom executor.""" await ctx.yield_output(list(conversation)) @handler async def end_with_agent_executor_response( self, response: AgentExecutorResponse, - ctx: WorkflowContext[Any, list[Message] | None], + ctx: WorkflowContext[Any], ) -> None: - """Handle case where last participant is an agent. - - The agent is wrapped by AgentExecutor and emits AgentExecutorResponse. - """ - await ctx.yield_output(response.full_conversation) + """Sink for the agent-terminator graph edge; the last AgentExecutor is the output.""" + return class SequentialBuilder: @@ -225,7 +210,13 @@ def with_request_info( return self def _resolve_participants(self) -> list[Executor]: - """Resolve participant instances into Executor objects.""" + """Resolve participant instances into Executor objects. + + Wraps `SupportsAgentRun` participants as `AgentExecutor`. When `intermediate_outputs=True`, + every wrapped agent except the final one is constructed with `emit_intermediate_data=True` + so its responses surface as workflow `data` events without polluting the single `output` + event reserved for the final answer. + """ if not self._participants: raise ValueError("No participants provided. Pass participants to the constructor.") @@ -235,18 +226,32 @@ def _resolve_participants(self) -> list[Executor]: "last_agent" if self._chain_only_agent_responses else None ) + last_idx = len(participants) - 1 executors: list[Executor] = [] - for p in participants: + for idx, p in enumerate(participants): if isinstance(p, Executor): executors.append(p) elif isinstance(p, SupportsAgentRun): + emit_intermediate = self._intermediate_outputs and idx != last_idx if self._request_info_enabled and ( not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(p, context_mode=context_mode)) + executors.append( + AgentApprovalExecutor( + p, + context_mode=context_mode, + emit_intermediate_data=emit_intermediate, + ) + ) else: - executors.append(AgentExecutor(p, context_mode=context_mode)) + executors.append( + AgentExecutor( + p, + context_mode=context_mode, + emit_intermediate_data=emit_intermediate, + ) + ) else: raise TypeError(f"Participants must be SupportsAgentRun or Executor instances. Got {type(p).__name__}.") @@ -258,11 +263,12 @@ def build(self) -> Workflow: Wiring pattern: - _InputToConversation normalizes the initial input into list[Message] - For each participant in order: - - If Agent (or AgentExecutor): pass conversation to the agent, then optionally - route through a request info interceptor, then convert response to conversation - via _ResponseToConversation - - Else (custom Executor): pass conversation directly to the executor - - _EndWithConversation yields the final conversation and the workflow becomes idle + - Agent or AgentExecutor: receives the conversation/AgentExecutorResponse, + produces an AgentExecutorResponse forwarded downstream + - Custom Executor: receives list[Message] and forwards a list[Message] + - The workflow's `output_executor` is selected based on the last participant: + - Agent terminator: the last AgentExecutor itself (its yield_output is the answer) + - Custom-executor terminator: `_EndWithConversation` (yields the final list[Message]) """ # Internal nodes input_conv = _InputToConversation(id="input-conversation") @@ -271,10 +277,15 @@ def build(self) -> Workflow: # Resolve participants and participant factories to executors participants: list[Executor] = self._resolve_participants() + last_executor = participants[-1] + output_executors: list[Executor | SupportsAgentRun] = [ + last_executor if isinstance(last_executor, AgentExecutor) else end + ] + builder = WorkflowBuilder( start_executor=input_conv, checkpoint_storage=self._checkpoint_storage, - output_executors=[end] if not self._intermediate_outputs else None, + output_executors=output_executors, ) # Start of the chain is the input normalizer diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py index 7d9a2bc534..0cfaee6519 100644 --- a/python/packages/orchestrations/tests/test_concurrent.py +++ b/python/packages/orchestrations/tests/test_concurrent.py @@ -49,36 +49,26 @@ def test_concurrent_builder_rejects_duplicate_executors() -> None: ConcurrentBuilder(participants=[a, b]) -async def test_concurrent_default_aggregator_emits_single_user_and_assistants() -> None: - # Three synthetic agent executors +async def test_concurrent_default_aggregator_emits_assistants_only() -> None: + """Default aggregator yields a single AgentResponse with one assistant message per participant. + + The user prompt is intentionally not included — that belongs in the input, not the answer. + """ e1 = _FakeAgentExec("agentA", "Alpha") e2 = _FakeAgentExec("agentB", "Beta") e3 = _FakeAgentExec("agentC", "Gamma") wf = ConcurrentBuilder(participants=[e1, e2, e3]).build() - completed = False - output: list[Message] | None = None - async for ev in wf.run("prompt: hello world", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = cast(list[Message], ev.data) - if completed and output is not None: - break - - assert completed - assert output is not None - messages: list[Message] = output - - # Expect one user message + one assistant message per participant - assert len(messages) == 1 + 3 - assert messages[0].role == "user" - assert "hello world" in messages[0].text + output_events = [ev for ev in await wf.run("prompt: hello world") if ev.type == "output"] + assert len(output_events) == 1 + response = output_events[0].data + assert isinstance(response, AgentResponse) - assistant_texts = {m.text for m in messages[1:]} - assert assistant_texts == {"Alpha", "Beta", "Gamma"} - assert all(m.role == "assistant" for m in messages[1:]) + # Exactly one assistant message per participant; no user prompt. + assert len(response.messages) == 3 + assert all(m.role == "assistant" for m in response.messages) + assert {m.text for m in response.messages} == {"Alpha", "Beta", "Gamma"} async def test_concurrent_custom_aggregator_callback_is_used() -> None: @@ -215,7 +205,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: wf = ConcurrentBuilder(participants=list(participants), checkpoint_storage=storage).build() - baseline_output: list[Message] | None = None + baseline_output: AgentResponse | None = None async for ev in wf.run("checkpoint concurrent", stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -236,7 +226,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: ) wf_resume = ConcurrentBuilder(participants=list(resumed_participants), checkpoint_storage=storage).build() - resumed_output: list[Message] | None = None + resumed_output: AgentResponse | None = None async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True): if ev.type == "output": resumed_output = ev.data # type: ignore[assignment] @@ -247,8 +237,8 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: break assert resumed_output is not None - assert [m.role for m in resumed_output] == [m.role for m in baseline_output] - assert [m.text for m in resumed_output] == [m.text for m in baseline_output] + assert [m.role for m in resumed_output.messages] == [m.role for m in baseline_output.messages] + assert [m.text for m in resumed_output.messages] == [m.text for m in baseline_output.messages] async def test_concurrent_checkpoint_runtime_only() -> None: @@ -258,7 +248,7 @@ async def test_concurrent_checkpoint_runtime_only() -> None: agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] wf = ConcurrentBuilder(participants=agents).build() - baseline_output: list[Message] | None = None + baseline_output: AgentResponse | None = None async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -278,7 +268,7 @@ async def test_concurrent_checkpoint_runtime_only() -> None: resumed_agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] wf_resume = ConcurrentBuilder(participants=resumed_agents).build() - resumed_output: list[Message] | None = None + resumed_output: AgentResponse | None = None async for ev in wf_resume.run( checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True ): @@ -291,7 +281,7 @@ async def test_concurrent_checkpoint_runtime_only() -> None: break assert resumed_output is not None - assert [m.role for m in resumed_output] == [m.role for m in baseline_output] + assert [m.role for m in resumed_output.messages] == [m.role for m in baseline_output.messages] async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None: diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 2118de5ba7..0ae736cc4b 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -238,18 +238,18 @@ async def test_group_chat_builder_basic_flow() -> None: orchestrator_name="manager", ).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run("coordinate task", stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if isinstance(data, AgentResponse): + outputs.append(data) + # Exactly one terminal `output` event = the orchestrator's completion AgentResponse. assert len(outputs) == 1 - assert len(outputs[0]) >= 1 - # Check that both agents contributed - authors = {msg.author_name for msg in outputs[0] if msg.author_name in ["alpha", "beta"]} - assert len(authors) == 2 + assert outputs[0].messages + # The completion message is authored by the orchestrator. + assert outputs[0].messages[-1].author_name == "manager" async def test_group_chat_as_agent_accepts_conversation() -> None: @@ -283,18 +283,18 @@ async def test_agent_manager_handles_concatenated_json_output() -> None: orchestrator_agent=manager, ).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run("coordinate task", stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if isinstance(data, AgentResponse): + outputs.append(data) assert outputs - conversation = outputs[-1] - assert any(msg.author_name == "agent" and msg.text == "worker response" for msg in conversation) - assert conversation[-1].author_name == manager.name - assert conversation[-1].text == "concatenated manager final" + final_response = outputs[-1] + # Terminal AgentResponse contains only the orchestrator's completion message. + assert final_response.messages[-1].author_name == manager.name + assert final_response.messages[-1].text == "concatenated manager final" # Comprehensive tests for group chat functionality @@ -400,19 +400,16 @@ def selector(state: GroupChatState) -> str: selection_func=selector, ).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run("test task", stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) - - # Should have terminated due to max_rounds, expect at least one output - assert len(outputs) >= 1 - # The final message in the conversation should be about round limit - conversation = outputs[-1] - assert len(conversation) >= 1 - final_output = conversation[-1] + if isinstance(data, AgentResponse): + outputs.append(data) + + # Exactly one terminal output event = orchestrator's max-rounds completion message. + assert len(outputs) == 1 + final_output = outputs[0].messages[-1] assert "maximum number of rounds" in final_output.text.lower() async def test_termination_condition_halts_conversation(self) -> None: @@ -431,22 +428,25 @@ def termination_condition(conversation: list[Message]) -> bool: participants=[agent], termination_condition=termination_condition, selection_func=selector, + intermediate_outputs=True, ).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] + intermediate_updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponse): + outputs.append(event.data) + elif event.type == "data" and isinstance(event.data, AgentResponseUpdate): + intermediate_updates.append(event.data) assert outputs, "Expected termination to yield output" - conversation = outputs[-1] - agent_replies = [msg for msg in conversation if msg.author_name == "agent" and msg.role == "assistant"] - assert len(agent_replies) == 2 - final_output = conversation[-1] - # The orchestrator uses its ID as author_name by default + # Terminal output is the orchestrator's completion message only. + final_output = outputs[-1].messages[-1] assert "termination condition" in final_output.text.lower() + # Agent's intermediate replies surface as `data` events (per-update in streaming mode). + agent_updates = [u for u in intermediate_updates if u.author_name == "agent"] + # Each agent reply produces at least one update; expect 2 agent rounds before termination. + assert len(agent_updates) >= 2 async def test_termination_condition_agent_manager_finalizes(self) -> None: """Test that termination condition with agent orchestrator produces default termination message.""" @@ -459,17 +459,17 @@ async def test_termination_condition_agent_manager_finalizes(self) -> None: orchestrator_agent=manager, ).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run("test task", stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if isinstance(data, AgentResponse): + outputs.append(data) assert outputs, "Expected termination to yield output" - conversation = outputs[-1] - assert conversation[-1].text == BaseGroupChatOrchestrator.TERMINATION_CONDITION_MET_MESSAGE - assert conversation[-1].author_name == manager.name + final_message = outputs[-1].messages[-1] + assert final_message.text == BaseGroupChatOrchestrator.TERMINATION_CONDITION_MET_MESSAGE + assert final_message.author_name == manager.name async def test_unknown_participant_error(self) -> None: """Test that unknown participant selection raises error.""" @@ -505,12 +505,12 @@ def selector(state: GroupChatState) -> str: selection_func=selector, ).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run("test task", stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if isinstance(data, AgentResponse): + outputs.append(data) assert len(outputs) == 1 # Should complete normally @@ -546,12 +546,12 @@ def selector(state: GroupChatState) -> str: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run("test string", stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if isinstance(data, AgentResponse): + outputs.append(data) assert len(outputs) == 1 @@ -569,12 +569,12 @@ def selector(state: GroupChatState) -> str: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run(task_message, stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if isinstance(data, AgentResponse): + outputs.append(data) assert len(outputs) == 1 @@ -595,12 +595,12 @@ def selector(state: GroupChatState) -> str: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run(conversation, stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if isinstance(data, AgentResponse): + outputs.append(data) assert len(outputs) == 1 @@ -625,19 +625,16 @@ def selector(state: GroupChatState) -> str: selection_func=selector, ).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run("test", stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) - - # Should have at least one output (the round limit message) - assert len(outputs) >= 1 - # The last message in the conversation should be about round limit - conversation = outputs[-1] - assert len(conversation) >= 1 - final_output = conversation[-1] + if isinstance(data, AgentResponse): + outputs.append(data) + + # Exactly one terminal output event = orchestrator's max-rounds completion message. + assert len(outputs) == 1 + final_output = outputs[0].messages[-1] assert "maximum number of rounds" in final_output.text.lower() async def test_round_limit_in_ingest_participant_message(self) -> None: @@ -658,19 +655,16 @@ def selector(state: GroupChatState) -> str: selection_func=selector, ).build() - outputs: list[list[Message]] = [] + outputs: list[AgentResponse] = [] async for event in workflow.run("test", stream=True): if event.type == "output": data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) - - # Should have at least one output (the round limit message) - assert len(outputs) >= 1 - # The last message in the conversation should be about round limit - conversation = outputs[-1] - assert len(conversation) >= 1 - final_output = conversation[-1] + if isinstance(data, AgentResponse): + outputs.append(data) + + # Exactly one terminal output event = orchestrator's max-rounds completion message. + assert len(outputs) == 1 + final_output = outputs[0].messages[-1] assert "maximum number of rounds" in final_output.text.lower() @@ -684,10 +678,10 @@ async def test_group_chat_checkpoint_runtime_only() -> None: wf = GroupChatBuilder(participants=[agent_a, agent_b], max_rounds=2, selection_func=selector).build() - baseline_output: list[Message] | None = None + baseline_output: AgentResponse | None = None async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): - if ev.type == "output": - baseline_output = cast(list[Message], ev.data) if isinstance(ev.data, list) else None # type: ignore + if ev.type == "output" and isinstance(ev.data, AgentResponse): + baseline_output = ev.data if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, @@ -720,10 +714,10 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None: checkpoint_storage=buildtime_storage, selection_func=selector, ).build() - baseline_output: list[Message] | None = None + baseline_output: AgentResponse | None = None async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True): - if ev.type == "output": - baseline_output = cast(list[Message], ev.data) if isinstance(ev.data, list) else None # type: ignore + if ev.type == "output" and isinstance(ev.data, AgentResponse): + baseline_output = ev.data if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, @@ -975,13 +969,9 @@ def agent_factory() -> Agent: assert len(outputs) == 1 # The DynamicManagerAgent terminates after second call with final_message - final_messages = outputs[0].data - assert isinstance(final_messages, list) - assert any( - msg.text == "dynamic manager final" - for msg in cast(list[Message], final_messages) - if msg.author_name == "dynamic_manager" - ) + final_response = outputs[0].data + assert isinstance(final_response, AgentResponse) + assert any(msg.text == "dynamic manager final" for msg in final_response.messages) def test_group_chat_with_orchestrator_factory_returning_base_orchestrator(): diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index a512d9b9df..33eed34406 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -9,6 +9,8 @@ import pytest from agent_framework import ( Agent, + AgentResponse, + AgentResponseUpdate, ChatResponse, ChatResponseUpdate, Content, @@ -856,10 +858,15 @@ async def test_autonomous_mode_yields_output_without_user_request(): outputs = [ev for ev in events if ev.type == "output"] assert outputs, "Autonomous mode should yield a workflow output" - final_conversation = outputs[-1].data - assert isinstance(final_conversation, list) - conversation_list = cast(list[Message], final_conversation) - assert any(msg.role == "assistant" and (msg.text or "").startswith("specialist reply") for msg in conversation_list) + # Per-agent activity surfaces as `output` events from each HandoffAgentExecutor as they + # speak. Handoff has no orchestrator that produces a separate "answer" — the conversation + # IS the result. In streaming mode payloads are AgentResponseUpdate; combined text should + # contain the specialist's reply. + payloads = [ev.data for ev in outputs if isinstance(ev.data, (AgentResponse, AgentResponseUpdate))] + combined = " ".join( + getattr(p, "text", None) or " ".join(m.text for m in getattr(p, "messages", [])) for p in payloads + ) + assert "specialist reply" in combined async def test_autonomous_mode_resumes_user_input_on_turn_limit(): @@ -923,14 +930,10 @@ async def async_termination(conv: list[Message]) -> bool: stream=True, responses={requests[-1].request_id: [Message(role="user", contents=["Second user message"])]} ) ) - outputs = [ev for ev in events if ev.type == "output"] - assert len(outputs) == 1 - - final_conversation = outputs[0].data - assert isinstance(final_conversation, list) - final_conv_list = cast(list[Message], final_conversation) - user_messages = [msg for msg in final_conv_list if msg.role == "user"] - assert len(user_messages) == 2 + # Resume run terminates without further agent activity once the second user message + # satisfies the termination condition. The workflow returns to idle cleanly. + idle_states = [ev for ev in events if ev.type == "status" and ev.state == WorkflowRunState.IDLE] + assert idle_states, "Workflow should become idle after termination" assert termination_call_count > 0 @@ -990,8 +993,9 @@ async def _get() -> ChatResponse: outputs = [event for event in events if event.type == "output"] assert outputs - conversation_outputs = [event for event in outputs if isinstance(event.data, list)] - assert len(conversation_outputs) == 1 + # Per-agent activity surfaces as output events (AgentResponseUpdate in streaming mode). + agent_payloads = [event for event in outputs if isinstance(event.data, (AgentResponse, AgentResponseUpdate))] + assert len(agent_payloads) >= 1 async def test_tool_choice_preserved_from_agent_config(): diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py index b87de8c6f4..96bfaeccfd 100644 --- a/python/packages/orchestrations/tests/test_magentic.py +++ b/python/packages/orchestrations/tests/test_magentic.py @@ -194,9 +194,9 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None: orchestrator_event_count = 0 async for event in workflow.run("compose summary", stream=True): if event.type == "output": - msg = event.data - if isinstance(msg, list): - outputs.extend(cast(list[Message], msg)) + data = event.data + if isinstance(data, AgentResponse): + outputs.extend(data.messages) elif event.type == "magentic_orchestrator": orchestrator_event_count += 1 @@ -250,7 +250,7 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): assert isinstance(req_event.data, MagenticPlanReviewRequest) completed = False - output: list[Message] | None = None + output: AgentResponse | None = None async for ev in wf.run(stream=True, responses={req_event.request_id: req_event.data.approve()}): if ev.type == "status" and ev.state == WorkflowRunState.IDLE: completed = True @@ -261,8 +261,8 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): assert completed assert output is not None - assert isinstance(output, list) - assert all(isinstance(msg, Message) for msg in output) + assert isinstance(output, AgentResponse) + assert all(isinstance(msg, Message) for msg in output.messages) async def test_magentic_plan_review_with_revise(): @@ -337,10 +337,10 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result(): output_event = next((e for e in events if e.type == "output"), None) assert output_event is not None data = output_event.data - assert isinstance(data, list) - assert len(data) > 0 # type: ignore - assert data[-1].role == "assistant" # type: ignore - assert all(isinstance(msg, Message) for msg in data) # type: ignore + assert isinstance(data, AgentResponse) + assert len(data.messages) > 0 + assert data.messages[-1].role == "assistant" + assert all(isinstance(msg, Message) for msg in data.messages) async def test_magentic_checkpoint_resume_round_trip(): @@ -576,12 +576,11 @@ async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[ wf = MagenticBuilder(participants=[participant], intermediate_outputs=True, manager=InvokeOnceManager()).build() - # Run a bounded stream to allow one invoke and then completion - events: list[WorkflowEvent] = [] - async for ev in wf.run("task", stream=True): # plan review disabled - events.append(ev) - # Capture streaming updates (type="output" with AgentResponseUpdate data) - if ev.type == "output" and isinstance(ev.data, AgentResponseUpdate): + # With intermediate_outputs=True, participant updates surface as `data` events + # carrying AgentResponseUpdate; the orchestrator's terminal AgentResponse comes via + # an `output` event. + async for ev in wf.run("task", stream=True): + if ev.type == "data" and isinstance(ev.data, AgentResponseUpdate): captured.append( Message( role=ev.data.role or "assistant", @@ -589,7 +588,6 @@ async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[ author_name=ev.data.author_name, ) ) - # Break on final AgentResponse output elif ev.type == "output" and isinstance(ev.data, AgentResponse): break @@ -753,11 +751,12 @@ async def test_magentic_stall_and_reset_reach_limits(): assert idle_status is not None output_event = next((e for e in events if e.type == "output"), None) assert output_event is not None - assert isinstance(output_event.data, list) - assert all(isinstance(msg, Message) for msg in output_event.data) # type: ignore - assert len(output_event.data) > 0 # type: ignore - assert output_event.data[-1].text is not None # type: ignore - assert output_event.data[-1].text == "Workflow terminated due to reaching maximum reset count." # type: ignore + assert isinstance(output_event.data, AgentResponse) + msgs = output_event.data.messages + assert all(isinstance(msg, Message) for msg in msgs) + assert len(msgs) > 0 + assert msgs[-1].text is not None + assert msgs[-1].text == "Workflow terminated due to reaching maximum reset count." async def test_magentic_checkpoint_runtime_only() -> None: diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index 0f000ef254..27b9d663b6 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -98,32 +98,123 @@ def test_sequential_builder_validation_rejects_invalid_executor() -> None: SequentialBuilder(participants=[_EchoAgent(id="agent1", name="A1"), _InvalidExecutor(id="invalid")]).build() -async def test_sequential_agents_append_to_context() -> None: +async def test_sequential_streaming_yields_only_last_agent_updates() -> None: + """Streaming mode surfaces only the last agent's AgentResponseUpdate chunks as outputs. + + Intermediate agents do NOT emit `output` events when intermediate_outputs=False (default); + only the last agent (the workflow's output_executor) emits chunks of the final answer. + """ a1 = _EchoAgent(id="agent1", name="A1") a2 = _EchoAgent(id="agent2", name="A2") wf = SequentialBuilder(participants=[a1, a2]).build() completed = False - output: list[Message] | None = None + update_events: list[AgentResponseUpdate] = [] async for ev in wf.run("hello sequential", stream=True): if ev.type == "status" and ev.state == WorkflowRunState.IDLE: completed = True elif ev.type == "output": - output = ev.data # type: ignore[assignment] - if completed and output is not None: + update_events.append(ev.data) # type: ignore[arg-type] + if completed: break assert completed - assert output is not None - assert isinstance(output, list) - msgs: list[Message] = output - assert len(msgs) == 3 - assert msgs[0].role == "user" and "hello sequential" in msgs[0].text - assert msgs[1].role == "assistant" and (msgs[1].author_name == "A1" or True) - assert msgs[2].role == "assistant" and (msgs[2].author_name == "A2" or True) - assert "A1 reply" in msgs[1].text - assert "A2 reply" in msgs[2].text + # Only the last agent's streaming chunks surface as `output` events. + assert update_events, "Expected at least one streaming update from the last agent" + for upd in update_events: + assert isinstance(upd, AgentResponseUpdate) + combined_text = "".join(u.text for u in update_events if hasattr(u, "text")) + assert "A2 reply" in combined_text + assert "A1 reply" not in combined_text + + +async def test_sequential_non_streaming_yields_only_last_agent_response() -> None: + """Non-streaming mode emits a single `output` event with the last agent's AgentResponse.""" + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + + wf = SequentialBuilder(participants=[a1, a2]).build() + + output_events = [ev for ev in await wf.run("hello sequential") if ev.type == "output"] + assert len(output_events) == 1 + response = output_events[0].data + assert isinstance(response, AgentResponse) + assert all(m.role == "assistant" for m in response.messages) + combined = " ".join(m.text for m in response.messages) + assert "A2 reply" in combined + assert "A1 reply" not in combined + + +async def test_sequential_intermediate_outputs_emits_data_events() -> None: + """When intermediate_outputs=True, intermediate agents surface as `data` events. + + The single `output` event still carries the last agent's AgentResponse; intermediate + agents are emitted as `data` events (not output events) so consumers can clearly tell + them apart. + """ + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + + wf = SequentialBuilder(participants=[a1, a2], intermediate_outputs=True).build() + + output_events = [] + data_events = [] + for ev in await wf.run("hello"): + if ev.type == "output": + output_events.append(ev) + elif ev.type == "data": + data_events.append(ev) + + # One output event = the final answer (last agent). + assert len(output_events) == 1 + final = output_events[0].data + assert isinstance(final, AgentResponse) + assert "A2 reply" in " ".join(m.text for m in final.messages) + + # Intermediate agents emit data events (not output events). + assert len(data_events) == 1 + intermediate = data_events[0].data + assert isinstance(intermediate, AgentResponse) + assert "A1 reply" in " ".join(m.text for m in intermediate.messages) + # Executor id derives from the agent's name (resolve_agent_id behavior). + assert data_events[0].executor_id == "A1" + + +async def test_sequential_as_agent_returns_only_last_agent_response() -> None: + """`workflow.as_agent().run(prompt)` returns ONLY the last agent's messages — not the user + input or earlier agents' replies. This is the core fix for the orchestration-as-agent + output contract.""" + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + + agent = SequentialBuilder(participants=[a1, a2]).build().as_agent() + response = await agent.run("hello as_agent") + + assert isinstance(response, AgentResponse) + # Only the last agent's reply — no user prompt, no agent1 messages. + combined = " ".join(m.text for m in response.messages) + assert "A2 reply" in combined + assert "A1 reply" not in combined + assert "hello as_agent" not in combined + + +async def test_sequential_as_agent_with_intermediate_outputs_includes_chain() -> None: + """With `intermediate_outputs=True`, `as_agent()` surfaces intermediate agent responses + (via `data` events) followed by the final answer.""" + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + + agent = SequentialBuilder(participants=[a1, a2], intermediate_outputs=True).build().as_agent() + response = await agent.run("hello as_agent") + + assert isinstance(response, AgentResponse) + combined_text = " ".join(m.text for m in response.messages) + assert "A1 reply" in combined_text + assert "A2 reply" in combined_text + # Final agent's reply should appear last in the message ordering. + last_text = response.messages[-1].text + assert "A2 reply" in last_text async def test_sequential_with_custom_executor_summary() -> None: @@ -158,14 +249,14 @@ async def test_sequential_checkpoint_resume_round_trip() -> None: initial_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf = SequentialBuilder(participants=list(initial_agents), checkpoint_storage=storage).build() - baseline_output: list[Message] | None = None + baseline_updates: list[AgentResponseUpdate] = [] async for ev in wf.run("checkpoint sequential", stream=True): if ev.type == "output": - baseline_output = ev.data # type: ignore[assignment] + baseline_updates.append(ev.data) # type: ignore[arg-type] if ev.type == "status" and ev.state == WorkflowRunState.IDLE: break - assert baseline_output is not None + assert baseline_updates checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert checkpoints @@ -175,19 +266,20 @@ async def test_sequential_checkpoint_resume_round_trip() -> None: resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf_resume = SequentialBuilder(participants=list(resumed_agents), checkpoint_storage=storage).build() - resumed_output: list[Message] | None = None + resumed_updates: list[AgentResponseUpdate] = [] async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True): if ev.type == "output": - resumed_output = ev.data # type: ignore[assignment] + resumed_updates.append(ev.data) # type: ignore[arg-type] if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, ): break - assert resumed_output is not None - assert [m.role for m in resumed_output] == [m.role for m in baseline_output] - assert [m.text for m in resumed_output] == [m.text for m in baseline_output] + assert resumed_updates + baseline_text = "".join(u.text for u in baseline_updates if hasattr(u, "text")) + resumed_text = "".join(u.text for u in resumed_updates if hasattr(u, "text")) + assert baseline_text == resumed_text async def test_sequential_checkpoint_runtime_only() -> None: @@ -197,14 +289,14 @@ async def test_sequential_checkpoint_runtime_only() -> None: agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf = SequentialBuilder(participants=list(agents)).build() - baseline_output: list[Message] | None = None + baseline_updates: list[AgentResponseUpdate] = [] async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): if ev.type == "output": - baseline_output = ev.data # type: ignore[assignment] + baseline_updates.append(ev.data) # type: ignore[arg-type] if ev.type == "status" and ev.state == WorkflowRunState.IDLE: break - assert baseline_output is not None + assert baseline_updates checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert checkpoints @@ -214,21 +306,22 @@ async def test_sequential_checkpoint_runtime_only() -> None: resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf_resume = SequentialBuilder(participants=list(resumed_agents)).build() - resumed_output: list[Message] | None = None + resumed_updates: list[AgentResponseUpdate] = [] async for ev in wf_resume.run( checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True ): if ev.type == "output": - resumed_output = ev.data # type: ignore[assignment] + resumed_updates.append(ev.data) # type: ignore[arg-type] if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, ): break - assert resumed_output is not None - assert [m.role for m in resumed_output] == [m.role for m in baseline_output] - assert [m.text for m in resumed_output] == [m.text for m in baseline_output] + assert resumed_updates + baseline_text = "".join(u.text for u in baseline_updates if hasattr(u, "text")) + resumed_text = "".join(u.text for u in resumed_updates if hasattr(u, "text")) + assert baseline_text == resumed_text async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None: diff --git a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py index 52de975173..12790224b8 100644 --- a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py @@ -3,9 +3,8 @@ import os from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import SequentialBuilder -from azure.identity import AzureCliCredential from dotenv import load_dotenv # Load environment variables from .env file @@ -33,11 +32,13 @@ async def main() -> None: # 1) Create agents - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ) + # client = FoundryChatClient( + # project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + # model=os.environ["FOUNDRY_MODEL"], + # credential=AzureCliCredential(), + # ) + + client = OpenAIChatClient(model=os.environ["OPENAI_CHAT_MODEL_ID"]) writer = Agent( client=client, @@ -68,28 +69,18 @@ async def main() -> None: """ Sample Output: - ===== Final Conversation ===== - ------------------------------------------------------------ - 01 [user] - Write a tagline for a budget-friendly eBike. - ------------------------------------------------------------ - 02 [writer] - Ride farther, spend less—your affordable eBike adventure starts here. - ------------------------------------------------------------ - 03 [reviewer] - This tagline clearly communicates affordability and the benefit of extended travel, making it - appealing to budget-conscious consumers. It has a friendly and motivating tone, though it could - be slightly shorter for more punch. Overall, a strong and effective suggestion! - - ===== as_agent() Conversation ===== + ===== Conversation ===== ------------------------------------------------------------ - 01 [writer] - Go electric, save big—your affordable ride awaits! - ------------------------------------------------------------ - 02 [reviewer] + 01 [reviewer] Catchy and straightforward! The tagline clearly emphasizes both the electric aspect and the affordability of the eBike. It's inviting and actionable. For even more impact, consider making it slightly shorter: "Go electric, save big." Overall, this is an effective and appealing suggestion for a budget-friendly eBike. + + Note: + `workflow.as_agent()` returns ONLY the final agent's response (the "answer") — the prior agents' work + is not included in the response. To observe intermediate agents while running as an agent, build with + `SequentialBuilder(participants=[...], intermediate_outputs=True)`; the intermediate replies are then + surfaced as `data` events and merged into the AgentResponse. """ From 5080f57ab84dda9427b396da8e9b61a381ca702b Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 08:52:22 +0000 Subject: [PATCH 02/16] Fix orchestration output issues from review comments 1. Sample cleanup: Remove commented-out FoundryChatClient block and update prerequisites to reference OPENAI_CHAT_MODEL_ID instead of FOUNDRY_* vars. 2. Sequential approval output: Change _EndWithConversation.end_with_agent_executor_response from a no-op sink to yield response.agent_response. When the last participant is AgentApprovalExecutor (via with_request_info), _EndWithConversation is the output executor so the yield produces the terminal answer. When the last participant is a regular AgentExecutor, _EndWithConversation is not in output_executors so the yield is silently filtered out. 3. Forward data events through WorkflowExecutor: _process_workflow_result now also forwards 'data' events from sub-workflows so that emit_intermediate_data=True on AgentExecutor works correctly when wrapped in AgentApprovalExecutor. 4. Concurrent docstring: Update _AggregateAgentConversations docstring to say 'deterministic participant order' instead of 'completion order'. 5. Add test_concurrent_intermediate_outputs_emits_data_events verifying that ConcurrentBuilder(intermediate_outputs=True) emits per-participant data events alongside the single aggregated output event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_workflows/_workflow_executor.py | 7 ++ .../_concurrent.py | 5 +- .../_sequential.py | 11 ++- .../orchestrations/tests/test_concurrent.py | 90 ++++++++++++++++++- .../agents/sequential_workflow_as_agent.py | 9 +- 5 files changed, 109 insertions(+), 13 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index afb6145251..14470fbc66 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -566,6 +566,13 @@ async def _process_workflow_result( else: await asyncio.gather(*[ctx.send_message(output) for output in outputs]) + # Forward data events from the sub-workflow so that intermediate + # observations (e.g. emit_intermediate_data from AgentExecutor) are + # visible in the parent workflow's event stream. + data_events = [event for event in result if isinstance(event, WorkflowEvent) and event.type == "data"] + for data_event in data_events: + await ctx.add_event(WorkflowEvent.emit(data_event.executor_id, data_event.data)) + # Process request info events for event in request_info_events: request_id = event.request_id diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py index f3f8c5e5e2..ad064abd71 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py @@ -74,8 +74,9 @@ class _AggregateAgentConversations(Executor): """Aggregates agent responses and completes with a single AgentResponse. Emits an `AgentResponse` whose `messages` are the final assistant message from each - participant (one message per agent), in the order participants completed. The - user prompt is intentionally not included — that is part of the input, not the answer. + participant (one message per agent), in deterministic participant order matching + the fan-in `sources` configuration. The user prompt is intentionally not included — + that is part of the input, not the answer. For each participant the final assistant message is sourced from `r.agent_response.messages`, falling back to scanning `r.full_conversation` for diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index 6de6e7ff48..e2c250e0fb 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -87,8 +87,15 @@ async def end_with_agent_executor_response( response: AgentExecutorResponse, ctx: WorkflowContext[Any], ) -> None: - """Sink for the agent-terminator graph edge; the last AgentExecutor is the output.""" - return + """Convert the agent-terminator response into a workflow output. + + When the last participant is a regular AgentExecutor (registered as the + output executor), this node is NOT in output_executors so the yield is + silently filtered — no duplicate output. When the last participant is an + AgentApprovalExecutor (or similar wrapper), this node IS the output + executor so the yield produces the workflow's terminal answer. + """ + await ctx.yield_output(response.agent_response) class SequentialBuilder: diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py index 0cfaee6519..5c879f6153 100644 --- a/python/packages/orchestrations/tests/test_concurrent.py +++ b/python/packages/orchestrations/tests/test_concurrent.py @@ -1,14 +1,21 @@ # Copyright (c) Microsoft. All rights reserved. -from typing import Any, cast +from collections.abc import AsyncIterable, Awaitable +from typing import Any, Literal, cast, overload import pytest from agent_framework import ( AgentExecutorRequest, AgentExecutorResponse, AgentResponse, + AgentResponseUpdate, + AgentRunInputs, + AgentSession, + BaseAgent, + Content, Executor, Message, + ResponseStream, WorkflowContext, WorkflowRunState, handler, @@ -324,3 +331,84 @@ async def test_concurrent_builder_reusable_after_build_with_participants() -> No assert builder._participants[0] is e1 # type: ignore assert builder._participants[1] is e2 # type: ignore + + +class _EchoAgent(BaseAgent): + """Simple agent that appends a single assistant message with its name.""" + + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[False] = ..., + session: AgentSession | None = ..., + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[True], + session: AgentSession | None = ..., + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + if stream: + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")]) + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + async def _run() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"])]) + + return _run() + + +async def test_concurrent_intermediate_outputs_emits_data_events() -> None: + """When intermediate_outputs=True, each participant emits a `data` event. + + The single `output` event still carries the aggregated AgentResponse; per-participant + responses are emitted as `data` events so consumers can tell them apart. + """ + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + a3 = _EchoAgent(id="agent3", name="A3") + + wf = ConcurrentBuilder(participants=[a1, a2, a3], intermediate_outputs=True).build() + + output_events = [] + data_events = [] + for ev in await wf.run("prompt: hello"): + if ev.type == "output": + output_events.append(ev) + elif ev.type == "data": + data_events.append(ev) + + # One output event = the aggregated answer from the aggregator. + assert len(output_events) == 1 + aggregated = output_events[0].data + assert isinstance(aggregated, AgentResponse) + assert len(aggregated.messages) == 3 + assert all(m.role == "assistant" for m in aggregated.messages) + + # Each participant emits a data event carrying its AgentResponse. + assert len(data_events) == 3 + for dev in data_events: + assert isinstance(dev.data, AgentResponse) + data_texts = {dev.data.messages[0].text for dev in data_events} + assert data_texts == {"A1 reply", "A2 reply", "A3 reply"} + # Executor ids derive from the agent's name (resolve_agent_id behavior). + data_executor_ids = {dev.executor_id for dev in data_events} + assert data_executor_ids == {"A1", "A2", "A3"} diff --git a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py index 12790224b8..7d15f2f7aa 100644 --- a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py @@ -25,19 +25,12 @@ You can safely ignore them when focusing on agent progress. Prerequisites: -- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name. +- OPENAI_CHAT_MODEL_ID must be set to the model name for the OpenAI chat client. """ async def main() -> None: # 1) Create agents - # client = FoundryChatClient( - # project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - # model=os.environ["FOUNDRY_MODEL"], - # credential=AzureCliCredential(), - # ) - client = OpenAIChatClient(model=os.environ["OPENAI_CHAT_MODEL_ID"]) writer = Agent( From 3af110e0c0aa2e6e74e258b7029d8280b81feb0c Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 09:49:05 +0000 Subject: [PATCH 03/16] Add tests for sequential workflow with_request_info and intermediate_outputs (#5301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review comments 2, 3, and 5: - Add test_sequential_request_info_last_participant_emits_output: Verifies that when the last participant is wrapped via with_request_info() (AgentApprovalExecutor), the workflow still emits a terminal output after approval, exercising the _EndWithConversation.end_with_agent_executor_response fallback path. - Add test_sequential_request_info_with_intermediate_outputs_emits_data_events: Verifies that emit_intermediate_data=True works correctly through AgentApprovalExecutor wrapping—WorkflowExecutor._process_result already forwards data events from sub-workflows, so intermediate agent responses surface as data events in the parent workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../orchestrations/tests/test_sequential.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index 27b9d663b6..51f3a6d42a 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -483,3 +483,106 @@ async def test_chain_only_agent_responses_three_agents() -> None: # a3 should see only A2's reply assert len(a3.last_messages) == 1 assert a3.last_messages[0].role == "assistant" and "A2 reply" in (a3.last_messages[0].text or "") + + +# --------------------------------------------------------------------------- +# with_request_info tests +# --------------------------------------------------------------------------- + + +async def test_sequential_request_info_last_participant_emits_output() -> None: + """When the last participant is wrapped via with_request_info(), the workflow + still emits a terminal output event after approval. + + This exercises the _EndWithConversation.end_with_agent_executor_response path + that converts the AgentApprovalExecutor's forwarded AgentExecutorResponse into + the workflow's final AgentResponse output. + """ + from agent_framework_orchestrations._orchestration_request_info import AgentRequestInfoResponse + + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + + wf = SequentialBuilder(participants=[a1, a2]).with_request_info().build() + + # First run: collect request_info events for both agents + request_events: list[Any] = [] + async for ev in wf.run("hello with approval", stream=True): + if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse): + request_events.append(ev) + + # Approve each agent in sequence until the workflow completes + while request_events: + responses = {req.request_id: AgentRequestInfoResponse.approve() for req in request_events} + request_events = [] + output_events: list[Any] = [] + async for ev in wf.run(stream=True, responses=responses): + if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse): + request_events.append(ev) + elif ev.type == "output": + output_events.append(ev) + + # The workflow must produce a terminal output with the last agent's response. + assert len(output_events) == 1 + response = output_events[0].data + assert isinstance(response, AgentResponse) + assert any("A2 reply" in m.text for m in response.messages) + + +async def test_sequential_request_info_with_intermediate_outputs_emits_data_events() -> None: + """With both with_request_info() and intermediate_outputs=True, intermediate + agents' responses are surfaced as data events while the final output is an + AgentResponse from the last agent. + + This verifies that WorkflowExecutor correctly forwards data events from the + inner AgentExecutor through the AgentApprovalExecutor wrapper. + """ + from agent_framework_orchestrations._orchestration_request_info import AgentRequestInfoResponse + + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + + wf = ( + SequentialBuilder(participants=[a1, a2], intermediate_outputs=True) + .with_request_info() + .build() + ) + + # Run and approve all request_info events until the workflow completes + all_data_events: list[Any] = [] + all_output_events: list[Any] = [] + request_events: list[Any] = [] + + async for ev in wf.run("hello intermediate", stream=True): + if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse): + request_events.append(ev) + elif ev.type == "data": + all_data_events.append(ev) + elif ev.type == "output": + all_output_events.append(ev) + + while request_events: + responses = {req.request_id: AgentRequestInfoResponse.approve() for req in request_events} + request_events = [] + async for ev in wf.run(stream=True, responses=responses): + if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse): + request_events.append(ev) + elif ev.type == "data": + all_data_events.append(ev) + elif ev.type == "output": + all_output_events.append(ev) + + # The first (intermediate) agent should emit a data event. + assert len(all_data_events) >= 1 + intermediate_texts = set() + for dev in all_data_events: + if isinstance(dev.data, AgentResponse): + for m in dev.data.messages: + intermediate_texts.add(m.text) + assert "A1 reply" in intermediate_texts + + # The final output should contain the last agent's response. + assert len(all_output_events) >= 1 + final = all_output_events[-1].data + assert isinstance(final, AgentResponse) + assert any("A2 reply" in m.text for m in final.messages) From 100093e0c79745baf09c6e8d6e800da55f052d39 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 09:56:46 +0000 Subject: [PATCH 04/16] Fix pyright type errors from AgentResponse output refactor (#5301) Update cast() calls in _group_chat.py and _magentic.py to use WorkflowContext[Never, AgentResponse] instead of the old WorkflowContext[Never, list[Message]], matching the updated method signatures in _base_group_chat_orchestrator.py. Fix _sequential.py _EndWithConversation.end_with_agent_executor_response to declare WorkflowContext[Any, AgentResponse] so yield_output accepts AgentResponse[None]. Fix _workflow_executor.py data event forwarding to handle nullable executor_id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_workflows/_workflow_executor.py | 2 +- .../_group_chat.py | 16 ++++++++-------- .../agent_framework_orchestrations/_magentic.py | 6 +++--- .../_sequential.py | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 14470fbc66..dfddc985b3 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -571,7 +571,7 @@ async def _process_workflow_result( # visible in the parent workflow's event stream. data_events = [event for event in result if isinstance(event, WorkflowEvent) and event.type == "data"] for data_event in data_events: - await ctx.add_event(WorkflowEvent.emit(data_event.executor_id, data_event.data)) + await ctx.add_event(WorkflowEvent.emit(data_event.executor_id or "", data_event.data)) # Process request info events for event in request_info_events: diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index 3b9f19555c..3074ea81e2 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -169,7 +169,7 @@ async def _handle_messages( """Initialize orchestrator state and start the conversation loop.""" self._append_messages(messages) # Termination condition will also be applied to the input messages - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_terminate_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): return next_speaker = await self._get_next_speaker() @@ -198,9 +198,9 @@ async def _handle_response( messages = clean_conversation_for_handoff(messages) self._append_messages(messages) - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_terminate_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): return - if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): return next_speaker = await self._get_next_speaker() @@ -332,13 +332,13 @@ async def _handle_messages( """Initialize orchestrator state and start the conversation loop.""" self._append_messages(messages) # Termination condition will also be applied to the input messages - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_terminate_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): return agent_orchestration_output = await self._invoke_agent() if await self._check_agent_terminate_and_yield( agent_orchestration_output, - cast(WorkflowContext[Never, list[Message]], ctx), + cast(WorkflowContext[Never, AgentResponse], ctx), ): return @@ -366,15 +366,15 @@ async def _handle_response( # Remove tool-related content to prevent API errors from empty messages messages = clean_conversation_for_handoff(messages) self._append_messages(messages) - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_terminate_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): return - if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): return agent_orchestration_output = await self._invoke_agent() if await self._check_agent_terminate_and_yield( agent_orchestration_output, - cast(WorkflowContext[Never, list[Message]], ctx), + cast(WorkflowContext[Never, AgentResponse], ctx), ): return diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index 0ce14444c7..d02da5ee9a 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -1057,7 +1057,7 @@ async def _run_inner_loop_helper( if self._magentic_context is None: raise RuntimeError("Context not initialized") # Check limits first - within_limits = await self._check_within_limits_or_complete(cast(WorkflowContext[Never, list[Message]], ctx)) + within_limits = await self._check_within_limits_or_complete(cast(WorkflowContext[Never, AgentResponse], ctx)) if not within_limits: return @@ -1092,7 +1092,7 @@ async def _run_inner_loop_helper( # Check for task completion if self._progress_ledger.is_request_satisfied.answer: logger.info("Magentic Orchestrator: Task completed") - await self._prepare_final_answer(cast(WorkflowContext[Never, list[Message]], ctx)) + await self._prepare_final_answer(cast(WorkflowContext[Never, AgentResponse], ctx)) return # Check for stalling or looping @@ -1116,7 +1116,7 @@ async def _run_inner_loop_helper( if next_speaker not in self._participant_registry.participants: logger.warning(f"Invalid next speaker: {next_speaker}") - await self._prepare_final_answer(cast(WorkflowContext[Never, list[Message]], ctx)) + await self._prepare_final_answer(cast(WorkflowContext[Never, AgentResponse], ctx)) return # Add instruction to conversation (assistant guidance) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index e2c250e0fb..717d4faf66 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -19,7 +19,7 @@ from collections.abc import Sequence from typing import Any, Literal -from agent_framework import Message, SupportsAgentRun +from agent_framework import AgentResponse, Message, SupportsAgentRun from agent_framework._workflows._agent_executor import ( AgentExecutor, AgentExecutorResponse, @@ -85,7 +85,7 @@ async def end_with_messages( async def end_with_agent_executor_response( self, response: AgentExecutorResponse, - ctx: WorkflowContext[Any], + ctx: WorkflowContext[Any, AgentResponse], ) -> None: """Convert the agent-terminator response into a workflow output. From 675afe09057ec8c4cc6f797a9a379612c7dbc0b4 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 10:03:30 +0000 Subject: [PATCH 05/16] Fix pyright reportUnknownVariableType in _agent.py (#5301) Extract event.data into a typed local variable before the isinstance check to avoid pyright narrowing it to AgentResponse[Unknown]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_workflows/_agent.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 0ada82f4e1..6aacf70090 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -628,12 +628,10 @@ def _convert_workflow_event_to_agent_response_updates( Returns: A list of AgentResponseUpdate objects. Empty list if the event is not relevant. """ - if event.type == "output" or ( - event.type == "data" and isinstance(event.data, (AgentResponse, AgentResponseUpdate)) - ): + data: Any = event.data + if event.type == "output" or (event.type == "data" and isinstance(data, (AgentResponse, AgentResponseUpdate))): # Convert workflow output to agent response updates. # Handle different data types appropriately. - data = event.data executor_id = event.executor_id if isinstance(data, AgentResponseUpdate): From 9d37c0884800f4d72d8fdb4279d625444f7e6464 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 10:06:25 +0000 Subject: [PATCH 06/16] Fix pyright reportMissingImports for orjson in file history samples (#5301) Add pyright: ignore[reportMissingImports] to orjson imports that are already guarded by try/except ImportError, matching the existing pattern used elsewhere in the samples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/samples/02-agents/conversations/file_history_provider.py | 2 +- .../file_history_provider_conversation_persistence.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/samples/02-agents/conversations/file_history_provider.py b/python/samples/02-agents/conversations/file_history_provider.py index 04a87f8224..20735ffd17 100644 --- a/python/samples/02-agents/conversations/file_history_provider.py +++ b/python/samples/02-agents/conversations/file_history_provider.py @@ -21,7 +21,7 @@ from pydantic import Field try: - import orjson + import orjson # pyright: ignore[reportMissingImports] except ImportError: orjson = None diff --git a/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py b/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py index 70c5d7e8e8..693501b0f9 100644 --- a/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py +++ b/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py @@ -22,7 +22,7 @@ from pydantic import Field try: - import orjson + import orjson # pyright: ignore[reportMissingImports] except ImportError: orjson = None From 28cf71f1dccd0013c8796829d664a3a1658b0014 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 10:10:29 +0000 Subject: [PATCH 07/16] Address review feedback for #5301: review comment fixes --- python/packages/orchestrations/tests/test_sequential.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index 51f3a6d42a..de11b4d359 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -542,11 +542,7 @@ async def test_sequential_request_info_with_intermediate_outputs_emits_data_even a1 = _EchoAgent(id="agent1", name="A1") a2 = _EchoAgent(id="agent2", name="A2") - wf = ( - SequentialBuilder(participants=[a1, a2], intermediate_outputs=True) - .with_request_info() - .build() - ) + wf = SequentialBuilder(participants=[a1, a2], intermediate_outputs=True).with_request_info().build() # Run and approve all request_info events until the workflow completes all_data_events: list[Any] = [] From 09a12fe99e2ab0c2ad8e9a10c8fe6d00c86eb474 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 10:50:41 +0000 Subject: [PATCH 08/16] Address review feedback for #5301: review comment fixes --- .../03-workflows/agents/sequential_workflow_as_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py index 7d15f2f7aa..10d9d35ad9 100644 --- a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py @@ -25,13 +25,13 @@ You can safely ignore them when focusing on agent progress. Prerequisites: -- OPENAI_CHAT_MODEL_ID must be set to the model name for the OpenAI chat client. +- OPENAI_CHAT_MODEL must be set to the model name for the OpenAI chat client. """ async def main() -> None: # 1) Create agents - client = OpenAIChatClient(model=os.environ["OPENAI_CHAT_MODEL_ID"]) + client = OpenAIChatClient(model=os.environ["OPENAI_CHAT_MODEL"]) writer = Agent( client=client, From cec19937549fcbb2611b5daebb447401833f3ac4 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 10:57:20 +0000 Subject: [PATCH 09/16] Revert sequential_workflow_as_agent sample to FoundryChatClient Reverts the mistaken switch from FoundryChatClient to OpenAIChatClient in the sequential workflow as agent sample. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agents/sequential_workflow_as_agent.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py index 10d9d35ad9..120bd448aa 100644 --- a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py @@ -3,8 +3,9 @@ import os from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential from dotenv import load_dotenv # Load environment variables from .env file @@ -25,13 +26,18 @@ You can safely ignore them when focusing on agent progress. Prerequisites: -- OPENAI_CHAT_MODEL must be set to the model name for the OpenAI chat client. +- FOUNDRY_PROJECT_ENDPOINT must be set to the Azure Foundry project endpoint. +- FOUNDRY_MODEL must be set to the model name for the Foundry chat client. """ async def main() -> None: # 1) Create agents - client = OpenAIChatClient(model=os.environ["OPENAI_CHAT_MODEL"]) + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) writer = Agent( client=client, From 12fe4bb91e32bc3937828af56ffbb65484858abd Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 17 Apr 2026 12:41:33 +0900 Subject: [PATCH 10/16] Address ultrareview feedback: emit_data_events rename + WorkflowAgent reasoning conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layered on top of the prior review-feedback work in this branch. Renames: - AgentExecutor.emit_intermediate_data -> emit_data_events (mechanical rename; orchestration semantics live at the orchestration layer, not the general-purpose executor). Forwarded through MagenticAgentExecutor, AgentApprovalExecutor, and all orchestration call sites. - HandoffAgentExecutor._check_terminate_and_yield -> _should_terminate (pure predicate; no longer yields anything). HandoffBuilder docstring rewritten to describe the new per-agent AgentResponse output contract. WorkflowAgent reasoning-content conversion: - Add _rewrite_text_to_reasoning(contents) and _msg_as_reasoning(msg) helpers; the as_agent() path now reframes text content from data events as text_reasoning Content blocks before merging into the AgentResponse. - Consumers iterate msg.contents and branch on content.type — same path they already use for Claude thinking and OpenAI reasoning. No new field on Message/AgentResponse/WorkflowEvent. - Streaming branch constructs fresh AgentResponseUpdate instances instead of mutating shared payloads (regression test added). - Helper _msg_maybe_reasoning consolidates the conditional rewrite at three call sites in the non-streaming conversion. Tests: - TestWorkflowAgentReasoningHelpers + TestWorkflowAgentDataEventReasoningConversion add 9 new tests covering helpers, non-streaming, streaming, mixed content, already-reasoning passthrough, and mutation-safety regression. - Updated test_sequential_as_agent_with_intermediate_outputs_includes_chain to assert text_reasoning content for intermediate agents. --- .gitignore | 4 + .../core/agent_framework/_workflows/_agent.py | 102 +++++++-- .../_workflows/_agent_executor.py | 10 +- .../_workflows/_workflow_executor.py | 2 +- .../tests/workflow/test_agent_executor.py | 18 +- .../tests/workflow/test_workflow_agent.py | 214 ++++++++++++++++++ .../_concurrent.py | 6 +- .../_group_chat.py | 6 +- .../_handoff.py | 28 ++- .../_magentic.py | 8 +- .../_orchestration_request_info.py | 8 +- .../_sequential.py | 10 +- .../orchestrations/tests/test_sequential.py | 15 +- 13 files changed, 361 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index 4994e9e2fe..5fa222ec40 100644 --- a/.gitignore +++ b/.gitignore @@ -235,3 +235,7 @@ python/dotnet-ref # Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1) dotnet/filtered-*.slnx + +# Local tool state +.omc/ +.omx/ diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 6aacf70090..a4a5f1f7ac 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -529,7 +529,14 @@ def _convert_workflow_events_to_agent_response( ) raw_representations.append(output_event) else: + # `data` events carry intermediate participant responses (e.g., orchestration + # agents emitting via emit_data_events). Reframe their text content as + # `text_reasoning` so consumers can render them like agent thinking, mirroring + # how reasoning-capable agents (Claude thinking, OpenAI reasoning) already + # surface intermediate content. `output` events pass through unchanged. + as_reasoning = output_event.type == "data" data = output_event.data + if isinstance(data, AgentResponseUpdate): # We cannot support AgentResponseUpdate in non-streaming mode. This is because the message # sequence cannot be guaranteed when there are streaming updates in between non-streaming @@ -540,7 +547,7 @@ def _convert_workflow_events_to_agent_response( ) if isinstance(data, AgentResponse): - messages.extend(data.messages) + messages.extend(self._msg_maybe_reasoning(m, as_reasoning=as_reasoning) for m in data.messages) raw_representations.append(data.raw_representation) merged_usage = add_usage_details(merged_usage, data.usage_details) latest_created_at = ( @@ -551,16 +558,18 @@ def _convert_workflow_events_to_agent_response( else latest_created_at ) elif isinstance(data, Message): - messages.append(data) + messages.append(self._msg_maybe_reasoning(data, as_reasoning=as_reasoning)) raw_representations.append(data.raw_representation) elif is_instance_of(data, list[Message]): chat_messages = cast(list[Message], data) - messages.extend(chat_messages) + messages.extend(self._msg_maybe_reasoning(m, as_reasoning=as_reasoning) for m in chat_messages) raw_representations.append(data) else: contents = self._extract_contents(data) if not contents: continue + if as_reasoning: + contents = self._rewrite_text_to_reasoning(contents) messages.append( Message( @@ -628,24 +637,41 @@ def _convert_workflow_event_to_agent_response_updates( Returns: A list of AgentResponseUpdate objects. Empty list if the event is not relevant. """ - data: Any = event.data - if event.type == "output" or (event.type == "data" and isinstance(data, (AgentResponse, AgentResponseUpdate))): - # Convert workflow output to agent response updates. - # Handle different data types appropriately. + if event.type == "output" or ( + event.type == "data" and isinstance(event.data, (AgentResponse, AgentResponseUpdate)) + ): + # `data` events carry intermediate participant content (e.g., orchestration agents + # via emit_data_events). Reframe their text content as `text_reasoning` so consumers + # render them as agent thinking. `output` events pass through unchanged. + as_reasoning = event.type == "data" + data = event.data executor_id = event.executor_id + def _contents(src: Sequence[Content]) -> list[Content]: + return self._rewrite_text_to_reasoning(src) if as_reasoning else list(src) + if isinstance(data, AgentResponseUpdate): - # Pass through AgentResponseUpdate directly (streaming from AgentExecutor) - if not data.author_name: - data.author_name = executor_id - return [data] + # Construct a fresh AgentResponseUpdate so we don't mutate a payload + # that AgentExecutor (and the data-event publisher) still hold references + # to in their `updates` list / output channel. + return [ + AgentResponseUpdate( + contents=_contents(data.contents), + role=data.role, + author_name=data.author_name or executor_id, + response_id=data.response_id, + message_id=data.message_id, + created_at=data.created_at, + raw_representation=data.raw_representation, + ) + ] if isinstance(data, AgentResponse): # Convert each message in AgentResponse to an AgentResponseUpdate updates: list[AgentResponseUpdate] = [] for msg in data.messages: updates.append( AgentResponseUpdate( - contents=list(msg.contents), + contents=_contents(msg.contents), role=msg.role, author_name=msg.author_name or executor_id, response_id=data.response_id or response_id, @@ -659,7 +685,7 @@ def _convert_workflow_event_to_agent_response_updates( if isinstance(data, Message): return [ AgentResponseUpdate( - contents=list(data.contents), + contents=_contents(data.contents), role=data.role, author_name=data.author_name or executor_id, response_id=response_id, @@ -675,7 +701,7 @@ def _convert_workflow_event_to_agent_response_updates( for msg in chat_messages: updates.append( AgentResponseUpdate( - contents=list(msg.contents), + contents=_contents(msg.contents), role=msg.role, author_name=msg.author_name or executor_id, response_id=response_id, @@ -688,6 +714,8 @@ def _convert_workflow_event_to_agent_response_updates( contents = self._extract_contents(data) if not contents: return [] + if as_reasoning: + contents = self._rewrite_text_to_reasoning(contents) return [ AgentResponseUpdate( contents=contents, @@ -792,6 +820,52 @@ def _extract_contents(self, data: Any) -> list[Content]: return [Content.from_text(text=data)] return [Content.from_text(text=str(data))] + @staticmethod + def _rewrite_text_to_reasoning(contents: Sequence[Content]) -> list[Content]: + """Rewrite TextContent blocks as TextReasoningContent. + + Used by WorkflowAgent to reframe content arriving on the workflow's `data` channel — + e.g., intermediate participants in an orchestration — as reasoning content from the + perspective of the wrapped workflow agent. This aligns workflow-as-agent intermediate + output with how reasoning-capable agents (Claude thinking, OpenAI reasoning) already + emit thinking content, so consumers can use one rendering path. + + Non-text content (function calls, results, already-reasoning text, hosted files, etc.) + passes through unchanged. + """ + rewritten: list[Content] = [] + for content in contents: + if content.type == "text": + rewritten.append( + Content.from_text_reasoning( + id=content.id, + text=content.text, + annotations=content.annotations, + additional_properties=content.additional_properties, + raw_representation=content.raw_representation, + ) + ) + else: + rewritten.append(content) + return rewritten + + @classmethod + def _msg_as_reasoning(cls, msg: Message) -> Message: + """Return a copy of `msg` with text content rewritten as reasoning content.""" + return Message( + role=msg.role, + contents=cls._rewrite_text_to_reasoning(msg.contents), + author_name=msg.author_name, + message_id=msg.message_id, + additional_properties=msg.additional_properties, + raw_representation=msg.raw_representation, + ) + + @classmethod + def _msg_maybe_reasoning(cls, msg: Message, *, as_reasoning: bool) -> Message: + """Conditional `_msg_as_reasoning`: rewrite when `as_reasoning` is True, pass through otherwise.""" + return cls._msg_as_reasoning(msg) if as_reasoning else msg + class _ResponseState(TypedDict): """State for grouping response updates by message_id.""" diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 05e90159f8..91c379fed6 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -142,7 +142,7 @@ def __init__( id: str | None = None, context_mode: Literal["full", "last_agent", "custom"] | None = None, context_filter: Callable[[list[Message]], list[Message]] | None = None, - emit_intermediate_data: bool = False, + emit_data_events: bool = False, ): """Initialize the executor with a unique identifier. @@ -160,7 +160,7 @@ def __init__( as context for the agent run. context_filter: An optional function for filtering conversation context when context_mode is set to "custom". - emit_intermediate_data: When True, additionally emits `data` events (via + emit_data_events: When True, additionally emits `data` events (via `WorkflowEvent.emit`) carrying each AgentResponse / AgentResponseUpdate alongside the existing `output` events. Orchestrations use this to surface intermediate participants while reserving `output` events for the workflow's final answer. @@ -189,7 +189,7 @@ def __init__( if self._context_mode == "custom" and not self._context_filter: raise ValueError("context_filter must be provided when context_mode is set to 'custom'.") - self._emit_intermediate_data = emit_intermediate_data + self._emit_data_events = emit_data_events @property def agent(self) -> SupportsAgentRun: @@ -437,7 +437,7 @@ async def _run_agent(self, ctx: WorkflowContext[Never, AgentResponse]) -> AgentR client_kwargs=client_kwargs, ) await ctx.yield_output(response) - if self._emit_intermediate_data: + if self._emit_data_events: await ctx.add_event(WorkflowEvent.emit(self.id, response)) # Handle any user input requests @@ -482,7 +482,7 @@ async def _run_agent_streaming(self, ctx: WorkflowContext[Never, AgentResponseUp async for update in stream: updates.append(update) await ctx.yield_output(update) - if self._emit_intermediate_data: + if self._emit_data_events: await ctx.add_event(WorkflowEvent.emit(self.id, update)) if update.user_input_requests: streamed_user_input_requests.extend(update.user_input_requests) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index dfddc985b3..43d841ac18 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -567,7 +567,7 @@ async def _process_workflow_result( await asyncio.gather(*[ctx.send_message(output) for output in outputs]) # Forward data events from the sub-workflow so that intermediate - # observations (e.g. emit_intermediate_data from AgentExecutor) are + # observations (e.g. emit_data_events from AgentExecutor) are # visible in the parent workflow's event stream. data_events = [event for event in result if isinstance(event, WorkflowEvent) and event.type == "data"] for data_event in data_events: diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index d7a3f667e8..1235ff3f2c 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -701,10 +701,10 @@ async def test_resolve_executor_kwargs_empty_per_executor_does_not_fallback_to_g assert result == {} -async def test_emit_intermediate_data_emits_data_events_non_streaming() -> None: - """When emit_intermediate_data=True, AgentExecutor emits a data event with the AgentResponse.""" +async def test_emit_data_events_mirrors_yield_output_non_streaming() -> None: + """When emit_data_events=True, AgentExecutor emits a data event with the AgentResponse.""" agent = _CountingAgent(id="agent_a", name="AgentA") - executor = AgentExecutor(agent, id="exec_a", emit_intermediate_data=True) + executor = AgentExecutor(agent, id="exec_a", emit_data_events=True) workflow = WorkflowBuilder(start_executor=executor).build() output_events: list[WorkflowEvent[Any]] = [] @@ -725,10 +725,10 @@ async def test_emit_intermediate_data_emits_data_events_non_streaming() -> None: assert data_events[0].data.messages[0].text == output_events[0].data.messages[0].text -async def test_emit_intermediate_data_emits_data_events_streaming() -> None: - """When emit_intermediate_data=True and streaming, data events accompany each AgentResponseUpdate.""" +async def test_emit_data_events_mirrors_yield_output_streaming() -> None: + """When emit_data_events=True and streaming, data events accompany each AgentResponseUpdate.""" agent = _CountingAgent(id="agent_a", name="AgentA") - executor = AgentExecutor(agent, id="exec_a", emit_intermediate_data=True) + executor = AgentExecutor(agent, id="exec_a", emit_data_events=True) workflow = WorkflowBuilder(start_executor=executor).build() output_updates: list[WorkflowEvent[Any]] = [] @@ -745,10 +745,10 @@ async def test_emit_intermediate_data_emits_data_events_streaming() -> None: assert all(e.executor_id == "exec_a" for e in data_updates) -async def test_emit_intermediate_data_default_false_no_data_events() -> None: - """When emit_intermediate_data is not set, no extra data events are emitted (default behavior).""" +async def test_emit_data_events_default_false_no_data_events() -> None: + """When emit_data_events is not set, no extra data events are emitted (default behavior).""" agent = _CountingAgent(id="agent_a", name="AgentA") - executor = AgentExecutor(agent, id="exec_a") # default: emit_intermediate_data=False + executor = AgentExecutor(agent, id="exec_a") # default: emit_data_events=False workflow = WorkflowBuilder(start_executor=executor).build() data_events: list[WorkflowEvent[Any]] = [] diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 0101a6e8a5..666a88e2c7 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -23,6 +23,7 @@ WorkflowAgent, WorkflowBuilder, WorkflowContext, + WorkflowEvent, executor, handler, response_handler, @@ -1562,3 +1563,216 @@ def test_merge_updates_function_result_no_matching_call(self): # Order: text (user), text (assistant), function_result (orphan at end) assert content_types == ["text", "text", "function_result"] + + +class _ReasoningEmittingExecutor(Executor): + """Test executor that emits a `data` event followed by an `output` event. + + Mirrors the pattern AgentExecutor(emit_data_events=True) uses: a data event surfaces + intermediate observation, an output event carries the workflow's terminal answer. + Used to validate WorkflowAgent's data → text_reasoning conversion in isolation. + """ + + def __init__( + self, + id: str, + intermediate_data: AgentResponse | Message | list[Message], + terminal_output: AgentResponse | Message | list[Message], + ): + super().__init__(id=id) + self._intermediate_data = intermediate_data + self._terminal_output = terminal_output + + @handler + async def handle( + self, + message: list[Message], + ctx: WorkflowContext[Any, Any], + ) -> None: + await ctx.add_event(WorkflowEvent.emit(self.id, self._intermediate_data)) + await ctx.yield_output(self._terminal_output) + + +class TestWorkflowAgentReasoningHelpers: + """Tests for WorkflowAgent._rewrite_text_to_reasoning and _msg_as_reasoning helpers.""" + + def test_rewrite_text_to_reasoning_converts_text(self) -> None: + """Text content blocks are converted to text_reasoning, preserving id and text.""" + text = Content.from_text(text="hello world", additional_properties={"src": "agent1"}) + result = WorkflowAgent._rewrite_text_to_reasoning([text]) + assert len(result) == 1 + assert result[0].type == "text_reasoning" + assert result[0].text == "hello world" # type: ignore[attr-defined] + assert result[0].additional_properties.get("src") == "agent1" + + def test_rewrite_text_to_reasoning_passes_through_function_call(self) -> None: + """Non-text content (function calls, results) passes through unchanged.""" + fc = Content.from_function_call(call_id="c1", name="my_tool", arguments={"x": 1}) + result = WorkflowAgent._rewrite_text_to_reasoning([fc]) + assert len(result) == 1 + assert result[0] is fc # same instance — passed through + + def test_rewrite_text_to_reasoning_no_double_wrap(self) -> None: + """Already-reasoning content stays as text_reasoning (not wrapped again).""" + already = Content.from_text_reasoning(text="thinking") + result = WorkflowAgent._rewrite_text_to_reasoning([already]) + assert len(result) == 1 + assert result[0] is already # same instance — only type=='text' is rewritten + + def test_rewrite_text_to_reasoning_handles_mixed_content(self) -> None: + """Mixed content: only text blocks are rewritten; others pass through.""" + text = Content.from_text(text="answer") + fc = Content.from_function_call(call_id="c1", name="my_tool", arguments={}) + already = Content.from_text_reasoning(text="prior thinking") + result = WorkflowAgent._rewrite_text_to_reasoning([text, fc, already]) + assert [c.type for c in result] == ["text_reasoning", "function_call", "text_reasoning"] + assert result[1] is fc + assert result[2] is already + + def test_msg_as_reasoning_preserves_role_and_metadata(self) -> None: + """_msg_as_reasoning copies the message with rewritten contents but preserves all other fields.""" + original = Message( + "assistant", + [Content.from_text(text="hi"), Content.from_function_call(call_id="c1", name="t", arguments={})], + author_name="agent1", + message_id="msg-123", + additional_properties={"meta": "value"}, + ) + new_msg = WorkflowAgent._msg_as_reasoning(original) + assert new_msg is not original + assert new_msg.role == "assistant" + assert new_msg.author_name == "agent1" + assert new_msg.message_id == "msg-123" + assert new_msg.additional_properties.get("meta") == "value" + assert [c.type for c in new_msg.contents] == ["text_reasoning", "function_call"] + # Original message is unmodified + assert [c.type for c in original.contents] == ["text", "function_call"] + + +class TestWorkflowAgentDataEventReasoningConversion: + """End-to-end tests for as_agent() rewriting data event content as reasoning.""" + + async def test_data_event_text_becomes_reasoning_non_streaming(self) -> None: + """A data event carrying AgentResponse with text content surfaces as text_reasoning in as_agent().""" + intermediate = AgentResponse(messages=[Message("assistant", [Content.from_text(text="thinking step")])]) + terminal = AgentResponse(messages=[Message("assistant", [Content.from_text(text="final answer")])]) + exec_ = _ReasoningEmittingExecutor(id="exec_a", intermediate_data=intermediate, terminal_output=terminal) + agent = WorkflowBuilder(start_executor=exec_).build().as_agent() + + response = await agent.run("go") + + assert isinstance(response, AgentResponse) + all_types = [c.type for m in response.messages for c in m.contents] + # Intermediate content rewritten to text_reasoning; terminal stays text. + assert "text_reasoning" in all_types + assert "text" in all_types + reasoning_text = " ".join( + c.text or "" for m in response.messages for c in m.contents if c.type == "text_reasoning" + ) + answer_text = " ".join(c.text or "" for m in response.messages for c in m.contents if c.type == "text") + assert reasoning_text == "thinking step" + assert answer_text == "final answer" + + async def test_output_event_text_passes_through_non_streaming(self) -> None: + """An output event with text content passes through unchanged (not rewritten).""" + + class _OutputOnly(Executor): + @handler + async def handle(self, message: list[Message], ctx: WorkflowContext[Any, Any]) -> None: + response = AgentResponse(messages=[Message("assistant", [Content.from_text(text="answer")])]) + await ctx.yield_output(response) + + agent = WorkflowBuilder(start_executor=_OutputOnly(id="solo")).build().as_agent() + response = await agent.run("go") + all_types = [c.type for m in response.messages for c in m.contents] + assert all_types == ["text"], "Output events must not be rewritten as reasoning" + + async def test_data_event_with_mixed_content_only_text_rewritten(self) -> None: + """In a data event, only text content is rewritten; function_call/result pass through.""" + intermediate = AgentResponse( + messages=[ + Message( + "assistant", + [ + Content.from_text(text="reasoning"), + Content.from_function_call(call_id="c1", name="search", arguments={"q": "x"}), + ], + ) + ] + ) + terminal = AgentResponse(messages=[Message("assistant", [Content.from_text(text="done")])]) + exec_ = _ReasoningEmittingExecutor(id="mix", intermediate_data=intermediate, terminal_output=terminal) + agent = WorkflowBuilder(start_executor=exec_).build().as_agent() + response = await agent.run("go") + + # Find the message that has the function_call (the intermediate one) + intermediate_msg = next(m for m in response.messages if any(c.type == "function_call" for c in m.contents)) + types = [c.type for c in intermediate_msg.contents] + assert types == ["text_reasoning", "function_call"] + + async def test_data_event_already_reasoning_not_double_wrapped(self) -> None: + """A data event whose content is already text_reasoning surfaces unchanged (no double wrap).""" + intermediate = AgentResponse( + messages=[Message("assistant", [Content.from_text_reasoning(text="already thinking")])] + ) + terminal = AgentResponse(messages=[Message("assistant", [Content.from_text(text="done")])]) + exec_ = _ReasoningEmittingExecutor(id="reasoning_in", intermediate_data=intermediate, terminal_output=terminal) + agent = WorkflowBuilder(start_executor=exec_).build().as_agent() + response = await agent.run("go") + + reasoning_blocks = [c for m in response.messages for c in m.contents if c.type == "text_reasoning"] + assert len(reasoning_blocks) == 1 + assert reasoning_blocks[0].text == "already thinking" # type: ignore[attr-defined] + + async def test_data_event_text_becomes_reasoning_streaming(self) -> None: + """In streaming mode, AgentResponseUpdate from data events carries text_reasoning content.""" + intermediate = AgentResponse(messages=[Message("assistant", [Content.from_text(text="midway")])]) + terminal = AgentResponse(messages=[Message("assistant", [Content.from_text(text="end")])]) + exec_ = _ReasoningEmittingExecutor(id="stream_x", intermediate_data=intermediate, terminal_output=terminal) + agent = WorkflowBuilder(start_executor=exec_).build().as_agent() + + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("go", stream=True): + updates.append(update) + + all_types = [c.type for u in updates for c in u.contents] + assert "text_reasoning" in all_types + assert "text" in all_types + # Reasoning chunk's text matches the intermediate + reasoning_chunks = [c for u in updates for c in u.contents if c.type == "text_reasoning"] + assert any((c.text or "") == "midway" for c in reasoning_chunks) + # Terminal text chunk matches + text_chunks = [c for u in updates for c in u.contents if c.type == "text"] + assert any((c.text or "") == "end" for c in text_chunks) + + async def test_data_event_streaming_does_not_mutate_source_update(self) -> None: + """Reasoning rewriting must not mutate the AgentResponseUpdate the source emitted. + + AgentExecutor (and other emit_data_events publishers) hold references to the + update in their local `updates` list and yielded output channel. Mutating + `data.contents` in place would silently corrupt the AgentResponse the executor + finalizes from those updates. + """ + original_update = AgentResponseUpdate( + contents=[Content.from_text(text="reason")], + role="assistant", + author_name="agent_a", + ) + + class _SharedUpdateExecutor(Executor): + @handler + async def handle(self, message: list[Message], ctx: WorkflowContext[Any, Any]) -> None: + # Mirror what AgentExecutor(emit_data_events=True) does: emit the same + # update via both data and output channels. + await ctx.add_event(WorkflowEvent.emit(self.id, original_update)) + await ctx.yield_output( + AgentResponse(messages=[Message("assistant", [Content.from_text(text="final")])]) + ) + + agent = WorkflowBuilder(start_executor=_SharedUpdateExecutor(id="src")).build().as_agent() + async for _ in agent.run("go", stream=True): + pass + + # Source update content must be unchanged (still `text`, never rewritten to `text_reasoning`). + assert [c.type for c in original_update.contents] == ["text"] + assert original_update.author_name == "agent_a" # not stamped with executor id diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py index ad064abd71..81cf6661f0 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py @@ -344,7 +344,7 @@ def _resolve_participants(self) -> list[Executor]: """Resolve participant instances into Executor objects. When `intermediate_outputs=True`, every wrapped agent is constructed with - `emit_intermediate_data=True` so its individual response surfaces as a `data` + `emit_data_events=True` so its individual response surfaces as a `data` event without polluting the single `output` event reserved for the aggregator's final answer. """ @@ -362,9 +362,9 @@ def _resolve_participants(self) -> list[Executor]: not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(p, emit_intermediate_data=self._intermediate_outputs)) + executors.append(AgentApprovalExecutor(p, emit_data_events=self._intermediate_outputs)) else: - executors.append(AgentExecutor(p, emit_intermediate_data=self._intermediate_outputs)) + executors.append(AgentExecutor(p, emit_data_events=self._intermediate_outputs)) else: raise TypeError(f"Participants must be SupportsAgentRun or Executor instances. Got {type(p).__name__}.") diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index 3074ea81e2..cf71760afa 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -964,11 +964,9 @@ def _resolve_participants(self) -> list[Executor]: not self._request_info_filter or resolve_agent_id(participant) in self._request_info_filter ): # Handle request info enabled agents - executors.append( - AgentApprovalExecutor(participant, emit_intermediate_data=self._intermediate_outputs) - ) + executors.append(AgentApprovalExecutor(participant, emit_data_events=self._intermediate_outputs)) else: - executors.append(AgentExecutor(participant, emit_intermediate_data=self._intermediate_outputs)) + executors.append(AgentExecutor(participant, emit_data_events=self._intermediate_outputs)) else: raise TypeError( f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}." diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index 6c06c0bd84..f555ab89b0 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -352,7 +352,7 @@ async def _run_agent_and_emit(self, ctx: WorkflowContext[Any, Any]) -> None: self._full_conversation.extend(self._cache.copy()) # Check termination condition before running the agent - if await self._check_terminate_and_yield(ctx): + if await self._should_terminate(): return # Run the agent @@ -410,7 +410,7 @@ async def _run_agent_and_emit(self, ctx: WorkflowContext[Any, Any]) -> None: # Re-evaluate termination after appending and broadcasting this response. # Without this check, workflows that become terminal due to the latest assistant # message would still emit request_info and require an unnecessary extra resume. - if await self._check_terminate_and_yield(ctx): + if await self._should_terminate(): return # Handle case where no handoff was requested @@ -520,14 +520,12 @@ def _is_handoff_requested(self, response: AgentResponse) -> tuple[str, Message] return None - async def _check_terminate_and_yield(self, ctx: WorkflowContext[Any, Any]) -> bool: - """Check termination conditions and yield completion if met. + async def _should_terminate(self) -> bool: + """Pure predicate: return True iff the configured termination condition is satisfied. - Args: - ctx: Workflow context for yielding output - - Returns: - True if termination condition met and output yielded, False otherwise + Per-agent responses already surface as `output` events as agents speak, so the + handoff workflow has no terminal yield to make — this method only decides whether + the workflow should stop iterating. """ if self._termination_condition is None: return False @@ -535,8 +533,6 @@ async def _check_terminate_and_yield(self, ctx: WorkflowContext[Any, Any]) -> bo terminated = self._termination_condition(self._full_conversation) if inspect.isawaitable(terminated): terminated = await terminated - - # Per-agent responses already surfaced as `output` events; no terminal yield needed. return bool(terminated) @override @@ -574,13 +570,15 @@ class HandoffBuilder: tool injection, and middleware — capabilities only available on ``Agent``. Outputs: - The final conversation history as a list of Message once the group chat completes. + Each agent's response surfaces as a workflow `output` event as it speaks; there is no + synthetic terminal event. Consumers iterating events see per-agent ``AgentResponse`` (or + ``AgentResponseUpdate`` while streaming) in conversation order. The workflow returns to + idle once the termination condition is met (or the user terminates an interactive run). Note: 1. Agents in handoff workflows must be ``Agent`` instances and support local tool calls. - 2. Handoff doesn't support intermediate outputs from agents. All outputs are returned as - they become available. This is because agents in handoff workflows are not considered - sub-agents of a central orchestrator, thus all outputs are directly emitted. + 2. Because each agent's response is itself a workflow output, handoff has no separate + "intermediate outputs" channel — every per-agent response is the primary output. """ def __init__( diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index d02da5ee9a..ada2354014 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -1313,7 +1313,7 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: class MagenticAgentExecutor(AgentExecutor): """Specialized AgentExecutor for Magentic agent participants.""" - def __init__(self, agent: SupportsAgentRun, *, emit_intermediate_data: bool = False) -> None: + def __init__(self, agent: SupportsAgentRun, *, emit_data_events: bool = False) -> None: """Initialize a Magentic Agent Executor. This executor wraps an SupportsAgentRun instance to be used as a participant @@ -1321,14 +1321,14 @@ def __init__(self, agent: SupportsAgentRun, *, emit_intermediate_data: bool = Fa Args: agent: The agent instance to wrap. - emit_intermediate_data: Forwarded to the base AgentExecutor. + emit_data_events: Forwarded to the base AgentExecutor. Notes: Magentic pattern requires a reset operation upon replanning. This executor extends the base AgentExecutor to handle resets appropriately. In order to handle resets, the agent threads and other states are reset when requested by the orchestrator. And because of this, MagenticAgentExecutor does not support custom threads. """ - super().__init__(agent, emit_intermediate_data=emit_intermediate_data) + super().__init__(agent, emit_data_events=emit_data_events) @handler async def handle_magentic_reset(self, signal: MagenticResetSignal, ctx: WorkflowContext) -> None: @@ -1739,7 +1739,7 @@ def _resolve_participants(self) -> list[Executor]: if isinstance(participant, Executor): executors.append(participant) elif isinstance(participant, SupportsAgentRun): - executors.append(MagenticAgentExecutor(participant, emit_intermediate_data=self._intermediate_outputs)) + executors.append(MagenticAgentExecutor(participant, emit_data_events=self._intermediate_outputs)) else: raise TypeError( f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}." diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py index 30601e587c..128714bddc 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py @@ -123,18 +123,18 @@ def __init__( agent: SupportsAgentRun, context_mode: Literal["full", "last_agent", "custom"] | None = None, *, - emit_intermediate_data: bool = False, + emit_data_events: bool = False, ) -> None: """Initialize the AgentApprovalExecutor. Args: agent: The agent protocol to use for generating responses. context_mode: The mode for providing context to the agent. - emit_intermediate_data: Forwarded to the inner AgentExecutor. + emit_data_events: Forwarded to the inner AgentExecutor. """ self._context_mode: Literal["full", "last_agent", "custom"] | None = context_mode self._description = agent.description - self._emit_intermediate_data = emit_intermediate_data + self._emit_data_events = emit_data_events super().__init__(workflow=self._build_workflow(agent), id=resolve_agent_id(agent), propagate_request=True) @@ -143,7 +143,7 @@ def _build_workflow(self, agent: SupportsAgentRun) -> Workflow: agent_executor = AgentExecutor( agent, context_mode=self._context_mode, - emit_intermediate_data=self._emit_intermediate_data, + emit_data_events=self._emit_data_events, ) request_info_executor = AgentRequestInfoExecutor(id="agent_request_info_executor") diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index 717d4faf66..79fc9d28c5 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -11,7 +11,7 @@ The workflow's final `output` event is either the last agent's `AgentResponse` (when the terminator is an agent) or the custom executor's `list[Message]`. With `intermediate_outputs=True`, intermediate agents emit `data` events (via -`AgentExecutor.emit_intermediate_data`) so consumers can observe them separately from the +`AgentExecutor.emit_data_events`) so consumers can observe them separately from the terminal answer. """ @@ -67,7 +67,7 @@ class _EndWithConversation(Executor): workflow's outputs directly. Intermediate participants emit observation `data` events (via - `AgentExecutor.emit_intermediate_data`) when `intermediate_outputs=True`; they never + `AgentExecutor.emit_data_events`) when `intermediate_outputs=True`; they never emit `output` events because output_executors is restricted to the terminator executor (the last agent or this node). """ @@ -220,7 +220,7 @@ def _resolve_participants(self) -> list[Executor]: """Resolve participant instances into Executor objects. Wraps `SupportsAgentRun` participants as `AgentExecutor`. When `intermediate_outputs=True`, - every wrapped agent except the final one is constructed with `emit_intermediate_data=True` + every wrapped agent except the final one is constructed with `emit_data_events=True` so its responses surface as workflow `data` events without polluting the single `output` event reserved for the final answer. """ @@ -248,7 +248,7 @@ def _resolve_participants(self) -> list[Executor]: AgentApprovalExecutor( p, context_mode=context_mode, - emit_intermediate_data=emit_intermediate, + emit_data_events=emit_intermediate, ) ) else: @@ -256,7 +256,7 @@ def _resolve_participants(self) -> list[Executor]: AgentExecutor( p, context_mode=context_mode, - emit_intermediate_data=emit_intermediate, + emit_data_events=emit_intermediate, ) ) else: diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index de11b4d359..1dd5bef45a 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -201,7 +201,7 @@ async def test_sequential_as_agent_returns_only_last_agent_response() -> None: async def test_sequential_as_agent_with_intermediate_outputs_includes_chain() -> None: """With `intermediate_outputs=True`, `as_agent()` surfaces intermediate agent responses - (via `data` events) followed by the final answer.""" + (rewritten as `text_reasoning` content) followed by the final answer (`text` content).""" a1 = _EchoAgent(id="agent1", name="A1") a2 = _EchoAgent(id="agent2", name="A2") @@ -209,12 +209,15 @@ async def test_sequential_as_agent_with_intermediate_outputs_includes_chain() -> response = await agent.run("hello as_agent") assert isinstance(response, AgentResponse) - combined_text = " ".join(m.text for m in response.messages) - assert "A1 reply" in combined_text - assert "A2 reply" in combined_text + + reasoning_text = " ".join(c.text or "" for m in response.messages for c in m.contents if c.type == "text_reasoning") + answer_text = " ".join(c.text or "" for m in response.messages for c in m.contents if c.type == "text") + assert "A1 reply" in reasoning_text, "Intermediate writer reply should arrive as reasoning content" + assert "A2 reply" in answer_text, "Terminal reviewer reply should arrive as text content" + assert "A1 reply" not in answer_text, "Intermediate content should not appear as final text" # Final agent's reply should appear last in the message ordering. - last_text = response.messages[-1].text - assert "A2 reply" in last_text + last_msg_text = " ".join(c.text or "" for c in response.messages[-1].contents if c.type == "text") + assert "A2 reply" in last_msg_text async def test_sequential_with_custom_executor_summary() -> None: From b6a6d60d1ec0c4a14e32e2cb0073bb0c66c211b1 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 17 Apr 2026 13:34:30 +0900 Subject: [PATCH 11/16] Fix pyright: widen event.data to Any to avoid partial-unknown narrowing The streaming conversion path narrowed event.data via isinstance against generic AgentResponse, producing AgentResponse[Unknown] and tripping reportUnknownVariableType/reportUnknownMemberType. Binding data: Any before the check keeps runtime behavior identical while restoring a fully known type for downstream access. --- python/packages/core/agent_framework/_workflows/_agent.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index a4a5f1f7ac..84c6ffafef 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -637,14 +637,12 @@ def _convert_workflow_event_to_agent_response_updates( Returns: A list of AgentResponseUpdate objects. Empty list if the event is not relevant. """ - if event.type == "output" or ( - event.type == "data" and isinstance(event.data, (AgentResponse, AgentResponseUpdate)) - ): + data: Any = event.data + if event.type == "output" or (event.type == "data" and isinstance(data, (AgentResponse, AgentResponseUpdate))): # `data` events carry intermediate participant content (e.g., orchestration agents # via emit_data_events). Reframe their text content as `text_reasoning` so consumers # render them as agent thinking. `output` events pass through unchanged. as_reasoning = event.type == "data" - data = event.data executor_id = event.executor_id def _contents(src: Sequence[Content]) -> list[Content]: From f2e4d530c4c6bd6eb95e8804045030289ab61a44 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 23 Apr 2026 15:03:31 +0900 Subject: [PATCH 12/16] Clean up design --- .../core/agent_framework/_workflows/_agent.py | 14 +++--- .../_workflows/_agent_executor.py | 47 ++++++++++++------- .../_workflows/_workflow_executor.py | 2 +- .../tests/workflow/test_agent_executor.py | 42 ++++++++++------- .../tests/workflow/test_workflow_agent.py | 19 ++++---- .../_concurrent.py | 10 ++-- .../_group_chat.py | 4 +- .../_magentic.py | 8 ++-- .../_orchestration_request_info.py | 8 ++-- .../_sequential.py | 24 +++++----- 10 files changed, 99 insertions(+), 79 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 84c6ffafef..a11c22508f 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -530,10 +530,11 @@ def _convert_workflow_events_to_agent_response( raw_representations.append(output_event) else: # `data` events carry intermediate participant responses (e.g., orchestration - # agents emitting via emit_data_events). Reframe their text content as - # `text_reasoning` so consumers can render them like agent thinking, mirroring - # how reasoning-capable agents (Claude thinking, OpenAI reasoning) already - # surface intermediate content. `output` events pass through unchanged. + # agents constructed with `AgentExecutor(..., intermediate=True)`). Reframe + # their text content as `text_reasoning` so consumers can render them like + # agent thinking, mirroring how reasoning-capable agents (Claude thinking, + # OpenAI reasoning) already surface intermediate content. `output` events + # pass through unchanged. as_reasoning = output_event.type == "data" data = output_event.data @@ -640,8 +641,9 @@ def _convert_workflow_event_to_agent_response_updates( data: Any = event.data if event.type == "output" or (event.type == "data" and isinstance(data, (AgentResponse, AgentResponseUpdate))): # `data` events carry intermediate participant content (e.g., orchestration agents - # via emit_data_events). Reframe their text content as `text_reasoning` so consumers - # render them as agent thinking. `output` events pass through unchanged. + # constructed with `AgentExecutor(..., intermediate=True)`). Reframe their text + # content as `text_reasoning` so consumers render them as agent thinking. `output` + # events pass through unchanged. as_reasoning = event.type == "data" executor_id = event.executor_id diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 91c379fed6..a5d12b717c 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -4,7 +4,7 @@ import sys from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Literal, cast +from typing import Any, Literal, TypeVar, cast from typing_extensions import Never @@ -117,6 +117,9 @@ async def upper_case( ) +_PayloadT = TypeVar("_PayloadT", AgentResponse, AgentResponseUpdate) + + class AgentExecutor(Executor): """built-in executor that wraps an agent for handling messages. @@ -142,7 +145,7 @@ def __init__( id: str | None = None, context_mode: Literal["full", "last_agent", "custom"] | None = None, context_filter: Callable[[list[Message]], list[Message]] | None = None, - emit_data_events: bool = False, + intermediate: bool = False, ): """Initialize the executor with a unique identifier. @@ -158,12 +161,13 @@ def __init__( the agent run. - "custom": use the provided context_filter function to determine which messages to include as context for the agent run. - context_filter: An optional function for filtering conversation context when context_mode is set - to "custom". - emit_data_events: When True, additionally emits `data` events (via - `WorkflowEvent.emit`) carrying each AgentResponse / AgentResponseUpdate alongside - the existing `output` events. Orchestrations use this to surface intermediate - participants while reserving `output` events for the workflow's final answer. + context_filter: A function that takes the full conversation (list of Messages) as input and returns + a filtered list of Messages to be used as context for the agent run. This is required + if context_mode is set to "custom". + intermediate: When True, this executor is an intermediate participant in an + orchestration: each response is published as an observable `data` event + rather than as the workflow's `output` event. Standalone callers should + leave this False (the default), so responses surface as workflow output. """ # Prefer provided id; else use agent.name if present; else generate deterministic prefix exec_id = id or resolve_agent_id(agent) @@ -189,7 +193,7 @@ def __init__( if self._context_mode == "custom" and not self._context_filter: raise ValueError("context_filter must be provided when context_mode is set to 'custom'.") - self._emit_data_events = emit_data_events + self._intermediate = intermediate @property def agent(self) -> SupportsAgentRun: @@ -377,15 +381,26 @@ def reset(self) -> None: logger.debug("AgentExecutor %s: Resetting cache", self.id) self._cache.clear() + async def _publish( + self, + ctx: WorkflowContext[Any, _PayloadT], + payload: _PayloadT, + ) -> None: + """Route the payload to exactly one channel based on `self._intermediate`.""" + if self._intermediate: + await ctx.add_event(WorkflowEvent.emit(self.id, payload)) + else: + await ctx.yield_output(payload) + async def _run_agent_and_emit( self, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate], ) -> None: """Execute the underlying agent, emit events, and enqueue response. - Checks ctx.is_streaming() to determine whether to emit output events (type='output') - containing incremental updates (streaming mode) or a single output event (type='output') - containing the complete response (non-streaming mode). + Checks ctx.is_streaming() to determine whether to publish per-update payloads + (streaming mode) or a single full-response payload (non-streaming mode). Each + payload is published on exactly one channel — see `_publish`. """ if ctx.is_streaming(): # Streaming mode: emit incremental updates @@ -436,9 +451,7 @@ async def _run_agent(self, ctx: WorkflowContext[Never, AgentResponse]) -> AgentR function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ) - await ctx.yield_output(response) - if self._emit_data_events: - await ctx.add_event(WorkflowEvent.emit(self.id, response)) + await self._publish(ctx, response) # Handle any user input requests if response.user_input_requests: @@ -481,9 +494,7 @@ async def _run_agent_streaming(self, ctx: WorkflowContext[Never, AgentResponseUp ) async for update in stream: updates.append(update) - await ctx.yield_output(update) - if self._emit_data_events: - await ctx.add_event(WorkflowEvent.emit(self.id, update)) + await self._publish(ctx, update) if update.user_input_requests: streamed_user_input_requests.extend(update.user_input_requests) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 43d841ac18..19cb636016 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -567,7 +567,7 @@ async def _process_workflow_result( await asyncio.gather(*[ctx.send_message(output) for output in outputs]) # Forward data events from the sub-workflow so that intermediate - # observations (e.g. emit_data_events from AgentExecutor) are + # observations (e.g. AgentExecutor with intermediate=True) are # visible in the parent workflow's event stream. data_events = [event for event in result if isinstance(event, WorkflowEvent) and event.type == "data"] for data_event in data_events: diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 1235ff3f2c..3d08160509 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -701,10 +701,11 @@ async def test_resolve_executor_kwargs_empty_per_executor_does_not_fallback_to_g assert result == {} -async def test_emit_data_events_mirrors_yield_output_non_streaming() -> None: - """When emit_data_events=True, AgentExecutor emits a data event with the AgentResponse.""" +async def test_intermediate_publishes_data_event_only_non_streaming() -> None: + """When intermediate=True, AgentExecutor publishes the response as a data event and + never as an output event — the two channels are mutually exclusive.""" agent = _CountingAgent(id="agent_a", name="AgentA") - executor = AgentExecutor(agent, id="exec_a", emit_data_events=True) + executor = AgentExecutor(agent, id="exec_a", intermediate=True) workflow = WorkflowBuilder(start_executor=executor).build() output_events: list[WorkflowEvent[Any]] = [] @@ -715,20 +716,19 @@ async def test_emit_data_events_mirrors_yield_output_non_streaming() -> None: elif event.type == "data": data_events.append(event) - # Output event still emitted (existing behavior unchanged) - assert len(output_events) == 1 - assert isinstance(output_events[0].data, AgentResponse) - # Plus a parallel data event with the same AgentResponse payload + # No output event from the intermediate executor — the standalone workflow has no terminator + assert output_events == [] + # Exactly one data event carrying the AgentResponse payload assert len(data_events) == 1 assert data_events[0].executor_id == "exec_a" assert isinstance(data_events[0].data, AgentResponse) - assert data_events[0].data.messages[0].text == output_events[0].data.messages[0].text -async def test_emit_data_events_mirrors_yield_output_streaming() -> None: - """When emit_data_events=True and streaming, data events accompany each AgentResponseUpdate.""" +async def test_intermediate_publishes_data_event_only_streaming() -> None: + """When intermediate=True and streaming, each AgentResponseUpdate publishes as a data + event and never as an output event.""" agent = _CountingAgent(id="agent_a", name="AgentA") - executor = AgentExecutor(agent, id="exec_a", emit_data_events=True) + executor = AgentExecutor(agent, id="exec_a", intermediate=True) workflow = WorkflowBuilder(start_executor=executor).build() output_updates: list[WorkflowEvent[Any]] = [] @@ -739,21 +739,27 @@ async def test_emit_data_events_mirrors_yield_output_streaming() -> None: elif event.type == "data": data_updates.append(event) - assert output_updates and all(isinstance(e.data, AgentResponseUpdate) for e in output_updates) - assert len(data_updates) == len(output_updates) - assert all(isinstance(e.data, AgentResponseUpdate) for e in data_updates) + assert output_updates == [] + assert data_updates and all(isinstance(e.data, AgentResponseUpdate) for e in data_updates) assert all(e.executor_id == "exec_a" for e in data_updates) -async def test_emit_data_events_default_false_no_data_events() -> None: - """When emit_data_events is not set, no extra data events are emitted (default behavior).""" +async def test_intermediate_default_false_publishes_output_event_only() -> None: + """Default (intermediate=False) publishes via yield_output — the standalone path — + and never produces a data event for the same payload.""" agent = _CountingAgent(id="agent_a", name="AgentA") - executor = AgentExecutor(agent, id="exec_a") # default: emit_data_events=False + executor = AgentExecutor(agent, id="exec_a") # default: intermediate=False workflow = WorkflowBuilder(start_executor=executor).build() + output_events: list[WorkflowEvent[Any]] = [] data_events: list[WorkflowEvent[Any]] = [] for event in await workflow.run("hello"): - if event.type == "data": + if event.type == "output": + output_events.append(event) + elif event.type == "data": data_events.append(event) + # Exactly one output event, no duplicate data event + assert len(output_events) == 1 + assert isinstance(output_events[0].data, AgentResponse) assert data_events == [] diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 666a88e2c7..a6b7398249 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -1568,9 +1568,10 @@ def test_merge_updates_function_result_no_matching_call(self): class _ReasoningEmittingExecutor(Executor): """Test executor that emits a `data` event followed by an `output` event. - Mirrors the pattern AgentExecutor(emit_data_events=True) uses: a data event surfaces - intermediate observation, an output event carries the workflow's terminal answer. - Used to validate WorkflowAgent's data → text_reasoning conversion in isolation. + Mirrors the orchestration pattern: an intermediate participant publishes a `data` + event (as `AgentExecutor(..., intermediate=True)` does), and a separate executor + publishes the workflow's terminal answer as an `output` event. Used to validate + WorkflowAgent's data → text_reasoning conversion in isolation. """ def __init__( @@ -1748,10 +1749,9 @@ async def test_data_event_text_becomes_reasoning_streaming(self) -> None: async def test_data_event_streaming_does_not_mutate_source_update(self) -> None: """Reasoning rewriting must not mutate the AgentResponseUpdate the source emitted. - AgentExecutor (and other emit_data_events publishers) hold references to the - update in their local `updates` list and yielded output channel. Mutating - `data.contents` in place would silently corrupt the AgentResponse the executor - finalizes from those updates. + AgentExecutor (and other publishers of intermediate data events) hold references + to the update in their local `updates` list. Mutating `data.contents` in place + would silently corrupt the AgentResponse the executor finalizes from those updates. """ original_update = AgentResponseUpdate( contents=[Content.from_text(text="reason")], @@ -1762,8 +1762,9 @@ async def test_data_event_streaming_does_not_mutate_source_update(self) -> None: class _SharedUpdateExecutor(Executor): @handler async def handle(self, message: list[Message], ctx: WorkflowContext[Any, Any]) -> None: - # Mirror what AgentExecutor(emit_data_events=True) does: emit the same - # update via both data and output channels. + # Exercise the data → text_reasoning rewrite while keeping a reference + # to `original_update`. Also yield an output event so the workflow has a + # terminal answer for the consumer. await ctx.add_event(WorkflowEvent.emit(self.id, original_update)) await ctx.yield_output( AgentResponse(messages=[Message("assistant", [Content.from_text(text="final")])]) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py index 81cf6661f0..97e1568f8c 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py @@ -344,9 +344,9 @@ def _resolve_participants(self) -> list[Executor]: """Resolve participant instances into Executor objects. When `intermediate_outputs=True`, every wrapped agent is constructed with - `emit_data_events=True` so its individual response surfaces as a `data` - event without polluting the single `output` event reserved for the aggregator's - final answer. + `intermediate=True` so its individual response publishes as a `data` event + instead of an `output` event, leaving the single `output` event reserved for + the aggregator's final answer. """ if not self._participants: raise ValueError("No participants provided. Pass participants to the constructor.") @@ -362,9 +362,9 @@ def _resolve_participants(self) -> list[Executor]: not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(p, emit_data_events=self._intermediate_outputs)) + executors.append(AgentApprovalExecutor(p, intermediate=self._intermediate_outputs)) else: - executors.append(AgentExecutor(p, emit_data_events=self._intermediate_outputs)) + executors.append(AgentExecutor(p, intermediate=self._intermediate_outputs)) else: raise TypeError(f"Participants must be SupportsAgentRun or Executor instances. Got {type(p).__name__}.") diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index cf71760afa..ae97f50169 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -964,9 +964,9 @@ def _resolve_participants(self) -> list[Executor]: not self._request_info_filter or resolve_agent_id(participant) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(participant, emit_data_events=self._intermediate_outputs)) + executors.append(AgentApprovalExecutor(participant, intermediate=self._intermediate_outputs)) else: - executors.append(AgentExecutor(participant, emit_data_events=self._intermediate_outputs)) + executors.append(AgentExecutor(participant, intermediate=self._intermediate_outputs)) else: raise TypeError( f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}." diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index ada2354014..5eb45c5659 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -1313,7 +1313,7 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: class MagenticAgentExecutor(AgentExecutor): """Specialized AgentExecutor for Magentic agent participants.""" - def __init__(self, agent: SupportsAgentRun, *, emit_data_events: bool = False) -> None: + def __init__(self, agent: SupportsAgentRun, *, intermediate: bool = False) -> None: """Initialize a Magentic Agent Executor. This executor wraps an SupportsAgentRun instance to be used as a participant @@ -1321,14 +1321,14 @@ def __init__(self, agent: SupportsAgentRun, *, emit_data_events: bool = False) - Args: agent: The agent instance to wrap. - emit_data_events: Forwarded to the base AgentExecutor. + intermediate: Forwarded to the base AgentExecutor. See ``AgentExecutor.__init__``. Notes: Magentic pattern requires a reset operation upon replanning. This executor extends the base AgentExecutor to handle resets appropriately. In order to handle resets, the agent threads and other states are reset when requested by the orchestrator. And because of this, MagenticAgentExecutor does not support custom threads. """ - super().__init__(agent, emit_data_events=emit_data_events) + super().__init__(agent, intermediate=intermediate) @handler async def handle_magentic_reset(self, signal: MagenticResetSignal, ctx: WorkflowContext) -> None: @@ -1739,7 +1739,7 @@ def _resolve_participants(self) -> list[Executor]: if isinstance(participant, Executor): executors.append(participant) elif isinstance(participant, SupportsAgentRun): - executors.append(MagenticAgentExecutor(participant, emit_data_events=self._intermediate_outputs)) + executors.append(MagenticAgentExecutor(participant, intermediate=self._intermediate_outputs)) else: raise TypeError( f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}." diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py index 128714bddc..6858f5feb7 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py @@ -123,18 +123,18 @@ def __init__( agent: SupportsAgentRun, context_mode: Literal["full", "last_agent", "custom"] | None = None, *, - emit_data_events: bool = False, + intermediate: bool = False, ) -> None: """Initialize the AgentApprovalExecutor. Args: agent: The agent protocol to use for generating responses. context_mode: The mode for providing context to the agent. - emit_data_events: Forwarded to the inner AgentExecutor. + intermediate: Forwarded to the inner AgentExecutor. See ``AgentExecutor.__init__``. """ self._context_mode: Literal["full", "last_agent", "custom"] | None = context_mode self._description = agent.description - self._emit_data_events = emit_data_events + self._intermediate = intermediate super().__init__(workflow=self._build_workflow(agent), id=resolve_agent_id(agent), propagate_request=True) @@ -143,7 +143,7 @@ def _build_workflow(self, agent: SupportsAgentRun) -> Workflow: agent_executor = AgentExecutor( agent, context_mode=self._context_mode, - emit_data_events=self._emit_data_events, + intermediate=self._intermediate, ) request_info_executor = AgentRequestInfoExecutor(id="agent_request_info_executor") diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index 79fc9d28c5..b885aa7638 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -10,9 +10,10 @@ The workflow's final `output` event is either the last agent's `AgentResponse` (when the terminator is an agent) or the custom executor's `list[Message]`. With -`intermediate_outputs=True`, intermediate agents emit `data` events (via -`AgentExecutor.emit_data_events`) so consumers can observe them separately from the -terminal answer. +`intermediate_outputs=True`, intermediate agents are constructed with +`AgentExecutor(..., intermediate=True)` so they publish each response as a `data` event +instead of an `output` event — consumers can observe intermediate participants without +those payloads being collected as workflow outputs. """ import logging @@ -66,10 +67,9 @@ class _EndWithConversation(Executor): non-streaming, or per-chunk `AgentResponseUpdate` events streaming — become the workflow's outputs directly. - Intermediate participants emit observation `data` events (via - `AgentExecutor.emit_data_events`) when `intermediate_outputs=True`; they never - emit `output` events because output_executors is restricted to the terminator - executor (the last agent or this node). + Intermediate participants are constructed with `AgentExecutor(..., intermediate=True)` + when `intermediate_outputs=True`, so they publish each response as a `data` event + rather than an `output` event. They never compete with the terminator's output. """ @handler @@ -220,9 +220,9 @@ def _resolve_participants(self) -> list[Executor]: """Resolve participant instances into Executor objects. Wraps `SupportsAgentRun` participants as `AgentExecutor`. When `intermediate_outputs=True`, - every wrapped agent except the final one is constructed with `emit_data_events=True` - so its responses surface as workflow `data` events without polluting the single `output` - event reserved for the final answer. + every wrapped agent except the final one is constructed with `intermediate=True` + so its responses publish as workflow `data` events instead of `output` events, + leaving the single `output` event reserved for the final answer. """ if not self._participants: raise ValueError("No participants provided. Pass participants to the constructor.") @@ -248,7 +248,7 @@ def _resolve_participants(self) -> list[Executor]: AgentApprovalExecutor( p, context_mode=context_mode, - emit_data_events=emit_intermediate, + intermediate=emit_intermediate, ) ) else: @@ -256,7 +256,7 @@ def _resolve_participants(self) -> list[Executor]: AgentExecutor( p, context_mode=context_mode, - emit_data_events=emit_intermediate, + intermediate=emit_intermediate, ) ) else: From 1a4c97504b4c716b29ca0f02f387f1d40d1667ca Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 28 Apr 2026 11:25:39 +0900 Subject: [PATCH 13/16] Scope to agent output semantics only --- .../core/agent_framework/_workflows/_agent.py | 99 ++------ .../_workflows/_agent_executor.py | 34 +-- .../_workflows/_workflow_executor.py | 11 +- .../tests/workflow/test_agent_executor.py | 64 ------ .../tests/workflow/test_workflow_agent.py | 215 ------------------ .../tests/workflow/test_workflow_kwargs.py | 24 +- .../_concurrent.py | 19 +- .../_group_chat.py | 13 +- .../_magentic.py | 14 +- .../_orchestration_request_info.py | 93 +++++++- .../_sequential.py | 118 +++------- .../orchestrations/tests/test_concurrent.py | 38 ---- .../orchestrations/tests/test_group_chat.py | 8 - .../orchestrations/tests/test_magentic.py | 10 +- .../orchestrations/tests/test_sequential.py | 161 ++----------- .../sequential_custom_executors.py | 65 +++--- 16 files changed, 232 insertions(+), 754 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index a11c22508f..111ef61e3b 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -300,9 +300,7 @@ async def _run_impl( function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ): - if event.type in ("output", "request_info") or ( - event.type == "data" and isinstance(event.data, (AgentResponse, AgentResponseUpdate)) - ): + if event.type == "output" or event.type == "request_info": output_events.append(event) result = self._convert_workflow_events_to_agent_response(response_id, output_events) @@ -529,13 +527,6 @@ def _convert_workflow_events_to_agent_response( ) raw_representations.append(output_event) else: - # `data` events carry intermediate participant responses (e.g., orchestration - # agents constructed with `AgentExecutor(..., intermediate=True)`). Reframe - # their text content as `text_reasoning` so consumers can render them like - # agent thinking, mirroring how reasoning-capable agents (Claude thinking, - # OpenAI reasoning) already surface intermediate content. `output` events - # pass through unchanged. - as_reasoning = output_event.type == "data" data = output_event.data if isinstance(data, AgentResponseUpdate): @@ -548,7 +539,7 @@ def _convert_workflow_events_to_agent_response( ) if isinstance(data, AgentResponse): - messages.extend(self._msg_maybe_reasoning(m, as_reasoning=as_reasoning) for m in data.messages) + messages.extend(data.messages) raw_representations.append(data.raw_representation) merged_usage = add_usage_details(merged_usage, data.usage_details) latest_created_at = ( @@ -559,18 +550,16 @@ def _convert_workflow_events_to_agent_response( else latest_created_at ) elif isinstance(data, Message): - messages.append(self._msg_maybe_reasoning(data, as_reasoning=as_reasoning)) + messages.append(data) raw_representations.append(data.raw_representation) elif is_instance_of(data, list[Message]): chat_messages = cast(list[Message], data) - messages.extend(self._msg_maybe_reasoning(m, as_reasoning=as_reasoning) for m in chat_messages) + messages.extend(chat_messages) raw_representations.append(data) else: contents = self._extract_contents(data) if not contents: continue - if as_reasoning: - contents = self._rewrite_text_to_reasoning(contents) messages.append( Message( @@ -630,33 +619,25 @@ def _convert_workflow_event_to_agent_response_updates( ) -> list[AgentResponseUpdate]: """Convert a workflow event to a list of AgentResponseUpdate objects. - Processes `output` and `request_info` events, plus `data` events carrying - `AgentResponse` or `AgentResponseUpdate` (emitted by orchestrations to surface - intermediate participants when `intermediate_outputs=True`). Other event types - are workflow-internal and ignored. + Events with type='output' and type='request_info' are processed. + Other workflow events are ignored as they are workflow-internal. + + For 'output' events, AgentExecutor yields AgentResponseUpdate for streaming updates + via ctx.yield_output(). This method converts those to agent response updates. Returns: A list of AgentResponseUpdate objects. Empty list if the event is not relevant. """ - data: Any = event.data - if event.type == "output" or (event.type == "data" and isinstance(data, (AgentResponse, AgentResponseUpdate))): - # `data` events carry intermediate participant content (e.g., orchestration agents - # constructed with `AgentExecutor(..., intermediate=True)`). Reframe their text - # content as `text_reasoning` so consumers render them as agent thinking. `output` - # events pass through unchanged. - as_reasoning = event.type == "data" + if event.type == "output": + data = event.data executor_id = event.executor_id - def _contents(src: Sequence[Content]) -> list[Content]: - return self._rewrite_text_to_reasoning(src) if as_reasoning else list(src) - if isinstance(data, AgentResponseUpdate): # Construct a fresh AgentResponseUpdate so we don't mutate a payload - # that AgentExecutor (and the data-event publisher) still hold references - # to in their `updates` list / output channel. + # that AgentExecutor still holds a reference to in its `updates` list. return [ AgentResponseUpdate( - contents=_contents(data.contents), + contents=list(data.contents), role=data.role, author_name=data.author_name or executor_id, response_id=data.response_id, @@ -671,7 +652,7 @@ def _contents(src: Sequence[Content]) -> list[Content]: for msg in data.messages: updates.append( AgentResponseUpdate( - contents=_contents(msg.contents), + contents=list(msg.contents), role=msg.role, author_name=msg.author_name or executor_id, response_id=data.response_id or response_id, @@ -685,7 +666,7 @@ def _contents(src: Sequence[Content]) -> list[Content]: if isinstance(data, Message): return [ AgentResponseUpdate( - contents=_contents(data.contents), + contents=list(data.contents), role=data.role, author_name=data.author_name or executor_id, response_id=response_id, @@ -701,7 +682,7 @@ def _contents(src: Sequence[Content]) -> list[Content]: for msg in chat_messages: updates.append( AgentResponseUpdate( - contents=_contents(msg.contents), + contents=list(msg.contents), role=msg.role, author_name=msg.author_name or executor_id, response_id=response_id, @@ -714,8 +695,6 @@ def _contents(src: Sequence[Content]) -> list[Content]: contents = self._extract_contents(data) if not contents: return [] - if as_reasoning: - contents = self._rewrite_text_to_reasoning(contents) return [ AgentResponseUpdate( contents=contents, @@ -820,52 +799,6 @@ def _extract_contents(self, data: Any) -> list[Content]: return [Content.from_text(text=data)] return [Content.from_text(text=str(data))] - @staticmethod - def _rewrite_text_to_reasoning(contents: Sequence[Content]) -> list[Content]: - """Rewrite TextContent blocks as TextReasoningContent. - - Used by WorkflowAgent to reframe content arriving on the workflow's `data` channel — - e.g., intermediate participants in an orchestration — as reasoning content from the - perspective of the wrapped workflow agent. This aligns workflow-as-agent intermediate - output with how reasoning-capable agents (Claude thinking, OpenAI reasoning) already - emit thinking content, so consumers can use one rendering path. - - Non-text content (function calls, results, already-reasoning text, hosted files, etc.) - passes through unchanged. - """ - rewritten: list[Content] = [] - for content in contents: - if content.type == "text": - rewritten.append( - Content.from_text_reasoning( - id=content.id, - text=content.text, - annotations=content.annotations, - additional_properties=content.additional_properties, - raw_representation=content.raw_representation, - ) - ) - else: - rewritten.append(content) - return rewritten - - @classmethod - def _msg_as_reasoning(cls, msg: Message) -> Message: - """Return a copy of `msg` with text content rewritten as reasoning content.""" - return Message( - role=msg.role, - contents=cls._rewrite_text_to_reasoning(msg.contents), - author_name=msg.author_name, - message_id=msg.message_id, - additional_properties=msg.additional_properties, - raw_representation=msg.raw_representation, - ) - - @classmethod - def _msg_maybe_reasoning(cls, msg: Message, *, as_reasoning: bool) -> Message: - """Conditional `_msg_as_reasoning`: rewrite when `as_reasoning` is True, pass through otherwise.""" - return cls._msg_as_reasoning(msg) if as_reasoning else msg - class _ResponseState(TypedDict): """State for grouping response updates by message_id.""" diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index a5d12b717c..9b16b1f291 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -4,7 +4,7 @@ import sys from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Literal, TypeVar, cast +from typing import Any, Literal, cast from typing_extensions import Never @@ -15,7 +15,6 @@ from .._types import AgentResponse, AgentResponseUpdate, Message, ResponseStream from ._agent_utils import resolve_agent_id from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY -from ._events import WorkflowEvent from ._executor import Executor, handler from ._message_utils import normalize_messages_input from ._request_info_mixin import response_handler @@ -117,9 +116,6 @@ async def upper_case( ) -_PayloadT = TypeVar("_PayloadT", AgentResponse, AgentResponseUpdate) - - class AgentExecutor(Executor): """built-in executor that wraps an agent for handling messages. @@ -145,7 +141,6 @@ def __init__( id: str | None = None, context_mode: Literal["full", "last_agent", "custom"] | None = None, context_filter: Callable[[list[Message]], list[Message]] | None = None, - intermediate: bool = False, ): """Initialize the executor with a unique identifier. @@ -164,10 +159,6 @@ def __init__( context_filter: A function that takes the full conversation (list of Messages) as input and returns a filtered list of Messages to be used as context for the agent run. This is required if context_mode is set to "custom". - intermediate: When True, this executor is an intermediate participant in an - orchestration: each response is published as an observable `data` event - rather than as the workflow's `output` event. Standalone callers should - leave this False (the default), so responses surface as workflow output. """ # Prefer provided id; else use agent.name if present; else generate deterministic prefix exec_id = id or resolve_agent_id(agent) @@ -193,8 +184,6 @@ def __init__( if self._context_mode == "custom" and not self._context_filter: raise ValueError("context_filter must be provided when context_mode is set to 'custom'.") - self._intermediate = intermediate - @property def agent(self) -> SupportsAgentRun: """Get the underlying agent wrapped by this executor.""" @@ -381,26 +370,15 @@ def reset(self) -> None: logger.debug("AgentExecutor %s: Resetting cache", self.id) self._cache.clear() - async def _publish( - self, - ctx: WorkflowContext[Any, _PayloadT], - payload: _PayloadT, - ) -> None: - """Route the payload to exactly one channel based on `self._intermediate`.""" - if self._intermediate: - await ctx.add_event(WorkflowEvent.emit(self.id, payload)) - else: - await ctx.yield_output(payload) - async def _run_agent_and_emit( self, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate], ) -> None: """Execute the underlying agent, emit events, and enqueue response. - Checks ctx.is_streaming() to determine whether to publish per-update payloads - (streaming mode) or a single full-response payload (non-streaming mode). Each - payload is published on exactly one channel — see `_publish`. + Checks ctx.is_streaming() to determine whether to emit output events (type='output') + containing incremental updates (streaming mode) or a single output event (type='output') + containing the complete response (non-streaming mode). """ if ctx.is_streaming(): # Streaming mode: emit incremental updates @@ -451,7 +429,7 @@ async def _run_agent(self, ctx: WorkflowContext[Never, AgentResponse]) -> AgentR function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ) - await self._publish(ctx, response) + await ctx.yield_output(response) # Handle any user input requests if response.user_input_requests: @@ -494,7 +472,7 @@ async def _run_agent_streaming(self, ctx: WorkflowContext[Never, AgentResponseUp ) async for update in stream: updates.append(update) - await self._publish(ctx, update) + await ctx.yield_output(update) if update.user_input_requests: streamed_user_input_requests.extend(update.user_input_requests) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 19cb636016..847b44863c 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -361,7 +361,7 @@ def can_handle(self, message: WorkflowMessage) -> bool: return any(is_instance_of(message.data, input_type) for input_type in self.workflow.input_types) @handler - async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any]) -> None: + async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any, Any]) -> None: """Execute the sub-workflow with raw input data. This handler starts a new sub-workflow execution. When the sub-workflow @@ -428,7 +428,7 @@ async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any]) async def handle_message_wrapped_request_response( self, response: SubWorkflowResponseMessage, - ctx: WorkflowContext[Any], + ctx: WorkflowContext[Any, Any], ) -> None: """Handle response from parent for a forwarded request. @@ -566,13 +566,6 @@ async def _process_workflow_result( else: await asyncio.gather(*[ctx.send_message(output) for output in outputs]) - # Forward data events from the sub-workflow so that intermediate - # observations (e.g. AgentExecutor with intermediate=True) are - # visible in the parent workflow's event stream. - data_events = [event for event in result if isinstance(event, WorkflowEvent) and event.type == "data"] - for data_event in data_events: - await ctx.add_event(WorkflowEvent.emit(data_event.executor_id or "", data_event.data)) - # Process request info events for event in request_info_events: request_id = event.request_id diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 3d08160509..5ffd60aa55 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -699,67 +699,3 @@ async def test_resolve_executor_kwargs_empty_per_executor_does_not_fallback_to_g resolved = {"exec_a": {}, GLOBAL_KWARGS_KEY: {"global_key": "global_val"}} result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage] assert result == {} - - -async def test_intermediate_publishes_data_event_only_non_streaming() -> None: - """When intermediate=True, AgentExecutor publishes the response as a data event and - never as an output event — the two channels are mutually exclusive.""" - agent = _CountingAgent(id="agent_a", name="AgentA") - executor = AgentExecutor(agent, id="exec_a", intermediate=True) - workflow = WorkflowBuilder(start_executor=executor).build() - - output_events: list[WorkflowEvent[Any]] = [] - data_events: list[WorkflowEvent[Any]] = [] - for event in await workflow.run("hello"): - if event.type == "output": - output_events.append(event) - elif event.type == "data": - data_events.append(event) - - # No output event from the intermediate executor — the standalone workflow has no terminator - assert output_events == [] - # Exactly one data event carrying the AgentResponse payload - assert len(data_events) == 1 - assert data_events[0].executor_id == "exec_a" - assert isinstance(data_events[0].data, AgentResponse) - - -async def test_intermediate_publishes_data_event_only_streaming() -> None: - """When intermediate=True and streaming, each AgentResponseUpdate publishes as a data - event and never as an output event.""" - agent = _CountingAgent(id="agent_a", name="AgentA") - executor = AgentExecutor(agent, id="exec_a", intermediate=True) - workflow = WorkflowBuilder(start_executor=executor).build() - - output_updates: list[WorkflowEvent[Any]] = [] - data_updates: list[WorkflowEvent[Any]] = [] - async for event in workflow.run("hello", stream=True): - if event.type == "output": - output_updates.append(event) - elif event.type == "data": - data_updates.append(event) - - assert output_updates == [] - assert data_updates and all(isinstance(e.data, AgentResponseUpdate) for e in data_updates) - assert all(e.executor_id == "exec_a" for e in data_updates) - - -async def test_intermediate_default_false_publishes_output_event_only() -> None: - """Default (intermediate=False) publishes via yield_output — the standalone path — - and never produces a data event for the same payload.""" - agent = _CountingAgent(id="agent_a", name="AgentA") - executor = AgentExecutor(agent, id="exec_a") # default: intermediate=False - workflow = WorkflowBuilder(start_executor=executor).build() - - output_events: list[WorkflowEvent[Any]] = [] - data_events: list[WorkflowEvent[Any]] = [] - for event in await workflow.run("hello"): - if event.type == "output": - output_events.append(event) - elif event.type == "data": - data_events.append(event) - - # Exactly one output event, no duplicate data event - assert len(output_events) == 1 - assert isinstance(output_events[0].data, AgentResponse) - assert data_events == [] diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index a6b7398249..0101a6e8a5 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -23,7 +23,6 @@ WorkflowAgent, WorkflowBuilder, WorkflowContext, - WorkflowEvent, executor, handler, response_handler, @@ -1563,217 +1562,3 @@ def test_merge_updates_function_result_no_matching_call(self): # Order: text (user), text (assistant), function_result (orphan at end) assert content_types == ["text", "text", "function_result"] - - -class _ReasoningEmittingExecutor(Executor): - """Test executor that emits a `data` event followed by an `output` event. - - Mirrors the orchestration pattern: an intermediate participant publishes a `data` - event (as `AgentExecutor(..., intermediate=True)` does), and a separate executor - publishes the workflow's terminal answer as an `output` event. Used to validate - WorkflowAgent's data → text_reasoning conversion in isolation. - """ - - def __init__( - self, - id: str, - intermediate_data: AgentResponse | Message | list[Message], - terminal_output: AgentResponse | Message | list[Message], - ): - super().__init__(id=id) - self._intermediate_data = intermediate_data - self._terminal_output = terminal_output - - @handler - async def handle( - self, - message: list[Message], - ctx: WorkflowContext[Any, Any], - ) -> None: - await ctx.add_event(WorkflowEvent.emit(self.id, self._intermediate_data)) - await ctx.yield_output(self._terminal_output) - - -class TestWorkflowAgentReasoningHelpers: - """Tests for WorkflowAgent._rewrite_text_to_reasoning and _msg_as_reasoning helpers.""" - - def test_rewrite_text_to_reasoning_converts_text(self) -> None: - """Text content blocks are converted to text_reasoning, preserving id and text.""" - text = Content.from_text(text="hello world", additional_properties={"src": "agent1"}) - result = WorkflowAgent._rewrite_text_to_reasoning([text]) - assert len(result) == 1 - assert result[0].type == "text_reasoning" - assert result[0].text == "hello world" # type: ignore[attr-defined] - assert result[0].additional_properties.get("src") == "agent1" - - def test_rewrite_text_to_reasoning_passes_through_function_call(self) -> None: - """Non-text content (function calls, results) passes through unchanged.""" - fc = Content.from_function_call(call_id="c1", name="my_tool", arguments={"x": 1}) - result = WorkflowAgent._rewrite_text_to_reasoning([fc]) - assert len(result) == 1 - assert result[0] is fc # same instance — passed through - - def test_rewrite_text_to_reasoning_no_double_wrap(self) -> None: - """Already-reasoning content stays as text_reasoning (not wrapped again).""" - already = Content.from_text_reasoning(text="thinking") - result = WorkflowAgent._rewrite_text_to_reasoning([already]) - assert len(result) == 1 - assert result[0] is already # same instance — only type=='text' is rewritten - - def test_rewrite_text_to_reasoning_handles_mixed_content(self) -> None: - """Mixed content: only text blocks are rewritten; others pass through.""" - text = Content.from_text(text="answer") - fc = Content.from_function_call(call_id="c1", name="my_tool", arguments={}) - already = Content.from_text_reasoning(text="prior thinking") - result = WorkflowAgent._rewrite_text_to_reasoning([text, fc, already]) - assert [c.type for c in result] == ["text_reasoning", "function_call", "text_reasoning"] - assert result[1] is fc - assert result[2] is already - - def test_msg_as_reasoning_preserves_role_and_metadata(self) -> None: - """_msg_as_reasoning copies the message with rewritten contents but preserves all other fields.""" - original = Message( - "assistant", - [Content.from_text(text="hi"), Content.from_function_call(call_id="c1", name="t", arguments={})], - author_name="agent1", - message_id="msg-123", - additional_properties={"meta": "value"}, - ) - new_msg = WorkflowAgent._msg_as_reasoning(original) - assert new_msg is not original - assert new_msg.role == "assistant" - assert new_msg.author_name == "agent1" - assert new_msg.message_id == "msg-123" - assert new_msg.additional_properties.get("meta") == "value" - assert [c.type for c in new_msg.contents] == ["text_reasoning", "function_call"] - # Original message is unmodified - assert [c.type for c in original.contents] == ["text", "function_call"] - - -class TestWorkflowAgentDataEventReasoningConversion: - """End-to-end tests for as_agent() rewriting data event content as reasoning.""" - - async def test_data_event_text_becomes_reasoning_non_streaming(self) -> None: - """A data event carrying AgentResponse with text content surfaces as text_reasoning in as_agent().""" - intermediate = AgentResponse(messages=[Message("assistant", [Content.from_text(text="thinking step")])]) - terminal = AgentResponse(messages=[Message("assistant", [Content.from_text(text="final answer")])]) - exec_ = _ReasoningEmittingExecutor(id="exec_a", intermediate_data=intermediate, terminal_output=terminal) - agent = WorkflowBuilder(start_executor=exec_).build().as_agent() - - response = await agent.run("go") - - assert isinstance(response, AgentResponse) - all_types = [c.type for m in response.messages for c in m.contents] - # Intermediate content rewritten to text_reasoning; terminal stays text. - assert "text_reasoning" in all_types - assert "text" in all_types - reasoning_text = " ".join( - c.text or "" for m in response.messages for c in m.contents if c.type == "text_reasoning" - ) - answer_text = " ".join(c.text or "" for m in response.messages for c in m.contents if c.type == "text") - assert reasoning_text == "thinking step" - assert answer_text == "final answer" - - async def test_output_event_text_passes_through_non_streaming(self) -> None: - """An output event with text content passes through unchanged (not rewritten).""" - - class _OutputOnly(Executor): - @handler - async def handle(self, message: list[Message], ctx: WorkflowContext[Any, Any]) -> None: - response = AgentResponse(messages=[Message("assistant", [Content.from_text(text="answer")])]) - await ctx.yield_output(response) - - agent = WorkflowBuilder(start_executor=_OutputOnly(id="solo")).build().as_agent() - response = await agent.run("go") - all_types = [c.type for m in response.messages for c in m.contents] - assert all_types == ["text"], "Output events must not be rewritten as reasoning" - - async def test_data_event_with_mixed_content_only_text_rewritten(self) -> None: - """In a data event, only text content is rewritten; function_call/result pass through.""" - intermediate = AgentResponse( - messages=[ - Message( - "assistant", - [ - Content.from_text(text="reasoning"), - Content.from_function_call(call_id="c1", name="search", arguments={"q": "x"}), - ], - ) - ] - ) - terminal = AgentResponse(messages=[Message("assistant", [Content.from_text(text="done")])]) - exec_ = _ReasoningEmittingExecutor(id="mix", intermediate_data=intermediate, terminal_output=terminal) - agent = WorkflowBuilder(start_executor=exec_).build().as_agent() - response = await agent.run("go") - - # Find the message that has the function_call (the intermediate one) - intermediate_msg = next(m for m in response.messages if any(c.type == "function_call" for c in m.contents)) - types = [c.type for c in intermediate_msg.contents] - assert types == ["text_reasoning", "function_call"] - - async def test_data_event_already_reasoning_not_double_wrapped(self) -> None: - """A data event whose content is already text_reasoning surfaces unchanged (no double wrap).""" - intermediate = AgentResponse( - messages=[Message("assistant", [Content.from_text_reasoning(text="already thinking")])] - ) - terminal = AgentResponse(messages=[Message("assistant", [Content.from_text(text="done")])]) - exec_ = _ReasoningEmittingExecutor(id="reasoning_in", intermediate_data=intermediate, terminal_output=terminal) - agent = WorkflowBuilder(start_executor=exec_).build().as_agent() - response = await agent.run("go") - - reasoning_blocks = [c for m in response.messages for c in m.contents if c.type == "text_reasoning"] - assert len(reasoning_blocks) == 1 - assert reasoning_blocks[0].text == "already thinking" # type: ignore[attr-defined] - - async def test_data_event_text_becomes_reasoning_streaming(self) -> None: - """In streaming mode, AgentResponseUpdate from data events carries text_reasoning content.""" - intermediate = AgentResponse(messages=[Message("assistant", [Content.from_text(text="midway")])]) - terminal = AgentResponse(messages=[Message("assistant", [Content.from_text(text="end")])]) - exec_ = _ReasoningEmittingExecutor(id="stream_x", intermediate_data=intermediate, terminal_output=terminal) - agent = WorkflowBuilder(start_executor=exec_).build().as_agent() - - updates: list[AgentResponseUpdate] = [] - async for update in agent.run("go", stream=True): - updates.append(update) - - all_types = [c.type for u in updates for c in u.contents] - assert "text_reasoning" in all_types - assert "text" in all_types - # Reasoning chunk's text matches the intermediate - reasoning_chunks = [c for u in updates for c in u.contents if c.type == "text_reasoning"] - assert any((c.text or "") == "midway" for c in reasoning_chunks) - # Terminal text chunk matches - text_chunks = [c for u in updates for c in u.contents if c.type == "text"] - assert any((c.text or "") == "end" for c in text_chunks) - - async def test_data_event_streaming_does_not_mutate_source_update(self) -> None: - """Reasoning rewriting must not mutate the AgentResponseUpdate the source emitted. - - AgentExecutor (and other publishers of intermediate data events) hold references - to the update in their local `updates` list. Mutating `data.contents` in place - would silently corrupt the AgentResponse the executor finalizes from those updates. - """ - original_update = AgentResponseUpdate( - contents=[Content.from_text(text="reason")], - role="assistant", - author_name="agent_a", - ) - - class _SharedUpdateExecutor(Executor): - @handler - async def handle(self, message: list[Message], ctx: WorkflowContext[Any, Any]) -> None: - # Exercise the data → text_reasoning rewrite while keeping a reference - # to `original_update`. Also yield an output event so the workflow has a - # terminal answer for the consumer. - await ctx.add_event(WorkflowEvent.emit(self.id, original_update)) - await ctx.yield_output( - AgentResponse(messages=[Message("assistant", [Content.from_text(text="final")])]) - ) - - agent = WorkflowBuilder(start_executor=_SharedUpdateExecutor(id="src")).build().as_agent() - async for _ in agent.run("go", stream=True): - pass - - # Source update content must be unchanged (still `text`, never rewritten to `text_reasoning`). - assert [c.type for c in original_update.contents] == ["text"] - assert original_update.author_name == "agent_a" # not stamped with executor id diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index bba808f87a..9c664c6ac2 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -232,16 +232,18 @@ def simple_selector(state: GroupChatState) -> str: async def test_kwargs_stored_in_state() -> None: """Test that function_invocation_kwargs are stored in State with the correct key.""" - from agent_framework import Executor, WorkflowContext, handler + from typing_extensions import Never + + from agent_framework import AgentResponse, Executor, WorkflowContext, handler stored_kwargs: dict[str, Any] | None = None class _StateInspector(Executor): @handler - async def inspect(self, msgs: list[Message], ctx: WorkflowContext[list[Message]]) -> None: + async def inspect(self, msgs: list[Message], ctx: WorkflowContext[Never, AgentResponse]) -> None: nonlocal stored_kwargs stored_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) - await ctx.send_message(msgs) + await ctx.yield_output(AgentResponse(messages=msgs)) inspector = _StateInspector(id="inspector") workflow = SequentialBuilder(participants=[inspector]).build() @@ -256,16 +258,18 @@ async def inspect(self, msgs: list[Message], ctx: WorkflowContext[list[Message]] async def test_empty_kwargs_stored_as_empty_dict() -> None: """Test that empty kwargs are stored as empty dict in State.""" - from agent_framework import Executor, WorkflowContext, handler + from typing_extensions import Never + + from agent_framework import AgentResponse, Executor, WorkflowContext, handler stored_kwargs: Any = "NOT_CHECKED" class _StateChecker(Executor): @handler - async def check(self, msgs: list[Message], ctx: WorkflowContext[list[Message]]) -> None: + async def check(self, msgs: list[Message], ctx: WorkflowContext[Never, AgentResponse]) -> None: nonlocal stored_kwargs stored_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) - await ctx.send_message(msgs) + await ctx.yield_output(AgentResponse(messages=msgs)) checker = _StateChecker(id="checker") workflow = SequentialBuilder(participants=[checker]).build() @@ -695,7 +699,9 @@ async def test_subworkflow_kwargs_accessible_via_state() -> None: Verifies that WORKFLOW_RUN_KWARGS_KEY is populated in the subworkflow's State with kwargs from the parent workflow. """ - from agent_framework import Executor, WorkflowContext, handler + from typing_extensions import Never + + from agent_framework import AgentResponse, Executor, WorkflowContext, handler from agent_framework._workflows._workflow_executor import WorkflowExecutor captured_kwargs_from_state: list[dict[str, Any]] = [] @@ -704,10 +710,10 @@ class _StateReader(Executor): """Executor that reads kwargs from State for verification.""" @handler - async def read_kwargs(self, msgs: list[Message], ctx: WorkflowContext[list[Message]]) -> None: + async def read_kwargs(self, msgs: list[Message], ctx: WorkflowContext[Never, AgentResponse]) -> None: kwargs_from_state = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) captured_kwargs_from_state.append(kwargs_from_state or {}) - await ctx.send_message(msgs) + await ctx.yield_output(AgentResponse(messages=msgs)) # Build inner workflow with State reader state_reader = _StateReader(id="state_reader") diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py index 97e1568f8c..e1a931019a 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py @@ -212,8 +212,9 @@ def __init__( Args: participants: Sequence of agent or executor instances to run in parallel. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. - intermediate_outputs: If True, enables intermediate outputs from agent participants - before aggregation. + intermediate_outputs: If True, every participant's `yield_output` surfaces as a + workflow `output` event in addition to the aggregator's. By default + (False) only the aggregator's output surfaces. """ self._participants: list[SupportsAgentRun | Executor] = [] self._aggregator: Executor | None = None @@ -341,13 +342,7 @@ def with_request_info( return self def _resolve_participants(self) -> list[Executor]: - """Resolve participant instances into Executor objects. - - When `intermediate_outputs=True`, every wrapped agent is constructed with - `intermediate=True` so its individual response publishes as a `data` event - instead of an `output` event, leaving the single `output` event reserved for - the aggregator's final answer. - """ + """Resolve participant instances into Executor objects.""" if not self._participants: raise ValueError("No participants provided. Pass participants to the constructor.") @@ -362,9 +357,9 @@ def _resolve_participants(self) -> list[Executor]: not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(p, intermediate=self._intermediate_outputs)) + executors.append(AgentApprovalExecutor(p)) else: - executors.append(AgentExecutor(p, intermediate=self._intermediate_outputs)) + executors.append(AgentExecutor(p)) else: raise TypeError(f"Participants must be SupportsAgentRun or Executor instances. Got {type(p).__name__}.") @@ -404,7 +399,7 @@ def build(self) -> Workflow: builder = WorkflowBuilder( start_executor=dispatcher, checkpoint_storage=self._checkpoint_storage, - output_executors=[aggregator], + output_executors=[aggregator] if not self._intermediate_outputs else None, ) # Fan-out for parallel execution builder.add_fan_out_edges(dispatcher, participants) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index ae97f50169..301eca5df4 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -623,7 +623,9 @@ def __init__( True to terminate the conversation, False to continue. max_rounds: Optional maximum number of orchestrator rounds to prevent infinite conversations. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. - intermediate_outputs: If True, enables intermediate outputs from agent participants. + intermediate_outputs: If True, every participant's `yield_output` surfaces as a + workflow `output` event in addition to the orchestrator's. By default (False) + only the orchestrator's output surfaces. """ self._participants: dict[str, SupportsAgentRun | Executor] = {} self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = [] @@ -644,8 +646,7 @@ def __init__( self._request_info_enabled: bool = False self._request_info_filter: set[str] = set() - # Intermediate outputs - self._intermediate_outputs = intermediate_outputs + self._intermediate_outputs: bool = intermediate_outputs if participants is None and participant_factories is None: raise ValueError("Either participants or participant_factories must be provided.") @@ -964,9 +965,9 @@ def _resolve_participants(self) -> list[Executor]: not self._request_info_filter or resolve_agent_id(participant) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(participant, intermediate=self._intermediate_outputs)) + executors.append(AgentApprovalExecutor(participant)) else: - executors.append(AgentExecutor(participant, intermediate=self._intermediate_outputs)) + executors.append(AgentExecutor(participant)) else: raise TypeError( f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}." @@ -992,7 +993,7 @@ def build(self) -> Workflow: workflow_builder = WorkflowBuilder( start_executor=orchestrator, checkpoint_storage=self._checkpoint_storage, - output_executors=[orchestrator], + output_executors=[orchestrator] if not self._intermediate_outputs else None, ) for participant in participants: # Orchestrator and participant bi-directional edges diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index 5eb45c5659..e8394402e5 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -1313,7 +1313,7 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: class MagenticAgentExecutor(AgentExecutor): """Specialized AgentExecutor for Magentic agent participants.""" - def __init__(self, agent: SupportsAgentRun, *, intermediate: bool = False) -> None: + def __init__(self, agent: SupportsAgentRun) -> None: """Initialize a Magentic Agent Executor. This executor wraps an SupportsAgentRun instance to be used as a participant @@ -1321,14 +1321,13 @@ def __init__(self, agent: SupportsAgentRun, *, intermediate: bool = False) -> No Args: agent: The agent instance to wrap. - intermediate: Forwarded to the base AgentExecutor. See ``AgentExecutor.__init__``. Notes: Magentic pattern requires a reset operation upon replanning. This executor extends the base AgentExecutor to handle resets appropriately. In order to handle resets, the agent threads and other states are reset when requested by the orchestrator. And because of this, MagenticAgentExecutor does not support custom threads. """ - super().__init__(agent, intermediate=intermediate) + super().__init__(agent) @handler async def handle_magentic_reset(self, signal: MagenticResetSignal, ctx: WorkflowContext) -> None: @@ -1425,7 +1424,9 @@ def __init__( max_round_count: Max total coordination rounds. None means unlimited. enable_plan_review: If True, requires human approval of the initial plan before proceeding. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. - intermediate_outputs: If True, enables intermediate outputs from agent participants. + intermediate_outputs: If True, every participant's `yield_output` surfaces as a + workflow `output` event in addition to the orchestrator's. By default (False) + only the orchestrator's output surfaces. """ self._participants: dict[str, SupportsAgentRun | Executor] = {} @@ -1438,7 +1439,6 @@ def __init__( self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage - # Intermediate outputs self._intermediate_outputs = intermediate_outputs self._set_participants(participants) @@ -1739,7 +1739,7 @@ def _resolve_participants(self) -> list[Executor]: if isinstance(participant, Executor): executors.append(participant) elif isinstance(participant, SupportsAgentRun): - executors.append(MagenticAgentExecutor(participant, intermediate=self._intermediate_outputs)) + executors.append(MagenticAgentExecutor(participant)) else: raise TypeError( f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}." @@ -1758,7 +1758,7 @@ def build(self) -> Workflow: workflow_builder = WorkflowBuilder( start_executor=orchestrator, checkpoint_storage=self._checkpoint_storage, - output_executors=[orchestrator], + output_executors=[orchestrator] if not self._intermediate_outputs else None, ) for participant in participants: # Orchestrator and participant bi-directional edges diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py index 6858f5feb7..23e382aa13 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py @@ -4,7 +4,7 @@ from typing import Literal from agent_framework._agents import SupportsAgentRun -from agent_framework._types import Message +from agent_framework._types import AgentResponse, Message from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._executor import Executor, handler @@ -86,7 +86,13 @@ def approve() -> "AgentRequestInfoResponse": class AgentRequestInfoExecutor(Executor): - """Executor for gathering request info from users to assist agents.""" + """Executor for gathering request info from users to assist agents. + + On approval (caller returned no follow-up messages), yields the original + ``AgentExecutorResponse`` so downstream ``AgentExecutor`` participants can consume it + via their ``from_response`` handler — i.e., the inner workflow's output type matches the + chain currency used between Sequential participants. + """ @handler async def request_info(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext) -> None: @@ -109,6 +115,56 @@ async def handle_request_info_response( await ctx.yield_output(original_request) +class _TerminalAgentRequestInfoExecutor(Executor): + """Sibling of ``AgentRequestInfoExecutor`` used when ``AgentApprovalExecutor`` is the workflow's terminator. + + This exists because: + - The orchestration contract established is that every orchestration's terminal + ``output`` event carries an ``AgentResponse``. That is the user-facing promise — e.g., + ``workflow.as_agent().run(prompt)`` returns an ``AgentResponse``. + - ``AgentRequestInfoExecutor`` yields ``AgentExecutorResponse`` because that is the chain + currency between Sequential participants: the next ``AgentExecutor`` consumes + ``AgentExecutorResponse`` via its ``from_response`` handler. That is correct when + ``AgentApprovalExecutor`` is *intermediate*. + - When ``AgentApprovalExecutor`` is the *terminator* (``allow_direct_output=True``), the + inner yield flows straight through ``WorkflowExecutor`` to the outer workflow's terminal + output. Yielding ``AgentExecutorResponse`` there would surface ``AgentExecutorResponse`` + as the workflow's terminal output — violating the orchestration contract. + + Used in place of ``AgentRequestInfoExecutor`` inside the terminator-mode inner workflow + built by ``AgentApprovalExecutor._build_workflow`` when ``allow_direct_output=True``. + + Translation belongs here — at the source of the yield in the orchestrations package — + rather than at the ``WorkflowExecutor`` boundary in core, because core has no opinion + about the orchestration's ``AgentResponse`` contract. + + Note: not a subclass of ``AgentRequestInfoExecutor``. The two classes have different + terminal yield contracts (``AgentExecutorResponse`` vs. ``AgentResponse``), and + ``WorkflowContext``'s output type parameter is invariant — so a subclass override would + be type-incompatible. They are siblings sharing only a small ``request_info`` handler. + """ + + @handler + async def request_info(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext) -> None: + """Handle the agent's response and gather additional info from users.""" + await ctx.request_info(agent_response, AgentRequestInfoResponse) + + @response_handler + async def handle_request_info_response( + self, + original_request: AgentExecutorResponse, + response: AgentRequestInfoResponse, + ctx: WorkflowContext[AgentExecutorRequest, AgentResponse], + ) -> None: + """Process the additional info provided by users; yield ``AgentResponse`` on approval.""" + if response.messages: + # User provided additional messages, further iterate on agent response + await ctx.send_message(AgentExecutorRequest(messages=response.messages, should_respond=True)) + else: + # No additional info, approve and surface the wrapped AgentResponse to the parent. + await ctx.yield_output(original_request.agent_response) + + class AgentApprovalExecutor(WorkflowExecutor): """Executor for enabling scenarios requiring agent approval in an orchestration. @@ -123,29 +179,46 @@ def __init__( agent: SupportsAgentRun, context_mode: Literal["full", "last_agent", "custom"] | None = None, *, - intermediate: bool = False, + allow_direct_output: bool = False, ) -> None: """Initialize the AgentApprovalExecutor. Args: agent: The agent protocol to use for generating responses. context_mode: The mode for providing context to the agent. - intermediate: Forwarded to the inner AgentExecutor. See ``AgentExecutor.__init__``. + allow_direct_output: When True, the inner agent's response is yielded as the + wrapping workflow's output (rather than forwarded as a message to a + downstream participant). Set this when this executor is the workflow's + terminator — so the user-approved final response surfaces as a workflow + ``output`` event. """ self._context_mode: Literal["full", "last_agent", "custom"] | None = context_mode self._description = agent.description - self._intermediate = intermediate - super().__init__(workflow=self._build_workflow(agent), id=resolve_agent_id(agent), propagate_request=True) + super().__init__( + workflow=self._build_workflow(agent, terminal=allow_direct_output), + id=resolve_agent_id(agent), + propagate_request=True, + allow_direct_output=allow_direct_output, + ) + + def _build_workflow(self, agent: SupportsAgentRun, *, terminal: bool) -> Workflow: + """Build the internal workflow for the AgentApprovalExecutor. + + Picks the right ``AgentRequestInfoExecutor`` variant for the role this approval flow + plays in the outer workflow: - def _build_workflow(self, agent: SupportsAgentRun) -> Workflow: - """Build the internal workflow for the AgentApprovalExecutor.""" + - Intermediate (``terminal=False``): inner workflow yields ``AgentExecutorResponse`` + so the next outer ``AgentExecutor`` participant can consume it via ``from_response``. + - Terminator (``terminal=True``): inner workflow yields ``AgentResponse`` so the outer + workflow's terminal output matches the orchestration contract. + """ agent_executor = AgentExecutor( agent, context_mode=self._context_mode, - intermediate=self._intermediate, ) - request_info_executor = AgentRequestInfoExecutor(id="agent_request_info_executor") + request_info_cls = _TerminalAgentRequestInfoExecutor if terminal else AgentRequestInfoExecutor + request_info_executor = request_info_cls(id="agent_request_info_executor") return ( WorkflowBuilder(start_executor=agent_executor) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index b885aa7638..36d4f23f49 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -6,25 +6,20 @@ conversation along the chain. Agents append their assistant messages; custom executors transform and return a refined `list[Message]`. -Wiring: input -> _InputToConversation -> participant1 -> ... -> participantN -> _EndWithConversation - -The workflow's final `output` event is either the last agent's `AgentResponse` (when the -terminator is an agent) or the custom executor's `list[Message]`. With -`intermediate_outputs=True`, intermediate agents are constructed with -`AgentExecutor(..., intermediate=True)` so they publish each response as a `data` event -instead of an `output` event — consumers can observe intermediate participants without -those payloads being collected as workflow outputs. +Wiring: input -> _InputToConversation -> participant1 -> ... -> participantN + +The workflow's final `output` event is the last participant's `yield_output(...)`. For +agent terminators that is an `AgentResponse` (or per-chunk `AgentResponseUpdate`s when +streaming). For custom-executor terminators, the executor itself yields whatever it +produces — by convention an `AgentResponse` so downstream consumers see a uniform shape. """ import logging from collections.abc import Sequence -from typing import Any, Literal +from typing import Literal -from agent_framework import AgentResponse, Message, SupportsAgentRun -from agent_framework._workflows._agent_executor import ( - AgentExecutor, - AgentExecutorResponse, -) +from agent_framework import Message, SupportsAgentRun +from agent_framework._workflows._agent_executor import AgentExecutor from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage from agent_framework._workflows._executor import ( @@ -57,47 +52,6 @@ async def from_messages(self, messages: list[str | Message], ctx: WorkflowContex await ctx.send_message(normalize_messages_input(messages)) -class _EndWithConversation(Executor): - """Graph terminator for the sequential workflow. - - For custom-executor terminators, this emits the final `list[Message]` as an `output` - event (the executor's own contract). For agent terminators it is a passive sink: the - last `AgentExecutor` is itself registered as the workflow's output executor in - `SequentialBuilder.build()`, so its `yield_output` calls — a single `AgentResponse` - non-streaming, or per-chunk `AgentResponseUpdate` events streaming — become the - workflow's outputs directly. - - Intermediate participants are constructed with `AgentExecutor(..., intermediate=True)` - when `intermediate_outputs=True`, so they publish each response as a `data` event - rather than an `output` event. They never compete with the terminator's output. - """ - - @handler - async def end_with_messages( - self, - conversation: list[Message], - ctx: WorkflowContext[Any, list[Message]], - ) -> None: - """Yield the final conversation when the last participant is a custom executor.""" - await ctx.yield_output(list(conversation)) - - @handler - async def end_with_agent_executor_response( - self, - response: AgentExecutorResponse, - ctx: WorkflowContext[Any, AgentResponse], - ) -> None: - """Convert the agent-terminator response into a workflow output. - - When the last participant is a regular AgentExecutor (registered as the - output executor), this node is NOT in output_executors so the yield is - silently filtered — no duplicate output. When the last participant is an - AgentApprovalExecutor (or similar wrapper), this node IS the output - executor so the yield produces the workflow's terminal answer. - """ - await ctx.yield_output(response.agent_response) - - class SequentialBuilder: r"""High-level builder for sequential agent/executor workflows with shared context. @@ -147,7 +101,9 @@ def __init__( chain_only_agent_responses: If True, only agent responses are chained between agents. By default, the full conversation context is passed to the next agent. This also applies to Executor -> Agent transitions if the executor sends `AgentExecutorResponse`. - intermediate_outputs: If True, enables intermediate outputs from agent participants. + intermediate_outputs: If True, every participant's `yield_output` surfaces as a + workflow `output` event in addition to the terminator's. By default (False) only + the last participant's output surfaces. """ self._participants: list[SupportsAgentRun | Executor] = [] self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage @@ -219,10 +175,11 @@ def with_request_info( def _resolve_participants(self) -> list[Executor]: """Resolve participant instances into Executor objects. - Wraps `SupportsAgentRun` participants as `AgentExecutor`. When `intermediate_outputs=True`, - every wrapped agent except the final one is constructed with `intermediate=True` - so its responses publish as workflow `data` events instead of `output` events, - leaving the single `output` event reserved for the final answer. + Wraps `SupportsAgentRun` participants as `AgentExecutor` (or `AgentApprovalExecutor` + when request-info is enabled for that participant). The last participant, when wrapped + as `AgentApprovalExecutor`, is constructed with `allow_direct_output=True` so the + approved response surfaces as the workflow's output event instead of being forwarded + as a message that has nowhere to go. """ if not self._participants: raise ValueError("No participants provided. Pass participants to the constructor.") @@ -239,7 +196,6 @@ def _resolve_participants(self) -> list[Executor]: if isinstance(p, Executor): executors.append(p) elif isinstance(p, SupportsAgentRun): - emit_intermediate = self._intermediate_outputs and idx != last_idx if self._request_info_enabled and ( not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter ): @@ -248,17 +204,11 @@ def _resolve_participants(self) -> list[Executor]: AgentApprovalExecutor( p, context_mode=context_mode, - intermediate=emit_intermediate, + allow_direct_output=(idx == last_idx), ) ) else: - executors.append( - AgentExecutor( - p, - context_mode=context_mode, - intermediate=emit_intermediate, - ) - ) + executors.append(AgentExecutor(p, context_mode=context_mode)) else: raise TypeError(f"Participants must be SupportsAgentRun or Executor instances. Got {type(p).__name__}.") @@ -268,39 +218,31 @@ def build(self) -> Workflow: """Build and validate the sequential workflow. Wiring pattern: - - _InputToConversation normalizes the initial input into list[Message] - - For each participant in order: - - Agent or AgentExecutor: receives the conversation/AgentExecutorResponse, - produces an AgentExecutorResponse forwarded downstream - - Custom Executor: receives list[Message] and forwards a list[Message] - - The workflow's `output_executor` is selected based on the last participant: - - Agent terminator: the last AgentExecutor itself (its yield_output is the answer) - - Custom-executor terminator: `_EndWithConversation` (yields the final list[Message]) + - `_InputToConversation` normalizes the initial input into `list[Message]`. + - Each participant runs in order: + - `AgentExecutor`: receives the conversation / `AgentExecutorResponse` and + forwards an `AgentExecutorResponse` downstream. + - Custom `Executor`: receives `list[Message]` and forwards `list[Message]`. + If used as the terminator, it must call `ctx.yield_output(AgentResponse(...))` + instead of `ctx.send_message(...)` — its yield becomes the workflow's output. + - The last participant is registered as the workflow's `output_executor`, so the + terminator's own `yield_output` is the workflow's terminal output (`AgentResponse`, + or per-chunk `AgentResponseUpdate` when streaming). """ - # Internal nodes input_conv = _InputToConversation(id="input-conversation") - end = _EndWithConversation(id="end") # Resolve participants and participant factories to executors participants: list[Executor] = self._resolve_participants() - last_executor = participants[-1] - output_executors: list[Executor | SupportsAgentRun] = [ - last_executor if isinstance(last_executor, AgentExecutor) else end - ] - builder = WorkflowBuilder( start_executor=input_conv, checkpoint_storage=self._checkpoint_storage, - output_executors=output_executors, + output_executors=[participants[-1]] if not self._intermediate_outputs else None, ) - # Start of the chain is the input normalizer prior: Executor | SupportsAgentRun = input_conv for p in participants: builder.add_edge(prior, p) prior = p - # Terminate with the final conversation - builder.add_edge(prior, end) return builder.build() diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py index 5c879f6153..e6160f39db 100644 --- a/python/packages/orchestrations/tests/test_concurrent.py +++ b/python/packages/orchestrations/tests/test_concurrent.py @@ -374,41 +374,3 @@ async def _run() -> AgentResponse: return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"])]) return _run() - - -async def test_concurrent_intermediate_outputs_emits_data_events() -> None: - """When intermediate_outputs=True, each participant emits a `data` event. - - The single `output` event still carries the aggregated AgentResponse; per-participant - responses are emitted as `data` events so consumers can tell them apart. - """ - a1 = _EchoAgent(id="agent1", name="A1") - a2 = _EchoAgent(id="agent2", name="A2") - a3 = _EchoAgent(id="agent3", name="A3") - - wf = ConcurrentBuilder(participants=[a1, a2, a3], intermediate_outputs=True).build() - - output_events = [] - data_events = [] - for ev in await wf.run("prompt: hello"): - if ev.type == "output": - output_events.append(ev) - elif ev.type == "data": - data_events.append(ev) - - # One output event = the aggregated answer from the aggregator. - assert len(output_events) == 1 - aggregated = output_events[0].data - assert isinstance(aggregated, AgentResponse) - assert len(aggregated.messages) == 3 - assert all(m.role == "assistant" for m in aggregated.messages) - - # Each participant emits a data event carrying its AgentResponse. - assert len(data_events) == 3 - for dev in data_events: - assert isinstance(dev.data, AgentResponse) - data_texts = {dev.data.messages[0].text for dev in data_events} - assert data_texts == {"A1 reply", "A2 reply", "A3 reply"} - # Executor ids derive from the agent's name (resolve_agent_id behavior). - data_executor_ids = {dev.executor_id for dev in data_events} - assert data_executor_ids == {"A1", "A2", "A3"} diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 0ae736cc4b..44a48b6487 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -428,25 +428,17 @@ def termination_condition(conversation: list[Message]) -> bool: participants=[agent], termination_condition=termination_condition, selection_func=selector, - intermediate_outputs=True, ).build() outputs: list[AgentResponse] = [] - intermediate_updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): if event.type == "output" and isinstance(event.data, AgentResponse): outputs.append(event.data) - elif event.type == "data" and isinstance(event.data, AgentResponseUpdate): - intermediate_updates.append(event.data) assert outputs, "Expected termination to yield output" # Terminal output is the orchestrator's completion message only. final_output = outputs[-1].messages[-1] assert "termination condition" in final_output.text.lower() - # Agent's intermediate replies surface as `data` events (per-update in streaming mode). - agent_updates = [u for u in intermediate_updates if u.author_name == "agent"] - # Each agent reply produces at least one update; expect 2 agent rounds before termination. - assert len(agent_updates) >= 2 async def test_termination_condition_agent_manager_finalizes(self) -> None: """Test that termination condition with agent orchestrator produces default termination message.""" diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py index 96bfaeccfd..b683d8b25b 100644 --- a/python/packages/orchestrations/tests/test_magentic.py +++ b/python/packages/orchestrations/tests/test_magentic.py @@ -576,11 +576,12 @@ async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[ wf = MagenticBuilder(participants=[participant], intermediate_outputs=True, manager=InvokeOnceManager()).build() - # With intermediate_outputs=True, participant updates surface as `data` events - # carrying AgentResponseUpdate; the orchestrator's terminal AgentResponse comes via - # an `output` event. + # Run a bounded stream to allow one invoke and then completion + events: list[WorkflowEvent] = [] async for ev in wf.run("task", stream=True): - if ev.type == "data" and isinstance(ev.data, AgentResponseUpdate): + events.append(ev) + # Capture streaming updates (type="output" with AgentResponseUpdate data) + if ev.type == "output" and isinstance(ev.data, AgentResponseUpdate): captured.append( Message( role=ev.data.role or "assistant", @@ -588,6 +589,7 @@ async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[ author_name=ev.data.author_name, ) ) + # Break on final AgentResponse output elif ev.type == "output" and isinstance(ev.data, AgentResponse): break diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index 1dd5bef45a..d119a20120 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -22,6 +22,7 @@ ) from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage from agent_framework.orchestrations import SequentialBuilder +from typing_extensions import Never class _EchoAgent(BaseAgent): @@ -67,16 +68,20 @@ async def _run() -> AgentResponse: return _run() -class _SummarizerExec(Executor): - """Custom executor that summarizes by appending a short assistant message.""" +class _SummarizerTerminator(Executor): + """Custom-executor terminator that yields a synthesized summary as the workflow's final answer.""" @handler - async def summarize(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[list[Message]]) -> None: + async def summarize( + self, + agent_response: AgentExecutorResponse, + ctx: WorkflowContext[Never, AgentResponse], + ) -> None: conversation = agent_response.full_conversation or [] user_texts = [m.text for m in conversation if m.role == "user"] agents = [m.author_name or m.role for m in conversation if m.role == "assistant"] summary = Message("assistant", [f"Summary of users:{len(user_texts)} agents:{len(agents)}"]) - await ctx.send_message(list(conversation) + [summary]) + await ctx.yield_output(AgentResponse(messages=[summary])) class _InvalidExecutor(Executor): @@ -101,8 +106,8 @@ def test_sequential_builder_validation_rejects_invalid_executor() -> None: async def test_sequential_streaming_yields_only_last_agent_updates() -> None: """Streaming mode surfaces only the last agent's AgentResponseUpdate chunks as outputs. - Intermediate agents do NOT emit `output` events when intermediate_outputs=False (default); - only the last agent (the workflow's output_executor) emits chunks of the final answer. + Intermediate agents do NOT emit `output` events; only the last agent (the workflow's + output_executor) emits chunks of the final answer. """ a1 = _EchoAgent(id="agent1", name="A1") a2 = _EchoAgent(id="agent2", name="A2") @@ -146,41 +151,6 @@ async def test_sequential_non_streaming_yields_only_last_agent_response() -> Non assert "A1 reply" not in combined -async def test_sequential_intermediate_outputs_emits_data_events() -> None: - """When intermediate_outputs=True, intermediate agents surface as `data` events. - - The single `output` event still carries the last agent's AgentResponse; intermediate - agents are emitted as `data` events (not output events) so consumers can clearly tell - them apart. - """ - a1 = _EchoAgent(id="agent1", name="A1") - a2 = _EchoAgent(id="agent2", name="A2") - - wf = SequentialBuilder(participants=[a1, a2], intermediate_outputs=True).build() - - output_events = [] - data_events = [] - for ev in await wf.run("hello"): - if ev.type == "output": - output_events.append(ev) - elif ev.type == "data": - data_events.append(ev) - - # One output event = the final answer (last agent). - assert len(output_events) == 1 - final = output_events[0].data - assert isinstance(final, AgentResponse) - assert "A2 reply" in " ".join(m.text for m in final.messages) - - # Intermediate agents emit data events (not output events). - assert len(data_events) == 1 - intermediate = data_events[0].data - assert isinstance(intermediate, AgentResponse) - assert "A1 reply" in " ".join(m.text for m in intermediate.messages) - # Executor id derives from the agent's name (resolve_agent_id behavior). - assert data_events[0].executor_id == "A1" - - async def test_sequential_as_agent_returns_only_last_agent_response() -> None: """`workflow.as_agent().run(prompt)` returns ONLY the last agent's messages — not the user input or earlier agents' replies. This is the core fix for the orchestration-as-agent @@ -199,51 +169,25 @@ async def test_sequential_as_agent_returns_only_last_agent_response() -> None: assert "hello as_agent" not in combined -async def test_sequential_as_agent_with_intermediate_outputs_includes_chain() -> None: - """With `intermediate_outputs=True`, `as_agent()` surfaces intermediate agent responses - (rewritten as `text_reasoning` content) followed by the final answer (`text` content).""" - a1 = _EchoAgent(id="agent1", name="A1") - a2 = _EchoAgent(id="agent2", name="A2") - - agent = SequentialBuilder(participants=[a1, a2], intermediate_outputs=True).build().as_agent() - response = await agent.run("hello as_agent") - - assert isinstance(response, AgentResponse) - - reasoning_text = " ".join(c.text or "" for m in response.messages for c in m.contents if c.type == "text_reasoning") - answer_text = " ".join(c.text or "" for m in response.messages for c in m.contents if c.type == "text") - assert "A1 reply" in reasoning_text, "Intermediate writer reply should arrive as reasoning content" - assert "A2 reply" in answer_text, "Terminal reviewer reply should arrive as text content" - assert "A1 reply" not in answer_text, "Intermediate content should not appear as final text" - # Final agent's reply should appear last in the message ordering. - last_msg_text = " ".join(c.text or "" for c in response.messages[-1].contents if c.type == "text") - assert "A2 reply" in last_msg_text - - async def test_sequential_with_custom_executor_summary() -> None: + """A custom-executor terminator yields its own AgentResponse — that becomes the workflow output. + + Custom executors used as the terminator must call `ctx.yield_output(AgentResponse(...))` + directly (rather than `ctx.send_message(list[Message])` like an intermediate executor would), + because the terminator IS the workflow's output executor. + """ a1 = _EchoAgent(id="agent1", name="A1") - summarizer = _SummarizerExec(id="summarizer") + summarizer = _SummarizerTerminator(id="summarizer") wf = SequentialBuilder(participants=[a1, summarizer]).build() - completed = False - output: list[Message] | None = None - async for ev in wf.run("topic X", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = ev.data - if completed and output is not None: - break - - assert completed - assert output is not None - msgs: list[Message] = output - # Expect: [user, A1 reply, summary] - assert len(msgs) == 3 - assert msgs[0].role == "user" - assert msgs[1].role == "assistant" and "A1 reply" in msgs[1].text - assert msgs[2].role == "assistant" and msgs[2].text.startswith("Summary of users:") + output_events = [ev for ev in await wf.run("topic X") if ev.type == "output"] + assert len(output_events) == 1 + response = output_events[0].data + assert isinstance(response, AgentResponse) + assert len(response.messages) == 1 + assert response.messages[0].role == "assistant" + assert response.messages[0].text.startswith("Summary of users:") async def test_sequential_checkpoint_resume_round_trip() -> None: @@ -530,58 +474,3 @@ async def test_sequential_request_info_last_participant_emits_output() -> None: response = output_events[0].data assert isinstance(response, AgentResponse) assert any("A2 reply" in m.text for m in response.messages) - - -async def test_sequential_request_info_with_intermediate_outputs_emits_data_events() -> None: - """With both with_request_info() and intermediate_outputs=True, intermediate - agents' responses are surfaced as data events while the final output is an - AgentResponse from the last agent. - - This verifies that WorkflowExecutor correctly forwards data events from the - inner AgentExecutor through the AgentApprovalExecutor wrapper. - """ - from agent_framework_orchestrations._orchestration_request_info import AgentRequestInfoResponse - - a1 = _EchoAgent(id="agent1", name="A1") - a2 = _EchoAgent(id="agent2", name="A2") - - wf = SequentialBuilder(participants=[a1, a2], intermediate_outputs=True).with_request_info().build() - - # Run and approve all request_info events until the workflow completes - all_data_events: list[Any] = [] - all_output_events: list[Any] = [] - request_events: list[Any] = [] - - async for ev in wf.run("hello intermediate", stream=True): - if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse): - request_events.append(ev) - elif ev.type == "data": - all_data_events.append(ev) - elif ev.type == "output": - all_output_events.append(ev) - - while request_events: - responses = {req.request_id: AgentRequestInfoResponse.approve() for req in request_events} - request_events = [] - async for ev in wf.run(stream=True, responses=responses): - if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse): - request_events.append(ev) - elif ev.type == "data": - all_data_events.append(ev) - elif ev.type == "output": - all_output_events.append(ev) - - # The first (intermediate) agent should emit a data event. - assert len(all_data_events) >= 1 - intermediate_texts = set() - for dev in all_data_events: - if isinstance(dev.data, AgentResponse): - for m in dev.data.messages: - intermediate_texts.add(m.text) - assert "A1 reply" in intermediate_texts - - # The final output should contain the last agent's response. - assert len(all_output_events) >= 1 - final = all_output_events[-1].data - assert isinstance(final, AgentResponse) - assert any("A2 reply" in m.text for m in final.messages) diff --git a/python/samples/03-workflows/orchestrations/sequential_custom_executors.py b/python/samples/03-workflows/orchestrations/sequential_custom_executors.py index a4fb2d602b..b907c24cdb 100644 --- a/python/samples/03-workflows/orchestrations/sequential_custom_executors.py +++ b/python/samples/03-workflows/orchestrations/sequential_custom_executors.py @@ -2,11 +2,11 @@ import asyncio import os -from typing import Any from agent_framework import ( Agent, AgentExecutorResponse, + AgentResponse, Executor, Message, WorkflowContext, @@ -16,6 +16,7 @@ from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv +from typing_extensions import Never # Load environment variables from .env file load_dotenv() @@ -25,13 +26,14 @@ This demonstrates how SequentialBuilder chains participants with a shared conversation context (list[Message]). An agent produces content; a custom -executor appends a compact summary to the conversation. The workflow completes -after all participants have executed in sequence, and the final output contains -the complete conversation. +executor synthesizes a compact summary and yields it as the workflow's terminal +output. Custom executor contract: -- Provide at least one @handler accepting AgentExecutorResponse and a WorkflowContext[list[Message]] -- Emit the updated conversation via ctx.send_message([...]) +- Intermediate custom executors: handle the message type from the prior participant + and forward `list[Message]` via `ctx.send_message(...)` for the next participant. +- Terminator custom executors: handle the message type from the prior participant and + yield the workflow's final answer as an `AgentResponse` via `ctx.yield_output(...)`. Prerequisites: - FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. @@ -41,27 +43,29 @@ class Summarizer(Executor): - """Simple summarizer: consumes full conversation and appends an assistant summary.""" + """Terminator custom executor: synthesizes a one-line summary as the workflow's final answer.""" @handler - async def summarize(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[list[Message]]) -> None: - """Append a summary message to a copy of the full conversation. - - Note: A custom executor must be able to handle the message type from the prior participant, and produce - the message type expected by the next participant. In this case, the prior participant is an agent thus - the input is AgentExecutorResponse (an agent will be wrapped in an AgentExecutor, which produces - `AgentExecutorResponse`). If the next participant is also an agent or this is the final participant, - the output must be `list[Message]`. + async def summarize( + self, + agent_response: AgentExecutorResponse, + ctx: WorkflowContext[Never, AgentResponse], + ) -> None: + """Yield a terminal AgentResponse containing the summary. + + The prior participant is an agent, which is wrapped in an `AgentExecutor` that + produces `AgentExecutorResponse`. As the last participant in the sequential workflow, + this executor calls `ctx.yield_output(AgentResponse(...))` so its output becomes the + workflow's terminal output (rather than being forwarded to a downstream participant). """ if not agent_response.full_conversation: - await ctx.send_message([Message("assistant", ["No conversation to summarize."])]) + await ctx.yield_output(AgentResponse(messages=[Message("assistant", ["No conversation to summarize."])])) return users = sum(1 for m in agent_response.full_conversation if m.role == "user") assistants = sum(1 for m in agent_response.full_conversation if m.role == "assistant") summary = Message("assistant", [f"Summary -> users:{users} assistants:{assistants}"]) - final_conversation = list(agent_response.full_conversation) + [summary] - await ctx.send_message(final_conversation) + await ctx.yield_output(AgentResponse(messages=[summary])) async def main() -> None: @@ -81,33 +85,20 @@ async def main() -> None: summarizer = Summarizer(id="summarizer") workflow = SequentialBuilder(participants=[content, summarizer]).build() - # 3) Run workflow and extract final conversation + # 3) Run workflow and extract the final summary events = await workflow.run("Explain the benefits of budget eBikes for commuters.") outputs = events.get_outputs() if outputs: - print("===== Final Conversation =====") - messages: list[Message] | Any = outputs[0] - for i, msg in enumerate(messages, start=1): - name = msg.author_name or ("assistant" if msg.role == "assistant" else "user") - print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}") + print("===== Final Summary =====") + final: AgentResponse = outputs[0] + for msg in final.messages: + print(msg.text) """ Sample Output: - ------------------------------------------------------------ - 01 [user] - Explain the benefits of budget eBikes for commuters. - ------------------------------------------------------------ - 02 [content] - Budget eBikes offer commuters an affordable, eco-friendly alternative to cars and public transport. - Their electric assistance reduces physical strain and allows riders to cover longer distances quickly, - minimizing travel time and fatigue. Budget models are low-cost to maintain and operate, making them accessible - for a wider range of people. Additionally, eBikes help reduce traffic congestion and carbon emissions, - supporting greener urban environments. Overall, budget eBikes provide cost-effective, efficient, and - sustainable transportation for daily commuting needs. - ------------------------------------------------------------ - 03 [assistant] + ===== Final Summary ===== Summary -> users:1 assistants:1 """ From 96cc45573567f142cac115c97b4bd29dc3589272 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 28 Apr 2026 13:57:52 +0900 Subject: [PATCH 14/16] yield AgentResponseUpdate streaming, AgentResponse non-streaming --- .../_base_group_chat_orchestrator.py | 38 ++- .../_group_chat.py | 8 +- .../_magentic.py | 28 +- .../orchestrations/tests/test_group_chat.py | 248 +++++++++++------- .../orchestrations/tests/test_magentic.py | 97 +++++-- 5 files changed, 280 insertions(+), 139 deletions(-) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py index 2cca41d9ee..a4108b23f0 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import Any, ClassVar, TypeAlias -from agent_framework._types import AgentResponse, Message +from agent_framework._types import AgentResponse, AgentResponseUpdate, Message from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._events import WorkflowEvent from agent_framework._workflows._executor import Executor, handler @@ -351,7 +351,9 @@ async def _check_termination(self) -> bool: result = await result return result - async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, AgentResponse]) -> bool: + async def _check_terminate_and_yield( + self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate] + ) -> bool: """Check termination conditions and yield the completion message if met. Args: @@ -364,11 +366,35 @@ async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, AgentResp if terminate: completion_message = self._create_completion_message(self.TERMINATION_CONDITION_MET_MESSAGE) self._append_messages([completion_message]) - await ctx.yield_output(AgentResponse(messages=[completion_message])) + await self._yield_completion(ctx, completion_message) return True return False + async def _yield_completion( + self, + ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate], + completion_message: Message, + ) -> None: + """Yield a synthesized terminal completion message in the right shape for the run mode. + + Mode-aware to mirror ``AgentExecutor`` semantics: + - Streaming (``ctx.is_streaming()``): yield a single ``AgentResponseUpdate`` so the + ``output`` event stream stays uniformly per-chunk. + - Non-streaming: yield the full ``AgentResponse``. + """ + if ctx.is_streaming(): + await ctx.yield_output( + AgentResponseUpdate( + contents=list(completion_message.contents), + role=completion_message.role, + author_name=completion_message.author_name, + message_id=completion_message.message_id, + ) + ) + else: + await ctx.yield_output(AgentResponse(messages=[completion_message])) + def _create_completion_message(self, message: str) -> Message: """Create a standardized completion message. @@ -491,7 +517,9 @@ def _check_round_limit(self) -> bool: return False - async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, AgentResponse]) -> bool: + async def _check_round_limit_and_yield( + self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate] + ) -> bool: """Check round limit and yield the max-rounds completion message if reached. Args: @@ -504,7 +532,7 @@ async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, AgentRe if reach_max_rounds: completion_message = self._create_completion_message(self.MAX_ROUNDS_MET_MESSAGE) self._append_messages([completion_message]) - await ctx.yield_output(AgentResponse(messages=[completion_message])) + await self._yield_completion(ctx, completion_message) return True return False diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index 301eca5df4..b4142a9e16 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -29,7 +29,7 @@ from dataclasses import dataclass from typing import Any, ClassVar, cast -from agent_framework import Agent, AgentResponse, AgentSession, Message, SupportsAgentRun +from agent_framework import Agent, AgentResponse, AgentResponseUpdate, AgentSession, Message, SupportsAgentRun from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage @@ -522,9 +522,9 @@ async def _invoke_agent_helper(conversation: list[Message]) -> AgentOrchestratio async def _check_agent_terminate_and_yield( self, agent_orchestration_output: AgentOrchestrationOutput, - ctx: WorkflowContext[Never, AgentResponse], + ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ) -> bool: - """Yield the orchestrator's completion `AgentResponse` if termination was requested. + """Yield the orchestrator's completion if termination was requested. Args: agent_orchestration_output: Output from the orchestrator agent @@ -538,7 +538,7 @@ async def _check_agent_terminate_and_yield( ) completion_message = self._create_completion_message(final_message) self._append_messages([completion_message]) - await ctx.yield_output(AgentResponse(messages=[completion_message])) + await self._yield_completion(ctx, completion_message) return True return False diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index e8394402e5..7f1854f914 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -14,6 +14,7 @@ from agent_framework import ( AgentResponse, + AgentResponseUpdate, AgentSession, Message, SupportsAgentRun, @@ -1057,7 +1058,9 @@ async def _run_inner_loop_helper( if self._magentic_context is None: raise RuntimeError("Context not initialized") # Check limits first - within_limits = await self._check_within_limits_or_complete(cast(WorkflowContext[Never, AgentResponse], ctx)) + within_limits = await self._check_within_limits_or_complete( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ) if not within_limits: return @@ -1092,7 +1095,7 @@ async def _run_inner_loop_helper( # Check for task completion if self._progress_ledger.is_request_satisfied.answer: logger.info("Magentic Orchestrator: Task completed") - await self._prepare_final_answer(cast(WorkflowContext[Never, AgentResponse], ctx)) + await self._prepare_final_answer(cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx)) return # Check for stalling or looping @@ -1116,7 +1119,7 @@ async def _run_inner_loop_helper( if next_speaker not in self._participant_registry.participants: logger.warning(f"Invalid next speaker: {next_speaker}") - await self._prepare_final_answer(cast(WorkflowContext[Never, AgentResponse], ctx)) + await self._prepare_final_answer(cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx)) return # Add instruction to conversation (assistant guidance) @@ -1192,23 +1195,28 @@ async def _run_outer_loop( # Start inner loop await self._run_inner_loop(ctx) - async def _prepare_final_answer(self, ctx: WorkflowContext[Never, AgentResponse]) -> None: - """Yield the manager's synthesized final answer as the workflow's `AgentResponse`.""" + async def _prepare_final_answer(self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate]) -> None: + """Yield the manager's synthesized final answer. + + Mode-aware: streaming -> ``AgentResponseUpdate``, non-streaming → ``AgentResponse``. + See ``BaseGroupChatOrchestrator._yield_completion``. + """ if self._magentic_context is None: raise RuntimeError("Context not initialized") logger.info("Magentic Orchestrator: Preparing final answer") final_answer = await self._manager.prepare_final_answer(self._magentic_context.clone(deep=True)) - await ctx.yield_output(AgentResponse(messages=[final_answer])) + await self._yield_completion(ctx, final_answer) self._terminated = True - async def _check_within_limits_or_complete(self, ctx: WorkflowContext[Never, AgentResponse]) -> bool: + async def _check_within_limits_or_complete( + self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate] + ) -> bool: """Check if orchestrator is within operational limits. - If limits are exceeded, yield a termination AgentResponse and mark the workflow - as terminated. + If limits are exceeded, yield a termination message and mark the workflow as terminated. Args: ctx: The workflow context. @@ -1234,7 +1242,7 @@ async def _check_within_limits_or_complete(self, ctx: WorkflowContext[Never, Age contents=[f"Workflow terminated due to reaching maximum {limit_type} count."], author_name=MAGENTIC_MANAGER_NAME, ) - await ctx.yield_output(AgentResponse(messages=[termination_message])) + await self._yield_completion(ctx, termination_message) self._terminated = True return False diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 44a48b6487..50f58e781a 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -238,18 +238,16 @@ async def test_group_chat_builder_basic_flow() -> None: orchestrator_name="manager", ).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("coordinate task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - # Exactly one terminal `output` event = the orchestrator's completion AgentResponse. - assert len(outputs) == 1 - assert outputs[0].messages + # Exactly one terminal `output` event = the orchestrator's completion AgentResponseUpdate + # (mode-aware: streaming yields a single update chunk for the synthesized message). + assert len(updates) == 1 # The completion message is authored by the orchestrator. - assert outputs[0].messages[-1].author_name == "manager" + assert updates[0].author_name == "manager" async def test_group_chat_as_agent_accepts_conversation() -> None: @@ -283,18 +281,16 @@ async def test_agent_manager_handles_concatenated_json_output() -> None: orchestrator_agent=manager, ).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("coordinate task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert outputs - final_response = outputs[-1] - # Terminal AgentResponse contains only the orchestrator's completion message. - assert final_response.messages[-1].author_name == manager.name - assert final_response.messages[-1].text == "concatenated manager final" + assert updates + final_update = updates[-1] + # Terminal update is the orchestrator's completion message. + assert final_update.author_name == manager.name + assert final_update.text == "concatenated manager final" # Comprehensive tests for group chat functionality @@ -400,17 +396,14 @@ def selector(state: GroupChatState) -> str: selection_func=selector, ).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - # Exactly one terminal output event = orchestrator's max-rounds completion message. - assert len(outputs) == 1 - final_output = outputs[0].messages[-1] - assert "maximum number of rounds" in final_output.text.lower() + # Exactly one terminal output event = orchestrator's max-rounds completion update. + assert len(updates) == 1 + assert "maximum number of rounds" in (updates[0].text or "").lower() async def test_termination_condition_halts_conversation(self) -> None: """Test that a custom termination condition stops the workflow.""" @@ -430,15 +423,89 @@ def termination_condition(conversation: list[Message]) -> bool: selection_func=selector, ).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): - if event.type == "output" and isinstance(event.data, AgentResponse): - outputs.append(event.data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert outputs, "Expected termination to yield output" - # Terminal output is the orchestrator's completion message only. - final_output = outputs[-1].messages[-1] - assert "termination condition" in final_output.text.lower() + assert updates, "Expected termination to yield output" + # Terminal update is the orchestrator's completion message only. + assert "termination condition" in (updates[-1].text or "").lower() + + async def test_termination_yields_update_in_streaming(self) -> None: + """In streaming mode, the orchestrator's terminal completion surfaces as `AgentResponseUpdate`. + + Mirrors AgentExecutor's mode-aware behavior: streaming workflows produce per-chunk + `AgentResponseUpdate` events; the synthesized termination message is logically a + single chunk, so it should be a single `AgentResponseUpdate`. + """ + + def selector(state: GroupChatState) -> str: + return "agent" + + def termination_condition(conversation: list[Message]) -> bool: + replies = [msg for msg in conversation if msg.role == "assistant" and msg.author_name == "agent"] + return len(replies) >= 2 + + workflow = GroupChatBuilder( + participants=[StubAgent("agent", "response")], + termination_condition=termination_condition, + selection_func=selector, + ).build() + + terminal: AgentResponseUpdate | None = None + async for event in workflow.run("test task", stream=True): + if event.type == "output": + terminal = event.data # last output event wins + + assert isinstance(terminal, AgentResponseUpdate), ( + f"Expected AgentResponseUpdate in streaming mode, got {type(terminal).__name__}" + ) + assert "termination condition" in (terminal.text or "").lower() + + async def test_termination_yields_response_in_non_streaming(self) -> None: + """In non-streaming mode, the orchestrator's terminal completion surfaces as `AgentResponse`.""" + + def selector(state: GroupChatState) -> str: + return "agent" + + def termination_condition(conversation: list[Message]) -> bool: + replies = [msg for msg in conversation if msg.role == "assistant" and msg.author_name == "agent"] + return len(replies) >= 2 + + workflow = GroupChatBuilder( + participants=[StubAgent("agent", "response")], + termination_condition=termination_condition, + selection_func=selector, + ).build() + + events = await workflow.run("test task") + outputs = [ev for ev in events if ev.type == "output"] + assert len(outputs) == 1 + assert isinstance(outputs[0].data, AgentResponse) + assert "termination condition" in outputs[0].data.messages[-1].text.lower() + + async def test_max_rounds_yields_update_in_streaming(self) -> None: + """Max-rounds completion in streaming mode surfaces as `AgentResponseUpdate`.""" + + def selector(state: GroupChatState) -> str: + return "agent" + + workflow = GroupChatBuilder( + participants=[StubAgent("agent", "response")], + max_rounds=2, + selection_func=selector, + ).build() + + terminal: AgentResponseUpdate | None = None + async for event in workflow.run("test task", stream=True): + if event.type == "output": + terminal = event.data + + assert isinstance(terminal, AgentResponseUpdate), ( + f"Expected AgentResponseUpdate in streaming mode, got {type(terminal).__name__}" + ) + assert "maximum number of rounds" in (terminal.text or "").lower() async def test_termination_condition_agent_manager_finalizes(self) -> None: """Test that termination condition with agent orchestrator produces default termination message.""" @@ -451,17 +518,15 @@ async def test_termination_condition_agent_manager_finalizes(self) -> None: orchestrator_agent=manager, ).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert outputs, "Expected termination to yield output" - final_message = outputs[-1].messages[-1] - assert final_message.text == BaseGroupChatOrchestrator.TERMINATION_CONDITION_MET_MESSAGE - assert final_message.author_name == manager.name + assert updates, "Expected termination to yield output" + final_update = updates[-1] + assert final_update.text == BaseGroupChatOrchestrator.TERMINATION_CONDITION_MET_MESSAGE + assert final_update.author_name == manager.name async def test_unknown_participant_error(self) -> None: """Test that unknown participant selection raises error.""" @@ -497,14 +562,12 @@ def selector(state: GroupChatState) -> str: selection_func=selector, ).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert len(outputs) == 1 # Should complete normally + assert len(updates) == 1 # Should complete normally class TestConversationHandling: @@ -538,14 +601,12 @@ def selector(state: GroupChatState) -> str: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test string", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert len(outputs) == 1 + assert len(updates) == 1 async def test_handle_chat_message_input(self) -> None: """Test handling Message input directly.""" @@ -561,14 +622,12 @@ def selector(state: GroupChatState) -> str: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run(task_message, stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert len(outputs) == 1 + assert len(updates) == 1 async def test_handle_conversation_list_input(self) -> None: """Test handling conversation list preserves context.""" @@ -587,14 +646,12 @@ def selector(state: GroupChatState) -> str: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run(conversation, stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert len(outputs) == 1 + assert len(updates) == 1 class TestRoundLimitEnforcement: @@ -617,17 +674,14 @@ def selector(state: GroupChatState) -> str: selection_func=selector, ).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - # Exactly one terminal output event = orchestrator's max-rounds completion message. - assert len(outputs) == 1 - final_output = outputs[0].messages[-1] - assert "maximum number of rounds" in final_output.text.lower() + # Exactly one terminal output event = orchestrator's max-rounds completion update. + assert len(updates) == 1 + assert "maximum number of rounds" in (updates[0].text or "").lower() async def test_round_limit_in_ingest_participant_message(self) -> None: """Test round limit enforcement after participant response.""" @@ -647,17 +701,14 @@ def selector(state: GroupChatState) -> str: selection_func=selector, ).build() - outputs: list[AgentResponse] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.append(data) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - # Exactly one terminal output event = orchestrator's max-rounds completion message. - assert len(outputs) == 1 - final_output = outputs[0].messages[-1] - assert "maximum number of rounds" in final_output.text.lower() + # Exactly one terminal output event = orchestrator's max-rounds completion update. + assert len(updates) == 1 + assert "maximum number of rounds" in (updates[0].text or "").lower() async def test_group_chat_checkpoint_runtime_only() -> None: @@ -670,17 +721,17 @@ async def test_group_chat_checkpoint_runtime_only() -> None: wf = GroupChatBuilder(participants=[agent_a, agent_b], max_rounds=2, selection_func=selector).build() - baseline_output: AgentResponse | None = None + baseline_update: AgentResponseUpdate | None = None async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): - if ev.type == "output" and isinstance(ev.data, AgentResponse): - baseline_output = ev.data + if ev.type == "output" and isinstance(ev.data, AgentResponseUpdate): + baseline_update = ev.data if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, ): break - assert baseline_output is not None + assert baseline_update is not None checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints" @@ -706,17 +757,17 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None: checkpoint_storage=buildtime_storage, selection_func=selector, ).build() - baseline_output: AgentResponse | None = None + baseline_update: AgentResponseUpdate | None = None async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True): - if ev.type == "output" and isinstance(ev.data, AgentResponse): - baseline_output = ev.data + if ev.type == "output" and isinstance(ev.data, AgentResponseUpdate): + baseline_update = ev.data if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, ): break - assert baseline_output is not None + assert baseline_update is not None buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name) runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name) @@ -960,10 +1011,11 @@ def agent_factory() -> Agent: outputs.append(event) assert len(outputs) == 1 - # The DynamicManagerAgent terminates after second call with final_message - final_response = outputs[0].data - assert isinstance(final_response, AgentResponse) - assert any(msg.text == "dynamic manager final" for msg in final_response.messages) + # Streaming mode: terminal yield is AgentResponseUpdate. The DynamicManagerAgent + # terminates after second call with final_message. + final_update = outputs[0].data + assert isinstance(final_update, AgentResponseUpdate) + assert final_update.text == "dynamic manager final" def test_group_chat_with_orchestrator_factory_returning_base_orchestrator(): diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py index b683d8b25b..0389fad94e 100644 --- a/python/packages/orchestrations/tests/test_magentic.py +++ b/python/packages/orchestrations/tests/test_magentic.py @@ -190,24 +190,82 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None: assert isinstance(workflow, Workflow) - outputs: list[Message] = [] + updates: list[AgentResponseUpdate] = [] orchestrator_event_count = 0 async for event in workflow.run("compose summary", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, AgentResponse): - outputs.extend(data.messages) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) elif event.type == "magentic_orchestrator": orchestrator_event_count += 1 - assert outputs, "Expected a final output message" - assert len(outputs) >= 1 - final = outputs[-1] + assert updates, "Expected a final output update" + final = updates[-1] assert final.text == manager.FINAL_ANSWER assert final.author_name == manager.name assert orchestrator_event_count > 0, "Expected orchestrator events to be emitted" +async def test_magentic_final_answer_yields_update_in_streaming() -> None: + """In streaming mode, Magentic's manager final-answer surfaces as `AgentResponseUpdate`. + + Mirrors AgentExecutor's mode-aware behavior: streaming workflows produce per-chunk + `AgentResponseUpdate` events; the synthesized final answer is logically a single chunk, + so it surfaces as a single `AgentResponseUpdate`. + """ + manager = FakeManager() + workflow = MagenticBuilder( + participants=[StubAgent(manager.next_speaker_name, "first draft")], + manager=manager, + ).build() + + terminal: AgentResponseUpdate | None = None + async for event in workflow.run("compose summary", stream=True): + if event.type == "output": + terminal = event.data + + assert isinstance(terminal, AgentResponseUpdate), ( + f"Expected AgentResponseUpdate in streaming mode, got {type(terminal).__name__}" + ) + assert terminal.text == manager.FINAL_ANSWER + assert terminal.author_name == manager.name + + +async def test_magentic_final_answer_yields_response_in_non_streaming() -> None: + """In non-streaming mode, Magentic's manager final-answer surfaces as `AgentResponse`.""" + manager = FakeManager() + workflow = MagenticBuilder( + participants=[StubAgent(manager.next_speaker_name, "first draft")], + manager=manager, + ).build() + + events = await workflow.run("compose summary") + outputs = [ev for ev in events if ev.type == "output"] + assert len(outputs) == 1 + assert isinstance(outputs[0].data, AgentResponse) + assert outputs[0].data.messages[-1].text == manager.FINAL_ANSWER + + +async def test_magentic_limit_termination_yields_update_in_streaming() -> None: + """In streaming mode, Magentic's round-limit termination surfaces as `AgentResponseUpdate`.""" + manager = FakeManager(max_round_count=1) + workflow = MagenticBuilder( + participants=[DummyExec(name=manager.next_speaker_name)], + manager=manager, + ).build() + + terminal: AgentResponseUpdate | None = None + async for event in workflow.run("round limit test", stream=True): + if event.type == "output": + terminal = event.data + + assert isinstance(terminal, AgentResponseUpdate), ( + f"Expected AgentResponseUpdate in streaming mode, got {type(terminal).__name__}" + ) + # Either the final answer OR the round-limit termination message — both are valid terminal states + # for max_round_count=1; the precise one depends on FakeManager's progression. + assert terminal.text + + async def test_magentic_as_agent_does_not_accept_conversation() -> None: manager = FakeManager() writer = StubAgent(manager.next_speaker_name, "summary response") @@ -250,7 +308,7 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): assert isinstance(req_event.data, MagenticPlanReviewRequest) completed = False - output: AgentResponse | None = None + output: AgentResponseUpdate | None = None async for ev in wf.run(stream=True, responses={req_event.request_id: req_event.data.approve()}): if ev.type == "status" and ev.state == WorkflowRunState.IDLE: completed = True @@ -261,8 +319,8 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): assert completed assert output is not None - assert isinstance(output, AgentResponse) - assert all(isinstance(msg, Message) for msg in output.messages) + # Streaming mode: terminal output is AgentResponseUpdate. + assert isinstance(output, AgentResponseUpdate) async def test_magentic_plan_review_with_revise(): @@ -333,14 +391,12 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result(): None, ) assert idle_status is not None - # Check that we got workflow output via WorkflowEvent with type "output" + # Streaming mode: terminal output is AgentResponseUpdate. output_event = next((e for e in events if e.type == "output"), None) assert output_event is not None data = output_event.data - assert isinstance(data, AgentResponse) - assert len(data.messages) > 0 - assert data.messages[-1].role == "assistant" - assert all(isinstance(msg, Message) for msg in data.messages) + assert isinstance(data, AgentResponseUpdate) + assert data.role == "assistant" async def test_magentic_checkpoint_resume_round_trip(): @@ -753,12 +809,9 @@ async def test_magentic_stall_and_reset_reach_limits(): assert idle_status is not None output_event = next((e for e in events if e.type == "output"), None) assert output_event is not None - assert isinstance(output_event.data, AgentResponse) - msgs = output_event.data.messages - assert all(isinstance(msg, Message) for msg in msgs) - assert len(msgs) > 0 - assert msgs[-1].text is not None - assert msgs[-1].text == "Workflow terminated due to reaching maximum reset count." + # Streaming mode: terminal output is AgentResponseUpdate. + assert isinstance(output_event.data, AgentResponseUpdate) + assert output_event.data.text == "Workflow terminated due to reaching maximum reset count." async def test_magentic_checkpoint_runtime_only() -> None: From d97a693cb6fb5337bae9aec761c48abfe2ba7921 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 28 Apr 2026 14:17:26 +0900 Subject: [PATCH 15/16] Fix mypy/pyright: widen cast types at GroupChat callsites Eight callsites in _group_chat.py still cast to WorkflowContext[Never, AgentResponse] but the base orchestrator methods now accept the wider WorkflowContext[Never, AgentResponse | AgentResponseUpdate] (mode-aware yields). W_OutT is invariant, so the narrower cast is not assignable. Magentic was widened in the same commit; this catches the GroupChat callsites that were missed. --- .../_group_chat.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index b4142a9e16..9f7e011252 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -169,7 +169,9 @@ async def _handle_messages( """Initialize orchestrator state and start the conversation loop.""" self._append_messages(messages) # Termination condition will also be applied to the input messages - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): + if await self._check_terminate_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return next_speaker = await self._get_next_speaker() @@ -198,9 +200,13 @@ async def _handle_response( messages = clean_conversation_for_handoff(messages) self._append_messages(messages) - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): + if await self._check_terminate_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return - if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): + if await self._check_round_limit_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return next_speaker = await self._get_next_speaker() @@ -332,13 +338,15 @@ async def _handle_messages( """Initialize orchestrator state and start the conversation loop.""" self._append_messages(messages) # Termination condition will also be applied to the input messages - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): + if await self._check_terminate_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return agent_orchestration_output = await self._invoke_agent() if await self._check_agent_terminate_and_yield( agent_orchestration_output, - cast(WorkflowContext[Never, AgentResponse], ctx), + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx), ): return @@ -366,15 +374,19 @@ async def _handle_response( # Remove tool-related content to prevent API errors from empty messages messages = clean_conversation_for_handoff(messages) self._append_messages(messages) - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): + if await self._check_terminate_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return - if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, AgentResponse], ctx)): + if await self._check_round_limit_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return agent_orchestration_output = await self._invoke_agent() if await self._check_agent_terminate_and_yield( agent_orchestration_output, - cast(WorkflowContext[Never, AgentResponse], ctx), + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx), ): return From ec2f0853d3b921f8fe43544cb80433659d2ada73 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 29 Apr 2026 09:29:56 +0900 Subject: [PATCH 16/16] Python: skip flaky Foundry / Foundry Hosting integration tests (#5553) These two integration tests have been failing in the merge queue across multiple unrelated PRs (5301, 5531). Both are marked `@pytest.mark.flaky` with 3 retries, but all attempts fail back-to-back. Skipping both with a reason pointing to #5553 so they can be fixed properly without continuing to block unrelated merges. - packages/foundry_hosting/tests/test_responses_int.py::TestOptions::test_temperature_and_max_tokens - packages/foundry/tests/foundry/test_foundry_embedding_client.py::TestFoundryEmbeddingIntegration::test_text_embedding_live Also includes a one-line uv.lock specifier-ordering normalization auto-applied by the poe-check pre-commit hook. --- .../foundry/tests/foundry/test_foundry_embedding_client.py | 1 + python/packages/foundry_hosting/tests/test_responses_int.py | 1 + python/uv.lock | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py index 664123637d..f005a737dc 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py @@ -303,6 +303,7 @@ def _foundry_integration_tests_enabled() -> bool: class TestFoundryEmbeddingIntegration: """Integration tests requiring a live Foundry inference endpoint.""" + @pytest.mark.skip(reason="Flaky in merge queue, blocking unrelated PRs. Tracked in #5553.") @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_inference_integration_tests_disabled diff --git a/python/packages/foundry_hosting/tests/test_responses_int.py b/python/packages/foundry_hosting/tests/test_responses_int.py index e64976989b..24c590f25c 100644 --- a/python/packages/foundry_hosting/tests/test_responses_int.py +++ b/python/packages/foundry_hosting/tests/test_responses_int.py @@ -559,6 +559,7 @@ async def test_tool_call_streaming(self, server_with_tools: ResponsesHostServer) class TestOptions: """Verify chat options are passed through to the model.""" + @pytest.mark.skip(reason="Flaky in merge queue, blocking unrelated PRs. Tracked in #5553.") @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_hosting_integration_tests_disabled diff --git a/python/uv.lock b/python/uv.lock index 4e60e5cfd6..8fb84afa44 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -585,7 +585,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=0.2.1,>=0.2.1" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" }, ] [[package]]