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
+1
-1
@@ -67,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **agent-framework-foundry-hosting**: Add hosted Durable Workflow support — propagate full conversation history to workflow agents and wire `Workflow.as_agent()` end-to-end via the foundry hosting layer ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-orchestrations**: [BREAKING] Standardize orchestration terminal outputs as `AgentResponse` so `Workflow.as_agent()` returns the final answer only; aligns sequential-approval (`with_request_info`) and concurrent (`intermediate_outputs=True`) flows on the same output contract ([#5301](https://github.com/microsoft/agent-framework/pull/5301))
|
||||
- **agent-framework-orchestrations**: [BREAKING] Standardize orchestration terminal outputs as `AgentResponse` so `Workflow.as_agent()` returns the final answer only; aligns sequential-approval (`with_request_info`) and concurrent participant output designation flows on the same output contract ([#5301](https://github.com/microsoft/agent-framework/pull/5301))
|
||||
- **agent-framework-core**, **agent-framework-declarative**: Preserve `Workflow.run()` shared state across calls so multi-turn `WorkflowAgent` invocations retain context, accept `list[Message]` input in the declarative start executor, and coerce `Enum` values when serializing PowerFx symbols ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
|
||||
- **dependencies**: Update workspace package dependencies and preserve `mcp[ws]` / `uvicorn[standard]` extras through override-dependencies in `/python` ([#5555](https://github.com/microsoft/agent-framework/pull/5555))
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
from agent_framework import AgentExecutor, SupportsAgentRun, Workflow, WorkflowEvent
|
||||
from agent_framework._workflows._runner_context import YieldOutputEventType
|
||||
from agent_framework_durabletask import (
|
||||
DEFAULT_MAX_POLL_RETRIES,
|
||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
@@ -307,6 +308,18 @@ class AgentFunctionApp(DFAppBase):
|
||||
async def run() -> dict[str, Any]:
|
||||
# Create runner context and shared state
|
||||
runner_context = CapturingRunnerContext()
|
||||
workflow = self.workflow
|
||||
|
||||
def classify_yielded_output(executor_id: str) -> YieldOutputEventType | None:
|
||||
if workflow is None:
|
||||
return "output"
|
||||
if workflow.is_terminal_executor(executor_id):
|
||||
return "output"
|
||||
if workflow.is_intermediate_executor(executor_id):
|
||||
return "intermediate"
|
||||
return None
|
||||
|
||||
runner_context.set_yield_output_classifier(classify_yielded_output)
|
||||
shared_state = State()
|
||||
|
||||
# Deserialize shared state values to reconstruct dataclasses/Pydantic models
|
||||
|
||||
@@ -19,6 +19,7 @@ from agent_framework import (
|
||||
WorkflowEvent,
|
||||
WorkflowMessage,
|
||||
)
|
||||
from agent_framework._workflows._runner_context import YieldOutputClassifier, YieldOutputEventType
|
||||
from agent_framework._workflows._state import State
|
||||
|
||||
|
||||
@@ -41,6 +42,7 @@ class CapturingRunnerContext(RunnerContext):
|
||||
self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {}
|
||||
self._workflow_id: str | None = None
|
||||
self._streaming: bool = False
|
||||
self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output"
|
||||
|
||||
# region Messaging
|
||||
|
||||
@@ -144,6 +146,14 @@ class CapturingRunnerContext(RunnerContext):
|
||||
"""Check if streaming mode is enabled (always False in activity context)."""
|
||||
return self._streaming
|
||||
|
||||
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
|
||||
"""Set the classifier used by WorkflowContext.yield_output()."""
|
||||
self._yield_output_classifier = classifier
|
||||
|
||||
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
|
||||
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
|
||||
return self._yield_output_classifier(executor_id)
|
||||
|
||||
# endregion Workflow Configuration
|
||||
|
||||
# region Request Info Events
|
||||
|
||||
@@ -107,7 +107,7 @@ class TestCapturingRunnerContext:
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_event_queues_event(self, context: CapturingRunnerContext) -> None:
|
||||
"""Test that add_event queues events correctly."""
|
||||
event = WorkflowEvent.output(executor_id="exec_1", data="output")
|
||||
event = WorkflowEvent("output", executor_id="exec_1", data="output")
|
||||
|
||||
await context.add_event(event)
|
||||
|
||||
@@ -120,7 +120,7 @@ class TestCapturingRunnerContext:
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_events_clears_queue(self, context: CapturingRunnerContext) -> None:
|
||||
"""Test that drain_events clears the event queue."""
|
||||
await context.add_event(WorkflowEvent.output(executor_id="e", data="test"))
|
||||
await context.add_event(WorkflowEvent("output", executor_id="e", data="test"))
|
||||
|
||||
await context.drain_events() # First drain
|
||||
events = await context.drain_events() # Second drain
|
||||
@@ -132,14 +132,14 @@ class TestCapturingRunnerContext:
|
||||
"""Test has_events returns correct boolean."""
|
||||
assert await context.has_events() is False
|
||||
|
||||
await context.add_event(WorkflowEvent.output(executor_id="e", data="test"))
|
||||
await context.add_event(WorkflowEvent("output", executor_id="e", data="test"))
|
||||
|
||||
assert await context.has_events() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_next_event_waits_for_event(self, context: CapturingRunnerContext) -> None:
|
||||
"""Test that next_event returns queued events."""
|
||||
event = WorkflowEvent.output(executor_id="e", data="waited")
|
||||
event = WorkflowEvent("output", executor_id="e", data="waited")
|
||||
await context.add_event(event)
|
||||
|
||||
result = await context.next_event()
|
||||
@@ -171,7 +171,7 @@ class TestCapturingRunnerContext:
|
||||
async def test_reset_for_new_run_clears_state(self, context: CapturingRunnerContext) -> None:
|
||||
"""Test that reset_for_new_run clears all state."""
|
||||
await context.send_message(WorkflowMessage(data="test", target_id="t", source_id="s"))
|
||||
await context.add_event(WorkflowEvent.output(executor_id="e", data="event"))
|
||||
await context.add_event(WorkflowEvent("output", executor_id="e", data="event"))
|
||||
context.set_streaming(True)
|
||||
|
||||
context.reset_for_new_run()
|
||||
|
||||
@@ -79,7 +79,14 @@ agent_framework/
|
||||
### Workflows (`_workflows/`)
|
||||
|
||||
- **`Workflow`** - Graph-based workflow definition
|
||||
- **`WorkflowBuilder`** - Fluent API for building workflows
|
||||
- **`WorkflowBuilder`** - Fluent API for building workflows, including explicit
|
||||
`output_from` / `intermediate_output_from` selection for caller-facing emissions. `output_from`
|
||||
is an allow-list for **Workflow Output**; unselected executor payloads are hidden unless
|
||||
`intermediate_output_from` selects them as **Intermediate Output**. Use `output_from="all"` for
|
||||
explicit all-output behavior and `intermediate_output_from="all_other"` for visible progress from
|
||||
every output-capable executor not selected by `output_from`.
|
||||
- **`WorkflowRunResult`** - Non-streaming workflow result with Workflow Output `get_outputs()`
|
||||
and Intermediate Output `get_intermediate_outputs()` accessors
|
||||
- **Orchestrators**: `SequentialOrchestrator`, `ConcurrentOrchestrator`, `GroupChatOrchestrator`, `MagenticOrchestrator`, `HandoffOrchestrator`
|
||||
|
||||
## Built-in Providers
|
||||
|
||||
@@ -32,6 +32,7 @@ from .._types import (
|
||||
from ..exceptions import AgentInvalidRequestException, AgentInvalidResponseException
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._events import (
|
||||
AGENT_FORWARDED_EVENT_TYPES,
|
||||
WorkflowEvent,
|
||||
)
|
||||
from ._message_utils import normalize_messages_input
|
||||
@@ -104,7 +105,7 @@ class WorkflowAgent(BaseAgent):
|
||||
Note:
|
||||
Only output events (type='output') and request_info events (type='request_info') from
|
||||
the workflow are considered and converted to agent responses of the WorkflowAgent.
|
||||
Other workflow events are ignored. Use `with_output_from` in WorkflowBuilder to control
|
||||
Other workflow events are ignored. Use `output_from` in WorkflowBuilder to control
|
||||
which executors' outputs are surfaced as agent responses.
|
||||
"""
|
||||
if id is None:
|
||||
@@ -300,7 +301,7 @@ class WorkflowAgent(BaseAgent):
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
if event.type == "output" or event.type == "request_info":
|
||||
if event.type in AGENT_FORWARDED_EVENT_TYPES:
|
||||
output_events.append(event)
|
||||
|
||||
result = self._convert_workflow_events_to_agent_response(response_id, output_events)
|
||||
@@ -514,7 +515,11 @@ class WorkflowAgent(BaseAgent):
|
||||
response_id: str,
|
||||
output_events: list[WorkflowEvent[Any]],
|
||||
) -> AgentResponse:
|
||||
"""Convert a list of workflow output events to an AgentResponse."""
|
||||
"""Convert a list of workflow events to an AgentResponse.
|
||||
|
||||
Caller-facing workflow events are forwarded as agent messages. Terminal and
|
||||
intermediate event payloads keep their original content types.
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
raw_representations: list[object] = []
|
||||
merged_usage: UsageDetails | None = None
|
||||
@@ -535,14 +540,19 @@ class WorkflowAgent(BaseAgent):
|
||||
raw_representations.append(output_event)
|
||||
else:
|
||||
data = output_event.data
|
||||
# Anything that isn't `output` is intermediate — this branch only sees
|
||||
# events that already passed the lifecycle filter and weren't request_info.
|
||||
is_intermediate = output_event.type != "output"
|
||||
|
||||
if isinstance(data, AgentResponseUpdate):
|
||||
# We cannot support AgentResponseUpdate in non-streaming mode. This is because the message
|
||||
# sequence cannot be guaranteed when there are streaming updates in between non-streaming
|
||||
# responses.
|
||||
# AgentResponseUpdate is a streaming-only payload. Accepting it
|
||||
# in non-streaming runs would make message ordering depend on
|
||||
# partial chunks for both terminal and intermediate events.
|
||||
event_label = "Intermediate" if is_intermediate else "Output"
|
||||
raise AgentInvalidRequestException(
|
||||
"Output event with AgentResponseUpdate data cannot be emitted in non-streaming mode. "
|
||||
"Please ensure executors emit AgentResponse for non-streaming workflows."
|
||||
f"{event_label} event with AgentResponseUpdate data cannot be emitted "
|
||||
"in non-streaming mode. Please ensure executors emit AgentResponse "
|
||||
"for non-streaming workflows."
|
||||
)
|
||||
|
||||
if isinstance(data, AgentResponse):
|
||||
@@ -626,16 +636,21 @@ class WorkflowAgent(BaseAgent):
|
||||
) -> list[AgentResponseUpdate]:
|
||||
"""Convert a workflow event to a list of AgentResponseUpdate objects.
|
||||
|
||||
Events with type='output' and type='request_info' are processed.
|
||||
Other workflow events are ignored as they are workflow-internal.
|
||||
Forwarding rule:
|
||||
|
||||
For 'output' events, AgentExecutor yields AgentResponseUpdate for streaming updates
|
||||
via ctx.yield_output(). This method converts those to agent response updates.
|
||||
|
||||
Returns:
|
||||
A list of AgentResponseUpdate objects. Empty list if the event is not relevant.
|
||||
- ``type='output'`` — terminal user-facing emission. Forwarded as-is.
|
||||
- ``type='intermediate'`` (and the deprecated ``type='data'``) — forwarded
|
||||
as-is.
|
||||
- ``type='request_info'`` — request-info translation (unchanged).
|
||||
- Everything else (lifecycle, diagnostics, executor bookkeeping,
|
||||
orchestration-internal events like ``group_chat``/``handoff_sent``/
|
||||
``magentic_orchestrator``) is dropped.
|
||||
"""
|
||||
if event.type == "output":
|
||||
# TODO(evmattso): https://github.com/microsoft/agent-framework/issues/5885
|
||||
if event.type not in AGENT_FORWARDED_EVENT_TYPES:
|
||||
return []
|
||||
|
||||
if event.type != "request_info":
|
||||
data = event.data
|
||||
executor_id = event.executor_id
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class AgentExecutor(Executor):
|
||||
- run(stream=True): Emits incremental output events (type='output') as the agent produces tokens
|
||||
- run(): Emits a single output event (type='output') containing the complete response
|
||||
|
||||
Use `with_output_from` in WorkflowBuilder to control whether the AgentResponse
|
||||
Use `output_from` in WorkflowBuilder to control whether the AgentResponse
|
||||
or AgentResponseUpdate objects are yielded as workflow outputs.
|
||||
|
||||
Messages sent to downstream executors will always be the complete AgentResponse. In
|
||||
@@ -478,7 +478,7 @@ class AgentExecutor(Executor):
|
||||
|
||||
# Prefer stream finalization when available so result hooks run
|
||||
# (e.g., thread conversation updates). Fall back to reconstructing from updates
|
||||
# for legacy/custom agents that return a plain async iterable.
|
||||
# for compatibility/custom agents that return a plain async iterable.
|
||||
# TODO(evmattso): Integrate workflow agent run handling around ResponseStream so
|
||||
# AgentExecutor does not need this conditional stream-finalization branch.
|
||||
maybe_get_final_response = getattr(stream, "get_final_response", None)
|
||||
|
||||
@@ -38,7 +38,12 @@ class EdgeRunner(ABC):
|
||||
self._executors = executors
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
|
||||
async def send_message(
|
||||
self,
|
||||
message: WorkflowMessage,
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
) -> bool:
|
||||
"""Send a message through the edge group.
|
||||
|
||||
Args:
|
||||
@@ -90,7 +95,12 @@ class SingleEdgeRunner(EdgeRunner):
|
||||
super().__init__(edge_group, executors)
|
||||
self._edge = edge_group.edges[0]
|
||||
|
||||
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
|
||||
async def send_message(
|
||||
self,
|
||||
message: WorkflowMessage,
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
) -> bool:
|
||||
"""Send a message through the single edge."""
|
||||
should_execute = False
|
||||
target_id: str | None = None
|
||||
@@ -162,7 +172,12 @@ class FanOutEdgeRunner(EdgeRunner):
|
||||
Callable[[Any, list[str]], list[str]] | None, getattr(edge_group, "selection_func", None)
|
||||
)
|
||||
|
||||
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
|
||||
async def send_message(
|
||||
self,
|
||||
message: WorkflowMessage,
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
) -> bool:
|
||||
"""Send a message through all edges in the fan-out edge group."""
|
||||
deliverable_edges: list[Edge] = []
|
||||
single_target_edge: Edge | None = None
|
||||
@@ -253,7 +268,11 @@ class FanOutEdgeRunner(EdgeRunner):
|
||||
# Execute outside the span
|
||||
if single_target_edge:
|
||||
await self._execute_on_target(
|
||||
single_target_edge.target_id, [single_target_edge.source_id], message, state, ctx
|
||||
single_target_edge.target_id,
|
||||
[single_target_edge.source_id],
|
||||
message,
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -285,7 +304,12 @@ class FanInEdgeRunner(EdgeRunner):
|
||||
# Key is the source executor ID, value is a list of messages
|
||||
self._buffer: dict[str, list[WorkflowMessage]] = defaultdict(list)
|
||||
|
||||
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
|
||||
async def send_message(
|
||||
self,
|
||||
message: WorkflowMessage,
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
) -> bool:
|
||||
"""Send a message through all edges in the fan-in edge group."""
|
||||
execution_data: dict[str, Any] | None = None
|
||||
with create_edge_group_processing_span(
|
||||
@@ -362,7 +386,11 @@ class FanInEdgeRunner(EdgeRunner):
|
||||
# Execute outside the span if needed
|
||||
if execution_data:
|
||||
await self._execute_on_target(
|
||||
execution_data["target_id"], execution_data["source_ids"], execution_data["message"], state, ctx
|
||||
execution_data["target_id"],
|
||||
execution_data["source_ids"],
|
||||
execution_data["message"],
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import builtins
|
||||
import sys
|
||||
import traceback as _traceback
|
||||
import warnings
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
@@ -106,8 +107,9 @@ WorkflowEventType = Literal[
|
||||
"status", # Workflow state changed (use .state)
|
||||
"failed", # Workflow terminated with error (use .details)
|
||||
# Data events
|
||||
"output", # Executor yielded final output (use .executor_id, .data)
|
||||
"data", # Executor emitted data during execution (use .executor_id, .data)
|
||||
"output", # Executor yielded final terminal output (use .executor_id, .data)
|
||||
"intermediate", # Executor emitted intermediate (non-terminal) output (use .executor_id, .data)
|
||||
"data", # DEPRECATED — compatibility alias for intermediate emissions; use type='intermediate' instead.
|
||||
# Request events (human-in-the-loop)
|
||||
"request_info", # Executor requests external info (use .request_id, .source_executor_id)
|
||||
# Diagnostic events (warnings/errors from user code)
|
||||
@@ -128,21 +130,34 @@ WorkflowEventType = Literal[
|
||||
]
|
||||
|
||||
|
||||
# Event types forwarded across the ``workflow.as_agent()`` boundary. Anything not
|
||||
# in this set — lifecycle events, diagnostics, executor bookkeeping, and
|
||||
# orchestration-internal events (``group_chat``, ``handoff_sent``,
|
||||
# ``magentic_orchestrator``) — stays inside the workflow and is not surfaced to
|
||||
# agent callers. Internal to the ``_workflows`` package.
|
||||
AGENT_FORWARDED_EVENT_TYPES: frozenset[str] = frozenset({
|
||||
"output",
|
||||
"intermediate",
|
||||
"data", # deprecated alias for intermediate; retained for backward compat
|
||||
"request_info",
|
||||
})
|
||||
|
||||
|
||||
class WorkflowEvent(Generic[DataT]):
|
||||
"""Unified event for all workflow emissions.
|
||||
|
||||
This single generic class handles all workflow events through a `type` discriminator,
|
||||
following the same pattern as the `Content` class.
|
||||
|
||||
Use factory methods for convenient construction:
|
||||
Use factory methods for convenient construction of lifecycle, diagnostic, request,
|
||||
and executor bookkeeping events. Workflow ``output`` and ``intermediate`` events
|
||||
are emitted by ``ctx.yield_output(...)`` based on workflow output selection.
|
||||
|
||||
- `WorkflowEvent.started()` - workflow run began
|
||||
- `WorkflowEvent.status(state)` - workflow state changed
|
||||
- `WorkflowEvent.failed(details)` - workflow terminated with error
|
||||
- `WorkflowEvent.warning(message)` - warning from user code
|
||||
- `WorkflowEvent.error(exception)` - error from user code
|
||||
- `WorkflowEvent.output(executor_id, data)` - executor yielded final output
|
||||
- `WorkflowEvent.data(executor_id, data)` - executor emitted data (e.g., AgentResponse)
|
||||
- `WorkflowEvent.request_info(...)` - executor requests external info
|
||||
- `WorkflowEvent.superstep_started(iteration)` - superstep began
|
||||
- `WorkflowEvent.superstep_completed(iteration)` - superstep ended
|
||||
@@ -158,14 +173,13 @@ class WorkflowEvent(Generic[DataT]):
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
# Create events via factory methods
|
||||
# Create lifecycle events via factory methods
|
||||
started = WorkflowEvent.started()
|
||||
status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
|
||||
output = WorkflowEvent.output("agent1", result_data)
|
||||
|
||||
# Emit typed data from executor
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent.data("agent1", response)
|
||||
data: AgentResponse = event.data # Type-safe access
|
||||
# Type-safe access to event data
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent("data", executor_id="agent1", data=response)
|
||||
data: AgentResponse = event.data
|
||||
|
||||
# Check event type
|
||||
if event.type == "status":
|
||||
@@ -263,18 +277,20 @@ class WorkflowEvent(Generic[DataT]):
|
||||
"""Create an 'error' event from user code."""
|
||||
return WorkflowEvent("error", data=exception)
|
||||
|
||||
@classmethod
|
||||
def output(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
|
||||
"""Create an 'output' event when an executor yields final output."""
|
||||
return cls("output", executor_id=executor_id, data=data)
|
||||
|
||||
@classmethod
|
||||
def emit(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'data' event when an executor emits data during execution.
|
||||
"""Create a 'data' event (deprecated alias for intermediate emissions).
|
||||
|
||||
This is the primary method for executors to emit typed data
|
||||
(e.g., AgentResponse, AgentResponseUpdate, custom data).
|
||||
.. deprecated::
|
||||
Use ``ctx.yield_output(...)`` and configure ``intermediate_output_from`` instead.
|
||||
Will be removed in a future major release along with the ``type='data'`` event variant.
|
||||
"""
|
||||
warnings.warn(
|
||||
"WorkflowEvent.emit() / type='data' are deprecated; use ctx.yield_output() from an "
|
||||
"intermediate-designated executor. Will be removed in a future major release.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return cls("data", executor_id=executor_id, data=data)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -982,7 +982,8 @@ class FunctionalWorkflow:
|
||||
|
||||
# Emit the return value as the workflow output.
|
||||
if return_value is not None:
|
||||
await ctx.add_event(WorkflowEvent.output(self.name, return_value))
|
||||
with _framework_event_origin():
|
||||
await ctx.add_event(WorkflowEvent("output", executor_id=self.name, data=return_value))
|
||||
|
||||
# Persist step cache for response-only replay
|
||||
self._last_step_cache = dict(ctx._step_cache)
|
||||
|
||||
@@ -4,10 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from copy import copy
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol, TypeVar, runtime_checkable
|
||||
from typing import Any, Literal, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
|
||||
from ._const import INTERNAL_SOURCE_ID
|
||||
@@ -18,6 +19,8 @@ from ._typing_utils import is_instance_of
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
YieldOutputEventType = Literal["output", "intermediate"]
|
||||
YieldOutputClassifier = Callable[[str], YieldOutputEventType | None]
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
@@ -263,6 +266,14 @@ class RunnerContext(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
|
||||
"""Set the classifier used by WorkflowContext.yield_output()."""
|
||||
...
|
||||
|
||||
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
|
||||
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
|
||||
...
|
||||
|
||||
|
||||
class InProcRunnerContext:
|
||||
"""In-process execution context for local execution and optional checkpointing."""
|
||||
@@ -286,6 +297,7 @@ class InProcRunnerContext:
|
||||
|
||||
# Streaming flag - set by workflow's run(..., stream=True) vs run(..., stream=False)
|
||||
self._streaming: bool = False
|
||||
self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output"
|
||||
|
||||
# region Messaging and Events
|
||||
async def send_message(self, message: WorkflowMessage) -> None:
|
||||
@@ -480,3 +492,11 @@ class InProcRunnerContext:
|
||||
A dictionary mapping request IDs to their corresponding WorkflowEvent (type='request_info').
|
||||
"""
|
||||
return dict(self._pending_request_info_events)
|
||||
|
||||
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
|
||||
"""Set the classifier used by WorkflowContext.yield_output()."""
|
||||
self._yield_output_classifier = classifier
|
||||
|
||||
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
|
||||
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
|
||||
return self._yield_output_classifier(executor_id)
|
||||
|
||||
@@ -104,6 +104,7 @@ class WorkflowGraphValidator:
|
||||
executors: dict[str, Executor],
|
||||
start_executor: Executor,
|
||||
output_executors: list[str],
|
||||
intermediate_executors: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Validate the entire workflow graph.
|
||||
|
||||
@@ -112,6 +113,7 @@ class WorkflowGraphValidator:
|
||||
executors: Map of executor IDs to executor instances
|
||||
start_executor: The starting executor
|
||||
output_executors: List of output executor IDs
|
||||
intermediate_executors: List of intermediate executor IDs
|
||||
|
||||
Raises:
|
||||
WorkflowValidationError: If any validation fails
|
||||
@@ -158,7 +160,7 @@ class WorkflowGraphValidator:
|
||||
self._validate_graph_connectivity(start_executor.id)
|
||||
self._validate_self_loops()
|
||||
self._validate_dead_ends()
|
||||
self._output_validation(output_executors)
|
||||
self._output_validation(output_executors, intermediate_executors or [])
|
||||
|
||||
def _validate_handler_output_annotations(self) -> None:
|
||||
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
|
||||
@@ -356,8 +358,15 @@ class WorkflowGraphValidator:
|
||||
|
||||
# region Output Validation
|
||||
|
||||
def _output_validation(self, output_executors: list[str]) -> None:
|
||||
"""Validate that output executors exist in the workflow and have the correct workflow context annotations."""
|
||||
def _output_validation(self, output_executors: list[str], intermediate_executors: list[str]) -> None:
|
||||
"""Validate that designated executors exist and have workflow output annotations."""
|
||||
overlap = sorted(set(output_executors).intersection(intermediate_executors))
|
||||
if overlap:
|
||||
raise WorkflowValidationError(
|
||||
f"Executors cannot be both output and intermediate designated: {overlap}",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
for output_id in output_executors:
|
||||
if output_id not in self._executors:
|
||||
raise WorkflowValidationError(
|
||||
@@ -372,6 +381,20 @@ class WorkflowGraphValidator:
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
for intermediate_id in intermediate_executors:
|
||||
if intermediate_id not in self._executors:
|
||||
raise WorkflowValidationError(
|
||||
f"Intermediate executor '{intermediate_id}' is not present in the workflow graph",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
intermediate_executor = self._executors[intermediate_id]
|
||||
if not intermediate_executor.workflow_output_types:
|
||||
raise WorkflowValidationError(
|
||||
f"Intermediate executor '{intermediate_id}' must have output type annotations defined.",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Additional Validation Scenarios
|
||||
@@ -415,6 +438,7 @@ def validate_workflow_graph(
|
||||
executors: dict[str, Executor],
|
||||
start_executor: Executor,
|
||||
output_executors: list[str],
|
||||
intermediate_executors: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Convenience function to validate a workflow graph.
|
||||
|
||||
@@ -423,6 +447,7 @@ def validate_workflow_graph(
|
||||
executors: Map of executor IDs to executor instances
|
||||
start_executor: The starting executor instance
|
||||
output_executors: List of output executor IDs
|
||||
intermediate_executors: List of intermediate executor IDs
|
||||
|
||||
Raises:
|
||||
WorkflowValidationError: If any validation fails
|
||||
@@ -433,4 +458,5 @@ def validate_workflow_graph(
|
||||
executors,
|
||||
start_executor,
|
||||
output_executors,
|
||||
intermediate_executors,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,9 @@ import json
|
||||
import logging
|
||||
import types
|
||||
import uuid
|
||||
import warnings
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
|
||||
from .._sessions import ContextProvider
|
||||
@@ -34,6 +36,7 @@ from ._runner import Runner
|
||||
from ._runner_context import RunnerContext
|
||||
from ._state import State
|
||||
from ._typing_utils import is_instance_of, try_coerce_to_type
|
||||
from ._validation import ValidationTypeEnum, WorkflowValidationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._agent import WorkflowAgent
|
||||
@@ -41,6 +44,60 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_MISSING: Any = object()
|
||||
|
||||
|
||||
def _coalesce_renamed_kwarg(old_name: str, old_value: Any, new_name: str, new_value: Any) -> Any:
|
||||
"""Resolve a renamed keyword argument while keeping the deprecated name working.
|
||||
|
||||
Pass ``_MISSING`` (not ``None``) for the value that was not supplied — ``None`` is
|
||||
a legitimate user-supplied value for these kwargs.
|
||||
"""
|
||||
old_supplied = old_value is not _MISSING
|
||||
new_supplied = new_value is not _MISSING
|
||||
if old_supplied and new_supplied:
|
||||
raise TypeError(f"Cannot pass both `{old_name}` (deprecated) and `{new_name}`; use `{new_name}` only.")
|
||||
if old_supplied:
|
||||
warnings.warn(
|
||||
f"`{old_name}` is deprecated and will be removed in a future version; use `{new_name}` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return old_value
|
||||
if new_supplied:
|
||||
return new_value
|
||||
return None
|
||||
|
||||
|
||||
def _coalesce_output_from_kwarg(
|
||||
output_from: Any,
|
||||
output_executors: Any,
|
||||
) -> Any:
|
||||
"""Resolve output-selection aliases to canonical ``output_from``."""
|
||||
supplied = [
|
||||
name
|
||||
for name, value in (
|
||||
("output_from", output_from),
|
||||
("output_executors", output_executors),
|
||||
)
|
||||
if value is not _MISSING
|
||||
]
|
||||
if len(supplied) > 1:
|
||||
formatted = ", ".join(f"`{name}`" for name in supplied)
|
||||
raise TypeError(f"Cannot pass multiple workflow output selection parameters ({formatted}); use `output_from`.")
|
||||
|
||||
if output_executors is not _MISSING:
|
||||
warnings.warn(
|
||||
"`output_executors` is deprecated and will be removed in a future version; use `output_from` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return output_executors
|
||||
if output_from is not _MISSING:
|
||||
return output_from
|
||||
return None
|
||||
|
||||
|
||||
class WorkflowRunResult(list[WorkflowEvent]):
|
||||
"""Container for events generated during non-streaming workflow execution.
|
||||
|
||||
@@ -73,6 +130,14 @@ class WorkflowRunResult(list[WorkflowEvent]):
|
||||
"""
|
||||
return [event.data for event in self if event.type == "output"]
|
||||
|
||||
def get_intermediate_outputs(self) -> list[Any]:
|
||||
"""Get all intermediate outputs from the workflow run result.
|
||||
|
||||
Returns:
|
||||
A list of intermediate outputs produced by the workflow during its execution.
|
||||
"""
|
||||
return [event.data for event in self if event.type == "intermediate"]
|
||||
|
||||
def get_request_info_events(self) -> list[WorkflowEvent[Any]]:
|
||||
"""Get all request info events from the workflow run result.
|
||||
|
||||
@@ -102,6 +167,42 @@ class WorkflowRunResult(list[WorkflowEvent]):
|
||||
# region Workflow
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputDesignation:
|
||||
"""Immutable rule for labeling executor yields as terminal, intermediate, or hidden outputs.
|
||||
|
||||
``outputs`` is ``None`` in omitted-selection compatibility mode (every yield is terminal). In explicit mode,
|
||||
``outputs`` and ``intermediates`` are disjoint executor ID sets; unlisted executor
|
||||
yields are hidden from caller-facing output/intermediate events.
|
||||
Package-internal value type owned by ``Workflow``; not exported from ``agent_framework``.
|
||||
"""
|
||||
|
||||
outputs: frozenset[str] | None = field(default=None)
|
||||
intermediates: frozenset[str] = field(default_factory=lambda: frozenset[str]())
|
||||
|
||||
def is_terminal(self, executor_id: str) -> bool:
|
||||
"""Return True when ``executor_id``'s yields should be labeled type='output'."""
|
||||
if self.outputs is None:
|
||||
return True
|
||||
return executor_id in self.outputs
|
||||
|
||||
def is_intermediate(self, executor_id: str) -> bool:
|
||||
"""Return True when ``executor_id``'s yields should be labeled type='intermediate'."""
|
||||
if self.outputs is None:
|
||||
return False
|
||||
return executor_id in self.intermediates
|
||||
|
||||
def classify(self, executor_id: str) -> Literal["output", "intermediate"] | None:
|
||||
"""Return the workflow event type for this executor's yield, or None when hidden."""
|
||||
if self.outputs is None:
|
||||
return "output"
|
||||
if executor_id in self.outputs:
|
||||
return "output"
|
||||
if executor_id in self.intermediates:
|
||||
return "intermediate"
|
||||
return None
|
||||
|
||||
|
||||
class Workflow(DictConvertible):
|
||||
"""A graph-based execution engine that orchestrates connected executors.
|
||||
|
||||
@@ -182,7 +283,11 @@ class Workflow(DictConvertible):
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
output_executors: list[str] | None = None,
|
||||
output_from: list[str] | None = _MISSING,
|
||||
intermediate_output_from: list[str] | None = _MISSING,
|
||||
*,
|
||||
output_executors: list[str] | None = _MISSING,
|
||||
intermediate_executors: list[str] | None = _MISSING,
|
||||
):
|
||||
"""Initialize the workflow with a list of edges.
|
||||
|
||||
@@ -198,9 +303,21 @@ class Workflow(DictConvertible):
|
||||
better observability and management.
|
||||
description: Optional description of what the workflow does. If the workflow is built using
|
||||
WorkflowBuilder, this will be the description of the builder.
|
||||
output_executors: Optional list of executor IDs whose outputs will be considered workflow outputs.
|
||||
If None or empty, all executor outputs are treated as workflow outputs.
|
||||
output_from: List of executor IDs designated as workflow outputs, or
|
||||
``None`` for omitted-selection compatibility behavior when ``intermediate_output_from`` is also
|
||||
``None``.
|
||||
intermediate_output_from: List of executor IDs designated as intermediate outputs.
|
||||
In explicit designation mode, unlisted executor yields are hidden from
|
||||
caller-facing output/intermediate events.
|
||||
output_executors: Deprecated alias for ``output_from``. Will be removed
|
||||
in a future version.
|
||||
intermediate_executors: Deprecated alias for ``intermediate_output_from``. Will be
|
||||
removed in a future version.
|
||||
"""
|
||||
output_from = _coalesce_output_from_kwarg(output_from, output_executors)
|
||||
intermediate_output_from = _coalesce_renamed_kwarg(
|
||||
"intermediate_executors", intermediate_executors, "intermediate_output_from", intermediate_output_from
|
||||
)
|
||||
self.edge_groups = list(edge_groups)
|
||||
self.executors = dict(executors)
|
||||
self.start_executor_id = start_executor.id
|
||||
@@ -215,12 +332,20 @@ class Workflow(DictConvertible):
|
||||
self.graph_signature = self._compute_graph_signature()
|
||||
self.graph_signature_hash = self._hash_graph_signature(self.graph_signature)
|
||||
|
||||
# Output events (WorkflowEvent with type='output') from these executors are treated as workflow outputs.
|
||||
# If None or empty, all executor outputs are considered workflow outputs.
|
||||
self._output_executors = list(output_executors) if output_executors else list(self.executors.keys())
|
||||
# Single value type encodes omitted-selection compatibility vs explicit output-designation policy.
|
||||
output_designation_ids = (
|
||||
frozenset(output_from)
|
||||
if output_from is not None
|
||||
else (frozenset[str]() if intermediate_output_from is not None else None)
|
||||
)
|
||||
self._output_designation: OutputDesignation = OutputDesignation(
|
||||
outputs=output_designation_ids,
|
||||
intermediates=frozenset(intermediate_output_from or []),
|
||||
)
|
||||
|
||||
# Store non-serializable runtime objects as private attributes
|
||||
self._runner_context = runner_context
|
||||
self._runner_context.set_yield_output_classifier(self._output_designation.classify)
|
||||
self._state = State()
|
||||
self._runner: Runner = Runner(
|
||||
self.edge_groups,
|
||||
@@ -254,7 +379,12 @@ class Workflow(DictConvertible):
|
||||
"max_iterations": self.max_iterations,
|
||||
"edge_groups": [group.to_dict() for group in self.edge_groups],
|
||||
"executors": {executor_id: executor.to_dict() for executor_id, executor in self.executors.items()},
|
||||
"output_executors": self._output_executors,
|
||||
"output_executors": (
|
||||
sorted(self._output_designation.outputs) if self._output_designation.outputs is not None else None
|
||||
),
|
||||
"intermediate_executors": (
|
||||
sorted(self._output_designation.intermediates) if self._output_designation.outputs is not None else None
|
||||
),
|
||||
}
|
||||
|
||||
if self.description is not None:
|
||||
@@ -289,8 +419,44 @@ class Workflow(DictConvertible):
|
||||
return self.executors[self.start_executor_id]
|
||||
|
||||
def get_output_executors(self) -> list[Executor]:
|
||||
"""Get the list of output executors in the workflow."""
|
||||
return [self.executors[executor_id] for executor_id in self._output_executors]
|
||||
"""Get the list of output executors in the workflow.
|
||||
|
||||
In omitted-selection compatibility mode (no explicit ``output_from``), returns every
|
||||
executor in the workflow. In explicit mode, returns only the designated output executors.
|
||||
"""
|
||||
designated = self._output_designation.outputs
|
||||
if designated is None:
|
||||
return list(self.executors.values())
|
||||
return [self._get_designated_executor(executor_id, kind="Output") for executor_id in designated]
|
||||
|
||||
def get_intermediate_executors(self) -> list[Executor]:
|
||||
"""Get the list of intermediate executors in the workflow."""
|
||||
return [
|
||||
self._get_designated_executor(executor_id, kind="Intermediate")
|
||||
for executor_id in self._output_designation.intermediates
|
||||
]
|
||||
|
||||
def _get_designated_executor(self, executor_id: str, *, kind: str) -> Executor:
|
||||
try:
|
||||
return self.executors[executor_id]
|
||||
except KeyError as exc:
|
||||
raise WorkflowValidationError(
|
||||
f"{kind} executor '{executor_id}' is not present in the workflow graph",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
) from exc
|
||||
|
||||
def is_terminal_executor(self, executor_id: str) -> bool:
|
||||
"""Return True when ``executor_id``'s yields are labeled type='output'.
|
||||
|
||||
Public read-only predicate over the workflow's output designation. External
|
||||
observers (e.g., orchestration tests, DevUI mappers) should consult this rather
|
||||
than re-encoding the rule as a set-membership check.
|
||||
"""
|
||||
return self._output_designation.is_terminal(executor_id)
|
||||
|
||||
def is_intermediate_executor(self, executor_id: str) -> bool:
|
||||
"""Return True when ``executor_id``'s yields are labeled type='intermediate'."""
|
||||
return self._output_designation.is_intermediate(executor_id)
|
||||
|
||||
def get_executors_list(self) -> list[Executor]:
|
||||
"""Get the list of executors in the workflow."""
|
||||
@@ -631,8 +797,6 @@ class Workflow(DictConvertible):
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
if event.type == "output" and not self._should_yield_output_event(event):
|
||||
continue
|
||||
if event.type == "request_info" and event.request_id in (responses or {}):
|
||||
# Don't yield request_info events for which we have responses to send -
|
||||
# these are considered "handled". This prevents the caller from seeing
|
||||
@@ -825,22 +989,6 @@ class Workflow(DictConvertible):
|
||||
)
|
||||
return {GLOBAL_KWARGS_KEY: dict(kwargs)}
|
||||
|
||||
def _should_yield_output_event(self, event: WorkflowEvent[Any]) -> bool:
|
||||
"""Determine if an output event should be yielded as a workflow output.
|
||||
|
||||
Args:
|
||||
event: The WorkflowEvent with type='output' to evaluate.
|
||||
|
||||
Returns:
|
||||
True if the event should be yielded as a workflow output, False otherwise.
|
||||
"""
|
||||
# If no specific output executors are defined, yield all outputs
|
||||
if not self._output_executors:
|
||||
return True
|
||||
|
||||
# Check if the event's source executor is in the list of output executors
|
||||
return event.executor_id in self._output_executors
|
||||
|
||||
# Graph signature helpers
|
||||
|
||||
def _compute_graph_signature(self) -> dict[str, Any]:
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
import warnings
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from .._agents import SupportsAgentRun
|
||||
from ..observability import OtelAttr, capture_exception, create_workflow_span
|
||||
@@ -27,8 +28,12 @@ from ._edge import (
|
||||
)
|
||||
from ._executor import Executor
|
||||
from ._runner_context import InProcRunnerContext
|
||||
from ._validation import validate_workflow_graph
|
||||
from ._workflow import Workflow
|
||||
from ._validation import ValidationTypeEnum, WorkflowValidationError, validate_workflow_graph
|
||||
from ._workflow import (
|
||||
_MISSING, # pyright: ignore[reportPrivateUsage]
|
||||
Workflow,
|
||||
_coalesce_output_from_kwarg, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # type: ignore # pragma: no cover
|
||||
@@ -38,6 +43,12 @@ else:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ALL_OUTPUTS: Literal["all"] = "all"
|
||||
_ALL_OTHER_OUTPUTS: Literal["all_other"] = "all_other"
|
||||
_OutputSelection = list[Executor | SupportsAgentRun] | Literal["all"] | None
|
||||
_IntermediateOutputSelection = list[Executor | SupportsAgentRun] | Literal["all", "all_other"] | None
|
||||
_AnyOutputSelection = _OutputSelection | _IntermediateOutputSelection
|
||||
|
||||
|
||||
class WorkflowBuilder:
|
||||
"""A builder class for constructing workflows.
|
||||
@@ -83,7 +94,9 @@ class WorkflowBuilder:
|
||||
*,
|
||||
start_executor: Executor | SupportsAgentRun,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
output_executors: list[Executor | SupportsAgentRun] | None = None,
|
||||
output_from: list[Executor | SupportsAgentRun] | Literal["all"] | None = _MISSING,
|
||||
intermediate_output_from: _IntermediateOutputSelection = _MISSING,
|
||||
output_executors: list[Executor | SupportsAgentRun] | None = _MISSING,
|
||||
):
|
||||
"""Initialize the WorkflowBuilder.
|
||||
|
||||
@@ -98,9 +111,39 @@ class WorkflowBuilder:
|
||||
start_executor: The starting executor for the workflow. Can be an Executor instance
|
||||
or SupportsAgentRun instance.
|
||||
checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence.
|
||||
output_executors: Optional list of executors whose outputs should be collected.
|
||||
If not provided, outputs from all executors are collected.
|
||||
output_from: Designates which executors emit workflow output
|
||||
(``type='output'`` workflow events). Pass ``"all"`` to explicitly select every
|
||||
executor with declared workflow output types.
|
||||
intermediate_output_from: Designates which executors emit intermediate output
|
||||
(``type='intermediate'`` workflow events). Pass ``"all"`` to select every executor
|
||||
with declared workflow output types as intermediate (no executor emits ``output``).
|
||||
Pass ``"all_other"`` to select every executor with declared workflow output types
|
||||
that is not selected by ``output_from``.
|
||||
If neither ``output_from`` nor ``intermediate_output_from`` is provided,
|
||||
omitted-selection compatibility behavior applies and every ``yield_output`` produces
|
||||
``type='output'``. If either is provided, explicit mode applies: listed
|
||||
workflow-output executors emit ``output``, listed intermediate executors emit
|
||||
``intermediate``, and unlisted executor yields are hidden.
|
||||
|
||||
Output selection behavior:
|
||||
- Omit both selections: every ``yield_output`` emits ``output`` for compatibility,
|
||||
with a deprecation warning.
|
||||
- ``output_from="all"``: every output-capable executor emits ``output``.
|
||||
- ``output_from=[A]``: only A emits ``output``; other executor payloads are hidden.
|
||||
- ``output_from=[A], intermediate_output_from="all_other"``: A emits ``output``;
|
||||
all other output-capable executors emit ``intermediate``.
|
||||
- ``intermediate_output_from="all_other"``: no executor emits ``output``; every
|
||||
output-capable executor emits ``intermediate``.
|
||||
- ``output_from=[], intermediate_output_from="all_other"``: no executor emits
|
||||
``output``; every output-capable executor emits ``intermediate``.
|
||||
- ``output_from=[A], intermediate_output_from=[B, C]``: A emits ``output``; B and C
|
||||
emit ``intermediate``; other executor payloads are hidden.
|
||||
output_executors: **Deprecated** alias for ``output_from``. Will be removed in a
|
||||
future version.
|
||||
"""
|
||||
output_from = _coalesce_output_from_kwarg(output_from, output_executors)
|
||||
if intermediate_output_from is _MISSING:
|
||||
intermediate_output_from = None
|
||||
self._edge_groups: list[EdgeGroup] = []
|
||||
self._executors: dict[str, Executor] = {}
|
||||
self._start_executor: Executor | None = None
|
||||
@@ -113,8 +156,13 @@ class WorkflowBuilder:
|
||||
# being created for the same agent.
|
||||
self._agent_wrappers: dict[str, Executor] = {}
|
||||
|
||||
# Output executors filter; if set, only outputs from these executors are yielded
|
||||
self._output_executors: list[Executor | SupportsAgentRun] = output_executors if output_executors else []
|
||||
# ``None`` for both means omitted-selection compatibility behavior
|
||||
# (every yield_output produces type='output').
|
||||
# If either is provided, explicit mode applies and unlisted executor yields are hidden.
|
||||
self._output_from: _OutputSelection = self._coerce_output_from(output_from)
|
||||
self._intermediate_output_from: _IntermediateOutputSelection = self._coerce_intermediate_output_from(
|
||||
intermediate_output_from
|
||||
)
|
||||
|
||||
# Set the start executor
|
||||
self._set_start_executor(start_executor)
|
||||
@@ -584,6 +632,96 @@ class WorkflowBuilder:
|
||||
if existing is not wrapped:
|
||||
self._add_executor(wrapped)
|
||||
|
||||
def _coerce_output_from(self, output_from: Any) -> _OutputSelection:
|
||||
"""Coerce workflow-output selection while preserving the explicit ``"all"`` literal."""
|
||||
if output_from is None:
|
||||
return None
|
||||
if output_from == _ALL_OUTPUTS:
|
||||
return _ALL_OUTPUTS
|
||||
if isinstance(output_from, str):
|
||||
raise ValueError(f"Unsupported output_from literal {output_from!r}; use 'all' or a list of executors.")
|
||||
return list(output_from)
|
||||
|
||||
def _coerce_intermediate_output_from(self, intermediate_output_from: Any) -> _IntermediateOutputSelection:
|
||||
"""Coerce intermediate-output selection and reject output-only literals."""
|
||||
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 executors."
|
||||
)
|
||||
return list(intermediate_output_from)
|
||||
|
||||
def _resolve_designated_executor_ids(
|
||||
self,
|
||||
designated: _AnyOutputSelection,
|
||||
) -> list[str] | None:
|
||||
"""Resolve an optional designation list into executor IDs without mutating the graph."""
|
||||
if designated is None:
|
||||
return None
|
||||
if designated == _ALL_OUTPUTS:
|
||||
return [executor_id for executor_id, executor in self._executors.items() if executor.workflow_output_types]
|
||||
if designated == _ALL_OTHER_OUTPUTS:
|
||||
raise ValueError("intermediate_output_from='all_other' must be expanded relative to output_from.")
|
||||
ids: list[str] = []
|
||||
for item in designated:
|
||||
if isinstance(item, Executor):
|
||||
ids.append(item.id)
|
||||
elif isinstance(item, SupportsAgentRun):
|
||||
ids.append(resolve_agent_id(item))
|
||||
else:
|
||||
raise TypeError(
|
||||
"WorkflowBuilder expected designation entries to be Executor or SupportsAgentRun instances; "
|
||||
f"got {type(item).__name__}."
|
||||
)
|
||||
return ids
|
||||
|
||||
def _validate_designation_lists(
|
||||
self,
|
||||
output_executor_ids: list[str] | None,
|
||||
intermediate_executor_ids: list[str] | None,
|
||||
) -> None:
|
||||
"""Validate builder-level designation rules that need omitted-vs-explicit context."""
|
||||
explicit_mode = output_executor_ids is not None or intermediate_executor_ids is not None
|
||||
if not explicit_mode:
|
||||
return
|
||||
|
||||
output_ids = output_executor_ids or []
|
||||
intermediate_ids = intermediate_executor_ids or []
|
||||
if not output_ids and not intermediate_ids:
|
||||
raise WorkflowValidationError(
|
||||
"Explicit workflow output designation must include at least one output or intermediate executor.",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
duplicate_outputs = sorted({executor_id for executor_id in output_ids if output_ids.count(executor_id) > 1})
|
||||
if duplicate_outputs:
|
||||
raise WorkflowValidationError(
|
||||
f"Duplicate output executor designation(s): {duplicate_outputs}",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
duplicate_intermediates = sorted({
|
||||
executor_id for executor_id in intermediate_ids if intermediate_ids.count(executor_id) > 1
|
||||
})
|
||||
if duplicate_intermediates:
|
||||
raise WorkflowValidationError(
|
||||
f"Duplicate intermediate executor designation(s): {duplicate_intermediates}",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
overlap = sorted(set(output_ids).intersection(intermediate_ids))
|
||||
if overlap:
|
||||
raise WorkflowValidationError(
|
||||
f"Executors cannot be both output and intermediate designated: {overlap}",
|
||||
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
|
||||
)
|
||||
|
||||
def build(self) -> Workflow:
|
||||
"""Build and return the constructed workflow.
|
||||
|
||||
@@ -625,6 +763,43 @@ class WorkflowBuilder:
|
||||
# Workflows can be reused multiple times
|
||||
events2 = await workflow.run("world")
|
||||
print(events2.get_outputs()) # ['WORLD']
|
||||
|
||||
# Select one executor as Workflow Output.
|
||||
workflow = WorkflowBuilder(start_executor=executor, output_from=[executor]).build()
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # ['HELLO']
|
||||
print(events.get_intermediate_outputs()) # []
|
||||
|
||||
# Make one executor Workflow Output and every other output-capable executor Intermediate Output.
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=planner,
|
||||
output_from=[answerer],
|
||||
intermediate_output_from="all_other",
|
||||
)
|
||||
.add_edge(planner, answerer)
|
||||
.build()
|
||||
)
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # outputs from answerer
|
||||
print(events.get_intermediate_outputs()) # outputs from planner
|
||||
|
||||
# Build a progress-only workflow: no Workflow Output, all output-capable executors are intermediate.
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=planner, intermediate_output_from="all_other")
|
||||
.add_edge(planner, answerer)
|
||||
.build()
|
||||
)
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # []
|
||||
print(events.get_intermediate_outputs()) # outputs from planner and answerer
|
||||
|
||||
# Explicitly preserve all-output behavior without relying on omitted-selection compatibility.
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=planner, output_from="all").add_edge(planner, answerer).build()
|
||||
)
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # outputs from planner and answerer
|
||||
"""
|
||||
# Create workflow build span that includes validation and workflow creation
|
||||
with create_workflow_span(OtelAttr.WORKFLOW_BUILD_SPAN) as span:
|
||||
@@ -637,19 +812,47 @@ class WorkflowBuilder:
|
||||
"Starting executor must be set via the start_executor constructor parameter before building."
|
||||
)
|
||||
|
||||
if self._output_from is None and self._intermediate_output_from is None:
|
||||
warnings.warn(
|
||||
"WorkflowBuilder built without explicit output_from or intermediate_output_from; "
|
||||
"every yield_output produces type='output' for compatibility. Pass output_from='all', "
|
||||
"output_from=[...], or intermediate_output_from=[...] to opt into explicit designation - "
|
||||
"explicit designation will be required in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
start_executor = self._start_executor
|
||||
executors = self._executors
|
||||
edge_groups = self._edge_groups
|
||||
output_executors = [ex.id for ex in self._output_executors if isinstance(ex, Executor)] + [
|
||||
resolve_agent_id(agent) for agent in self._output_executors if isinstance(agent, SupportsAgentRun)
|
||||
]
|
||||
output_ids = self._resolve_designated_executor_ids(self._output_from)
|
||||
intermediate_output_ids: list[str] | None
|
||||
if self._intermediate_output_from == _ALL_OTHER_OUTPUTS:
|
||||
output_ids_for_all_other = output_ids or []
|
||||
intermediate_output_ids = [
|
||||
executor_id
|
||||
for executor_id, executor in self._executors.items()
|
||||
if executor.workflow_output_types and executor_id not in output_ids_for_all_other
|
||||
]
|
||||
else:
|
||||
intermediate_output_ids = self._resolve_designated_executor_ids(self._intermediate_output_from)
|
||||
self._validate_designation_lists(output_ids, intermediate_output_ids)
|
||||
|
||||
explicit_mode = output_ids is not None or intermediate_output_ids is not None
|
||||
output_for_workflow: list[str] | None = output_ids if explicit_mode else None
|
||||
if explicit_mode and output_for_workflow is None:
|
||||
output_for_workflow = []
|
||||
intermediate_output_for_workflow: list[str] | None = intermediate_output_ids if explicit_mode else None
|
||||
if explicit_mode and intermediate_output_for_workflow is None:
|
||||
intermediate_output_for_workflow = []
|
||||
|
||||
# Perform validation before creating the workflow
|
||||
validate_workflow_graph(
|
||||
edge_groups,
|
||||
executors,
|
||||
start_executor,
|
||||
output_executors,
|
||||
output_for_workflow or [],
|
||||
intermediate_output_for_workflow or [],
|
||||
)
|
||||
|
||||
# Add validation completed event
|
||||
@@ -666,7 +869,8 @@ class WorkflowBuilder:
|
||||
self._name,
|
||||
description=self._description,
|
||||
max_iterations=self._max_iterations,
|
||||
output_executors=output_executors,
|
||||
output_from=output_for_workflow,
|
||||
intermediate_output_from=intermediate_output_for_workflow,
|
||||
)
|
||||
build_attributes: dict[str, Any] = {
|
||||
OtelAttr.WORKFLOW_BUILDER_NAME: self._name,
|
||||
|
||||
@@ -201,6 +201,7 @@ def validate_workflow_context_annotation(
|
||||
|
||||
# Event types reserved for framework lifecycle (not allowed from user code)
|
||||
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: frozenset[str] = frozenset({"started", "status", "failed"})
|
||||
_OUTPUT_SELECTION_EVENT_TYPES: frozenset[str] = frozenset({"output", "intermediate"})
|
||||
|
||||
|
||||
class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
@@ -337,7 +338,20 @@ class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
await self._runner_context.send_message(msg)
|
||||
|
||||
async def yield_output(self, output: W_OutT) -> None:
|
||||
"""Set the output of the workflow.
|
||||
"""Yield an output from this executor.
|
||||
|
||||
The framework labels the resulting workflow event based on the workflow's explicit
|
||||
output designation:
|
||||
|
||||
- Omitted-selection compatibility behavior: every yield produces ``type='output'``.
|
||||
- Explicit mode: output-designated executors produce ``type='output'``,
|
||||
intermediate-designated executors produce ``type='intermediate'``, and
|
||||
unlisted executor yields are hidden from caller-facing events.
|
||||
|
||||
Whether a given executor produces ``output`` or ``intermediate`` events is fixed at
|
||||
workflow-build time via ``output_from`` / ``intermediate_output_from`` on
|
||||
:class:`WorkflowBuilder`; an executor cannot vary the label per yield. To change an
|
||||
executor's role, list it under a different designation when building the workflow.
|
||||
|
||||
Args:
|
||||
output: The output to yield. This must conform to the workflow output type(s)
|
||||
@@ -347,12 +361,24 @@ class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
# (deepcopy to capture state at yield time)
|
||||
self._yielded_outputs.append(copy.deepcopy(output))
|
||||
|
||||
event_type = self._runner_context.classify_yielded_output(self._executor_id)
|
||||
if event_type is None:
|
||||
return
|
||||
|
||||
with _framework_event_origin():
|
||||
event = WorkflowEvent.output(self._executor_id, output)
|
||||
event = WorkflowEvent(event_type, executor_id=self._executor_id, data=output)
|
||||
await self._runner_context.add_event(event)
|
||||
|
||||
async def add_event(self, event: WorkflowEvent[Any]) -> None:
|
||||
"""Add an event to the workflow context."""
|
||||
if event.origin == WorkflowEventSource.EXECUTOR and event.type in _OUTPUT_SELECTION_EVENT_TYPES:
|
||||
warning_msg = (
|
||||
f"Executor '{self._executor_id}' attempted to emit a '{event.type}' event directly, "
|
||||
"which is reserved for ctx.yield_output(). The event was ignored."
|
||||
)
|
||||
logger.warning(warning_msg)
|
||||
await self._runner_context.add_event(WorkflowEvent.warning(warning_msg))
|
||||
return
|
||||
if event.origin == WorkflowEventSource.EXECUTOR and event.type in _FRAMEWORK_LIFECYCLE_EVENT_TYPES:
|
||||
warning_msg = (
|
||||
f"Executor '{self._executor_id}' attempted to emit a '{event.type}' event, "
|
||||
|
||||
@@ -16,6 +16,7 @@ from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._events import (
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
_framework_event_origin, # type: ignore[reportPrivateUsage]
|
||||
)
|
||||
from ._executor import Executor, handler
|
||||
from ._request_info_mixin import response_handler
|
||||
@@ -552,10 +553,12 @@ class WorkflowExecutor(Executor):
|
||||
# Collect all events from the workflow
|
||||
request_info_events = result.get_request_info_events()
|
||||
outputs = result.get_outputs()
|
||||
intermediate_outputs = result.get_intermediate_outputs()
|
||||
workflow_run_state = result.get_final_state()
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} processing workflow result with "
|
||||
f"{len(outputs)} outputs and {len(request_info_events)} request info events. "
|
||||
f"{len(outputs)} outputs, {len(intermediate_outputs)} intermediate outputs, "
|
||||
f"and {len(request_info_events)} request info events. "
|
||||
f"Workflow run state: {workflow_run_state}"
|
||||
)
|
||||
|
||||
@@ -566,6 +569,19 @@ class WorkflowExecutor(Executor):
|
||||
else:
|
||||
await asyncio.gather(*[ctx.send_message(output) for output in outputs])
|
||||
|
||||
# Pipe sub-workflow intermediate emissions up through the parent's event stream.
|
||||
# Bypasses the parent's yield-output classifier so the 'intermediate' label is preserved
|
||||
# across the encapsulation boundary; uses this WorkflowExecutor's id as the source
|
||||
# so outer callers don't need to know the sub-workflow's internal executor layout.
|
||||
if intermediate_outputs:
|
||||
|
||||
async def _forward_intermediate_output(output: Any) -> None:
|
||||
with _framework_event_origin():
|
||||
event = WorkflowEvent("intermediate", executor_id=self.id, data=output)
|
||||
await ctx.add_event(event)
|
||||
|
||||
await asyncio.gather(*[_forward_intermediate_output(output) for output in intermediate_outputs])
|
||||
|
||||
# Process request info events
|
||||
for event in request_info_events:
|
||||
request_id = event.request_id
|
||||
|
||||
@@ -272,9 +272,7 @@ async def test_agent_executor_tool_call_with_approval() -> None:
|
||||
tools=[mock_tool_requiring_approval],
|
||||
)
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build()
|
||||
)
|
||||
workflow = WorkflowBuilder(start_executor=agent, output_from=[test_executor]).add_edge(agent, test_executor).build()
|
||||
|
||||
# Act
|
||||
events = await workflow.run("Invoke tool requiring approval")
|
||||
@@ -343,9 +341,7 @@ async def test_agent_executor_parallel_tool_call_with_approval() -> None:
|
||||
tools=[mock_tool_requiring_approval],
|
||||
)
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build()
|
||||
)
|
||||
workflow = WorkflowBuilder(start_executor=agent, output_from=[test_executor]).add_edge(agent, test_executor).build()
|
||||
|
||||
# Act
|
||||
events = await workflow.run("Invoke tool requiring approval")
|
||||
@@ -512,9 +508,7 @@ async def test_agent_executor_declaration_only_tool_emits_request_info() -> None
|
||||
tools=[declaration_only_tool],
|
||||
)
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build()
|
||||
)
|
||||
workflow = WorkflowBuilder(start_executor=agent, output_from=[test_executor]).add_edge(agent, test_executor).build()
|
||||
|
||||
# Act
|
||||
events = await workflow.run("Use the client side tool")
|
||||
@@ -587,9 +581,7 @@ async def test_agent_executor_parallel_declaration_only_tool_emits_request_info(
|
||||
tools=[declaration_only_tool],
|
||||
)
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build()
|
||||
)
|
||||
workflow = WorkflowBuilder(start_executor=agent, output_from=[test_executor]).add_edge(agent, test_executor).build()
|
||||
|
||||
# Act
|
||||
events = await workflow.run("Use the client side tool")
|
||||
|
||||
@@ -9,7 +9,7 @@ from agent_framework._workflows._events import WorkflowEvent
|
||||
def test_workflow_event_with_agent_response_data_type() -> None:
|
||||
"""Verify WorkflowEvent[AgentResponse].data is typed as AgentResponse."""
|
||||
response = AgentResponse(messages=[Message(role="assistant", contents=["Hello"])])
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response)
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent("intermediate", executor_id="test", data=response)
|
||||
|
||||
# This assignment should pass type checking without a cast
|
||||
data: AgentResponse = event.data
|
||||
@@ -20,7 +20,7 @@ def test_workflow_event_with_agent_response_data_type() -> None:
|
||||
def test_workflow_event_with_agent_response_update_data_type() -> None:
|
||||
"""Verify WorkflowEvent[AgentResponseUpdate].data is typed as AgentResponseUpdate."""
|
||||
update = AgentResponseUpdate()
|
||||
event: WorkflowEvent[AgentResponseUpdate] = WorkflowEvent.emit(executor_id="test", data=update)
|
||||
event: WorkflowEvent[AgentResponseUpdate] = WorkflowEvent("intermediate", executor_id="test", data=update)
|
||||
|
||||
# This assignment should pass type checking without a cast
|
||||
data: AgentResponseUpdate = event.data
|
||||
@@ -30,7 +30,7 @@ def test_workflow_event_with_agent_response_update_data_type() -> None:
|
||||
def test_workflow_event_repr() -> None:
|
||||
"""Verify WorkflowEvent.__repr__ uses consistent format."""
|
||||
response = AgentResponse(messages=[Message(role="assistant", contents=["Hello"])])
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response)
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent("intermediate", executor_id="test", data=response)
|
||||
|
||||
repr_str = repr(event)
|
||||
assert "WorkflowEvent" in repr_str
|
||||
|
||||
@@ -177,7 +177,7 @@ async def test_agent_executor_populates_full_conversation_non_streaming() -> Non
|
||||
agent_exec = AgentExecutor(agent, id="agent1-exec")
|
||||
capturer = _CaptureFullConversation(id="capture")
|
||||
|
||||
wf = WorkflowBuilder(start_executor=agent_exec, output_executors=[capturer]).add_edge(agent_exec, capturer).build()
|
||||
wf = WorkflowBuilder(start_executor=agent_exec, output_from=[capturer]).add_edge(agent_exec, capturer).build()
|
||||
|
||||
# Act: use run() to test non-streaming mode
|
||||
result = await wf.run("hello world")
|
||||
@@ -344,7 +344,7 @@ async def test_agent_executor_full_conversation_round_trip_does_not_duplicate_hi
|
||||
coordinator = _RoundTripCoordinator(target_agent_id="writer_agent")
|
||||
|
||||
wf = (
|
||||
WorkflowBuilder(start_executor=agent_exec, output_executors=[coordinator])
|
||||
WorkflowBuilder(start_executor=agent_exec, output_from=[coordinator])
|
||||
.add_edge(agent_exec, coordinator)
|
||||
.add_edge(coordinator, agent_exec)
|
||||
.build()
|
||||
@@ -450,7 +450,7 @@ async def test_run_request_with_full_history_clears_service_session_id() -> None
|
||||
coordinator = _FullHistoryReplayCoordinator(id="coord", target_exec=spy_exec)
|
||||
|
||||
wf = (
|
||||
WorkflowBuilder(start_executor=tool_exec, output_executors=[coordinator])
|
||||
WorkflowBuilder(start_executor=tool_exec, output_from=[coordinator])
|
||||
.add_edge(tool_exec, coordinator)
|
||||
.add_edge(coordinator, spy_exec)
|
||||
.build()
|
||||
@@ -478,7 +478,7 @@ async def test_from_response_preserves_service_session_id() -> None:
|
||||
# Simulate a prior run on the spy executor.
|
||||
spy_exec._session.service_session_id = "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
wf = WorkflowBuilder(start_executor=tool_exec, output_executors=[spy_exec]).add_edge(tool_exec, spy_exec).build()
|
||||
wf = WorkflowBuilder(start_executor=tool_exec, output_from=[spy_exec]).add_edge(tool_exec, spy_exec).build()
|
||||
|
||||
result = await wf.run("start")
|
||||
assert result.get_outputs() is not None
|
||||
@@ -517,7 +517,7 @@ async def test_with_text_preserves_full_conversation_through_custom_executor() -
|
||||
capturer = _CaptureFullConversation(id="capture")
|
||||
|
||||
wf = (
|
||||
WorkflowBuilder(start_executor=agent1, output_executors=[capturer])
|
||||
WorkflowBuilder(start_executor=agent1, output_from=[capturer])
|
||||
.add_chain([agent1, agent2, _upper_case_executor, agent3, capturer])
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -165,13 +165,13 @@ class TestEventEmission:
|
||||
|
||||
@workflow
|
||||
async def pipeline(x: int, ctx: RunContext) -> int:
|
||||
await ctx.add_event(WorkflowEvent.emit("pipeline", "custom_data"))
|
||||
await ctx.add_event(WorkflowEvent("intermediate", executor_id="pipeline", data="custom_data"))
|
||||
return x
|
||||
|
||||
result = await pipeline.run(1)
|
||||
data_events = [e for e in result if e.type == "data"]
|
||||
assert len(data_events) == 1
|
||||
assert data_events[0].data == "custom_data"
|
||||
intermediate_events = [e for e in result if e.type == "intermediate"]
|
||||
assert len(intermediate_events) == 1
|
||||
assert intermediate_events[0].data == "custom_data"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the ``OutputDesignation`` value type and the ``Workflow.is_terminal_executor``
|
||||
public predicate that delegates to it.
|
||||
|
||||
The states the value type encodes:
|
||||
- Omitted-selection compatibility: ``outputs=None`` -> every executor is terminal.
|
||||
- Explicit: disjoint ``outputs`` and ``intermediates`` sets classify listed executors,
|
||||
and hide unlisted executors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowValidationError,
|
||||
executor,
|
||||
)
|
||||
from agent_framework._workflows._runner_context import InProcRunnerContext
|
||||
from agent_framework._workflows._workflow import OutputDesignation, Workflow
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OutputDesignation value type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_omitted_selection_designation_marks_every_executor_as_terminal() -> None:
|
||||
designation = OutputDesignation() # designated defaults to None
|
||||
assert designation.outputs is None
|
||||
assert designation.is_terminal("anything")
|
||||
assert designation.is_terminal("else")
|
||||
assert designation.classify("anything") == "output"
|
||||
|
||||
|
||||
def test_strict_empty_designation_marks_no_executor_as_terminal() -> None:
|
||||
designation = OutputDesignation(outputs=frozenset())
|
||||
assert designation.outputs == frozenset()
|
||||
assert not designation.is_terminal("anything")
|
||||
assert not designation.is_terminal("else")
|
||||
assert designation.classify("anything") is None
|
||||
|
||||
|
||||
def test_strict_designated_set_only_terminal_for_members() -> None:
|
||||
designation = OutputDesignation(outputs=frozenset({"alpha", "beta"}), intermediates=frozenset({"gamma"}))
|
||||
assert designation.is_terminal("alpha")
|
||||
assert designation.is_terminal("beta")
|
||||
assert not designation.is_terminal("gamma")
|
||||
assert designation.is_intermediate("gamma")
|
||||
assert designation.classify("alpha") == "output"
|
||||
assert designation.classify("gamma") == "intermediate"
|
||||
assert designation.classify("delta") is None
|
||||
|
||||
|
||||
def test_designation_is_frozen() -> None:
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
designation = OutputDesignation(outputs=frozenset({"alpha"}))
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
designation.outputs = frozenset({"beta"}) # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow.is_terminal_executor delegates to the designation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@executor
|
||||
async def _emit_one(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("hello")
|
||||
|
||||
|
||||
@executor
|
||||
async def _downstream(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("downstream")
|
||||
|
||||
|
||||
def test_is_terminal_executor_omitted_selection_returns_true_for_any_id() -> None:
|
||||
"""Omitted-selection compatibility behavior: every executor is terminal."""
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
workflow = WorkflowBuilder(start_executor=_emit_one).build()
|
||||
assert workflow.is_terminal_executor(_emit_one.id)
|
||||
assert workflow.is_terminal_executor("anything-else")
|
||||
|
||||
|
||||
def test_is_intermediate_executor_explicit_list_returns_true_only_for_designated() -> None:
|
||||
"""Explicit mode tracks intermediate-designated executors separately."""
|
||||
workflow = WorkflowBuilder(start_executor=_emit_one, intermediate_output_from=[_emit_one]).build()
|
||||
assert not workflow.is_terminal_executor(_emit_one.id)
|
||||
assert not workflow.is_terminal_executor("nope")
|
||||
assert workflow.is_intermediate_executor(_emit_one.id)
|
||||
assert not workflow.is_intermediate_executor("nope")
|
||||
|
||||
|
||||
def test_is_terminal_executor_strict_list_returns_true_only_for_designated() -> None:
|
||||
"""Strict mode with a designated list: only listed executors are terminal."""
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=_emit_one, output_from=[_emit_one]).add_edge(_emit_one, _downstream).build()
|
||||
)
|
||||
assert workflow.is_terminal_executor(_emit_one.id)
|
||||
assert not workflow.is_terminal_executor(_downstream.id)
|
||||
|
||||
|
||||
def test_get_output_executors_throws_when_designation_references_missing_executor() -> None:
|
||||
workflow = Workflow(
|
||||
[],
|
||||
{_emit_one.id: _emit_one},
|
||||
_emit_one,
|
||||
InProcRunnerContext(),
|
||||
"test",
|
||||
output_from=["missing"],
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowValidationError, match="Output executor 'missing' is not present"):
|
||||
workflow.get_output_executors()
|
||||
|
||||
|
||||
def test_get_intermediate_executors_throws_when_designation_references_missing_executor() -> None:
|
||||
workflow = Workflow(
|
||||
[],
|
||||
{_emit_one.id: _emit_one},
|
||||
_emit_one,
|
||||
InProcRunnerContext(),
|
||||
"test",
|
||||
output_from=[],
|
||||
intermediate_output_from=["missing"],
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowValidationError, match="Intermediate executor 'missing' is not present"):
|
||||
workflow.get_intermediate_executors()
|
||||
@@ -0,0 +1,287 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the explicit output/intermediate selection contract on WorkflowBuilder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowValidationError,
|
||||
executor,
|
||||
)
|
||||
|
||||
|
||||
@executor
|
||||
async def _emit_one(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("hello")
|
||||
|
||||
|
||||
@executor
|
||||
async def _start(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output("from-start")
|
||||
await ctx.send_message("downstream")
|
||||
|
||||
|
||||
@executor
|
||||
async def _downstream(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("from-downstream")
|
||||
|
||||
|
||||
def test_designation_unset_emits_deprecation_warning() -> None:
|
||||
"""State A: WorkflowBuilder built without explicit designation warns."""
|
||||
with pytest.warns(DeprecationWarning, match="output_from or intermediate_output_from") as warning_info:
|
||||
WorkflowBuilder(start_executor=_emit_one).build()
|
||||
assert str(warning_info[0].message) == (
|
||||
"WorkflowBuilder built without explicit output_from or intermediate_output_from; "
|
||||
"every yield_output produces type='output' for compatibility. Pass output_from='all', "
|
||||
"output_from=[...], or intermediate_output_from=[...] to opt into explicit designation - "
|
||||
"explicit designation will be required in a future version."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_designation_unset_preserves_compatibility_all_output_behavior() -> None:
|
||||
"""Omitted designation keeps compatibility all-output behavior while warning."""
|
||||
with pytest.warns(DeprecationWarning, match="output_from or intermediate_output_from"):
|
||||
workflow = WorkflowBuilder(start_executor=_start).add_edge(_start, _downstream).build()
|
||||
|
||||
result = await workflow.run([Message(role="user", contents=["hi"])])
|
||||
|
||||
assert result.get_outputs() == ["from-start", "from-downstream"]
|
||||
assert result.get_intermediate_outputs() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_from_all_emits_all_outputs_without_omitted_selection_warning() -> None:
|
||||
"""Explicit all-output designation emits every executor payload without omitted-selection warning."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", DeprecationWarning)
|
||||
workflow = WorkflowBuilder(start_executor=_start, output_from="all").add_edge(_start, _downstream).build()
|
||||
|
||||
result = await workflow.run([Message(role="user", contents=["hi"])])
|
||||
|
||||
assert result.get_outputs() == ["from-start", "from-downstream"]
|
||||
assert result.get_intermediate_outputs() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_from_all_with_empty_intermediate_list_is_valid() -> None:
|
||||
"""Explicit all-output plus an empty intermediate list is a concrete no-intermediate selection."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", DeprecationWarning)
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=_start, output_from="all", intermediate_output_from=[])
|
||||
.add_edge(_start, _downstream)
|
||||
.build()
|
||||
)
|
||||
|
||||
result = await workflow.run([Message(role="user", contents=["hi"])])
|
||||
|
||||
assert result.get_outputs() == ["from-start", "from-downstream"]
|
||||
assert result.get_intermediate_outputs() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intermediate_output_from_all_other_marks_non_outputs_as_intermediate() -> None:
|
||||
"""All-other intermediate designation classifies every non-output executor yield as intermediate."""
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=_start,
|
||||
output_from=[_downstream],
|
||||
intermediate_output_from="all_other",
|
||||
)
|
||||
.add_edge(_start, _downstream)
|
||||
.build()
|
||||
)
|
||||
|
||||
result = await workflow.run([Message(role="user", contents=["hi"])])
|
||||
|
||||
assert result.get_outputs() == ["from-downstream"]
|
||||
assert result.get_intermediate_outputs() == ["from-start"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_other_streaming_events_mark_non_outputs_as_intermediate() -> None:
|
||||
"""All-other emits intermediate events while streaming, not just in collected results."""
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=_start,
|
||||
output_from=[_downstream],
|
||||
intermediate_output_from="all_other",
|
||||
)
|
||||
.add_edge(_start, _downstream)
|
||||
.build()
|
||||
)
|
||||
outputs: list[str] = []
|
||||
intermediates: list[str] = []
|
||||
|
||||
async for event in workflow.run([Message(role="user", contents=["hi"])], stream=True):
|
||||
if event.type == "output":
|
||||
outputs.append(event.data)
|
||||
elif event.type == "intermediate":
|
||||
intermediates.append(event.data)
|
||||
|
||||
assert outputs == ["from-downstream"]
|
||||
assert intermediates == ["from-start"]
|
||||
|
||||
|
||||
def test_all_other_expands_to_concrete_intermediate_executor_selection_at_build_time() -> None:
|
||||
"""The runner receives concrete executor IDs after all-other expansion."""
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=_start,
|
||||
output_from=[_downstream],
|
||||
intermediate_output_from="all_other",
|
||||
)
|
||||
.add_edge(_start, _downstream)
|
||||
.build()
|
||||
)
|
||||
|
||||
assert {executor.id for executor in workflow.get_output_executors()} == {_downstream.id}
|
||||
assert {executor.id for executor in workflow.get_intermediate_executors()} == {_start.id}
|
||||
assert workflow.is_intermediate_executor(_start.id)
|
||||
assert not workflow.is_intermediate_executor(_downstream.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_other_with_omitted_output_from_emits_only_intermediate_outputs() -> None:
|
||||
"""All-other intermediate designation opts out of omitted-selection all-output behavior."""
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=_start,
|
||||
intermediate_output_from="all_other",
|
||||
)
|
||||
.add_edge(_start, _downstream)
|
||||
.build()
|
||||
)
|
||||
|
||||
result = await workflow.run([Message(role="user", contents=["hi"])])
|
||||
|
||||
assert result.get_outputs() == []
|
||||
assert result.get_intermediate_outputs() == ["from-start", "from-downstream"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_other_with_empty_output_from_emits_only_intermediate_outputs() -> None:
|
||||
"""All-other intermediate designation treats an empty output list as selecting no workflow outputs."""
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=_start,
|
||||
output_from=[],
|
||||
intermediate_output_from="all_other",
|
||||
)
|
||||
.add_edge(_start, _downstream)
|
||||
.build()
|
||||
)
|
||||
|
||||
result = await workflow.run([Message(role="user", contents=["hi"])])
|
||||
|
||||
assert result.get_outputs() == []
|
||||
assert result.get_intermediate_outputs() == ["from-start", "from-downstream"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_other_with_output_from_all_expands_to_empty_intermediate_selection() -> None:
|
||||
"""All-other is empty when every output-capable executor is already selected as workflow output."""
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=_start,
|
||||
output_from="all",
|
||||
intermediate_output_from="all_other",
|
||||
)
|
||||
.add_edge(_start, _downstream)
|
||||
.build()
|
||||
)
|
||||
|
||||
result = await workflow.run([Message(role="user", contents=["hi"])])
|
||||
|
||||
assert result.get_outputs() == ["from-start", "from-downstream"]
|
||||
assert result.get_intermediate_outputs() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intermediate_output_from_all_routes_every_yield_to_intermediate() -> None:
|
||||
"""``intermediate_output_from="all"`` designates every output-capable executor as intermediate."""
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=_start, intermediate_output_from="all").add_edge(_start, _downstream).build()
|
||||
)
|
||||
|
||||
result = await workflow.run([Message(role="user", contents=["hi"])])
|
||||
|
||||
assert result.get_outputs() == []
|
||||
assert result.get_intermediate_outputs() == ["from-start", "from-downstream"]
|
||||
|
||||
|
||||
def test_output_from_all_other_is_rejected() -> None:
|
||||
"""The all-other literal is only valid for intermediate output selection."""
|
||||
with pytest.raises(ValueError, match="output_from.*all_other"):
|
||||
WorkflowBuilder(start_executor=_emit_one, output_from="all_other") # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("output_from", "intermediate_output_from"),
|
||||
[([_emit_one], None), (None, [_emit_one]), ([], [_emit_one])],
|
||||
ids=["output_list", "intermediate_list", "empty_output_with_intermediate"],
|
||||
)
|
||||
def test_explicit_designation_with_executor_does_not_warn(output_from, intermediate_output_from) -> None:
|
||||
"""State B: any explicit designation with at least one executor opts into explicit mode without warning."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", DeprecationWarning)
|
||||
WorkflowBuilder(
|
||||
start_executor=_emit_one,
|
||||
output_from=output_from,
|
||||
intermediate_output_from=intermediate_output_from,
|
||||
).build()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("output_from", "intermediate_output_from"),
|
||||
[([], None), (None, []), ([], [])],
|
||||
ids=["empty_output", "empty_intermediate", "both_empty"],
|
||||
)
|
||||
def test_empty_explicit_designation_fails(output_from, intermediate_output_from) -> None:
|
||||
"""State C: explicit mode needs at least one output or intermediate executor."""
|
||||
with pytest.raises(WorkflowValidationError, match="at least one output or intermediate executor"):
|
||||
WorkflowBuilder(
|
||||
start_executor=_emit_one,
|
||||
output_from=output_from,
|
||||
intermediate_output_from=intermediate_output_from,
|
||||
).build()
|
||||
|
||||
|
||||
def test_passing_both_output_executors_and_output_from_raises_type_error() -> None:
|
||||
"""State D: supplying a deprecated alias and the canonical kwarg is unambiguous user error."""
|
||||
with pytest.raises(TypeError, match="Cannot pass multiple workflow output selection parameters"):
|
||||
WorkflowBuilder(
|
||||
start_executor=_emit_one,
|
||||
output_executors=[_emit_one],
|
||||
output_from=[_emit_one],
|
||||
)
|
||||
|
||||
|
||||
def test_intermediate_executors_builder_parameter_is_not_public() -> None:
|
||||
"""The branch-only intermediate_executors builder parameter is not supported."""
|
||||
builder_type: Any = WorkflowBuilder
|
||||
with pytest.raises(TypeError, match="unexpected keyword argument 'intermediate_executors'"):
|
||||
builder_type(
|
||||
start_executor=_emit_one,
|
||||
intermediate_executors=[_emit_one],
|
||||
)
|
||||
|
||||
|
||||
def test_final_output_from_builder_parameter_is_not_public() -> None:
|
||||
"""The branch-only final_output_from builder parameter is not supported."""
|
||||
builder_type: Any = WorkflowBuilder
|
||||
with pytest.raises(TypeError, match="unexpected keyword argument 'final_output_from'"):
|
||||
builder_type(
|
||||
start_executor=_emit_one,
|
||||
final_output_from=[_emit_one],
|
||||
)
|
||||
@@ -158,7 +158,9 @@ async def test_runner_run_iteration_preserves_message_order_per_edge_runner() ->
|
||||
def __init__(self) -> None:
|
||||
self.received: list[int] = []
|
||||
|
||||
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
|
||||
async def send_message(
|
||||
self, message: WorkflowMessage, state: State, ctx: RunnerContext, *args: object, **kwargs: object
|
||||
) -> bool:
|
||||
message_data = message.data
|
||||
assert isinstance(message_data, MockMessage)
|
||||
self.received.append(message_data.data)
|
||||
@@ -188,7 +190,9 @@ async def test_runner_run_iteration_delivers_different_edge_runners_concurrently
|
||||
self.release = asyncio.Event()
|
||||
self.call_count = 0
|
||||
|
||||
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
|
||||
async def send_message(
|
||||
self, message: WorkflowMessage, state: State, ctx: RunnerContext, *args: object, **kwargs: object
|
||||
) -> bool:
|
||||
self.call_count += 1
|
||||
self.started.set()
|
||||
await self.release.wait()
|
||||
@@ -199,7 +203,9 @@ async def test_runner_run_iteration_delivers_different_edge_runners_concurrently
|
||||
self.probe_completed = asyncio.Event()
|
||||
self.call_count = 0
|
||||
|
||||
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
|
||||
async def send_message(
|
||||
self, message: WorkflowMessage, state: State, ctx: RunnerContext, *args: object, **kwargs: object
|
||||
) -> bool:
|
||||
self.call_count += 1
|
||||
self.probe_completed.set()
|
||||
return True
|
||||
@@ -766,7 +772,7 @@ async def test_runner_with_pre_loop_events():
|
||||
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Add an event before running
|
||||
await ctx.add_event(WorkflowEvent.output(executor_id="test_executor", data="pre-loop-output"))
|
||||
await ctx.add_event(WorkflowEvent("output", executor_id="test_executor", data="pre-loop-output"))
|
||||
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in runner.run_until_convergence():
|
||||
@@ -891,7 +897,7 @@ class ExecutorThatFailsWithEvents(Executor):
|
||||
# First emit an output event to the workflow context
|
||||
await ctx.yield_output(f"output-before-failure-{message.data}")
|
||||
# Add some events directly to the runner context
|
||||
await self._runner_ctx.add_event(WorkflowEvent.output(executor_id=self.id, data="pending-event"))
|
||||
await self._runner_ctx.add_event(WorkflowEvent("output", executor_id=self.id, data="pending-event"))
|
||||
# Fail on the specified iteration
|
||||
if self._iteration_count >= self._fail_on_iteration:
|
||||
raise RuntimeError("Executor failed with pending events")
|
||||
|
||||
@@ -799,3 +799,48 @@ def test_comprehensive_edge_groups_workflow_serialization() -> None:
|
||||
assert len(fan_in_groups[0]["edges"]) == 2, "FanInEdgeGroup should have 2 edges (from parallel_1 and parallel_2)"
|
||||
for single_group in single_groups:
|
||||
assert len(single_group["edges"]) == 1, "Each SingleEdgeGroup should have exactly 1 edge"
|
||||
|
||||
|
||||
def test_to_dict_preserves_compatibility_wire_keys_for_output_designation() -> None:
|
||||
"""to_dict() must emit the compatibility wire keys regardless of the Python kwarg names.
|
||||
|
||||
The Python API renamed ``output_executors`` -> ``output_from`` and
|
||||
uses ``intermediate_output_from`` for intermediate selection, but the serialized
|
||||
dict must keep the old keys so existing checkpoints stay readable. This is a
|
||||
regression guard against accidental renames of the wire format.
|
||||
"""
|
||||
|
||||
class _Yielder(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output(message)
|
||||
await ctx.send_message(message)
|
||||
|
||||
class _Terminal(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output(f"final: {message}")
|
||||
|
||||
start = _Yielder(id="start")
|
||||
progress = _Yielder(id="progress")
|
||||
final = _Terminal(id="final")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=start,
|
||||
output_from=[final],
|
||||
intermediate_output_from=[progress],
|
||||
)
|
||||
.add_edge(start, progress)
|
||||
.add_edge(progress, final)
|
||||
.build()
|
||||
)
|
||||
|
||||
d = workflow.to_dict()
|
||||
|
||||
assert "output_executors" in d, "wire key 'output_executors' must be preserved"
|
||||
assert "intermediate_executors" in d, "wire key 'intermediate_executors' must be preserved"
|
||||
assert "output_from" not in d, "new Python kwarg name must NOT leak into the wire format"
|
||||
assert "intermediate_output_from" not in d, "new Python kwarg name must NOT leak into the wire format"
|
||||
assert d["output_executors"] == ["final"]
|
||||
assert d["intermediate_executors"] == ["progress"]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the runner's explicit output selection event labeling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
executor,
|
||||
)
|
||||
|
||||
|
||||
@executor
|
||||
async def _start(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output("from-start")
|
||||
await ctx.send_message("downstream")
|
||||
|
||||
|
||||
@executor
|
||||
async def _downstream(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("from-downstream")
|
||||
|
||||
|
||||
def _input_msg() -> list[Message]:
|
||||
return [Message(role="user", contents=["hi"])]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_mode_designated_executor_emits_output_events() -> None:
|
||||
"""Output-designated executor yields produce type='output' events."""
|
||||
workflow = WorkflowBuilder(start_executor=_start, output_from=[_start]).add_edge(_start, _downstream).build()
|
||||
output_events: list[Any] = []
|
||||
intermediate_events: list[Any] = []
|
||||
async for event in workflow.run(_input_msg(), stream=True):
|
||||
if event.type == "output":
|
||||
output_events.append(event)
|
||||
elif event.type == "intermediate":
|
||||
intermediate_events.append(event)
|
||||
|
||||
assert any(ev.data == "from-start" for ev in output_events), "designated executor's yield is type='output'"
|
||||
assert intermediate_events == []
|
||||
assert all(ev.data != "from-downstream" for ev in output_events), "unlisted executor yield is hidden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intermediate_designated_executor_emits_intermediate_events() -> None:
|
||||
"""Intermediate-designated executor yields produce type='intermediate' events."""
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=_start, intermediate_output_from=[_downstream])
|
||||
.add_edge(_start, _downstream)
|
||||
.build()
|
||||
)
|
||||
output_events: list[Any] = []
|
||||
intermediate_events: list[Any] = []
|
||||
async for event in workflow.run(_input_msg(), stream=True):
|
||||
if event.type == "output":
|
||||
output_events.append(event)
|
||||
elif event.type == "intermediate":
|
||||
intermediate_events.append(event)
|
||||
|
||||
assert len(output_events) == 0
|
||||
assert {ev.data for ev in intermediate_events} == {"from-downstream"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_omitted_selection_keeps_all_yields_as_output() -> None:
|
||||
"""Omitted output selection preserves today's behavior: all yields are type='output'."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
workflow = WorkflowBuilder(start_executor=_start).add_edge(_start, _downstream).build()
|
||||
output_events: list[Any] = []
|
||||
intermediate_events: list[Any] = []
|
||||
async for event in workflow.run(_input_msg(), stream=True):
|
||||
if event.type == "output":
|
||||
output_events.append(event)
|
||||
elif event.type == "intermediate":
|
||||
intermediate_events.append(event)
|
||||
|
||||
assert {ev.data for ev in output_events} == {"from-start", "from-downstream"}
|
||||
assert len(intermediate_events) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_mode_get_outputs_returns_only_designated() -> None:
|
||||
"""WorkflowRunResult.get_outputs() returns only output-designated payloads."""
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=_start,
|
||||
output_from=[_downstream],
|
||||
intermediate_output_from=[_start],
|
||||
)
|
||||
.add_edge(_start, _downstream)
|
||||
.build()
|
||||
)
|
||||
result = await workflow.run(_input_msg())
|
||||
assert result.get_outputs() == ["from-downstream"]
|
||||
assert result.get_intermediate_outputs() == ["from-start"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hidden_yields_remain_in_executor_completion_events() -> None:
|
||||
"""Hidden yield_output payloads stay available through executor_completed observability."""
|
||||
workflow = WorkflowBuilder(start_executor=_start, output_from=[_downstream]).add_edge(_start, _downstream).build()
|
||||
result = await workflow.run(_input_msg())
|
||||
assert result.get_outputs() == ["from-downstream"]
|
||||
assert result.get_intermediate_outputs() == []
|
||||
assert not any(event.type in {"output", "intermediate"} and event.data == "from-start" for event in result)
|
||||
completed = [event for event in result if event.type == "executor_completed" and event.executor_id == _start.id]
|
||||
assert completed
|
||||
assert completed[0].data == ["downstream", "from-start"]
|
||||
@@ -617,3 +617,75 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
|
||||
# Key assertion: Only the second request should be received, not a duplicate of the first
|
||||
assert len(request_events) == 1
|
||||
assert request_events[0].data.prompt == "Second request"
|
||||
|
||||
|
||||
async def test_sub_workflow_intermediate_outputs_propagate_to_parent() -> None:
|
||||
"""A child workflow's intermediate emissions must bubble up through the parent.
|
||||
|
||||
Regression guard for the bug where WorkflowExecutor._process_workflow_result only
|
||||
forwarded result.get_outputs() and silently dropped result.get_intermediate_outputs().
|
||||
The forwarded event must carry the WorkflowExecutor's own id as the source so outer
|
||||
callers don't have to know the child's internal executor layout, and it must keep
|
||||
type='intermediate' regardless of how the parent designates the WorkflowExecutor.
|
||||
"""
|
||||
|
||||
class _ProgressEmitter(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="progress_emitter")
|
||||
|
||||
@handler
|
||||
async def run(self, message: str, ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output(f"progress: {message}")
|
||||
await ctx.send_message(message)
|
||||
|
||||
class _Finalizer(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="finalizer")
|
||||
|
||||
@handler
|
||||
async def run(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(f"final: {message}")
|
||||
|
||||
progress = _ProgressEmitter()
|
||||
finalizer = _Finalizer()
|
||||
child = (
|
||||
WorkflowBuilder(
|
||||
start_executor=progress,
|
||||
output_from=[finalizer],
|
||||
intermediate_output_from=[progress],
|
||||
)
|
||||
.add_edge(progress, finalizer)
|
||||
.build()
|
||||
)
|
||||
|
||||
sub = WorkflowExecutor(child, id="sub")
|
||||
|
||||
class _ParentSink(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="parent_sink")
|
||||
self.received: list[str] = []
|
||||
|
||||
@handler
|
||||
async def run(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
self.received.append(message)
|
||||
await ctx.yield_output(message)
|
||||
|
||||
sink = _ParentSink()
|
||||
parent = WorkflowBuilder(start_executor=sub, output_from=[sink]).add_edge(sub, sink).build()
|
||||
|
||||
intermediate_events: list[WorkflowEvent[Any]] = []
|
||||
output_events: list[WorkflowEvent[Any]] = []
|
||||
async for event in parent.run("hello", stream=True):
|
||||
if event.type == "intermediate":
|
||||
intermediate_events.append(event)
|
||||
elif event.type == "output":
|
||||
output_events.append(event)
|
||||
|
||||
# The child's intermediate emission bubbled up labeled with the WorkflowExecutor id,
|
||||
# not the child's internal executor id.
|
||||
assert len(intermediate_events) == 1, [(e.executor_id, e.data) for e in intermediate_events]
|
||||
assert intermediate_events[0].executor_id == "sub"
|
||||
assert intermediate_events[0].data == "progress: hello"
|
||||
|
||||
# The parent's own terminal output is unaffected.
|
||||
assert any(e.executor_id == "parent_sink" and e.data == "final: hello" for e in output_events)
|
||||
|
||||
@@ -550,12 +550,10 @@ def test_output_validation_with_valid_output_executors():
|
||||
executor2 = OutputExecutor(id="executor2")
|
||||
|
||||
# Build workflow with valid output executors
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor1, output_executors=[executor2]).add_edge(executor1, executor2).build()
|
||||
)
|
||||
workflow = WorkflowBuilder(start_executor=executor1, output_from=[executor2]).add_edge(executor1, executor2).build()
|
||||
|
||||
assert workflow is not None
|
||||
assert workflow._output_executors == ["executor2"] # pyright: ignore[reportPrivateUsage]
|
||||
assert {ex.id for ex in workflow.get_output_executors()} == {"executor2"}
|
||||
|
||||
|
||||
def test_output_validation_with_multiple_valid_output_executors():
|
||||
@@ -565,14 +563,14 @@ def test_output_validation_with_multiple_valid_output_executors():
|
||||
executor3 = OutputExecutor(id="executor3")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor1, output_executors=[executor1, executor3])
|
||||
WorkflowBuilder(start_executor=executor1, output_from=[executor1, executor3])
|
||||
.add_edge(executor1, executor2)
|
||||
.add_edge(executor2, executor3)
|
||||
.build()
|
||||
)
|
||||
|
||||
assert workflow is not None
|
||||
assert set(workflow._output_executors) == {"executor1", "executor3"} # pyright: ignore[reportPrivateUsage]
|
||||
assert {ex.id for ex in workflow.get_output_executors()} == {"executor1", "executor3"}
|
||||
|
||||
|
||||
def test_output_validation_fails_for_nonexistent_executor():
|
||||
@@ -598,7 +596,7 @@ def test_output_validation_fails_for_executor_without_output_types():
|
||||
|
||||
with pytest.raises(WorkflowValidationError) as exc_info:
|
||||
(
|
||||
WorkflowBuilder(start_executor=executor1, output_executors=[no_output_executor])
|
||||
WorkflowBuilder(start_executor=executor1, output_from=[no_output_executor])
|
||||
.add_edge(executor1, no_output_executor)
|
||||
.build()
|
||||
)
|
||||
@@ -608,16 +606,77 @@ def test_output_validation_fails_for_executor_without_output_types():
|
||||
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
|
||||
|
||||
|
||||
def test_output_validation_empty_list_passes():
|
||||
"""Test that output validation passes with an empty output executors list."""
|
||||
def test_output_validation_empty_explicit_designation_fails():
|
||||
"""Test that explicit mode rejects an empty output/intermediate designation."""
|
||||
executor1 = OutputExecutor(id="executor1")
|
||||
executor2 = OutputExecutor(id="executor2")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=executor1, output_executors=[]).add_edge(executor1, executor2).build()
|
||||
with pytest.raises(WorkflowValidationError) as exc_info:
|
||||
WorkflowBuilder(start_executor=executor1, output_from=[]).add_edge(executor1, executor2).build()
|
||||
|
||||
assert "at least one output or intermediate executor" in str(exc_info.value)
|
||||
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
|
||||
|
||||
|
||||
def test_output_validation_with_valid_intermediate_executors():
|
||||
"""Test that output validation passes when intermediate executors exist and have output types."""
|
||||
executor1 = OutputExecutor(id="executor1")
|
||||
executor2 = OutputExecutor(id="executor2")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor1, intermediate_output_from=[executor1])
|
||||
.add_edge(executor1, executor2)
|
||||
.build()
|
||||
)
|
||||
|
||||
assert workflow is not None
|
||||
# All executors are outputs
|
||||
assert workflow._output_executors == ["executor1", "executor2"] # type: ignore
|
||||
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"executor1"}
|
||||
assert workflow.is_intermediate_executor("executor1")
|
||||
assert not workflow.is_terminal_executor("executor2")
|
||||
|
||||
|
||||
def test_output_validation_fails_for_designation_overlap():
|
||||
"""Test that an executor cannot be both terminal and intermediate."""
|
||||
executor1 = OutputExecutor(id="executor1")
|
||||
|
||||
with pytest.raises(WorkflowValidationError) as exc_info:
|
||||
WorkflowBuilder(
|
||||
start_executor=executor1,
|
||||
output_from=[executor1],
|
||||
intermediate_output_from=[executor1],
|
||||
).build()
|
||||
|
||||
assert "both output and intermediate" in str(exc_info.value)
|
||||
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
|
||||
|
||||
|
||||
def test_output_validation_fails_for_duplicate_designation():
|
||||
"""Test that duplicate output or intermediate designation entries are rejected."""
|
||||
executor1 = OutputExecutor(id="executor1")
|
||||
|
||||
with pytest.raises(WorkflowValidationError) as exc_info:
|
||||
WorkflowBuilder(start_executor=executor1, output_from=[executor1, executor1]).build()
|
||||
|
||||
assert "Duplicate output executor designation" in str(exc_info.value)
|
||||
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
|
||||
|
||||
|
||||
def test_output_validation_fails_for_unknown_intermediate_executor():
|
||||
"""Test that intermediate designation rejects executors outside the workflow graph."""
|
||||
executor1 = OutputExecutor(id="executor1")
|
||||
executor2 = OutputExecutor(id="executor2")
|
||||
missing = OutputExecutor(id="missing")
|
||||
|
||||
with pytest.raises(WorkflowValidationError) as exc_info:
|
||||
(
|
||||
WorkflowBuilder(start_executor=executor1, intermediate_output_from=[missing])
|
||||
.add_edge(executor1, executor2)
|
||||
.build()
|
||||
)
|
||||
|
||||
assert "not present in the workflow graph" in str(exc_info.value)
|
||||
assert "missing" in str(exc_info.value)
|
||||
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
|
||||
|
||||
|
||||
def test_output_validation_with_direct_validate_workflow_graph():
|
||||
|
||||
@@ -1056,7 +1056,7 @@ class PassthroughExecutor(Executor):
|
||||
|
||||
|
||||
async def test_output_executors_empty_yields_all_outputs() -> None:
|
||||
"""Test that when _output_executors is empty (default), all outputs are yielded."""
|
||||
"""Test that omitted output selection yields all outputs for compatibility."""
|
||||
# Create executors that each produce different outputs
|
||||
executor_a = PassthroughExecutor(id="executor_a", output_value=10)
|
||||
executor_b = OutputProducerExecutor(id="executor_b", output_value=20)
|
||||
@@ -1085,9 +1085,7 @@ async def test_output_executors_filters_outputs_non_streaming() -> None:
|
||||
|
||||
# Build workflow with a -> b
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_b])
|
||||
.add_edge(executor_a, executor_b)
|
||||
.build()
|
||||
WorkflowBuilder(start_executor=executor_a, output_from=[executor_b]).add_edge(executor_a, executor_b).build()
|
||||
)
|
||||
|
||||
result = await workflow.run(NumberMessage(data=0))
|
||||
@@ -1110,9 +1108,7 @@ async def test_output_executors_filters_outputs_streaming() -> None:
|
||||
|
||||
# Build workflow with a -> b
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_a])
|
||||
.add_edge(executor_a, executor_b)
|
||||
.build()
|
||||
WorkflowBuilder(start_executor=executor_a, output_from=[executor_a]).add_edge(executor_a, executor_b).build()
|
||||
)
|
||||
|
||||
# Collect outputs from streaming
|
||||
@@ -1136,7 +1132,7 @@ async def test_output_executors_with_multiple_specified_executors() -> None:
|
||||
|
||||
# Build workflow with a -> b -> c
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_a, executor_c])
|
||||
WorkflowBuilder(start_executor=executor_a, output_from=[executor_a, executor_c])
|
||||
.add_edge(executor_a, executor_b)
|
||||
.add_edge(executor_b, executor_c)
|
||||
.build()
|
||||
@@ -1154,12 +1150,15 @@ async def test_output_executors_with_multiple_specified_executors() -> None:
|
||||
|
||||
async def test_output_executors_with_nonexistent_executor_id() -> None:
|
||||
"""Test that specifying a non-existent executor ID doesn't break the workflow."""
|
||||
from agent_framework._workflows._workflow import OutputDesignation
|
||||
|
||||
executor_a = OutputProducerExecutor(id="executor_a", output_value=42)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=executor_a).build()
|
||||
|
||||
# Set output_executors to an ID that doesn't exist
|
||||
workflow._output_executors = ["nonexistent_executor"] # type: ignore
|
||||
# Designate a nonexistent executor so the workflow-level filter drops every yield.
|
||||
workflow._output_designation = OutputDesignation(outputs=frozenset({"nonexistent_executor"})) # type: ignore[attr-defined]
|
||||
workflow._runner.context.set_yield_output_classifier(workflow._output_designation.classify) # type: ignore[attr-defined,reportPrivateUsage]
|
||||
|
||||
result = await workflow.run(NumberMessage(data=0))
|
||||
outputs = result.get_outputs()
|
||||
@@ -1199,7 +1198,7 @@ async def test_output_executors_filtering_with_fan_in() -> None:
|
||||
|
||||
# Build fan-in workflow: start -> [a, b] -> aggregator
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor_start, output_executors=[aggregator])
|
||||
WorkflowBuilder(start_executor=executor_start, output_from=[aggregator])
|
||||
.add_fan_out_edges(executor_start, [executor_a, executor_b])
|
||||
.add_fan_in_edges([executor_a, executor_b], aggregator)
|
||||
.build()
|
||||
@@ -1218,7 +1217,7 @@ async def test_output_executors_filtering_with_run_responses() -> None:
|
||||
"""Test output filtering works correctly with run(responses=...) method."""
|
||||
executor = MockExecutorRequestApproval(id="approval_executor")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=executor, output_executors=[executor]).build()
|
||||
workflow = WorkflowBuilder(start_executor=executor, output_from=[executor]).build()
|
||||
|
||||
# Run workflow which will request approval
|
||||
result = await workflow.run(NumberMessage(data=42))
|
||||
@@ -1252,8 +1251,11 @@ async def test_output_executors_filtering_with_run_responses_streaming() -> None
|
||||
request_events = [e for e in events_list if e.type == "request_info"]
|
||||
assert len(request_events) == 1
|
||||
|
||||
# Set output_executors to exclude the approval executor
|
||||
workflow._output_executors = ["other_executor"] # type: ignore
|
||||
# Designate a different executor so the workflow-level filter drops the approval yield.
|
||||
from agent_framework._workflows._workflow import OutputDesignation
|
||||
|
||||
workflow._output_designation = OutputDesignation(outputs=frozenset({"other_executor"})) # type: ignore[attr-defined]
|
||||
workflow._runner.context.set_yield_output_classifier(workflow._output_designation.classify) # type: ignore[attr-defined,reportPrivateUsage]
|
||||
|
||||
# Send approval response via streaming
|
||||
responses = {request_events[0].request_id: ApprovalMessage(approved=True)}
|
||||
|
||||
@@ -923,7 +923,7 @@ class TestWorkflowAgent:
|
||||
|
||||
# Build workflow: start -> agent1 (no output) -> agent2 (output visible)
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=start_exec, output_executors=[start_exec, agent2])
|
||||
WorkflowBuilder(start_executor=start_exec, output_from=[start_exec, agent2])
|
||||
.add_edge(start_exec, agent1)
|
||||
.add_edge(agent1, agent2)
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for WorkflowAgent forwarding of intermediate workflow events.
|
||||
|
||||
Covers:
|
||||
- type='intermediate' surfaces as AgentResponseUpdate without content-type rewriting
|
||||
- type='data' (compatibility alias via WorkflowEvent.emit) is forwarded
|
||||
- Message.additional_properties survives the intermediate translation path
|
||||
- Terminal yields keep using regular text content (backward compat)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
executor,
|
||||
)
|
||||
from agent_framework.exceptions import AgentInvalidRequestException
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_forwards_intermediate_events_without_content_rewrite() -> None:
|
||||
"""An intermediate yield from an intermediate-designated executor surfaces through as_agent
|
||||
as an AgentResponseUpdate carrying its original content type."""
|
||||
|
||||
@executor
|
||||
async def emit(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output("intermediate progress")
|
||||
await ctx.send_message("downstream")
|
||||
|
||||
@executor
|
||||
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("FINAL")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=emit,
|
||||
output_from=[terminal],
|
||||
intermediate_output_from=[emit],
|
||||
)
|
||||
.add_edge(emit, terminal)
|
||||
.build()
|
||||
)
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("hi", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
text = " ".join(c.text for u in updates for c in u.contents if c.type == "text")
|
||||
reasoning_text = " ".join(c.text for u in updates for c in u.contents if c.type == "text_reasoning")
|
||||
|
||||
assert "intermediate progress" in text
|
||||
assert "FINAL" in text
|
||||
assert reasoning_text == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_text_accessor_includes_forwarded_intermediate_text() -> None:
|
||||
"""Intermediate text is forwarded as text until issue 5885 defines the final mapping."""
|
||||
|
||||
@executor
|
||||
async def emit(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output("invisible-progress")
|
||||
await ctx.send_message("forward")
|
||||
|
||||
@executor
|
||||
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("the-answer")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=emit,
|
||||
output_from=[terminal],
|
||||
intermediate_output_from=[emit],
|
||||
)
|
||||
.add_edge(emit, terminal)
|
||||
.build()
|
||||
)
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
response = await agent.run("hi")
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert "invisible-progress" in response.text
|
||||
assert "the-answer" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_hidden_yields_do_not_surface_non_streaming() -> None:
|
||||
"""In explicit designation mode, unlisted executor yields stay out of agent responses."""
|
||||
|
||||
@executor
|
||||
async def hidden(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output("hidden-progress")
|
||||
await ctx.send_message("forward")
|
||||
|
||||
@executor
|
||||
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("visible-answer")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=hidden, output_from=[terminal]).add_edge(hidden, terminal).build()
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
response = await agent.run("hi")
|
||||
all_text = " ".join(c.text for m in response.messages for c in m.contents if hasattr(c, "text"))
|
||||
|
||||
assert response.text == "visible-answer"
|
||||
assert "hidden-progress" not in all_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_hidden_yields_do_not_surface_streaming() -> None:
|
||||
"""In explicit designation mode, unlisted executor yields stay out of agent updates."""
|
||||
|
||||
@executor
|
||||
async def hidden(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output("hidden-progress")
|
||||
await ctx.send_message("forward")
|
||||
|
||||
@executor
|
||||
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("visible-answer")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=hidden, output_from=[terminal]).add_edge(hidden, terminal).build()
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("hi", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
all_text = " ".join(c.text for u in updates for c in u.contents if hasattr(c, "text"))
|
||||
|
||||
assert "visible-answer" in all_text
|
||||
assert "hidden-progress" not in all_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_data_event_emit_factory_still_forwarded() -> None:
|
||||
"""Even the deprecated WorkflowEvent.emit() / type='data' path is forwarded."""
|
||||
|
||||
@executor
|
||||
async def emit_data_alias(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
await ctx.add_event(WorkflowEvent.emit("emit_data_alias", "data-alias-payload"))
|
||||
await ctx.yield_output("DONE")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=emit_data_alias, output_from=[emit_data_alias]).build()
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("hi", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
text = " ".join(c.text for u in updates for c in u.contents if c.type == "text")
|
||||
assert "data-alias-payload" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_intermediate_message_preserves_additional_properties() -> None:
|
||||
"""Message.additional_properties survives intermediate forwarding.
|
||||
|
||||
Producer-attached metadata (tracking_id, conversation_id, etc.) must not disappear
|
||||
for messages flowing through intermediate-designated executors.
|
||||
"""
|
||||
|
||||
@executor
|
||||
async def emit(messages: list[Message], ctx: WorkflowContext[str, AgentResponse]) -> None:
|
||||
msg = Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text(text="hi")],
|
||||
additional_properties={"tracking_id": "abc-123"},
|
||||
)
|
||||
await ctx.yield_output(AgentResponse(messages=[msg]))
|
||||
await ctx.send_message("forward")
|
||||
|
||||
@executor
|
||||
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("done")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=emit,
|
||||
output_from=[terminal],
|
||||
intermediate_output_from=[emit],
|
||||
)
|
||||
.add_edge(emit, terminal)
|
||||
.build()
|
||||
)
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
response = await agent.run("hi")
|
||||
intermediate_msgs = [m for m in response.messages if any(c.type == "text" and c.text == "hi" for c in m.contents)]
|
||||
assert intermediate_msgs, "expected at least one intermediate message in the response"
|
||||
assert intermediate_msgs[0].additional_properties.get("tracking_id") == "abc-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_terminal_text_stays_text_not_reasoning() -> None:
|
||||
"""A designated executor's text yield surfaces as Content.text."""
|
||||
|
||||
@executor
|
||||
async def only(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("the-answer")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=only, output_from=[only]).build()
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
response = await agent.run("hi")
|
||||
assert response.text == "the-answer"
|
||||
# No text_reasoning content because everything from `only` is terminal.
|
||||
assert all(c.type != "text_reasoning" for m in response.messages for c in m.contents)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_non_streaming_rejects_terminal_update() -> None:
|
||||
"""A terminal event carrying AgentResponseUpdate is streaming-only and invalid in run()."""
|
||||
|
||||
@executor
|
||||
async def emit(messages: list[Message], ctx: WorkflowContext[Never, AgentResponseUpdate]) -> None:
|
||||
await ctx.yield_output(AgentResponseUpdate(contents=[Content.from_text(text="partial")], role="assistant"))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=emit, output_from=[emit]).build()
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
with pytest.raises(AgentInvalidRequestException, match="AgentResponseUpdate"):
|
||||
await agent.run("hi")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_non_streaming_rejects_intermediate_update() -> None:
|
||||
"""An intermediate event carrying AgentResponseUpdate is streaming-only and invalid in run()."""
|
||||
|
||||
@executor
|
||||
async def emit(messages: list[Message], ctx: WorkflowContext[str, AgentResponseUpdate]) -> None:
|
||||
await ctx.yield_output(AgentResponseUpdate(contents=[Content.from_text(text="partial")], role="assistant"))
|
||||
await ctx.send_message("forward")
|
||||
|
||||
@executor
|
||||
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("FINAL")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=emit,
|
||||
output_from=[terminal],
|
||||
intermediate_output_from=[emit],
|
||||
)
|
||||
.add_edge(emit, terminal)
|
||||
.build()
|
||||
)
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
with pytest.raises(AgentInvalidRequestException, match="AgentResponseUpdate"):
|
||||
await agent.run("hi")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_streaming_update_payloads_preserve_classification() -> None:
|
||||
"""Streaming AgentResponseUpdate payloads preserve original content types."""
|
||||
|
||||
@executor
|
||||
async def emit(messages: list[Message], ctx: WorkflowContext[str, AgentResponseUpdate]) -> None:
|
||||
await ctx.yield_output(
|
||||
AgentResponseUpdate(contents=[Content.from_text(text="intermediate-chunk")], role="assistant")
|
||||
)
|
||||
await ctx.send_message("forward")
|
||||
|
||||
@executor
|
||||
async def terminal(message: str, ctx: WorkflowContext[Never, AgentResponseUpdate]) -> None:
|
||||
await ctx.yield_output(
|
||||
AgentResponseUpdate(contents=[Content.from_text(text="terminal-chunk")], role="assistant")
|
||||
)
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=emit,
|
||||
output_from=[terminal],
|
||||
intermediate_output_from=[emit],
|
||||
)
|
||||
.add_edge(emit, terminal)
|
||||
.build()
|
||||
)
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("hi", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
text = " ".join(c.text for u in updates for c in u.contents if c.type == "text")
|
||||
reasoning_text = " ".join(c.text for u in updates for c in u.contents if c.type == "text_reasoning")
|
||||
|
||||
assert "intermediate-chunk" in text
|
||||
assert "terminal-chunk" in text
|
||||
assert reasoning_text == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_drops_orchestration_internal_events() -> None:
|
||||
"""Orchestration-internal event types (group_chat / handoff_sent / magentic_orchestrator)
|
||||
must not surface through workflow.as_agent(). Their dataclass payloads would otherwise
|
||||
be stringified by the generic fallback path and leak into response history."""
|
||||
|
||||
@executor
|
||||
async def emit(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
|
||||
# Construct typed orchestration-internal events directly to assert they get
|
||||
# dropped at the agent boundary regardless of payload.
|
||||
await ctx.add_event(WorkflowEvent("group_chat", data={"orchestrator": "details"})) # type: ignore[arg-type]
|
||||
await ctx.add_event(WorkflowEvent("handoff_sent", data={"target": "agent_b"})) # type: ignore[arg-type]
|
||||
await ctx.add_event(WorkflowEvent("magentic_orchestrator", data={"plan": "..."})) # type: ignore[arg-type]
|
||||
await ctx.yield_output("FINAL")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=emit, output_from=[emit]).build()
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
response = await agent.run("hi")
|
||||
all_text = " ".join(c.text for m in response.messages for c in m.contents if hasattr(c, "text"))
|
||||
assert "orchestrator" not in all_text
|
||||
assert "agent_b" not in all_text
|
||||
assert "plan" not in all_text
|
||||
assert response.text == "FINAL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_agent_drops_orchestration_internal_events_streaming() -> None:
|
||||
"""Streaming counterpart — orchestration-internal events stay inside the workflow."""
|
||||
|
||||
@executor
|
||||
async def emit(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.add_event(WorkflowEvent("group_chat", data={"orchestrator": "details"})) # type: ignore[arg-type]
|
||||
await ctx.yield_output("FINAL")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=emit, output_from=[emit]).build()
|
||||
agent = workflow.as_agent("test")
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("hi", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
all_text = " ".join(c.text for u in updates for c in u.contents if hasattr(c, "text"))
|
||||
assert "orchestrator" not in all_text
|
||||
assert "FINAL" in all_text
|
||||
@@ -254,10 +254,10 @@ def test_switch_case_with_agents():
|
||||
def test_with_output_from_returns_builder():
|
||||
"""Test that with_output_from returns the builder for method chaining."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
builder = WorkflowBuilder(output_executors=[executor_a], start_executor=executor_a)
|
||||
builder = WorkflowBuilder(output_from=[executor_a], start_executor=executor_a)
|
||||
|
||||
# Verify builder was created with output_executors
|
||||
assert builder._output_executors == [executor_a] # pyright: ignore[reportPrivateUsage]
|
||||
# Verify builder was created with output_from
|
||||
assert builder._output_from == [executor_a] # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
def test_with_output_from_with_executor_instances():
|
||||
@@ -266,13 +266,11 @@ def test_with_output_from_with_executor_instances():
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_b])
|
||||
.add_edge(executor_a, executor_b)
|
||||
.build()
|
||||
WorkflowBuilder(start_executor=executor_a, output_from=[executor_b]).add_edge(executor_a, executor_b).build()
|
||||
)
|
||||
|
||||
# Verify that the workflow was built with the correct output executors
|
||||
assert workflow._output_executors == ["executor_b"] # type: ignore
|
||||
assert {ex.id for ex in workflow.get_output_executors()} == {"executor_b"}
|
||||
|
||||
|
||||
def test_with_output_from_with_agent_instances():
|
||||
@@ -280,10 +278,10 @@ def test_with_output_from_with_agent_instances():
|
||||
agent_a = DummyAgent(id="agent_a", name="writer")
|
||||
agent_b = DummyAgent(id="agent_b", name="reviewer")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=agent_a, output_executors=[agent_b]).add_edge(agent_a, agent_b).build()
|
||||
workflow = WorkflowBuilder(start_executor=agent_a, output_from=[agent_b]).add_edge(agent_a, agent_b).build()
|
||||
|
||||
# Verify that the workflow was built with the agent's name as output executor
|
||||
assert workflow._output_executors == ["reviewer"] # type: ignore
|
||||
assert {ex.id for ex in workflow.get_output_executors()} == {"reviewer"}
|
||||
|
||||
|
||||
def test_with_output_from_with_executor_instances_by_id():
|
||||
@@ -292,12 +290,10 @@ def test_with_output_from_with_executor_instances_by_id():
|
||||
executor_b = MockExecutor(id="ExecutorB")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_b])
|
||||
.add_edge(executor_a, executor_b)
|
||||
.build()
|
||||
WorkflowBuilder(start_executor=executor_a, output_from=[executor_b]).add_edge(executor_a, executor_b).build()
|
||||
)
|
||||
|
||||
assert workflow._output_executors == ["ExecutorB"] # type: ignore
|
||||
assert {ex.id for ex in workflow.get_output_executors()} == {"ExecutorB"}
|
||||
|
||||
|
||||
def test_with_output_from_with_multiple_executors():
|
||||
@@ -307,29 +303,27 @@ def test_with_output_from_with_multiple_executors():
|
||||
executor_c = MockExecutor(id="executor_c")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_a, executor_c])
|
||||
WorkflowBuilder(start_executor=executor_a, output_from=[executor_a, executor_c])
|
||||
.add_edge(executor_a, executor_b)
|
||||
.add_edge(executor_b, executor_c)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Verify that the workflow was built with both output executors
|
||||
assert set(workflow._output_executors) == {"executor_a", "executor_c"} # type: ignore
|
||||
assert {ex.id for ex in workflow.get_output_executors()} == {"executor_a", "executor_c"}
|
||||
|
||||
|
||||
def test_with_output_from_can_be_set_to_different_value():
|
||||
"""Test that output_executors can be set at construction time."""
|
||||
"""Test that output_from can be set at construction time."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_b])
|
||||
.add_edge(executor_a, executor_b)
|
||||
.build()
|
||||
WorkflowBuilder(start_executor=executor_a, output_from=[executor_b]).add_edge(executor_a, executor_b).build()
|
||||
)
|
||||
|
||||
# Verify that the setting is applied
|
||||
assert workflow._output_executors == ["executor_b"] # type: ignore
|
||||
assert {ex.id for ex in workflow.get_output_executors()} == {"executor_b"}
|
||||
|
||||
|
||||
def test_with_output_from_with_agent_instances_resolves_name():
|
||||
@@ -338,37 +332,37 @@ def test_with_output_from_with_agent_instances_resolves_name():
|
||||
agent_reviewer = DummyAgent(id="agent2", name="reviewer")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=agent_writer, output_executors=[agent_reviewer])
|
||||
WorkflowBuilder(start_executor=agent_writer, output_from=[agent_reviewer])
|
||||
.add_edge(agent_writer, agent_reviewer)
|
||||
.build()
|
||||
)
|
||||
|
||||
assert workflow._output_executors == ["reviewer"] # type: ignore
|
||||
assert {ex.id for ex in workflow.get_output_executors()} == {"reviewer"}
|
||||
|
||||
|
||||
def test_with_output_from_in_constructor():
|
||||
"""Test that output_executors works correctly when set in the constructor."""
|
||||
"""Test that output_from works correctly when set in the constructor."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
executor_c = MockExecutor(id="executor_c")
|
||||
|
||||
# Build workflow with output_executors in the constructor
|
||||
# Build workflow with output_from in the constructor
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_c])
|
||||
WorkflowBuilder(start_executor=executor_a, output_from=[executor_c])
|
||||
.add_edge(executor_a, executor_b)
|
||||
.add_edge(executor_b, executor_c)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Verify that the setting persists through the chain
|
||||
assert workflow._output_executors == ["executor_c"] # type: ignore
|
||||
assert {ex.id for ex in workflow.get_output_executors()} == {"executor_c"}
|
||||
|
||||
|
||||
def test_with_output_from_with_invalid_executor_raises_validation_error():
|
||||
"""Test that with_output_from with an invalid executor raises an error."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
|
||||
builder = WorkflowBuilder(start_executor=executor_a, output_executors=[MockExecutor(id="executor_b")])
|
||||
builder = WorkflowBuilder(start_executor=executor_a, output_from=[MockExecutor(id="executor_b")])
|
||||
|
||||
# Attempting to set output from an executor not in the workflow should raise an error
|
||||
with pytest.raises(
|
||||
|
||||
@@ -5,6 +5,7 @@ from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
@@ -72,6 +73,31 @@ async def test_executor_cannot_emit_framework_lifecycle_event(caplog: "LogCaptur
|
||||
assert any("attempted to emit" in message and "'status'" in message for message in list(caplog.messages))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
[
|
||||
WorkflowEvent("output", executor_id="exec", data="output-payload"),
|
||||
WorkflowEvent("intermediate", executor_id="exec", data="intermediate-payload"),
|
||||
],
|
||||
)
|
||||
async def test_executor_cannot_emit_output_selection_events(
|
||||
event: WorkflowEvent[Any],
|
||||
caplog: "LogCaptureFixture",
|
||||
) -> None:
|
||||
async with make_context() as (ctx, runner_ctx):
|
||||
caplog.clear()
|
||||
with caplog.at_level("WARNING"):
|
||||
await ctx.add_event(event)
|
||||
|
||||
events: list[WorkflowEvent] = await runner_ctx.drain_events()
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "warning"
|
||||
data = events[0].data
|
||||
assert isinstance(data, str)
|
||||
assert "reserved for ctx.yield_output()" in data
|
||||
assert event.data not in [emitted.data for emitted in events]
|
||||
|
||||
|
||||
async def test_executor_emits_normal_event() -> None:
|
||||
async with make_context() as (ctx, runner_ctx):
|
||||
# Create a normal event to test event emission
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for WorkflowEvent factory methods and WorkflowEvent.emit() deprecation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import AgentResponse, Message
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
|
||||
def test_workflow_event_output_selection_factories_are_not_public() -> None:
|
||||
"""Callers should use ctx.yield_output(), not direct output/intermediate factories."""
|
||||
assert not hasattr(WorkflowEvent, "output")
|
||||
assert not hasattr(WorkflowEvent, "intermediate")
|
||||
|
||||
|
||||
def test_workflow_event_emit_emits_deprecation_warning() -> None:
|
||||
"""Calling WorkflowEvent.emit() raises a DeprecationWarning recommending the new path."""
|
||||
response = AgentResponse(messages=[Message(role="assistant", contents=["x"])])
|
||||
with pytest.warns(DeprecationWarning, match="yield_output"):
|
||||
WorkflowEvent.emit(executor_id="t", data=response)
|
||||
|
||||
|
||||
def test_workflow_event_emit_still_returns_data_event() -> None:
|
||||
"""During the deprecation window, emit() still produces a type='data' event."""
|
||||
response = AgentResponse(messages=[Message(role="assistant", contents=["x"])])
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
event = WorkflowEvent.emit(executor_id="t", data=response)
|
||||
assert event.type == "data"
|
||||
@@ -377,7 +377,7 @@ async def test_kwargs_preserved_on_response_continuation() -> None:
|
||||
from agent_framework import WorkflowBuilder
|
||||
|
||||
agent = _ApprovalCapturingAgent()
|
||||
workflow = WorkflowBuilder(start_executor=agent, output_executors=[agent]).build()
|
||||
workflow = WorkflowBuilder(start_executor=agent, output_from=[agent]).build()
|
||||
|
||||
# Initial run with function_invocation_kwargs — workflow should pause for approval
|
||||
fi_kwargs = {"token": "abc"}
|
||||
|
||||
@@ -72,6 +72,17 @@ def _stringify_name(value: Any) -> str:
|
||||
return value if isinstance(value, str) else str(value)
|
||||
|
||||
|
||||
def _workflow_output_metadata(event_type: Any, executor_id: Any) -> dict[str, Any] | None:
|
||||
"""Return metadata that preserves workflow yield designation on visible output."""
|
||||
if event_type not in ("output", "intermediate", "data"):
|
||||
return None
|
||||
return {
|
||||
"workflow_event_type": event_type,
|
||||
"workflow_output_kind": "terminal" if event_type == "output" else "intermediate",
|
||||
"executor_id": executor_id,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_content_recursive(value: Any) -> Any:
|
||||
"""Recursively serialize Agent Framework Content objects to JSON-compatible values.
|
||||
|
||||
@@ -200,15 +211,21 @@ class MessageMapper:
|
||||
try:
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, WorkflowEvent
|
||||
|
||||
# Handle WorkflowEvent with type='output' or 'data' wrapping AgentResponseUpdate
|
||||
# This must be checked BEFORE generic WorkflowEvent check
|
||||
# Note: AgentExecutor uses type='output' for streaming updates
|
||||
if isinstance(raw_event, WorkflowEvent) and raw_event.type in ("output", "data"):
|
||||
# Handle WorkflowEvent with type='output', 'intermediate', or 'data' wrapping
|
||||
# AgentResponseUpdate. This must be checked BEFORE generic WorkflowEvent check.
|
||||
# Note: AgentExecutor uses type='output' for streaming updates from designated
|
||||
# executors and type='intermediate' from non-designated executors. type='data'
|
||||
# is the deprecated legacy variant retained for backward compat.
|
||||
if isinstance(raw_event, WorkflowEvent) and raw_event.type in ("output", "intermediate", "data"):
|
||||
event_data = getattr(cast(Any, raw_event), "data", None)
|
||||
if isinstance(event_data, AgentResponseUpdate):
|
||||
# Preserve executor_id in context for proper output routing
|
||||
context["current_executor_id"] = getattr(cast(Any, raw_event), "executor_id", None)
|
||||
return await self._convert_agent_update(event_data, context)
|
||||
context["current_workflow_event_type"] = raw_event.type
|
||||
try:
|
||||
return await self._convert_agent_update(event_data, context)
|
||||
finally:
|
||||
context.pop("current_workflow_event_type", None)
|
||||
|
||||
# Handle complete agent response (AgentResponse) - for non-streaming agent execution
|
||||
if isinstance(raw_event, AgentResponse):
|
||||
@@ -633,6 +650,13 @@ class MessageMapper:
|
||||
# Check if we're in an executor context with an existing item
|
||||
executor_id = context.get("current_executor_id")
|
||||
executor_item_key = f"exec_item_{executor_id}" if executor_id else None
|
||||
workflow_metadata = _workflow_output_metadata(context.get("current_workflow_event_type"), executor_id)
|
||||
|
||||
if has_text_content and workflow_metadata is not None:
|
||||
current_metadata = context.get("current_message_workflow_metadata")
|
||||
if current_metadata != workflow_metadata:
|
||||
context.pop("current_message_id", None)
|
||||
context["current_message_workflow_metadata"] = workflow_metadata
|
||||
|
||||
# If we have an executor item, use it for deltas instead of creating a message
|
||||
if has_text_content and executor_item_key and executor_item_key in context:
|
||||
@@ -644,6 +668,15 @@ class MessageMapper:
|
||||
message_id = f"msg_{uuid4().hex[:8]}"
|
||||
context["current_message_id"] = message_id
|
||||
context["output_index"] = context.get("output_index", -1) + 1
|
||||
message_item = ResponseOutputMessage(
|
||||
type="message",
|
||||
id=message_id,
|
||||
role="assistant",
|
||||
content=[],
|
||||
status="in_progress",
|
||||
)
|
||||
if workflow_metadata is not None:
|
||||
cast(Any, message_item).metadata = workflow_metadata
|
||||
|
||||
# Add message output item
|
||||
events.append(
|
||||
@@ -651,9 +684,7 @@ class MessageMapper:
|
||||
type="response.output_item.added",
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
item=ResponseOutputMessage(
|
||||
type="message", id=message_id, role="assistant", content=[], status="in_progress"
|
||||
),
|
||||
item=message_item,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -675,17 +706,18 @@ class MessageMapper:
|
||||
# Special handling for TextContent to use proper delta events
|
||||
if content.type == "text" and "current_message_id" in context:
|
||||
# Stream text content via proper delta events
|
||||
events.append(
|
||||
ResponseTextDeltaEvent(
|
||||
type="response.output_text.delta",
|
||||
output_index=context["output_index"],
|
||||
content_index=context.get("content_index", 0),
|
||||
item_id=context["current_message_id"],
|
||||
delta=content.text,
|
||||
logprobs=[], # We don't have logprobs from Agent Framework
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
delta_event = ResponseTextDeltaEvent(
|
||||
type="response.output_text.delta",
|
||||
output_index=context["output_index"],
|
||||
content_index=context.get("content_index", 0),
|
||||
item_id=context["current_message_id"],
|
||||
delta=content.text,
|
||||
logprobs=[], # We don't have logprobs from Agent Framework
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
if workflow_metadata is not None:
|
||||
cast(Any, delta_event).metadata = workflow_metadata
|
||||
events.append(delta_event)
|
||||
elif content.type in self.content_mappers:
|
||||
# Use existing mappers for other content types
|
||||
mapped_events = await self.content_mappers[content.type](content, context)
|
||||
@@ -899,10 +931,14 @@ class MessageMapper:
|
||||
|
||||
return events
|
||||
|
||||
# Handle output events separately to preserve output data
|
||||
if event_type == "output":
|
||||
# Handle yield events (output / intermediate / data) by extracting visible
|
||||
# text from the payload. All three render as a visible message item so the
|
||||
# gap that previously dropped intermediate yields into generic completed-
|
||||
# trace events is closed.
|
||||
if event_type in ("output", "intermediate", "data"):
|
||||
output_data = getattr(event, "data", None)
|
||||
executor_id = getattr(event, "executor_id", "unknown")
|
||||
workflow_metadata = _workflow_output_metadata(event_type, executor_id)
|
||||
|
||||
if output_data is not None:
|
||||
# Import required types
|
||||
@@ -960,6 +996,8 @@ class MessageMapper:
|
||||
content=[text_content],
|
||||
status="completed",
|
||||
)
|
||||
if workflow_metadata is not None:
|
||||
cast(Any, output_message).metadata = workflow_metadata
|
||||
|
||||
# Emit output_item.added for each yield_output
|
||||
logger.debug(
|
||||
|
||||
+43
-2
@@ -96,6 +96,14 @@ function getStateBadgeClass(state: ExecutorState) {
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageText(item: unknown): string {
|
||||
const content = (item as { content?: Array<{ type: string; text?: string }> }).content;
|
||||
return content
|
||||
?.filter((content) => content.type === "output_text" && content.text)
|
||||
.map((content) => content.text)
|
||||
.join("\n") ?? "";
|
||||
}
|
||||
|
||||
function ExecutorRunItem({
|
||||
run,
|
||||
isExpanded,
|
||||
@@ -282,7 +290,12 @@ export function ExecutionTimeline({
|
||||
});
|
||||
} else if (item && item.type === "message" && "metadata" in item && item.id) {
|
||||
// Handle message items from Magentic agents
|
||||
const metadata = item.metadata as { agent_id?: string; source?: string } | undefined;
|
||||
const metadata = item.metadata as {
|
||||
agent_id?: string;
|
||||
executor_id?: string;
|
||||
source?: string;
|
||||
workflow_output_kind?: string;
|
||||
} | undefined;
|
||||
if (metadata?.agent_id && metadata?.source === "magentic") {
|
||||
const executorId = metadata.agent_id;
|
||||
const itemId = item.id;
|
||||
@@ -298,6 +311,21 @@ export function ExecutionTimeline({
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
|
||||
const executorId = metadata.executor_id;
|
||||
const itemId = item.id;
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
executorName: truncateText(executorId, 35),
|
||||
itemId,
|
||||
state: item.status === "completed" ? "completed" : "running",
|
||||
output: itemOutputs[itemId] || getMessageText(item),
|
||||
timestamp: uiTimestamp,
|
||||
runNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,7 +355,12 @@ export function ExecutionTimeline({
|
||||
}
|
||||
} else if (item && item.type === "message" && "metadata" in item && item.id) {
|
||||
// Handle message completion from Magentic agents
|
||||
const metadata = item.metadata as { agent_id?: string; source?: string } | undefined;
|
||||
const metadata = item.metadata as {
|
||||
agent_id?: string;
|
||||
executor_id?: string;
|
||||
source?: string;
|
||||
workflow_output_kind?: string;
|
||||
} | undefined;
|
||||
if (metadata?.agent_id && metadata?.source === "magentic") {
|
||||
const itemId = item.id;
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
@@ -336,6 +369,14 @@ export function ExecutionTimeline({
|
||||
existingRun.state = item.status === "completed" ? "completed" : "failed";
|
||||
existingRun.output = itemOutputs[itemId] || "";
|
||||
}
|
||||
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
|
||||
const itemId = item.id;
|
||||
const existingRun = runs.find((r) => r.itemId === itemId);
|
||||
|
||||
if (existingRun) {
|
||||
existingRun.state = item.status === "completed" ? "completed" : "failed";
|
||||
existingRun.output = itemOutputs[itemId] || getMessageText(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,6 +663,7 @@ export function WorkflowView({
|
||||
item &&
|
||||
item.type === "message" &&
|
||||
(!("metadata" in item) || !(item.metadata as { source?: string } | undefined)?.source) &&
|
||||
(item.metadata as { workflow_output_kind?: string } | undefined)?.workflow_output_kind !== "intermediate" &&
|
||||
"content" in item &&
|
||||
Array.isArray(item.content)
|
||||
) {
|
||||
@@ -1121,27 +1122,30 @@ export function WorkflowView({
|
||||
|
||||
// Handle workflow output messages
|
||||
if (item && item.type === "message" && "content" in item && Array.isArray(item.content)) {
|
||||
// Extract text from message content
|
||||
for (const content of item.content as Array<{ type: string; text?: string }>) {
|
||||
if (content.type === "output_text" && content.text) {
|
||||
const text = content.text; // Capture for closure
|
||||
// Append to workflow result (support multiple yield_output calls)
|
||||
setWorkflowResult((prev) => {
|
||||
if (prev && prev.length > 0) {
|
||||
// If there's existing output, add separator
|
||||
return prev + "\n\n" + text;
|
||||
}
|
||||
return text;
|
||||
});
|
||||
const metadata = item.metadata as { workflow_output_kind?: string } | undefined;
|
||||
if (metadata?.workflow_output_kind !== "intermediate") {
|
||||
// Extract text from message content
|
||||
for (const content of item.content as Array<{ type: string; text?: string }>) {
|
||||
if (content.type === "output_text" && content.text) {
|
||||
const text = content.text; // Capture for closure
|
||||
// Append to workflow result (support multiple yield_output calls)
|
||||
setWorkflowResult((prev) => {
|
||||
if (prev && prev.length > 0) {
|
||||
// If there's existing output, add separator
|
||||
return prev + "\n\n" + text;
|
||||
}
|
||||
return text;
|
||||
});
|
||||
|
||||
// Try to parse as JSON for structured metadata
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
workflowMetadata.current = parsed;
|
||||
// Try to parse as JSON for structured metadata
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
workflowMetadata.current = parsed;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, keep as text
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, keep as text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +376,7 @@ export interface ResponseTextDeltaEvent extends ResponseStreamEvent {
|
||||
content_index: number;
|
||||
sequence_number: number;
|
||||
logprobs: Record<string, unknown>[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// OpenAI Response for non-streaming
|
||||
@@ -397,6 +398,7 @@ export interface ResponseOutputMessage {
|
||||
content: ResponseOutputText[];
|
||||
id: string;
|
||||
status: "completed" | "failed" | "in_progress";
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ResponseOutputText {
|
||||
|
||||
@@ -517,7 +517,8 @@ async def test_magentic_executor_event_with_agent_delta_metadata(
|
||||
"""Test that WorkflowEvent[AgentResponseUpdate] with magentic_event_type='agent_delta' is handled correctly.
|
||||
|
||||
This tests the ACTUAL event format Magentic emits - not a fake MagenticAgentDeltaEvent class.
|
||||
Magentic uses WorkflowEvent.emit() with additional_properties containing magentic_event_type.
|
||||
Magentic emits type='intermediate' WorkflowEvent instances with additional_properties
|
||||
containing magentic_event_type.
|
||||
"""
|
||||
from agent_framework._types import AgentResponseUpdate
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
@@ -532,7 +533,7 @@ async def test_magentic_executor_event_with_agent_delta_metadata(
|
||||
"agent_id": "writer_agent",
|
||||
},
|
||||
)
|
||||
event = WorkflowEvent.emit(executor_id="magentic_executor", data=update)
|
||||
event = WorkflowEvent("intermediate", executor_id="magentic_executor", data=update)
|
||||
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
@@ -547,8 +548,8 @@ async def test_magentic_executor_event_with_agent_delta_metadata(
|
||||
async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test that WorkflowEvent[AgentResponseUpdate] with magentic_event_type='orchestrator_message' is handled.
|
||||
|
||||
Magentic emits orchestrator planning/instruction messages using WorkflowEvent.emit()
|
||||
with additional_properties containing magentic_event_type='orchestrator_message'.
|
||||
Magentic emits orchestrator planning/instruction messages using type='intermediate'
|
||||
WorkflowEvent instances with additional_properties containing magentic_event_type='orchestrator_message'.
|
||||
"""
|
||||
from agent_framework._types import AgentResponseUpdate
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
@@ -564,7 +565,7 @@ async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_r
|
||||
"orchestrator_id": "magentic_orchestrator",
|
||||
},
|
||||
)
|
||||
event = WorkflowEvent.emit(executor_id="magentic_orchestrator", data=update)
|
||||
event = WorkflowEvent("intermediate", executor_id="magentic_orchestrator", data=update)
|
||||
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
@@ -595,7 +596,7 @@ async def test_magentic_events_use_same_event_class_as_other_workflows(
|
||||
contents=[Content.from_text(text="Regular workflow response")],
|
||||
role="assistant",
|
||||
)
|
||||
regular_event = WorkflowEvent.emit(executor_id="regular_executor", data=regular_update)
|
||||
regular_event = WorkflowEvent("intermediate", executor_id="regular_executor", data=regular_update)
|
||||
|
||||
# 2. Magentic workflow (with additional_properties)
|
||||
magentic_update = AgentResponseUpdate(
|
||||
@@ -603,7 +604,7 @@ async def test_magentic_events_use_same_event_class_as_other_workflows(
|
||||
role="assistant",
|
||||
additional_properties={"magentic_event_type": "agent_delta"},
|
||||
)
|
||||
magentic_event = WorkflowEvent.emit(executor_id="magentic_executor", data=magentic_update)
|
||||
magentic_event = WorkflowEvent("intermediate", executor_id="magentic_executor", data=magentic_update)
|
||||
|
||||
# Both should be the SAME class
|
||||
assert type(regular_event) is type(magentic_event)
|
||||
@@ -653,7 +654,7 @@ async def test_workflow_output_event(mapper: MessageMapper, test_request: AgentF
|
||||
"""Test output event (type='output') is converted to output_item.added."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
event = WorkflowEvent.output(executor_id="final_executor", data="Final workflow output")
|
||||
event = WorkflowEvent("output", executor_id="final_executor", data="Final workflow output")
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# output event (type='output') should emit output_item.added
|
||||
@@ -662,6 +663,9 @@ async def test_workflow_output_event(mapper: MessageMapper, test_request: AgentF
|
||||
# Check item contains the output text
|
||||
item = events[0].item
|
||||
assert item.type == "message"
|
||||
assert item.metadata["workflow_event_type"] == "output"
|
||||
assert item.metadata["workflow_output_kind"] == "terminal"
|
||||
assert item.metadata["executor_id"] == "final_executor"
|
||||
assert any("Final workflow output" in str(c) for c in item.content)
|
||||
|
||||
|
||||
@@ -675,13 +679,104 @@ async def test_workflow_output_event_with_list_data(mapper: MessageMapper, test_
|
||||
Message(role="user", contents=[Content.from_text(text="Hello")]),
|
||||
Message(role="assistant", contents=[Content.from_text(text="World")]),
|
||||
]
|
||||
event = WorkflowEvent.output(executor_id="complete", data=messages)
|
||||
event = WorkflowEvent("output", executor_id="complete", data=messages)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.output_item.added"
|
||||
|
||||
|
||||
async def test_workflow_intermediate_event_with_agent_response_update_dispatched(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""A WorkflowEvent with type='intermediate' wrapping an AgentResponseUpdate is mapped
|
||||
just like type='output' / type='data' — to OpenAI text-delta events."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="intermediate progress")],
|
||||
role="assistant",
|
||||
author_name="non-designated-agent",
|
||||
)
|
||||
event = WorkflowEvent("intermediate", executor_id="non_designated", data=update)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
assert len(events) >= 1
|
||||
added_events = [e for e in events if getattr(e, "type", "") == "response.output_item.added"]
|
||||
assert added_events
|
||||
item = added_events[0].item
|
||||
assert item.metadata["workflow_event_type"] == "intermediate"
|
||||
assert item.metadata["workflow_output_kind"] == "intermediate"
|
||||
assert item.metadata["executor_id"] == "non_designated"
|
||||
text_events = [e for e in events if getattr(e, "type", "") == "response.output_text.delta"]
|
||||
assert len(text_events) >= 1
|
||||
assert text_events[0].metadata["workflow_event_type"] == "intermediate"
|
||||
assert text_events[0].metadata["workflow_output_kind"] == "intermediate"
|
||||
assert text_events[0].metadata["executor_id"] == "non_designated"
|
||||
assert text_events[0].delta == "intermediate progress"
|
||||
|
||||
|
||||
async def test_workflow_intermediate_event_with_string_payload_renders_visible_text(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""A WorkflowEvent with type='intermediate' wrapping a plain string surfaces as a
|
||||
visible output item — not a generic completed-trace event. Without this, executors
|
||||
that ``await ctx.yield_output("plan: …")`` from non-designated nodes are silently
|
||||
dropped in DevUI."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
event = WorkflowEvent("intermediate", executor_id="planner", data="plan: starting work")
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.output_item.added"
|
||||
item = events[0].item
|
||||
assert item.type == "message"
|
||||
assert item.metadata["workflow_event_type"] == "intermediate"
|
||||
assert item.metadata["workflow_output_kind"] == "intermediate"
|
||||
assert item.metadata["executor_id"] == "planner"
|
||||
assert any("plan: starting work" in str(c) for c in item.content)
|
||||
|
||||
|
||||
async def test_workflow_intermediate_event_with_message_payload_renders_visible_text(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""type='intermediate' wrapping a Message surfaces visibly — same path as type='output'."""
|
||||
from agent_framework import Message
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
msg = Message(role="assistant", contents=[Content.from_text(text="research note")])
|
||||
event = WorkflowEvent("intermediate", executor_id="researcher", data=msg)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.output_item.added"
|
||||
item = events[0].item
|
||||
assert item.metadata["workflow_event_type"] == "intermediate"
|
||||
assert item.metadata["workflow_output_kind"] == "intermediate"
|
||||
assert item.metadata["executor_id"] == "researcher"
|
||||
assert any("research note" in str(c) for c in item.content)
|
||||
|
||||
|
||||
async def test_workflow_data_event_keeps_intermediate_compatibility_metadata(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""Deprecated type='data' workflow events remain visible and explicitly intermediate."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
with pytest.warns(DeprecationWarning):
|
||||
event = WorkflowEvent.emit(executor_id="legacy", data="legacy progress")
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.output_item.added"
|
||||
item = events[0].item
|
||||
assert item.metadata["workflow_event_type"] == "data"
|
||||
assert item.metadata["workflow_output_kind"] == "intermediate"
|
||||
assert item.metadata["executor_id"] == "legacy"
|
||||
assert any("legacy progress" in str(c) for c in item.content)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# failed event (type='failed') Tests
|
||||
# =============================================================================
|
||||
|
||||
@@ -1816,7 +1816,7 @@ class TestEvaluateWorkflow:
|
||||
WorkflowEvent.executor_completed("writer", [aer1]),
|
||||
WorkflowEvent.executor_invoked("reviewer", [aer1]),
|
||||
WorkflowEvent.executor_completed("reviewer", [aer2]),
|
||||
WorkflowEvent.output("end", final_output),
|
||||
WorkflowEvent("output", executor_id="end", data=final_output),
|
||||
]
|
||||
wf_result = WorkflowRunResult(events, [])
|
||||
|
||||
@@ -1845,7 +1845,7 @@ class TestEvaluateWorkflow:
|
||||
events = [
|
||||
WorkflowEvent.executor_invoked("agent", "Test query"),
|
||||
WorkflowEvent.executor_completed("agent", [aer]),
|
||||
WorkflowEvent.output("end", final_output),
|
||||
WorkflowEvent("output", executor_id="end", data=final_output),
|
||||
]
|
||||
wf_result = WorkflowRunResult(events, [])
|
||||
|
||||
@@ -1875,7 +1875,7 @@ class TestEvaluateWorkflow:
|
||||
WorkflowEvent.executor_completed("input-conversation", None),
|
||||
WorkflowEvent.executor_invoked("planner", "Plan trip"),
|
||||
WorkflowEvent.executor_completed("planner", [aer]),
|
||||
WorkflowEvent.output("end", final_output),
|
||||
WorkflowEvent("output", executor_id="end", data=final_output),
|
||||
]
|
||||
wf_result = WorkflowRunResult(events, [])
|
||||
|
||||
@@ -1941,7 +1941,7 @@ class TestEvaluateWorkflow:
|
||||
WorkflowEvent.executor_completed("input-conversation", None),
|
||||
WorkflowEvent.executor_invoked("researcher", "What's the weather?"),
|
||||
WorkflowEvent.executor_completed("researcher", [aer]),
|
||||
WorkflowEvent.output("end", [Message("assistant", ["Weather is sunny"])]),
|
||||
WorkflowEvent("output", executor_id="end", data=[Message("assistant", ["Weather is sunny"])]),
|
||||
]
|
||||
wf_result = WorkflowRunResult(events, [])
|
||||
|
||||
@@ -2050,7 +2050,7 @@ class TestEvaluateWorkflow:
|
||||
events = [
|
||||
WorkflowEvent.executor_invoked("agent", "Test query"),
|
||||
WorkflowEvent.executor_completed("agent", [aer]),
|
||||
WorkflowEvent.output("end", final_output),
|
||||
WorkflowEvent("output", executor_id="end", data=final_output),
|
||||
]
|
||||
wf_result = WorkflowRunResult(events, [])
|
||||
|
||||
@@ -2089,7 +2089,7 @@ class TestEvaluateWorkflow:
|
||||
events = [
|
||||
WorkflowEvent.executor_invoked("agent", "Test query"),
|
||||
WorkflowEvent.executor_completed("agent", [aer]),
|
||||
WorkflowEvent.output("end", final_output),
|
||||
WorkflowEvent("output", executor_id="end", data=final_output),
|
||||
]
|
||||
wf_result = WorkflowRunResult(events, [])
|
||||
|
||||
|
||||
@@ -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"])
|
||||
@@ -89,6 +89,7 @@ Write workflows as plain Python async functions — no graph concepts, no execut
|
||||
| Multi-Selection Edge Group | [control-flow/multi_selection_edge_group.py](./control-flow/multi_selection_edge_group.py) | Select one or many targets dynamically (subset fan-out) |
|
||||
| Simple Loop | [control-flow/simple_loop.py](./control-flow/simple_loop.py) | Feedback loop where an agent judges ABOVE/BELOW/MATCHED |
|
||||
| Workflow Cancellation | [control-flow/workflow_cancellation.py](./control-flow/workflow_cancellation.py) | Cancel a running workflow using asyncio tasks |
|
||||
| Workflow and Intermediate Outputs | [control-flow/intermediate_vs_terminal_outputs.py](./control-flow/intermediate_vs_terminal_outputs.py) | Select Workflow Output and Intermediate Output executors; hide unselected yields; map Intermediate Output events to `text_reasoning` content via `as_agent` |
|
||||
|
||||
### human-in-the-loop
|
||||
|
||||
@@ -118,6 +119,43 @@ For additional observability samples in Agent Framework, see the [observability
|
||||
Orchestration-focused samples (Sequential, Concurrent, Handoff, GroupChat, Magentic), including builder-based
|
||||
`workflow.as_agent(...)` variants, are documented in the [orchestrations](./orchestrations/README.md) directory.
|
||||
|
||||
### output selection
|
||||
|
||||
Workflow Output selection controls which `ctx.yield_output(...)` calls are visible to callers as `type='output'`
|
||||
events and through `WorkflowRunResult.get_outputs()`. The core rule is that `output_from` is an allow-list for
|
||||
Workflow Output, not a routing rule for every other executor output. Unselected executor payloads are hidden unless
|
||||
`intermediate_output_from` explicitly selects them as Intermediate Output.
|
||||
|
||||
Use `output_from` and `intermediate_output_from` as the canonical API:
|
||||
|
||||
| Selection | Workflow Output | Intermediate Output | Hidden payloads |
|
||||
| --- | --- | --- | --- |
|
||||
| Omit both selections | Every executor `yield_output`; emits a deprecation warning | None | None |
|
||||
| `output_from="all"` | Every executor `yield_output`; no warning | None | None |
|
||||
| `output_from=[answerer]` | Only `answerer` | None | All other executor payloads |
|
||||
| `output_from=[answerer], intermediate_output_from="all_other"` | Only `answerer` | Every output-capable executor not selected by `output_from` | None |
|
||||
| `intermediate_output_from="all_other"` | None | Every output-capable executor | None |
|
||||
| `output_from=[], intermediate_output_from="all_other"` | None | Every output-capable executor | None |
|
||||
| `output_from=[answerer], intermediate_output_from=[planner, researcher]` | Only `answerer` | `planner` and `researcher` | Any other executor 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 executor in both selections | One payload cannot be both Workflow Output and Intermediate Output |
|
||||
| Duplicate executor selections | Duplicates are treated as configuration errors |
|
||||
| Unknown executor selections | Typos and missing participants are rejected |
|
||||
| `output_from=[], intermediate_output_from=[]` | Both explicit selections are empty |
|
||||
|
||||
Compatibility aliases such as `output_executors` emit deprecation warnings where supported. New samples and
|
||||
applications should use `output_from` and `intermediate_output_from`.
|
||||
|
||||
When a workflow is wrapped with `workflow.as_agent()`, Workflow Output becomes normal agent text content. Intermediate
|
||||
Output becomes `text_reasoning` content, so `AgentResponse.text` remains focused on the caller-facing answer while
|
||||
callers can still inspect progress or supporting work from the response messages.
|
||||
|
||||
### parallelism
|
||||
|
||||
| Sample | File | Concepts |
|
||||
@@ -174,7 +212,7 @@ Sequential orchestration uses a few small adapter nodes for plumbing:
|
||||
|
||||
- "input-conversation" normalizes input to `list[Message]`
|
||||
- "to-conversation:<participant>" converts agent responses into the shared conversation
|
||||
- "complete" publishes the final output event (type='output')
|
||||
- "complete" publishes the Workflow Output event (`type='output'`)
|
||||
These may appear in event streams (executor_invoked/executor_completed). They're analogous to
|
||||
concurrent’s dispatcher and aggregator and can be ignored if you only care about agent activity.
|
||||
|
||||
|
||||
@@ -54,11 +54,11 @@ async def main() -> None:
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# intermediate_outputs=True: Enable intermediate outputs to observe the conversation as it unfolds
|
||||
# (Intermediate outputs will be emitted as WorkflowOutputEvent events)
|
||||
# Mark participant responses as intermediate so workflow.as_agent() maps
|
||||
# them to text_reasoning content while the final answer remains normal text.
|
||||
workflow = GroupChatBuilder(
|
||||
participants=[researcher, writer],
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[researcher, writer],
|
||||
orchestrator_agent=Agent(
|
||||
client=_orch_client,
|
||||
name="Orchestrator",
|
||||
|
||||
@@ -72,11 +72,11 @@ async def main() -> None:
|
||||
|
||||
print("\nBuilding Magentic Workflow...")
|
||||
|
||||
# intermediate_outputs=True: Enable intermediate outputs to observe the conversation as it unfolds
|
||||
# (Intermediate outputs will be emitted as WorkflowOutputEvent events)
|
||||
# Mark participant responses as intermediate so workflow.as_agent() maps
|
||||
# them to text_reasoning content while the final answer remains normal text.
|
||||
workflow = MagenticBuilder(
|
||||
participants=[researcher_agent, coder_agent],
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[researcher_agent, coder_agent],
|
||||
manager_agent=manager_agent,
|
||||
max_round_count=10,
|
||||
max_stall_count=3,
|
||||
|
||||
@@ -77,9 +77,9 @@ async def main() -> None:
|
||||
|
||||
Note:
|
||||
`workflow.as_agent()` returns ONLY the final agent's response (the "answer") — the prior agents' work
|
||||
is not included in the response. To observe intermediate agents while running as an agent, build with
|
||||
`SequentialBuilder(participants=[...], intermediate_outputs=True)`; the intermediate replies are then
|
||||
surfaced as `data` events and merged into the AgentResponse.
|
||||
is not included in the response. To preserve earlier participant replies while running as an agent, build with
|
||||
`SequentialBuilder(participants=[...], intermediate_output_from=[writer])`; intermediate workflow events become
|
||||
`text_reasoning` content on the AgentResponse, while `.text` remains terminal-output only.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import (
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
executor,
|
||||
)
|
||||
from typing_extensions import Never
|
||||
|
||||
"""
|
||||
Sample: Workflow Output vs Intermediate Output labeling
|
||||
|
||||
What this sample shows
|
||||
- How ``WorkflowBuilder(output_from=[...])`` designates which executors emit
|
||||
Workflow Output.
|
||||
- How ``WorkflowBuilder(intermediate_output_from=[...])`` designates which executor
|
||||
yields surface as Intermediate Output (``type='intermediate'`` events).
|
||||
- How unlisted executor yields are hidden from caller-facing output/intermediate
|
||||
events in explicit designation mode.
|
||||
- How the same workflow wrapped via ``workflow.as_agent()`` translates intermediate
|
||||
events to ``text_reasoning`` content so existing ``.text`` accessors keep
|
||||
returning Workflow Output only.
|
||||
- How a sub-workflow embedded via ``WorkflowExecutor`` bubbles its intermediate
|
||||
emissions up through the parent's event stream, attributed to the
|
||||
``WorkflowExecutor`` id rather than the child's internal executor ids.
|
||||
|
||||
The output selection contract:
|
||||
- Compatibility mode: when neither ``output_from`` nor ``intermediate_output_from``
|
||||
is provided, every ``yield_output`` produces Workflow Output and a deprecation
|
||||
warning points to explicit selection.
|
||||
- Explicit selection mode: provide either ``output_from`` or
|
||||
``intermediate_output_from``. Executors selected by ``output_from`` emit Workflow Output
|
||||
(``type='output'`` events); executors selected by ``intermediate_output_from`` emit
|
||||
Intermediate Output (``type='intermediate'`` events); unselected executor yields are
|
||||
hidden from the stream and ``WorkflowRunResult`` output accessors.
|
||||
- Validation: explicit selections must not both be empty; duplicate executor entries,
|
||||
overlap between Workflow Output and Intermediate Output, unknown executors, invalid
|
||||
literals, and selected executors without workflow output types are rejected.
|
||||
|
||||
Prerequisites
|
||||
- No external services required.
|
||||
"""
|
||||
|
||||
|
||||
@executor(id="planner")
|
||||
async def planner(messages: list[Message], ctx: WorkflowContext[list[Message], str]) -> None:
|
||||
"""Intermediate step: emits a visible progress note, then forwards."""
|
||||
prompt = messages[0].text if messages else ""
|
||||
await ctx.yield_output(f"plan: starting work on '{prompt}'")
|
||||
await ctx.send_message(messages)
|
||||
|
||||
|
||||
@executor(id="researcher")
|
||||
async def researcher(messages: list[Message], ctx: WorkflowContext[list[Message], str]) -> None:
|
||||
"""Intermediate step: emits visible progress, then forwards."""
|
||||
prompt = messages[0].text if messages else ""
|
||||
await ctx.yield_output(f"research: gathering data for '{prompt}'")
|
||||
await ctx.send_message(messages)
|
||||
|
||||
|
||||
@executor(id="answerer")
|
||||
async def answerer(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Designated Workflow Output: emits the workflow's answer."""
|
||||
prompt = messages[0].text if messages else ""
|
||||
await ctx.yield_output(f"final answer to '{prompt}': 42")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Build with explicit Workflow Output and Intermediate Output selections.
|
||||
# `answerer` produces type='output' events; planner and researcher produce
|
||||
# visible type='intermediate' events.
|
||||
workflow = (
|
||||
WorkflowBuilder(
|
||||
start_executor=planner,
|
||||
output_from=[answerer],
|
||||
intermediate_output_from=[planner, researcher],
|
||||
)
|
||||
.add_edge(planner, researcher)
|
||||
.add_edge(researcher, answerer)
|
||||
.build()
|
||||
)
|
||||
|
||||
initial = [Message(role="user", contents=["life, the universe, and everything"])]
|
||||
|
||||
print("=== Streaming events (workflow.run(stream=True)) ===")
|
||||
async for event in workflow.run(initial, stream=True):
|
||||
if event.type == "intermediate":
|
||||
print(f" [intermediate] {event.executor_id}: {event.data}")
|
||||
elif event.type == "output":
|
||||
print(f" [output] {event.executor_id}: {event.data}")
|
||||
|
||||
# WorkflowRunResult.get_outputs() filters to type='output' events, so it
|
||||
# only returns the selected Workflow Output yield.
|
||||
print("\n=== Non-streaming run().get_outputs() ===")
|
||||
result = await workflow.run(initial)
|
||||
print(f" outputs: {result.get_outputs()}")
|
||||
|
||||
# When the same workflow is wrapped via as_agent(), intermediate events
|
||||
# surface as ``text_reasoning`` content; Workflow Output surfaces as
|
||||
# ``text`` content. Existing callers reading ``response.text`` get only
|
||||
# the selected Workflow Output because ``.text`` filters to text content.
|
||||
print("\n=== workflow.as_agent() -- intermediate -> text_reasoning content ===")
|
||||
agent = workflow.as_agent("planner-agent")
|
||||
response = await agent.run("life, the universe, and everything")
|
||||
print(f" response.text (Workflow Output only): {response.text!r}")
|
||||
reasoning = " | ".join(c.text for m in response.messages for c in m.contents if c.type == "text_reasoning")
|
||||
print(f" reasoning content (intermediates): {reasoning!r}")
|
||||
|
||||
# Embed the same workflow as a node inside a larger workflow via WorkflowExecutor.
|
||||
# Child intermediate emissions are forwarded to the parent's event stream with the
|
||||
# WorkflowExecutor's id as the source, so outer callers don't have to know the
|
||||
# child's internal executor layout. The 'intermediate' label is preserved across
|
||||
# the boundary regardless of how the parent designates the WorkflowExecutor.
|
||||
print("\n=== Embedding as a sub-workflow -- intermediates bubble up ===")
|
||||
sub = WorkflowExecutor(workflow, id="sub")
|
||||
|
||||
@executor(id="parent_sink")
|
||||
async def parent_sink(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(message)
|
||||
|
||||
parent_workflow = WorkflowBuilder(start_executor=sub, output_from=[parent_sink]).add_edge(sub, parent_sink).build()
|
||||
|
||||
async for event in parent_workflow.run(initial, stream=True):
|
||||
if event.type == "intermediate":
|
||||
print(f" [intermediate] {event.executor_id}: {event.data}")
|
||||
elif event.type == "output":
|
||||
print(f" [output] {event.executor_id}: {event.data}")
|
||||
|
||||
"""
|
||||
Sample output:
|
||||
|
||||
=== Streaming events (workflow.run(stream=True)) ===
|
||||
[intermediate] planner: plan: starting work on 'life, the universe, and everything'
|
||||
[intermediate] researcher: research: gathering data for 'life, the universe, and everything'
|
||||
[output] answerer: final answer to 'life, the universe, and everything': 42
|
||||
|
||||
=== Non-streaming run().get_outputs() ===
|
||||
outputs: ["final answer to 'life, the universe, and everything': 42"]
|
||||
|
||||
=== workflow.as_agent() -- intermediate -> text_reasoning content ===
|
||||
response.text (Workflow Output only): "final answer to 'life, the universe, and everything': 42"
|
||||
reasoning content (intermediates): "plan: starting work on ... | research: gathering data for ..."
|
||||
|
||||
=== Embedding as a sub-workflow -- intermediates bubble up ===
|
||||
[intermediate] sub: plan: starting work on 'life, the universe, and everything'
|
||||
[intermediate] sub: research: gathering data for 'life, the universe, and everything'
|
||||
[output] parent_sink: final answer to 'life, the universe, and everything': 42
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -248,7 +248,7 @@ async def main() -> None:
|
||||
|
||||
# Build the workflow
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=email_processor, output_executors=[conclude_workflow])
|
||||
WorkflowBuilder(start_executor=email_processor, output_from=[conclude_workflow])
|
||||
.add_edge(email_processor, email_writer_agent)
|
||||
.add_edge(email_writer_agent, conclude_workflow)
|
||||
.build()
|
||||
|
||||
@@ -81,6 +81,41 @@ from agent_framework.orchestrations import (
|
||||
|
||||
## Tips
|
||||
|
||||
**Participant output selection**: Orchestration builders use participant-oriented names for Workflow Output selection.
|
||||
Use `output_from=[...]` when participant responses should be Workflow Output (`type='output'` events), and
|
||||
`intermediate_output_from=[...]` when participant responses should be Intermediate Output (`type='intermediate'`
|
||||
events). `output_from` is an allow-list for Workflow Output, not a routing rule for every other participant output.
|
||||
Unselected participant responses are hidden unless `intermediate_output_from` selects them.
|
||||
|
||||
| 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 |
|
||||
|
||||
By default, Sequential keeps the last participant as Workflow Output. Concurrent, GroupChat, and Magentic keep their
|
||||
synthetic aggregator/orchestrator/manager executors as Workflow Output, while participant responses stay hidden unless
|
||||
selected. Handoff keeps participants as Workflow Output by default.
|
||||
|
||||
When an orchestration workflow is exposed via `workflow.as_agent()`, Workflow Output becomes normal text content in
|
||||
the `AgentResponse`; Intermediate Output becomes `text_reasoning` content. This preserves `.text` while making
|
||||
selected progress available for callers that inspect message contents.
|
||||
|
||||
**Magentic checkpointing tip**: Treat `MagenticBuilder.participants` keys as stable identifiers. When resuming from a checkpoint, the rebuilt workflow must reuse the same participant names; otherwise the checkpoint cannot be applied and the run will fail fast.
|
||||
|
||||
**Handoff workflow tip**: Handoff workflows maintain the full conversation history including any `Message.additional_properties` emitted by your agents. This ensures routing metadata remains intact across all agent transitions. For specialist-to-specialist handoffs, use `.add_handoff(source, targets)` to configure which agents can route to which others with a fluent, type-safe API.
|
||||
@@ -90,7 +125,7 @@ from agent_framework.orchestrations import (
|
||||
**Sequential orchestration note**: Sequential orchestration uses a few small adapter nodes for plumbing:
|
||||
- `input-conversation` normalizes input to `list[Message]`
|
||||
- `to-conversation:<participant>` converts agent responses into the shared conversation
|
||||
- `complete` publishes the final output event (type='output')
|
||||
- `complete` publishes the Workflow Output event (`type='output'`)
|
||||
|
||||
These may appear in event streams (executor_invoked/executor_completed). They're analogous to concurrent's dispatcher and aggregator and can be ignored if you only care about agent activity.
|
||||
|
||||
|
||||
@@ -78,13 +78,14 @@ async def main() -> None:
|
||||
# Build the group chat workflow
|
||||
# termination_condition: stop after 4 assistant messages
|
||||
# (The agent orchestrator will intelligently decide when to end before this limit but just in case)
|
||||
# intermediate_outputs=True: Enable intermediate outputs to observe the conversation as it unfolds
|
||||
# (Intermediate outputs will be emitted as WorkflowOutputEvent events)
|
||||
# Mark participant responses as intermediate so the stream shows the
|
||||
# conversation as it unfolds while the orchestrator's transcript remains the
|
||||
# terminal workflow output.
|
||||
workflow = (
|
||||
GroupChatBuilder(
|
||||
participants=[researcher, writer],
|
||||
termination_condition=lambda messages: sum(1 for msg in messages if msg.role == "assistant") >= 4,
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[researcher, writer],
|
||||
orchestrator_agent=orchestrator_agent,
|
||||
)
|
||||
# Set a hard termination condition: stop after 4 assistant messages
|
||||
@@ -102,7 +103,7 @@ async def main() -> None:
|
||||
# Keep track of the last response to format output nicely in streaming mode
|
||||
last_response_id: str | None = None
|
||||
async for event in workflow.run(task, stream=True):
|
||||
if event.type == "output":
|
||||
if event.type in ("intermediate", "output"):
|
||||
data = event.data
|
||||
if isinstance(data, AgentResponseUpdate):
|
||||
rid = data.response_id
|
||||
|
||||
@@ -219,13 +219,16 @@ Share your perspective authentically. Feel free to:
|
||||
)
|
||||
|
||||
# termination_condition: stop after 10 assistant messages
|
||||
# intermediate_outputs=True: Enable intermediate outputs to observe the conversation as it unfolds
|
||||
# (Intermediate outputs will be emitted as WorkflowOutputEvent events)
|
||||
# Mark participant responses as intermediate so the stream shows the
|
||||
# conversation as it unfolds while the orchestrator's transcript remains the
|
||||
# terminal workflow output.
|
||||
workflow = (
|
||||
GroupChatBuilder(
|
||||
participants=[farmer, developer, teacher, activist, spiritual_leader, artist, immigrant, doctor],
|
||||
termination_condition=lambda messages: sum(1 for msg in messages if msg.role == "assistant") >= 10,
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[
|
||||
"all",
|
||||
],
|
||||
orchestrator_agent=moderator,
|
||||
)
|
||||
.with_termination_condition(lambda messages: sum(1 for msg in messages if msg.role == "assistant") >= 10)
|
||||
@@ -254,7 +257,7 @@ Share your perspective authentically. Feel free to:
|
||||
# Keep track of the last response to format output nicely in streaming mode
|
||||
last_response_id: str | None = None
|
||||
async for event in workflow.run(f"Please begin the discussion on: {topic}", stream=True):
|
||||
if event.type == "output":
|
||||
if event.type in ("intermediate", "output"):
|
||||
data = event.data
|
||||
if isinstance(data, AgentResponseUpdate):
|
||||
rid = data.response_id
|
||||
|
||||
@@ -96,13 +96,14 @@ async def main() -> None:
|
||||
# This will end the conversation after the expert has spoken 2 times (one iteration loop)
|
||||
# Note: it's possible that the expert gets it right the first time and the other participants
|
||||
# have nothing to add, but for demo purposes we want to see at least one full round of interaction.
|
||||
# intermediate_outputs=True: Enable intermediate outputs to observe the conversation as it unfolds
|
||||
# (Intermediate outputs will be emitted as WorkflowOutputEvent events)
|
||||
# Mark participant responses as intermediate so the stream shows the
|
||||
# conversation as it unfolds while the orchestrator's transcript remains the
|
||||
# terminal workflow output.
|
||||
workflow = (
|
||||
GroupChatBuilder(
|
||||
participants=[expert, verifier, clarifier, skeptic],
|
||||
termination_condition=lambda conversation: len(conversation) >= 6,
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[expert, verifier, clarifier, skeptic],
|
||||
selection_func=round_robin_selector,
|
||||
)
|
||||
# Set a hard termination condition: stop after 6 messages (user task + one full rounds + 1)
|
||||
@@ -123,7 +124,7 @@ async def main() -> None:
|
||||
# Keep track of the last response to format output nicely in streaming mode
|
||||
last_response_id: str | None = None
|
||||
async for event in workflow.run(task, stream=True):
|
||||
if event.type == "output":
|
||||
if event.type in ("intermediate", "output"):
|
||||
data = event.data
|
||||
if isinstance(data, AgentResponseUpdate):
|
||||
rid = data.response_id
|
||||
|
||||
@@ -88,11 +88,12 @@ async def main() -> None:
|
||||
|
||||
print("\nBuilding Magentic Workflow...")
|
||||
|
||||
# intermediate_outputs=True: Enable intermediate outputs to observe the conversation as it unfolds
|
||||
# (Intermediate outputs will be emitted as WorkflowOutputEvent events)
|
||||
# Mark participant responses as intermediate so the stream shows the
|
||||
# conversation as it unfolds while the manager's final answer remains the
|
||||
# terminal workflow output.
|
||||
workflow = MagenticBuilder(
|
||||
participants=[researcher_agent, coder_agent],
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[researcher_agent, coder_agent],
|
||||
manager_agent=manager_agent,
|
||||
max_round_count=10,
|
||||
max_stall_count=3,
|
||||
@@ -115,7 +116,7 @@ async def main() -> None:
|
||||
last_response_id: str | None = None
|
||||
output_event: WorkflowEvent | None = None
|
||||
async for event in workflow.run(task, stream=True):
|
||||
if event.type == "output" and isinstance(event.data, AgentResponseUpdate):
|
||||
if event.type in ("intermediate", "output") and isinstance(event.data, AgentResponseUpdate):
|
||||
response_id = event.data.response_id
|
||||
if response_id != last_response_id:
|
||||
if last_response_id is not None:
|
||||
|
||||
@@ -55,7 +55,7 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
|
||||
if event.type == "request_info" and event.request_type is MagenticPlanReviewRequest:
|
||||
requests[event.request_id] = cast(MagenticPlanReviewRequest, event.data)
|
||||
|
||||
if event.type == "output":
|
||||
if event.type in ("intermediate", "output"):
|
||||
data = event.data
|
||||
if isinstance(data, AgentResponseUpdate):
|
||||
rid = data.response_id
|
||||
@@ -129,13 +129,14 @@ async def main() -> None:
|
||||
|
||||
print("\nBuilding Magentic Workflow with Human Plan Review...")
|
||||
|
||||
# enable_plan_review=True: Request human input for plan review
|
||||
# intermediate_outputs=True: Enable intermediate outputs to observe the conversation as it unfolds
|
||||
# (Intermediate outputs will be emitted as WorkflowOutputEvent events)
|
||||
# enable_plan_review=True: Request human input for plan review.
|
||||
# Mark participant responses as intermediate so the stream shows the
|
||||
# conversation as it unfolds while the manager's final answer remains the
|
||||
# terminal workflow output.
|
||||
workflow = MagenticBuilder(
|
||||
participants=[researcher_agent, analyst_agent],
|
||||
enable_plan_review=True,
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[researcher_agent, analyst_agent],
|
||||
manager_agent=manager_agent,
|
||||
max_round_count=10,
|
||||
max_stall_count=1,
|
||||
|
||||
@@ -66,13 +66,13 @@ async def main() -> None:
|
||||
workflow = SequentialBuilder(
|
||||
participants=[writer, translator, reviewer],
|
||||
chain_only_agent_responses=True,
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[writer, translator],
|
||||
).build()
|
||||
|
||||
# 3) Run and collect outputs
|
||||
last_agent: str | None = None
|
||||
async for event in workflow.run("Write a tagline for a budget-friendly eBike.", stream=True):
|
||||
if event.type == "output" and isinstance(event.data, AgentResponseUpdate):
|
||||
if event.type in ("intermediate", "output") and isinstance(event.data, AgentResponseUpdate):
|
||||
if event.data.author_name != last_agent:
|
||||
last_agent = event.data.author_name
|
||||
print()
|
||||
|
||||
@@ -52,9 +52,9 @@ def main():
|
||||
workflow_agent = (
|
||||
WorkflowBuilder(
|
||||
start_executor=writer_executor,
|
||||
# Limiting the output to only the final formatted result.
|
||||
# If this is not set, all intermediate results will be included in the output.
|
||||
output_executors=[format_executor],
|
||||
# Select only the formatted result as Workflow Output.
|
||||
# Unselected executor payloads are hidden unless selected as Intermediate Output.
|
||||
output_from=[format_executor],
|
||||
)
|
||||
.add_edge(writer_executor, legal_executor)
|
||||
.add_edge(legal_executor, format_executor)
|
||||
|
||||
@@ -8,7 +8,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
|
||||
|--------|-------------|
|
||||
| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting |
|
||||
| [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations |
|
||||
| [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative |
|
||||
| [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative, explicit output designation |
|
||||
| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A |
|
||||
| [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos |
|
||||
|
||||
|
||||
@@ -248,13 +248,13 @@ async def run_agent_framework_example(task: str) -> str:
|
||||
participants=[researcher, planner],
|
||||
orchestrator_agent=Agent(client=client),
|
||||
max_rounds=8,
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[researcher, planner],
|
||||
).build()
|
||||
|
||||
output_messages: list[Message] = []
|
||||
last_message_id: str | None = None
|
||||
async for event in workflow.run(task, stream=True):
|
||||
if event.type == "output":
|
||||
if event.type in ("intermediate", "output"):
|
||||
if isinstance(event.data, AgentResponseUpdate):
|
||||
if event.data.message_id != last_message_id:
|
||||
last_message_id = event.data.message_id
|
||||
|
||||
@@ -164,13 +164,13 @@ async def run_agent_framework_example(prompt: str) -> str | None:
|
||||
workflow = MagenticBuilder(
|
||||
participants=[researcher, coder],
|
||||
manager_agent=manager_agent, # type: ignore
|
||||
intermediate_outputs=True,
|
||||
intermediate_output_from=[researcher, coder],
|
||||
).build()
|
||||
|
||||
output_messages: list[Message] = []
|
||||
last_message_id: str | None = None
|
||||
async for event in workflow.run(prompt, stream=True):
|
||||
if event.type == "output":
|
||||
if event.type in ("intermediate", "output"):
|
||||
if isinstance(event.data, AgentResponseUpdate):
|
||||
if event.data.message_id != last_message_id:
|
||||
last_message_id = event.data.message_id
|
||||
|
||||
@@ -17,8 +17,6 @@ from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import Never
|
||||
|
||||
from sample_validation.const import WORKER_COMPLETED
|
||||
from sample_validation.discovery import DiscoveryResult
|
||||
from sample_validation.models import (
|
||||
@@ -29,6 +27,7 @@ from sample_validation.models import (
|
||||
ValidationConfig,
|
||||
WorkflowCreationResult,
|
||||
)
|
||||
from typing_extensions import Never
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -249,7 +248,7 @@ class CollectorExecutor(Executor):
|
||||
batch_completion: BatchCompletion,
|
||||
ctx: WorkflowContext[Never, ExecutionResult],
|
||||
) -> None:
|
||||
"""Receive all results at once and emit final output."""
|
||||
"""Receive all results at once and emit Workflow Output."""
|
||||
await ctx.yield_output(ExecutionResult(results=self._results))
|
||||
|
||||
@handler
|
||||
@@ -305,9 +304,7 @@ class CreateConcurrentValidationWorkflowExecutor(Executor):
|
||||
)
|
||||
collector = CollectorExecutor()
|
||||
|
||||
nested_builder = WorkflowBuilder(
|
||||
start_executor=coordinator, output_executors=[collector]
|
||||
)
|
||||
nested_builder = WorkflowBuilder(start_executor=coordinator, output_from=[collector])
|
||||
nested_builder.add_edge(coordinator, collector)
|
||||
for worker in workers:
|
||||
nested_builder.add_edge(coordinator, worker)
|
||||
|
||||
Reference in New Issue
Block a user