mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: Replace RequestInfoExecutor with request_info API and @response_handler (#1466)
* Prototype: Add request_info API and @response_handler * Add original_request as a parameter to the response handler * Prototype: request interception in sub workflows * Prototype: request interception in sub workflows 2 * WIP: Make checkpointing work * checkpointing with sub workflow * Fix function executor * Allow sub-workflow to output directly * Remove ReqeustInfoExecutor and related classes; Debugging checkpoint_with_human_in_the_loop * Fix Handoff and sample * fix pending requests in checkpoint * Fix unit tests * Fix formatting * Resolve comments * Address comment * Add checkpoint tests * Add tests * misc * fix mypy * fix mypy * Use request type as part of the key * Log warning if there is not response handler for a request * Update Internal edge group comments * REcord message type in executor processing span * Update sample * Improve tests
This commit is contained in:
committed by
GitHub
Unverified
parent
f6eadd412e
commit
943d92674e
+43
-66
@@ -10,16 +10,14 @@ from agent_framework import (
|
||||
ChatMessage, # Chat message structure
|
||||
Executor, # Base class for workflow executors
|
||||
RequestInfoEvent, # Event emitted when human input is requested
|
||||
RequestInfoExecutor, # Special executor that collects human input out of band
|
||||
RequestInfoMessage, # Base class for request payloads sent to RequestInfoExecutor
|
||||
RequestResponse, # Correlates a human response with the original request
|
||||
Role, # Enum of chat roles (user, assistant, system)
|
||||
WorkflowBuilder, # Fluent builder for assembling the graph
|
||||
WorkflowContext, # Per run context and event bus
|
||||
WorkflowOutputEvent, # Event emitted when workflow yields output
|
||||
WorkflowRunState, # Enum of workflow run states
|
||||
WorkflowStatusEvent, # Event emitted on run state changes
|
||||
handler, # Decorator to expose an Executor method as a step
|
||||
handler,
|
||||
response_handler, # Decorator to expose an Executor method as a step
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
@@ -29,12 +27,12 @@ from pydantic import BaseModel
|
||||
Sample: Human in the loop guessing game
|
||||
|
||||
An agent guesses a number, then a human guides it with higher, lower, or
|
||||
correct via RequestInfoExecutor. The loop continues until the human confirms
|
||||
correct, at which point the workflow completes when idle with no pending work.
|
||||
correct. The loop continues until the human confirms correct, at which point
|
||||
the workflow completes when idle with no pending work.
|
||||
|
||||
Purpose:
|
||||
Show how to integrate a human step in the middle of an LLM workflow using RequestInfoExecutor and correlated
|
||||
RequestResponse objects.
|
||||
Show how to integrate a human step in the middle of an LLM workflow by using
|
||||
`request_info` and `send_responses_streaming`.
|
||||
|
||||
Demonstrate:
|
||||
- Alternating turns between an AgentExecutor and a human, driven by events.
|
||||
@@ -47,27 +45,20 @@ Prerequisites:
|
||||
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs.
|
||||
"""
|
||||
|
||||
# What RequestInfoExecutor does:
|
||||
# RequestInfoExecutor is a workflow-native bridge that pauses the graph at a request for information,
|
||||
# emits a RequestInfoEvent with a typed payload, and then resumes the graph only after your application
|
||||
# supplies a matching RequestResponse keyed by the emitted request_id. It does not gather input by itself.
|
||||
# Your application is responsible for collecting the human reply from any UI or CLI and then calling
|
||||
# send_responses_streaming with a dict mapping request_id to the human's answer. The executor exists to
|
||||
# standardize pause-and-resume human gating, to carry typed request payloads, and to preserve correlation.
|
||||
# How human-in-the-loop is achieved via `request_info` and `send_responses_streaming`:
|
||||
# - An executor (TurnManager) calls `ctx.request_info` with a payload (HumanFeedbackRequest).
|
||||
# - The workflow run pauses and emits a RequestInfoEvent with the payload and the request_id.
|
||||
# - The application captures the event, prompts the user, and collects replies.
|
||||
# - The application calls `send_responses_streaming` with a map of request_ids to replies.
|
||||
# - The workflow resumes, and the response is delivered to the executor method decorated with @response_handler.
|
||||
# - The executor can then continue the workflow, e.g., by sending a new message to the agent.
|
||||
|
||||
|
||||
# Request type sent to the RequestInfoExecutor for human feedback.
|
||||
# Including the agent's last guess allows the UI or CLI to display context and helps
|
||||
# the turn manager avoid extra state reads.
|
||||
# Why subclass RequestInfoMessage:
|
||||
# Subclassing RequestInfoMessage defines the exact schema of the request that the human will see.
|
||||
# This gives you strong typing, forward-compatible validation, and clear correlation semantics.
|
||||
# It also lets you attach contextual fields (such as the previous guess) so the UI can render a rich prompt
|
||||
# without fetching extra state from elsewhere.
|
||||
@dataclass
|
||||
class HumanFeedbackRequest(RequestInfoMessage):
|
||||
prompt: str = ""
|
||||
guess: int | None = None
|
||||
class HumanFeedbackRequest:
|
||||
"""Request sent to the human for feedback on the agent's guess."""
|
||||
|
||||
prompt: str
|
||||
|
||||
|
||||
class GuessOutput(BaseModel):
|
||||
@@ -103,47 +94,45 @@ class TurnManager(Executor):
|
||||
async def on_agent_response(
|
||||
self,
|
||||
result: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[HumanFeedbackRequest],
|
||||
ctx: WorkflowContext,
|
||||
) -> None:
|
||||
"""Handle the agent's guess and request human guidance.
|
||||
|
||||
Steps:
|
||||
1) Parse the agent's JSON into GuessOutput for robustness.
|
||||
2) Send a HumanFeedbackRequest to the RequestInfoExecutor with a clear instruction:
|
||||
- higher means the human's secret number is higher than the agent's guess.
|
||||
- lower means the human's secret number is lower than the agent's guess.
|
||||
- correct confirms the guess is exactly right.
|
||||
- exit quits the demo.
|
||||
2) Request info with a HumanFeedbackRequest as the payload.
|
||||
"""
|
||||
# Parse structured model output (defensive default if the agent did not reply).
|
||||
text = result.agent_run_response.text or ""
|
||||
last_guess = GuessOutput.model_validate_json(text).guess if text else None
|
||||
# Parse structured model output
|
||||
text = result.agent_run_response.text
|
||||
last_guess = GuessOutput.model_validate_json(text).guess
|
||||
|
||||
# Craft a precise human prompt that defines higher and lower relative to the agent's guess.
|
||||
prompt = (
|
||||
f"The agent guessed: {last_guess if last_guess is not None else text}. "
|
||||
f"The agent guessed: {last_guess}. "
|
||||
"Type one of: higher (your number is higher than this guess), "
|
||||
"lower (your number is lower than this guess), correct, or exit."
|
||||
)
|
||||
await ctx.send_message(HumanFeedbackRequest(prompt=prompt, guess=last_guess))
|
||||
# Send a request with a prompt as the payload and expect a string reply.
|
||||
await ctx.request_info(
|
||||
request_data=HumanFeedbackRequest(prompt=prompt),
|
||||
request_type=HumanFeedbackRequest,
|
||||
response_type=str,
|
||||
)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def on_human_feedback(
|
||||
self,
|
||||
feedback: RequestResponse[HumanFeedbackRequest, str],
|
||||
original_request: HumanFeedbackRequest,
|
||||
feedback: str,
|
||||
ctx: WorkflowContext[AgentExecutorRequest, str],
|
||||
) -> None:
|
||||
"""Continue the game or finish based on human feedback.
|
||||
"""Continue the game or finish based on human feedback."""
|
||||
print(f"Feedback for prompt '{original_request.prompt}' received: {feedback}")
|
||||
|
||||
The RequestResponse contains both the human's string reply and the correlated HumanFeedbackRequest,
|
||||
which carries the prior guess for convenience.
|
||||
"""
|
||||
reply = (feedback.data or "").strip().lower()
|
||||
# Prefer the correlated request's guess to avoid extra shared state reads.
|
||||
last_guess = getattr(feedback.original_request, "guess", None)
|
||||
reply = feedback.strip().lower()
|
||||
|
||||
if reply == "correct":
|
||||
await ctx.yield_output(f"Guessed correctly: {last_guess}")
|
||||
await ctx.yield_output("Guessed correctly!")
|
||||
return
|
||||
|
||||
# Provide feedback to the agent to try again.
|
||||
@@ -166,35 +155,24 @@ async def main() -> None:
|
||||
'You MUST return ONLY a JSON object exactly matching this schema: {"guess": <integer 1..10>}. '
|
||||
"No explanations or additional text."
|
||||
),
|
||||
# Structured output enforced via Pydantic model.
|
||||
response_format=GuessOutput,
|
||||
)
|
||||
|
||||
# Build a simple loop: TurnManager <-> AgentExecutor <-> RequestInfoExecutor.
|
||||
# TurnManager coordinates, AgentExecutor runs the model, RequestInfoExecutor gathers human replies.
|
||||
# Build a simple loop: TurnManager <-> AgentExecutor.
|
||||
# TurnManager coordinates and gathers human replies while AgentExecutor runs the model.
|
||||
turn_manager = TurnManager(id="turn_manager")
|
||||
agent_exec = AgentExecutor(agent=agent, id="agent")
|
||||
|
||||
# Naming note:
|
||||
# This variable is currently named hitl for historical reasons. The name can feel ambiguous or magical.
|
||||
# Consider renaming to request_info_executor in your own code for clarity, since it directly represents
|
||||
# the RequestInfoExecutor node that gathers human replies out of band.
|
||||
hitl = RequestInfoExecutor(id="request_info")
|
||||
|
||||
top_builder = (
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(turn_manager)
|
||||
.add_edge(turn_manager, agent_exec) # Ask agent to make/adjust a guess
|
||||
.add_edge(agent_exec, turn_manager) # Agent's response comes back to coordinator
|
||||
.add_edge(turn_manager, hitl) # Ask human for guidance
|
||||
.add_edge(hitl, turn_manager) # Feed human guidance back to coordinator
|
||||
)
|
||||
|
||||
# Build the workflow (no checkpointing in this minimal sample).
|
||||
workflow = top_builder.build()
|
||||
).build()
|
||||
|
||||
# Human in the loop run: alternate between invoking the workflow and supplying collected responses.
|
||||
pending_responses: dict[str, str] | None = None
|
||||
completed = False
|
||||
workflow_output: str | None = None
|
||||
|
||||
# User guidance printing:
|
||||
@@ -206,7 +184,7 @@ async def main() -> None:
|
||||
# flush=True,
|
||||
# )
|
||||
|
||||
while not completed:
|
||||
while workflow_output is None:
|
||||
# First iteration uses run_stream("start").
|
||||
# Subsequent iterations use send_responses_streaming with pending_responses from the console.
|
||||
stream = (
|
||||
@@ -228,7 +206,6 @@ async def main() -> None:
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
# Capture workflow output as they're yielded
|
||||
workflow_output = str(event.data)
|
||||
completed = True # In this sample, we finish after one output.
|
||||
|
||||
# Detect run state transitions for a better developer experience.
|
||||
pending_status = any(
|
||||
@@ -245,7 +222,7 @@ async def main() -> None:
|
||||
print("State: IDLE_WITH_PENDING_REQUESTS (awaiting human input)")
|
||||
|
||||
# If we have any human requests, prompt the user and prepare responses.
|
||||
if requests and not completed:
|
||||
if requests:
|
||||
responses: dict[str, str] = {}
|
||||
for req_id, prompt in requests:
|
||||
# Simple console prompt for the sample.
|
||||
|
||||
Reference in New Issue
Block a user