From bf7e1e31aee06772fb0edfef932ece2e421aa210 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 4 Dec 2025 14:25:10 +0900 Subject: [PATCH 1/8] Support HITL for orchestration patterns --- .../agent_framework/_workflows/__init__.py | 10 + .../agent_framework/_workflows/_concurrent.py | 30 +- .../agent_framework/_workflows/_group_chat.py | 144 +++++++- .../_workflows/_human_input.py | 336 ++++++++++++++++++ .../agent_framework/_workflows/_sequential.py | 36 +- .../tests/workflow/test_human_input_hook.py | 147 ++++++++ python/samples/SAMPLE_GUIDELINES.md | 2 +- .../getting_started/workflows/README.md | 17 +- .../concurrent_human_input_hook.py | 244 +++++++++++++ .../group_chat_human_input_hook.py | 227 ++++++++++++ .../sequential_human_input_hook.py | 179 ++++++++++ .../concurrent_builder_tool_approval.py | 183 ++++++++++ .../group_chat_builder_tool_approval.py | 206 +++++++++++ .../sequential_builder_tool_approval.py | 144 ++++++++ 14 files changed, 1889 insertions(+), 16 deletions(-) create mode 100644 python/packages/core/agent_framework/_workflows/_human_input.py create mode 100644 python/packages/core/tests/workflow/test_human_input_hook.py create mode 100644 python/samples/getting_started/workflows/human-in-the-loop/concurrent_human_input_hook.py create mode 100644 python/samples/getting_started/workflows/human-in-the-loop/group_chat_human_input_hook.py create mode 100644 python/samples/getting_started/workflows/human-in-the-loop/sequential_human_input_hook.py create mode 100644 python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py create mode 100644 python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py create mode 100644 python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 04623c87d9..a9a2ad2cec 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -65,6 +65,12 @@ ManagerSelectionResponse, ) from ._handoff import HandoffBuilder, HandoffUserInputRequest +from ._human_input import ( + HumanInputHook, + HumanInputHookMixin, + HumanInputHookResult, + HumanInputRequest, +) from ._magentic import ( MAGENTIC_EVENT_TYPE_AGENT_DELTA, MAGENTIC_EVENT_TYPE_ORCHESTRATOR, @@ -145,6 +151,10 @@ "GroupChatStateSnapshot", "HandoffBuilder", "HandoffUserInputRequest", + "HumanInputHook", + "HumanInputHookMixin", + "HumanInputHookResult", + "HumanInputRequest", "InMemoryCheckpointStorage", "InProcRunnerContext", "MagenticBuilder", diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index 6b3e1ac05e..2955622e64 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -13,6 +13,7 @@ from ._agent_executor import AgentExecutorRequest, AgentExecutorResponse from ._checkpoint import CheckpointStorage from ._executor import Executor, handler +from ._human_input import HumanInputHookMixin from ._message_utils import normalize_messages_input from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -184,7 +185,7 @@ async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowCon await ctx.yield_output(ret) -class ConcurrentBuilder: +class ConcurrentBuilder(HumanInputHookMixin): r"""High-level builder for concurrent agent workflows. - `participants([...])` accepts a list of AgentProtocol (recommended) or Executor. @@ -212,6 +213,16 @@ def summarize(results): # Enable checkpoint persistence so runs can resume workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_checkpointing(storage).build() + + + # Enable human input hook for review before aggregation + def request_review(conversation, agent_id): + # For concurrent workflows, agent_id is None and conversation contains + # merged outputs from all parallel agents + return HumanInputRequest(prompt="Review all outputs:", conversation=conversation) + + + workflow = ConcurrentBuilder().participants([agent1, agent2]).with_human_input_hook(request_review).build() """ def __init__(self) -> None: @@ -301,7 +312,9 @@ def build(self) -> Workflow: Wiring pattern: - Dispatcher (internal) fans out the input to all `participants` - - Fan-in aggregator collects `AgentExecutorResponse` objects + - Fan-in collects `AgentExecutorResponse` objects from all participants + - If human input hook is configured, the checkpoint executor checks for input + before passing results to the aggregator - Aggregator yields output and the workflow becomes idle. The output is either: - list[ChatMessage] (default aggregator: one user + one assistant per agent) - custom payload from the provided callback/executor @@ -324,10 +337,21 @@ def build(self) -> Workflow: dispatcher = _DispatchToAllParticipants(id="dispatcher") aggregator = self._aggregator or _AggregateAgentConversations(id="aggregator") + # Create human input checkpoint if hook is configured + human_input_checkpoint = self._create_human_input_executor() + builder = WorkflowBuilder() builder.set_start_executor(dispatcher) builder.add_fan_out_edges(dispatcher, list(self._participants)) - builder.add_fan_in_edges(list(self._participants), aggregator) + + if human_input_checkpoint is not None: + # Insert checkpoint between fan-in and aggregator + # participants -> fan-in -> checkpoint -> aggregator + builder.add_fan_in_edges(list(self._participants), human_input_checkpoint) + builder.add_edge(human_input_checkpoint, aggregator) + else: + # Direct fan-in to aggregator + builder.add_fan_in_edges(list(self._participants), aggregator) if self._checkpoint_storage is not None: builder = builder.with_checkpointing(self._checkpoint_storage) diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 78ddb5c2eb..321d0de10c 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -36,6 +36,7 @@ from ._checkpoint import CheckpointStorage from ._conversation_history import ensure_author, latest_user_message from ._executor import Executor, handler +from ._human_input import HumanInputHookMixin, _HumanInputCheckpoint # type: ignore from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, wrap_participant from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -562,14 +563,35 @@ async def _ingest_participant_message( participant_name: str, message: ChatMessage, ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], + trailing_messages: list[ChatMessage] | None = None, ) -> None: - """Common response ingestion logic shared by agent and custom participants.""" + """Common response ingestion logic shared by agent and custom participants. + + Args: + participant_name: Name of the participant who sent the message + message: The participant's response message + ctx: Workflow context for routing and output + trailing_messages: Optional list of messages to inject after the participant's + message (e.g., human input from the human input hook) + """ if participant_name not in self._participants: raise ValueError(f"Received response from unknown participant '{participant_name}'.") message = ensure_author(message, participant_name) self._conversation.extend((message,)) self._history.append(_GroupChatTurn(participant_name, "agent", message)) + + # Inject any trailing messages (e.g., human input) into the conversation + if trailing_messages: + for trailing_msg in trailing_messages: + self._conversation.extend((trailing_msg,)) + # Record as user input in history + author = trailing_msg.author_name or "human" + self._history.append(_GroupChatTurn(author, "user", trailing_msg)) + logger.debug( + f"Injected human input into group chat conversation: {trailing_msg.text[:50] if trailing_msg.text else '(empty)'}..." + ) + self._pending_agent = None if await self._complete_on_termination(ctx): @@ -685,14 +707,18 @@ async def _handle_manager_response( to the selected participant. This method implements the core orchestration logic for agent-based managers. + Also handles any human input that was injected into the response's full_conversation + by the human input hook checkpoint. + Args: response: AgentExecutor response from manager agent ctx: Workflow context for routing and output Behavior: + - Extracts any human input from the response - Parses manager selection from response - If finish=True: yields final message and completes workflow - - If participant selected: routes request to that participant + - If participant selected: routes request to that participant with human input included - Validates selected participant exists - Enforces round limits if configured @@ -700,6 +726,9 @@ async def _handle_manager_response( ValueError: If manager selects invalid/unknown participant RuntimeError: If manager response cannot be parsed """ + # Extract any human input that was injected by the human input hook + trailing_user_messages = self._extract_trailing_user_messages(response) + selection = self._parse_manager_selection(response) if self._pending_finalization: @@ -753,6 +782,19 @@ async def _handle_manager_response( self._conversation.append(manager_message) self._history.append(_GroupChatTurn(self._manager_name, "manager", manager_message)) + # Inject any human input that was attached to the manager's response + # This ensures the next participant sees the human's guidance + if trailing_user_messages: + for human_msg in trailing_user_messages: + conversation.append(human_msg) + self._conversation.append(human_msg) + author = human_msg.author_name or "human" + self._history.append(_GroupChatTurn(author, "user", human_msg)) + logger.debug( + f"Injected human input after manager selection: " + f"{human_msg.text[:50] if human_msg.text else '(empty)'}..." + ) + if await self._complete_on_termination(ctx): return @@ -808,6 +850,41 @@ def _extract_agent_message(response: AgentExecutorResponse, participant_name: st ) return ensure_author(final_message, participant_name) + @staticmethod + def _extract_trailing_user_messages(response: AgentExecutorResponse) -> list[ChatMessage]: + """Extract any user messages that appear after the last assistant message. + + This is used to capture human input that was injected by the human input hook + checkpoint. The hook adds user messages to full_conversation after the agent's + response, so they appear at the end of the sequence. + + Args: + response: AgentExecutor response that may contain trailing user messages + + Returns: + List of user messages that appear after the last assistant message, + or empty list if none found + """ + if not response.full_conversation: + return [] + + # Find index of last assistant message + last_assistant_idx = -1 + for i, msg in enumerate(response.full_conversation): + if msg.role == Role.ASSISTANT: + last_assistant_idx = i + + if last_assistant_idx < 0: + return [] + + # Collect any user messages after the last assistant message + trailing_user: list[ChatMessage] = [] + for msg in response.full_conversation[last_assistant_idx + 1 :]: + if msg.role == Role.USER: + trailing_user.append(msg) + + return trailing_user + async def _handle_task_message( self, task_message: ChatMessage, @@ -979,6 +1056,9 @@ async def handle_agent_executor_response( Routes responses based on whether they come from the manager or a participant: - Manager responses: parsed for speaker selection decisions - Participant responses: ingested as conversation messages + + Also handles any human input that was injected into the response's full_conversation + by the human input hook checkpoint. """ participant_name = self._registry.get_participant_name(response.executor_id) if participant_name is None: @@ -994,7 +1074,13 @@ async def handle_agent_executor_response( else: # Regular participant response message = self._extract_agent_message(response, participant_name) - await self._ingest_participant_message(participant_name, message, ctx) + + # Check for human input injected by human input hook + # Human input appears as user messages at the end of full_conversation + # after the agent's assistant message + trailing_user_messages = self._extract_trailing_user_messages(response) + + await self._ingest_participant_message(participant_name, message, ctx, trailing_user_messages) def _default_orchestrator_factory(wiring: _GroupChatConfig) -> Executor: @@ -1149,7 +1235,7 @@ def assemble_group_chat_workflow( # region Builder -class GroupChatBuilder: +class GroupChatBuilder(HumanInputHookMixin): r"""High-level builder for manager-directed group chat workflows with dynamic orchestration. GroupChat coordinates multi-agent conversations using a manager that selects which participant @@ -1213,6 +1299,31 @@ def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: .build() ) + *Pattern 3: Human input hook for mid-conversation feedback* + + .. code-block:: python + + from agent_framework import GroupChatBuilder, HumanInputRequest + + + def request_review(conversation, agent_id): + if "review" in conversation[-1].text.lower(): + return HumanInputRequest( + prompt="Please review and provide feedback:", + conversation=conversation, + source_agent_id=agent_id, + ) + return None + + + workflow = ( + GroupChatBuilder() + .set_select_speakers_func(select_next_speaker) + .participants([researcher, writer]) + .with_human_input_hook(request_review) + .build() + ) + **Participant Specification:** Two ways to specify participants: @@ -1754,9 +1865,32 @@ def build(self) -> Workflow: participant_executors=metadata["executors"], ) + # Determine participant factory - wrap if human input hook is configured + participant_factory = self._participant_factory + if self._human_input_hook is not None: + # Create a wrapper factory that adds human input checkpoint to each pipeline + base_factory = participant_factory + hook = self._human_input_hook + + def _factory_with_human_input( + spec: GroupChatParticipantSpec, + config: _GroupChatConfig, + ) -> _GroupChatParticipantPipeline: + pipeline = list(base_factory(spec, config)) + if pipeline: + # Add checkpoint executor after the participant + checkpoint = _HumanInputCheckpoint( + hook, + executor_id=f"human_input_checkpoint:{spec.name}", + ) + pipeline.append(checkpoint) + return tuple(pipeline) + + participant_factory = _factory_with_human_input + result = assemble_group_chat_workflow( wiring=wiring, - participant_factory=self._participant_factory, + participant_factory=participant_factory, orchestrator_factory=self._orchestrator_factory, interceptors=self._interceptors, checkpoint_storage=self._checkpoint_storage, diff --git a/python/packages/core/agent_framework/_workflows/_human_input.py b/python/packages/core/agent_framework/_workflows/_human_input.py new file mode 100644 index 0000000000..0a74d00e4f --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_human_input.py @@ -0,0 +1,336 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Human input hook support for high-level builder APIs. + +This module provides infrastructure for requesting arbitrary human feedback +mid-workflow in `SequentialBuilder`, `ConcurrentBuilder`, and `GroupChatBuilder`. + +Key components: +- HumanInputRequest: Standard request type emitted via RequestInfoEvent +- HumanInputHook: Callable type alias for hook functions +- HumanInputHookMixin: Mixin class providing `.with_human_input_hook()` method +- _HumanInputCheckpoint: Internal executor that intercepts responses and invokes the hook +""" + +import inspect +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, TypeAlias + +from typing_extensions import Self + +from .._types import ChatMessage, Role +from ._agent_executor import AgentExecutorResponse +from ._executor import Executor, handler +from ._request_info_mixin import response_handler +from ._workflow_context import WorkflowContext + +logger = logging.getLogger(__name__) + + +@dataclass +class HumanInputRequest: + """Request for human input in high-level builder workflows. + + Emitted via RequestInfoEvent when a workflow needs human guidance beyond + binary tool approval. The human's response is injected into the conversation + as a user message before the workflow continues. + + Attributes: + prompt: Human-readable prompt explaining what input is needed + conversation: Full conversation history at the time of the request + source_agent_id: ID of the agent whose output triggered the request (if known) + metadata: Optional builder-specific context (round index, agent name, etc.) + """ + + prompt: str + conversation: list[ChatMessage] = field(default_factory=lambda: []) + source_agent_id: str | None = None + metadata: dict[str, Any] = field(default_factory=lambda: {}) + + +# Type alias for human input hook result +HumanInputHookResult: TypeAlias = HumanInputRequest | None + +# Type alias for the human input hook callback +# Accepts (conversation, agent_id) and returns HumanInputRequest to pause or None to continue +# Supports both sync and async callbacks +HumanInputHook: TypeAlias = Callable[ + [list[ChatMessage], str | None], + HumanInputHookResult | Awaitable[HumanInputHookResult], +] + + +class HumanInputHookMixin: + """Mixin providing human input hook capability for high-level builders. + + Builders that inherit this mixin gain the `.with_human_input_hook()` method + and internal state management for the hook. The mixin provides a factory + method to create the checkpoint executor when the hook is configured. + """ + + _human_input_hook: HumanInputHook | None = None + + def with_human_input_hook( + self: Self, + hook: HumanInputHook, + ) -> Self: + """Add a hook that can request human input between agent turns. + + The hook is called after each agent completes. If it returns a + HumanInputRequest, the workflow pauses and emits a RequestInfoEvent. + The human's response is injected into the conversation as a user + message before the next agent runs. + + The hook can be either sync or async. Async hooks are awaited internally. + + Args: + hook: Callback that receives (conversation, agent_id) and returns + HumanInputRequest to pause, or None to continue. Can be + sync or async. + + Returns: + Self for method chaining. + + Example: + + .. code-block:: python + + # Sync hook - simple inspection + def review_on_keyword( + conversation: list[ChatMessage], + agent_id: str | None, + ) -> HumanInputRequest | None: + if conversation and "review" in conversation[-1].text.lower(): + return HumanInputRequest( + prompt="Please review and provide feedback:", + conversation=conversation, + source_agent_id=agent_id, + ) + return None + + + # Async hook - checks external service + async def check_policy_service( + conversation: list[ChatMessage], + agent_id: str | None, + ) -> HumanInputRequest | None: + requires_review = await policy_api.check_content(conversation[-1].text) + if requires_review: + return HumanInputRequest( + prompt="Content flagged for review:", + conversation=conversation, + source_agent_id=agent_id, + ) + return None + + + # Both work the same way + workflow = ( + SequentialBuilder().participants([agent1, agent2]).with_human_input_hook(check_policy_service).build() + ) + """ + self._human_input_hook = hook + return self + + def _create_human_input_executor( + self, + executor_id: str = "human_input_checkpoint", + ) -> "_HumanInputCheckpoint | None": + """Factory method for builders to create the checkpoint executor if hook is set. + + Args: + executor_id: ID for the checkpoint executor (default: "human_input_checkpoint") + + Returns: + _HumanInputCheckpoint instance if hook is configured, None otherwise. + """ + if self._human_input_hook is None: + return None + return _HumanInputCheckpoint(self._human_input_hook, executor_id=executor_id) + + +class _HumanInputCheckpoint(Executor): + """Internal executor that checks for human input after each agent response. + + This executor is inserted into the workflow graph by builders that use + the HumanInputHookMixin. It intercepts AgentExecutorResponse messages, + invokes the configured hook, and either: + - Passes through the response unchanged (hook returns None) + - Pauses the workflow via ctx.request_info() (hook returns HumanInputRequest) + + When the human responds, the response handler injects the human's input + as a user message into the conversation and continues the workflow. + + For ConcurrentBuilder, this executor also handles list[AgentExecutorResponse] + from fan-in aggregation. In that case, all conversations are merged before + invoking the hook with agent_id=None. + """ + + def __init__( + self, + hook: HumanInputHook, + executor_id: str = "human_input_checkpoint", + ) -> None: + """Initialize the checkpoint executor. + + Args: + hook: The human input hook callback + executor_id: ID for this executor (default: "human_input_checkpoint") + """ + super().__init__(executor_id) + self._hook = hook + + async def _invoke_hook( + self, + conversation: list[ChatMessage], + agent_id: str | None, + ) -> HumanInputRequest | None: + """Invoke the hook, handling both sync and async callbacks. + + Args: + conversation: Current conversation history + agent_id: ID of the agent that produced the last response + + Returns: + HumanInputRequest if human input is needed, None otherwise + """ + result = self._hook(conversation, agent_id) + if inspect.iscoroutine(result): + return await result + return result # type: ignore[return-value] + + @handler + async def check_for_input( + self, + response: AgentExecutorResponse, + ctx: WorkflowContext[AgentExecutorResponse, Any], + ) -> None: + """Check if human input is needed after an agent response. + + If the hook returns a HumanInputRequest, the workflow pauses and emits + a RequestInfoEvent. Otherwise, the response passes through unchanged. + + Args: + response: The agent's response to check + ctx: Workflow context for sending messages or requesting info + """ + conversation = list(response.full_conversation or []) + agent_id = response.executor_id + + request = await self._invoke_hook(conversation, agent_id) + if request is not None: + # Store the original response so we can continue after human input + request.metadata["_original_response"] = response + await ctx.request_info(request, str) + else: + # No human input needed, pass through the response + await ctx.send_message(response) + + @handler + async def check_for_input_concurrent( + self, + responses: list[AgentExecutorResponse], + ctx: WorkflowContext[list[AgentExecutorResponse], Any], + ) -> None: + """Check if human input is needed after concurrent agents complete. + + This handler is used by ConcurrentBuilder to check all parallel agent + outputs before aggregation. The hook is called with a merged view of + all agent conversations and agent_id=None. + + Args: + responses: List of responses from all concurrent agents + ctx: Workflow context for sending messages or requesting info + """ + # Merge all conversations into a combined view for the hook + # Take the first response's conversation as base (they share user prompt) + # then append each agent's final assistant messages + combined_conversation: list[ChatMessage] = [] + if responses: + # Use the first response's full conversation as the base + first_conv = responses[0].full_conversation or [] + combined_conversation = list(first_conv) + + # For subsequent responses, just add their assistant messages to avoid + # duplicating the user prompt. Note: this is a simplified merge. + for resp in responses[1:]: + if resp.agent_run_response and resp.agent_run_response.messages: + combined_conversation.extend(resp.agent_run_response.messages) + + request = await self._invoke_hook(combined_conversation, None) + if request is not None: + # Store the original responses so we can continue after human input + request.metadata["_original_responses"] = responses + await ctx.request_info(request, str) + else: + # No human input needed, pass through the responses + await ctx.send_message(responses) + + @response_handler + async def handle_human_response( + self, + original_request: HumanInputRequest, + response: str, + ctx: WorkflowContext[AgentExecutorResponse, Any], + ) -> None: + """Handle the human's response and continue the workflow. + + Injects the human's response as a user message into the conversation + and forwards the modified AgentExecutorResponse to continue the workflow. + + Args: + original_request: The HumanInputRequest that triggered the pause + response: The human's response text + ctx: Workflow context for continuing the workflow + """ + # Check if this is from concurrent (list) or sequential (single) response + original_responses: list[AgentExecutorResponse] | None = original_request.metadata.get("_original_responses") + if original_responses is not None: + # Concurrent case: inject human response and forward list + human_message = ChatMessage(role=Role.USER, text=response) + + # Add the human message to all responses' conversations + updated_responses: list[AgentExecutorResponse] = [] + for orig_resp in original_responses: + conversation = list(orig_resp.full_conversation or []) + conversation.append(human_message) + updated_responses.append( + AgentExecutorResponse( + executor_id=orig_resp.executor_id, + agent_run_response=orig_resp.agent_run_response, + full_conversation=conversation, + ) + ) + + logger.debug( + f"Human input received for concurrent workflow, " + f"continuing with {len(updated_responses)} updated responses" + ) + await ctx.send_message(updated_responses) # type: ignore[arg-type] + return + + # Sequential case: single response + original_response: AgentExecutorResponse | None = original_request.metadata.get("_original_response") + if original_response is None: + logger.error("Human input response handler missing original response in request metadata") + raise RuntimeError("Missing original response in HumanInputRequest metadata") + + # Inject human response into the conversation + human_message = ChatMessage(role=Role.USER, text=response) + conversation = list(original_response.full_conversation or []) + conversation.append(human_message) + + # Create updated response with the human input included + updated_response = AgentExecutorResponse( + executor_id=original_response.executor_id, + agent_run_response=original_response.agent_run_response, + full_conversation=conversation, + ) + + logger.debug( + f"Human input received for agent {original_response.executor_id}, " + f"continuing workflow with updated conversation" + ) + await ctx.send_message(updated_response) diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index 38fbc53c04..7c36f4a4f7 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -51,6 +51,7 @@ Executor, handler, ) +from ._human_input import HumanInputHookMixin from ._message_utils import normalize_messages_input from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -100,7 +101,7 @@ async def end(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any, l await ctx.yield_output(list(conversation)) -class SequentialBuilder: +class SequentialBuilder(HumanInputHookMixin): r"""High-level builder for sequential agent/executor workflows with shared context. - `participants([...])` accepts a list of AgentProtocol (recommended) or Executor @@ -119,6 +120,16 @@ class SequentialBuilder: # Enable checkpoint persistence workflow = SequentialBuilder().participants([agent1, agent2]).with_checkpointing(storage).build() + + + # Enable human input hook for mid-workflow feedback + def request_review(conversation, agent_id): + if "review" in conversation[-1].text.lower(): + return HumanInputRequest(prompt="Please review:", conversation=conversation) + return None + + + workflow = SequentialBuilder().participants([agent1, agent2]).with_human_input_hook(request_review).build() """ def __init__(self) -> None: @@ -163,8 +174,9 @@ def build(self) -> Workflow: Wiring pattern: - _InputToConversation normalizes the initial input into list[ChatMessage] - For each participant in order: - - If Agent (or AgentExecutor): pass conversation to the agent, then convert response - to conversation via _ResponseToConversation + - If Agent (or AgentExecutor): pass conversation to the agent, then optionally + route through human input checkpoint, then convert response to conversation + via _ResponseToConversation - Else (custom Executor): pass conversation directly to the executor - _EndWithConversation yields the final conversation and the workflow becomes idle """ @@ -175,6 +187,9 @@ def build(self) -> Workflow: input_conv = _InputToConversation(id="input-conversation") end = _EndWithConversation(id="end") + # Create human input checkpoint if hook is configured + human_input_checkpoint = self._create_human_input_executor() + builder = WorkflowBuilder() builder.set_start_executor(input_conv) @@ -184,13 +199,24 @@ def build(self) -> Workflow: for p in self._participants: # Agent-like branch: either explicitly an AgentExecutor or any non-AgentExecutor if not (isinstance(p, Executor) and not isinstance(p, AgentExecutor)): - # input conversation -> (agent) -> response -> conversation + # input conversation -> (agent) -> [human_input_checkpoint] -> response -> conversation builder.add_edge(prior, p) + # Give the adapter a deterministic, self-describing id label: str label = p.id if isinstance(p, Executor) else getattr(p, "name", None) or p.__class__.__name__ resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}") - builder.add_edge(p, resp_to_conv) + + if human_input_checkpoint is not None: + # Insert human input checkpoint between agent and response converter + # Create a dedicated checkpoint per agent to avoid ID conflicts + checkpoint = self._create_human_input_executor(f"human_input_checkpoint:{label}") + if checkpoint is not None: + builder.add_edge(p, checkpoint) + builder.add_edge(checkpoint, resp_to_conv) + else: + builder.add_edge(p, resp_to_conv) + prior = resp_to_conv elif isinstance(p, Executor): # Custom executor operates on list[ChatMessage] diff --git a/python/packages/core/tests/workflow/test_human_input_hook.py b/python/packages/core/tests/workflow/test_human_input_hook.py new file mode 100644 index 0000000000..d274b779c3 --- /dev/null +++ b/python/packages/core/tests/workflow/test_human_input_hook.py @@ -0,0 +1,147 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for HumanInputHookMixin and related classes.""" + +from agent_framework import ( + ChatMessage, + HumanInputHookMixin, + HumanInputRequest, + Role, +) +from agent_framework._workflows._human_input import _HumanInputCheckpoint # type: ignore + + +class TestHumanInputRequest: + """Tests for HumanInputRequest dataclass.""" + + def test_create_request(self): + """Test creating a HumanInputRequest with all fields.""" + conversation = [ChatMessage(role=Role.USER, text="Hello")] + request = HumanInputRequest( + prompt="Please review:", + conversation=conversation, + source_agent_id="test_agent", + metadata={"key": "value"}, + ) + + assert request.prompt == "Please review:" + assert request.conversation == conversation + assert request.source_agent_id == "test_agent" + assert request.metadata == {"key": "value"} + + def test_create_request_defaults(self): + """Test creating a HumanInputRequest with default values.""" + request = HumanInputRequest(prompt="Enter input:") + + assert request.prompt == "Enter input:" + assert request.conversation == [] + assert request.source_agent_id is None + assert request.metadata == {} + + +class TestHumanInputHookMixin: + """Tests for HumanInputHookMixin.""" + + def test_mixin_with_hook(self): + """Test setting a human input hook via the mixin.""" + + class TestBuilder(HumanInputHookMixin): + pass + + def my_hook( + conversation: list[ChatMessage], + agent_id: str | None, + ) -> HumanInputRequest | None: + return None + + builder = TestBuilder() + result = builder.with_human_input_hook(my_hook) + + assert result is builder # Method chaining + assert builder._human_input_hook is my_hook # type: ignore + + def test_create_executor_returns_none_without_hook(self): + """Test that _create_human_input_executor returns None when no hook is set.""" + + class TestBuilder(HumanInputHookMixin): + pass + + builder = TestBuilder() + executor = builder._create_human_input_executor() # type: ignore + + assert executor is None + + def test_create_executor_returns_checkpoint_with_hook(self): + """Test that _create_human_input_executor returns a checkpoint when hook is set.""" + + class TestBuilder(HumanInputHookMixin): + pass + + def my_hook( + conversation: list[ChatMessage], + agent_id: str | None, + ) -> HumanInputRequest | None: + return None + + builder = TestBuilder() + builder.with_human_input_hook(my_hook) + executor = builder._create_human_input_executor("custom_id") # type: ignore + + assert executor is not None + assert isinstance(executor, _HumanInputCheckpoint) + assert executor.id == "custom_id" + + +class TestHumanInputCheckpoint: + """Tests for _HumanInputCheckpoint executor.""" + + async def test_invoke_sync_hook(self): + """Test invoking a synchronous hook.""" + + def sync_hook( + conversation: list[ChatMessage], + agent_id: str | None, + ) -> HumanInputRequest | None: + if conversation and "review" in conversation[-1].text.lower(): + return HumanInputRequest( + prompt="Review requested", + conversation=conversation, + source_agent_id=agent_id, + ) + return None + + checkpoint = _HumanInputCheckpoint(sync_hook) + + # Test hook returns None + result = await checkpoint._invoke_hook([], None) # type: ignore + assert result is None + + # Test hook returns request + conversation = [ChatMessage(role=Role.ASSISTANT, text="Please review this")] + result = await checkpoint._invoke_hook(conversation, "test_agent") # type: ignore + assert result is not None + assert result.prompt == "Review requested" + assert result.source_agent_id == "test_agent" + + async def test_invoke_async_hook(self): + """Test invoking an asynchronous hook.""" + + async def async_hook( + conversation: list[ChatMessage], + agent_id: str | None, + ) -> HumanInputRequest | None: + if conversation: + return HumanInputRequest( + prompt="Async review", + conversation=conversation, + source_agent_id=agent_id, + ) + return None + + checkpoint = _HumanInputCheckpoint(async_hook) + + # Test async hook returns request + conversation = [ChatMessage(role=Role.USER, text="Test")] + result = await checkpoint._invoke_hook(conversation, "async_agent") # type: ignore + assert result is not None + assert result.prompt == "Async review" diff --git a/python/samples/SAMPLE_GUIDELINES.md b/python/samples/SAMPLE_GUIDELINES.md index 05c567a008..e8c1589ef0 100644 --- a/python/samples/SAMPLE_GUIDELINES.md +++ b/python/samples/SAMPLE_GUIDELINES.md @@ -33,7 +33,7 @@ Try to over-document the samples. This includes comments in the code, README.md For the getting started samples and the concept samples, we should have the following: 1. A README.md file is included in each set of samples that explains the purpose of the samples and the setup required to run them. -2. A summary should be included at the top of the file that explains the purpose of the sample and required components/concepts to understand the sample. For example: +2. A summary should be included underneath the imports that explains the purpose of the sample and required components/concepts to understand the sample. For example: ```python ''' diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 79b23d03d8..81db481061 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -78,9 +78,22 @@ Once comfortable with these, explore the rest of the samples below. | Sample | File | Concepts | |---|---|---| -| Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human | -| Azure Agents Tool Feedback Loop | [agents/azure_chat_agents_tool_calls_with_feedback.py](./agents/azure_chat_agents_tool_calls_with_feedback.py) | Two-agent workflow that streams tool calls and pauses for human guidance between passes | +| Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human via `ctx.request_info()` | | Agents with Approval Requests in Workflows | [human-in-the-loop/agents_with_approval_requests.py](./human-in-the-loop/agents_with_approval_requests.py) | Agents that create approval requests during workflow execution and wait for human approval to proceed | +| SequentialBuilder Human Input Hook | [human-in-the-loop/sequential_human_input_hook.py](./human-in-the-loop/sequential_human_input_hook.py) | Request arbitrary human feedback mid-workflow using `.with_human_input_hook()` on SequentialBuilder | +| ConcurrentBuilder Human Input Hook | [human-in-the-loop/concurrent_human_input_hook.py](./human-in-the-loop/concurrent_human_input_hook.py) | Review concurrent agent outputs before aggregation using `.with_human_input_hook()` on ConcurrentBuilder | +| GroupChatBuilder Human Input Hook | [human-in-the-loop/group_chat_human_input_hook.py](./human-in-the-loop/group_chat_human_input_hook.py) | Steer group discussions with periodic human guidance using `.with_human_input_hook()` on GroupChatBuilder | + + +### tool-approval + +Tool approval samples demonstrate using `@ai_function(approval_mode="always_require")` to gate sensitive tool executions with human approval. These work with the high-level builder APIs. + +| Sample | File | Concepts | +|---|---|---| +| SequentialBuilder Tool Approval | [tool-approval/sequential_builder_tool_approval.py](./tool-approval/sequential_builder_tool_approval.py) | Sequential workflow with tool approval gates for sensitive operations | +| ConcurrentBuilder Tool Approval | [tool-approval/concurrent_builder_tool_approval.py](./tool-approval/concurrent_builder_tool_approval.py) | Concurrent workflow with tool approvals across parallel agents | +| GroupChatBuilder Tool Approval | [tool-approval/group_chat_builder_tool_approval.py](./tool-approval/group_chat_builder_tool_approval.py) | Group chat workflow with tool approval for multi-agent collaboration | ### observability diff --git a/python/samples/getting_started/workflows/human-in-the-loop/concurrent_human_input_hook.py b/python/samples/getting_started/workflows/human-in-the-loop/concurrent_human_input_hook.py new file mode 100644 index 0000000000..ba6b40a302 --- /dev/null +++ b/python/samples/getting_started/workflows/human-in-the-loop/concurrent_human_input_hook.py @@ -0,0 +1,244 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Any + +from agent_framework import ( + ChatMessage, + ConcurrentBuilder, + HumanInputRequest, + RequestInfoEvent, + Role, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, +) +from agent_framework._workflows._agent_executor import AgentExecutorResponse +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + +""" +Sample: Human Input Hook with ConcurrentBuilder + +This sample demonstrates using the `.with_human_input_hook()` method to request +arbitrary human feedback mid-workflow with ConcurrentBuilder. The hook is called +after all parallel agents complete but before the aggregator runs. + +Purpose: +Show how to use HumanInputRequest to pause a ConcurrentBuilder workflow and request +human review of parallel agent outputs before aggregation. + +Demonstrate: +- Configuring a human input hook on ConcurrentBuilder +- Reviewing outputs from multiple concurrent agents simultaneously +- Injecting human guidance before the aggregator synthesizes results + +Prerequisites: +- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables +- Authentication via azure-identity (run az login before executing) +""" + +# Store chat client at module level for aggregator access +_chat_client: AzureOpenAIChatClient | None = None + + +def review_concurrent_outputs( + conversation: list[ChatMessage], + agent_id: str | None, +) -> HumanInputRequest | None: + """Hook that requests human input after concurrent agents complete. + + This is a simple demonstration heuristic that always requests review when + multiple agents have responded. In practice, you might use other strategies: + - Always pause unconditionally for mandatory human review + - Check for conflicting opinions between agents + - Call an async policy service to determine if review is needed + - Use content classification to detect topics requiring human judgment + - Skip review if all agents reached similar conclusions + + For ConcurrentBuilder, the hook is called once with a merged view of all + agent outputs. The agent_id is None since multiple agents contributed. + + Args: + conversation: Merged conversation including all concurrent agent responses + agent_id: None for concurrent (multiple agents contributed) + + Returns: + HumanInputRequest to pause and request input, or None to continue + """ + if not conversation: + return None + + # Example heuristic: request review when we have multiple perspectives + # This is just one approach - replace with your own logic as needed + assistant_msgs = [m for m in conversation if m.role and m.role.value == "assistant"] + + if len(assistant_msgs) >= 2: + return HumanInputRequest( + prompt=( + f"Received {len(assistant_msgs)} different perspectives. " + "Please review and provide guidance on which aspects to prioritize in the final summary:" + ), + conversation=conversation, + source_agent_id=agent_id, + metadata={"num_perspectives": len(assistant_msgs)}, + ) + return None + + +async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any: + """Custom aggregator that synthesizes concurrent agent outputs using an LLM. + + This aggregator extracts the outputs from each parallel agent and uses the + chat client to create a unified summary, incorporating any human feedback + that was injected into the conversation. + + Args: + results: List of responses from all concurrent agents + + Returns: + The synthesized summary text + """ + if not _chat_client: + return "Error: Chat client not initialized" + + # Extract each agent's final output + expert_sections: list[str] = [] + human_guidance = "" + + for r in results: + try: + messages = getattr(r.agent_run_response, "messages", []) + final_text = messages[-1].text if messages and hasattr(messages[-1], "text") else "(no content)" + expert_sections.append(f"{getattr(r, 'executor_id', 'analyst')}:\n{final_text}") + + # Check for human feedback in the conversation (will be last user message if present) + if r.full_conversation: + for msg in reversed(r.full_conversation): + if msg.role == Role.USER and msg.text and "perspectives" not in msg.text.lower(): + human_guidance = msg.text + break + except Exception: + expert_sections.append(f"{getattr(r, 'executor_id', 'analyst')}: (error extracting output)") + + # Build prompt with human guidance if provided + guidance_text = f"\n\nHuman guidance: {human_guidance}" if human_guidance else "" + + system_msg = ChatMessage( + Role.SYSTEM, + text=( + "You are a synthesis expert. Consolidate the following analyst perspectives " + "into one cohesive, balanced summary (3-4 sentences). If human guidance is provided, " + "prioritize aspects as directed." + ), + ) + user_msg = ChatMessage(Role.USER, text="\n\n".join(expert_sections) + guidance_text) + + response = await _chat_client.get_response([system_msg, user_msg]) + return response.messages[-1].text if response.messages else "" + + +async def main() -> None: + global _chat_client + _chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + + # Create agents that analyze from different perspectives + technical_analyst = _chat_client.create_agent( + name="technical_analyst", + instructions=( + "You are a technical analyst. When given a topic, provide a technical " + "perspective focusing on implementation details, performance, and architecture. " + "Keep your analysis to 2-3 sentences." + ), + ) + + business_analyst = _chat_client.create_agent( + name="business_analyst", + instructions=( + "You are a business analyst. When given a topic, provide a business " + "perspective focusing on ROI, market impact, and strategic value. " + "Keep your analysis to 2-3 sentences." + ), + ) + + user_experience_analyst = _chat_client.create_agent( + name="ux_analyst", + instructions=( + "You are a UX analyst. When given a topic, provide a user experience " + "perspective focusing on usability, accessibility, and user satisfaction. " + "Keep your analysis to 2-3 sentences." + ), + ) + + # Build workflow with human input hook and custom aggregator + workflow = ( + ConcurrentBuilder() + .participants([technical_analyst, business_analyst, user_experience_analyst]) + .with_aggregator(aggregate_with_synthesis) + .with_human_input_hook(review_concurrent_outputs) + .build() + ) + + # Run the workflow with human-in-the-loop + pending_responses: dict[str, str] | None = None + workflow_complete = False + + 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, HumanInputRequest): + # Display the concurrent agent outputs + print("\n" + "-" * 40) + print("HUMAN INPUT REQUESTED") + print("-" * 40) + print("Concurrent agent outputs:") + + # Show each assistant message (one per analyst) + for msg in event.data.conversation: + if msg.role and msg.role.value == "assistant": + text = (msg.text or "")[:250] + print(f"\n [analyst]: {text}...") + + print("\n" + "-" * 40) + print(f"Prompt: {event.data.prompt}") + print("(Workflow paused)") + + # Get human input + user_input = input("Your guidance (or 'skip' to continue): ") + if user_input.lower() == "skip": + user_input = "All perspectives are equally important. Please create a balanced summary." + + 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): + if event.state == WorkflowRunState.IDLE: + workflow_complete = True + # Note: IDLE_WITH_PENDING_REQUESTS is handled inline with RequestInfoEvent + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/human-in-the-loop/group_chat_human_input_hook.py b/python/samples/getting_started/workflows/human-in-the-loop/group_chat_human_input_hook.py new file mode 100644 index 0000000000..db4c712f77 --- /dev/null +++ b/python/samples/getting_started/workflows/human-in-the-loop/group_chat_human_input_hook.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import ( + ChatMessage, + GroupChatBuilder, + HumanInputRequest, + RequestInfoEvent, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + +""" +Sample: Human Input Hook with GroupChatBuilder + +This sample demonstrates using the `.with_human_input_hook()` method to request +arbitrary human feedback mid-workflow with GroupChatBuilder. The hook is called +after each participant takes a turn, allowing human intervention in multi-turn +group conversations. + +Purpose: +Show how to use HumanInputRequest to pause a GroupChatBuilder workflow and request +human guidance during a dynamic group conversation. + +Demonstrate: +- Configuring a human input hook on GroupChatBuilder +- Monitoring conversation progress and intervening when needed +- Steering group discussions with human input + +Prerequisites: +- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables +- Authentication via azure-identity (run az login before executing) +""" + +# Track conversation turns for the hook +turn_count = 0 + + +def review_group_discussion( + conversation: list[ChatMessage], + agent_id: str | None, +) -> HumanInputRequest | None: + """Hook that requests human input periodically during group chat. + + This is a simple demonstration using turn counting and keyword detection. + In practice, you might use other strategies: + - Always pause after specific participants (by checking agent_id) + - Pause only when the manager is about to select a critical agent + - Call an async policy service to determine if review is needed + - Use sentiment analysis to detect when the discussion needs moderation + - Return HumanInputRequest unconditionally for mandatory review at every step + + Args: + conversation: Full conversation history including latest agent response + agent_id: ID of the agent that just responded + + Returns: + HumanInputRequest to pause and request input, or None to continue + """ + global turn_count + turn_count += 1 + + if not conversation: + return None + + # Check if the conversation is finishing - don't request input if manager said finish + last_message = conversation[-1] + text = last_message.text or "" + + # Skip human input if the manager has decided to finish the conversation + if '"finish":true' in text.replace(" ", "").lower() or '"finish": true' in text.lower(): + return None + + # Example heuristic: request human input every 3 turns + # This is just one approach - replace with your own logic as needed + if turn_count % 3 == 0: + return HumanInputRequest( + prompt=( + f"The group has completed {turn_count} turns. " + "Would you like to provide any guidance or redirect the discussion?" + ), + conversation=conversation, + source_agent_id=agent_id, + metadata={"turn_count": turn_count}, + ) + + # Also request input if the conversation hits a decision point + decision_keywords = ["disagree", "alternative", "however", "but i think", "on the other hand"] + + if any(keyword in text.lower() for keyword in decision_keywords): + return HumanInputRequest( + prompt=( + f"Agent '{agent_id}' has raised a different perspective. " + "Would you like to weigh in on this discussion point?" + ), + conversation=conversation, + source_agent_id=agent_id, + metadata={"trigger": "disagreement", "turn_count": turn_count}, + ) + + return None + + +async def main() -> None: + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + + # Create agents for a group discussion + optimist = chat_client.create_agent( + name="optimist", + instructions=( + "You are an optimistic team member. You see opportunities and potential " + "in ideas. Engage constructively with the discussion, building on others' " + "points while maintaining a positive outlook. Keep responses to 2-3 sentences." + ), + ) + + pragmatist = chat_client.create_agent( + name="pragmatist", + instructions=( + "You are a pragmatic team member. You focus on practical implementation " + "and realistic timelines. Sometimes you disagree with overly optimistic views. " + "Keep responses to 2-3 sentences." + ), + ) + + creative = chat_client.create_agent( + name="creative", + instructions=( + "You are a creative team member. You propose innovative solutions and " + "think outside the box. You may suggest alternatives to conventional approaches. " + "Keep responses to 2-3 sentences." + ), + ) + + # Manager orchestrates the discussion + manager = chat_client.create_agent( + name="manager", + instructions=( + "You are a discussion manager. Facilitate the group discussion by acknowledging " + "contributions and keeping the conversation productive. If human feedback is provided, " + "incorporate it to guide the discussion. Decide when to conclude after 4-6 exchanges." + ), + ) + + # Build workflow with human input hook + workflow = ( + GroupChatBuilder() + .set_manager(manager=manager, display_name="Discussion Manager") + .participants([optimist, pragmatist, creative]) + .with_max_rounds(6) + .with_human_input_hook(review_group_discussion) + .build() + ) + + # Run the workflow with human-in-the-loop + pending_responses: dict[str, str] | None = None + workflow_complete = False + + 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, RequestInfoEvent): + if isinstance(event.data, HumanInputRequest): + # Display recent conversation + print("\n" + "-" * 40) + print("HUMAN INPUT REQUESTED") + print(f"After turn: {event.data.metadata.get('turn_count', 'unknown')}") + if event.data.source_agent_id: + print(f"Triggered by: {event.data.source_agent_id}") + print("-" * 40) + print("Recent discussion:") + for msg in event.data.conversation[-4:]: + role = msg.role.value if msg.role else "unknown" + text = (msg.text or "")[:150] + print(f" [{role}]: {text}...") + print("-" * 40) + print(f"Prompt: {event.data.prompt}") + print("(Discussion paused)") + + # Get human input + user_input = input("Your input (or 'skip' to continue): ") + if user_input.lower() == "skip": + user_input = "Please continue the discussion naturally." + + pending_responses = {event.request_id: 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[-4:] + for msg in messages: + role = msg.role.value if msg.role else "unknown" + text = (msg.text or "")[:200] + print(f"[{role}]: {text}...") + workflow_complete = True + + elif isinstance(event, WorkflowStatusEvent): + if event.state == WorkflowRunState.IDLE: + workflow_complete = True + # Note: IDLE_WITH_PENDING_REQUESTS is handled inline with RequestInfoEvent + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/human-in-the-loop/sequential_human_input_hook.py b/python/samples/getting_started/workflows/human-in-the-loop/sequential_human_input_hook.py new file mode 100644 index 0000000000..bbfeffc4af --- /dev/null +++ b/python/samples/getting_started/workflows/human-in-the-loop/sequential_human_input_hook.py @@ -0,0 +1,179 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import ( + ChatMessage, + HumanInputRequest, + RequestInfoEvent, + SequentialBuilder, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + +""" +Sample: Human Input Hook with SequentialBuilder + +This sample demonstrates using the `.with_human_input_hook()` method to request +arbitrary human feedback mid-workflow with high-level builders. Unlike tool approval +(binary approve/deny), human input hooks allow injecting custom guidance into the +conversation. + +Purpose: +Show how to use HumanInputRequest to pause a SequentialBuilder workflow and request +human review or additional input before continuing to the next agent. + +Demonstrate: +- Configuring a human input hook that triggers based on conversation content +- Handling RequestInfoEvent with HumanInputRequest data +- Injecting human responses back into the workflow via send_responses_streaming + +Prerequisites: +- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables +- Authentication via azure-identity (run az login before executing) +""" + + +def request_review_on_keyword( + conversation: list[ChatMessage], + agent_id: str | None, +) -> HumanInputRequest | None: + """Hook that requests human input when an agent mentions 'review'. + + This is a simple demonstration heuristic that triggers on keyword matches. + In practice, you might use other strategies: + - Always pause after specific agents (by checking agent_id) + - Pause every N steps using a counter + - Call an async policy service to determine if review is needed + - Use content classification to detect sensitive topics + - Return HumanInputRequest unconditionally for mandatory review at every step + + Args: + conversation: Full conversation history including agent's latest response + agent_id: ID of the agent that just responded + + Returns: + HumanInputRequest to pause and request input, or None to continue + """ + if not conversation: + return None + + # Example heuristic: check the last message for keywords suggesting review is needed + # This is just one approach - replace with your own logic as needed + last_message = conversation[-1] + text = last_message.text or "" + keywords = ["review", "confirm", "approve", "feedback", "check"] + + if any(keyword in text.lower() for keyword in keywords): + return HumanInputRequest( + prompt=f"Agent '{agent_id}' is requesting your review. Please provide feedback:", + conversation=conversation, + source_agent_id=agent_id, + metadata={"trigger": "keyword_match"}, + ) + return None + + +async def main() -> None: + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + + # Create agents for a sequential document review workflow + drafter = chat_client.create_agent( + name="drafter", + instructions=( + "You are a document drafter. When given a topic, create a brief draft " + "(2-3 sentences). Always end with 'Please review this draft and provide feedback.'" + ), + ) + + editor = chat_client.create_agent( + name="editor", + instructions=( + "You are an editor. Review the draft and suggest improvements. " + "Incorporate any human feedback that was provided. " + "Always end with 'Please confirm these edits are acceptable.'" + ), + ) + + finalizer = chat_client.create_agent( + name="finalizer", + instructions=( + "You are a finalizer. Take the edited content and create a polished final version. " + "Incorporate any additional feedback provided. " + "Present the final document without asking for review." + ), + ) + + # Build workflow with human input hook + workflow = ( + SequentialBuilder() + .participants([drafter, editor, finalizer]) + .with_human_input_hook(request_review_on_keyword) + .build() + ) + + # Run the workflow with human-in-the-loop + pending_responses: dict[str, str] | None = None + workflow_complete = False + + 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, HumanInputRequest): + # Display the conversation context + print("\n" + "-" * 40) + print("HUMAN INPUT REQUESTED") + print(f"From agent: {event.data.source_agent_id}") + print("-" * 40) + print("Recent conversation:") + for msg in event.data.conversation[-3:]: + role = msg.role.value if msg.role else "unknown" + text = (msg.text or "")[:200] + print(f" [{role}]: {text}...") + print("-" * 40) + print(f"Prompt: {event.data.prompt}") + print("(Workflow paused)") + + # Get human input + user_input = input("Your feedback (or 'skip' to continue): ") # noqa: ASYNC250 + if user_input.lower() == "skip": + user_input = "Looks good, please continue." + + 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.value 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 + # Note: IDLE_WITH_PENDING_REQUESTS is handled inline with RequestInfoEvent + + +if __name__ == "__main__": + asyncio.run(main()) 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 new file mode 100644 index 0000000000..e4092414fc --- /dev/null +++ b/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py @@ -0,0 +1,183 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework import ( + ChatMessage, + ConcurrentBuilder, + FunctionApprovalRequestContent, + FunctionApprovalResponseContent, + RequestInfoEvent, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, + ai_function, +) +from agent_framework.openai import OpenAIChatClient + +""" +Sample: Concurrent Workflow with Tool Approval Requests + +This sample demonstrates how to use ConcurrentBuilder with tools that require human +approval before execution. Multiple agents run in parallel, and any tool requiring +approval will pause the workflow until the human responds. + +This sample works as follows: +1. A ConcurrentBuilder workflow is created with two agents running in parallel. +2. One agent has a tool requiring approval (financial transaction). +3. The other agent has only non-approval tools (market data lookup). +4. Both agents receive the same task and work concurrently. +5. When the financial agent tries to execute a trade, it triggers an approval request. +6. The sample simulates human approval and the workflow completes. +7. Results from both agents are aggregated and output. + +Purpose: +Show how tool call approvals work in parallel execution scenarios where only some +agents have sensitive tools. + +Demonstrate: +- Combining agents with and without approval-required tools in concurrent workflows. +- Handling RequestInfoEvent during concurrent agent execution. +- Understanding that approval pauses only the agent that triggered it, not all agents. + +Prerequisites: +- OpenAI or Azure OpenAI configured with the required environment variables. +- Basic familiarity with ConcurrentBuilder and streaming workflow events. +""" + + +# 1. Define tools for the research agent (no approval required) +@ai_function +def get_stock_price(symbol: Annotated[str, "The stock ticker symbol"]) -> str: + """Get the current stock price for a given symbol.""" + # Mock data for demonstration + prices = {"AAPL": 175.50, "GOOGL": 140.25, "MSFT": 378.90, "AMZN": 178.75} + price = prices.get(symbol.upper(), 100.00) + return f"{symbol.upper()}: ${price:.2f}" + + +@ai_function +def get_market_sentiment(symbol: Annotated[str, "The stock ticker symbol"]) -> str: + """Get market sentiment analysis for a stock.""" + # Mock sentiment data + return f"Market sentiment for {symbol.upper()}: Bullish (72% positive mentions in last 24h)" + + +# 2. Define tools for the trading agent (approval required for trades) +@ai_function(approval_mode="always_require") +def execute_trade( + symbol: Annotated[str, "The stock ticker symbol"], + action: Annotated[str, "Either 'buy' or 'sell'"], + quantity: Annotated[int, "Number of shares to trade"], +) -> str: + """Execute a stock trade. Requires human approval due to financial impact.""" + return f"Trade executed: {action.upper()} {quantity} shares of {symbol.upper()}" + + +@ai_function +def get_portfolio_balance() -> str: + """Get current portfolio balance and available funds.""" + return "Portfolio: $50,000 invested, $10,000 cash available" + + +async def main() -> None: + # 3. Create two agents with different tool sets + chat_client = OpenAIChatClient() + + research_agent = chat_client.create_agent( + name="ResearchAgent", + instructions=( + "You are a market research analyst. Analyze stock data and provide " + "recommendations based on price and sentiment. Do not execute trades." + ), + tools=[get_stock_price, get_market_sentiment], + ) + + trading_agent = chat_client.create_agent( + name="TradingAgent", + instructions=( + "You are a trading assistant. When asked to buy or sell shares, you MUST " + "call the execute_trade function to complete the transaction. Check portfolio " + "balance first, then execute the requested trade." + ), + tools=[get_portfolio_balance, execute_trade], + ) + + # 4. Build a concurrent workflow with both agents + # ConcurrentBuilder requires at least 2 participants for fan-out + workflow = ConcurrentBuilder().participants([research_agent, trading_agent]).build() + + # 5. Start the workflow - both agents will process the same task in parallel + print("Starting concurrent workflow with tool approval...") + print("Two agents will analyze MSFT - one for research, one for trading.") + print("-" * 60) + + # Phase 1: Run workflow and collect all events (stream ends at IDLE or IDLE_WITH_PENDING_REQUESTS) + request_info_events: list[RequestInfoEvent] = [] + workflow_completed_without_approvals = False + async for event in workflow.run_stream("Analyze MSFT stock and if sentiment is positive, buy 10 shares."): + if isinstance(event, RequestInfoEvent): + request_info_events.append(event) + if isinstance(event.data, FunctionApprovalRequestContent): + print(f"\nApproval requested for tool: {event.data.function_call.name}") + print(f" Arguments: {event.data.function_call.arguments}") + elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE: + workflow_completed_without_approvals = True + + # 6. Handle approval requests (if any) + if request_info_events: + responses: dict[str, FunctionApprovalResponseContent] = {} + for request_event in request_info_events: + if isinstance(request_event.data, FunctionApprovalRequestContent): + print(f"\nSimulating human approval for: {request_event.data.function_call.name}") + # Create approval response + responses[request_event.request_id] = request_event.data.create_response(approved=True) + + if responses: + # Phase 2: Send all approvals and continue workflow + output: list[ChatMessage] | None = None + async for event in workflow.send_responses_streaming(responses): + if isinstance(event, WorkflowOutputEvent): + output = event.data + + if output: + print("\n" + "-" * 60) + print("Workflow completed. Aggregated results from both agents:") + for msg in output: + if hasattr(msg, "author_name") and msg.author_name: + print(f"\n[{msg.author_name}]:") + text = msg.text[:300] + "..." if len(msg.text) > 300 else msg.text + if text: + print(f" {text}") + elif workflow_completed_without_approvals: + print("\nWorkflow completed without requiring approvals.") + print("(The trading agent may have only checked balance without executing a trade)") + + """ + Sample Output: + Starting concurrent workflow with tool approval... + Two agents will analyze MSFT - one for research, one for trading. + ------------------------------------------------------------ + + Approval requested for tool: execute_trade + Arguments: {"symbol": "MSFT", "action": "buy", "quantity": 10} + Simulating human approval for: execute_trade + + ------------------------------------------------------------ + Workflow completed. Aggregated results from both agents: + + [ResearchAgent]: + MSFT is currently trading at $175.50 with bullish market sentiment + (72% positive mentions). Based on the positive sentiment, this could + be a good opportunity to consider buying. + + [TradingAgent]: + I've checked your portfolio balance ($10,000 cash available) and + executed the trade: BUY 10 shares of MSFT at approximately $175.50 + per share, totaling ~$1,755. + """ + + +if __name__ == "__main__": + asyncio.run(main()) 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 new file mode 100644 index 0000000000..565002c794 --- /dev/null +++ b/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py @@ -0,0 +1,206 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework import ( + FunctionApprovalRequestContent, + GroupChatBuilder, + GroupChatStateSnapshot, + RequestInfoEvent, + ai_function, +) +from agent_framework.openai import OpenAIChatClient + +""" +Sample: Group Chat Workflow with Tool Approval Requests + +This sample demonstrates how to use GroupChatBuilder with tools that require human +approval before execution. A group of specialized agents collaborate on a task, and +sensitive tool calls trigger human-in-the-loop approval. + +This sample works as follows: +1. A GroupChatBuilder workflow is created with multiple specialized agents. +2. A selector function determines which agent speaks next based on conversation state. +3. Agents collaborate on a software deployment task. +4. When the deployment agent tries to deploy to production, it triggers an approval request. +5. The sample simulates human approval and the workflow completes. + +Purpose: +Show how tool call approvals integrate with multi-agent group chat workflows where +different agents have different levels of tool access. + +Demonstrate: +- Using set_select_speakers_func with agents that have approval-required tools. +- Handling RequestInfoEvent in group chat scenarios. +- Multi-round group chat with tool approval interruption and resumption. + +Prerequisites: +- OpenAI or Azure OpenAI configured with the required environment variables. +- Basic familiarity with GroupChatBuilder and streaming workflow events. +""" + + +# 1. Define tools for different agents +@ai_function +def run_tests(test_suite: Annotated[str, "Name of the test suite to run"]) -> str: + """Run automated tests for the application.""" + return f"Test suite '{test_suite}' completed: 47 passed, 0 failed, 0 skipped" + + +@ai_function +def check_staging_status() -> str: + """Check the current status of the staging environment.""" + return "Staging environment: Healthy, Version 2.3.0 deployed, All services running" + + +@ai_function(approval_mode="always_require") +def deploy_to_production( + version: Annotated[str, "The version to deploy"], + components: Annotated[str, "Comma-separated list of components to deploy"], +) -> str: + """Deploy specified components to production. Requires human approval.""" + return f"Production deployment complete: Version {version}, Components: {components}" + + +@ai_function +def create_rollback_plan(version: Annotated[str, "The version being deployed"]) -> str: + """Create a rollback plan for the deployment.""" + return ( + f"Rollback plan created for version {version}: " + "Automated rollback to v2.2.0 if health checks fail within 5 minutes" + ) + + +# 2. Define the speaker selector function +def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: + """Select the next speaker based on the conversation flow. + + This simple selector follows a predefined flow: + 1. QA Engineer runs tests + 2. DevOps Engineer checks staging and creates rollback plan + 3. DevOps Engineer deploys to production (triggers approval) + """ + round_index: int = state["round_index"] + + # Define the conversation flow + speaker_order: list[str] = [ + "QAEngineer", # Round 0: Run tests + "DevOpsEngineer", # Round 1: Check staging, create rollback + "DevOpsEngineer", # Round 2: Deploy to production (approval required) + ] + + if round_index >= len(speaker_order): + return None # End the conversation + + return speaker_order[round_index] + + +async def main() -> None: + # 3. Create specialized agents + chat_client = OpenAIChatClient() + + qa_engineer = chat_client.create_agent( + name="QAEngineer", + instructions=( + "You are a QA engineer responsible for running tests before deployment. " + "Run the appropriate test suites and report results clearly." + ), + tools=[run_tests], + ) + + devops_engineer = chat_client.create_agent( + name="DevOpsEngineer", + instructions=( + "You are a DevOps engineer responsible for deployments. First check staging " + "status and create a rollback plan, then proceed with production deployment. " + "Always ensure safety measures are in place before deploying." + ), + tools=[check_staging_status, create_rollback_plan, deploy_to_production], + ) + + # 4. Build a group chat workflow with the selector function + workflow = ( + GroupChatBuilder() + # Optionally, use `.set_manager(...)` to customize the group chat manager + .set_select_speakers_func(select_next_speaker) + .participants([qa_engineer, devops_engineer]) + .with_max_rounds(5) + .build() + ) + + # 5. Start the workflow + print("Starting group chat workflow for software deployment...") + print("Agents: QA Engineer, DevOps Engineer") + 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( + "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, FunctionApprovalRequestContent): + print("\n[APPROVAL REQUIRED]") + print(f" Tool: {event.data.function_call.name}") + print(f" Arguments: {event.data.function_call.arguments}") + + # 6. Handle approval requests + if request_info_events: + for request_event in request_info_events: + if isinstance(request_event.data, FunctionApprovalRequestContent): + 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.create_response(approved=True) + + # Phase 2: Send approval and continue workflow + async for _ in workflow.send_responses_streaming({request_event.request_id: approval_response}): + pass # Consume all events + + print("\n" + "-" * 60) + print("Deployment workflow completed successfully!") + print("All agents have finished their tasks.") + else: + print("\nWorkflow completed without requiring production deployment approval.") + + """ + Sample Output: + Starting group chat workflow for software deployment... + Agents: QA Engineer, DevOps Engineer + ------------------------------------------------------------ + + [QAEngineer]: Running the integration test suite to verify the application + before deployment... Test suite 'integration' completed: 47 passed, 0 failed. + All tests passing - ready for deployment. + + [DevOpsEngineer]: Checking staging environment status... Staging is healthy + with version 2.3.0. Creating rollback plan for version 2.4.0... Rollback plan + created with automated rollback to v2.2.0 if health checks fail. + + [APPROVAL REQUIRED] + Tool: deploy_to_production + Arguments: {"version": "2.4.0", "components": "api,web,worker"} + + ============================================================ + Human review required for production deployment! + In a real scenario, you would review the deployment details here. + Simulating approval for demo purposes... + ============================================================ + + [DevOpsEngineer]: Production deployment complete! Version 2.4.0 has been + successfully deployed with components: api, web, worker. + + ------------------------------------------------------------ + Deployment workflow completed successfully! + All agents have finished their tasks. + """ + + +if __name__ == "__main__": + asyncio.run(main()) 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 new file mode 100644 index 0000000000..b4b134c75e --- /dev/null +++ b/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework import ( + ChatMessage, + FunctionApprovalRequestContent, + RequestInfoEvent, + SequentialBuilder, + WorkflowOutputEvent, + ai_function, +) +from agent_framework.openai import OpenAIChatClient + +""" +Sample: Sequential Workflow with Tool Approval Requests + +This sample demonstrates how to use SequentialBuilder with tools that require human +approval before execution. The approval flow uses the existing @ai_function decorator +with approval_mode="always_require" to trigger human-in-the-loop interactions. + +This sample works as follows: +1. A SequentialBuilder workflow is created with a single agent that has tools requiring approval. +2. The agent receives a user task and determines it needs to call a sensitive tool. +3. The tool call triggers a FunctionApprovalRequestContent, pausing the workflow. +4. The sample simulates human approval by responding to the RequestInfoEvent. +5. Once approved, the tool executes and the agent completes its response. +6. The workflow outputs the final conversation with all messages. + +Purpose: +Show how tool call approvals integrate seamlessly with SequentialBuilder without +requiring any additional builder configuration. + +Demonstrate: +- Using @ai_function(approval_mode="always_require") for sensitive operations. +- Handling RequestInfoEvent with FunctionApprovalRequestContent in sequential workflows. +- Resuming workflow execution after approval via send_responses_streaming. + +Prerequisites: +- OpenAI or Azure OpenAI configured with the required environment variables. +- Basic familiarity with SequentialBuilder and streaming workflow events. +""" + + +# 1. Define tools - one requiring approval, one that doesn't +@ai_function(approval_mode="always_require") +def execute_database_query( + query: Annotated[str, "The SQL query to execute against the production database"], +) -> str: + """Execute a SQL query against the production database. Requires human approval.""" + # In a real implementation, this would execute the query + return f"Query executed successfully. Results: 3 rows affected by '{query}'" + + +@ai_function +def get_database_schema() -> str: + """Get the current database schema. Does not require approval.""" + return """ + Tables: + - users (id, name, email, created_at) + - orders (id, user_id, total, status, created_at) + - products (id, name, price, stock) + """ + + +async def main() -> None: + # 2. Create the agent with tools (approval mode is set per-tool via decorator) + chat_client = OpenAIChatClient() + database_agent = chat_client.create_agent( + name="DatabaseAgent", + instructions=( + "You are a database assistant. You can view the database schema and execute " + "queries. Always check the schema before running queries. Be careful with " + "queries that modify data." + ), + tools=[get_database_schema, execute_database_query], + ) + + # 3. Build a sequential workflow with the agent + workflow = SequentialBuilder().participants([database_agent]).build() + + # 4. Start the workflow with a user task + 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, FunctionApprovalRequestContent): + print(f"\nApproval requested for tool: {event.data.function_call.name}") + print(f" Arguments: {event.data.function_call.arguments}") + + # 5. Handle approval requests + if request_info_events: + for request_event in request_info_events: + if isinstance(request_event.data, FunctionApprovalRequestContent): + # 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.create_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.value 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).") + + """ + Sample Output: + Starting sequential workflow with tool approval... + ------------------------------------------------------------ + + Approval requested for tool: execute_database_query + Arguments: {"query": "UPDATE orders SET status = 'processing' WHERE status = 'pending'"} + + Simulating human approval (auto-approving for demo)... + + ------------------------------------------------------------ + Workflow completed. Final conversation: + [user]: Check the schema and then update all orders with status 'pending' to 'processing' + [assistant]: I've checked the schema and executed the update query. The query + "UPDATE orders SET status = 'processing' WHERE status = 'pending'" + was executed successfully, affecting 3 rows. + """ + + +if __name__ == "__main__": + asyncio.run(main()) From 00524b42505aef816ab8ab88aad188443c18c2e2 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 4 Dec 2025 14:48:02 +0900 Subject: [PATCH 2/8] Cleanup around naming --- .../agent_framework/_workflows/_concurrent.py | 14 ++--- .../agent_framework/_workflows/_group_chat.py | 13 +++-- .../agent_framework/_workflows/_handoff.py | 50 +++++++++++++++- .../_workflows/_human_input.py | 26 ++++----- .../agent_framework/_workflows/_magentic.py | 13 ++++- .../agent_framework/_workflows/_sequential.py | 20 +++---- .../core/tests/workflow/test_handoff.py | 57 +++++++++++++++++++ .../tests/workflow/test_human_input_hook.py | 18 +++--- .../core/tests/workflow/test_magentic.py | 19 +++++++ 9 files changed, 181 insertions(+), 49 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index 2955622e64..6e4fdeff0a 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -337,18 +337,18 @@ def build(self) -> Workflow: dispatcher = _DispatchToAllParticipants(id="dispatcher") aggregator = self._aggregator or _AggregateAgentConversations(id="aggregator") - # Create human input checkpoint if hook is configured - human_input_checkpoint = self._create_human_input_executor() + # Create human input interceptor if hook is configured + human_input_interceptor = self._create_human_input_executor() builder = WorkflowBuilder() builder.set_start_executor(dispatcher) builder.add_fan_out_edges(dispatcher, list(self._participants)) - if human_input_checkpoint is not None: - # Insert checkpoint between fan-in and aggregator - # participants -> fan-in -> checkpoint -> aggregator - builder.add_fan_in_edges(list(self._participants), human_input_checkpoint) - builder.add_edge(human_input_checkpoint, aggregator) + if human_input_interceptor is not None: + # Insert interceptor between fan-in and aggregator + # participants -> fan-in -> interceptor -> aggregator + builder.add_fan_in_edges(list(self._participants), human_input_interceptor) + builder.add_edge(human_input_interceptor, aggregator) else: # Direct fan-in to aggregator builder.add_fan_in_edges(list(self._participants), aggregator) diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 321d0de10c..da58cc8f8c 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -36,7 +36,7 @@ from ._checkpoint import CheckpointStorage from ._conversation_history import ensure_author, latest_user_message from ._executor import Executor, handler -from ._human_input import HumanInputHookMixin, _HumanInputCheckpoint # type: ignore +from ._human_input import HumanInputHookMixin, _HumanInputInterceptor # type: ignore from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, wrap_participant from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -589,7 +589,8 @@ async def _ingest_participant_message( author = trailing_msg.author_name or "human" self._history.append(_GroupChatTurn(author, "user", trailing_msg)) logger.debug( - f"Injected human input into group chat conversation: {trailing_msg.text[:50] if trailing_msg.text else '(empty)'}..." + f"Injected human input into group chat conversation: " + f"{trailing_msg.text[:50] if trailing_msg.text else '(empty)'}..." ) self._pending_agent = None @@ -1878,12 +1879,12 @@ def _factory_with_human_input( ) -> _GroupChatParticipantPipeline: pipeline = list(base_factory(spec, config)) if pipeline: - # Add checkpoint executor after the participant - checkpoint = _HumanInputCheckpoint( + # Add interceptor executor after the participant + interceptor = _HumanInputInterceptor( hook, - executor_id=f"human_input_checkpoint:{spec.name}", + executor_id=f"human_input_interceptor:{spec.name}", ) - pipeline.append(checkpoint) + pipeline.append(interceptor) return tuple(pipeline) participant_factory = _factory_with_human_input diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 054c53f6e3..46a38a8f5e 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -42,8 +42,10 @@ from ._group_chat import ( _default_participant_factory, # type: ignore[reportPrivateUsage] _GroupChatConfig, # type: ignore[reportPrivateUsage] + _GroupChatParticipantPipeline, # type: ignore[reportPrivateUsage] assemble_group_chat_workflow, ) +from ._human_input import HumanInputHookMixin, _HumanInputInterceptor from ._orchestrator_helpers import clean_conversation_for_handoff from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, sanitize_identifier from ._request_info_mixin import response_handler @@ -307,6 +309,25 @@ def _get_author_name(self) -> str: """Get the coordinator name for orchestrator-generated messages.""" return "handoff_coordinator" + def _extract_agent_id_from_source(self, source: str | None) -> str | None: + """Extract the original agent ID from the source executor ID. + + When a human input interceptor is in the pipeline, the source will be + like 'human_input_interceptor:agent_name'. This method extracts the + actual agent ID. + + Args: + source: The source executor ID from the workflow context + + Returns: + The actual agent ID, or the original source if not an interceptor + """ + if source is None: + return None + if source.startswith("human_input_interceptor:"): + return source[len("human_input_interceptor:") :] + return source + @handler async def handle_agent_response( self, @@ -314,7 +335,8 @@ async def handle_agent_response( ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], list[ChatMessage] | _ConversationForUserInput], ) -> None: """Process an agent's response and determine whether to route, request input, or terminate.""" - source = ctx.get_source_executor_id() + raw_source = ctx.get_source_executor_id() + source = self._extract_agent_id_from_source(raw_source) is_starting_agent = source == self._starting_agent_id # On first turn of a run, conversation is empty @@ -600,7 +622,7 @@ def _default_termination_condition(conversation: list[ChatMessage]) -> bool: return user_message_count >= 10 -class HandoffBuilder: +class HandoffBuilder(HumanInputHookMixin): r"""Fluent builder for conversational handoff workflows with coordinator and specialist agents. The handoff pattern enables a coordinator agent to route requests to specialist agents. @@ -1449,9 +1471,31 @@ def _handoff_orchestrator_factory(_: _GroupChatConfig) -> Executor: participant_executors=self._executors, ) + # Determine participant factory - wrap with human input checkpoint if hook is configured + participant_factory = _default_participant_factory + hook = self._human_input_hook + if hook is not None: + base_factory = _default_participant_factory + + def _factory_with_human_input( + spec: GroupChatParticipantSpec, + config: _GroupChatConfig, + ) -> _GroupChatParticipantPipeline: + pipeline = list(base_factory(spec, config)) + if pipeline: + # Add interceptor executor after the participant + interceptor = _HumanInputInterceptor( + hook, + executor_id=f"human_input_interceptor:{spec.name}", + ) + pipeline.append(interceptor) + return tuple(pipeline) + + participant_factory = _factory_with_human_input + result = assemble_group_chat_workflow( wiring=wiring, - participant_factory=_default_participant_factory, + participant_factory=participant_factory, orchestrator_factory=_handoff_orchestrator_factory, interceptors=(), checkpoint_storage=self._checkpoint_storage, diff --git a/python/packages/core/agent_framework/_workflows/_human_input.py b/python/packages/core/agent_framework/_workflows/_human_input.py index 0a74d00e4f..d491b531bb 100644 --- a/python/packages/core/agent_framework/_workflows/_human_input.py +++ b/python/packages/core/agent_framework/_workflows/_human_input.py @@ -9,7 +9,7 @@ - HumanInputRequest: Standard request type emitted via RequestInfoEvent - HumanInputHook: Callable type alias for hook functions - HumanInputHookMixin: Mixin class providing `.with_human_input_hook()` method -- _HumanInputCheckpoint: Internal executor that intercepts responses and invokes the hook +- _HumanInputInterceptor: Internal executor that intercepts responses and invokes the hook """ import inspect @@ -67,7 +67,7 @@ class HumanInputHookMixin: Builders that inherit this mixin gain the `.with_human_input_hook()` method and internal state management for the hook. The mixin provides a factory - method to create the checkpoint executor when the hook is configured. + method to create the interceptor executor when the hook is configured. """ _human_input_hook: HumanInputHook | None = None @@ -136,23 +136,23 @@ async def check_policy_service( def _create_human_input_executor( self, - executor_id: str = "human_input_checkpoint", - ) -> "_HumanInputCheckpoint | None": - """Factory method for builders to create the checkpoint executor if hook is set. + executor_id: str = "human_input_interceptor", + ) -> "_HumanInputInterceptor | None": + """Factory method for builders to create the interceptor executor if hook is set. Args: - executor_id: ID for the checkpoint executor (default: "human_input_checkpoint") + executor_id: ID for the interceptor executor (default: "human_input_interceptor") Returns: - _HumanInputCheckpoint instance if hook is configured, None otherwise. + _HumanInputInterceptor instance if hook is configured, None otherwise. """ if self._human_input_hook is None: return None - return _HumanInputCheckpoint(self._human_input_hook, executor_id=executor_id) + return _HumanInputInterceptor(self._human_input_hook, executor_id=executor_id) -class _HumanInputCheckpoint(Executor): - """Internal executor that checks for human input after each agent response. +class _HumanInputInterceptor(Executor): + """Internal executor that intercepts agent responses and invokes the human input hook. This executor is inserted into the workflow graph by builders that use the HumanInputHookMixin. It intercepts AgentExecutorResponse messages, @@ -171,13 +171,13 @@ class _HumanInputCheckpoint(Executor): def __init__( self, hook: HumanInputHook, - executor_id: str = "human_input_checkpoint", + executor_id: str = "human_input_interceptor", ) -> None: - """Initialize the checkpoint executor. + """Initialize the interceptor executor. Args: hook: The human input hook callback - executor_id: ID for this executor (default: "human_input_checkpoint") + executor_id: ID for this executor (default: "human_input_interceptor") """ super().__init__(executor_id) self._hook = hook diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index d91cf2a3b8..1e9f283492 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -609,7 +609,7 @@ def _extract_json(text: str) -> dict[str, Any]: The `text` method is concatenating multiple text contents from diff msgs into a single string. """ - fence = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text, flags=re.IGNORECASE) + fence = re.search(r"``(?:json)?\s*(\{[\s\S]*?\})\s*``", text, flags=re.IGNORECASE) if fence: candidate = fence.group(1) else: @@ -2089,6 +2089,17 @@ class MagenticBuilder: The builder provides a fluent API for configuring participants, the manager, optional plan review, checkpointing, and event callbacks. + Human-in-the-loop Support: + Magentic provides specialized HITL mechanisms via: + + - `.with_plan_review()` - Review and approve/revise plans before execution + - `.with_human_input_on_stall()` - Intervene when workflow stalls + - Tool approval via `FunctionApprovalRequestContent` - Approve individual tool calls + + These emit `MagenticHumanInterventionRequest` events that provide structured + decision options (APPROVE, REVISE, CONTINUE, REPLAN, GUIDANCE) appropriate + for Magentic's planning-based orchestration. + Usage: .. code-block:: python diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index 7c36f4a4f7..36ad706cfa 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -187,8 +187,8 @@ def build(self) -> Workflow: input_conv = _InputToConversation(id="input-conversation") end = _EndWithConversation(id="end") - # Create human input checkpoint if hook is configured - human_input_checkpoint = self._create_human_input_executor() + # Create human input interceptor if hook is configured + human_input_interceptor = self._create_human_input_executor() builder = WorkflowBuilder() builder.set_start_executor(input_conv) @@ -199,7 +199,7 @@ def build(self) -> Workflow: for p in self._participants: # Agent-like branch: either explicitly an AgentExecutor or any non-AgentExecutor if not (isinstance(p, Executor) and not isinstance(p, AgentExecutor)): - # input conversation -> (agent) -> [human_input_checkpoint] -> response -> conversation + # input conversation -> (agent) -> [human_input_interceptor] -> response -> conversation builder.add_edge(prior, p) # Give the adapter a deterministic, self-describing id @@ -207,13 +207,13 @@ def build(self) -> Workflow: label = p.id if isinstance(p, Executor) else getattr(p, "name", None) or p.__class__.__name__ resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}") - if human_input_checkpoint is not None: - # Insert human input checkpoint between agent and response converter - # Create a dedicated checkpoint per agent to avoid ID conflicts - checkpoint = self._create_human_input_executor(f"human_input_checkpoint:{label}") - if checkpoint is not None: - builder.add_edge(p, checkpoint) - builder.add_edge(checkpoint, resp_to_conv) + if human_input_interceptor is not None: + # Insert human input interceptor between agent and response converter + # Create a dedicated interceptor per agent to avoid ID conflicts + interceptor = self._create_human_input_executor(f"human_input_interceptor:{label}") + if interceptor is not None: + builder.add_edge(p, interceptor) + builder.add_edge(interceptor, resp_to_conv) else: builder.add_edge(p, resp_to_conv) diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 1f37a33525..353c6fdd21 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -553,6 +553,63 @@ async def mock_get_response(messages: Any, **kwargs: Any) -> ChatResponse: assert str(last_tool_choice) == "required", f"Expected 'required', got {last_tool_choice}" +async def test_handoff_builder_with_human_input_hook(): + """Test that HandoffBuilder supports human input hook via HumanInputHookMixin.""" + from agent_framework import HumanInputRequest + + # Create test agents + coordinator = _RecordingAgent(name="coordinator") + specialist = _RecordingAgent(name="specialist") + + hook_calls: list[tuple[list[ChatMessage], str | None]] = [] + + def test_hook( + conversation: list[ChatMessage], + agent_id: str | None, + ) -> HumanInputRequest | None: + hook_calls.append((list(conversation), agent_id)) + # Return None to continue without requesting input + return None + + # Build workflow with human input hook + workflow = ( + HandoffBuilder(participants=[coordinator, specialist]) + .set_coordinator("coordinator") + .with_termination_condition(lambda conv: len([m for m in conv if m.role == Role.USER]) >= 1) + .with_human_input_hook(test_hook) + .build() + ) + + # Run workflow + events = [e async for e in workflow.run_stream("Hello")] + + # Verify the hook was called (at least once for the coordinator's response) + assert len(hook_calls) > 0, "Human input hook should have been called" + + # Verify we got output events + output_events = [e for e in events if isinstance(e, WorkflowOutputEvent)] + assert len(output_events) > 0, "Should produce output events" + + +async def test_handoff_builder_mixin_method_chaining(): + """Test that with_human_input_hook returns self for method chaining.""" + from agent_framework import HumanInputRequest + + coordinator = _RecordingAgent(name="coordinator") + + def test_hook( + conversation: list[ChatMessage], + agent_id: str | None, + ) -> HumanInputRequest | None: + return None + + builder = HandoffBuilder(participants=[coordinator]) + result = builder.with_human_input_hook(test_hook) + + assert result is builder, "with_human_input_hook should return self for chaining" + assert builder._human_input_hook is test_hook # type: ignore + + async def test_return_to_previous_state_serialization(): """Test that return_to_previous state is properly serialized/deserialized for checkpointing.""" from agent_framework._workflows._handoff import _HandoffCoordinator # type: ignore[reportPrivateUsage] diff --git a/python/packages/core/tests/workflow/test_human_input_hook.py b/python/packages/core/tests/workflow/test_human_input_hook.py index d274b779c3..20e8108190 100644 --- a/python/packages/core/tests/workflow/test_human_input_hook.py +++ b/python/packages/core/tests/workflow/test_human_input_hook.py @@ -8,7 +8,7 @@ HumanInputRequest, Role, ) -from agent_framework._workflows._human_input import _HumanInputCheckpoint # type: ignore +from agent_framework._workflows._human_input import _HumanInputInterceptor # type: ignore class TestHumanInputRequest: @@ -88,12 +88,12 @@ def my_hook( executor = builder._create_human_input_executor("custom_id") # type: ignore assert executor is not None - assert isinstance(executor, _HumanInputCheckpoint) + assert isinstance(executor, _HumanInputInterceptor) assert executor.id == "custom_id" -class TestHumanInputCheckpoint: - """Tests for _HumanInputCheckpoint executor.""" +class TestHumanInputInterceptor: + """Tests for _HumanInputInterceptor executor.""" async def test_invoke_sync_hook(self): """Test invoking a synchronous hook.""" @@ -110,15 +110,15 @@ def sync_hook( ) return None - checkpoint = _HumanInputCheckpoint(sync_hook) + interceptor = _HumanInputInterceptor(sync_hook) # Test hook returns None - result = await checkpoint._invoke_hook([], None) # type: ignore + result = await interceptor._invoke_hook([], None) # type: ignore assert result is None # Test hook returns request conversation = [ChatMessage(role=Role.ASSISTANT, text="Please review this")] - result = await checkpoint._invoke_hook(conversation, "test_agent") # type: ignore + result = await interceptor._invoke_hook(conversation, "test_agent") # type: ignore assert result is not None assert result.prompt == "Review requested" assert result.source_agent_id == "test_agent" @@ -138,10 +138,10 @@ async def async_hook( ) return None - checkpoint = _HumanInputCheckpoint(async_hook) + interceptor = _HumanInputInterceptor(async_hook) # Test async hook returns request conversation = [ChatMessage(role=Role.USER, text="Test")] - result = await checkpoint._invoke_hook(conversation, "async_agent") # type: ignore + result = await interceptor._invoke_hook(conversation, "async_agent") # type: ignore assert result is not None assert result.prompt == "Async review" diff --git a/python/packages/core/tests/workflow/test_magentic.py b/python/packages/core/tests/workflow/test_magentic.py index e9f5dcf70d..71cfc6752a 100644 --- a/python/packages/core/tests/workflow/test_magentic.py +++ b/python/packages/core/tests/workflow/test_magentic.py @@ -857,3 +857,22 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None: assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" + + +def test_magentic_builder_does_not_have_human_input_hook(): + """Test that MagenticBuilder does not expose with_human_input_hook (uses specialized HITL instead). + + Magentic uses specialized human intervention mechanisms: + - with_plan_review() for plan approval + - with_human_input_on_stall() for stall intervention + - Tool approval via FunctionApprovalRequestContent + + These emit MagenticHumanInterventionRequest events with structured decision options. + """ + builder = MagenticBuilder() + + # MagenticBuilder should NOT have the generic human input hook mixin + assert not hasattr(builder, "with_human_input_hook"), ( + "MagenticBuilder should not have with_human_input_hook - " + "use with_plan_review() or with_human_input_on_stall() instead" + ) From a6c7c20cb787d932b0614bdb04761f6311911b1c Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 4 Dec 2025 15:00:35 +0900 Subject: [PATCH 3/8] Fix typing issues --- .../core/agent_framework/_workflows/_handoff.py | 10 ++++++---- .../core/agent_framework/_workflows/_human_input.py | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 46a38a8f5e..b14b985114 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -392,8 +392,8 @@ async def handle_agent_response( cleaned_for_display = clean_conversation_for_handoff(conversation) # The awaiting_agent_id is the agent that just responded and is awaiting user input - # This is the source of the current response - next_agent_id = source + # This is the source of the current response (fallback to starting agent if source is unknown) + next_agent_id = source or self._starting_agent_id message_to_gateway = _ConversationForUserInput(conversation=cleaned_for_display, next_agent_id=next_agent_id) await ctx.send_message(message_to_gateway, target_id=self._input_gateway_id) # type: ignore[arg-type] @@ -1471,8 +1471,10 @@ def _handoff_orchestrator_factory(_: _GroupChatConfig) -> Executor: participant_executors=self._executors, ) - # Determine participant factory - wrap with human input checkpoint if hook is configured - participant_factory = _default_participant_factory + # Determine participant factory - wrap with human input interceptor if hook is configured + participant_factory: Callable[[GroupChatParticipantSpec, _GroupChatConfig], _GroupChatParticipantPipeline] = ( + _default_participant_factory + ) hook = self._human_input_hook if hook is not None: base_factory = _default_participant_factory diff --git a/python/packages/core/agent_framework/_workflows/_human_input.py b/python/packages/core/agent_framework/_workflows/_human_input.py index d491b531bb..1fb9df3926 100644 --- a/python/packages/core/agent_framework/_workflows/_human_input.py +++ b/python/packages/core/agent_framework/_workflows/_human_input.py @@ -16,7 +16,7 @@ import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import Any, TypeAlias +from typing import Any, TypeAlias, cast from typing_extensions import Self @@ -198,8 +198,8 @@ async def _invoke_hook( """ result = self._hook(conversation, agent_id) if inspect.iscoroutine(result): - return await result - return result # type: ignore[return-value] + return cast("HumanInputRequest | None", await result) + return cast("HumanInputRequest | None", result) @handler async def check_for_input( From d1ad27870a426ff97f876b0e71ed91c17ea4d9fc Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 4 Dec 2025 16:09:46 +0900 Subject: [PATCH 4/8] Clean up --- python/packages/core/agent_framework/_workflows/_magentic.py | 2 +- .../packages/core/agent_framework/_workflows/_sequential.py | 5 +---- python/packages/core/tests/workflow/test_human_input_hook.py | 4 ++-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index 1e9f283492..a24fd77b16 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -609,7 +609,7 @@ def _extract_json(text: str) -> dict[str, Any]: The `text` method is concatenating multiple text contents from diff msgs into a single string. """ - fence = re.search(r"``(?:json)?\s*(\{[\s\S]*?\})\s*``", text, flags=re.IGNORECASE) + fence = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text, flags=re.IGNORECASE) if fence: candidate = fence.group(1) else: diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index 36ad706cfa..a03bc1fa82 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -187,9 +187,6 @@ def build(self) -> Workflow: input_conv = _InputToConversation(id="input-conversation") end = _EndWithConversation(id="end") - # Create human input interceptor if hook is configured - human_input_interceptor = self._create_human_input_executor() - builder = WorkflowBuilder() builder.set_start_executor(input_conv) @@ -207,7 +204,7 @@ def build(self) -> Workflow: label = p.id if isinstance(p, Executor) else getattr(p, "name", None) or p.__class__.__name__ resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}") - if human_input_interceptor is not None: + if self._human_input_hook is not None: # Insert human input interceptor between agent and response converter # Create a dedicated interceptor per agent to avoid ID conflicts interceptor = self._create_human_input_executor(f"human_input_interceptor:{label}") diff --git a/python/packages/core/tests/workflow/test_human_input_hook.py b/python/packages/core/tests/workflow/test_human_input_hook.py index 20e8108190..9f8fcd05a5 100644 --- a/python/packages/core/tests/workflow/test_human_input_hook.py +++ b/python/packages/core/tests/workflow/test_human_input_hook.py @@ -71,8 +71,8 @@ class TestBuilder(HumanInputHookMixin): assert executor is None - def test_create_executor_returns_checkpoint_with_hook(self): - """Test that _create_human_input_executor returns a checkpoint when hook is set.""" + def test_create_executor_returns_interceptor_with_hook(self): + """Test that _create_human_input_executor returns an interceptor when hook is set.""" class TestBuilder(HumanInputHookMixin): pass From 2be82bcc2d4297861f64b78650713611aed40226 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 4 Dec 2025 16:16:23 +0900 Subject: [PATCH 5/8] Naming clean up --- .../core/agent_framework/_workflows/_concurrent.py | 2 +- .../core/agent_framework/_workflows/_group_chat.py | 8 ++++---- .../core/agent_framework/_workflows/_sequential.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index 6e4fdeff0a..df3abe3905 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -313,7 +313,7 @@ def build(self) -> Workflow: Wiring pattern: - Dispatcher (internal) fans out the input to all `participants` - Fan-in collects `AgentExecutorResponse` objects from all participants - - If human input hook is configured, the checkpoint executor checks for input + - If human input hook is configured, the interceptor executor checks for input before passing results to the aggregator - Aggregator yields output and the workflow becomes idle. The output is either: - list[ChatMessage] (default aggregator: one user + one assistant per agent) diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index da58cc8f8c..36fc4dcf84 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -709,7 +709,7 @@ async def _handle_manager_response( logic for agent-based managers. Also handles any human input that was injected into the response's full_conversation - by the human input hook checkpoint. + by the human input hook interceptor. Args: response: AgentExecutor response from manager agent @@ -856,7 +856,7 @@ def _extract_trailing_user_messages(response: AgentExecutorResponse) -> list[Cha """Extract any user messages that appear after the last assistant message. This is used to capture human input that was injected by the human input hook - checkpoint. The hook adds user messages to full_conversation after the agent's + interceptor. The hook adds user messages to full_conversation after the agent's response, so they appear at the end of the sequence. Args: @@ -1059,7 +1059,7 @@ async def handle_agent_executor_response( - Participant responses: ingested as conversation messages Also handles any human input that was injected into the response's full_conversation - by the human input hook checkpoint. + by the human input hook interceptor. """ participant_name = self._registry.get_participant_name(response.executor_id) if participant_name is None: @@ -1869,7 +1869,7 @@ def build(self) -> Workflow: # Determine participant factory - wrap if human input hook is configured participant_factory = self._participant_factory if self._human_input_hook is not None: - # Create a wrapper factory that adds human input checkpoint to each pipeline + # Create a wrapper factory that adds human input interceptor to each pipeline base_factory = participant_factory hook = self._human_input_hook diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index a03bc1fa82..b15eb914bd 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -175,7 +175,7 @@ def build(self) -> Workflow: - _InputToConversation normalizes the initial input into list[ChatMessage] - For each participant in order: - If Agent (or AgentExecutor): pass conversation to the agent, then optionally - route through human input checkpoint, then convert response to conversation + route through human input interceptor, then convert response to conversation via _ResponseToConversation - Else (custom Executor): pass conversation directly to the executor - _EndWithConversation yields the final conversation and the workflow becomes idle From 43302998103dfd839e5f000a61c8c36926857977 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 9 Dec 2025 13:00:16 +0900 Subject: [PATCH 6/8] Updates to HITL to make it cleaner --- .../agent_framework/_workflows/__init__.py | 14 +- .../_base_group_chat_orchestrator.py | 8 +- .../agent_framework/_workflows/_concurrent.py | 46 +- .../agent_framework/_workflows/_group_chat.py | 115 ++++- .../agent_framework/_workflows/_handoff.py | 94 +++- .../_workflows/_human_input.py | 480 +++++++++--------- .../_workflows/_orchestrator_helpers.py | 12 +- .../agent_framework/_workflows/_sequential.py | 91 +++- .../core/agent_framework/observability.py | 4 +- .../core/tests/workflow/test_group_chat.py | 103 ++++ .../core/tests/workflow/test_handoff.py | 59 +-- .../tests/workflow/test_human_input_hook.py | 242 +++++---- .../tests/workflow/test_workflow_builder.py | 19 +- .../getting_started/workflows/README.md | 6 +- ...put_hook.py => concurrent_request_info.py} | 126 ++--- .../group_chat_human_input_hook.py | 227 --------- .../group_chat_request_info.py | 175 +++++++ .../sequential_human_input_hook.py | 179 ------- .../sequential_request_info.py | 128 +++++ 19 files changed, 1130 insertions(+), 998 deletions(-) rename python/samples/getting_started/workflows/human-in-the-loop/{concurrent_human_input_hook.py => concurrent_request_info.py} (62%) delete mode 100644 python/samples/getting_started/workflows/human-in-the-loop/group_chat_human_input_hook.py create mode 100644 python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py delete mode 100644 python/samples/getting_started/workflows/human-in-the-loop/sequential_human_input_hook.py create mode 100644 python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index a9a2ad2cec..479669355c 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -65,12 +65,7 @@ ManagerSelectionResponse, ) from ._handoff import HandoffBuilder, HandoffUserInputRequest -from ._human_input import ( - HumanInputHook, - HumanInputHookMixin, - HumanInputHookResult, - HumanInputRequest, -) +from ._human_input import AgentInputRequest, AgentResponseReviewRequest, RequestInfoInterceptor from ._magentic import ( MAGENTIC_EVENT_TYPE_AGENT_DELTA, MAGENTIC_EVENT_TYPE_ORCHESTRATOR, @@ -128,6 +123,8 @@ "AgentExecutor", "AgentExecutorRequest", "AgentExecutorResponse", + "AgentInputRequest", + "AgentResponseReviewRequest", "AgentRunEvent", "AgentRunUpdateEvent", "Case", @@ -151,10 +148,6 @@ "GroupChatStateSnapshot", "HandoffBuilder", "HandoffUserInputRequest", - "HumanInputHook", - "HumanInputHookMixin", - "HumanInputHookResult", - "HumanInputRequest", "InMemoryCheckpointStorage", "InProcRunnerContext", "MagenticBuilder", @@ -174,6 +167,7 @@ "Message", "OrchestrationState", "RequestInfoEvent", + "RequestInfoInterceptor", "Runner", "RunnerContext", "SequentialBuilder", diff --git a/python/packages/core/agent_framework/_workflows/_base_group_chat_orchestrator.py b/python/packages/core/agent_framework/_workflows/_base_group_chat_orchestrator.py index 8b49de740c..5576246a8e 100644 --- a/python/packages/core/agent_framework/_workflows/_base_group_chat_orchestrator.py +++ b/python/packages/core/agent_framework/_workflows/_base_group_chat_orchestrator.py @@ -47,7 +47,9 @@ def __init__(self, executor_id: str) -> None: self._max_rounds: int | None = None self._termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] | None = None - def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool) -> None: + def register_participant_entry( + self, name: str, *, entry_id: str, is_agent: bool, exit_id: str | None = None + ) -> None: """Record routing details for a participant's entry executor. This method provides a unified interface for registering participants @@ -57,8 +59,10 @@ def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool name: Participant name (used for selection and tracking) entry_id: Executor ID for this participant's entry point is_agent: Whether this is an AgentExecutor (True) or custom Executor (False) + exit_id: Executor ID for this participant's exit point (where responses come from). + If None, defaults to entry_id. """ - self._registry.register(name, entry_id=entry_id, is_agent=is_agent) + self._registry.register(name, entry_id=entry_id, is_agent=is_agent, exit_id=exit_id) # Conversation state management (shared across all patterns) diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index df3abe3905..171d24ce4b 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -13,7 +13,7 @@ from ._agent_executor import AgentExecutorRequest, AgentExecutorResponse from ._checkpoint import CheckpointStorage from ._executor import Executor, handler -from ._human_input import HumanInputHookMixin +from ._human_input import RequestInfoInterceptor from ._message_utils import normalize_messages_input from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -185,7 +185,7 @@ async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowCon await ctx.yield_output(ret) -class ConcurrentBuilder(HumanInputHookMixin): +class ConcurrentBuilder: r"""High-level builder for concurrent agent workflows. - `participants([...])` accepts a list of AgentProtocol (recommended) or Executor. @@ -210,25 +210,18 @@ def summarize(results): workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_custom_aggregator(summarize).build() - # Enable checkpoint persistence so runs can resume workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_checkpointing(storage).build() - - # Enable human input hook for review before aggregation - def request_review(conversation, agent_id): - # For concurrent workflows, agent_id is None and conversation contains - # merged outputs from all parallel agents - return HumanInputRequest(prompt="Review all outputs:", conversation=conversation) - - - workflow = ConcurrentBuilder().participants([agent1, agent2]).with_human_input_hook(request_review).build() + # Enable request info before aggregation + workflow = ConcurrentBuilder().participants([agent1, agent2]).with_request_info().build() """ def __init__(self) -> None: self._participants: list[AgentProtocol | Executor] = [] self._aggregator: Executor | None = None self._checkpoint_storage: CheckpointStorage | None = None + self._request_info_enabled: bool = False def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "ConcurrentBuilder": r"""Define the parallel participants for this concurrent workflow. @@ -307,6 +300,25 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Concurre self._checkpoint_storage = checkpoint_storage return self + def with_request_info(self) -> "ConcurrentBuilder": + """Enable request info before aggregation in the workflow. + + When enabled, the workflow pauses after all parallel agents complete, + emitting a RequestInfoEvent that allows the caller to review and optionally + modify the combined results before aggregation. The caller provides feedback + via the standard response_handler/request_info pattern. + + Note: + Unlike SequentialBuilder and GroupChatBuilder, ConcurrentBuilder does not + support per-agent filtering since all agents run in parallel and results + are collected together. The pause occurs once with all agent outputs combined. + + Returns: + self: The builder instance for fluent chaining. + """ + self._request_info_enabled = True + return self + def build(self) -> Workflow: r"""Build and validate the concurrent workflow. @@ -337,18 +349,16 @@ def build(self) -> Workflow: dispatcher = _DispatchToAllParticipants(id="dispatcher") aggregator = self._aggregator or _AggregateAgentConversations(id="aggregator") - # Create human input interceptor if hook is configured - human_input_interceptor = self._create_human_input_executor() - builder = WorkflowBuilder() builder.set_start_executor(dispatcher) builder.add_fan_out_edges(dispatcher, list(self._participants)) - if human_input_interceptor is not None: + if self._request_info_enabled: # Insert interceptor between fan-in and aggregator # participants -> fan-in -> interceptor -> aggregator - builder.add_fan_in_edges(list(self._participants), human_input_interceptor) - builder.add_edge(human_input_interceptor, aggregator) + request_info_interceptor = RequestInfoInterceptor(executor_id="request_info") + builder.add_fan_in_edges(list(self._participants), request_info_interceptor) + builder.add_edge(request_info_interceptor, aggregator) else: # Direct fan-in to aggregator builder.add_fan_in_edges(list(self._participants), aggregator) diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 36fc4dcf84..bdf372e3fa 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -36,7 +36,7 @@ from ._checkpoint import CheckpointStorage from ._conversation_history import ensure_author, latest_user_message from ._executor import Executor, handler -from ._human_input import HumanInputHookMixin, _HumanInputInterceptor # type: ignore +from ._human_input import RequestInfoInterceptor from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, wrap_participant from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -1176,13 +1176,14 @@ def assemble_group_chat_workflow( manager_entry = manager_pipeline[0] manager_exit = manager_pipeline[-1] - # Register manager with orchestrator + # Register manager with orchestrator (with entry and exit IDs for pipeline routing) register_entry = getattr(orchestrator, "register_participant_entry", None) if callable(register_entry): register_entry( wiring.manager_name, entry_id=manager_entry.id, is_agent=not isinstance(wiring.manager_participant, Executor), + exit_id=manager_exit.id if manager_exit is not manager_entry else None, ) # Wire manager edges: Orchestrator ↔ Manager @@ -1205,10 +1206,13 @@ def assemble_group_chat_workflow( register_entry = getattr(orchestrator, "register_participant_entry", None) if callable(register_entry): + # Register both entry and exit IDs so responses can be routed correctly + # when interceptors are prepended to the pipeline register_entry( name, entry_id=entry_executor.id, is_agent=not isinstance(spec.participant, Executor), + exit_id=exit_executor.id if exit_executor is not entry_executor else None, ) workflow_builder = workflow_builder.add_edge(orchestrator, entry_executor) @@ -1236,7 +1240,7 @@ def assemble_group_chat_workflow( # region Builder -class GroupChatBuilder(HumanInputHookMixin): +class GroupChatBuilder: r"""High-level builder for manager-directed group chat workflows with dynamic orchestration. GroupChat coordinates multi-agent conversations using a manager that selects which participant @@ -1300,28 +1304,27 @@ def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: .build() ) - *Pattern 3: Human input hook for mid-conversation feedback* + *Pattern 3: Request info for mid-conversation feedback* .. code-block:: python - from agent_framework import GroupChatBuilder, HumanInputRequest - - - def request_review(conversation, agent_id): - if "review" in conversation[-1].text.lower(): - return HumanInputRequest( - prompt="Please review and provide feedback:", - conversation=conversation, - source_agent_id=agent_id, - ) - return None - + from agent_framework import GroupChatBuilder + # Pause before all participants workflow = ( GroupChatBuilder() .set_select_speakers_func(select_next_speaker) .participants([researcher, writer]) - .with_human_input_hook(request_review) + .with_request_info() + .build() + ) + + # Pause only before specific participants + workflow = ( + GroupChatBuilder() + .set_select_speakers_func(select_next_speaker) + .participants([researcher, writer, editor]) + .with_request_info(agents=[editor]) # Only pause before editor responds .build() ) @@ -1374,6 +1377,8 @@ def __init__( self._interceptors: list[_InterceptorSpec] = [] self._orchestrator_factory = group_chat_orchestrator(_orchestrator_factory) self._participant_factory = _participant_factory or _default_participant_factory + self._request_info_enabled: bool = False + self._request_info_filter: set[str] | None = None def _set_manager_function( self, @@ -1450,6 +1455,12 @@ def set_manager( Note: The manager agent's response_format must be ManagerSelectionResponse for structured output. Custom response formats raise ValueError instead of being overridden. + + The manager can be included in :py:meth:`with_request_info` to pause before the manager + runs, allowing human steering of orchestration decisions. If no filter is specified, + the manager is included automatically. To filter explicitly:: + + .with_request_info(agents=[manager, writer]) # Pause before manager and writer """ if self._manager is not None or self._manager_participant is not None: raise ValueError( @@ -1780,6 +1791,54 @@ def with_max_rounds(self, max_rounds: int | None) -> "GroupChatBuilder": self._max_rounds = max_rounds return self + def with_request_info( + self, + *, + agents: Sequence[str | AgentProtocol | Executor] | None = None, + ) -> "GroupChatBuilder": + """Enable request info before participants run in the workflow. + + When enabled, the workflow pauses before each participant runs, emitting + a RequestInfoEvent that allows the caller to review the conversation and + optionally inject guidance before the participant responds. The caller provides + input via the standard response_handler/request_info pattern. + + Args: + agents: Optional filter - only pause before these specific agents/executors. + Accepts agent names (str), agent instances, or executor instances. + If None (default), pauses before every participant. + + Returns: + self: The builder instance for fluent chaining. + + Example: + + .. code-block:: python + + # Pause before all participants + workflow = ( + GroupChatBuilder() + .set_manager(manager) + .participants([optimist, pragmatist, creative]) + .with_request_info() + .build() + ) + + # Pause only before specific participants + workflow = ( + GroupChatBuilder() + .set_manager(manager) + .participants([optimist, pragmatist, creative]) + .with_request_info(agents=[pragmatist]) # Only pause before pragmatist + .build() + ) + """ + from ._human_input import resolve_request_info_filter + + self._request_info_enabled = True + self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None) + return self + def _get_participant_metadata(self) -> dict[str, Any]: if self._participant_metadata is None: self._participant_metadata = prepare_participant_metadata( @@ -1866,28 +1925,28 @@ def build(self) -> Workflow: participant_executors=metadata["executors"], ) - # Determine participant factory - wrap if human input hook is configured + # Determine participant factory - wrap if request info is enabled participant_factory = self._participant_factory - if self._human_input_hook is not None: - # Create a wrapper factory that adds human input interceptor to each pipeline + if self._request_info_enabled: + # Create a wrapper factory that adds request info interceptor before each participant base_factory = participant_factory - hook = self._human_input_hook + agent_filter = self._request_info_filter - def _factory_with_human_input( + def _factory_with_request_info( spec: GroupChatParticipantSpec, config: _GroupChatConfig, ) -> _GroupChatParticipantPipeline: pipeline = list(base_factory(spec, config)) if pipeline: - # Add interceptor executor after the participant - interceptor = _HumanInputInterceptor( - hook, - executor_id=f"human_input_interceptor:{spec.name}", + # Add interceptor executor BEFORE the participant (prepend) + interceptor = RequestInfoInterceptor( + executor_id=f"request_info:{spec.name}", + agent_filter=agent_filter, ) - pipeline.append(interceptor) + pipeline.insert(0, interceptor) return tuple(pipeline) - participant_factory = _factory_with_human_input + participant_factory = _factory_with_request_info result = assemble_group_chat_workflow( wiring=wiring, diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index dc58904b79..75e0ba6c64 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -48,7 +48,7 @@ _GroupChatParticipantPipeline, # type: ignore[reportPrivateUsage] assemble_group_chat_workflow, ) -from ._human_input import HumanInputHookMixin, _HumanInputInterceptor +from ._human_input import RequestInfoInterceptor from ._orchestrator_helpers import clean_conversation_for_handoff from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, sanitize_identifier from ._request_info_mixin import response_handler @@ -320,8 +320,8 @@ def _get_author_name(self) -> str: def _extract_agent_id_from_source(self, source: str | None) -> str | None: """Extract the original agent ID from the source executor ID. - When a human input interceptor is in the pipeline, the source will be - like 'human_input_interceptor:agent_name'. This method extracts the + When a request info interceptor is in the pipeline, the source will be + like 'request_info:agent_name'. This method extracts the actual agent ID. Args: @@ -332,6 +332,11 @@ def _extract_agent_id_from_source(self, source: str | None) -> str | None: """ if source is None: return None + if source.startswith("request_info:"): + return source[len("request_info:") :] + # Legacy support + if source.startswith("human_review:"): + return source[len("human_review:") :] if source.startswith("human_input_interceptor:"): return source[len("human_input_interceptor:") :] return source @@ -658,7 +663,7 @@ def _default_termination_condition(conversation: list[ChatMessage]) -> bool: return user_message_count >= 10 -class HandoffBuilder(HumanInputHookMixin): +class HandoffBuilder: r"""Fluent builder for conversational handoff workflows with coordinator and specialist agents. The handoff pattern enables a coordinator agent to route requests to specialist agents. @@ -848,6 +853,8 @@ def __init__( self._return_to_previous: bool = False self._interaction_mode: Literal["human_in_loop", "autonomous"] = "human_in_loop" self._autonomous_turn_limit: int | None = _DEFAULT_AUTONOMOUS_TURN_LIMIT + self._request_info_enabled: bool = False + self._request_info_filter: set[str] | None = None if participants: self.participants(participants) @@ -1440,6 +1447,52 @@ def enable_return_to_previous(self, enabled: bool = True) -> "HandoffBuilder": self._return_to_previous = enabled return self + def with_request_info( + self, + *, + agents: Sequence[str | AgentProtocol | Executor] | None = None, + ) -> "HandoffBuilder": + """Enable request info before participants run in the workflow. + + When enabled, the workflow pauses before each participant runs, emitting + a RequestInfoEvent that allows the caller to review the conversation and + optionally inject guidance before the participant responds. The caller provides + input via the standard response_handler/request_info pattern. + + Args: + agents: Optional filter - only pause before these specific agents/executors. + Accepts agent names (str), agent instances, or executor instances. + If None (default), pauses before every participant. + + Returns: + self: The builder instance for fluent chaining. + + Example: + + .. code-block:: python + + # Pause before all participants + workflow = ( + HandoffBuilder(participants=[coordinator, refund, shipping]) + .set_coordinator("coordinator_agent") + .with_request_info() + .build() + ) + + # Pause only before specialist agents (not coordinator) + workflow = ( + HandoffBuilder(participants=[coordinator, refund, shipping]) + .set_coordinator("coordinator_agent") + .with_request_info(agents=[refund, shipping]) + .build() + ) + """ + from ._human_input import resolve_request_info_filter + + self._request_info_enabled = True + self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None) + return self + def build(self) -> Workflow: """Construct the final Workflow instance from the configured builder. @@ -1584,29 +1637,29 @@ def _handoff_orchestrator_factory(_: _GroupChatConfig) -> Executor: participant_executors=self._executors, ) - # Determine participant factory - wrap with human input interceptor if hook is configured + # Determine participant factory - wrap with request info interceptor if enabled participant_factory: Callable[[GroupChatParticipantSpec, _GroupChatConfig], _GroupChatParticipantPipeline] = ( _default_participant_factory ) - hook = self._human_input_hook - if hook is not None: + if self._request_info_enabled: base_factory = _default_participant_factory + agent_filter = self._request_info_filter - def _factory_with_human_input( + def _factory_with_request_info( spec: GroupChatParticipantSpec, config: _GroupChatConfig, ) -> _GroupChatParticipantPipeline: pipeline = list(base_factory(spec, config)) if pipeline: - # Add interceptor executor after the participant - interceptor = _HumanInputInterceptor( - hook, - executor_id=f"human_input_interceptor:{spec.name}", + # Add interceptor executor BEFORE the participant (prepend) + interceptor = RequestInfoInterceptor( + executor_id=f"request_info:{spec.name}", + agent_filter=agent_filter, ) - pipeline.append(interceptor) + pipeline.insert(0, interceptor) return tuple(pipeline) - participant_factory = _factory_with_human_input + participant_factory = _factory_with_request_info result = assemble_group_chat_workflow( wiring=wiring, @@ -1621,7 +1674,18 @@ def _factory_with_human_input( raise TypeError("Expected tuple from assemble_group_chat_workflow with return_builder=True") builder, coordinator = result - builder = builder.add_edge(input_node, starting_executor) + # When request_info is enabled, the input should go through the interceptor first + if self._request_info_enabled: + # Get the entry executor from the builder's registered executors + starting_entry_id = f"request_info:{self._starting_agent_id}" + starting_entry_executor = builder._executors.get(starting_entry_id) # type: ignore + if starting_entry_executor: + builder = builder.add_edge(input_node, starting_entry_executor) + else: + # Fallback to direct connection if interceptor not found + builder = builder.add_edge(input_node, starting_executor) + else: + builder = builder.add_edge(input_node, starting_executor) builder = builder.add_edge(coordinator, user_gateway) builder = builder.add_edge(user_gateway, coordinator) diff --git a/python/packages/core/agent_framework/_workflows/_human_input.py b/python/packages/core/agent_framework/_workflows/_human_input.py index 1fb9df3926..cfd2419994 100644 --- a/python/packages/core/agent_framework/_workflows/_human_input.py +++ b/python/packages/core/agent_framework/_workflows/_human_input.py @@ -1,27 +1,26 @@ # Copyright (c) Microsoft. All rights reserved. -"""Human input hook support for high-level builder APIs. +"""Request info support for high-level builder APIs. -This module provides infrastructure for requesting arbitrary human feedback -mid-workflow in `SequentialBuilder`, `ConcurrentBuilder`, and `GroupChatBuilder`. +This module provides a mechanism for pausing workflows to request external input +before agent turns in `SequentialBuilder`, `ConcurrentBuilder`, `GroupChatBuilder`, +and `HandoffBuilder`. + +The design follows the standard `request_info` pattern used throughout the +workflow system, keeping the API consistent and predictable. Key components: -- HumanInputRequest: Standard request type emitted via RequestInfoEvent -- HumanInputHook: Callable type alias for hook functions -- HumanInputHookMixin: Mixin class providing `.with_human_input_hook()` method -- _HumanInputInterceptor: Internal executor that intercepts responses and invokes the hook +- AgentInputRequest: Request type emitted via RequestInfoEvent for pre-agent steering +- RequestInfoInterceptor: Internal executor that pauses workflow before agent runs """ -import inspect import logging -from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import Any, TypeAlias, cast - -from typing_extensions import Self +from typing import Any +from .._agents import AgentProtocol from .._types import ChatMessage, Role -from ._agent_executor import AgentExecutorResponse +from ._agent_executor import AgentExecutorRequest from ._executor import Executor, handler from ._request_info_mixin import response_handler from ._workflow_context import WorkflowContext @@ -29,308 +28,291 @@ logger = logging.getLogger(__name__) -@dataclass -class HumanInputRequest: - """Request for human input in high-level builder workflows. +def resolve_request_info_filter( + agents: list[str | AgentProtocol | Executor] | None, +) -> set[str] | None: + """Resolve a list of agent/executor references to a set of IDs for filtering. - Emitted via RequestInfoEvent when a workflow needs human guidance beyond - binary tool approval. The human's response is injected into the conversation - as a user message before the workflow continues. + Args: + agents: List of agent names (str), AgentProtocol instances, or Executor instances. + If None, returns None (meaning no filtering - pause for all). - Attributes: - prompt: Human-readable prompt explaining what input is needed - conversation: Full conversation history at the time of the request - source_agent_id: ID of the agent whose output triggered the request (if known) - metadata: Optional builder-specific context (round index, agent name, etc.) + Returns: + Set of executor/agent IDs to filter on, or None if no filtering. """ + if agents is None: + return None + + result: set[str] = set() + for agent in agents: + if isinstance(agent, str): + result.add(agent) + elif isinstance(agent, Executor): + result.add(agent.id) + elif isinstance(agent, AgentProtocol): + name = getattr(agent, "name", None) + if name: + result.add(name) + else: + logger.warning("AgentProtocol without name cannot be used for request_info filtering") + else: + logger.warning(f"Unsupported type for request_info filter: {type(agent).__name__}") - prompt: str - conversation: list[ChatMessage] = field(default_factory=lambda: []) - source_agent_id: str | None = None - metadata: dict[str, Any] = field(default_factory=lambda: {}) - + return result if result else None -# Type alias for human input hook result -HumanInputHookResult: TypeAlias = HumanInputRequest | None -# Type alias for the human input hook callback -# Accepts (conversation, agent_id) and returns HumanInputRequest to pause or None to continue -# Supports both sync and async callbacks -HumanInputHook: TypeAlias = Callable[ - [list[ChatMessage], str | None], - HumanInputHookResult | Awaitable[HumanInputHookResult], -] +@dataclass +class AgentInputRequest: + """Request for human input before an agent runs in high-level builder workflows. + Emitted via RequestInfoEvent when a workflow pauses before an agent executes. + The response is injected into the conversation as a user message to steer + the agent's behavior. -class HumanInputHookMixin: - """Mixin providing human input hook capability for high-level builders. + This is the standard request type used by `.with_request_info()` on + SequentialBuilder, ConcurrentBuilder, GroupChatBuilder, and HandoffBuilder. - Builders that inherit this mixin gain the `.with_human_input_hook()` method - and internal state management for the hook. The mixin provides a factory - method to create the interceptor executor when the hook is configured. + Attributes: + target_agent_id: ID of the agent that is about to run + conversation: Current conversation history the agent will receive + instruction: Optional instruction from the orchestrator (e.g., manager in GroupChat) + metadata: Builder-specific context (stores internal state for resume) """ - _human_input_hook: HumanInputHook | None = None - - def with_human_input_hook( - self: Self, - hook: HumanInputHook, - ) -> Self: - """Add a hook that can request human input between agent turns. - - The hook is called after each agent completes. If it returns a - HumanInputRequest, the workflow pauses and emits a RequestInfoEvent. - The human's response is injected into the conversation as a user - message before the next agent runs. - - The hook can be either sync or async. Async hooks are awaited internally. - - Args: - hook: Callback that receives (conversation, agent_id) and returns - HumanInputRequest to pause, or None to continue. Can be - sync or async. - - Returns: - Self for method chaining. - - Example: - - .. code-block:: python - - # Sync hook - simple inspection - def review_on_keyword( - conversation: list[ChatMessage], - agent_id: str | None, - ) -> HumanInputRequest | None: - if conversation and "review" in conversation[-1].text.lower(): - return HumanInputRequest( - prompt="Please review and provide feedback:", - conversation=conversation, - source_agent_id=agent_id, - ) - return None - - - # Async hook - checks external service - async def check_policy_service( - conversation: list[ChatMessage], - agent_id: str | None, - ) -> HumanInputRequest | None: - requires_review = await policy_api.check_content(conversation[-1].text) - if requires_review: - return HumanInputRequest( - prompt="Content flagged for review:", - conversation=conversation, - source_agent_id=agent_id, - ) - return None - - - # Both work the same way - workflow = ( - SequentialBuilder().participants([agent1, agent2]).with_human_input_hook(check_policy_service).build() - ) - """ - self._human_input_hook = hook - return self - - def _create_human_input_executor( - self, - executor_id: str = "human_input_interceptor", - ) -> "_HumanInputInterceptor | None": - """Factory method for builders to create the interceptor executor if hook is set. + target_agent_id: str | None + conversation: list[ChatMessage] = field(default_factory=lambda: []) + instruction: str | None = None + metadata: dict[str, Any] = field(default_factory=lambda: {}) - Args: - executor_id: ID for the interceptor executor (default: "human_input_interceptor") - Returns: - _HumanInputInterceptor instance if hook is configured, None otherwise. - """ - if self._human_input_hook is None: - return None - return _HumanInputInterceptor(self._human_input_hook, executor_id=executor_id) +# Keep legacy name as alias for backward compatibility +AgentResponseReviewRequest = AgentInputRequest -class _HumanInputInterceptor(Executor): - """Internal executor that intercepts agent responses and invokes the human input hook. +class RequestInfoInterceptor(Executor): + """Internal executor that pauses workflow for human input before agent runs. - This executor is inserted into the workflow graph by builders that use - the HumanInputHookMixin. It intercepts AgentExecutorResponse messages, - invokes the configured hook, and either: - - Passes through the response unchanged (hook returns None) - - Pauses the workflow via ctx.request_info() (hook returns HumanInputRequest) + This executor is inserted into the workflow graph by builders when + `.with_request_info()` is called. It intercepts AgentExecutorRequest messages + BEFORE the agent runs and pauses the workflow via `ctx.request_info()` with + an AgentInputRequest. - When the human responds, the response handler injects the human's input - as a user message into the conversation and continues the workflow. + When a response is received, the response handler injects the input + as a user message into the conversation and forwards the request to the agent. - For ConcurrentBuilder, this executor also handles list[AgentExecutorResponse] - from fan-in aggregation. In that case, all conversations are merged before - invoking the hook with agent_id=None. + The optional `agent_filter` parameter allows limiting which agents trigger the pause. + If the target agent's ID is not in the filter set, the request is forwarded + without pausing. """ def __init__( self, - hook: HumanInputHook, - executor_id: str = "human_input_interceptor", + executor_id: str = "request_info_interceptor", + agent_filter: set[str] | None = None, ) -> None: - """Initialize the interceptor executor. + """Initialize the request info interceptor executor. Args: - hook: The human input hook callback - executor_id: ID for this executor (default: "human_input_interceptor") + executor_id: ID for this executor (default: "request_info_interceptor") + agent_filter: Optional set of agent/executor IDs to filter on. + If provided, only requests to these agents trigger a pause. + If None (default), all requests trigger a pause. """ super().__init__(executor_id) - self._hook = hook + self._agent_filter = agent_filter + + def _should_pause_for_agent(self, agent_id: str | None) -> bool: + """Check if we should pause for the given agent ID.""" + if self._agent_filter is None: + return True + if agent_id is None: + return False + # Check both the full ID and any name portion after a prefix + # e.g., "groupchat_agent:writer" should match filter "writer" + if agent_id in self._agent_filter: + return True + # Extract name from prefixed IDs like "groupchat_agent:writer" or "request_info:writer" + if ":" in agent_id: + name_part = agent_id.split(":", 1)[1] + if name_part in self._agent_filter: + return True + return False + + def _extract_agent_name_from_executor_id(self) -> str | None: + """Extract the agent name from this interceptor's executor ID. + + The interceptor ID is typically "request_info:", so we + extract the agent name to determine which agent we're intercepting for. + """ + if ":" in self.id: + return self.id.split(":", 1)[1] + return None - async def _invoke_hook( + @handler + async def intercept_agent_request( self, - conversation: list[ChatMessage], - agent_id: str | None, - ) -> HumanInputRequest | None: - """Invoke the hook, handling both sync and async callbacks. + request: AgentExecutorRequest, + ctx: WorkflowContext[AgentExecutorRequest, Any], + ) -> None: + """Intercept request before agent runs and pause for human input. - Args: - conversation: Current conversation history - agent_id: ID of the agent that produced the last response + Pauses the workflow and emits a RequestInfoEvent with the current + conversation for steering. If an agent filter is configured and this + agent is not in the filter, the request is forwarded without pausing. - Returns: - HumanInputRequest if human input is needed, None otherwise + Args: + request: The request about to be sent to the agent + ctx: Workflow context for requesting info """ - result = self._hook(conversation, agent_id) - if inspect.iscoroutine(result): - return cast("HumanInputRequest | None", await result) - return cast("HumanInputRequest | None", result) + # Determine the target agent from our executor ID + target_agent = self._extract_agent_name_from_executor_id() + + # Check if we should pause for this agent + if not self._should_pause_for_agent(target_agent): + logger.debug(f"Skipping request_info pause for agent {target_agent} (not in filter)") + await ctx.send_message(request) + return + + conversation = list(request.messages or []) + + input_request = AgentInputRequest( + target_agent_id=target_agent, + conversation=conversation, + instruction=None, # Could be extended to include manager instruction + metadata={"_original_request": request, "_input_type": "AgentExecutorRequest"}, + ) + await ctx.request_info(input_request, str) @handler - async def check_for_input( + async def intercept_conversation( self, - response: AgentExecutorResponse, - ctx: WorkflowContext[AgentExecutorResponse, Any], + messages: list[ChatMessage], + ctx: WorkflowContext[list[ChatMessage], Any], ) -> None: - """Check if human input is needed after an agent response. + """Intercept conversation before agent runs (used by SequentialBuilder). - If the hook returns a HumanInputRequest, the workflow pauses and emits - a RequestInfoEvent. Otherwise, the response passes through unchanged. + SequentialBuilder passes list[ChatMessage] directly to agents. This handler + intercepts that flow and pauses for human input. Args: - response: The agent's response to check - ctx: Workflow context for sending messages or requesting info + messages: The conversation about to be sent to the agent + ctx: Workflow context for requesting info """ - conversation = list(response.full_conversation or []) - agent_id = response.executor_id - - request = await self._invoke_hook(conversation, agent_id) - if request is not None: - # Store the original response so we can continue after human input - request.metadata["_original_response"] = response - await ctx.request_info(request, str) - else: - # No human input needed, pass through the response - await ctx.send_message(response) + # Determine the target agent from our executor ID + target_agent = self._extract_agent_name_from_executor_id() + + # Check if we should pause for this agent + if not self._should_pause_for_agent(target_agent): + logger.debug(f"Skipping request_info pause for agent {target_agent} (not in filter)") + await ctx.send_message(messages) + return + + input_request = AgentInputRequest( + target_agent_id=target_agent, + conversation=list(messages), + instruction=None, + metadata={"_original_messages": messages, "_input_type": "list[ChatMessage]"}, + ) + await ctx.request_info(input_request, str) @handler - async def check_for_input_concurrent( + async def intercept_concurrent_requests( self, - responses: list[AgentExecutorResponse], - ctx: WorkflowContext[list[AgentExecutorResponse], Any], + requests: list[AgentExecutorRequest], + ctx: WorkflowContext[list[AgentExecutorRequest], Any], ) -> None: - """Check if human input is needed after concurrent agents complete. + """Intercept requests before concurrent agents run. - This handler is used by ConcurrentBuilder to check all parallel agent - outputs before aggregation. The hook is called with a merged view of - all agent conversations and agent_id=None. + This handler is used by ConcurrentBuilder to get human input before + all parallel agents execute. Args: - responses: List of responses from all concurrent agents - ctx: Workflow context for sending messages or requesting info + requests: List of requests for all concurrent agents + ctx: Workflow context for requesting info """ - # Merge all conversations into a combined view for the hook - # Take the first response's conversation as base (they share user prompt) - # then append each agent's final assistant messages + # Combine conversations for display combined_conversation: list[ChatMessage] = [] - if responses: - # Use the first response's full conversation as the base - first_conv = responses[0].full_conversation or [] - combined_conversation = list(first_conv) - - # For subsequent responses, just add their assistant messages to avoid - # duplicating the user prompt. Note: this is a simplified merge. - for resp in responses[1:]: - if resp.agent_run_response and resp.agent_run_response.messages: - combined_conversation.extend(resp.agent_run_response.messages) - - request = await self._invoke_hook(combined_conversation, None) - if request is not None: - # Store the original responses so we can continue after human input - request.metadata["_original_responses"] = responses - await ctx.request_info(request, str) - else: - # No human input needed, pass through the responses - await ctx.send_message(responses) + if requests: + combined_conversation = list(requests[0].messages or []) + + input_request = AgentInputRequest( + target_agent_id=None, # Multiple agents + conversation=combined_conversation, + instruction=None, + metadata={"_original_requests": requests}, + ) + await ctx.request_info(input_request, str) @response_handler - async def handle_human_response( + async def handle_input_response( self, - original_request: HumanInputRequest, + original_request: AgentInputRequest, response: str, - ctx: WorkflowContext[AgentExecutorResponse, Any], + ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], Any], ) -> None: - """Handle the human's response and continue the workflow. + """Handle the human input and forward the modified request to the agent. - Injects the human's response as a user message into the conversation - and forwards the modified AgentExecutorResponse to continue the workflow. + Injects the response as a user message into the conversation + and forwards the modified request to the agent. Args: - original_request: The HumanInputRequest that triggered the pause - response: The human's response text + original_request: The AgentInputRequest that triggered the pause + response: The human input text ctx: Workflow context for continuing the workflow """ - # Check if this is from concurrent (list) or sequential (single) response - original_responses: list[AgentExecutorResponse] | None = original_request.metadata.get("_original_responses") - if original_responses is not None: - # Concurrent case: inject human response and forward list - human_message = ChatMessage(role=Role.USER, text=response) - - # Add the human message to all responses' conversations - updated_responses: list[AgentExecutorResponse] = [] - for orig_resp in original_responses: - conversation = list(orig_resp.full_conversation or []) - conversation.append(human_message) - updated_responses.append( - AgentExecutorResponse( - executor_id=orig_resp.executor_id, - agent_run_response=orig_resp.agent_run_response, - full_conversation=conversation, + human_message = ChatMessage(role=Role.USER, text=response) + + # Handle concurrent case (list of AgentExecutorRequest) + original_requests: list[AgentExecutorRequest] | None = original_request.metadata.get("_original_requests") + if original_requests is not None: + updated_requests: list[AgentExecutorRequest] = [] + for orig_req in original_requests: + messages = list(orig_req.messages or []) + messages.append(human_message) + updated_requests.append( + AgentExecutorRequest( + messages=messages, + should_respond=orig_req.should_respond, ) ) logger.debug( f"Human input received for concurrent workflow, " - f"continuing with {len(updated_responses)} updated responses" + f"continuing with {len(updated_requests)} updated requests" ) - await ctx.send_message(updated_responses) # type: ignore[arg-type] + await ctx.send_message(updated_requests) # type: ignore[arg-type] return - # Sequential case: single response - original_response: AgentExecutorResponse | None = original_request.metadata.get("_original_response") - if original_response is None: - logger.error("Human input response handler missing original response in request metadata") - raise RuntimeError("Missing original response in HumanInputRequest metadata") + # Handle list[ChatMessage] case (SequentialBuilder) + original_messages: list[ChatMessage] | None = original_request.metadata.get("_original_messages") + if original_messages is not None: + messages = list(original_messages) + messages.append(human_message) - # Inject human response into the conversation - human_message = ChatMessage(role=Role.USER, text=response) - conversation = list(original_response.full_conversation or []) - conversation.append(human_message) - - # Create updated response with the human input included - updated_response = AgentExecutorResponse( - executor_id=original_response.executor_id, - agent_run_response=original_response.agent_run_response, - full_conversation=conversation, - ) + logger.debug( + f"Human input received for agent {original_request.target_agent_id}, " + f"forwarding conversation with steering context" + ) + await ctx.send_message(messages) + return - logger.debug( - f"Human input received for agent {original_response.executor_id}, " - f"continuing workflow with updated conversation" - ) - await ctx.send_message(updated_response) + # Handle AgentExecutorRequest case (GroupChatBuilder) + orig_request: AgentExecutorRequest | None = original_request.metadata.get("_original_request") + if orig_request is not None: + messages = list(orig_request.messages or []) + messages.append(human_message) + + updated_request = AgentExecutorRequest( + messages=messages, + should_respond=orig_request.should_respond, + ) + + logger.debug( + f"Human input received for agent {original_request.target_agent_id}, " + f"forwarding request with steering context" + ) + await ctx.send_message(updated_request) + return + + logger.error("Input response handler missing original request/messages in metadata") + raise RuntimeError("Missing original request or messages in AgentInputRequest metadata") diff --git a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py index 9da726faf4..14fd68fa46 100644 --- a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py +++ b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py @@ -140,6 +140,9 @@ class ParticipantRegistry: Provides a clean interface for the common pattern of mapping participant names to executor IDs and tracking which are agents vs custom executors. + + Tracks both entry IDs (where to send requests) and exit IDs (where responses + come from) to support pipeline configurations where these differ. """ def __init__(self) -> None: @@ -154,19 +157,26 @@ def register( *, entry_id: str, is_agent: bool, + exit_id: str | None = None, ) -> None: """Register a participant's routing information. Args: name: Participant name - entry_id: Executor ID for this participant's entry point + entry_id: Executor ID for this participant's entry point (where to send) is_agent: Whether this is an AgentExecutor (True) or custom Executor (False) + exit_id: Executor ID for this participant's exit point (where responses come from). + If None, defaults to entry_id (single-executor pipeline). """ self._participant_entry_ids[name] = entry_id + actual_exit_id = exit_id if exit_id is not None else entry_id if is_agent: self._agent_executor_ids[name] = entry_id + # Map both entry and exit IDs to participant name for response routing self._executor_id_to_participant[entry_id] = name + if actual_exit_id != entry_id: + self._executor_id_to_participant[actual_exit_id] = name else: self._non_agent_participants.add(name) diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index b15eb914bd..55068adc61 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -51,7 +51,7 @@ Executor, handler, ) -from ._human_input import HumanInputHookMixin +from ._human_input import RequestInfoInterceptor from ._message_utils import normalize_messages_input from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -77,9 +77,7 @@ async def from_messages( messages: list[str | ChatMessage], ctx: WorkflowContext[list[ChatMessage]], ) -> None: - # Make a copy to avoid mutation downstream - normalized = normalize_messages_input(messages) - await ctx.send_message(list(normalized)) + await ctx.send_message(normalize_messages_input(messages)) class _ResponseToConversation(Executor): @@ -101,7 +99,7 @@ async def end(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any, l await ctx.yield_output(list(conversation)) -class SequentialBuilder(HumanInputHookMixin): +class SequentialBuilder: r"""High-level builder for sequential agent/executor workflows with shared context. - `participants([...])` accepts a list of AgentProtocol (recommended) or Executor @@ -121,20 +119,23 @@ class SequentialBuilder(HumanInputHookMixin): # Enable checkpoint persistence workflow = SequentialBuilder().participants([agent1, agent2]).with_checkpointing(storage).build() + # Enable request info for mid-workflow feedback (pauses before each agent) + workflow = SequentialBuilder().participants([agent1, agent2]).with_request_info().build() - # Enable human input hook for mid-workflow feedback - def request_review(conversation, agent_id): - if "review" in conversation[-1].text.lower(): - return HumanInputRequest(prompt="Please review:", conversation=conversation) - return None - - - workflow = SequentialBuilder().participants([agent1, agent2]).with_human_input_hook(request_review).build() + # Enable request info only for specific agents + workflow = ( + SequentialBuilder() + .participants([agent1, agent2, agent3]) + .with_request_info(agents=[agent2]) # Only pause before agent2 + .build() + ) """ def __init__(self) -> None: self._participants: list[AgentProtocol | Executor] = [] self._checkpoint_storage: CheckpointStorage | None = None + self._request_info_enabled: bool = False + self._request_info_filter: set[str] | None = None def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "SequentialBuilder": """Define the ordered participants for this sequential workflow. @@ -168,6 +169,47 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Sequenti self._checkpoint_storage = checkpoint_storage return self + def with_request_info( + self, + *, + agents: Sequence[str | AgentProtocol | Executor] | None = None, + ) -> "SequentialBuilder": + """Enable request info before agents run in the workflow. + + When enabled, the workflow pauses before each agent runs, emitting + a RequestInfoEvent that allows the caller to review the conversation and + optionally inject guidance before the agent responds. The caller provides + input via the standard response_handler/request_info pattern. + + Args: + agents: Optional filter - only pause before these specific agents/executors. + Accepts agent names (str), agent instances, or executor instances. + If None (default), pauses before every agent. + + Returns: + self: The builder instance for fluent chaining. + + Example: + + .. code-block:: python + + # Pause before all agents + workflow = SequentialBuilder().participants([a1, a2]).with_request_info().build() + + # Pause only before specific agents + workflow = ( + SequentialBuilder() + .participants([drafter, reviewer, finalizer]) + .with_request_info(agents=[reviewer]) # Only pause before reviewer + .build() + ) + """ + from ._human_input import resolve_request_info_filter + + self._request_info_enabled = True + self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None) + return self + def build(self) -> Workflow: """Build and validate the sequential workflow. @@ -196,24 +238,23 @@ def build(self) -> Workflow: for p in self._participants: # Agent-like branch: either explicitly an AgentExecutor or any non-AgentExecutor if not (isinstance(p, Executor) and not isinstance(p, AgentExecutor)): - # input conversation -> (agent) -> [human_input_interceptor] -> response -> conversation - builder.add_edge(prior, p) - - # Give the adapter a deterministic, self-describing id + # input conversation -> [human_input_interceptor] -> (agent) -> response -> conversation label: str label = p.id if isinstance(p, Executor) else getattr(p, "name", None) or p.__class__.__name__ resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}") - if self._human_input_hook is not None: - # Insert human input interceptor between agent and response converter - # Create a dedicated interceptor per agent to avoid ID conflicts - interceptor = self._create_human_input_executor(f"human_input_interceptor:{label}") - if interceptor is not None: - builder.add_edge(p, interceptor) - builder.add_edge(interceptor, resp_to_conv) + if self._request_info_enabled: + # Insert request info interceptor BEFORE the agent + interceptor = RequestInfoInterceptor( + executor_id=f"request_info:{label}", + agent_filter=self._request_info_filter, + ) + builder.add_edge(prior, interceptor) + builder.add_edge(interceptor, p) else: - builder.add_edge(p, resp_to_conv) + builder.add_edge(prior, p) + builder.add_edge(p, resp_to_conv) prior = resp_to_conv elif isinstance(p, Executor): # Custom executor operates on list[ChatMessage] diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index b86ee3eb4a..f3e1d9bd68 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -521,9 +521,9 @@ def _configure_providers(self, exporters: list["LogRecordExporter | MetricExport logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) should_add_console_exporter = False if should_add_console_exporter: - from opentelemetry.sdk._logs.export import ConsoleLogExporter + from opentelemetry.sdk._logs.export import ConsoleLogRecordExporter - logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter())) + logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogRecordExporter())) # Attach a handler with the provider to the root logger logger = logging.getLogger() diff --git a/python/packages/core/tests/workflow/test_group_chat.py b/python/packages/core/tests/workflow/test_group_chat.py index 5d11e64c79..a99af64102 100644 --- a/python/packages/core/tests/workflow/test_group_chat.py +++ b/python/packages/core/tests/workflow/test_group_chat.py @@ -1082,3 +1082,106 @@ def test_set_manager_builds_with_agent_manager() -> None: assert isinstance(orchestrator, GroupChatOrchestratorExecutor) assert orchestrator._is_manager_agent() + + +async def test_group_chat_with_request_info_filtering(): + """Test that with_request_info(agents=[...]) only pauses before specified agents run.""" + from agent_framework import AgentInputRequest, RequestInfoEvent + + # Create agents - we want to verify only beta triggers pause + alpha = StubAgent("alpha", "response from alpha") + beta = StubAgent("beta", "response from beta") + + # Manager that selects alpha first, then beta, then finishes + call_count = 0 + + async def selector(state: GroupChatStateSnapshot) -> str | None: + nonlocal call_count + call_count += 1 + if call_count == 1: + return "alpha" + if call_count == 2: + return "beta" + return None + + workflow = ( + GroupChatBuilder() + .set_select_speakers_func(selector, display_name="manager", final_message="done") + .participants(alpha=alpha, beta=beta) + .with_request_info(agents=["beta"]) # Only pause before beta runs + .build() + ) + + # Run until we get a request info event (should be before beta, not alpha) + request_events: list[RequestInfoEvent] = [] + async for event in workflow.run_stream("test task"): + if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentInputRequest): + request_events.append(event) + # Don't break - let stream complete naturally when paused + + # Should have exactly one request event before beta + assert len(request_events) == 1 + request_event = request_events[0] + + # The target agent should be beta's executor ID (groupchat_agent:beta) + assert request_event.data.target_agent_id is not None + assert "beta" in request_event.data.target_agent_id + + # Continue the workflow with a response + outputs: list[WorkflowOutputEvent] = [] + async for event in workflow.send_responses_streaming({request_event.request_id: "continue please"}): + if isinstance(event, WorkflowOutputEvent): + outputs.append(event) + + # Workflow should complete + assert len(outputs) == 1 + + +async def test_group_chat_with_request_info_no_filter_pauses_all(): + """Test that with_request_info() without agents pauses before all participants.""" + from agent_framework import AgentInputRequest, RequestInfoEvent + + # Create agents + alpha = StubAgent("alpha", "response from alpha") + + # Manager selects alpha then finishes + call_count = 0 + + async def selector(state: GroupChatStateSnapshot) -> str | None: + nonlocal call_count + call_count += 1 + if call_count == 1: + return "alpha" + return None + + workflow = ( + GroupChatBuilder() + .set_select_speakers_func(selector, display_name="manager", final_message="done") + .participants(alpha=alpha) + .with_request_info() # No filter - pause for all + .build() + ) + + # Run until we get a request info event + request_events: list[RequestInfoEvent] = [] + async for event in workflow.run_stream("test task"): + if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentInputRequest): + request_events.append(event) + break + + # Should pause before alpha + assert len(request_events) == 1 + assert request_events[0].data.target_agent_id is not None + assert "alpha" in request_events[0].data.target_agent_id + + +def test_group_chat_builder_with_request_info_returns_self(): + """Test that with_request_info() returns self for method chaining.""" + builder = GroupChatBuilder() + result = builder.with_request_info() + assert result is builder + + # Also test with agents parameter + builder2 = GroupChatBuilder() + result2 = builder2.with_request_info(agents=["test"]) + assert result2 is builder2 diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 1c6a3a157e..0ceccfaf15 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -687,61 +687,52 @@ async def mock_get_response(messages: Any, **kwargs: Any) -> ChatResponse: assert str(last_tool_choice) == "required", f"Expected 'required', got {last_tool_choice}" -async def test_handoff_builder_with_human_input_hook(): - """Test that HandoffBuilder supports human input hook via HumanInputHookMixin.""" - from agent_framework import HumanInputRequest +async def test_handoff_builder_with_request_info(): + """Test that HandoffBuilder supports request info via with_request_info().""" + from agent_framework import AgentInputRequest, RequestInfoEvent # Create test agents coordinator = _RecordingAgent(name="coordinator") specialist = _RecordingAgent(name="specialist") - hook_calls: list[tuple[list[ChatMessage], str | None]] = [] - - def test_hook( - conversation: list[ChatMessage], - agent_id: str | None, - ) -> HumanInputRequest | None: - hook_calls.append((list(conversation), agent_id)) - # Return None to continue without requesting input - return None - - # Build workflow with human input hook + # Build workflow with request info enabled workflow = ( HandoffBuilder(participants=[coordinator, specialist]) .set_coordinator("coordinator") .with_termination_condition(lambda conv: len([m for m in conv if m.role == Role.USER]) >= 1) - .with_human_input_hook(test_hook) + .with_request_info() .build() ) - # Run workflow - events = [e async for e in workflow.run_stream("Hello")] + # Run workflow until it pauses for request info + request_event: RequestInfoEvent | None = None + async for event in workflow.run_stream("Hello"): + if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentInputRequest): + request_event = event - # Verify the hook was called (at least once for the coordinator's response) - assert len(hook_calls) > 0, "Human input hook should have been called" + # Verify request info was emitted + assert request_event is not None, "Request info should have been emitted" + assert isinstance(request_event.data, AgentInputRequest) - # Verify we got output events - output_events = [e for e in events if isinstance(e, WorkflowOutputEvent)] - assert len(output_events) > 0, "Should produce output events" + # Provide response and continue + output_events: list[WorkflowOutputEvent] = [] + async for event in workflow.send_responses_streaming({request_event.request_id: "approved"}): + if isinstance(event, WorkflowOutputEvent): + output_events.append(event) + # Verify we got output events + assert len(output_events) > 0, "Should produce output events after response" -async def test_handoff_builder_mixin_method_chaining(): - """Test that with_human_input_hook returns self for method chaining.""" - from agent_framework import HumanInputRequest +async def test_handoff_builder_with_request_info_method_chaining(): + """Test that with_request_info returns self for method chaining.""" coordinator = _RecordingAgent(name="coordinator") - def test_hook( - conversation: list[ChatMessage], - agent_id: str | None, - ) -> HumanInputRequest | None: - return None - builder = HandoffBuilder(participants=[coordinator]) - result = builder.with_human_input_hook(test_hook) + result = builder.with_request_info() - assert result is builder, "with_human_input_hook should return self for chaining" - assert builder._human_input_hook is test_hook # type: ignore + assert result is builder, "with_request_info should return self for chaining" + assert builder._request_info_enabled is True # type: ignore async def test_return_to_previous_state_serialization(): diff --git a/python/packages/core/tests/workflow/test_human_input_hook.py b/python/packages/core/tests/workflow/test_human_input_hook.py index 9f8fcd05a5..3e22faaacf 100644 --- a/python/packages/core/tests/workflow/test_human_input_hook.py +++ b/python/packages/core/tests/workflow/test_human_input_hook.py @@ -1,147 +1,165 @@ # Copyright (c) Microsoft. All rights reserved. -"""Unit tests for HumanInputHookMixin and related classes.""" +"""Unit tests for request info support in high-level builders.""" + +from typing import Any +from unittest.mock import MagicMock from agent_framework import ( + AgentInputRequest, + AgentProtocol, + AgentResponseReviewRequest, ChatMessage, - HumanInputHookMixin, - HumanInputRequest, + RequestInfoInterceptor, Role, ) -from agent_framework._workflows._human_input import _HumanInputInterceptor # type: ignore +from agent_framework._workflows._executor import Executor, handler +from agent_framework._workflows._human_input import resolve_request_info_filter +from agent_framework._workflows._workflow_context import WorkflowContext -class TestHumanInputRequest: - """Tests for HumanInputRequest dataclass.""" +class DummyExecutor(Executor): + """Dummy executor with a handler for testing.""" - def test_create_request(self): - """Test creating a HumanInputRequest with all fields.""" - conversation = [ChatMessage(role=Role.USER, text="Hello")] - request = HumanInputRequest( - prompt="Please review:", - conversation=conversation, - source_agent_id="test_agent", - metadata={"key": "value"}, - ) + @handler + async def handle(self, data: str, ctx: WorkflowContext[Any, Any]) -> None: + pass - assert request.prompt == "Please review:" - assert request.conversation == conversation - assert request.source_agent_id == "test_agent" - assert request.metadata == {"key": "value"} - - def test_create_request_defaults(self): - """Test creating a HumanInputRequest with default values.""" - request = HumanInputRequest(prompt="Enter input:") - assert request.prompt == "Enter input:" - assert request.conversation == [] - assert request.source_agent_id is None - assert request.metadata == {} +class TestResolveRequestInfoFilter: + """Tests for resolve_request_info_filter function.""" + def test_returns_none_for_none_input(self): + """Test that None input returns None (no filtering).""" + result = resolve_request_info_filter(None) + assert result is None -class TestHumanInputHookMixin: - """Tests for HumanInputHookMixin.""" + def test_returns_none_for_empty_list(self): + """Test that empty list returns None.""" + result = resolve_request_info_filter([]) + assert result is None - def test_mixin_with_hook(self): - """Test setting a human input hook via the mixin.""" + def test_resolves_string_names(self): + """Test resolving string agent names.""" + result = resolve_request_info_filter(["agent1", "agent2"]) + assert result == {"agent1", "agent2"} - class TestBuilder(HumanInputHookMixin): - pass + def test_resolves_executor_ids(self): + """Test resolving Executor instances by ID.""" + exec1 = DummyExecutor(id="executor1") + exec2 = DummyExecutor(id="executor2") - def my_hook( - conversation: list[ChatMessage], - agent_id: str | None, - ) -> HumanInputRequest | None: - return None + result = resolve_request_info_filter([exec1, exec2]) + assert result == {"executor1", "executor2"} - builder = TestBuilder() - result = builder.with_human_input_hook(my_hook) + def test_resolves_agent_names(self): + """Test resolving AgentProtocol-like objects by name attribute.""" + agent1 = MagicMock(spec=AgentProtocol) + agent1.name = "writer" + agent2 = MagicMock(spec=AgentProtocol) + agent2.name = "reviewer" - assert result is builder # Method chaining - assert builder._human_input_hook is my_hook # type: ignore + result = resolve_request_info_filter([agent1, agent2]) + assert result == {"writer", "reviewer"} - def test_create_executor_returns_none_without_hook(self): - """Test that _create_human_input_executor returns None when no hook is set.""" + def test_mixed_types(self): + """Test resolving a mix of strings, agents, and executors.""" + agent = MagicMock(spec=AgentProtocol) + agent.name = "writer" + executor = DummyExecutor(id="custom_exec") - class TestBuilder(HumanInputHookMixin): - pass + result = resolve_request_info_filter(["manual_name", agent, executor]) + assert result == {"manual_name", "writer", "custom_exec"} - builder = TestBuilder() - executor = builder._create_human_input_executor() # type: ignore + def test_skips_agent_without_name(self): + """Test that agents without names are skipped.""" + agent_with_name = MagicMock(spec=AgentProtocol) + agent_with_name.name = "valid" + agent_without_name = MagicMock(spec=AgentProtocol) + agent_without_name.name = None - assert executor is None + result = resolve_request_info_filter([agent_with_name, agent_without_name]) + assert result == {"valid"} - def test_create_executor_returns_interceptor_with_hook(self): - """Test that _create_human_input_executor returns an interceptor when hook is set.""" - class TestBuilder(HumanInputHookMixin): - pass +class TestAgentInputRequest: + """Tests for AgentInputRequest dataclass (formerly AgentResponseReviewRequest).""" - def my_hook( - conversation: list[ChatMessage], - agent_id: str | None, - ) -> HumanInputRequest | None: - return None + def test_create_request(self): + """Test creating an AgentInputRequest with all fields.""" + conversation = [ChatMessage(role=Role.USER, text="Hello")] + request = AgentInputRequest( + target_agent_id="test_agent", + conversation=conversation, + instruction="Review this", + metadata={"key": "value"}, + ) - builder = TestBuilder() - builder.with_human_input_hook(my_hook) - executor = builder._create_human_input_executor("custom_id") # type: ignore + assert request.target_agent_id == "test_agent" + assert request.conversation == conversation + assert request.instruction == "Review this" + assert request.metadata == {"key": "value"} - assert executor is not None - assert isinstance(executor, _HumanInputInterceptor) - assert executor.id == "custom_id" + def test_create_request_defaults(self): + """Test creating an AgentInputRequest with default values.""" + request = AgentInputRequest(target_agent_id="test_agent") + assert request.target_agent_id == "test_agent" + assert request.conversation == [] + assert request.instruction is None + assert request.metadata == {} -class TestHumanInputInterceptor: - """Tests for _HumanInputInterceptor executor.""" + def test_backward_compatibility_alias(self): + """Test that AgentResponseReviewRequest is an alias for AgentInputRequest.""" + assert AgentResponseReviewRequest is AgentInputRequest - async def test_invoke_sync_hook(self): - """Test invoking a synchronous hook.""" - def sync_hook( - conversation: list[ChatMessage], - agent_id: str | None, - ) -> HumanInputRequest | None: - if conversation and "review" in conversation[-1].text.lower(): - return HumanInputRequest( - prompt="Review requested", - conversation=conversation, - source_agent_id=agent_id, - ) - return None +class TestRequestInfoInterceptor: + """Tests for RequestInfoInterceptor executor.""" - interceptor = _HumanInputInterceptor(sync_hook) + def test_interceptor_creation(self): + """Test creating a RequestInfoInterceptor.""" + interceptor = RequestInfoInterceptor() + assert interceptor.id == "request_info_interceptor" - # Test hook returns None - result = await interceptor._invoke_hook([], None) # type: ignore - assert result is None + def test_interceptor_with_custom_id(self): + """Test creating a RequestInfoInterceptor with custom ID.""" + interceptor = RequestInfoInterceptor(executor_id="custom_review") + assert interceptor.id == "custom_review" - # Test hook returns request - conversation = [ChatMessage(role=Role.ASSISTANT, text="Please review this")] - result = await interceptor._invoke_hook(conversation, "test_agent") # type: ignore - assert result is not None - assert result.prompt == "Review requested" - assert result.source_agent_id == "test_agent" - - async def test_invoke_async_hook(self): - """Test invoking an asynchronous hook.""" - - async def async_hook( - conversation: list[ChatMessage], - agent_id: str | None, - ) -> HumanInputRequest | None: - if conversation: - return HumanInputRequest( - prompt="Async review", - conversation=conversation, - source_agent_id=agent_id, - ) - return None - - interceptor = _HumanInputInterceptor(async_hook) - - # Test async hook returns request - conversation = [ChatMessage(role=Role.USER, text="Test")] - result = await interceptor._invoke_hook(conversation, "async_agent") # type: ignore - assert result is not None - assert result.prompt == "Async review" + def test_interceptor_with_agent_filter(self): + """Test creating a RequestInfoInterceptor with agent filter.""" + agent_filter = {"agent1", "agent2"} + interceptor = RequestInfoInterceptor( + executor_id="filtered_review", + agent_filter=agent_filter, + ) + assert interceptor.id == "filtered_review" + assert interceptor._agent_filter == agent_filter + + def test_should_pause_for_agent_no_filter(self): + """Test that interceptor pauses for all agents when no filter is set.""" + interceptor = RequestInfoInterceptor() + assert interceptor._should_pause_for_agent("any_agent") is True + assert interceptor._should_pause_for_agent("another_agent") is True + assert interceptor._should_pause_for_agent(None) is True + + def test_should_pause_for_agent_with_filter(self): + """Test that interceptor only pauses for agents in the filter.""" + agent_filter = {"writer", "reviewer"} + interceptor = RequestInfoInterceptor(agent_filter=agent_filter) + + assert interceptor._should_pause_for_agent("writer") is True + assert interceptor._should_pause_for_agent("reviewer") is True + assert interceptor._should_pause_for_agent("drafter") is False + assert interceptor._should_pause_for_agent(None) is False + + def test_should_pause_for_agent_with_prefixed_id(self): + """Test that filter matches agent names in prefixed executor IDs.""" + agent_filter = {"writer"} + interceptor = RequestInfoInterceptor(agent_filter=agent_filter) + + # Should match the name portion after the colon + assert interceptor._should_pause_for_agent("groupchat_agent:writer") is True + assert interceptor._should_pause_for_agent("request_info:writer") is True + assert interceptor._should_pause_for_agent("groupchat_agent:editor") is False diff --git a/python/packages/core/tests/workflow/test_workflow_builder.py b/python/packages/core/tests/workflow/test_workflow_builder.py index b85ca5d787..a037bf51b6 100644 --- a/python/packages/core/tests/workflow/test_workflow_builder.py +++ b/python/packages/core/tests/workflow/test_workflow_builder.py @@ -111,7 +111,8 @@ def test_add_agent_with_custom_parameters(): builder = WorkflowBuilder() # Add agent with custom parameters - result = builder.add_agent(agent, output_response=True, id="my_custom_id") + 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 @@ -133,7 +134,8 @@ def test_add_agent_reuses_same_wrapper(): builder = WorkflowBuilder() # Add agent with specific parameters - builder.add_agent(agent, output_response=True, id="agent_exec") + 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) @@ -158,8 +160,9 @@ def test_add_agent_then_use_in_edges(): builder = WorkflowBuilder() # Add agents with specific settings - builder.add_agent(agent1, output_response=False, id="exec1") - builder.add_agent(agent2, output_response=True, id="exec2") + 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() @@ -183,7 +186,8 @@ def test_add_agent_without_explicit_id_uses_agent_name(): agent = DummyAgent(id="agent_x", name="named_agent") builder = WorkflowBuilder() - result = builder.add_agent(agent) + with pytest.deprecated_call(): + result = builder.add_agent(agent) # Verify that add_agent returns the builder for chaining assert result is builder @@ -203,10 +207,11 @@ def test_add_agent_duplicate_id_raises_error(): builder = WorkflowBuilder() # Add first agent - builder.add_agent(agent1) + with pytest.deprecated_call(): + builder.add_agent(agent1) # Adding second agent with same name should raise ValueError - with pytest.raises(ValueError, match="Duplicate executor ID"): + with pytest.deprecated_call(), pytest.raises(ValueError, match="Duplicate executor ID"): builder.add_agent(agent2) diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 1b6c339d06..e1e18eab91 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -80,9 +80,9 @@ Once comfortable with these, explore the rest of the samples below. |---|---|---| | Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human via `ctx.request_info()` | | Agents with Approval Requests in Workflows | [human-in-the-loop/agents_with_approval_requests.py](./human-in-the-loop/agents_with_approval_requests.py) | Agents that create approval requests during workflow execution and wait for human approval to proceed | -| SequentialBuilder Human Input Hook | [human-in-the-loop/sequential_human_input_hook.py](./human-in-the-loop/sequential_human_input_hook.py) | Request arbitrary human feedback mid-workflow using `.with_human_input_hook()` on SequentialBuilder | -| ConcurrentBuilder Human Input Hook | [human-in-the-loop/concurrent_human_input_hook.py](./human-in-the-loop/concurrent_human_input_hook.py) | Review concurrent agent outputs before aggregation using `.with_human_input_hook()` on ConcurrentBuilder | -| GroupChatBuilder Human Input Hook | [human-in-the-loop/group_chat_human_input_hook.py](./human-in-the-loop/group_chat_human_input_hook.py) | Steer group discussions with periodic human guidance using `.with_human_input_hook()` on GroupChatBuilder | +| SequentialBuilder Request Info | [human-in-the-loop/sequential_request_info.py](./human-in-the-loop/sequential_request_info.py) | Request info for agent responses mid-workflow using `.with_request_info()` on SequentialBuilder | +| ConcurrentBuilder Request Info | [human-in-the-loop/concurrent_request_info.py](./human-in-the-loop/concurrent_request_info.py) | Review concurrent agent outputs before aggregation using `.with_request_info()` on ConcurrentBuilder | +| GroupChatBuilder Request Info | [human-in-the-loop/group_chat_request_info.py](./human-in-the-loop/group_chat_request_info.py) | Steer group discussions with periodic guidance using `.with_request_info()` on GroupChatBuilder | ### tool-approval diff --git a/python/samples/getting_started/workflows/human-in-the-loop/concurrent_human_input_hook.py b/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py similarity index 62% rename from python/samples/getting_started/workflows/human-in-the-loop/concurrent_human_input_hook.py rename to python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py index ba6b40a302..c8c4f40e41 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/concurrent_human_input_hook.py +++ b/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py @@ -1,12 +1,33 @@ # Copyright (c) Microsoft. All rights reserved. +""" +Sample: Request Info with ConcurrentBuilder + +This sample demonstrates using the `.with_request_info()` method to pause a +ConcurrentBuilder workflow AFTER all parallel agents complete but BEFORE +aggregation, allowing human review and modification of the combined results. + +Purpose: +Show how to use the request info API that pauses after concurrent agents run, +allowing review and steering of results before they are aggregated. + +Demonstrate: +- Configuring request info with `.with_request_info()` +- Reviewing outputs from multiple concurrent agents +- Injecting human guidance after agents execute but before aggregation + +Prerequisites: +- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables +- Authentication via azure-identity (run az login before executing) +""" + import asyncio from typing import Any from agent_framework import ( + AgentInputRequest, ChatMessage, ConcurrentBuilder, - HumanInputRequest, RequestInfoEvent, Role, WorkflowOutputEvent, @@ -17,75 +38,10 @@ from agent_framework.azure import AzureOpenAIChatClient from azure.identity import AzureCliCredential -""" -Sample: Human Input Hook with ConcurrentBuilder - -This sample demonstrates using the `.with_human_input_hook()` method to request -arbitrary human feedback mid-workflow with ConcurrentBuilder. The hook is called -after all parallel agents complete but before the aggregator runs. - -Purpose: -Show how to use HumanInputRequest to pause a ConcurrentBuilder workflow and request -human review of parallel agent outputs before aggregation. - -Demonstrate: -- Configuring a human input hook on ConcurrentBuilder -- Reviewing outputs from multiple concurrent agents simultaneously -- Injecting human guidance before the aggregator synthesizes results - -Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables -- Authentication via azure-identity (run az login before executing) -""" - # Store chat client at module level for aggregator access _chat_client: AzureOpenAIChatClient | None = None -def review_concurrent_outputs( - conversation: list[ChatMessage], - agent_id: str | None, -) -> HumanInputRequest | None: - """Hook that requests human input after concurrent agents complete. - - This is a simple demonstration heuristic that always requests review when - multiple agents have responded. In practice, you might use other strategies: - - Always pause unconditionally for mandatory human review - - Check for conflicting opinions between agents - - Call an async policy service to determine if review is needed - - Use content classification to detect topics requiring human judgment - - Skip review if all agents reached similar conclusions - - For ConcurrentBuilder, the hook is called once with a merged view of all - agent outputs. The agent_id is None since multiple agents contributed. - - Args: - conversation: Merged conversation including all concurrent agent responses - agent_id: None for concurrent (multiple agents contributed) - - Returns: - HumanInputRequest to pause and request input, or None to continue - """ - if not conversation: - return None - - # Example heuristic: request review when we have multiple perspectives - # This is just one approach - replace with your own logic as needed - assistant_msgs = [m for m in conversation if m.role and m.role.value == "assistant"] - - if len(assistant_msgs) >= 2: - return HumanInputRequest( - prompt=( - f"Received {len(assistant_msgs)} different perspectives. " - "Please review and provide guidance on which aspects to prioritize in the final summary:" - ), - conversation=conversation, - source_agent_id=agent_id, - metadata={"num_perspectives": len(assistant_msgs)}, - ) - return None - - async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any: """Custom aggregator that synthesizes concurrent agent outputs using an LLM. @@ -170,12 +126,12 @@ async def main() -> None: ), ) - # Build workflow with human input hook and custom aggregator + # Build workflow with request info enabled and custom aggregator workflow = ( ConcurrentBuilder() .participants([technical_analyst, business_analyst, user_experience_analyst]) .with_aggregator(aggregate_with_synthesis) - .with_human_input_hook(review_concurrent_outputs) + .with_request_info() .build() ) @@ -199,27 +155,26 @@ async def main() -> None: # Process events async for event in stream: if isinstance(event, RequestInfoEvent): - if isinstance(event.data, HumanInputRequest): - # Display the concurrent agent outputs + if isinstance(event.data, AgentInputRequest): + # Display pre-execution context for steering concurrent agents print("\n" + "-" * 40) - print("HUMAN INPUT REQUESTED") + print("INPUT REQUESTED (BEFORE CONCURRENT AGENTS)") + print("-" * 40) + print(f"About to call agents: {event.data.target_agent_id}") + print("Conversation context:") + recent = ( + event.data.conversation[-2:] if len(event.data.conversation) > 2 else event.data.conversation + ) + for msg in recent: + role = msg.role.value if msg.role else "unknown" + text = (msg.text or "")[:150] + print(f" [{role}]: {text}...") print("-" * 40) - print("Concurrent agent outputs:") - - # Show each assistant message (one per analyst) - for msg in event.data.conversation: - if msg.role and msg.role.value == "assistant": - text = (msg.text or "")[:250] - print(f"\n [analyst]: {text}...") - - print("\n" + "-" * 40) - print(f"Prompt: {event.data.prompt}") - print("(Workflow paused)") - # Get human input - user_input = input("Your guidance (or 'skip' to continue): ") + # Get human input to steer all agents + user_input = input("Your guidance for the analysts (or 'skip' to continue): ") # noqa: ASYNC250 if user_input.lower() == "skip": - user_input = "All perspectives are equally important. Please create a balanced summary." + user_input = "Please analyze objectively from your unique perspective." pending_responses = {event.request_id: user_input} print("(Resuming workflow...)") @@ -237,7 +192,6 @@ async def main() -> None: elif isinstance(event, WorkflowStatusEvent): if event.state == WorkflowRunState.IDLE: workflow_complete = True - # Note: IDLE_WITH_PENDING_REQUESTS is handled inline with RequestInfoEvent if __name__ == "__main__": diff --git a/python/samples/getting_started/workflows/human-in-the-loop/group_chat_human_input_hook.py b/python/samples/getting_started/workflows/human-in-the-loop/group_chat_human_input_hook.py deleted file mode 100644 index db4c712f77..0000000000 --- a/python/samples/getting_started/workflows/human-in-the-loop/group_chat_human_input_hook.py +++ /dev/null @@ -1,227 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import ( - ChatMessage, - GroupChatBuilder, - HumanInputRequest, - RequestInfoEvent, - WorkflowOutputEvent, - WorkflowRunState, - WorkflowStatusEvent, -) -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential - -""" -Sample: Human Input Hook with GroupChatBuilder - -This sample demonstrates using the `.with_human_input_hook()` method to request -arbitrary human feedback mid-workflow with GroupChatBuilder. The hook is called -after each participant takes a turn, allowing human intervention in multi-turn -group conversations. - -Purpose: -Show how to use HumanInputRequest to pause a GroupChatBuilder workflow and request -human guidance during a dynamic group conversation. - -Demonstrate: -- Configuring a human input hook on GroupChatBuilder -- Monitoring conversation progress and intervening when needed -- Steering group discussions with human input - -Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables -- Authentication via azure-identity (run az login before executing) -""" - -# Track conversation turns for the hook -turn_count = 0 - - -def review_group_discussion( - conversation: list[ChatMessage], - agent_id: str | None, -) -> HumanInputRequest | None: - """Hook that requests human input periodically during group chat. - - This is a simple demonstration using turn counting and keyword detection. - In practice, you might use other strategies: - - Always pause after specific participants (by checking agent_id) - - Pause only when the manager is about to select a critical agent - - Call an async policy service to determine if review is needed - - Use sentiment analysis to detect when the discussion needs moderation - - Return HumanInputRequest unconditionally for mandatory review at every step - - Args: - conversation: Full conversation history including latest agent response - agent_id: ID of the agent that just responded - - Returns: - HumanInputRequest to pause and request input, or None to continue - """ - global turn_count - turn_count += 1 - - if not conversation: - return None - - # Check if the conversation is finishing - don't request input if manager said finish - last_message = conversation[-1] - text = last_message.text or "" - - # Skip human input if the manager has decided to finish the conversation - if '"finish":true' in text.replace(" ", "").lower() or '"finish": true' in text.lower(): - return None - - # Example heuristic: request human input every 3 turns - # This is just one approach - replace with your own logic as needed - if turn_count % 3 == 0: - return HumanInputRequest( - prompt=( - f"The group has completed {turn_count} turns. " - "Would you like to provide any guidance or redirect the discussion?" - ), - conversation=conversation, - source_agent_id=agent_id, - metadata={"turn_count": turn_count}, - ) - - # Also request input if the conversation hits a decision point - decision_keywords = ["disagree", "alternative", "however", "but i think", "on the other hand"] - - if any(keyword in text.lower() for keyword in decision_keywords): - return HumanInputRequest( - prompt=( - f"Agent '{agent_id}' has raised a different perspective. " - "Would you like to weigh in on this discussion point?" - ), - conversation=conversation, - source_agent_id=agent_id, - metadata={"trigger": "disagreement", "turn_count": turn_count}, - ) - - return None - - -async def main() -> None: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - - # Create agents for a group discussion - optimist = chat_client.create_agent( - name="optimist", - instructions=( - "You are an optimistic team member. You see opportunities and potential " - "in ideas. Engage constructively with the discussion, building on others' " - "points while maintaining a positive outlook. Keep responses to 2-3 sentences." - ), - ) - - pragmatist = chat_client.create_agent( - name="pragmatist", - instructions=( - "You are a pragmatic team member. You focus on practical implementation " - "and realistic timelines. Sometimes you disagree with overly optimistic views. " - "Keep responses to 2-3 sentences." - ), - ) - - creative = chat_client.create_agent( - name="creative", - instructions=( - "You are a creative team member. You propose innovative solutions and " - "think outside the box. You may suggest alternatives to conventional approaches. " - "Keep responses to 2-3 sentences." - ), - ) - - # Manager orchestrates the discussion - manager = chat_client.create_agent( - name="manager", - instructions=( - "You are a discussion manager. Facilitate the group discussion by acknowledging " - "contributions and keeping the conversation productive. If human feedback is provided, " - "incorporate it to guide the discussion. Decide when to conclude after 4-6 exchanges." - ), - ) - - # Build workflow with human input hook - workflow = ( - GroupChatBuilder() - .set_manager(manager=manager, display_name="Discussion Manager") - .participants([optimist, pragmatist, creative]) - .with_max_rounds(6) - .with_human_input_hook(review_group_discussion) - .build() - ) - - # Run the workflow with human-in-the-loop - pending_responses: dict[str, str] | None = None - workflow_complete = False - - 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, RequestInfoEvent): - if isinstance(event.data, HumanInputRequest): - # Display recent conversation - print("\n" + "-" * 40) - print("HUMAN INPUT REQUESTED") - print(f"After turn: {event.data.metadata.get('turn_count', 'unknown')}") - if event.data.source_agent_id: - print(f"Triggered by: {event.data.source_agent_id}") - print("-" * 40) - print("Recent discussion:") - for msg in event.data.conversation[-4:]: - role = msg.role.value if msg.role else "unknown" - text = (msg.text or "")[:150] - print(f" [{role}]: {text}...") - print("-" * 40) - print(f"Prompt: {event.data.prompt}") - print("(Discussion paused)") - - # Get human input - user_input = input("Your input (or 'skip' to continue): ") - if user_input.lower() == "skip": - user_input = "Please continue the discussion naturally." - - pending_responses = {event.request_id: 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[-4:] - for msg in messages: - role = msg.role.value if msg.role else "unknown" - text = (msg.text or "")[:200] - print(f"[{role}]: {text}...") - workflow_complete = True - - elif isinstance(event, WorkflowStatusEvent): - if event.state == WorkflowRunState.IDLE: - workflow_complete = True - # Note: IDLE_WITH_PENDING_REQUESTS is handled inline with RequestInfoEvent - - -if __name__ == "__main__": - asyncio.run(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 new file mode 100644 index 0000000000..c3a193a6a8 --- /dev/null +++ b/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Sample: Request Info with GroupChatBuilder + +This sample demonstrates using the `.with_request_info()` method to pause a +GroupChatBuilder workflow BEFORE specific participants speak. By using the +`agents=` filter parameter, you can target only certain participants rather +than pausing before every turn. + +Purpose: +Show how to use the request info API with selective filtering to pause before +specific participants speak, allowing human input to steer their response. + +Demonstrate: +- Configuring request info with `.with_request_info(agents=[...])` +- Using agent filtering to reduce interruptions +- Steering agent behavior with pre-agent human input + +Prerequisites: +- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables +- Authentication via azure-identity (run az login before executing) +""" + +import asyncio + +from agent_framework import ( + AgentInputRequest, + AgentRunUpdateEvent, + ChatMessage, + GroupChatBuilder, + RequestInfoEvent, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + + +async def main() -> None: + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + + # Create agents for a group discussion + optimist = chat_client.create_agent( + name="optimist", + instructions=( + "You are an optimistic team member. You see opportunities and potential " + "in ideas. Engage constructively with the discussion, building on others' " + "points while maintaining a positive outlook. Keep responses to 2-3 sentences." + ), + ) + + pragmatist = chat_client.create_agent( + name="pragmatist", + instructions=( + "You are a pragmatic team member. You focus on practical implementation " + "and realistic timelines. Sometimes you disagree with overly optimistic views. " + "Keep responses to 2-3 sentences." + ), + ) + + creative = chat_client.create_agent( + name="creative", + instructions=( + "You are a creative team member. You propose innovative solutions and " + "think outside the box. You may suggest alternatives to conventional approaches. " + "Keep responses to 2-3 sentences." + ), + ) + + # Manager orchestrates the discussion + manager = chat_client.create_agent( + name="manager", + instructions=( + "You are a discussion manager coordinating a team conversation between optimist, " + "pragmatist, and creative. Your job is to select who speaks next.\n\n" + "RULES:\n" + "1. Rotate through ALL participants - do not favor any single participant\n" + "2. Each participant should speak at least once before any participant speaks twice\n" + "3. If human feedback redirects the topic, acknowledge it and continue rotating\n" + "4. Continue for at least 5 participant turns before concluding\n" + "5. Do NOT select the same participant twice in a row" + ), + ) + + # Build workflow with request info enabled + # Using agents= filter to only pause before pragmatist speaks (not every turn) + workflow = ( + GroupChatBuilder() + .set_manager(manager=manager, display_name="Discussion Manager") + .participants([optimist, pragmatist, creative]) + .with_max_rounds(6) + .with_request_info(agents=[pragmatist]) # Only pause before pragmatist speaks + .build() + ) + + # Run the workflow with human-in-the-loop + pending_responses: dict[str, str] | None = None + workflow_complete = False + current_agent: str | None = None # Track current streaming agent + + 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, AgentInputRequest): + # Display pre-agent context for human input + print("\n" + "-" * 40) + print("INPUT REQUESTED") + print(f"About to call agent: {event.data.target_agent_id}") + print("-" * 40) + print("Conversation context:") + recent = ( + event.data.conversation[-3:] if len(event.data.conversation) > 3 else event.data.conversation + ) + for msg in recent: + role = msg.role.value if msg.role else "unknown" + text = (msg.text or "")[:100] + print(f" [{role}]: {text}...") + print("-" * 40) + + # Get human input to steer the agent + user_input = input("Steer the discussion (or 'skip' to continue): ") # noqa: ASYNC250 + if user_input.lower() == "skip": + user_input = "Please continue the discussion naturally." + + pending_responses = {event.request_id: 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[-4:] + for msg in messages: + role = msg.role.value if msg.role else "unknown" + text = (msg.text or "")[:200] + print(f"[{role}]: {text}...") + workflow_complete = True + + elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE: + workflow_complete = True + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/human-in-the-loop/sequential_human_input_hook.py b/python/samples/getting_started/workflows/human-in-the-loop/sequential_human_input_hook.py deleted file mode 100644 index bbfeffc4af..0000000000 --- a/python/samples/getting_started/workflows/human-in-the-loop/sequential_human_input_hook.py +++ /dev/null @@ -1,179 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import ( - ChatMessage, - HumanInputRequest, - RequestInfoEvent, - SequentialBuilder, - WorkflowOutputEvent, - WorkflowRunState, - WorkflowStatusEvent, -) -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential - -""" -Sample: Human Input Hook with SequentialBuilder - -This sample demonstrates using the `.with_human_input_hook()` method to request -arbitrary human feedback mid-workflow with high-level builders. Unlike tool approval -(binary approve/deny), human input hooks allow injecting custom guidance into the -conversation. - -Purpose: -Show how to use HumanInputRequest to pause a SequentialBuilder workflow and request -human review or additional input before continuing to the next agent. - -Demonstrate: -- Configuring a human input hook that triggers based on conversation content -- Handling RequestInfoEvent with HumanInputRequest data -- Injecting human responses back into the workflow via send_responses_streaming - -Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables -- Authentication via azure-identity (run az login before executing) -""" - - -def request_review_on_keyword( - conversation: list[ChatMessage], - agent_id: str | None, -) -> HumanInputRequest | None: - """Hook that requests human input when an agent mentions 'review'. - - This is a simple demonstration heuristic that triggers on keyword matches. - In practice, you might use other strategies: - - Always pause after specific agents (by checking agent_id) - - Pause every N steps using a counter - - Call an async policy service to determine if review is needed - - Use content classification to detect sensitive topics - - Return HumanInputRequest unconditionally for mandatory review at every step - - Args: - conversation: Full conversation history including agent's latest response - agent_id: ID of the agent that just responded - - Returns: - HumanInputRequest to pause and request input, or None to continue - """ - if not conversation: - return None - - # Example heuristic: check the last message for keywords suggesting review is needed - # This is just one approach - replace with your own logic as needed - last_message = conversation[-1] - text = last_message.text or "" - keywords = ["review", "confirm", "approve", "feedback", "check"] - - if any(keyword in text.lower() for keyword in keywords): - return HumanInputRequest( - prompt=f"Agent '{agent_id}' is requesting your review. Please provide feedback:", - conversation=conversation, - source_agent_id=agent_id, - metadata={"trigger": "keyword_match"}, - ) - return None - - -async def main() -> None: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - - # Create agents for a sequential document review workflow - drafter = chat_client.create_agent( - name="drafter", - instructions=( - "You are a document drafter. When given a topic, create a brief draft " - "(2-3 sentences). Always end with 'Please review this draft and provide feedback.'" - ), - ) - - editor = chat_client.create_agent( - name="editor", - instructions=( - "You are an editor. Review the draft and suggest improvements. " - "Incorporate any human feedback that was provided. " - "Always end with 'Please confirm these edits are acceptable.'" - ), - ) - - finalizer = chat_client.create_agent( - name="finalizer", - instructions=( - "You are a finalizer. Take the edited content and create a polished final version. " - "Incorporate any additional feedback provided. " - "Present the final document without asking for review." - ), - ) - - # Build workflow with human input hook - workflow = ( - SequentialBuilder() - .participants([drafter, editor, finalizer]) - .with_human_input_hook(request_review_on_keyword) - .build() - ) - - # Run the workflow with human-in-the-loop - pending_responses: dict[str, str] | None = None - workflow_complete = False - - 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, HumanInputRequest): - # Display the conversation context - print("\n" + "-" * 40) - print("HUMAN INPUT REQUESTED") - print(f"From agent: {event.data.source_agent_id}") - print("-" * 40) - print("Recent conversation:") - for msg in event.data.conversation[-3:]: - role = msg.role.value if msg.role else "unknown" - text = (msg.text or "")[:200] - print(f" [{role}]: {text}...") - print("-" * 40) - print(f"Prompt: {event.data.prompt}") - print("(Workflow paused)") - - # Get human input - user_input = input("Your feedback (or 'skip' to continue): ") # noqa: ASYNC250 - if user_input.lower() == "skip": - user_input = "Looks good, please continue." - - 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.value 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 - # Note: IDLE_WITH_PENDING_REQUESTS is handled inline with RequestInfoEvent - - -if __name__ == "__main__": - asyncio.run(main()) 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 new file mode 100644 index 0000000000..55c8652984 --- /dev/null +++ b/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Sample: Request Info with SequentialBuilder + +This sample demonstrates using the `.with_request_info()` method to pause a +SequentialBuilder workflow BEFORE each agent runs, allowing external input +(e.g., human steering) before the agent responds. + +Purpose: +Show how to use the request info API that pauses before every agent response, +using the standard request_info pattern for consistency. + +Demonstrate: +- Configuring request info with `.with_request_info()` +- Handling RequestInfoEvent with AgentInputRequest data +- Injecting responses back into the workflow via send_responses_streaming + +Prerequisites: +- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables +- Authentication via azure-identity (run az login before executing) +""" + +import asyncio + +from agent_framework import ( + AgentInputRequest, + ChatMessage, + RequestInfoEvent, + SequentialBuilder, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + + +async def main() -> None: + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + + # Create agents for a sequential document review workflow + drafter = chat_client.create_agent( + name="drafter", + instructions=("You are a document drafter. When given a topic, create a brief draft (2-3 sentences)."), + ) + + editor = chat_client.create_agent( + name="editor", + instructions=( + "You are an editor. Review the draft and suggest improvements. " + "Incorporate any human feedback that was provided." + ), + ) + + finalizer = chat_client.create_agent( + name="finalizer", + instructions=( + "You are a finalizer. Take the edited content and create a polished final version. " + "Incorporate any additional feedback provided." + ), + ) + + # Build workflow with request info enabled (pauses before each agent) + workflow = SequentialBuilder().participants([drafter, editor, finalizer]).with_request_info().build() + + # Run the workflow with request info handling + pending_responses: dict[str, str] | None = None + workflow_complete = False + + 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, AgentInputRequest): + # Display pre-agent context for steering + print("\n" + "-" * 40) + print("REQUEST INFO: INPUT REQUESTED") + print(f"About to call agent: {event.data.target_agent_id}") + print("-" * 40) + print("Conversation context:") + recent = ( + event.data.conversation[-2:] if len(event.data.conversation) > 2 else event.data.conversation + ) + for msg in recent: + role = msg.role.value if msg.role else "unknown" + text = (msg.text or "")[:150] + print(f" [{role}]: {text}...") + print("-" * 40) + + # Get input to steer the agent + user_input = input("Your guidance (or 'skip' to continue): ") # noqa: ASYNC250 + if user_input.lower() == "skip": + user_input = "Please continue naturally." + + 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.value 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 + + +if __name__ == "__main__": + asyncio.run(main()) From 4d51be6f1aa42f474b8a7b7546bad69663c0fca1 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 9 Dec 2025 13:06:32 +0900 Subject: [PATCH 7/8] Rename human input hook to orchestration request info --- python/packages/core/agent_framework/_workflows/__init__.py | 2 +- .../packages/core/agent_framework/_workflows/_concurrent.py | 2 +- .../packages/core/agent_framework/_workflows/_group_chat.py | 4 ++-- python/packages/core/agent_framework/_workflows/_handoff.py | 4 ++-- .../{_human_input.py => _orchestration_request_info.py} | 0 .../packages/core/agent_framework/_workflows/_sequential.py | 4 ++-- ...human_input_hook.py => test_orchestration_request_info.py} | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) rename python/packages/core/agent_framework/_workflows/{_human_input.py => _orchestration_request_info.py} (100%) rename python/packages/core/tests/workflow/{test_human_input_hook.py => test_orchestration_request_info.py} (98%) diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 479669355c..42e48c50cf 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -65,7 +65,6 @@ ManagerSelectionResponse, ) from ._handoff import HandoffBuilder, HandoffUserInputRequest -from ._human_input import AgentInputRequest, AgentResponseReviewRequest, RequestInfoInterceptor from ._magentic import ( MAGENTIC_EVENT_TYPE_AGENT_DELTA, MAGENTIC_EVENT_TYPE_ORCHESTRATOR, @@ -86,6 +85,7 @@ MagenticStallInterventionRequest, StandardMagenticManager, ) +from ._orchestration_request_info import AgentInputRequest, AgentResponseReviewRequest, RequestInfoInterceptor from ._orchestration_state import OrchestrationState from ._request_info_mixin import response_handler from ._runner import Runner diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index 171d24ce4b..d1204ac077 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -13,8 +13,8 @@ from ._agent_executor import AgentExecutorRequest, AgentExecutorResponse from ._checkpoint import CheckpointStorage from ._executor import Executor, handler -from ._human_input import RequestInfoInterceptor from ._message_utils import normalize_messages_input +from ._orchestration_request_info import RequestInfoInterceptor from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder from ._workflow_context import WorkflowContext diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index bdf372e3fa..2200890ee6 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -36,7 +36,7 @@ from ._checkpoint import CheckpointStorage from ._conversation_history import ensure_author, latest_user_message from ._executor import Executor, handler -from ._human_input import RequestInfoInterceptor +from ._orchestration_request_info import RequestInfoInterceptor from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, wrap_participant from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -1833,7 +1833,7 @@ def with_request_info( .build() ) """ - from ._human_input import resolve_request_info_filter + from ._orchestration_request_info import resolve_request_info_filter self._request_info_enabled = True self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None) diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 75e0ba6c64..31e7a69f57 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -48,7 +48,7 @@ _GroupChatParticipantPipeline, # type: ignore[reportPrivateUsage] assemble_group_chat_workflow, ) -from ._human_input import RequestInfoInterceptor +from ._orchestration_request_info import RequestInfoInterceptor from ._orchestrator_helpers import clean_conversation_for_handoff from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, sanitize_identifier from ._request_info_mixin import response_handler @@ -1487,7 +1487,7 @@ def with_request_info( .build() ) """ - from ._human_input import resolve_request_info_filter + from ._orchestration_request_info import resolve_request_info_filter self._request_info_enabled = True self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None) diff --git a/python/packages/core/agent_framework/_workflows/_human_input.py b/python/packages/core/agent_framework/_workflows/_orchestration_request_info.py similarity index 100% rename from python/packages/core/agent_framework/_workflows/_human_input.py rename to python/packages/core/agent_framework/_workflows/_orchestration_request_info.py diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index 55068adc61..93fc5e991c 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -51,8 +51,8 @@ Executor, handler, ) -from ._human_input import RequestInfoInterceptor from ._message_utils import normalize_messages_input +from ._orchestration_request_info import RequestInfoInterceptor from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder from ._workflow_context import WorkflowContext @@ -204,7 +204,7 @@ def with_request_info( .build() ) """ - from ._human_input import resolve_request_info_filter + from ._orchestration_request_info import resolve_request_info_filter self._request_info_enabled = True self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None) diff --git a/python/packages/core/tests/workflow/test_human_input_hook.py b/python/packages/core/tests/workflow/test_orchestration_request_info.py similarity index 98% rename from python/packages/core/tests/workflow/test_human_input_hook.py rename to python/packages/core/tests/workflow/test_orchestration_request_info.py index 3e22faaacf..7c42ad29c7 100644 --- a/python/packages/core/tests/workflow/test_human_input_hook.py +++ b/python/packages/core/tests/workflow/test_orchestration_request_info.py @@ -14,7 +14,7 @@ Role, ) from agent_framework._workflows._executor import Executor, handler -from agent_framework._workflows._human_input import resolve_request_info_filter +from agent_framework._workflows._orchestration_request_info import resolve_request_info_filter from agent_framework._workflows._workflow_context import WorkflowContext From f8278674e26955983314cb9e89593961b3fa016e Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 10 Dec 2025 11:31:59 +0900 Subject: [PATCH 8/8] Clean up per PR feedback --- .../agent_framework/_workflows/_concurrent.py | 6 +++--- .../agent_framework/_workflows/_group_chat.py | 2 +- .../core/agent_framework/_workflows/_handoff.py | 2 +- .../_workflows/_orchestration_request_info.py | 15 +++++++++++++-- .../workflow/test_orchestration_request_info.py | 11 +++++++---- 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index d1204ac077..f6a7b09e60 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -311,7 +311,7 @@ def with_request_info(self) -> "ConcurrentBuilder": Note: Unlike SequentialBuilder and GroupChatBuilder, ConcurrentBuilder does not support per-agent filtering since all agents run in parallel and results - are collected together. The pause occurs once with all agent outputs combined. + are collected together. The pause occurs once with all agent outputs received. Returns: self: The builder instance for fluent chaining. @@ -325,8 +325,8 @@ def build(self) -> Workflow: Wiring pattern: - Dispatcher (internal) fans out the input to all `participants` - Fan-in collects `AgentExecutorResponse` objects from all participants - - If human input hook is configured, the interceptor executor checks for input - before passing results to the aggregator + - If request info is enabled, the orchestration emits a request info event with outputs from all participants + before sending the outputs to the aggregator - Aggregator yields output and the workflow becomes idle. The output is either: - list[ChatMessage] (default aggregator: one user + one assistant per agent) - custom payload from the provided callback/executor diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 2200890ee6..eab720a4b1 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -572,7 +572,7 @@ async def _ingest_participant_message( message: The participant's response message ctx: Workflow context for routing and output trailing_messages: Optional list of messages to inject after the participant's - message (e.g., human input from the human input hook) + message (e.g., additional input from the RequestInfoInterceptor) """ if participant_name not in self._participants: raise ValueError(f"Received response from unknown participant '{participant_name}'.") diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 31e7a69f57..8e0a7aec1e 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -334,7 +334,7 @@ def _extract_agent_id_from_source(self, source: str | None) -> str | None: return None if source.startswith("request_info:"): return source[len("request_info:") :] - # Legacy support + # TODO(@moonbox3): Remove legacy prefix support in a separate PR (GA cleanup) if source.startswith("human_review:"): return source[len("human_review:") :] if source.startswith("human_input_interceptor:"): diff --git a/python/packages/core/agent_framework/_workflows/_orchestration_request_info.py b/python/packages/core/agent_framework/_workflows/_orchestration_request_info.py index cfd2419994..91c9ec799a 100644 --- a/python/packages/core/agent_framework/_workflows/_orchestration_request_info.py +++ b/python/packages/core/agent_framework/_workflows/_orchestration_request_info.py @@ -15,6 +15,7 @@ """ import logging +import uuid from dataclasses import dataclass, field from typing import Any @@ -89,6 +90,9 @@ class AgentInputRequest: AgentResponseReviewRequest = AgentInputRequest +DEFAULT_REQUEST_INFO_ID = "request_info_interceptor" + + class RequestInfoInterceptor(Executor): """Internal executor that pauses workflow for human input before agent runs. @@ -107,17 +111,20 @@ class RequestInfoInterceptor(Executor): def __init__( self, - executor_id: str = "request_info_interceptor", + executor_id: str | None = None, agent_filter: set[str] | None = None, ) -> None: """Initialize the request info interceptor executor. Args: - executor_id: ID for this executor (default: "request_info_interceptor") + executor_id: ID for this executor. If None, generates a unique ID + using the format "request_info_interceptor-". agent_filter: Optional set of agent/executor IDs to filter on. If provided, only requests to these agents trigger a pause. If None (default), all requests trigger a pause. """ + if executor_id is None: + executor_id = f"{DEFAULT_REQUEST_INFO_ID}-{uuid.uuid4().hex[:8]}" super().__init__(executor_id) self._agent_filter = agent_filter @@ -247,6 +254,7 @@ async def intercept_concurrent_requests( async def handle_input_response( self, original_request: AgentInputRequest, + # TODO(@moonbox3): Extend to support other content types response: str, ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], Any], ) -> None: @@ -259,6 +267,9 @@ async def handle_input_response( original_request: The AgentInputRequest that triggered the pause response: The human input text ctx: Workflow context for continuing the workflow + + TODO: Consider having each orchestration implement its own response handler + for more specialized behavior. """ human_message = ChatMessage(role=Role.USER, text=response) diff --git a/python/packages/core/tests/workflow/test_orchestration_request_info.py b/python/packages/core/tests/workflow/test_orchestration_request_info.py index 7c42ad29c7..e5f4d7a11f 100644 --- a/python/packages/core/tests/workflow/test_orchestration_request_info.py +++ b/python/packages/core/tests/workflow/test_orchestration_request_info.py @@ -117,10 +117,13 @@ def test_backward_compatibility_alias(self): class TestRequestInfoInterceptor: """Tests for RequestInfoInterceptor executor.""" - def test_interceptor_creation(self): - """Test creating a RequestInfoInterceptor.""" - interceptor = RequestInfoInterceptor() - assert interceptor.id == "request_info_interceptor" + def test_interceptor_creation_generates_unique_id(self): + """Test creating a RequestInfoInterceptor generates unique IDs.""" + interceptor1 = RequestInfoInterceptor() + interceptor2 = RequestInfoInterceptor() + assert interceptor1.id.startswith("request_info_interceptor-") + assert interceptor2.id.startswith("request_info_interceptor-") + assert interceptor1.id != interceptor2.id def test_interceptor_with_custom_id(self): """Test creating a RequestInfoInterceptor with custom ID."""