mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: Add factory pattern to GroupChat and Magentic (#3224)
* group chat * magentic * Fix tests * AI comments * Unifiy error message and add warning * misc * Add overload * Collapse orchestrator params
This commit is contained in:
committed by
GitHub
Unverified
parent
a7d924a7d2
commit
739edc7307
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable, Callable
|
||||
from collections.abc import AsyncIterable, Callable, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
@@ -40,7 +40,7 @@ class StubAgent(BaseAgent):
|
||||
|
||||
async def run( # type: ignore[override]
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -50,7 +50,7 @@ class StubAgent(BaseAgent):
|
||||
|
||||
def run_stream( # type: ignore[override]
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -66,9 +66,7 @@ class StubAgent(BaseAgent):
|
||||
class MockChatClient:
|
||||
"""Mock chat client that raises NotImplementedError for all methods."""
|
||||
|
||||
@property
|
||||
def additional_properties(self) -> dict[str, Any]:
|
||||
return {}
|
||||
additional_properties: dict[str, Any]
|
||||
|
||||
async def get_response(self, messages: Any, **kwargs: Any) -> ChatResponse:
|
||||
raise NotImplementedError
|
||||
@@ -84,7 +82,7 @@ class StubManagerAgent(ChatAgent):
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -130,7 +128,7 @@ class StubManagerAgent(ChatAgent):
|
||||
|
||||
def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -230,7 +228,7 @@ async def test_group_chat_builder_basic_flow() -> None:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_select_speaker_func(selector, orchestrator_name="manager")
|
||||
.with_orchestrator(selection_func=selector, orchestrator_name="manager")
|
||||
.participants([alpha, beta])
|
||||
.with_max_rounds(2) # Limit rounds to prevent infinite loop
|
||||
.build()
|
||||
@@ -257,7 +255,7 @@ async def test_group_chat_as_agent_accepts_conversation() -> None:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_select_speaker_func(selector, orchestrator_name="manager")
|
||||
.with_orchestrator(selection_func=selector, orchestrator_name="manager")
|
||||
.participants([alpha, beta])
|
||||
.with_max_rounds(2) # Limit rounds to prevent infinite loop
|
||||
.build()
|
||||
@@ -285,7 +283,9 @@ class TestGroupChatBuilder:
|
||||
|
||||
builder = GroupChatBuilder().participants([agent])
|
||||
|
||||
with pytest.raises(RuntimeError, match="Orchestrator could not be resolved"):
|
||||
with pytest.raises(
|
||||
ValueError, match=r"No orchestrator has been configured\. Call with_orchestrator\(\) to set one\."
|
||||
):
|
||||
builder.build()
|
||||
|
||||
def test_build_without_participants_raises_error(self) -> None:
|
||||
@@ -294,9 +294,12 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatState) -> str:
|
||||
return "agent"
|
||||
|
||||
builder = GroupChatBuilder().with_select_speaker_func(selector)
|
||||
builder = GroupChatBuilder().with_orchestrator(selection_func=selector)
|
||||
|
||||
with pytest.raises(ValueError, match="participants must be configured before build"):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"No participants provided\. Call \.participants\(\) or \.register_participants\(\) first\.",
|
||||
):
|
||||
builder.build()
|
||||
|
||||
def test_duplicate_manager_configuration_raises_error(self) -> None:
|
||||
@@ -305,10 +308,13 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatState) -> str:
|
||||
return "agent"
|
||||
|
||||
builder = GroupChatBuilder().with_select_speaker_func(selector)
|
||||
builder = GroupChatBuilder().with_orchestrator(selection_func=selector)
|
||||
|
||||
with pytest.raises(ValueError, match="select_speakers_func has already been configured"):
|
||||
builder.with_select_speaker_func(selector)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"A selection function has already been configured\. Call with_orchestrator\(\.\.\.\) once only\.",
|
||||
):
|
||||
builder.with_orchestrator(selection_func=selector)
|
||||
|
||||
def test_empty_participants_raises_error(self) -> None:
|
||||
"""Test that empty participants list raises ValueError."""
|
||||
@@ -316,7 +322,7 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatState) -> str:
|
||||
return "agent"
|
||||
|
||||
builder = GroupChatBuilder().with_select_speaker_func(selector)
|
||||
builder = GroupChatBuilder().with_orchestrator(selection_func=selector)
|
||||
|
||||
with pytest.raises(ValueError, match="participants cannot be empty"):
|
||||
builder.participants([])
|
||||
@@ -329,7 +335,7 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatState) -> str:
|
||||
return "agent"
|
||||
|
||||
builder = GroupChatBuilder().with_select_speaker_func(selector)
|
||||
builder = GroupChatBuilder().with_orchestrator(selection_func=selector)
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate participant name 'test'"):
|
||||
builder.participants([agent1, agent2])
|
||||
@@ -357,7 +363,7 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatState) -> str:
|
||||
return "agent"
|
||||
|
||||
builder = GroupChatBuilder().with_select_speaker_func(selector)
|
||||
builder = GroupChatBuilder().with_orchestrator(selection_func=selector)
|
||||
|
||||
with pytest.raises(ValueError, match="AgentProtocol participants must have a non-empty name"):
|
||||
builder.participants([agent])
|
||||
@@ -369,7 +375,7 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatState) -> str:
|
||||
return "agent"
|
||||
|
||||
builder = GroupChatBuilder().with_select_speaker_func(selector)
|
||||
builder = GroupChatBuilder().with_orchestrator(selection_func=selector)
|
||||
|
||||
with pytest.raises(ValueError, match="AgentProtocol participants must have a non-empty name"):
|
||||
builder.participants([agent])
|
||||
@@ -391,7 +397,7 @@ class TestGroupChatWorkflow:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_select_speaker_func(selector)
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(2) # Limit to 2 rounds
|
||||
.build()
|
||||
@@ -426,7 +432,7 @@ class TestGroupChatWorkflow:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_select_speaker_func(selector)
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.participants([agent])
|
||||
.with_termination_condition(termination_condition)
|
||||
.build()
|
||||
@@ -454,7 +460,7 @@ class TestGroupChatWorkflow:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_agent_orchestrator(manager)
|
||||
.with_orchestrator(agent=manager)
|
||||
.participants([worker])
|
||||
.with_termination_condition(lambda conv: any(msg.author_name == "agent" for msg in conv))
|
||||
.build()
|
||||
@@ -480,7 +486,7 @@ class TestGroupChatWorkflow:
|
||||
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = GroupChatBuilder().with_select_speaker_func(selector).participants([agent]).build()
|
||||
workflow = GroupChatBuilder().with_orchestrator(selection_func=selector).participants([agent]).build()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Selection function returned unknown participant 'unknown_agent'"):
|
||||
async for _ in workflow.run_stream("test task"):
|
||||
@@ -501,7 +507,7 @@ class TestCheckpointing:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_select_speaker_func(selector)
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(1)
|
||||
.with_checkpointing(storage)
|
||||
@@ -530,7 +536,11 @@ class TestConversationHandling:
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder().with_select_speaker_func(selector).participants([agent]).with_max_rounds(1).build()
|
||||
GroupChatBuilder()
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(1)
|
||||
.build()
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="At least one ChatMessage is required to start the group chat workflow."):
|
||||
@@ -550,7 +560,11 @@ class TestConversationHandling:
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder().with_select_speaker_func(selector).participants([agent]).with_max_rounds(1).build()
|
||||
GroupChatBuilder()
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(1)
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
@@ -575,7 +589,11 @@ class TestConversationHandling:
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder().with_select_speaker_func(selector).participants([agent]).with_max_rounds(1).build()
|
||||
GroupChatBuilder()
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(1)
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
@@ -603,7 +621,11 @@ class TestConversationHandling:
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder().with_select_speaker_func(selector).participants([agent]).with_max_rounds(1).build()
|
||||
GroupChatBuilder()
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(1)
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
@@ -632,7 +654,7 @@ class TestRoundLimitEnforcement:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_select_speaker_func(selector)
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(1) # Very low limit
|
||||
.build()
|
||||
@@ -667,7 +689,7 @@ class TestRoundLimitEnforcement:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_select_speaker_func(selector)
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(1) # Hit limit after first response
|
||||
.build()
|
||||
@@ -700,7 +722,7 @@ async def test_group_chat_checkpoint_runtime_only() -> None:
|
||||
wf = (
|
||||
GroupChatBuilder()
|
||||
.participants([agent_a, agent_b])
|
||||
.with_select_speaker_func(selector)
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.with_max_rounds(2)
|
||||
.build()
|
||||
)
|
||||
@@ -738,7 +760,7 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
wf = (
|
||||
GroupChatBuilder()
|
||||
.participants([agent_a, agent_b])
|
||||
.with_select_speaker_func(selector)
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.with_max_rounds(2)
|
||||
.with_checkpointing(buildtime_storage)
|
||||
.build()
|
||||
@@ -783,7 +805,7 @@ async def test_group_chat_with_request_info_filtering():
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_select_speaker_func(selector, orchestrator_name="manager")
|
||||
.with_orchestrator(selection_func=selector, orchestrator_name="manager")
|
||||
.participants([alpha, beta])
|
||||
.with_max_rounds(2)
|
||||
.with_request_info(agents=["beta"]) # Only pause before beta runs
|
||||
@@ -835,7 +857,7 @@ async def test_group_chat_with_request_info_no_filter_pauses_all():
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.with_select_speaker_func(selector, orchestrator_name="manager")
|
||||
.with_orchestrator(selection_func=selector, orchestrator_name="manager")
|
||||
.participants([alpha])
|
||||
.with_max_rounds(1)
|
||||
.with_request_info() # No filter - pause for all
|
||||
@@ -864,3 +886,450 @@ def test_group_chat_builder_with_request_info_returns_self():
|
||||
builder2 = GroupChatBuilder()
|
||||
result2 = builder2.with_request_info(agents=["test"])
|
||||
assert result2 is builder2
|
||||
|
||||
|
||||
# region Participant Factory Tests
|
||||
|
||||
|
||||
def test_group_chat_builder_rejects_empty_participant_factories():
|
||||
"""Test that GroupChatBuilder rejects empty participant_factories list."""
|
||||
|
||||
def selector(state: GroupChatState) -> str:
|
||||
return list(state.participants.keys())[0]
|
||||
|
||||
with pytest.raises(ValueError, match=r"participant_factories cannot be empty"):
|
||||
GroupChatBuilder().register_participants([])
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"No participants provided\. Call \.participants\(\) or \.register_participants\(\) first\.",
|
||||
):
|
||||
GroupChatBuilder().with_orchestrator(selection_func=selector).build()
|
||||
|
||||
|
||||
def test_group_chat_builder_rejects_mixing_participants_and_factories():
|
||||
"""Test that mixing .participants() and .register_participants() raises an error."""
|
||||
alpha = StubAgent("alpha", "reply from alpha")
|
||||
|
||||
# Case 1: participants first, then register_participants
|
||||
with pytest.raises(ValueError, match="Cannot mix .participants"):
|
||||
GroupChatBuilder().participants([alpha]).register_participants([lambda: StubAgent("beta", "reply from beta")])
|
||||
|
||||
# Case 2: register_participants first, then participants
|
||||
with pytest.raises(ValueError, match="Cannot mix .participants"):
|
||||
GroupChatBuilder().register_participants([lambda: alpha]).participants([StubAgent("beta", "reply from beta")])
|
||||
|
||||
|
||||
def test_group_chat_builder_rejects_multiple_calls_to_register_participants():
|
||||
"""Test that multiple calls to .register_participants() raises an error."""
|
||||
with pytest.raises(
|
||||
ValueError, match=r"register_participants\(\) has already been called on this builder instance."
|
||||
):
|
||||
(
|
||||
GroupChatBuilder()
|
||||
.register_participants([lambda: StubAgent("alpha", "reply from alpha")])
|
||||
.register_participants([lambda: StubAgent("beta", "reply from beta")])
|
||||
)
|
||||
|
||||
|
||||
def test_group_chat_builder_rejects_multiple_calls_to_participants():
|
||||
"""Test that multiple calls to .participants() raises an error."""
|
||||
with pytest.raises(ValueError, match="participants have already been set"):
|
||||
(
|
||||
GroupChatBuilder()
|
||||
.participants([StubAgent("alpha", "reply from alpha")])
|
||||
.participants([StubAgent("beta", "reply from beta")])
|
||||
)
|
||||
|
||||
|
||||
async def test_group_chat_with_participant_factories():
|
||||
"""Test workflow creation using participant_factories."""
|
||||
call_count = 0
|
||||
|
||||
def create_alpha() -> StubAgent:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return StubAgent("alpha", "reply from alpha")
|
||||
|
||||
def create_beta() -> StubAgent:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return StubAgent("beta", "reply from beta")
|
||||
|
||||
selector = make_sequence_selector()
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.register_participants([create_alpha, create_beta])
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.with_max_rounds(2)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Factories should be called during build
|
||||
assert call_count == 2
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
async for event in workflow.run_stream("coordinate task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
outputs.append(event)
|
||||
|
||||
assert len(outputs) == 1
|
||||
|
||||
|
||||
async def test_group_chat_participant_factories_reusable_builder():
|
||||
"""Test that the builder can be reused to build multiple workflows with factories."""
|
||||
call_count = 0
|
||||
|
||||
def create_alpha() -> StubAgent:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return StubAgent("alpha", "reply from alpha")
|
||||
|
||||
def create_beta() -> StubAgent:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return StubAgent("beta", "reply from beta")
|
||||
|
||||
selector = make_sequence_selector()
|
||||
|
||||
builder = (
|
||||
GroupChatBuilder()
|
||||
.register_participants([create_alpha, create_beta])
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.with_max_rounds(2)
|
||||
)
|
||||
|
||||
# Build first workflow
|
||||
wf1 = builder.build()
|
||||
assert call_count == 2
|
||||
|
||||
# Build second workflow
|
||||
wf2 = builder.build()
|
||||
assert call_count == 4
|
||||
|
||||
# Verify that the two workflows have different agent instances
|
||||
assert wf1.executors["alpha"] is not wf2.executors["alpha"]
|
||||
assert wf1.executors["beta"] is not wf2.executors["beta"]
|
||||
|
||||
|
||||
async def test_group_chat_participant_factories_with_checkpointing():
|
||||
"""Test checkpointing with participant_factories."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
def create_alpha() -> StubAgent:
|
||||
return StubAgent("alpha", "reply from alpha")
|
||||
|
||||
def create_beta() -> StubAgent:
|
||||
return StubAgent("beta", "reply from beta")
|
||||
|
||||
selector = make_sequence_selector()
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.register_participants([create_alpha, create_beta])
|
||||
.with_orchestrator(selection_func=selector)
|
||||
.with_checkpointing(storage)
|
||||
.with_max_rounds(2)
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
async for event in workflow.run_stream("checkpoint test"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
outputs.append(event)
|
||||
|
||||
assert outputs, "Should have workflow output"
|
||||
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
assert checkpoints, "Checkpoints should be created during workflow execution"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Orchestrator Factory Tests
|
||||
|
||||
|
||||
def test_group_chat_builder_rejects_multiple_orchestrator_configurations():
|
||||
"""Test that configuring multiple orchestrators raises ValueError."""
|
||||
|
||||
def selector(state: GroupChatState) -> str:
|
||||
return list(state.participants.keys())[0]
|
||||
|
||||
def agent_factory() -> ChatAgent:
|
||||
return cast(ChatAgent, StubManagerAgent())
|
||||
|
||||
builder = GroupChatBuilder().with_orchestrator(selection_func=selector)
|
||||
|
||||
# Already has a selection_func, should fail on second call
|
||||
with pytest.raises(ValueError, match=r"A selection function has already been configured"):
|
||||
builder.with_orchestrator(selection_func=selector)
|
||||
|
||||
# Test with agent_factory
|
||||
builder2 = GroupChatBuilder().with_orchestrator(agent=agent_factory)
|
||||
with pytest.raises(ValueError, match=r"A factory has already been configured"):
|
||||
builder2.with_orchestrator(agent=agent_factory)
|
||||
|
||||
|
||||
def test_group_chat_builder_requires_exactly_one_orchestrator_option():
|
||||
"""Test that exactly one orchestrator option must be provided."""
|
||||
|
||||
def selector(state: GroupChatState) -> str:
|
||||
return list(state.participants.keys())[0]
|
||||
|
||||
def agent_factory() -> ChatAgent:
|
||||
return cast(ChatAgent, StubManagerAgent())
|
||||
|
||||
# No options provided
|
||||
with pytest.raises(ValueError, match="Exactly one of"):
|
||||
GroupChatBuilder().with_orchestrator() # type: ignore
|
||||
|
||||
# Multiple options provided
|
||||
with pytest.raises(ValueError, match="Exactly one of"):
|
||||
GroupChatBuilder().with_orchestrator(selection_func=selector, agent=agent_factory) # type: ignore
|
||||
|
||||
|
||||
async def test_group_chat_with_orchestrator_factory_returning_chat_agent():
|
||||
"""Test workflow creation using orchestrator_factory that returns ChatAgent."""
|
||||
factory_call_count = 0
|
||||
|
||||
class DynamicManagerAgent(ChatAgent):
|
||||
"""Manager agent that dynamically selects from available participants."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(chat_client=MockChatClient(), name="dynamic_manager", description="Dynamic manager")
|
||||
self._call_count = 0
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse:
|
||||
if self._call_count == 0:
|
||||
self._call_count += 1
|
||||
payload = {
|
||||
"terminate": False,
|
||||
"reason": "Selecting alpha",
|
||||
"next_speaker": "alpha",
|
||||
"final_message": None,
|
||||
}
|
||||
return AgentResponse(
|
||||
messages=[
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
text=(
|
||||
'{"terminate": false, "reason": "Selecting alpha", '
|
||||
'"next_speaker": "alpha", "final_message": null}'
|
||||
),
|
||||
author_name=self.name,
|
||||
)
|
||||
],
|
||||
value=payload,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"terminate": True,
|
||||
"reason": "Task complete",
|
||||
"next_speaker": None,
|
||||
"final_message": "dynamic manager final",
|
||||
}
|
||||
return AgentResponse(
|
||||
messages=[
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
text=(
|
||||
'{"terminate": true, "reason": "Task complete", '
|
||||
'"next_speaker": null, "final_message": "dynamic manager final"}'
|
||||
),
|
||||
author_name=self.name,
|
||||
)
|
||||
],
|
||||
value=payload,
|
||||
)
|
||||
|
||||
def agent_factory() -> ChatAgent:
|
||||
nonlocal factory_call_count
|
||||
factory_call_count += 1
|
||||
return cast(ChatAgent, DynamicManagerAgent())
|
||||
|
||||
alpha = StubAgent("alpha", "reply from alpha")
|
||||
beta = StubAgent("beta", "reply from beta")
|
||||
|
||||
workflow = GroupChatBuilder().participants([alpha, beta]).with_orchestrator(agent=agent_factory).build()
|
||||
|
||||
# Factory should be called during build
|
||||
assert factory_call_count == 1
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
async for event in workflow.run_stream("coordinate task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
outputs.append(event)
|
||||
|
||||
assert len(outputs) == 1
|
||||
# The DynamicManagerAgent terminates after second call with final_message
|
||||
final_messages = outputs[0].data
|
||||
assert isinstance(final_messages, list)
|
||||
assert any(
|
||||
msg.text == "dynamic manager final"
|
||||
for msg in cast(list[ChatMessage], final_messages)
|
||||
if msg.author_name == "dynamic_manager"
|
||||
)
|
||||
|
||||
|
||||
def test_group_chat_with_orchestrator_factory_returning_base_orchestrator():
|
||||
"""Test that orchestrator_factory returning BaseGroupChatOrchestrator is used as-is."""
|
||||
factory_call_count = 0
|
||||
selector = make_sequence_selector()
|
||||
|
||||
def orchestrator_factory() -> BaseGroupChatOrchestrator:
|
||||
nonlocal factory_call_count
|
||||
factory_call_count += 1
|
||||
from agent_framework._workflows._base_group_chat_orchestrator import ParticipantRegistry
|
||||
from agent_framework._workflows._group_chat import GroupChatOrchestrator
|
||||
|
||||
# Create a custom orchestrator; when returning BaseGroupChatOrchestrator,
|
||||
# the builder uses it as-is without modifying its participant registry
|
||||
return GroupChatOrchestrator(
|
||||
id="custom_orchestrator",
|
||||
participant_registry=ParticipantRegistry([]),
|
||||
selection_func=selector,
|
||||
max_rounds=2,
|
||||
)
|
||||
|
||||
alpha = StubAgent("alpha", "reply from alpha")
|
||||
|
||||
workflow = GroupChatBuilder().participants([alpha]).with_orchestrator(orchestrator=orchestrator_factory).build()
|
||||
|
||||
# Factory should be called during build
|
||||
assert factory_call_count == 1
|
||||
# Verify the custom orchestrator is in the workflow
|
||||
assert "custom_orchestrator" in workflow.executors
|
||||
|
||||
|
||||
async def test_group_chat_orchestrator_factory_reusable_builder():
|
||||
"""Test that the builder can be reused to build multiple workflows with orchestrator factory."""
|
||||
factory_call_count = 0
|
||||
|
||||
def agent_factory() -> ChatAgent:
|
||||
nonlocal factory_call_count
|
||||
factory_call_count += 1
|
||||
return cast(ChatAgent, StubManagerAgent())
|
||||
|
||||
alpha = StubAgent("alpha", "reply from alpha")
|
||||
beta = StubAgent("beta", "reply from beta")
|
||||
|
||||
builder = GroupChatBuilder().participants([alpha, beta]).with_orchestrator(agent=agent_factory)
|
||||
|
||||
# Build first workflow
|
||||
wf1 = builder.build()
|
||||
assert factory_call_count == 1
|
||||
|
||||
# Build second workflow
|
||||
wf2 = builder.build()
|
||||
assert factory_call_count == 2
|
||||
|
||||
# Verify that the two workflows have different orchestrator instances
|
||||
assert wf1.executors["manager_agent"] is not wf2.executors["manager_agent"]
|
||||
|
||||
|
||||
def test_group_chat_orchestrator_factory_invalid_return_type():
|
||||
"""Test that orchestrator_factory raising error for invalid return type."""
|
||||
|
||||
def invalid_factory() -> Any:
|
||||
return "invalid type"
|
||||
|
||||
alpha = StubAgent("alpha", "reply from alpha")
|
||||
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match=r"Orchestrator factory must return ChatAgent or BaseGroupChatOrchestrator instance",
|
||||
):
|
||||
(GroupChatBuilder().participants([alpha]).with_orchestrator(orchestrator=invalid_factory).build())
|
||||
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match=r"Orchestrator factory must return ChatAgent or BaseGroupChatOrchestrator instance",
|
||||
):
|
||||
(GroupChatBuilder().participants([alpha]).with_orchestrator(agent=invalid_factory).build())
|
||||
|
||||
|
||||
def test_group_chat_with_both_participant_and_orchestrator_factories():
|
||||
"""Test workflow creation using both participant_factories and orchestrator_factory."""
|
||||
participant_factory_call_count = 0
|
||||
agent_factory_call_count = 0
|
||||
|
||||
def create_alpha() -> StubAgent:
|
||||
nonlocal participant_factory_call_count
|
||||
participant_factory_call_count += 1
|
||||
return StubAgent("alpha", "reply from alpha")
|
||||
|
||||
def create_beta() -> StubAgent:
|
||||
nonlocal participant_factory_call_count
|
||||
participant_factory_call_count += 1
|
||||
return StubAgent("beta", "reply from beta")
|
||||
|
||||
def agent_factory() -> ChatAgent:
|
||||
nonlocal agent_factory_call_count
|
||||
agent_factory_call_count += 1
|
||||
return cast(ChatAgent, StubManagerAgent())
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.register_participants([create_alpha, create_beta])
|
||||
.with_orchestrator(agent=agent_factory)
|
||||
.build()
|
||||
)
|
||||
|
||||
# All factories should be called during build
|
||||
assert participant_factory_call_count == 2
|
||||
assert agent_factory_call_count == 1
|
||||
|
||||
# Verify all executors are present in the workflow
|
||||
assert "alpha" in workflow.executors
|
||||
assert "beta" in workflow.executors
|
||||
assert "manager_agent" in workflow.executors
|
||||
|
||||
|
||||
async def test_group_chat_factories_reusable_for_multiple_workflows():
|
||||
"""Test that both factories are reused correctly for multiple workflow builds."""
|
||||
participant_factory_call_count = 0
|
||||
agent_factory_call_count = 0
|
||||
|
||||
def create_alpha() -> StubAgent:
|
||||
nonlocal participant_factory_call_count
|
||||
participant_factory_call_count += 1
|
||||
return StubAgent("alpha", "reply from alpha")
|
||||
|
||||
def create_beta() -> StubAgent:
|
||||
nonlocal participant_factory_call_count
|
||||
participant_factory_call_count += 1
|
||||
return StubAgent("beta", "reply from beta")
|
||||
|
||||
def agent_factory() -> ChatAgent:
|
||||
nonlocal agent_factory_call_count
|
||||
agent_factory_call_count += 1
|
||||
return cast(ChatAgent, StubManagerAgent())
|
||||
|
||||
builder = (
|
||||
GroupChatBuilder().register_participants([create_alpha, create_beta]).with_orchestrator(agent=agent_factory)
|
||||
)
|
||||
|
||||
# Build first workflow
|
||||
wf1 = builder.build()
|
||||
assert participant_factory_call_count == 2
|
||||
assert agent_factory_call_count == 1
|
||||
|
||||
# Build second workflow
|
||||
wf2 = builder.build()
|
||||
assert participant_factory_call_count == 4
|
||||
assert agent_factory_call_count == 2
|
||||
|
||||
# Verify that the workflows have different agent and orchestrator instances
|
||||
assert wf1.executors["alpha"] is not wf2.executors["alpha"]
|
||||
assert wf1.executors["beta"] is not wf2.executors["beta"]
|
||||
assert wf1.executors["manager_agent"] is not wf2.executors["manager_agent"]
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -209,7 +209,9 @@ def test_build_fails_without_start_agent():
|
||||
|
||||
def test_build_fails_without_participants():
|
||||
"""Verify that build() raises ValueError when no participants are provided."""
|
||||
with pytest.raises(ValueError, match="No participants or participant_factories have been configured."):
|
||||
with pytest.raises(
|
||||
ValueError, match=r"No participants provided\. Call \.participants\(\) or \.register_participants\(\) first."
|
||||
):
|
||||
HandoffBuilder().build()
|
||||
|
||||
|
||||
@@ -273,7 +275,7 @@ async def test_tool_choice_preserved_from_agent_config():
|
||||
agent = ChatAgent(
|
||||
chat_client=mock_client,
|
||||
name="test_agent",
|
||||
default_options={"tool_choice": {"mode": "required"}},
|
||||
default_options={"tool_choice": {"mode": "required"}}, # type: ignore
|
||||
)
|
||||
|
||||
# Run the agent
|
||||
@@ -293,9 +295,11 @@ def test_handoff_builder_rejects_empty_participant_factories():
|
||||
"""Test that HandoffBuilder rejects empty participant_factories dictionary."""
|
||||
# Empty factories are rejected immediately when calling participant_factories()
|
||||
with pytest.raises(ValueError, match=r"participant_factories cannot be empty"):
|
||||
HandoffBuilder().participant_factories({})
|
||||
HandoffBuilder().register_participants({})
|
||||
|
||||
with pytest.raises(ValueError, match=r"No participants or participant_factories have been configured"):
|
||||
with pytest.raises(
|
||||
ValueError, match=r"No participants provided\. Call \.participants\(\) or \.register_participants\(\) first\."
|
||||
):
|
||||
HandoffBuilder(participant_factories={}).build()
|
||||
|
||||
|
||||
@@ -312,7 +316,7 @@ def test_handoff_builder_rejects_mixing_participants_and_participant_factories_m
|
||||
|
||||
# Case 1: participants first, then participant_factories
|
||||
with pytest.raises(ValueError, match="Cannot mix .participants"):
|
||||
HandoffBuilder(participants=[triage]).participant_factories({
|
||||
HandoffBuilder(participants=[triage]).register_participants({
|
||||
"specialist": lambda: MockHandoffAgent(name="specialist")
|
||||
})
|
||||
|
||||
@@ -324,13 +328,13 @@ def test_handoff_builder_rejects_mixing_participants_and_participant_factories_m
|
||||
|
||||
# Case 3: participants(), then participant_factories()
|
||||
with pytest.raises(ValueError, match="Cannot mix .participants"):
|
||||
HandoffBuilder().participants([triage]).participant_factories({
|
||||
HandoffBuilder().participants([triage]).register_participants({
|
||||
"specialist": lambda: MockHandoffAgent(name="specialist")
|
||||
})
|
||||
|
||||
# Case 4: participant_factories(), then participants()
|
||||
with pytest.raises(ValueError, match="Cannot mix .participants"):
|
||||
HandoffBuilder().participant_factories({"triage": lambda: triage}).participants([
|
||||
HandoffBuilder().register_participants({"triage": lambda: triage}).participants([
|
||||
MockHandoffAgent(name="specialist")
|
||||
])
|
||||
|
||||
@@ -343,11 +347,13 @@ def test_handoff_builder_rejects_mixing_participants_and_participant_factories_m
|
||||
|
||||
def test_handoff_builder_rejects_multiple_calls_to_participant_factories():
|
||||
"""Test that multiple calls to .participant_factories() raises an error."""
|
||||
with pytest.raises(ValueError, match=r"participant_factories\(\) has already been called"):
|
||||
with pytest.raises(
|
||||
ValueError, match=r"register_participants\(\) has already been called on this builder instance."
|
||||
):
|
||||
(
|
||||
HandoffBuilder()
|
||||
.participant_factories({"agent1": lambda: MockHandoffAgent(name="agent1")})
|
||||
.participant_factories({"agent2": lambda: MockHandoffAgent(name="agent2")})
|
||||
.register_participants({"agent1": lambda: MockHandoffAgent(name="agent1")})
|
||||
.register_participants({"agent2": lambda: MockHandoffAgent(name="agent2")})
|
||||
)
|
||||
|
||||
|
||||
@@ -386,7 +392,7 @@ def test_handoff_builder_rejects_factory_name_coordinator_with_instances():
|
||||
triage = MockHandoffAgent(name="triage")
|
||||
specialist = MockHandoffAgent(name="specialist")
|
||||
|
||||
with pytest.raises(ValueError, match="Call participant_factories.*before with_start_agent"):
|
||||
with pytest.raises(ValueError, match=r"Call register_participants\(...\) before with_start_agent\(...\)"):
|
||||
(
|
||||
HandoffBuilder(participants=[triage, specialist]).with_start_agent(
|
||||
"triage"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
from collections.abc import AsyncIterable
|
||||
from collections.abc import AsyncIterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
@@ -155,7 +155,7 @@ class StubAgent(BaseAgent):
|
||||
|
||||
async def run( # type: ignore[override]
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -165,7 +165,7 @@ class StubAgent(BaseAgent):
|
||||
|
||||
def run_stream( # type: ignore[override]
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -193,7 +193,7 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None:
|
||||
manager = FakeManager()
|
||||
agent = StubAgent(manager.next_speaker_name, "first draft")
|
||||
|
||||
workflow = MagenticBuilder().participants([agent]).with_standard_manager(manager).build()
|
||||
workflow = MagenticBuilder().participants([agent]).with_manager(manager=manager).build()
|
||||
|
||||
assert isinstance(workflow, Workflow)
|
||||
|
||||
@@ -219,7 +219,7 @@ async def test_magentic_as_agent_does_not_accept_conversation() -> None:
|
||||
manager = FakeManager()
|
||||
writer = StubAgent(manager.next_speaker_name, "summary response")
|
||||
|
||||
workflow = MagenticBuilder().participants([writer]).with_standard_manager(manager).build()
|
||||
workflow = MagenticBuilder().participants([writer]).with_manager(manager=manager).build()
|
||||
|
||||
agent = workflow.as_agent(name="magentic-agent")
|
||||
conversation = [
|
||||
@@ -247,7 +247,7 @@ async def test_standard_manager_plan_and_replan_combined_ledger():
|
||||
|
||||
async def test_magentic_workflow_plan_review_approval_to_completion():
|
||||
manager = FakeManager()
|
||||
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_standard_manager(manager).with_plan_review().build()
|
||||
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_manager(manager=manager).with_plan_review().build()
|
||||
|
||||
req_event: RequestInfoEvent | None = None
|
||||
async for ev in wf.run_stream("do work"):
|
||||
@@ -288,7 +288,7 @@ async def test_magentic_plan_review_with_revise():
|
||||
wf = (
|
||||
MagenticBuilder()
|
||||
.participants([DummyExec(name=manager.next_speaker_name)])
|
||||
.with_standard_manager(manager)
|
||||
.with_manager(manager=manager)
|
||||
.with_plan_review()
|
||||
.build()
|
||||
)
|
||||
@@ -333,7 +333,7 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result():
|
||||
wf = (
|
||||
MagenticBuilder()
|
||||
.participants([DummyExec(name=manager.next_speaker_name)])
|
||||
.with_standard_manager(manager)
|
||||
.with_manager(manager=manager)
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -363,7 +363,7 @@ async def test_magentic_checkpoint_resume_round_trip():
|
||||
wf = (
|
||||
MagenticBuilder()
|
||||
.participants([DummyExec(name=manager1.next_speaker_name)])
|
||||
.with_standard_manager(manager1)
|
||||
.with_manager(manager=manager1)
|
||||
.with_plan_review()
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
@@ -386,7 +386,7 @@ async def test_magentic_checkpoint_resume_round_trip():
|
||||
wf_resume = (
|
||||
MagenticBuilder()
|
||||
.participants([DummyExec(name=manager2.next_speaker_name)])
|
||||
.with_standard_manager(manager2)
|
||||
.with_manager(manager=manager2)
|
||||
.with_plan_review()
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
@@ -422,7 +422,7 @@ class StubManagerAgent(BaseAgent):
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: Any = None,
|
||||
**kwargs: Any,
|
||||
@@ -431,7 +431,7 @@ class StubManagerAgent(BaseAgent):
|
||||
|
||||
def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: Any = None,
|
||||
**kwargs: Any,
|
||||
@@ -575,7 +575,7 @@ class StubAssistantsAgent(BaseAgent):
|
||||
async def _collect_agent_responses_setup(participant: AgentProtocol) -> list[ChatMessage]:
|
||||
captured: list[ChatMessage] = []
|
||||
|
||||
wf = MagenticBuilder().participants([participant]).with_standard_manager(InvokeOnceManager()).build()
|
||||
wf = MagenticBuilder().participants([participant]).with_manager(manager=InvokeOnceManager()).build()
|
||||
|
||||
# Run a bounded stream to allow one invoke and then completion
|
||||
events: list[WorkflowEvent] = []
|
||||
@@ -623,7 +623,7 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep():
|
||||
workflow = (
|
||||
MagenticBuilder()
|
||||
.participants([StubThreadAgent()])
|
||||
.with_standard_manager(InvokeOnceManager())
|
||||
.with_manager(manager=InvokeOnceManager())
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
@@ -638,7 +638,7 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep():
|
||||
resumed = (
|
||||
MagenticBuilder()
|
||||
.participants([StubThreadAgent()])
|
||||
.with_standard_manager(InvokeOnceManager())
|
||||
.with_manager(manager=InvokeOnceManager())
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
@@ -661,7 +661,7 @@ async def test_magentic_checkpoint_resume_from_saved_state():
|
||||
workflow = (
|
||||
MagenticBuilder()
|
||||
.participants([StubThreadAgent()])
|
||||
.with_standard_manager(manager)
|
||||
.with_manager(manager=manager)
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
@@ -678,7 +678,7 @@ async def test_magentic_checkpoint_resume_from_saved_state():
|
||||
resumed_workflow = (
|
||||
MagenticBuilder()
|
||||
.participants([StubThreadAgent()])
|
||||
.with_standard_manager(InvokeOnceManager())
|
||||
.with_manager(manager=InvokeOnceManager())
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
@@ -699,7 +699,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
|
||||
workflow = (
|
||||
MagenticBuilder()
|
||||
.participants([StubThreadAgent()])
|
||||
.with_standard_manager(manager)
|
||||
.with_manager(manager=manager)
|
||||
.with_plan_review()
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
@@ -719,7 +719,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
|
||||
renamed_workflow = (
|
||||
MagenticBuilder()
|
||||
.participants([StubThreadAgent(name="renamedAgent")])
|
||||
.with_standard_manager(InvokeOnceManager())
|
||||
.with_manager(manager=InvokeOnceManager())
|
||||
.with_plan_review()
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
@@ -759,7 +759,7 @@ class NotProgressingManager(MagenticManagerBase):
|
||||
async def test_magentic_stall_and_reset_reach_limits():
|
||||
manager = NotProgressingManager(max_round_count=10, max_stall_count=0, max_reset_count=1)
|
||||
|
||||
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_standard_manager(manager).build()
|
||||
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_manager(manager=manager).build()
|
||||
|
||||
events: list[WorkflowEvent] = []
|
||||
async for ev in wf.run_stream("test limits"):
|
||||
@@ -784,7 +784,7 @@ async def test_magentic_checkpoint_runtime_only() -> None:
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
manager = FakeManager(max_round_count=10)
|
||||
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_standard_manager(manager).build()
|
||||
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_manager(manager=manager).build()
|
||||
|
||||
baseline_output: ChatMessage | None = None
|
||||
async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
|
||||
@@ -819,7 +819,7 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
wf = (
|
||||
MagenticBuilder()
|
||||
.participants([DummyExec("agentA")])
|
||||
.with_standard_manager(manager)
|
||||
.with_manager(manager=manager)
|
||||
.with_checkpointing(buildtime_storage)
|
||||
.build()
|
||||
)
|
||||
@@ -874,7 +874,7 @@ async def test_magentic_checkpoint_restore_no_duplicate_history():
|
||||
wf = (
|
||||
MagenticBuilder()
|
||||
.participants([DummyExec("agentA")])
|
||||
.with_standard_manager(manager)
|
||||
.with_manager(manager=manager)
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
@@ -927,3 +927,374 @@ async def test_magentic_checkpoint_restore_no_duplicate_history():
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Participant Factory Tests
|
||||
|
||||
|
||||
def test_magentic_builder_rejects_empty_participant_factories():
|
||||
"""Test that MagenticBuilder rejects empty participant_factories list."""
|
||||
with pytest.raises(ValueError, match=r"participant_factories cannot be empty"):
|
||||
MagenticBuilder().register_participants([])
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"No participants provided\. Call \.participants\(\) or \.register_participants\(\) first\.",
|
||||
):
|
||||
MagenticBuilder().with_manager(manager=FakeManager()).build()
|
||||
|
||||
|
||||
def test_magentic_builder_rejects_mixing_participants_and_factories():
|
||||
"""Test that mixing .participants() and .register_participants() raises an error."""
|
||||
agent = StubAgent("agentA", "reply from agentA")
|
||||
|
||||
# Case 1: participants first, then register_participants
|
||||
with pytest.raises(ValueError, match="Cannot mix .participants"):
|
||||
MagenticBuilder().participants([agent]).register_participants([lambda: StubAgent("agentB", "reply")])
|
||||
|
||||
# Case 2: register_participants first, then participants
|
||||
with pytest.raises(ValueError, match="Cannot mix .participants"):
|
||||
MagenticBuilder().register_participants([lambda: agent]).participants([StubAgent("agentB", "reply")])
|
||||
|
||||
|
||||
def test_magentic_builder_rejects_multiple_calls_to_register_participants():
|
||||
"""Test that multiple calls to .register_participants() raises an error."""
|
||||
with pytest.raises(
|
||||
ValueError, match=r"register_participants\(\) has already been called on this builder instance."
|
||||
):
|
||||
(
|
||||
MagenticBuilder()
|
||||
.register_participants([lambda: StubAgent("agentA", "reply from agentA")])
|
||||
.register_participants([lambda: StubAgent("agentB", "reply from agentB")])
|
||||
)
|
||||
|
||||
|
||||
def test_magentic_builder_rejects_multiple_calls_to_participants():
|
||||
"""Test that multiple calls to .participants() raises an error."""
|
||||
with pytest.raises(ValueError, match="participants have already been set"):
|
||||
(
|
||||
MagenticBuilder()
|
||||
.participants([StubAgent("agentA", "reply from agentA")])
|
||||
.participants([StubAgent("agentB", "reply from agentB")])
|
||||
)
|
||||
|
||||
|
||||
async def test_magentic_with_participant_factories():
|
||||
"""Test workflow creation using participant_factories."""
|
||||
call_count = 0
|
||||
|
||||
def create_agent() -> StubAgent:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return StubAgent("agentA", "reply from agentA")
|
||||
|
||||
manager = FakeManager()
|
||||
workflow = MagenticBuilder().register_participants([create_agent]).with_manager(manager=manager).build()
|
||||
|
||||
# Factory should be called during build
|
||||
assert call_count == 1
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
async for event in workflow.run_stream("test task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
outputs.append(event)
|
||||
|
||||
assert len(outputs) == 1
|
||||
|
||||
|
||||
async def test_magentic_participant_factories_reusable_builder():
|
||||
"""Test that the builder can be reused to build multiple workflows with factories."""
|
||||
call_count = 0
|
||||
|
||||
def create_agent() -> StubAgent:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return StubAgent("agentA", "reply from agentA")
|
||||
|
||||
builder = MagenticBuilder().register_participants([create_agent]).with_manager(manager=FakeManager())
|
||||
|
||||
# Build first workflow
|
||||
wf1 = builder.build()
|
||||
assert call_count == 1
|
||||
|
||||
# Build second workflow
|
||||
wf2 = builder.build()
|
||||
assert call_count == 2
|
||||
|
||||
# Verify that the two workflows have different agent instances
|
||||
assert wf1.executors["agentA"] is not wf2.executors["agentA"]
|
||||
|
||||
|
||||
async def test_magentic_participant_factories_with_checkpointing():
|
||||
"""Test checkpointing with participant_factories."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
def create_agent() -> StubAgent:
|
||||
return StubAgent("agentA", "reply from agentA")
|
||||
|
||||
manager = FakeManager()
|
||||
workflow = (
|
||||
MagenticBuilder()
|
||||
.register_participants([create_agent])
|
||||
.with_manager(manager=manager)
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
async for event in workflow.run_stream("checkpoint test"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
outputs.append(event)
|
||||
|
||||
assert outputs, "Should have workflow output"
|
||||
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
assert checkpoints, "Checkpoints should be created during workflow execution"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Manager Factory Tests
|
||||
|
||||
|
||||
def test_magentic_builder_rejects_multiple_manager_configurations():
|
||||
"""Test that configuring multiple managers raises ValueError."""
|
||||
manager = FakeManager()
|
||||
|
||||
builder = MagenticBuilder().with_manager(manager=manager)
|
||||
|
||||
with pytest.raises(ValueError, match=r"with_manager\(\) has already been called"):
|
||||
builder.with_manager(manager=manager)
|
||||
|
||||
|
||||
def test_magentic_builder_requires_exactly_one_manager_option():
|
||||
"""Test that exactly one manager option must be provided."""
|
||||
manager = FakeManager()
|
||||
|
||||
def manager_factory() -> MagenticManagerBase:
|
||||
return FakeManager()
|
||||
|
||||
# No options provided
|
||||
with pytest.raises(ValueError, match="Exactly one of"):
|
||||
MagenticBuilder().with_manager() # type: ignore
|
||||
|
||||
# Multiple options provided
|
||||
with pytest.raises(ValueError, match="Exactly one of"):
|
||||
MagenticBuilder().with_manager(manager=manager, manager_factory=manager_factory) # type: ignore
|
||||
|
||||
|
||||
async def test_magentic_with_manager_factory():
|
||||
"""Test workflow creation using manager_factory."""
|
||||
factory_call_count = 0
|
||||
|
||||
def manager_factory() -> MagenticManagerBase:
|
||||
nonlocal factory_call_count
|
||||
factory_call_count += 1
|
||||
return FakeManager()
|
||||
|
||||
agent = StubAgent("agentA", "reply from agentA")
|
||||
workflow = MagenticBuilder().participants([agent]).with_manager(manager_factory=manager_factory).build()
|
||||
|
||||
# Factory should be called during build
|
||||
assert factory_call_count == 1
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
async for event in workflow.run_stream("test task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
outputs.append(event)
|
||||
|
||||
assert len(outputs) == 1
|
||||
|
||||
|
||||
async def test_magentic_with_agent_factory():
|
||||
"""Test workflow creation using agent_factory for StandardMagenticManager."""
|
||||
factory_call_count = 0
|
||||
|
||||
def agent_factory() -> AgentProtocol:
|
||||
nonlocal factory_call_count
|
||||
factory_call_count += 1
|
||||
return cast(AgentProtocol, StubManagerAgent())
|
||||
|
||||
participant = StubAgent("agentA", "reply from agentA")
|
||||
workflow = (
|
||||
MagenticBuilder()
|
||||
.participants([participant])
|
||||
.with_manager(agent_factory=agent_factory, max_round_count=1)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Factory should be called during build
|
||||
assert factory_call_count == 1
|
||||
|
||||
# Verify workflow can be started (may not complete successfully due to stub behavior)
|
||||
event_count = 0
|
||||
async for _ in workflow.run_stream("test task"):
|
||||
event_count += 1
|
||||
if event_count > 10:
|
||||
break
|
||||
|
||||
assert event_count > 0
|
||||
|
||||
|
||||
async def test_magentic_manager_factory_reusable_builder():
|
||||
"""Test that the builder can be reused to build multiple workflows with manager factory."""
|
||||
factory_call_count = 0
|
||||
|
||||
def manager_factory() -> MagenticManagerBase:
|
||||
nonlocal factory_call_count
|
||||
factory_call_count += 1
|
||||
return FakeManager()
|
||||
|
||||
agent = StubAgent("agentA", "reply from agentA")
|
||||
builder = MagenticBuilder().participants([agent]).with_manager(manager_factory=manager_factory)
|
||||
|
||||
# Build first workflow
|
||||
wf1 = builder.build()
|
||||
assert factory_call_count == 1
|
||||
|
||||
# Build second workflow
|
||||
wf2 = builder.build()
|
||||
assert factory_call_count == 2
|
||||
|
||||
# Verify that the two workflows have different orchestrator instances
|
||||
orchestrator1 = next(e for e in wf1.executors.values() if isinstance(e, MagenticOrchestrator))
|
||||
orchestrator2 = next(e for e in wf2.executors.values() if isinstance(e, MagenticOrchestrator))
|
||||
assert orchestrator1 is not orchestrator2
|
||||
|
||||
|
||||
def test_magentic_with_both_participant_and_manager_factories():
|
||||
"""Test workflow creation using both participant_factories and manager_factory."""
|
||||
participant_factory_call_count = 0
|
||||
manager_factory_call_count = 0
|
||||
|
||||
def create_agent() -> StubAgent:
|
||||
nonlocal participant_factory_call_count
|
||||
participant_factory_call_count += 1
|
||||
return StubAgent("agentA", "reply from agentA")
|
||||
|
||||
def manager_factory() -> MagenticManagerBase:
|
||||
nonlocal manager_factory_call_count
|
||||
manager_factory_call_count += 1
|
||||
return FakeManager()
|
||||
|
||||
workflow = (
|
||||
MagenticBuilder().register_participants([create_agent]).with_manager(manager_factory=manager_factory).build()
|
||||
)
|
||||
|
||||
# All factories should be called during build
|
||||
assert participant_factory_call_count == 1
|
||||
assert manager_factory_call_count == 1
|
||||
|
||||
# Verify executor is present in the workflow
|
||||
assert "agentA" in workflow.executors
|
||||
|
||||
|
||||
async def test_magentic_factories_reusable_for_multiple_workflows():
|
||||
"""Test that both factories are reused correctly for multiple workflow builds."""
|
||||
participant_factory_call_count = 0
|
||||
manager_factory_call_count = 0
|
||||
|
||||
def create_agent() -> StubAgent:
|
||||
nonlocal participant_factory_call_count
|
||||
participant_factory_call_count += 1
|
||||
return StubAgent("agentA", "reply from agentA")
|
||||
|
||||
def manager_factory() -> MagenticManagerBase:
|
||||
nonlocal manager_factory_call_count
|
||||
manager_factory_call_count += 1
|
||||
return FakeManager()
|
||||
|
||||
builder = MagenticBuilder().register_participants([create_agent]).with_manager(manager_factory=manager_factory)
|
||||
|
||||
# Build first workflow
|
||||
wf1 = builder.build()
|
||||
assert participant_factory_call_count == 1
|
||||
assert manager_factory_call_count == 1
|
||||
|
||||
# Build second workflow
|
||||
wf2 = builder.build()
|
||||
assert participant_factory_call_count == 2
|
||||
assert manager_factory_call_count == 2
|
||||
|
||||
# Verify that the workflows have different agent and orchestrator instances
|
||||
assert wf1.executors["agentA"] is not wf2.executors["agentA"]
|
||||
|
||||
orchestrator1 = next(e for e in wf1.executors.values() if isinstance(e, MagenticOrchestrator))
|
||||
orchestrator2 = next(e for e in wf2.executors.values() if isinstance(e, MagenticOrchestrator))
|
||||
assert orchestrator1 is not orchestrator2
|
||||
|
||||
|
||||
def test_magentic_agent_factory_with_standard_manager_options():
|
||||
"""Test that agent_factory properly passes through standard manager options."""
|
||||
factory_call_count = 0
|
||||
|
||||
def agent_factory() -> AgentProtocol:
|
||||
nonlocal factory_call_count
|
||||
factory_call_count += 1
|
||||
return cast(AgentProtocol, StubManagerAgent())
|
||||
|
||||
# Custom options to verify they are passed through
|
||||
custom_max_stall_count = 5
|
||||
custom_max_reset_count = 2
|
||||
custom_max_round_count = 10
|
||||
custom_facts_prompt = "Custom facts prompt: {task}"
|
||||
custom_plan_prompt = "Custom plan prompt: {team}"
|
||||
custom_full_prompt = "Custom full prompt: {task} {team} {facts} {plan}"
|
||||
custom_facts_update_prompt = "Custom facts update: {task} {old_facts}"
|
||||
custom_plan_update_prompt = "Custom plan update: {team}"
|
||||
custom_progress_prompt = "Custom progress: {task} {team} {names}"
|
||||
custom_final_prompt = "Custom final: {task}"
|
||||
|
||||
# Create a custom task ledger
|
||||
from agent_framework._workflows._magentic import _MagenticTaskLedger
|
||||
|
||||
custom_task_ledger = _MagenticTaskLedger(
|
||||
facts=ChatMessage(role=Role.ASSISTANT, text="Custom facts"),
|
||||
plan=ChatMessage(role=Role.ASSISTANT, text="Custom plan"),
|
||||
)
|
||||
|
||||
participant = StubAgent("agentA", "reply from agentA")
|
||||
workflow = (
|
||||
MagenticBuilder()
|
||||
.participants([participant])
|
||||
.with_manager(
|
||||
agent_factory=agent_factory,
|
||||
task_ledger=custom_task_ledger,
|
||||
max_stall_count=custom_max_stall_count,
|
||||
max_reset_count=custom_max_reset_count,
|
||||
max_round_count=custom_max_round_count,
|
||||
task_ledger_facts_prompt=custom_facts_prompt,
|
||||
task_ledger_plan_prompt=custom_plan_prompt,
|
||||
task_ledger_full_prompt=custom_full_prompt,
|
||||
task_ledger_facts_update_prompt=custom_facts_update_prompt,
|
||||
task_ledger_plan_update_prompt=custom_plan_update_prompt,
|
||||
progress_ledger_prompt=custom_progress_prompt,
|
||||
final_answer_prompt=custom_final_prompt,
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Factory should be called during build
|
||||
assert factory_call_count == 1
|
||||
|
||||
# Get the orchestrator and verify the manager has the custom options
|
||||
orchestrator = next(e for e in workflow.executors.values() if isinstance(e, MagenticOrchestrator))
|
||||
manager = orchestrator._manager # type: ignore[reportPrivateUsage]
|
||||
|
||||
# Verify the manager is a StandardMagenticManager with the expected options
|
||||
from agent_framework import StandardMagenticManager
|
||||
|
||||
assert isinstance(manager, StandardMagenticManager)
|
||||
assert manager.task_ledger is custom_task_ledger
|
||||
assert manager.max_stall_count == custom_max_stall_count
|
||||
assert manager.max_reset_count == custom_max_reset_count
|
||||
assert manager.max_round_count == custom_max_round_count
|
||||
assert manager.task_ledger_facts_prompt == custom_facts_prompt
|
||||
assert manager.task_ledger_plan_prompt == custom_plan_prompt
|
||||
assert manager.task_ledger_full_prompt == custom_full_prompt
|
||||
assert manager.task_ledger_facts_update_prompt == custom_facts_update_prompt
|
||||
assert manager.task_ledger_plan_update_prompt == custom_plan_update_prompt
|
||||
assert manager.progress_ledger_prompt == custom_progress_prompt
|
||||
assert manager.final_answer_prompt == custom_final_prompt
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from collections.abc import AsyncIterable, Sequence
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
@@ -51,7 +51,7 @@ class _KwargsCapturingAgent(BaseAgent):
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -61,7 +61,7 @@ class _KwargsCapturingAgent(BaseAgent):
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -187,7 +187,7 @@ async def test_groupchat_kwargs_flow_to_agents() -> None:
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.participants([agent1, agent2])
|
||||
.with_select_speaker_func(simple_selector)
|
||||
.with_orchestrator(selection_func=simple_selector)
|
||||
.with_max_rounds(2) # Limit rounds to prevent infinite loop
|
||||
.build()
|
||||
)
|
||||
@@ -408,7 +408,7 @@ async def test_magentic_kwargs_flow_to_agents() -> None:
|
||||
agent = _KwargsCapturingAgent(name="agent1")
|
||||
manager = _MockManager()
|
||||
|
||||
workflow = MagenticBuilder().participants([agent]).with_standard_manager(manager=manager).build()
|
||||
workflow = MagenticBuilder().participants([agent]).with_manager(manager=manager).build()
|
||||
|
||||
custom_data = {"session_id": "magentic123"}
|
||||
|
||||
@@ -457,7 +457,7 @@ async def test_magentic_kwargs_stored_in_shared_state() -> None:
|
||||
agent = _KwargsCapturingAgent(name="agent1")
|
||||
manager = _MockManager()
|
||||
|
||||
magentic_workflow = MagenticBuilder().participants([agent]).with_standard_manager(manager=manager).build()
|
||||
magentic_workflow = MagenticBuilder().participants([agent]).with_manager(manager=manager).build()
|
||||
|
||||
# Use MagenticWorkflow.run_stream() which goes through the kwargs attachment path
|
||||
custom_data = {"magentic_key": "magentic_value"}
|
||||
|
||||
Reference in New Issue
Block a user