Skip to content

Commit 7cee839

Browse files
authored
Python: Fix workflow samples for bugbash: part 1 (#4055)
* Fix workflow samples for bugbash: part 1 * Fix mypy * Fix tests
1 parent 2dfe903 commit 7cee839

11 files changed

Lines changed: 132 additions & 113 deletions

File tree

python/packages/orchestrations/agent_framework_orchestrations/_handoff.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"""
3131

3232
import inspect
33+
import json
3334
import logging
3435
import sys
3536
from collections.abc import Awaitable, Callable, Sequence
@@ -139,7 +140,10 @@ async def process(
139140
from agent_framework._middleware import MiddlewareTermination
140141

141142
# Short-circuit execution and provide deterministic response payload for the tool call.
142-
context.result = {HANDOFF_FUNCTION_RESULT_KEY: self._handoff_functions[context.function.name]}
143+
# Parse the result using the default parser to ensure in a form that can be passed directly to LLM APIs.
144+
context.result = FunctionTool.parse_result({
145+
HANDOFF_FUNCTION_RESULT_KEY: self._handoff_functions[context.function.name]
146+
})
143147
raise MiddlewareTermination(result=context.result)
144148

145149

@@ -493,9 +497,22 @@ def _is_handoff_requested(self, response: AgentResponse) -> str | None:
493497
last_message = response.messages[-1]
494498
for content in last_message.contents:
495499
if content.type == "function_result":
496-
# Use string comparison instead of isinstance to improve performance
497-
if content.result and isinstance(content.result, dict):
498-
handoff_target = content.result.get(HANDOFF_FUNCTION_RESULT_KEY) # type: ignore
500+
if not content.result:
501+
continue
502+
503+
parsed_result: dict[str, Any] | None = None
504+
if isinstance(content.result, dict):
505+
parsed_result = content.result
506+
elif isinstance(content.result, str):
507+
try:
508+
loaded_result = json.loads(content.result)
509+
except json.JSONDecodeError:
510+
continue
511+
if isinstance(loaded_result, dict):
512+
parsed_result = loaded_result
513+
514+
if parsed_result is not None:
515+
handoff_target = parsed_result.get(HANDOFF_FUNCTION_RESULT_KEY)
499516
if isinstance(handoff_target, str):
500517
return handoff_target
501518
else:

python/packages/orchestrations/tests/test_handoff.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@
1717
resolve_agent_id,
1818
)
1919
from agent_framework._clients import BaseChatClient
20-
from agent_framework._middleware import ChatMiddlewareLayer
21-
from agent_framework._tools import FunctionInvocationLayer
20+
from agent_framework._middleware import ChatMiddlewareLayer, FunctionInvocationContext, MiddlewareTermination
21+
from agent_framework._tools import FunctionInvocationLayer, FunctionTool, tool
2222
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
2323

24+
from agent_framework_orchestrations._handoff import (
25+
HANDOFF_FUNCTION_RESULT_KEY,
26+
HandoffConfiguration,
27+
_AutoHandoffMiddleware, # pyright: ignore[reportPrivateUsage]
28+
get_handoff_tool_name,
29+
)
30+
2431

2532
class MockChatClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
2633
"""Mock chat client for testing handoff workflows."""
@@ -365,3 +372,41 @@ def test_handoff_builder_accepts_all_instances_in_add_handoff():
365372
assert "triage" in workflow.executors
366373
assert "specialist_a" in workflow.executors
367374
assert "specialist_b" in workflow.executors
375+
376+
377+
async def test_auto_handoff_middleware_intercepts_handoff_tool_call() -> None:
378+
"""Middleware should short-circuit matching handoff tool calls with a synthetic result."""
379+
target_id = "specialist"
380+
middleware = _AutoHandoffMiddleware([HandoffConfiguration(target=target_id)])
381+
382+
@tool(name=get_handoff_tool_name(target_id), approval_mode="never_require")
383+
def handoff_tool() -> str:
384+
return "unreachable"
385+
386+
context = FunctionInvocationContext(function=handoff_tool, arguments={})
387+
call_next = AsyncMock()
388+
389+
with pytest.raises(MiddlewareTermination) as exc_info:
390+
await middleware.process(context, call_next)
391+
392+
call_next.assert_not_awaited()
393+
expected_result = FunctionTool.parse_result({HANDOFF_FUNCTION_RESULT_KEY: target_id})
394+
assert context.result == expected_result
395+
assert exc_info.value.result == expected_result
396+
397+
398+
async def test_auto_handoff_middleware_calls_next_for_non_handoff_tool() -> None:
399+
"""Middleware should pass through when the function name is not a configured handoff tool."""
400+
middleware = _AutoHandoffMiddleware([HandoffConfiguration(target="specialist")])
401+
402+
@tool(name="regular_tool", approval_mode="never_require")
403+
def regular_tool() -> str:
404+
return "ok"
405+
406+
context = FunctionInvocationContext(function=regular_tool, arguments={})
407+
call_next = AsyncMock()
408+
409+
await middleware.process(context, call_next)
410+
411+
call_next.assert_awaited_once()
412+
assert context.result is None

python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py

Lines changed: 37 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import json
55
import os
6+
from collections.abc import AsyncIterable
67
from dataclasses import dataclass, field
78
from typing import Annotated
89

@@ -12,7 +13,6 @@
1213
AgentExecutorRequest,
1314
AgentExecutorResponse,
1415
AgentResponse,
15-
AgentResponseUpdate,
1616
Executor,
1717
Message,
1818
WorkflowBuilder,
@@ -246,6 +246,31 @@ def display_agent_run_update(event: WorkflowEvent, last_executor: str | None) ->
246246
print(update, end="", flush=True)
247247

248248

249+
async def consume_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, str] | None:
250+
"""Consume a workflow event stream, printing outputs and returning any pending human responses."""
251+
requests: list[WorkflowEvent] = []
252+
async for event in stream:
253+
if event.type == "request_info" and isinstance(event.data, DraftFeedbackRequest):
254+
# Stash the request so we can prompt the human after the stream completes.
255+
requests.append(event)
256+
257+
if requests:
258+
pending_responses: dict[str, str] = {}
259+
for request in requests:
260+
print("\n----- Writer draft -----")
261+
print(request.data.draft_text.strip())
262+
print("\nProvide guidance for the editor (or 'approve' to accept the draft).")
263+
answer = input("Human feedback: ").strip() # noqa: ASYNC250
264+
if answer.lower() == "exit":
265+
print("Exiting...")
266+
exit(0)
267+
pending_responses[request.request_id] = answer
268+
269+
return pending_responses
270+
271+
return None
272+
273+
249274
async def main() -> None:
250275
"""Run the workflow and bridge human feedback between two agents."""
251276

@@ -267,66 +292,23 @@ async def main() -> None:
267292
.build()
268293
)
269294

270-
# Switch to turn on agent run update display.
271-
# By default this is off to reduce clutter during human input.
272-
display_agent_run_update_switch = False
273-
274295
print(
275296
"Interactive mode. When prompted, provide a short feedback note for the editor.",
276297
flush=True,
277298
)
278299

279-
pending_responses: dict[str, str] | None = None
280-
completed = False
281-
initial_run = True
300+
# Initiate the first run of the workflow.
301+
# Runs are not isolated; state is preserved across multiple calls to run.
302+
stream = workflow.run(
303+
"Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting.",
304+
stream=True,
305+
)
306+
pending_responses = await consume_stream(stream)
282307

283-
while not completed:
284-
last_executor: str | None = None
285-
if initial_run:
286-
stream = workflow.run(
287-
"Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting.",
288-
stream=True,
289-
)
290-
initial_run = False
291-
elif pending_responses is not None:
292-
stream = workflow.run(stream=True, responses=pending_responses)
293-
pending_responses = None
294-
else:
295-
break
296-
297-
requests: list[tuple[str, DraftFeedbackRequest]] = []
298-
299-
async for event in stream:
300-
if (
301-
event.type == "output"
302-
and isinstance(event.data, AgentResponseUpdate)
303-
and display_agent_run_update_switch
304-
):
305-
display_agent_run_update(event, last_executor)
306-
if event.type == "request_info" and isinstance(event.data, DraftFeedbackRequest):
307-
# Stash the request so we can prompt the human after the stream completes.
308-
requests.append((event.request_id, event.data))
309-
last_executor = None
310-
elif event.type == "output" and not isinstance(event.data, AgentResponseUpdate):
311-
# Only mark as completed for final outputs, not streaming updates
312-
last_executor = None
313-
response = event.data
314-
final_text = getattr(response, "text", str(response))
315-
print(final_text, flush=True, end="")
316-
completed = True
317-
318-
if requests and not completed:
319-
responses: dict[str, str] = {}
320-
for request_id, request in requests:
321-
print("\n----- Writer draft -----")
322-
print(request.draft_text.strip())
323-
print("\nProvide guidance for the editor (or 'approve' to accept the draft).")
324-
answer = input("Human feedback: ").strip() # noqa: ASYNC250
325-
if answer.lower() == "exit":
326-
print("Exiting...")
327-
return
328-
responses[request_id] = answer
329-
pending_responses = responses
308+
# Run until there are no more requests
309+
while pending_responses is not None:
310+
stream = workflow.run(stream=True, responses=pending_responses)
311+
pending_responses = await consume_stream(stream)
330312

331313
print("Workflow complete.")
332314

python/samples/03-workflows/agents/concurrent_workflow_as_agent.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,6 @@
2626
"""
2727

2828

29-
def clear_and_redraw(buffers: dict[str, str], agent_order: list[str]) -> None:
30-
"""Clear terminal and redraw all agent outputs grouped together."""
31-
# ANSI escape: clear screen and move cursor to top-left
32-
print("\033[2J\033[H", end="")
33-
print("===== Concurrent Agent Streaming (Live) =====\n")
34-
for name in agent_order:
35-
print(f"--- {name} ---")
36-
print(buffers.get(name, ""))
37-
print()
38-
print("", end="", flush=True)
39-
40-
4129
async def main() -> None:
4230
# 1) Create three domain agents using AzureOpenAIResponsesClient
4331
client = AzureOpenAIResponsesClient(

python/samples/03-workflows/agents/workflow_as_agent_human_in_the_loop.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ async def main() -> None:
106106
# and escalation paths for human review.
107107
worker = Worker(
108108
id="worker",
109-
chat_client=AzureOpenAIResponsesClient(
109+
client=AzureOpenAIResponsesClient(
110110
project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"],
111111
deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
112112
credential=AzureCliCredential(),
@@ -161,7 +161,7 @@ async def main() -> None:
161161

162162
request_id = agent_request.request_id
163163
# Mock a human response approval for demonstration purposes.
164-
human_response = ReviewResponse(request_id=request_id, feedback="Approved", approved=True)
164+
human_response = ReviewResponse(request_id=request_id, feedback="", approved=True)
165165

166166
# Create the function call result object to send back to the agent.
167167
human_review_function_result = Content.from_function_result(

python/samples/03-workflows/control-flow/workflow_cancellation.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,7 @@ async def step3(text: str, ctx: WorkflowContext[Never, str]) -> None:
5050

5151
def build_workflow():
5252
"""Build a simple 3-step sequential workflow (~6 seconds total)."""
53-
return (
54-
WorkflowBuilder(start_executor=step1)
55-
.add_edge(step1, step2)
56-
.add_edge(step2, step3)
57-
.build()
58-
)
53+
return WorkflowBuilder(start_executor=step1).add_edge(step1, step2).add_edge(step2, step3).build()
5954

6055

6156
async def run_with_cancellation() -> None:
@@ -64,7 +59,7 @@ async def run_with_cancellation() -> None:
6459
workflow = build_workflow()
6560

6661
# Wrap workflow.run() in a task to enable cancellation
67-
task = asyncio.create_task(workflow.run("hello world"))
62+
task = asyncio.ensure_future(workflow.run("hello world"))
6863

6964
# Wait 3 seconds (Step1 completes, Step2 is mid-execution), then cancel
7065
await asyncio.sleep(3)

python/samples/03-workflows/declarative/deep_research/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ async def main() -> None:
180180
)
181181

182182
# Load workflow from YAML
183-
samples_root = Path(__file__).parent.parent.parent.parent.parent.parent.parent
183+
samples_root = Path(__file__).parent.parent.parent.parent.parent.parent
184184
workflow_path = samples_root / "workflow-samples" / "DeepResearch.yaml"
185185
if not workflow_path.exists():
186186
# Fall back to local copy if workflow-samples doesn't exist

python/samples/03-workflows/declarative/human_in_loop/main.py

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import asyncio
1616
from pathlib import Path
17+
from typing import cast
1718

1819
from agent_framework import Workflow
1920
from agent_framework.declarative import ExternalInputRequest, WorkflowFactory
@@ -31,27 +32,18 @@ async def run_with_streaming(workflow: Workflow) -> None:
3132
data = event.data
3233
if isinstance(data, TextOutputEvent):
3334
print(f"[Bot]: {data.text}")
34-
elif isinstance(data, ExternalInputRequest):
35-
# In a real scenario, you would:
36-
# 1. Display the prompt to the user
37-
# 2. Wait for their response
38-
# 3. Use the response to continue the workflow
39-
output_property = data.metadata.get("output_property", "unknown")
40-
print(f"[System] Input requested for: {output_property}")
41-
if data.message:
42-
print(f"[System] Prompt: {data.message}")
4335
else:
4436
print(f"[Output]: {data}")
45-
46-
47-
async def run_with_result(workflow: Workflow) -> None:
48-
"""Demonstrate batch workflow execution with run()."""
49-
print("\n=== Batch Execution (run) ===")
50-
print("-" * 40)
51-
52-
result = await workflow.run({})
53-
for output in result.get_outputs():
54-
print(f" Output: {output}")
37+
elif event.type == "request_info":
38+
request = cast(ExternalInputRequest, event.data)
39+
# In a real scenario, you would:
40+
# 1. Display the prompt to the user
41+
# 2. Wait for their response
42+
# 3. Use the response to continue the workflow
43+
output_property = request.metadata.get("output_property", "unknown")
44+
print(f"[System] Input requested for: {output_property}")
45+
if request.message:
46+
print(f"[System] Prompt: {request.message}")
5547

5648

5749
async def main() -> None:
@@ -70,9 +62,6 @@ async def main() -> None:
7062
# Demonstrate streaming execution
7163
await run_with_streaming(workflow)
7264

73-
# Demonstrate batch execution
74-
# await run_with_result(workflow)
75-
7665
print("\n" + "-" * 40)
7766
print("=== Workflow Complete ===")
7867
print()

python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from typing_extensions import Never
2424

2525
"""
26-
Sample: AzureOpenAI Chat Agents in workflow with human feedback
26+
Sample: Azure AI Agents in workflow with human feedback
2727
2828
Pipeline layout:
2929
writer_agent -> Coordinator -> writer_agent -> Coordinator -> final_editor_agent -> Coordinator -> output

python/samples/03-workflows/orchestrations/magentic.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
logging.basicConfig(level=logging.WARNING)
1818
logger = logging.getLogger(__name__)
1919

20+
2021
"""
2122
Sample: Magentic Orchestration (multi-agent)
2223

0 commit comments

Comments
 (0)