chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,375 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, Awaitable
from typing import Any, Literal, cast, overload
import pytest
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
AgentSession,
BaseAgent,
Content,
Executor,
Message,
ResponseStream,
WorkflowContext,
WorkflowRunState,
handler,
)
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from agent_framework.orchestrations import ConcurrentBuilder
class _FakeAgentExec(Executor):
"""Test executor that mimics an agent by emitting an AgentExecutorResponse.
It takes the incoming AgentExecutorRequest, produces a single assistant message
with the configured reply text, and sends an AgentExecutorResponse that includes
full_conversation (the original user prompt followed by the assistant message).
"""
def __init__(self, id: str, reply_text: str) -> None:
super().__init__(id)
self._reply_text = reply_text
@handler
async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
response = AgentResponse(messages=Message(role="assistant", contents=[self._reply_text]))
full_conversation = list(request.messages) + list(response.messages)
await ctx.send_message(AgentExecutorResponse(self.id, response, full_conversation=full_conversation))
def test_concurrent_builder_rejects_empty_participants() -> None:
with pytest.raises(ValueError):
ConcurrentBuilder(participants=[])
def test_concurrent_builder_rejects_duplicate_executors() -> None:
a = _FakeAgentExec("dup", "A")
b = _FakeAgentExec("dup", "B") # same executor id
with pytest.raises(ValueError):
ConcurrentBuilder(participants=[a, b])
async def test_concurrent_default_aggregator_emits_assistants_only() -> None:
"""Default aggregator yields a single AgentResponse with one assistant message per participant.
The user prompt is intentionally not included — that belongs in the input, not the answer.
"""
e1 = _FakeAgentExec("agentA", "Alpha")
e2 = _FakeAgentExec("agentB", "Beta")
e3 = _FakeAgentExec("agentC", "Gamma")
wf = ConcurrentBuilder(participants=[e1, e2, e3]).build()
output_events = [ev for ev in await wf.run("prompt: hello world") if ev.type == "output"]
assert len(output_events) == 1
response = output_events[0].data
assert isinstance(response, AgentResponse)
# Exactly one assistant message per participant; no user prompt.
assert len(response.messages) == 3
assert all(m.role == "assistant" for m in response.messages)
assert {m.text for m in response.messages} == {"Alpha", "Beta", "Gamma"}
async def test_concurrent_custom_aggregator_callback_is_used() -> None:
# Two synthetic agent executors for brevity
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
async def summarize(results: list[AgentExecutorResponse]) -> str:
texts: list[str] = []
for r in results:
msgs: list[Message] = r.agent_response.messages
texts.append(msgs[-1].text if msgs else "")
return " | ".join(sorted(texts))
wf = ConcurrentBuilder(participants=[e1, e2]).with_aggregator(summarize).build()
completed = False
output: str | None = None
async for ev in wf.run("prompt: custom", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
output = cast(str, ev.data)
if completed and output is not None:
break
assert completed
assert output is not None
# Custom aggregator returns a string payload
assert isinstance(output, str)
assert output == "One | Two"
async def test_concurrent_custom_aggregator_sync_callback_is_used() -> None:
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
# Sync callback with ctx parameter (should run via asyncio.to_thread)
def summarize_sync(results: list[AgentExecutorResponse], _ctx: WorkflowContext[Any]) -> str: # type: ignore[unused-argument]
texts: list[str] = []
for r in results:
msgs: list[Message] = r.agent_response.messages
texts.append(msgs[-1].text if msgs else "")
return " | ".join(sorted(texts))
wf = ConcurrentBuilder(participants=[e1, e2]).with_aggregator(summarize_sync).build()
completed = False
output: str | None = None
async for ev in wf.run("prompt: custom sync", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
output = cast(str, ev.data)
if completed and output is not None:
break
assert completed
assert output is not None
assert isinstance(output, str)
assert output == "One | Two"
def test_concurrent_custom_aggregator_uses_callback_name_for_id() -> None:
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
def summarize(results: list[AgentExecutorResponse]) -> str: # type: ignore[override]
return str(len(results))
wf = ConcurrentBuilder(participants=[e1, e2]).with_aggregator(summarize).build()
assert "summarize" in wf.executors
aggregator = wf.executors["summarize"]
assert aggregator.id == "summarize"
async def test_concurrent_with_aggregator_executor_instance() -> None:
"""Test with_aggregator using an Executor instance (not factory)."""
class CustomAggregator(Executor):
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any, str]) -> None:
texts: list[str] = []
for r in results:
msgs: list[Message] = r.agent_response.messages
texts.append(msgs[-1].text if msgs else "")
await ctx.yield_output(" & ".join(sorted(texts)))
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
aggregator_instance = CustomAggregator(id="instance_aggregator")
wf = ConcurrentBuilder(participants=[e1, e2]).with_aggregator(aggregator_instance).build()
completed = False
output: str | None = None
async for ev in wf.run("prompt: instance test", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
output = cast(str, ev.data)
if completed and output is not None:
break
assert completed
assert output is not None
assert isinstance(output, str)
assert output == "One & Two"
def test_concurrent_builder_rejects_multiple_calls_to_with_aggregator() -> None:
"""Test that multiple calls to .with_aggregator() raises an error."""
def summarize(results: list[AgentExecutorResponse]) -> str: # type: ignore[override]
return str(len(results))
with pytest.raises(ValueError, match=r"with_aggregator\(\) has already been called"):
(
ConcurrentBuilder(participants=[_FakeAgentExec("a", "A")])
.with_aggregator(summarize)
.with_aggregator(summarize)
)
async def test_concurrent_checkpoint_resume_round_trip() -> None:
storage = InMemoryCheckpointStorage()
participants = (
_FakeAgentExec("agentA", "Alpha"),
_FakeAgentExec("agentB", "Beta"),
_FakeAgentExec("agentC", "Gamma"),
)
wf = ConcurrentBuilder(participants=list(participants), checkpoint_storage=storage).build()
baseline_output: AgentResponse | None = None
async for ev in wf.run("checkpoint concurrent", stream=True):
if ev.type == "output":
baseline_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[1]
resumed_participants = (
_FakeAgentExec("agentA", "Alpha"),
_FakeAgentExec("agentB", "Beta"),
_FakeAgentExec("agentC", "Gamma"),
)
wf_resume = ConcurrentBuilder(participants=list(resumed_participants), checkpoint_storage=storage).build()
resumed_output: AgentResponse | None = None
async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if ev.type == "output":
resumed_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_output is not None
assert [m.role for m in resumed_output.messages] == [m.role for m in baseline_output.messages]
assert [m.text for m in resumed_output.messages] == [m.text for m in baseline_output.messages]
async def test_concurrent_checkpoint_runtime_only() -> None:
"""Test checkpointing configured ONLY at runtime, not at build time."""
storage = InMemoryCheckpointStorage()
agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf = ConcurrentBuilder(participants=agents).build()
baseline_output: AgentResponse | None = None
async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
if ev.type == "output":
baseline_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) >= 2, (
"Expected at least 2 checkpoints. The first one is after the start executor, "
"and the second one is after the first round of agent executions."
)
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[1]
resumed_agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf_resume = ConcurrentBuilder(participants=resumed_agents).build()
resumed_output: AgentResponse | None = None
async for ev in wf_resume.run(
checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True
):
if ev.type == "output":
resumed_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_output is not None
assert [m.role for m in resumed_output.messages] == [m.role for m in baseline_output.messages]
async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None:
"""Test that runtime checkpoint storage overrides build-time configuration."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
from agent_framework._workflows._checkpoint import FileCheckpointStorage
buildtime_storage = FileCheckpointStorage(temp_dir1)
runtime_storage = FileCheckpointStorage(temp_dir2)
agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf = ConcurrentBuilder(participants=agents, checkpoint_storage=buildtime_storage).build()
baseline_output: list[Message] | None = None
async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
if ev.type == "output":
baseline_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
async def test_concurrent_builder_reusable_after_build_with_participants() -> None:
"""Test that the builder can be reused to build multiple identical workflows with participants()."""
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
builder = ConcurrentBuilder(participants=[e1, e2])
builder.build()
assert builder._participants[0] is e1 # type: ignore
assert builder._participants[1] is e2 # type: ignore
class _EchoAgent(BaseAgent):
"""Simple agent that appends a single assistant message with its name."""
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")])
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"])])
return _run()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,776 @@
# 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, cast, overload
import pytest
from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
AgentSession,
BaseAgent,
Content,
Message,
ResponseStream,
)
from agent_framework.orchestrations import (
ConcurrentBuilder,
GroupChatBuilder,
GroupChatState,
HandoffBuilder,
MagenticBuilder,
MagenticContext,
MagenticManagerBase,
MagenticProgressLedger,
MagenticProgressLedgerItem,
SequentialBuilder,
)
def _as_handoff_agent(agent: Any) -> Agent:
return cast(Agent, agent)
def _as_handoff_agents(*agents: Any) -> list[Agent]:
return [_as_handoff_agent(agent) for agent in agents]
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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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 or "") 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=_as_handoff_agents(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 or "" 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=_as_handoff_agents(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 or "") for c in text_contents)
assert any("A reply" in (c.text or "") for c in text_contents)
assert any("B reply" in (c.text or "") 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=_as_handoff_agents(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 or "") for c in reasoning_contents)
assert not any("B reply" in (c.text or "") 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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(alpha, beta)).with_start_agent(_as_handoff_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=_as_handoff_agents(alpha, beta), output_from=["alpha"])
.with_start_agent(_as_handoff_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=_as_handoff_agents(alpha, beta), intermediate_output_from=["alpha"])
.with_start_agent(_as_handoff_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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(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=_as_handoff_agents(_EchoAgent(name="alpha"), _EchoAgent(name="beta")), **kwargs
).build()
def _build_concurrent_with_designation(**kwargs: Any) -> None:
ConcurrentBuilder(
participants=_as_handoff_agents(_EchoAgent(name="alpha"), _EchoAgent(name="beta")), **kwargs
).build()
def _build_group_chat_with_designation(**kwargs: Any) -> None:
GroupChatBuilder(
participants=_as_handoff_agents(_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=_as_handoff_agents(_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=_as_handoff_agents(alpha, beta), **kwargs).with_start_agent(
_as_handoff_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"])
@@ -0,0 +1,258 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for orchestration request info support."""
from collections.abc import AsyncIterable
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentSession,
Content,
Message,
SupportsAgentRun,
)
from agent_framework._workflows._agent_executor import AgentExecutorRequest, AgentExecutorResponse
from agent_framework._workflows._workflow_context import WorkflowContext
from agent_framework_orchestrations._orchestration_request_info import (
AgentApprovalExecutor,
AgentRequestInfoExecutor,
AgentRequestInfoResponse,
resolve_request_info_filter,
)
class TestResolveRequestInfoFilter:
"""Tests for resolve_request_info_filter function."""
def test_returns_empty_set_for_none_input(self):
"""Test that None input returns empty set (no filtering)."""
result = resolve_request_info_filter(None)
assert result == set()
def test_returns_empty_set_for_empty_list(self):
"""Test that empty list returns empty set."""
result = resolve_request_info_filter([])
assert result == set()
def test_resolves_string_names(self):
"""Test resolving string agent names."""
result = resolve_request_info_filter(["agent1", "agent2"])
assert result == {"agent1", "agent2"}
def test_resolves_agent_display_names(self):
"""Test resolving SupportsAgentRun instances by name attribute."""
agent1 = MagicMock(spec=SupportsAgentRun)
agent1.name = "writer"
agent2 = MagicMock(spec=SupportsAgentRun)
agent2.name = "reviewer"
result = resolve_request_info_filter([agent1, agent2])
assert result == {"writer", "reviewer"}
def test_mixed_types(self):
"""Test resolving a mix of strings and agents."""
agent = MagicMock(spec=SupportsAgentRun)
agent.name = "writer"
result = resolve_request_info_filter(["manual_name", agent])
assert result == {"manual_name", "writer"}
def test_raises_on_unsupported_type(self):
"""Test that unsupported types raise TypeError."""
with pytest.raises(TypeError, match="Unsupported type for request_info filter"):
resolve_request_info_filter([123]) # type: ignore
class TestAgentRequestInfoResponse:
"""Tests for AgentRequestInfoResponse dataclass."""
def test_create_response_with_messages(self):
"""Test creating an AgentRequestInfoResponse with messages."""
messages = [Message(role="user", contents=["Additional info"])]
response = AgentRequestInfoResponse(messages=messages)
assert response.messages == messages
def test_from_messages_factory(self):
"""Test creating response from Message list."""
messages = [
Message(role="user", contents=["Message 1"]),
Message(role="user", contents=["Message 2"]),
]
response = AgentRequestInfoResponse.from_messages(messages)
assert response.messages == messages
def test_from_strings_factory(self):
"""Test creating response from string list."""
texts = ["First message", "Second message"]
response = AgentRequestInfoResponse.from_strings(texts)
assert len(response.messages) == 2
assert response.messages[0].role == "user"
assert response.messages[0].text == "First message"
assert response.messages[1].role == "user"
assert response.messages[1].text == "Second message"
def test_approve_factory(self):
"""Test creating an approval response (empty messages)."""
response = AgentRequestInfoResponse.approve()
assert response.messages == []
class TestAgentRequestInfoExecutor:
"""Tests for AgentRequestInfoExecutor."""
@pytest.mark.asyncio
async def test_request_info_handler(self):
"""Test that request_info handler calls ctx.request_info."""
executor = AgentRequestInfoExecutor(id="test_executor")
agent_response = AgentResponse(messages=[Message(role="assistant", contents=["Agent response"])])
executor_response = AgentExecutorResponse(
executor_id="test_agent",
agent_response=agent_response,
full_conversation=agent_response.messages,
)
ctx = MagicMock(spec=WorkflowContext)
ctx.request_info = AsyncMock()
await executor.request_info(executor_response, ctx)
ctx.request_info.assert_called_once_with(executor_response, AgentRequestInfoResponse)
@pytest.mark.asyncio
async def test_handle_request_info_response_with_messages(self):
"""Test response handler when user provides additional messages."""
executor = AgentRequestInfoExecutor(id="test_executor")
agent_response = AgentResponse(messages=[Message(role="assistant", contents=["Original"])])
original_request = AgentExecutorResponse(
executor_id="test_agent",
agent_response=agent_response,
full_conversation=agent_response.messages,
)
response = AgentRequestInfoResponse.from_strings(["Additional input"])
ctx = MagicMock(spec=WorkflowContext)
ctx.send_message = AsyncMock()
await executor.handle_request_info_response(original_request, response, ctx)
# Should send new request with additional messages
ctx.send_message.assert_called_once()
call_args = ctx.send_message.call_args[0][0]
assert isinstance(call_args, AgentExecutorRequest)
assert call_args.should_respond is True
assert len(call_args.messages) == 1
assert call_args.messages[0].text == "Additional input"
@pytest.mark.asyncio
async def test_handle_request_info_response_approval(self):
"""Test response handler when user approves (no additional messages)."""
executor = AgentRequestInfoExecutor(id="test_executor")
agent_response = AgentResponse(messages=[Message(role="assistant", contents=["Original"])])
original_request = AgentExecutorResponse(
executor_id="test_agent",
agent_response=agent_response,
full_conversation=agent_response.messages,
)
response = AgentRequestInfoResponse.approve()
ctx = MagicMock(spec=WorkflowContext)
ctx.yield_output = AsyncMock()
await executor.handle_request_info_response(original_request, response, ctx)
# Should yield original response without modification
ctx.yield_output.assert_called_once_with(original_request)
class _TestAgent:
"""Simple test agent implementation."""
def __init__(self, id: str, name: str | None = None, description: str | None = None):
self._id = id
self._name = name
self._description = description
@property
def id(self) -> str:
return self._id
@property
def name(self) -> str | None:
return self._name
@property
def display_name(self) -> str:
return self._name or self._id
@property
def description(self) -> str | None:
return self._description
async def run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Any:
"""Dummy run method."""
if stream:
return self._run_stream_impl()
return AgentResponse(messages=[Message(role="assistant", contents=["Test response"])])
async def _run_stream_impl(self) -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text="Test response stream")])
def create_session(self, **kwargs: Any) -> AgentSession:
"""Creates a new conversation session for the agent."""
return AgentSession(**kwargs)
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession:
"""Gets a conversation session for the agent."""
return AgentSession(service_session_id=service_session_id, session_id=session_id)
class TestAgentApprovalExecutor:
"""Tests for AgentApprovalExecutor."""
def test_initialization(self):
"""Test that AgentApprovalExecutor initializes correctly."""
agent = _TestAgent(id="test_id", name="test_agent", description="Test agent description")
executor = AgentApprovalExecutor(cast(SupportsAgentRun, agent))
assert executor.id == "test_agent"
assert executor.description == "Test agent description"
def test_builds_workflow_with_agent_and_request_info_executors(self):
"""Test that the internal workflow is created successfully."""
agent = _TestAgent(id="test_id", name="test_agent", description="Test description")
executor = AgentApprovalExecutor(cast(SupportsAgentRun, agent))
# Verify the executor has a workflow
assert executor.workflow is not None
assert executor.id == "test_agent"
def test_propagate_request_enabled(self):
"""Test that AgentApprovalExecutor has propagate_request enabled."""
agent = _TestAgent(id="test_id", name="test_agent", description="Test description")
executor = AgentApprovalExecutor(cast(SupportsAgentRun, agent))
assert executor._propagate_request is True # type: ignore
@@ -0,0 +1,477 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, Awaitable, Sequence
from typing import Any, Literal, overload
import pytest
from agent_framework import (
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
AgentSession,
BaseAgent,
Content,
Executor,
Message,
ResponseStream,
TypeCompatibilityError,
WorkflowContext,
WorkflowRunState,
handler,
)
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from agent_framework.orchestrations import SequentialBuilder
class _EchoAgent(BaseAgent):
"""Simple agent that appends a single assistant message with its name."""
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")])
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"])])
return _run()
class _SummarizerTerminator(Executor):
"""Custom-executor terminator that yields a synthesized summary as the workflow's final answer."""
@handler
async def summarize(
self,
agent_response: AgentExecutorResponse,
ctx: WorkflowContext[Any, AgentResponse],
) -> None:
conversation = agent_response.full_conversation or []
user_texts = [m.text for m in conversation if m.role == "user"]
agents = [m.author_name or m.role for m in conversation if m.role == "assistant"]
summary = Message("assistant", [f"Summary of users:{len(user_texts)} agents:{len(agents)}"])
await ctx.yield_output(AgentResponse(messages=[summary]))
class _InvalidExecutor(Executor):
"""Invalid executor that does not have a handler that accepts a list of chat messages"""
@handler
async def summarize(self, conversation: list[str], ctx: WorkflowContext[list[Message]]) -> None:
pass
def test_sequential_builder_rejects_empty_participants() -> None:
with pytest.raises(ValueError):
SequentialBuilder(participants=[])
def test_sequential_builder_validation_rejects_invalid_executor() -> None:
"""Test that adding an invalid executor to the builder raises an error."""
with pytest.raises(TypeCompatibilityError):
SequentialBuilder(participants=[_EchoAgent(id="agent1", name="A1"), _InvalidExecutor(id="invalid")]).build()
async def test_sequential_streaming_yields_only_last_agent_updates() -> None:
"""Streaming mode surfaces only the last agent's AgentResponseUpdate chunks as outputs.
Intermediate agents do NOT emit `output` events; only the last agent (the workflow's
output_executor) emits chunks of the final answer.
"""
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
wf = SequentialBuilder(participants=[a1, a2]).build()
completed = False
update_events: list[AgentResponseUpdate] = []
async for ev in wf.run("hello sequential", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
update_events.append(ev.data) # type: ignore[arg-type]
if completed:
break
assert completed
# Only the last agent's streaming chunks surface as `output` events.
assert update_events, "Expected at least one streaming update from the last agent"
for upd in update_events:
assert isinstance(upd, AgentResponseUpdate)
combined_text = "".join(u.text for u in update_events if hasattr(u, "text"))
assert "A2 reply" in combined_text
assert "A1 reply" not in combined_text
async def test_sequential_non_streaming_yields_only_last_agent_response() -> None:
"""Non-streaming mode emits a single `output` event with the last agent's AgentResponse."""
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
wf = SequentialBuilder(participants=[a1, a2]).build()
output_events = [ev for ev in await wf.run("hello sequential") if ev.type == "output"]
assert len(output_events) == 1
response = output_events[0].data
assert isinstance(response, AgentResponse)
assert all(m.role == "assistant" for m in response.messages)
combined = " ".join(m.text for m in response.messages)
assert "A2 reply" in combined
assert "A1 reply" not in combined
async def test_sequential_as_agent_returns_only_last_agent_response() -> None:
"""`workflow.as_agent().run(prompt)` returns ONLY the last agent's messages — not the user
input or earlier agents' replies. This is the core fix for the orchestration-as-agent
output contract."""
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
agent = SequentialBuilder(participants=[a1, a2]).build().as_agent()
response = await agent.run("hello as_agent")
assert isinstance(response, AgentResponse)
# Only the last agent's reply — no user prompt, no agent1 messages.
combined = " ".join(m.text for m in response.messages)
assert "A2 reply" in combined
assert "A1 reply" not in combined
assert "hello as_agent" not in combined
async def test_sequential_with_custom_executor_summary() -> None:
"""A custom-executor terminator yields its own AgentResponse — that becomes the workflow output.
Custom executors used as the terminator must call `ctx.yield_output(AgentResponse(...))`
directly (rather than `ctx.send_message(list[Message])` like an intermediate executor would),
because the terminator IS the workflow's output executor.
"""
a1 = _EchoAgent(id="agent1", name="A1")
summarizer = _SummarizerTerminator(id="summarizer")
wf = SequentialBuilder(participants=[a1, summarizer]).build()
output_events = [ev for ev in await wf.run("topic X") if ev.type == "output"]
assert len(output_events) == 1
response = output_events[0].data
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
assert response.messages[0].role == "assistant"
assert response.messages[0].text.startswith("Summary of users:")
async def test_sequential_checkpoint_resume_round_trip() -> None:
storage = InMemoryCheckpointStorage()
initial_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf = SequentialBuilder(participants=list(initial_agents), checkpoint_storage=storage).build()
baseline_updates: list[AgentResponseUpdate] = []
async for ev in wf.run("checkpoint sequential", stream=True):
if ev.type == "output":
baseline_updates.append(ev.data) # type: ignore[arg-type]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_updates
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[0]
resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf_resume = SequentialBuilder(participants=list(resumed_agents), checkpoint_storage=storage).build()
resumed_updates: list[AgentResponseUpdate] = []
async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if ev.type == "output":
resumed_updates.append(ev.data) # type: ignore[arg-type]
if ev.type == "status" and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_updates
baseline_text = "".join(u.text for u in baseline_updates if hasattr(u, "text"))
resumed_text = "".join(u.text for u in resumed_updates if hasattr(u, "text"))
assert baseline_text == resumed_text
async def test_sequential_checkpoint_runtime_only() -> None:
"""Test checkpointing configured ONLY at runtime, not at build time."""
storage = InMemoryCheckpointStorage()
agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf = SequentialBuilder(participants=list(agents)).build()
baseline_updates: list[AgentResponseUpdate] = []
async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
if ev.type == "output":
baseline_updates.append(ev.data) # type: ignore[arg-type]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_updates
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[0]
resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf_resume = SequentialBuilder(participants=list(resumed_agents)).build()
resumed_updates: list[AgentResponseUpdate] = []
async for ev in wf_resume.run(
checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True
):
if ev.type == "output":
resumed_updates.append(ev.data) # type: ignore[arg-type]
if ev.type == "status" and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_updates
baseline_text = "".join(u.text for u in baseline_updates if hasattr(u, "text"))
resumed_text = "".join(u.text for u in resumed_updates if hasattr(u, "text"))
assert baseline_text == resumed_text
async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
"""Test that runtime checkpoint storage overrides build-time configuration."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
from agent_framework._workflows._checkpoint import FileCheckpointStorage
buildtime_storage = FileCheckpointStorage(temp_dir1)
runtime_storage = FileCheckpointStorage(temp_dir2)
agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf = SequentialBuilder(participants=list(agents), checkpoint_storage=buildtime_storage).build()
baseline_output: list[Message] | None = None
async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
if ev.type == "output":
baseline_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
async def test_sequential_builder_reusable_after_build_with_participants() -> None:
"""Test that the builder can be reused to build multiple identical workflows with participants()."""
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
builder = SequentialBuilder(participants=[a1, a2])
# Build first workflow
builder.build()
assert builder._participants[0] is a1 # type: ignore
assert builder._participants[1] is a2 # type: ignore
# ---------------------------------------------------------------------------
# chain_only_agent_responses tests
# ---------------------------------------------------------------------------
class _CapturingAgent(BaseAgent):
"""Agent that records the messages it received and returns a configurable reply."""
def __init__(self, *, reply_text: str = "reply", **kwargs: Any):
super().__init__(**kwargs)
self.reply_text = reply_text
self.last_messages: list[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]]:
captured: list[Message] = []
if messages:
message_items = messages if isinstance(messages, Sequence) and not isinstance(messages, str) else [messages]
for m in message_items:
if isinstance(m, Message):
captured.append(m)
elif isinstance(m, str):
captured.append(Message("user", [m]))
self.last_messages = captured
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text=self.reply_text)])
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", [self.reply_text])])
return _run()
async def test_chain_only_agent_responses_false_passes_full_conversation() -> None:
"""Default (chain_only_agent_responses=False) passes full conversation to the second agent."""
a1 = _CapturingAgent(id="agent1", name="A1", reply_text="A1 reply")
a2 = _CapturingAgent(id="agent2", name="A2", reply_text="A2 reply")
wf = SequentialBuilder(participants=[a1, a2], chain_only_agent_responses=False).build()
async for ev in wf.run("hello", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
# Second agent should see full conversation: [user("hello"), assistant("A1 reply")]
seen = a2.last_messages
assert len(seen) == 2
assert seen[0].role == "user" and "hello" in (seen[0].text or "")
assert seen[1].role == "assistant" and "A1 reply" in (seen[1].text or "")
async def test_chain_only_agent_responses_true_passes_only_agent_messages() -> None:
"""chain_only_agent_responses=True passes only the previous agent's response messages."""
a1 = _CapturingAgent(id="agent1", name="A1", reply_text="A1 reply")
a2 = _CapturingAgent(id="agent2", name="A2", reply_text="A2 reply")
wf = SequentialBuilder(participants=[a1, a2], chain_only_agent_responses=True).build()
async for ev in wf.run("hello", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
# Second agent should see only the assistant message: [assistant("A1 reply")]
seen = a2.last_messages
assert len(seen) == 1
assert seen[0].role == "assistant" and "A1 reply" in (seen[0].text or "")
async def test_chain_only_agent_responses_three_agents() -> None:
"""chain_only_agent_responses=True with three agents: each sees only the prior agent's reply."""
a1 = _CapturingAgent(id="agent1", name="A1", reply_text="A1 reply")
a2 = _CapturingAgent(id="agent2", name="A2", reply_text="A2 reply")
a3 = _CapturingAgent(id="agent3", name="A3", reply_text="A3 reply")
wf = SequentialBuilder(participants=[a1, a2, a3], chain_only_agent_responses=True).build()
async for ev in wf.run("hello", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
# a2 should see only A1's reply
assert len(a2.last_messages) == 1
assert a2.last_messages[0].role == "assistant" and "A1 reply" in (a2.last_messages[0].text or "")
# a3 should see only A2's reply
assert len(a3.last_messages) == 1
assert a3.last_messages[0].role == "assistant" and "A2 reply" in (a3.last_messages[0].text or "")
# ---------------------------------------------------------------------------
# with_request_info tests
# ---------------------------------------------------------------------------
async def test_sequential_request_info_last_participant_emits_output() -> None:
"""When the last participant is wrapped via with_request_info(), the workflow
still emits a terminal output event after approval.
This exercises the _EndWithConversation.end_with_agent_executor_response path
that converts the AgentApprovalExecutor's forwarded AgentExecutorResponse into
the workflow's final AgentResponse output.
"""
from agent_framework_orchestrations._orchestration_request_info import AgentRequestInfoResponse
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
wf = SequentialBuilder(participants=[a1, a2]).with_request_info().build()
# First run: collect request_info events for both agents
request_events: list[Any] = []
async for ev in wf.run("hello with approval", stream=True):
if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse):
request_events.append(ev)
# Approve each agent in sequence until the workflow completes
output_events: list[Any] = []
while request_events:
responses = {req.request_id: AgentRequestInfoResponse.approve() for req in request_events}
request_events = []
output_events = []
async for ev in wf.run(stream=True, responses=responses):
if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse):
request_events.append(ev)
elif ev.type == "output":
output_events.append(ev)
# The workflow must produce a terminal output with the last agent's response.
assert len(output_events) == 1
response = output_events[0].data
assert isinstance(response, AgentResponse)
assert any("A2 reply" in m.text for m in response.messages)