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
|
||||
|
||||
@@ -145,7 +145,7 @@ actions:
|
||||
result = await workflow.run({})
|
||||
outputs = result.get_outputs()
|
||||
|
||||
# Check for the expected text in WorkflowOutputEvent
|
||||
# Check for the expected text in output event (type='output')
|
||||
_text_outputs = [str(o) for o in outputs if isinstance(o, str) or hasattr(o, "data")] # noqa: F841
|
||||
assert any("Condition was true" in str(o) for o in outputs)
|
||||
|
||||
|
||||
@@ -249,9 +249,9 @@ Given that DevUI offers an OpenAI Responses API, it internally maps messages and
|
||||
| `response.created` + `response.in_progress` | `AgentStartedEvent` | OpenAI |
|
||||
| `response.completed` | `AgentCompletedEvent` | OpenAI |
|
||||
| `response.failed` | `AgentFailedEvent` | OpenAI |
|
||||
| `response.created` + `response.in_progress` | `WorkflowStartedEvent` | OpenAI |
|
||||
| `response.completed` | `WorkflowCompletedEvent` | OpenAI |
|
||||
| `response.failed` | `WorkflowFailedEvent` | OpenAI |
|
||||
| `response.created` + `response.in_progress` | `WorkflowEvent (type='started')` | OpenAI |
|
||||
| `response.completed` | `WorkflowEvent (type='status')` | OpenAI |
|
||||
| `response.failed` | `WorkflowEvent (type='failed')` | OpenAI |
|
||||
| | **Content Types** | |
|
||||
| `response.content_part.added` + `response.output_text.delta` | `TextContent` | OpenAI |
|
||||
| `response.reasoning_text.delta` | `TextReasoningContent` | OpenAI |
|
||||
@@ -267,13 +267,13 @@ Given that DevUI offers an OpenAI Responses API, it internally maps messages and
|
||||
| `error` | `ErrorContent` | OpenAI |
|
||||
| Final `Response.usage` field (not streamed) | `UsageContent` | OpenAI |
|
||||
| | **Workflow Events** | |
|
||||
| `response.output_item.added` (ExecutorActionItem)* | `ExecutorInvokedEvent` | OpenAI |
|
||||
| `response.output_item.done` (ExecutorActionItem)* | `ExecutorCompletedEvent` | OpenAI |
|
||||
| `response.output_item.done` (ExecutorActionItem with error)* | `ExecutorFailedEvent` | OpenAI |
|
||||
| `response.output_item.added` (ResponseOutputMessage) | `WorkflowOutputEvent` | OpenAI |
|
||||
| `response.workflow_event.complete` | `WorkflowEvent` (other) | DevUI |
|
||||
| `response.trace.complete` | `WorkflowStatusEvent` | DevUI |
|
||||
| `response.trace.complete` | `WorkflowWarningEvent` | DevUI |
|
||||
| `response.output_item.added` (ExecutorActionItem)* | `WorkflowEvent (type='executor_invoked')` | OpenAI |
|
||||
| `response.output_item.done` (ExecutorActionItem)* | `WorkflowEvent (type='executor_completed')` | OpenAI |
|
||||
| `response.output_item.done` (ExecutorActionItem with error)* | `WorkflowEvent (type='executor_failed')` | OpenAI |
|
||||
| `response.output_item.added` (ResponseOutputMessage) | `WorkflowEvent (type='output')` | OpenAI |
|
||||
| `response.workflow_event.complete` | `WorkflowEvent` (other types) | DevUI |
|
||||
| `response.trace.complete` | `WorkflowEvent (type='status')` | DevUI |
|
||||
| `response.trace.complete` | `WorkflowEvent (type='warning')` | DevUI |
|
||||
| | **Trace Content** | |
|
||||
| `response.trace.complete` | `DataContent` (no data/errors) | DevUI |
|
||||
| `response.trace.complete` | `UriContent` (unsupported MIME) | DevUI |
|
||||
|
||||
@@ -7,8 +7,7 @@ import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentProtocol, Content
|
||||
from agent_framework._workflows._events import RequestInfoEvent
|
||||
from agent_framework import AgentProtocol, Content, Workflow
|
||||
|
||||
from ._conversations import ConversationStore, InMemoryConversationStore
|
||||
from ._discovery import EntityDiscovery
|
||||
@@ -262,10 +261,11 @@ class AgentFrameworkExecutor:
|
||||
yield event
|
||||
elif entity_info.type == "workflow":
|
||||
async for event in self._execute_workflow(entity_obj, request, trace_collector):
|
||||
# Log RequestInfoEvent for debugging HIL flow
|
||||
event_class = event.__class__.__name__ if hasattr(event, "__class__") else type(event).__name__
|
||||
if event_class == "RequestInfoEvent":
|
||||
logger.info("🔔 [EXECUTOR] RequestInfoEvent detected from workflow!")
|
||||
# Log request_info event (type='request_info') for debugging HIL flow
|
||||
if event.type == "request_info":
|
||||
logger.info(
|
||||
"🔔 [EXECUTOR] request_info event (type='request_info') detected from workflow!"
|
||||
)
|
||||
logger.info(f" request_id: {getattr(event, 'request_id', 'N/A')}")
|
||||
logger.info(f" source_executor_id: {getattr(event, 'source_executor_id', 'N/A')}")
|
||||
logger.info(f" request_type: {getattr(event, 'request_type', 'N/A')}")
|
||||
@@ -360,7 +360,7 @@ class AgentFrameworkExecutor:
|
||||
yield {"type": "error", "message": f"Agent execution error: {e!s}"}
|
||||
|
||||
async def _execute_workflow(
|
||||
self, workflow: Any, request: AgentFrameworkRequest, trace_collector: Any
|
||||
self, workflow: Workflow, request: AgentFrameworkRequest, trace_collector: Any
|
||||
) -> AsyncGenerator[Any, None]:
|
||||
"""Execute Agent Framework workflow with checkpoint support via conversation items.
|
||||
|
||||
@@ -515,8 +515,9 @@ class AgentFrameworkExecutor:
|
||||
logger.warning(f"Could not convert HIL responses to proper types: {e}")
|
||||
|
||||
async for event in workflow.send_responses_streaming(hil_responses):
|
||||
# Enrich new RequestInfoEvents that may come from subsequent HIL requests
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
# Enrich new request_info events (type='request_info')
|
||||
# that may come from subsequent HIL requests
|
||||
if event.type == "request_info":
|
||||
self._enrich_request_info_event_with_response_schema(event, workflow)
|
||||
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
@@ -538,7 +539,7 @@ class AgentFrameworkExecutor:
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
self._enrich_request_info_event_with_response_schema(event, workflow)
|
||||
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
@@ -546,7 +547,7 @@ class AgentFrameworkExecutor:
|
||||
|
||||
yield event
|
||||
|
||||
# Note: Removed break on RequestInfoEvent - continue yielding all events
|
||||
# Note: Removed break on request_info event (type='request_info') - continue yielding all events
|
||||
# The workflow is already paused by ctx.request_info() in the framework
|
||||
# DevUI should continue yielding events even during HIL pause
|
||||
|
||||
@@ -562,7 +563,7 @@ class AgentFrameworkExecutor:
|
||||
parsed_input = await self._parse_workflow_input(workflow, request.input)
|
||||
|
||||
async for event in workflow.run(parsed_input, stream=True, checkpoint_storage=checkpoint_storage):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
self._enrich_request_info_event_with_response_schema(event, workflow)
|
||||
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
@@ -570,7 +571,7 @@ class AgentFrameworkExecutor:
|
||||
|
||||
yield event
|
||||
|
||||
# Note: Removed break on RequestInfoEvent - continue yielding all events
|
||||
# Note: Removed break on request_info event (type='request_info') - continue yielding all events
|
||||
# The workflow is already paused by ctx.request_info() in the framework
|
||||
# DevUI should continue yielding events even during HIL pause
|
||||
|
||||
@@ -1015,10 +1016,12 @@ class AgentFrameworkExecutor:
|
||||
return raw_input
|
||||
|
||||
def _enrich_request_info_event_with_response_schema(self, event: Any, workflow: Any) -> None:
|
||||
"""Extract response type from workflow executor and attach response schema to RequestInfoEvent.
|
||||
"""Extract response type from workflow executor.
|
||||
|
||||
Attach response schema to request_info event (type='request_info').
|
||||
|
||||
Args:
|
||||
event: RequestInfoEvent to enrich
|
||||
event: request_info event (type='request_info') to enrich
|
||||
workflow: Workflow object containing executors
|
||||
"""
|
||||
try:
|
||||
@@ -1029,7 +1032,7 @@ class AgentFrameworkExecutor:
|
||||
request_type = getattr(event, "request_type", None)
|
||||
|
||||
if not source_executor_id or not request_type:
|
||||
logger.debug("RequestInfoEvent missing source_executor_id or request_type")
|
||||
logger.debug("request_info event (type='request_info') missing source_executor_id or request_type")
|
||||
return
|
||||
|
||||
# Find the source executor in the workflow
|
||||
@@ -1062,4 +1065,4 @@ class AgentFrameworkExecutor:
|
||||
event._response_schema = response_schema
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to enrich RequestInfoEvent with response schema: {e}")
|
||||
logger.warning(f"Failed to enrich request_info event (type='request_info') with response schema: {e}")
|
||||
|
||||
@@ -12,7 +12,7 @@ from datetime import datetime
|
||||
from typing import Any, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import ChatMessage, Content, WorkflowOutputEvent
|
||||
from agent_framework import ChatMessage, Content
|
||||
from openai.types.responses import (
|
||||
Response,
|
||||
ResponseContentPartAddedEvent,
|
||||
@@ -180,16 +180,18 @@ class MessageMapper:
|
||||
try:
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, WorkflowEvent
|
||||
|
||||
# Handle AgentRunUpdateEvent - workflow event wrapping AgentResponseUpdate
|
||||
# Handle WorkflowEvent with type='output' or 'data' wrapping AgentResponseUpdate
|
||||
# This must be checked BEFORE generic WorkflowEvent check
|
||||
if isinstance(raw_event, WorkflowOutputEvent):
|
||||
# Extract the AgentResponseUpdate from the event's data attribute
|
||||
if raw_event.data and isinstance(raw_event.data, AgentResponseUpdate):
|
||||
# Preserve executor_id in context for proper output routing
|
||||
context["current_executor_id"] = raw_event.executor_id
|
||||
return await self._convert_agent_update(raw_event.data, context)
|
||||
# If no data, treat as generic workflow event
|
||||
return await self._convert_workflow_event(raw_event, context)
|
||||
# Note: AgentExecutor uses type='output' for streaming updates
|
||||
if (
|
||||
isinstance(raw_event, WorkflowEvent)
|
||||
and raw_event.type in ("output", "data")
|
||||
and raw_event.data
|
||||
and isinstance(raw_event.data, AgentResponseUpdate)
|
||||
):
|
||||
# Preserve executor_id in context for proper output routing
|
||||
context["current_executor_id"] = raw_event.executor_id
|
||||
return await self._convert_agent_update(raw_event.data, context)
|
||||
|
||||
# Handle complete agent response (AgentResponse) - for non-streaming agent execution
|
||||
if isinstance(raw_event, AgentResponse):
|
||||
@@ -824,10 +826,12 @@ class MessageMapper:
|
||||
List of OpenAI response stream events
|
||||
"""
|
||||
try:
|
||||
event_class = event.__class__.__name__
|
||||
# Use event.type for discriminated union pattern (similar to Content class)
|
||||
event_type = getattr(event, "type", None)
|
||||
event_class = event.__class__.__name__ # Fallback for non-workflow events
|
||||
|
||||
# Response-level events - construct proper OpenAI objects
|
||||
if event_class == "WorkflowStartedEvent":
|
||||
if event_type == "started":
|
||||
workflow_id = getattr(event, "workflow_id", str(uuid4()))
|
||||
context["workflow_id"] = workflow_id
|
||||
|
||||
@@ -871,8 +875,8 @@ class MessageMapper:
|
||||
|
||||
return events
|
||||
|
||||
# Handle WorkflowOutputEvent separately to preserve output data
|
||||
if event_class == "WorkflowOutputEvent":
|
||||
# Handle output events separately to preserve output data
|
||||
if event_type == "output":
|
||||
output_data = getattr(event, "data", None)
|
||||
executor_id = getattr(event, "executor_id", "unknown")
|
||||
|
||||
@@ -934,7 +938,7 @@ class MessageMapper:
|
||||
|
||||
# Emit output_item.added for each yield_output
|
||||
logger.debug(
|
||||
f"WorkflowOutputEvent converted to output_item.added "
|
||||
f"output event (type='output') converted to output_item.added "
|
||||
f"(executor: {executor_id}, length: {len(text)})"
|
||||
)
|
||||
return [
|
||||
@@ -946,15 +950,15 @@ class MessageMapper:
|
||||
)
|
||||
]
|
||||
|
||||
# Handle WorkflowCompletedEvent - Don't emit response.completed here
|
||||
# Handle completed event - Don't emit response.completed here
|
||||
# The server will emit a proper one with usage data after aggregating all events
|
||||
if event_class == "WorkflowCompletedEvent":
|
||||
if event_type == "completed":
|
||||
return []
|
||||
|
||||
if event_class == "WorkflowFailedEvent":
|
||||
if event_type == "failed":
|
||||
workflow_id = context.get("workflow_id", str(uuid4()))
|
||||
# WorkflowFailedEvent uses 'details' field (WorkflowErrorDetails), not 'error'
|
||||
# This matches ExecutorFailedEvent which also uses 'details'
|
||||
# failed event (type='failed') uses 'details' field (WorkflowErrorDetails), not 'error'
|
||||
# This matches executor_failed event which also uses 'details'
|
||||
details = getattr(event, "details", None)
|
||||
|
||||
# Import Response and ResponseError types
|
||||
@@ -1000,7 +1004,8 @@ class MessageMapper:
|
||||
]
|
||||
|
||||
# Executor-level events (output items)
|
||||
if event_class == "ExecutorInvokedEvent":
|
||||
# Check for executor lifecycle events via event.type
|
||||
if event_type == "executor_invoked":
|
||||
executor_id = getattr(event, "executor_id", "unknown")
|
||||
item_id = f"exec_{executor_id}_{uuid4().hex[:8]}"
|
||||
context[f"exec_item_{executor_id}"] = item_id
|
||||
@@ -1029,7 +1034,7 @@ class MessageMapper:
|
||||
)
|
||||
]
|
||||
|
||||
if event_class == "ExecutorCompletedEvent":
|
||||
if event_type == "executor_completed":
|
||||
executor_id = getattr(event, "executor_id", "unknown")
|
||||
item_id = context.get(f"exec_item_{executor_id}", f"exec_{executor_id}_unknown")
|
||||
|
||||
@@ -1038,7 +1043,7 @@ class MessageMapper:
|
||||
context.pop("current_executor_id", None)
|
||||
|
||||
# Create ExecutorActionItem with completed status
|
||||
# ExecutorCompletedEvent uses 'data' field, not 'result'
|
||||
# executor_completed event (type='executor_completed') uses 'data' field, not 'result'
|
||||
# Serialize the result data to ensure it's JSON-serializable
|
||||
# (AgentExecutorResponse contains AgentResponse/ChatMessage which are SerializationMixin)
|
||||
raw_result = getattr(event, "data", None)
|
||||
@@ -1061,10 +1066,11 @@ class MessageMapper:
|
||||
)
|
||||
]
|
||||
|
||||
if event_class == "ExecutorFailedEvent":
|
||||
if event_type == "executor_failed":
|
||||
executor_id = getattr(event, "executor_id", "unknown")
|
||||
item_id = context.get(f"exec_item_{executor_id}", f"exec_{executor_id}_unknown")
|
||||
# ExecutorFailedEvent uses 'details' field (WorkflowErrorDetails), not 'error'
|
||||
# executor_failed event (type='executor_failed') uses 'details' property (WorkflowErrorDetails)
|
||||
# not 'error'. This matches WorkflowEvent.details which returns self.data for executor_failed type
|
||||
details = getattr(event, "details", None)
|
||||
if details:
|
||||
err_msg = getattr(details, "message", None) or str(details)
|
||||
@@ -1093,8 +1099,8 @@ class MessageMapper:
|
||||
)
|
||||
]
|
||||
|
||||
# Handle RequestInfoEvent specially - emit as HIL event with schema
|
||||
if event_class == "RequestInfoEvent":
|
||||
# Handle request_info events specially - emit as HIL event with schema
|
||||
if event_type == "request_info":
|
||||
from .models._openai_custom import ResponseRequestInfoEvent
|
||||
|
||||
request_id = getattr(event, "request_id", "")
|
||||
@@ -1102,7 +1108,7 @@ class MessageMapper:
|
||||
request_type_class = getattr(event, "request_type", None)
|
||||
request_data = getattr(event, "data", None)
|
||||
|
||||
logger.info("📨 [MAPPER] Processing RequestInfoEvent")
|
||||
logger.info("📨 [MAPPER] Processing request_info event (type='request_info')")
|
||||
logger.info(f" request_id: {request_id}")
|
||||
logger.info(f" source_executor_id: {source_executor_id}")
|
||||
logger.info(f" request_type_class: {request_type_class}")
|
||||
@@ -1163,26 +1169,23 @@ class MessageMapper:
|
||||
return [hil_event]
|
||||
|
||||
# Handle other informational workflow events (status, warnings, errors)
|
||||
if event_class in ["WorkflowStatusEvent", "WorkflowWarningEvent", "WorkflowErrorEvent"]:
|
||||
if event_type in ["status", "warning", "error"]:
|
||||
# These are informational events that don't map to OpenAI lifecycle events
|
||||
# Convert them to trace events for debugging visibility
|
||||
event_data: dict[str, Any] = {}
|
||||
|
||||
# Extract relevant data based on event type
|
||||
if event_class == "WorkflowStatusEvent":
|
||||
if event_type == "status":
|
||||
event_data["state"] = str(getattr(event, "state", "unknown"))
|
||||
elif event_class == "WorkflowWarningEvent":
|
||||
event_data["message"] = str(getattr(event, "message", ""))
|
||||
elif event_class == "WorkflowErrorEvent":
|
||||
event_data["message"] = str(getattr(event, "message", ""))
|
||||
event_data["error"] = str(getattr(event, "error", ""))
|
||||
elif event_type == "warning" or event_type == "error":
|
||||
event_data["message"] = str(getattr(event, "data", ""))
|
||||
|
||||
# Create a trace event for debugging
|
||||
trace_event = ResponseTraceEventComplete(
|
||||
type="response.trace.completed",
|
||||
data={
|
||||
"trace_type": "workflow_info",
|
||||
"event_type": event_class,
|
||||
"event_type": event_type,
|
||||
"data": event_data,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
|
||||
@@ -32,10 +32,8 @@ from agent_framework import (
|
||||
from agent_framework._clients import TOptions_co
|
||||
from agent_framework._workflows._agent_executor import AgentExecutorResponse
|
||||
from agent_framework._workflows._events import (
|
||||
ExecutorCompletedEvent,
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
)
|
||||
from agent_framework.orchestrations import ConcurrentBuilder, SequentialBuilder
|
||||
|
||||
@@ -284,7 +282,8 @@ def _create_agent_executor_response(
|
||||
executor_id: str = "test_executor",
|
||||
response_text: str = "Executor response",
|
||||
) -> AgentExecutorResponse:
|
||||
"""Create an AgentExecutorResponse - the type that's nested in ExecutorCompletedEvent.data."""
|
||||
"""Create an AgentExecutorResponse - the type that's nested in
|
||||
executor_completed event (type='executor_completed').data."""
|
||||
agent_response = _create_agent_run_response(response_text)
|
||||
return AgentExecutorResponse(
|
||||
executor_id=executor_id,
|
||||
@@ -306,32 +305,32 @@ def create_agent_run_response(text: str = "Test response") -> AgentResponse:
|
||||
return _create_agent_run_response(text)
|
||||
|
||||
|
||||
def create_executor_invoked_event(executor_id: str = "test_executor") -> ExecutorInvokedEvent:
|
||||
"""Create an ExecutorInvokedEvent."""
|
||||
return ExecutorInvokedEvent(executor_id=executor_id)
|
||||
def create_executor_invoked_event(executor_id: str = "test_executor") -> WorkflowEvent[Any]:
|
||||
"""Create a WorkflowEvent(type='executor_invoked')."""
|
||||
return WorkflowEvent.executor_invoked(executor_id=executor_id)
|
||||
|
||||
|
||||
def create_executor_completed_event(
|
||||
executor_id: str = "test_executor",
|
||||
with_agent_response: bool = True,
|
||||
) -> ExecutorCompletedEvent:
|
||||
"""Create an ExecutorCompletedEvent with realistic nested data.
|
||||
) -> WorkflowEvent[Any]:
|
||||
"""Create a WorkflowEvent(type='executor_completed') with realistic nested data.
|
||||
|
||||
This creates the exact data structure that caused the serialization bug:
|
||||
ExecutorCompletedEvent.data contains AgentExecutorResponse which contains
|
||||
WorkflowEvent.data contains AgentExecutorResponse which contains
|
||||
AgentResponse and ChatMessage objects (SerializationMixin, not Pydantic).
|
||||
"""
|
||||
data = _create_agent_executor_response(executor_id) if with_agent_response else {"simple": "dict"}
|
||||
return ExecutorCompletedEvent(executor_id=executor_id, data=data)
|
||||
return WorkflowEvent.executor_completed(executor_id=executor_id, data=data)
|
||||
|
||||
|
||||
def create_executor_failed_event(
|
||||
executor_id: str = "test_executor",
|
||||
error_message: str = "Test error",
|
||||
) -> ExecutorFailedEvent:
|
||||
"""Create an ExecutorFailedEvent."""
|
||||
) -> WorkflowEvent[WorkflowErrorDetails]:
|
||||
"""Create a WorkflowEvent(type='executor_failed')."""
|
||||
details = WorkflowErrorDetails(error_type="TestError", message=error_message)
|
||||
return ExecutorFailedEvent(executor_id=executor_id, details=details)
|
||||
return WorkflowEvent.executor_failed(executor_id=executor_id, details=details)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -386,28 +385,28 @@ def agent_run_response() -> AgentResponse:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor_completed_event() -> ExecutorCompletedEvent:
|
||||
"""Create an ExecutorCompletedEvent with realistic nested data.
|
||||
def executor_completed_event() -> WorkflowEvent[Any]:
|
||||
"""Create a WorkflowEvent(type='executor_completed') with realistic nested data.
|
||||
|
||||
This creates the exact data structure that caused the serialization bug:
|
||||
ExecutorCompletedEvent.data contains AgentExecutorResponse which contains
|
||||
executor_completed event (type='executor_completed').data contains AgentExecutorResponse which contains
|
||||
AgentResponse and ChatMessage objects (SerializationMixin, not Pydantic).
|
||||
"""
|
||||
data = _create_agent_executor_response("test_executor")
|
||||
return ExecutorCompletedEvent(executor_id="test_executor", data=data)
|
||||
return WorkflowEvent.executor_completed(executor_id="test_executor", data=data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor_invoked_event() -> ExecutorInvokedEvent:
|
||||
"""Create an ExecutorInvokedEvent."""
|
||||
return ExecutorInvokedEvent(executor_id="test_executor")
|
||||
def executor_invoked_event() -> WorkflowEvent[Any]:
|
||||
"""Create a WorkflowEvent(type='executor_invoked')."""
|
||||
return WorkflowEvent.executor_invoked(executor_id="test_executor")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor_failed_event() -> ExecutorFailedEvent:
|
||||
"""Create an ExecutorFailedEvent."""
|
||||
def executor_failed_event() -> WorkflowEvent[WorkflowErrorDetails]:
|
||||
"""Create a WorkflowEvent(type='executor_failed')."""
|
||||
details = WorkflowErrorDetails(error_type="TestError", message="Test error")
|
||||
return ExecutorFailedEvent(executor_id="test_executor", details=details)
|
||||
return WorkflowEvent.executor_failed(executor_id="test_executor", details=details)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -8,10 +8,8 @@ import pytest
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
InMemoryCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
@@ -428,13 +426,13 @@ class TestIntegration:
|
||||
# Run workflow until it reaches IDLE_WITH_PENDING_REQUESTS (after checkpoint is created)
|
||||
saw_request_event = False
|
||||
async for event in test_workflow.run(WorkflowTestData(value="test"), stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
saw_request_event = True
|
||||
# Wait for IDLE_WITH_PENDING_REQUESTS status (comes after checkpoint creation)
|
||||
if isinstance(event, WorkflowStatusEvent) and "IDLE_WITH_PENDING_REQUESTS" in str(event.state):
|
||||
if event.type == "status" and "IDLE_WITH_PENDING_REQUESTS" in str(event.state):
|
||||
break
|
||||
|
||||
assert saw_request_event, "Test workflow should have emitted RequestInfoEvent"
|
||||
assert saw_request_event, "Test workflow should have emitted request_info event (type='request_info')"
|
||||
|
||||
# Verify checkpoint was AUTOMATICALLY saved to our storage by the framework
|
||||
checkpoints_after = await checkpoint_storage.list_checkpoints()
|
||||
|
||||
@@ -292,7 +292,7 @@ async def test_full_pipeline_workflow_events_are_json_serializable():
|
||||
"""CRITICAL TEST: Verify ALL events from workflow execution can be JSON serialized.
|
||||
|
||||
This is particularly important for workflows with AgentExecutor because:
|
||||
- AgentExecutor produces ExecutorCompletedEvent with AgentExecutorResponse
|
||||
- AgentExecutor produces executor_completed event (type='executor_completed') with AgentExecutorResponse
|
||||
- AgentExecutorResponse contains AgentResponse and ChatMessage objects
|
||||
- These are SerializationMixin objects, not Pydantic, which caused the original bug
|
||||
|
||||
@@ -672,10 +672,10 @@ async def test_full_pipeline_concurrent_workflow(concurrent_workflow):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_pipeline_workflow_output_event_serialization():
|
||||
"""Test that WorkflowOutputEvent from ctx.yield_output() serializes correctly.
|
||||
"""Test that output event (type='output') from ctx.yield_output() serializes correctly.
|
||||
|
||||
This tests the pattern where executors yield output via ctx.yield_output(),
|
||||
which emits WorkflowOutputEvent that DevUI must serialize for SSE.
|
||||
which emits output event (type='output') that DevUI must serialize for SSE.
|
||||
"""
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
|
||||
@@ -19,9 +19,8 @@ from agent_framework._types import (
|
||||
|
||||
# Import real workflow event classes - NOT mocks!
|
||||
from agent_framework._workflows._events import (
|
||||
ExecutorCompletedEvent,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
)
|
||||
|
||||
# Import factory functions from conftest for parameterized test data creation
|
||||
@@ -261,7 +260,7 @@ async def test_agent_run_response_mapping(mapper: MessageMapper, test_request: A
|
||||
|
||||
|
||||
async def test_executor_invoked_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test ExecutorInvokedEvent using the REAL class from agent_framework."""
|
||||
"""Test WorkflowEvent(type='executor_invoked') using the REAL class from agent_framework."""
|
||||
# Use real class, not mock!
|
||||
event = create_executor_invoked_event(executor_id="exec_123")
|
||||
|
||||
@@ -277,9 +276,9 @@ async def test_executor_invoked_event(mapper: MessageMapper, test_request: Agent
|
||||
|
||||
|
||||
async def test_executor_completed_event_simple_data(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test ExecutorCompletedEvent with simple dict data."""
|
||||
"""Test WorkflowEvent(type='executor_completed') with simple dict data."""
|
||||
# Create event with simple data
|
||||
event = ExecutorCompletedEvent(executor_id="exec_123", data={"simple": "result"})
|
||||
event = WorkflowEvent.executor_completed(executor_id="exec_123", data={"simple": "result"})
|
||||
|
||||
# First need to invoke the executor to set up context
|
||||
invoke_event = create_executor_invoked_event(executor_id="exec_123")
|
||||
@@ -301,10 +300,10 @@ async def test_executor_completed_event_simple_data(mapper: MessageMapper, test_
|
||||
async def test_executor_completed_event_with_agent_response(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""Test ExecutorCompletedEvent with nested AgentExecutorResponse.
|
||||
"""Test WorkflowEvent(type='executor_completed') with nested AgentExecutorResponse.
|
||||
|
||||
This is a REGRESSION TEST for the serialization bug where
|
||||
ExecutorCompletedEvent.data contained AgentExecutorResponse with nested
|
||||
WorkflowEvent.data contained AgentExecutorResponse with nested
|
||||
AgentResponse and ChatMessage objects (SerializationMixin) that
|
||||
Pydantic couldn't serialize.
|
||||
"""
|
||||
@@ -374,7 +373,7 @@ async def test_executor_completed_event_serialization_to_json(
|
||||
|
||||
|
||||
async def test_executor_failed_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test ExecutorFailedEvent using the REAL class."""
|
||||
"""Test WorkflowEvent(type='executor_failed') using the REAL class."""
|
||||
# First invoke the executor
|
||||
invoke_event = create_executor_invoked_event(executor_id="exec_fail")
|
||||
await mapper.convert_event(invoke_event, test_request)
|
||||
@@ -398,22 +397,21 @@ async def test_executor_failed_event(mapper: MessageMapper, test_request: AgentF
|
||||
|
||||
|
||||
async def test_workflow_started_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test WorkflowStartedEvent using the REAL class."""
|
||||
"""Test WorkflowEvent(type='started') using the REAL class."""
|
||||
|
||||
event = WorkflowStartedEvent(data=None)
|
||||
event = WorkflowEvent.started()
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# WorkflowStartedEvent should emit response.created and response.in_progress
|
||||
# WorkflowEvent(type='started') should emit response.created and response.in_progress
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "response.created"
|
||||
assert events[1].type == "response.in_progress"
|
||||
|
||||
|
||||
async def test_workflow_status_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test WorkflowStatusEvent using the REAL class."""
|
||||
from agent_framework._workflows._events import WorkflowRunState
|
||||
"""Test WorkflowEvent(type='status') using the REAL class."""
|
||||
|
||||
event = WorkflowStatusEvent(state=WorkflowRunState.IN_PROGRESS)
|
||||
event = WorkflowEvent.status(state=WorkflowRunState.IN_PROGRESS)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# Should emit some status-related event
|
||||
@@ -421,20 +419,20 @@ async def test_workflow_status_event(mapper: MessageMapper, test_request: AgentF
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Magentic Event Tests - Testing WorkflowOutputEvent with additional_properties
|
||||
# Magentic Event Tests - Testing WorkflowEvent[AgentResponseUpdate] with additional_properties
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def test_magentic_agent_run_update_event_with_agent_delta_metadata(
|
||||
async def test_magentic_executor_event_with_agent_delta_metadata(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""Test that WorkflowOutputEvent with magentic_event_type='agent_delta' is handled correctly.
|
||||
"""Test that WorkflowEvent[AgentResponseUpdate] with magentic_event_type='agent_delta' is handled correctly.
|
||||
|
||||
This tests the ACTUAL event format Magentic emits - not a fake MagenticAgentDeltaEvent class.
|
||||
Magentic uses WorkflowOutputEvent wrapping AgentResponseUpdate with additional_properties.
|
||||
Magentic uses WorkflowEvent.emit() with additional_properties containing magentic_event_type.
|
||||
"""
|
||||
from agent_framework._types import AgentResponseUpdate
|
||||
from agent_framework._workflows._events import WorkflowOutputEvent
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
# Create the REAL event format that Magentic emits
|
||||
update = AgentResponseUpdate(
|
||||
@@ -446,11 +444,11 @@ async def test_magentic_agent_run_update_event_with_agent_delta_metadata(
|
||||
"agent_id": "writer_agent",
|
||||
},
|
||||
)
|
||||
event = WorkflowOutputEvent(executor_id="magentic_executor", data=update)
|
||||
event = WorkflowEvent.emit(executor_id="magentic_executor", data=update)
|
||||
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# Should be treated as a regular WorkflowOutputEvent with text content
|
||||
# Should be treated as a regular WorkflowEvent[AgentResponseUpdate] with text content
|
||||
# The mapper should emit text delta events
|
||||
assert len(events) >= 1
|
||||
text_events = [e for e in events if getattr(e, "type", "") == "response.output_text.delta"]
|
||||
@@ -459,13 +457,13 @@ async def test_magentic_agent_run_update_event_with_agent_delta_metadata(
|
||||
|
||||
|
||||
async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test that WorkflowOutputEvent with magentic_event_type='orchestrator_message' is handled.
|
||||
"""Test that WorkflowEvent[AgentResponseUpdate] with magentic_event_type='orchestrator_message' is handled.
|
||||
|
||||
Magentic emits orchestrator planning/instruction messages using WorkflowOutputEvent
|
||||
wrapping AgentResponseUpdate with additional_properties.
|
||||
Magentic emits orchestrator planning/instruction messages using WorkflowEvent.emit()
|
||||
with additional_properties containing magentic_event_type='orchestrator_message'.
|
||||
"""
|
||||
from agent_framework._types import AgentResponseUpdate
|
||||
from agent_framework._workflows._events import WorkflowOutputEvent
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
# Create orchestrator message event (REAL format from Magentic)
|
||||
update = AgentResponseUpdate(
|
||||
@@ -478,11 +476,11 @@ async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_r
|
||||
"orchestrator_id": "magentic_orchestrator",
|
||||
},
|
||||
)
|
||||
event = WorkflowOutputEvent(executor_id="magentic_orchestrator", data=update)
|
||||
event = WorkflowEvent.emit(executor_id="magentic_orchestrator", data=update)
|
||||
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# Currently, mapper treats this as regular WorkflowOutputEvent (no special handling)
|
||||
# Currently, mapper treats this as regular WorkflowEvent[AgentResponseUpdate] (no special handling)
|
||||
# This test documents the current behavior
|
||||
assert len(events) >= 1
|
||||
text_events = [e for e in events if getattr(e, "type", "") == "response.output_text.delta"]
|
||||
@@ -493,15 +491,15 @@ async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_r
|
||||
async def test_magentic_events_use_same_event_class_as_other_workflows(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""Verify Magentic uses the same WorkflowOutputEvent class as other workflows.
|
||||
"""Verify Magentic uses the same WorkflowEvent class as other workflows.
|
||||
|
||||
This test documents that Magentic does NOT define separate event classes like
|
||||
MagenticAgentDeltaEvent - it reuses WorkflowOutputEvent with metadata in
|
||||
MagenticAgentDeltaEvent - it reuses WorkflowEvent with metadata in
|
||||
additional_properties. Any mapper code checking for 'MagenticAgentDeltaEvent'
|
||||
class names is dead code.
|
||||
"""
|
||||
from agent_framework._types import AgentResponseUpdate
|
||||
from agent_framework._workflows._events import WorkflowOutputEvent
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
# Create events the way different workflows do it
|
||||
# 1. Regular workflow (no additional_properties)
|
||||
@@ -509,7 +507,7 @@ async def test_magentic_events_use_same_event_class_as_other_workflows(
|
||||
contents=[Content.from_text(text="Regular workflow response")],
|
||||
role="assistant",
|
||||
)
|
||||
regular_event = WorkflowOutputEvent(executor_id="regular_executor", data=regular_update)
|
||||
regular_event = WorkflowEvent.emit(executor_id="regular_executor", data=regular_update)
|
||||
|
||||
# 2. Magentic workflow (with additional_properties)
|
||||
magentic_update = AgentResponseUpdate(
|
||||
@@ -517,12 +515,12 @@ async def test_magentic_events_use_same_event_class_as_other_workflows(
|
||||
role="assistant",
|
||||
additional_properties={"magentic_event_type": "agent_delta"},
|
||||
)
|
||||
magentic_event = WorkflowOutputEvent(executor_id="magentic_executor", data=magentic_update)
|
||||
magentic_event = WorkflowEvent.emit(executor_id="magentic_executor", data=magentic_update)
|
||||
|
||||
# Both should be the SAME class
|
||||
assert type(regular_event) is type(magentic_event)
|
||||
assert isinstance(regular_event, WorkflowOutputEvent)
|
||||
assert isinstance(magentic_event, WorkflowOutputEvent)
|
||||
assert isinstance(regular_event, WorkflowEvent)
|
||||
assert isinstance(magentic_event, WorkflowEvent)
|
||||
|
||||
# Both should be handled by the same isinstance check in mapper
|
||||
regular_events = await mapper.convert_event(regular_event, test_request)
|
||||
@@ -559,18 +557,18 @@ async def test_unknown_content_fallback(mapper: MessageMapper, test_request: Age
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WorkflowOutputEvent Tests
|
||||
# output event (type='output') Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def test_workflow_output_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test WorkflowOutputEvent is converted to output_item.added."""
|
||||
from agent_framework._workflows._events import WorkflowOutputEvent
|
||||
"""Test output event (type='output') is converted to output_item.added."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
event = WorkflowOutputEvent(data="Final workflow output", executor_id="final_executor")
|
||||
event = WorkflowEvent.output(executor_id="final_executor", data="Final workflow output")
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# WorkflowOutputEvent should emit output_item.added
|
||||
# output event (type='output') should emit output_item.added
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.output_item.added"
|
||||
# Check item contains the output text
|
||||
@@ -580,16 +578,16 @@ async def test_workflow_output_event(mapper: MessageMapper, test_request: AgentF
|
||||
|
||||
|
||||
async def test_workflow_output_event_with_list_data(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test WorkflowOutputEvent with list data (common for sequential/concurrent workflows)."""
|
||||
"""Test output event (type='output') with list data (common for sequential/concurrent workflows)."""
|
||||
from agent_framework import ChatMessage
|
||||
from agent_framework._workflows._events import WorkflowOutputEvent
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
# Sequential/Concurrent workflows often output list[ChatMessage]
|
||||
messages = [
|
||||
ChatMessage(role="user", contents=[Content.from_text(text="Hello")]),
|
||||
ChatMessage(role="assistant", contents=[Content.from_text(text="World")]),
|
||||
]
|
||||
event = WorkflowOutputEvent(data=messages, executor_id="complete")
|
||||
event = WorkflowEvent.output(executor_id="complete", data=messages)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
assert len(events) == 1
|
||||
@@ -597,23 +595,23 @@ async def test_workflow_output_event_with_list_data(mapper: MessageMapper, test_
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WorkflowFailedEvent Tests
|
||||
# failed event (type='failed') Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def test_workflow_failed_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test WorkflowFailedEvent is converted to response.failed."""
|
||||
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowFailedEvent
|
||||
"""Test failed event (type='failed') is converted to response.failed."""
|
||||
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowEvent
|
||||
|
||||
details = WorkflowErrorDetails(
|
||||
error_type="TestError",
|
||||
message="Workflow failed due to test error",
|
||||
executor_id="failing_executor",
|
||||
)
|
||||
event = WorkflowFailedEvent(details=details)
|
||||
event = WorkflowEvent.failed(details=details)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# WorkflowFailedEvent should emit response.failed
|
||||
# failed event (type='failed') should emit response.failed
|
||||
assert len(events) >= 1
|
||||
# Find the failed event
|
||||
failed_events = [e for e in events if getattr(e, "type", "") == "response.failed"]
|
||||
@@ -628,8 +626,8 @@ async def test_workflow_failed_event(mapper: MessageMapper, test_request: AgentF
|
||||
|
||||
|
||||
async def test_workflow_failed_event_with_extra(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test WorkflowFailedEvent includes extra context when available."""
|
||||
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowFailedEvent
|
||||
"""Test failed event (type='failed') includes extra context when available."""
|
||||
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowEvent
|
||||
|
||||
details = WorkflowErrorDetails(
|
||||
error_type="ValidationError",
|
||||
@@ -637,7 +635,7 @@ async def test_workflow_failed_event_with_extra(mapper: MessageMapper, test_requ
|
||||
executor_id="validation_executor",
|
||||
extra={"field": "email", "reason": "invalid format"},
|
||||
)
|
||||
event = WorkflowFailedEvent(details=details)
|
||||
event = WorkflowEvent.failed(details=details)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
assert len(events) == 1
|
||||
@@ -650,8 +648,8 @@ async def test_workflow_failed_event_with_extra(mapper: MessageMapper, test_requ
|
||||
|
||||
|
||||
async def test_workflow_failed_event_with_traceback(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test WorkflowFailedEvent includes traceback when available."""
|
||||
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowFailedEvent
|
||||
"""Test failed event (type='failed') includes traceback when available."""
|
||||
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowEvent
|
||||
|
||||
details = WorkflowErrorDetails(
|
||||
error_type="ValueError",
|
||||
@@ -659,7 +657,7 @@ async def test_workflow_failed_event_with_traceback(mapper: MessageMapper, test_
|
||||
traceback="Traceback (most recent call last):\n File ...\nValueError: Invalid input",
|
||||
executor_id="validation_executor",
|
||||
)
|
||||
event = WorkflowFailedEvent(details=details)
|
||||
event = WorkflowEvent.failed(details=details)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
assert len(events) == 1
|
||||
@@ -672,41 +670,41 @@ async def test_workflow_failed_event_with_traceback(mapper: MessageMapper, test_
|
||||
|
||||
|
||||
async def test_workflow_warning_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test WorkflowWarningEvent is converted to trace event."""
|
||||
from agent_framework._workflows._events import WorkflowWarningEvent
|
||||
"""Test WorkflowEvent(type='warning') is converted to trace event."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
event = WorkflowWarningEvent(data="This is a warning message")
|
||||
event = WorkflowEvent.warning("This is a warning message")
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# WorkflowWarningEvent should emit a trace event
|
||||
# WorkflowEvent(type='warning') should emit a trace event
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.trace.completed"
|
||||
assert events[0].data["event_type"] == "WorkflowWarningEvent"
|
||||
assert events[0].data["event_type"] == "warning"
|
||||
|
||||
|
||||
async def test_workflow_error_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test WorkflowErrorEvent is converted to trace event."""
|
||||
from agent_framework._workflows._events import WorkflowErrorEvent
|
||||
"""Test WorkflowEvent(type='error') is converted to trace event."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
event = WorkflowErrorEvent(data=ValueError("Something went wrong"))
|
||||
event = WorkflowEvent.error(ValueError("Something went wrong"))
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# WorkflowErrorEvent should emit a trace event
|
||||
# WorkflowEvent(type='error') should emit a trace event
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.trace.completed"
|
||||
assert events[0].data["event_type"] == "WorkflowErrorEvent"
|
||||
assert events[0].data["event_type"] == "error"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RequestInfoEvent Tests (Human-in-the-Loop)
|
||||
# request_info event (type='request_info') Tests (Human-in-the-Loop)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def test_request_info_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test RequestInfoEvent is converted to HIL request event."""
|
||||
from agent_framework._workflows._events import RequestInfoEvent
|
||||
"""Test request_info event (type='request_info') is converted to HIL request event."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
event = RequestInfoEvent(
|
||||
event = WorkflowEvent.request_info(
|
||||
request_id="req_123",
|
||||
source_executor_id="approval_executor",
|
||||
request_data={"action": "approve", "details": "Please approve this action"},
|
||||
@@ -714,7 +712,7 @@ async def test_request_info_event(mapper: MessageMapper, test_request: AgentFram
|
||||
)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# RequestInfoEvent should emit response.request_info.requested
|
||||
# request_info event (type='request_info') should emit response.request_info.requested
|
||||
assert len(events) >= 1
|
||||
# Check that request info is captured
|
||||
has_hil_event = any(getattr(e, "type", "") == "response.request_info.requested" for e in events)
|
||||
@@ -732,24 +730,24 @@ async def test_request_info_event(mapper: MessageMapper, test_request: AgentFram
|
||||
|
||||
|
||||
async def test_superstep_started_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test SuperStepStartedEvent is handled gracefully."""
|
||||
from agent_framework._workflows._events import SuperStepStartedEvent
|
||||
"""Test superstep_started event (type='superstep_started') is handled gracefully."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
event = SuperStepStartedEvent(iteration=1)
|
||||
event = WorkflowEvent.superstep_started(iteration=1)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# SuperStepStartedEvent may not emit events (internal workflow signal)
|
||||
# superstep_started event (type='superstep_started') may not emit events (internal workflow signal)
|
||||
# Just ensure it doesn't crash
|
||||
assert isinstance(events, list)
|
||||
|
||||
|
||||
async def test_superstep_completed_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test SuperStepCompletedEvent is handled gracefully."""
|
||||
from agent_framework._workflows._events import SuperStepCompletedEvent
|
||||
"""Test superstep_completed event (type='superstep_completed') is handled gracefully."""
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
|
||||
event = SuperStepCompletedEvent(iteration=1)
|
||||
event = WorkflowEvent.superstep_completed(iteration=1)
|
||||
events = await mapper.convert_event(event, test_request)
|
||||
|
||||
# SuperStepCompletedEvent may not emit events (internal workflow signal)
|
||||
# superstep_completed event (type='superstep_completed') may not emit events (internal workflow signal)
|
||||
# Just ensure it doesn't crash
|
||||
assert isinstance(events, list)
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
|
||||
agentlightning = pytest.importorskip("agentlightning")
|
||||
|
||||
from agent_framework import AgentExecutor, ChatAgent, WorkflowBuilder, Workflow, WorkflowOutputEvent
|
||||
from agent_framework import AgentExecutor, AgentResponse, ChatAgent, WorkflowBuilder, Workflow
|
||||
from agent_framework_lab_lightning import AgentFrameworkTracer
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agentlightning import TracerTraceToTriplet
|
||||
@@ -109,8 +109,8 @@ def workflow_two_agents():
|
||||
async def test_openai_workflow_two_agents(workflow_two_agents: Workflow):
|
||||
events = await workflow_two_agents.run("Please analyze the quarterly sales data")
|
||||
|
||||
# Get all WorkflowOutputEvent data
|
||||
agent_outputs = [event.data for event in events if isinstance(event, WorkflowOutputEvent)]
|
||||
# Get all output events with AgentResponse
|
||||
agent_outputs = [event.data for event in events if event.type == "output" and isinstance(event.data, AgentResponse)]
|
||||
|
||||
# Check that we have outputs from both agents
|
||||
assert len(agent_outputs) == 2
|
||||
|
||||
+28
-48
@@ -61,48 +61,22 @@ GroupChatWorkflowContextOutT: TypeAlias = AgentExecutorRequest | GroupChatReques
|
||||
|
||||
|
||||
# region Group chat events
|
||||
class GroupChatEvent(WorkflowEvent):
|
||||
"""Base class for group chat workflow events."""
|
||||
|
||||
def __init__(self, round_index: int, data: Any | None = None) -> None:
|
||||
"""Initialize group chat event.
|
||||
|
||||
Args:
|
||||
round_index: Current round index
|
||||
data: Optional event-specific data
|
||||
"""
|
||||
super().__init__(data)
|
||||
self.round_index = round_index
|
||||
|
||||
|
||||
class GroupChatResponseReceivedEvent(GroupChatEvent):
|
||||
"""Event emitted when a participant response is received."""
|
||||
@dataclass
|
||||
class GroupChatRequestSentEvent:
|
||||
"""Data payload for group_chat request sent events."""
|
||||
|
||||
def __init__(self, round_index: int, participant_name: str, data: Any | None = None) -> None:
|
||||
"""Initialize response received event.
|
||||
|
||||
Args:
|
||||
round_index: Current round index
|
||||
participant_name: Name of the participant who sent the response
|
||||
data: Optional event-specific data
|
||||
"""
|
||||
super().__init__(round_index, data)
|
||||
self.participant_name = participant_name
|
||||
round_index: int
|
||||
participant_name: str
|
||||
|
||||
|
||||
class GroupChatRequestSentEvent(GroupChatEvent):
|
||||
"""Event emitted when a request is sent to a participant."""
|
||||
@dataclass
|
||||
class GroupChatResponseReceivedEvent:
|
||||
"""Data payload for group_chat response received events."""
|
||||
|
||||
def __init__(self, round_index: int, participant_name: str, data: Any | None = None) -> None:
|
||||
"""Initialize request sent event.
|
||||
|
||||
Args:
|
||||
round_index: Current round index
|
||||
participant_name: Name of the participant to whom the request was sent
|
||||
data: Optional event-specific data
|
||||
"""
|
||||
super().__init__(round_index, data)
|
||||
self.participant_name = participant_name
|
||||
round_index: int
|
||||
participant_name: str
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -273,10 +247,12 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
ctx: Workflow context
|
||||
"""
|
||||
await ctx.add_event(
|
||||
GroupChatResponseReceivedEvent(
|
||||
round_index=self._round_index,
|
||||
participant_name=ctx.source_executor_ids[0] if ctx.source_executor_ids else "unknown",
|
||||
data=response,
|
||||
WorkflowEvent(
|
||||
"group_chat",
|
||||
data=GroupChatResponseReceivedEvent(
|
||||
round_index=self._round_index,
|
||||
participant_name=ctx.source_executor_ids[0] if ctx.source_executor_ids else "unknown",
|
||||
),
|
||||
)
|
||||
)
|
||||
await self._handle_response(response, ctx)
|
||||
@@ -469,10 +445,12 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
request = AgentExecutorRequest(messages=messages, should_respond=True)
|
||||
await ctx.send_message(request, target_id=target)
|
||||
await ctx.add_event(
|
||||
GroupChatRequestSentEvent(
|
||||
round_index=self._round_index,
|
||||
participant_name=target,
|
||||
data=request,
|
||||
WorkflowEvent(
|
||||
"group_chat",
|
||||
data=GroupChatRequestSentEvent(
|
||||
round_index=self._round_index,
|
||||
participant_name=target,
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -480,10 +458,12 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
request = GroupChatRequestMessage(additional_instruction=additional_instruction, metadata=metadata) # type: ignore[assignment]
|
||||
await ctx.send_message(request, target_id=target)
|
||||
await ctx.add_event(
|
||||
GroupChatRequestSentEvent(
|
||||
round_index=self._round_index,
|
||||
participant_name=target,
|
||||
data=request,
|
||||
WorkflowEvent(
|
||||
"group_chat",
|
||||
data=GroupChatRequestSentEvent(
|
||||
round_index=self._round_index,
|
||||
participant_name=target,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -463,9 +463,9 @@ class ConcurrentBuilder:
|
||||
) -> "ConcurrentBuilder":
|
||||
"""Enable request info after agent participant responses.
|
||||
|
||||
This enables human-in-the-loop (HIL) scenarios for the sequential orchestration.
|
||||
This enables human-in-the-loop (HIL) scenarios for the concurrent orchestration.
|
||||
When enabled, the workflow pauses after each agent participant runs, emitting
|
||||
a RequestInfoEvent that allows the caller to review the conversation and optionally
|
||||
a request_info event (type='request_info') that allows the caller to review the conversation and optionally
|
||||
inject guidance for the agent participant to iterate. The caller provides input via
|
||||
the standard response_handler/request_info pattern.
|
||||
|
||||
|
||||
@@ -866,7 +866,7 @@ class GroupChatBuilder:
|
||||
|
||||
This enables human-in-the-loop (HIL) scenarios for the group chat orchestration.
|
||||
When enabled, the workflow pauses after each agent participant runs, emitting
|
||||
a RequestInfoEvent that allows the caller to review the conversation and optionally
|
||||
a request_info event (type='request_info') that allows the caller to review the conversation and optionally
|
||||
inject guidance for the agent participant to iterate. The caller provides input via
|
||||
the standard response_handler/request_info pattern.
|
||||
|
||||
|
||||
@@ -64,20 +64,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# region Handoff events
|
||||
class HandoffSentEvent(WorkflowEvent):
|
||||
"""Base class for handoff workflow events."""
|
||||
|
||||
def __init__(self, source: str, target: str, data: Any | None = None) -> None:
|
||||
"""Initialize handoff sent event.
|
||||
|
||||
Args:
|
||||
source: Identifier of the source agent initiating the handoff
|
||||
target: Identifier of the target agent receiving the handoff
|
||||
data: Optional event-specific data
|
||||
"""
|
||||
super().__init__(data)
|
||||
self.source = source
|
||||
self.target = target
|
||||
@dataclass
|
||||
class HandoffSentEvent:
|
||||
"""Data payload for handoff_sent events."""
|
||||
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -421,7 +415,9 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
await cast(WorkflowContext[AgentExecutorRequest], ctx).send_message(
|
||||
AgentExecutorRequest(messages=[], should_respond=True), target_id=handoff_target
|
||||
)
|
||||
await ctx.add_event(HandoffSentEvent(source=self.id, target=handoff_target))
|
||||
await ctx.add_event(
|
||||
WorkflowEvent("handoff_sent", data=HandoffSentEvent(source=self.id, target=handoff_target))
|
||||
)
|
||||
self._autonomous_mode_turns = 0 # Reset autonomous mode turn counter on handoff
|
||||
return
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
|
||||
from agent_framework._workflows._checkpoint import CheckpointStorage
|
||||
from agent_framework._workflows._events import ExecutorEvent
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
from agent_framework._workflows._executor import Executor, handler
|
||||
from agent_framework._workflows._model_utils import DictConvertible, encode_value
|
||||
from agent_framework._workflows._request_info_mixin import response_handler
|
||||
@@ -771,20 +771,11 @@ class MagenticOrchestratorEventType(str, Enum):
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagenticOrchestratorEvent(ExecutorEvent):
|
||||
"""Base class for Magentic orchestrator events."""
|
||||
class MagenticOrchestratorEvent:
|
||||
"""Data payload for magentic_orchestrator events."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor_id: str,
|
||||
event_type: MagenticOrchestratorEventType,
|
||||
data: ChatMessage | MagenticProgressLedger,
|
||||
) -> None:
|
||||
super().__init__(executor_id, data)
|
||||
self.event_type = event_type
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id}, event_type={self.event_type})"
|
||||
event_type: MagenticOrchestratorEventType
|
||||
content: ChatMessage | MagenticProgressLedger
|
||||
|
||||
|
||||
# region Request info related types
|
||||
@@ -928,10 +919,13 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator):
|
||||
# Initial planning using the manager with real model calls
|
||||
self._task_ledger = await self._manager.plan(self._magentic_context.clone(deep=True))
|
||||
await ctx.add_event(
|
||||
MagenticOrchestratorEvent(
|
||||
WorkflowEvent(
|
||||
"magentic_orchestrator",
|
||||
executor_id=self.id,
|
||||
event_type=MagenticOrchestratorEventType.PLAN_CREATED,
|
||||
data=self._task_ledger,
|
||||
data=MagenticOrchestratorEvent(
|
||||
event_type=MagenticOrchestratorEventType.PLAN_CREATED,
|
||||
content=self._task_ledger,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1006,10 +1000,13 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator):
|
||||
self._magentic_context.chat_history.extend(response.review)
|
||||
self._task_ledger = await self._manager.replan(self._magentic_context.clone(deep=True))
|
||||
await ctx.add_event(
|
||||
MagenticOrchestratorEvent(
|
||||
WorkflowEvent(
|
||||
"magentic_orchestrator",
|
||||
executor_id=self.id,
|
||||
event_type=MagenticOrchestratorEventType.REPLANNED,
|
||||
data=self._task_ledger,
|
||||
data=MagenticOrchestratorEvent(
|
||||
event_type=MagenticOrchestratorEventType.REPLANNED,
|
||||
content=self._task_ledger,
|
||||
),
|
||||
)
|
||||
)
|
||||
# Continue the review process by sending the new plan for review again until approved
|
||||
@@ -1072,10 +1069,13 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator):
|
||||
return
|
||||
|
||||
await ctx.add_event(
|
||||
MagenticOrchestratorEvent(
|
||||
WorkflowEvent(
|
||||
"magentic_orchestrator",
|
||||
executor_id=self.id,
|
||||
event_type=MagenticOrchestratorEventType.PROGRESS_LEDGER_UPDATED,
|
||||
data=self._progress_ledger,
|
||||
data=MagenticOrchestratorEvent(
|
||||
event_type=MagenticOrchestratorEventType.PROGRESS_LEDGER_UPDATED,
|
||||
content=self._progress_ledger,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1149,10 +1149,13 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator):
|
||||
# Replan
|
||||
self._task_ledger = await self._manager.replan(self._magentic_context.clone(deep=True))
|
||||
await ctx.add_event(
|
||||
MagenticOrchestratorEvent(
|
||||
WorkflowEvent(
|
||||
"magentic_orchestrator",
|
||||
executor_id=self.id,
|
||||
event_type=MagenticOrchestratorEventType.REPLANNED,
|
||||
data=self._task_ledger,
|
||||
data=MagenticOrchestratorEvent(
|
||||
event_type=MagenticOrchestratorEventType.REPLANNED,
|
||||
content=self._task_ledger,
|
||||
),
|
||||
)
|
||||
)
|
||||
# If a human must sign off, ask now and return. The response handler will resume.
|
||||
@@ -1515,7 +1518,7 @@ class MagenticBuilder:
|
||||
|
||||
# During execution, handle plan review
|
||||
async for event in workflow.run("task", stream=True):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if event.type == "request_info":
|
||||
request = event.data
|
||||
if isinstance(request, MagenticHumanInterventionRequest):
|
||||
if request.kind == MagenticHumanInterventionKind.PLAN_REVIEW:
|
||||
|
||||
+3
-1
@@ -6,6 +6,8 @@ Provides OrchestrationState dataclass for standardized checkpoint serialization
|
||||
across GroupChat, Handoff, and Magentic patterns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -69,7 +71,7 @@ class OrchestrationState:
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OrchestrationState":
|
||||
def from_dict(cls, data: dict[str, Any]) -> OrchestrationState:
|
||||
"""Deserialize from checkpointed dict.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -219,7 +219,7 @@ class SequentialBuilder:
|
||||
|
||||
This enables human-in-the-loop (HIL) scenarios for the sequential orchestration.
|
||||
When enabled, the workflow pauses after each agent participant runs, emitting
|
||||
a RequestInfoEvent that allows the caller to review the conversation and optionally
|
||||
a request_info event (type='request_info') that allows the caller to review the conversation and optionally
|
||||
inject guidance for the agent participant to iterate. The caller provides input via
|
||||
the standard response_handler/request_info pattern.
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
Executor,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
@@ -111,9 +109,9 @@ async def test_concurrent_default_aggregator_emits_single_user_and_assistants()
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("prompt: hello world", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = cast(list[ChatMessage], ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -149,9 +147,9 @@ async def test_concurrent_custom_aggregator_callback_is_used() -> None:
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run("prompt: custom", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -180,9 +178,9 @@ async def test_concurrent_custom_aggregator_sync_callback_is_used() -> None:
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run("prompt: custom sync", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -228,9 +226,9 @@ async def test_concurrent_with_aggregator_executor_instance() -> None:
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run("prompt: instance test", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -266,9 +264,9 @@ async def test_concurrent_with_aggregator_executor_factory() -> None:
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run("prompt: factory test", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -302,9 +300,9 @@ async def test_concurrent_with_aggregator_executor_factory_with_default_id() ->
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run("prompt: factory test", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -352,9 +350,9 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None:
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("checkpoint concurrent", stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_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 baseline_output is not None
|
||||
@@ -376,9 +374,9 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None:
|
||||
|
||||
resumed_output: list[ChatMessage] | None = None
|
||||
async for ev in wf_resume.run(checkpoint_id=resume_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,
|
||||
):
|
||||
@@ -398,9 +396,9 @@ async def test_concurrent_checkpoint_runtime_only() -> None:
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_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 baseline_output is not None
|
||||
@@ -421,9 +419,9 @@ async def test_concurrent_checkpoint_runtime_only() -> None:
|
||||
async for ev in wf_resume.run(
|
||||
checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, 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,
|
||||
):
|
||||
@@ -448,9 +446,9 @@ async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_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 baseline_output is not None
|
||||
@@ -530,9 +528,9 @@ async def test_concurrent_with_register_participants() -> None:
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("test prompt", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = cast(list[ChatMessage], ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
@@ -15,10 +15,8 @@ from agent_framework import (
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
RequestInfoEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
from agent_framework.orchestrations import (
|
||||
@@ -190,7 +188,7 @@ async def test_group_chat_builder_basic_flow() -> None:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run("coordinate task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -362,7 +360,7 @@ class TestGroupChatWorkflow:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run("test task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -397,7 +395,7 @@ class TestGroupChatWorkflow:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run("test task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -425,7 +423,7 @@ class TestGroupChatWorkflow:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run("test task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -473,7 +471,7 @@ class TestCheckpointing:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run("test task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -526,7 +524,7 @@ class TestConversationHandling:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run("test string", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -555,7 +553,7 @@ class TestConversationHandling:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run(task_message, stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -587,7 +585,7 @@ class TestConversationHandling:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run(conversation, stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -619,7 +617,7 @@ class TestRoundLimitEnforcement:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run("test", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -654,7 +652,7 @@ class TestRoundLimitEnforcement:
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run("test", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
@@ -686,9 +684,9 @@ async def test_group_chat_checkpoint_runtime_only() -> None:
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None # type: ignore
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
|
||||
if ev.type == "status" and ev.state in (
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
):
|
||||
@@ -724,9 +722,9 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
)
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None # type: ignore
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
|
||||
if ev.type == "status" and ev.state in (
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
):
|
||||
@@ -770,9 +768,9 @@ async def test_group_chat_with_request_info_filtering():
|
||||
)
|
||||
|
||||
# Run until we get a request info event (should be before beta, not alpha)
|
||||
request_events: list[RequestInfoEvent] = []
|
||||
request_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("test task", stream=True):
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentExecutorResponse):
|
||||
if event.type == "request_info" and isinstance(event.data, AgentExecutorResponse):
|
||||
request_events.append(event)
|
||||
# Don't break - let stream complete naturally when paused
|
||||
|
||||
@@ -785,11 +783,11 @@ async def test_group_chat_with_request_info_filtering():
|
||||
assert request_event.source_executor_id == "beta"
|
||||
|
||||
# Continue the workflow with a response
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
outputs: list[WorkflowEvent] = []
|
||||
async for event in workflow.send_responses_streaming({
|
||||
request_event.request_id: AgentRequestInfoResponse.approve()
|
||||
}):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
outputs.append(event)
|
||||
|
||||
# Workflow should complete
|
||||
@@ -822,9 +820,9 @@ async def test_group_chat_with_request_info_no_filter_pauses_all():
|
||||
)
|
||||
|
||||
# Run until we get a request info event
|
||||
request_events: list[RequestInfoEvent] = []
|
||||
request_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("test task", stream=True):
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentExecutorResponse):
|
||||
if event.type == "request_info" and isinstance(event.data, AgentExecutorResponse):
|
||||
request_events.append(event)
|
||||
break
|
||||
|
||||
@@ -926,9 +924,9 @@ async def test_group_chat_with_participant_factories():
|
||||
# Factories should be called during build
|
||||
assert call_count == 2
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
outputs: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("coordinate task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
outputs.append(event)
|
||||
|
||||
assert len(outputs) == 1
|
||||
@@ -991,9 +989,9 @@ async def test_group_chat_participant_factories_with_checkpointing():
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
outputs: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("checkpoint test", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
outputs.append(event)
|
||||
|
||||
assert outputs, "Should have workflow output"
|
||||
@@ -1119,9 +1117,9 @@ async def test_group_chat_with_orchestrator_factory_returning_chat_agent():
|
||||
# Factory should be called during build
|
||||
assert factory_call_count == 1
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
outputs: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("coordinate task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
outputs.append(event)
|
||||
|
||||
assert len(outputs) == 1
|
||||
|
||||
@@ -11,10 +11,8 @@ from agent_framework import (
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
RequestInfoEvent,
|
||||
ResponseStream,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
resolve_agent_id,
|
||||
)
|
||||
from agent_framework._clients import BaseChatClient
|
||||
@@ -150,7 +148,7 @@ async def test_handoff():
|
||||
# escalation won't trigger a handoff, so the response from it will become
|
||||
# a request for user input because autonomous mode is not enabled by default.
|
||||
events = await _drain(workflow.run("Need technical support", stream=True))
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
requests = [ev for ev in events if ev.type == "request_info"]
|
||||
|
||||
assert requests
|
||||
assert len(requests) == 1
|
||||
@@ -184,10 +182,10 @@ async def test_autonomous_mode_yields_output_without_user_request():
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run("Package arrived broken", stream=True))
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
requests = [ev for ev in events if ev.type == "request_info"]
|
||||
assert not requests, "Autonomous mode should not request additional user input"
|
||||
|
||||
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
|
||||
outputs = [ev for ev in events if ev.type == "output"]
|
||||
assert outputs, "Autonomous mode should yield a workflow output"
|
||||
|
||||
final_conversation = outputs[-1].data
|
||||
@@ -210,7 +208,7 @@ async def test_autonomous_mode_resumes_user_input_on_turn_limit():
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run("Start", stream=True))
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
requests = [ev for ev in events if ev.type == "request_info"]
|
||||
assert requests and len(requests) == 1, "Turn limit should force a user input request"
|
||||
assert requests[0].source_executor_id == worker.name
|
||||
|
||||
@@ -253,7 +251,7 @@ async def test_handoff_async_termination_condition() -> None:
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run("First user message", stream=True))
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
requests = [ev for ev in events if ev.type == "request_info"]
|
||||
assert requests
|
||||
|
||||
events = await _drain(
|
||||
@@ -261,7 +259,7 @@ async def test_handoff_async_termination_condition() -> None:
|
||||
requests[-1].request_id: [ChatMessage(role="user", text="Second user message")]
|
||||
})
|
||||
)
|
||||
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
|
||||
outputs = [ev for ev in events if ev.type == "output"]
|
||||
assert len(outputs) == 1
|
||||
|
||||
final_conversation = outputs[0].data
|
||||
@@ -505,14 +503,14 @@ async def test_handoff_with_participant_factories():
|
||||
assert call_count == 2
|
||||
|
||||
events = await _drain(workflow.run("Need help", stream=True))
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
requests = [ev for ev in events if ev.type == "request_info"]
|
||||
assert requests
|
||||
|
||||
# Follow-up message
|
||||
events = await _drain(
|
||||
workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage(role="user", text="More details")]})
|
||||
)
|
||||
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
|
||||
outputs = [ev for ev in events if ev.type == "output"]
|
||||
assert outputs
|
||||
|
||||
|
||||
@@ -576,7 +574,7 @@ async def test_handoff_with_participant_factories_and_add_handoff():
|
||||
|
||||
# Start conversation - triage hands off to specialist_a
|
||||
events = await _drain(workflow.run("Initial request", stream=True))
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
requests = [ev for ev in events if ev.type == "request_info"]
|
||||
assert requests
|
||||
|
||||
# Verify specialist_a executor exists and was called
|
||||
@@ -586,7 +584,7 @@ async def test_handoff_with_participant_factories_and_add_handoff():
|
||||
events = await _drain(
|
||||
workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage(role="user", text="Need escalation")]})
|
||||
)
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
requests = [ev for ev in events if ev.type == "request_info"]
|
||||
assert requests
|
||||
|
||||
# Verify specialist_b executor exists
|
||||
@@ -615,13 +613,13 @@ async def test_handoff_participant_factories_with_checkpointing():
|
||||
|
||||
# Run workflow and capture output
|
||||
events = await _drain(workflow.run("checkpoint test", stream=True))
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
requests = [ev for ev in events if ev.type == "request_info"]
|
||||
assert requests
|
||||
|
||||
events = await _drain(
|
||||
workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage(role="user", text="follow up")]})
|
||||
)
|
||||
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
|
||||
outputs = [ev for ev in events if ev.type == "output"]
|
||||
assert outputs, "Should have workflow output after termination condition is met"
|
||||
|
||||
# List checkpoints - just verify they were created
|
||||
@@ -693,7 +691,7 @@ async def test_handoff_participant_factories_autonomous_mode():
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run("Issue", stream=True))
|
||||
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
|
||||
requests = [ev for ev in events if ev.type == "request_info"]
|
||||
assert requests and len(requests) == 1
|
||||
assert requests[0].source_executor_id == "specialist"
|
||||
|
||||
|
||||
@@ -15,15 +15,12 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
Content,
|
||||
Executor,
|
||||
RequestInfoEvent,
|
||||
Workflow,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
@@ -33,7 +30,6 @@ from agent_framework.orchestrations import (
|
||||
MagenticContext,
|
||||
MagenticManagerBase,
|
||||
MagenticOrchestrator,
|
||||
MagenticOrchestratorEvent,
|
||||
MagenticPlanReviewRequest,
|
||||
MagenticProgressLedger,
|
||||
MagenticProgressLedgerItem,
|
||||
@@ -197,11 +193,11 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None:
|
||||
outputs: list[ChatMessage] = []
|
||||
orchestrator_event_count = 0
|
||||
async for event in workflow.run("compose summary", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
msg = event.data
|
||||
if isinstance(msg, list):
|
||||
outputs.extend(cast(list[ChatMessage], msg))
|
||||
elif isinstance(event, MagenticOrchestratorEvent):
|
||||
elif event.type == "magentic_orchestrator":
|
||||
orchestrator_event_count += 1
|
||||
|
||||
assert outputs, "Expected a final output message"
|
||||
@@ -246,9 +242,9 @@ async def test_magentic_workflow_plan_review_approval_to_completion():
|
||||
manager = FakeManager()
|
||||
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_manager(manager=manager).with_plan_review().build()
|
||||
|
||||
req_event: RequestInfoEvent | None = None
|
||||
req_event: WorkflowEvent | None = None
|
||||
async for ev in wf.run("do work", stream=True):
|
||||
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
|
||||
if ev.type == "request_info" and ev.request_type is MagenticPlanReviewRequest:
|
||||
req_event = ev
|
||||
assert req_event is not None
|
||||
assert isinstance(req_event.data, MagenticPlanReviewRequest)
|
||||
@@ -256,9 +252,9 @@ async def test_magentic_workflow_plan_review_approval_to_completion():
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.send_responses_streaming(responses={req_event.request_id: req_event.data.approve()}):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = ev.data # type: ignore[assignment]
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -291,9 +287,9 @@ async def test_magentic_plan_review_with_revise():
|
||||
)
|
||||
|
||||
# Wait for the initial plan review request
|
||||
req_event: RequestInfoEvent | None = None
|
||||
req_event: WorkflowEvent | None = None
|
||||
async for ev in wf.run("do work", stream=True):
|
||||
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
|
||||
if ev.type == "request_info" and ev.request_type is MagenticPlanReviewRequest:
|
||||
req_event = ev
|
||||
assert req_event is not None
|
||||
assert isinstance(req_event.data, MagenticPlanReviewRequest)
|
||||
@@ -304,7 +300,7 @@ async def test_magentic_plan_review_with_revise():
|
||||
async for ev in wf.send_responses_streaming(
|
||||
responses={req_event.request_id: req_event.data.revise("Looks good; consider Z")}
|
||||
):
|
||||
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
|
||||
if ev.type == "request_info" and ev.request_type is MagenticPlanReviewRequest:
|
||||
saw_second_review = True
|
||||
req_event = ev
|
||||
|
||||
@@ -312,7 +308,7 @@ async def test_magentic_plan_review_with_revise():
|
||||
async for ev in wf.send_responses_streaming(
|
||||
responses={req_event.request_id: req_event.data.approve()} # type: ignore[union-attr]
|
||||
):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
break
|
||||
|
||||
@@ -339,12 +335,12 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result():
|
||||
events.append(ev)
|
||||
|
||||
idle_status = next(
|
||||
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE),
|
||||
(e for e in events if e.type == "status" and e.state == WorkflowRunState.IDLE),
|
||||
None,
|
||||
)
|
||||
assert idle_status is not None
|
||||
# Check that we got workflow output via WorkflowOutputEvent
|
||||
output_event = next((e for e in events if isinstance(e, WorkflowOutputEvent)), None)
|
||||
# Check that we got workflow output via WorkflowEvent with type "output"
|
||||
output_event = next((e for e in events if e.type == "output"), None)
|
||||
assert output_event is not None
|
||||
data = output_event.data
|
||||
assert isinstance(data, list)
|
||||
@@ -367,9 +363,9 @@ async def test_magentic_checkpoint_resume_round_trip():
|
||||
)
|
||||
|
||||
task_text = "checkpoint task"
|
||||
req_event: RequestInfoEvent | None = None
|
||||
req_event: WorkflowEvent | None = None
|
||||
async for ev in wf.run(task_text, stream=True):
|
||||
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
|
||||
if ev.type == "request_info" and ev.request_type is MagenticPlanReviewRequest:
|
||||
req_event = ev
|
||||
assert req_event is not None
|
||||
assert isinstance(req_event.data, MagenticPlanReviewRequest)
|
||||
@@ -389,20 +385,20 @@ async def test_magentic_checkpoint_resume_round_trip():
|
||||
.build()
|
||||
)
|
||||
|
||||
completed: WorkflowOutputEvent | None = None
|
||||
completed: WorkflowEvent | None = None
|
||||
req_event = None
|
||||
async for event in wf_resume.run(
|
||||
resume_checkpoint.checkpoint_id,
|
||||
stream=True,
|
||||
):
|
||||
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
|
||||
if event.type == "request_info" and event.request_type is MagenticPlanReviewRequest:
|
||||
req_event = event
|
||||
assert req_event is not None
|
||||
assert isinstance(req_event.data, MagenticPlanReviewRequest)
|
||||
|
||||
responses = {req_event.request_id: req_event.data.approve()}
|
||||
async for event in wf_resume.send_responses_streaming(responses=responses):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
completed = event
|
||||
assert completed is not None
|
||||
|
||||
@@ -595,7 +591,8 @@ async def _collect_agent_responses_setup(participant: AgentProtocol) -> list[Cha
|
||||
events: list[WorkflowEvent] = []
|
||||
async for ev in wf.run("task", stream=True): # plan review disabled
|
||||
events.append(ev)
|
||||
if isinstance(ev, WorkflowOutputEvent) and isinstance(ev.data, AgentResponseUpdate):
|
||||
# Capture streaming updates (type="output" with AgentResponseUpdate data)
|
||||
if ev.type == "output" and isinstance(ev.data, AgentResponseUpdate):
|
||||
captured.append(
|
||||
ChatMessage(
|
||||
role=ev.data.role or "assistant",
|
||||
@@ -603,6 +600,9 @@ async def _collect_agent_responses_setup(participant: AgentProtocol) -> list[Cha
|
||||
author_name=ev.data.author_name,
|
||||
)
|
||||
)
|
||||
# Break on final AgentResponse output
|
||||
elif ev.type == "output" and isinstance(ev.data, AgentResponse):
|
||||
break
|
||||
|
||||
return captured
|
||||
|
||||
@@ -640,7 +640,7 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep():
|
||||
)
|
||||
|
||||
async for event in workflow.run("inner-loop task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
break
|
||||
|
||||
checkpoints = await _collect_checkpoints(storage)
|
||||
@@ -654,9 +654,9 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep():
|
||||
.build()
|
||||
)
|
||||
|
||||
completed: WorkflowOutputEvent | None = None
|
||||
completed: WorkflowEvent | None = None
|
||||
async for event in resumed.run(checkpoint_id=inner_loop_checkpoint.checkpoint_id, stream=True): # type: ignore[reportUnknownMemberType]
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
completed = event
|
||||
|
||||
assert completed is not None
|
||||
@@ -678,7 +678,7 @@ async def test_magentic_checkpoint_resume_from_saved_state():
|
||||
)
|
||||
|
||||
async for event in workflow.run("checkpoint resume task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
break
|
||||
|
||||
checkpoints = await _collect_checkpoints(storage)
|
||||
@@ -694,9 +694,9 @@ async def test_magentic_checkpoint_resume_from_saved_state():
|
||||
.build()
|
||||
)
|
||||
|
||||
completed: WorkflowOutputEvent | None = None
|
||||
completed: WorkflowEvent | None = None
|
||||
async for event in resumed_workflow.run(checkpoint_id=resumed_state.checkpoint_id, stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
completed = event
|
||||
|
||||
assert completed is not None
|
||||
@@ -716,9 +716,9 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
|
||||
.build()
|
||||
)
|
||||
|
||||
req_event: RequestInfoEvent | None = None
|
||||
req_event: WorkflowEvent | None = None
|
||||
async for event in workflow.run("task", stream=True):
|
||||
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
|
||||
if event.type == "request_info" and event.request_type is MagenticPlanReviewRequest:
|
||||
req_event = event
|
||||
|
||||
assert req_event is not None
|
||||
@@ -778,11 +778,11 @@ async def test_magentic_stall_and_reset_reach_limits():
|
||||
events.append(ev)
|
||||
|
||||
idle_status = next(
|
||||
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE),
|
||||
(e for e in events if e.type == "status" and e.state == WorkflowRunState.IDLE),
|
||||
None,
|
||||
)
|
||||
assert idle_status is not None
|
||||
output_event = next((e for e in events if isinstance(e, WorkflowOutputEvent)), None)
|
||||
output_event = next((e for e in events if e.type == "output"), None)
|
||||
assert output_event is not None
|
||||
assert isinstance(output_event.data, list)
|
||||
assert all(isinstance(msg, ChatMessage) for msg in output_event.data) # type: ignore
|
||||
@@ -800,9 +800,9 @@ async def test_magentic_checkpoint_runtime_only() -> None:
|
||||
|
||||
baseline_output: ChatMessage | None = None
|
||||
async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_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,
|
||||
):
|
||||
@@ -838,9 +838,9 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
|
||||
baseline_output: ChatMessage | None = None
|
||||
async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_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,
|
||||
):
|
||||
@@ -897,7 +897,7 @@ async def test_magentic_checkpoint_restore_no_duplicate_history():
|
||||
]
|
||||
|
||||
async for event in wf.run(conversation, stream=True):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state in (
|
||||
if event.type == "status" and event.state in (
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
):
|
||||
@@ -1005,9 +1005,9 @@ async def test_magentic_with_participant_factories():
|
||||
# Factory should be called during build
|
||||
assert call_count == 1
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
outputs: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("test task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
outputs.append(event)
|
||||
|
||||
assert len(outputs) == 1
|
||||
@@ -1052,9 +1052,9 @@ async def test_magentic_participant_factories_with_checkpointing():
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
outputs: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("checkpoint test", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
outputs.append(event)
|
||||
|
||||
assert outputs, "Should have workflow output"
|
||||
@@ -1109,9 +1109,9 @@ async def test_magentic_with_manager_factory():
|
||||
# Factory should be called during build
|
||||
assert factory_call_count == 1
|
||||
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
outputs: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("test task", stream=True):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
if event.type == "output":
|
||||
outputs.append(event)
|
||||
|
||||
assert len(outputs) == 1
|
||||
|
||||
@@ -15,9 +15,7 @@ from agent_framework import (
|
||||
Executor,
|
||||
TypeCompatibilityError,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
@@ -106,9 +104,9 @@ async def test_sequential_agents_append_to_context() -> None:
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("hello sequential", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = ev.data # type: ignore[assignment]
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -139,9 +137,9 @@ async def test_sequential_register_participants_with_agent_factories() -> None:
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("hello factories", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = ev.data
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -165,9 +163,9 @@ async def test_sequential_with_custom_executor_summary() -> None:
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("topic X", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = ev.data
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -196,9 +194,9 @@ async def test_sequential_register_participants_mixed_agents_and_executors() ->
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("topic Y", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = ev.data
|
||||
if completed and output is not None:
|
||||
break
|
||||
@@ -221,9 +219,9 @@ async def test_sequential_checkpoint_resume_round_trip() -> None:
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("checkpoint sequential", stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_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 baseline_output is not None
|
||||
@@ -242,9 +240,9 @@ async def test_sequential_checkpoint_resume_round_trip() -> None:
|
||||
|
||||
resumed_output: list[ChatMessage] | None = None
|
||||
async for ev in wf_resume.run(checkpoint_id=resume_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,
|
||||
):
|
||||
@@ -264,9 +262,9 @@ async def test_sequential_checkpoint_runtime_only() -> None:
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_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 baseline_output is not None
|
||||
@@ -287,9 +285,9 @@ async def test_sequential_checkpoint_runtime_only() -> None:
|
||||
async for ev in wf_resume.run(
|
||||
checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, 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,
|
||||
):
|
||||
@@ -315,9 +313,9 @@ async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_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 baseline_output is not None
|
||||
@@ -343,9 +341,9 @@ async def test_sequential_register_participants_with_checkpointing() -> None:
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("checkpoint with factories", stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
baseline_output = ev.data
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
assert baseline_output is not None
|
||||
@@ -365,9 +363,9 @@ async def test_sequential_register_participants_with_checkpointing() -> None:
|
||||
|
||||
resumed_output: list[ChatMessage] | None = None
|
||||
async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
if ev.type == "output":
|
||||
resumed_output = ev.data
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
|
||||
if ev.type == "status" and ev.state in (
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
):
|
||||
@@ -401,9 +399,9 @@ async def test_sequential_register_participants_factories_called_on_build() -> N
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run("test factories timing", stream=True):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
elif ev.type == "output":
|
||||
output = ev.data # type: ignore[assignment]
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user