mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
3ebbdb01b4
commit
3bbc81554b
@@ -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
|
||||
|
||||
+7
-1
@@ -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)
|
||||
|
||||
+166
@@ -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"])
|
||||
Reference in New Issue
Block a user