diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 7c0a2e4ad4..743ae459ee 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -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", diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 28482820a0..6ff1970209 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -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): diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 9849d351d1..d5c65367b5 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -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, diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index afa0ef99e7..11b97a9706 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -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) diff --git a/python/packages/core/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py index dcd6ab5866..b43511cbc2 100644 --- a/python/packages/core/agent_framework/_workflows/_events.py +++ b/python/packages/core/agent_framework/_workflows/_events.py @@ -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 diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 95a3670828..566a090b67 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -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) diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 875fdc36c8..03ea7824dd 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -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__( diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index dd6a379e01..8dec78944e 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -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() diff --git a/python/packages/core/agent_framework/_workflows/_runner_context.py b/python/packages/core/agent_framework/_workflows/_runner_context.py index ce9fff6617..95dc352f26 100644 --- a/python/packages/core/agent_framework/_workflows/_runner_context.py +++ b/python/packages/core/agent_framework/_workflows/_runner_context.py @@ -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 diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index 663e85c9dd..3cc916ff1d 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -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) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index 3fe42fd053..ca1e358546 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -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( diff --git a/python/packages/core/agent_framework/_workflows/_validation.py b/python/packages/core/agent_framework/_workflows/_validation.py index ff8a74028d..6c08f60099 100644 --- a/python/packages/core/agent_framework/_workflows/_validation.py +++ b/python/packages/core/agent_framework/_workflows/_validation.py @@ -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, ) diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index dfd0331282..9c237203fe 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -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]: diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index 14cabc219b..43178bf1d8 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -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, diff --git a/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py b/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py index 2b1f11423b..9101cdf751 100644 --- a/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py +++ b/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py @@ -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") diff --git a/python/packages/core/tests/workflow/test_agent_run_event_typing.py b/python/packages/core/tests/workflow/test_agent_run_event_typing.py index 4ba1328fc1..58ac2cbf27 100644 --- a/python/packages/core/tests/workflow/test_agent_run_event_typing.py +++ b/python/packages/core/tests/workflow/test_agent_run_event_typing.py @@ -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 diff --git a/python/packages/core/tests/workflow/test_full_conversation.py b/python/packages/core/tests/workflow/test_full_conversation.py index 1c84e04494..ca882ef5f8 100644 --- a/python/packages/core/tests/workflow/test_full_conversation.py +++ b/python/packages/core/tests/workflow/test_full_conversation.py @@ -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, diff --git a/python/packages/core/tests/workflow/test_magentic.py b/python/packages/core/tests/workflow/test_magentic.py index 8f116aa1ad..096b72183a 100644 --- a/python/packages/core/tests/workflow/test_magentic.py +++ b/python/packages/core/tests/workflow/test_magentic.py @@ -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) diff --git a/python/packages/core/tests/workflow/test_validation.py b/python/packages/core/tests/workflow/test_validation.py index dee491a10b..3fbb1d6d59 100644 --- a/python/packages/core/tests/workflow/test_validation.py +++ b/python/packages/core/tests/workflow/test_validation.py @@ -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 diff --git a/python/packages/core/tests/workflow/test_workflow.py b/python/packages/core/tests/workflow/test_workflow.py index 1bca73b565..80447c82d7 100644 --- a/python/packages/core/tests/workflow/test_workflow.py +++ b/python/packages/core/tests/workflow/test_workflow.py @@ -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 diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index b12c916d84..9a17d476b7 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -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": diff --git a/python/packages/core/tests/workflow/test_workflow_builder.py b/python/packages/core/tests/workflow/test_workflow_builder.py index 26bee34f6c..2d0861e0a8 100644 --- a/python/packages/core/tests/workflow/test_workflow_builder.py +++ b/python/packages/core/tests/workflow/test_workflow_builder.py @@ -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 diff --git a/python/packages/devui/agent_framework_devui/_mapper.py b/python/packages/devui/agent_framework_devui/_mapper.py index f11a6811ce..7acb247c20 100644 --- a/python/packages/devui/agent_framework_devui/_mapper.py +++ b/python/packages/devui/agent_framework_devui/_mapper.py @@ -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 diff --git a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py index 38df1424db..09e7f2411a 100644 --- a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py +++ b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py @@ -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: diff --git a/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py b/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py index f8c170cbef..d9aea5a8f2 100644 --- a/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py +++ b/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py @@ -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: diff --git a/python/samples/autogen-migration/orchestrations/03_swarm.py b/python/samples/autogen-migration/orchestrations/03_swarm.py index 09d8ac0486..e29c2748c7 100644 --- a/python/samples/autogen-migration/orchestrations/03_swarm.py +++ b/python/samples/autogen-migration/orchestrations/03_swarm.py @@ -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 ( diff --git a/python/samples/autogen-migration/orchestrations/04_magentic_one.py b/python/samples/autogen-migration/orchestrations/04_magentic_one.py index 30ccd0aa01..dbe6f43bc7 100644 --- a/python/samples/autogen-migration/orchestrations/04_magentic_one.py +++ b/python/samples/autogen-migration/orchestrations/04_magentic_one.py @@ -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: diff --git a/python/samples/getting_started/workflows/_start-here/step2_agents_in_a_workflow.py b/python/samples/getting_started/workflows/_start-here/step2_agents_in_a_workflow.py index 4fb3340c5b..6ecfbe55a8 100644 --- a/python/samples/getting_started/workflows/_start-here/step2_agents_in_a_workflow.py +++ b/python/samples/getting_started/workflows/_start-here/step2_agents_in_a_workflow.py @@ -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 """ diff --git a/python/samples/getting_started/workflows/_start-here/step3_streaming.py b/python/samples/getting_started/workflows/_start-here/step3_streaming.py index f44ececc63..be7d2a3de6 100644 --- a/python/samples/getting_started/workflows/_start-here/step3_streaming.py +++ b/python/samples/getting_started/workflows/_start-here/step3_streaming.py @@ -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!" """ diff --git a/python/samples/getting_started/workflows/_start-here/step4_using_factories.py b/python/samples/getting_started/workflows/_start-here/step4_using_factories.py index a7b9918991..c39a198edc 100644 --- a/python/samples/getting_started/workflows/_start-here/step4_using_factories.py +++ b/python/samples/getting_started/workflows/_start-here/step4_using_factories.py @@ -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 """ diff --git a/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py b/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py index 42f7dc3d23..94386909e6 100644 --- a/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py +++ b/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py @@ -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__": diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_function_bridge.py b/python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py similarity index 68% rename from python/samples/getting_started/workflows/agents/azure_chat_agents_function_bridge.py rename to python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py index 64fb3f3e9a..d7c7b8c1d3 100644 --- a/python/samples/getting_started/workflows/agents/azure_chat_agents_function_bridge.py +++ b/python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py @@ -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__": diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py b/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py index d8a8021a75..ab1dc29ec1 100644 --- a/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py +++ b/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py @@ -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__": diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py b/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py deleted file mode 100644 index 73e08bd0c0..0000000000 --- a/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py index 9ed1887736..75e7e07573 100644 --- a/python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py +++ b/python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py @@ -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 design—helping 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__": diff --git a/python/samples/getting_started/workflows/agents/custom_agent_executors.py b/python/samples/getting_started/workflows/agents/custom_agent_executors.py index c9fe07b0a2..cab73bc761 100644 --- a/python/samples/getting_started/workflows/agents/custom_agent_executors.py +++ b/python/samples/getting_started/workflows/agents/custom_agent_executors.py @@ -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. diff --git a/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py index c6ec8ec3b0..fa227826d0 100644 --- a/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py +++ b/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py @@ -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) diff --git a/python/samples/getting_started/workflows/agents/handoff_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/handoff_workflow_as_agent.py index 46c015fa42..99f9cca02a 100644 --- a/python/samples/getting_started/workflows/agents/handoff_workflow_as_agent.py +++ b/python/samples/getting_started/workflows/agents/handoff_workflow_as_agent.py @@ -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) diff --git a/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py index 3badeae78a..4e5b700e66 100644 --- a/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py +++ b/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py @@ -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}") diff --git a/python/samples/getting_started/workflows/agents/mixed_agents_and_executors.py b/python/samples/getting_started/workflows/agents/mixed_agents_and_executors.py deleted file mode 100644 index 3ec8d0f530..0000000000 --- a/python/samples/getting_started/workflows/agents/mixed_agents_and_executors.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py index 3a0264844b..6339f88ba2 100644 --- a/python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py +++ b/python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py @@ -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}") """ diff --git a/python/samples/getting_started/workflows/agents/workflow_as_agent_human_in_the_loop.py b/python/samples/getting_started/workflows/agents/workflow_as_agent_human_in_the_loop.py index a0d9769695..d1bdcb71ba 100644 --- a/python/samples/getting_started/workflows/agents/workflow_as_agent_human_in_the_loop.py +++ b/python/samples/getting_started/workflows/agents/workflow_as_agent_human_in_the_loop.py @@ -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. diff --git a/python/samples/getting_started/workflows/agents/workflow_as_agent_reflection_pattern.py b/python/samples/getting_started/workflows/agents/workflow_as_agent_reflection_pattern.py index 577a892066..2db380ea77 100644 --- a/python/samples/getting_started/workflows/agents/workflow_as_agent_reflection_pattern.py +++ b/python/samples/getting_started/workflows/agents/workflow_as_agent_reflection_pattern.py @@ -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__": diff --git a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py index e35894b8db..dbc51263d8 100644 --- a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py +++ b/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py @@ -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 diff --git a/python/samples/getting_started/workflows/control-flow/edge_condition.py b/python/samples/getting_started/workflows/control-flow/edge_condition.py index cdb1d2fb03..8c7dc4b760 100644 --- a/python/samples/getting_started/workflows/control-flow/edge_condition.py +++ b/python/samples/getting_started/workflows/control-flow/edge_condition.py @@ -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 diff --git a/python/samples/getting_started/workflows/control-flow/switch_case_edge_group.py b/python/samples/getting_started/workflows/control-flow/switch_case_edge_group.py index 3fe613e6f8..475f86b543 100644 --- a/python/samples/getting_started/workflows/control-flow/switch_case_edge_group.py +++ b/python/samples/getting_started/workflows/control-flow/switch_case_edge_group.py @@ -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 diff --git a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py new file mode 100644 index 0000000000..d2db9ac1c7 --- /dev/null +++ b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py @@ -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()) diff --git a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py index 24d39f02ae..b82f41b545 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py +++ b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py @@ -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: diff --git a/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py b/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py index 752956d0f2..f548515fe3 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py +++ b/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py @@ -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__": diff --git a/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py b/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py index 5d36fbd13a..2e4c639bc9 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py +++ b/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py @@ -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__": diff --git a/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py b/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py index dba7f56b66..01801f0f72 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py +++ b/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py @@ -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: diff --git a/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py b/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py index afb19753e5..913d2e514e 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py +++ b/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py @@ -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__": diff --git a/python/samples/getting_started/workflows/orchestration/concurrent_agents.py b/python/samples/getting_started/workflows/orchestration/concurrent_agents.py index 2be0f29f9c..51d8c0ef06 100644 --- a/python/samples/getting_started/workflows/orchestration/concurrent_agents.py +++ b/python/samples/getting_started/workflows/orchestration/concurrent_agents.py @@ -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) """ diff --git a/python/samples/getting_started/workflows/orchestration/group_chat_agent_manager.py b/python/samples/getting_started/workflows/orchestration/group_chat_agent_manager.py index cdc03a5ea5..29cc965e80 100644 --- a/python/samples/getting_started/workflows/orchestration/group_chat_agent_manager.py +++ b/python/samples/getting_started/workflows/orchestration/group_chat_agent_manager.py @@ -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__": diff --git a/python/samples/getting_started/workflows/orchestration/group_chat_philosophical_debate.py b/python/samples/getting_started/workflows/orchestration/group_chat_philosophical_debate.py index de613dea2e..116adcb475 100644 --- a/python/samples/getting_started/workflows/orchestration/group_chat_philosophical_debate.py +++ b/python/samples/getting_started/workflows/orchestration/group_chat_philosophical_debate.py @@ -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: diff --git a/python/samples/getting_started/workflows/orchestration/group_chat_simple_selector.py b/python/samples/getting_started/workflows/orchestration/group_chat_simple_selector.py index 1047cd6f22..0beeda6b72 100644 --- a/python/samples/getting_started/workflows/orchestration/group_chat_simple_selector.py +++ b/python/samples/getting_started/workflows/orchestration/group_chat_simple_selector.py @@ -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__": diff --git a/python/samples/getting_started/workflows/orchestration/handoff_autonomous.py b/python/samples/getting_started/workflows/orchestration/handoff_autonomous.py index e33b230ce7..21d102fd04 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_autonomous.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_autonomous.py @@ -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: diff --git a/python/samples/getting_started/workflows/orchestration/handoff_participant_factory.py b/python/samples/getting_started/workflows/orchestration/handoff_participant_factory.py index 9107e217c6..3cfe746bc1 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_participant_factory.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_participant_factory.py @@ -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() + ) ) ) diff --git a/python/samples/getting_started/workflows/orchestration/handoff_simple.py b/python/samples/getting_started/workflows/orchestration/handoff_simple.py index 2e7f53a82d..062f10db7d 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_simple.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_simple.py @@ -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) diff --git a/python/samples/getting_started/workflows/orchestration/handoff_with_code_interpreter_file.py b/python/samples/getting_started/workflows/orchestration/handoff_with_code_interpreter_file.py index 0c0616850b..ff0fd159fd 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_with_code_interpreter_file.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_with_code_interpreter_file.py @@ -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 diff --git a/python/samples/getting_started/workflows/orchestration/magentic.py b/python/samples/getting_started/workflows/orchestration/magentic.py index 60746bc113..b44a57112d 100644 --- a/python/samples/getting_started/workflows/orchestration/magentic.py +++ b/python/samples/getting_started/workflows/orchestration/magentic.py @@ -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__": diff --git a/python/samples/getting_started/workflows/orchestration/magentic_human_plan_review.py b/python/samples/getting_started/workflows/orchestration/magentic_human_plan_review.py index 1050463d01..1a5271813f 100644 --- a/python/samples/getting_started/workflows/orchestration/magentic_human_plan_review.py +++ b/python/samples/getting_started/workflows/orchestration/magentic_human_plan_review.py @@ -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__": diff --git a/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py b/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py index f59b1ea0c8..040d402d7b 100644 --- a/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py +++ b/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py @@ -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. diff --git a/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py b/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py index f2ed5ad677..a7a856606a 100644 --- a/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py +++ b/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py @@ -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. diff --git a/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py b/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py index 9b46e74bd2..712a6d0162 100644 --- a/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py +++ b/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py @@ -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"], diff --git a/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py b/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py index 4e202026fb..fa56109a98 100644 --- a/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py +++ b/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py @@ -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: diff --git a/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py b/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py index b4bc773eba..d16ee85b13 100644 --- a/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py +++ b/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py @@ -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: diff --git a/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py b/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py index 30c6b2358f..5493bc7588 100644 --- a/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py +++ b/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py @@ -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: