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
+112
-73
@@ -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...")
|
||||
|
||||
+23
-29
@@ -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 Worker ↔ Reviewer 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
|
||||
|
||||
Reference in New Issue
Block a user