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
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:
@@ -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)
|
||||
Reference in New Issue
Block a user