[BREAKING] Python: Fix workflow as agent streaming output (#3649)

* WIP: with_output_from

* Add with_output_from to other modules; next: workflow as agent

* WIP: remove agent run events

* orchestrations

* WIP: update samples; next start at guessing_game_With_human_input.py

* Update all samples

* WIP: consolidate workflow as agent streaming vs non-streaming

* Consolidate workflow as agent streaming vs non-streaming

* Move request info event processing to a share method

* Final pass on the samples

* Fix mypy

* Fix mypy

* Comments

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
This commit is contained in:
Tao Chen
2026-02-05 00:16:45 +00:00
committed by GitHub
co-authored by Evan Mattson
parent 907654a489
commit a971d24f1e
68 changed files with 2652 additions and 2247 deletions
@@ -38,8 +38,6 @@ from ._edge import (
)
from ._edge_runner import create_edge_runner
from ._events import (
AgentRunEvent,
AgentRunUpdateEvent,
ExecutorCompletedEvent,
ExecutorEvent,
ExecutorFailedEvent,
@@ -131,8 +129,6 @@ __all__ = [
"AgentExecutorRequest",
"AgentExecutorResponse",
"AgentRequestInfoResponse",
"AgentRunEvent",
"AgentRunUpdateEvent",
"BaseGroupChatOrchestrator",
"Case",
"CheckpointStorage",
@@ -21,16 +21,14 @@ from agent_framework import (
from .._types import add_usage_details
from ..exceptions import AgentExecutionException
from ._agent_executor import AgentExecutor
from ._checkpoint import CheckpointStorage
from ._events import (
AgentRunUpdateEvent,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
)
from ._message_utils import normalize_messages_input
from ._typing_utils import is_type_compatible
from ._typing_utils import is_instance_of, is_type_compatible
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
@@ -93,6 +91,12 @@ class WorkflowAgent(BaseAgent):
name: Optional name for the agent.
description: Optional description of the agent.
**kwargs: Additional keyword arguments passed to BaseAgent.
Note:
Only WorkflowOutputEvents and RequestInfoEvents from the workflow are considered and
converted to agent responses of the WorkflowAgent. Other workflow events are ignored.
Use `with_output_from` in WorkflowBuilder to control which executors' outputs are surfaced
as agent responses.
"""
if id is None:
id = f"WorkflowAgent_{uuid.uuid4().hex[:8]}"
@@ -118,6 +122,8 @@ class WorkflowAgent(BaseAgent):
def pending_requests(self) -> dict[str, RequestInfoEvent]:
return self._pending_requests
# region Run Methods
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
@@ -129,7 +135,7 @@ class WorkflowAgent(BaseAgent):
) -> AgentResponse:
"""Get a response from the workflow agent (non-streaming).
This method collects all streaming updates and merges them into a single response.
This method runs the workflow in non-streaming mode.
Args:
messages: The message(s) to send to the workflow. Required for new runs,
@@ -146,21 +152,19 @@ class WorkflowAgent(BaseAgent):
and tool functions.
Returns:
The final workflow response as an AgentResponse.
An AgentResponse representing the workflow execution results. The response
includes all output events and requests emitted during the workflow run.
WorkflowOutputEvents will be converted to ChatMessages in the response.
RequestInfoEvents will be converted to function call and approval request contents
in the response.
"""
# Collect all streaming updates
response_updates: list[AgentResponseUpdate] = []
input_messages = normalize_messages_input(messages)
thread = thread or self.get_new_thread()
response_id = str(uuid.uuid4())
async for update in self._run_stream_impl(
response = await self._run_impl(
input_messages, response_id, thread, checkpoint_id, checkpoint_storage, **kwargs
):
response_updates.append(update)
# Convert updates to final response.
response = self.merge_updates(response_updates, response_id)
)
# Notify thread of new messages (both input and response messages)
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
@@ -194,6 +198,10 @@ class WorkflowAgent(BaseAgent):
Yields:
AgentResponseUpdate objects representing the workflow execution progress.
Updates include output events and requests emitted during the workflow run.
WorkflowOutputEvents will be converted to AgentResponseUpdate objects.
RequestInfoEvents will be converted to function call and approval request contents
in the updates.
"""
input_messages = normalize_messages_input(messages)
thread = thread or self.get_new_thread()
@@ -212,6 +220,38 @@ class WorkflowAgent(BaseAgent):
# Notify thread of new messages (both input and response messages)
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
async def _run_impl(
self,
input_messages: list[ChatMessage],
response_id: str,
thread: AgentThread,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
) -> AgentResponse:
"""Internal implementation of non-streaming execution.
Args:
input_messages: Normalized input messages to process.
response_id: The unique response ID for this workflow execution.
thread: The conversation thread containing message history.
checkpoint_id: ID of checkpoint to restore from.
checkpoint_storage: Runtime checkpoint storage.
**kwargs: Additional keyword arguments passed through to the underlying
workflow and tool functions.
Returns:
An AgentResponse representing the workflow execution results.
"""
output_events: list[WorkflowOutputEvent | RequestInfoEvent] = []
async for event in self._run_core(
input_messages, thread, checkpoint_id, checkpoint_storage, streaming=False, **kwargs
):
if isinstance(event, WorkflowOutputEvent | RequestInfoEvent):
output_events.append(event)
return self._convert_workflow_events_to_agent_response(response_id, output_events)
async def _run_stream_impl(
self,
input_messages: list[ChatMessage],
@@ -235,150 +275,325 @@ class WorkflowAgent(BaseAgent):
Yields:
AgentResponseUpdate objects representing the workflow execution progress.
"""
# Determine the event stream based on whether we have function responses
async for event in self._run_core(
input_messages, thread, checkpoint_id, checkpoint_storage, streaming=True, **kwargs
):
updates = self._convert_workflow_event_to_agent_response_update(response_id, event)
for update in updates:
yield update
async def _run_core(
self,
input_messages: list[ChatMessage],
thread: AgentThread,
checkpoint_id: str | None,
checkpoint_storage: CheckpointStorage | None,
streaming: bool,
**kwargs: Any,
) -> AsyncIterable[WorkflowEvent]:
"""Core implementation that yields workflow events for both streaming and non-streaming modes.
Args:
input_messages: Normalized input messages to process.
thread: The conversation thread containing message history.
checkpoint_id: ID of checkpoint to restore from.
checkpoint_storage: Runtime checkpoint storage.
streaming: Whether to use streaming workflow methods.
**kwargs: Additional keyword arguments passed through to the underlying
workflow and tool functions.
Yields:
WorkflowEvent objects from the workflow execution.
"""
# Determine the execution mode based on state
if bool(self.pending_requests):
# This is a continuation - use send_responses_streaming to send function responses back
logger.info(f"Continuing workflow to address {len(self.pending_requests)} requests")
# This is a continuation - send function responses back
function_responses = self._process_pending_requests(input_messages)
# Extract function responses from input messages, and ensure that
# only function responses are present in messages if there is any
# pending request.
function_responses = self._extract_function_responses(input_messages)
if streaming:
async for event in self.workflow.send_responses_streaming(function_responses):
yield event
else:
workflow_result = await self.workflow.send_responses(function_responses)
for event in workflow_result:
yield event
# Pop pending requests if fulfilled.
for request_id in list(self.pending_requests.keys()):
if request_id in function_responses:
self.pending_requests.pop(request_id)
# NOTE: It is possible that some pending requests are not fulfilled,
# and we will let the workflow to handle this -- the agent does not
# have an opinion on this.
event_stream = self.workflow.send_responses_streaming(function_responses)
elif checkpoint_id is not None:
# Resume from checkpoint - don't prepend thread history since workflow state
# is being restored from the checkpoint
event_stream = self.workflow.run_stream(
message=None,
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
**kwargs,
)
if streaming:
async for event in self.workflow.run_stream(
message=None,
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
**kwargs,
):
yield event
else:
workflow_result = await self.workflow.run(
message=None,
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
**kwargs,
)
for event in workflow_result:
yield event
else:
# Execute workflow with streaming (initial run or no function responses)
# Build the complete conversation by prepending thread history to input messages
conversation_messages: list[ChatMessage] = []
if thread.message_store:
history = await thread.message_store.list_messages()
if history:
conversation_messages.extend(history)
conversation_messages.extend(input_messages)
event_stream = self.workflow.run_stream(
message=conversation_messages,
checkpoint_storage=checkpoint_storage,
**kwargs,
)
# Initial run - build conversation from thread history
conversation_messages = await self._build_conversation_messages(thread, input_messages)
# Process events from the stream
async for event in event_stream:
# Convert workflow event to agent update
update = self._convert_workflow_event_to_agent_update(response_id, event)
if update:
yield update
if streaming:
async for event in self.workflow.run_stream(
message=conversation_messages,
checkpoint_storage=checkpoint_storage,
**kwargs,
):
yield event
else:
workflow_result = await self.workflow.run(
message=conversation_messages,
checkpoint_storage=checkpoint_storage,
**kwargs,
)
for event in workflow_result:
yield event
def _convert_workflow_event_to_agent_update(
# endregion Run Methods
async def _build_conversation_messages(
self,
thread: AgentThread,
input_messages: list[ChatMessage],
) -> list[ChatMessage]:
"""Build the complete conversation by prepending thread history to input messages.
Args:
thread: The conversation thread containing message history.
input_messages: The new input messages to append.
Returns:
A list of ChatMessage objects representing the full conversation.
"""
conversation_messages: list[ChatMessage] = []
if thread.message_store:
history = await thread.message_store.list_messages()
if history:
conversation_messages.extend(history)
conversation_messages.extend(input_messages)
return conversation_messages
def _process_pending_requests(self, input_messages: list[ChatMessage]) -> dict[str, Any]:
"""Process pending requests by extracting function responses and updating state.
Args:
input_messages: Input messages that may contain function responses.
Returns:
A dictionary mapping request IDs to their response data.
"""
logger.info(f"Continuing workflow to address {len(self.pending_requests)} requests")
# Extract function responses from input messages, and ensure that
# only function responses are present in messages if there is any
# pending request.
function_responses = self._extract_function_responses(input_messages)
# Pop pending requests if fulfilled.
for request_id in list(self.pending_requests.keys()):
if request_id in function_responses:
self.pending_requests.pop(request_id)
# NOTE: It is possible that some pending requests are not fulfilled,
# and we will let the workflow to handle this -- the agent does not
# have an opinion on this.
return function_responses
def _convert_workflow_events_to_agent_response(
self,
response_id: str,
output_events: list[WorkflowOutputEvent | RequestInfoEvent],
) -> AgentResponse:
"""Convert a list of workflow output events to an AgentResponse."""
messages: list[ChatMessage] = []
raw_representations: list[object] = []
merged_usage: UsageDetails | None = None
latest_created_at: str | None = None
for output_event in output_events:
if isinstance(output_event, RequestInfoEvent):
function_call, approval_request = self._process_request_info_event(output_event)
messages.append(
ChatMessage(
contents=[function_call, approval_request],
role="assistant",
author_name=output_event.source_executor_id,
message_id=str(uuid.uuid4()),
raw_representation=output_event,
)
)
raw_representations.append(output_event)
else:
data = output_event.data
if isinstance(data, AgentResponseUpdate):
# We cannot support AgentResponseUpdate in non-streaming mode. This is because the message
# sequence cannot be guaranteed when there are streaming updates in between non-streaming
# responses.
raise AgentExecutionException(
"WorkflowOutputEvent with AgentResponseUpdate data cannot be emitted in non-streaming mode. "
"Please ensure executors emit AgentResponse for non-streaming workflows."
)
if isinstance(data, AgentResponse):
messages.extend(data.messages)
raw_representations.append(data.raw_representation)
merged_usage = add_usage_details(merged_usage, data.usage_details)
latest_created_at = (
data.created_at
if not latest_created_at
else max(latest_created_at, data.created_at)
if data.created_at
else latest_created_at
)
elif isinstance(data, ChatMessage):
messages.append(data)
raw_representations.append(data.raw_representation)
elif is_instance_of(data, list[ChatMessage]):
chat_messages = cast(list[ChatMessage], data)
messages.extend(chat_messages)
raw_representations.append(data)
else:
contents = self._extract_contents(data)
if not contents:
continue
messages.append(
ChatMessage(
contents=contents,
role="assistant",
author_name=output_event.executor_id,
message_id=str(uuid.uuid4()),
raw_representation=data,
)
)
raw_representations.append(data)
return AgentResponse(
messages=messages,
response_id=response_id,
created_at=latest_created_at,
usage_details=merged_usage,
raw_representation=raw_representations,
)
def _convert_workflow_event_to_agent_response_update(
self,
response_id: str,
event: WorkflowEvent,
) -> AgentResponseUpdate | None:
) -> list[AgentResponseUpdate]:
"""Convert a workflow event to an AgentResponseUpdate.
AgentRunUpdateEvent, RequestInfoEvent, and WorkflowOutputEvent are processed.
Only WorkflowOutputEvent and RequestInfoEvent are processed.
Other workflow events are ignored as they are workflow-internal.
For AgentRunUpdateEvent from AgentExecutor instances, only events from executors
with output_response=True are converted to agent updates. This prevents agent
responses from executors that were not explicitly marked to surface their output.
Non-AgentExecutor executors that emit AgentRunUpdateEvent directly are allowed
through since they explicitly chose to emit the event.
"""
match event:
case AgentRunUpdateEvent(data=update, executor_id=executor_id):
# For AgentExecutor instances, only pass through if output_response=True.
# Non-AgentExecutor executors that emit AgentRunUpdateEvent are allowed through.
executor = self.workflow.executors.get(executor_id)
if isinstance(executor, AgentExecutor) and not executor.output_response:
return None
if update:
# Enrich with executor identity if author_name is not already set
if not update.author_name:
update.author_name = executor_id
return update
return None
# Convert workflow output to an agent response update.
case WorkflowOutputEvent(data=data, executor_id=executor_id):
# Convert workflow output to an agent response update.
# Handle different data types appropriately.
# Skip AgentResponse from AgentExecutor with output_response=True
# since streaming events already surfaced the content.
if isinstance(data, AgentResponse):
executor = self.workflow.executors.get(executor_id)
if isinstance(executor, AgentExecutor) and executor.output_response:
return None
return [
AgentResponseUpdate(
contents=[content for message in data.messages for content in message.contents],
role="assistant",
author_name=executor_id,
response_id=response_id,
created_at=data.created_at,
raw_representation=data,
)
]
if isinstance(data, AgentResponseUpdate):
return data
return [data]
if isinstance(data, ChatMessage):
return AgentResponseUpdate(
contents=list(data.contents),
role=data.role,
author_name=data.author_name or executor_id,
return [
AgentResponseUpdate(
contents=list(data.contents),
role=data.role,
author_name=data.author_name,
response_id=response_id,
message_id=data.message_id or str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=data,
)
]
if is_instance_of(data, list[ChatMessage]):
chat_messages = cast(list[ChatMessage], data)
return [
AgentResponseUpdate(
contents=list(msg.contents),
role=msg.role,
author_name=msg.author_name,
response_id=response_id,
message_id=msg.message_id or str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=msg,
)
for msg in chat_messages
]
contents = self._extract_contents(data)
if not contents:
return []
return [
AgentResponseUpdate(
contents=contents,
role="assistant",
author_name=executor_id,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=data,
)
contents = self._extract_contents(data)
if not contents:
return None
return AgentResponseUpdate(
contents=contents,
role="assistant",
author_name=executor_id,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=data,
)
]
case RequestInfoEvent(request_id=request_id):
# Store the pending request for later correlation
self.pending_requests[request_id] = event
args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict()
function_call = Content.from_function_call(
call_id=request_id,
name=self.REQUEST_INFO_FUNCTION_NAME,
arguments=args,
)
approval_request = Content.from_function_approval_request(
id=request_id,
function_call=function_call,
additional_properties={"request_id": request_id},
)
return AgentResponseUpdate(
contents=[function_call, approval_request],
role="assistant",
author_name=self.name,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
)
case RequestInfoEvent():
function_call, approval_request = self._process_request_info_event(event)
return [
AgentResponseUpdate(
contents=[function_call, approval_request],
role="assistant",
author_name=self.name,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
)
]
case _:
# Ignore workflow-internal events
pass
return None
return []
def _process_request_info_event(self, event: RequestInfoEvent) -> tuple[Content, Content]:
"""Process a RequestInfoEvent by adding it to pending requests."""
# Store the pending request for later correlation
self.pending_requests[event.request_id] = event
args = self.RequestInfoFunctionArgs(request_id=event.request_id, data=event.data).to_dict()
function_call = Content.from_function_call(
call_id=event.request_id,
name=self.REQUEST_INFO_FUNCTION_NAME,
arguments=args,
)
approval_request = Content.from_function_approval_request(
id=event.request_id,
function_call=function_call,
additional_properties={"request_id": event.request_id},
)
return function_call, approval_request
def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict[str, Any]:
"""Extract function responses from input messages."""
@@ -428,8 +643,6 @@ class WorkflowAgent(BaseAgent):
def _extract_contents(self, data: Any) -> list[Content]:
"""Recursively extract Content from workflow output data."""
if isinstance(data, ChatMessage):
return list(data.contents)
if isinstance(data, list):
return [c for item in data for c in self._extract_contents(item)]
if isinstance(data, Content):
@@ -2,26 +2,24 @@
import logging
import sys
import types
from dataclasses import dataclass
from typing import Any, cast
from typing_extensions import Never
from agent_framework import Content
from .._agents import AgentProtocol, ChatAgent
from .._agents import AgentProtocol
from .._threads import AgentThread
from .._types import AgentResponse, AgentResponseUpdate, ChatMessage
from ._agent_utils import resolve_agent_id
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._const import WORKFLOW_RUN_KWARGS_KEY
from ._conversation_state import encode_chat_messages
from ._events import (
AgentRunEvent,
AgentRunUpdateEvent,
)
from ._executor import Executor, handler
from ._message_utils import normalize_messages_input
from ._request_info_mixin import response_handler
from ._typing_utils import is_chat_agent
from ._workflow_context import WorkflowContext
if sys.version_info >= (3, 12):
@@ -55,7 +53,7 @@ class AgentExecutorResponse:
agent_response: The underlying agent run response (unaltered from client).
full_conversation: The full conversation context (prior inputs + all assistant/tool outputs) that
should be used when chaining to another AgentExecutor. This prevents downstream agents losing
user prompts while keeping the emitted AgentRunEvent text faithful to the raw agent output.
user prompts.
"""
executor_id: str
@@ -67,8 +65,15 @@ class AgentExecutor(Executor):
"""built-in executor that wraps an agent for handling messages.
AgentExecutor adapts its behavior based on the workflow execution mode:
- run_stream(): Emits incremental AgentRunUpdateEvent events as the agent produces tokens
- run(): Emits a single AgentRunEvent containing the complete response
- run_stream(): Emits incremental WorkflowOutputEvents as the agent produces tokens
- run(): Emits a single WorkflowOutputEvent containing the complete response
Use `with_output_from` in WorkflowBuilder to control whether the AgentResponse
or AgentResponseUpdate objects are yielded as workflow outputs.
Messages sent to downstream executors will always be the complete AgentResponse. In
streaming mode, incremental AgentResponseUpdates will be concatenated to form the full
response to be sent downstream.
The executor automatically detects the mode via WorkflowContext.is_streaming().
"""
@@ -78,7 +83,6 @@ class AgentExecutor(Executor):
agent: AgentProtocol,
*,
agent_thread: AgentThread | None = None,
output_response: bool = False,
id: str | None = None,
):
"""Initialize the executor with a unique identifier.
@@ -86,7 +90,6 @@ class AgentExecutor(Executor):
Args:
agent: The agent to be wrapped by this executor.
agent_thread: The thread to use for running the agent. If None, a new thread will be created.
output_response: Whether to yield an AgentResponse as a workflow output when the agent completes.
id: A unique identifier for the executor. If None, the agent's name will be used if available.
"""
# Prefer provided id; else use agent.name if present; else generate deterministic prefix
@@ -96,27 +99,15 @@ class AgentExecutor(Executor):
super().__init__(exec_id)
self._agent = agent
self._agent_thread = agent_thread or self._agent.get_new_thread()
self._pending_agent_requests: dict[str, Content] = {}
self._pending_responses_to_agent: list[Content] = []
self._output_response = output_response
# AgentExecutor maintains an internal cache of messages in between runs
self._cache: list[ChatMessage] = []
# This tracks the full conversation after each run
self._full_conversation: list[ChatMessage] = []
@property
def output_response(self) -> bool:
"""Whether this executor yields AgentResponse as workflow output when complete."""
return self._output_response
@property
def workflow_output_types(self) -> list[type[Any] | types.UnionType]:
# Override to declare AgentResponse as a possible output type only if enabled.
if self._output_response:
return [AgentResponse]
return []
@property
def description(self) -> str | None:
"""Get the description of the underlying agent."""
@@ -124,7 +115,9 @@ class AgentExecutor(Executor):
@handler
async def run(
self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse]
self,
request: AgentExecutorRequest,
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
) -> None:
"""Handle an AgentExecutorRequest (canonical input).
@@ -137,7 +130,9 @@ class AgentExecutor(Executor):
@handler
async def from_response(
self, prior: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse]
self,
prior: AgentExecutorResponse,
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
) -> None:
"""Enable seamless chaining: accept a prior AgentExecutorResponse as input.
@@ -152,7 +147,9 @@ class AgentExecutor(Executor):
await self._run_agent_and_emit(ctx)
@handler
async def from_str(self, text: str, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse]) -> None:
async def from_str(
self, text: str, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate]
) -> None:
"""Accept a raw user prompt string and run the agent (one-shot)."""
self._cache = normalize_messages_input(text)
await self._run_agent_and_emit(ctx)
@@ -161,7 +158,7 @@ class AgentExecutor(Executor):
async def from_message(
self,
message: ChatMessage,
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse],
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
) -> None:
"""Accept a single ChatMessage as input."""
self._cache = normalize_messages_input(message)
@@ -171,7 +168,7 @@ class AgentExecutor(Executor):
async def from_messages(
self,
messages: list[str | ChatMessage],
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse],
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
) -> None:
"""Accept a list of chat inputs (strings or ChatMessage) as conversation context."""
self._cache = normalize_messages_input(messages)
@@ -182,7 +179,7 @@ class AgentExecutor(Executor):
self,
original_request: Content,
response: Content,
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse],
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
) -> None:
"""Handle user input responses for function approvals during agent execution.
@@ -215,7 +212,7 @@ class AgentExecutor(Executor):
Dict containing serialized cache and thread state
"""
# Check if using AzureAIAgentClient with server-side thread and warn about checkpointing limitations
if isinstance(self._agent, ChatAgent) and self._agent_thread.service_thread_id is not None:
if is_chat_agent(self._agent) and self._agent_thread.service_thread_id is not None:
client_class_name = self._agent.chat_client.__class__.__name__
client_module = self._agent.chat_client.__class__.__module__
@@ -293,23 +290,26 @@ class AgentExecutor(Executor):
logger.debug("AgentExecutor %s: Resetting cache", self.id)
self._cache.clear()
async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse]) -> None:
async def _run_agent_and_emit(
self,
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate],
) -> None:
"""Execute the underlying agent, emit events, and enqueue response.
Checks ctx.is_streaming() to determine whether to emit incremental AgentRunUpdateEvent
events (streaming mode) or a single AgentRunEvent (non-streaming mode).
Checks ctx.is_streaming() to determine whether to emit WorkflowOutputEvents
containing incremental updates (streaming mode) or a single WorkflowOutputEvent
containing the complete response (non-streaming mode).
"""
if ctx.is_streaming():
# Streaming mode: emit incremental updates
response = await self._run_agent_streaming(cast(WorkflowContext, ctx))
response = await self._run_agent_streaming(cast(WorkflowContext[Never, AgentResponseUpdate], ctx))
else:
# Non-streaming mode: use run() and emit single event
response = await self._run_agent(cast(WorkflowContext, ctx))
response = await self._run_agent(cast(WorkflowContext[Never, AgentResponse], ctx))
# Always extend full conversation with cached messages plus agent outputs
# (agent_response.messages) after each run. This is to avoid losing context
# when agent did not complete and the cache is cleared when responses come back.
# Do not mutate response.messages so AgentRunEvent remains faithful to the raw output.
self._full_conversation.extend(list(self._cache) + (list(response.messages) if response else []))
if response is None:
@@ -317,14 +317,11 @@ class AgentExecutor(Executor):
logger.info("AgentExecutor %s: Agent did not complete, awaiting user input", self.id)
return
if self._output_response:
await ctx.yield_output(response)
agent_response = AgentExecutorResponse(self.id, response, full_conversation=self._full_conversation)
await ctx.send_message(agent_response)
self._cache.clear()
async def _run_agent(self, ctx: WorkflowContext) -> AgentResponse | None:
async def _run_agent(self, ctx: WorkflowContext[Never, AgentResponse]) -> AgentResponse | None:
"""Execute the underlying agent in non-streaming mode.
Args:
@@ -340,7 +337,7 @@ class AgentExecutor(Executor):
thread=self._agent_thread,
**run_kwargs,
)
await ctx.add_event(AgentRunEvent(self.id, response))
await ctx.yield_output(response)
# Handle any user input requests
if response.user_input_requests:
@@ -351,7 +348,7 @@ class AgentExecutor(Executor):
return response
async def _run_agent_streaming(self, ctx: WorkflowContext) -> AgentResponse | None:
async def _run_agent_streaming(self, ctx: WorkflowContext[Never, AgentResponseUpdate]) -> AgentResponse | None:
"""Execute the underlying agent in streaming mode and collect the full response.
Args:
@@ -370,13 +367,13 @@ class AgentExecutor(Executor):
**run_kwargs,
):
updates.append(update)
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
await ctx.yield_output(update)
if update.user_input_requests:
user_input_requests.extend(update.user_input_requests)
# Build the final AgentResponse from the collected updates
if isinstance(self._agent, ChatAgent):
if is_chat_agent(self._agent):
response_format = self._agent.default_options.get("response_format")
response = AgentResponse.from_updates(
updates,
@@ -246,6 +246,7 @@ class ConcurrentBuilder:
self._checkpoint_storage: CheckpointStorage | None = None
self._request_info_enabled: bool = False
self._request_info_filter: set[str] | None = None
self._intermediate_outputs: bool = False
def register_participants(
self,
@@ -489,6 +490,19 @@ class ConcurrentBuilder:
return self
def with_intermediate_outputs(self) -> "ConcurrentBuilder":
"""Enable intermediate outputs from agent participants before aggregation.
When enabled, the workflow returns each agent participant's response or yields
streaming updates as they become available. The output of the aggregator will
always be available as the final output of the workflow.
Returns:
Self for fluent chaining
"""
self._intermediate_outputs = True
return self
def _resolve_participants(self) -> list[Executor]:
"""Resolve participant instances into Executor objects."""
if not self._participants and not self._participant_factories:
@@ -568,6 +582,10 @@ class ConcurrentBuilder:
# Direct fan-in to aggregator
builder.add_fan_in_edges(participants, aggregator)
if not self._intermediate_outputs:
# Constrain output to aggregator only
builder = builder.with_output_from([aggregator])
if self._checkpoint_storage is not None:
builder = builder.with_checkpointing(self._checkpoint_storage)
@@ -8,8 +8,6 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any, TypeAlias
from agent_framework import AgentResponse, AgentResponseUpdate
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._typing_utils import deserialize_type, serialize_type
@@ -364,32 +362,4 @@ class ExecutorFailedEvent(ExecutorEvent):
return f"{self.__class__.__name__}(executor_id={self.executor_id}, details={self.details})"
class AgentRunUpdateEvent(ExecutorEvent):
"""Event triggered when an agent is streaming messages."""
data: AgentResponseUpdate
def __init__(self, executor_id: str, data: AgentResponseUpdate):
"""Initialize the agent streaming event."""
super().__init__(executor_id, data)
def __repr__(self) -> str:
"""Return a string representation of the agent streaming event."""
return f"{self.__class__.__name__}(executor_id={self.executor_id}, messages={self.data})"
class AgentRunEvent(ExecutorEvent):
"""Event triggered when an agent run is completed."""
data: AgentResponse
def __init__(self, executor_id: str, data: AgentResponse):
"""Initialize the agent run event."""
super().__init__(executor_id, data)
def __repr__(self) -> str:
"""Return a string representation of the agent run event."""
return f"{self.__class__.__name__}(executor_id={self.executor_id}, data={self.data})"
WorkflowLifecycleEvent: TypeAlias = WorkflowStartedEvent | WorkflowStatusEvent | WorkflowFailedEvent
@@ -537,6 +537,9 @@ class GroupChatBuilder:
self._request_info_enabled: bool = False
self._request_info_filter: set[str] = set()
# Intermediate outputs
self._intermediate_outputs = False
@overload
def with_orchestrator(self, *, agent: ChatAgent | Callable[[], ChatAgent]) -> "GroupChatBuilder":
"""Set the orchestrator for this group chat workflow using a ChatAgent.
@@ -880,6 +883,19 @@ class GroupChatBuilder:
return self
def with_intermediate_outputs(self) -> "GroupChatBuilder":
"""Enable intermediate outputs from agent participants.
When enabled, the workflow returns each agent participant's response or yields
streaming updates as they become available. The output of the orchestrator will
always be available as the final output of the workflow.
Returns:
Self for fluent chaining
"""
self._intermediate_outputs = True
return self
def _resolve_orchestrator(self, participants: Sequence[Executor]) -> Executor:
"""Determine the orchestrator to use for the workflow.
@@ -986,6 +1002,11 @@ class GroupChatBuilder:
# Orchestrator and participant bi-directional edges
workflow_builder = workflow_builder.add_edge(orchestrator, participant)
workflow_builder = workflow_builder.add_edge(participant, orchestrator)
if not self._intermediate_outputs:
# Constrain output to orchestrator only
workflow_builder = workflow_builder.with_output_from([orchestrator])
if self._checkpoint_storage is not None:
workflow_builder = workflow_builder.with_checkpointing(self._checkpoint_storage)
@@ -42,7 +42,7 @@ from .._agents import AgentProtocol, ChatAgent
from .._middleware import FunctionInvocationContext, FunctionMiddleware
from .._threads import AgentThread
from .._tools import FunctionTool, tool
from .._types import AgentResponse, ChatMessage
from .._types import AgentResponse, AgentResponseUpdate, ChatMessage
from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
from ._agent_utils import resolve_agent_id
from ._base_group_chat_orchestrator import TerminationCondition
@@ -365,7 +365,9 @@ class HandoffAgentExecutor(AgentExecutor):
return _handoff_tool
@override
async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse]) -> None:
async def _run_agent_and_emit(
self, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate]
) -> None:
"""Override to support handoff."""
# When the full conversation is empty, it means this is the first run.
# Broadcast the initial cache to all other agents. Subsequent runs won't
@@ -383,10 +385,10 @@ class HandoffAgentExecutor(AgentExecutor):
# Run the agent
if ctx.is_streaming():
# Streaming mode: emit incremental updates
response = await self._run_agent_streaming(cast(WorkflowContext, ctx))
response = await self._run_agent_streaming(cast(WorkflowContext[Never, AgentResponseUpdate], ctx))
else:
# Non-streaming mode: use run() and emit single event
response = await self._run_agent(cast(WorkflowContext, ctx))
response = await self._run_agent(cast(WorkflowContext[Never, AgentResponse], ctx))
# Clear the cache after running the agent
self._cache.clear()
@@ -466,7 +468,9 @@ class HandoffAgentExecutor(AgentExecutor):
# Append the user response messages to the cache
self._cache.extend(response)
await self._run_agent_and_emit(ctx)
await self._run_agent_and_emit(
cast(WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate], ctx)
)
async def _broadcast_messages(
self,
@@ -562,7 +566,10 @@ class HandoffBuilder:
The final conversation history as a list of ChatMessage once the group chat completes.
Note:
Agents in handoff workflows must be ChatAgent instances and support local tool calls.
1. Agents in handoff workflows must be ChatAgent instances and support local tool calls.
2. Handoff doesn't support intermediate outputs from agents. All outputs are returned as
they become available. This is because agents in handoff workflows are not considered
sub-agents of a central orchestrator, thus all outputs are directly emitted.
"""
def __init__(
@@ -1389,6 +1389,9 @@ class MagenticBuilder:
self._checkpoint_storage: CheckpointStorage | None = None
# Intermediate outputs
self._intermediate_outputs = False
def register_participants(
self,
participant_factories: Sequence[Callable[[], AgentProtocol | Executor]],
@@ -1904,6 +1907,19 @@ class MagenticBuilder:
return self
def with_intermediate_outputs(self) -> Self:
"""Enable intermediate outputs from agent participants before aggregation.
When enabled, the workflow returns each agent participant's response or yields
streaming updates as they become available. The output of the orchestrator will
always be available as the final output of the workflow.
Returns:
Self for fluent chaining
"""
self._intermediate_outputs = True
return self
def _resolve_orchestrator(self, participants: Sequence[Executor]) -> Executor:
"""Determine the orchestrator to use for the workflow.
@@ -1977,6 +1993,10 @@ class MagenticBuilder:
if self._checkpoint_storage is not None:
workflow_builder = workflow_builder.with_checkpointing(self._checkpoint_storage)
if not self._intermediate_outputs:
# Constrain output to orchestrator only
workflow_builder = workflow_builder.with_output_from([orchestrator])
return workflow_builder.build()
@@ -290,7 +290,7 @@ class InProcRunnerContext:
checkpoint_storage: Optional storage to enable checkpointing.
"""
self._messages: dict[str, list[Message]] = {}
# Event queue for immediate streaming of events (e.g., AgentRunUpdateEvent)
# Event queue for immediate streaming of events
self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue()
# An additional storage for pending request info events
@@ -153,6 +153,7 @@ class SequentialBuilder:
self._checkpoint_storage: CheckpointStorage | None = None
self._request_info_enabled: bool = False
self._request_info_filter: set[str] | None = None
self._intermediate_outputs: bool = False
def register_participants(
self,
@@ -242,6 +243,19 @@ class SequentialBuilder:
return self
def with_intermediate_outputs(self) -> "SequentialBuilder":
"""Enable intermediate outputs from agent participants.
When enabled, the workflow returns each agent participant's response or yields
streaming updates as they become available. The output of the last participant
will always be available as the final output of the workflow.
Returns:
Self for fluent chaining
"""
self._intermediate_outputs = True
return self
def _resolve_participants(self) -> list[Executor]:
"""Resolve participant instances into Executor objects."""
if not self._participants and not self._participant_factories:
@@ -305,6 +319,10 @@ class SequentialBuilder:
# Terminate with the final conversation
builder.add_edge(prior, end)
if not self._intermediate_outputs:
# Constrain output to end only
builder = builder.with_output_from([end])
if self._checkpoint_storage is not None:
builder = builder.with_checkpointing(self._checkpoint_storage)
@@ -1,9 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
from types import UnionType
from typing import Any, TypeVar, Union, cast, get_args, get_origin
from typing import Any, TypeGuard, Union, cast, get_args, get_origin
T = TypeVar("T")
from .._agents import ChatAgent
def is_chat_agent(agent: Any) -> TypeGuard[ChatAgent]:
"""Check if the given agent is a ChatAgent.
Args:
agent (Any): The agent to check.
Returns:
TypeGuard[ChatAgent]: True if the agent is a ChatAgent, False otherwise.
"""
return isinstance(agent, ChatAgent)
def resolve_type_annotation(
@@ -23,7 +23,7 @@ class ValidationTypeEnum(Enum):
TYPE_COMPATIBILITY = "TYPE_COMPATIBILITY"
GRAPH_CONNECTIVITY = "GRAPH_CONNECTIVITY"
HANDLER_OUTPUT_ANNOTATION = "HANDLER_OUTPUT_ANNOTATION"
INTERCEPTOR_CONFLICT = "INTERCEPTOR_CONFLICT"
OUTPUT_VALIDATION = "OUTPUT_VALIDATION"
class WorkflowValidationError(Exception):
@@ -79,13 +79,6 @@ class GraphConnectivityError(WorkflowValidationError):
super().__init__(message, validation_type=ValidationTypeEnum.GRAPH_CONNECTIVITY)
class InterceptorConflictError(WorkflowValidationError):
"""Exception raised when multiple executors intercept the same request type from the same sub-workflow."""
def __init__(self, message: str):
super().__init__(message, validation_type=ValidationTypeEnum.INTERCEPTOR_CONFLICT)
# endregion
@@ -109,6 +102,7 @@ class WorkflowGraphValidator:
edge_groups: Sequence[EdgeGroup],
executors: dict[str, Executor],
start_executor: Executor,
output_executors: list[str],
) -> None:
"""Validate the entire workflow graph.
@@ -116,6 +110,7 @@ class WorkflowGraphValidator:
edge_groups: list of edge groups in the workflow
executors: Map of executor IDs to executor instances
start_executor: The starting executor
output_executors: List of output executor IDs
Raises:
WorkflowValidationError: If any validation fails
@@ -162,6 +157,7 @@ class WorkflowGraphValidator:
self._validate_graph_connectivity(start_executor.id)
self._validate_self_loops()
self._validate_dead_ends()
self._output_validation(output_executors)
def _validate_handler_output_annotations(self) -> None:
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
@@ -357,6 +353,26 @@ class WorkflowGraphValidator:
# endregion
# region Output Validation
def _output_validation(self, output_executors: list[str]) -> None:
"""Validate that output executors exist in the workflow and have the correct workflow context annotations."""
for output_id in output_executors:
if output_id not in self._executors:
raise WorkflowValidationError(
f"Output executor '{output_id}' is not present in the workflow graph",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
output_executor = self._executors[output_id]
if not output_executor.workflow_output_types:
raise WorkflowValidationError(
f"Output executor '{output_id}' must have output type annotations defined.",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
# endregion
# region Additional Validation Scenarios
def _validate_self_loops(self) -> None:
"""Detect and log self-loops (edges from executor to itself).
@@ -397,13 +413,15 @@ def validate_workflow_graph(
edge_groups: Sequence[EdgeGroup],
executors: dict[str, Executor],
start_executor: Executor,
output_executors: list[str],
) -> None:
"""Convenience function to validate a workflow graph.
Args:
edge_groups: list of edge groups in the workflow
executors: Map of executor IDs to executor instances
start_executor: The starting executor (can be instance or ID)
start_executor: The starting executor instance
output_executors: List of output executor IDs
Raises:
WorkflowValidationError: If any validation fails
@@ -413,4 +431,5 @@ def validate_workflow_graph(
edge_groups,
executors,
start_executor,
output_executors,
)
@@ -180,6 +180,7 @@ class Workflow(DictConvertible):
max_iterations: int = DEFAULT_MAX_ITERATIONS,
name: str | None = None,
description: str | None = None,
output_executors: list[str] | None = None,
**kwargs: Any,
):
"""Initialize the workflow with a list of edges.
@@ -192,6 +193,8 @@ class Workflow(DictConvertible):
max_iterations: The maximum number of iterations the workflow will run for convergence.
name: Optional human-readable name for the workflow.
description: Optional description of what the workflow does.
output_executors: Optional list of executor IDs whose outputs will be considered workflow outputs.
If None or empty, all executor outputs are treated as workflow outputs.
kwargs: Additional keyword arguments. Unused in this implementation.
"""
self.edge_groups = list(edge_groups)
@@ -202,6 +205,10 @@ class Workflow(DictConvertible):
self.name = name
self.description = description
# `WorkflowOutputEvent`s from these executors are treated as workflow outputs.
# If None or empty, all executor outputs are considered workflow outputs.
self._output_executors = list(output_executors) if output_executors else list(self.executors.keys())
# Store non-serializable runtime objects as private attributes
self._runner_context = runner_context
self._shared_state = SharedState()
@@ -241,6 +248,7 @@ class Workflow(DictConvertible):
"max_iterations": self.max_iterations,
"edge_groups": [group.to_dict() for group in self.edge_groups],
"executors": {executor_id: executor.to_dict() for executor_id, executor in self.executors.items()},
"output_executors": self._output_executors,
}
# Add optional name and description if provided
@@ -277,6 +285,10 @@ class Workflow(DictConvertible):
"""
return self.executors[self.start_executor_id]
def get_output_executors(self) -> list[Executor]:
"""Get the list of output executors in the workflow."""
return [self.executors[executor_id] for executor_id in self._output_executors]
def get_executors_list(self) -> list[Executor]:
"""Get the list of executors in the workflow."""
return list(self.executors.values())
@@ -539,6 +551,8 @@ class Workflow(DictConvertible):
streaming=True,
run_kwargs=kwargs if kwargs else None,
):
if isinstance(event, WorkflowOutputEvent) and not self._should_yield_output_event(event):
continue
yield event
finally:
if checkpoint_storage is not None:
@@ -562,6 +576,8 @@ class Workflow(DictConvertible):
reset_context=False, # Don't reset context when sending responses
streaming=True,
):
if isinstance(event, WorkflowOutputEvent) and not self._should_yield_output_event(event):
continue
yield event
finally:
self._reset_running_flag()
@@ -687,6 +703,8 @@ class Workflow(DictConvertible):
if include_status_events:
filtered.append(ev)
continue
if isinstance(ev, WorkflowOutputEvent) and not self._should_yield_output_event(ev):
continue
filtered.append(ev)
return WorkflowRunResult(filtered, status_events)
@@ -710,7 +728,13 @@ class Workflow(DictConvertible):
)
]
status_events = [e for e in events if isinstance(e, WorkflowStatusEvent)]
filtered_events = [e for e in events if not isinstance(e, (WorkflowStatusEvent, WorkflowStartedEvent))]
filtered_events: list[WorkflowEvent] = []
for e in events:
if isinstance(e, WorkflowOutputEvent) and not self._should_yield_output_event(e):
continue
if isinstance(e, (WorkflowStatusEvent, WorkflowStartedEvent)):
continue
filtered_events.append(e)
return WorkflowRunResult(filtered_events, status_events)
finally:
self._reset_running_flag()
@@ -750,6 +774,22 @@ class Workflow(DictConvertible):
raise ValueError(f"Executor with ID {executor_id} not found.")
return self.executors[executor_id]
def _should_yield_output_event(self, event: WorkflowOutputEvent) -> bool:
"""Determine if a WorkflowOutputEvent should be yielded as a workflow output.
Args:
event: The WorkflowOutputEvent to evaluate.
Returns:
True if the event should be yielded as a workflow output, False otherwise.
"""
# If no specific output executors are defined, yield all outputs
if not self._output_executors:
return True
# Check if the event's source executor is in the list of output executors
return event.executor_id in self._output_executors
# Graph signature helpers
def _compute_graph_signature(self) -> dict[str, Any]:
@@ -6,12 +6,11 @@ from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Any
from typing_extensions import deprecated
from .._agents import AgentProtocol
from .._threads import AgentThread
from ..observability import OtelAttr, capture_exception, create_workflow_span
from ._agent_executor import AgentExecutor
from ._agent_utils import resolve_agent_id
from ._checkpoint import CheckpointStorage
from ._const import DEFAULT_MAX_ITERATIONS
from ._edge import (
@@ -173,10 +172,9 @@ class WorkflowBuilder:
self._name: str | None = name
self._description: str | None = description
# Maps underlying AgentProtocol object id -> wrapped Executor so we reuse the same wrapper
# across set_start_executor / add_edge calls. Without this, unnamed agents (which receive
# random UUID based executor ids) end up wrapped multiple times, giving different ids for
# the start node vs edge nodes and triggering a GraphConnectivityError during validation.
self._agent_wrappers: dict[int, Executor] = {}
# across set_start_executor / add_edge calls. This avoids multiple AgentExecutor instances
# being created for the same agent.
self._agent_wrappers: dict[str, Executor] = {}
# Registrations for lazy initialization of executors
self._edge_registry: list[
@@ -188,6 +186,9 @@ class WorkflowBuilder:
] = []
self._executor_registry: dict[str, Callable[[], Executor]] = {}
# Output executors filter; if set, only outputs from these executors are yielded
self._output_executors: list[Executor | AgentProtocol | str] = []
# Agents auto-wrapped by builder now always stream incremental updates.
def _add_executor(self, executor: Executor) -> str:
@@ -207,13 +208,7 @@ class WorkflowBuilder:
return executor.id
def _maybe_wrap_agent(
self,
candidate: Executor | AgentProtocol,
agent_thread: Any | None = None,
output_response: bool = False,
executor_id: str | None = None,
) -> Executor:
def _maybe_wrap_agent(self, candidate: Executor | AgentProtocol) -> Executor:
"""If the provided object implements AgentProtocol, wrap it in an AgentExecutor.
This allows fluent builder APIs to directly accept agents instead of
@@ -221,9 +216,9 @@ class WorkflowBuilder:
Args:
candidate: The executor or agent to wrap.
agent_thread: The thread to use for running the agent. If None, a new thread will be created.
output_response: Whether to yield an AgentResponse as a workflow output when the agent completes.
executor_id: A unique identifier for the executor. If None, the agent's name will be used if available.
Returns:
An Executor instance, wrapping the agent if necessary.
"""
try: # Local import to avoid hard dependency at import time
from agent_framework import AgentProtocol # type: ignore
@@ -234,28 +229,20 @@ class WorkflowBuilder:
return candidate
if isinstance(candidate, AgentProtocol): # type: ignore[arg-type]
# Reuse existing wrapper for the same agent instance if present
agent_instance_id = id(candidate)
agent_instance_id = str(id(candidate))
existing = self._agent_wrappers.get(agent_instance_id)
if existing is not None:
return existing
# Use agent name if available and unique among current executors
name = getattr(candidate, "name", None)
proposed_id: str | None = executor_id
if proposed_id is None and name:
proposed_id = str(name)
if proposed_id in self._executors:
raise ValueError(
f"Duplicate executor ID '{proposed_id}' from agent name. "
"Agent names must be unique within a workflow."
)
wrapper = AgentExecutor(
candidate,
agent_thread=agent_thread,
output_response=output_response,
id=proposed_id,
)
executor_id = resolve_agent_id(candidate)
if executor_id in self._executors:
raise ValueError(
f"Duplicate executor ID '{executor_id}' from agent. "
"Agent IDs or names must be unique within a workflow."
)
wrapper = AgentExecutor(candidate, id=executor_id)
self._agent_wrappers[agent_instance_id] = wrapper
return wrapper
raise TypeError(
f"WorkflowBuilder expected an Executor or AgentProtocol instance; got {type(candidate).__name__}."
)
@@ -337,7 +324,6 @@ class WorkflowBuilder:
factory_func: Callable[[], AgentProtocol],
name: str,
agent_thread: AgentThread | None = None,
output_response: bool = False,
) -> Self:
"""Register an agent factory function for lazy initialization.
@@ -351,7 +337,6 @@ class WorkflowBuilder:
the agent's internal name. But it must be unique within the workflow.
agent_thread: The thread to use for running the agent. If None, a new thread will be created when
the agent is instantiated.
output_response: Whether to yield an AgentResponse as a workflow output when the agent completes.
Example:
.. code-block:: python
@@ -382,69 +367,12 @@ class WorkflowBuilder:
return AgentExecutor(
agent,
agent_thread=agent_thread,
output_response=output_response,
)
self._executor_registry[name] = wrapped_factory
return self
@deprecated("Use register_agent() for lazy initialization instead.")
def add_agent(
self,
agent: AgentProtocol,
agent_thread: Any | None = None,
output_response: bool = False,
id: str | None = None,
) -> Self:
"""Add an agent to the workflow by wrapping it in an AgentExecutor.
This method creates an AgentExecutor that wraps the agent with the given parameters
and ensures that subsequent uses of the same agent instance in other builder methods
(like add_edge, set_start_executor, etc.) will reuse the same wrapped executor.
Note: Agents adapt their behavior based on how the workflow is executed:
- run_stream(): Agents emit incremental AgentRunUpdateEvent events as tokens are produced
- run(): Agents emit a single AgentRunEvent containing the complete response
Args:
agent: The agent to add to the workflow.
agent_thread: The thread to use for running the agent. If None, a new thread will be created.
output_response: Whether to yield an AgentResponse as a workflow output when the agent completes.
id: A unique identifier for the executor. If None, the agent's name will be used if available.
Returns:
Self: The WorkflowBuilder instance for method chaining.
Raises:
ValueError: If the provided id or agent name conflicts with an existing executor.
Example:
.. code-block:: python
from agent_framework import WorkflowBuilder
from agent_framework_anthropic import AnthropicAgent
# Create an agent
agent = AnthropicAgent(name="writer", model="claude-3-5-sonnet-20241022")
# Add the agent to a workflow
workflow = WorkflowBuilder().add_agent(agent, output_response=True).set_start_executor(agent).build()
"""
logger.warning(
"Adding an agent instance directly to WorkflowBuilder is not recommended, "
"because workflow instances created from the builder will share the same agent instance. "
"Consider using register_agent() for lazy initialization instead."
)
executor = self._maybe_wrap_agent(
agent,
agent_thread=agent_thread,
output_response=output_response,
executor_id=id,
)
self._add_executor(executor)
return self
def add_edge(
self,
source: Executor | AgentProtocol | str,
@@ -1139,10 +1067,35 @@ class WorkflowBuilder:
self._checkpoint_storage = checkpoint_storage
return self
def _resolve_edge_registry(
self,
) -> tuple[Executor, list[Executor], list[EdgeGroup]]:
"""Resolve deferred edge registrations into executors and edge groups."""
def with_output_from(self, executors: list[Executor | AgentProtocol | str]) -> Self:
"""Specify which executors' outputs should be collected as workflow outputs.
By default, outputs from all executors are collected. This method allows
filtering to only include outputs from specified executors.
Args:
executors: A list of executors or registered names of the executor factories
whose outputs should be collected.
Returns:
Self: The WorkflowBuilder instance for method chaining.
"""
self._output_executors = list(executors)
return self
def _resolve_edge_registry(self) -> tuple[Executor, dict[str, Executor], list[EdgeGroup]]:
"""Resolve deferred edge registrations into executors and edge groups.
Returns:
tuple: A tuple containing:
- The starting Executor instance.
- A dictionary mapping registered factory names to resolved Executor instances.
- A list of EdgeGroup instances representing the workflow edges composed of resolved executors.
Notes:
Non-factory executors (i.e., those added directly) are not included in the returned list,
as they are already part of the workflow builder's internal state.
"""
if not self._start_executor:
raise ValueError("Starting executor must be set using set_start_executor before building the workflow.")
@@ -1158,7 +1111,9 @@ class WorkflowBuilder:
for name, exec_factory in self._executor_registry.items():
instance = exec_factory()
if instance.id in executor_id_to_instance:
raise ValueError(f"Executor with ID '{instance.id}' has already been created.")
raise ValueError(f"Executor with ID '{instance.id}' has already been registered.")
if instance.id in self._executors:
raise ValueError(f"Executor ID collision: An executor with ID '{instance.id}' already exists.")
executor_id_to_instance[instance.id] = instance
if isinstance(self._start_executor, str) and name == self._start_executor:
@@ -1211,11 +1166,7 @@ class WorkflowBuilder:
if start_executor is None:
raise ValueError("Failed to resolve starting executor from registered factories.")
return (
start_executor,
list(executor_id_to_instance.values()),
deferred_edge_groups,
)
return (start_executor, factory_name_to_instance, deferred_edge_groups)
def build(self) -> Workflow:
"""Build and return the constructed workflow.
@@ -1271,14 +1222,24 @@ class WorkflowBuilder:
# Resolve lazy edge registrations
start_executor, deferred_executors, deferred_edge_groups = self._resolve_edge_registry()
executors = self._executors | {exe.id: exe for exe in deferred_executors}
executors = self._executors | {exe.id: exe for exe in deferred_executors.values()}
edge_groups = self._edge_groups + deferred_edge_groups
output_executors = (
[
deferred_executors[factory_name].id
for factory_name in self._output_executors
if isinstance(factory_name, str)
]
+ [ex.id for ex in self._output_executors if isinstance(ex, Executor)]
+ [resolve_agent_id(agent) for agent in self._output_executors if isinstance(agent, AgentProtocol)]
)
# Perform validation before creating the workflow
validate_workflow_graph(
edge_groups,
executors,
start_executor,
output_executors,
)
# Add validation completed event
@@ -1295,6 +1256,7 @@ class WorkflowBuilder:
self._max_iterations,
name=self._name,
description=self._description,
output_executors=output_executors,
)
build_attributes: dict[str, Any] = {
OtelAttr.WORKFLOW_ID: workflow.id,
@@ -2,7 +2,7 @@
"""Tests for AgentExecutor handling of tool calls and results in streaming mode."""
from collections.abc import AsyncIterable
from collections.abc import AsyncIterable, Sequence
from typing import Any
from typing_extensions import Never
@@ -12,7 +12,6 @@ from agent_framework import (
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
AgentRunUpdateEvent,
AgentThread,
BaseAgent,
ChatAgent,
@@ -38,7 +37,7 @@ class _ToolCallingAgent(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,
@@ -48,7 +47,7 @@ class _ToolCallingAgent(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,
@@ -99,9 +98,9 @@ async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None:
workflow = WorkflowBuilder().set_start_executor(agent_exec).build()
# Act: run in streaming mode
events: list[AgentRunUpdateEvent] = []
events: list[WorkflowOutputEvent] = []
async for event in workflow.run_stream("What's the weather?"):
if isinstance(event, AgentRunUpdateEvent):
if isinstance(event, WorkflowOutputEvent):
events.append(event)
# Assert: we should receive 4 events (text, function call, function result, text)
@@ -148,7 +147,7 @@ class MockChatClient:
async def get_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
messages: str | ChatMessage | Sequence[str | ChatMessage],
**kwargs: Any,
) -> ChatResponse:
if self._iteration == 0:
@@ -185,7 +184,7 @@ class MockChatClient:
async def get_streaming_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
messages: str | ChatMessage | Sequence[str | ChatMessage],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
if self._iteration == 0:
@@ -231,7 +230,13 @@ async def test_agent_executor_tool_call_with_approval() -> None:
tools=[mock_tool_requiring_approval],
)
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
workflow = (
WorkflowBuilder()
.set_start_executor(agent)
.add_edge(agent, test_executor)
.with_output_from([test_executor])
.build()
)
# Act
events = await workflow.run("Invoke tool requiring approval")
@@ -300,7 +305,13 @@ async def test_agent_executor_parallel_tool_call_with_approval() -> None:
tools=[mock_tool_requiring_approval],
)
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
workflow = (
WorkflowBuilder()
.set_start_executor(agent)
.add_edge(agent, test_executor)
.with_output_from([test_executor])
.build()
)
# Act
events = await workflow.run("Invoke tool requiring approval")
@@ -1,15 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for AgentRunEvent and AgentRunUpdateEvent type annotations."""
"""Tests for agent run event typing."""
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage
from agent_framework._workflows._events import AgentRunEvent, AgentRunUpdateEvent
from agent_framework._workflows._events import WorkflowOutputEvent
def test_agent_run_event_data_type() -> None:
"""Verify AgentRunEvent.data is typed as AgentResponse | None."""
response = AgentResponse(messages=[ChatMessage("assistant", ["Hello"])])
event = AgentRunEvent(executor_id="test", data=response)
"""Verify WorkflowOutputEvent.data is typed as AgentResponse | None."""
response = AgentResponse(messages=[ChatMessage(role="assistant", text="Hello")])
event = WorkflowOutputEvent(data=response, executor_id="test")
# This assignment should pass type checking without a cast
data: AgentResponse | None = event.data
@@ -18,9 +18,9 @@ def test_agent_run_event_data_type() -> None:
def test_agent_run_update_event_data_type() -> None:
"""Verify AgentRunUpdateEvent.data is typed as AgentResponseUpdate | None."""
"""Verify WorkflowOutputEvent.data is typed as AgentResponseUpdate | None."""
update = AgentResponseUpdate()
event = AgentRunUpdateEvent(executor_id="test", data=update)
event = WorkflowOutputEvent(data=update, executor_id="test")
# This assignment should pass type checking without a cast
data: AgentResponseUpdate | None = event.data
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable
from collections.abc import AsyncIterable, Sequence
from typing import Any
from pydantic import PrivateAttr
@@ -34,7 +34,7 @@ class _SimpleAgent(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,
@@ -43,7 +43,7 @@ class _SimpleAgent(BaseAgent):
async 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,
@@ -56,7 +56,7 @@ class _CaptureFullConversation(Executor):
"""Captures AgentExecutorResponse.full_conversation and completes the workflow."""
@handler
async def capture(self, response: AgentExecutorResponse, ctx: WorkflowContext[Never, dict]) -> None:
async def capture(self, response: AgentExecutorResponse, ctx: WorkflowContext[Never, dict[str, Any]]) -> None:
full = response.full_conversation
# The AgentExecutor contract guarantees full_conversation is populated.
assert full is not None
@@ -75,7 +75,13 @@ async def test_agent_executor_populates_full_conversation_non_streaming() -> Non
agent_exec = AgentExecutor(agent, id="agent1-exec")
capturer = _CaptureFullConversation(id="capture")
wf = WorkflowBuilder().set_start_executor(agent_exec).add_edge(agent_exec, capturer).build()
wf = (
WorkflowBuilder()
.set_start_executor(agent_exec)
.add_edge(agent_exec, capturer)
.with_output_from([capturer])
.build()
)
# Act: use run() instead of run_stream() to test non-streaming mode
result = await wf.run("hello world")
@@ -103,7 +109,7 @@ class _CaptureAgent(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,
@@ -121,7 +127,7 @@ class _CaptureAgent(BaseAgent):
async 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,
@@ -11,7 +11,6 @@ from agent_framework import (
AgentProtocol,
AgentResponse,
AgentResponseUpdate,
AgentRunUpdateEvent,
AgentThread,
BaseAgent,
ChatMessage,
@@ -574,15 +573,19 @@ class StubAssistantsAgent(BaseAgent):
async def _collect_agent_responses_setup(participant: AgentProtocol) -> list[ChatMessage]:
captured: list[ChatMessage] = []
wf = MagenticBuilder().participants([participant]).with_manager(manager=InvokeOnceManager()).build()
wf = (
MagenticBuilder()
.participants([participant])
.with_manager(manager=InvokeOnceManager())
.with_intermediate_outputs()
.build()
)
# Run a bounded stream to allow one invoke and then completion
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, AgentRunUpdateEvent):
if isinstance(ev, WorkflowOutputEvent) and isinstance(ev.data, AgentResponseUpdate):
captured.append(
ChatMessage(
role=ev.data.role or "assistant",
@@ -597,7 +600,6 @@ async def _collect_agent_responses_setup(participant: AgentProtocol) -> list[Cha
async def test_agent_executor_invoke_with_thread_chat_client():
agent = StubThreadAgent()
captured = await _collect_agent_responses_setup(agent)
# Should have at least one response from agentA via _MagenticAgentExecutor path
assert any((m.author_name == agent.name and "ok" in (m.text or "")) for m in captured)
@@ -177,7 +177,7 @@ def test_graph_connectivity_isolated_executors():
executors: dict[str, Executor] = {executor1.id: executor1, executor2.id: executor2, executor3.id: executor3}
with pytest.raises(GraphConnectivityError) as exc_info:
validate_workflow_graph(edge_groups, executors, executor1)
validate_workflow_graph(edge_groups, executors, executor1, [])
assert "unreachable" in str(exc_info.value).lower()
assert "executor3" in str(exc_info.value)
@@ -258,12 +258,12 @@ def test_direct_validation_function():
executors: dict[str, Executor] = {executor1.id: executor1, executor2.id: executor2}
# This should not raise any exceptions
validate_workflow_graph(edge_groups, executors, executor1)
validate_workflow_graph(edge_groups, executors, executor1, [])
# Test with invalid start executor
executor3 = StringExecutor(id="executor3")
with pytest.raises(GraphConnectivityError):
validate_workflow_graph(edge_groups, executors, executor3)
validate_workflow_graph(edge_groups, executors, executor3, [])
def test_fan_out_validation():
@@ -557,3 +557,155 @@ def test_handler_ctx_any_is_allowed_but_skips_type_checks(caplog: Any) -> None:
# Builds; later edges from this executor will skip type compatibility when outputs are unspecified
wf = WorkflowBuilder().add_edge(start, any_out).set_start_executor(start).build()
assert wf is not None
# region Output Validation Tests
class OutputExecutor(Executor):
@handler
async def handle_string(self, message: str, ctx: WorkflowContext[str, str]) -> None:
pass
def test_output_validation_with_valid_output_executors():
"""Test that output validation passes when output executors exist and have output types."""
executor1 = OutputExecutor(id="executor1")
executor2 = OutputExecutor(id="executor2")
# Build workflow with valid output executors
workflow = (
WorkflowBuilder()
.add_edge(executor1, executor2)
.set_start_executor(executor1)
.with_output_from([executor2])
.build()
)
assert workflow is not None
assert workflow._output_executors == ["executor2"]
def test_output_validation_with_multiple_valid_output_executors():
"""Test that output validation passes with multiple valid output executors."""
executor1 = OutputExecutor(id="executor1")
executor2 = OutputExecutor(id="executor2")
executor3 = OutputExecutor(id="executor3")
workflow = (
WorkflowBuilder()
.add_edge(executor1, executor2)
.add_edge(executor2, executor3)
.set_start_executor(executor1)
.with_output_from([executor1, executor3])
.build()
)
assert workflow is not None
assert set(workflow._output_executors) == {"executor1", "executor3"}
def test_output_validation_fails_for_nonexistent_executor():
"""Test that output validation fails when an output executor doesn't exist in the graph."""
executor1 = OutputExecutor(id="executor1")
executor2 = OutputExecutor(id="executor2")
edge_groups = [SingleEdgeGroup(executor1.id, executor2.id)]
executors: dict[str, Executor] = {executor1.id: executor1, executor2.id: executor2}
# Directly test validation with a nonexistent output executor
with pytest.raises(WorkflowValidationError) as exc_info:
validate_workflow_graph(edge_groups, executors, executor1, ["nonexistent_executor"])
assert "not present in the workflow graph" in str(exc_info.value)
assert "nonexistent_executor" in str(exc_info.value)
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
def test_output_validation_fails_for_executor_without_output_types():
"""Test that output validation fails when an output executor has no output type annotations."""
executor1 = OutputExecutor(id="executor1")
no_output_executor = NoOutputTypesExecutor(id="no_output")
with pytest.raises(WorkflowValidationError) as exc_info:
(
WorkflowBuilder()
.add_edge(executor1, no_output_executor)
.set_start_executor(executor1)
.with_output_from([no_output_executor])
.build()
)
assert "must have output type annotations defined" in str(exc_info.value)
assert "no_output" in str(exc_info.value)
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
def test_output_validation_empty_list_passes():
"""Test that output validation passes with an empty output executors list."""
executor1 = OutputExecutor(id="executor1")
executor2 = OutputExecutor(id="executor2")
workflow = (
WorkflowBuilder().add_edge(executor1, executor2).set_start_executor(executor1).with_output_from([]).build()
)
assert workflow is not None
# All executors are outputs
assert workflow._output_executors == ["executor1", "executor2"] # type: ignore
def test_output_validation_with_direct_validate_workflow_graph():
"""Test _output_validation directly via validate_workflow_graph function."""
executor1 = OutputExecutor(id="executor1")
executor2 = OutputExecutor(id="executor2")
edge_groups = [SingleEdgeGroup(executor1.id, executor2.id)]
executors: dict[str, Executor] = {executor1.id: executor1, executor2.id: executor2}
# Valid output executors
validate_workflow_graph(edge_groups, executors, executor1, ["executor2"])
# Invalid output executor (doesn't exist)
with pytest.raises(WorkflowValidationError) as exc_info:
validate_workflow_graph(edge_groups, executors, executor1, ["nonexistent"])
assert "not present in the workflow graph" in str(exc_info.value)
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
def test_output_validation_with_no_output_types_via_direct_validation():
"""Test _output_validation fails for executors without output types via direct validation."""
executor1 = OutputExecutor(id="executor1")
no_output_executor = NoOutputTypesExecutor(id="no_output")
edge_groups = [SingleEdgeGroup(executor1.id, no_output_executor.id)]
executors: dict[str, Executor] = {executor1.id: executor1, no_output_executor.id: no_output_executor}
# Should fail because no_output_executor has no output types
with pytest.raises(WorkflowValidationError) as exc_info:
validate_workflow_graph(edge_groups, executors, executor1, ["no_output"])
assert "must have output type annotations defined" in str(exc_info.value)
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
def test_output_validation_partial_invalid_list():
"""Test that output validation fails if any executor in the list is invalid."""
executor1 = OutputExecutor(id="executor1")
executor2 = OutputExecutor(id="executor2")
edge_groups = [SingleEdgeGroup(executor1.id, executor2.id)]
executors: dict[str, Executor] = {executor1.id: executor1, executor2.id: executor2}
# First executor is valid, second doesn't exist - validation should fail
with pytest.raises(WorkflowValidationError) as exc_info:
validate_workflow_graph(edge_groups, executors, executor1, ["executor2", "nonexistent"])
assert "not present in the workflow graph" in str(exc_info.value)
assert "nonexistent" in str(exc_info.value)
def test_output_validation_type_enum_value():
"""Test that OUTPUT_VALIDATION is properly defined in ValidationTypeEnum."""
assert hasattr(ValidationTypeEnum, "OUTPUT_VALIDATION")
assert ValidationTypeEnum.OUTPUT_VALIDATION.value == "OUTPUT_VALIDATION"
# endregion
@@ -2,9 +2,9 @@
import asyncio
import tempfile
from collections.abc import AsyncIterable
from collections.abc import AsyncIterable, Sequence
from dataclasses import dataclass, field
from typing import Any
from typing import Any, cast
from uuid import uuid4
import pytest
@@ -13,8 +13,6 @@ from agent_framework import (
AgentExecutor,
AgentResponse,
AgentResponseUpdate,
AgentRunEvent,
AgentRunUpdateEvent,
AgentThread,
BaseAgent,
ChatMessage,
@@ -862,7 +860,7 @@ class _StreamingTestAgent(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,
@@ -872,7 +870,7 @@ class _StreamingTestAgent(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,
@@ -884,7 +882,7 @@ class _StreamingTestAgent(BaseAgent):
async def test_agent_streaming_vs_non_streaming() -> None:
"""Test that run() emits AgentRunEvent while run_stream() emits AgentRunUpdateEvent."""
"""Test that run() and run_stream() both emits WorkflowOutputEvents correctly with the right data types."""
agent = _StreamingTestAgent(id="test_agent", name="TestAgent", reply_text="Hello World")
agent_exec = AgentExecutor(agent, id="agent_exec")
@@ -894,15 +892,17 @@ async def test_agent_streaming_vs_non_streaming() -> None:
result = await workflow.run("test message")
# Filter for agent events (result is a list of events)
agent_run_events = [e for e in result if isinstance(e, AgentRunEvent)]
agent_update_events = [e for e in result if isinstance(e, AgentRunUpdateEvent)]
agent_response = [e for e in result if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponse)]
agent_response_updates = [
e for e in result if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponseUpdate)
]
# In non-streaming mode, should have AgentRunEvent, no AgentRunUpdateEvent
assert len(agent_run_events) == 1, "Expected exactly one AgentRunEvent in non-streaming mode"
assert len(agent_update_events) == 0, "Expected no AgentRunUpdateEvent in non-streaming mode"
assert agent_run_events[0].executor_id == "agent_exec"
assert agent_run_events[0].data is not None
assert agent_run_events[0].data.messages[0].text == "Hello World"
# In non-streaming mode, should have AgentResponse, no AgentResponseUpdate
assert len(agent_response) == 1, "Expected exactly one AgentResponse in non-streaming mode"
assert len(agent_response_updates) == 0, "Expected no AgentResponseUpdate in non-streaming mode"
assert agent_response[0].executor_id == "agent_exec"
assert agent_response[0].data is not None
assert agent_response[0].data.messages[0].text == "Hello World"
# Test streaming mode with run_stream()
stream_events: list[WorkflowEvent] = []
@@ -910,22 +910,31 @@ async def test_agent_streaming_vs_non_streaming() -> None:
stream_events.append(event)
# Filter for agent events
stream_agent_run_events = [e for e in stream_events if isinstance(e, AgentRunEvent)]
stream_agent_update_events = [e for e in stream_events if isinstance(e, AgentRunUpdateEvent)]
agent_response = [
cast(AgentResponse, e.data) # type: ignore
for e in stream_events
if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponse)
]
agent_response_updates = [
e.data for e in stream_events if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponseUpdate)
]
# In streaming mode, should have AgentRunUpdateEvent, no AgentRunEvent
assert len(stream_agent_run_events) == 0, "Expected no AgentRunEvent in streaming mode"
assert len(stream_agent_update_events) > 0, "Expected AgentRunUpdateEvent events in streaming mode"
# In streaming mode, should have AgentResponseUpdate, no AgentResponse
assert len(agent_response) == 0, "Expected no AgentResponse in streaming mode"
assert len(agent_response_updates) > 0, "Expected AgentResponseUpdate events in streaming mode"
# Verify we got incremental updates (one per character in "Hello World")
assert len(stream_agent_update_events) == len("Hello World"), "Expected one update per character"
assert len(agent_response_updates) == len("Hello World"), "Expected one update per character"
# Verify the updates build up to the full message
accumulated_text = "".join(
e.data.contents[0].text
for e in stream_agent_update_events
if e.data and e.data.contents and e.data.contents[0].text
)
accumulated_text = "".join([
e.contents[0].text
for e in agent_response_updates
if e.contents
and isinstance(e.contents[0], Content)
and e.contents[0].type == "text"
and e.contents[0].text is not None
])
assert accumulated_text == "Hello World", f"Expected 'Hello World', got '{accumulated_text}'"
@@ -974,3 +983,253 @@ async def test_workflow_run_stream_parameter_validation(
# Invalid combinations already tested in test_workflow_run_parameter_validation
# This test ensures streaming works correctly for valid parameters
# region Output executor filtering tests
class OutputProducerExecutor(Executor):
"""An executor that produces a unique output value for testing output filtering."""
def __init__(self, id: str, output_value: int) -> None:
super().__init__(id=id)
self.output_value = output_value
@handler
async def handle_message(self, message: NumberMessage, ctx: WorkflowContext[NumberMessage, int]) -> None:
await ctx.yield_output(self.output_value)
class PassthroughExecutor(Executor):
"""An executor that passes through messages and produces an output."""
def __init__(self, id: str, output_value: int) -> None:
super().__init__(id=id)
self.output_value = output_value
@handler
async def handle_message(self, message: NumberMessage, ctx: WorkflowContext[NumberMessage, int]) -> None:
await ctx.yield_output(self.output_value)
await ctx.send_message(message)
async def test_output_executors_empty_yields_all_outputs() -> None:
"""Test that when _output_executors is empty (default), all outputs are yielded."""
# Create executors that each produce different outputs
executor_a = PassthroughExecutor(id="executor_a", output_value=10)
executor_b = OutputProducerExecutor(id="executor_b", output_value=20)
# Build workflow with a -> b
workflow = WorkflowBuilder().set_start_executor(executor_a).add_edge(executor_a, executor_b).build()
result = await workflow.run(NumberMessage(data=0))
outputs = result.get_outputs()
# Both executors' outputs should be present
assert len(outputs) == 2
assert outputs == [10, 20]
output_events = [event for event in result if isinstance(event, WorkflowOutputEvent)]
assert len(output_events) == 2
assert output_events[0].executor_id == "executor_a"
assert output_events[1].executor_id == "executor_b"
async def test_output_executors_filters_outputs_non_streaming() -> None:
"""Test that only outputs from specified executors are yielded in non-streaming mode."""
# Create executors that each produce different outputs
executor_a = PassthroughExecutor(id="executor_a", output_value=10)
executor_b = OutputProducerExecutor(id="executor_b", output_value=20)
# Build workflow with a -> b
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.with_output_from([executor_b])
.build()
)
result = await workflow.run(NumberMessage(data=0))
outputs = result.get_outputs()
# Only executor_b's output should be present
assert len(outputs) == 1
assert outputs[0] == 20
output_events = [event for event in result if isinstance(event, WorkflowOutputEvent)]
assert len(output_events) == 1
assert output_events[0].executor_id == "executor_b"
async def test_output_executors_filters_outputs_streaming() -> None:
"""Test that only outputs from specified executors are yielded in streaming mode."""
# Create executors that each produce different outputs
executor_a = PassthroughExecutor(id="executor_a", output_value=100)
executor_b = OutputProducerExecutor(id="executor_b", output_value=200)
# Build workflow with a -> b
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.with_output_from([executor_a])
.build()
)
# Collect outputs from streaming
output_events: list[WorkflowOutputEvent] = []
async for event in workflow.run_stream(NumberMessage(data=0)):
if isinstance(event, WorkflowOutputEvent):
output_events.append(event)
# Only executor_a's output should be present
assert len(output_events) == 1
assert output_events[0].data == 100
assert output_events[0].executor_id == "executor_a"
async def test_output_executors_with_multiple_specified_executors() -> None:
"""Test filtering with multiple executors in the output list."""
# Create three executors with pass-through to reach all of them
executor_a = PassthroughExecutor(id="executor_a", output_value=1)
executor_b = PassthroughExecutor(id="executor_b", output_value=2)
executor_c = OutputProducerExecutor(id="executor_c", output_value=3)
# Build workflow with a -> b -> c
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_c)
.with_output_from([executor_a, executor_c])
.build()
)
result = await workflow.run(NumberMessage(data=0))
outputs = result.get_outputs()
# Only executor_a and executor_c outputs should be present
assert len(outputs) == 2
assert 1 in outputs # executor_a
assert 3 in outputs # executor_c
assert 2 not in outputs # executor_b should be filtered out
async def test_output_executors_with_nonexistent_executor_id() -> None:
"""Test that specifying a non-existent executor ID doesn't break the workflow."""
executor_a = OutputProducerExecutor(id="executor_a", output_value=42)
workflow = WorkflowBuilder().set_start_executor(executor_a).build()
# Set output_executors to an ID that doesn't exist
workflow._output_executors = ["nonexistent_executor"] # type: ignore
result = await workflow.run(NumberMessage(data=0))
outputs = result.get_outputs()
# No outputs should be yielded since the executor ID doesn't match
assert len(outputs) == 0
async def test_output_executors_filtering_with_fan_in() -> None:
"""Test output filtering in a fan-in workflow."""
class FanOutStartExecutor(Executor):
"""Executor that sends messages to fan-out targets."""
@handler
async def handle(self, message: NumberMessage, ctx: WorkflowContext[NumberMessage, int]) -> None:
await ctx.yield_output(999) # This should be filtered out
await ctx.send_message(NumberMessage(data=5))
class FanOutTargetExecutor(Executor):
"""Executor that processes fan-out messages."""
def __init__(self, id: str, increment: int) -> None:
super().__init__(id=id)
self.increment = increment
@handler
async def handle(self, message: NumberMessage, ctx: WorkflowContext[NumberMessage, int]) -> None:
await ctx.yield_output(888) # This should be filtered out
await ctx.send_message(NumberMessage(data=message.data + self.increment))
# Create executors for fan-in pattern
executor_start = FanOutStartExecutor(id="executor_start")
executor_a = FanOutTargetExecutor(id="executor_a", increment=10)
executor_b = FanOutTargetExecutor(id="executor_b", increment=20)
aggregator = AggregatorExecutor(id="aggregator")
# Build fan-in workflow: start -> [a, b] -> aggregator
workflow = (
WorkflowBuilder()
.set_start_executor(executor_start)
.add_fan_out_edges(executor_start, [executor_a, executor_b])
.add_fan_in_edges([executor_a, executor_b], aggregator)
.with_output_from([aggregator])
.build()
)
result = await workflow.run(NumberMessage(data=0))
outputs = result.get_outputs()
# Only aggregator output should be present
# executor_a sends 5+10=15, executor_b sends 5+20=25, aggregator sums: 15+25=40
assert len(outputs) == 1
assert outputs[0] == 40
async def test_output_executors_filtering_with_send_responses() -> None:
"""Test output filtering works correctly with send_responses method."""
executor = MockExecutorRequestApproval(id="approval_executor")
workflow = WorkflowBuilder().set_start_executor(executor).with_output_from([executor]).build()
# Run workflow which will request approval
result = await workflow.run(NumberMessage(data=42))
# Get request info events
request_events = result.get_request_info_events()
assert len(request_events) == 1
# Send approval response
responses = {request_events[0].request_id: ApprovalMessage(approved=True)}
response_result = await workflow.send_responses(responses)
outputs = response_result.get_outputs()
# Output should be yielded since approval_executor is in output_executors
assert len(outputs) == 1
assert outputs[0] == 42
async def test_output_executors_filtering_with_send_responses_streaming() -> None:
"""Test output filtering works correctly with send_responses_streaming method."""
executor = MockExecutorRequestApproval(id="approval_executor")
workflow = WorkflowBuilder().set_start_executor(executor).build()
# Run workflow which will request approval
events_list: list[WorkflowEvent] = []
async for event in workflow.run_stream(NumberMessage(data=99)):
events_list.append(event)
# Get request info events
request_events = [e for e in events_list if isinstance(e, RequestInfoEvent)]
assert len(request_events) == 1
# Set output_executors to exclude the approval executor
workflow._output_executors = ["other_executor"] # type: ignore
# Send approval response via streaming
responses = {request_events[0].request_id: ApprovalMessage(approved=True)}
output_events: list[WorkflowOutputEvent] = []
async for event in workflow.send_responses_streaming(responses):
if isinstance(event, WorkflowOutputEvent):
output_events.append(event)
# No outputs should be yielded since approval_executor is not in output_executors
assert len(output_events) == 0
# endregion
@@ -1,16 +1,17 @@
# Copyright (c) Microsoft. All rights reserved.
import uuid
from collections.abc import AsyncIterable
from collections.abc import AsyncIterable, Sequence
from typing import Any
import pytest
from typing_extensions import Never
from agent_framework import (
AgentExecutorRequest,
AgentProtocol,
AgentResponse,
AgentResponseUpdate,
AgentRunUpdateEvent,
AgentThread,
ChatMessage,
ChatMessageStore,
@@ -27,26 +28,34 @@ from agent_framework import (
class SimpleExecutor(Executor):
"""Simple executor that emits AgentRunEvent or AgentRunStreamingEvent."""
"""Simple executor that emits a response based on input."""
def __init__(self, id: str, response_text: str, emit_streaming: bool = False):
def __init__(self, id: str, response_text: str, streaming: bool = False):
super().__init__(id=id)
self.response_text = response_text
self.emit_streaming = emit_streaming
self.streaming = streaming
@handler
async def handle_message(self, message: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
async def handle_message(
self,
message: list[ChatMessage],
ctx: WorkflowContext[list[ChatMessage], AgentResponseUpdate | AgentResponse],
) -> None:
input_text = message[0].contents[0].text if message and message[0].contents[0].type == "text" else "no input"
response_text = f"{self.response_text}: {input_text}"
# Create response message for both streaming and non-streaming cases
response_message = ChatMessage("assistant", [Content.from_text(text=response_text)])
# Emit update event.
streaming_update = AgentResponseUpdate(
contents=[Content.from_text(text=response_text)], role="assistant", message_id=str(uuid.uuid4())
)
await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=streaming_update))
if self.streaming:
# Emit update event.
streaming_update = AgentResponseUpdate(
contents=[Content.from_text(text=response_text)], role="assistant", message_id=str(uuid.uuid4())
)
await ctx.yield_output(streaming_update)
else:
response = AgentResponse(messages=[response_message])
await ctx.yield_output(response)
# Pass message to next executor if any (for both streaming and non-streaming)
await ctx.send_message([response_message])
@@ -55,6 +64,10 @@ class SimpleExecutor(Executor):
class RequestingExecutor(Executor):
"""Executor that requests info."""
def __init__(self, id: str, streaming: bool = False):
super().__init__(id=id)
self.streaming = streaming
@handler
async def handle_message(self, _: list[ChatMessage], ctx: WorkflowContext) -> None:
# Send a RequestInfoMessage to trigger the request info process
@@ -62,26 +75,49 @@ class RequestingExecutor(Executor):
@response_handler
async def handle_request_response(
self, original_request: str, response: str, ctx: WorkflowContext[ChatMessage]
self,
original_request: str,
response: str,
ctx: WorkflowContext[ChatMessage, AgentResponseUpdate | AgentResponse],
) -> None:
# Handle the response and emit completion response
update = AgentResponseUpdate(
contents=[Content.from_text(text="Request completed successfully")],
role="assistant",
message_id=str(uuid.uuid4()),
content = Content.from_text(text=f"Request completed with response: {response}")
if self.streaming:
await ctx.yield_output(
AgentResponseUpdate(
contents=[content],
role="assistant",
message_id=str(uuid.uuid4()),
)
)
return
await ctx.yield_output(
AgentResponse(
messages=[
ChatMessage(
role="assistant",
contents=[content],
)
],
)
)
await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=update))
class ConversationHistoryCapturingExecutor(Executor):
"""Executor that captures the received conversation history for verification."""
def __init__(self, id: str):
def __init__(self, id: str, streaming: bool = False):
super().__init__(id=id)
self.received_messages: list[ChatMessage] = []
self.streaming = streaming
@handler
async def handle_message(self, messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
async def handle_message(
self,
messages: list[ChatMessage],
ctx: WorkflowContext[list[ChatMessage], AgentResponseUpdate | AgentResponse],
) -> None:
# Capture all received messages
self.received_messages = list(messages)
@@ -91,10 +127,16 @@ class ConversationHistoryCapturingExecutor(Executor):
response_message = ChatMessage("assistant", [Content.from_text(text=response_text)])
streaming_update = AgentResponseUpdate(
contents=[Content.from_text(text=response_text)], role="assistant", message_id=str(uuid.uuid4())
)
await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=streaming_update))
if self.streaming:
# Emit streaming update
streaming_update = AgentResponseUpdate(
contents=[Content.from_text(text=response_text)], role="assistant", message_id=str(uuid.uuid4())
)
await ctx.yield_output(streaming_update)
else:
response = AgentResponse(messages=[response_message])
await ctx.yield_output(response)
await ctx.send_message([response_message])
@@ -102,10 +144,10 @@ class TestWorkflowAgent:
"""Test cases for WorkflowAgent end-to-end functionality."""
async def test_end_to_end_basic_workflow(self):
"""Test basic end-to-end workflow execution with 2 executors emitting AgentRunEvent."""
"""Test basic end-to-end workflow execution with 2 executors emitting AgentResponse."""
# Create workflow with two executors
executor1 = SimpleExecutor(id="executor1", response_text="Step1", emit_streaming=False)
executor2 = SimpleExecutor(id="executor2", response_text="Step2", emit_streaming=False)
executor1 = SimpleExecutor(id="executor1", response_text="Step1", streaming=False)
executor2 = SimpleExecutor(id="executor2", response_text="Step2", streaming=False)
workflow = WorkflowBuilder().set_start_executor(executor1).add_edge(executor1, executor2).build()
@@ -126,6 +168,7 @@ class TestWorkflowAgent:
first_content = message.contents[0]
if first_content.type == "text":
text = first_content.text
assert text is not None
if text.startswith("Step1:"):
step1_messages.append(message)
elif text.startswith("Step2:"):
@@ -136,16 +179,18 @@ class TestWorkflowAgent:
assert len(step2_messages) >= 1, "Should have received message from Step2 executor"
# Verify the processing worked for both
step1_text: str = step1_messages[0].contents[0].text # type: ignore[attr-defined]
step2_text: str = step2_messages[0].contents[0].text # type: ignore[attr-defined]
step1_text = step1_messages[0].contents[0].text
step2_text = step2_messages[0].contents[0].text
assert step1_text is not None
assert step2_text is not None
assert "Step1: Hello World" in step1_text
assert "Step2: Step1: Hello World" in step2_text
async def test_end_to_end_basic_workflow_streaming(self):
"""Test end-to-end workflow with streaming executor that emits AgentRunStreamingEvent."""
# Create a single streaming executor
executor1 = SimpleExecutor(id="stream1", response_text="Streaming1", emit_streaming=True)
executor2 = SimpleExecutor(id="stream2", response_text="Streaming2", emit_streaming=True)
executor1 = SimpleExecutor(id="stream1", response_text="Streaming1")
executor2 = SimpleExecutor(id="stream2", response_text="Streaming2")
# Create workflow with just one executor
workflow = WorkflowBuilder().set_start_executor(executor1).add_edge(executor1, executor2).build()
@@ -165,15 +210,17 @@ class TestWorkflowAgent:
first_content: Content = updates[0].contents[0] # type: ignore[assignment]
second_content: Content = updates[1].contents[0] # type: ignore[assignment]
assert first_content.type == "text"
assert first_content.text is not None
assert "Streaming1: Test input" in first_content.text
assert second_content.type == "text"
assert second_content.text is not None
assert "Streaming2: Streaming1: Test input" in second_content.text
async def test_end_to_end_request_info_handling(self):
"""Test end-to-end workflow with RequestInfoEvent handling."""
# Create workflow with requesting executor -> request info executor (no cycle)
simple_executor = SimpleExecutor(id="simple", response_text="SimpleResponse", emit_streaming=False)
requesting_executor = RequestingExecutor(id="requester")
simple_executor = SimpleExecutor(id="simple", response_text="SimpleResponse", streaming=False)
requesting_executor = RequestingExecutor(id="requester", streaming=False)
workflow = (
WorkflowBuilder().set_start_executor(simple_executor).add_edge(simple_executor, requesting_executor).build()
@@ -208,6 +255,8 @@ class TestWorkflowAgent:
assert function_call.arguments.get("request_id") == approval_request.id
# Approval request should reference the same function call
assert approval_request.id is not None
assert approval_request.function_call is not None
assert approval_request.function_call.call_id == function_call.call_id
assert approval_request.function_call.name == function_call.name
@@ -245,7 +294,7 @@ class TestWorkflowAgent:
def test_workflow_as_agent_method(self) -> None:
"""Test that Workflow.as_agent() creates a properly configured WorkflowAgent."""
# Create a simple workflow
executor = SimpleExecutor(id="executor1", response_text="Response", emit_streaming=False)
executor = SimpleExecutor(id="executor1", response_text="Response")
workflow = WorkflowBuilder().set_start_executor(executor).build()
# Test as_agent with a name
@@ -286,7 +335,7 @@ class TestWorkflowAgent:
"""
@executor
async def yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
async def yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext[Never, str]) -> None:
# Extract text from input for demonstration
input_text = messages[0].text if messages else "no input"
await ctx.yield_output(f"processed: {input_text}")
@@ -311,7 +360,7 @@ class TestWorkflowAgent:
"""Test that ctx.yield_output() surfaces as AgentResponseUpdate when streaming."""
@executor
async def yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
async def yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("first output")
await ctx.yield_output("second output")
@@ -331,7 +380,7 @@ class TestWorkflowAgent:
"""Test that yield_output preserves different content types (Content, Content, etc.)."""
@executor
async def content_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
async def content_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext[Never, Content]) -> None:
# Yield different content types
await ctx.yield_output(Content.from_text(text="text content"))
await ctx.yield_output(Content.from_data(data=b"binary data", media_type="application/octet-stream"))
@@ -359,7 +408,7 @@ class TestWorkflowAgent:
"""Test that yield_output with ChatMessage preserves the message structure."""
@executor
async def chat_message_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
async def chat_message_executor(messages: list[ChatMessage], ctx: WorkflowContext[Never, ChatMessage]) -> None:
msg = ChatMessage(
role="assistant",
contents=[Content.from_text(text="response text")],
@@ -389,7 +438,9 @@ class TestWorkflowAgent:
return f"CustomData({self.value})"
@executor
async def raw_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
async def raw_yielding_executor(
messages: list[ChatMessage], ctx: WorkflowContext[Never, Content | CustomData | str]
) -> None:
# Yield different types of data
await ctx.yield_output("simple string")
await ctx.yield_output(Content.from_text(text="text content"))
@@ -408,8 +459,11 @@ class TestWorkflowAgent:
# Verify raw_representation is set for each update
assert updates[0].raw_representation == "simple string"
assert isinstance(updates[1].raw_representation, Content)
assert updates[1].raw_representation.type == "text"
assert updates[1].raw_representation.text == "text content"
assert isinstance(updates[2].raw_representation, CustomData)
assert updates[2].raw_representation.value == 42
@@ -421,7 +475,9 @@ class TestWorkflowAgent:
"""
@executor
async def list_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
async def list_yielding_executor(
messages: list[ChatMessage], ctx: WorkflowContext[Never, list[ChatMessage]]
) -> None:
# Yield a list of ChatMessages (as SequentialBuilder does)
msg_list = [
ChatMessage("user", [Content.from_text(text="first message")]),
@@ -441,19 +497,20 @@ class TestWorkflowAgent:
async for update in agent.run_stream("test"):
updates.append(update)
assert len(updates) == 1
assert len(updates[0].contents) == 4
texts = [c.text for c in updates[0].contents if c.type == "text"]
assert texts == ["first message", "second message", "third", "fourth"]
assert len(updates) == 3
full_response = AgentResponse.from_updates(updates)
assert len(full_response.messages) == 3
texts = [message.text for message in full_response.messages]
# Note: `from_agent_run_response_updates` coalesces multiple text contents into one content
assert texts == ["first message", "second message", "thirdfourth"]
# Verify run() coalesces text contents (expected behavior)
# Verify run()
result = await agent.run("test")
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
# Content items are coalesced into one
assert len(result.messages[0].contents) == 1
assert result.messages[0].text == "first messagesecond messagethirdfourth"
assert len(result.messages) == 3
texts = [message.text for message in result.messages]
assert texts == ["first message", "second message", "third fourth"]
async def test_thread_conversation_history_included_in_workflow_run(self) -> None:
"""Test that conversation history from thread is included when running WorkflowAgent.
@@ -462,7 +519,7 @@ class TestWorkflowAgent:
the workflow receives the complete conversation history (thread history + new messages).
"""
# Create an executor that captures all received messages
capturing_executor = ConversationHistoryCapturingExecutor(id="capturing")
capturing_executor = ConversationHistoryCapturingExecutor(id="capturing", streaming=False)
workflow = WorkflowBuilder().set_start_executor(capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="Thread History Test Agent")
@@ -561,41 +618,41 @@ class TestWorkflowAgent:
"""Mock agent for testing."""
def __init__(self, name: str, response_text: str) -> None:
self._name = name
self.id = str(uuid.uuid4())
self.name = name
self.description: str | None = None
self._response_text = response_text
self._description: str | None = None
@property
def name(self) -> str | None:
return self._name
@property
def description(self) -> str | None:
return self._description
def get_new_thread(self) -> AgentThread:
def get_new_thread(self, **kwargs: Any) -> AgentThread:
return AgentThread()
async def run(self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any) -> AgentResponse:
async def run(
self,
messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AgentResponse:
return AgentResponse(
messages=[ChatMessage("assistant", [self._response_text])],
text=self._response_text,
)
async def run_stream(
self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any
self,
messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
for word in self._response_text.split():
yield AgentResponseUpdate(
contents=[Content.from_text(text=word + " ")],
role="assistant",
author_name=self._name,
author_name=self.name,
)
@executor
async def start_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
from agent_framework import AgentExecutorRequest
async def start_executor(messages: list[ChatMessage], ctx: WorkflowContext[AgentExecutorRequest, str]) -> None:
await ctx.yield_output("Start output")
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
@@ -604,12 +661,11 @@ class TestWorkflowAgent:
WorkflowBuilder()
.register_executor(lambda: start_executor, "start")
.register_agent(lambda: MockAgent("agent1", "Agent1 output - should NOT appear"), "agent1")
.register_agent(
lambda: MockAgent("agent2", "Agent2 output - SHOULD appear"), "agent2", output_response=True
)
.register_agent(lambda: MockAgent("agent2", "Agent2 output - SHOULD appear"), "agent2")
.set_start_executor("start")
.add_edge("start", "agent1")
.add_edge("agent1", "agent2")
.with_output_from(["start", "agent2"])
.build()
)
@@ -635,47 +691,45 @@ class TestWorkflowAgent:
"""Mock agent for testing."""
def __init__(self, name: str, response_text: str) -> None:
self._name = name
self.id = str(uuid.uuid4())
self.name = name
self.description: str | None = None
self._response_text = response_text
self._description: str | None = None
@property
def name(self) -> str | None:
return self._name
@property
def description(self) -> str | None:
return self._description
def get_new_thread(self) -> AgentThread:
def get_new_thread(self, **kwargs: Any) -> AgentThread:
return AgentThread()
async def run(self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any) -> AgentResponse:
return AgentResponse(
messages=[ChatMessage("assistant", [self._response_text])],
text=self._response_text,
)
async def run(
self,
messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AgentResponse:
return AgentResponse(messages=[ChatMessage("assistant", [self._response_text])])
async def run_stream(
self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any
self,
messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[Content.from_text(text=self._response_text)],
role="assistant",
author_name=self._name,
author_name=self.name,
)
@executor
async def start_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
from agent_framework import AgentExecutorRequest
async def start_executor(messages: list[ChatMessage], ctx: WorkflowContext[AgentExecutorRequest]) -> None:
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
# Build workflow with single agent that has output_response=True
# Build workflow with single agent
workflow = (
WorkflowBuilder()
.register_executor(lambda: start_executor, "start")
.register_agent(lambda: MockAgent("agent", "Unique response text"), "agent", output_response=True)
.register_agent(lambda: MockAgent("agent", "Unique response text"), "agent")
.set_start_executor("start")
.add_edge("start", "agent")
.build()
@@ -694,14 +748,14 @@ class TestWorkflowAgent:
class TestWorkflowAgentAuthorName:
"""Test cases for author_name enrichment in WorkflowAgent (GitHub issue #1331)."""
async def test_agent_run_update_event_gets_executor_id_as_author_name(self):
"""Test that AgentRunUpdateEvent gets executor_id as author_name when not already set.
async def test_agent_response_update_gets_executor_id_as_author_name(self):
"""Test that AgentResponseUpdate gets executor_id as author_name when not already set.
This validates the fix for GitHub issue #1331: agent responses should include
identification of which agent produced them in multi-agent workflows.
"""
# Create workflow with executor that emits AgentRunUpdateEvent without author_name
executor1 = SimpleExecutor(id="my_executor_id", response_text="Response", emit_streaming=False)
# Create workflow with executor that emits AgentResponseUpdate without author_name
executor1 = SimpleExecutor(id="my_executor_id", response_text="Response")
workflow = WorkflowBuilder().set_start_executor(executor1).build()
agent = WorkflowAgent(workflow=workflow, name="Test Agent")
@@ -716,14 +770,18 @@ class TestWorkflowAgentAuthorName:
# Verify author_name is set to executor_id
assert updates[0].author_name == "my_executor_id"
async def test_agent_run_update_event_preserves_existing_author_name(self):
async def test_agent_response_update_preserves_existing_author_name(self):
"""Test that existing author_name is preserved and not overwritten."""
class AuthorNameExecutor(Executor):
"""Executor that sets author_name explicitly."""
@handler
async def handle_message(self, message: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
async def handle_message(
self,
message: list[ChatMessage],
ctx: WorkflowContext[list[ChatMessage], AgentResponseUpdate],
) -> None:
# Emit update with explicit author_name
update = AgentResponseUpdate(
contents=[Content.from_text(text="Response with author")],
@@ -731,7 +789,7 @@ class TestWorkflowAgentAuthorName:
author_name="custom_author_name", # Explicitly set
message_id=str(uuid.uuid4()),
)
await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=update))
await ctx.yield_output(update)
executor = AuthorNameExecutor(id="executor_id")
workflow = WorkflowBuilder().set_start_executor(executor).build()
@@ -749,8 +807,8 @@ class TestWorkflowAgentAuthorName:
async def test_multiple_executors_have_distinct_author_names(self):
"""Test that multiple executors in a workflow have their own author_name."""
# Create workflow with two executors
executor1 = SimpleExecutor(id="first_executor", response_text="First", emit_streaming=False)
executor2 = SimpleExecutor(id="second_executor", response_text="Second", emit_streaming=False)
executor1 = SimpleExecutor(id="first_executor", response_text="First")
executor2 = SimpleExecutor(id="second_executor", response_text="Second")
workflow = WorkflowBuilder().set_start_executor(executor1).add_edge(executor1, executor2).build()
agent = WorkflowAgent(workflow=workflow, name="Multi-Executor Agent")
@@ -834,11 +892,11 @@ class TestWorkflowAgentMergeUpdates:
# The exact order depends on dict iteration order for response_ids,
# but within each response group, chronological order should be maintained
# and global dangling should be last
assert "Global-Dangling" in message_texts[-1] # Global dangling at end
assert "Global-Dangling" in message_texts[-1] # type: ignore # Global dangling at end
# Find positions of resp-a and resp-b messages
resp_a_positions = [i for i, text in enumerate(message_texts) if "RespA" in text]
resp_b_positions = [i for i, text in enumerate(message_texts) if "RespB" in text]
resp_a_positions = [i for i, text in enumerate(message_texts) if "RespA" in text] # type: ignore
resp_b_positions = [i for i, text in enumerate(message_texts) if "RespB" in text] # type: ignore
# Within resp-a group: Msg1 (earlier) should come before Msg2 (later)
resp_a_texts = [message_texts[i] for i in resp_a_positions]
@@ -1013,7 +1071,7 @@ class TestWorkflowAgentMergeUpdates:
assert len(result.messages) == 4
# Extract content types for verification
content_sequence = []
content_sequence: list[tuple[str, str]] = []
for msg in result.messages:
for content in msg.contents:
if content.type == "text":
@@ -1128,7 +1186,7 @@ class TestWorkflowAgentMergeUpdates:
assert len(result.messages) == 6
# Build a sequence of (content_type, call_id_if_applicable)
content_sequence = []
content_sequence: list[tuple[str, str | None]] = []
for msg in result.messages:
for content in msg.contents:
if content.type == "text":
@@ -1194,7 +1252,7 @@ class TestWorkflowAgentMergeUpdates:
assert len(result.messages) == 3
# Orphan function result should be at the end since it can't be matched
content_types = []
content_types: list[str] = []
for msg in result.messages:
for content in msg.contents:
if content.type == "text":
@@ -15,6 +15,7 @@ from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowContext,
WorkflowValidationError,
handler,
)
@@ -57,7 +58,7 @@ class MockExecutor(Executor):
"""A mock executor for testing purposes."""
@handler
async def mock_handler(self, message: MockMessage, ctx: WorkflowContext[MockMessage]) -> None:
async def mock_handler(self, message: MockMessage, ctx: WorkflowContext[MockMessage, MockMessage]) -> None:
"""A mock handler that does nothing."""
pass
@@ -104,99 +105,23 @@ def test_workflow_builder_fluent_api():
assert len(workflow.executors) == 6
def test_add_agent_with_custom_parameters():
"""Test adding an agent with custom parameters."""
agent = DummyAgent(id="agent_custom", name="custom_agent")
builder = WorkflowBuilder()
# Add agent with custom parameters
with pytest.deprecated_call():
result = builder.add_agent(agent, output_response=True, id="my_custom_id")
# Verify that add_agent returns the builder for chaining
assert result is builder
# Build workflow and verify executor is present
workflow = builder.set_start_executor(agent).build()
assert "my_custom_id" in workflow.executors
# Verify the executor was created with correct parameters
executor = workflow.executors["my_custom_id"]
assert isinstance(executor, AgentExecutor)
assert executor.id == "my_custom_id"
assert getattr(executor, "_output_response", False) is True
def test_add_agent_reuses_same_wrapper():
"""Test that using the same agent instance multiple times reuses the same wrapper."""
agent = DummyAgent(id="agent_reuse", name="reuse_agent")
reuse_agent = DummyAgent(id="agent_reuse", name="reuse_agent")
agent_a = DummyAgent(id="agent_a", name="agent_a")
builder = WorkflowBuilder()
# Add agent with specific parameters
with pytest.deprecated_call():
builder.add_agent(agent, output_response=True, id="agent_exec")
# Use the same agent instance in add_edge - should reuse the same wrapper
builder.set_start_executor(agent)
builder.set_start_executor(reuse_agent)
builder.add_edge(reuse_agent, agent_a)
builder.add_edge(agent_a, reuse_agent)
workflow = builder.build()
# Verify only one executor exists for this agent
assert workflow.start_executor_id == "agent_exec"
assert "agent_exec" in workflow.executors
assert len([e for e in workflow.executors.values() if isinstance(e, AgentExecutor)]) == 1
# Verify the executor has the parameters from add_agent
start_executor = workflow.get_start_executor()
assert isinstance(start_executor, AgentExecutor)
assert getattr(start_executor, "_output_response", False) is True
def test_add_agent_then_use_in_edges():
"""Test that an agent added via add_agent can be used in edge definitions."""
agent1 = DummyAgent(id="agent1", name="first")
agent2 = DummyAgent(id="agent2", name="second")
builder = WorkflowBuilder()
# Add agents with specific settings
with pytest.deprecated_call():
builder.add_agent(agent1, output_response=False, id="exec1")
builder.add_agent(agent2, output_response=True, id="exec2")
# Use the same agent instances to create edges
workflow = builder.set_start_executor(agent1).add_edge(agent1, agent2).build()
# Verify the executors maintain their settings
assert workflow.start_executor_id == "exec1"
assert "exec1" in workflow.executors
assert "exec2" in workflow.executors
e1 = workflow.executors["exec1"]
e2 = workflow.executors["exec2"]
assert isinstance(e1, AgentExecutor)
assert isinstance(e2, AgentExecutor)
assert getattr(e1, "_output_response", True) is False
assert getattr(e2, "_output_response", False) is True
def test_add_agent_without_explicit_id_uses_agent_name():
"""Test that add_agent uses agent name as id when no explicit id is provided."""
agent = DummyAgent(id="agent_x", name="named_agent")
builder = WorkflowBuilder()
with pytest.deprecated_call():
result = builder.add_agent(agent)
# Verify that add_agent returns the builder for chaining
assert result is builder
workflow = builder.set_start_executor(agent).build()
assert "named_agent" in workflow.executors
# Verify the executor id matches the agent name
executor = workflow.executors["named_agent"]
assert executor.id == "named_agent"
assert workflow.start_executor_id == "reuse_agent"
assert "reuse_agent" in workflow.executors
assert len([e for e in workflow.executors.values() if isinstance(e, AgentExecutor)]) == 2
def test_add_agent_duplicate_id_raises_error():
@@ -205,13 +130,8 @@ def test_add_agent_duplicate_id_raises_error():
agent2 = DummyAgent(id="agent2", name="first") # Same name as agent1
builder = WorkflowBuilder()
# Add first agent
with pytest.deprecated_call():
builder.add_agent(agent1)
# Adding second agent with same name should raise ValueError
with pytest.deprecated_call(), pytest.raises(ValueError, match="Duplicate executor ID"):
builder.add_agent(agent2)
with pytest.raises(ValueError, match="Duplicate executor ID"):
builder.set_start_executor(agent1).add_edge(agent1, agent2).build()
# Tests for new executor registration patterns
@@ -303,7 +223,7 @@ def test_register_duplicate_id_raises_error():
builder.set_start_executor("MyExecutor1")
# Registering second executor with same ID should raise ValueError
with pytest.raises(ValueError, match="Executor with ID 'executor' has already been created."):
with pytest.raises(ValueError, match="Executor with ID 'executor' has already been registered."):
builder.build()
@@ -312,9 +232,7 @@ def test_register_agent_basic():
builder = WorkflowBuilder()
# Register an agent factory
result = builder.register_agent(
lambda: DummyAgent(id="agent_test", name="test_agent"), name="TestAgent", output_response=True
)
result = builder.register_agent(lambda: DummyAgent(id="agent_test", name="test_agent"), name="TestAgent")
# Verify that register_agent returns the builder for chaining
assert result is builder
@@ -323,7 +241,6 @@ def test_register_agent_basic():
workflow = builder.set_start_executor("TestAgent").build()
assert "test_agent" in workflow.executors
assert isinstance(workflow.executors["test_agent"], AgentExecutor)
assert workflow.executors["test_agent"]._output_response is True # type: ignore
def test_register_agent_with_thread():
@@ -336,7 +253,6 @@ def test_register_agent_with_thread():
lambda: DummyAgent(id="agent_with_thread", name="threaded_agent"),
name="ThreadedAgent",
agent_thread=custom_thread,
output_response=False,
)
# Build workflow and verify agent executor configuration
@@ -345,7 +261,6 @@ def test_register_agent_with_thread():
assert isinstance(executor, AgentExecutor)
assert executor.id == "threaded_agent"
assert executor._output_response is False # type: ignore
assert executor._agent_thread is custom_thread # type: ignore
@@ -549,3 +464,151 @@ def test_register_agent_creates_unique_instances():
# Verify that two different agent instances were created
assert len(instance_ids) == 2
assert instance_ids[0] != instance_ids[1]
# region with_output_from tests
def test_with_output_from_returns_builder():
"""Test that with_output_from returns the builder for method chaining."""
executor_a = MockExecutor(id="executor_a")
builder = WorkflowBuilder()
result = builder.with_output_from([executor_a])
assert result is builder
def test_with_output_from_with_executor_instances():
"""Test with_output_from with direct executor instances."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.with_output_from([executor_b])
.build()
)
# Verify that the workflow was built with the correct output executors
assert workflow._output_executors == ["executor_b"] # type: ignore
def test_with_output_from_with_agent_instances():
"""Test with_output_from with agent instances."""
agent_a = DummyAgent(id="agent_a", name="writer")
agent_b = DummyAgent(id="agent_b", name="reviewer")
workflow = (
WorkflowBuilder().set_start_executor(agent_a).add_edge(agent_a, agent_b).with_output_from([agent_b]).build()
)
# Verify that the workflow was built with the agent's name as output executor
assert workflow._output_executors == ["reviewer"] # type: ignore
def test_with_output_from_with_registered_names():
"""Test with_output_from with registered factory names (strings)."""
workflow = (
WorkflowBuilder()
.register_executor(lambda: MockExecutor(id="ExecutorA"), name="ExecutorAFactory")
.register_executor(lambda: MockExecutor(id="ExecutorB"), name="ExecutorBFactory")
.set_start_executor("ExecutorAFactory")
.add_edge("ExecutorAFactory", "ExecutorBFactory")
.with_output_from(["ExecutorBFactory"])
.build()
)
# Verify that the workflow was built with the correct output executors
assert workflow._output_executors == ["ExecutorB"] # type: ignore
def test_with_output_from_with_multiple_executors():
"""Test with_output_from with multiple executors."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
executor_c = MockExecutor(id="executor_c")
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_c)
.with_output_from([executor_a, executor_c])
.build()
)
# Verify that the workflow was built with both output executors
assert set(workflow._output_executors) == {"executor_a", "executor_c"} # type: ignore
def test_with_output_from_can_be_called_multiple_times():
"""Test that calling with_output_from multiple times overwrites the previous setting."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.with_output_from([executor_a])
.with_output_from([executor_b]) # This should overwrite the previous setting
.build()
)
# Verify that only the last setting is applied
assert workflow._output_executors == ["executor_b"] # type: ignore
def test_with_output_from_with_registered_agents():
"""Test with_output_from with registered agent factory names."""
workflow = (
WorkflowBuilder()
.register_agent(lambda: DummyAgent(id="agent1", name="writer"), name="WriterAgent")
.register_agent(lambda: DummyAgent(id="agent2", name="reviewer"), name="ReviewerAgent")
.set_start_executor("WriterAgent")
.add_edge("WriterAgent", "ReviewerAgent")
.with_output_from(["ReviewerAgent"])
.build()
)
# Verify that the workflow was built with the agent's resolved name
assert workflow._output_executors == ["reviewer"] # type: ignore
def test_with_output_from_in_fluent_chain():
"""Test that with_output_from works correctly in a fluent builder chain."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
executor_c = MockExecutor(id="executor_c")
# Build workflow with with_output_from in the middle of the chain
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.with_output_from([executor_c]) # Set early in the chain
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_c)
.build()
)
# Verify that the setting persists through the chain
assert workflow._output_executors == ["executor_c"] # type: ignore
def test_with_output_from_with_invalid_executor_raises_validation_error():
"""Test that with_output_from with an invalid executor raises an error."""
executor_a = MockExecutor(id="executor_a")
builder = WorkflowBuilder().set_start_executor(executor_a)
# Attempting to set output from an executor not in the workflow should raise an error
with pytest.raises(
WorkflowValidationError, match="Output executor 'executor_b' is not present in the workflow graph"
):
builder.with_output_from([MockExecutor(id="executor_b")]).build()
# endregion
@@ -12,7 +12,7 @@ from datetime import datetime
from typing import Any, Union
from uuid import uuid4
from agent_framework import ChatMessage, Content
from agent_framework import ChatMessage, Content, WorkflowOutputEvent
from openai.types.responses import (
Response,
ResponseContentPartAddedEvent,
@@ -179,11 +179,10 @@ class MessageMapper:
# Import Agent Framework types for proper isinstance checks
try:
from agent_framework import AgentResponse, AgentResponseUpdate, WorkflowEvent
from agent_framework._workflows._events import AgentRunUpdateEvent
# Handle AgentRunUpdateEvent - workflow event wrapping AgentResponseUpdate
# This must be checked BEFORE generic WorkflowEvent check
if isinstance(raw_event, AgentRunUpdateEvent):
if isinstance(raw_event, WorkflowOutputEvent):
# Extract the AgentResponseUpdate from the event's data attribute
if raw_event.data and isinstance(raw_event.data, AgentResponseUpdate):
# Preserve executor_id in context for proper output routing
@@ -7,6 +7,8 @@ the task in a round-robin fashion.
import asyncio
from agent_framework import AgentResponseUpdate, WorkflowOutputEvent
async def run_autogen() -> None:
"""AutoGen's RoundRobinGroupChat for sequential agent orchestration."""
@@ -53,7 +55,7 @@ async def run_autogen() -> None:
async def run_agent_framework() -> None:
"""Agent Framework's SequentialBuilder for sequential agent orchestration."""
from agent_framework import AgentRunUpdateEvent, SequentialBuilder
from agent_framework import SequentialBuilder
from agent_framework.openai import OpenAIChatClient
client = OpenAIChatClient(model_id="gpt-4.1-mini")
@@ -81,14 +83,14 @@ async def run_agent_framework() -> None:
print("[Agent Framework] Sequential conversation:")
current_executor = None
async for event in workflow.run_stream("Create a brief summary about electric vehicles"):
if isinstance(event, AgentRunUpdateEvent):
if isinstance(event, WorkflowOutputEvent):
# Print executor name header when switching to a new agent
if current_executor != event.executor_id:
if current_executor is not None:
print() # Newline after previous agent's message
print(f"---------- {event.executor_id} ----------")
current_executor = event.executor_id
if event.data:
if isinstance(event.data, AgentResponseUpdate):
print(event.data.text, end="", flush=True)
print() # Final newline after conversation
@@ -98,7 +100,6 @@ async def run_agent_framework_with_cycle() -> None:
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentRunUpdateEvent,
WorkflowBuilder,
WorkflowContext,
WorkflowOutputEvent,
@@ -153,10 +154,7 @@ async def run_agent_framework_with_cycle() -> None:
print("[Agent Framework with Cycle] Cyclic conversation:")
current_executor = None
async for event in workflow.run_stream("Create a brief summary about electric vehicles"):
if isinstance(event, WorkflowOutputEvent):
print("\n---------- Workflow Output ----------")
print(event.data)
elif isinstance(event, AgentRunUpdateEvent):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
# Print executor name header when switching to a new agent
if current_executor != event.executor_id:
if current_executor is not None:
@@ -7,6 +7,8 @@ which agent should speak next based on the conversation context.
import asyncio
from agent_framework import AgentResponseUpdate, WorkflowOutputEvent
async def run_autogen() -> None:
"""AutoGen's SelectorGroupChat with LLM-based speaker selection."""
@@ -59,7 +61,7 @@ async def run_autogen() -> None:
async def run_agent_framework() -> None:
"""Agent Framework's GroupChatBuilder with LLM-based speaker selection."""
from agent_framework import AgentRunUpdateEvent, GroupChatBuilder
from agent_framework import GroupChatBuilder
from agent_framework.openai import OpenAIChatClient
client = OpenAIChatClient(model_id="gpt-4.1-mini")
@@ -100,7 +102,7 @@ async def run_agent_framework() -> None:
print("[Agent Framework] Group chat conversation:")
current_executor = None
async for event in workflow.run_stream("How do I connect to a PostgreSQL database using Python?"):
if isinstance(event, AgentRunUpdateEvent):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
# Print executor name header when switching to a new agent
if current_executor != event.executor_id:
if current_executor is not None:
@@ -7,6 +7,8 @@ to other specialized agents based on the task requirements.
import asyncio
from agent_framework import AgentResponseUpdate, HandoffAgentUserRequest, WorkflowOutputEvent
async def run_autogen() -> None:
"""AutoGen's Swarm pattern with human-in-the-loop handoffs."""
@@ -96,9 +98,7 @@ async def run_autogen() -> None:
async def run_agent_framework() -> None:
"""Agent Framework's HandoffBuilder for agent coordination."""
from agent_framework import (
AgentRunUpdateEvent,
HandoffBuilder,
HandoffUserInputRequest,
RequestInfoEvent,
WorkflowRunState,
WorkflowStatusEvent,
@@ -139,7 +139,7 @@ async def run_agent_framework() -> None:
name="support_handoff",
participants=[triage_agent, billing_agent, tech_support],
)
.set_coordinator(triage_agent)
.with_start_agent(triage_agent)
.add_handoff(triage_agent, [billing_agent, tech_support])
.with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role == "user") > 3)
.build()
@@ -162,7 +162,7 @@ async def run_agent_framework() -> None:
pending_requests: list[RequestInfoEvent] = []
async for event in workflow.run_stream(scripted_responses[0]):
if isinstance(event, AgentRunUpdateEvent):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
# Print executor name header when switching to a new agent
if current_executor != event.executor_id:
if stream_line_open:
@@ -174,7 +174,7 @@ async def run_agent_framework() -> None:
if event.data:
print(event.data.text, end="", flush=True)
elif isinstance(event, RequestInfoEvent):
if isinstance(event.data, HandoffUserInputRequest):
if isinstance(event.data, HandoffAgentUserRequest):
pending_requests.append(event)
elif isinstance(event, WorkflowStatusEvent):
if event.state in {WorkflowRunState.IDLE_WITH_PENDING_REQUESTS} and stream_line_open:
@@ -194,7 +194,7 @@ async def run_agent_framework() -> None:
stream_line_open = False
async for event in workflow.send_responses_streaming(responses):
if isinstance(event, AgentRunUpdateEvent):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
# Print executor name header when switching to a new agent
if current_executor != event.executor_id:
if stream_line_open:
@@ -206,7 +206,7 @@ async def run_agent_framework() -> None:
if event.data:
print(event.data.text, end="", flush=True)
elif isinstance(event, RequestInfoEvent):
if isinstance(event.data, HandoffUserInputRequest):
if isinstance(event.data, HandoffAgentUserRequest):
pending_requests.append(event)
elif isinstance(event, WorkflowStatusEvent):
if (
@@ -10,7 +10,7 @@ import json
from typing import cast
from agent_framework import (
AgentRunUpdateEvent,
AgentResponseUpdate,
ChatMessage,
MagenticOrchestratorEvent,
MagenticProgressLedger,
@@ -113,7 +113,7 @@ async def run_agent_framework() -> None:
output_event: WorkflowOutputEvent | None = None
print("[Agent Framework] Magentic conversation:")
async for event in workflow.run_stream("Research Python async patterns and write a simple example"):
if isinstance(event, AgentRunUpdateEvent):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
message_id = event.data.message_id
if message_id != last_message_id:
if last_message_id is not None:
@@ -1,26 +1,26 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import cast
from agent_framework import AgentRunEvent, WorkflowBuilder
from agent_framework import AgentResponse, WorkflowBuilder
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
"""
Step 2: Agents in a Workflow non-streaming
This sample uses two custom executors. A Writer agent creates or edits content,
then hands the conversation to a Reviewer agent which evaluates and finalizes the result.
This sample creates two agents: a Writer agent creates or edits content, and a Reviewer agent which
evaluates and provides feedback.
Purpose:
Show how to wrap chat agents created by AzureOpenAIChatClient inside workflow executors. Demonstrate how agents
automatically yield outputs when they complete, removing the need for explicit completion events.
The workflow completes when it becomes idle.
Show how to create agents from AzureOpenAIChatClient and use them directly in a workflow. Demonstrate
how agents can be used in a workflow.
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming or non streaming runs.
- Basic familiarity with WorkflowBuilder, edges, events, and streaming or non-streaming runs.
"""
@@ -51,34 +51,26 @@ async def main():
# Run the workflow with the user's initial message.
# For foundational clarity, use run (non streaming) and print the terminal event.
events = await workflow.run("Create a slogan for a new electric SUV that is affordable and fun to drive.")
# Print agent run events and final outputs
for event in events:
if isinstance(event, AgentRunEvent):
print(f"{event.executor_id}: {event.data}")
print(f"{'=' * 60}\nWorkflow Outputs: {events.get_outputs()}")
outputs = events.get_outputs()
# The outputs of the workflow are whatever the agents produce. So the outputs are expected to be a list
# of `AgentResponse` from the agents in the workflow.
outputs = cast(list[AgentResponse], outputs)
for output in outputs:
# TODO: author_name should be available in AgentResponse
print(f"{output.messages[0].author_name}: {output.text}\n")
# Summarize the final run state (e.g., COMPLETED)
print("Final state:", events.get_final_state())
"""
Sample Output:
writer: "Charge Ahead: Affordable Adventure Awaits!"
writer: "Charge Up Your Adventure—Affordable Fun, Electrified!"
reviewer: Slogan: "Plug Into Fun—Affordable Adventure, Electrified."
reviewer: - Consider emphasizing both affordability and fun in a more dynamic way.
- Try using a catchy phrase that includes a play on words, like Electrify Your Drive: Fun Meets Affordability!
- Ensure the slogan is succinct while capturing the essence of the car's unique selling proposition.
**Feedback:**
- Clear focus on affordability and enjoyment.
- "Plug into fun" connects emotionally and highlights electric nature.
- Consider specifying "SUV" for clarity in some uses.
- Strong, upbeat tone suitable for marketing.
============================================================
Workflow Outputs: ['Slogan: "Plug Into Fun—Affordable Adventure, Electrified."
**Feedback:**
- Clear focus on affordability and enjoyment.
- "Plug into fun" connects emotionally and highlights electric nature.
- Consider specifying "SUV" for clarity in some uses.
- Strong, upbeat tone suitable for marketing.']
Final state: WorkflowRunState.IDLE
"""
@@ -2,36 +2,20 @@
import asyncio
from agent_framework import (
ChatAgent,
ChatMessage,
Executor,
ExecutorFailedEvent,
WorkflowBuilder,
WorkflowContext,
WorkflowFailedEvent,
WorkflowRunState,
WorkflowStatusEvent,
handler,
)
from agent_framework import AgentResponseUpdate, ChatMessage, WorkflowBuilder
from agent_framework._workflows._events import WorkflowOutputEvent
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
from typing_extensions import Never
"""
Step 3: Agents in a workflow with streaming
A Writer agent generates content,
then passes the conversation to a Reviewer agent that finalizes the result.
The workflow is invoked with run_stream so you can observe events as they occur.
This sample creates two agents: a Writer agent creates or edits content, and a Reviewer agent which
evaluates and provides feedback.
Purpose:
Show how to wrap chat agents created by AzureOpenAIChatClient inside workflow executors, wire them with WorkflowBuilder,
and consume streaming events from the workflow. Demonstrate the @handler pattern with typed inputs and typed
WorkflowContext[T_Out, T_W_Out] outputs. Agents automatically yield outputs when they complete.
The streaming loop also surfaces WorkflowEvent.origin so you can distinguish runner-generated lifecycle events
from executor-generated data-plane events.
Show how to create agents from AzureOpenAIChatClient and use them directly in a workflow. Demonstrate
how agents can be used in a workflow.
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
@@ -40,125 +24,59 @@ Prerequisites:
"""
class Writer(Executor):
"""Custom executor that owns a domain specific agent for content generation.
This class demonstrates:
- Attaching a ChatAgent to an Executor so it participates as a node in a workflow.
- Using a @handler method to accept a typed input and forward a typed output via ctx.send_message.
"""
agent: ChatAgent
def __init__(self, chat_client: AzureOpenAIChatClient, id: str = "writer"):
# Create a domain specific agent using your configured AzureOpenAIChatClient.
self.agent = chat_client.as_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
)
# Associate this agent with the executor node. The base Executor stores it on self.agent.
super().__init__(id=id)
@handler
async def handle(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None:
"""Generate content and forward the updated conversation.
Contract for this handler:
- message is the inbound user ChatMessage.
- ctx is a WorkflowContext that expects a list[ChatMessage] to be sent downstream.
Pattern shown here:
1) Seed the conversation with the inbound message.
2) Run the attached agent to produce assistant messages.
3) Forward the cumulative messages to the next executor with ctx.send_message.
"""
# Start the conversation with the incoming user message.
messages: list[ChatMessage] = [message]
# Run the agent and extend the conversation with the agent's messages.
response = await self.agent.run(messages)
messages.extend(response.messages)
# Forward the accumulated messages to the next executor in the workflow.
await ctx.send_message(messages)
class Reviewer(Executor):
"""Custom executor that owns a review agent and completes the workflow."""
agent: ChatAgent
def __init__(self, chat_client: AzureOpenAIChatClient, id: str = "reviewer"):
# Create a domain specific agent that evaluates and refines content.
self.agent = chat_client.as_agent(
instructions=(
"You are an excellent content reviewer. You review the content and provide feedback to the writer."
),
)
super().__init__(id=id)
@handler
async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[Never, str]) -> None:
"""Review the full conversation transcript and yield the final output.
This node consumes all messages so far. It uses its agent to produce the final text,
then yields the output. The workflow completes when it becomes idle.
"""
response = await self.agent.run(messages)
await ctx.yield_output(response.text)
async def main():
"""Build the two node workflow and run it with streaming to observe events."""
# Create the Azure chat client. AzureCliCredential uses your current az login.
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
# Instantiate the two agent backed executors.
writer = Writer(chat_client)
reviewer = Reviewer(chat_client)
writer_agent = chat_client.as_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
name="writer",
)
reviewer_agent = chat_client.as_agent(
instructions=(
"You are an excellent content reviewer."
"Provide actionable feedback to the writer about the provided content."
"Provide the feedback in the most concise manner possible."
),
name="reviewer",
)
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
workflow = WorkflowBuilder().set_start_executor(writer).add_edge(writer, reviewer).build()
workflow = WorkflowBuilder().set_start_executor(writer_agent).add_edge(writer_agent, reviewer_agent).build()
# Track the last author to format streaming output.
last_author: str | None = None
# Run the workflow with the user's initial message and stream events as they occur.
# This surfaces executor events, workflow outputs, run-state changes, and errors.
async for event in workflow.run_stream(
ChatMessage("user", ["Create a slogan for a new electric SUV that is affordable and fun to drive."])
):
if isinstance(event, WorkflowStatusEvent):
prefix = f"State ({event.origin.value}): "
if event.state == WorkflowRunState.IN_PROGRESS:
print(prefix + "IN_PROGRESS")
elif event.state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
print(prefix + "IN_PROGRESS_PENDING_REQUESTS (requests in flight)")
elif event.state == WorkflowRunState.IDLE:
print(prefix + "IDLE (no active work)")
elif event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
print(prefix + "IDLE_WITH_PENDING_REQUESTS (prompt user or UI now)")
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print() # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(prefix + str(event.state))
elif isinstance(event, WorkflowOutputEvent):
print(f"Workflow output ({event.origin.value}): {event.data}")
elif isinstance(event, ExecutorFailedEvent):
print(
f"Executor failed ({event.origin.value}): "
f"{event.executor_id} {event.details.error_type}: {event.details.message}"
)
elif isinstance(event, WorkflowFailedEvent):
details = event.details
print(f"Workflow failed ({event.origin.value}): {details.error_type}: {details.message}")
else:
print(f"{event.__class__.__name__} ({event.origin.value}): {event}")
print(update.text, end="", flush=True)
"""
Sample Output:
writer: "Electrify Your Journey: Affordable Fun Awaits!"
reviewer: Feedback:
State (RUNNER): IN_PROGRESS
ExecutorInvokeEvent (RUNNER): ExecutorInvokeEvent(executor_id=writer)
ExecutorCompletedEvent (RUNNER): ExecutorCompletedEvent(executor_id=writer)
ExecutorInvokeEvent (RUNNER): ExecutorInvokeEvent(executor_id=reviewer)
Workflow output (EXECUTOR): Drive the Future. Affordable Adventure, Electrified.
ExecutorCompletedEvent (RUNNER): ExecutorCompletedEvent(executor_id=reviewer)
State (RUNNER): IDLE
1. **Clarity**: Consider simplifying the message. "Affordable Fun" could be more direct.
2. **Emotional Appeal**: Emphasize the thrill of driving more. Try using words that evoke excitement.
3. **Unique Selling Proposition**: Highlight the electric aspect more boldly.
Example revision: "Charge Your Adventure: Affordable SUVs for Fun-Loving Drivers!"
"""
@@ -3,7 +3,7 @@
import asyncio
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
ChatAgent,
Executor,
WorkflowBuilder,
@@ -77,26 +77,28 @@ async def main():
WorkflowBuilder()
.register_executor(lambda: UpperCase(id="upper_case_executor"), name="UpperCase")
.register_executor(lambda: reverse_text, name="ReverseText")
.register_agent(create_agent, name="DecoderAgent", output_response=True)
.register_agent(create_agent, name="DecoderAgent")
.add_chain(["UpperCase", "ReverseText", "DecoderAgent"])
.set_start_executor("UpperCase")
.build()
)
output: AgentResponse | None = None
first_update = True
async for event in workflow.run_stream("hello world"):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponse):
output = event.data
if output:
print(f"Decoded output: {output.text}")
else:
print("No output received.")
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
update = event.data
if first_update:
print(f"{update.author_name}: {update.text}", end="", flush=True)
first_update = False
else:
print(update.text, end="", flush=True)
"""
Sample Output:
HELLO WORLD
decoder: HELLO WORLD
"""
@@ -2,22 +2,14 @@
import asyncio
from agent_framework import AgentRunUpdateEvent, ChatAgent, WorkflowBuilder, WorkflowOutputEvent
from agent_framework import AgentResponseUpdate, WorkflowBuilder, WorkflowOutputEvent
from agent_framework.azure import AzureAIAgentClient
from azure.identity.aio import AzureCliCredential
"""
Sample: Agents in a workflow with streaming
Sample: Azure AI Agents in a Workflow with Streaming
A Writer agent generates content, then a Reviewer agent critiques it.
The workflow uses streaming so you can observe incremental AgentRunUpdateEvent chunks as each agent produces tokens.
Purpose:
Show how to wire chat agents into a WorkflowBuilder pipeline by adding agents directly as edges.
Demonstrate:
- Automatic streaming of agent deltas via AgentRunUpdateEvent when using run_stream().
- Agents adapt to workflow mode: run_stream() emits incremental updates, run() emits complete responses.
This sample shows how to create Azure AI Agents and use them in a workflow with streaming.
Prerequisites:
- Azure AI Agent Service configured, along with the required environment variables.
@@ -26,54 +18,46 @@ Prerequisites:
"""
def create_writer_agent(client: AzureAIAgentClient) -> ChatAgent:
return client.as_agent(
name="Writer",
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
)
def create_reviewer_agent(client: AzureAIAgentClient) -> ChatAgent:
return client.as_agent(
name="Reviewer",
instructions=(
"You are an excellent content reviewer. "
"Provide actionable feedback to the writer about the provided content. "
"Provide the feedback in the most concise manner possible."
),
)
async def main() -> None:
async with AzureCliCredential() as cred, AzureAIAgentClient(async_credential=cred) as client:
# Build the workflow by adding agents directly as edges.
# Agents adapt to workflow mode: run_stream() for incremental updates, run() for complete responses.
workflow = (
WorkflowBuilder()
.register_agent(lambda: create_writer_agent(client), name="writer")
.register_agent(lambda: create_reviewer_agent(client), name="reviewer", output_response=True)
.set_start_executor("writer")
.add_edge("writer", "reviewer")
.build()
async with AzureCliCredential() as cred, AzureAIAgentClient(credential=cred) as client:
# Create two agents: a Writer and a Reviewer.
writer_agent = client.as_agent(
name="Writer",
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
)
last_executor_id: str | None = None
reviewer_agent = client.as_agent(
name="Reviewer",
instructions=(
"You are an excellent content reviewer. "
"Provide actionable feedback to the writer about the provided content. "
"Provide the feedback in the most concise manner possible."
),
)
# Build the workflow by adding agents directly as edges.
# Agents adapt to workflow mode: run_stream() for incremental updates, run() for complete responses.
workflow = WorkflowBuilder().set_start_executor(writer_agent).add_edge(writer_agent, reviewer_agent).build()
# Track the last author to format streaming output.
last_author: str | None = None
events = workflow.run_stream("Create a slogan for a new electric SUV that is affordable and fun to drive.")
async for event in events:
if isinstance(event, AgentRunUpdateEvent):
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):
print("\n===== Final output =====")
print(event.data)
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print() # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(update.text, end="", flush=True)
if __name__ == "__main__":
@@ -6,8 +6,7 @@ from typing import Final
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
AgentRunUpdateEvent,
AgentResponseUpdate,
ChatMessage,
WorkflowBuilder,
WorkflowContext,
@@ -18,7 +17,7 @@ from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
"""
Sample: Two agents connected by a function executor bridge
Sample: AzureOpenAI Chat Agents and an Executor in a Workflow with Streaming
Pipeline layout:
research_agent -> enrich_with_references (@executor) -> final_editor_agent
@@ -30,7 +29,6 @@ The final agent incorporates the new note and produces the polished output.
Demonstrates:
- Using the @executor decorator to create a function-style Workflow node.
- Consuming an AgentExecutorResponse and forwarding an AgentExecutorRequest for the next agent.
- Streaming AgentRunUpdateEvent events across agent + function + agent chain.
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
@@ -68,7 +66,14 @@ async def enrich_with_references(
draft: AgentExecutorResponse,
ctx: WorkflowContext[AgentExecutorRequest],
) -> None:
"""Inject a follow-up user instruction that adds an external note for the next agent."""
"""Inject a follow-up user instruction that adds an external note for the next agent.
Args:
draft: The response from the research_agent containing the initial draft. This is
a `AgentExecutorResponse` because agents in workflows send their full response
wrapped in this type to connected executors.
ctx: The workflow context to send the next request.
"""
conversation = list(draft.full_conversation or draft.agent_response.messages)
original_prompt = next((message.text for message in conversation if message.role == "user"), "")
external_note = _lookup_external_note(original_prompt) or (
@@ -82,20 +87,22 @@ async def enrich_with_references(
)
conversation.append(ChatMessage("user", [follow_up]))
# Output a new AgentExecutorRequest for the next agent in the workflow.
# Agents in workflows handle this type and will generate a response based on the request.
await ctx.send_message(AgentExecutorRequest(messages=conversation))
def create_research_agent():
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
async def main() -> None:
"""Run the workflow and stream combined updates from both agents."""
# Create the agents
research_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name="research_agent",
instructions=(
"Produce a short, bullet-style briefing with two actionable ideas. Label the section as 'Initial Draft'."
),
)
def create_final_editor_agent():
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
final_editor_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name="final_editor_agent",
instructions=(
"Use all conversation context (including external notes) to produce the final answer. "
@@ -103,17 +110,11 @@ def create_final_editor_agent():
),
)
async def main() -> None:
"""Run the workflow and stream combined updates from both agents."""
workflow = (
WorkflowBuilder()
.register_agent(create_research_agent, name="research_agent")
.register_agent(create_final_editor_agent, name="final_editor_agent")
.register_executor(lambda: enrich_with_references, name="enrich_with_references")
.set_start_executor("research_agent")
.add_edge("research_agent", "enrich_with_references")
.add_edge("enrich_with_references", "final_editor_agent")
.set_start_executor(research_agent)
.add_edge(research_agent, enrich_with_references)
.add_edge(enrich_with_references, final_editor_agent)
.build()
)
@@ -121,22 +122,22 @@ async def main() -> None:
"Create quick workspace wellness tips for a remote analyst working across two monitors."
)
last_executor: str | None = None
# Track the last author to format streaming output.
last_author: str | None = None
async for event in events:
if isinstance(event, AgentRunUpdateEvent):
if event.executor_id != last_executor:
if last_executor is not None:
print()
print(f"{event.executor_id}:", end=" ", flush=True)
last_executor = event.executor_id
print(event.data, end="", flush=True)
elif isinstance(event, WorkflowOutputEvent):
print("\n\n===== Final Output =====")
response = event.data
if isinstance(response, AgentResponse):
print(response.text or "(empty response)")
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print("\n") # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(response if response is not None else "No response generated.")
print(update.text, end="", flush=True)
if __name__ == "__main__":
@@ -2,22 +2,14 @@
import asyncio
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowOutputEvent
from agent_framework import AgentResponseUpdate, WorkflowBuilder, WorkflowOutputEvent
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
"""
Sample: Agents in a workflow with streaming
Sample: AzureOpenAI Chat Agents in a Workflow with Streaming
A Writer agent generates content, then a Reviewer agent critiques it.
The workflow uses streaming so you can observe incremental AgentRunUpdateEvent chunks as each agent produces tokens.
Purpose:
Show how to wire chat agents into a WorkflowBuilder pipeline by adding agents directly as edges.
Demonstrate:
- Automatic streaming of agent deltas via AgentRunUpdateEvent when using run_stream().
- Agents adapt to workflow mode: run_stream() emits incremental updates, run() emits complete responses.
This sample shows how to create AzureOpenAI Chat Agents and use them in a workflow with streaming.
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
@@ -26,17 +18,17 @@ Prerequisites:
"""
def create_writer_agent():
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Create the agents
writer_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
name="writer",
)
def create_reviewer_agent():
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
reviewer_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions=(
"You are an excellent content reviewer."
"Provide actionable feedback to the writer about the provided content."
@@ -45,50 +37,28 @@ def create_reviewer_agent():
name="reviewer",
)
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
# Agents adapt to workflow mode: run_stream() for incremental updates, run() for complete responses.
workflow = (
WorkflowBuilder()
.register_agent(create_writer_agent, name="writer")
.register_agent(create_reviewer_agent, name="reviewer", output_response=True)
.set_start_executor("writer")
.add_edge("writer", "reviewer")
.build()
)
workflow = WorkflowBuilder().set_start_executor(writer_agent).add_edge(writer_agent, reviewer_agent).build()
# Stream events from the workflow. We aggregate partial token updates per executor for readable output.
last_executor_id: str | None = None
# Track the last author to format streaming output.
last_author: str | None = None
events = workflow.run_stream("Create a slogan for a new electric SUV that is affordable and fun to drive.")
async for event in events:
if isinstance(event, AgentRunUpdateEvent):
# AgentRunUpdateEvent contains incremental text deltas from the underlying agent.
# Print a prefix when the executor changes, then append updates on the same line.
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):
print("\n===== Final output =====")
print(event.data)
"""
Sample Output:
writer_agent: Charge Up Your Journey. Fun, Affordable, Electric.
reviewer_agent: Clear message, but consider highlighting SUV specific benefits (space, versatility) for stronger
impact. Try more vivid language to evoke excitement. Example: "Big on Space. Big on Fun. Electric for Everyone."
===== Final Output =====
Clear message, but consider highlighting SUV specific benefits (space, versatility) for stronger impact. Try more
vivid language to evoke excitement. Example: "Big on Space. Big on Fun. Electric for Everyone."
"""
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print() # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(update.text, end="", flush=True)
if __name__ == "__main__":
@@ -1,324 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
from dataclasses import dataclass, field
from typing import Annotated
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
AgentRunUpdateEvent,
ChatAgent,
ChatMessage,
Executor,
FunctionCallContent,
FunctionResultContent,
RequestInfoEvent,
WorkflowBuilder,
WorkflowContext,
WorkflowOutputEvent,
handler,
response_handler,
tool,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
from pydantic import Field
from typing_extensions import Never
"""
Sample: Tool-enabled agents with human feedback
Pipeline layout:
writer_agent (uses Azure OpenAI tools) -> Coordinator -> writer_agent
-> Coordinator -> final_editor_agent -> Coordinator -> output
The writer agent calls tools to gather product facts before drafting copy. A custom executor
packages the draft and emits a RequestInfoEvent so a human can comment, then replays the human
guidance back into the conversation before the final editor agent produces the polished output.
Demonstrates:
- Attaching Python function tools to an agent inside a workflow.
- Capturing the writer's output for human review.
- Streaming AgentRunUpdateEvent updates alongside human-in-the-loop pauses.
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
- Authentication via azure-identity. Run `az login` before executing.
"""
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py.
@tool(approval_mode="never_require")
def fetch_product_brief(
product_name: Annotated[str, Field(description="Product name to look up.")],
) -> str:
"""Return a marketing brief for a product."""
briefs = {
"lumenx desk lamp": (
"Product: LumenX Desk Lamp\n"
"- Three-point adjustable arm with 270° rotation.\n"
"- Custom warm-to-neutral LED spectrum (2700K-4000K).\n"
"- USB-C charging pad integrated in the base.\n"
"- Designed for home offices and late-night study sessions."
)
}
return briefs.get(product_name.lower(), f"No stored brief for '{product_name}'.")
@tool(approval_mode="never_require")
def get_brand_voice_profile(
voice_name: Annotated[str, Field(description="Brand or campaign voice to emulate.")],
) -> str:
"""Return guidance for the requested brand voice."""
voices = {
"lumenx launch": (
"Voice guidelines:\n"
"- Friendly and modern with concise sentences.\n"
"- Highlight practical benefits before aesthetics.\n"
"- End with an invitation to imagine the product in daily use."
)
}
return voices.get(voice_name.lower(), f"No stored voice profile for '{voice_name}'.")
@dataclass
class DraftFeedbackRequest:
"""Payload sent for human review."""
prompt: str = ""
draft_text: str = ""
conversation: list[ChatMessage] = field(default_factory=list) # type: ignore[reportUnknownVariableType]
class Coordinator(Executor):
"""Bridge between the writer agent, human feedback, and final editor."""
def __init__(self, id: str, writer_id: str, final_editor_id: str) -> None:
super().__init__(id)
self.writer_id = writer_id
self.final_editor_id = final_editor_id
@handler
async def on_writer_response(
self,
draft: AgentExecutorResponse,
ctx: WorkflowContext[Never, AgentResponse],
) -> None:
"""Handle responses from the other two agents in the workflow."""
if draft.executor_id == self.final_editor_id:
# Final editor response; yield output directly.
await ctx.yield_output(draft.agent_response)
return
# Writer agent response; request human feedback.
# Preserve the full conversation so the final editor
# can see tool traces and the initial prompt.
conversation: list[ChatMessage]
if draft.full_conversation is not None:
conversation = list(draft.full_conversation)
else:
conversation = list(draft.agent_response.messages)
draft_text = draft.agent_response.text.strip()
if not draft_text:
draft_text = "No draft text was produced."
prompt = (
"Review the draft from the writer and provide a short directional note "
"(tone tweaks, must-have detail, target audience, etc.). "
"Keep it under 30 words."
)
await ctx.request_info(
request_data=DraftFeedbackRequest(prompt=prompt, draft_text=draft_text, conversation=conversation),
response_type=str,
)
@response_handler
async def on_human_feedback(
self,
original_request: DraftFeedbackRequest,
feedback: str,
ctx: WorkflowContext[AgentExecutorRequest],
) -> None:
note = feedback.strip()
if note.lower() == "approve":
# Human approved the draft as-is; forward it unchanged.
await ctx.send_message(
AgentExecutorRequest(
messages=original_request.conversation
+ [ChatMessage("user", text="The draft is approved as-is.")],
should_respond=True,
),
target_id=self.final_editor_id,
)
return
# Human provided feedback; prompt the writer to revise.
conversation: list[ChatMessage] = list(original_request.conversation)
instruction = (
"A human reviewer shared the following guidance:\n"
f"{note or 'No specific guidance provided.'}\n\n"
"Rewrite the draft from the previous assistant message into a polished final version. "
"Keep the response under 120 words and reflect any requested tone adjustments."
)
conversation.append(ChatMessage("user", text=instruction))
await ctx.send_message(
AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_id
)
def create_writer_agent() -> ChatAgent:
"""Creates a writer agent with tools."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name="writer_agent",
instructions=(
"You are a marketing writer. Call the available tools before drafting copy so you are precise. "
"Always call both tools once before drafting. Summarize tool outputs as bullet points, then "
"produce a 3-sentence draft."
),
tools=[fetch_product_brief, get_brand_voice_profile],
tool_choice="required",
)
def create_final_editor_agent() -> ChatAgent:
"""Creates a final editor agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name="final_editor_agent",
instructions=(
"You are an editor who polishes marketing copy after human approval. "
"Correct any legal or factual issues. Return the final version even if no changes are made. "
),
)
def display_agent_run_update(event: AgentRunUpdateEvent, last_executor: str | None) -> None:
"""Display an AgentRunUpdateEvent in a readable format."""
printed_tool_calls: set[str] = set()
printed_tool_results: set[str] = set()
executor_id = event.executor_id
update = event.data
# Extract and print any new tool calls or results from the update.
function_calls = [c for c in update.contents if isinstance(c, FunctionCallContent)] # type: ignore[union-attr]
function_results = [c for c in update.contents if isinstance(c, FunctionResultContent)] # type: ignore[union-attr]
if executor_id != last_executor:
if last_executor is not None:
print()
print(f"{executor_id}:", end=" ", flush=True)
last_executor = executor_id
# Print any new tool calls before the text update.
for call in function_calls:
if call.call_id in printed_tool_calls:
continue
printed_tool_calls.add(call.call_id)
args = call.arguments
args_preview = json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else (args or "").strip()
print(
f"\n{executor_id} [tool-call] {call.name}({args_preview})",
flush=True,
)
print(f"{executor_id}:", end=" ", flush=True)
# Print any new tool results before the text update.
for result in function_results:
if result.call_id in printed_tool_results:
continue
printed_tool_results.add(result.call_id)
result_text = result.result
if not isinstance(result_text, str):
result_text = json.dumps(result_text, ensure_ascii=False)
print(
f"\n{executor_id} [tool-result] {result.call_id}: {result_text}",
flush=True,
)
print(f"{executor_id}:", end=" ", flush=True)
# Finally, print the text update.
print(update, end="", flush=True)
async def main() -> None:
"""Run the workflow and bridge human feedback between two agents."""
# Build the workflow.
workflow = (
WorkflowBuilder()
.register_agent(create_writer_agent, name="writer_agent")
.register_agent(create_final_editor_agent, name="final_editor_agent")
.register_executor(
lambda: Coordinator(
id="coordinator",
writer_id="writer_agent",
final_editor_id="final_editor_agent",
),
name="coordinator",
)
.set_start_executor("writer_agent")
.add_edge("writer_agent", "coordinator")
.add_edge("coordinator", "writer_agent")
.add_edge("final_editor_agent", "coordinator")
.add_edge("coordinator", "final_editor_agent")
.build()
)
# Switch to turn on agent run update display.
# By default this is off to reduce clutter during human input.
display_agent_run_update_switch = False
print(
"Interactive mode. When prompted, provide a short feedback note for the editor.",
flush=True,
)
pending_responses: dict[str, str] | None = None
completed = False
initial_run = True
while not completed:
last_executor: str | None = None
if initial_run:
stream = workflow.run_stream(
"Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting."
)
initial_run = False
elif pending_responses is not None:
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = None
else:
break
requests: list[tuple[str, DraftFeedbackRequest]] = []
async for event in stream:
if isinstance(event, AgentRunUpdateEvent) and display_agent_run_update_switch:
display_agent_run_update(event, last_executor)
if isinstance(event, RequestInfoEvent) and isinstance(event.data, DraftFeedbackRequest):
# Stash the request so we can prompt the human after the stream completes.
requests.append((event.request_id, event.data))
last_executor = None
elif isinstance(event, WorkflowOutputEvent):
last_executor = None
response = event.data
print("\n===== Final output =====")
final_text = getattr(response, "text", str(response))
print(final_text.strip())
completed = True
if requests and not completed:
responses: dict[str, str] = {}
for request_id, request in requests:
print("\n----- Writer draft -----")
print(request.draft_text.strip())
print("\nProvide guidance for the editor (or 'approve' to accept the draft).")
answer = input("Human feedback: ").strip() # noqa: ASYNC250
if answer.lower() == "exit":
print("Exiting...")
return
responses[request_id] = answer
pending_responses = responses
print("Workflow complete.")
if __name__ == "__main__":
asyncio.run(main())
@@ -20,10 +20,22 @@ Demonstrates:
Prerequisites:
- Azure OpenAI access configured for AzureOpenAIChatClient (use az login + env vars)
- Familiarity with Workflow events (AgentRunEvent, WorkflowOutputEvent)
- Familiarity with Workflow events (WorkflowOutputEvent)
"""
def clear_and_redraw(buffers: dict[str, str], agent_order: list[str]) -> None:
"""Clear terminal and redraw all agent outputs grouped together."""
# ANSI escape: clear screen and move cursor to top-left
print("\033[2J\033[H", end="")
print("===== Concurrent Agent Streaming (Live) =====\n")
for name in agent_order:
print(f"--- {name} ---")
print(buffers.get(name, ""))
print()
print("", end="", flush=True)
async def main() -> None:
# 1) Create three domain agents using AzureOpenAIChatClient
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
@@ -58,68 +70,13 @@ async def main() -> None:
# 3) Expose the concurrent workflow as an agent for easy reuse
agent = workflow.as_agent(name="ConcurrentWorkflowAgent")
prompt = "We are launching a new budget-friendly electric bike for urban commuters."
agent_response = await agent.run(prompt)
if agent_response.messages:
print("\n===== Aggregated Messages =====")
for i, msg in enumerate(agent_response.messages, start=1):
role = getattr(msg.role, "value", msg.role)
name = msg.author_name if msg.author_name else role
print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}")
"""
Sample Output:
===== Aggregated Messages =====
------------------------------------------------------------
01 [user]:
We are launching a new budget-friendly electric bike for urban commuters.
------------------------------------------------------------
02 [researcher]:
**Insights:**
- **Target Demographic:** Urban commuters seeking affordable, eco-friendly transport;
likely to include students, young professionals, and price-sensitive urban residents.
- **Market Trends:** E-bike sales are growing globally, with increasing urbanization,
higher fuel costs, and sustainability concerns driving adoption.
- **Competitive Landscape:** Key competitors include brands like Rad Power Bikes, Aventon,
Lectric, and domestic budget-focused manufacturers in North America, Europe, and Asia.
- **Feature Expectations:** Customers expect reliability, ease-of-use, theft protection,
lightweight design, sufficient battery range for daily city commutes (typically 25-40 miles),
and low-maintenance components.
**Opportunities:**
- **First-time Buyers:** Capture newcomers to e-biking by emphasizing affordability, ease of
operation, and cost savings vs. public transit/car ownership.
...
------------------------------------------------------------
03 [marketer]:
**Value Proposition:**
"Empowering your city commute: Our new electric bike combines affordability, reliability, and
sustainable designhelping you conquer urban journeys without breaking the bank."
**Target Messaging:**
*For Young Professionals:*
...
------------------------------------------------------------
04 [legal]:
**Constraints, Disclaimers, & Policy Concerns for Launching a Budget-Friendly Electric Bike for Urban Commuters:**
**1. Regulatory Compliance**
- Verify that the electric bike meets all applicable federal, state, and local regulations
regarding e-bike classification, speed limits, power output, and safety features.
- Ensure necessary certifications (e.g., UL certification for batteries, CE markings if sold internationally) are obtained.
**2. Product Safety**
- Include consumer safety warnings regarding use, battery handling, charging protocols, and age restrictions.
...
""" # noqa: E501
print("===== Final Aggregated Response =====\n")
for message in agent_response.messages:
# The agent_response contains messages from all participants concatenated
# into a single message.
print(f"{message.author_name}: {message.text}\n")
if __name__ == "__main__":
@@ -14,15 +14,17 @@ from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
"""
Step 2: Agents in a Workflow non-streaming
Sample: Custom Agent Executors in a Workflow
This sample uses two custom executors. A Writer agent creates or edits content,
then hands the conversation to a Reviewer agent which evaluates and finalizes the result.
Purpose:
Show how to wrap chat agents created by AzureOpenAIChatClient inside workflow executors. Demonstrate the @handler pattern
with typed inputs and typed WorkflowContext[T] outputs, connect executors with the fluent WorkflowBuilder, and finish
by yielding outputs from the terminal node.
Show how to wrap chat agents created by AzureOpenAIChatClient inside workflow executors. Demonstrate the @handler
pattern with typed inputs and typed WorkflowContext[T] outputs, connect executors with the fluent WorkflowBuilder,
and finish by yielding outputs from the terminal node.
Note: When an agent is passed to a workflow, the workflow essenatially wrap the agent in a more sophisticated executor.
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
@@ -105,17 +107,13 @@ class Reviewer(Executor):
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Create the executors
writer = Writer()
reviewer = Reviewer()
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
workflow = (
WorkflowBuilder()
.register_executor(Writer, name="writer")
.register_executor(Reviewer, name="reviewer")
.set_start_executor("writer")
.add_edge("writer", "reviewer")
.build()
)
workflow = WorkflowBuilder().set_start_executor(writer).add_edge(writer, reviewer).build()
# Run the workflow with the user's initial message.
# For foundational clarity, use run (non streaming) and print the workflow output.
@@ -41,6 +41,9 @@ async def main() -> None:
)
)
.participants([researcher, writer])
# Enable intermediate outputs to observe the conversation as it unfolds
# Intermediate outputs will be emitted as WorkflowOutputEvent events
.with_intermediate_outputs()
.build()
)
@@ -54,6 +57,8 @@ async def main() -> None:
agent_result = await workflow_agent.run(task)
if agent_result.messages:
# The output should contain a message from the researcher, a message from the writer,
# and a final synthesized answer from the orchestrator.
print("\n===== as_agent() Transcript =====")
for i, msg in enumerate(agent_result.messages, start=1):
role_value = getattr(msg.role, "value", msg.role)
@@ -7,8 +7,7 @@ from agent_framework import (
AgentResponse,
ChatAgent,
ChatMessage,
FunctionCallContent,
FunctionResultContent,
Content,
HandoffAgentUserRequest,
HandoffBuilder,
WorkflowAgent,
@@ -37,7 +36,10 @@ Key Concepts:
"""
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py.
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# See:
# samples/getting_started/tools/function_tool_with_approval.py
# samples/getting_started/tools/function_tool_with_approval_and_threads.py.
@tool(approval_mode="never_require")
def process_refund(order_number: Annotated[str, "Order number to process refund for"]) -> str:
"""Simulated function to process a refund for a given order number."""
@@ -119,7 +121,7 @@ def handle_response_and_requests(response: AgentResponse) -> dict[str, HandoffAg
if message.text:
print(f"- {message.author_name or message.role}: {message.text}")
for content in message.contents:
if isinstance(content, FunctionCallContent):
if content.type == "function_call":
if isinstance(content.arguments, dict):
request = WorkflowAgent.RequestInfoFunctionArgs.from_dict(content.arguments)
elif isinstance(content.arguments, str):
@@ -128,6 +130,7 @@ def handle_response_and_requests(response: AgentResponse) -> dict[str, HandoffAg
raise ValueError("Invalid arguments type. Expecting a request info structure for this sample.")
if isinstance(request.data, HandoffAgentUserRequest):
pending_requests[request.request_id] = request.data
return pending_requests
@@ -196,11 +199,6 @@ async def main() -> None:
# 1. The termination condition is met, OR
# 2. We run out of scripted responses
while pending_requests:
for request in pending_requests.values():
for message in request.agent_response.messages:
if message.text:
print(f"- {message.author_name or message.role}: {message.text}")
if not scripted_responses:
# No more scripted responses; terminate the workflow
responses = {req_id: HandoffAgentUserRequest.terminate() for req_id in pending_requests}
@@ -214,7 +212,7 @@ async def main() -> None:
responses = {req_id: HandoffAgentUserRequest.create_response(user_response) for req_id in pending_requests}
function_results = [
FunctionResultContent(call_id=req_id, result=response) for req_id, response in responses.items()
Content.from_function_result(call_id=req_id, result=response) for req_id, response in responses.items()
]
response = await agent.run(ChatMessage("tool", function_results))
pending_requests = handle_response_and_requests(response)
@@ -61,6 +61,9 @@ async def main() -> None:
max_stall_count=3,
max_reset_count=2,
)
# Enable intermediate outputs to observe the conversation as it unfolds
# Intermediate outputs will be emitted as WorkflowOutputEvent events
.with_intermediate_outputs()
.build()
)
@@ -80,9 +83,17 @@ async def main() -> None:
# Wrap the workflow as an agent for composition scenarios
print("\nWrapping workflow as an agent and running...")
workflow_agent = workflow.as_agent(name="MagenticWorkflowAgent")
async for response in workflow_agent.run_stream(task):
last_response_id: str | None = None
async for update in workflow_agent.run_stream(task):
# Fallback for any other events with text
print(response.text, end="", flush=True)
if last_response_id != update.response_id:
if last_response_id is not None:
print() # Newline between different responses
print(f"{update.author_name}: ", end="", flush=True)
last_response_id = update.response_id
else:
print(update.text, end="", flush=True)
except Exception as e:
print(f"Workflow execution failed: {e}")
@@ -1,122 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Never
from agent_framework import (
AgentExecutorResponse,
ChatAgent,
Executor,
HostedCodeInterpreterTool,
WorkflowBuilder,
WorkflowContext,
handler,
)
from agent_framework.azure import AzureAIAgentClient
from azure.identity.aio import AzureCliCredential
"""
This sample demonstrates how to create a workflow that combines an AI agent executor
with a custom executor.
The workflow consists of two stages:
1. An AI agent with code interpreter capabilities that generates and executes Python code
2. An evaluator executor that reviews the agent's output and provides a final assessment
Key concepts demonstrated:
- Creating an AI agent with tool capabilities (HostedCodeInterpreterTool)
- Building workflows using WorkflowBuilder with an agent and a custom executor
- Using the @handler decorator in the executor to process AgentExecutorResponse from the agent
- Connecting workflow executors with edges to create a processing pipeline
- Yielding final outputs from terminal executors
- Non-streaming workflow execution and result collection
Prerequisites:
- Azure AI services configured with required environment variables
- Azure CLI authentication (run 'az login' before executing)
- Basic understanding of async Python and workflow concepts
"""
class Evaluator(Executor):
"""Custom executor that evaluates the output from an AI agent.
This executor demonstrates how to:
- Create a custom workflow executor that processes agent responses
- Use the @handler decorator to define the processing logic
- Access agent execution details including response text and usage metrics
- Yield final results to complete the workflow execution
The evaluator checks if the agent successfully generated the Fibonacci sequence
and provides feedback on correctness along with resource consumption details.
"""
@handler
async def handle(self, message: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
"""Evaluate the agent's response and complete the workflow with a final assessment.
This handler:
1. Receives the AgentExecutorResponse containing the agent's complete interaction
2. Checks if the expected Fibonacci sequence appears in the response text
3. Extracts usage details (token consumption, execution time, etc.)
4. Yields a final evaluation string to complete the workflow
Args:
message: The response from the Azure AI agent containing text and metadata
ctx: Workflow context for yielding the final output string
"""
target_text = "1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89"
correctness = target_text in message.agent_response.text
consumption = message.agent_response.usage_details
await ctx.yield_output(f"Correctness: {correctness}, Consumption: {consumption}")
def create_coding_agent(client: AzureAIAgentClient) -> ChatAgent:
"""Create an AI agent with code interpretation capabilities.
This agent can generate and execute Python code to solve problems.
Args:
client: The AzureAIAgentClient used to create the agent
Returns:
A ChatAgent configured with coding instructions and tools
"""
return client.as_agent(
name="CodingAgent",
instructions=("You are a helpful assistant that can write and execute Python code to solve problems."),
tools=HostedCodeInterpreterTool(),
)
async def main():
async with (
AzureCliCredential() as credential,
AzureAIAgentClient(credential=credential) as chat_client,
):
# Build a workflow: Agent generates code -> Evaluator assesses results
# The agent will be wrapped in a special agent executor which produces AgentExecutorResponse
workflow = (
WorkflowBuilder()
.register_agent(lambda: create_coding_agent(chat_client), name="coding_agent")
.register_executor(lambda: Evaluator(id="evaluator"), name="evaluator")
.set_start_executor("coding_agent")
.add_edge("coding_agent", "evaluator")
.build()
)
# Execute the workflow with a specific coding task
results = await workflow.run(
"Generate the fibonacci numbers to 100 using python code, show the code and execute it."
)
# Extract and display the final evaluation
outputs = results.get_outputs()
if isinstance(outputs, list) and len(outputs) == 1:
print("Workflow results:", outputs[0])
else:
raise ValueError("Unexpected workflow outputs:", outputs)
if __name__ == "__main__":
asyncio.run(main())
@@ -50,9 +50,7 @@ async def main() -> None:
if agent_response.messages:
print("\n===== Conversation =====")
for i, msg in enumerate(agent_response.messages, start=1):
role_value = getattr(msg.role, "value", msg.role)
normalized_role = str(role_value).lower() if role_value is not None else "assistant"
name = msg.author_name or ("assistant" if normalized_role == "assistant".value else "user")
name = msg.author_name or msg.role
print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}")
"""
@@ -17,9 +17,8 @@ if str(_SAMPLES_ROOT) not in sys.path:
from agent_framework import ( # noqa: E402
ChatMessage,
Content,
Executor,
FunctionCallContent,
FunctionResultContent,
WorkflowAgent,
WorkflowBuilder,
WorkflowContext,
@@ -129,10 +128,10 @@ async def main() -> None:
)
# Locate the human review function call in the response messages.
human_review_function_call: FunctionCallContent | None = None
human_review_function_call: Content | None = None
for message in response.messages:
for content in message.contents:
if isinstance(content, FunctionCallContent) and content.name == WorkflowAgent.REQUEST_INFO_FUNCTION_NAME:
if content.name == WorkflowAgent.REQUEST_INFO_FUNCTION_NAME:
human_review_function_call = content
# Handle the human review if required.
@@ -161,8 +160,8 @@ async def main() -> None:
human_response = ReviewResponse(request_id=request_id, feedback="Approved", approved=True)
# Create the function call result object to send back to the agent.
human_review_function_result = FunctionResultContent(
call_id=human_review_function_call.call_id,
human_review_function_result = Content.from_function_result(
call_id=human_review_function_call.call_id, # type: ignore
result=human_response,
)
# Send the human review result back to the agent.
@@ -5,11 +5,9 @@ from dataclasses import dataclass
from uuid import uuid4
from agent_framework import (
AgentResponseUpdate,
AgentRunUpdateEvent,
AgentResponse,
ChatClientProtocol,
ChatMessage,
Content,
Executor,
WorkflowBuilder,
WorkflowContext,
@@ -31,7 +29,6 @@ approved responses are emitted to the external consumer. The workflow completes
Key Concepts Demonstrated:
- WorkflowAgent: Wraps a workflow to behave like a regular agent.
- Cyclic workflow design (Worker Reviewer) for iterative improvement.
- AgentRunUpdateEvent: Mechanism for emitting approved responses externally.
- Structured output parsing for review feedback using Pydantic.
- State management for pending requests and retry logic.
@@ -144,7 +141,9 @@ class Worker(Executor):
self._pending_requests[request.request_id] = (request, messages)
@handler
async def handle_review_response(self, review: ReviewResponse, ctx: WorkflowContext[ReviewRequest]) -> None:
async def handle_review_response(
self, review: ReviewResponse, ctx: WorkflowContext[ReviewRequest, AgentResponse]
) -> None:
print(f"Worker: Received review for request {review.request_id[:8]} - Approved: {review.approved}")
if review.request_id not in self._pending_requests:
@@ -154,14 +153,8 @@ class Worker(Executor):
if review.approved:
print("Worker: Response approved. Emitting to external consumer...")
contents: list[Content] = []
for message in request.agent_messages:
contents.extend(message.contents)
# Emit approved result to external consumer via AgentRunUpdateEvent.
await ctx.add_event(
AgentRunUpdateEvent(self.id, data=AgentResponseUpdate(contents=contents, role="assistant"))
)
# Emit approved result to external consumer
await ctx.yield_output(AgentResponse(messages=request.agent_messages))
return
print(f"Worker: Response not approved. Feedback: {review.feedback}")
@@ -169,9 +162,7 @@ class Worker(Executor):
# Incorporate review feedback.
messages.append(ChatMessage("system", [review.feedback]))
messages.append(
ChatMessage("system", ["Please incorporate the feedback and regenerate the response."])
)
messages.append(ChatMessage("system", ["Please incorporate the feedback and regenerate the response."]))
messages.extend(request.user_messages)
# Retry with updated prompt.
@@ -217,13 +208,13 @@ async def main() -> None:
print("-" * 50)
# Run agent in streaming mode to observe incremental updates.
async for event in agent.run_stream(
response = await agent.run(
"Write code for parallel reading 1 million files on disk and write to a sorted output file."
):
print(f"Agent Response: {event}")
)
print("=" * 50)
print("Workflow completed!")
print("-" * 50)
print("Final Approved Response:")
print(f"{response.agent_id}: {response.text}")
if __name__ == "__main__":
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import cast
from agent_framework import (
AgentResponse,
ChatAgent,
ChatMessage,
Content,
@@ -26,7 +27,7 @@ from azure.identity import AzureCliCredential
Sample: Handoff Workflow with Tool Approvals + Checkpoint Resume
Demonstrates the two-step pattern for resuming a handoff workflow from a checkpoint
while handling both HandoffUserInputRequest prompts and function approval request Content
while handling both HandoffAgentUserRequest prompts and function approval request Content
for tool calls (e.g., submit_refund).
Scenario:
@@ -124,7 +125,7 @@ def _print_handoff_agent_user_request(response: AgentResponse) -> None:
for message in response.messages:
if not message.text:
continue
speaker = message.author_name or message.role.value
speaker = message.author_name or message.role
print(f" {speaker}: {message.text}")
@@ -133,6 +134,7 @@ def _print_handoff_request(request: HandoffAgentUserRequest, request_id: str) ->
print(f"\n{'=' * 60}")
print("WORKFLOW PAUSED - User input needed")
print(f"Request ID: {request_id}")
print(f"Awaiting agent: {request.agent_response.agent_id}")
_print_handoff_agent_user_request(request.agent_response)
@@ -141,11 +143,11 @@ def _print_handoff_request(request: HandoffAgentUserRequest, request_id: str) ->
def _print_function_approval_request(request: Content, request_id: str) -> None:
"""Log pending tool approval details for debugging."""
args = request.function_call.parse_arguments() or {}
args = request.function_call.parse_arguments() or {} # type: ignore
print(f"\n{'=' * 60}")
print("WORKFLOW PAUSED - Tool approval required")
print(f"Request ID: {request_id}")
print(f"Function: {request.function_call.name}")
print(f"Function: {request.function_call.name}") # type: ignore
print(f"Arguments:\n{json.dumps(args, indent=2)}")
print(f"{'=' * 60}\n")
@@ -161,7 +163,7 @@ def _build_responses_for_requests(
for request in pending_requests:
if isinstance(request.data, HandoffAgentUserRequest):
if user_response is None:
raise ValueError("User response is required for HandoffUserInputRequest")
raise ValueError("User response is required for HandoffAgentUserRequest")
responses[request.request_id] = user_response
elif isinstance(request.data, Content) and request.data.type == "function_approval_request":
if approve_tools is None:
@@ -281,9 +283,9 @@ async def resume_with_responses(
elif isinstance(event, WorkflowOutputEvent):
print("\n[Workflow Output Event - Conversation Update]")
if event.data and isinstance(event.data, list) and all(isinstance(msg, ChatMessage) for msg in event.data):
if event.data and isinstance(event.data, list) and all(isinstance(msg, ChatMessage) for msg in event.data): # type: ignore
# Now safe to cast event.data to list[ChatMessage]
conversation = cast(list[ChatMessage], event.data)
conversation = cast(list[ChatMessage], event.data) # type: ignore
for msg in conversation[-3:]: # Show last 3 messages
author = msg.author_name or msg.role
text = msg.text[:100] + "..." if len(msg.text) > 100 else msg.text
@@ -12,7 +12,7 @@ from agent_framework import ( # Core chat primitives used to build requests
WorkflowBuilder, # Fluent builder for wiring executors and edges
WorkflowContext, # Per-run context and event bus
executor, # Decorator to declare a Python function as a workflow executor
)
)
from agent_framework.azure import AzureOpenAIChatClient # Thin client wrapper for Azure OpenAI chat models
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
from pydantic import BaseModel # Structured outputs for safer parsing
@@ -16,7 +16,7 @@ from agent_framework import ( # Core chat primitives used to form LLM requests
WorkflowBuilder, # Fluent builder for assembling the graph
WorkflowContext, # Per-run context and event bus
executor, # Decorator to turn a function into a workflow executor
)
)
from agent_framework.azure import AzureOpenAIChatClient # Thin client for Azure OpenAI chat models
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
from pydantic import BaseModel # Structured outputs with validation
@@ -0,0 +1,222 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import AsyncIterable
from dataclasses import dataclass, field
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
ChatMessage,
Executor,
RequestInfoEvent,
Role,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
WorkflowOutputEvent,
handler,
response_handler,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
from typing_extensions import Never
"""
Sample: AzureOpenAI Chat Agents in workflow with human feedback
Pipeline layout:
writer_agent -> Coordinator -> writer_agent -> Coordinator -> final_editor_agent -> Coordinator -> output
The writer agent drafts marketing copy. A custom executor emits a RequestInfoEvent so a human can comment,
then relays the human guidance back into the conversation before the final editor agent produces the polished
output.
Demonstrates:
- Capturing agent responses in a custom executor.
- Emitting RequestInfoEvent to request human input.
- Handling human feedback and routing it to the appropriate agents.
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
- Authentication via azure-identity. Run `az login` before executing.
"""
@dataclass
class DraftFeedbackRequest:
"""Payload sent for human review."""
prompt: str = ""
conversation: list[ChatMessage] = field(default_factory=lambda: [])
class Coordinator(Executor):
"""Bridge between the writer agent, human feedback, and final editor."""
def __init__(self, id: str, writer_name: str, final_editor_name: str) -> None:
super().__init__(id)
self.writer_name = writer_name
self.final_editor_name = final_editor_name
@handler
async def on_writer_response(
self,
draft: AgentExecutorResponse,
ctx: WorkflowContext[Never, AgentResponse],
) -> None:
"""Handle responses from the writer and final editor agents."""
if draft.executor_id == self.final_editor_name:
# No further processing is needed when the final editor has responded.
return
# Writer agent response; request human feedback.
# Preserve the full conversation so that the final editor has context.
conversation: list[ChatMessage]
if draft.full_conversation is not None:
conversation = list(draft.full_conversation)
else:
conversation = list(draft.agent_response.messages)
prompt = (
"Review the draft from the writer and provide a short directional note "
"(tone tweaks, must-have detail, target audience, etc.). "
"Keep it under 30 words."
)
await ctx.request_info(
request_data=DraftFeedbackRequest(prompt=prompt, conversation=conversation),
response_type=str,
)
@response_handler
async def on_human_feedback(
self,
original_request: DraftFeedbackRequest,
feedback: str,
ctx: WorkflowContext[AgentExecutorRequest],
) -> None:
"""Process human feedback and forward to the appropriate agent."""
note = feedback.strip()
if note.lower() == "approve":
# Human approved the draft as-is; forward it unchanged.
await ctx.send_message(
AgentExecutorRequest(
messages=original_request.conversation
+ [ChatMessage(Role.USER, text="The draft is approved as-is.")],
should_respond=True,
),
target_id=self.final_editor_name,
)
return
# Human provided feedback; prompt the writer to revise.
conversation: list[ChatMessage] = list(original_request.conversation)
instruction = (
"A human reviewer shared the following guidance:\n"
f"{note or 'No specific guidance provided.'}\n\n"
"Rewrite the draft from the previous assistant message into a polished final version. "
"Keep the response under 120 words and reflect any requested tone adjustments."
)
conversation.append(ChatMessage(Role.USER, text=instruction))
await ctx.send_message(
AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_name
)
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, str] | None:
"""Process events from the workflow stream to capture human feedback requests."""
# Track the last author to format streaming output.
last_author: str | None = None
requests: list[tuple[str, DraftFeedbackRequest]] = []
async for event in stream:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, DraftFeedbackRequest):
requests.append((event.request_id, event.data))
elif isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
# This workflow should only produce AgentResponseUpdate as outputs.
# Streaming updates from an agent will be consecutive, because no two agents run simultaneously
# in this workflow. So we can use last_author to format output nicely.
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print() # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(update.text, end="", flush=True)
# Handle any pending human feedback requests.
if requests:
responses: dict[str, str] = {}
for request_id, _ in requests:
print("\nProvide guidance for the editor (or 'approve' to accept the draft).")
answer = input("Human feedback: ").strip() # noqa: ASYNC250
if answer.lower() == "exit":
print("Exiting...")
return None
responses[request_id] = answer
return responses
return None
async def main() -> None:
"""Run the workflow and bridge human feedback between two agents."""
# Create the agents
writer_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name="writer_agent",
instructions=("You are a marketing writer."),
tool_choice="required",
)
final_editor_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name="final_editor_agent",
instructions=(
"You are an editor who polishes marketing copy after human approval. "
"Correct any legal or factual issues. Return the final version even if no changes are made. "
),
)
# Create the executor
coordinator = Coordinator(
id="coordinator",
writer_name=writer_agent.name, # type: ignore
final_editor_name=final_editor_agent.name, # type: ignore
)
# Build the workflow.
workflow = (
WorkflowBuilder()
.set_start_executor(writer_agent)
.add_edge(writer_agent, coordinator)
.add_edge(coordinator, writer_agent)
.add_edge(final_editor_agent, coordinator)
.add_edge(coordinator, final_editor_agent)
.build()
)
print(
"Interactive mode. When prompted, provide a short feedback note for the editor.",
flush=True,
)
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
stream = workflow.run_stream(
"Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting."
)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = await process_event_stream(stream)
print("\nWorkflow complete.")
if __name__ == "__main__":
asyncio.run(main())
@@ -7,8 +7,6 @@ from typing import Annotated, Never
from agent_framework import (
AgentExecutorResponse,
ChatAgent,
ChatMessage,
Content,
Executor,
WorkflowBuilder,
@@ -52,7 +50,10 @@ Prerequisites:
"""
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py.
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# See:
# samples/getting_started/tools/function_tool_with_approval.py
# samples/getting_started/tools/function_tool_with_approval_and_threads.py.
@tool(approval_mode="never_require")
def get_current_date() -> str:
"""Get the current date in YYYY-MM-DD format."""
@@ -211,10 +212,10 @@ async def conclude_workflow(
await ctx.yield_output(email_response.agent_response.text)
def create_email_writer_agent() -> ChatAgent:
"""Create the Email Writer agent with tools that require approval."""
return OpenAIChatClient().as_agent(
name="Email Writer",
async def main() -> None:
# Create agent
email_writer_agent = OpenAIChatClient().as_agent(
name="EmailWriter",
instructions=("You are an excellent email assistant. You respond to incoming emails."),
# tools with `approval_mode="always_require"` will trigger approval requests
tools=[
@@ -226,20 +227,16 @@ def create_email_writer_agent() -> ChatAgent:
],
)
# Create executor
email_processor = EmailPreprocessor(special_email_addresses={"mike@contoso.com"})
async def main() -> None:
# Build the workflow
workflow = (
WorkflowBuilder()
.register_agent(create_email_writer_agent, name="email_writer")
.register_executor(
lambda: EmailPreprocessor(special_email_addresses={"mike@contoso.com"}),
name="email_preprocessor",
)
.register_executor(lambda: conclude_workflow, name="conclude_workflow")
.set_start_executor("email_preprocessor")
.add_edge("email_preprocessor", "email_writer")
.add_edge("email_writer", "conclude_workflow")
.set_start_executor(email_processor)
.add_edge(email_processor, email_writer_agent)
.add_edge(email_writer_agent, conclude_workflow)
.with_output_from([conclude_workflow])
.build()
)
@@ -250,46 +247,40 @@ async def main() -> None:
body="Please provide your team's status update on the project since last week.",
)
responses: dict[str, Content] = {}
output: list[ChatMessage] | None = None
while True:
if responses:
events = await workflow.send_responses(responses)
responses.clear()
else:
events = await workflow.run(incoming_email)
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
events = await workflow.run(incoming_email)
request_info_events = events.get_request_info_events()
request_info_events = events.get_request_info_events()
# Run until there are no more approval requests
while request_info_events:
responses: dict[str, Content] = {}
for request_info_event in request_info_events:
# We should only expect function_approval_request Content in this sample
if not isinstance(request_info_event.data, Content) or request_info_event.data.type != "function_approval_request":
raise ValueError(f"Unexpected request info content type: {type(request_info_event.data)}")
# We should only expect FunctionApprovalRequestContent in this sample
data = request_info_event.data
if not isinstance(data, Content) or data.type != "function_approval_request":
raise ValueError(f"Unexpected request info content type: {type(data)}")
# To make the type checker happy, we make sure function_call is not None
if data.function_call is None:
raise ValueError("Function call information is missing in the approval request.")
# Pretty print the function call details
arguments = json.dumps(request_info_event.data.function_call.parse_arguments(), indent=2)
print(
f"Received approval request for function: {request_info_event.data.function_call.name} "
f"with args:\n{arguments}"
)
arguments = json.dumps(data.function_call.parse_arguments(), indent=2)
print(f"Received approval request for function: {data.function_call.name} with args:\n{arguments}")
# For demo purposes, we automatically approve the request
# The expected response type of the request is `function_approval_response Content`,
# which can be created via `to_function_approval_response` method on the request content
print("Performing automatic approval for demo purposes...")
responses[request_info_event.request_id] = request_info_event.data.to_function_approval_response(approved=True)
responses[request_info_event.request_id] = data.to_function_approval_response(approved=True)
# Once we get an output event, we can conclude the workflow
# Outputs can only be produced by the conclude_workflow_executor in this sample
if outputs := events.get_outputs():
# We expect only one output from the conclude_workflow_executor
output = outputs[0]
break
if not output:
raise RuntimeError("Workflow did not produce any output event.")
events = await workflow.send_responses(responses)
request_info_events = events.get_request_info_events()
# The output should only come from conclude_workflow executor and it's a single string
print("Final email response conversation:")
print(output)
print(events.get_outputs()[0])
"""
Sample Output:
@@ -22,6 +22,7 @@ Prerequisites:
"""
import asyncio
from collections.abc import AsyncIterable
from typing import Any
from agent_framework import (
@@ -29,9 +30,8 @@ from agent_framework import (
ChatMessage,
ConcurrentBuilder,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
)
from agent_framework._workflows._agent_executor import AgentExecutorResponse
from agent_framework.azure import AzureOpenAIChatClient
@@ -93,6 +93,57 @@ async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any:
return response.messages[-1].text if response.messages else ""
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, AgentRequestInfoResponse] | None:
"""Process events from the workflow stream to capture human feedback requests."""
requests: dict[str, AgentExecutorResponse] = {}
async for event in stream:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentExecutorResponse):
# Display agent output for review and potential modification
requests[event.request_id] = event.data
if isinstance(event, WorkflowOutputEvent):
# The output of the workflow comes from the aggregator and it's a single string
print("\n" + "=" * 60)
print("ANALYSIS COMPLETE")
print("=" * 60)
print("Final synthesized analysis:")
print(event.data)
# Process any requests for human feedback
responses: dict[str, AgentRequestInfoResponse] = {}
if requests:
for request_id, request in requests.items():
print("\n" + "-" * 40)
print("INPUT REQUESTED")
print(
f"Agent {request.executor_id} just responded with: '{request.agent_response.text}'. "
"Please provide your feedback."
)
print("-" * 40)
if request.full_conversation:
print("Conversation context:")
recent = (
request.full_conversation[-2:] if len(request.full_conversation) > 2 else request.full_conversation
)
for msg in recent:
name = msg.author_name or msg.role
text = (msg.text or "")[:150]
print(f" [{name}]: {text}...")
print("-" * 40)
# Get human input to steer this agent's contribution
user_input = input("Your guidance for the analysts (or 'skip' to approve): ") # noqa: ASYNC250
if user_input.lower() == "skip":
user_input = AgentRequestInfoResponse.approve()
else:
user_input = AgentRequestInfoResponse.from_strings([user_input])
responses[request_id] = user_input
return responses if responses else None
async def main() -> None:
global _chat_client
_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
@@ -135,70 +186,16 @@ async def main() -> None:
.build()
)
# Run the workflow with human-in-the-loop
pending_responses: dict[str, AgentRequestInfoResponse] | None = None
workflow_complete = False
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
stream = workflow.run_stream("Analyze the impact of large language models on software development.")
print("Starting multi-perspective analysis workflow...")
print("=" * 60)
while not workflow_complete:
# Run or continue the workflow
stream = (
workflow.send_responses_streaming(pending_responses)
if pending_responses
else workflow.run_stream("Analyze the impact of large language models on software development.")
)
pending_responses = None
# Process events
async for event in stream:
if isinstance(event, RequestInfoEvent):
if isinstance(event.data, AgentExecutorResponse):
# Display agent output for review and potential modification
print("\n" + "-" * 40)
print("INPUT REQUESTED")
print(
f"Agent {event.source_executor_id} just responded with: '{event.data.agent_response.text}'. "
"Please provide your feedback."
)
print("-" * 40)
if event.data.full_conversation:
print("Conversation context:")
recent = (
event.data.full_conversation[-2:]
if len(event.data.full_conversation) > 2
else event.data.full_conversation
)
for msg in recent:
name = msg.author_name or msg.role
text = (msg.text or "")[:150]
print(f" [{name}]: {text}...")
print("-" * 40)
# Get human input to steer this agent's contribution
user_input = input("Your guidance for the analysts (or 'skip' to approve): ") # noqa: ASYNC250
if user_input.lower() == "skip":
user_input = AgentRequestInfoResponse.approve()
else:
user_input = AgentRequestInfoResponse.from_strings([user_input])
pending_responses = {event.request_id: user_input}
print("(Resuming workflow...)")
elif isinstance(event, WorkflowOutputEvent):
print("\n" + "=" * 60)
print("WORKFLOW COMPLETE")
print("=" * 60)
print("Aggregated output:")
# Custom aggregator returns a string
if event.data:
print(event.data)
workflow_complete = True
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
workflow_complete = True
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = await process_event_stream(stream)
if __name__ == "__main__":
@@ -23,23 +23,76 @@ Prerequisites:
"""
import asyncio
from collections.abc import AsyncIterable
from typing import cast
from agent_framework import (
AgentExecutorResponse,
AgentRequestInfoResponse,
AgentResponse,
AgentRunUpdateEvent,
ChatMessage,
GroupChatBuilder,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, AgentRequestInfoResponse] | None:
"""Process events from the workflow stream to capture human feedback requests."""
requests: dict[str, AgentExecutorResponse] = {}
async for event in stream:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentExecutorResponse):
requests[event.request_id] = event.data
if isinstance(event, WorkflowOutputEvent):
# The output of the workflow comes from the orchestrator and it's a list of messages
print("\n" + "=" * 60)
print("DISCUSSION COMPLETE")
print("=" * 60)
print("Final discussion summary:")
# To make the type checker happy, we cast event.data to the expected type
outputs = cast(list[ChatMessage], event.data)
for msg in outputs:
speaker = msg.author_name or msg.role
print(f"[{speaker}]: {msg.text}")
responses: dict[str, AgentRequestInfoResponse] = {}
if requests:
for request_id, request in requests.items():
# Display pre-agent context for human input
print("\n" + "-" * 40)
print("INPUT REQUESTED")
print(
f"Agent {request.executor_id} just responded with: '{request.agent_response.text}'. "
"Please provide your feedback."
)
print("-" * 40)
if request.full_conversation:
print("Conversation context:")
recent = (
request.full_conversation[-2:] if len(request.full_conversation) > 2 else request.full_conversation
)
for msg in recent:
name = msg.author_name or msg.role
text = (msg.text or "")[:150]
print(f" [{name}]: {text}...")
print("-" * 40)
# Get human input to steer the agent
user_input = input(f"Feedback for {request.executor_id} (or 'skip' to approve): ") # noqa: ASYNC250
if user_input.lower() == "skip":
user_input = AgentRequestInfoResponse.approve()
else:
user_input = AgentRequestInfoResponse.from_strings([user_input])
responses[request_id] = user_input
return responses if responses else None
async def main() -> None:
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
@@ -96,81 +149,19 @@ async def main() -> None:
.build()
)
# Run the workflow with human-in-the-loop
pending_responses: dict[str, AgentRequestInfoResponse] | None = None
workflow_complete = False
current_agent: str | None = None # Track current streaming agent
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
stream = workflow.run_stream(
"Discuss how our team should approach adopting AI tools for productivity. "
"Consider benefits, risks, and implementation strategies."
)
print("Starting group discussion workflow...")
print("=" * 60)
while not workflow_complete:
# Run or continue the workflow
stream = (
workflow.send_responses_streaming(pending_responses)
if pending_responses
else workflow.run_stream(
"Discuss how our team should approach adopting AI tools for productivity. "
"Consider benefits, risks, and implementation strategies."
)
)
pending_responses = None
# Process events
async for event in stream:
if isinstance(event, AgentRunUpdateEvent):
# Show all agent responses as they stream
if event.data and event.data.text:
agent_name = event.data.author_name or "unknown"
# Print agent name header only when agent changes
if agent_name != current_agent:
current_agent = agent_name
print(f"\n[{agent_name}]: ", end="", flush=True)
print(event.data.text, end="", flush=True)
elif isinstance(event, RequestInfoEvent):
current_agent = None # Reset for next agent
if isinstance(event.data, AgentExecutorResponse):
# Display pre-agent context for human input
print("\n" + "-" * 40)
print("INPUT REQUESTED")
print(f"About to call agent: {event.source_executor_id}")
print("-" * 40)
print("Conversation context:")
agent_response: AgentResponse = event.data.agent_response
messages: list[ChatMessage] = agent_response.messages
recent: list[ChatMessage] = messages[-3:] if len(messages) > 3 else messages # type: ignore
for msg in recent:
name = msg.author_name or "unknown"
text = (msg.text or "")[:100]
print(f" [{name}]: {text}...")
print("-" * 40)
# Get human input to steer the agent
user_input = input(f"Feedback for {event.source_executor_id} (or 'skip' to approve): ") # noqa: ASYNC250
if user_input.lower() == "skip":
pending_responses = {event.request_id: AgentRequestInfoResponse.approve()}
else:
pending_responses = {event.request_id: AgentRequestInfoResponse.from_strings([user_input])}
print("(Resuming discussion...)")
elif isinstance(event, WorkflowOutputEvent):
print("\n" + "=" * 60)
print("DISCUSSION COMPLETE")
print("=" * 60)
print("Final conversation:")
if event.data:
messages: list[ChatMessage] = event.data
for msg in messages:
role = msg.role.capitalize()
name = msg.author_name or "unknown"
text = (msg.text or "")[:200]
print(f"[{role}][{name}]: {text}...")
workflow_complete = True
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
workflow_complete = True
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = await process_event_stream(stream)
if __name__ == "__main__":
@@ -1,23 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import AsyncIterable
from dataclasses import dataclass
from agent_framework import (
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
AgentExecutorRequest,
AgentExecutorResponse,
ChatAgent, # Result returned by an AgentExecutor
ChatMessage, # Chat message structure
Executor, # Base class for workflow executors
RequestInfoEvent, # Event emitted when human input is requested
WorkflowBuilder, # Fluent builder for assembling the graph
WorkflowContext, # Per run context and event bus
WorkflowOutputEvent, # Event emitted when workflow yields output
WorkflowRunState, # Enum of workflow run states
WorkflowStatusEvent, # Event emitted on run state changes
AgentResponseUpdate,
ChatMessage,
Executor,
RequestInfoEvent,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
WorkflowOutputEvent,
handler,
response_handler, # Decorator to expose an Executor method as a step
)
response_handler,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
from pydantic import BaseModel
@@ -125,8 +125,6 @@ class TurnManager(Executor):
ctx: WorkflowContext[AgentExecutorRequest, str],
) -> None:
"""Continue the game or finish based on human feedback."""
print(f"Feedback for prompt '{original_request.prompt}' received: {feedback}")
reply = feedback.strip().lower()
if reply == "correct":
@@ -142,9 +140,50 @@ class TurnManager(Executor):
await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True))
def create_guessing_agent() -> ChatAgent:
"""Create the guessing agent with instructions to guess a number between 1 and 10."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, str] | None:
"""Process events from the workflow stream to capture human feedback requests."""
# Track the last author to format streaming output.
last_response_id: str | None = None
requests: list[tuple[str, HumanFeedbackRequest]] = []
async for event in stream:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanFeedbackRequest):
requests.append((event.request_id, event.data))
elif isinstance(event, WorkflowOutputEvent):
if isinstance(event.data, AgentResponseUpdate):
update = event.data
response_id = update.response_id
if response_id != last_response_id:
if last_response_id is not None:
print() # Newline between different responses
print(f"{update.author_name}: {update.text}", end="", flush=True)
last_response_id = response_id
else:
print(update.text, end="", flush=True)
else:
print(f"\n{event.executor_id}: {event.data}")
# Handle any pending human feedback requests.
if requests:
responses: dict[str, str] = {}
for request_id, request in requests:
print(f"\nHITL: {request.prompt}")
# Instructional print already appears above. The input line below is the user entry point.
# If desired, you can add more guidance here, but keep it concise.
answer = input("Enter higher/lower/correct/exit: ").lower() # noqa: ASYNC250
if answer == "exit":
print("Exiting...")
return None
responses[request_id] = answer
return responses
return None
async def main() -> None:
"""Run the human-in-the-loop guessing game workflow."""
# Create agent and executor
guessing_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name="GuessingAgent",
instructions=(
"You guess a number between 1 and 10. "
@@ -155,88 +194,27 @@ def create_guessing_agent() -> ChatAgent:
# response_format enforces that the model produces JSON compatible with GuessOutput.
default_options={"response_format": GuessOutput},
)
async def main() -> None:
"""Run the human-in-the-loop guessing game workflow."""
turn_manager = TurnManager(id="turn_manager")
# Build a simple loop: TurnManager <-> AgentExecutor.
workflow = (
WorkflowBuilder()
.register_agent(create_guessing_agent, name="guessing_agent")
.register_executor(lambda: TurnManager(id="turn_manager"), name="turn_manager")
.set_start_executor("turn_manager")
.add_edge("turn_manager", "guessing_agent") # Ask agent to make/adjust a guess
.add_edge("guessing_agent", "turn_manager") # Agent's response comes back to coordinator
.set_start_executor(turn_manager)
.add_edge(turn_manager, guessing_agent) # Ask agent to make/adjust a guess
.add_edge(guessing_agent, turn_manager) # Agent's response comes back to coordinator
).build()
# Human in the loop run: alternate between invoking the workflow and supplying collected responses.
pending_responses: dict[str, str] | None = None
workflow_output: str | None = None
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
stream = workflow.run_stream("start")
# User guidance printing:
# If you want to instruct users up front, print a short banner before the loop.
# Example:
# print(
# "Interactive mode. When prompted, type one of: higher, lower, correct, or exit. "
# "The agent will keep guessing until you reply correct.",
# flush=True,
# )
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = await process_event_stream(stream)
while workflow_output is None:
# First iteration uses run_stream("start").
# Subsequent iterations use send_responses_streaming with pending_responses from the console.
stream = (
workflow.send_responses_streaming(pending_responses) if pending_responses else workflow.run_stream("start")
)
# Collect events for this turn. Among these you may see WorkflowStatusEvent
# with state IDLE_WITH_PENDING_REQUESTS when the workflow pauses for
# human input, preceded by IN_PROGRESS_PENDING_REQUESTS as requests are
# emitted.
events = [event async for event in stream]
pending_responses = None
# Collect human requests, workflow outputs, and check for completion.
requests: list[tuple[str, str]] = [] # (request_id, prompt)
for event in events:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanFeedbackRequest):
# RequestInfoEvent for our HumanFeedbackRequest.
requests.append((event.request_id, event.data.prompt))
elif isinstance(event, WorkflowOutputEvent):
# Capture workflow output as they're yielded
workflow_output = str(event.data)
# Detect run state transitions for a better developer experience.
pending_status = any(
isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS
for e in events
)
idle_with_requests = any(
isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
for e in events
)
if pending_status:
print("State: IN_PROGRESS_PENDING_REQUESTS (requests outstanding)")
if idle_with_requests:
print("State: IDLE_WITH_PENDING_REQUESTS (awaiting human input)")
# If we have any human requests, prompt the user and prepare responses.
if requests:
responses: dict[str, str] = {}
for req_id, prompt in requests:
# Simple console prompt for the sample.
print(f"HITL> {prompt}")
# Instructional print already appears above. The input line below is the user entry point.
# If desired, you can add more guidance here, but keep it concise.
answer = input("Enter higher/lower/correct/exit: ").lower() # noqa: ASYNC250
if answer == "exit":
print("Exiting...")
return
responses[req_id] = answer
pending_responses = responses
# Show final result from workflow output captured during streaming.
print(f"Workflow output: {workflow_output}")
"""
Sample Output:
@@ -22,6 +22,8 @@ Prerequisites:
"""
import asyncio
from collections.abc import AsyncIterable
from typing import cast
from agent_framework import (
AgentExecutorResponse,
@@ -29,14 +31,65 @@ from agent_framework import (
ChatMessage,
RequestInfoEvent,
SequentialBuilder,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, AgentRequestInfoResponse] | None:
"""Process events from the workflow stream to capture human feedback requests."""
requests: dict[str, AgentExecutorResponse] = {}
async for event in stream:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentExecutorResponse):
requests[event.request_id] = event.data
elif isinstance(event, WorkflowOutputEvent):
# The output of the sequential workflow is a list of ChatMessages
print("\n" + "=" * 60)
print("WORKFLOW COMPLETE")
print("=" * 60)
print("Final output:")
outputs = cast(list[ChatMessage], event.data)
for message in outputs:
print(f"[{message.author_name or message.role}]: {message.text}")
responses: dict[str, AgentRequestInfoResponse] = {}
if requests:
for request_id, request in requests.items():
# Display agent response and conversation context for review
print("\n" + "-" * 40)
print("REQUEST INFO: INPUT REQUESTED")
print(
f"Agent {request.executor_id} just responded with: '{request.agent_response.text}'. "
"Please provide your feedback."
)
print("-" * 40)
if request.full_conversation:
print("Conversation context:")
recent = (
request.full_conversation[-2:] if len(request.full_conversation) > 2 else request.full_conversation
)
for msg in recent:
name = msg.author_name or msg.role
text = (msg.text or "")[:150]
print(f" [{name}]: {text}...")
print("-" * 40)
# Get feedback on the agent's response (approve or request iteration)
user_input = input("Your guidance (or 'skip' to approve): ") # noqa: ASYNC250
if user_input.lower() == "skip":
user_input = AgentRequestInfoResponse.approve()
else:
user_input = AgentRequestInfoResponse.from_strings([user_input])
responses[request_id] = user_input
return responses if responses else None
async def main() -> None:
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
@@ -71,72 +124,16 @@ async def main() -> None:
.build()
)
# Run the workflow with request info handling
pending_responses: dict[str, AgentRequestInfoResponse] | None = None
workflow_complete = False
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
stream = workflow.run_stream("Write a brief introduction to artificial intelligence.")
print("Starting document review workflow...")
print("=" * 60)
while not workflow_complete:
# Run or continue the workflow
stream = (
workflow.send_responses_streaming(pending_responses)
if pending_responses
else workflow.run_stream("Write a brief introduction to artificial intelligence.")
)
pending_responses = None
# Process events
async for event in stream:
if isinstance(event, RequestInfoEvent):
if isinstance(event.data, AgentExecutorResponse):
# Display agent response and conversation context for review
print("\n" + "-" * 40)
print("REQUEST INFO: INPUT REQUESTED")
print(
f"Agent {event.source_executor_id} just responded with: '{event.data.agent_response.text}'. "
"Please provide your feedback."
)
print("-" * 40)
if event.data.full_conversation:
print("Conversation context:")
recent = (
event.data.full_conversation[-2:]
if len(event.data.full_conversation) > 2
else event.data.full_conversation
)
for msg in recent:
name = msg.author_name or msg.role
text = (msg.text or "")[:150]
print(f" [{name}]: {text}...")
print("-" * 40)
# Get feedback on the agent's response (approve or request iteration)
user_input = input("Your guidance (or 'skip' to approve): ") # noqa: ASYNC250
if user_input.lower() == "skip":
user_input = AgentRequestInfoResponse.approve()
else:
user_input = AgentRequestInfoResponse.from_strings([user_input])
pending_responses = {event.request_id: user_input}
print("(Resuming workflow...)")
elif isinstance(event, WorkflowOutputEvent):
print("\n" + "=" * 60)
print("WORKFLOW COMPLETE")
print("=" * 60)
print("Final output:")
if event.data:
messages: list[ChatMessage] = event.data[-3:]
for msg in messages:
role = msg.role if msg.role else "unknown"
print(f"[{role}]: {msg.text}")
workflow_complete = True
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
workflow_complete = True
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = await process_event_stream(stream)
if __name__ == "__main__":
@@ -22,7 +22,7 @@ Demonstrates:
Prerequisites:
- Azure OpenAI access configured for AzureOpenAIChatClient (use az login + env vars)
- Familiarity with Workflow events (AgentRunEvent, WorkflowOutputEvent)
- Familiarity with Workflow events (WorkflowOutputEvent)
"""
@@ -1,9 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import cast
from agent_framework import (
AgentRunUpdateEvent,
AgentResponseUpdate,
ChatAgent,
ChatMessage,
GroupChatBuilder,
@@ -72,6 +73,9 @@ async def main() -> None:
# Set a hard termination condition: stop after 4 assistant messages
# The agent orchestrator will intelligently decide when to end before this limit but just in case
.with_termination_condition(lambda messages: sum(1 for msg in messages if msg.role == "assistant") >= 4)
# Enable intermediate outputs to observe the conversation as it unfolds
# Intermediate outputs will be emitted as WorkflowOutputEvent events
.with_intermediate_outputs()
.build()
)
@@ -81,35 +85,26 @@ async def main() -> None:
print(f"TASK: {task}\n")
print("=" * 80)
# Keep track of the last executor to format output nicely in streaming mode
last_executor_id: str | None = None
output_event: WorkflowOutputEvent | None = None
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
async for event in workflow.run_stream(task):
if isinstance(event, AgentRunUpdateEvent):
eid = event.executor_id
if eid != last_executor_id:
if last_executor_id is not None:
print("\n")
print(f"{eid}:", end=" ", flush=True)
last_executor_id = eid
print(event.data, end="", flush=True)
elif isinstance(event, WorkflowOutputEvent):
output_event = event
# The output of the workflow is the full list of messages exchanged
if output_event:
if not isinstance(output_event.data, list) or not all(
isinstance(msg, ChatMessage)
for msg in output_event.data # type: ignore
):
raise RuntimeError("Unexpected output event data format.")
print("\n" + "=" * 80)
print("\nFINAL OUTPUT (The conversation history)\n")
for msg in output_event.data: # type: ignore
assert isinstance(msg, ChatMessage)
print(f"{msg.author_name or msg.role}: {msg.text}\n")
else:
raise RuntimeError("Workflow did not produce a final output event.")
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, AgentResponseUpdate):
rid = data.response_id
if rid != last_response_id:
if last_response_id is not None:
print("\n")
print(f"{data.author_name}:", end=" ", flush=True)
last_response_id = rid
print(data.text, end="", flush=True)
else:
# The output of the group chat workflow is a collection of chat messages from all participants
outputs = cast(list[ChatMessage], event.data)
print("\n" + "=" * 80)
print("\nFinal Conversation Transcript:\n")
for message in outputs:
print(f"{message.author_name or message.role}: {message.text}\n")
if __name__ == "__main__":
@@ -4,13 +4,7 @@ import asyncio
import logging
from typing import cast
from agent_framework import (
AgentRunUpdateEvent,
ChatAgent,
ChatMessage,
GroupChatBuilder,
WorkflowOutputEvent,
)
from agent_framework import AgentResponseUpdate, ChatAgent, ChatMessage, GroupChatBuilder, WorkflowOutputEvent
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
@@ -213,6 +207,9 @@ Share your perspective authentically. Feel free to:
.with_orchestrator(agent=moderator)
.participants([farmer, developer, teacher, activist, spiritual_leader, artist, immigrant, doctor])
.with_termination_condition(lambda messages: sum(1 for msg in messages if msg.role == "assistant") >= 10)
# Enable intermediate outputs to observe the conversation as it unfolds
# Intermediate outputs will be emitted as WorkflowOutputEvent events
.with_intermediate_outputs()
.build()
)
@@ -235,30 +232,26 @@ Share your perspective authentically. Feel free to:
print("DISCUSSION BEGINS")
print("=" * 80 + "\n")
final_conversation: list[ChatMessage] = []
current_speaker: str | None = None
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
async for event in workflow.run_stream(f"Please begin the discussion on: {topic}"):
if isinstance(event, AgentRunUpdateEvent):
if event.executor_id != current_speaker:
if current_speaker is not None:
print("\n")
print(f"[{event.executor_id}]", flush=True)
current_speaker = event.executor_id
print(event.data, end="", flush=True)
elif isinstance(event, WorkflowOutputEvent):
final_conversation = cast(list[ChatMessage], event.data)
print("\n\n" + "=" * 80)
print("DISCUSSION SUMMARY")
print("=" * 80)
if final_conversation and isinstance(final_conversation, list) and final_conversation:
final_msg = final_conversation[-1]
if hasattr(final_msg, "author_name") and final_msg.author_name == "Moderator":
print(f"\n{final_msg.text}")
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, AgentResponseUpdate):
rid = data.response_id
if rid != last_response_id:
if last_response_id is not None:
print("\n")
print(f"{data.author_name}:", end=" ", flush=True)
last_response_id = rid
print(data.text, end="", flush=True)
else:
# The output of the group chat workflow is a collection of chat messages from all participants
outputs = cast(list[ChatMessage], event.data)
print("\n" + "=" * 80)
print("\nFinal Conversation Transcript:\n")
for message in outputs:
print(f"{message.author_name or message.role}: {message.text}\n")
"""
Sample Output:
@@ -1,9 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import cast
from agent_framework import (
AgentRunUpdateEvent,
AgentResponseUpdate,
ChatAgent,
ChatMessage,
GroupChatBuilder,
@@ -91,6 +92,9 @@ async def main() -> None:
# Note: it's possible that the expert gets it right the first time and the other participants
# have nothing to add, but for demo purposes we want to see at least one full round of interaction.
.with_termination_condition(lambda conversation: len(conversation) >= 6)
# Enable intermediate outputs to observe the conversation as it unfolds
# Intermediate outputs will be emitted as WorkflowOutputEvent events
.with_intermediate_outputs()
.build()
)
@@ -100,35 +104,26 @@ async def main() -> None:
print(f"TASK: {task}\n")
print("=" * 80)
# Keep track of the last executor to format output nicely in streaming mode
last_executor_id: str | None = None
output_event: WorkflowOutputEvent | None = None
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
async for event in workflow.run_stream(task):
if isinstance(event, AgentRunUpdateEvent):
eid = event.executor_id
if eid != last_executor_id:
if last_executor_id is not None:
print("\n")
print(f"{eid}:", end=" ", flush=True)
last_executor_id = eid
print(event.data, end="", flush=True)
elif isinstance(event, WorkflowOutputEvent):
output_event = event
# The output of the workflow is the full list of messages exchanged
if output_event:
if not isinstance(output_event.data, list) or not all(
isinstance(msg, ChatMessage)
for msg in output_event.data # type: ignore
):
raise RuntimeError("Unexpected output event data format.")
print("\n" + "=" * 80)
print("\nFINAL OUTPUT (The conversation history)\n")
for msg in output_event.data: # type: ignore
assert isinstance(msg, ChatMessage)
print(f"{msg.author_name or msg.role}: {msg.text}\n")
else:
raise RuntimeError("Workflow did not produce a final output event.")
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, AgentResponseUpdate):
rid = data.response_id
if rid != last_response_id:
if last_response_id is not None:
print("\n")
print(f"{data.author_name}:", end=" ", flush=True)
last_response_id = rid
print(data.text, end="", flush=True)
else:
# The output of the group chat workflow is a collection of chat messages from all participants
outputs = cast(list[ChatMessage], event.data)
print("\n" + "=" * 80)
print("\nFinal Conversation Transcript:\n")
for message in outputs:
print(f"{message.author_name or message.role}: {message.text}\n")
if __name__ == "__main__":
@@ -6,12 +6,11 @@ from typing import cast
from agent_framework import (
AgentResponseUpdate,
AgentRunUpdateEvent,
ChatAgent,
ChatMessage,
HandoffBuilder,
HandoffSentEvent,
HostedWebSearchTool,
WorkflowEvent,
WorkflowOutputEvent,
resolve_agent_id,
)
@@ -76,31 +75,6 @@ def create_agents(
return coordinator, research_agent, summary_agent
last_response_id: str | None = None
def _display_event(event: WorkflowEvent) -> None:
"""Print the final conversation snapshot from workflow output events."""
if isinstance(event, AgentRunUpdateEvent) and event.data:
update: AgentResponseUpdate = event.data
if not update.text:
return
global last_response_id
if update.response_id != last_response_id:
last_response_id = update.response_id
print(f"\n- {update.author_name}: ", flush=True, end="")
print(event.data, flush=True, end="")
elif isinstance(event, WorkflowOutputEvent):
conversation = cast(list[ChatMessage], event.data)
print("\n=== Final Conversation (Autonomous with Iteration) ===")
for message in conversation:
speaker = message.author_name or message.role
text_preview = message.text[:200] + "..." if len(message.text) > 200 else message.text
print(f"- {speaker}: {text_preview}")
print(f"\nTotal messages: {len(conversation)}")
print("=====================================================")
async def main() -> None:
"""Run an autonomous handoff workflow with specialist iteration enabled."""
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
@@ -130,16 +104,39 @@ async def main() -> None:
)
.with_termination_condition(
# Terminate after coordinator provides 5 assistant responses
lambda conv: sum(1 for msg in conv if msg.author_name == "coordinator" and msg.role == "assistant")
>= 5
lambda conv: sum(1 for msg in conv if msg.author_name == "coordinator" and msg.role == "assistant") >= 5
)
.build()
)
request = "Perform a comprehensive research on Microsoft Agent Framework."
print("Request:", request)
last_response_id: str | None = None
async for event in workflow.run_stream(request):
_display_event(event)
if isinstance(event, HandoffSentEvent):
print(f"\nHandoff Event: from {event.source} to {event.target}\n")
elif isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, AgentResponseUpdate):
if not data.text:
# Skip updates that don't have text content
# These can be tool calls or other non-text events
continue
rid = data.response_id
if rid != last_response_id:
if last_response_id is not None:
print("\n")
print(f"{data.author_name}:", end=" ", flush=True)
last_response_id = rid
print(data.text, end="", flush=True)
else:
# The output of the group chat workflow is a collection of chat messages from all participants
outputs = cast(list[ChatMessage], event.data)
print("\n" + "=" * 80)
print("\nFinal Conversation Transcript:\n")
for message in outputs:
print(f"{message.author_name or message.role}: {message.text}\n")
"""
Expected behavior:
@@ -6,7 +6,6 @@ from typing import Annotated, cast
from agent_framework import (
AgentResponse,
AgentRunEvent,
ChatAgent,
ChatMessage,
HandoffAgentUserRequest,
@@ -47,7 +46,10 @@ Key Concepts:
"""
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py.
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# See:
# samples/getting_started/tools/function_tool_with_approval.py
# samples/getting_started/tools/function_tool_with_approval_and_threads.py.
@tool(approval_mode="never_require")
def process_refund(order_number: Annotated[str, "Order number to process refund for"]) -> str:
"""Simulated function to process a refund for a given order number."""
@@ -125,38 +127,36 @@ def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
requests: list[RequestInfoEvent] = []
for event in events:
# AgentRunEvent: Contains messages generated by agents during their turn
if isinstance(event, AgentRunEvent):
for message in event.data.messages:
if not message.text:
# Skip messages without text (e.g., tool calls)
continue
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text}")
# HandoffSentEvent: Indicates a handoff has been initiated
if isinstance(event, HandoffSentEvent):
# HandoffSentEvent: Indicates a handoff has been initiated
print(f"\n[Handoff from {event.source} to {event.target} initiated.]")
# WorkflowStatusEvent: Indicates workflow state changes
if isinstance(event, WorkflowStatusEvent) and event.state in {
elif isinstance(event, WorkflowStatusEvent) and event.state in {
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
}:
# WorkflowStatusEvent: Indicates workflow state changes
print(f"\n[Workflow Status] {event.state.name}")
# WorkflowOutputEvent: Contains the final conversation when workflow terminates
elif isinstance(event, WorkflowOutputEvent):
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list):
print("\n=== Final Conversation Snapshot ===")
for message in conversation:
# WorkflowOutputEvent: Contains contents generated by the workflow
data = event.data
if isinstance(data, AgentResponse):
for message in data.messages:
if not message.text:
# Skip messages without text (e.g., tool calls)
continue
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text or [content.type for content in message.contents]}")
print("===================================")
# RequestInfoEvent: Workflow is requesting user input
print(f"- {speaker}: {message.text}")
else:
# The output of the handoff workflow is a collection of chat messages from all participants
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list):
print("\n=== Final Conversation Snapshot ===")
for message in conversation:
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text or [content.type for content in message.contents]}")
print("===================================")
elif isinstance(event, RequestInfoEvent):
# RequestInfoEvent: Workflow is requesting user input
if isinstance(event.data, HandoffAgentUserRequest):
_print_handoff_agent_user_request(event.data.agent_response)
requests.append(event)
@@ -237,9 +237,11 @@ async def main() -> None:
# Custom termination: Check if the triage agent has provided a closing message.
# This looks for the last message being from triage_agent and containing "welcome",
# which indicates the conversation has concluded naturally.
lambda conversation: len(conversation) > 0
and conversation[-1].author_name == "triage_agent"
and "welcome" in conversation[-1].text.lower()
lambda conversation: (
len(conversation) > 0
and conversation[-1].author_name == "triage_agent"
and "welcome" in conversation[-1].text.lower()
)
)
)
@@ -5,7 +5,6 @@ from typing import Annotated, cast
from agent_framework import (
AgentResponse,
AgentRunEvent,
ChatAgent,
ChatMessage,
HandoffAgentUserRequest,
@@ -38,7 +37,10 @@ Key Concepts:
"""
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py.
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# See:
# samples/getting_started/tools/function_tool_with_approval.py
# samples/getting_started/tools/function_tool_with_approval_and_threads.py.
@tool(approval_mode="never_require")
def process_refund(order_number: Annotated[str, "Order number to process refund for"]) -> str:
"""Simulated function to process a refund for a given order number."""
@@ -120,38 +122,36 @@ def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
requests: list[RequestInfoEvent] = []
for event in events:
# AgentRunEvent: Contains messages generated by agents during their turn
if isinstance(event, AgentRunEvent):
for message in event.data.messages:
if not message.text:
# Skip messages without text (e.g., tool calls)
continue
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text}")
# HandoffSentEvent: Indicates a handoff has been initiated
if isinstance(event, HandoffSentEvent):
# HandoffSentEvent: Indicates a handoff has been initiated
print(f"\n[Handoff from {event.source} to {event.target} initiated.]")
# WorkflowStatusEvent: Indicates workflow state changes
if isinstance(event, WorkflowStatusEvent) and event.state in {
elif isinstance(event, WorkflowStatusEvent) and event.state in {
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
}:
# WorkflowStatusEvent: Indicates workflow state changes
print(f"\n[Workflow Status] {event.state.name}")
# WorkflowOutputEvent: Contains the final conversation when workflow terminates
elif isinstance(event, WorkflowOutputEvent):
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list):
print("\n=== Final Conversation Snapshot ===")
for message in conversation:
# WorkflowOutputEvent: Contains contents generated by the workflow
data = event.data
if isinstance(data, AgentResponse):
for message in data.messages:
if not message.text:
# Skip messages without text (e.g., tool calls)
continue
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text or [content.type for content in message.contents]}")
print("===================================")
# RequestInfoEvent: Workflow is requesting user input
print(f"- {speaker}: {message.text}")
else:
# The output of the handoff workflow is a collection of chat messages from all participants
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list):
print("\n=== Final Conversation Snapshot ===")
for message in conversation:
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text or [content.type for content in message.contents]}")
print("===================================")
elif isinstance(event, RequestInfoEvent):
# RequestInfoEvent: Workflow is requesting user input
if isinstance(event.data, HandoffAgentUserRequest):
_print_handoff_agent_user_request(event.data.agent_response)
requests.append(event)
@@ -6,7 +6,7 @@ Handoff Workflow with Code Interpreter File Generation Sample
This sample demonstrates retrieving file IDs from code interpreter output
in a handoff workflow context. A triage agent routes to a code specialist
that generates a text file, and we verify the file_id is captured correctly
from the streaming AgentRunUpdateEvent events.
from the streaming WorkflowOutputEvent events.
Verifies GitHub issue #2718: files generated by code interpreter in
HandoffBuilder workflows can be properly retrieved.
@@ -28,17 +28,19 @@ Prerequisites:
import asyncio
from collections.abc import AsyncIterable, AsyncIterator
from contextlib import asynccontextmanager
from typing import cast
from agent_framework import (
AgentRunUpdateEvent,
AgentResponseUpdate,
ChatAgent,
Content,
ChatMessage,
HandoffAgentUserRequest,
HandoffBuilder,
HandoffSentEvent,
HostedCodeInterpreterTool,
HostedFileContent,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
)
@@ -63,24 +65,42 @@ def _handle_events(events: list[WorkflowEvent]) -> tuple[list[RequestInfoEvent],
file_ids: list[str] = []
for event in events:
if isinstance(event, WorkflowStatusEvent):
if event.state in {WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS}:
print(f"[status] {event.state.name}")
if isinstance(event, HandoffSentEvent):
# HandoffSentEvent: Indicates a handoff has been initiated
print(f"\n[Handoff from {event.source} to {event.target} initiated.]")
elif isinstance(event, WorkflowStatusEvent) and event.state in {
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
}:
# WorkflowStatusEvent: Indicates workflow state changes
print(f"\n[Workflow Status] {event.state.name}")
elif isinstance(event, WorkflowOutputEvent):
# WorkflowOutputEvent: Contains contents generated by the workflow
data = event.data
if isinstance(data, AgentResponseUpdate):
# AgentResponseUpdate: Intermediate output from an agent
for content in data.contents:
if content.type == "hosted_file":
file_ids.append(content.file_id) # type: ignore
print(f"[Found HostedFileContent: file_id={content.file_id}]")
elif content.type == "text" and content.annotations:
for annotation in content.annotations:
file_id = annotation["file_id"] # type: ignore
file_ids.append(file_id)
print(f"[Found file annotation: file_id={file_id}]")
else:
# The output of the handoff workflow is a collection of chat messages from all participants
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list):
print("\n=== Final Conversation Snapshot ===")
for message in conversation:
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text or [content.type for content in message.contents]}")
print("===================================")
elif isinstance(event, RequestInfoEvent):
# RequestInfoEvent: Workflow is requesting user input
requests.append(event)
elif isinstance(event, AgentRunUpdateEvent):
for content in event.data.contents:
if isinstance(content, HostedFileContent):
file_ids.append(content.file_id)
print(f"[Found HostedFileContent: file_id={content.file_id}]")
elif content.type == "text" and content.annotations:
for annotation in content.annotations:
if hasattr(annotation, "file_id") and annotation.file_id:
file_ids.append(annotation.file_id)
print(f"[Found file annotation: file_id={annotation.file_id}]")
return requests, file_ids
@@ -108,7 +128,7 @@ async def create_agents_v1(credential: AzureCliCredential) -> AsyncIterator[tupl
tools=[HostedCodeInterpreterTool()],
)
yield triage, code_specialist
yield triage, code_specialist # type: ignore
@asynccontextmanager
@@ -6,7 +6,7 @@ import logging
from typing import cast
from agent_framework import (
AgentRunUpdateEvent,
AgentResponseUpdate,
ChatAgent,
ChatMessage,
GroupChatRequestSentEvent,
@@ -86,6 +86,9 @@ async def main() -> None:
max_stall_count=3,
max_reset_count=2,
)
# Enable intermediate outputs to observe the conversation as it unfolds
# Intermediate outputs will be emitted as WorkflowOutputEvent events
.with_intermediate_outputs()
.build()
)
@@ -102,19 +105,9 @@ async def main() -> None:
print("\nStarting workflow execution...")
# Keep track of the last executor to format output nicely in streaming mode
last_message_id: str | None = None
output_event: WorkflowOutputEvent | None = None
last_response_id: str | None = None
async for event in workflow.run_stream(task):
if isinstance(event, AgentRunUpdateEvent):
message_id = event.data.message_id
if message_id != last_message_id:
if last_message_id is not None:
print("\n")
print(f"- {event.executor_id}:", end=" ", flush=True)
last_message_id = message_id
print(event.data, end="", flush=True)
elif isinstance(event, MagenticOrchestratorEvent):
if isinstance(event, MagenticOrchestratorEvent):
print(f"\n[Magentic Orchestrator Event] Type: {event.event_type.name}")
if isinstance(event.data, ChatMessage):
print(f"Please review the plan:\n{event.data.text}")
@@ -132,18 +125,22 @@ async def main() -> None:
print(f"\n[REQUEST SENT ({event.round_index})] to agent: {event.participant_name}")
elif isinstance(event, WorkflowOutputEvent):
output_event = event
if not output_event:
raise RuntimeError("Workflow did not produce a final output event.")
print("\n\nWorkflow completed!")
print("Final Output:")
# The output of the Magentic workflow is a list of ChatMessages with only one final message
# generated by the orchestrator.
output_messages = cast(list[ChatMessage], output_event.data)
if output_messages:
output = output_messages[-1].text
print(output)
data = event.data
if isinstance(data, AgentResponseUpdate):
response_id = data.response_id
if response_id != last_response_id:
if last_response_id is not None:
print("\n")
print(f"- {event.executor_id}:", end=" ", flush=True)
last_response_id = response_id
print(event.data, end="", flush=True)
else:
# The output of the magentic workflow is a collection of chat messages from all participants
outputs = cast(list[ChatMessage], event.data)
print("\n" + "=" * 80)
print("\nFinal Conversation Transcript:\n")
for message in outputs:
print(f"{message.author_name or message.role}: {message.text}\n")
if __name__ == "__main__":
@@ -2,15 +2,18 @@
import asyncio
import json
from collections.abc import AsyncIterable
from typing import cast
from agent_framework import (
AgentRunUpdateEvent,
AgentResponseUpdate,
ChatAgent,
ChatMessage,
MagenticBuilder,
MagenticPlanReviewRequest,
MagenticPlanReviewResponse,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
)
from agent_framework.openai import OpenAIChatClient
@@ -35,6 +38,62 @@ Prerequisites:
- OpenAI credentials configured for `OpenAIChatClient`.
"""
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, MagenticPlanReviewResponse] | None:
"""Process events from the workflow stream to capture human feedback requests."""
global last_response_id
requests: dict[str, MagenticPlanReviewRequest] = {}
async for event in stream:
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
requests[event.request_id] = cast(MagenticPlanReviewRequest, event.data)
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, AgentResponseUpdate):
rid = data.response_id
if rid != last_response_id:
if last_response_id is not None:
print("\n")
print(f"{data.author_name}:", end=" ", flush=True)
last_response_id = rid
print(data.text, end="", flush=True)
else:
# The output of the workflow comes from the orchestrator and it's a list of messages
print("\n" + "=" * 60)
print("DISCUSSION COMPLETE")
print("=" * 60)
print("Final discussion summary:")
# To make the type checker happy, we cast event.data to the expected type
outputs = cast(list[ChatMessage], event.data)
for msg in outputs:
speaker = msg.author_name or msg.role.value
print(f"[{speaker}]: {msg.text}")
responses: dict[str, MagenticPlanReviewResponse] = {}
if requests:
for request_id, request in requests.items():
print("\n\n[Magentic Plan Review Request]")
if request.current_progress is not None:
print("Current Progress Ledger:")
print(json.dumps(request.current_progress.to_dict(), indent=2))
print()
print(f"Proposed Plan:\n{request.plan.text}\n")
print("Please provide your feedback (press Enter to approve):")
reply = input("> ") # noqa: ASYNC250
if reply.strip() == "":
print("Plan approved.\n")
responses[request_id] = request.approve()
else:
print("Plan revised by human.\n")
responses[request_id] = request.revise(reply)
return responses if responses else None
async def main() -> None:
researcher_agent = ChatAgent(
@@ -69,7 +128,11 @@ async def main() -> None:
max_stall_count=1,
max_reset_count=2,
)
.with_plan_review() # Request human input for plan review
# Request human input for plan review
.with_plan_review()
# Enable intermediate outputs to observe the conversation as it unfolds
# Intermediate outputs will be emitted as WorkflowOutputEvent events
.with_intermediate_outputs()
.build()
)
@@ -79,66 +142,16 @@ async def main() -> None:
print("\nStarting workflow execution...")
print("=" * 60)
pending_request: RequestInfoEvent | None = None
pending_responses: dict[str, object] | None = None
output_event: WorkflowOutputEvent | None = None
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
stream = workflow.run_stream(task)
while not output_event:
if pending_responses is not None:
stream = workflow.send_responses_streaming(pending_responses)
else:
stream = workflow.run_stream(task)
last_message_id: str | None = None
async for event in stream:
if isinstance(event, AgentRunUpdateEvent):
message_id = event.data.message_id
if message_id != last_message_id:
if last_message_id is not None:
print("\n")
print(f"- {event.executor_id}:", end=" ", flush=True)
last_message_id = message_id
print(event.data, end="", flush=True)
elif isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
pending_request = event
elif isinstance(event, WorkflowOutputEvent):
output_event = event
pending_responses = None
# Handle plan review request if any
if pending_request is not None:
event_data = cast(MagenticPlanReviewRequest, pending_request.data)
print("\n\n[Magentic Plan Review Request]")
if event_data.current_progress is not None:
print("Current Progress Ledger:")
print(json.dumps(event_data.current_progress.to_dict(), indent=2))
print()
print(f"Proposed Plan:\n{event_data.plan.text}\n")
print("Please provide your feedback (press Enter to approve):")
reply = await asyncio.get_event_loop().run_in_executor(None, input, "> ")
if reply.strip() == "":
print("Plan approved.\n")
pending_responses = {pending_request.request_id: event_data.approve()}
else:
print("Plan revised by human.\n")
pending_responses = {pending_request.request_id: event_data.revise(reply)}
pending_request = None
print("\n" + "=" * 60)
print("WORKFLOW COMPLETED")
print("=" * 60)
print("Final Output:")
# The output of the Magentic workflow is a list of ChatMessages with only one final message
# generated by the orchestrator.
output_messages = cast(list[ChatMessage], output_event.data)
if output_messages:
output = output_messages[-1].text
print(output)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = await process_event_stream(stream)
if __name__ == "__main__":
@@ -13,7 +13,6 @@ Purpose:
Show how to construct a parallel branch pattern in workflows. Demonstrate:
- Fan out by targeting multiple executors from one dispatcher.
- Fan in by collecting a list of results from the executors.
- Simple tracing using AgentRunEvent to observe execution order and progress.
Prerequisites:
- Familiarity with WorkflowBuilder, executors, edges, events, and streaming runs.
@@ -15,7 +15,7 @@ from agent_framework import ( # Core chat primitives to build LLM requests
WorkflowContext, # Per run context and event bus
WorkflowOutputEvent, # Event emitted when workflow yields output
handler, # Decorator to mark an Executor method as invokable
)
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
from typing_extensions import Never
@@ -30,7 +30,6 @@ Purpose:
Show how to construct a parallel branch pattern in workflows. Demonstrate:
- Fan out by targeting multiple AgentExecutor nodes from one dispatcher.
- Fan in by collecting a list of AgentExecutorResponse objects and reducing them to a single result.
- Simple tracing using AgentRunEvent to observe execution order and progress.
Prerequisites:
- Familiarity with WorkflowBuilder, executors, edges, events, and streaming runs.
@@ -14,7 +14,7 @@ from agent_framework import (
WorkflowOutputEvent, # Event emitted when workflow yields output
WorkflowViz, # Utility to visualize a workflow graph
handler, # Decorator to expose an Executor method as a step
)
)
from typing_extensions import Never
"""
@@ -286,7 +286,8 @@ async def main():
# Step 2: Build the workflow graph using fan out and fan in edges.
workflow = (
workflow_builder.set_start_executor("split_data_executor")
workflow_builder
.set_start_executor("split_data_executor")
.add_fan_out_edges(
"split_data_executor",
["map_executor_0", "map_executor_1", "map_executor_2"],
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import AsyncIterable
from typing import Annotated
from agent_framework import (
@@ -8,6 +9,7 @@ from agent_framework import (
ConcurrentBuilder,
Content,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
tool,
)
@@ -44,7 +46,10 @@ Prerequisites:
# 1. Define market data tools (no approval required)
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py.
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# See:
# samples/getting_started/tools/function_tool_with_approval.py
# samples/getting_started/tools/function_tool_with_approval_and_threads.py.
@tool(approval_mode="never_require")
def get_stock_price(symbol: Annotated[str, "The stock ticker symbol"]) -> str:
"""Get the current stock price for a given symbol."""
@@ -100,6 +105,27 @@ def _print_output(event: WorkflowOutputEvent) -> None:
print(f"- {msg.author_name or msg.role}: {msg.text}")
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, Content] | None:
"""Process events from the workflow stream to capture human feedback requests."""
requests: dict[str, Content] = {}
async for event in stream:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, Content):
# We are only expecting tool approval requests in this sample
requests[event.request_id] = event.data
elif isinstance(event, WorkflowOutputEvent):
_print_output(event)
responses: dict[str, Content] = {}
if requests:
for request_id, request in requests.items():
if request.type == "function_approval_request":
print(f"\nSimulating human approval for: {request.function_call.name}") # type: ignore
# Create approval response
responses[request_id] = request.to_function_approval_response(approved=True)
return responses if responses else None
async def main() -> None:
# 3. Create two agents focused on different stocks but with the same tool sets
chat_client = OpenAIChatClient()
@@ -130,37 +156,19 @@ async def main() -> None:
print("Starting concurrent workflow with tool approval...")
print("-" * 60)
# Phase 1: Run workflow and collect request info events
request_info_events: list[RequestInfoEvent] = []
async for event in workflow.run_stream(
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
stream = workflow.run_stream(
"Manage my portfolio. Use a max of 5000 dollars to adjust my position using "
"your best judgment based on market sentiment. No need to confirm trades with me."
):
if isinstance(event, RequestInfoEvent):
request_info_events.append(event)
if isinstance(event.data, Content) and event.data.type == "function_approval_request":
print(f"\nApproval requested for tool: {event.data.function_call.name}")
print(f" Arguments: {event.data.function_call.arguments}")
elif isinstance(event, WorkflowOutputEvent):
_print_output(event)
)
# 6. Handle approval requests (if any)
if request_info_events:
responses: dict[str, Content] = {}
for request_event in request_info_events:
if isinstance(request_event.data, Content) and request_event.data.type == "function_approval_request":
print(f"\nSimulating human approval for: {request_event.data.function_call.name}")
# Create approval response
responses[request_event.request_id] = request_event.data.to_function_approval_response(approved=True)
if responses:
# Phase 2: Send all approvals and continue workflow
async for event in workflow.send_responses_streaming(responses):
if isinstance(event, WorkflowOutputEvent):
_print_output(event)
else:
print("\nWorkflow completed without requiring approvals.")
print("(The agents may have only checked data without executing trades)")
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = await process_event_stream(stream)
"""
Sample Output:
@@ -1,15 +1,17 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Annotated
from collections.abc import AsyncIterable
from typing import Annotated, cast
from agent_framework import (
AgentRunUpdateEvent,
ChatMessage,
Content,
GroupChatBuilder,
GroupChatRequestSentEvent,
GroupChatState,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
tool,
)
from agent_framework.openai import OpenAIChatClient
@@ -93,6 +95,36 @@ def select_next_speaker(state: GroupChatState) -> str:
return "DevOpsEngineer" # Subsequent speakers
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, Content] | None:
"""Process events from the workflow stream to capture human feedback requests."""
requests: dict[str, Content] = {}
async for event in stream:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, Content):
# We are only expecting tool approval requests in this sample
requests[event.request_id] = event.data
elif isinstance(event, WorkflowOutputEvent):
# The output of the workflow comes from the orchestrator and it's a list of messages
print("\n" + "=" * 60)
print("Workflow summary:")
outputs = cast(list[ChatMessage], event.data)
for msg in outputs:
speaker = msg.author_name or msg.role.value
print(f"[{speaker}]: {msg.text}")
responses: dict[str, Content] = {}
if requests:
for request_id, request in requests.items():
if request.type == "function_approval_request":
print("\n[APPROVAL REQUIRED]")
print(f" Tool: {request.function_call.name}") # type: ignore
print(f" Arguments: {request.function_call.arguments}") # type: ignore
print(f"Simulating human approval for: {request.function_call.name}") # type: ignore
# Create approval response
responses[request_id] = request.to_function_approval_response(approved=True)
return responses if responses else None
async def main() -> None:
# 3. Create specialized agents
chat_client = OpenAIChatClient()
@@ -135,67 +167,16 @@ async def main() -> None:
print(f"Agents: {[qa_engineer.name, devops_engineer.name]}")
print("-" * 60)
# Phase 1: Run workflow and collect all events (stream ends at IDLE or IDLE_WITH_PENDING_REQUESTS)
request_info_events: list[RequestInfoEvent] = []
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
async for event in workflow.run_stream(
"We need to deploy version 2.4.0 to production. Please coordinate the deployment."
):
if isinstance(event, RequestInfoEvent):
request_info_events.append(event)
if isinstance(event.data, Content) and event.data.type == "function_approval_request":
print("\n[APPROVAL REQUIRED] From agent:", event.source_executor_id)
print(f" Tool: {event.data.function_call.name}")
print(f" Arguments: {event.data.function_call.arguments}")
elif isinstance(event, AgentRunUpdateEvent):
if not event.data.text:
continue # Skip empty updates
response_id = event.data.response_id
if response_id != last_response_id:
if last_response_id is not None:
print("\n")
print(f"- {event.executor_id}:", end=" ", flush=True)
last_response_id = response_id
print(event.data, end="", flush=True)
elif isinstance(event, GroupChatRequestSentEvent):
print(f"\n[REQUEST SENT ({event.round_index})] to agent: {event.participant_name}")
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
stream = workflow.run_stream("We need to deploy version 2.4.0 to production. Please coordinate the deployment.")
# 6. Handle approval requests
if request_info_events:
for request_event in request_info_events:
if isinstance(request_event.data, Content) and request_event.data.type == "function_approval_request":
print("\n" + "=" * 60)
print("Human review required for production deployment!")
print("In a real scenario, you would review the deployment details here.")
print("Simulating approval for demo purposes...")
print("=" * 60)
# Create approval response
approval_response = request_event.data.to_function_approval_response(approved=True)
# Phase 2: Send approval and continue workflow
# Keep track of the response to format output nicely in streaming mode
last_response_id: str | None = None
async for event in workflow.send_responses_streaming({request_event.request_id: approval_response}):
if isinstance(event, AgentRunUpdateEvent):
if not event.data.text:
continue # Skip empty updates
response_id = event.data.response_id
if response_id != last_response_id:
if last_response_id is not None:
print("\n")
print(f"- {event.executor_id}:", end=" ", flush=True)
last_response_id = response_id
print(event.data, end="", flush=True)
elif isinstance(event, GroupChatRequestSentEvent):
print(f"\n[REQUEST SENT ({event.round_index})] To agent: {event.participant_name}")
print("\n" + "-" * 60)
print("Deployment workflow completed successfully!")
print("All agents have finished their tasks.")
else:
print("\nWorkflow completed without requiring production deployment approval.")
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = await process_event_stream(stream)
"""
Sample Output:
@@ -1,13 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Annotated
from collections.abc import AsyncIterable
from typing import Annotated, cast
from agent_framework import (
ChatMessage,
Content,
RequestInfoEvent,
SequentialBuilder,
WorkflowEvent,
WorkflowOutputEvent,
tool,
)
@@ -65,6 +67,36 @@ def get_database_schema() -> str:
"""
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, Content] | None:
"""Process events from the workflow stream to capture human feedback requests."""
requests: dict[str, Content] = {}
async for event in stream:
if isinstance(event, RequestInfoEvent) and isinstance(event.data, Content):
# We are only expecting tool approval requests in this sample
requests[event.request_id] = event.data
elif isinstance(event, WorkflowOutputEvent):
# The output of the workflow comes from the orchestrator and it's a list of messages
print("\n" + "=" * 60)
print("Workflow summary:")
outputs = cast(list[ChatMessage], event.data)
for msg in outputs:
speaker = msg.author_name or msg.role
print(f"[{speaker}]: {msg.text}")
responses: dict[str, Content] = {}
if requests:
for request_id, request in requests.items():
if request.type == "function_approval_request":
print("\n[APPROVAL REQUIRED]")
print(f" Tool: {request.function_call.name}") # type: ignore
print(f" Arguments: {request.function_call.arguments}") # type: ignore
print(f"Simulating human approval for: {request.function_call.name}") # type: ignore
# Create approval response
responses[request_id] = request.to_function_approval_response(approved=True)
return responses if responses else None
async def main() -> None:
# 2. Create the agent with tools (approval mode is set per-tool via decorator)
chat_client = OpenAIChatClient()
@@ -85,42 +117,16 @@ async def main() -> None:
print("Starting sequential workflow with tool approval...")
print("-" * 60)
# Phase 1: Run workflow and collect all events (stream ends at IDLE or IDLE_WITH_PENDING_REQUESTS)
request_info_events: list[RequestInfoEvent] = []
async for event in workflow.run_stream(
"Check the schema and then update all orders with status 'pending' to 'processing'"
):
if isinstance(event, RequestInfoEvent):
request_info_events.append(event)
if isinstance(event.data, Content) and event.data.type == "function_approval_request":
print(f"\nApproval requested for tool: {event.data.function_call.name}")
print(f" Arguments: {event.data.function_call.arguments}")
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
stream = workflow.run_stream("Check the schema and then update all orders with status 'pending' to 'processing'")
# 5. Handle approval requests
if request_info_events:
for request_event in request_info_events:
if isinstance(request_event.data, Content) and request_event.data.type == "function_approval_request":
# In a real application, you would prompt the user here
print("\nSimulating human approval (auto-approving for demo)...")
# Create approval response
approval_response = request_event.data.to_function_approval_response(approved=True)
# Phase 2: Send approval and continue workflow
output: list[ChatMessage] | None = None
async for event in workflow.send_responses_streaming({request_event.request_id: approval_response}):
if isinstance(event, WorkflowOutputEvent):
output = event.data
if output:
print("\n" + "-" * 60)
print("Workflow completed. Final conversation:")
for msg in output:
role = msg.role if hasattr(msg.role, "value") else msg.role
text = msg.text[:200] + "..." if len(msg.text) > 200 else msg.text
print(f" [{role}]: {text}")
else:
print("No approval requests were generated (schema check may have been sufficient).")
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = await process_event_stream(stream)
"""
Sample Output: