Python: Improve the workflow getting started samples (#570)

* Wip: samples

* wip - samples

* Updates to workflow getting started samples

* Checkpointing enhancements

* Cleanup

* PR feedback

* Updates

* Sample updates

* Updates

* Revamp samples, improve doc strings and code comments

* Cleanup unused comment

* Formatting cleanup

* wip

* Further work on samples. Allow agent to be specified as edge.

* Cleanup

* Typing cleanup

* Sample updates

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Evan Mattson
2025-09-06 04:16:25 +09:00
committed by GitHub
Unverified
parent cd0587c5f6
commit 518fd447fd
46 changed files with 4130 additions and 1683 deletions
@@ -777,11 +777,15 @@ class AgentExecutorResponse:
Attributes:
executor_id: The ID of the executor that generated the response.
response: The agent run response containing the messages generated by the agent.
agent_run_response: The underlying agent run response (unaltered from client).
full_conversation: The full conversation context (prior inputs + all assistant/tool outputs) that
should be used when chaining to another AgentExecutor. This prevents downstream agents losing
user prompts while keeping the emitted AgentRunEvent text faithful to the raw agent output.
"""
executor_id: str
agent_run_response: AgentRunResponse
full_conversation: list[ChatMessage] | None = None
class AgentExecutor(Executor):
@@ -800,21 +804,75 @@ class AgentExecutor(Executor):
Args:
agent: The agent to be wrapped by this executor.
agent_thread: The thread to use for running the agent. If None, a new thread will be created.
streaming: Whether to enable streaming for the agent. If enabled, the executor will emit
AgentRunStreamingEvent updates instead of a single AgentRunEvent.
streaming: Enable streaming (emits incremental AgentRunUpdateEvent events) vs single response.
id: A unique identifier for the executor. If None, a new UUID will be generated.
"""
super().__init__(id or agent.id)
# Prefer provided id; else use agent.name if present; else generate deterministic prefix
if id is not None:
exec_id = id
else:
agent_name = agent.name
exec_id = str(agent_name) if agent_name else f"executor_{uuid.uuid4()}"
super().__init__(exec_id)
self._agent = agent
self._agent_thread = agent_thread or self._agent.get_new_thread()
self._streaming = streaming
self._cache: list[ChatMessage] = []
async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
"""Execute the underlying agent, emit events, and enqueue response.
Terminal detection & WorkflowCompletedEvent emission are handled centrally in Runner.
This method only produces AgentRunEvent/AgentRunUpdateEvent plus enqueues an
AgentExecutorResponse message for routing.
"""
if self._streaming:
updates: list[AgentRunResponseUpdate] = []
async for update in self._agent.run_stream(
self._cache,
thread=self._agent_thread,
):
# Skip empty updates (no textual or structural content)
if not update:
continue
contents = getattr(update, "contents", None)
text_val = getattr(update, "text", "")
has_text_content = False
if contents:
for c in contents:
if getattr(c, "text", None):
has_text_content = True
break
if not (text_val or has_text_content):
continue
updates.append(update)
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
response = AgentRunResponse.from_agent_run_response_updates(updates)
else:
response = await self._agent.run(
self._cache,
thread=self._agent_thread,
)
await ctx.add_event(AgentRunEvent(self.id, response))
full_conversation: list[ChatMessage] | None = None
if self._cache:
# Construct conversation snapshot = inputs (cache) + agent outputs (agent_run_response.messages).
# Do not mutate response.messages so AgentRunEvent remains clean.
full_conversation = list(self._cache) + list(response.messages)
agent_response = AgentExecutorResponse(self.id, response, full_conversation=full_conversation)
await ctx.send_message(agent_response)
self._cache.clear()
@handler
async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
"""Run the agent executor with the given request."""
self._cache.extend(request.messages)
"""Handle an AgentExecutorRequest (canonical input).
This is the standard path: extend cache with provided messages; if should_respond
run the agent and emit an AgentExecutorResponse downstream.
"""
self._cache.extend(request.messages)
if request.should_respond:
if self._streaming:
updates: list[AgentRunResponseUpdate] = []
@@ -822,6 +880,18 @@ class AgentExecutor(Executor):
self._cache,
thread=self._agent_thread,
):
if not update:
continue
contents = getattr(update, "contents", None)
text_val = getattr(update, "text", "")
has_text_content = False
if contents:
for c in contents:
if getattr(c, "text", None):
has_text_content = True
break
if not (text_val or has_text_content):
continue
updates.append(update)
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
response = AgentRunResponse.from_agent_run_response_updates(updates)
@@ -832,8 +902,37 @@ class AgentExecutor(Executor):
)
await ctx.add_event(AgentRunEvent(self.id, response))
await ctx.send_message(AgentExecutorResponse(self.id, response))
self._cache.clear()
@handler
async def from_response(self, prior: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
"""Enable seamless chaining: accept a prior AgentExecutorResponse as input.
Strategy: treat the prior response's messages as the conversation state and
immediately run the agent to produce a new response.
"""
# Replace cache with full conversation if available, else fall back to agent_run_response messages.
if prior.full_conversation is not None:
self._cache = list(prior.full_conversation)
else:
self._cache = list(prior.agent_run_response.messages)
await self._run_agent_and_emit(ctx)
@handler
async def from_str(self, text: str, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
"""Accept a raw user prompt string and run the agent (one-shot)."""
self._cache = [ChatMessage(role="user", text=text)] # type: ignore[arg-type]
await self._run_agent_and_emit(ctx)
@handler
async def from_message(self, message: ChatMessage, ctx: WorkflowContext[AgentExecutorResponse]) -> None: # type: ignore[name-defined]
"""Accept a single ChatMessage as input."""
self._cache = [message]
await self._run_agent_and_emit(ctx)
@handler
async def from_messages(self, messages: list[ChatMessage], ctx: WorkflowContext[AgentExecutorResponse]) -> None: # type: ignore[name-defined]
"""Accept a list of ChatMessage objects as conversation context."""
self._cache = list(messages)
await self._run_agent_and_emit(ctx)
# endregion: Agent Executor
@@ -10,8 +10,6 @@ This module provides:
with proper type validation and handler registration.
"""
from __future__ import annotations
import asyncio
import inspect
from collections.abc import Awaitable, Callable
@@ -3,7 +3,7 @@
import asyncio
import logging
from collections import defaultdict
from collections.abc import AsyncIterable, Sequence
from collections.abc import AsyncGenerator, Sequence
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
from ._events import WorkflowEvent
from ._events import WorkflowCompletedEvent, WorkflowEvent
from ._executor import Executor
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
@@ -74,19 +74,17 @@ class Runner:
if max_iterations is not None:
self._max_iterations = max_iterations
async def run_until_convergence(self) -> AsyncIterable[WorkflowEvent]:
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
"""Run the workflow until no more messages are sent."""
if self._running:
raise RuntimeError("Runner is already running.")
self._running = True
try:
# Process any events from initial execution before checkpointing
# Emit any events already produced prior to entering loop
if await self._ctx.has_events():
logger.info("Processing events from initial execution")
events = await self._ctx.drain_events()
for event in events:
logger.info(f"Yielding initial event: {event}")
logger.info("Yielding pre-loop events")
for event in await self._ctx.drain_events():
yield event
# Create first checkpoint if there are messages from initial execution
@@ -102,22 +100,33 @@ class Runner:
while self._iteration < self._max_iterations:
logger.info(f"Starting superstep {self._iteration + 1}")
await self._run_iteration()
# Run iteration concurrently with live event streaming: we poll
# for new events while the iteration coroutine progresses.
iteration_task = asyncio.create_task(self._run_iteration())
while not iteration_task.done():
try:
# Wait briefly for any new event; timeout allows progress checks
event = await asyncio.wait_for(self._ctx.next_event(), timeout=0.05)
yield event
except asyncio.TimeoutError:
# Periodically continue to let iteration advance
continue
# Propagate errors from iteration
await iteration_task
self._iteration += 1
# Drain any straggler events emitted at tail end
if await self._ctx.has_events():
for event in await self._ctx.drain_events():
yield event
# Update context with current iteration state immediately
await self._update_context_with_shared_state()
logger.info(f"Completed superstep {self._iteration}")
# Process events first before any checkpointing
if await self._ctx.has_events():
logger.info("Processing events before checkpointing")
events = await self._ctx.drain_events()
for event in events:
logger.debug(f"Yielding event: {event}")
yield event
# Create checkpoint after each superstep iteration
await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
@@ -142,7 +151,7 @@ class Runner:
from ._executor import SubWorkflowRequestInfo
# Handle SubWorkflowRequestInfo messages - only process those not already targeted
sub_workflow_messages = []
sub_workflow_messages: list[Message] = []
for msg in messages:
# Skip messages sent directly to RequestInfoExecutor - they are already forwarded
if self._is_message_to_request_info_executor(msg):
@@ -152,14 +161,15 @@ class Runner:
sub_workflow_messages.append(msg)
for message in sub_workflow_messages:
sub_request = message.data
# message.data is guaranteed to be SubWorkflowRequestInfo via filtering above
sub_request = message.data # type: ignore[assignment]
# Find executor that can intercept the wrapped type
interceptor_found = False
for executor in self._executors.values():
if hasattr(executor, "_request_interceptors") and executor.id != message.source_id:
# Check if any registered interceptor can handle this request type
for registered_type in executor._request_interceptors:
interceptors = getattr(executor, "_request_interceptors", [])
if interceptors and executor.id != message.source_id:
for registered_type in interceptors: # type: ignore[assignment]
# Check type matching - handle both type and string cases
matched = False
if (
@@ -234,7 +244,7 @@ class Runner:
# since they were handled specially
from ._executor import SubWorkflowRequestInfo
non_sub_workflow_messages = []
non_sub_workflow_messages: list[Message] = []
for msg in messages:
# Keep messages sent directly to RequestInfoExecutor (forwarded messages)
if self._is_message_to_request_info_executor(msg):
@@ -251,8 +261,43 @@ class Runner:
for message in non_sub_workflow_messages:
# Deliver a message through all edge runners associated with the source executor concurrently.
tasks = [_deliver_message_inner(edge_runner, message) for edge_runner in associated_edge_runners]
if not tasks:
# No outgoing edges. If this is an AgentExecutorResponse, treat it as an
# intentional terminal emission and emit a WorkflowCompletedEvent here.
# (Previously this relied on the executor to emit, but AgentExecutor only
# sends an AgentExecutorResponse message; centralized completion keeps the
# contract consistent with other executors.)
try: # Local import to avoid circular dependencies at module import time.
from ._executor import AgentExecutorResponse # type: ignore
if isinstance(message.data, AgentExecutorResponse):
final_messages = message.data.agent_run_response.messages
final_text = final_messages[-1].text if final_messages else "(no content)"
await self._ctx.add_event(WorkflowCompletedEvent(final_text))
continue # Terminal handled
except Exception as exc: # pragma: no cover - defensive
logger.debug("Suppressed exception during terminal message type check: %s", exc)
# Otherwise keep prior behavior (emit warning for unexpected undelivered message).
logger.warning(
f"Message {message} could not be delivered (no outgoing edges). "
"Add a downstream executor or remove the send if this is unexpected."
)
continue
results = await asyncio.gather(*tasks)
if not any(results):
# Outgoing edges exist but none accepted the message. If this is an
# AgentExecutorResponse, treat as natural terminal and emit completion.
try:
from ._executor import AgentExecutorResponse # type: ignore
if isinstance(message.data, AgentExecutorResponse):
# Emit a single completion event with final text (best-effort extraction)
final_messages = message.data.agent_run_response.messages
final_text = final_messages[-1].text if final_messages else "(no content)"
await self._ctx.add_event(WorkflowCompletedEvent(final_text))
continue
except Exception as exc: # pragma: no cover
logger.debug("Terminal completion emission failed: %s", exc)
logger.warning(
f"Message {message} could not be delivered. "
"This may be due to type incompatibility or no matching targets."
@@ -389,7 +434,8 @@ class Runner:
"""
parsed: defaultdict[str, list[EdgeRunner]] = defaultdict(list)
for runner in edge_runners:
for source_executor_id in runner._edge_group.source_executor_ids:
# Accessing protected attribute (_edge_group) intentionally for internal wiring.
for source_executor_id in runner._edge_group.source_executor_ids: # type: ignore[attr-defined]
parsed[source_executor_id].append(runner)
return parsed
@@ -1,14 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import importlib
import logging
import uuid
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable
from dataclasses import dataclass, fields, is_dataclass
from typing import Any, Protocol, TypedDict, TypeVar, cast, runtime_checkable
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._const import DEFAULT_MAX_ITERATIONS
from ._events import WorkflowEvent
from ._events import AgentRunUpdateEvent, WorkflowEvent
from ._shared_state import SharedState
logger = logging.getLogger(__name__)
@@ -49,6 +51,176 @@ class CheckpointState(TypedDict):
max_iterations: int
# Checkpoint serialization helpers
_PYDANTIC_MARKER = "__af_pydantic_model__"
_DATACLASS_MARKER = "__af_dataclass__"
# Guards to prevent runaway recursion while encoding arbitrary user data
_MAX_ENCODE_DEPTH = 100
_CYCLE_SENTINEL = "<cycle>"
def _is_pydantic_model(obj: object) -> bool:
"""Best-effort check for Pydantic models (e.g., AFBaseModel).
We avoid hard dependencies by duck-typing on model_dump/model_validate.
"""
try:
obj_type: type[Any] = type(obj)
return hasattr(obj, "model_dump") and hasattr(obj_type, "model_validate")
except Exception:
return False
def _encode_checkpoint_value(value: Any) -> Any:
"""Recursively encode values into JSON-serializable structures.
- Pydantic models -> { _PYDANTIC_MARKER: "module:Class", value: model_dump(mode="json") }
- dataclass instances -> { _DATACLASS_MARKER: "module:Class", value: {field: encoded} }
- dict -> encode keys as str and values recursively
- list/tuple/set -> list of encoded items
- other -> returned as-is if already JSON-serializable
Includes cycle and depth protection to avoid infinite recursion.
"""
def _enc(v: Any, stack: set[int], depth: int) -> Any:
# Depth guard
if depth > _MAX_ENCODE_DEPTH:
logger.debug(f"Max encode depth reached at depth={depth} for type={type(v)}")
return "<max_depth>"
# Pydantic (AFBaseModel) handling
if _is_pydantic_model(v):
cls = cast(type[Any], type(v)) # type: ignore
try:
return {
_PYDANTIC_MARKER: f"{cls.__module__}:{cls.__name__}",
"value": v.model_dump(mode="json"),
}
except Exception as exc: # best-effort fallback
logger.debug("Pydantic model_dump failed for %s: %s", cls, exc)
return str(v)
# Dataclasses (instances only)
if is_dataclass(v) and not isinstance(v, type):
oid = id(v)
if oid in stack:
logger.debug("Cycle detected while encoding dataclass instance")
return _CYCLE_SENTINEL
stack.add(oid)
try:
# type(v) already narrows sufficiently; cast was redundant
dc_cls: type[Any] = type(v)
field_values: dict[str, Any] = {}
for f in fields(v): # type: ignore[arg-type]
field_values[f.name] = _enc(getattr(v, f.name), stack, depth + 1)
return {
_DATACLASS_MARKER: f"{dc_cls.__module__}:{dc_cls.__name__}",
"value": field_values,
}
finally:
stack.remove(oid)
# Collections
if isinstance(v, dict):
v_dict = cast("dict[object, object]", v)
oid = id(v_dict)
if oid in stack:
logger.debug("Cycle detected while encoding dict")
return _CYCLE_SENTINEL
stack.add(oid)
try:
json_dict: dict[str, Any] = {}
for k_any, val_any in v_dict.items(): # type: ignore[assignment]
k_str: str = str(k_any)
json_dict[k_str] = _enc(val_any, stack, depth + 1)
return json_dict
finally:
stack.remove(oid)
if isinstance(v, (list, tuple, set)):
iterable_v = cast("list[object] | tuple[object, ...] | set[object]", v)
oid = id(iterable_v)
if oid in stack:
logger.debug("Cycle detected while encoding iterable")
return _CYCLE_SENTINEL
stack.add(oid)
try:
seq: list[object] = list(iterable_v)
encoded_list: list[Any] = []
for item in seq:
encoded_list.append(_enc(item, stack, depth + 1))
return encoded_list
finally:
stack.remove(oid)
# Primitives (or unknown objects): ensure JSON-serializable
if isinstance(v, (str, int, float, bool)) or v is None:
return v
# Fallback: stringify unknown objects to avoid JSON serialization errors
try:
return str(v)
except Exception:
return f"<{type(v).__name__}>"
return _enc(value, set(), 0)
def _decode_checkpoint_value(value: Any) -> Any:
"""Recursively decode values previously encoded by _encode_checkpoint_value."""
if isinstance(value, dict):
value_dict = cast(dict[str, Any], value) # encoded form always uses string keys
# Pydantic marker handling
if _PYDANTIC_MARKER in value_dict and "value" in value_dict:
type_key: str | None = value_dict.get(_PYDANTIC_MARKER) # type: ignore[assignment]
raw: Any = value_dict.get("value")
if isinstance(type_key, str):
try:
module_name, class_name = type_key.split(":", 1)
module = importlib.import_module(module_name)
cls: Any = getattr(module, class_name)
if hasattr(cls, "model_validate"):
return cls.model_validate(raw)
except Exception as exc:
logger.debug(
"Failed to decode pydantic model %s: %s; returning raw value",
type_key,
exc,
)
# Dataclass marker handling
if _DATACLASS_MARKER in value_dict and "value" in value_dict:
type_key_dc: str | None = value_dict.get(_DATACLASS_MARKER) # type: ignore[assignment]
raw_dc: Any = value_dict.get("value")
if isinstance(type_key_dc, str):
try:
module_name, class_name = type_key_dc.split(":", 1)
module = importlib.import_module(module_name)
cls_dc: Any = getattr(module, class_name)
decoded_raw = _decode_checkpoint_value(raw_dc)
if isinstance(decoded_raw, dict):
return cls_dc(**decoded_raw)
except Exception as exc:
logger.debug(
"Failed to decode dataclass %s: %s; returning raw value",
type_key_dc,
exc,
)
# Fallback to decoded raw value
return _decode_checkpoint_value(raw_dc)
# Regular dict: decode recursively
decoded: dict[str, Any] = {}
for k_any, v_any in value_dict.items():
decoded[k_any] = _decode_checkpoint_value(v_any)
return decoded
if isinstance(value, list):
# After isinstance check, treat value as list[Any] for decoding
value_list: list[Any] = value # type: ignore[assignment]
return [_decode_checkpoint_value(v_any) for v_any in value_list]
return value
@runtime_checkable
class RunnerContext(Protocol):
"""Protocol for the execution context used by the runner.
@@ -105,6 +277,10 @@ class RunnerContext(Protocol):
"""
...
async def next_event(self) -> WorkflowEvent: # pragma: no cover - interface only
"""Wait for and return the next event emitted by the workflow run."""
...
async def set_state(self, executor_id: str, state: dict[str, Any]) -> None:
"""Set the state for a specific executor.
@@ -185,7 +361,8 @@ class InProcRunnerContext:
checkpoint_storage: Optional storage to enable checkpointing.
"""
self._messages: defaultdict[str, list[Message]] = defaultdict(list)
self._events: list[WorkflowEvent] = []
# Event queue for immediate streaming of events (e.g., AgentRunUpdateEvent)
self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue()
# Checkpointing configuration/state
self._checkpoint_storage = checkpoint_storage
@@ -207,15 +384,54 @@ class InProcRunnerContext:
return bool(self._messages)
async def add_event(self, event: WorkflowEvent) -> None:
self._events.append(event)
"""Add an event to the context immediately.
Events are enqueued so runners can stream them in real time instead of
waiting for superstep boundaries.
"""
# Filter out empty AgentRunUpdateEvent updates to avoid emitting None/empty chunks
try:
if isinstance(event, AgentRunUpdateEvent):
update = getattr(event, "data", None)
# Skip if no update payload
if not update:
return
# Robust emptiness check: allow either top-level text or any text-bearing content
text_val = getattr(update, "text", None)
contents = getattr(update, "contents", None)
has_text_content = False
if contents:
for c in contents:
if getattr(c, "text", None):
has_text_content = True
break
if not (text_val or has_text_content):
return
except Exception as exc: # pragma: no cover - defensive logging path
# Best-effort filtering only; never block event delivery on filtering errors
logger.debug("Error while filtering event %r: %s", event, exc, exc_info=True)
await self._event_queue.put(event)
async def drain_events(self) -> list[WorkflowEvent]:
events = self._events.copy()
self._events.clear()
"""Drain all currently queued events without blocking for new ones."""
events: list[WorkflowEvent] = []
while True:
try:
events.append(self._event_queue.get_nowait())
except asyncio.QueueEmpty: # type: ignore[attr-defined]
break
return events
async def has_events(self) -> bool:
return bool(self._events)
return not self._event_queue.empty()
async def next_event(self) -> WorkflowEvent:
"""Wait for and return the next event.
Used by the runner to interleave event emission with ongoing iteration work.
"""
return await self._event_queue.get()
async def set_state(self, executor_id: str, state: dict[str, Any]) -> None:
self._executor_states[executor_id] = state
@@ -229,9 +445,10 @@ class InProcRunnerContext:
def set_workflow_id(self, workflow_id: str) -> None:
self._workflow_id = workflow_id
def reset_for_new_run(self, workflow_shared_state: "SharedState | None" = None) -> None:
def reset_for_new_run(self, workflow_shared_state: SharedState | None = None) -> None:
self._messages.clear()
self._events.clear()
# Clear any pending events (best-effort) by recreating the queue
self._event_queue = asyncio.Queue()
self._shared_state.clear()
self._executor_states.clear()
self._iteration_count = 0
@@ -285,7 +502,7 @@ class InProcRunnerContext:
for source_id, message_list in self._messages.items():
serializable_messages[source_id] = [
{
"data": msg.data,
"data": _encode_checkpoint_value(msg.data),
"source_id": msg.source_id,
"target_id": msg.target_id,
"trace_contexts": msg.trace_contexts,
@@ -295,8 +512,8 @@ class InProcRunnerContext:
]
return {
"messages": serializable_messages,
"shared_state": self._shared_state,
"executor_states": self._executor_states,
"shared_state": _encode_checkpoint_value(self._shared_state),
"executor_states": _encode_checkpoint_value(self._executor_states),
"iteration_count": self._iteration_count,
"max_iterations": self._max_iterations,
}
@@ -307,7 +524,7 @@ class InProcRunnerContext:
for source_id, message_list in messages_data.items():
self._messages[source_id] = [
Message(
data=msg.get("data"),
data=_decode_checkpoint_value(msg.get("data")),
source_id=msg.get("source_id", ""),
target_id=msg.get("target_id"),
trace_contexts=msg.get("trace_contexts"),
@@ -315,7 +532,28 @@ class InProcRunnerContext:
)
for msg in message_list
]
self._shared_state = state.get("shared_state", {})
self._executor_states = state.get("executor_states", {})
# Restore shared_state
decoded_shared_raw = _decode_checkpoint_value(state.get("shared_state", {}))
if isinstance(decoded_shared_raw, dict):
self._shared_state = cast(dict[str, Any], decoded_shared_raw)
else: # fallback to empty dict if corrupted
self._shared_state = {}
# Restore executor_states ensuring value types are dicts
decoded_exec_raw = _decode_checkpoint_value(state.get("executor_states", {}))
if isinstance(decoded_exec_raw, dict):
typed_exec: dict[str, dict[str, Any]] = {}
for k_raw, v_raw in decoded_exec_raw.items(): # type: ignore[assignment]
if isinstance(k_raw, str) and isinstance(v_raw, dict):
# Filter inner dict to string keys only (best-effort)
inner: dict[str, Any] = {}
for inner_k, inner_v in v_raw.items(): # type: ignore[assignment]
if isinstance(inner_k, str):
inner[inner_k] = inner_v
typed_exec[k_raw] = inner
self._executor_states = typed_exec
else:
self._executor_states = {}
self._iteration_count = state.get("iteration_count", 0)
self._max_iterations = state.get("max_iterations", 100)
@@ -167,6 +167,23 @@ class WorkflowGraphValidator:
if start_executor_id not in self._executors:
raise GraphConnectivityError(f"Start executor '{start_executor_id}' is not present in the workflow graph")
# Additional presence verification:
# A start executor that is only injected via the builder (present in the executors map)
# but not referenced by any edge while other executors ARE referenced indicates a
# configuration error: the chosen start node is effectively disconnected / unknown to the
# defined graph topology. For single-node workflows (no edges) we allow the start executor
# to stand alone (handled above when we inject it into the map). We perform this refined
# check only when there is at least one edge group defined.
if self._edges: # Only evaluate when the workflow defines edges
edge_executor_ids: set[str] = set()
for _e in self._edges:
edge_executor_ids.add(_e.source_id)
edge_executor_ids.add(_e.target_id)
if start_executor_id not in edge_executor_ids:
raise GraphConnectivityError(
f"Start executor '{start_executor_id}' is not present in the workflow graph"
)
# Run all checks
self._validate_edge_duplication()
self._validate_handler_output_annotations()
@@ -5,11 +5,13 @@ import logging
import sys
import uuid
from collections.abc import AsyncIterable, Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Any
from typing import Any
from agent_framework import AgentProtocol
from agent_framework._pydantic import AFBaseModel
from pydantic import Field
from ._agent import WorkflowAgent
from ._checkpoint import CheckpointStorage
from ._const import DEFAULT_MAX_ITERATIONS
from ._edge import (
@@ -24,7 +26,7 @@ from ._edge import (
SwitchCaseEdgeGroupDefault,
)
from ._events import RequestInfoEvent, WorkflowCompletedEvent, WorkflowEvent
from ._executor import Executor, RequestInfoExecutor
from ._executor import AgentExecutor, Executor, RequestInfoExecutor
from ._runner import Runner
from ._runner_context import CheckpointState, InProcRunnerContext, RunnerContext
from ._shared_state import SharedState
@@ -39,9 +41,6 @@ else:
logger = logging.getLogger(__name__)
if TYPE_CHECKING: # Avoid runtime import cycles; enables proper type checking of as_agent return type
from ._agent import WorkflowAgent
class WorkflowRunResult(list[WorkflowEvent]):
"""A list of events generated during the workflow execution in non-streaming mode."""
@@ -368,8 +367,48 @@ class Workflow(AFBaseModel):
Returns:
A WorkflowRunResult instance containing a list of events generated during the workflow execution.
"""
events = [event async for event in self.run_stream(message)]
return WorkflowRunResult(events)
from agent_framework import AgentRunResponse, AgentRunResponseUpdate
from ._events import AgentRunEvent, AgentRunUpdateEvent # Local import to avoid cycles
raw_events = [event async for event in self.run_stream(message)]
# Coalesce streaming update events into a single AgentRunEvent per executor sequence.
coalesced: list[WorkflowEvent] = [] # type: ignore[name-defined]
pending_updates: list[AgentRunResponseUpdate] = []
pending_executor: str | None = None
def _flush_pending() -> None:
nonlocal pending_updates, pending_executor
if pending_executor is None or not pending_updates:
return
# Aggregate updates into a final AgentRunResponse using existing helper
aggregated = AgentRunResponse.from_agent_run_response_updates(pending_updates)
coalesced.append(AgentRunEvent(pending_executor, aggregated))
pending_updates = []
pending_executor = None
for ev in raw_events:
if isinstance(ev, AgentRunUpdateEvent):
# Start new grouping or continue existing if same executor
if pending_executor is None:
pending_executor = ev.executor_id
if ev.executor_id != pending_executor:
# Different executor encountered; flush previous first
_flush_pending()
pending_executor = ev.executor_id
if ev.data is not None:
pending_updates.append(ev.data)
# Do NOT append update event itself (non-streaming contract)
continue
# Flush before adding any non-update event
_flush_pending()
coalesced.append(ev)
# Flush any trailing updates
_flush_pending()
return WorkflowRunResult(coalesced)
async def run_from_checkpoint(
self,
@@ -423,7 +462,7 @@ class Workflow(AFBaseModel):
raise ValueError(f"Executor with ID {executor_id} not found.")
return self.executors[executor_id]
def _find_request_info_executor(self) -> "RequestInfoExecutor | None":
def _find_request_info_executor(self) -> RequestInfoExecutor | None:
"""Find the RequestInfoExecutor instance in this workflow.
Returns:
@@ -537,7 +576,7 @@ class Workflow(AFBaseModel):
)
)
def as_agent(self, name: str | None = None) -> "WorkflowAgent":
def as_agent(self, name: str | None = None) -> WorkflowAgent:
"""Create a WorkflowAgent that wraps this workflow.
Args:
@@ -568,18 +607,57 @@ class WorkflowBuilder:
self._start_executor: Executor | str | None = None
self._checkpoint_storage: CheckpointStorage | None = None
self._max_iterations: int = max_iterations
# Maps underlying AgentProtocol object id -> wrapped Executor so we reuse the same wrapper
# across set_start_executor / add_edge calls. Without this, unnamed agents (which receive
# random UUID based executor ids) end up wrapped multiple times, giving different ids for
# the start node vs edge nodes and triggering a GraphConnectivityError during validation.
self._agent_wrappers: dict[int, Executor] = {}
# Agents auto-wrapped by builder now always stream incremental updates.
def _add_executor(self, executor: Executor) -> str:
"""Add an executor to the map and return its ID."""
self._executors[executor.id] = executor
return executor.id
def _maybe_wrap_agent(self, candidate: Executor | AgentProtocol) -> Executor:
"""If the provided object implements AgentProtocol, wrap it in an AgentExecutor.
This allows fluent builder APIs to directly accept agents instead of
requiring callers to manually instantiate AgentExecutor.
"""
try: # Local import to avoid hard dependency at import time
from agent_framework import AgentProtocol # type: ignore
except Exception: # pragma: no cover - defensive
AgentProtocol = object # type: ignore
if isinstance(candidate, Executor): # Already an executor
return candidate
if isinstance(candidate, AgentProtocol): # type: ignore[arg-type]
# Reuse existing wrapper for the same agent instance if present
existing = self._agent_wrappers.get(id(candidate))
if existing is not None:
return existing
# Use agent name if available and unique among current executors
name = getattr(candidate, "name", None)
proposed_id: str | None = None
if name:
proposed_id = str(name)
if proposed_id in self._executors:
proposed_id = f"{proposed_id}-{uuid.uuid4().hex[:8]}"
wrapper = AgentExecutor(candidate, id=proposed_id, streaming=True)
self._agent_wrappers[id(candidate)] = wrapper
return wrapper
raise TypeError(
f"WorkflowBuilder expected an Executor or AgentProtocol instance; got {type(candidate).__name__}."
)
def add_edge(
self,
source: Executor,
target: Executor,
source: Executor | AgentProtocol,
target: Executor | AgentProtocol,
condition: Callable[[Any], bool] | None = None,
) -> "Self":
) -> Self:
"""Add a directed edge between two executors.
The output types of the source and the input types of the target must be compatible.
@@ -591,12 +669,18 @@ class WorkflowBuilder:
should be traversed based on the message type.
"""
# TODO(@taochen): Support executor factories for lazy initialization
source_id = self._add_executor(source)
target_id = self._add_executor(target)
source_exec = self._maybe_wrap_agent(source)
target_exec = self._maybe_wrap_agent(target)
source_id = self._add_executor(source_exec)
target_id = self._add_executor(target_exec)
self._edge_groups.append(SingleEdgeGroup(source_id, target_id, condition))
return self
def add_fan_out_edges(self, source: Executor, targets: Sequence[Executor]) -> "Self":
def add_fan_out_edges(
self,
source: Executor | AgentProtocol,
targets: Sequence[Executor | AgentProtocol],
) -> Self:
"""Add multiple edges to the workflow where messages from the source will be sent to all target.
The output types of the source and the input types of the targets must be compatible.
@@ -605,13 +689,19 @@ class WorkflowBuilder:
source: The source executor of the edges.
targets: A list of target executors for the edges.
"""
source_id = self._add_executor(source)
target_ids = [self._add_executor(target) for target in targets]
source_exec = self._maybe_wrap_agent(source)
target_execs = [self._maybe_wrap_agent(t) for t in targets]
source_id = self._add_executor(source_exec)
target_ids = [self._add_executor(t) for t in target_execs]
self._edge_groups.append(FanOutEdgeGroup(source_id, target_ids))
return self
def add_switch_case_edge_group(self, source: Executor, cases: Sequence[Case | Default]) -> "Self":
def add_switch_case_edge_group(
self,
source: Executor | AgentProtocol,
cases: Sequence[Case | Default],
) -> Self:
"""Add an edge group that represents a switch-case statement.
The output types of the source and the input types of the targets must be compatible.
@@ -629,10 +719,13 @@ class WorkflowBuilder:
source: The source executor of the edges.
cases: A list of case objects that determine the target executor for each message.
"""
source_id = self._add_executor(source)
source_exec = self._maybe_wrap_agent(source)
source_id = self._add_executor(source_exec)
# Convert case data types to internal types that only uses target_id.
internal_cases: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = []
for case in cases:
# Allow case targets to be agents
case.target = self._maybe_wrap_agent(case.target) # type: ignore[attr-defined]
self._add_executor(case.target)
if isinstance(case, Default):
internal_cases.append(SwitchCaseEdgeGroupDefault(target_id=case.target.id))
@@ -644,10 +737,10 @@ class WorkflowBuilder:
def add_multi_selection_edge_group(
self,
source: Executor,
targets: Sequence[Executor],
source: Executor | AgentProtocol,
targets: Sequence[Executor | AgentProtocol],
selection_func: Callable[[Any, list[str]], list[str]],
) -> "Self":
) -> Self:
"""Add an edge group that represents a multi-selection execution model.
The output types of the source and the input types of the targets must be compatible.
@@ -662,13 +755,19 @@ class WorkflowBuilder:
targets: A list of target executors for the edges.
selection_func: A function that selects target executors for messages.
"""
source_id = self._add_executor(source)
target_ids = [self._add_executor(target) for target in targets]
source_exec = self._maybe_wrap_agent(source)
target_execs = [self._maybe_wrap_agent(t) for t in targets]
source_id = self._add_executor(source_exec)
target_ids = [self._add_executor(t) for t in target_execs]
self._edge_groups.append(FanOutEdgeGroup(source_id, target_ids, selection_func))
return self
def add_fan_in_edges(self, sources: Sequence[Executor], target: Executor) -> "Self":
def add_fan_in_edges(
self,
sources: Sequence[Executor | AgentProtocol],
target: Executor | AgentProtocol,
) -> Self:
"""Add multiple edges from sources to a single target executor.
The edges will be grouped together for synchronized processing, meaning
@@ -702,13 +801,15 @@ class WorkflowBuilder:
sources: A list of source executors for the edges.
target: The target executor for the edges.
"""
source_ids = [self._add_executor(source) for source in sources]
target_id = self._add_executor(target)
source_execs = [self._maybe_wrap_agent(s) for s in sources]
target_exec = self._maybe_wrap_agent(target)
source_ids = [self._add_executor(s) for s in source_execs]
target_id = self._add_executor(target_exec)
self._edge_groups.append(FanInEdgeGroup(source_ids, target_id))
return self
def add_chain(self, executors: Sequence[Executor]) -> "Self":
def add_chain(self, executors: Sequence[Executor | AgentProtocol]) -> Self:
"""Add a chain of executors to the workflow.
The output of each executor in the chain will be sent to the next executor in the chain.
@@ -719,20 +820,30 @@ class WorkflowBuilder:
Args:
executors: A list of executors to be added to the chain.
"""
for i in range(len(executors) - 1):
self.add_edge(executors[i], executors[i + 1])
# Wrap each candidate first to ensure stable IDs before adding edges
wrapped: list[Executor] = [self._maybe_wrap_agent(e) for e in executors]
for i in range(len(wrapped) - 1):
self.add_edge(wrapped[i], wrapped[i + 1])
return self
def set_start_executor(self, executor: Executor | str) -> "Self":
def set_start_executor(self, executor: Executor | AgentProtocol | str) -> Self:
"""Set the starting executor for the workflow.
Args:
executor: The starting executor, which can be an Executor instance or its ID.
"""
self._start_executor = executor
if isinstance(executor, str):
self._start_executor = executor
else:
wrapped = self._maybe_wrap_agent(executor) # type: ignore[arg-type]
self._start_executor = wrapped
# Ensure the start executor is present in the executor map so validation succeeds
# even if no edges are added yet, or before edges wrap the same agent again.
if wrapped.id not in self._executors:
self._executors[wrapped.id] = wrapped
return self
def set_max_iterations(self, max_iterations: int) -> "Self":
def set_max_iterations(self, max_iterations: int) -> Self:
"""Set the maximum number of iterations for the workflow.
Args:
@@ -741,7 +852,9 @@ class WorkflowBuilder:
self._max_iterations = max_iterations
return self
def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Self":
# Removed explicit set_agent_streaming() API; agents always stream updates.
def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> Self:
"""Enable checkpointing with the specified storage.
Args:
@@ -318,7 +318,7 @@ def test_logging_for_missing_input_types(caplog: Any) -> None:
class NoInputTypesExecutor(Executor):
# Handler without type annotation for input parameter
async def handle_message(self, message: Any, ctx: WorkflowContext) -> None:
async def handle_message(self, message: Any, ctx: WorkflowContext[Any]) -> None:
await ctx.send_message("processed")
def _discover_handlers(self) -> None:
@@ -581,7 +581,7 @@ def test_handler_ctx_missing_annotation_raises() -> None:
def test_handler_ctx_unsubscripted_workflow_context_raises() -> None:
class BadExecutor(Executor):
@handler
async def handle(self, message: str, ctx: WorkflowContext) -> None: # missing T
async def handle(self, message: str, ctx: WorkflowContext) -> None: # type: ignore # missing T
pass
start = StringExecutor(id="s")
@@ -4,7 +4,43 @@ from dataclasses import dataclass
from typing import Any
import pytest
from agent_framework.workflow import Executor, WorkflowBuilder, WorkflowContext, handler
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, AgentThread, BaseAgent, ChatMessage, Role
from agent_framework.workflow import AgentExecutor, Executor, WorkflowBuilder, WorkflowContext, handler
class DummyAgent(BaseAgent):
async def run(self, messages=None, *, thread: AgentThread | None = None, **kwargs): # type: ignore[override]
norm: list[ChatMessage] = []
if messages:
for m in messages: # type: ignore[iteration-over-optional]
if isinstance(m, ChatMessage):
norm.append(m)
elif isinstance(m, str):
norm.append(ChatMessage(role=Role.USER, text=m))
return AgentRunResponse(messages=norm)
async def run_stream(self, messages=None, *, thread: AgentThread | None = None, **kwargs): # type: ignore[override]
# Minimal async generator
yield AgentRunResponseUpdate()
def test_builder_accepts_agents_directly():
agent1 = DummyAgent(id="agent1", name="writer")
agent2 = DummyAgent(id="agent2", name="reviewer")
wf = WorkflowBuilder().set_start_executor(agent1).add_edge(agent1, agent2).build()
# Confirm auto-wrapped executors use agent names as IDs
assert wf.start_executor_id == "writer"
assert any(isinstance(e, AgentExecutor) and e.id in {"writer", "reviewer"} for e in wf.executors.values())
def test_builder_agents_always_stream():
agent = DummyAgent(id="agentX", name="streamer")
wf = WorkflowBuilder().set_start_executor(agent).build()
exec_obj = wf.get_start_executor()
assert isinstance(exec_obj, AgentExecutor)
assert getattr(exec_obj, "_streaming", False) is True
@dataclass