Python: Improve the handling of intermediate outputs for workflows and orchestrations (#5623)

* Improve the handling of intermediate outputs for workflows and orchestrations

* Address PR review feedback on intermediate output forwarding

- Switch workflow.as_agent() forwarding to an explicit allowlist of {output,
  intermediate, data, request_info} so orchestration-internal events
  (group_chat, handoff_sent, magentic_orchestrator) stay inside the workflow
  instead of leaking into agent responses via str(data) coercion.
- Stop raising on intermediate AgentResponseUpdate in non-streaming run();
  surface the partial as a Message with text_reasoning content. The defensive
  raise still applies to terminal output events, where Update payloads would
  corrupt message ordering.
- Extend the DevUI workflow-event mapper so intermediate yields wrapping
  plain strings, Messages, and list[Message] render as visible output items
  instead of generic completed-trace events.
- Add orchestration coverage for GroupChat, Handoff, and Magentic builders
  (default vs intermediate_outputs=True; structural where end-to-end is heavy).

* Lift output-designation policy into a value type

Replace the ``Workflow._output_executors`` list and the
``RunnerContext.should_label_as_intermediate`` Protocol method with a single
immutable ``OutputDesignation`` value type owned by ``Workflow``. Thread the
designation as a parameter through the existing call chain (Runner ->
EdgeRunner -> Executor -> WorkflowContext) so ``yield_output`` consults the
threaded snapshot directly rather than calling back into the runner context.

Removes the ``InProcRunnerContext._workflow`` back-reference and the
``WorkflowBuilder.build()`` assignment that wired it up. Adds the public
predicate ``Workflow.is_terminal_executor(executor_id)`` for external
observers; ``OutputDesignation`` itself stays package-internal.

Key decisions
- ``OutputDesignation.designated`` is ``frozenset[str] | None`` -- ``None``
  preserves legacy "every yield is type='output'" behavior, any frozenset
  (including empty) opts into strict mode. The ``DeprecationWarning`` for
  legacy mode at build time is unchanged.
- ``output_designation`` is an optional parameter on ``Runner``,
  ``EdgeRunner.send_message``, ``EdgeRunner._execute_on_target``,
  ``Executor.execute``, ``Executor._create_context_for_handler``, and
  ``WorkflowContext.__init__``. Each defaults to legacy ``OutputDesignation()``
  so direct callers (Azure Functions ``CapturingRunnerContext``,
  ``test_runner`` recording fixtures) keep working without ceremony.
- The workflow-level filter in ``_run_core`` reads ``self._output_designation``
  live, preserving today's semantics where mutating the designation after
  build still affects subsequent runs (used by two existing tests).
- ``Workflow.to_dict()`` continues to emit ``"output_executors":
  list[str] | None`` (sorted from the frozenset). Checkpoint format unchanged.

Files changed
- _workflow.py: add ``OutputDesignation`` dataclass; replace
  ``_output_executors`` with ``_output_designation``; add
  ``is_terminal_executor``; delete ``_should_yield_output_event``.
- _runner_context.py: drop ``should_label_as_intermediate`` Protocol method
  and ``InProcRunnerContext`` impl; drop ``_workflow`` back-reference.
- _workflow_builder.py: remove ``context._workflow = workflow`` assignment.
- _runner.py, _edge_runner.py, _executor.py, _workflow_context.py: thread
  ``output_designation`` parameter through the call chain.
- tests/workflow/test_output_designation.py (new): three-state coverage of
  the value type plus the public predicate delegation.
- tests/workflow/test_workflow_builder.py, test_validation.py,
  test_workflow.py, test_runner.py and
  orchestrations/tests/test_orchestration_intermediate_vs_terminal.py:
  switch probes from ``_output_executors`` set checks to
  ``get_output_executors`` / ``is_terminal_executor``; update two
  post-build mutation tests to set ``_output_designation`` instead.

Verification
- core/tests/workflow/, orchestrations/tests/, azurefunctions/tests/:
  1119 passed, 42 skipped, 2 xfailed.
- ``uv run poe lint``: clean.
- ``uv run poe typing``: only the pre-existing
  ``_AGENT_FORWARDED_EVENT_TYPES`` pyright warning from 394bcd607 remains.

Notes for next iteration
- The builder's own ``_output_executors`` attribute (``list[Executor |
  SupportsAgentRun]``) is intentionally untouched; the issue scoped the
  rename to the workflow attribute.
- Adjacent review candidates (twin ``WorkflowAgent`` translators,
  ``_AGENT_FORWARDED_EVENT_TYPES`` kind classifier,
  ``_event_origin_context`` ContextVar removal, ``WorkflowEvent`` ADT
  split, legacy-mode removal) remain out of scope.

* Add explicit workflow output designation

Key decisions

- Extend the internal OutputDesignation value type from terminal-only membership to output/intermediate/hidden classification. Legacy mode remains outputs=None, so workflows built without output_executors or intermediate_executors still label every yield_output as type='output'.

- WorkflowBuilder now accepts intermediate_executors. Providing either designation enters explicit mode; output executors emit output, intermediate executors emit intermediate, and unlisted yield_output payloads are hidden from caller-facing events while remaining in executor_completed data.

- Empty explicit designation, duplicate entries, overlaps, unknown executors, and designated executors without workflow output annotations fail build validation. Existing orchestration builders pass intermediate-capable participants through intermediate_executors to preserve current intermediate_outputs behavior until participant-oriented designation lands.

Files changed

- packages/core/agent_framework/_workflows/_workflow.py, _workflow_builder.py, _workflow_context.py, _validation.py, _events.py

- packages/core/tests/workflow/test_output_designation.py, test_output_executors_contract.py, test_strict_mode_event_labeling.py, test_validation.py, test_workflow.py, test_workflow_agent_intermediate.py

- packages/orchestrations/agent_framework_orchestrations/_sequential.py, _concurrent.py, _group_chat.py, _magentic.py

- packages/core/AGENTS.md

Verification

- uv run pytest packages/core/tests/workflow packages/orchestrations/tests packages/devui/tests/devui/test_mapper.py -q

- uv run pytest packages/azurefunctions/tests -q

- uv run poe lint

- uv run poe typing fails only on pre-existing packages/core/agent_framework/_workflows/_agent.py _AGENT_FORWARDED_EVENT_TYPES private-use pyright error.

Notes for next iteration

- issues/03-core-workflow-explicit-designation.md was moved to issues/done but issues/ remains untracked and intentionally excluded from this commit.

- Slice 4 should tighten workflow.as_agent() mapping for hidden emissions and streaming-only update payloads; Slice 5 should replace orchestration intermediate_outputs with participant-oriented designation.

* Tighten workflow-as-agent output mapping

Key decisions

- Treat AgentResponseUpdate as a streaming-only payload across the workflow.as_agent() adapter, so non-streaming agent runs now reject both terminal output and intermediate workflow events carrying updates.
- Keep streaming classification behavior explicit: terminal update payloads remain normal text content, while intermediate update payloads are rewritten to text_reasoning content.
- Add explicit-mode coverage proving hidden yield_output emissions do not appear in non-streaming AgentResponse messages or streaming AgentResponseUpdate chunks.

Files changed

- packages/core/agent_framework/_workflows/_agent.py
- packages/core/tests/workflow/test_workflow_agent_intermediate.py

Verification

- uv run pytest packages/core/tests/workflow/test_workflow_agent_intermediate.py -q
- uv run pytest packages/core/tests/workflow/test_workflow_agent.py packages/core/tests/workflow/test_workflow_agent_intermediate.py -q
- uv run pytest packages/core/tests/workflow packages/orchestrations/tests packages/devui/tests/devui/test_mapper.py -q
- uv run poe lint
- uv run poe typing fails only on the pre-existing packages/core/agent_framework/_workflows/_agent.py _AGENT_FORWARDED_EVENT_TYPES private-use pyright error.

Blockers or notes for next iteration

