[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:
Evan Mattson
2026-02-06 16:47:20 +09:00
committed by GitHub
Unverified
parent 09f59b21ad
commit 0f3f4dbcaf
127 changed files with 1646 additions and 1703 deletions
@@ -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. **")
@@ -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())