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 09:14:06 +09:00
committed by GitHub
Unverified
parent 899d8ff775
commit e3aad8e4e0
38 changed files with 5024 additions and 814 deletions
@@ -0,0 +1,75 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from agent_framework import AgentRunUpdateEvent, ChatAgent, GroupChatBuilder, WorkflowOutputEvent
from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient
logging.basicConfig(level=logging.INFO)
"""
Sample: Group Chat Orchestration (manager-directed)
What it does:
- Demonstrates the generic GroupChatBuilder with a language-model manager directing two agents.
- The manager coordinates a researcher (chat completions) and a writer (responses API) to solve a task.
- Uses the default group chat orchestration pipeline shared with Magentic.
Prerequisites:
- OpenAI environment variables configured for `OpenAIChatClient` and `OpenAIResponsesClient`.
"""
async def main() -> None:
researcher = ChatAgent(
name="Researcher",
description="Collects relevant background information.",
instructions="Gather concise facts that help a teammate answer the question.",
chat_client=OpenAIChatClient(model_id="gpt-4o-mini"),
)
writer = ChatAgent(
name="Writer",
description="Synthesizes a polished answer using the gathered notes.",
instructions="Compose clear and structured answers using any notes provided.",
chat_client=OpenAIResponsesClient(),
)
workflow = (
GroupChatBuilder()
.set_prompt_based_manager(chat_client=OpenAIChatClient(), display_name="Coordinator")
.participants(researcher=researcher, writer=writer)
.build()
)
task = "Outline the core considerations for planning a community hackathon, and finish with a concise action plan."
print("\nStarting Group Chat Workflow...\n")
print(f"TASK: {task}\n")
final_response = None
last_executor_id: str | None = None
async for event in workflow.run_stream(task):
if isinstance(event, AgentRunUpdateEvent):
# Handle the streaming agent update as it's produced
eid = event.executor_id
if eid != last_executor_id:
if last_executor_id is not None:
print()
print(f"{eid}:", end=" ", flush=True)
last_executor_id = eid
print(event.data, end="", flush=True)
elif isinstance(event, WorkflowOutputEvent):
final_response = getattr(event.data, "text", str(event.data))
if final_response:
print("=" * 60)
print("FINAL RESPONSE")
print("=" * 60)
print(final_response)
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,110 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from agent_framework import ChatAgent, GroupChatBuilder, GroupChatStateSnapshot, WorkflowOutputEvent
from agent_framework.openai import OpenAIChatClient
logging.basicConfig(level=logging.INFO)
"""
Sample: Group Chat with Simple Speaker Selector Function
What it does:
- Demonstrates the select_speakers() API for GroupChat orchestration
- Uses a pure Python function to control speaker selection based on conversation state
- Alternates between researcher and writer agents in a simple round-robin pattern
- Shows how to access conversation history, round index, and participant metadata
Key pattern:
def select_next_speaker(state: GroupChatStateSnapshot) -> str | None:
# state contains: task, participants, conversation, history, round_index
# Return participant name to continue, or None to finish
...
Prerequisites:
- OpenAI environment variables configured for OpenAIChatClient
"""
def select_next_speaker(state: GroupChatStateSnapshot) -> str | None:
"""Simple speaker selector that alternates between researcher and writer.
This function demonstrates the core pattern:
1. Examine the current state of the group chat
2. Decide who should speak next
3. Return participant name or None to finish
Args:
state: Immutable snapshot containing:
- task: ChatMessage - original user task
- participants: dict[str, str] - participant names → descriptions
- conversation: tuple[ChatMessage, ...] - full conversation history
- history: tuple[GroupChatTurn, ...] - turn-by-turn with speaker attribution
- round_index: int - number of selection rounds so far
- pending_agent: str | None - currently active agent (if any)
Returns:
Name of next speaker, or None to finish the conversation
"""
round_idx = state["round_index"]
history = state["history"]
# Finish after 4 turns (researcher → writer → researcher → writer)
if round_idx >= 4:
return None
# Get the last speaker from history
last_speaker = history[-1].speaker if history else None
# Simple alternation: researcher → writer → researcher → writer
if last_speaker == "Researcher":
return "Writer"
return "Researcher"
async def main() -> None:
researcher = ChatAgent(
name="Researcher",
description="Collects relevant background information.",
instructions="Gather concise facts that help answer the question. Be brief.",
chat_client=OpenAIChatClient(model_id="gpt-4o-mini"),
)
writer = ChatAgent(
name="Writer",
description="Synthesizes a polished answer using the gathered notes.",
instructions="Compose a clear, structured answer using any notes provided.",
chat_client=OpenAIChatClient(model_id="gpt-4o-mini"),
)
# Two ways to specify participants:
# 1. List form - uses agent.name attribute: .participants([researcher, writer])
# 2. Dict form - explicit names: .participants(researcher=researcher, writer=writer)
workflow = (
GroupChatBuilder()
.select_speakers(select_next_speaker, display_name="Orchestrator")
.participants([researcher, writer]) # Uses agent.name for participant names
.build()
)
task = "What are the key benefits of using async/await in Python?"
print("\nStarting Group Chat with Simple Speaker Selector...\n")
print(f"TASK: {task}\n")
print("=" * 80)
async for event in workflow.run_stream(task):
if isinstance(event, WorkflowOutputEvent):
final_message = event.data
author = getattr(final_message, "author_name", "Unknown")
text = getattr(final_message, "text", str(final_message))
print(f"\n[{author}]\n{text}\n")
print("-" * 80)
print("\nWorkflow completed.")
if __name__ == "__main__":
asyncio.run(main())
@@ -9,8 +9,6 @@ from agent_framework import (
MagenticAgentDeltaEvent,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticCallbackEvent,
MagenticCallbackMode,
MagenticFinalResultEvent,
MagenticOrchestratorMessageEvent,
WorkflowOutputEvent,
@@ -66,40 +64,6 @@ async def main() -> None:
tools=HostedCodeInterpreterTool(),
)
# Unified callback
async def on_event(event: MagenticCallbackEvent) -> None:
"""
The `on_event` callback processes events emitted by the workflow.
Events include: orchestrator messages, agent delta updates, agent messages, and final result events.
"""
nonlocal last_stream_agent_id, stream_line_open
if isinstance(event, MagenticOrchestratorMessageEvent):
print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}")
elif isinstance(event, MagenticAgentDeltaEvent):
if last_stream_agent_id != event.agent_id or not stream_line_open:
if stream_line_open:
print()
print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True)
last_stream_agent_id = event.agent_id
stream_line_open = True
print(event.text, end="", flush=True)
elif isinstance(event, MagenticAgentMessageEvent):
if stream_line_open:
print(" (final)")
stream_line_open = False
print()
msg = event.message
if msg is not None:
response_text = (msg.text or "").replace("\n", " ")
print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}")
elif isinstance(event, MagenticFinalResultEvent):
print("\n" + "=" * 50)
print("FINAL RESULT:")
print("=" * 50)
if event.message is not None:
print(event.message.text)
print("=" * 50)
print("\nBuilding Magentic Workflow...")
# State used by on_agent_stream callback
@@ -109,7 +73,6 @@ async def main() -> None:
workflow = (
MagenticBuilder()
.participants(researcher=researcher_agent, coder=coder_agent)
.on_event(on_event, mode=MagenticCallbackMode.STREAMING)
.with_standard_manager(
chat_client=OpenAIChatClient(),
max_round_count=10,
@@ -134,9 +97,39 @@ async def main() -> None:
try:
output: str | None = None
async for event in workflow.run_stream(task):
print(event)
if isinstance(event, WorkflowOutputEvent):
output = str(event.data)
if isinstance(event, MagenticOrchestratorMessageEvent):
print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}")
elif isinstance(event, MagenticAgentDeltaEvent):
if last_stream_agent_id != event.agent_id or not stream_line_open:
if stream_line_open:
print()
print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True)
last_stream_agent_id = event.agent_id
stream_line_open = True
if event.text:
print(event.text, end="", flush=True)
elif isinstance(event, MagenticAgentMessageEvent):
if stream_line_open:
print(" (final)")
stream_line_open = False
print()
msg = event.message
if msg is not None:
response_text = (msg.text or "").replace("\n", " ")
print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}")
elif isinstance(event, MagenticFinalResultEvent):
print("\n" + "=" * 50)
print("FINAL RESULT:")
print("=" * 50)
if event.message is not None:
print(event.message.text)
print("=" * 50)
elif isinstance(event, WorkflowOutputEvent):
output = str(event.data) if event.data is not None else None
if stream_line_open:
print()
stream_line_open = False
if output is not None:
print(f"Workflow completed with result:\n\n{output}")
@@ -113,7 +113,7 @@ async def main() -> None:
print("No plan review request emitted; nothing to resume.")
return
checkpoints = await checkpoint_storage.list_checkpoints(workflow.workflow.id)
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
if not checkpoints:
print("No checkpoints persisted.")
return
@@ -141,7 +141,7 @@ async def main() -> None:
# and then continues the workflow. Because we only captured the initial plan review
# checkpoint, the resumed run should complete almost immediately.
final_event: WorkflowOutputEvent | None = None
async for event in resumed_workflow.workflow.run_stream_from_checkpoint(
async for event in resumed_workflow.run_stream_from_checkpoint(
resume_checkpoint.checkpoint_id,
responses={plan_review_request_id: approval},
):
@@ -204,7 +204,7 @@ async def main() -> None:
final_event_post: WorkflowOutputEvent | None = None
post_emitted_events = False
post_plan_workflow = build_workflow(checkpoint_storage)
async for event in post_plan_workflow.workflow.run_stream_from_checkpoint(
async for event in post_plan_workflow.run_stream_from_checkpoint(
post_plan_checkpoint.checkpoint_id,
responses={},
):
@@ -10,8 +10,6 @@ from agent_framework import (
MagenticAgentDeltaEvent,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticCallbackEvent,
MagenticCallbackMode,
MagenticFinalResultEvent,
MagenticOrchestratorMessageEvent,
MagenticPlanReviewDecision,
@@ -77,43 +75,11 @@ async def main() -> None:
last_stream_agent_id: str | None = None
stream_line_open: bool = False
# Unified callback
async def on_event(event: MagenticCallbackEvent) -> None:
nonlocal last_stream_agent_id, stream_line_open
if isinstance(event, MagenticOrchestratorMessageEvent):
print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}")
elif isinstance(event, MagenticAgentDeltaEvent):
if last_stream_agent_id != event.agent_id or not stream_line_open:
if stream_line_open:
print()
print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True)
last_stream_agent_id = event.agent_id
stream_line_open = True
print(event.text, end="", flush=True)
elif isinstance(event, MagenticAgentMessageEvent):
if stream_line_open:
print(" (final)")
stream_line_open = False
print()
msg = event.message
if msg is not None:
response_text = (msg.text or "").replace("\n", " ")
print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}")
elif isinstance(event, MagenticFinalResultEvent):
print("\n" + "=" * 50)
print("FINAL RESULT:")
print("=" * 50)
if event.message is not None:
print(event.message.text)
print("=" * 50)
print("\nBuilding Magentic Workflow...")
workflow = (
MagenticBuilder()
.participants(researcher=researcher_agent, coder=coder_agent)
.on_exception(on_exception)
.on_event(on_event, mode=MagenticCallbackMode.STREAMING)
.with_standard_manager(
chat_client=OpenAIChatClient(),
max_round_count=10,
@@ -150,11 +116,34 @@ async def main() -> None:
stream = workflow.run_stream(task)
# Collect events from the stream
events = [event async for event in stream]
pending_responses = None
# Process events to find request info events, outputs, and completion status
for event in events:
async for event in stream:
if isinstance(event, MagenticOrchestratorMessageEvent):
print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}")
elif isinstance(event, MagenticAgentDeltaEvent):
if last_stream_agent_id != event.agent_id or not stream_line_open:
if stream_line_open:
print()
print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True)
last_stream_agent_id = event.agent_id
stream_line_open = True
if event.text:
print(event.text, end="", flush=True)
elif isinstance(event, MagenticAgentMessageEvent):
if stream_line_open:
print(" (final)")
stream_line_open = False
print()
msg = event.message
if msg is not None:
response_text = (msg.text or "").replace("\n", " ")
print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}")
elif isinstance(event, MagenticFinalResultEvent):
print("\n" + "=" * 50)
print("FINAL RESULT:")
print("=" * 50)
if event.message is not None:
print(event.message.text)
print("=" * 50)
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
pending_request = event
review_req = cast(MagenticPlanReviewRequest, event.data)
@@ -162,9 +151,14 @@ async def main() -> None:
print(f"\n=== PLAN REVIEW REQUEST ===\n{review_req.plan_text}\n")
elif isinstance(event, WorkflowOutputEvent):
# Capture workflow output during streaming
workflow_output = str(event.data)
workflow_output = str(event.data) if event.data else None
completed = True
if stream_line_open:
print()
stream_line_open = False
pending_responses = None
# Handle pending plan review request
if pending_request is not None:
# Get human input for plan review decision