- issues/04-workflow-as-agent-output-mapping.md was moved to issues/done/ but issues/ remains untracked and intentionally excluded from this commit.
- Slice 5 should replace orchestration intermediate_outputs with participant-oriented designation.

* Add orchestration participant output designation

Key decisions

- Replace orchestration intermediate_outputs with participant-oriented output_participants and intermediate_participants across Sequential, Concurrent, GroupChat, Magentic, and Handoff builders.
- Keep synthetic final executors terminal by default for Concurrent, GroupChat, and Magentic; keep Sequential's final participant terminal by default; keep Handoff participants terminal by default.
- Centralize participant designation validation for empty explicit designation, duplicates, overlaps, and unknown participants, then map validated participants to workflow output/intermediate executors.

Files changed

- packages/orchestrations/agent_framework_orchestrations/_participant_designation.py
- packages/orchestrations/agent_framework_orchestrations/_sequential.py
- packages/orchestrations/agent_framework_orchestrations/_concurrent.py
- packages/orchestrations/agent_framework_orchestrations/_group_chat.py
- packages/orchestrations/agent_framework_orchestrations/_magentic.py
- packages/orchestrations/agent_framework_orchestrations/_handoff.py
- packages/orchestrations/tests/test_orchestration_intermediate_vs_terminal.py
- packages/orchestrations/tests/test_magentic.py

Blockers or notes for next iteration

- issues/05-orchestration-participant-designation.md was moved to issues/done/ but issues/ remains untracked and intentionally excluded from this commit.
- Slice 7 should migrate samples and docs away from intermediate_outputs to the new participant designation API.
- uv run poe typing still fails only on the pre-existing packages/core/agent_framework/_workflows/_agent.py _AGENT_FORWARDED_EVENT_TYPES private-use pyright error.

* Migrate samples to explicit output designation

Key decisions

- Replace sample usage of the removed orchestration intermediate_outputs boolean with participant-oriented intermediate_participants designation.
- Update raw workflow guidance to show output_executors together with intermediate_executors, and document that unlisted yields are hidden in explicit designation mode.
- Keep orchestration final outputs terminal while streaming designated participant responses as intermediate progress, including workflow.as_agent() samples where intermediates map to text_reasoning content.
- Refresh workflow and orchestration README guidance plus the changelog reference so public docs no longer point users at intermediate_outputs.

Files changed

- CHANGELOG.md
- packages/orchestrations/README.md
- samples/README.md
- samples/03-workflows/README.md
- samples/03-workflows/control-flow/intermediate_vs_terminal_outputs.py
- samples/03-workflows/orchestrations/README.md
- samples/03-workflows/orchestrations/group_chat_agent_manager.py
- samples/03-workflows/orchestrations/group_chat_philosophical_debate.py
- samples/03-workflows/orchestrations/group_chat_simple_selector.py
- samples/03-workflows/orchestrations/magentic.py
- samples/03-workflows/orchestrations/magentic_human_plan_review.py
- samples/03-workflows/orchestrations/sequential_chain_only_agent_responses.py
- samples/03-workflows/agents/group_chat_workflow_as_agent.py
- samples/03-workflows/agents/magentic_workflow_as_agent.py
- samples/03-workflows/agents/sequential_workflow_as_agent.py
- samples/semantic-kernel-migration/orchestrations/group_chat.py
- samples/semantic-kernel-migration/orchestrations/magentic.py

Blockers or notes for next iteration

- issues/07-samples-and-docs-explicit-output-designation.md was moved to issues/done/ but issues/ remains untracked and intentionally excluded from this commit.
- issues/06-devui-intermediate-event-rendering.md remains present and appears already satisfied by existing DevUI mapper/tests from the prior implementation slice.
- PRD-explicit-workflow-output-designation.md remains untracked and intentionally excluded from this commit.

* Render DevUI intermediate workflow outputs

Key decisions

- Preserve workflow output designation metadata on visible DevUI output messages and text deltas so intermediate/data emissions remain distinguishable from terminal output.
- Render intermediate workflow message items in the execution timeline using executor metadata, while excluding them from the final workflow result aggregation.
- Keep terminal output message rendering unchanged and retain legacy data events on the intermediate compatibility path.

Files changed

- packages/devui/agent_framework_devui/_mapper.py
- packages/devui/frontend/src/components/features/workflow/execution-timeline.tsx
- packages/devui/frontend/src/components/features/workflow/workflow-view.tsx
- packages/devui/frontend/src/types/openai.ts
- packages/devui/tests/devui/test_mapper.py

Blockers or notes for next iteration

- issues/06-devui-intermediate-event-rendering.md was moved to issues/done/ but issues/ remains untracked and intentionally excluded from this commit.
- PRD-explicit-workflow-output-designation.md remains untracked and intentionally excluded from this commit.
- uv run poe typing still fails only on the pre-existing packages/core/agent_framework/_workflows/_agent.py _AGENT_FORWARDED_EVENT_TYPES private-use pyright error.

* Fix mypy

* Clarify orchestration participant output config

* Rename participant output kwargs for clarity

output_participants -> final_output_from, intermediate_participants ->
intermediate_output_from. The old names read like categories of
participant; the new names make it clear the kwarg designates which
participants' outputs surface as final vs. intermediate events.

* Rename core workflow output kwargs with deprecation shim

Adds final_output_from / intermediate_output_from as canonical kwargs on
Workflow and WorkflowBuilder. Old output_executors / intermediate_executors
kwargs continue to work but emit DeprecationWarning via a shared coalesce
helper that also rejects supplying both. Wire-format keys in to_dict()
stay as output_executors / intermediate_executors so checkpoint
compatibility is preserved.

Internal call sites in orchestrations and samples updated to the new
names so users following sample code learn the canonical vocabulary;
legacy callers still work with a one-shot warning.

* Suppress pyright reportPrivateUsage on cross-module sentinel import

* Update docstrings

* Propagate sub-workflow intermediate outputs, fix handoff/sequential intermediate-only designation, and shore up tests, sample, and docstrings around the intermediate output contract.

* Add canonical workflow output_from selection

