Python: [BREAKING] Python: Intro group chat and refactor orchestrations. Fix as_agent(). Standardize orchestration start msg types. (#1538)

* Intro group chat and refactor magentic. Fix as_agent()

* Cleanup and improvements

* Add as_agent docstring clarification

* Standardize orchestration messages to use agent-style inputs.

* Simplify group chat constructs

* Further cleanup

* Add sk to af group chat migration sample. Update README.

* Improvements and simplifications

* consolidating shared orchestration logic

* Further clean up

* Add group chat sample

* Improve typing

* Fix test imports

* Fix readme links

* Cleanup per PR Feedback
This commit is contained in:
Evan Mattson
2025-10-25 00:14:06 +00:00
committed by GitHub
parent 899d8ff775
commit e3aad8e4e0
38 changed files with 5024 additions and 814 deletions
@@ -0,0 +1,744 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, Callable
from typing import Any
import pytest
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
BaseAgent,
ChatMessage,
GroupChatBuilder,
GroupChatDirective,
GroupChatStateSnapshot,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticContext,
MagenticManagerBase,
MagenticOrchestratorMessageEvent,
Role,
TextContent,
Workflow,
WorkflowOutputEvent,
)
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from agent_framework._workflows._group_chat import (
GroupChatOrchestratorExecutor,
_default_orchestrator_factory, # type: ignore
_GroupChatConfig, # type: ignore
_PromptBasedGroupChatManager, # type: ignore
_SpeakerSelectorAdapter, # type: ignore
)
from agent_framework._workflows._magentic import (
_MagenticProgressLedger, # type: ignore
_MagenticProgressLedgerItem, # type: ignore
_MagenticStartMessage, # type: ignore
)
class StubAgent(BaseAgent):
def __init__(self, agent_name: str, reply_text: str, **kwargs: Any) -> None:
super().__init__(name=agent_name, description=f"Stub agent {agent_name}", **kwargs)
self._reply_text = reply_text
async def run( # type: ignore[override]
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AgentRunResponse:
response = ChatMessage(role=Role.ASSISTANT, text=self._reply_text, author_name=self.name)
return AgentRunResponse(messages=[response])
def run_stream( # type: ignore[override]
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentRunResponseUpdate]:
async def _stream() -> AsyncIterable[AgentRunResponseUpdate]:
yield AgentRunResponseUpdate(
contents=[TextContent(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name
)
return _stream()
def make_sequence_selector() -> Callable[[GroupChatStateSnapshot], Any]:
state_counter = {"value": 0}
async def _selector(state: GroupChatStateSnapshot) -> str | None:
participants = list(state["participants"].keys())
step = state_counter["value"]
if step == 0:
state_counter["value"] = step + 1
return participants[0]
if step == 1 and len(participants) > 1:
state_counter["value"] = step + 1
return participants[1]
return None
_selector.name = "manager" # type: ignore[attr-defined]
return _selector
class StubMagenticManager(MagenticManagerBase):
def __init__(self) -> None:
super().__init__(max_stall_count=3, max_round_count=5)
self._round = 0
async def plan(self, magentic_context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="plan", author_name="magentic_manager")
async def replan(self, magentic_context: MagenticContext) -> ChatMessage:
return await self.plan(magentic_context)
async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger:
participants = list(magentic_context.participant_descriptions.keys())
target = participants[0] if participants else "agent"
if self._round == 0:
self._round += 1
return _MagenticProgressLedger(
is_request_satisfied=_MagenticProgressLedgerItem(reason="", answer=False),
is_in_loop=_MagenticProgressLedgerItem(reason="", answer=False),
is_progress_being_made=_MagenticProgressLedgerItem(reason="", answer=True),
next_speaker=_MagenticProgressLedgerItem(reason="", answer=target),
instruction_or_question=_MagenticProgressLedgerItem(reason="", answer="respond"),
)
return _MagenticProgressLedger(
is_request_satisfied=_MagenticProgressLedgerItem(reason="", answer=True),
is_in_loop=_MagenticProgressLedgerItem(reason="", answer=False),
is_progress_being_made=_MagenticProgressLedgerItem(reason="", answer=True),
next_speaker=_MagenticProgressLedgerItem(reason="", answer=target),
instruction_or_question=_MagenticProgressLedgerItem(reason="", answer=""),
)
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="final", author_name="magentic_manager")
async def test_group_chat_builder_basic_flow() -> None:
selector = make_sequence_selector()
alpha = StubAgent("alpha", "ack from alpha")
beta = StubAgent("beta", "ack from beta")
workflow = (
GroupChatBuilder()
.select_speakers(selector, display_name="manager", final_message="done")
.participants(alpha=alpha, beta=beta)
.build()
)
outputs: list[ChatMessage] = []
async for event in workflow.run_stream("coordinate task"):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, ChatMessage):
outputs.append(data)
assert len(outputs) == 1
assert outputs[0].text == "done"
assert outputs[0].author_name == "manager"
async def test_magentic_builder_returns_workflow_and_runs() -> None:
manager = StubMagenticManager()
agent = StubAgent("writer", "first draft")
workflow = MagenticBuilder().participants(writer=agent).with_standard_manager(manager=manager).build()
assert isinstance(workflow, Workflow)
outputs: list[ChatMessage] = []
orchestrator_events: list[MagenticOrchestratorMessageEvent] = []
agent_events: list[MagenticAgentMessageEvent] = []
start_message = _MagenticStartMessage.from_string("compose summary")
async for event in workflow.run_stream(start_message):
if isinstance(event, MagenticOrchestratorMessageEvent):
orchestrator_events.append(event)
if isinstance(event, MagenticAgentMessageEvent):
agent_events.append(event)
if isinstance(event, WorkflowOutputEvent):
msg = event.data
if isinstance(msg, ChatMessage):
outputs.append(msg)
assert outputs, "Expected a final output message"
final = outputs[-1]
assert final.text == "final"
assert final.author_name == "magentic_manager"
assert orchestrator_events, "Expected orchestrator events to be emitted"
assert agent_events, "Expected agent message events to be emitted"
async def test_group_chat_as_agent_accepts_conversation() -> None:
selector = make_sequence_selector()
alpha = StubAgent("alpha", "ack from alpha")
beta = StubAgent("beta", "ack from beta")
workflow = (
GroupChatBuilder()
.select_speakers(selector, display_name="manager", final_message="done")
.participants(alpha=alpha, beta=beta)
.build()
)
agent = workflow.as_agent(name="group-chat-agent")
conversation = [
ChatMessage(role=Role.USER, text="kickoff", author_name="user"),
ChatMessage(role=Role.ASSISTANT, text="noted", author_name="alpha"),
]
response = await agent.run(conversation)
assert response.messages, "Expected agent conversation output"
async def test_magentic_as_agent_accepts_conversation() -> None:
manager = StubMagenticManager()
writer = StubAgent("writer", "draft")
workflow = MagenticBuilder().participants(writer=writer).with_standard_manager(manager=manager).build()
agent = workflow.as_agent(name="magentic-agent")
conversation = [
ChatMessage(role=Role.SYSTEM, text="Guidelines", author_name="system"),
ChatMessage(role=Role.USER, text="Summarize the findings", author_name="requester"),
]
response = await agent.run(conversation)
assert isinstance(response, AgentRunResponse)
# Comprehensive tests for group chat functionality
class TestGroupChatBuilder:
"""Tests for GroupChatBuilder validation and configuration."""
def test_build_without_manager_raises_error(self) -> None:
"""Test that building without a manager raises ValueError."""
agent = StubAgent("test", "response")
builder = GroupChatBuilder().participants([agent])
with pytest.raises(ValueError, match="manager must be configured before build"):
builder.build()
def test_build_without_participants_raises_error(self) -> None:
"""Test that building without participants raises ValueError."""
def selector(state: GroupChatStateSnapshot) -> str | None:
return None
builder = GroupChatBuilder().select_speakers(selector)
with pytest.raises(ValueError, match="participants must be configured before build"):
builder.build()
def test_duplicate_manager_configuration_raises_error(self) -> None:
"""Test that configuring multiple managers raises ValueError."""
def selector(state: GroupChatStateSnapshot) -> str | None:
return None
builder = GroupChatBuilder().select_speakers(selector)
with pytest.raises(ValueError, match="already has a manager configured"):
builder.select_speakers(selector)
def test_empty_participants_raises_error(self) -> None:
"""Test that empty participants list raises ValueError."""
def selector(state: GroupChatStateSnapshot) -> str | None:
return None
builder = GroupChatBuilder().select_speakers(selector)
with pytest.raises(ValueError, match="participants cannot be empty"):
builder.participants([])
def test_duplicate_participant_names_raises_error(self) -> None:
"""Test that duplicate participant names raise ValueError."""
agent1 = StubAgent("test", "response1")
agent2 = StubAgent("test", "response2")
def selector(state: GroupChatStateSnapshot) -> str | None:
return None
builder = GroupChatBuilder().select_speakers(selector)
with pytest.raises(ValueError, match="Duplicate participant name 'test'"):
builder.participants([agent1, agent2])
def test_agent_without_name_raises_error(self) -> None:
"""Test that agent without name attribute raises ValueError."""
class AgentWithoutName(BaseAgent):
def __init__(self) -> None:
super().__init__(name="", description="test")
async def run(self, messages: Any = None, *, thread: Any = None, **kwargs: Any) -> AgentRunResponse:
return AgentRunResponse(messages=[])
def run_stream(
self, messages: Any = None, *, thread: Any = None, **kwargs: Any
) -> AsyncIterable[AgentRunResponseUpdate]:
async def _stream() -> AsyncIterable[AgentRunResponseUpdate]:
yield AgentRunResponseUpdate(contents=[])
return _stream()
agent = AgentWithoutName()
def selector(state: GroupChatStateSnapshot) -> str | None:
return None
builder = GroupChatBuilder().select_speakers(selector)
with pytest.raises(ValueError, match="must define a non-empty 'name' attribute"):
builder.participants([agent])
def test_empty_participant_name_raises_error(self) -> None:
"""Test that empty participant name raises ValueError."""
agent = StubAgent("test", "response")
def selector(state: GroupChatStateSnapshot) -> str | None:
return None
builder = GroupChatBuilder().select_speakers(selector)
with pytest.raises(ValueError, match="participant names must be non-empty strings"):
builder.participants({"": agent})
class TestGroupChatOrchestrator:
"""Tests for GroupChatOrchestratorExecutor core functionality."""
async def test_max_rounds_enforcement(self) -> None:
"""Test that max_rounds properly limits conversation rounds."""
call_count = {"value": 0}
def selector(state: GroupChatStateSnapshot) -> str | None:
call_count["value"] += 1
# Always return the agent name to try to continue indefinitely
return "agent"
agent = StubAgent("agent", "response")
workflow = (
GroupChatBuilder()
.select_speakers(selector)
.participants([agent])
.with_max_rounds(2) # Limit to 2 rounds
.build()
)
outputs: list[ChatMessage] = []
async for event in workflow.run_stream("test task"):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, ChatMessage):
outputs.append(data)
# Should have terminated due to max_rounds, expect at least one output
assert len(outputs) >= 1
# The final message should be about round limit
final_output = outputs[-1]
assert "round limit" in final_output.text.lower()
async def test_unknown_participant_error(self) -> None:
"""Test that _apply_directive raises error for unknown participants."""
def selector(state: GroupChatStateSnapshot) -> str | None:
return "unknown_agent" # Return non-existent participant
agent = StubAgent("agent", "response")
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
with pytest.raises(ValueError, match="Manager selected unknown participant 'unknown_agent'"):
async for _ in workflow.run_stream("test task"):
pass
async def test_directive_without_agent_name_raises_error(self) -> None:
"""Test that directive without agent_name raises error when finish=False."""
def bad_selector(state: GroupChatStateSnapshot) -> GroupChatDirective:
# Return a GroupChatDirective object instead of string to trigger error
return GroupChatDirective(finish=False, agent_name=None) # type: ignore
agent = StubAgent("agent", "response")
# The _SpeakerSelectorAdapter will catch this and raise TypeError
workflow = GroupChatBuilder().select_speakers(bad_selector).participants([agent]).build() # type: ignore
# This should raise a TypeError because selector doesn't return str or None
with pytest.raises(TypeError, match="must return a participant name \\(str\\) or None"):
async for _ in workflow.run_stream("test"):
pass
async def test_handle_empty_conversation_raises_error(self) -> None:
"""Test that empty conversation list raises ValueError."""
def selector(state: GroupChatStateSnapshot) -> str | None:
return None
agent = StubAgent("agent", "response")
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
with pytest.raises(ValueError, match="requires at least one chat message"):
async for _ in workflow.run_stream([]):
pass
async def test_unknown_participant_response_raises_error(self) -> None:
"""Test that responses from unknown participants raise errors."""
def selector(state: GroupChatStateSnapshot) -> str | None:
return "agent"
# Create orchestrator to test _ingest_participant_message directly
orchestrator = GroupChatOrchestratorExecutor(
manager=selector, # type: ignore
participants={"agent": "test agent"},
manager_name="test_manager", # type: ignore
)
# Mock the workflow context
class MockContext:
async def yield_output(self, message: ChatMessage) -> None:
pass
ctx = MockContext()
# Initialize orchestrator state
orchestrator._task_message = ChatMessage(role=Role.USER, text="test") # type: ignore
orchestrator._conversation = [orchestrator._task_message] # type: ignore
orchestrator._history = [] # type: ignore
orchestrator._pending_agent = None # type: ignore
orchestrator._round_index = 0 # type: ignore
# Test with unknown participant
message = ChatMessage(role=Role.ASSISTANT, text="response")
with pytest.raises(ValueError, match="Received response from unknown participant 'unknown'"):
await orchestrator._ingest_participant_message("unknown", message, ctx) # type: ignore
async def test_state_build_before_initialization_raises_error(self) -> None:
"""Test that _build_state raises error before task message initialization."""
def selector(state: GroupChatStateSnapshot) -> str | None:
return None
orchestrator = GroupChatOrchestratorExecutor(
manager=selector, # type: ignore
participants={"agent": "test agent"},
manager_name="test_manager", # type: ignore
)
with pytest.raises(RuntimeError, match="state not initialized with task message"):
orchestrator._build_state() # type: ignore
class TestSpeakerSelectorAdapter:
"""Tests for _SpeakerSelectorAdapter functionality."""
async def test_selector_returning_list_with_multiple_items_raises_error(self) -> None:
"""Test that selector returning list with multiple items raises error."""
def bad_selector(state: GroupChatStateSnapshot) -> list[str]:
return ["agent1", "agent2"] # Multiple items
adapter = _SpeakerSelectorAdapter(bad_selector, manager_name="manager")
state = {
"participants": {"agent1": "desc1", "agent2": "desc2"},
"task": ChatMessage(role=Role.USER, text="test"),
"conversation": (),
"history": (),
"round_index": 0,
"pending_agent": None,
}
with pytest.raises(ValueError, match="must return a single participant name"):
await adapter(state)
async def test_selector_returning_non_string_raises_error(self) -> None:
"""Test that selector returning non-string raises TypeError."""
def bad_selector(state: GroupChatStateSnapshot) -> int:
return 42 # Not a string
adapter = _SpeakerSelectorAdapter(bad_selector, manager_name="manager")
state = {
"participants": {"agent": "desc"},
"task": ChatMessage(role=Role.USER, text="test"),
"conversation": (),
"history": (),
"round_index": 0,
"pending_agent": None,
}
with pytest.raises(TypeError, match="must return a participant name \\(str\\) or None"):
await adapter(state)
async def test_selector_returning_empty_list_finishes(self) -> None:
"""Test that selector returning empty list finishes conversation."""
def empty_selector(state: GroupChatStateSnapshot) -> list[str]:
return [] # Empty list should finish
adapter = _SpeakerSelectorAdapter(empty_selector, manager_name="manager")
state = {
"participants": {"agent": "desc"},
"task": ChatMessage(role=Role.USER, text="test"),
"conversation": (),
"history": (),
"round_index": 0,
"pending_agent": None,
}
directive = await adapter(state)
assert directive.finish is True
assert directive.final_message is not None
class TestCheckpointing:
"""Tests for checkpointing functionality."""
async def test_workflow_with_checkpointing(self) -> None:
"""Test that workflow works with checkpointing enabled."""
def selector(state: GroupChatStateSnapshot) -> str | None:
if state["round_index"] >= 1:
return None
return "agent"
agent = StubAgent("agent", "response")
storage = InMemoryCheckpointStorage()
workflow = (
GroupChatBuilder().select_speakers(selector).participants([agent]).with_checkpointing(storage).build()
)
outputs: list[ChatMessage] = []
async for event in workflow.run_stream("test task"):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, ChatMessage):
outputs.append(data)
assert len(outputs) == 1 # Should complete normally
class TestPromptBasedManager:
"""Tests for _PromptBasedGroupChatManager."""
async def test_manager_with_missing_next_agent_raises_error(self) -> None:
"""Test that manager directive without next_agent raises RuntimeError."""
class MockChatClient:
async def get_response(self, messages: Any, response_format: Any = None) -> Any:
# Return response that has finish=False but no next_agent
class MockResponse:
def __init__(self) -> None:
self.value = {"finish": False, "next_agent": None}
self.messages: list[Any] = []
return MockResponse()
manager = _PromptBasedGroupChatManager(MockChatClient()) # type: ignore
state = {
"participants": {"agent": "desc"},
"task": ChatMessage(role=Role.USER, text="test"),
"conversation": (),
}
with pytest.raises(RuntimeError, match="missing next_agent while finish is False"):
await manager(state)
async def test_manager_with_unknown_participant_raises_error(self) -> None:
"""Test that manager selecting unknown participant raises RuntimeError."""
class MockChatClient:
async def get_response(self, messages: Any, response_format: Any = None) -> Any:
# Return response selecting unknown participant
class MockResponse:
def __init__(self) -> None:
self.value = {"finish": False, "next_agent": "unknown"}
self.messages: list[Any] = []
return MockResponse()
manager = _PromptBasedGroupChatManager(MockChatClient()) # type: ignore
state = {
"participants": {"agent": "desc"},
"task": ChatMessage(role=Role.USER, text="test"),
"conversation": (),
}
with pytest.raises(RuntimeError, match="Manager selected unknown participant 'unknown'"):
await manager(state)
class TestFactoryFunctions:
"""Tests for factory functions."""
def test_default_orchestrator_factory_without_manager_raises_error(self) -> None:
"""Test that default factory requires manager to be set."""
config = _GroupChatConfig(manager=None, manager_name="test", participants={})
with pytest.raises(RuntimeError, match="requires a manager to be set"):
_default_orchestrator_factory(config)
class TestConversationHandling:
"""Tests for different conversation input types."""
async def test_handle_string_input(self) -> None:
"""Test handling string input creates proper ChatMessage."""
def selector(state: GroupChatStateSnapshot) -> str | None:
# Verify the task was properly converted
assert state["task"].role == Role.USER
assert state["task"].text == "test string"
return None
agent = StubAgent("agent", "response")
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
outputs: list[ChatMessage] = []
async for event in workflow.run_stream("test string"):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, ChatMessage):
outputs.append(data)
assert len(outputs) == 1
async def test_handle_chat_message_input(self) -> None:
"""Test handling ChatMessage input directly."""
task_message = ChatMessage(role=Role.USER, text="test message")
def selector(state: GroupChatStateSnapshot) -> str | None:
# Verify the task message was preserved
assert state["task"] == task_message
return None
agent = StubAgent("agent", "response")
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
outputs: list[ChatMessage] = []
async for event in workflow.run_stream(task_message):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, ChatMessage):
outputs.append(data)
assert len(outputs) == 1
async def test_handle_conversation_list_input(self) -> None:
"""Test handling conversation list preserves context."""
conversation = [
ChatMessage(role=Role.SYSTEM, text="system message"),
ChatMessage(role=Role.USER, text="user message"),
]
def selector(state: GroupChatStateSnapshot) -> str | None:
# Verify conversation context is preserved
assert len(state["conversation"]) == 2
assert state["task"].text == "user message"
return None
agent = StubAgent("agent", "response")
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
outputs: list[ChatMessage] = []
async for event in workflow.run_stream(conversation):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, ChatMessage):
outputs.append(data)
assert len(outputs) == 1
class TestRoundLimitEnforcement:
"""Tests for round limit checking functionality."""
async def test_round_limit_in_apply_directive(self) -> None:
"""Test round limit enforcement in _apply_directive."""
rounds_called = {"count": 0}
def selector(state: GroupChatStateSnapshot) -> str | None:
rounds_called["count"] += 1
# Keep trying to select agent to test limit enforcement
return "agent"
agent = StubAgent("agent", "response")
workflow = (
GroupChatBuilder()
.select_speakers(selector)
.participants([agent])
.with_max_rounds(1) # Very low limit
.build()
)
outputs: list[ChatMessage] = []
async for event in workflow.run_stream("test"):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, ChatMessage):
outputs.append(data)
# Should have at least one output (the round limit message)
assert len(outputs) >= 1
# The last message should be about round limit
final_output = outputs[-1]
assert "round limit" in final_output.text.lower()
async def test_round_limit_in_ingest_participant_message(self) -> None:
"""Test round limit enforcement after participant response."""
responses_received = {"count": 0}
def selector(state: GroupChatStateSnapshot) -> str | None:
responses_received["count"] += 1
if responses_received["count"] == 1:
return "agent" # First call selects agent
return "agent" # Try to continue, but should hit limit
agent = StubAgent("agent", "response from agent")
workflow = (
GroupChatBuilder()
.select_speakers(selector)
.participants([agent])
.with_max_rounds(1) # Hit limit after first response
.build()
)
outputs: list[ChatMessage] = []
async for event in workflow.run_stream("test"):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, ChatMessage):
outputs.append(data)
# Should have at least one output (the round limit message)
assert len(outputs) >= 1
# The last message should be about round limit
final_output = outputs[-1]
assert "round limit" in final_output.text.lower()
@@ -54,6 +54,7 @@ class _RecordingAgent(BaseAgent):
extra_properties: dict[str, object] | None = None,
) -> None:
super().__init__(id=name, name=name, display_name=name)
self._agent_name = name
self.handoff_to = handoff_to
self.calls: list[list[ChatMessage]] = []
self._text_handoff = text_handoff
@@ -72,7 +73,7 @@ class _RecordingAgent(BaseAgent):
additional_properties = _merge_additional_properties(
self.handoff_to, self._text_handoff, self._extra_properties
)
contents = _build_reply_contents(self.name, self.handoff_to, self._text_handoff, self._next_call_id())
contents = _build_reply_contents(self._agent_name, self.handoff_to, self._text_handoff, self._next_call_id())
reply = ChatMessage(
role=Role.ASSISTANT,
contents=contents,
@@ -91,7 +92,7 @@ class _RecordingAgent(BaseAgent):
conversation = _normalise(messages)
self.calls.append(conversation)
additional_props = _merge_additional_properties(self.handoff_to, self._text_handoff, self._extra_properties)
contents = _build_reply_contents(self.name, self.handoff_to, self._text_handoff, self._next_call_id())
contents = _build_reply_contents(self._agent_name, self.handoff_to, self._text_handoff, self._next_call_id())
yield AgentRunResponseUpdate(
contents=contents,
role=Role.ASSISTANT,
@@ -357,3 +358,38 @@ async def test_multiple_runs_dont_leak_conversation():
assert not any("First run message" in msg.text for msg in second_run_user_messages if msg.text), (
"Second run should NOT contain first run's messages"
)
async def test_handoff_async_termination_condition() -> None:
"""Test that async termination conditions work correctly."""
termination_call_count = 0
async def async_termination(conv: list[ChatMessage]) -> bool:
nonlocal termination_call_count
termination_call_count += 1
user_count = sum(1 for msg in conv if msg.role == Role.USER)
return user_count >= 2
coordinator = _RecordingAgent(name="coordinator")
workflow = (
HandoffBuilder(participants=[coordinator])
.set_coordinator(coordinator)
.with_termination_condition(async_termination)
.build()
)
events = await _drain(workflow.run_stream("First user message"))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Second user message"}))
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
assert len(outputs) == 1
final_conversation = outputs[0].data
assert isinstance(final_conversation, list)
final_conv_list = cast(list[ChatMessage], final_conversation)
user_messages = [msg for msg in final_conv_list if msg.role == Role.USER]
assert len(user_messages) == 2
assert termination_call_count > 0
@@ -2,7 +2,7 @@
from collections.abc import AsyncIterable
from dataclasses import dataclass
from typing import Any
from typing import Any, cast
import pytest
@@ -15,13 +15,12 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
Executor,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticManagerBase,
MagenticPlanReviewDecision,
MagenticPlanReviewReply,
MagenticPlanReviewRequest,
MagenticProgressLedger,
MagenticProgressLedgerItem,
RequestInfoEvent,
Role,
TextContent,
@@ -34,17 +33,19 @@ from agent_framework import (
handler,
)
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from agent_framework._workflows._magentic import (
from agent_framework._workflows._magentic import ( # type: ignore[reportPrivateUsage]
MagenticAgentExecutor,
MagenticContext,
MagenticOrchestratorExecutor,
MagenticStartMessage,
_MagenticProgressLedger, # type: ignore
_MagenticProgressLedgerItem, # type: ignore
_MagenticStartMessage, # type: ignore
)
def test_magentic_start_message_from_string():
msg = MagenticStartMessage.from_string("Do the thing")
assert isinstance(msg, MagenticStartMessage)
msg = _MagenticStartMessage.from_string("Do the thing")
assert isinstance(msg, _MagenticStartMessage)
assert isinstance(msg.task, ChatMessage)
assert msg.task.role == Role.USER
assert msg.task.text == "Do the thing"
@@ -114,8 +115,9 @@ class FakeManager(MagenticManagerBase):
super().restore_state(state)
ledger_state = state.get("task_ledger")
if isinstance(ledger_state, dict):
facts_payload = ledger_state.get("facts") # type: ignore[reportUnknownMemberType]
plan_payload = ledger_state.get("plan") # type: ignore[reportUnknownMemberType]
ledger_dict = cast(dict[str, Any], ledger_state)
facts_payload = cast(dict[str, Any] | None, ledger_dict.get("facts"))
plan_payload = cast(dict[str, Any] | None, ledger_dict.get("plan"))
if facts_payload is not None and plan_payload is not None:
try:
facts = ChatMessage.from_dict(facts_payload)
@@ -138,14 +140,14 @@ class FakeManager(MagenticManagerBase):
combined = f"Task: {magentic_context.task.text}\n\nFacts:\n{facts.text}\n\nPlan:\n{plan.text}"
return ChatMessage(role=Role.ASSISTANT, text=combined, author_name="magentic_manager")
async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger:
async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger:
is_satisfied = self.satisfied_after_signoff and len(magentic_context.chat_history) > 0
return MagenticProgressLedger(
is_request_satisfied=MagenticProgressLedgerItem(reason="test", answer=is_satisfied),
is_in_loop=MagenticProgressLedgerItem(reason="test", answer=False),
is_progress_being_made=MagenticProgressLedgerItem(reason="test", answer=True),
next_speaker=MagenticProgressLedgerItem(reason="test", answer=self.next_speaker_name),
instruction_or_question=MagenticProgressLedgerItem(reason="test", answer=self.instruction_text),
return _MagenticProgressLedger(
is_request_satisfied=_MagenticProgressLedgerItem(reason="test", answer=is_satisfied),
is_in_loop=_MagenticProgressLedgerItem(reason="test", answer=False),
is_progress_being_made=_MagenticProgressLedgerItem(reason="test", answer=True),
next_speaker=_MagenticProgressLedgerItem(reason="test", answer=self.next_speaker_name),
instruction_or_question=_MagenticProgressLedgerItem(reason="test", answer=self.instruction_text),
)
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage:
@@ -175,7 +177,7 @@ async def test_standard_manager_progress_ledger_and_fallback():
)
ledger = await manager.create_progress_ledger(ctx.clone())
assert isinstance(ledger, MagenticProgressLedger)
assert isinstance(ledger, _MagenticProgressLedger)
assert ledger.next_speaker.answer == "agentA"
manager.satisfied_after_signoff = False
@@ -328,13 +330,11 @@ async def test_magentic_checkpoint_resume_round_trip():
.build()
)
orchestrator = next(
exec for exec in wf_resume.workflow.executors.values() if isinstance(exec, MagenticOrchestratorExecutor)
)
orchestrator = next(exec for exec in wf_resume.executors.values() if isinstance(exec, MagenticOrchestratorExecutor))
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
completed: WorkflowOutputEvent | None = None
async for event in wf_resume.workflow.run_stream_from_checkpoint(
async for event in wf_resume.run_stream_from_checkpoint(
resume_checkpoint.checkpoint_id,
responses={req_event.request_id: reply},
):
@@ -346,8 +346,8 @@ async def test_magentic_checkpoint_resume_round_trip():
assert orchestrator._context.chat_history # type: ignore[reportPrivateUsage]
assert orchestrator._task_ledger is not None # type: ignore[reportPrivateUsage]
assert manager2.task_ledger is not None
# Initial message should be the task ledger plan
assert orchestrator._context.chat_history[0].text == orchestrator._task_ledger.text # type: ignore[reportPrivateUsage]
# Latest entry in chat history should be the task ledger plan
assert orchestrator._context.chat_history[-1].text == orchestrator._task_ledger.text # type: ignore[reportPrivateUsage]
class _DummyExec(Executor):
@@ -472,24 +472,24 @@ class InvokeOnceManager(MagenticManagerBase):
async def replan(self, magentic_context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="re-ledger")
async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger:
async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger:
if not self._invoked:
# First round: ask agentA to respond
self._invoked = True
return MagenticProgressLedger(
is_request_satisfied=MagenticProgressLedgerItem(reason="r", answer=False),
is_in_loop=MagenticProgressLedgerItem(reason="r", answer=False),
is_progress_being_made=MagenticProgressLedgerItem(reason="r", answer=True),
next_speaker=MagenticProgressLedgerItem(reason="r", answer="agentA"),
instruction_or_question=MagenticProgressLedgerItem(reason="r", answer="say hi"),
return _MagenticProgressLedger(
is_request_satisfied=_MagenticProgressLedgerItem(reason="r", answer=False),
is_in_loop=_MagenticProgressLedgerItem(reason="r", answer=False),
is_progress_being_made=_MagenticProgressLedgerItem(reason="r", answer=True),
next_speaker=_MagenticProgressLedgerItem(reason="r", answer="agentA"),
instruction_or_question=_MagenticProgressLedgerItem(reason="r", answer="say hi"),
)
# Next round: mark satisfied so run can conclude
return MagenticProgressLedger(
is_request_satisfied=MagenticProgressLedgerItem(reason="r", answer=True),
is_in_loop=MagenticProgressLedgerItem(reason="r", answer=False),
is_progress_being_made=MagenticProgressLedgerItem(reason="r", answer=True),
next_speaker=MagenticProgressLedgerItem(reason="r", answer="agentA"),
instruction_or_question=MagenticProgressLedgerItem(reason="r", answer="done"),
return _MagenticProgressLedger(
is_request_satisfied=_MagenticProgressLedgerItem(reason="r", answer=True),
is_in_loop=_MagenticProgressLedgerItem(reason="r", answer=False),
is_progress_being_made=_MagenticProgressLedgerItem(reason="r", answer=True),
next_speaker=_MagenticProgressLedgerItem(reason="r", answer="agentA"),
instruction_or_question=_MagenticProgressLedgerItem(reason="r", answer="done"),
)
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage:
@@ -533,17 +533,10 @@ class StubAssistantsAgent(BaseAgent):
async def _collect_agent_responses_setup(participant_obj: object):
captured: list[ChatMessage] = []
async def sink(event) -> None: # type: ignore[no-untyped-def]
from agent_framework._workflows._magentic import MagenticAgentMessageEvent
if isinstance(event, MagenticAgentMessageEvent) and event.message is not None:
captured.append(event.message)
wf = (
MagenticBuilder()
.participants(agentA=participant_obj) # type: ignore[arg-type]
.with_standard_manager(InvokeOnceManager())
.on_event(sink) # type: ignore
.build()
)
@@ -551,6 +544,10 @@ async def _collect_agent_responses_setup(participant_obj: object):
events: list[WorkflowEvent] = []
async for ev in wf.run_stream("task"): # plan review disabled
events.append(ev)
if isinstance(ev, WorkflowOutputEvent):
break
if isinstance(ev, MagenticAgentMessageEvent) and ev.message is not None:
captured.append(ev.message)
if len(events) > 50:
break
@@ -559,7 +556,7 @@ async def _collect_agent_responses_setup(participant_obj: object):
async def test_agent_executor_invoke_with_thread_chat_client():
captured = await _collect_agent_responses_setup(StubThreadAgent())
# Should have at least one response from agentA via MagenticAgentExecutor path
# Should have at least one response from agentA via _MagenticAgentExecutor path
assert any((m.author_name == "agentA" and "ok" in (m.text or "")) for m in captured)
@@ -685,7 +682,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
.build()
)
with pytest.raises(RuntimeError, match="participant names do not match"):
with pytest.raises(ValueError, match="Workflow graph has changed"):
async for _ in renamed_workflow.run_stream_from_checkpoint(
target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType]
responses={req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)},
@@ -704,13 +701,13 @@ class NotProgressingManager(MagenticManagerBase):
async def replan(self, magentic_context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="re-ledger")
async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger:
return MagenticProgressLedger(
is_request_satisfied=MagenticProgressLedgerItem(reason="r", answer=False),
is_in_loop=MagenticProgressLedgerItem(reason="r", answer=True),
is_progress_being_made=MagenticProgressLedgerItem(reason="r", answer=False),
next_speaker=MagenticProgressLedgerItem(reason="r", answer="agentA"),
instruction_or_question=MagenticProgressLedgerItem(reason="r", answer="done"),
async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger:
return _MagenticProgressLedger(
is_request_satisfied=_MagenticProgressLedgerItem(reason="r", answer=False),
is_in_loop=_MagenticProgressLedgerItem(reason="r", answer=True),
is_progress_being_made=_MagenticProgressLedgerItem(reason="r", answer=False),
next_speaker=_MagenticProgressLedgerItem(reason="r", answer="agentA"),
instruction_or_question=_MagenticProgressLedgerItem(reason="r", answer="done"),
)
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: