[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:
Tao Chen
2025-10-29 16:31:23 -07:00
committed by GitHub
Unverified
parent f6eadd412e
commit 943d92674e
54 changed files with 7532 additions and 6684 deletions
@@ -8,32 +8,32 @@ from typing import Annotated
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentRunResponse,
AgentRunUpdateEvent,
ChatMessage,
Executor,
FunctionCallContent,
FunctionResultContent,
RequestInfoEvent,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
Role,
ToolMode,
WorkflowBuilder,
WorkflowContext,
WorkflowOutputEvent,
handler,
response_handler,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
from pydantic import Field
from typing_extensions import Never
"""
Sample: Tool-enabled agents with human feedback
Pipeline layout:
writer_agent (uses Azure OpenAI tools) -> DraftFeedbackCoordinator -> RequestInfoExecutor
-> DraftFeedbackCoordinator -> final_editor_agent
writer_agent (uses Azure OpenAI tools) -> Coordinator -> writer_agent
-> Coordinator -> final_editor_agent -> Coordinator -> output
The writer agent calls tools to gather product facts before drafting copy. A custom executor
packages the draft and emits a RequestInfoEvent so a human can comment, then replays the human
@@ -41,7 +41,7 @@ guidance back into the conversation before the final editor agent produces the p
Demonstrates:
- Attaching Python function tools to an agent inside a workflow.
- Capturing the writer's output and routing it through RequestInfoExecutor for human review.
- Capturing the writer's output for human review.
- Streaming AgentRunUpdateEvent updates alongside human-in-the-loop pauses.
Prerequisites:
@@ -82,27 +82,37 @@ def get_brand_voice_profile(
@dataclass
class DraftFeedbackRequest(RequestInfoMessage):
"""Payload sent to RequestInfoExecutor for human review."""
class DraftFeedbackRequest:
"""Payload sent for human review."""
prompt: str = ""
draft_text: str = ""
conversation: list[ChatMessage] = field(default_factory=list) # type: ignore[reportUnknownVariableType]
class DraftFeedbackCoordinator(Executor):
class Coordinator(Executor):
"""Bridge between the writer agent, human feedback, and final editor."""
def __init__(self, *, id: str = "draft_feedback_coordinator") -> None:
def __init__(self, id: str, writer_id: str, final_editor_id: str) -> None:
super().__init__(id)
self.writer_id = writer_id
self.final_editor_id = final_editor_id
@handler
async def on_writer_response(
self,
draft: AgentExecutorResponse,
ctx: WorkflowContext[DraftFeedbackRequest],
ctx: WorkflowContext[Never, AgentRunResponse],
) -> None:
# Preserve the full conversation so the final editor can see tool traces and the initial prompt.
"""Handle responses from the other two agents in the workflow."""
if draft.executor_id == self.final_editor_id:
# Final editor response; yield output directly.
await ctx.yield_output(draft.agent_run_response)
return
# Writer agent response; request human feedback.
# Preserve the full conversation so the final editor
# can see tool traces and the initial prompt.
conversation: list[ChatMessage]
if draft.full_conversation is not None:
conversation = list(draft.full_conversation)
@@ -117,18 +127,34 @@ class DraftFeedbackCoordinator(Executor):
"(tone tweaks, must-have detail, target audience, etc.). "
"Keep it under 30 words."
)
await ctx.send_message(DraftFeedbackRequest(prompt=prompt, draft_text=draft_text, conversation=conversation))
await ctx.request_info(
DraftFeedbackRequest(prompt=prompt, draft_text=draft_text, conversation=conversation),
DraftFeedbackRequest,
str,
)
@handler
@response_handler
async def on_human_feedback(
self,
feedback: RequestResponse[DraftFeedbackRequest, str],
original_request: DraftFeedbackRequest,
feedback: str,
ctx: WorkflowContext[AgentExecutorRequest],
) -> None:
note = (feedback.data or "").strip()
request = feedback.original_request
note = feedback.strip()
if note.lower() == "approve":
# Human approved the draft as-is; forward it unchanged.
await ctx.send_message(
AgentExecutorRequest(
messages=original_request.conversation
+ [ChatMessage(Role.USER, text="The draft is approved as-is.")],
should_respond=True,
),
target_id=self.final_editor_id,
)
return
conversation: list[ChatMessage] = list(request.conversation)
# Human provided feedback; prompt the writer to revise.
conversation: list[ChatMessage] = list(original_request.conversation)
instruction = (
"A human reviewer shared the following guidance:\n"
f"{note or 'No specific guidance provided.'}\n\n"
@@ -136,11 +162,57 @@ class DraftFeedbackCoordinator(Executor):
"Keep the response under 120 words and reflect any requested tone adjustments."
)
conversation.append(ChatMessage(Role.USER, text=instruction))
await ctx.send_message(AgentExecutorRequest(messages=conversation, should_respond=True))
await ctx.send_message(
AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_id
)
def display_agent_run_update(event: AgentRunUpdateEvent, last_executor: str | None) -> None:
"""Display an AgentRunUpdateEvent in a readable format."""
printed_tool_calls: set[str] = set()
printed_tool_results: set[str] = set()
executor_id = event.executor_id
update = event.data
# Extract and print any new tool calls or results from the update.
function_calls = [c for c in update.contents if isinstance(c, FunctionCallContent)] # type: ignore[union-attr]
function_results = [c for c in update.contents if isinstance(c, FunctionResultContent)] # type: ignore[union-attr]
if executor_id != last_executor:
if last_executor is not None:
print()
print(f"{executor_id}:", end=" ", flush=True)
last_executor = executor_id
# Print any new tool calls before the text update.
for call in function_calls:
if call.call_id in printed_tool_calls:
continue
printed_tool_calls.add(call.call_id)
args = call.arguments
args_preview = json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else (args or "").strip()
print(
f"\n{executor_id} [tool-call] {call.name}({args_preview})",
flush=True,
)
print(f"{executor_id}:", end=" ", flush=True)
# Print any new tool results before the text update.
for result in function_results:
if result.call_id in printed_tool_results:
continue
printed_tool_results.add(result.call_id)
result_text = result.result
if not isinstance(result_text, str):
result_text = json.dumps(result_text, ensure_ascii=False)
print(
f"\n{executor_id} [tool-result] {result.call_id}: {result_text}",
flush=True,
)
print(f"{executor_id}:", end=" ", flush=True)
# Finally, print the text update.
print(update, end="", flush=True)
async def main() -> None:
"""Run the workflow and bridge human feedback between two agents."""
# Create agents with tools and instructions.
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
writer_agent = chat_client.create_agent(
@@ -157,33 +229,39 @@ async def main() -> None:
final_editor_agent = chat_client.create_agent(
name="final_editor_agent",
instructions=(
"You are an editor who polishes marketing copy using human guidance. "
"Respect factual details from the prior messages while applying the feedback."
"You are an editor who polishes marketing copy after human approval. "
"Correct any legal or factual issues. Return the final version even if no changes are made. "
),
)
feedback_coordinator = DraftFeedbackCoordinator()
request_info_executor = RequestInfoExecutor(id="human_feedback")
coordinator = Coordinator(
id="coordinator",
writer_id="writer_agent",
final_editor_id="final_editor_agent",
)
# Build the workflow.
workflow = (
WorkflowBuilder()
.set_start_executor(writer_agent)
.add_edge(writer_agent, feedback_coordinator)
.add_edge(feedback_coordinator, request_info_executor)
.add_edge(request_info_executor, feedback_coordinator)
.add_edge(feedback_coordinator, final_editor_agent)
.add_edge(writer_agent, coordinator)
.add_edge(coordinator, writer_agent)
.add_edge(final_editor_agent, coordinator)
.add_edge(coordinator, final_editor_agent)
.build()
)
# Switch to turn on agent run update display.
# By default this is off to reduce clutter during human input.
display_agent_run_update_switch = False
print(
"Interactive mode. When prompted, provide a short feedback note for the editor (type 'exit' to quit).",
"Interactive mode. When prompted, provide a short feedback note for the editor.",
flush=True,
)
pending_responses: dict[str, str] | None = None
completed = False
printed_tool_calls: set[str] = set()
printed_tool_results: set[str] = set()
while not completed:
last_executor: str | None = None
@@ -198,48 +276,9 @@ async def main() -> None:
requests: list[tuple[str, DraftFeedbackRequest]] = []
async for event in stream:
if isinstance(event, AgentRunUpdateEvent):
executor_id = event.executor_id
update = event.data
# Extract and print any new tool calls or results from the update.
function_calls = [c for c in update.contents if isinstance(c, FunctionCallContent)] # type: ignore[union-attr]
function_results = [c for c in update.contents if isinstance(c, FunctionResultContent)] # type: ignore[union-attr]
if executor_id != last_executor:
if last_executor is not None:
print()
print(f"{executor_id}:", end=" ", flush=True)
last_executor = executor_id
# Print any new tool calls before the text update.
for call in function_calls:
if call.call_id in printed_tool_calls:
continue
printed_tool_calls.add(call.call_id)
args = call.arguments
if isinstance(args, dict):
args_preview = json.dumps(args, ensure_ascii=False)
else:
args_preview = (args or "").strip()
print(
f"\n{executor_id} [tool-call] {call.name}({args_preview})",
flush=True,
)
print(f"{executor_id}:", end=" ", flush=True)
# Print any new tool results before the text update.
for result in function_results:
if result.call_id in printed_tool_results:
continue
printed_tool_results.add(result.call_id)
result_text = result.result
if not isinstance(result_text, str):
result_text = json.dumps(result_text, ensure_ascii=False)
print(
f"\n{executor_id} [tool-result] {result.call_id}: {result_text}",
flush=True,
)
print(f"{executor_id}:", end=" ", flush=True)
# Finally, print the text update.
print(update, end="", flush=True)
elif isinstance(event, RequestInfoEvent) and isinstance(event.data, DraftFeedbackRequest):
if isinstance(event, AgentRunUpdateEvent) and display_agent_run_update_switch:
display_agent_run_update(event, last_executor)
if isinstance(event, RequestInfoEvent) and isinstance(event.data, DraftFeedbackRequest):
# Stash the request so we can prompt the human after the stream completes.
requests.append((event.request_id, event.data))
last_executor = None
@@ -256,7 +295,7 @@ async def main() -> None:
for request_id, request in requests:
print("\n----- Writer draft -----")
print(request.draft_text.strip())
print("\nProvide guidance for the editor (or press Enter to accept the draft).")
print("\nProvide guidance for the editor (or 'approve' to accept the draft).")
answer = input("Human feedback: ").strip() # noqa: ASYNC250
if answer.lower() == "exit":
print("Exiting...")
@@ -7,6 +7,9 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
# Ensure local getting_started package can be imported when running as a script.
_SAMPLES_ROOT = Path(__file__).resolve().parents[3]
if str(_SAMPLES_ROOT) not in sys.path:
@@ -17,16 +20,13 @@ from agent_framework import ( # noqa: E402
Executor,
FunctionCallContent,
FunctionResultContent,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
Role,
WorkflowAgent,
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
from agent_framework.openai import OpenAIChatClient # noqa: E402
from getting_started.workflows.agents.workflow_as_agent_reflection_pattern import ( # noqa: E402
ReviewRequest,
ReviewResponse,
@@ -40,20 +40,20 @@ Purpose:
This sample demonstrates how to build a workflow agent that escalates uncertain
decisions to a human manager. A Worker generates results, while a Reviewer
evaluates them. When the Reviewer is not confident, it escalates the decision
to a human via RequestInfoExecutor, receives the human response, and then
forwards that response back to the Worker. The workflow completes when idle.
to a human, receives the human response, and then forwards that response back
to the Worker. The workflow completes when idle.
Prerequisites:
- OpenAI account configured and accessible for OpenAIChatClient.
- Familiarity with WorkflowBuilder, Executor, and WorkflowContext from agent_framework.
- Understanding of request-response message handling (RequestInfoMessage, RequestResponse).
- Understanding of request-response message handling in executors.
- (Optional) Review of reflection and escalation patterns, such as those in
workflow_as_agent_reflection.py.
"""
@dataclass
class HumanReviewRequest(RequestInfoMessage):
class HumanReviewRequest:
"""A request message type for escalation to a human reviewer."""
agent_request: ReviewRequest | None = None
@@ -62,14 +62,13 @@ class HumanReviewRequest(RequestInfoMessage):
class ReviewerWithHumanInTheLoop(Executor):
"""Executor that always escalates reviews to a human manager."""
def __init__(self, worker_id: str, request_info_id: str, reviewer_id: str | None = None) -> None:
def __init__(self, worker_id: str, reviewer_id: str | None = None) -> None:
unique_id = reviewer_id or f"{worker_id}-reviewer"
super().__init__(id=unique_id)
self._worker_id = worker_id
self._request_info_id = request_info_id
@handler
async def review(self, request: ReviewRequest, ctx: WorkflowContext[ReviewResponse | HumanReviewRequest]) -> None:
async def review(self, request: ReviewRequest, ctx: WorkflowContext) -> None:
# In this simplified example, we always escalate to a human manager.
# See workflow_as_agent_reflection.py for an implementation
# using an automated agent to make the review decision.
@@ -77,23 +76,21 @@ class ReviewerWithHumanInTheLoop(Executor):
print("Reviewer: Escalating to human manager...")
# Forward the request to a human manager by sending a HumanReviewRequest.
await ctx.send_message(
HumanReviewRequest(agent_request=request),
target_id=self._request_info_id,
)
await ctx.request_info(HumanReviewRequest(agent_request=request), HumanReviewRequest, ReviewResponse)
@handler
@response_handler
async def accept_human_review(
self, response: RequestResponse[HumanReviewRequest, ReviewResponse], ctx: WorkflowContext[ReviewResponse]
self,
original_request: ReviewRequest,
response: ReviewResponse,
ctx: WorkflowContext[ReviewResponse],
) -> None:
# Accept the human review response and forward it back to the Worker.
human_response = response.data
assert isinstance(human_response, ReviewResponse)
print(f"Reviewer: Accepting human review for request {human_response.request_id[:8]}...")
print(f"Reviewer: Human feedback: {human_response.feedback}")
print(f"Reviewer: Human approved: {human_response.approved}")
print(f"Reviewer: Accepting human review for request {response.request_id[:8]}...")
print(f"Reviewer: Human feedback: {response.feedback}")
print(f"Reviewer: Human approved: {response.approved}")
print("Reviewer: Forwarding human review back to worker...")
await ctx.send_message(human_response, target_id=self._worker_id)
await ctx.send_message(response, target_id=self._worker_id)
async def main() -> None:
@@ -102,20 +99,17 @@ async def main() -> None:
# Create executors for the workflow.
print("Creating chat client and executors...")
mini_chat_client = OpenAIChatClient(model_id="gpt-4.1-nano")
mini_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
worker = Worker(id="sub-worker", chat_client=mini_chat_client)
request_info_executor = RequestInfoExecutor(id="request_info")
reviewer = ReviewerWithHumanInTheLoop(worker_id=worker.id, request_info_id=request_info_executor.id)
reviewer = ReviewerWithHumanInTheLoop(worker_id=worker.id)
print("Building workflow with WorkerReviewer cycle...")
print("Building workflow with Worker-Reviewer cycle...")
# Build a workflow with bidirectional communication between Worker and Reviewer,
# and escalation paths for human review.
agent = (
WorkflowBuilder()
.add_edge(worker, reviewer) # Worker sends requests to Reviewer
.add_edge(reviewer, worker) # Reviewer sends feedback to Worker
.add_edge(reviewer, request_info_executor) # Reviewer requests human input
.add_edge(request_info_executor, reviewer) # Human input forwarded back to Reviewer
.set_start_executor(worker)
.build()
.as_agent() # Convert workflow into an agent interface