mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: Refactor workflow events to unified discriminated union pattern (#3690)
* Refactor events * Merge main * Fixes * Cleanup * Update samples and tests * Remove unused imports * PR feedback * Merge main. Add properties for events to help typing * Formatting * Cleanup * use builtins.type to avoid shadowing by WorkflowEvent.type attribute * Final improvements
This commit is contained in:
committed by
GitHub
Unverified
parent
09f59b21ad
commit
0f3f4dbcaf
+12
-8
@@ -1,9 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, override
|
||||
from typing import Any
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore[import] # pragma: no cover
|
||||
|
||||
|
||||
# NOTE: the Azure client imports above are real dependencies. When running this
|
||||
# sample outside of Azure-enabled environments you may wish to swap in the
|
||||
@@ -15,13 +22,10 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowStatusEvent,
|
||||
get_checkpoint_summary,
|
||||
handler,
|
||||
response_handler,
|
||||
@@ -53,7 +57,7 @@ Typical pause/resume flow
|
||||
3. Later, restart the script, select that checkpoint, and provide the stored
|
||||
human decision when prompted to pre-supply responses.
|
||||
Doing so applies the answer immediately on resume, so the system does **not**
|
||||
re-emit the same `RequestInfoEvent`.
|
||||
re-emit the same ``.
|
||||
"""
|
||||
|
||||
# Directory used for the sample's temporary checkpoint files. We isolate the
|
||||
@@ -259,11 +263,11 @@ async def run_interactive_session(
|
||||
raise ValueError("Either initial_message or checkpoint_id must be provided")
|
||||
|
||||
async for event in event_stream:
|
||||
if isinstance(event, WorkflowStatusEvent):
|
||||
if event.type == "status":
|
||||
print(event)
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
completed_output = event.data
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
if isinstance(event.data, HumanApprovalRequest):
|
||||
requests[event.request_id] = event.data
|
||||
else:
|
||||
|
||||
@@ -24,21 +24,25 @@ Prerequisites:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from random import random
|
||||
from typing import Any, override
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
InMemoryCheckpointStorage,
|
||||
SuperStepCompletedEvent,
|
||||
WorkflowBuilder,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
handler,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore[import] # pragma: no cover
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComputeTask:
|
||||
@@ -126,12 +130,12 @@ async def main():
|
||||
|
||||
output: str | None = None
|
||||
async for event in event_stream:
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
output = event.data
|
||||
break
|
||||
if isinstance(event, SuperStepCompletedEvent) and random() < 0.5:
|
||||
if event.type == "superstep_completed" and random() < 0.5:
|
||||
# Randomly simulate system interruptions
|
||||
# The `SuperStepCompletedEvent` ensures we only interrupt after
|
||||
# The type="superstep_completed" event ensures we only interrupt after
|
||||
# the current super-step is fully complete and checkpointed.
|
||||
# If we interrupt mid-step, the workflow may resume from an earlier point.
|
||||
print("\n** Simulating workflow interruption. Stopping execution. **")
|
||||
|
||||
+22
-21
@@ -12,15 +12,12 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
Content,
|
||||
FileCheckpointStorage,
|
||||
HandoffAgentUserRequest,
|
||||
HandoffBuilder,
|
||||
RequestInfoEvent,
|
||||
Workflow,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowStatusEvent,
|
||||
WorkflowEvent,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
"""
|
||||
@@ -153,7 +150,7 @@ def _print_function_approval_request(request: Content, request_id: str) -> None:
|
||||
|
||||
|
||||
def _build_responses_for_requests(
|
||||
pending_requests: list[RequestInfoEvent],
|
||||
pending_requests: list[WorkflowEvent],
|
||||
*,
|
||||
user_response: str | None,
|
||||
approve_tools: bool | None,
|
||||
@@ -161,11 +158,15 @@ def _build_responses_for_requests(
|
||||
"""Create response payloads for each pending request."""
|
||||
responses: dict[str, object] = {}
|
||||
for request in pending_requests:
|
||||
if isinstance(request.data, HandoffAgentUserRequest):
|
||||
if isinstance(request.data, HandoffAgentUserRequest) and request.request_id:
|
||||
if user_response is None:
|
||||
raise ValueError("User response is required for HandoffAgentUserRequest")
|
||||
responses[request.request_id] = user_response
|
||||
elif isinstance(request.data, Content) and request.data.type == "function_approval_request":
|
||||
elif (
|
||||
isinstance(request.data, Content)
|
||||
and request.data.type == "function_approval_request"
|
||||
and request.request_id
|
||||
):
|
||||
if approve_tools is None:
|
||||
raise ValueError("Approval decision is required for function approval request")
|
||||
responses[request.request_id] = request.data.to_function_approval_response(approved=approve_tools)
|
||||
@@ -178,14 +179,14 @@ async def run_until_user_input_needed(
|
||||
workflow: Workflow,
|
||||
initial_message: str | None = None,
|
||||
checkpoint_id: str | None = None,
|
||||
) -> tuple[list[RequestInfoEvent], str | None]:
|
||||
) -> tuple[list[WorkflowEvent], str | None]:
|
||||
"""
|
||||
Run the workflow until it needs user input or approval, or completes.
|
||||
|
||||
Returns:
|
||||
Tuple of (pending_requests, checkpoint_id_to_use_for_resume)
|
||||
"""
|
||||
pending_requests: list[RequestInfoEvent] = []
|
||||
pending_requests: list[WorkflowEvent] = []
|
||||
latest_checkpoint_id: str | None = checkpoint_id
|
||||
|
||||
if initial_message:
|
||||
@@ -198,17 +199,17 @@ async def run_until_user_input_needed(
|
||||
raise ValueError("Must provide either initial_message or checkpoint_id")
|
||||
|
||||
async for event in event_stream:
|
||||
if isinstance(event, WorkflowStatusEvent):
|
||||
if event.type == "status":
|
||||
print(f"[Status] {event.state}")
|
||||
|
||||
elif isinstance(event, RequestInfoEvent):
|
||||
elif event.type == "request_info":
|
||||
pending_requests.append(event)
|
||||
if isinstance(event.data, HandoffAgentUserRequest):
|
||||
_print_handoff_request(event.data, event.request_id)
|
||||
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
|
||||
_print_function_approval_request(event.data, event.request_id)
|
||||
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
elif event.type == "output":
|
||||
print("\n[Workflow Completed]")
|
||||
if event.data:
|
||||
print(f"Final conversation length: {len(event.data)} messages")
|
||||
@@ -225,7 +226,7 @@ async def resume_with_responses(
|
||||
checkpoint_storage: FileCheckpointStorage,
|
||||
user_response: str | None = None,
|
||||
approve_tools: bool | None = None,
|
||||
) -> tuple[list[RequestInfoEvent], str | None]:
|
||||
) -> tuple[list[WorkflowEvent], str | None]:
|
||||
"""
|
||||
Two-step resume pattern (answers customer questions and tool approvals):
|
||||
|
||||
@@ -255,10 +256,10 @@ async def resume_with_responses(
|
||||
print(f"Step 1: Restoring checkpoint {latest_checkpoint.checkpoint_id}")
|
||||
|
||||
# Step 1: Restore the checkpoint to load pending requests into memory
|
||||
# The checkpoint restoration re-emits pending RequestInfoEvents
|
||||
restored_requests: list[RequestInfoEvent] = []
|
||||
# The checkpoint restoration re-emits pending request_info events
|
||||
restored_requests: list[WorkflowEvent] = []
|
||||
async for event in workflow.run(checkpoint_id=latest_checkpoint.checkpoint_id, stream=True): # type: ignore[attr-defined]
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
restored_requests.append(event)
|
||||
if isinstance(event.data, HandoffAgentUserRequest):
|
||||
_print_handoff_request(event.data, event.request_id)
|
||||
@@ -275,13 +276,13 @@ async def resume_with_responses(
|
||||
)
|
||||
print(f"Step 2: Sending responses for {len(responses)} request(s)")
|
||||
|
||||
new_pending_requests: list[RequestInfoEvent] = []
|
||||
new_pending_requests: list[WorkflowEvent] = []
|
||||
|
||||
async for event in workflow.send_responses_streaming(responses):
|
||||
if isinstance(event, WorkflowStatusEvent):
|
||||
if event.type == "status":
|
||||
print(f"[Status] {event.state}")
|
||||
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
elif event.type == "output":
|
||||
print("\n[Workflow Output Event - Conversation Update]")
|
||||
if event.data and isinstance(event.data, list) and all(isinstance(msg, ChatMessage) for msg in event.data): # type: ignore
|
||||
# Now safe to cast event.data to list[ChatMessage]
|
||||
@@ -291,7 +292,7 @@ async def resume_with_responses(
|
||||
text = msg.text[:100] + "..." if len(msg.text) > 100 else msg.text
|
||||
print(f" {author}: {text}")
|
||||
|
||||
elif isinstance(event, RequestInfoEvent):
|
||||
elif event.type == "request_info":
|
||||
new_pending_requests.append(event)
|
||||
if isinstance(event.data, HandoffAgentUserRequest):
|
||||
_print_handoff_request(event.data, event.request_id)
|
||||
|
||||
@@ -3,29 +3,33 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, override
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowExecutor,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore[import] # pragma: no cover
|
||||
|
||||
CHECKPOINT_DIR = Path(__file__).with_suffix("").parent / "tmp" / "sub_workflow_checkpoints"
|
||||
|
||||
"""
|
||||
@@ -335,10 +339,10 @@ async def main() -> None:
|
||||
|
||||
request_id: str | None = None
|
||||
async for event in workflow.run("Contoso Gadget Launch", stream=True):
|
||||
if isinstance(event, RequestInfoEvent) and request_id is None:
|
||||
if event.type == "request_info" and request_id is None:
|
||||
request_id = event.request_id
|
||||
print(f"Captured review request id: {request_id}")
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state is WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
if event.type == "status" and event.state is WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
break
|
||||
|
||||
if request_id is None:
|
||||
@@ -364,9 +368,9 @@ async def main() -> None:
|
||||
# Rebuild fresh instances to mimic a separate process resuming
|
||||
workflow2 = build_parent_workflow(storage)
|
||||
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
request_info_event: WorkflowEvent | None = None
|
||||
async for event in workflow2.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_info_event = event
|
||||
|
||||
if request_info_event is None:
|
||||
@@ -375,9 +379,9 @@ async def main() -> None:
|
||||
print("\n=== Stage 3: approve draft ==")
|
||||
|
||||
approval_response = "approve"
|
||||
output_event: WorkflowOutputEvent | None = None
|
||||
output_event: WorkflowEvent | None = None
|
||||
async for event in workflow2.send_responses_streaming({request_info_event.request_id: approval_response}):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
output_event = event
|
||||
|
||||
if output_event is None:
|
||||
|
||||
@@ -30,9 +30,9 @@ from agent_framework import (
|
||||
ChatAgent,
|
||||
ChatMessageStore,
|
||||
InMemoryCheckpointStorage,
|
||||
SequentialBuilder,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.orchestrations import SequentialBuilder
|
||||
|
||||
|
||||
async def basic_checkpointing() -> None:
|
||||
@@ -157,7 +157,12 @@ async def streaming_with_checkpoints() -> None:
|
||||
print(f"\nCheckpoints created during stream: {len(checkpoints)}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run all checkpoint examples."""
|
||||
await basic_checkpointing()
|
||||
await checkpointing_with_thread()
|
||||
await streaming_with_checkpoints()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(basic_checkpointing())
|
||||
asyncio.run(checkpointing_with_thread())
|
||||
asyncio.run(streaming_with_checkpoints())
|
||||
asyncio.run(main())
|
||||
|
||||
Reference in New Issue
Block a user