Key decisions:\n- Make output_from the canonical workflow-output allow-list and keep output_executors/final_output_from as deprecated compatibility aliases.\n- Treat empty output_from/intermediate_output_from lists as explicit selections and keep validation responsible for empty, duplicate, overlap, and unknown selections.\n- Remove the branch-only public intermediate_executors WorkflowBuilder kwarg while preserving legacy wire keys in to_dict().\n\nFiles changed:\n- packages/core/agent_framework/_workflows/_workflow.py\n- packages/core/agent_framework/_workflows/_workflow_builder.py\n- packages/core/agent_framework/_workflows/_workflow_context.py\n- packages/core/agent_framework/_workflows/_agent.py\n- packages/core/agent_framework/_workflows/_agent_executor.py\n- packages/core/tests/workflow/* output-selection coverage updates\n- packages/core/AGENTS.md\n- issues/done/001-canonical-list-based-output-selection.md\n\nBlockers/notes:\n- Orchestration builders still pass final_output_from internally; follow-up issue 004 should migrate them to output_from.\n- Legacy omitted-selection behavior and explicit all/all_other literals are left for issues 002 and 003.

* Add explicit all workflow output selection

Key decisions:
- Treat output_from='all' as an explicit workflow-output selection sentinel and expand it at build time to executors with declared workflow output types.
- Keep omitted output selections in legacy all-output mode with a deprecation warning that names output_from and intermediate_output_from and points to output_from='all'.
- Reject intermediate_output_from='all' at construction because the all-output literal is output-only for this issue.

Files changed:
- packages/core/agent_framework/_workflows/_workflow_builder.py
- packages/core/tests/workflow/test_output_executors_contract.py
- issues/done/002-explicit-all-output-and-legacy-migration.md

Blockers/notes:
- all_other intermediate-output selection remains for issue 003.
- Workflow-as-agent/orchestration parity remains for issue 004.

* Add all-other intermediate output selection

Key decisions:
- Treat intermediate_output_from='all_other' as an explicit intermediate-output selection sentinel and expand it at build time after the workflow graph is complete.
- Expand all_other to output-capable executors not selected by output_from; omitted or empty output_from selects no workflow outputs, while output_from='all' leaves an empty intermediate selection.
- Keep output_from='all_other' invalid so all_other remains intermediate-output-only and runtime classification still receives concrete executor-id sets.

Files changed:
- packages/core/agent_framework/_workflows/_workflow_builder.py
- packages/core/tests/workflow/test_output_executors_contract.py
- issues/done/003-all-other-intermediate-output-selection.md

Blockers/notes:
- Workflow-as-agent and orchestration parity remains for issue 004.
- Full documentation updates remain for issue 005.

* Add orchestration output selection parity

Key decisions:
- Expose output_from on sequential, concurrent, group chat, handoff, and magentic builders while keeping final_output_from as a deprecated compatibility alias.
- Resolve orchestration participant selections through the same explicit rules as workflows: output_from='all', intermediate_output_from='all_other', hidden unselected participant payloads, and overlap/duplicate/unknown/invalid-literal validation.
- Continue preserving documented orchestration defaults by always designating each pattern's terminal internal executor where applicable.

Files changed:
- packages/orchestrations/agent_framework_orchestrations/_participant_output_config.py
- packages/orchestrations/agent_framework_orchestrations/_sequential.py
- packages/orchestrations/agent_framework_orchestrations/_concurrent.py
- packages/orchestrations/agent_framework_orchestrations/_group_chat.py
- packages/orchestrations/agent_framework_orchestrations/_handoff.py
- packages/orchestrations/agent_framework_orchestrations/_magentic.py
- packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py
- packages/orchestrations/tests/test_orchestration_intermediate_vs_terminal.py
- issues/done/004-workflow-as-agent-and-orchestration-parity.md

Blockers/notes:
- Full documentation and sample migration wording remains for issue 005.
- Existing tests that intentionally use final_output_from now emit the new deprecation warning.

* Document workflow output selection contract

Key decisions:
- Use Workflow Output and Intermediate Output as the developer-facing terms for selected caller-facing emissions.
- Document output_from and intermediate_output_from as the canonical API, with output_from as an allow-list and unselected payloads hidden unless explicitly selected as intermediate.
- Add scenario and invalid-selection tables for workflow and orchestration docs, including legacy omission warnings, output_from='all', intermediate_output_from='all_other', list selections, invalid literals, overlap, duplicates, unknown selections, and empty explicit selections.
- Migrate samples away from final_output_from and output_executors except where compatibility aliases are explicitly documented.

Files changed:
- packages/core/AGENTS.md
- packages/orchestrations/README.md
- packages/orchestrations/agent_framework_orchestrations/_handoff.py
- packages/orchestrations/agent_framework_orchestrations/_sequential.py
- samples/03-workflows/README.md
- samples/03-workflows/control-flow/intermediate_vs_terminal_outputs.py
- samples/03-workflows/human-in-the-loop/agents_with_approval_requests.py
- samples/03-workflows/orchestrations/README.md
- samples/04-hosting/foundry-hosted-agents/responses/05_workflows/main.py
- scripts/sample_validation/create_dynamic_workflow_executor.py
- issues/done/005-document-output-selection-contract.md

Blockers/notes:
- Direct full Ruff on scripts/sample_validation/create_dynamic_workflow_executor.py still reports pre-existing docstring/print/line-length issues outside this docs migration; syntax-focused checks for changed files pass.
- No remaining AFK issue files are present under issues/.

* Latest updates

* Typing fixes

* Cleanup
This commit is contained in:
Evan Mattson
2026-05-19 09:15:25 +09:00
committed by GitHub
Unverified
parent 3ebbdb01b4
commit 3bbc81554b
68 changed files with 3480 additions and 325 deletions
+45
View File
@@ -18,6 +18,12 @@ Chain agents/executors in sequence, passing conversation context along:
from agent_framework.orchestrations import SequentialBuilder
workflow = SequentialBuilder(participants=[agent1, agent2, agent3]).build()
# Preserve agent1 and agent2 as visible progress, while the default builder output remains Workflow Output.
workflow = SequentialBuilder(
participants=[agent1, agent2, agent3],
intermediate_output_from=[agent1, agent2],
).build()
```
### ConcurrentBuilder
@@ -55,6 +61,7 @@ from agent_framework.orchestrations import GroupChatBuilder
workflow = GroupChatBuilder(
participants=[agent1, agent2],
selection_func=my_selector,
intermediate_output_from=[agent1, agent2],
).build()
```
@@ -68,9 +75,47 @@ from agent_framework.orchestrations import MagenticBuilder
workflow = MagenticBuilder(
participants=[researcher, writer, reviewer],
manager_agent=manager_agent,
intermediate_output_from=[researcher, writer, reviewer],
).build()
```
## Output Selection
Orchestration builders expose Workflow Output selection using participant names. The core rule is that `output_from`
is an allow-list for Workflow Output, not a routing rule for every other participant output. Unselected participant
payloads are hidden unless `intermediate_output_from` explicitly selects them as Intermediate Output.
- `output_from` designates participant emissions as Workflow Output (`type='output'` events).
- `intermediate_output_from` designates participant emissions as Intermediate Output (`type='intermediate'` events).
If neither list is provided, each builder uses its documented default Workflow Output contract. Sequential emits the
last participant; Concurrent, GroupChat, and Magentic emit their aggregator/orchestrator/manager output; Handoff emits
participants.
| Selection | Workflow Output | Intermediate Output | Hidden payloads |
| --- | --- | --- | --- |
| Omit both selections | Builder default Workflow Output contract | None | Builder-specific non-output participant payloads |
| `output_from="all"` | Every output-capable participant | None | None |
| `output_from=[writer]` | Only `writer` | None | All other participant payloads |
| `output_from=[writer], intermediate_output_from="all_other"` | Only `writer` | Every output-capable participant not selected by `output_from` | None |
| `intermediate_output_from="all_other"` | None, except builder-internal default output executors where applicable | Every output-capable participant | Builder-internal plumbing payloads |
| `output_from=[], intermediate_output_from="all_other"` | None, except builder-internal default output executors where applicable | Every output-capable participant | Builder-internal plumbing payloads |
| `output_from=[writer], intermediate_output_from=[researcher, reviewer]` | Only `writer` | `researcher` and `reviewer` | Any other participant payloads |
Invalid selections fail at construction or build time:
| Invalid selection | Why it fails |
| --- | --- |
| `output_from="all_other"` | `"all_other"` is only valid for `intermediate_output_from` |
| `intermediate_output_from="all"` | `"all"` is only valid for `output_from` |
| The same participant in both selections | One payload cannot be both Workflow Output and Intermediate Output |
| Duplicate participant selections | Duplicates are treated as configuration errors |
| Unknown participant selections | Typos and missing participants are rejected |
| `output_from=[], intermediate_output_from=[]` | Both explicit selections are empty |
When an orchestration is wrapped with `workflow.as_agent()`, Workflow Output becomes normal response text. Intermediate
Output becomes `text_reasoning` content so callers can inspect progress without changing `.text` behavior.
## Documentation
For more information, see the [Agent Framework documentation](https://aka.ms/agent-framework).
@@ -4,7 +4,7 @@ import asyncio
import inspect
import logging
from collections.abc import Callable, Sequence
from typing import Any
from typing import Any, Literal, cast
from agent_framework import AgentResponse, Message, SupportsAgentRun
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
@@ -18,6 +18,14 @@ from agent_framework._workflows._workflow_context import WorkflowContext
from typing_extensions import Never
from ._orchestration_request_info import AgentApprovalExecutor
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
_ParticipantOutputSpecifier, # pyright: ignore[reportPrivateUsage]
_resolve_participant_output_config, # pyright: ignore[reportPrivateUsage]
)
logger = logging.getLogger(__name__)
@@ -205,23 +213,28 @@ class ConcurrentBuilder:
*,
participants: Sequence[SupportsAgentRun | Executor],
checkpoint_storage: CheckpointStorage | None = None,
intermediate_outputs: bool = False,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the ConcurrentBuilder.
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, 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.
output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``output`` events alongside the aggregator. Pass ``"all"`` to select every
participant.
intermediate_output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant
not selected by ``output_from``. Unlisted participant outputs are hidden.
"""
self._participants: list[SupportsAgentRun | Executor] = []
self._aggregator: Executor | None = None
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
self._request_info_enabled: bool = False
self._request_info_filter: set[str] | None = None
self._intermediate_outputs: bool = intermediate_outputs
self._output_from = _coalesce_output_from(output_from=output_from)
self._intermediate_output_from = _coerce_intermediate_output_from(intermediate_output_from)
self._set_participants(participants)
@@ -396,10 +409,19 @@ class ConcurrentBuilder:
# Resolve participants and participant factories to executors
participants: list[Executor] = self._resolve_participants()
# Default: only the aggregator is terminal; participant outputs are hidden
# unless explicitly designated as terminal or intermediate.
designated, intermediate_designated = _resolve_participant_output_config(
participants=participants,
output_from=self._output_from,
intermediate_output_from=self._intermediate_output_from,
extra_output_executors=[aggregator],
)
builder = WorkflowBuilder(
start_executor=dispatcher,
checkpoint_storage=self._checkpoint_storage,
output_executors=[aggregator] if not self._intermediate_outputs else None,
output_from=designated,
intermediate_output_from=intermediate_designated,
)
# Fan-out for parallel execution
builder.add_fan_out_edges(dispatcher, participants)
@@ -27,7 +27,7 @@ import sys
from collections import OrderedDict
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from typing import Any, ClassVar, cast
from typing import Any, ClassVar, Literal, cast
from agent_framework import Agent, AgentResponse, AgentResponseUpdate, AgentSession, Message, SupportsAgentRun
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
@@ -51,6 +51,14 @@ from ._base_group_chat_orchestrator import (
)
from ._orchestration_request_info import AgentApprovalExecutor
from ._orchestrator_helpers import clean_conversation_for_handoff
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
_ParticipantOutputSpecifier, # pyright: ignore[reportPrivateUsage]
_resolve_participant_output_config, # pyright: ignore[reportPrivateUsage]
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
@@ -618,7 +626,8 @@ class GroupChatBuilder:
termination_condition: TerminationCondition | None = None,
max_rounds: int | None = None,
checkpoint_storage: CheckpointStorage | None = None,
intermediate_outputs: bool = False,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the GroupChatBuilder.
@@ -635,9 +644,12 @@ class GroupChatBuilder:
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, 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.
output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``output`` events alongside the orchestrator. Pass ``"all"`` to select every
participant.
intermediate_output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant
not selected by ``output_from``. Unlisted participant outputs are hidden.
"""
self._participants: dict[str, SupportsAgentRun | Executor] = {}
self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = []
@@ -658,7 +670,8 @@ class GroupChatBuilder:
self._request_info_enabled: bool = False
self._request_info_filter: set[str] = set()
self._intermediate_outputs: bool = intermediate_outputs
self._output_from = _coalesce_output_from(output_from=output_from)
self._intermediate_output_from = _coerce_intermediate_output_from(intermediate_output_from)
if participants is None and participant_factories is None:
raise ValueError("Either participants or participant_factories must be provided.")
@@ -1001,11 +1014,20 @@ class GroupChatBuilder:
participants: list[Executor] = self._resolve_participants()
orchestrator: Executor = self._resolve_orchestrator(participants)
# Build workflow graph
# Default: only the orchestrator is terminal; participant outputs are hidden
# unless explicitly designated as terminal or intermediate.
# `group_chat` orchestrator-progress events keep their dedicated event type.
designated, intermediate_designated = _resolve_participant_output_config(
participants=participants,
output_from=self._output_from,
intermediate_output_from=self._intermediate_output_from,
extra_output_executors=[orchestrator],
)
workflow_builder = WorkflowBuilder(
start_executor=orchestrator,
checkpoint_storage=self._checkpoint_storage,
output_executors=[orchestrator] if not self._intermediate_outputs else None,
output_from=designated,
intermediate_output_from=intermediate_designated,
)
for participant in participants:
# Orchestrator and participant bi-directional edges
@@ -36,7 +36,7 @@ import sys
from collections.abc import Awaitable, Callable, Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from typing import Any, Literal, cast
from agent_framework import Agent, AgentResponse, Message, SupportsAgentRun
from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware, MiddlewareTermination
@@ -53,6 +53,14 @@ from agent_framework._workflows._workflow_context import WorkflowContext
from ._base_group_chat_orchestrator import TerminationCondition
from ._orchestrator_helpers import clean_conversation_for_handoff
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
_ParticipantOutputSpecifier, # pyright: ignore[reportPrivateUsage]
_resolve_participant_output_config, # pyright: ignore[reportPrivateUsage]
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
@@ -377,7 +385,7 @@ class HandoffAgentExecutor(AgentExecutor):
# Append the agent response to the full conversation history. This list removes
# function call related content such that the result stays consistent regardless
# of which agent yields the final output.
# of which agent yields Workflow Output.
self._full_conversation.extend(cleaned_response)
# Broadcast only the cleaned response to other agents (without function_calls/results)
@@ -577,7 +585,7 @@ class HandoffBuilder:
Note:
1. Agents in handoff workflows must be ``Agent`` instances and support local tool calls.
2. Because each agent's response is itself a workflow output, handoff has no separate
2. Because each agent's response is itself Workflow Output, handoff has no separate
"intermediate outputs" channel — every per-agent response is the primary output.
"""
@@ -589,6 +597,8 @@ class HandoffBuilder:
description: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
termination_condition: TerminationCondition | None = None,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
r"""Initialize a HandoffBuilder for creating conversational handoff workflows.
@@ -610,6 +620,12 @@ class HandoffBuilder:
checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence.
termination_condition: Optional callable that receives the full conversation and returns True
(or awaitable True) if the workflow should terminate.
output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``output`` events. Defaults to all participants; pass ``"all"`` to select every
participant explicitly.
intermediate_output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant
not selected by ``output_from``. Unlisted participant outputs are hidden.
"""
self._name = name
self._description = description
@@ -635,6 +651,8 @@ class HandoffBuilder:
# Termination related members
self._termination_condition: Callable[[list[Message]], bool | Awaitable[bool]] | None = termination_condition
self._output_from = _coalesce_output_from(output_from=output_from)
self._intermediate_output_from = _coerce_intermediate_output_from(intermediate_output_from)
def participants(self, participants: Sequence[Agent]) -> "HandoffBuilder":
"""Register the agents that will participate in the handoff workflow.
@@ -955,11 +973,22 @@ class HandoffBuilder:
if self._start_id is None:
raise ValueError("Must call with_start_agent(...) before building the workflow.")
start_executor = executors[self._resolve_to_id(resolved_agents[self._start_id])]
# Handoff has no separate terminator: every participant's reply is a primary
# output by default. Explicit participant designation can narrow or reclassify
# selected speakers.
output, intermediate_output = _resolve_participant_output_config(
participants=list(executors.values()),
output_from=self._output_from,
intermediate_output_from=self._intermediate_output_from,
default_output_from=list(executors.values()),
)
builder = WorkflowBuilder(
name=self._name,
description=self._description,
start_executor=start_executor,
checkpoint_storage=self._checkpoint_storage,
output_from=output,
intermediate_output_from=intermediate_output,
)
# Add the appropriate edges
@@ -10,7 +10,7 @@ from abc import ABC, abstractmethod
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, ClassVar, TypeVar, cast
from typing import Any, ClassVar, Literal, TypeVar, cast
from agent_framework import (
AgentResponse,
@@ -38,6 +38,14 @@ from ._base_group_chat_orchestrator import (
GroupChatWorkflowContextOutT,
ParticipantRegistry,
)
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
_ParticipantOutputSpecifier, # pyright: ignore[reportPrivateUsage]
_resolve_participant_output_config, # pyright: ignore[reportPrivateUsage]
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
@@ -1409,7 +1417,8 @@ class MagenticBuilder:
# Existing params
enable_plan_review: bool = False,
checkpoint_storage: CheckpointStorage | None = None,
intermediate_outputs: bool = False,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the Magentic workflow builder.
@@ -1432,9 +1441,12 @@ class MagenticBuilder:
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, 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.
output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``output`` events alongside the manager. Pass ``"all"`` to select every
participant.
intermediate_output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant
not selected by ``output_from``. Unlisted participant outputs are hidden.
"""
self._participants: dict[str, SupportsAgentRun | Executor] = {}
@@ -1447,7 +1459,8 @@ class MagenticBuilder:
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
self._intermediate_outputs = intermediate_outputs
self._output_from = _coalesce_output_from(output_from=output_from)
self._intermediate_output_from = _coerce_intermediate_output_from(intermediate_output_from)
self._set_participants(participants)
@@ -1762,11 +1775,20 @@ class MagenticBuilder:
participants: list[Executor] = self._resolve_participants()
orchestrator: Executor = self._resolve_orchestrator(participants)
# Build workflow graph
# Default: only the manager is terminal; worker outputs are hidden unless
# explicitly designated as terminal or intermediate.
# `magentic_orchestrator` events keep their dedicated event type.
designated, intermediate_designated = _resolve_participant_output_config(
participants=participants,
output_from=self._output_from,
intermediate_output_from=self._intermediate_output_from,
extra_output_executors=[orchestrator],
)
workflow_builder = WorkflowBuilder(
start_executor=orchestrator,
checkpoint_storage=self._checkpoint_storage,
output_executors=[orchestrator] if not self._intermediate_outputs else None,
output_from=designated,
intermediate_output_from=intermediate_designated,
)
for participant in participants:
# Orchestrator and participant bi-directional edges
@@ -220,8 +220,14 @@ class AgentApprovalExecutor(WorkflowExecutor):
request_info_cls = _TerminalAgentRequestInfoExecutor if terminal else AgentRequestInfoExecutor
request_info_executor = request_info_cls(id="agent_request_info_executor")
# Both inner executors yield the inner workflow's terminal output (the agent
# during its turn; the _TerminalAgentRequestInfoExecutor after approval), so
# both must be designated for WorkflowExecutor.get_outputs() to surface them.
return (
WorkflowBuilder(start_executor=agent_executor)
WorkflowBuilder(
start_executor=agent_executor,
output_from=[agent_executor, request_info_executor],
)
# Create a loop between agent executor and request info executor
.add_edge(agent_executor, request_info_executor)
.add_edge(request_info_executor, agent_executor)
@@ -0,0 +1,166 @@
# Copyright (c) Microsoft. All rights reserved.
"""Participant-oriented workflow output configuration helpers."""
from collections.abc import Sequence
from typing import Any, Literal
from agent_framework import SupportsAgentRun
from agent_framework._workflows._agent_utils import resolve_agent_id
from agent_framework._workflows._executor import Executor
_MISSING = object()
_ALL_OUTPUTS: Literal["all"] = "all"
_ALL_OTHER_OUTPUTS: Literal["all_other"] = "all_other"
_ParticipantOutputSpecifier = str | SupportsAgentRun | Executor
_ParticipantOutputSelection = Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None
_ParticipantIntermediateOutputSelection = Sequence[_ParticipantOutputSpecifier] | Literal["all", "all_other"] | None
_WorkflowExecutorSpecifier = Executor | SupportsAgentRun
def _coalesce_output_from( # pyright: ignore[reportUnusedFunction]
*,
output_from: Any = _MISSING,
) -> _ParticipantOutputSelection:
"""Resolve orchestration output selection to ``output_from``."""
if output_from is not _MISSING:
return _coerce_output_from(output_from)
return None
def _coerce_output_from(output_from: Any) -> _ParticipantOutputSelection:
"""Coerce workflow-output participant selection while preserving the ``"all"`` literal."""
if output_from is None:
return None
if isinstance(output_from, str):
if output_from == _ALL_OUTPUTS:
return _ALL_OUTPUTS
if output_from == _ALL_OTHER_OUTPUTS:
raise ValueError("output_from='all_other' is invalid; use intermediate_output_from='all_other' instead.")
raise ValueError(f"Unsupported output_from literal {output_from!r}; use 'all' or a list of participants.")
return list(output_from)
def _coerce_intermediate_output_from( # pyright: ignore[reportUnusedFunction]
intermediate_output_from: Any,
) -> _ParticipantIntermediateOutputSelection:
"""Coerce intermediate-output participant selection while preserving ``"all_other"``."""
if intermediate_output_from is None:
return None
if isinstance(intermediate_output_from, str):
if intermediate_output_from == _ALL_OUTPUTS:
return _ALL_OUTPUTS
if intermediate_output_from == _ALL_OTHER_OUTPUTS:
return _ALL_OTHER_OUTPUTS
raise ValueError(
f"Unsupported intermediate_output_from literal {intermediate_output_from!r}; "
"use 'all', 'all_other', or a list of participants."
)
return list(intermediate_output_from)
def _resolve_participant_output_config( # pyright: ignore[reportUnusedFunction]
*,
participants: Sequence[Executor],
output_from: _ParticipantOutputSelection,
intermediate_output_from: _ParticipantIntermediateOutputSelection,
default_output_from: Sequence[Executor] = (),
extra_output_executors: Sequence[Executor] = (),
) -> tuple[list[_WorkflowExecutorSpecifier], list[_WorkflowExecutorSpecifier]]:
"""Resolve public participant output config into workflow executor config."""
explicit_config = output_from is not None or intermediate_output_from is not None
if explicit_config and not (output_from or intermediate_output_from):
raise ValueError("output_from and intermediate_output_from cannot both be empty.")
participants_by_id = {participant.id: participant for participant in participants}
known_participants = sorted(participants_by_id)
if output_from == _ALL_OUTPUTS:
output_designated = list(participants)
elif output_from is not None:
output_designated = _resolve_designated_participants(
output_from,
kind="output",
participants_by_id=participants_by_id,
known_participants=known_participants,
)
elif intermediate_output_from in (_ALL_OTHER_OUTPUTS, _ALL_OUTPUTS):
output_designated = []
else:
intermediate_designated = (
_resolve_designated_participants(
intermediate_output_from,
kind="intermediate",
participants_by_id=participants_by_id,
known_participants=known_participants,
)
if intermediate_output_from is not None
else []
)
# The caller-supplied default applies only to participants not explicitly designated as
# intermediate. Without this subtraction, builders that pre-populate a default output list
# (Handoff defaults to all participants, Sequential defaults to the last) would force
# an overlap error whenever a user passed `intermediate_output_from=[X]` for an X in
# the default set, contradicting the public docstring contract.
intermediate_ids = {participant.id for participant in intermediate_designated}
output_designated = [
participant for participant in default_output_from if participant.id not in intermediate_ids
]
if intermediate_output_from == _ALL_OUTPUTS:
intermediate_designated = list(participants)
elif intermediate_output_from == _ALL_OTHER_OUTPUTS:
output_ids = {participant.id for participant in output_designated}
intermediate_designated = [participant for participant in participants if participant.id not in output_ids]
elif intermediate_output_from is not None:
intermediate_designated = _resolve_designated_participants(
intermediate_output_from,
kind="intermediate",
participants_by_id=participants_by_id,
known_participants=known_participants,
)
else:
intermediate_designated = []
overlap = sorted(
{participant.id for participant in output_designated}.intersection(
participant.id for participant in intermediate_designated
)
)
if overlap:
raise ValueError(f"Participants cannot be both output and intermediate designated: {overlap}")
output_executors: list[_WorkflowExecutorSpecifier] = [*extra_output_executors, *output_designated]
intermediate_executors: list[_WorkflowExecutorSpecifier] = list(intermediate_designated)
return output_executors, intermediate_executors
def _resolve_designated_participants(
designations: Sequence[_ParticipantOutputSpecifier],
*,
kind: str,
participants_by_id: dict[str, Executor],
known_participants: Sequence[str],
) -> list[Executor]:
resolved: list[Executor] = []
seen: set[str] = set()
for designation in designations:
participant_id = _participant_id(designation)
if participant_id in seen:
raise ValueError(f"Duplicate {kind} participant '{participant_id}' in {kind}_participants.")
seen.add(participant_id)
try:
resolved.append(participants_by_id[participant_id])
except KeyError as exc:
raise ValueError(
f"Unknown {kind} participant '{participant_id}'. Known participants: {known_participants}"
) from exc
return resolved
def _participant_id(participant: _ParticipantOutputSpecifier) -> str:
if isinstance(participant, str):
return participant
if isinstance(participant, Executor):
return participant.id
return resolve_agent_id(participant)
@@ -16,7 +16,7 @@ produces — by convention an `AgentResponse` so downstream consumers see a unif
import logging
from collections.abc import Sequence
from typing import Literal
from typing import Any, Literal, cast
from agent_framework import Message, SupportsAgentRun
from agent_framework._workflows._agent_executor import AgentExecutor
@@ -32,6 +32,14 @@ from agent_framework._workflows._workflow_builder import WorkflowBuilder
from agent_framework._workflows._workflow_context import WorkflowContext
from ._orchestration_request_info import AgentApprovalExecutor
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
_ParticipantOutputSpecifier, # pyright: ignore[reportPrivateUsage]
_resolve_participant_output_config, # pyright: ignore[reportPrivateUsage]
)
logger = logging.getLogger(__name__)
@@ -60,7 +68,7 @@ class SequentialBuilder:
- The workflow wires participants in order, passing a list[Message] down the chain
- Agents append their assistant messages to the conversation
- Custom executors can transform/summarize and return a list[Message]
- The final output is the conversation produced by the last participant
- The default Workflow Output is the conversation produced by the last participant
Usage:
@@ -91,7 +99,8 @@ class SequentialBuilder:
participants: Sequence[SupportsAgentRun | Executor],
checkpoint_storage: CheckpointStorage | None = None,
chain_only_agent_responses: bool = False,
intermediate_outputs: bool = False,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the SequentialBuilder.
@@ -101,16 +110,19 @@ class SequentialBuilder:
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, 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.
output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``output`` events. Pass ``"all"`` to select every participant.
intermediate_output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant
not selected by ``output_from``. Unlisted participant outputs are hidden.
"""
self._participants: list[SupportsAgentRun | Executor] = []
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
self._chain_only_agent_responses: bool = chain_only_agent_responses
self._request_info_enabled: bool = False
self._request_info_filter: set[str] | None = None
self._intermediate_outputs: bool = intermediate_outputs
self._output_from = _coalesce_output_from(output_from=output_from)
self._intermediate_output_from = _coerce_intermediate_output_from(intermediate_output_from)
self._set_participants(participants)
@@ -225,8 +237,8 @@ class SequentialBuilder:
- 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`,
- The last participant is selected as Workflow Output by default, so the
terminator's own `yield_output` is Workflow Output (`AgentResponse`,
or per-chunk `AgentResponseUpdate` when streaming).
"""
input_conv = _InputToConversation(id="input-conversation")
@@ -234,10 +246,19 @@ class SequentialBuilder:
# Resolve participants and participant factories to executors
participants: list[Executor] = self._resolve_participants()
# Default: only the terminator is terminal. Explicit participant designation
# can surface selected earlier participant outputs as terminal or intermediate.
designated, intermediate_designated = _resolve_participant_output_config(
participants=participants,
output_from=self._output_from,
intermediate_output_from=self._intermediate_output_from,
default_output_from=[participants[-1]],
)
builder = WorkflowBuilder(
start_executor=input_conv,
checkpoint_storage=self._checkpoint_storage,
output_executors=[participants[-1]] if not self._intermediate_outputs else None,
output_from=designated,
intermediate_output_from=intermediate_designated,
)
prior: Executor | SupportsAgentRun = input_conv
@@ -630,9 +630,14 @@ class StubAssistantsAgent(BaseAgent):
async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[Message]:
captured: list[Message] = []
wf = MagenticBuilder(participants=[participant], intermediate_outputs=True, manager=InvokeOnceManager()).build()
wf = MagenticBuilder(
participants=[participant],
output_from=[participant],
manager=InvokeOnceManager(),
).build()
# Run a bounded stream to allow one invoke and then completion
# With output_from, participants are designated as outputs alongside
# the manager — so their streaming chunks surface as type='output' (not intermediate).
events: list[WorkflowEvent] = []
async for ev in wf.run("task", stream=True):
events.append(ev)
@@ -0,0 +1,749 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for orchestration intermediate vs terminal output labeling.
Verifies that under the strict-output model:
- Sequential / Concurrent / GroupChat / Magentic designate their terminator,
aggregator, orchestrator, or manager as the sole output executor; per-step
yields from non-designated executors emit `type='intermediate'` events.
- Handoff designates ALL participants — every reply is `type='output'`.
- When wrapped via `workflow.as_agent()`, caller-facing workflow events surface
with their original content types.
"""
from __future__ import annotations
from collections.abc import AsyncIterable, Awaitable, Callable
from typing import Any, ClassVar, Literal, overload
import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
AgentSession,
BaseAgent,
Content,
Message,
ResponseStream,
)
from agent_framework.orchestrations import (
ConcurrentBuilder,
GroupChatBuilder,
GroupChatState,
HandoffBuilder,
MagenticBuilder,
MagenticContext,
MagenticManagerBase,
MagenticProgressLedger,
MagenticProgressLedgerItem,
SequentialBuilder,
)
class _EchoAgent(BaseAgent):
"""Minimal non-streaming agent that returns a single assistant message."""
@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")], author_name=self.name
)
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"], author_name=self.name)])
return _run()
# ---------------------------------------------------------------------------
# Sequential
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_sequential_default_only_terminator_is_output() -> None:
"""Default Sequential designates only the terminator; earlier participants are hidden."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=[a, b, c]).build()
output_events: list[Any] = []
intermediate_events: list[Any] = []
async for event in workflow.run("hello", stream=True):
if event.type == "output":
output_events.append(event)
elif event.type == "intermediate":
intermediate_events.append(event)
# Only the terminator (C) emits type='output'.
assert len(output_events) == 1
assert "C" in {ev.executor_id for ev in output_events}
assert not intermediate_events
@pytest.mark.asyncio
async def test_sequential_output_from_designates_workflow_output_participants() -> None:
"""Sequential output_from controls which participant yields surface as workflow output."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=[a, b, c], output_from=["A", "B", "C"]).build()
result = await workflow.run("hello")
outputs = result.get_outputs()
assert len(outputs) == 3
@pytest.mark.asyncio
async def test_sequential_intermediate_output_from_surface_as_intermediate() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=[a, b, c], intermediate_output_from=[a, "B"]).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("hello", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
assert output_executors == {"C"}
assert intermediate_executors == {"A", "B"}
@pytest.mark.asyncio
async def test_sequential_intermediate_can_demote_default_terminator() -> None:
"""Regression: marking the default output terminator as intermediate must not raise an overlap error.
Sequential's default output list is `[participants[-1]]`. Before the fix, designating that
same participant via `intermediate_output_from` triggered the
"Participants cannot be both output and intermediate designated" overlap rejection in
`_participant_output_config`, contradicting the public contract that
`intermediate_output_from` can be used independently of `output_from`.
"""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=[a, b, c], intermediate_output_from=["C"]).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("hello", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
# The default-final list ([C]) is implicitly narrowed by the intermediate designation,
# so no participant surfaces as terminal output and C surfaces as intermediate.
assert output_executors == set()
assert intermediate_executors == {"C"}
@pytest.mark.asyncio
async def test_sequential_get_outputs_returns_terminator_only() -> None:
"""WorkflowRunResult.get_outputs() returns only the terminator's yield."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = SequentialBuilder(participants=[a, b]).build()
result = await workflow.run("hi")
outputs = result.get_outputs()
assert len(outputs) == 1
# ---------------------------------------------------------------------------
# Concurrent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_default_only_aggregator_is_output() -> None:
"""Default Concurrent designates only the aggregator; participants are hidden."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = ConcurrentBuilder(participants=[a, b]).build()
output_events: list[Any] = []
intermediate_events: list[Any] = []
async for event in workflow.run("hello", stream=True):
if event.type == "output":
output_events.append(event)
elif event.type == "intermediate":
intermediate_events.append(event)
# Aggregator is the only designated executor → only it emits type='output'.
assert len(output_events) == 1
assert not intermediate_events
@pytest.mark.asyncio
async def test_concurrent_output_from_designates_workflow_output_participants() -> None:
"""Concurrent output_from designates participant outputs alongside the aggregator."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = ConcurrentBuilder(participants=[a, b], output_from=[a, "B"]).build()
result = await workflow.run("hello")
outputs = result.get_outputs()
assert len(outputs) == 3
@pytest.mark.asyncio
async def test_concurrent_intermediate_output_from_surface_as_intermediate() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = ConcurrentBuilder(participants=[a, b], intermediate_output_from=["A", b]).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("hello", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
assert "aggregator" in output_executors
assert intermediate_executors == {"A", "B"}
# ---------------------------------------------------------------------------
# Sequential wrapped as_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_sequential_default_as_agent_forwards_original_content_types() -> None:
"""Default Sequential wrapped as_agent forwards original content types."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=[a, b, c]).build()
agent = workflow.as_agent("seq")
response = await agent.run("hi")
text_contents = [c for m in response.messages for c in m.contents if c.type == "text"]
reasoning_contents = [c for m in response.messages for c in m.contents if c.type == "text_reasoning"]
assert any("C reply" in c.text for c in text_contents)
assert not reasoning_contents
@pytest.mark.asyncio
async def test_sequential_as_agent_output_from_all_text() -> None:
"""output_from makes designated participant replies normal response text content."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=[a, b, c], output_from=["A", "B", "C"]).build()
agent = workflow.as_agent("seq")
response = await agent.run("hi")
text_contents = [c for m in response.messages for c in m.contents if c.type == "text"]
text = " ".join(c.text for c in text_contents)
assert "A reply" in text
assert "B reply" in text
assert "C reply" in text
@pytest.mark.asyncio
async def test_sequential_as_agent_intermediate_output_from_keeps_text_content() -> None:
"""intermediate_output_from keeps selected participant replies as their original content type."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=[a, b, c], intermediate_output_from=["A", "B"]).build()
agent = workflow.as_agent("seq")
response = await agent.run("hi")
text_contents = [c for m in response.messages for c in m.contents if c.type == "text"]
reasoning_contents = [c for m in response.messages for c in m.contents if c.type == "text_reasoning"]
assert any("C reply" in c.text for c in text_contents)
assert any("A reply" in c.text for c in text_contents)
assert any("B reply" in c.text for c in text_contents)
assert not reasoning_contents
# ---------------------------------------------------------------------------
# Concurrent wrapped as_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_default_as_agent_participants_keep_text_content() -> None:
"""Default Concurrent wrapped as_agent keeps original participant content types."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = ConcurrentBuilder(participants=[a, b]).build()
agent = workflow.as_agent("concurrent")
response = await agent.run("hi")
text_contents = [c for m in response.messages for c in m.contents if c.type == "text"]
reasoning_contents = [c for m in response.messages for c in m.contents if c.type == "text_reasoning"]
assert not any("A reply" in c.text for c in reasoning_contents)
assert not any("B reply" in c.text for c in reasoning_contents)
# The aggregator's default-yielded AgentResponse passes through as text content.
assert text_contents, "expected at least one terminal text content from the aggregator"
# ---------------------------------------------------------------------------
# GroupChat
# ---------------------------------------------------------------------------
def _two_step_selector() -> Callable[[GroupChatState], str]:
"""Selector that picks each participant once, then keeps the first to keep tests bounded."""
counter = {"n": 0}
def _select(state: GroupChatState) -> str:
participants = list(state.participants.keys())
step = counter["n"]
counter["n"] = step + 1
if step == 0:
return participants[0]
if step == 1 and len(participants) > 1:
return participants[1]
return participants[0]
return _select
@pytest.mark.asyncio
async def test_group_chat_default_only_orchestrator_is_output() -> None:
"""Default GroupChat designates only the orchestrator; participant replies are hidden."""
alpha = _EchoAgent(name="alpha")
beta = _EchoAgent(name="beta")
workflow = GroupChatBuilder(
participants=[alpha, beta],
max_rounds=2,
selection_func=_two_step_selector(),
).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("kickoff", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
assert "group_chat_orchestrator" in output_executors
assert "alpha" not in intermediate_executors
assert "beta" not in intermediate_executors
# Participants must NOT appear among designated outputs in the default contract.
assert "alpha" not in output_executors
assert "beta" not in output_executors
@pytest.mark.asyncio
async def test_group_chat_output_from_designates_workflow_output_participants() -> None:
"""GroupChat output_from designates participants alongside the orchestrator."""
alpha = _EchoAgent(name="alpha")
beta = _EchoAgent(name="beta")
workflow = GroupChatBuilder(
participants=[alpha, beta],
max_rounds=2,
selection_func=_two_step_selector(),
output_from=[alpha, "beta"],
).build()
output_executors: set[str] = set()
async for event in workflow.run("kickoff", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
assert {"group_chat_orchestrator", "alpha", "beta"}.issubset(output_executors)
@pytest.mark.asyncio
async def test_group_chat_intermediate_output_from_surface_as_intermediate() -> None:
alpha = _EchoAgent(name="alpha")
beta = _EchoAgent(name="beta")
workflow = GroupChatBuilder(
participants=[alpha, beta],
max_rounds=2,
selection_func=_two_step_selector(),
intermediate_output_from=["alpha", beta],
).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("kickoff", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
assert "group_chat_orchestrator" in output_executors
assert intermediate_executors == {"alpha", "beta"}
# ---------------------------------------------------------------------------
# Handoff
# ---------------------------------------------------------------------------
def test_handoff_builder_designates_every_participant_as_output() -> None:
"""Handoff has no intermediate channel — every participant's reply is a primary
output. The builder must designate all participants in the workflow's
output designation so each per-agent yield surfaces as type='output'.
Structural assertion (vs end-to-end) because Handoff agents require a full
chat-client/middleware stack that we don't want to reproduce in this contract test.
"""
from agent_framework import Agent
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
class _StubClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
def __init__(self) -> None:
ChatMiddlewareLayer.__init__(self)
FunctionInvocationLayer.__init__(self)
BaseChatClient.__init__(self)
def _inner_get_response(self, **kwargs: Any) -> Any: # pragma: no cover - never called
raise NotImplementedError
alpha = Agent(
name="alpha",
id="alpha",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
beta = Agent(
name="beta",
id="beta",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
workflow = HandoffBuilder(participants=[alpha, beta]).with_start_agent(alpha).build()
designated = {ex.id for ex in workflow.get_output_executors()}
assert "alpha" in designated, f"alpha must be designated; got {designated}"
assert "beta" in designated, f"beta must be designated; got {designated}"
def test_handoff_builder_output_from_can_select_workflow_output_participants() -> None:
from agent_framework import Agent
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
class _StubClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
def __init__(self) -> None:
ChatMiddlewareLayer.__init__(self)
FunctionInvocationLayer.__init__(self)
BaseChatClient.__init__(self)
def _inner_get_response(self, **kwargs: Any) -> Any: # pragma: no cover - never called
raise NotImplementedError
alpha = Agent(
name="alpha",
id="alpha",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
beta = Agent(
name="beta",
id="beta",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
workflow = HandoffBuilder(participants=[alpha, beta], output_from=["alpha"]).with_start_agent(alpha).build()
assert {ex.id for ex in workflow.get_output_executors()} == {"alpha"}
def test_handoff_builder_intermediate_output_from_demotes_from_default_output() -> None:
"""Regression: `intermediate_output_from` alone must not collide with the default output list.
Handoff defaults workflow output to every participant. Before the fix, supplying
`intermediate_output_from=["alpha"]` without restating `output_from` triggered
"Participants cannot be both output and intermediate designated: ['alpha']" because
alpha was simultaneously in the default output list and the explicit intermediate list.
The contract documented at `_handoff.py:619-622` promises `intermediate_output_from` is
usable on its own.
"""
from agent_framework import Agent
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
class _StubClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
def __init__(self) -> None:
ChatMiddlewareLayer.__init__(self)
FunctionInvocationLayer.__init__(self)
BaseChatClient.__init__(self)
def _inner_get_response(self, **kwargs: Any) -> Any: # pragma: no cover - never called
raise NotImplementedError
alpha = Agent(name="alpha", id="alpha", client=_StubClient(), require_per_service_call_history_persistence=True)
beta = Agent(name="beta", id="beta", client=_StubClient(), require_per_service_call_history_persistence=True)
workflow = (
HandoffBuilder(participants=[alpha, beta], intermediate_output_from=["alpha"]).with_start_agent(alpha).build()
)
# alpha is implicitly removed from the default-final set; beta remains final.
assert {ex.id for ex in workflow.get_output_executors()} == {"beta"}
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"alpha"}
# ---------------------------------------------------------------------------
# Magentic
# ---------------------------------------------------------------------------
class _StubMagenticManager(MagenticManagerBase):
"""Deterministic manager that finishes after one round with a fixed final answer."""
FINAL_ANSWER: ClassVar[str] = "MAGENTIC_FINAL"
def __init__(self) -> None:
super().__init__(max_stall_count=3)
self.name = "magentic_manager"
self.next_speaker_name = "alpha"
async def plan(self, magentic_context: MagenticContext) -> Message:
return Message("assistant", ["Plan: do the thing."], author_name=self.name)
async def replan(self, magentic_context: MagenticContext) -> Message:
return Message("assistant", ["Replan."], author_name=self.name)
async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger:
is_satisfied = len(magentic_context.chat_history) > 1
return MagenticProgressLedger(
is_request_satisfied=MagenticProgressLedgerItem(reason="t", answer=is_satisfied),
is_in_loop=MagenticProgressLedgerItem(reason="t", answer=False),
is_progress_being_made=MagenticProgressLedgerItem(reason="t", answer=True),
next_speaker=MagenticProgressLedgerItem(reason="t", answer=self.next_speaker_name),
instruction_or_question=MagenticProgressLedgerItem(reason="t", answer="Go."),
)
async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message:
return Message("assistant", [self.FINAL_ANSWER], author_name=self.name)
def test_magentic_builder_default_only_manager_designated() -> None:
"""Default Magentic: only the orchestrator (manager) is designated for terminal output;
participant replies surface as type='intermediate'.
Structural assertion on the workflow's output designation because exercising a Magentic
plan/replan loop end-to-end is heavy and orthogonal to this contract.
"""
manager = _StubMagenticManager()
alpha = _EchoAgent(name="alpha")
workflow = MagenticBuilder(participants=[alpha], manager=manager).build()
designated = {ex.id for ex in workflow.get_output_executors()}
assert "magentic_orchestrator" in designated, f"manager must be designated; got {designated}"
assert "alpha" not in designated, f"participant must not be designated by default; got {designated}"
def test_magentic_builder_output_from_designates_workflow_output_participants() -> None:
"""Magentic output_from designates workers alongside the orchestrator."""
manager = _StubMagenticManager()
alpha = _EchoAgent(name="alpha")
workflow = MagenticBuilder(participants=[alpha], manager=manager, output_from=["alpha"]).build()
designated = {ex.id for ex in workflow.get_output_executors()}
assert {"magentic_orchestrator", "alpha"}.issubset(designated)
def test_magentic_builder_intermediate_output_from_designates_intermediate_workers() -> None:
manager = _StubMagenticManager()
alpha = _EchoAgent(name="alpha")
workflow = MagenticBuilder(participants=[alpha], manager=manager, intermediate_output_from=[alpha]).build()
assert {ex.id for ex in workflow.get_output_executors()} == {"magentic_orchestrator"}
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"alpha"}
def test_sequential_output_from_all_selects_all_participants() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=[a, b, c], output_from="all").build()
assert {ex.id for ex in workflow.get_output_executors()} == {"A", "B", "C"}
def test_sequential_intermediate_output_from_all_other_selects_non_outputs() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(
participants=[a, b, c], output_from=["C"], intermediate_output_from="all_other"
).build()
assert {ex.id for ex in workflow.get_output_executors()} == {"C"}
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"A", "B"}
def test_sequential_all_other_with_omitted_output_from_selects_all_intermediate() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = SequentialBuilder(participants=[a, b], intermediate_output_from="all_other").build()
assert {ex.id for ex in workflow.get_output_executors()} == set()
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"A", "B"}
# ---------------------------------------------------------------------------
# Participant designation validation
# ---------------------------------------------------------------------------
def _build_sequential_with_designation(**kwargs: Any) -> None:
SequentialBuilder(participants=[_EchoAgent(name="alpha"), _EchoAgent(name="beta")], **kwargs).build()
def _build_concurrent_with_designation(**kwargs: Any) -> None:
ConcurrentBuilder(participants=[_EchoAgent(name="alpha"), _EchoAgent(name="beta")], **kwargs).build()
def _build_group_chat_with_designation(**kwargs: Any) -> None:
GroupChatBuilder(
participants=[_EchoAgent(name="alpha"), _EchoAgent(name="beta")],
max_rounds=1,
selection_func=_two_step_selector(),
**kwargs,
).build()
def _build_magentic_with_designation(**kwargs: Any) -> None:
MagenticBuilder(participants=[_EchoAgent(name="alpha")], manager=_StubMagenticManager(), **kwargs).build()
def _build_handoff_with_designation(**kwargs: Any) -> None:
from agent_framework import Agent
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
class _StubClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
def __init__(self) -> None:
ChatMiddlewareLayer.__init__(self)
FunctionInvocationLayer.__init__(self)
BaseChatClient.__init__(self)
def _inner_get_response(self, **kwargs: Any) -> Any: # pragma: no cover - never called
raise NotImplementedError
alpha = Agent(
name="alpha",
id="alpha",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
beta = Agent(
name="beta",
id="beta",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
HandoffBuilder(participants=[alpha, beta], **kwargs).with_start_agent(alpha).build()
@pytest.mark.parametrize(
"build",
[
_build_sequential_with_designation,
_build_concurrent_with_designation,
_build_group_chat_with_designation,
_build_magentic_with_designation,
_build_handoff_with_designation,
],
)
@pytest.mark.parametrize(
("kwargs", "match"),
[
({"output_from": [], "intermediate_output_from": []}, "cannot both be empty"),
({"output_from": ["alpha", "alpha"]}, "Duplicate output participant"),
({"output_from": ["alpha"], "intermediate_output_from": ["alpha"]}, "cannot be both output"),
({"output_from": ["missing"]}, "Unknown output participant"),
({"output_from": "all_other"}, "output_from='all_other'"),
],
)
def test_participant_output_config_validation(build: Callable[..., None], kwargs: dict[str, Any], match: str) -> None:
with pytest.raises(ValueError, match=match):
build(**kwargs)
@pytest.mark.parametrize(
"build",
[
_build_sequential_with_designation,
_build_concurrent_with_designation,
_build_group_chat_with_designation,
_build_magentic_with_designation,
_build_handoff_with_designation,
],
)
def test_participant_output_config_rejects_final_output_from_parameter(build: Callable[..., None]) -> None:
with pytest.raises(TypeError, match="final_output_from"):
build(final_output_from=["beta"])