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
@@ -2135,6 +2135,9 @@ class FunctionInvocationLayer(Generic[TOptions_co]):
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
|
||||
# Make options mutable so we can update conversation_id during function invocation loop
|
||||
mutable_options: dict[str, Any] = dict(options) if options else {}
|
||||
# Remove additional_function_arguments from options passed to underlying chat client
|
||||
# It's for tool invocation only and not recognized by chat service APIs
|
||||
mutable_options.pop("additional_function_arguments", None)
|
||||
|
||||
if not stream:
|
||||
|
||||
|
||||
@@ -31,22 +31,11 @@ from ._edge import (
|
||||
)
|
||||
from ._edge_runner import create_edge_runner
|
||||
from ._events import (
|
||||
ExecutorCompletedEvent,
|
||||
ExecutorEvent,
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokedEvent,
|
||||
RequestInfoEvent,
|
||||
SuperStepCompletedEvent,
|
||||
SuperStepStartedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowEventSource,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowLifecycleEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowEventType,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from ._exceptions import (
|
||||
WorkflowCheckpointException,
|
||||
@@ -96,10 +85,6 @@ __all__ = [
|
||||
"EdgeCondition",
|
||||
"EdgeDuplicationError",
|
||||
"Executor",
|
||||
"ExecutorCompletedEvent",
|
||||
"ExecutorEvent",
|
||||
"ExecutorFailedEvent",
|
||||
"ExecutorInvokedEvent",
|
||||
"FanInEdgeGroup",
|
||||
"FanOutEdgeGroup",
|
||||
"FileCheckpointStorage",
|
||||
@@ -108,14 +93,11 @@ __all__ = [
|
||||
"InMemoryCheckpointStorage",
|
||||
"InProcRunnerContext",
|
||||
"Message",
|
||||
"RequestInfoEvent",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SingleEdgeGroup",
|
||||
"SubWorkflowRequestMessage",
|
||||
"SubWorkflowResponseMessage",
|
||||
"SuperStepCompletedEvent",
|
||||
"SuperStepStartedEvent",
|
||||
"SwitchCaseEdgeGroup",
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
"SwitchCaseEdgeGroupDefault",
|
||||
@@ -132,16 +114,12 @@ __all__ = [
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowEventSource",
|
||||
"WorkflowEventType",
|
||||
"WorkflowException",
|
||||
"WorkflowExecutor",
|
||||
"WorkflowFailedEvent",
|
||||
"WorkflowLifecycleEvent",
|
||||
"WorkflowOutputEvent",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowRunnerException",
|
||||
"WorkflowStartedEvent",
|
||||
"WorkflowStatusEvent",
|
||||
"WorkflowValidationError",
|
||||
"WorkflowViz",
|
||||
"create_edge_runner",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
@@ -23,9 +25,7 @@ from .._types import add_usage_details
|
||||
from ..exceptions import AgentExecutionException
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._events import (
|
||||
RequestInfoEvent,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._typing_utils import is_instance_of, is_type_compatible
|
||||
@@ -59,11 +59,11 @@ class WorkflowAgent(BaseAgent):
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "WorkflowAgent.RequestInfoFunctionArgs":
|
||||
def from_dict(cls, payload: dict[str, Any]) -> WorkflowAgent.RequestInfoFunctionArgs:
|
||||
return cls(request_id=payload.get("request_id", ""), data=payload.get("data"))
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw: str) -> "WorkflowAgent.RequestInfoFunctionArgs":
|
||||
def from_json(cls, raw: str) -> WorkflowAgent.RequestInfoFunctionArgs:
|
||||
try:
|
||||
parsed: Any = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -74,7 +74,7 @@ class WorkflowAgent(BaseAgent):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: "Workflow",
|
||||
workflow: Workflow,
|
||||
*,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
@@ -93,10 +93,10 @@ class WorkflowAgent(BaseAgent):
|
||||
**kwargs: Additional keyword arguments passed to BaseAgent.
|
||||
|
||||
Note:
|
||||
Only WorkflowOutputEvents and RequestInfoEvents from the workflow are considered and
|
||||
converted to agent responses of the WorkflowAgent. Other workflow events are ignored.
|
||||
Use `with_output_from` in WorkflowBuilder to control which executors' outputs are surfaced
|
||||
as agent responses.
|
||||
Only output events (type='output') and request_info events (type='request_info') from
|
||||
the workflow are considered and converted to agent responses of the WorkflowAgent.
|
||||
Other workflow events are ignored. Use `with_output_from` in WorkflowBuilder to control
|
||||
which executors' outputs are surfaced as agent responses.
|
||||
"""
|
||||
if id is None:
|
||||
id = f"WorkflowAgent_{uuid.uuid4().hex[:8]}"
|
||||
@@ -111,15 +111,15 @@ class WorkflowAgent(BaseAgent):
|
||||
raise ValueError("Workflow's start executor cannot handle list[ChatMessage]")
|
||||
|
||||
super().__init__(id=id, name=name, description=description, **kwargs)
|
||||
self._workflow: "Workflow" = workflow
|
||||
self._pending_requests: dict[str, RequestInfoEvent] = {}
|
||||
self._workflow: Workflow = workflow
|
||||
self._pending_requests: dict[str, WorkflowEvent[Any]] = {}
|
||||
|
||||
@property
|
||||
def workflow(self) -> "Workflow":
|
||||
def workflow(self) -> Workflow:
|
||||
return self._workflow
|
||||
|
||||
@property
|
||||
def pending_requests(self) -> dict[str, RequestInfoEvent]:
|
||||
def pending_requests(self) -> dict[str, WorkflowEvent[Any]]:
|
||||
return self._pending_requests
|
||||
|
||||
# region Run Methods
|
||||
@@ -179,6 +179,10 @@ class WorkflowAgent(BaseAgent):
|
||||
Returns:
|
||||
When stream=True: An AsyncIterable[AgentResponseUpdate] for streaming updates.
|
||||
When stream=False: An Awaitable[AgentResponse] with the complete response.
|
||||
|
||||
Output events (type='output') from the workflow will be converted to ChatMessages
|
||||
or AgentResponseUpdate objects. Request info events (type='request_info') will be
|
||||
converted to function call and approval request contents.
|
||||
"""
|
||||
if stream:
|
||||
return self._run_streaming(
|
||||
@@ -228,7 +232,12 @@ class WorkflowAgent(BaseAgent):
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentResponseUpdate]:
|
||||
"""Internal streaming implementation."""
|
||||
"""Internal streaming implementation.
|
||||
|
||||
Yields AgentResponseUpdate objects. Output events (type='output') from the workflow
|
||||
are converted to updates. Request info events (type='request_info') are converted
|
||||
to function call and approval request contents.
|
||||
"""
|
||||
input_messages = normalize_messages_input(messages)
|
||||
thread = thread or self.get_new_thread()
|
||||
response_updates: list[AgentResponseUpdate] = []
|
||||
@@ -269,11 +278,11 @@ class WorkflowAgent(BaseAgent):
|
||||
Returns:
|
||||
An AgentResponse representing the workflow execution results.
|
||||
"""
|
||||
output_events: list[WorkflowOutputEvent | RequestInfoEvent] = []
|
||||
output_events: list[WorkflowEvent[Any]] = []
|
||||
async for event in self._run_core(
|
||||
input_messages, thread, checkpoint_id, checkpoint_storage, streaming=False, **kwargs
|
||||
):
|
||||
if isinstance(event, WorkflowOutputEvent | RequestInfoEvent):
|
||||
if event.type == "output" or event.type == "request_info":
|
||||
output_events.append(event)
|
||||
|
||||
return self._convert_workflow_events_to_agent_response(response_id, output_events)
|
||||
@@ -304,7 +313,7 @@ class WorkflowAgent(BaseAgent):
|
||||
async for event in self._run_core(
|
||||
input_messages, thread, checkpoint_id, checkpoint_storage, streaming=True, **kwargs
|
||||
):
|
||||
updates = self._convert_workflow_event_to_agent_response_update(response_id, event)
|
||||
updates = self._convert_workflow_event_to_agent_response_updates(response_id, event)
|
||||
for update in updates:
|
||||
yield update
|
||||
|
||||
@@ -440,7 +449,7 @@ class WorkflowAgent(BaseAgent):
|
||||
def _convert_workflow_events_to_agent_response(
|
||||
self,
|
||||
response_id: str,
|
||||
output_events: list[WorkflowOutputEvent | RequestInfoEvent],
|
||||
output_events: list[WorkflowEvent[Any]],
|
||||
) -> AgentResponse:
|
||||
"""Convert a list of workflow output events to an AgentResponse."""
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -449,7 +458,7 @@ class WorkflowAgent(BaseAgent):
|
||||
latest_created_at: str | None = None
|
||||
|
||||
for output_event in output_events:
|
||||
if isinstance(output_event, RequestInfoEvent):
|
||||
if output_event.type == "request_info":
|
||||
function_call, approval_request = self._process_request_info_event(output_event)
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
@@ -468,7 +477,7 @@ class WorkflowAgent(BaseAgent):
|
||||
# sequence cannot be guaranteed when there are streaming updates in between non-streaming
|
||||
# responses.
|
||||
raise AgentExecutionException(
|
||||
"WorkflowOutputEvent with AgentResponseUpdate data cannot be emitted in non-streaming mode. "
|
||||
"Output event with AgentResponseUpdate data cannot be emitted in non-streaming mode. "
|
||||
"Please ensure executors emit AgentResponse for non-streaming workflows."
|
||||
)
|
||||
|
||||
@@ -514,115 +523,160 @@ class WorkflowAgent(BaseAgent):
|
||||
raw_representation=raw_representations,
|
||||
)
|
||||
|
||||
def _convert_workflow_event_to_agent_response_update(
|
||||
def _process_request_info_event(
|
||||
self,
|
||||
event: WorkflowEvent[Any],
|
||||
) -> tuple[Content, Content]:
|
||||
"""Convert a request_info event to FunctionCallContent and FunctionApprovalRequestContent.
|
||||
|
||||
Args:
|
||||
event: A WorkflowEvent with type='request_info'.
|
||||
|
||||
Returns:
|
||||
A tuple of (FunctionCallContent, FunctionApprovalRequestContent).
|
||||
"""
|
||||
request_id = event.request_id
|
||||
if not request_id:
|
||||
raise ValueError("request_info event must have a request_id")
|
||||
|
||||
self.pending_requests[request_id] = event
|
||||
|
||||
args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict()
|
||||
|
||||
function_call = Content.from_function_call(
|
||||
call_id=request_id,
|
||||
name=self.REQUEST_INFO_FUNCTION_NAME,
|
||||
arguments=args,
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=request_id,
|
||||
function_call=function_call,
|
||||
additional_properties={"request_id": request_id},
|
||||
)
|
||||
return function_call, approval_request
|
||||
|
||||
def _convert_workflow_event_to_agent_response_updates(
|
||||
self,
|
||||
response_id: str,
|
||||
event: WorkflowEvent,
|
||||
event: WorkflowEvent[Any],
|
||||
) -> list[AgentResponseUpdate]:
|
||||
"""Convert a workflow event to an AgentResponseUpdate.
|
||||
"""Convert a workflow event to a list of AgentResponseUpdate objects.
|
||||
|
||||
Only WorkflowOutputEvent and RequestInfoEvent are processed.
|
||||
Events with type='output' and type='request_info' are processed.
|
||||
Other workflow events are ignored as they are workflow-internal.
|
||||
|
||||
For 'output' events, AgentExecutor yields AgentResponseUpdate for streaming updates
|
||||
via ctx.yield_output(). This method converts those to agent response updates.
|
||||
|
||||
Returns:
|
||||
A list of AgentResponseUpdate objects. Empty list if the event is not relevant.
|
||||
"""
|
||||
match event:
|
||||
# Convert workflow output to an agent response update.
|
||||
case WorkflowOutputEvent(data=data, executor_id=executor_id):
|
||||
# Handle different data types appropriately.
|
||||
if isinstance(data, AgentResponse):
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=[content for message in data.messages for content in message.contents],
|
||||
role="assistant",
|
||||
author_name=executor_id,
|
||||
response_id=response_id,
|
||||
created_at=data.created_at,
|
||||
raw_representation=data,
|
||||
)
|
||||
]
|
||||
if event.type == "output":
|
||||
# Convert workflow output to agent response updates.
|
||||
# Handle different data types appropriately.
|
||||
data = event.data
|
||||
executor_id = event.executor_id
|
||||
|
||||
if isinstance(data, AgentResponseUpdate):
|
||||
return [data]
|
||||
|
||||
if isinstance(data, ChatMessage):
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=list(data.contents),
|
||||
role=data.role,
|
||||
author_name=data.author_name,
|
||||
response_id=response_id,
|
||||
message_id=data.message_id or str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=data,
|
||||
)
|
||||
]
|
||||
|
||||
if is_instance_of(data, list[ChatMessage]):
|
||||
chat_messages = cast(list[ChatMessage], data)
|
||||
return [
|
||||
if isinstance(data, AgentResponseUpdate):
|
||||
# Pass through AgentResponseUpdate directly (streaming from AgentExecutor)
|
||||
if not data.author_name:
|
||||
data.author_name = executor_id
|
||||
return [data]
|
||||
if isinstance(data, AgentResponse):
|
||||
# Convert each message in AgentResponse to an AgentResponseUpdate
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
for msg in data.messages:
|
||||
updates.append(
|
||||
AgentResponseUpdate(
|
||||
contents=list(msg.contents),
|
||||
role=msg.role,
|
||||
author_name=msg.author_name,
|
||||
response_id=response_id,
|
||||
author_name=msg.author_name or executor_id,
|
||||
response_id=data.response_id or response_id,
|
||||
message_id=msg.message_id or str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
created_at=data.created_at
|
||||
or datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=msg,
|
||||
)
|
||||
for msg in chat_messages
|
||||
]
|
||||
|
||||
contents = self._extract_contents(data)
|
||||
if not contents:
|
||||
return []
|
||||
|
||||
)
|
||||
return updates
|
||||
if isinstance(data, ChatMessage):
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant",
|
||||
author_name=executor_id,
|
||||
contents=list(data.contents),
|
||||
role=data.role,
|
||||
author_name=data.author_name or executor_id,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=data,
|
||||
)
|
||||
]
|
||||
|
||||
case RequestInfoEvent():
|
||||
function_call, approval_request = self._process_request_info_event(event)
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=[function_call, approval_request],
|
||||
role="assistant",
|
||||
author_name=self.name,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
if is_instance_of(data, list[ChatMessage]):
|
||||
# Convert each ChatMessage to an AgentResponseUpdate
|
||||
chat_messages = cast(list[ChatMessage], data)
|
||||
updates = []
|
||||
for msg in chat_messages:
|
||||
updates.append(
|
||||
AgentResponseUpdate(
|
||||
contents=list(msg.contents),
|
||||
role=msg.role,
|
||||
author_name=msg.author_name or executor_id,
|
||||
response_id=response_id,
|
||||
message_id=msg.message_id or str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=msg,
|
||||
)
|
||||
)
|
||||
]
|
||||
case _:
|
||||
# Ignore workflow-internal events
|
||||
pass
|
||||
return updates
|
||||
contents = self._extract_contents(data)
|
||||
if not contents:
|
||||
return []
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant",
|
||||
author_name=executor_id,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=data,
|
||||
)
|
||||
]
|
||||
|
||||
if event.type == "request_info":
|
||||
# Store the pending request for later correlation
|
||||
request_id = event.request_id
|
||||
if not request_id:
|
||||
raise ValueError("request_info event must have a request_id")
|
||||
|
||||
self.pending_requests[request_id] = event
|
||||
|
||||
args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict()
|
||||
|
||||
function_call = Content.from_function_call(
|
||||
call_id=request_id,
|
||||
name=self.REQUEST_INFO_FUNCTION_NAME,
|
||||
arguments=args,
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=request_id,
|
||||
function_call=function_call,
|
||||
additional_properties={"request_id": request_id},
|
||||
)
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=[function_call, approval_request],
|
||||
role="assistant",
|
||||
author_name=self.name,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
)
|
||||
]
|
||||
|
||||
# Ignore workflow-internal events
|
||||
return []
|
||||
|
||||
def _process_request_info_event(self, event: RequestInfoEvent) -> tuple[Content, Content]:
|
||||
"""Process a RequestInfoEvent by adding it to pending requests."""
|
||||
# Store the pending request for later correlation
|
||||
self.pending_requests[event.request_id] = event
|
||||
|
||||
args = self.RequestInfoFunctionArgs(request_id=event.request_id, data=event.data).to_dict()
|
||||
function_call = Content.from_function_call(
|
||||
call_id=event.request_id,
|
||||
name=self.REQUEST_INFO_FUNCTION_NAME,
|
||||
arguments=args,
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=event.request_id,
|
||||
function_call=function_call,
|
||||
additional_properties={"request_id": event.request_id},
|
||||
)
|
||||
return function_call, approval_request
|
||||
|
||||
def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict[str, Any]:
|
||||
"""Extract function responses from input messages."""
|
||||
function_responses: dict[str, Any] = {}
|
||||
|
||||
@@ -65,8 +65,8 @@ class AgentExecutor(Executor):
|
||||
"""built-in executor that wraps an agent for handling messages.
|
||||
|
||||
AgentExecutor adapts its behavior based on the workflow execution mode:
|
||||
- run(stream=True): Emits incremental WorkflowOutputEvents as the agent produces tokens
|
||||
- run(): Emits a single WorkflowOutputEvent containing the complete response
|
||||
- run(stream=True): Emits incremental output events (type='output') as the agent produces tokens
|
||||
- run(): Emits a single output event (type='output') containing the complete response
|
||||
|
||||
Use `with_output_from` in WorkflowBuilder to control whether the AgentResponse
|
||||
or AgentResponseUpdate objects are yielded as workflow outputs.
|
||||
@@ -296,8 +296,8 @@ class AgentExecutor(Executor):
|
||||
) -> None:
|
||||
"""Execute the underlying agent, emit events, and enqueue response.
|
||||
|
||||
Checks ctx.is_streaming() to determine whether to emit WorkflowOutputEvents
|
||||
containing incremental updates (streaming mode) or a single WorkflowOutputEvent
|
||||
Checks ctx.is_streaming() to determine whether to emit output events (type='output')
|
||||
containing incremental updates (streaming mode) or a single output event (type='output')
|
||||
containing the complete response (non-streaming mode).
|
||||
"""
|
||||
if ctx.is_streaming():
|
||||
@@ -332,10 +332,16 @@ class AgentExecutor(Executor):
|
||||
"""
|
||||
run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
|
||||
# Build options dict with additional_function_arguments for tool kwargs propagation
|
||||
options: dict[str, Any] | None = None
|
||||
if run_kwargs:
|
||||
options = {"additional_function_arguments": run_kwargs}
|
||||
|
||||
response = await self._agent.run(
|
||||
self._cache,
|
||||
stream=False,
|
||||
thread=self._agent_thread,
|
||||
options=options,
|
||||
**run_kwargs,
|
||||
)
|
||||
await ctx.yield_output(response)
|
||||
@@ -360,12 +366,18 @@ class AgentExecutor(Executor):
|
||||
"""
|
||||
run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) or {}
|
||||
|
||||
# Build options dict with additional_function_arguments for tool kwargs propagation
|
||||
options: dict[str, Any] | None = None
|
||||
if run_kwargs:
|
||||
options = {"additional_function_arguments": run_kwargs}
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
user_input_requests: list[Content] = []
|
||||
async for update in self._agent.run(
|
||||
self._cache,
|
||||
stream=True,
|
||||
thread=self._agent_thread,
|
||||
options=options,
|
||||
**run_kwargs,
|
||||
):
|
||||
updates.append(update)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
@@ -59,7 +61,7 @@ class WorkflowCheckpoint:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> "WorkflowCheckpoint":
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> WorkflowCheckpoint:
|
||||
return cls(**data)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
||||
|
||||
from ._checkpoint import WorkflowCheckpoint
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
from ._events import RequestInfoEvent
|
||||
from ._events import WorkflowEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,14 +20,14 @@ class WorkflowCheckpointSummary:
|
||||
targets: list[str]
|
||||
executor_ids: list[str]
|
||||
status: str
|
||||
pending_request_info_events: list[RequestInfoEvent]
|
||||
pending_request_info_events: list[WorkflowEvent]
|
||||
|
||||
|
||||
def get_checkpoint_summary(checkpoint: WorkflowCheckpoint) -> WorkflowCheckpointSummary:
|
||||
targets = sorted(checkpoint.messages.keys())
|
||||
executor_ids = sorted(checkpoint.state.get(EXECUTOR_STATE_KEY, {}).keys())
|
||||
pending_request_info_events = [
|
||||
RequestInfoEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values()
|
||||
WorkflowEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values()
|
||||
]
|
||||
|
||||
status = "idle"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
@@ -214,7 +216,7 @@ class Edge(DictConvertible):
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Edge":
|
||||
def from_dict(cls, data: dict[str, Any]) -> Edge:
|
||||
"""Reconstruct an `Edge` from its serialised dictionary form.
|
||||
|
||||
The deserialised edge will lack the executable predicate because we do
|
||||
@@ -311,7 +313,7 @@ class EdgeGroup(DictConvertible):
|
||||
|
||||
from builtins import type as builtin_type
|
||||
|
||||
_TYPE_REGISTRY: ClassVar[dict[str, builtin_type["EdgeGroup"]]] = {}
|
||||
_TYPE_REGISTRY: ClassVar[dict[str, builtin_type[EdgeGroup]]] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -415,7 +417,7 @@ class EdgeGroup(DictConvertible):
|
||||
return subclass
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "EdgeGroup":
|
||||
def from_dict(cls, data: dict[str, Any]) -> EdgeGroup:
|
||||
"""Hydrate the correct `EdgeGroup` subclass from serialised state.
|
||||
|
||||
The method inspects the `type` field, allocates the corresponding class
|
||||
@@ -735,7 +737,7 @@ class SwitchCaseEdgeGroupCase(DictConvertible):
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "SwitchCaseEdgeGroupCase":
|
||||
def from_dict(cls, data: dict[str, Any]) -> SwitchCaseEdgeGroupCase:
|
||||
"""Instantiate a case from its serialised dictionary payload.
|
||||
|
||||
Examples:
|
||||
@@ -789,7 +791,7 @@ class SwitchCaseEdgeGroupDefault(DictConvertible):
|
||||
return {"target_id": self.target_id, "type": self.type}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "SwitchCaseEdgeGroupDefault":
|
||||
def from_dict(cls, data: dict[str, Any]) -> SwitchCaseEdgeGroupDefault:
|
||||
"""Recreate the default branch from its persisted form.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import sys
|
||||
import traceback as _traceback
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, TypeAlias
|
||||
from typing import Any, Generic, Literal, cast
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._typing_utils import deserialize_type, serialize_type
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypeVar # type: ignore[import] # pragma: no cover
|
||||
|
||||
DataT = TypeVar("DataT", default=Any)
|
||||
|
||||
|
||||
class WorkflowEventSource(str, Enum):
|
||||
"""Identifies whether a workflow event came from the framework or an executor.
|
||||
@@ -44,114 +55,16 @@ def _framework_event_origin() -> Iterator[None]: # pyright: ignore[reportUnused
|
||||
_event_origin_context.reset(token)
|
||||
|
||||
|
||||
class WorkflowEvent:
|
||||
"""Base class for workflow events."""
|
||||
|
||||
def __init__(self, data: Any | None = None):
|
||||
"""Initialize the workflow event with optional data."""
|
||||
self.data = data
|
||||
self.origin = _current_event_origin()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the workflow event."""
|
||||
data_repr = self.data if self.data is not None else "None"
|
||||
return f"{self.__class__.__name__}(origin={self.origin}, data={data_repr})"
|
||||
|
||||
|
||||
class WorkflowStartedEvent(WorkflowEvent):
|
||||
"""Built-in lifecycle event emitted when a workflow run begins."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class WorkflowWarningEvent(WorkflowEvent):
|
||||
"""Executor-origin event signaling a warning surfaced by user code."""
|
||||
|
||||
def __init__(self, data: str):
|
||||
"""Initialize the workflow warning event with optional data and warning message."""
|
||||
super().__init__(data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the workflow warning event."""
|
||||
return f"{self.__class__.__name__}(message={self.data}, origin={self.origin})"
|
||||
|
||||
|
||||
class WorkflowErrorEvent(WorkflowEvent):
|
||||
"""Executor-origin event signaling an error surfaced by user code."""
|
||||
|
||||
def __init__(self, data: Exception):
|
||||
"""Initialize the workflow error event with optional data and error message."""
|
||||
super().__init__(data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the workflow error event."""
|
||||
return f"{self.__class__.__name__}(exception={self.data}, origin={self.origin})"
|
||||
|
||||
|
||||
class WorkflowRunState(str, Enum):
|
||||
"""Run-level state of a workflow execution.
|
||||
"""Run-level state of a workflow execution."""
|
||||
|
||||
Semantics:
|
||||
- STARTED: Run has been initiated and the workflow context has been created.
|
||||
This is an initial state before any meaningful work is performed. In this
|
||||
codebase we emit a dedicated `WorkflowStartedEvent` for telemetry, and
|
||||
typically advance the status directly to `IN_PROGRESS`. Consumers may
|
||||
still rely on `STARTED` for state machines that need an explicit pre-work
|
||||
phase.
|
||||
|
||||
- IN_PROGRESS: The workflow is actively executing (e.g., the initial
|
||||
message has been delivered to the start executor or a superstep is
|
||||
running). This status is emitted at the beginning of a run and can be
|
||||
followed by other statuses as the run progresses.
|
||||
|
||||
- IN_PROGRESS_PENDING_REQUESTS: Active execution while one or more
|
||||
request-for-information operations are outstanding. New work may still
|
||||
be scheduled while requests are in flight.
|
||||
|
||||
- IDLE: The workflow is quiescent with no outstanding requests and no more
|
||||
work to do. This is the normal terminal state for workflows that have
|
||||
finished executing, potentially having produced outputs along the way.
|
||||
|
||||
- IDLE_WITH_PENDING_REQUESTS: The workflow is paused awaiting external
|
||||
input (e.g., emitted a `RequestInfoEvent`). This is a non-terminal
|
||||
state; the workflow can resume when responses are supplied.
|
||||
|
||||
- FAILED: Terminal state indicating an error surfaced. Accompanied by a
|
||||
`WorkflowFailedEvent` with structured error details.
|
||||
|
||||
- CANCELLED: Terminal state indicating the run was cancelled by a caller
|
||||
or orchestrator. Not currently emitted by default runner paths but
|
||||
included for integrators/orchestrators that support cancellation.
|
||||
"""
|
||||
|
||||
STARTED = "STARTED" # Explicit pre-work phase (rarely emitted as status; see note above)
|
||||
IN_PROGRESS = "IN_PROGRESS" # Active execution is underway
|
||||
IN_PROGRESS_PENDING_REQUESTS = "IN_PROGRESS_PENDING_REQUESTS" # Active execution with outstanding requests
|
||||
IDLE = "IDLE" # No active work and no outstanding requests
|
||||
IDLE_WITH_PENDING_REQUESTS = "IDLE_WITH_PENDING_REQUESTS" # Paused awaiting external responses
|
||||
FAILED = "FAILED" # Finished with an error
|
||||
CANCELLED = "CANCELLED" # Finished due to cancellation
|
||||
|
||||
|
||||
class WorkflowStatusEvent(WorkflowEvent):
|
||||
"""Built-in lifecycle event emitted for workflow run state transitions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state: WorkflowRunState,
|
||||
data: Any | None = None,
|
||||
):
|
||||
"""Initialize the workflow status event with a new state and optional data.
|
||||
|
||||
Args:
|
||||
state: The new state of the workflow run.
|
||||
data: Optional additional data associated with the state change.
|
||||
"""
|
||||
super().__init__(data)
|
||||
self.state = state
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - representation only
|
||||
return f"{self.__class__.__name__}(state={self.state}, data={self.data!r}, origin={self.origin})"
|
||||
STARTED = "STARTED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
IN_PROGRESS_PENDING_REQUESTS = "IN_PROGRESS_PENDING_REQUESTS"
|
||||
IDLE = "IDLE"
|
||||
IDLE_WITH_PENDING_REQUESTS = "IDLE_WITH_PENDING_REQUESTS"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -171,7 +84,7 @@ class WorkflowErrorDetails:
|
||||
*,
|
||||
executor_id: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> "WorkflowErrorDetails":
|
||||
) -> WorkflowErrorDetails:
|
||||
tb = None
|
||||
try:
|
||||
tb = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
@@ -186,180 +99,328 @@ class WorkflowErrorDetails:
|
||||
)
|
||||
|
||||
|
||||
class WorkflowFailedEvent(WorkflowEvent):
|
||||
"""Built-in lifecycle event emitted when a workflow run terminates with an error."""
|
||||
# Type discriminator for workflow events.
|
||||
# Includes both framework lifecycle types and well-known orchestration types.
|
||||
WorkflowEventType = Literal[
|
||||
# Lifecycle events (workflow-level)
|
||||
"started", # Workflow run began
|
||||
"status", # Workflow state changed (use .state)
|
||||
"failed", # Workflow terminated with error (use .details)
|
||||
# Data events
|
||||
"output", # Executor yielded final output (use .executor_id, .data)
|
||||
"data", # Executor emitted data during execution (use .executor_id, .data)
|
||||
# Request events (human-in-the-loop)
|
||||
"request_info", # Executor requests external info (use .request_id, .source_executor_id)
|
||||
# Diagnostic events (warnings/errors from user code)
|
||||
"warning", # Warning from user code (use .data as str)
|
||||
"error", # Error from user code, non-fatal (use .data as Exception)
|
||||
# Iteration events (supersteps)
|
||||
"superstep_started", # Superstep began (use .iteration)
|
||||
"superstep_completed", # Superstep ended (use .iteration)
|
||||
# Executor lifecycle events
|
||||
"executor_invoked", # Executor handler was called (use .executor_id, .data)
|
||||
"executor_completed", # Executor handler completed (use .executor_id, .data)
|
||||
"executor_failed", # Executor handler raised error (use .executor_id, .details)
|
||||
# Orchestration event types (use .data for typed payload)
|
||||
"group_chat", # Group chat orchestrator events (use .data as GroupChatRequestSentEvent | GroupChatResponseReceivedEvent) # noqa: E501
|
||||
"handoff_sent", # Handoff routing events (use .data as HandoffSentEvent)
|
||||
"magentic_orchestrator", # Magentic orchestrator events (use .data as MagenticOrchestratorEvent)
|
||||
]
|
||||
|
||||
|
||||
class WorkflowEvent(Generic[DataT]):
|
||||
"""Unified event for all workflow emissions.
|
||||
|
||||
This single generic class handles all workflow events through a `type` discriminator,
|
||||
following the same pattern as the `Content` class.
|
||||
|
||||
Use factory methods for convenient construction:
|
||||
|
||||
- `WorkflowEvent.started()` - workflow run began
|
||||
- `WorkflowEvent.status(state)` - workflow state changed
|
||||
- `WorkflowEvent.failed(details)` - workflow terminated with error
|
||||
- `WorkflowEvent.warning(message)` - warning from user code
|
||||
- `WorkflowEvent.error(exception)` - error from user code
|
||||
- `WorkflowEvent.output(executor_id, data)` - executor yielded final output
|
||||
- `WorkflowEvent.data(executor_id, data)` - executor emitted data (e.g., AgentResponse)
|
||||
- `WorkflowEvent.request_info(...)` - executor requests external info
|
||||
- `WorkflowEvent.superstep_started(iteration)` - superstep began
|
||||
- `WorkflowEvent.superstep_completed(iteration)` - superstep ended
|
||||
- `WorkflowEvent.executor_invoked(executor_id)` - executor handler called
|
||||
- `WorkflowEvent.executor_completed(executor_id)` - executor handler completed
|
||||
- `WorkflowEvent.executor_failed(executor_id, details)` - executor handler failed
|
||||
|
||||
The generic parameter DataT represents the type of the event's data payload:
|
||||
- Lifecycle events: `WorkflowEvent[None]` (data is None)
|
||||
- Data events: `WorkflowEvent[DataT]` where DataT is the payload type (e.g., AgentResponse)
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
# Create events via factory methods
|
||||
started = WorkflowEvent.started()
|
||||
status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
|
||||
output = WorkflowEvent.output("agent1", result_data)
|
||||
|
||||
# Emit typed data from executor
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent.data("agent1", response)
|
||||
data: AgentResponse = event.data # Type-safe access
|
||||
|
||||
# Check event type
|
||||
if event.type == "status":
|
||||
print(f"State: {event.state}")
|
||||
elif event.type == "output":
|
||||
print(f"Output from {event.executor_id}: {event.data}")
|
||||
elif event.type == "data":
|
||||
if isinstance(event.data, AgentResponse):
|
||||
print(f"Agent response: {event.data.text}")
|
||||
"""
|
||||
|
||||
type: WorkflowEventType
|
||||
data: DataT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
details: WorkflowErrorDetails,
|
||||
data: Any | None = None,
|
||||
):
|
||||
super().__init__(data)
|
||||
type: WorkflowEventType,
|
||||
data: DataT | None = None,
|
||||
*,
|
||||
# Event context fields
|
||||
origin: WorkflowEventSource | None = None,
|
||||
# STATUS event fields
|
||||
state: WorkflowRunState | None = None,
|
||||
# FAILED event fields
|
||||
details: WorkflowErrorDetails | None = None,
|
||||
# OUTPUT/DATA event fields
|
||||
executor_id: str | None = None,
|
||||
# REQUEST_INFO event fields
|
||||
request_id: str | None = None,
|
||||
source_executor_id: str | None = None,
|
||||
request_type: builtins.type[Any] | None = None,
|
||||
response_type: builtins.type[Any] | None = None,
|
||||
# SUPERSTEP event fields
|
||||
iteration: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the workflow event.
|
||||
|
||||
Prefer using factory methods like `WorkflowEvent.started()` instead of __init__ directly.
|
||||
"""
|
||||
self.type = type
|
||||
self.data = data # type: ignore[assignment]
|
||||
self.origin = origin if origin is not None else _current_event_origin()
|
||||
|
||||
# Event-specific fields
|
||||
self.state = state
|
||||
self.details = details
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - representation only
|
||||
return f"{self.__class__.__name__}(details={self.details}, data={self.data!r}, origin={self.origin})"
|
||||
|
||||
|
||||
class RequestInfoEvent(WorkflowEvent):
|
||||
"""Event triggered when a workflow executor requests external information."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request_id: str,
|
||||
source_executor_id: str,
|
||||
request_data: Any,
|
||||
response_type: type[Any],
|
||||
):
|
||||
"""Initialize the request info event.
|
||||
|
||||
Args:
|
||||
request_id: Unique identifier for the request.
|
||||
source_executor_id: ID of the executor that made the request.
|
||||
request_data: The data associated with the request.
|
||||
response_type: Expected type of the response.
|
||||
"""
|
||||
super().__init__(request_data)
|
||||
self.request_id = request_id
|
||||
self.source_executor_id = source_executor_id
|
||||
self.request_type: type[Any] = type(request_data)
|
||||
self.response_type = response_type
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the request info event."""
|
||||
return (
|
||||
f"{self.__class__.__name__}("
|
||||
f"request_id={self.request_id}, "
|
||||
f"source_executor_id={self.source_executor_id}, "
|
||||
f"request_type={self.request_type.__name__}, "
|
||||
f"data={self.data}, "
|
||||
f"response_type={self.response_type.__name__})"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert the request info event to a dictionary for serialization."""
|
||||
return {
|
||||
"data": encode_checkpoint_value(self.data),
|
||||
"request_id": self.request_id,
|
||||
"source_executor_id": self.source_executor_id,
|
||||
"request_type": serialize_type(self.request_type),
|
||||
"response_type": serialize_type(self.response_type),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, Any]) -> "RequestInfoEvent":
|
||||
"""Create a RequestInfoEvent from a dictionary."""
|
||||
# Validation
|
||||
for property in ["data", "request_id", "source_executor_id", "request_type", "response_type"]:
|
||||
if property not in data:
|
||||
raise KeyError(f"Missing '{property}' field in RequestInfoEvent dictionary.")
|
||||
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_id=data["request_id"],
|
||||
source_executor_id=data["source_executor_id"],
|
||||
request_data=decode_checkpoint_value(data["data"]),
|
||||
response_type=deserialize_type(data["response_type"]),
|
||||
)
|
||||
|
||||
# Verify that the deserialized request_data matches the declared request_type
|
||||
if deserialize_type(data["request_type"]) is not type(request_info_event.data):
|
||||
raise TypeError(
|
||||
"Mismatch between deserialized request_data type and request_type field in RequestInfoEvent dictionary."
|
||||
)
|
||||
|
||||
return request_info_event
|
||||
|
||||
|
||||
class WorkflowOutputEvent(WorkflowEvent):
|
||||
"""Event triggered when a workflow executor yields output."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: Any,
|
||||
executor_id: str,
|
||||
):
|
||||
"""Initialize the workflow output event.
|
||||
|
||||
Args:
|
||||
data: The output yielded by the executor.
|
||||
executor_id: ID of the executor that yielded the output.
|
||||
"""
|
||||
super().__init__(data)
|
||||
self.executor_id = executor_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the workflow output event."""
|
||||
return f"{self.__class__.__name__}(data={self.data}, executor_id={self.executor_id})"
|
||||
|
||||
|
||||
class SuperStepEvent(WorkflowEvent):
|
||||
"""Event triggered when a superstep starts or ends."""
|
||||
|
||||
def __init__(self, iteration: int, data: Any | None = None):
|
||||
"""Initialize the superstep event.
|
||||
|
||||
Args:
|
||||
iteration: The number of the superstep (1-based index).
|
||||
data: Optional data associated with the superstep event.
|
||||
"""
|
||||
super().__init__(data)
|
||||
self._request_id = request_id
|
||||
self._source_executor_id = source_executor_id
|
||||
self._request_type = request_type
|
||||
self._response_type = response_type
|
||||
self.iteration = iteration
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the superstep event."""
|
||||
return f"{self.__class__.__name__}(iteration={self.iteration}, data={self.data})"
|
||||
"""Return a string representation of the workflow event."""
|
||||
parts = [f"type={self.type!r}"]
|
||||
if self.state is not None:
|
||||
parts.append(f"state={self.state.value}")
|
||||
if self.executor_id is not None:
|
||||
parts.append(f"executor_id={self.executor_id!r}")
|
||||
if self.iteration is not None:
|
||||
parts.append(f"iteration={self.iteration}")
|
||||
if self._request_id is not None:
|
||||
parts.append(f"request_id={self._request_id!r}")
|
||||
if self.data is not None:
|
||||
parts.append(f"data={self.data!r}")
|
||||
return f"WorkflowEvent({', '.join(parts)})" # pragma: no cover
|
||||
|
||||
# ==========================================================================
|
||||
# Factory methods
|
||||
# ==========================================================================
|
||||
|
||||
class SuperStepStartedEvent(SuperStepEvent):
|
||||
"""Event triggered when a superstep starts."""
|
||||
@classmethod
|
||||
def started(cls, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'started' event when a workflow run begins."""
|
||||
return cls("started", data=data)
|
||||
|
||||
...
|
||||
@classmethod
|
||||
def status(cls, state: WorkflowRunState, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'status' event for workflow state transitions."""
|
||||
return cls("status", data=data, state=state)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, details: WorkflowErrorDetails, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'failed' event when a workflow terminates with error."""
|
||||
return cls("failed", data=data, details=details)
|
||||
|
||||
class SuperStepCompletedEvent(SuperStepEvent):
|
||||
"""Event triggered when a superstep ends."""
|
||||
@classmethod
|
||||
def warning(cls, message: str) -> WorkflowEvent[str]:
|
||||
"""Create a 'warning' event from user code."""
|
||||
return WorkflowEvent("warning", data=message)
|
||||
|
||||
...
|
||||
@classmethod
|
||||
def error(cls, exception: Exception) -> WorkflowEvent[Exception]:
|
||||
"""Create an 'error' event from user code."""
|
||||
return WorkflowEvent("error", data=exception)
|
||||
|
||||
@classmethod
|
||||
def output(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
|
||||
"""Create an 'output' event when an executor yields final output."""
|
||||
return cls("output", executor_id=executor_id, data=data)
|
||||
|
||||
class ExecutorEvent(WorkflowEvent):
|
||||
"""Base class for executor events."""
|
||||
@classmethod
|
||||
def emit(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'data' event when an executor emits data during execution.
|
||||
|
||||
def __init__(self, executor_id: str, data: Any | None = None):
|
||||
"""Initialize the executor event with an executor ID and optional data."""
|
||||
super().__init__(data)
|
||||
self.executor_id = executor_id
|
||||
This is the primary method for executors to emit typed data
|
||||
(e.g., AgentResponse, AgentResponseUpdate, custom data).
|
||||
"""
|
||||
return cls("data", executor_id=executor_id, data=data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the executor event."""
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id}, data={self.data})"
|
||||
@classmethod
|
||||
def request_info(
|
||||
cls,
|
||||
request_id: str,
|
||||
source_executor_id: str,
|
||||
request_data: DataT,
|
||||
response_type: builtins.type[Any],
|
||||
) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'request_info' event when an executor requests external information."""
|
||||
return cls(
|
||||
"request_info",
|
||||
data=request_data,
|
||||
request_id=request_id,
|
||||
source_executor_id=source_executor_id,
|
||||
request_type=type(request_data),
|
||||
response_type=response_type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def superstep_started(cls, iteration: int, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'superstep_started' event when a superstep begins."""
|
||||
return cls("superstep_started", iteration=iteration, data=data)
|
||||
|
||||
class ExecutorInvokedEvent(ExecutorEvent):
|
||||
"""Event triggered when an executor handler is invoked."""
|
||||
@classmethod
|
||||
def superstep_completed(cls, iteration: int, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create a 'superstep_completed' event when a superstep ends."""
|
||||
return cls("superstep_completed", iteration=iteration, data=data)
|
||||
|
||||
...
|
||||
@classmethod
|
||||
def executor_invoked(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create an 'executor_invoked' event when an executor handler is called."""
|
||||
return cls("executor_invoked", executor_id=executor_id, data=data)
|
||||
|
||||
@classmethod
|
||||
def executor_completed(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]:
|
||||
"""Create an 'executor_completed' event when an executor handler completes."""
|
||||
return cls("executor_completed", executor_id=executor_id, data=data)
|
||||
|
||||
class ExecutorCompletedEvent(ExecutorEvent):
|
||||
"""Event triggered when an executor handler is completed."""
|
||||
@classmethod
|
||||
def executor_failed(cls, executor_id: str, details: WorkflowErrorDetails) -> WorkflowEvent[WorkflowErrorDetails]:
|
||||
"""Create an 'executor_failed' event when an executor handler raises an error."""
|
||||
return WorkflowEvent("executor_failed", executor_id=executor_id, data=details, details=details)
|
||||
|
||||
...
|
||||
# ==========================================================================
|
||||
# Property for type-safe access
|
||||
# ==========================================================================
|
||||
|
||||
@property
|
||||
def request_id(self) -> str:
|
||||
"""Get request_id for request_info events.
|
||||
|
||||
class ExecutorFailedEvent(ExecutorEvent):
|
||||
"""Event triggered when an executor handler raises an error."""
|
||||
Returns:
|
||||
The request ID as a non-None string.
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor_id: str,
|
||||
details: WorkflowErrorDetails,
|
||||
):
|
||||
super().__init__(executor_id, details)
|
||||
self.details = details
|
||||
Raises:
|
||||
RuntimeError: If called on an event that is not a request_info event,
|
||||
or if the event is malformed (request_info without request_id).
|
||||
"""
|
||||
if self.type != "request_info" or self._request_id is None:
|
||||
raise RuntimeError(f"request_id is only available for request_info events, got type={self.type!r}")
|
||||
return self._request_id
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - representation only
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id}, details={self.details})"
|
||||
@property
|
||||
def source_executor_id(self) -> str:
|
||||
"""Get source_executor_id for request_info events.
|
||||
|
||||
Returns:
|
||||
The source executor ID as a non-None string.
|
||||
|
||||
WorkflowLifecycleEvent: TypeAlias = WorkflowStartedEvent | WorkflowStatusEvent | WorkflowFailedEvent
|
||||
Raises:
|
||||
RuntimeError: If called on an event that is not a request_info event,
|
||||
or if the event is malformed (request_info without source_executor_id).
|
||||
"""
|
||||
if self.type != "request_info" or self._source_executor_id is None:
|
||||
raise RuntimeError(f"source_executor_id is only available for request_info events, got type={self.type!r}")
|
||||
return self._source_executor_id
|
||||
|
||||
@property
|
||||
def request_type(self) -> builtins.type[Any]:
|
||||
"""Get request_type for request_info events.
|
||||
|
||||
Returns:
|
||||
The request data type as a non-None type object.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called on an event that is not a request_info event,
|
||||
or if the event is malformed (request_info without request_type).
|
||||
"""
|
||||
if self.type != "request_info" or self._request_type is None:
|
||||
raise RuntimeError(f"request_type is only available for request_info events, got type={self.type!r}")
|
||||
return self._request_type
|
||||
|
||||
@property
|
||||
def response_type(self) -> builtins.type[Any]:
|
||||
"""Get response_type for request_info events.
|
||||
|
||||
Returns:
|
||||
The response data type as a non-None type object.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called on an event that is not a request_info event,
|
||||
or if the event is malformed (request_info without response_type).
|
||||
"""
|
||||
if self.type != "request_info" or self._response_type is None:
|
||||
raise RuntimeError(f"response_type is only available for request_info events, got type={self.type!r}")
|
||||
return self._response_type
|
||||
|
||||
# ==========================================================================
|
||||
# Serialization methods (primarily for REQUEST_INFO events)
|
||||
# ==========================================================================
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for serialization.
|
||||
|
||||
Currently only implemented for 'request_info' events for checkpoint storage.
|
||||
"""
|
||||
if self.type != "request_info":
|
||||
raise ValueError(f"to_dict() only supported for 'request_info' events, got '{self.type}'")
|
||||
return {
|
||||
"type": self.type,
|
||||
"data": encode_checkpoint_value(self.data),
|
||||
"request_id": self._request_id,
|
||||
"source_executor_id": self._source_executor_id,
|
||||
"request_type": serialize_type(self._request_type) if self._request_type else None,
|
||||
"response_type": serialize_type(self._response_type) if self._response_type else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> WorkflowEvent[Any]:
|
||||
"""Create a REQUEST_INFO event from a dictionary."""
|
||||
for prop in ["data", "request_id", "source_executor_id", "request_type", "response_type"]:
|
||||
if prop not in data:
|
||||
raise KeyError(f"Missing '{prop}' field in WorkflowEvent dictionary.")
|
||||
|
||||
request_data = decode_checkpoint_value(data["data"])
|
||||
request_type = deserialize_type(data["request_type"])
|
||||
|
||||
if request_type is not type(request_data):
|
||||
raise TypeError(
|
||||
"Mismatch between deserialized request_data type and request_type field in WorkflowEvent dictionary."
|
||||
)
|
||||
|
||||
return cls.request_info(
|
||||
request_id=data["request_id"],
|
||||
source_executor_id=data["source_executor_id"],
|
||||
request_data=cast(Any, request_data), # type: ignore
|
||||
response_type=deserialize_type(data["response_type"]),
|
||||
)
|
||||
|
||||
@@ -11,10 +11,8 @@ from typing import Any, TypeVar, overload
|
||||
|
||||
from ..observability import create_processing_span
|
||||
from ._events import (
|
||||
ExecutorCompletedEvent,
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
_framework_event_origin, # type: ignore[reportPrivateUsage]
|
||||
)
|
||||
from ._model_utils import DictConvertible
|
||||
@@ -274,14 +272,14 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
# Invoke the handler with the message and context
|
||||
# Use deepcopy to capture original input state before handler can mutate it
|
||||
with _framework_event_origin():
|
||||
invoke_event = ExecutorInvokedEvent(self.id, copy.deepcopy(message))
|
||||
invoke_event = WorkflowEvent.executor_invoked(self.id, copy.deepcopy(message))
|
||||
await context.add_event(invoke_event)
|
||||
try:
|
||||
await handler(message, context)
|
||||
except Exception as exc:
|
||||
# Surface structured executor failure before propagating
|
||||
with _framework_event_origin():
|
||||
failure_event = ExecutorFailedEvent(self.id, WorkflowErrorDetails.from_exception(exc))
|
||||
failure_event = WorkflowEvent.executor_failed(self.id, WorkflowErrorDetails.from_exception(exc))
|
||||
await context.add_event(failure_event)
|
||||
raise
|
||||
with _framework_event_origin():
|
||||
@@ -289,7 +287,9 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
sent_messages = context.get_sent_messages()
|
||||
yielded_outputs = context.get_yielded_outputs()
|
||||
completion_data = sent_messages + yielded_outputs
|
||||
completed_event = ExecutorCompletedEvent(self.id, completion_data if completion_data else None)
|
||||
completed_event = WorkflowEvent.executor_completed(
|
||||
self.id, completion_data if completion_data else None
|
||||
)
|
||||
await context.add_event(completed_event)
|
||||
|
||||
def _create_context_for_handler(
|
||||
@@ -538,8 +538,8 @@ def handler(
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> Callable[
|
||||
[Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]],
|
||||
Callable[[ExecutorT, Any, ContextT], Awaitable[Any]],
|
||||
[Callable[..., Awaitable[Any]]],
|
||||
Callable[..., Awaitable[Any]],
|
||||
]: ...
|
||||
|
||||
|
||||
@@ -724,9 +724,15 @@ def _validate_handler_signature(
|
||||
|
||||
# Validate ctx parameter is WorkflowContext and extract type args
|
||||
ctx_param = params[2]
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Handler"
|
||||
)
|
||||
if skip_message_annotation and ctx_param.annotation == inspect.Parameter.empty:
|
||||
# When explicit types are provided via @handler(input=..., output=...),
|
||||
# the ctx parameter doesn't need a type annotation - types come from the decorator.
|
||||
output_types: list[type[Any] | types.UnionType] = []
|
||||
workflow_output_types: list[type[Any] | types.UnionType] = []
|
||||
else:
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Handler"
|
||||
)
|
||||
|
||||
message_type = message_param.annotation if message_param.annotation != inspect.Parameter.empty else None
|
||||
ctx_annotation = ctx_param.annotation
|
||||
|
||||
@@ -16,7 +16,7 @@ from ._checkpoint_encoding import (
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
from ._edge import EdgeGroup
|
||||
from ._edge_runner import EdgeRunner, create_edge_runner
|
||||
from ._events import SuperStepCompletedEvent, SuperStepStartedEvent, WorkflowEvent
|
||||
from ._events import WorkflowEvent
|
||||
from ._exceptions import (
|
||||
WorkflowCheckpointException,
|
||||
WorkflowConvergenceException,
|
||||
@@ -102,7 +102,7 @@ class Runner:
|
||||
|
||||
while self._iteration < self._max_iterations:
|
||||
logger.info(f"Starting superstep {self._iteration + 1}")
|
||||
yield SuperStepStartedEvent(iteration=self._iteration + 1)
|
||||
yield WorkflowEvent.superstep_started(iteration=self._iteration + 1)
|
||||
|
||||
# Run iteration concurrently with live event streaming: we poll
|
||||
# for new events while the iteration coroutine progresses.
|
||||
@@ -147,7 +147,7 @@ class Runner:
|
||||
# Create checkpoint after each superstep iteration
|
||||
await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
|
||||
|
||||
yield SuperStepCompletedEvent(iteration=self._iteration)
|
||||
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
|
||||
|
||||
# Check for convergence: no more messages to process
|
||||
if not await self._ctx.has_messages():
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
@@ -12,7 +14,7 @@ from typing import Any, Protocol, TypeVar, runtime_checkable
|
||||
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._const import INTERNAL_SOURCE_ID
|
||||
from ._events import RequestInfoEvent, WorkflowEvent
|
||||
from ._events import WorkflowEvent
|
||||
from ._state import State
|
||||
from ._typing_utils import is_instance_of
|
||||
|
||||
@@ -51,7 +53,7 @@ class Message:
|
||||
source_span_ids: list[str] | None = None # Publishing span IDs for linking from multiple sources
|
||||
|
||||
# For response messages, the original request data
|
||||
original_request_info_event: RequestInfoEvent | None = None
|
||||
original_request_info_event: WorkflowEvent[Any] | None = None
|
||||
|
||||
# Backward compatibility properties
|
||||
@property
|
||||
@@ -77,7 +79,7 @@ class Message:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, Any]) -> "Message":
|
||||
def from_dict(data: dict[str, Any]) -> Message:
|
||||
"""Create a Message from a dictionary."""
|
||||
# Validation
|
||||
if "data" not in data:
|
||||
@@ -254,11 +256,11 @@ class RunnerContext(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def add_request_info_event(self, event: RequestInfoEvent) -> None:
|
||||
"""Add a RequestInfoEvent to the context and track it for correlation.
|
||||
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
|
||||
"""Add a request_info event to the context and track it for correlation.
|
||||
|
||||
Args:
|
||||
event: The RequestInfoEvent to be added.
|
||||
event: The WorkflowEvent with type='request_info' to be added.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -271,11 +273,11 @@ class RunnerContext(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_pending_request_info_events(self) -> dict[str, RequestInfoEvent]:
|
||||
"""Get the mapping of request IDs to their corresponding RequestInfoEvent.
|
||||
async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]:
|
||||
"""Get the mapping of request IDs to their corresponding request_info events.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping request IDs to their corresponding RequestInfoEvent.
|
||||
A dictionary mapping request IDs to their corresponding WorkflowEvent (type='request_info').
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -294,7 +296,7 @@ class InProcRunnerContext:
|
||||
self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue()
|
||||
|
||||
# An additional storage for pending request info events
|
||||
self._pending_request_info_events: dict[str, RequestInfoEvent] = {}
|
||||
self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {}
|
||||
|
||||
# Checkpointing configuration/state
|
||||
self._checkpoint_storage = checkpoint_storage
|
||||
@@ -426,7 +428,7 @@ class InProcRunnerContext:
|
||||
self._pending_request_info_events.clear()
|
||||
pending_requests_data = checkpoint.pending_request_info_events
|
||||
for request_id, request_data in pending_requests_data.items():
|
||||
request_info_event = RequestInfoEvent.from_dict(request_data)
|
||||
request_info_event = WorkflowEvent.from_dict(request_data)
|
||||
self._pending_request_info_events[request_id] = request_info_event
|
||||
await self.add_event(request_info_event)
|
||||
|
||||
@@ -470,12 +472,14 @@ class InProcRunnerContext:
|
||||
"pending_request_info_events": serialized_pending_request_info_events,
|
||||
}
|
||||
|
||||
async def add_request_info_event(self, event: RequestInfoEvent) -> None:
|
||||
"""Add a RequestInfoEvent to the context and track it for correlation.
|
||||
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
|
||||
"""Add a request_info event to the context and track it for correlation.
|
||||
|
||||
Args:
|
||||
event: The RequestInfoEvent to be added.
|
||||
event: The WorkflowEvent with type='request_info' to be added.
|
||||
"""
|
||||
if event.request_id is None:
|
||||
raise ValueError("request_info event must have a request_id")
|
||||
self._pending_request_info_events[event.request_id] = event
|
||||
await self.add_event(event)
|
||||
|
||||
@@ -497,21 +501,23 @@ class InProcRunnerContext:
|
||||
f"expected {event.response_type.__name__}, got {type(response).__name__}"
|
||||
)
|
||||
|
||||
source_executor_id = event.source_executor_id
|
||||
|
||||
# Create ResponseMessage instance
|
||||
response_msg = Message(
|
||||
data=response,
|
||||
source_id=INTERNAL_SOURCE_ID(event.source_executor_id),
|
||||
target_id=event.source_executor_id,
|
||||
source_id=INTERNAL_SOURCE_ID(source_executor_id),
|
||||
target_id=source_executor_id,
|
||||
type=MessageType.RESPONSE,
|
||||
original_request_info_event=event,
|
||||
)
|
||||
|
||||
await self.send_message(response_msg)
|
||||
|
||||
async def get_pending_request_info_events(self) -> dict[str, RequestInfoEvent]:
|
||||
"""Get the mapping of request IDs to their corresponding RequestInfoEvent.
|
||||
async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]:
|
||||
"""Get the mapping of request IDs to their corresponding request_info events.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping request IDs to their corresponding RequestInfoEvent.
|
||||
A dictionary mapping request IDs to their corresponding WorkflowEvent (type='request_info').
|
||||
"""
|
||||
return dict(self._pending_request_info_events)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
@@ -19,14 +21,9 @@ from ._edge import (
|
||||
FanOutEdgeGroup,
|
||||
)
|
||||
from ._events import (
|
||||
RequestInfoEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
_framework_event_origin, # type: ignore
|
||||
)
|
||||
from ._executor import Executor
|
||||
@@ -59,9 +56,9 @@ class WorkflowRunResult(list[WorkflowEvent]):
|
||||
- status_timeline(): Access the complete status event history
|
||||
"""
|
||||
|
||||
def __init__(self, events: list[WorkflowEvent], status_events: list[WorkflowStatusEvent] | None = None) -> None:
|
||||
def __init__(self, events: list[WorkflowEvent[Any]], status_events: list[WorkflowEvent[Any]] | None = None) -> None:
|
||||
super().__init__(events)
|
||||
self._status_events: list[WorkflowStatusEvent] = status_events or []
|
||||
self._status_events: list[WorkflowEvent[Any]] = status_events or []
|
||||
|
||||
def get_outputs(self) -> list[Any]:
|
||||
"""Get all outputs from the workflow run result.
|
||||
@@ -69,30 +66,30 @@ class WorkflowRunResult(list[WorkflowEvent]):
|
||||
Returns:
|
||||
A list of outputs produced by the workflow during its execution.
|
||||
"""
|
||||
return [event.data for event in self if isinstance(event, WorkflowOutputEvent)]
|
||||
return [event.data for event in self if event.type == "output"]
|
||||
|
||||
def get_request_info_events(self) -> list[RequestInfoEvent]:
|
||||
def get_request_info_events(self) -> list[WorkflowEvent[Any]]:
|
||||
"""Get all request info events from the workflow run result.
|
||||
|
||||
Returns:
|
||||
A list of RequestInfoEvent instances found in the workflow run result.
|
||||
A list of WorkflowEvent instances with type='request_info' found in the workflow run result.
|
||||
"""
|
||||
return [event for event in self if isinstance(event, RequestInfoEvent)]
|
||||
return [event for event in self if event.type == "request_info"]
|
||||
|
||||
def get_final_state(self) -> WorkflowRunState:
|
||||
"""Return the final run state based on explicit status events.
|
||||
|
||||
Returns the last WorkflowStatusEvent.state observed. Raises if none were emitted.
|
||||
Returns the last status event's state observed. Raises if none were emitted.
|
||||
"""
|
||||
if self._status_events:
|
||||
return self._status_events[-1].state # type: ignore[return-value]
|
||||
raise RuntimeError(
|
||||
"Final state is unknown because no WorkflowStatusEvent was emitted. "
|
||||
"Final state is unknown because no status event was emitted. "
|
||||
"Ensure your workflow entry points are used (which emit status events) "
|
||||
"or handle the absence of status explicitly."
|
||||
)
|
||||
|
||||
def status_timeline(self) -> list[WorkflowStatusEvent]:
|
||||
def status_timeline(self) -> list[WorkflowEvent[Any]]:
|
||||
"""Return the list of status events emitted during the run (control-plane)."""
|
||||
return list(self._status_events)
|
||||
|
||||
@@ -145,7 +142,7 @@ class Workflow(DictConvertible):
|
||||
Executors within a workflow can request external input using `ctx.request_info()`:
|
||||
1. Executor calls `ctx.request_info()` to request input
|
||||
2. Executor implements `response_handler()` to process the response
|
||||
3. Requests are emitted as RequestInfoEvent instances in the event stream
|
||||
3. Requests are emitted as request_info events (WorkflowEvent with type='request_info') in the event stream
|
||||
4. Workflow enters IDLE_WITH_PENDING_REQUESTS state
|
||||
5. Caller handles requests and provides responses via the `send_responses` or `send_responses_streaming` methods
|
||||
6. Responses are routed to the requesting executors and response handlers are invoked
|
||||
@@ -205,7 +202,7 @@ class Workflow(DictConvertible):
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
# `WorkflowOutputEvent`s from these executors are treated as workflow outputs.
|
||||
# Output events (WorkflowEvent with type='output') from these executors are treated as workflow outputs.
|
||||
# If None or empty, all executor outputs are considered workflow outputs.
|
||||
self._output_executors = list(output_executors) if output_executors else list(self.executors.keys())
|
||||
|
||||
@@ -332,10 +329,10 @@ class Workflow(DictConvertible):
|
||||
span.add_event(OtelAttr.WORKFLOW_STARTED)
|
||||
# Emit explicit start/status events to the stream
|
||||
with _framework_event_origin():
|
||||
started = WorkflowStartedEvent()
|
||||
started = WorkflowEvent.started()
|
||||
yield started
|
||||
with _framework_event_origin():
|
||||
in_progress = WorkflowStatusEvent(WorkflowRunState.IN_PROGRESS)
|
||||
in_progress = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
|
||||
yield in_progress
|
||||
|
||||
# Reset context for a new run if supported
|
||||
@@ -359,39 +356,39 @@ class Workflow(DictConvertible):
|
||||
# All executor executions happen within workflow span
|
||||
async for event in self._runner.run_until_convergence():
|
||||
# Track request events for final status determination
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
saw_request = True
|
||||
yield event
|
||||
|
||||
if isinstance(event, RequestInfoEvent) and not emitted_in_progress_pending:
|
||||
if event.type == "request_info" and not emitted_in_progress_pending:
|
||||
emitted_in_progress_pending = True
|
||||
with _framework_event_origin():
|
||||
pending_status = WorkflowStatusEvent(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
|
||||
pending_status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
|
||||
yield pending_status
|
||||
|
||||
# Workflow runs until idle - emit final status based on whether requests are pending
|
||||
if saw_request:
|
||||
with _framework_event_origin():
|
||||
terminal_status = WorkflowStatusEvent(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS)
|
||||
terminal_status = WorkflowEvent.status(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS)
|
||||
yield terminal_status
|
||||
else:
|
||||
with _framework_event_origin():
|
||||
terminal_status = WorkflowStatusEvent(WorkflowRunState.IDLE)
|
||||
terminal_status = WorkflowEvent.status(WorkflowRunState.IDLE)
|
||||
yield terminal_status
|
||||
|
||||
span.add_event(OtelAttr.WORKFLOW_COMPLETED)
|
||||
except Exception as exc:
|
||||
# Drain any pending events (for example, ExecutorFailedEvent) before yielding WorkflowFailedEvent
|
||||
# Drain any pending events (for example, executor_failed) before yielding failed event
|
||||
for event in await self._runner.context.drain_events():
|
||||
yield event
|
||||
|
||||
# Surface structured failure details before propagating exception
|
||||
details = WorkflowErrorDetails.from_exception(exc)
|
||||
with _framework_event_origin():
|
||||
failed_event = WorkflowFailedEvent(details)
|
||||
failed_event = WorkflowEvent.failed(details)
|
||||
yield failed_event
|
||||
with _framework_event_origin():
|
||||
failed_status = WorkflowStatusEvent(WorkflowRunState.FAILED)
|
||||
failed_status = WorkflowEvent.status(WorkflowRunState.FAILED)
|
||||
yield failed_status
|
||||
span.add_event(
|
||||
name=OtelAttr.WORKFLOW_ERROR,
|
||||
@@ -554,7 +551,7 @@ class Workflow(DictConvertible):
|
||||
streaming=True,
|
||||
run_kwargs=kwargs if kwargs else None,
|
||||
):
|
||||
if isinstance(event, WorkflowOutputEvent) and not self._should_yield_output_event(event):
|
||||
if event.type == "output" and not self._should_yield_output_event(event):
|
||||
continue
|
||||
yield event
|
||||
finally:
|
||||
@@ -579,7 +576,7 @@ class Workflow(DictConvertible):
|
||||
reset_context=False, # Don't reset context when sending responses
|
||||
streaming=True,
|
||||
):
|
||||
if isinstance(event, WorkflowOutputEvent) and not self._should_yield_output_event(event):
|
||||
if event.type == "output" and not self._should_yield_output_event(event):
|
||||
continue
|
||||
yield event
|
||||
finally:
|
||||
@@ -628,20 +625,20 @@ class Workflow(DictConvertible):
|
||||
self._reset_running_flag()
|
||||
|
||||
# Filter events for non-streaming mode
|
||||
filtered: list[WorkflowEvent] = []
|
||||
status_events: list[WorkflowStatusEvent] = []
|
||||
filtered: list[WorkflowEvent[Any]] = []
|
||||
status_events: list[WorkflowEvent[Any]] = []
|
||||
|
||||
for ev in raw_events:
|
||||
# Omit WorkflowStartedEvent from non-streaming (telemetry-only)
|
||||
if isinstance(ev, WorkflowStartedEvent):
|
||||
# Omit started events from non-streaming (telemetry-only)
|
||||
if ev.type == "started":
|
||||
continue
|
||||
# Track status; include inline only if explicitly requested
|
||||
if isinstance(ev, WorkflowStatusEvent):
|
||||
if ev.type == "status":
|
||||
status_events.append(ev)
|
||||
if include_status_events:
|
||||
filtered.append(ev)
|
||||
continue
|
||||
if isinstance(ev, WorkflowOutputEvent) and not self._should_yield_output_event(ev):
|
||||
if ev.type == "output" and not self._should_yield_output_event(ev):
|
||||
continue
|
||||
filtered.append(ev)
|
||||
|
||||
@@ -665,12 +662,12 @@ class Workflow(DictConvertible):
|
||||
reset_context=False, # Don't reset context when sending responses
|
||||
)
|
||||
]
|
||||
status_events = [e for e in events if isinstance(e, WorkflowStatusEvent)]
|
||||
filtered_events: list[WorkflowEvent] = []
|
||||
status_events = [e for e in events if e.type == "status"]
|
||||
filtered_events: list[WorkflowEvent[Any]] = []
|
||||
for e in events:
|
||||
if isinstance(e, WorkflowOutputEvent) and not self._should_yield_output_event(e):
|
||||
if e.type == "output" and not self._should_yield_output_event(e):
|
||||
continue
|
||||
if isinstance(e, (WorkflowStatusEvent, WorkflowStartedEvent)):
|
||||
if e.type in ("status", "started"):
|
||||
continue
|
||||
filtered_events.append(e)
|
||||
return WorkflowRunResult(filtered_events, status_events)
|
||||
@@ -712,11 +709,11 @@ class Workflow(DictConvertible):
|
||||
raise ValueError(f"Executor with ID {executor_id} not found.")
|
||||
return self.executors[executor_id]
|
||||
|
||||
def _should_yield_output_event(self, event: WorkflowOutputEvent) -> bool:
|
||||
"""Determine if a WorkflowOutputEvent should be yielded as a workflow output.
|
||||
def _should_yield_output_event(self, event: WorkflowEvent[Any]) -> bool:
|
||||
"""Determine if an output event should be yielded as a workflow output.
|
||||
|
||||
Args:
|
||||
event: The WorkflowOutputEvent to evaluate.
|
||||
event: The WorkflowEvent with type='output' to evaluate.
|
||||
|
||||
Returns:
|
||||
True if the event should be yielded as a workflow output, False otherwise.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import inspect
|
||||
import logging
|
||||
@@ -13,15 +15,8 @@ from typing_extensions import Never, TypeVar
|
||||
|
||||
from ..observability import OtelAttr, create_workflow_span
|
||||
from ._events import (
|
||||
RequestInfoEvent,
|
||||
WorkflowEvent,
|
||||
WorkflowEventSource,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowLifecycleEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
WorkflowWarningEvent,
|
||||
_framework_event_origin, # type: ignore
|
||||
)
|
||||
from ._runner_context import Message, RunnerContext
|
||||
@@ -204,15 +199,8 @@ def validate_workflow_context_annotation(
|
||||
return infer_output_types_from_ctx_annotation(annotation)
|
||||
|
||||
|
||||
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: tuple[type[WorkflowEvent], ...] = cast(
|
||||
tuple[type[WorkflowEvent], ...],
|
||||
tuple(get_args(WorkflowLifecycleEvent))
|
||||
or (
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
WorkflowFailedEvent,
|
||||
),
|
||||
)
|
||||
# Event types reserved for framework lifecycle (not allowed from user code)
|
||||
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: frozenset[str] = frozenset({"started", "status", "failed"})
|
||||
|
||||
|
||||
class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
@@ -264,7 +252,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor: "Executor",
|
||||
executor: Executor,
|
||||
source_executor_ids: list[str],
|
||||
state: State,
|
||||
runner_context: RunnerContext,
|
||||
@@ -291,10 +279,10 @@ class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
self._runner_context = runner_context
|
||||
self._state = state
|
||||
|
||||
# Track messages sent via send_message() for ExecutorCompletedEvent
|
||||
# Track messages sent via send_message() for executor_completed event (type='executor_completed')
|
||||
self._sent_messages: list[Any] = []
|
||||
|
||||
# Track outputs yielded via yield_output() for ExecutorCompletedEvent
|
||||
# Track outputs yielded via yield_output() for executor_completed event (type='executor_completed')
|
||||
self._yielded_outputs: list[Any] = []
|
||||
|
||||
# Store trace contexts and source span IDs for linking (supporting multiple sources)
|
||||
@@ -335,7 +323,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
# Create Message wrapper
|
||||
msg = Message(data=message, source_id=self._executor_id, target_id=target_id)
|
||||
|
||||
# Track sent message for ExecutorCompletedEvent
|
||||
# Track sent message for executor_completed event (type='executor_completed')
|
||||
self._sent_messages.append(message)
|
||||
|
||||
# Inject current trace context if tracing enabled
|
||||
@@ -355,31 +343,31 @@ class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
output: The output to yield. This must conform to the workflow output type(s)
|
||||
declared on this context.
|
||||
"""
|
||||
# Track yielded output for ExecutorCompletedEvent (deepcopy to capture state at yield time)
|
||||
# Track yielded output for executor_completed event (type='executor_completed')
|
||||
# (deepcopy to capture state at yield time)
|
||||
self._yielded_outputs.append(copy.deepcopy(output))
|
||||
|
||||
with _framework_event_origin():
|
||||
event = WorkflowOutputEvent(data=output, executor_id=self._executor_id)
|
||||
event = WorkflowEvent.output(self._executor_id, output)
|
||||
await self._runner_context.add_event(event)
|
||||
|
||||
async def add_event(self, event: WorkflowEvent) -> None:
|
||||
async def add_event(self, event: WorkflowEvent[Any]) -> None:
|
||||
"""Add an event to the workflow context."""
|
||||
if event.origin == WorkflowEventSource.EXECUTOR and isinstance(event, _FRAMEWORK_LIFECYCLE_EVENT_TYPES):
|
||||
event_name = event.__class__.__name__
|
||||
if event.origin == WorkflowEventSource.EXECUTOR and event.type in _FRAMEWORK_LIFECYCLE_EVENT_TYPES:
|
||||
warning_msg = (
|
||||
f"Executor '{self._executor_id}' attempted to emit {event_name}, "
|
||||
f"Executor '{self._executor_id}' attempted to emit a '{event.type}' event, "
|
||||
"which is reserved for framework lifecycle notifications. The "
|
||||
"event was ignored."
|
||||
)
|
||||
logger.warning(warning_msg)
|
||||
await self._runner_context.add_event(WorkflowWarningEvent(warning_msg))
|
||||
await self._runner_context.add_event(WorkflowEvent.warning(warning_msg))
|
||||
return
|
||||
await self._runner_context.add_event(event)
|
||||
|
||||
async def request_info(self, request_data: object, response_type: type, *, request_id: str | None = None) -> None:
|
||||
"""Request information from outside of the workflow.
|
||||
|
||||
Calling this method will cause the workflow to emit a RequestInfoEvent, carrying the
|
||||
Calling this method will cause the workflow to emit a request_info event (type='request_info'), carrying the
|
||||
provided request_data and request_type. External systems listening for such events
|
||||
can then process the request and respond accordingly.
|
||||
|
||||
@@ -401,7 +389,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
|
||||
"not be processed. Please define a response handler using the @response_handler decorator."
|
||||
)
|
||||
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_info_event = WorkflowEvent.request_info(
|
||||
request_id=request_id or str(uuid.uuid4()),
|
||||
source_executor_id=self._executor_id,
|
||||
request_data=request_data,
|
||||
|
||||
@@ -14,9 +14,7 @@ if TYPE_CHECKING:
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._events import (
|
||||
RequestInfoEvent,
|
||||
WorkflowErrorEvent,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
)
|
||||
from ._executor import Executor, handler
|
||||
@@ -52,38 +50,38 @@ class ExecutionContext:
|
||||
|
||||
# Pending requests to be fulfilled. This will get updated as the
|
||||
# WorkflowExecutor receives responses.
|
||||
pending_requests: dict[str, RequestInfoEvent] # request_id -> request_info_event
|
||||
pending_requests: dict[str, WorkflowEvent] # request_id -> request_info_event
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubWorkflowResponseMessage:
|
||||
"""Message sent from a parent workflow to a sub-workflow via WorkflowExecutor to provide requested information.
|
||||
|
||||
This message wraps the response data along with the original RequestInfoEvent emitted by the sub-workflow executor.
|
||||
This message wraps the response data along with the original WorkflowEvent emitted by the sub-workflow executor.
|
||||
|
||||
Attributes:
|
||||
data: The response data to the original request.
|
||||
source_event: The original RequestInfoEvent emitted by the sub-workflow executor.
|
||||
source_event: The original WorkflowEvent emitted by the sub-workflow executor.
|
||||
"""
|
||||
|
||||
data: Any
|
||||
source_event: RequestInfoEvent
|
||||
source_event: WorkflowEvent
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubWorkflowRequestMessage:
|
||||
"""Message sent from a sub-workflow to an executor in the parent workflow to request information.
|
||||
|
||||
This message wraps a RequestInfoEvent emitted by the executor in the sub-workflow.
|
||||
This message wraps a WorkflowEvent emitted by the executor in the sub-workflow.
|
||||
|
||||
Attributes:
|
||||
source_event: The original RequestInfoEvent emitted by the sub-workflow executor.
|
||||
source_event: The original WorkflowEvent emitted by the sub-workflow executor.
|
||||
executor_id: The ID of the WorkflowExecutor in the parent workflow that is
|
||||
responsible for this sub-workflow. This can be used to ensure that the response
|
||||
is sent back to the correct sub-workflow instance.
|
||||
"""
|
||||
|
||||
source_event: RequestInfoEvent
|
||||
source_event: WorkflowEvent
|
||||
executor_id: str
|
||||
|
||||
def create_response(self, data: Any) -> SubWorkflowResponseMessage:
|
||||
@@ -153,7 +151,7 @@ class WorkflowExecutor(Executor):
|
||||
# An executor in the sub-workflow makes request
|
||||
request = MyDataRequest(query="user info")
|
||||
|
||||
# WorkflowExecutor captures RequestInfoEvent and wraps it in a SubWorkflowRequestMessage
|
||||
# WorkflowExecutor captures WorkflowEvent and wraps it in a SubWorkflowRequestMessage
|
||||
# then send it to the receiving executor in parent workflow. The executor in parent workflow
|
||||
# can handle the request locally or forward it to an external source.
|
||||
# The WorkflowExecutor tracks the pending request, and implements a response handler.
|
||||
@@ -191,8 +189,8 @@ class WorkflowExecutor(Executor):
|
||||
|
||||
## Error Handling
|
||||
WorkflowExecutor propagates sub-workflow failures:
|
||||
- Captures WorkflowFailedEvent from sub-workflow
|
||||
- Converts to WorkflowErrorEvent in parent context
|
||||
- Captures failed event (type='failed') from sub-workflow
|
||||
- Converts to error event in parent context
|
||||
- Provides detailed error information including sub-workflow ID
|
||||
|
||||
## Concurrent Execution Support
|
||||
@@ -285,7 +283,7 @@ class WorkflowExecutor(Executor):
|
||||
workflow's event stream.
|
||||
propagate_request: Whether to propagate requests from the sub-workflow to the
|
||||
parent workflow. If set to true, requests from the sub-workflow
|
||||
will be propagated as the original RequestInfoEvent to the parent
|
||||
will be propagated as the original WorkflowEvent to the parent
|
||||
workflow. Otherwise, they will be wrapped in a SubWorkflowRequestMessage,
|
||||
which should be handled by an executor in the parent workflow.
|
||||
|
||||
@@ -421,8 +419,9 @@ class WorkflowExecutor(Executor):
|
||||
response: The response to a previous request.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
request_id = response.source_event.request_id
|
||||
await self._handle_response(
|
||||
request_id=response.source_event.request_id,
|
||||
request_id=request_id,
|
||||
response=response.data,
|
||||
ctx=ctx,
|
||||
)
|
||||
@@ -437,7 +436,7 @@ class WorkflowExecutor(Executor):
|
||||
"""Handle response for a request that was propagated to the parent workflow.
|
||||
|
||||
Args:
|
||||
original_request: The original RequestInfoEvent.
|
||||
original_request: The original WorkflowEvent.
|
||||
response: The response data.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
@@ -550,15 +549,17 @@ class WorkflowExecutor(Executor):
|
||||
|
||||
# Process request info events
|
||||
for event in request_info_events:
|
||||
request_id = event.request_id
|
||||
response_type = event.response_type
|
||||
# Track the pending request in execution context
|
||||
execution_context.pending_requests[event.request_id] = event
|
||||
execution_context.pending_requests[request_id] = event
|
||||
# Map request to execution for response routing
|
||||
self._request_to_execution[event.request_id] = execution_context.execution_id
|
||||
self._request_to_execution[request_id] = execution_context.execution_id
|
||||
if self._propagate_request:
|
||||
# In a workflow where the parent workflow does not handle the request, the request
|
||||
# should be propagated via the `request_info` mechanism to an external source. And
|
||||
# a @response_handler would be required in the WorkflowExecutor to handle the response.
|
||||
await ctx.request_info(event.data, event.response_type, request_id=event.request_id)
|
||||
await ctx.request_info(event.data, response_type, request_id=request_id)
|
||||
else:
|
||||
# In a workflow where the parent workflow has an executor that may intercept the
|
||||
# request and handle it directly, a message should be sent.
|
||||
@@ -569,18 +570,19 @@ class WorkflowExecutor(Executor):
|
||||
|
||||
# Handle final state
|
||||
if workflow_run_state == WorkflowRunState.FAILED:
|
||||
# Find the WorkflowFailedEvent.
|
||||
failed_events = [e for e in result if isinstance(e, WorkflowFailedEvent)]
|
||||
# Find the failed event (type='failed').
|
||||
failed_events = [e for e in result if isinstance(e, WorkflowEvent) and e.type == "failed"]
|
||||
if failed_events:
|
||||
failed_event = failed_events[0]
|
||||
error_type = failed_event.details.error_type
|
||||
error_message = failed_event.details.message
|
||||
exception = Exception(
|
||||
f"Sub-workflow {self.workflow.id} failed with error: {error_type} - {error_message}"
|
||||
)
|
||||
error_event = WorkflowErrorEvent(
|
||||
data=exception,
|
||||
)
|
||||
if failed_event.details is not None:
|
||||
error_type = failed_event.details.error_type
|
||||
error_message = failed_event.details.message
|
||||
exception = Exception(
|
||||
f"Sub-workflow {self.workflow.id} failed with error: {error_type} - {error_message}"
|
||||
)
|
||||
else:
|
||||
exception = Exception(f"Sub-workflow {self.workflow.id} failed with unknown error")
|
||||
error_event = WorkflowEvent.error(exception)
|
||||
await ctx.add_event(error_event)
|
||||
elif workflow_run_state == WorkflowRunState.IDLE:
|
||||
# Sub-workflow is idle - nothing more to do now
|
||||
@@ -661,11 +663,7 @@ class WorkflowExecutor(Executor):
|
||||
# requesting the same information again.
|
||||
for request_id in responses_to_send:
|
||||
event_to_remove = next(
|
||||
(
|
||||
event
|
||||
for event in result
|
||||
if isinstance(event, RequestInfoEvent) and event.request_id == request_id
|
||||
),
|
||||
(event for event in result if event.type == "request_info" and event.request_id == request_id),
|
||||
None,
|
||||
)
|
||||
if event_to_remove:
|
||||
|
||||
@@ -12,6 +12,8 @@ from agent_framework_orchestrations import (
|
||||
ConcurrentBuilder,
|
||||
GroupChatBuilder,
|
||||
GroupChatOrchestrator,
|
||||
GroupChatRequestMessage,
|
||||
GroupChatRequestSentEvent,
|
||||
GroupChatSelectionFunction,
|
||||
GroupChatState,
|
||||
HandoffAgentExecutor,
|
||||
@@ -48,6 +50,8 @@ __all__ = [
|
||||
"ConcurrentBuilder",
|
||||
"GroupChatBuilder",
|
||||
"GroupChatOrchestrator",
|
||||
"GroupChatRequestMessage",
|
||||
"GroupChatRequestSentEvent",
|
||||
"GroupChatSelectionFunction",
|
||||
"GroupChatState",
|
||||
"HandoffAgentExecutor",
|
||||
|
||||
@@ -13,9 +13,7 @@ from agent_framework import (
|
||||
ChatMessageStore,
|
||||
Content,
|
||||
ResponseStream,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from agent_framework._workflows._agent_executor import AgentExecutorResponse
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
@@ -77,9 +75,9 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
|
||||
# Run the workflow with a user message
|
||||
first_run_output: AgentExecutorResponse | None = None
|
||||
async for ev in wf.run("First workflow run", stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
first_run_output = ev.data # type: ignore[assignment]
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
assert first_run_output is not None
|
||||
@@ -131,9 +129,9 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
|
||||
# Resume from checkpoint
|
||||
resumed_output: AgentExecutorResponse | None = None
|
||||
async for ev in wf_resume.run(checkpoint_id=restore_checkpoint.checkpoint_id, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
resumed_output = ev.data # type: ignore[assignment]
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
|
||||
if ev.type == "status" and ev.state in (
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
):
|
||||
|
||||
@@ -19,11 +19,10 @@ from agent_framework import (
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
RequestInfoEvent,
|
||||
ResponseStream,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowEvent,
|
||||
executor,
|
||||
tool,
|
||||
)
|
||||
@@ -100,9 +99,9 @@ async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None:
|
||||
workflow = WorkflowBuilder().set_start_executor(agent_exec).build()
|
||||
|
||||
# Act: run in streaming mode
|
||||
events: list[WorkflowOutputEvent] = []
|
||||
events: list[WorkflowEvent[AgentResponseUpdate]] = []
|
||||
async for event in workflow.run("What's the weather?", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output" and isinstance(event.data, AgentResponseUpdate):
|
||||
events.append(event)
|
||||
|
||||
# Assert: we should receive 4 events (text, function call, function result, text)
|
||||
@@ -290,9 +289,9 @@ async def test_agent_executor_tool_call_with_approval_streaming() -> None:
|
||||
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
|
||||
|
||||
# Act
|
||||
request_info_events: list[RequestInfoEvent] = []
|
||||
request_info_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("Invoke tool requiring approval", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_info_events.append(event)
|
||||
|
||||
# Assert
|
||||
@@ -307,7 +306,7 @@ async def test_agent_executor_tool_call_with_approval_streaming() -> None:
|
||||
async for event in workflow.send_responses_streaming({
|
||||
approval_request.request_id: approval_request.data.to_function_approval_response(True)
|
||||
}):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
output = event.data
|
||||
|
||||
# Assert
|
||||
@@ -367,9 +366,9 @@ async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> No
|
||||
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
|
||||
|
||||
# Act
|
||||
request_info_events: list[RequestInfoEvent] = []
|
||||
request_info_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("Invoke tool requiring approval", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_info_events.append(event)
|
||||
|
||||
# Assert
|
||||
@@ -387,7 +386,7 @@ async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> No
|
||||
|
||||
output: str | None = None
|
||||
async for event in workflow.send_responses_streaming(responses):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
output = event.data
|
||||
|
||||
# Assert
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for agent run event typing."""
|
||||
"""Tests for WorkflowEvent[T] generic type annotations."""
|
||||
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage
|
||||
from agent_framework._workflows._events import WorkflowOutputEvent
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
|
||||
def test_agent_run_event_data_type() -> None:
|
||||
"""Verify WorkflowOutputEvent.data is typed as AgentResponse | None."""
|
||||
def test_workflow_event_with_agent_response_data_type() -> None:
|
||||
"""Verify WorkflowEvent[AgentResponse].data is typed as AgentResponse."""
|
||||
response = AgentResponse(messages=[ChatMessage(role="assistant", text="Hello")])
|
||||
event = WorkflowOutputEvent(data=response, executor_id="test")
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response)
|
||||
|
||||
# This assignment should pass type checking without a cast
|
||||
data: AgentResponse | None = event.data
|
||||
data: AgentResponse = event.data
|
||||
assert data is not None
|
||||
assert data.text == "Hello"
|
||||
|
||||
|
||||
def test_agent_run_update_event_data_type() -> None:
|
||||
"""Verify WorkflowOutputEvent.data is typed as AgentResponseUpdate | None."""
|
||||
def test_workflow_event_with_agent_response_update_data_type() -> None:
|
||||
"""Verify WorkflowEvent[AgentResponseUpdate].data is typed as AgentResponseUpdate."""
|
||||
update = AgentResponseUpdate()
|
||||
event = WorkflowOutputEvent(data=update, executor_id="test")
|
||||
event: WorkflowEvent[AgentResponseUpdate] = WorkflowEvent.emit(executor_id="test", data=update)
|
||||
|
||||
# This assignment should pass type checking without a cast
|
||||
data: AgentResponseUpdate | None = event.data
|
||||
data: AgentResponseUpdate = event.data
|
||||
assert data is not None
|
||||
|
||||
|
||||
def test_workflow_event_repr() -> None:
|
||||
"""Verify WorkflowEvent.__repr__ uses consistent format."""
|
||||
response = AgentResponse(messages=[ChatMessage(role="assistant", text="Hello")])
|
||||
event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response)
|
||||
|
||||
repr_str = repr(event)
|
||||
assert "WorkflowEvent" in repr_str
|
||||
assert "executor_id='test'" in repr_str
|
||||
assert "data=" in repr_str
|
||||
|
||||
@@ -8,7 +8,6 @@ from agent_framework import (
|
||||
WorkflowCheckpointException,
|
||||
WorkflowContext,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
@@ -80,4 +79,4 @@ async def test_resume_succeeds_when_graph_matches() -> None:
|
||||
)
|
||||
]
|
||||
|
||||
assert any(isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE for event in events)
|
||||
assert any(event.type == "status" and event.state == WorkflowRunState.IDLE for event in events)
|
||||
|
||||
@@ -8,11 +8,10 @@ from typing_extensions import Never
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
Executor,
|
||||
ExecutorCompletedEvent,
|
||||
ExecutorInvokedEvent,
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
executor,
|
||||
handler,
|
||||
response_handler,
|
||||
@@ -139,7 +138,7 @@ def test_executor_handlers_with_output_types():
|
||||
|
||||
|
||||
async def test_executor_invoked_event_contains_input_data():
|
||||
"""Test that ExecutorInvokedEvent contains the input message data."""
|
||||
"""Test that executor_invoked event (type='executor_invoked') contains the input message data."""
|
||||
|
||||
class UpperCaseExecutor(Executor):
|
||||
@handler
|
||||
@@ -157,7 +156,7 @@ async def test_executor_invoked_event_contains_input_data():
|
||||
workflow = WorkflowBuilder().add_edge(upper, collector).set_start_executor(upper).build()
|
||||
|
||||
events = await workflow.run("hello world")
|
||||
invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)]
|
||||
invoked_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"]
|
||||
|
||||
assert len(invoked_events) == 2
|
||||
|
||||
@@ -171,7 +170,7 @@ async def test_executor_invoked_event_contains_input_data():
|
||||
|
||||
|
||||
async def test_executor_completed_event_contains_sent_messages():
|
||||
"""Test that ExecutorCompletedEvent contains the messages sent via ctx.send_message()."""
|
||||
"""Test that event (type='executor_completed') contains the messages sent via ctx.send_message()."""
|
||||
|
||||
class MultiSenderExecutor(Executor):
|
||||
@handler
|
||||
@@ -194,7 +193,7 @@ async def test_executor_completed_event_contains_sent_messages():
|
||||
workflow = WorkflowBuilder().add_edge(sender, collector).set_start_executor(sender).build()
|
||||
|
||||
events = await workflow.run("hello")
|
||||
completed_events = [e for e in events if isinstance(e, ExecutorCompletedEvent)]
|
||||
completed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
|
||||
|
||||
# Sender should have completed with the sent messages
|
||||
sender_completed = next(e for e in completed_events if e.executor_id == "sender")
|
||||
@@ -210,9 +209,7 @@ async def test_executor_completed_event_contains_sent_messages():
|
||||
|
||||
|
||||
async def test_executor_completed_event_includes_yielded_outputs():
|
||||
"""Test that ExecutorCompletedEvent.data includes yielded outputs."""
|
||||
|
||||
from agent_framework import WorkflowOutputEvent
|
||||
"""Test that WorkflowEvent(type='executor_completed').data includes yielded outputs."""
|
||||
|
||||
class YieldOnlyExecutor(Executor):
|
||||
@handler
|
||||
@@ -223,15 +220,15 @@ async def test_executor_completed_event_includes_yielded_outputs():
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
events = await workflow.run("test")
|
||||
completed_events = [e for e in events if isinstance(e, ExecutorCompletedEvent)]
|
||||
completed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
|
||||
|
||||
assert len(completed_events) == 1
|
||||
assert completed_events[0].executor_id == "yielder"
|
||||
# Yielded outputs are now included in ExecutorCompletedEvent.data
|
||||
# Yielded outputs are now included in executor_completed event (type='executor_completed').data
|
||||
assert completed_events[0].data == ["TEST"]
|
||||
|
||||
# Verify the output was also yielded as WorkflowOutputEvent
|
||||
output_events = [e for e in events if isinstance(e, WorkflowOutputEvent)]
|
||||
# Verify the output was also yielded as an output event (type='output')
|
||||
output_events = [e for e in events if e.type == "output"]
|
||||
assert len(output_events) == 1
|
||||
assert output_events[0].data == "TEST"
|
||||
|
||||
@@ -268,8 +265,8 @@ async def test_executor_events_with_complex_message_types():
|
||||
input_request = Request(query="hello", limit=3)
|
||||
events = await workflow.run(input_request)
|
||||
|
||||
invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)]
|
||||
completed_events = [e for e in events if isinstance(e, ExecutorCompletedEvent)]
|
||||
invoked_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"]
|
||||
completed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
|
||||
|
||||
# Check processor invoked event has the Request object
|
||||
processor_invoked = next(e for e in invoked_events if e.executor_id == "processor")
|
||||
@@ -531,7 +528,7 @@ def test_executor_response_handler_union_output_types():
|
||||
|
||||
|
||||
async def test_executor_invoked_event_data_not_mutated_by_handler():
|
||||
"""Test that ExecutorInvokedEvent.data captures original input, not mutated input."""
|
||||
"""Test that executor_invoked event (type='executor_invoked').data captures original input, not mutated input."""
|
||||
|
||||
@executor(id="Mutator")
|
||||
async def mutator(messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
@@ -549,7 +546,7 @@ async def test_executor_invoked_event_data_not_mutated_by_handler():
|
||||
events = await workflow.run(input_messages)
|
||||
|
||||
# Find the invoked event for the Mutator executor
|
||||
invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)]
|
||||
invoked_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"]
|
||||
assert len(invoked_events) == 1
|
||||
mutator_invoked = invoked_events[0]
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from agent_framework import (
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.orchestrations import SequentialBuilder
|
||||
@@ -149,7 +148,7 @@ async def test_sequential_adapter_uses_full_conversation() -> None:
|
||||
|
||||
# Act
|
||||
async for ev in wf.run("hello seq", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Assert: second agent should have seen the user prompt and A1's assistant reply
|
||||
|
||||
@@ -4,11 +4,10 @@ from dataclasses import dataclass
|
||||
|
||||
from agent_framework import (
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
@@ -182,9 +181,9 @@ class TestRequestInfoAndResponse:
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# First run the workflow until it emits a request
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
request_info_event: WorkflowEvent | None = None
|
||||
async for event in workflow.run("test operation", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_info_event = event
|
||||
|
||||
assert request_info_event is not None
|
||||
@@ -194,7 +193,7 @@ class TestRequestInfoAndResponse:
|
||||
# Send response and continue workflow
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
@@ -207,9 +206,9 @@ class TestRequestInfoAndResponse:
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# First run the workflow until it emits a calculation request
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
request_info_event: WorkflowEvent | None = None
|
||||
async for event in workflow.run("multiply 15.5 2.0", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_info_event = event
|
||||
|
||||
assert request_info_event is not None
|
||||
@@ -221,7 +220,7 @@ class TestRequestInfoAndResponse:
|
||||
calculated_result = 31.0
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming({request_info_event.request_id: calculated_result}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
@@ -234,18 +233,18 @@ class TestRequestInfoAndResponse:
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# Collect all request events by running the full stream
|
||||
request_events: list[RequestInfoEvent] = []
|
||||
request_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("start batch", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_events.append(event)
|
||||
|
||||
assert len(request_events) == 2
|
||||
|
||||
# Find the approval and calculation requests
|
||||
approval_event: RequestInfoEvent | None = next(
|
||||
approval_event: WorkflowEvent | None = next(
|
||||
(e for e in request_events if isinstance(e.data, UserApprovalRequest)), None
|
||||
)
|
||||
calc_event: RequestInfoEvent | None = next(
|
||||
calc_event: WorkflowEvent | None = next(
|
||||
(e for e in request_events if isinstance(e.data, CalculationRequest)), None
|
||||
)
|
||||
|
||||
@@ -256,7 +255,7 @@ class TestRequestInfoAndResponse:
|
||||
responses = {approval_event.request_id: True, calc_event.request_id: 50.0}
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming(responses):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
@@ -268,9 +267,9 @@ class TestRequestInfoAndResponse:
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# First run the workflow until it emits a request
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
request_info_event: WorkflowEvent | None = None
|
||||
async for event in workflow.run("sensitive operation", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_info_event = event
|
||||
|
||||
assert request_info_event is not None
|
||||
@@ -278,7 +277,7 @@ class TestRequestInfoAndResponse:
|
||||
# Deny the request
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming({request_info_event.request_id: False}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
@@ -291,12 +290,12 @@ class TestRequestInfoAndResponse:
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# Run workflow until idle with pending requests
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
request_info_event: WorkflowEvent | None = None
|
||||
idle_with_pending = False
|
||||
async for event in workflow.run("test operation", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_info_event = event
|
||||
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
idle_with_pending = True
|
||||
|
||||
assert request_info_event is not None
|
||||
@@ -305,7 +304,7 @@ class TestRequestInfoAndResponse:
|
||||
# Continue with response
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
@@ -318,7 +317,7 @@ class TestRequestInfoAndResponse:
|
||||
# Send invalid input (no numbers)
|
||||
completed = False
|
||||
async for event in workflow.run("invalid input", stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
@@ -338,9 +337,9 @@ class TestRequestInfoAndResponse:
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).with_checkpointing(storage).build()
|
||||
|
||||
# Step 1: Run workflow to completion to ensure checkpoints are created
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
request_info_event: WorkflowEvent | None = None
|
||||
async for event in workflow.run("checkpoint test operation", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_info_event = event
|
||||
|
||||
# Verify request was emitted
|
||||
@@ -377,15 +376,12 @@ class TestRequestInfoAndResponse:
|
||||
|
||||
# Step 5: Resume from checkpoint and verify the request can be continued
|
||||
completed = False
|
||||
restored_request_event: RequestInfoEvent | None = None
|
||||
restored_request_event: WorkflowEvent | None = None
|
||||
async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True):
|
||||
# Should re-emit the pending request info event
|
||||
if isinstance(event, RequestInfoEvent) and event.request_id == request_info_event.request_id:
|
||||
if event.type == "request_info" and event.request_id == request_info_event.request_id:
|
||||
restored_request_event = event
|
||||
elif (
|
||||
isinstance(event, WorkflowStatusEvent)
|
||||
and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
):
|
||||
elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
completed = True
|
||||
|
||||
assert completed, "Workflow should reach idle with pending requests state after restoration"
|
||||
@@ -402,7 +398,7 @@ class TestRequestInfoAndResponse:
|
||||
async for event in restored_workflow.send_responses_streaming({
|
||||
request_info_event.request_id: True # Approve the request
|
||||
}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
final_completed = True
|
||||
|
||||
assert final_completed, "Workflow should complete after providing response to restored request"
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from agent_framework import InMemoryCheckpointStorage, InProcRunnerContext
|
||||
from agent_framework._workflows._checkpoint_encoding import DATACLASS_MARKER, encode_checkpoint_value
|
||||
from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
|
||||
from agent_framework._workflows._events import RequestInfoEvent
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
from agent_framework._workflows._state import State
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class TimedApproval:
|
||||
|
||||
async def test_rehydrate_request_info_event() -> None:
|
||||
"""Rehydration should succeed for valid request info events."""
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_info_event = WorkflowEvent.request_info(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_data=MockRequest(),
|
||||
@@ -69,7 +69,7 @@ async def test_rehydrate_request_info_event() -> None:
|
||||
|
||||
async def test_rehydrate_fails_when_request_type_missing() -> None:
|
||||
"""Rehydration should fail is the request type is missing or fails to import."""
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_info_event = WorkflowEvent.request_info(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_data=MockRequest(),
|
||||
@@ -97,7 +97,7 @@ async def test_rehydrate_fails_when_request_type_missing() -> None:
|
||||
|
||||
async def test_rehydrate_fails_when_request_type_mismatch() -> None:
|
||||
"""Rehydration should fail if the request type is mismatched."""
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_info_event = WorkflowEvent.request_info(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_data=MockRequest(),
|
||||
@@ -127,7 +127,7 @@ async def test_rehydrate_fails_when_request_type_mismatch() -> None:
|
||||
|
||||
async def test_pending_requests_in_summary() -> None:
|
||||
"""Test that pending requests are correctly summarized in the checkpoint summary."""
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_info_event = WorkflowEvent.request_info(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_data=MockRequest(),
|
||||
@@ -148,7 +148,8 @@ async def test_pending_requests_in_summary() -> None:
|
||||
|
||||
assert len(summary.pending_request_info_events) == 1
|
||||
pending_event = summary.pending_request_info_events[0]
|
||||
assert isinstance(pending_event, RequestInfoEvent)
|
||||
assert isinstance(pending_event, WorkflowEvent)
|
||||
assert pending_event.type == "request_info"
|
||||
assert pending_event.request_id == "request-123"
|
||||
|
||||
assert pending_event.source_executor_id == "review_gateway"
|
||||
@@ -158,13 +159,13 @@ async def test_pending_requests_in_summary() -> None:
|
||||
|
||||
|
||||
async def test_request_info_event_serializes_non_json_payloads() -> None:
|
||||
req_1 = RequestInfoEvent(
|
||||
req_1 = WorkflowEvent.request_info(
|
||||
request_id="req-1",
|
||||
source_executor_id="source",
|
||||
request_data=TimedApproval(issued_at=datetime(2024, 5, 4, 12, 30, 45)),
|
||||
response_type=bool,
|
||||
)
|
||||
req_2 = RequestInfoEvent(
|
||||
req_2 = WorkflowEvent.request_info(
|
||||
request_id="req-2",
|
||||
source_executor_id="source",
|
||||
request_data=SlottedApproval(note="slot-based"),
|
||||
|
||||
@@ -12,10 +12,8 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunnerException,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._edge import SingleEdgeGroup
|
||||
@@ -97,7 +95,7 @@ async def test_runner_run_until_convergence():
|
||||
)
|
||||
async for event in runner.run_until_convergence():
|
||||
assert isinstance(event, WorkflowEvent)
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
result = event.data
|
||||
|
||||
assert result is not None and result == 10
|
||||
@@ -137,7 +135,7 @@ async def test_runner_run_until_convergence_not_completed():
|
||||
match="Runner did not converge after 5 iterations.",
|
||||
):
|
||||
async for event in runner.run_until_convergence():
|
||||
assert not isinstance(event, WorkflowStatusEvent) or event.state != WorkflowRunState.IDLE
|
||||
assert event.type != "status" or event.state != WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_runner_already_running():
|
||||
|
||||
@@ -8,12 +8,12 @@ from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
RequestInfoEvent,
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
response_handler,
|
||||
@@ -592,7 +592,7 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
|
||||
|
||||
first_request_id: str | None = None
|
||||
async for event in workflow1.run("test_value", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
first_request_id = event.request_id
|
||||
|
||||
assert first_request_id is not None
|
||||
@@ -606,15 +606,15 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
|
||||
|
||||
resumed_first_request_id: str | None = None
|
||||
async for event in workflow2.run(checkpoint_id=checkpoint_id, stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
resumed_first_request_id = event.request_id
|
||||
|
||||
assert resumed_first_request_id is not None
|
||||
assert resumed_first_request_id == first_request_id
|
||||
|
||||
request_events: list[RequestInfoEvent] = []
|
||||
request_events: list[WorkflowEvent] = []
|
||||
async for event in workflow2.send_responses_streaming({resumed_first_request_id: "first_answer"}):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request_events.append(event)
|
||||
|
||||
# Key assertion: Only the second request should be received, not a duplicate of the first
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, Generic, Optional, TypeVar, Union
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import RequestInfoEvent
|
||||
from agent_framework import WorkflowEvent
|
||||
from agent_framework._workflows._typing_utils import (
|
||||
deserialize_type,
|
||||
is_instance_of,
|
||||
@@ -308,18 +308,19 @@ def test_serialize_deserialize_roundtrip() -> None:
|
||||
|
||||
# Test agent framework type roundtrip
|
||||
|
||||
serialized = serialize_type(RequestInfoEvent)
|
||||
serialized = serialize_type(WorkflowEvent)
|
||||
deserialized = deserialize_type(serialized)
|
||||
assert deserialized is RequestInfoEvent
|
||||
assert deserialized is WorkflowEvent
|
||||
|
||||
# Verify we can instantiate the deserialized type
|
||||
instance = deserialized(
|
||||
# Verify we can instantiate the deserialized type via factory method
|
||||
instance = WorkflowEvent.request_info(
|
||||
request_id="request-123",
|
||||
source_executor_id="executor_1",
|
||||
request_data="test",
|
||||
response_type=str,
|
||||
)
|
||||
assert isinstance(instance, RequestInfoEvent)
|
||||
assert isinstance(instance, WorkflowEvent)
|
||||
assert instance.type == "request_info"
|
||||
|
||||
|
||||
def test_deserialize_type_error_handling() -> None:
|
||||
|
||||
@@ -20,16 +20,13 @@ from agent_framework import (
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
Message,
|
||||
RequestInfoEvent,
|
||||
ResponseStream,
|
||||
WorkflowBuilder,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowContext,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
@@ -123,7 +120,7 @@ async def test_workflow_run_streaming() -> None:
|
||||
result: int | None = None
|
||||
async for event in workflow.run(NumberMessage(data=0), stream=True):
|
||||
assert isinstance(event, WorkflowEvent)
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
result = event.data
|
||||
|
||||
assert result is not None and result == 10
|
||||
@@ -197,9 +194,10 @@ async def test_fan_out():
|
||||
|
||||
events = await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Each executor will emit two events: ExecutorInvokedEvent and ExecutorCompletedEvent
|
||||
# executor_b will also emit a WorkflowOutputEvent (no WorkflowCompletedEvent anymore)
|
||||
# Each superstep will emit also emit a WorkflowStartedEvent and WorkflowCompletedEvent
|
||||
# Each executor will emit two events: executor_invoked (type='executor_invoked')
|
||||
# and executor_completed (type='executor_completed')
|
||||
# executor_b will also emit an output event (type='output')
|
||||
# Each superstep will emit a started event (type='started') and status event (type='status')
|
||||
# This workflow will converge in 2 supersteps because executor_c will send one more message
|
||||
# after executor_b completes
|
||||
assert len(events) == 11
|
||||
@@ -221,9 +219,10 @@ async def test_fan_out_multiple_completed_events():
|
||||
|
||||
events = await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Each executor will emit two events: ExecutorInvokedEvent and ExecutorCompletedEvent
|
||||
# executor_b and executor_c will also emit a WorkflowOutputEvent (no WorkflowCompletedEvent anymore)
|
||||
# Each superstep will emit also emit a WorkflowStartedEvent and WorkflowCompletedEvent
|
||||
# Each executor will emit two events: executor_invoked (type='executor_invoked')
|
||||
# and executor_completed (type='executor_completed')
|
||||
# executor_b and executor_c will also emit an output event (type='output')
|
||||
# Each superstep will emit a started event (type='started') and status event (type='status')
|
||||
# This workflow will converge in 1 superstep because executor_a and executor_b will not send further messages
|
||||
assert len(events) == 10
|
||||
|
||||
@@ -249,9 +248,10 @@ async def test_fan_in():
|
||||
|
||||
events = await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Each executor will emit two events: ExecutorInvokedEvent and ExecutorCompletedEvent
|
||||
# aggregator will also emit a WorkflowOutputEvent (no WorkflowCompletedEvent anymore)
|
||||
# Each superstep will emit also emit a WorkflowStartedEvent and WorkflowCompletedEvent
|
||||
# Each executor will emit two events: executor_invoked (type='executor_invoked')
|
||||
# and executor_completed (type='executor_completed')
|
||||
# aggregator will also emit an output event (type='output')
|
||||
# Each superstep will emit a started event (type='started') and status event (type='status')
|
||||
assert len(events) == 13
|
||||
|
||||
assert events.get_final_state() == WorkflowRunState.IDLE
|
||||
@@ -427,7 +427,7 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
|
||||
async def test_workflow_run_stream_from_checkpoint_with_responses(
|
||||
simple_executor: Executor,
|
||||
):
|
||||
"""Test that workflow can be resumed from checkpoint with pending RequestInfoEvents."""
|
||||
"""Test that workflow can be resumed from checkpoint with pending request_info events."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
@@ -439,7 +439,7 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
|
||||
messages={},
|
||||
state={},
|
||||
pending_request_info_events={
|
||||
"request_123": RequestInfoEvent(
|
||||
"request_123": WorkflowEvent.request_info(
|
||||
request_id="request_123",
|
||||
source_executor_id=simple_executor.id,
|
||||
request_data="Mock",
|
||||
@@ -465,9 +465,7 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
|
||||
events.append(event)
|
||||
|
||||
# Verify that the pending request event was emitted
|
||||
assert next(
|
||||
event for event in events if isinstance(event, RequestInfoEvent) and event.request_id == "request_123"
|
||||
)
|
||||
assert next(event for event in events if event.type == "request_info" and event.request_id == "request_123")
|
||||
|
||||
assert len(events) > 0 # Just ensure we processed some events
|
||||
|
||||
@@ -730,10 +728,12 @@ async def test_workflow_with_simple_cycle_and_exit_condition():
|
||||
assert outputs[0] is not None and outputs[0] >= 6 # Should complete when executor_a reaches its limit
|
||||
|
||||
# Verify cycling occurred (should have events from both executors)
|
||||
# Check for ExecutorInvokedEvent and ExecutorCompletedEvent types that have executor_id
|
||||
from agent_framework import ExecutorCompletedEvent, ExecutorInvokedEvent
|
||||
# Check for executor events that have executor_id
|
||||
from agent_framework import WorkflowEvent
|
||||
|
||||
executor_events = [e for e in events if isinstance(e, (ExecutorInvokedEvent, ExecutorCompletedEvent))]
|
||||
executor_events = [
|
||||
e for e in events if isinstance(e, WorkflowEvent) and e.type in ("executor_invoked", "executor_completed")
|
||||
]
|
||||
executor_ids = {e.executor_id for e in executor_events}
|
||||
assert "exec_a" in executor_ids, "Should have events from executor A"
|
||||
assert "exec_b" in executor_ids, "Should have events from executor B"
|
||||
@@ -880,7 +880,7 @@ class _StreamingTestAgent(BaseAgent):
|
||||
|
||||
|
||||
async def test_agent_streaming_vs_non_streaming() -> None:
|
||||
"""Test that stream=True/False both emits WorkflowOutputEvents correctly with the right data types."""
|
||||
"""Test that stream=True/False both emit output events (type='output') with the right data types."""
|
||||
agent = _StreamingTestAgent(id="test_agent", name="TestAgent", reply_text="Hello World")
|
||||
agent_exec = AgentExecutor(agent, id="agent_exec")
|
||||
|
||||
@@ -890,17 +890,15 @@ async def test_agent_streaming_vs_non_streaming() -> None:
|
||||
result = await workflow.run("test message")
|
||||
|
||||
# Filter for agent events (result is a list of events)
|
||||
agent_response = [e for e in result if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponse)]
|
||||
agent_response_updates = [
|
||||
e for e in result if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponseUpdate)
|
||||
]
|
||||
agent_run_events = [e for e in result if e.type == "output" and isinstance(e.data, AgentResponse)]
|
||||
agent_update_events = [e for e in result if e.type == "output" and isinstance(e.data, AgentResponseUpdate)]
|
||||
|
||||
# In non-streaming mode, should have AgentResponse, no AgentResponseUpdate
|
||||
assert len(agent_response) == 1, "Expected exactly one AgentResponse in non-streaming mode"
|
||||
assert len(agent_response_updates) == 0, "Expected no AgentResponseUpdate in non-streaming mode"
|
||||
assert agent_response[0].executor_id == "agent_exec"
|
||||
assert agent_response[0].data is not None
|
||||
assert agent_response[0].data.messages[0].text == "Hello World"
|
||||
# In non-streaming mode, should have output event with AgentResponse, no AgentResponseUpdate
|
||||
assert len(agent_run_events) == 1, "Expected exactly one output event with AgentResponse in non-streaming mode"
|
||||
assert len(agent_update_events) == 0, "Expected no output event with AgentResponseUpdate in non-streaming mode"
|
||||
assert agent_run_events[0].executor_id == "agent_exec"
|
||||
assert agent_run_events[0].data is not None
|
||||
assert agent_run_events[0].data.messages[0].text == "Hello World"
|
||||
|
||||
# Test streaming mode with run(stream=True)
|
||||
stream_events: list[WorkflowEvent] = []
|
||||
@@ -909,12 +907,10 @@ async def test_agent_streaming_vs_non_streaming() -> None:
|
||||
|
||||
# Filter for agent events
|
||||
agent_response = [
|
||||
cast(AgentResponse, e.data) # type: ignore
|
||||
for e in stream_events
|
||||
if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponse)
|
||||
cast(AgentResponse, e.data) for e in stream_events if e.type == "output" and isinstance(e.data, AgentResponse)
|
||||
]
|
||||
agent_response_updates = [
|
||||
e.data for e in stream_events if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponseUpdate)
|
||||
e.data for e in stream_events if e.type == "output" and isinstance(e.data, AgentResponseUpdate)
|
||||
]
|
||||
|
||||
# In streaming mode, should have AgentResponseUpdate, no AgentResponse
|
||||
@@ -977,7 +973,7 @@ async def test_workflow_run_stream_parameter_validation(
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run(test_message, stream=True):
|
||||
events.append(event)
|
||||
assert any(isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE for e in events)
|
||||
assert any(e.type == "status" and e.state == WorkflowRunState.IDLE for e in events)
|
||||
|
||||
# Invalid combinations already tested in test_workflow_run_parameter_validation
|
||||
# This test ensures streaming works correctly for valid parameters
|
||||
@@ -1027,7 +1023,7 @@ async def test_output_executors_empty_yields_all_outputs() -> None:
|
||||
assert len(outputs) == 2
|
||||
assert outputs == [10, 20]
|
||||
|
||||
output_events = [event for event in result if isinstance(event, WorkflowOutputEvent)]
|
||||
output_events = [event for event in result if event.type == "output"]
|
||||
assert len(output_events) == 2
|
||||
assert output_events[0].executor_id == "executor_a"
|
||||
assert output_events[1].executor_id == "executor_b"
|
||||
@@ -1055,7 +1051,7 @@ async def test_output_executors_filters_outputs_non_streaming() -> None:
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0] == 20
|
||||
|
||||
output_events = [event for event in result if isinstance(event, WorkflowOutputEvent)]
|
||||
output_events = [event for event in result if event.type == "output"]
|
||||
assert len(output_events) == 1
|
||||
assert output_events[0].executor_id == "executor_b"
|
||||
|
||||
@@ -1076,9 +1072,9 @@ async def test_output_executors_filters_outputs_streaming() -> None:
|
||||
)
|
||||
|
||||
# Collect outputs from streaming
|
||||
output_events: list[WorkflowOutputEvent] = []
|
||||
output_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run(NumberMessage(data=0), stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
output_events.append(event)
|
||||
|
||||
# Only executor_a's output should be present
|
||||
@@ -1213,7 +1209,7 @@ async def test_output_executors_filtering_with_send_responses_streaming() -> Non
|
||||
events_list.append(event)
|
||||
|
||||
# Get request info events
|
||||
request_events = [e for e in events_list if isinstance(e, RequestInfoEvent)]
|
||||
request_events = [e for e in events_list if e.type == "request_info"]
|
||||
assert len(request_events) == 1
|
||||
|
||||
# Set output_executors to exclude the approval executor
|
||||
@@ -1221,9 +1217,9 @@ async def test_output_executors_filtering_with_send_responses_streaming() -> Non
|
||||
|
||||
# Send approval response via streaming
|
||||
responses = {request_events[0].request_id: ApprovalMessage(approved=True)}
|
||||
output_events: list[WorkflowOutputEvent] = []
|
||||
output_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.send_responses_streaming(responses):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
output_events.append(event)
|
||||
|
||||
# No outputs should be yielded since approval_executor is not in output_executors
|
||||
|
||||
@@ -218,7 +218,7 @@ class TestWorkflowAgent:
|
||||
assert "Streaming2: Streaming1: Test input" in second_content.text
|
||||
|
||||
async def test_end_to_end_request_info_handling(self):
|
||||
"""Test end-to-end workflow with RequestInfoEvent handling."""
|
||||
"""Test end-to-end workflow with request_info event (type='request_info') handling."""
|
||||
# Create workflow with requesting executor -> request info executor (no cycle)
|
||||
simple_executor = SimpleExecutor(id="simple", response_text="SimpleResponse", streaming=False)
|
||||
requesting_executor = RequestingExecutor(id="requester", streaming=False)
|
||||
@@ -331,7 +331,7 @@ class TestWorkflowAgent:
|
||||
async def test_workflow_as_agent_yield_output_surfaces_as_agent_response(self) -> None:
|
||||
"""Test that ctx.yield_output() in a workflow executor surfaces as agent output when using .as_agent().
|
||||
|
||||
This validates the fix for issue #2813: WorkflowOutputEvent should be converted to
|
||||
This validates the fix for issue #2813: output event (type='output') should be converted to
|
||||
AgentResponseUpdate when the workflow is wrapped via .as_agent().
|
||||
"""
|
||||
|
||||
@@ -343,7 +343,7 @@ class TestWorkflowAgent:
|
||||
|
||||
workflow = WorkflowBuilder().set_start_executor(yielding_executor).build()
|
||||
|
||||
# Run directly - should return WorkflowOutputEvent in result
|
||||
# Run directly - should return output event (type='output') in result
|
||||
direct_result = await workflow.run([ChatMessage(role="user", text="hello")])
|
||||
direct_outputs = direct_result.get_outputs()
|
||||
assert len(direct_outputs) == 1
|
||||
@@ -779,7 +779,7 @@ class TestWorkflowAgent:
|
||||
# Count occurrences of the unique response text
|
||||
unique_text_count = sum(1 for msg in result.messages if msg.text and "Unique response text" in msg.text)
|
||||
|
||||
# Should appear exactly once (not duplicated from both streaming and WorkflowOutputEvent)
|
||||
# Should appear exactly once (not duplicated from both streaming and output event)
|
||||
assert unique_text_count == 1, f"Response should appear exactly once, but appeared {unique_text_count} times"
|
||||
|
||||
|
||||
@@ -793,7 +793,7 @@ class TestWorkflowAgentAuthorName:
|
||||
identification of which agent produced them in multi-agent workflows.
|
||||
"""
|
||||
# Create workflow with executor that emits AgentResponseUpdate without author_name
|
||||
executor1 = SimpleExecutor(id="my_executor_id", response_text="Response")
|
||||
executor1 = SimpleExecutor(id="my_executor_id", response_text="Response", streaming=True)
|
||||
workflow = WorkflowBuilder().set_start_executor(executor1).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Test Agent")
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
executor,
|
||||
handler,
|
||||
)
|
||||
@@ -62,15 +61,15 @@ async def test_executor_cannot_emit_framework_lifecycle_event(caplog: "LogCaptur
|
||||
async with make_context() as (ctx, runner_ctx):
|
||||
caplog.clear()
|
||||
with caplog.at_level("WARNING"):
|
||||
await ctx.add_event(WorkflowStatusEvent(state=WorkflowRunState.IN_PROGRESS))
|
||||
await ctx.add_event(WorkflowEvent.status(state=WorkflowRunState.IN_PROGRESS))
|
||||
|
||||
events: list[WorkflowEvent] = await runner_ctx.drain_events()
|
||||
assert len(events) == 1
|
||||
assert type(events[0]).__name__ == "WorkflowWarningEvent"
|
||||
data = getattr(events[0], "data", None)
|
||||
assert events[0].type == "warning"
|
||||
data = events[0].data
|
||||
assert isinstance(data, str)
|
||||
assert "reserved for framework lifecycle notifications" in data
|
||||
assert any("attempted to emit WorkflowStatusEvent" in message for message in list(caplog.messages))
|
||||
assert any("attempted to emit" in message and "'status'" in message for message in list(caplog.messages))
|
||||
|
||||
|
||||
async def test_executor_emits_normal_event() -> None:
|
||||
@@ -84,7 +83,8 @@ async def test_executor_emits_normal_event() -> None:
|
||||
|
||||
|
||||
class _TestEvent(WorkflowEvent):
|
||||
pass
|
||||
def __init__(self, data: Any = None) -> None:
|
||||
super().__init__("test_event", data=data)
|
||||
|
||||
|
||||
async def test_workflow_context_type_annotations_no_parameter() -> None:
|
||||
|
||||
@@ -14,7 +14,6 @@ from agent_framework import (
|
||||
Content,
|
||||
ResponseStream,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
@@ -90,7 +89,7 @@ async def test_sequential_kwargs_flow_to_agent() -> None:
|
||||
custom_data=custom_data,
|
||||
user_token=user_token,
|
||||
):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Verify agent received kwargs
|
||||
@@ -111,7 +110,7 @@ async def test_sequential_kwargs_flow_to_multiple_agents() -> None:
|
||||
custom_data = {"key": "value"}
|
||||
|
||||
async for event in workflow.run("test", custom_data=custom_data, stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Both agents should have received kwargs
|
||||
@@ -153,7 +152,7 @@ async def test_concurrent_kwargs_flow_to_agents() -> None:
|
||||
custom_data=custom_data,
|
||||
user_token=user_token,
|
||||
):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Both agents should have received kwargs
|
||||
@@ -200,7 +199,7 @@ async def test_groupchat_kwargs_flow_to_agents() -> None:
|
||||
custom_data = {"session_id": "group123"}
|
||||
|
||||
async for event in workflow.run("group chat test", custom_data=custom_data, stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# At least one agent should have received kwargs
|
||||
@@ -234,7 +233,7 @@ async def test_kwargs_stored_in_state() -> None:
|
||||
workflow = SequentialBuilder().participants([inspector]).build()
|
||||
|
||||
async for event in workflow.run("test", my_kwarg="my_value", another=123, stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
assert stored_kwargs is not None, "kwargs should be stored in State"
|
||||
@@ -260,7 +259,7 @@ async def test_empty_kwargs_stored_as_empty_dict() -> None:
|
||||
|
||||
# Run without any kwargs
|
||||
async for event in workflow.run("test", stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# State should have empty dict when no kwargs provided
|
||||
@@ -279,7 +278,7 @@ async def test_kwargs_with_none_values() -> None:
|
||||
workflow = SequentialBuilder().participants([agent]).build()
|
||||
|
||||
async for event in workflow.run("test", optional_param=None, other_param="value", stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
assert len(agent.captured_kwargs) >= 1
|
||||
@@ -306,7 +305,7 @@ async def test_kwargs_with_complex_nested_data() -> None:
|
||||
}
|
||||
|
||||
async for event in workflow.run("test", complex_data=complex_data, stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
assert len(agent.captured_kwargs) >= 1
|
||||
@@ -324,12 +323,12 @@ async def test_kwargs_preserved_across_workflow_reruns() -> None:
|
||||
|
||||
# First run
|
||||
async for event in workflow1.run("run1", run_id="first", stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Second run with different kwargs (using fresh workflow)
|
||||
async for event in workflow2.run("run2", run_id="second", stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
assert len(agent.captured_kwargs) >= 2
|
||||
@@ -361,7 +360,7 @@ async def test_handoff_kwargs_flow_to_agents() -> None:
|
||||
custom_data = {"session_id": "handoff123"}
|
||||
|
||||
async for event in workflow.run("handoff test", custom_data=custom_data, stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Coordinator agent should have received kwargs
|
||||
@@ -419,7 +418,7 @@ async def test_magentic_kwargs_flow_to_agents() -> None:
|
||||
custom_data = {"session_id": "magentic123"}
|
||||
|
||||
async for event in workflow.run("magentic test", custom_data=custom_data, stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# The workflow completes immediately via prepare_final_answer without invoking agents
|
||||
@@ -470,7 +469,7 @@ async def test_magentic_kwargs_stored_in_state() -> None:
|
||||
custom_data = {"magentic_key": "magentic_value"}
|
||||
|
||||
async for event in magentic_workflow.run("test task", custom_data=custom_data, stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Verify the workflow completed (kwargs were stored, even if agent wasn't invoked)
|
||||
@@ -626,7 +625,7 @@ async def test_subworkflow_kwargs_propagation() -> None:
|
||||
custom_data=custom_data,
|
||||
user_token=user_token,
|
||||
):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Verify that the inner agent was called
|
||||
@@ -686,7 +685,7 @@ async def test_subworkflow_kwargs_accessible_via_state() -> None:
|
||||
my_custom_kwarg="should_be_propagated",
|
||||
another_kwarg=42,
|
||||
):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Verify the state reader was invoked
|
||||
@@ -732,7 +731,7 @@ async def test_nested_subworkflow_kwargs_propagation() -> None:
|
||||
stream=True,
|
||||
deep_kwarg="should_reach_inner",
|
||||
):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Verify inner agent was called
|
||||
|
||||
@@ -5,18 +5,14 @@ from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
ExecutorFailedEvent,
|
||||
InProcRunnerContext,
|
||||
RequestInfoEvent,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowEventSource,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunResult,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._state import State
|
||||
@@ -39,24 +35,26 @@ async def test_executor_failed_and_workflow_failed_events_streaming():
|
||||
async for ev in wf.run(0, stream=True):
|
||||
events.append(ev)
|
||||
|
||||
# ExecutorFailedEvent should be emitted before WorkflowFailedEvent
|
||||
executor_failed_events = [e for e in events if isinstance(e, ExecutorFailedEvent)]
|
||||
assert executor_failed_events, "ExecutorFailedEvent should be emitted when start executor fails"
|
||||
# executor_failed event (type='executor_failed') should be emitted before workflow failed event
|
||||
executor_failed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_failed"]
|
||||
assert executor_failed_events, "executor_failed event should be emitted when start executor fails"
|
||||
assert executor_failed_events[0].executor_id == "f"
|
||||
assert executor_failed_events[0].origin is WorkflowEventSource.FRAMEWORK
|
||||
|
||||
# Workflow-level failure and FAILED status should be surfaced
|
||||
failed_events = [e for e in events if isinstance(e, WorkflowFailedEvent)]
|
||||
failed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "failed"]
|
||||
assert failed_events
|
||||
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed_events)
|
||||
status = [e for e in events if isinstance(e, WorkflowStatusEvent)]
|
||||
status = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "status"]
|
||||
assert status and status[-1].state == WorkflowRunState.FAILED
|
||||
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in status)
|
||||
|
||||
# Verify ExecutorFailedEvent comes before WorkflowFailedEvent
|
||||
# Verify executor_failed event comes before workflow failed event
|
||||
executor_failed_idx = events.index(executor_failed_events[0])
|
||||
workflow_failed_idx = events.index(failed_events[0])
|
||||
assert executor_failed_idx < workflow_failed_idx, "ExecutorFailedEvent should be emitted before WorkflowFailedEvent"
|
||||
assert executor_failed_idx < workflow_failed_idx, (
|
||||
"executor_failed event should be emitted before workflow failed event"
|
||||
)
|
||||
|
||||
|
||||
async def test_executor_failed_event_emitted_on_direct_execute():
|
||||
@@ -71,7 +69,7 @@ async def test_executor_failed_event_emitted_on_direct_execute():
|
||||
ctx,
|
||||
)
|
||||
drained = await ctx.drain_events()
|
||||
failed = [e for e in drained if isinstance(e, ExecutorFailedEvent)]
|
||||
failed = [e for e in drained if isinstance(e, WorkflowEvent) and e.type == "executor_failed"]
|
||||
assert failed
|
||||
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed)
|
||||
|
||||
@@ -85,7 +83,7 @@ class PassthroughExecutor(Executor):
|
||||
|
||||
|
||||
async def test_executor_failed_event_from_second_executor_in_chain():
|
||||
"""Test that ExecutorFailedEvent is emitted when a non-start executor fails."""
|
||||
"""Test that executor_failed event is emitted when a non-start executor fails."""
|
||||
passthrough = PassthroughExecutor(id="passthrough")
|
||||
failing = FailingExecutor(id="failing")
|
||||
wf: Workflow = WorkflowBuilder().set_start_executor(passthrough).add_edge(passthrough, failing).build()
|
||||
@@ -95,21 +93,23 @@ async def test_executor_failed_event_from_second_executor_in_chain():
|
||||
async for ev in wf.run(0, stream=True):
|
||||
events.append(ev)
|
||||
|
||||
# ExecutorFailedEvent should be emitted for the failing executor
|
||||
executor_failed_events = [e for e in events if isinstance(e, ExecutorFailedEvent)]
|
||||
assert executor_failed_events, "ExecutorFailedEvent should be emitted when second executor fails"
|
||||
# executor_failed event should be emitted for the failing executor
|
||||
executor_failed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_failed"]
|
||||
assert executor_failed_events, "executor_failed event should be emitted when second executor fails"
|
||||
assert executor_failed_events[0].executor_id == "failing"
|
||||
assert executor_failed_events[0].origin is WorkflowEventSource.FRAMEWORK
|
||||
|
||||
# Workflow-level failure should also be surfaced
|
||||
failed_events = [e for e in events if isinstance(e, WorkflowFailedEvent)]
|
||||
failed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "failed"]
|
||||
assert failed_events
|
||||
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed_events)
|
||||
|
||||
# Verify ExecutorFailedEvent comes before WorkflowFailedEvent
|
||||
# Verify executor_failed event comes before workflow failed event
|
||||
executor_failed_idx = events.index(executor_failed_events[0])
|
||||
workflow_failed_idx = events.index(failed_events[0])
|
||||
assert executor_failed_idx < workflow_failed_idx, "ExecutorFailedEvent should be emitted before WorkflowFailedEvent"
|
||||
assert executor_failed_idx < workflow_failed_idx, (
|
||||
"executor_failed event should be emitted before workflow failed event"
|
||||
)
|
||||
|
||||
|
||||
class SimpleExecutor(Executor):
|
||||
@@ -136,8 +136,8 @@ async def test_idle_with_pending_requests_status_streaming():
|
||||
events = [ev async for ev in wf.run("start", stream=True)] # Consume stream fully
|
||||
|
||||
# Ensure a request was emitted
|
||||
assert any(isinstance(e, RequestInfoEvent) for e in events)
|
||||
status_events = [e for e in events if isinstance(e, WorkflowStatusEvent)]
|
||||
assert any(isinstance(e, WorkflowEvent) and e.type == "request_info" for e in events)
|
||||
status_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "status"]
|
||||
assert len(status_events) >= 3
|
||||
assert status_events[-2].state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS
|
||||
assert status_events[-1].state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
@@ -156,7 +156,7 @@ async def test_completed_status_streaming():
|
||||
wf = WorkflowBuilder().set_start_executor(c).build()
|
||||
events = [ev async for ev in wf.run("ok", stream=True)] # no raise
|
||||
# Last status should be IDLE
|
||||
status = [e for e in events if isinstance(e, WorkflowStatusEvent)]
|
||||
status = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "status"]
|
||||
assert status and status[-1].state == WorkflowRunState.IDLE
|
||||
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in status)
|
||||
|
||||
@@ -166,12 +166,13 @@ async def test_started_and_completed_event_origins():
|
||||
wf = WorkflowBuilder().set_start_executor(c).build()
|
||||
events = [ev async for ev in wf.run("payload", stream=True)]
|
||||
|
||||
started = next(e for e in events if isinstance(e, WorkflowStartedEvent))
|
||||
started = next(e for e in events if isinstance(e, WorkflowEvent) and e.type == "started")
|
||||
assert started.origin is WorkflowEventSource.FRAMEWORK
|
||||
|
||||
# Check for IDLE status indicating completion
|
||||
idle_status = next(
|
||||
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE), None
|
||||
(e for e in events if isinstance(e, WorkflowEvent) and e.type == "status" and e.state == WorkflowRunState.IDLE),
|
||||
None,
|
||||
)
|
||||
assert idle_status is not None
|
||||
assert idle_status.origin is WorkflowEventSource.FRAMEWORK
|
||||
|
||||
Reference in New Issue
Block a user