mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [BREAKING] Move workflow to main package (#767)
* Move workflow to main package * Remove workflow specific unit test config * Remove workflow-specific version info * Revert unintended telemetry changes * Removed the obsolete packages/workflow/tests target * Rename dir workflow to _workflow * Fix test imports
This commit is contained in:
committed by
GitHub
Unverified
parent
65dd48aa1d
commit
732d9f6cd7
@@ -16,3 +16,4 @@ from ._memory import * # noqa: F403
|
||||
from ._threads import * # noqa: F403
|
||||
from ._tools import * # noqa: F403
|
||||
from ._types import * # noqa: F403
|
||||
from ._workflow import * # noqa: F403
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Get Started with Microsoft Agent Framework Workflow
|
||||
|
||||
Workflow capabilities now ship with the core `agent-framework` package.
|
||||
|
||||
```bash
|
||||
pip install agent-framework
|
||||
```
|
||||
|
||||
Optional visualization support is still available via the `viz` extra:
|
||||
|
||||
```bash
|
||||
pip install agent-framework[viz]
|
||||
```
|
||||
|
||||
See the [project README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information.
|
||||
@@ -0,0 +1,197 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import contextlib
|
||||
|
||||
from ._agent import WorkflowAgent
|
||||
from ._checkpoint import (
|
||||
CheckpointStorage,
|
||||
FileCheckpointStorage,
|
||||
InMemoryCheckpointStorage,
|
||||
WorkflowCheckpoint,
|
||||
)
|
||||
from ._concurrent import ConcurrentBuilder
|
||||
from ._const import (
|
||||
DEFAULT_MAX_ITERATIONS,
|
||||
)
|
||||
from ._edge import (
|
||||
Case,
|
||||
Default,
|
||||
Edge,
|
||||
FanInEdgeGroup,
|
||||
FanOutEdgeGroup,
|
||||
SingleEdgeGroup,
|
||||
SwitchCaseEdgeGroup,
|
||||
SwitchCaseEdgeGroupCase,
|
||||
SwitchCaseEdgeGroupDefault,
|
||||
)
|
||||
from ._edge_runner import create_edge_runner
|
||||
from ._events import (
|
||||
AgentRunEvent,
|
||||
AgentRunUpdateEvent,
|
||||
ExecutorCompletedEvent,
|
||||
ExecutorEvent,
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokeEvent,
|
||||
RequestInfoEvent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from ._executor import (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
Executor,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
SubWorkflowRequestInfo,
|
||||
SubWorkflowResponse,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
intercepts_request,
|
||||
)
|
||||
from ._function_executor import FunctionExecutor, executor
|
||||
from ._magentic import (
|
||||
MagenticAgentDeltaEvent,
|
||||
MagenticAgentExecutor,
|
||||
MagenticAgentMessageEvent,
|
||||
MagenticBuilder,
|
||||
MagenticCallbackEvent,
|
||||
MagenticCallbackMode,
|
||||
MagenticContext,
|
||||
MagenticFinalResultEvent,
|
||||
MagenticManagerBase,
|
||||
MagenticOrchestratorExecutor,
|
||||
MagenticOrchestratorMessageEvent,
|
||||
MagenticPlanReviewDecision,
|
||||
MagenticPlanReviewReply,
|
||||
MagenticPlanReviewRequest,
|
||||
MagenticProgressLedger,
|
||||
MagenticProgressLedgerItem,
|
||||
MagenticRequestMessage,
|
||||
MagenticResponseMessage,
|
||||
MagenticStartMessage,
|
||||
StandardMagenticManager,
|
||||
)
|
||||
from ._runner import Runner
|
||||
from ._runner_context import (
|
||||
InProcRunnerContext,
|
||||
Message,
|
||||
RunnerContext,
|
||||
)
|
||||
from ._sequential import SequentialBuilder
|
||||
from ._shared_state import SharedState
|
||||
from ._telemetry import EdgeGroupDeliveryStatus, WorkflowTracer, workflow_tracer
|
||||
from ._validation import (
|
||||
EdgeDuplicationError,
|
||||
GraphConnectivityError,
|
||||
HandlerOutputAnnotationError,
|
||||
TypeCompatibilityError,
|
||||
ValidationTypeEnum,
|
||||
WorkflowValidationError,
|
||||
validate_workflow_graph,
|
||||
)
|
||||
from ._viz import WorkflowViz
|
||||
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"AgentRunEvent",
|
||||
"AgentRunUpdateEvent",
|
||||
"Case",
|
||||
"CheckpointStorage",
|
||||
"ConcurrentBuilder",
|
||||
"Default",
|
||||
"Edge",
|
||||
"EdgeDuplicationError",
|
||||
"EdgeGroupDeliveryStatus",
|
||||
"Executor",
|
||||
"ExecutorCompletedEvent",
|
||||
"ExecutorEvent",
|
||||
"ExecutorFailedEvent",
|
||||
"ExecutorInvokeEvent",
|
||||
"FanInEdgeGroup",
|
||||
"FanOutEdgeGroup",
|
||||
"FileCheckpointStorage",
|
||||
"FunctionExecutor",
|
||||
"GraphConnectivityError",
|
||||
"HandlerOutputAnnotationError",
|
||||
"InMemoryCheckpointStorage",
|
||||
"InProcRunnerContext",
|
||||
"MagenticAgentDeltaEvent",
|
||||
"MagenticAgentExecutor",
|
||||
"MagenticAgentMessageEvent",
|
||||
"MagenticBuilder",
|
||||
"MagenticCallbackEvent",
|
||||
"MagenticCallbackMode",
|
||||
"MagenticContext",
|
||||
"MagenticFinalResultEvent",
|
||||
"MagenticManagerBase",
|
||||
"MagenticOrchestratorExecutor",
|
||||
"MagenticOrchestratorMessageEvent",
|
||||
"MagenticPlanReviewDecision",
|
||||
"MagenticPlanReviewReply",
|
||||
"MagenticPlanReviewRequest",
|
||||
"MagenticProgressLedger",
|
||||
"MagenticProgressLedgerItem",
|
||||
"MagenticRequestMessage",
|
||||
"MagenticResponseMessage",
|
||||
"MagenticStartMessage",
|
||||
"Message",
|
||||
"RequestInfoEvent",
|
||||
"RequestInfoExecutor",
|
||||
"RequestInfoMessage",
|
||||
"RequestResponse",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SequentialBuilder",
|
||||
"SharedState",
|
||||
"SingleEdgeGroup",
|
||||
"StandardMagenticManager",
|
||||
"SubWorkflowRequestInfo",
|
||||
"SubWorkflowResponse",
|
||||
"SwitchCaseEdgeGroup",
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
"SwitchCaseEdgeGroupDefault",
|
||||
"TypeCompatibilityError",
|
||||
"ValidationTypeEnum",
|
||||
"Workflow",
|
||||
"WorkflowAgent",
|
||||
"WorkflowBuilder",
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCompletedEvent",
|
||||
"WorkflowContext",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowExecutor",
|
||||
"WorkflowFailedEvent",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowStartedEvent",
|
||||
"WorkflowStatusEvent",
|
||||
"WorkflowTracer",
|
||||
"WorkflowValidationError",
|
||||
"WorkflowViz",
|
||||
"create_edge_runner",
|
||||
"executor",
|
||||
"handler",
|
||||
"intercepts_request",
|
||||
"validate_workflow_graph",
|
||||
"workflow_tracer",
|
||||
]
|
||||
|
||||
# Rebuild models to resolve forward references after all imports are complete
|
||||
with contextlib.suppress(AttributeError, TypeError, ValueError):
|
||||
# Rebuild WorkflowExecutor to resolve Workflow forward reference
|
||||
WorkflowExecutor.model_rebuild()
|
||||
# Rebuild WorkflowAgent to resolve Workflow forward reference
|
||||
WorkflowAgent.model_rebuild()
|
||||
@@ -0,0 +1,186 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from ._agent import WorkflowAgent
|
||||
from ._checkpoint import (
|
||||
CheckpointStorage,
|
||||
FileCheckpointStorage,
|
||||
InMemoryCheckpointStorage,
|
||||
WorkflowCheckpoint,
|
||||
)
|
||||
from ._concurrent import ConcurrentBuilder
|
||||
from ._const import DEFAULT_MAX_ITERATIONS
|
||||
from ._edge import (
|
||||
Case,
|
||||
Default,
|
||||
Edge,
|
||||
FanInEdgeGroup,
|
||||
FanOutEdgeGroup,
|
||||
SingleEdgeGroup,
|
||||
SwitchCaseEdgeGroup,
|
||||
SwitchCaseEdgeGroupCase,
|
||||
SwitchCaseEdgeGroupDefault,
|
||||
)
|
||||
from ._edge_runner import create_edge_runner
|
||||
from ._events import (
|
||||
AgentRunEvent,
|
||||
AgentRunUpdateEvent,
|
||||
ExecutorCompletedEvent,
|
||||
ExecutorEvent,
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokeEvent,
|
||||
RequestInfoEvent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from ._executor import (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
Executor,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
SubWorkflowRequestInfo,
|
||||
SubWorkflowResponse,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
intercepts_request,
|
||||
)
|
||||
from ._function_executor import FunctionExecutor, executor
|
||||
from ._magentic import (
|
||||
MagenticAgentDeltaEvent,
|
||||
MagenticAgentExecutor,
|
||||
MagenticAgentMessageEvent,
|
||||
MagenticBuilder,
|
||||
MagenticCallbackEvent,
|
||||
MagenticCallbackMode,
|
||||
MagenticContext,
|
||||
MagenticFinalResultEvent,
|
||||
MagenticManagerBase,
|
||||
MagenticOrchestratorExecutor,
|
||||
MagenticOrchestratorMessageEvent,
|
||||
MagenticPlanReviewDecision,
|
||||
MagenticPlanReviewReply,
|
||||
MagenticPlanReviewRequest,
|
||||
MagenticProgressLedger,
|
||||
MagenticProgressLedgerItem,
|
||||
MagenticRequestMessage,
|
||||
MagenticResponseMessage,
|
||||
MagenticStartMessage,
|
||||
StandardMagenticManager,
|
||||
)
|
||||
from ._runner import Runner
|
||||
from ._runner_context import (
|
||||
InProcRunnerContext,
|
||||
Message,
|
||||
RunnerContext,
|
||||
)
|
||||
from ._sequential import SequentialBuilder
|
||||
from ._shared_state import SharedState
|
||||
from ._telemetry import EdgeGroupDeliveryStatus, WorkflowTracer, workflow_tracer
|
||||
from ._validation import (
|
||||
EdgeDuplicationError,
|
||||
GraphConnectivityError,
|
||||
HandlerOutputAnnotationError,
|
||||
TypeCompatibilityError,
|
||||
ValidationTypeEnum,
|
||||
WorkflowValidationError,
|
||||
validate_workflow_graph,
|
||||
)
|
||||
from ._viz import WorkflowViz
|
||||
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"AgentRunEvent",
|
||||
"AgentRunUpdateEvent",
|
||||
"Case",
|
||||
"CheckpointStorage",
|
||||
"ConcurrentBuilder",
|
||||
"Default",
|
||||
"Edge",
|
||||
"EdgeDuplicationError",
|
||||
"EdgeGroupDeliveryStatus",
|
||||
"Executor",
|
||||
"ExecutorCompletedEvent",
|
||||
"ExecutorEvent",
|
||||
"ExecutorFailedEvent",
|
||||
"ExecutorInvokeEvent",
|
||||
"FanInEdgeGroup",
|
||||
"FanOutEdgeGroup",
|
||||
"FileCheckpointStorage",
|
||||
"FunctionExecutor",
|
||||
"GraphConnectivityError",
|
||||
"HandlerOutputAnnotationError",
|
||||
"InMemoryCheckpointStorage",
|
||||
"InProcRunnerContext",
|
||||
"MagenticAgentDeltaEvent",
|
||||
"MagenticAgentExecutor",
|
||||
"MagenticAgentMessageEvent",
|
||||
"MagenticBuilder",
|
||||
"MagenticCallbackEvent",
|
||||
"MagenticCallbackMode",
|
||||
"MagenticContext",
|
||||
"MagenticFinalResultEvent",
|
||||
"MagenticManagerBase",
|
||||
"MagenticOrchestratorExecutor",
|
||||
"MagenticOrchestratorMessageEvent",
|
||||
"MagenticPlanReviewDecision",
|
||||
"MagenticPlanReviewReply",
|
||||
"MagenticPlanReviewRequest",
|
||||
"MagenticProgressLedger",
|
||||
"MagenticProgressLedgerItem",
|
||||
"MagenticRequestMessage",
|
||||
"MagenticResponseMessage",
|
||||
"MagenticStartMessage",
|
||||
"Message",
|
||||
"RequestInfoEvent",
|
||||
"RequestInfoExecutor",
|
||||
"RequestInfoMessage",
|
||||
"RequestResponse",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SequentialBuilder",
|
||||
"SharedState",
|
||||
"SingleEdgeGroup",
|
||||
"StandardMagenticManager",
|
||||
"SubWorkflowRequestInfo",
|
||||
"SubWorkflowResponse",
|
||||
"SwitchCaseEdgeGroup",
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
"SwitchCaseEdgeGroupDefault",
|
||||
"TypeCompatibilityError",
|
||||
"ValidationTypeEnum",
|
||||
"Workflow",
|
||||
"WorkflowAgent",
|
||||
"WorkflowBuilder",
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCompletedEvent",
|
||||
"WorkflowContext",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowExecutor",
|
||||
"WorkflowFailedEvent",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowStartedEvent",
|
||||
"WorkflowStatusEvent",
|
||||
"WorkflowTracer",
|
||||
"WorkflowValidationError",
|
||||
"WorkflowViz",
|
||||
"create_edge_runner",
|
||||
"executor",
|
||||
"handler",
|
||||
"intercepts_request",
|
||||
"validate_workflow_graph",
|
||||
"workflow_tracer",
|
||||
]
|
||||
@@ -0,0 +1,454 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AgentThread,
|
||||
BaseAgent,
|
||||
ChatMessage,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
UsageDetails,
|
||||
)
|
||||
from agent_framework._pydantic import AFBaseModel
|
||||
from agent_framework.exceptions import AgentExecutionException
|
||||
|
||||
from ._events import (
|
||||
AgentRunUpdateEvent,
|
||||
RequestInfoEvent,
|
||||
WorkflowEvent,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowAgent(BaseAgent):
|
||||
"""An `Agent` subclass that wraps a workflow and exposes it as an agent."""
|
||||
|
||||
# Class variable for the request info function name
|
||||
REQUEST_INFO_FUNCTION_NAME: ClassVar[str] = "request_info"
|
||||
|
||||
class RequestInfoFunctionArgs(AFBaseModel):
|
||||
request_id: str
|
||||
data: Any
|
||||
|
||||
workflow: "Workflow" = Field(description="The workflow wrapped as an agent")
|
||||
pending_requests: dict[str, RequestInfoEvent] = Field(
|
||||
default_factory=dict, description="Pending request info events"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: "Workflow",
|
||||
*,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the WorkflowAgent.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to wrap as an agent.
|
||||
id: Unique identifier for the agent. If None, will be generated.
|
||||
name: Optional name for the agent.
|
||||
description: Optional description of the agent.
|
||||
**kwargs: Additional keyword arguments passed to BaseAgent.
|
||||
"""
|
||||
if id is None:
|
||||
id = f"WorkflowAgent_{uuid.uuid4().hex[:8]}"
|
||||
# Initialize with standard BaseAgent parameters first
|
||||
kwargs["workflow"] = workflow
|
||||
|
||||
# Validate the workflow's start executor can handle agent-facing message inputs
|
||||
try:
|
||||
start_executor = workflow.get_start_executor()
|
||||
except KeyError as exc: # Defensive: workflow lacks a configured entry point
|
||||
raise ValueError("Workflow's start executor is not defined.") from exc
|
||||
|
||||
if not start_executor.can_handle_type(list[ChatMessage]):
|
||||
raise ValueError("Workflow's start executor cannot handle list[ChatMessage]")
|
||||
|
||||
super().__init__(id=id, name=name, description=description, **kwargs)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentRunResponse:
|
||||
"""Get a response from the workflow agent (non-streaming).
|
||||
|
||||
This method collects all streaming updates and merges them into a single response.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the workflow.
|
||||
thread: The conversation thread. If None, a new thread will be created.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
The final workflow response as an AgentRunResponse.
|
||||
"""
|
||||
# Collect all streaming updates
|
||||
response_updates: list[AgentRunResponseUpdate] = []
|
||||
input_messages = self._normalize_messages(messages)
|
||||
thread = thread or self.get_new_thread()
|
||||
response_id = str(uuid.uuid4())
|
||||
|
||||
async for update in self._run_stream_impl(input_messages, response_id):
|
||||
response_updates.append(update)
|
||||
|
||||
# Convert updates to final response.
|
||||
response = self.merge_updates(response_updates, response_id)
|
||||
|
||||
# Notify thread of new messages (both input and response messages)
|
||||
await self._notify_thread_of_new_messages(thread, input_messages)
|
||||
await self._notify_thread_of_new_messages(thread, response.messages)
|
||||
|
||||
return response
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
"""Stream response updates from the workflow agent.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the workflow.
|
||||
thread: The conversation thread. If None, a new thread will be created.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Yields:
|
||||
AgentRunResponseUpdate objects representing the workflow execution progress.
|
||||
"""
|
||||
input_messages = self._normalize_messages(messages)
|
||||
thread = thread or self.get_new_thread()
|
||||
response_updates: list[AgentRunResponseUpdate] = []
|
||||
response_id = str(uuid.uuid4())
|
||||
|
||||
async for update in self._run_stream_impl(input_messages, response_id):
|
||||
response_updates.append(update)
|
||||
yield update
|
||||
|
||||
# Convert updates to final response.
|
||||
response = self.merge_updates(response_updates, response_id)
|
||||
|
||||
# Notify thread of new messages (both input and response messages)
|
||||
await self._notify_thread_of_new_messages(thread, input_messages)
|
||||
await self._notify_thread_of_new_messages(thread, response.messages)
|
||||
|
||||
async def _run_stream_impl(
|
||||
self,
|
||||
input_messages: list[ChatMessage],
|
||||
response_id: str,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
"""Internal implementation of streaming execution.
|
||||
|
||||
Args:
|
||||
input_messages: Normalized input messages to process.
|
||||
response_id: The unique response ID for this workflow execution.
|
||||
|
||||
Yields:
|
||||
AgentRunResponseUpdate objects representing the workflow execution progress.
|
||||
"""
|
||||
# Determine the event stream based on whether we have function responses
|
||||
if bool(self.pending_requests):
|
||||
# This is a continuation - use send_responses_streaming to send function responses back
|
||||
logger.info(f"Continuing workflow to address {len(self.pending_requests)} requests")
|
||||
|
||||
# Extract function responses from input messages, and ensure that
|
||||
# only function responses are present in messages if there is any
|
||||
# pending request.
|
||||
function_responses = self._extract_function_responses(input_messages)
|
||||
|
||||
# Pop pending requests if fulfilled.
|
||||
for request_id in list(self.pending_requests.keys()):
|
||||
if request_id in function_responses:
|
||||
self.pending_requests.pop(request_id)
|
||||
|
||||
# NOTE: It is possible that some pending requests are not fulfilled,
|
||||
# and we will let the workflow to handle this -- the agent does not
|
||||
# have an opinion on this.
|
||||
event_stream = self.workflow.send_responses_streaming(function_responses)
|
||||
else:
|
||||
# Execute workflow with streaming (initial run or no function responses)
|
||||
# Pass the new input messages directly to the workflow
|
||||
event_stream = self.workflow.run_stream(input_messages)
|
||||
|
||||
# Process events from the stream
|
||||
async for event in event_stream:
|
||||
# Convert workflow event to agent update
|
||||
update = self._convert_workflow_event_to_agent_update(response_id, event)
|
||||
if update:
|
||||
yield update
|
||||
|
||||
def _normalize_messages(
|
||||
self,
|
||||
messages: str | ChatMessage | Sequence[str] | Sequence[ChatMessage] | None = None,
|
||||
) -> list[ChatMessage]:
|
||||
"""Normalize input messages to a list of ChatMessage objects."""
|
||||
if messages is None:
|
||||
return []
|
||||
|
||||
if isinstance(messages, str):
|
||||
return [ChatMessage(role=Role.USER, contents=[TextContent(text=messages)])]
|
||||
|
||||
if isinstance(messages, ChatMessage):
|
||||
return [messages]
|
||||
|
||||
normalized: list[ChatMessage] = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, str):
|
||||
normalized.append(ChatMessage(role=Role.USER, contents=[TextContent(text=msg)]))
|
||||
elif isinstance(msg, ChatMessage):
|
||||
normalized.append(msg)
|
||||
return normalized
|
||||
|
||||
def _convert_workflow_event_to_agent_update(
|
||||
self,
|
||||
response_id: str,
|
||||
event: WorkflowEvent,
|
||||
) -> AgentRunResponseUpdate | None:
|
||||
"""Convert a workflow event to an AgentRunResponseUpdate.
|
||||
|
||||
Only AgentRunUpdateEvent and RequestInfoEvent are processed and the rest
|
||||
are not relevant. Returns None if the event is not relevant.
|
||||
"""
|
||||
match event:
|
||||
case AgentRunUpdateEvent(data=update):
|
||||
# Direct pass-through of update in an agent streaming event
|
||||
if update:
|
||||
return cast(AgentRunResponseUpdate, update)
|
||||
return None
|
||||
|
||||
case RequestInfoEvent(request_id=request_id):
|
||||
# Store the pending request for later correlation
|
||||
self.pending_requests[request_id] = event
|
||||
|
||||
# Convert to function call content
|
||||
# TODO(ekzhu): update this to FunctionApprovalRequestContent
|
||||
# monitor: https://github.com/microsoft/agent-framework/issues/285
|
||||
function_call = FunctionCallContent(
|
||||
call_id=request_id,
|
||||
name=self.REQUEST_INFO_FUNCTION_NAME,
|
||||
arguments=self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).model_dump(),
|
||||
)
|
||||
return AgentRunResponseUpdate(
|
||||
contents=[function_call],
|
||||
role=Role.ASSISTANT,
|
||||
author_name=self.name,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
)
|
||||
case _:
|
||||
# Ignore non-agent workflow events
|
||||
pass
|
||||
# We only care about the above two events and discard the rest.
|
||||
return None
|
||||
|
||||
def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict[str, Any]:
|
||||
"""Extract function responses from input messages."""
|
||||
function_responses: dict[str, Any] = {}
|
||||
for message in input_messages:
|
||||
for content in message.contents:
|
||||
# TODO(ekzhu): update this to FunctionApprovalResponseContent
|
||||
# monitor: https://github.com/microsoft/agent-framework/issues/285
|
||||
if isinstance(content, FunctionResultContent):
|
||||
request_id = content.call_id
|
||||
# Check if we have a pending request for this call_id
|
||||
if request_id in self.pending_requests:
|
||||
response_data = content.result if hasattr(content, "result") else str(content)
|
||||
function_responses[request_id] = response_data
|
||||
elif bool(self.pending_requests):
|
||||
# Function result for unknown request when we have pending requests - this is an error
|
||||
raise AgentExecutionException(
|
||||
"Only FunctionResultContent for pending requests is allowed in input messages "
|
||||
"when there are pending requests."
|
||||
)
|
||||
else:
|
||||
if bool(self.pending_requests):
|
||||
# Non-function content when we have pending requests - this is an error
|
||||
raise AgentExecutionException(
|
||||
"Only FunctionResultContent is allowed in input messages when there are pending requests."
|
||||
)
|
||||
return function_responses
|
||||
|
||||
class _ResponseState(TypedDict):
|
||||
"""State for grouping response updates by message_id."""
|
||||
|
||||
by_msg: dict[str, list[AgentRunResponseUpdate]]
|
||||
dangling: list[AgentRunResponseUpdate]
|
||||
|
||||
@staticmethod
|
||||
def merge_updates(updates: list[AgentRunResponseUpdate], response_id: str) -> AgentRunResponse:
|
||||
"""Merge streaming updates into a single AgentRunResponse.
|
||||
|
||||
Behavior:
|
||||
- Group updates by response_id; within each response_id, group by message_id and keep a dangling bucket for
|
||||
updates without message_id.
|
||||
- Convert each group (per message and dangling) into an intermediate AgentRunResponse via
|
||||
AgentRunResponse.from_agent_run_response_updates, then sort by created_at and merge.
|
||||
- Append messages from updates without any response_id at the end (global dangling), while aggregating metadata.
|
||||
|
||||
Args:
|
||||
updates: The list of AgentRunResponseUpdate objects to merge.
|
||||
response_id: The response identifier to set on the returned AgentRunResponse.
|
||||
|
||||
Returns:
|
||||
An AgentRunResponse with messages in processing order and aggregated metadata.
|
||||
"""
|
||||
# PHASE 1: GROUP UPDATES BY RESPONSE_ID AND MESSAGE_ID
|
||||
states: dict[str, WorkflowAgent._ResponseState] = {}
|
||||
global_dangling: list[AgentRunResponseUpdate] = []
|
||||
|
||||
for u in updates:
|
||||
if u.response_id:
|
||||
state = states.setdefault(u.response_id, {"by_msg": {}, "dangling": []})
|
||||
by_msg = state["by_msg"]
|
||||
dangling = state["dangling"]
|
||||
if u.message_id:
|
||||
by_msg.setdefault(u.message_id, []).append(u)
|
||||
else:
|
||||
dangling.append(u)
|
||||
else:
|
||||
global_dangling.append(u)
|
||||
|
||||
# HELPER FUNCTIONS
|
||||
def _parse_dt(value: str | None) -> tuple[int, datetime | str | None]:
|
||||
if not value:
|
||||
return (1, None)
|
||||
v = value
|
||||
if v.endswith("Z"):
|
||||
v = v[:-1] + "+00:00"
|
||||
try:
|
||||
return (0, datetime.fromisoformat(v))
|
||||
except Exception:
|
||||
return (0, v)
|
||||
|
||||
def _sum_usage(a: UsageDetails | None, b: UsageDetails | None) -> UsageDetails | None:
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return a + b
|
||||
|
||||
def _merge_responses(current: AgentRunResponse | None, incoming: AgentRunResponse) -> AgentRunResponse:
|
||||
if current is None:
|
||||
return incoming
|
||||
raw_list: list[object] = []
|
||||
|
||||
def _add_raw(value: object) -> None:
|
||||
if isinstance(value, list):
|
||||
raw_list.extend(cast(list[object], value))
|
||||
else:
|
||||
raw_list.append(value)
|
||||
|
||||
if current.raw_representation is not None:
|
||||
_add_raw(current.raw_representation)
|
||||
if incoming.raw_representation is not None:
|
||||
_add_raw(incoming.raw_representation)
|
||||
return AgentRunResponse(
|
||||
messages=(current.messages or []) + (incoming.messages or []),
|
||||
response_id=current.response_id or incoming.response_id,
|
||||
created_at=incoming.created_at or current.created_at,
|
||||
usage_details=_sum_usage(current.usage_details, incoming.usage_details),
|
||||
raw_representation=raw_list if raw_list else None,
|
||||
additional_properties=incoming.additional_properties or current.additional_properties,
|
||||
)
|
||||
|
||||
# PHASE 2: CONVERT GROUPED UPDATES TO RESPONSES AND MERGE
|
||||
final_messages: list[ChatMessage] = []
|
||||
merged_usage: UsageDetails | None = None
|
||||
latest_created_at: str | None = None
|
||||
merged_additional_properties: dict[str, Any] | None = None
|
||||
raw_representations: list[object] = []
|
||||
|
||||
for grouped_response_id in states:
|
||||
state = states[grouped_response_id]
|
||||
by_msg = state["by_msg"]
|
||||
dangling = state["dangling"]
|
||||
|
||||
per_message_responses: list[AgentRunResponse] = []
|
||||
for _, msg_updates in by_msg.items():
|
||||
if msg_updates:
|
||||
per_message_responses.append(AgentRunResponse.from_agent_run_response_updates(msg_updates))
|
||||
if dangling:
|
||||
per_message_responses.append(AgentRunResponse.from_agent_run_response_updates(dangling))
|
||||
|
||||
per_message_responses.sort(key=lambda r: _parse_dt(r.created_at))
|
||||
|
||||
aggregated: AgentRunResponse | None = None
|
||||
for resp in per_message_responses:
|
||||
if resp.response_id and grouped_response_id and resp.response_id != grouped_response_id:
|
||||
resp.response_id = grouped_response_id
|
||||
aggregated = _merge_responses(aggregated, resp)
|
||||
|
||||
if aggregated:
|
||||
final_messages.extend(aggregated.messages)
|
||||
if aggregated.usage_details:
|
||||
merged_usage = _sum_usage(merged_usage, aggregated.usage_details)
|
||||
if aggregated.created_at and (
|
||||
not latest_created_at or _parse_dt(aggregated.created_at) > _parse_dt(latest_created_at)
|
||||
):
|
||||
latest_created_at = aggregated.created_at
|
||||
if aggregated.additional_properties:
|
||||
if merged_additional_properties is None:
|
||||
merged_additional_properties = {}
|
||||
merged_additional_properties.update(aggregated.additional_properties)
|
||||
raw_value = aggregated.raw_representation
|
||||
if raw_value:
|
||||
cast_value = cast(object | list[object], raw_value)
|
||||
if isinstance(cast_value, list):
|
||||
raw_representations.extend(cast(list[object], cast_value))
|
||||
else:
|
||||
raw_representations.append(cast_value)
|
||||
|
||||
# PHASE 3: HANDLE GLOBAL DANGLING UPDATES (NO RESPONSE_ID)
|
||||
if global_dangling:
|
||||
flattened = AgentRunResponse.from_agent_run_response_updates(global_dangling)
|
||||
final_messages.extend(flattened.messages)
|
||||
if flattened.usage_details:
|
||||
merged_usage = _sum_usage(merged_usage, flattened.usage_details)
|
||||
if flattened.created_at and (
|
||||
not latest_created_at or _parse_dt(flattened.created_at) > _parse_dt(latest_created_at)
|
||||
):
|
||||
latest_created_at = flattened.created_at
|
||||
if flattened.additional_properties:
|
||||
if merged_additional_properties is None:
|
||||
merged_additional_properties = {}
|
||||
merged_additional_properties.update(flattened.additional_properties)
|
||||
flat_raw = flattened.raw_representation
|
||||
if flat_raw:
|
||||
cast_flat = cast(object | list[object], flat_raw)
|
||||
if isinstance(cast_flat, list):
|
||||
raw_representations.extend(cast(list[object], cast_flat))
|
||||
else:
|
||||
raw_representations.append(cast_flat)
|
||||
|
||||
# PHASE 4: CONSTRUCT FINAL RESPONSE WITH INPUT RESPONSE_ID
|
||||
return AgentRunResponse(
|
||||
messages=final_messages,
|
||||
response_id=response_id,
|
||||
created_at=latest_created_at,
|
||||
usage_details=merged_usage,
|
||||
raw_representation=raw_representations if raw_representations else None,
|
||||
additional_properties=merged_additional_properties,
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ._const import DEFAULT_MAX_ITERATIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkflowCheckpoint:
|
||||
"""Represents a complete checkpoint of workflow state."""
|
||||
|
||||
checkpoint_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
workflow_id: str = ""
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
# Core workflow state
|
||||
messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # type: ignore[misc]
|
||||
shared_state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
|
||||
executor_states: dict[str, dict[str, Any]] = field(default_factory=dict) # type: ignore[misc]
|
||||
|
||||
# Runtime state
|
||||
iteration_count: int = 0
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS
|
||||
|
||||
# Metadata
|
||||
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
|
||||
version: str = "1.0"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "WorkflowCheckpoint":
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class CheckpointStorage(Protocol):
|
||||
"""Protocol for checkpoint storage backends."""
|
||||
|
||||
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
|
||||
"""Save a checkpoint and return its ID."""
|
||||
...
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
"""Load a checkpoint by ID."""
|
||||
...
|
||||
|
||||
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
|
||||
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
|
||||
...
|
||||
|
||||
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
|
||||
...
|
||||
|
||||
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
"""Delete a checkpoint by ID."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryCheckpointStorage:
|
||||
"""In-memory checkpoint storage for testing and development."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the memory storage."""
|
||||
self._checkpoints: dict[str, WorkflowCheckpoint] = {}
|
||||
|
||||
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
|
||||
"""Save a checkpoint and return its ID."""
|
||||
self._checkpoints[checkpoint.checkpoint_id] = checkpoint
|
||||
logger.debug(f"Saved checkpoint {checkpoint.checkpoint_id} to memory")
|
||||
return checkpoint.checkpoint_id
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
"""Load a checkpoint by ID."""
|
||||
checkpoint = self._checkpoints.get(checkpoint_id)
|
||||
if checkpoint:
|
||||
logger.debug(f"Loaded checkpoint {checkpoint_id} from memory")
|
||||
return checkpoint
|
||||
|
||||
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
|
||||
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
|
||||
if workflow_id is None:
|
||||
return list(self._checkpoints.keys())
|
||||
return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
|
||||
|
||||
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
|
||||
if workflow_id is None:
|
||||
return list(self._checkpoints.values())
|
||||
return [cp for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
|
||||
|
||||
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
"""Delete a checkpoint by ID."""
|
||||
if checkpoint_id in self._checkpoints:
|
||||
del self._checkpoints[checkpoint_id]
|
||||
logger.debug(f"Deleted checkpoint {checkpoint_id} from memory")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FileCheckpointStorage:
|
||||
"""File-based checkpoint storage for persistence."""
|
||||
|
||||
def __init__(self, storage_path: str | Path):
|
||||
"""Initialize the file storage."""
|
||||
self.storage_path = Path(storage_path)
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Initialized file checkpoint storage at {self.storage_path}")
|
||||
|
||||
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
|
||||
"""Save a checkpoint and return its ID."""
|
||||
file_path = self.storage_path / f"{checkpoint.checkpoint_id}.json"
|
||||
checkpoint_dict = asdict(checkpoint)
|
||||
|
||||
def _write_atomic() -> None:
|
||||
tmp_path = file_path.with_suffix(".json.tmp")
|
||||
with open(tmp_path, "w") as f:
|
||||
json.dump(checkpoint_dict, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, file_path)
|
||||
|
||||
await asyncio.to_thread(_write_atomic)
|
||||
|
||||
logger.info(f"Saved checkpoint {checkpoint.checkpoint_id} to {file_path}")
|
||||
return checkpoint.checkpoint_id
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
"""Load a checkpoint by ID."""
|
||||
file_path = self.storage_path / f"{checkpoint_id}.json"
|
||||
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
def _read() -> dict[str, Any]:
|
||||
with open(file_path) as f:
|
||||
return json.load(f) # type: ignore[no-any-return]
|
||||
|
||||
checkpoint_dict = await asyncio.to_thread(_read)
|
||||
|
||||
checkpoint = WorkflowCheckpoint(**checkpoint_dict)
|
||||
logger.info(f"Loaded checkpoint {checkpoint_id} from {file_path}")
|
||||
return checkpoint
|
||||
|
||||
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
|
||||
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
|
||||
|
||||
def _list_ids() -> list[str]:
|
||||
checkpoint_ids: list[str] = []
|
||||
for file_path in self.storage_path.glob("*.json"):
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
data = json.load(f)
|
||||
if workflow_id is None or data.get("workflow_id") == workflow_id:
|
||||
checkpoint_ids.append(data.get("checkpoint_id", file_path.stem))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
|
||||
return checkpoint_ids
|
||||
|
||||
return await asyncio.to_thread(_list_ids)
|
||||
|
||||
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
|
||||
|
||||
def _list_checkpoints() -> list[WorkflowCheckpoint]:
|
||||
checkpoints: list[WorkflowCheckpoint] = []
|
||||
for file_path in self.storage_path.glob("*.json"):
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
data = json.load(f)
|
||||
if workflow_id is None or data.get("workflow_id") == workflow_id:
|
||||
checkpoints.append(WorkflowCheckpoint.from_dict(data))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
|
||||
return checkpoints
|
||||
|
||||
return await asyncio.to_thread(_list_checkpoints)
|
||||
|
||||
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
"""Delete a checkpoint by ID."""
|
||||
file_path = self.storage_path / f"{checkpoint_id}.json"
|
||||
|
||||
def _delete() -> bool:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
logger.info(f"Deleted checkpoint {checkpoint_id} from {file_path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_delete)
|
||||
@@ -0,0 +1,304 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentProtocol, ChatMessage, Role
|
||||
|
||||
from ._events import WorkflowCompletedEvent
|
||||
from ._executor import AgentExecutorRequest, AgentExecutorResponse, Executor, handler
|
||||
from ._workflow import Workflow, WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""Concurrent builder for agent-only fan-out/fan-in workflows.
|
||||
|
||||
This module provides a high-level, agent-focused API to quickly assemble a
|
||||
parallel workflow with:
|
||||
- a default dispatcher that broadcasts the input to all agent participants
|
||||
- a default aggregator that combines all agent conversations and completes the workflow
|
||||
|
||||
Notes:
|
||||
- Participants should be AgentProtocol instances or Executors.
|
||||
- A custom aggregator can be provided as:
|
||||
- an Executor instance (it should handle list[AgentExecutorResponse] and add a WorkflowCompletedEvent), or
|
||||
- a callback function with signature:
|
||||
def cb(results: list[AgentExecutorResponse]) -> Any | None
|
||||
def cb(results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> Any | None
|
||||
If the callback returns a non-None value, it is sent as the data of a WorkflowCompletedEvent.
|
||||
If it returns None, the callback may have already emitted a completion event via ctx.
|
||||
"""
|
||||
|
||||
|
||||
class _DispatchToAllParticipants(Executor):
|
||||
"""Broadcasts input to all downstream participants (via fan-out edges)."""
|
||||
|
||||
@handler
|
||||
async def from_request(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
# No explicit target: edge routing delivers to all connected participants.
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def from_str(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
request = AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=prompt)], should_respond=True)
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def from_message(self, message: ChatMessage, ctx: WorkflowContext[AgentExecutorRequest]) -> None: # type: ignore[name-defined]
|
||||
request = AgentExecutorRequest(messages=[message], should_respond=True)
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def from_messages(self, messages: list[ChatMessage], ctx: WorkflowContext[AgentExecutorRequest]) -> None: # type: ignore[name-defined]
|
||||
request = AgentExecutorRequest(messages=list(messages), should_respond=True)
|
||||
await ctx.send_message(request)
|
||||
|
||||
|
||||
class _AggregateAgentConversations(Executor):
|
||||
"""Aggregates agent responses and completes with combined ChatMessages.
|
||||
|
||||
Emits a list[ChatMessage] shaped as:
|
||||
[ single_user_prompt?, agent1_final_assistant, agent2_final_assistant, ... ]
|
||||
|
||||
- Extracts a single user prompt (first user message seen across results).
|
||||
- For each result, selects the final assistant message (prefers agent_run_response.messages).
|
||||
- Avoids duplicating the same user message per agent.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
|
||||
if not results:
|
||||
logger.error("Concurrent aggregator received empty results list")
|
||||
raise ValueError("Aggregation failed: no results provided")
|
||||
|
||||
def _is_role(msg: Any, role: Role) -> bool:
|
||||
r = getattr(msg, "role", None)
|
||||
if r is None:
|
||||
return False
|
||||
# Normalize both r and role to lowercase strings for comparison
|
||||
r_str = str(r).lower() if isinstance(r, str) or hasattr(r, "__str__") else r
|
||||
role_str = getattr(role, "value", None)
|
||||
if role_str is None:
|
||||
role_str = str(role)
|
||||
role_str = role_str.lower()
|
||||
return r_str == role_str
|
||||
|
||||
prompt_message: ChatMessage | None = None
|
||||
assistant_replies: list[ChatMessage] = []
|
||||
|
||||
for r in results:
|
||||
resp_messages = list(getattr(r.agent_run_response, "messages", []) or [])
|
||||
conv = r.full_conversation if r.full_conversation is not None else resp_messages
|
||||
|
||||
logger.debug(
|
||||
f"Aggregating executor {getattr(r, 'executor_id', '<unknown>')}: "
|
||||
f"{len(resp_messages)} response msgs, {len(conv)} conversation msgs"
|
||||
)
|
||||
|
||||
# Capture a single user prompt (first encountered across any conversation)
|
||||
if prompt_message is None:
|
||||
found_user = next((m for m in conv if _is_role(m, Role.USER)), None)
|
||||
if found_user is not None:
|
||||
prompt_message = found_user
|
||||
|
||||
# Pick the final assistant message from the response; fallback to conversation search
|
||||
final_assistant = next((m for m in reversed(resp_messages) if _is_role(m, Role.ASSISTANT)), None)
|
||||
if final_assistant is None:
|
||||
final_assistant = next((m for m in reversed(conv) if _is_role(m, Role.ASSISTANT)), None)
|
||||
|
||||
if final_assistant is not None:
|
||||
assistant_replies.append(final_assistant)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No assistant reply found for executor {getattr(r, 'executor_id', '<unknown>')}; skipping"
|
||||
)
|
||||
|
||||
if not assistant_replies:
|
||||
logger.error(f"Aggregation failed: no assistant replies found across {len(results)} results")
|
||||
raise RuntimeError("Aggregation failed: no assistant replies found")
|
||||
|
||||
output: list[ChatMessage] = []
|
||||
if prompt_message is not None:
|
||||
output.append(prompt_message)
|
||||
else:
|
||||
logger.warning("No user prompt found in any conversation; emitting assistants only")
|
||||
output.extend(assistant_replies)
|
||||
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=output))
|
||||
|
||||
|
||||
class _CallbackAggregator(Executor):
|
||||
"""Wraps a Python callback as an aggregator.
|
||||
|
||||
Accepts either an async or sync callback with one of the signatures:
|
||||
- (results: list[AgentExecutorResponse]) -> Any | None
|
||||
- (results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> Any | None
|
||||
|
||||
Notes:
|
||||
- Async callbacks are awaited directly.
|
||||
- Sync callbacks are executed via asyncio.to_thread to avoid blocking the event loop.
|
||||
- If the callback returns a non-None value, it is wrapped in a WorkflowCompletedEvent.
|
||||
"""
|
||||
|
||||
def __init__(self, callback: Callable[..., Any], id: str | None = None) -> None:
|
||||
super().__init__(id)
|
||||
self._callback = callback
|
||||
self._param_count = len(inspect.signature(callback).parameters)
|
||||
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
|
||||
# Call according to provided signature, always non-blocking for sync callbacks
|
||||
if self._param_count >= 2:
|
||||
if inspect.iscoroutinefunction(self._callback):
|
||||
ret = await self._callback(results, ctx) # type: ignore[misc]
|
||||
else:
|
||||
ret = await asyncio.to_thread(self._callback, results, ctx)
|
||||
else:
|
||||
if inspect.iscoroutinefunction(self._callback):
|
||||
ret = await self._callback(results) # type: ignore[misc]
|
||||
else:
|
||||
ret = await asyncio.to_thread(self._callback, results)
|
||||
|
||||
# If the callback returned a value, finalize the workflow with it
|
||||
if ret is not None:
|
||||
await ctx.add_event(WorkflowCompletedEvent(ret))
|
||||
|
||||
|
||||
class ConcurrentBuilder:
|
||||
r"""High-level builder for concurrent agent workflows.
|
||||
|
||||
- `participants([...])` accepts a list of AgentProtocol (recommended) or Executor.
|
||||
- `build()` wires: dispatcher -> fan-out -> participants -> fan-in -> aggregator.
|
||||
- `with_custom_aggregator(...)` overrides the default aggregator with an Executor or callback.
|
||||
|
||||
Usage:
|
||||
```python
|
||||
from agent_framework import ConcurrentBuilder
|
||||
|
||||
# Minimal: use default aggregator (returns list[ChatMessage])
|
||||
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).build()
|
||||
|
||||
|
||||
# Custom aggregator via callback (sync or async). The callback receives
|
||||
# list[AgentExecutorResponse] and its return value becomes
|
||||
# WorkflowCompletedEvent.data
|
||||
def summarize(results):
|
||||
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
|
||||
|
||||
|
||||
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_custom_aggregator(summarize).build()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._participants: list[AgentProtocol | Executor] = []
|
||||
self._aggregator: Executor | None = None
|
||||
|
||||
def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "ConcurrentBuilder":
|
||||
r"""Define the parallel participants for this concurrent workflow.
|
||||
|
||||
Accepts AgentProtocol instances (e.g., created by a chat client) or Executor
|
||||
instances. Each participant is wired as a parallel branch using fan-out edges
|
||||
from an internal dispatcher.
|
||||
|
||||
Raises:
|
||||
ValueError: if `participants` is empty or contains duplicates
|
||||
TypeError: if any entry is not AgentProtocol or Executor
|
||||
|
||||
Example:
|
||||
```python
|
||||
wf = ConcurrentBuilder().participants([researcher_agent, marketer_agent, legal_agent]).build()
|
||||
|
||||
# Mixing agent(s) and executor(s) is supported
|
||||
wf2 = ConcurrentBuilder().participants([researcher_agent, my_custom_executor]).build()
|
||||
```
|
||||
"""
|
||||
if not participants:
|
||||
raise ValueError("participants cannot be empty")
|
||||
|
||||
# Defensive duplicate detection
|
||||
seen_agent_ids: set[int] = set()
|
||||
seen_executor_ids: set[str] = set()
|
||||
for p in participants:
|
||||
if isinstance(p, Executor):
|
||||
if p.id in seen_executor_ids:
|
||||
raise ValueError(f"Duplicate executor participant detected: id '{p.id}'")
|
||||
seen_executor_ids.add(p.id)
|
||||
elif isinstance(p, AgentProtocol):
|
||||
pid = id(p)
|
||||
if pid in seen_agent_ids:
|
||||
raise ValueError("Duplicate agent participant detected (same agent instance provided twice)")
|
||||
seen_agent_ids.add(pid)
|
||||
else:
|
||||
raise TypeError(f"participants must be AgentProtocol or Executor instances; got {type(p).__name__}")
|
||||
|
||||
self._participants = list(participants)
|
||||
return self
|
||||
|
||||
def with_aggregator(self, aggregator: Executor | Callable[..., Any]) -> "ConcurrentBuilder":
|
||||
r"""Override the default aggregator with an Executor or a callback.
|
||||
|
||||
- Executor: must handle `list[AgentExecutorResponse]` and add a
|
||||
`WorkflowCompletedEvent` to the context.
|
||||
- Callback: sync or async callable with one of the signatures:
|
||||
`(results: list[AgentExecutorResponse]) -> Any | None` or
|
||||
`(results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> Any | None`.
|
||||
If the callback returns a non-None value, it becomes the
|
||||
`WorkflowCompletedEvent.data`.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Callback-based aggregator (string result)
|
||||
async def summarize(results):
|
||||
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
|
||||
|
||||
|
||||
wf = ConcurrentBuilder().participants([a1, a2, a3]).with_custom_aggregator(summarize).build()
|
||||
```
|
||||
"""
|
||||
if isinstance(aggregator, Executor):
|
||||
self._aggregator = aggregator
|
||||
elif callable(aggregator):
|
||||
self._aggregator = _CallbackAggregator(aggregator)
|
||||
else:
|
||||
raise TypeError("aggregator must be an Executor or a callable")
|
||||
return self
|
||||
|
||||
def build(self) -> Workflow:
|
||||
r"""Build and validate the concurrent workflow.
|
||||
|
||||
Wiring pattern:
|
||||
- Dispatcher (internal) fans out the input to all `participants`
|
||||
- Fan-in aggregator collects `AgentExecutorResponse` objects
|
||||
- Aggregator emits a `WorkflowCompletedEvent` with either:
|
||||
- list[ChatMessage] (default aggregator: one user + one assistant per agent)
|
||||
- custom payload from the provided callback/executor
|
||||
|
||||
Returns:
|
||||
Workflow: a ready-to-run workflow instance
|
||||
|
||||
Raises:
|
||||
ValueError: if no participants were defined
|
||||
|
||||
Example:
|
||||
```python
|
||||
workflow = ConcurrentBuilder().participants([agent1, agent2]).build()
|
||||
```
|
||||
"""
|
||||
if not self._participants:
|
||||
raise ValueError("No participants provided. Call .participants([...]) first.")
|
||||
|
||||
dispatcher = _DispatchToAllParticipants(id="dispatcher")
|
||||
aggregator = self._aggregator or _AggregateAgentConversations(id="aggregator")
|
||||
|
||||
builder = WorkflowBuilder()
|
||||
return (
|
||||
builder.set_start_executor(dispatcher)
|
||||
.add_fan_out_edges(dispatcher, list(self._participants))
|
||||
.add_fan_in_edges(list(self._participants), aggregator)
|
||||
.build()
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
DEFAULT_MAX_ITERATIONS = 100 # Default maximum iterations for workflow execution.
|
||||
@@ -0,0 +1,349 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from agent_framework._pydantic import AFBaseModel
|
||||
|
||||
from ._executor import Executor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_function_name(func: Callable[..., Any]) -> str:
|
||||
"""Extract the name of any callable function for serialization.
|
||||
|
||||
Args:
|
||||
func: The function to extract the name from.
|
||||
|
||||
Returns:
|
||||
The name of the function, or a placeholder for lambda functions.
|
||||
"""
|
||||
if hasattr(func, "__name__"):
|
||||
name = func.__name__
|
||||
# Check if it's a lambda function
|
||||
if name == "<lambda>":
|
||||
return "<lambda>"
|
||||
return name
|
||||
# Fallback for other callable objects
|
||||
return "<callable>"
|
||||
|
||||
|
||||
class Edge(AFBaseModel):
|
||||
"""Represents a directed edge in a graph."""
|
||||
|
||||
ID_SEPARATOR: ClassVar[str] = "->"
|
||||
|
||||
source_id: str = Field(min_length=1, description="The ID of the source executor of the edge")
|
||||
target_id: str = Field(min_length=1, description="The ID of the target executor of the edge")
|
||||
condition_name: str | None = Field(default=None, description="The name of the condition function for serialization")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
condition: Callable[[Any], bool] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the edge with a source and target node.
|
||||
|
||||
Args:
|
||||
source_id (str): The ID of the source executor of the edge.
|
||||
target_id (str): The ID of the target executor of the edge.
|
||||
condition (Callable[[Any], bool], optional): A condition function that determines
|
||||
if the edge can handle the data. If None, the edge can handle any data type.
|
||||
Defaults to None.
|
||||
kwargs: Additional keyword arguments. Unused in this implementation.
|
||||
"""
|
||||
condition_name = _extract_function_name(condition) if condition is not None else None
|
||||
kwargs.update({"source_id": source_id, "target_id": target_id, "condition_name": condition_name})
|
||||
super().__init__(**kwargs)
|
||||
self._condition = condition
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Get the unique ID of the edge."""
|
||||
return f"{self.source_id}{self.ID_SEPARATOR}{self.target_id}"
|
||||
|
||||
def should_route(self, data: Any) -> bool:
|
||||
"""Determine if message should be routed through this edge based on the condition."""
|
||||
if self._condition is None:
|
||||
return True
|
||||
|
||||
return self._condition(data)
|
||||
|
||||
|
||||
def _default_edge_list() -> list[Edge]:
|
||||
"""Get the default list of edges for the group."""
|
||||
return []
|
||||
|
||||
|
||||
class EdgeGroup(AFBaseModel):
|
||||
"""Represents a group of edges that share some common properties and can be triggered together."""
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: f"EdgeGroup/{uuid.uuid4()}", description="Unique identifier for the edge group"
|
||||
)
|
||||
type: str = Field(description="The type of edge group, corresponding to the class name")
|
||||
edges: list[Edge] = Field(default_factory=_default_edge_list, description="List of edges in this group")
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""Initialize the edge group."""
|
||||
if "id" not in kwargs:
|
||||
kwargs["id"] = f"{self.__class__.__name__}/{uuid.uuid4()}"
|
||||
if "type" not in kwargs:
|
||||
kwargs["type"] = self.__class__.__name__
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@property
|
||||
def source_executor_ids(self) -> list[str]:
|
||||
"""Get the source executor IDs of the edges in the group."""
|
||||
return list(dict.fromkeys(edge.source_id for edge in self.edges))
|
||||
|
||||
@property
|
||||
def target_executor_ids(self) -> list[str]:
|
||||
"""Get the target executor IDs of the edges in the group."""
|
||||
return list(dict.fromkeys(edge.target_id for edge in self.edges))
|
||||
|
||||
|
||||
class SingleEdgeGroup(EdgeGroup):
|
||||
"""Represents a single edge group that contains only one edge.
|
||||
|
||||
A concrete implementation of EdgeGroup that represent a group containing exactly one edge.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, source_id: str, target_id: str, condition: Callable[[Any], bool] | None = None, **kwargs: Any
|
||||
) -> None:
|
||||
"""Initialize the single edge group with an edge.
|
||||
|
||||
Args:
|
||||
source_id (str): The source executor ID.
|
||||
target_id (str): The target executor ID that the source executor can send messages to.
|
||||
condition (Callable[[Any], bool], optional): A condition function that determines
|
||||
if the edge will pass the data to the target executor. If None, the edge will
|
||||
always pass the data to the target executor.
|
||||
kwargs: Additional keyword arguments. Unused in this implementation.
|
||||
"""
|
||||
edge = Edge(source_id=source_id, target_id=target_id, condition=condition)
|
||||
kwargs["edges"] = [edge]
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class FanOutEdgeGroup(EdgeGroup):
|
||||
"""Represents a group of edges that share the same source executor.
|
||||
|
||||
Assembles a Fan-out pattern where multiple edges share the same source executor
|
||||
and send messages to their respective target executors.
|
||||
"""
|
||||
|
||||
selection_func_name: str | None = Field(
|
||||
default=None, description="The name of the selection function for serialization"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str,
|
||||
target_ids: Sequence[str],
|
||||
selection_func: Callable[[Any, list[str]], list[str]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the fan-out edge group with a list of edges.
|
||||
|
||||
Args:
|
||||
source_id (str): The source executor ID.
|
||||
target_ids (Sequence[str]): A list of target executor IDs that the source executor can send messages to.
|
||||
selection_func (Callable[[Any, list[str]], list[str]], optional): A function that selects which target
|
||||
executors to send messages to. The function takes in the message data and a list of target executor
|
||||
IDs, and returns a list of selected target executor IDs.
|
||||
kwargs: Additional keyword arguments. Unused in this implementation.
|
||||
"""
|
||||
if len(target_ids) <= 1:
|
||||
raise ValueError("FanOutEdgeGroup must contain at least two targets.")
|
||||
|
||||
# Extract selection function name for serialization
|
||||
selection_func_name = None
|
||||
if selection_func is not None:
|
||||
selection_func_name = _extract_function_name(selection_func)
|
||||
|
||||
edges = [Edge(source_id=source_id, target_id=target_id) for target_id in target_ids]
|
||||
kwargs.update({"edges": edges, "selection_func_name": selection_func_name})
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self._target_ids = list(target_ids)
|
||||
self._selection_func = selection_func
|
||||
|
||||
@property
|
||||
def target_ids(self) -> list[str]:
|
||||
"""Get the target executor IDs for selection."""
|
||||
return self._target_ids
|
||||
|
||||
@property
|
||||
def selection_func(self) -> Callable[[Any, list[str]], list[str]] | None:
|
||||
"""Get the selection function for this fan-out group."""
|
||||
return self._selection_func
|
||||
|
||||
|
||||
class FanInEdgeGroup(EdgeGroup):
|
||||
"""Represents a group of edges that share the same target executor.
|
||||
|
||||
Assembles a Fan-in pattern where multiple edges send messages to a single target executor.
|
||||
Messages are buffered until all edges in the group have data to send.
|
||||
"""
|
||||
|
||||
def __init__(self, source_ids: Sequence[str], target_id: str, **kwargs: Any) -> None:
|
||||
"""Initialize the fan-in edge group with a list of edges.
|
||||
|
||||
Args:
|
||||
source_ids (Sequence[str]): A list of source executor IDs that can send messages to the target executor.
|
||||
target_id (str): The target executor ID that receives a list of messages aggregated from all sources.
|
||||
kwargs: Additional keyword arguments. Unused in this implementation.
|
||||
"""
|
||||
if len(source_ids) <= 1:
|
||||
raise ValueError("FanInEdgeGroup must contain at least two sources.")
|
||||
|
||||
edges = [Edge(source_id=source_id, target_id=target_id) for source_id in source_ids]
|
||||
kwargs["edges"] = edges
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Case:
|
||||
"""Represents a single case in the switch-case edge group.
|
||||
|
||||
Args:
|
||||
condition (Callable[[Any], bool]): The condition function for the case.
|
||||
target (Executor): The target executor for the case.
|
||||
"""
|
||||
|
||||
condition: Callable[[Any], bool]
|
||||
target: Executor
|
||||
|
||||
|
||||
@dataclass
|
||||
class Default:
|
||||
"""Represents the default case in the switch-case edge group.
|
||||
|
||||
Args:
|
||||
target (Executor): The target executor for the default case.
|
||||
"""
|
||||
|
||||
target: Executor
|
||||
|
||||
|
||||
class SwitchCaseEdgeGroupCase(AFBaseModel):
|
||||
"""A single case in the SwitchCaseEdgeGroup. This is used internally."""
|
||||
|
||||
target_id: str = Field(description="The target executor ID for this case")
|
||||
condition_name: str | None = Field(default=None, description="The name of the condition function for serialization")
|
||||
type: str = Field(default="Case", description="The type of the case")
|
||||
|
||||
def __init__(self, condition: Callable[[Any], bool], target_id: str, **kwargs: Any) -> None:
|
||||
"""Initialize the switch case with a condition and target.
|
||||
|
||||
Args:
|
||||
condition: The condition function for the case.
|
||||
target_id: The target executor ID for this case.
|
||||
kwargs: Additional keyword arguments.
|
||||
"""
|
||||
condition_name = _extract_function_name(condition)
|
||||
kwargs.update({"target_id": target_id, "condition_name": condition_name})
|
||||
super().__init__(**kwargs)
|
||||
self._condition = condition
|
||||
|
||||
@property
|
||||
def condition(self) -> Callable[[Any], bool]:
|
||||
"""Get the condition function for this case."""
|
||||
return self._condition
|
||||
|
||||
|
||||
class SwitchCaseEdgeGroupDefault(AFBaseModel):
|
||||
"""The default case in the SwitchCaseEdgeGroup. This is used internally."""
|
||||
|
||||
target_id: str = Field(description="The target executor ID for the default case")
|
||||
type: str = Field(default="Default", description="The type of the case")
|
||||
|
||||
|
||||
def _default_case_list() -> list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault]:
|
||||
"""Get the default list of cases for the group."""
|
||||
return []
|
||||
|
||||
|
||||
class SwitchCaseEdgeGroup(FanOutEdgeGroup):
|
||||
"""Represents a group of edges that assemble a conditional routing pattern.
|
||||
|
||||
This is similar to a switch-case construct:
|
||||
switch(data):
|
||||
case condition_1:
|
||||
edge_1
|
||||
break
|
||||
case condition_2:
|
||||
edge_2
|
||||
break
|
||||
default:
|
||||
edge_3
|
||||
break
|
||||
Or equivalently an if-elif-else construct:
|
||||
if condition_1:
|
||||
edge_1
|
||||
elif condition_2:
|
||||
edge_2
|
||||
else:
|
||||
edge_4
|
||||
"""
|
||||
|
||||
cases: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = Field(
|
||||
default_factory=_default_case_list,
|
||||
description="List of conditional cases for this switch-case group",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str,
|
||||
cases: Sequence[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the switch-case edge group with a list of edges.
|
||||
|
||||
Args:
|
||||
source_id (str): The source executor ID.
|
||||
cases (Sequence[Case | Default]): A list of cases for the switch-case edge group.
|
||||
There should be exactly one default case.
|
||||
kwargs: Additional keyword arguments. Unused in this implementation.
|
||||
"""
|
||||
if len(cases) < 2:
|
||||
raise ValueError("SwitchCaseEdgeGroup must contain at least two cases (including the default case).")
|
||||
|
||||
default_case = [isinstance(case, SwitchCaseEdgeGroupDefault) for case in cases]
|
||||
if sum(default_case) != 1:
|
||||
raise ValueError("SwitchCaseEdgeGroup must contain exactly one default case.")
|
||||
|
||||
if not isinstance(cases[-1], SwitchCaseEdgeGroupDefault):
|
||||
logger.warning(
|
||||
"Default case in the switch-case edge group is not the last case. "
|
||||
"This will result in unexpected behavior."
|
||||
)
|
||||
|
||||
def selection_func(data: Any, targets: list[str]) -> list[str]:
|
||||
"""Select the target executor based on the conditions."""
|
||||
for index, case in enumerate(cases):
|
||||
if isinstance(case, SwitchCaseEdgeGroupDefault):
|
||||
return [case.target_id]
|
||||
if isinstance(case, SwitchCaseEdgeGroupCase):
|
||||
try:
|
||||
if case.condition(data):
|
||||
return [case.target_id]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error occurred while evaluating condition for case {index}: {e}")
|
||||
|
||||
raise RuntimeError("No matching case found in SwitchCaseEdgeGroup.")
|
||||
|
||||
target_ids = [case.target_id for case in cases]
|
||||
|
||||
kwargs.update({"cases": cases})
|
||||
super().__init__(source_id, target_ids, selection_func=selection_func, **kwargs)
|
||||
@@ -0,0 +1,349 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from ._edge import Edge, EdgeGroup, FanInEdgeGroup, FanOutEdgeGroup, SingleEdgeGroup, SwitchCaseEdgeGroup
|
||||
from ._executor import Executor
|
||||
from ._runner_context import Message, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
from ._telemetry import EdgeGroupDeliveryStatus, workflow_tracer
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EdgeRunner(ABC):
|
||||
"""Abstract base class for edge runners that handle message delivery."""
|
||||
|
||||
def __init__(self, edge_group: EdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
"""Initialize the edge runner with an edge group and executor map.
|
||||
|
||||
Args:
|
||||
edge_group: The edge group to run.
|
||||
executors: Map of executor IDs to executor instances.
|
||||
"""
|
||||
self._edge_group = edge_group
|
||||
self._executors = executors
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
|
||||
"""Send a message through the edge group.
|
||||
|
||||
Args:
|
||||
message: The message to send.
|
||||
shared_state: The shared state to use for holding data.
|
||||
ctx: The context for the runner.
|
||||
|
||||
Returns:
|
||||
bool: True if the message was processed successfully,
|
||||
False if the target executor cannot handle the message.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _can_handle(self, executor_id: str, message_data: Any) -> bool:
|
||||
"""Check if an executor can handle the given message data."""
|
||||
if executor_id not in self._executors:
|
||||
return False
|
||||
return self._executors[executor_id].can_handle(message_data)
|
||||
|
||||
async def _execute_on_target(
|
||||
self,
|
||||
target_id: str,
|
||||
source_ids: list[str],
|
||||
message: Message,
|
||||
shared_state: SharedState,
|
||||
ctx: RunnerContext,
|
||||
) -> None:
|
||||
"""Execute a message on a target executor with trace context."""
|
||||
if target_id not in self._executors:
|
||||
raise RuntimeError(f"Target executor {target_id} not found.")
|
||||
|
||||
target_executor = self._executors[target_id]
|
||||
|
||||
# Create WorkflowContext with trace contexts from message
|
||||
workflow_context: WorkflowContext[Any] = WorkflowContext(
|
||||
target_id,
|
||||
source_ids,
|
||||
shared_state,
|
||||
ctx,
|
||||
trace_contexts=message.trace_contexts, # Pass trace contexts to WorkflowContext
|
||||
source_span_ids=message.source_span_ids, # Pass source span IDs for linking
|
||||
)
|
||||
|
||||
# Execute with trace context in WorkflowContext
|
||||
await target_executor.execute(message.data, workflow_context)
|
||||
|
||||
|
||||
class SingleEdgeRunner(EdgeRunner):
|
||||
"""Runner for single edge groups."""
|
||||
|
||||
def __init__(self, edge_group: SingleEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
super().__init__(edge_group, executors)
|
||||
self._edge = edge_group.edges[0]
|
||||
|
||||
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
|
||||
"""Send a message through the single edge."""
|
||||
should_execute = False
|
||||
target_id = None
|
||||
source_id = None
|
||||
|
||||
with workflow_tracer.create_edge_group_processing_span(
|
||||
self._edge_group.__class__.__name__,
|
||||
edge_group_id=self._edge_group.id,
|
||||
message_source_id=message.source_id,
|
||||
message_target_id=message.target_id,
|
||||
source_trace_contexts=message.trace_contexts,
|
||||
source_span_ids=message.source_span_ids,
|
||||
):
|
||||
try:
|
||||
if message.target_id and message.target_id != self._edge.target_id:
|
||||
workflow_tracer.set_edge_group_span_attributes(
|
||||
False, EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH
|
||||
)
|
||||
return False
|
||||
|
||||
if self._can_handle(self._edge.target_id, message.data):
|
||||
if self._edge.should_route(message.data):
|
||||
workflow_tracer.set_edge_group_span_attributes(True, EdgeGroupDeliveryStatus.DELIVERED)
|
||||
should_execute = True
|
||||
target_id = self._edge.target_id
|
||||
source_id = self._edge.source_id
|
||||
else:
|
||||
workflow_tracer.set_edge_group_span_attributes(
|
||||
False, EdgeGroupDeliveryStatus.DROPPED_CONDITION_FALSE
|
||||
)
|
||||
# Return True here because message was processed, just condition failed
|
||||
return True
|
||||
else:
|
||||
workflow_tracer.set_edge_group_span_attributes(False, EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH)
|
||||
return False
|
||||
except Exception as e:
|
||||
workflow_tracer.set_edge_group_span_attributes(False, EdgeGroupDeliveryStatus.EXCEPTION)
|
||||
raise e
|
||||
|
||||
# Execute outside the span
|
||||
if should_execute and target_id and source_id:
|
||||
await self._execute_on_target(target_id, [source_id], message, shared_state, ctx)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class FanOutEdgeRunner(EdgeRunner):
|
||||
"""Runner for fan-out edge groups."""
|
||||
|
||||
def __init__(self, edge_group: FanOutEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
super().__init__(edge_group, executors)
|
||||
self._edges = edge_group.edges
|
||||
self._target_ids = edge_group.target_ids
|
||||
self._target_map = {edge.target_id: edge for edge in self._edges}
|
||||
self._selection_func = edge_group.selection_func
|
||||
|
||||
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
|
||||
"""Send a message through all edges in the fan-out edge group."""
|
||||
deliverable_edges = []
|
||||
single_target_edge = None
|
||||
|
||||
# Process routing logic within span
|
||||
with workflow_tracer.create_edge_group_processing_span(
|
||||
self._edge_group.__class__.__name__,
|
||||
edge_group_id=self._edge_group.id,
|
||||
message_source_id=message.source_id,
|
||||
message_target_id=message.target_id,
|
||||
source_trace_contexts=message.trace_contexts,
|
||||
source_span_ids=message.source_span_ids,
|
||||
):
|
||||
try:
|
||||
selection_results = (
|
||||
self._selection_func(message.data, self._target_ids) if self._selection_func else self._target_ids
|
||||
)
|
||||
if not self._validate_selection_result(selection_results):
|
||||
workflow_tracer.set_edge_group_span_attributes(False, EdgeGroupDeliveryStatus.EXCEPTION)
|
||||
raise RuntimeError(
|
||||
f"Invalid selection result: {selection_results}. "
|
||||
f"Expected selections to be a subset of valid target executor IDs: {self._target_ids}."
|
||||
)
|
||||
|
||||
if message.target_id:
|
||||
# If the target ID is specified and the selection result contains it, send the message to that edge
|
||||
if message.target_id in selection_results:
|
||||
edge = self._target_map.get(message.target_id)
|
||||
if edge and self._can_handle(edge.target_id, message.data):
|
||||
if edge.should_route(message.data):
|
||||
workflow_tracer.set_edge_group_span_attributes(True, EdgeGroupDeliveryStatus.DELIVERED)
|
||||
single_target_edge = edge
|
||||
else:
|
||||
workflow_tracer.set_edge_group_span_attributes(
|
||||
False, EdgeGroupDeliveryStatus.DROPPED_CONDITION_FALSE
|
||||
)
|
||||
# For targeted messages with condition failure, return True (message was processed)
|
||||
return True
|
||||
else:
|
||||
workflow_tracer.set_edge_group_span_attributes(
|
||||
False, EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH
|
||||
)
|
||||
# For targeted messages that can't be handled, return False
|
||||
return False
|
||||
else:
|
||||
workflow_tracer.set_edge_group_span_attributes(
|
||||
False, EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH
|
||||
)
|
||||
# For targeted messages not in selection, return False
|
||||
return False
|
||||
else:
|
||||
# If no target ID, send the message to the selected targets
|
||||
for target_id in selection_results:
|
||||
edge = self._target_map[target_id]
|
||||
if self._can_handle(edge.target_id, message.data) and edge.should_route(message.data):
|
||||
deliverable_edges.append(edge)
|
||||
|
||||
if len(deliverable_edges) > 0:
|
||||
workflow_tracer.set_edge_group_span_attributes(True, EdgeGroupDeliveryStatus.DELIVERED)
|
||||
else:
|
||||
workflow_tracer.set_edge_group_span_attributes(
|
||||
False, EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
workflow_tracer.set_edge_group_span_attributes(False, EdgeGroupDeliveryStatus.EXCEPTION)
|
||||
raise e
|
||||
|
||||
# Execute outside the span
|
||||
if single_target_edge:
|
||||
await self._execute_on_target(
|
||||
single_target_edge.target_id, [single_target_edge.source_id], message, shared_state, ctx
|
||||
)
|
||||
return True
|
||||
|
||||
if deliverable_edges:
|
||||
|
||||
async def send_to_edge(edge: Edge) -> bool:
|
||||
await self._execute_on_target(edge.target_id, [edge.source_id], message, shared_state, ctx)
|
||||
return True
|
||||
|
||||
tasks = [send_to_edge(edge) for edge in deliverable_edges]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return any(results)
|
||||
|
||||
# If we get here, it's a broadcast message with no deliverable edges
|
||||
return False
|
||||
|
||||
def _validate_selection_result(self, selection_results: list[str]) -> bool:
|
||||
"""Validate the selection results to ensure all IDs are valid target executor IDs."""
|
||||
return all(result in self._target_ids for result in selection_results)
|
||||
|
||||
|
||||
class FanInEdgeRunner(EdgeRunner):
|
||||
"""Runner for fan-in edge groups."""
|
||||
|
||||
def __init__(self, edge_group: FanInEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
super().__init__(edge_group, executors)
|
||||
self._edges = edge_group.edges
|
||||
# Buffer to hold messages before sending them to the target executor
|
||||
# Key is the source executor ID, value is a list of messages
|
||||
self._buffer: dict[str, list[Message]] = defaultdict(list)
|
||||
|
||||
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
|
||||
"""Send a message through all edges in the fan-in edge group."""
|
||||
execution_data: dict[str, Any] | None = None
|
||||
|
||||
with workflow_tracer.create_edge_group_processing_span(
|
||||
self._edge_group.__class__.__name__,
|
||||
edge_group_id=self._edge_group.id,
|
||||
message_source_id=message.source_id,
|
||||
message_target_id=message.target_id,
|
||||
source_trace_contexts=message.trace_contexts,
|
||||
source_span_ids=message.source_span_ids,
|
||||
):
|
||||
try:
|
||||
if message.target_id and message.target_id != self._edges[0].target_id:
|
||||
workflow_tracer.set_edge_group_span_attributes(
|
||||
False, EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH
|
||||
)
|
||||
return False
|
||||
|
||||
# Check if target can handle list of message data (fan-in aggregates multiple messages)
|
||||
if self._can_handle(self._edges[0].target_id, [message.data]):
|
||||
# If the edge can handle the data, buffer the message
|
||||
self._buffer[message.source_id].append(message)
|
||||
workflow_tracer.set_edge_group_span_attributes(True, EdgeGroupDeliveryStatus.BUFFERED)
|
||||
else:
|
||||
# If the edge cannot handle the data, return False
|
||||
workflow_tracer.set_edge_group_span_attributes(False, EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH)
|
||||
return False
|
||||
|
||||
if self._is_ready_to_send():
|
||||
# If all edges in the group have data, prepare for execution
|
||||
messages_to_send = [msg for edge in self._edges for msg in self._buffer[edge.source_id]]
|
||||
self._buffer.clear()
|
||||
# Send aggregated data to target
|
||||
aggregated_data = [msg.data for msg in messages_to_send]
|
||||
|
||||
# Collect all trace contexts and source span IDs for fan-in linking
|
||||
trace_contexts = [msg.trace_context for msg in messages_to_send if msg.trace_context]
|
||||
source_span_ids = [msg.source_span_id for msg in messages_to_send if msg.source_span_id]
|
||||
|
||||
# Create a new Message object for the aggregated data
|
||||
aggregated_message = Message(
|
||||
data=aggregated_data,
|
||||
source_id=self._edge_group.__class__.__name__, # This won't be used in self._execute_on_target.
|
||||
trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
)
|
||||
workflow_tracer.set_edge_group_span_attributes(True, EdgeGroupDeliveryStatus.DELIVERED)
|
||||
|
||||
# Store execution data for later
|
||||
execution_data = {
|
||||
"target_id": self._edges[0].target_id,
|
||||
"source_ids": [edge.source_id for edge in self._edges],
|
||||
"message": aggregated_message,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
workflow_tracer.set_edge_group_span_attributes(False, EdgeGroupDeliveryStatus.EXCEPTION)
|
||||
raise e
|
||||
|
||||
# Execute outside the span if needed
|
||||
if execution_data:
|
||||
await self._execute_on_target(
|
||||
execution_data["target_id"], execution_data["source_ids"], execution_data["message"], shared_state, ctx
|
||||
)
|
||||
return True
|
||||
|
||||
return True # Return True for buffered messages (waiting for more)
|
||||
|
||||
def _is_ready_to_send(self) -> bool:
|
||||
"""Check if all edges in the group have data to send."""
|
||||
return all(self._buffer[edge.source_id] for edge in self._edges)
|
||||
|
||||
|
||||
class SwitchCaseEdgeRunner(FanOutEdgeRunner):
|
||||
"""Runner for switch-case edge groups (inherits from FanOutEdgeRunner)."""
|
||||
|
||||
def __init__(self, edge_group: SwitchCaseEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
super().__init__(edge_group, executors)
|
||||
|
||||
|
||||
def create_edge_runner(edge_group: EdgeGroup, executors: dict[str, Executor]) -> EdgeRunner:
|
||||
"""Factory function to create the appropriate edge runner for an edge group.
|
||||
|
||||
Args:
|
||||
edge_group: The edge group to create a runner for.
|
||||
executors: Map of executor IDs to executor instances.
|
||||
|
||||
Returns:
|
||||
The appropriate EdgeRunner instance.
|
||||
"""
|
||||
if isinstance(edge_group, SingleEdgeGroup):
|
||||
return SingleEdgeRunner(edge_group, executors)
|
||||
if isinstance(edge_group, SwitchCaseEdgeGroup):
|
||||
return SwitchCaseEdgeRunner(edge_group, executors)
|
||||
if isinstance(edge_group, FanOutEdgeGroup):
|
||||
return FanOutEdgeRunner(edge_group, executors)
|
||||
if isinstance(edge_group, FanInEdgeGroup):
|
||||
return FanInEdgeRunner(edge_group, executors)
|
||||
raise ValueError(f"Unsupported edge group type: {type(edge_group)}")
|
||||
@@ -0,0 +1,259 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import traceback as _traceback
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._executor import RequestInfoMessage
|
||||
|
||||
|
||||
class WorkflowEvent:
|
||||
"""Base class for workflow events."""
|
||||
|
||||
def __init__(self, data: Any | None = None):
|
||||
"""Initialize the workflow event with optional data."""
|
||||
self.data = data
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the workflow event."""
|
||||
return f"{self.__class__.__name__}(data={self.data if self.data is not None else 'None'})"
|
||||
|
||||
|
||||
class WorkflowStartedEvent(WorkflowEvent):
|
||||
"""Event triggered when a workflow starts."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class WorkflowCompletedEvent(WorkflowEvent):
|
||||
"""Event triggered when a workflow completes."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class WorkflowWarningEvent(WorkflowEvent):
|
||||
"""Event triggered when a warning occurs in the workflow."""
|
||||
|
||||
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})"
|
||||
|
||||
|
||||
class WorkflowErrorEvent(WorkflowEvent):
|
||||
"""Event triggered when an error occurs in the workflow."""
|
||||
|
||||
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})"
|
||||
|
||||
|
||||
class WorkflowRunState(str, Enum):
|
||||
"""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, but has
|
||||
not yet emitted a terminal result. Rare in practice but provided for
|
||||
orchestration integrations that distinguish a quiescent state.
|
||||
|
||||
- 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.
|
||||
|
||||
- COMPLETED: Normal terminal state indicating successful completion.
|
||||
|
||||
- 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
|
||||
COMPLETED = "COMPLETED" # Finished successfully
|
||||
FAILED = "FAILED" # Finished with an error
|
||||
CANCELLED = "CANCELLED" # Finished due to cancellation
|
||||
|
||||
|
||||
class WorkflowStatusEvent(WorkflowEvent):
|
||||
"""Event indicating a transition in the workflow run state."""
|
||||
|
||||
def __init__(self, state: WorkflowRunState, data: Any | None = None):
|
||||
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})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowErrorDetails:
|
||||
"""Structured error information to surface in error events/results."""
|
||||
|
||||
error_type: str
|
||||
message: str
|
||||
traceback: str | None = None
|
||||
executor_id: str | None = None
|
||||
extra: dict[str, Any] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_exception(
|
||||
cls,
|
||||
exc: BaseException,
|
||||
*,
|
||||
executor_id: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> "WorkflowErrorDetails":
|
||||
tb = None
|
||||
try:
|
||||
tb = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
except Exception:
|
||||
tb = None
|
||||
return cls(
|
||||
error_type=exc.__class__.__name__,
|
||||
message=str(exc),
|
||||
traceback=tb,
|
||||
executor_id=executor_id,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowFailedEvent(WorkflowEvent):
|
||||
"""Terminal failure event for a workflow run."""
|
||||
|
||||
def __init__(self, details: WorkflowErrorDetails, data: Any | None = None):
|
||||
super().__init__(data)
|
||||
self.details = details
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - representation only
|
||||
return f"{self.__class__.__name__}(details={self.details}, data={self.data!r})"
|
||||
|
||||
|
||||
class RequestInfoEvent(WorkflowEvent):
|
||||
"""Event triggered when a workflow executor requests external information."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request_id: str,
|
||||
source_executor_id: str,
|
||||
request_type: type,
|
||||
request_data: "RequestInfoMessage",
|
||||
):
|
||||
"""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_type: Type of the request (e.g., a specific data type).
|
||||
request_data: The data associated with the request.
|
||||
"""
|
||||
super().__init__(request_data)
|
||||
self.request_id = request_id
|
||||
self.source_executor_id = source_executor_id
|
||||
self.request_type = request_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})"
|
||||
)
|
||||
|
||||
|
||||
class ExecutorEvent(WorkflowEvent):
|
||||
"""Base class for executor events."""
|
||||
|
||||
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
|
||||
|
||||
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})"
|
||||
|
||||
|
||||
class ExecutorInvokeEvent(ExecutorEvent):
|
||||
"""Event triggered when an executor handler is invoked."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the executor handler invoke event."""
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id})"
|
||||
|
||||
|
||||
class ExecutorCompletedEvent(ExecutorEvent):
|
||||
"""Event triggered when an executor handler is completed."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the executor handler complete event."""
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id})"
|
||||
|
||||
|
||||
class ExecutorFailedEvent(ExecutorEvent):
|
||||
"""Event triggered when an executor handler raises an error."""
|
||||
|
||||
def __init__(self, executor_id: str, details: WorkflowErrorDetails):
|
||||
super().__init__(executor_id, details)
|
||||
self.details = details
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - representation only
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id}, details={self.details})"
|
||||
|
||||
|
||||
class AgentRunUpdateEvent(ExecutorEvent):
|
||||
"""Event triggered when an agent is streaming messages."""
|
||||
|
||||
def __init__(self, executor_id: str, data: AgentRunResponseUpdate | None = None):
|
||||
"""Initialize the agent streaming event."""
|
||||
super().__init__(executor_id, data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the agent streaming event."""
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id}, messages={self.data})"
|
||||
|
||||
|
||||
class AgentRunEvent(ExecutorEvent):
|
||||
"""Event triggered when an agent run is completed."""
|
||||
|
||||
def __init__(self, executor_id: str, data: AgentRunResponse | None = None):
|
||||
"""Initialize the agent run event."""
|
||||
super().__init__(executor_id, data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the agent run event."""
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id}, data={self.data})"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Function-based Executor and decorator utilities.
|
||||
|
||||
This module provides:
|
||||
- FunctionExecutor: an Executor subclass that wraps a user-defined function
|
||||
with signature (message) or (message, ctx: WorkflowContext[T]). Both sync and async functions are supported.
|
||||
Synchronous functions are executed in a thread pool using asyncio.to_thread() to avoid blocking the event loop.
|
||||
- executor decorator: converts such a function into a ready-to-use Executor instance
|
||||
with proper type validation and handler registration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from types import UnionType
|
||||
from typing import Any, Union, get_args, get_origin, overload
|
||||
|
||||
from ._executor import Executor
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
|
||||
def _is_workflow_context_type(annotation: Any) -> bool:
|
||||
"""Check if an annotation represents WorkflowContext[T]."""
|
||||
origin = get_origin(annotation)
|
||||
if origin is WorkflowContext:
|
||||
return True
|
||||
# Also handle the case where the raw WorkflowContext class is used
|
||||
return annotation is WorkflowContext
|
||||
|
||||
|
||||
def _infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> list[type]:
|
||||
"""Infer output types list from the WorkflowContext generic parameter.
|
||||
|
||||
Examples:
|
||||
- WorkflowContext[str] -> [str]
|
||||
- WorkflowContext[str | int] -> [str, int]
|
||||
- WorkflowContext[Union[str, int]] -> [str, int]
|
||||
- WorkflowContext[Any] -> [] (unknown)
|
||||
- WorkflowContext[None] -> []
|
||||
"""
|
||||
# If no annotation or not parameterized, return empty list
|
||||
try:
|
||||
origin = get_origin(ctx_annotation)
|
||||
except Exception:
|
||||
origin = None
|
||||
|
||||
# If annotation is unsubscripted WorkflowContext, nothing to infer
|
||||
if origin is None:
|
||||
return []
|
||||
|
||||
# Expecting WorkflowContext[T]
|
||||
if origin is not WorkflowContext:
|
||||
return []
|
||||
|
||||
args = get_args(ctx_annotation)
|
||||
if not args:
|
||||
return []
|
||||
|
||||
t = args[0]
|
||||
# If t is a Union, flatten it
|
||||
t_origin = get_origin(t)
|
||||
# If Any, treat as unknown -> no output types inferred
|
||||
if t is Any:
|
||||
return []
|
||||
|
||||
if t_origin in (Union, UnionType):
|
||||
# Return all union args as-is (may include generic aliases like list[str])
|
||||
return [arg for arg in get_args(t) if arg is not Any and arg is not type(None)]
|
||||
|
||||
# Single concrete or generic alias type (e.g., str, int, list[str])
|
||||
if t is Any or t is type(None):
|
||||
return []
|
||||
return [t]
|
||||
|
||||
|
||||
class FunctionExecutor(Executor):
|
||||
"""Executor that wraps a user-defined function.
|
||||
|
||||
This executor allows users to define simple functions (both sync and async) and use them
|
||||
as workflow executors without needing to create full executor classes.
|
||||
|
||||
Synchronous functions are executed in a thread pool using asyncio.to_thread() to avoid
|
||||
blocking the event loop.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _validate_function(func: Callable[..., Any]) -> None:
|
||||
"""Validate that the function has the correct signature for an executor.
|
||||
|
||||
Args:
|
||||
func: The function to validate (can be sync or async)
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is incorrect
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
if len(params) not in (1, 2):
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} must have one or two parameters: "
|
||||
f"(message: T) or (message: T, ctx: WorkflowContext[U]). Got {len(params)} parameters."
|
||||
)
|
||||
|
||||
message_param = params[0]
|
||||
|
||||
# Check message parameter has type annotation
|
||||
if message_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Function {func.__name__} must have a type annotation for the message parameter")
|
||||
|
||||
# If there's a second parameter, validate it's WorkflowContext[T]
|
||||
if len(params) == 2:
|
||||
ctx_param = params[1]
|
||||
|
||||
# Check ctx parameter has proper type annotation
|
||||
if ctx_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Function {func.__name__} second parameter must be annotated as WorkflowContext[T]")
|
||||
|
||||
# Validate that ctx parameter is WorkflowContext[T]
|
||||
if not _is_workflow_context_type(ctx_param.annotation):
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} second parameter must be annotated as WorkflowContext[T], "
|
||||
f"got {ctx_param.annotation}"
|
||||
)
|
||||
|
||||
# Check that WorkflowContext has a concrete type parameter
|
||||
if ctx_param.annotation is WorkflowContext:
|
||||
# This is unparameterized WorkflowContext
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} WorkflowContext must be parameterized with a concrete T. "
|
||||
f"Use WorkflowContext[str], WorkflowContext[int], etc."
|
||||
)
|
||||
|
||||
if hasattr(ctx_param.annotation, "__args__") and ctx_param.annotation.__args__:
|
||||
# This is WorkflowContext[T] with a concrete T
|
||||
pass
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} WorkflowContext must be parameterized with a concrete T. "
|
||||
f"Use WorkflowContext[str], WorkflowContext[int], etc."
|
||||
)
|
||||
|
||||
def __init__(self, func: Callable[..., Any], id: str | None = None):
|
||||
"""Initialize the FunctionExecutor with a user-defined function.
|
||||
|
||||
Args:
|
||||
func: The function to wrap as an executor (can be sync or async)
|
||||
id: Optional executor ID. If None, uses the function name.
|
||||
"""
|
||||
# Validate function signature first
|
||||
self._validate_function(func)
|
||||
|
||||
# Extract types from function signature
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
message_type = params[0].annotation
|
||||
|
||||
# Determine if function has WorkflowContext parameter
|
||||
has_context = len(params) == 2
|
||||
is_async = asyncio.iscoroutinefunction(func)
|
||||
|
||||
if has_context:
|
||||
ctx_annotation = params[1].annotation
|
||||
output_types = _infer_output_types_from_ctx_annotation(ctx_annotation)
|
||||
else:
|
||||
# For single-parameter functions, we can't infer output types
|
||||
ctx_annotation = None
|
||||
output_types = []
|
||||
|
||||
# Initialize parent WITHOUT calling _discover_handlers yet
|
||||
# We'll manually set up the attributes first
|
||||
executor_id = id or getattr(func, "__name__", "FunctionExecutor")
|
||||
kwargs = {"id": executor_id, "type": "FunctionExecutor"}
|
||||
|
||||
# Set up the base class attributes manually to avoid _discover_handlers
|
||||
from pydantic import BaseModel
|
||||
|
||||
BaseModel.__init__(self, **kwargs)
|
||||
|
||||
self._handlers: dict[type, Callable[[Any, WorkflowContext[Any]], Any]] = {}
|
||||
self._request_interceptors: dict[type | str, list[dict[str, Any]]] = {}
|
||||
self._instance_handler_specs: list[dict[str, Any]] = []
|
||||
|
||||
# Store the original function and whether it has context
|
||||
self._original_func = func
|
||||
self._has_context = has_context
|
||||
self._is_async = is_async
|
||||
|
||||
# Create a wrapper function that always accepts both message and context
|
||||
if has_context and is_async:
|
||||
# Async function with context - already has the right signature
|
||||
wrapped_func: Callable[[Any, WorkflowContext[Any]], Awaitable[Any]] = func # type: ignore
|
||||
elif has_context and not is_async:
|
||||
# Sync function with context - wrap to make async using thread pool
|
||||
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
|
||||
# Call the sync function with both parameters in a thread
|
||||
return await asyncio.to_thread(func, message, ctx) # type: ignore
|
||||
|
||||
elif not has_context and is_async:
|
||||
# Async function without context - wrap to ignore context
|
||||
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
|
||||
# Call the async function with just the message
|
||||
return await func(message) # type: ignore
|
||||
|
||||
else:
|
||||
# Sync function without context - wrap to make async and ignore context using thread pool
|
||||
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
|
||||
# Call the sync function with just the message in a thread
|
||||
return await asyncio.to_thread(func, message) # type: ignore
|
||||
|
||||
# Now register our instance handler
|
||||
self.register_instance_handler(
|
||||
name=func.__name__,
|
||||
func=wrapped_func,
|
||||
message_type=message_type,
|
||||
ctx_annotation=ctx_annotation,
|
||||
output_types=output_types,
|
||||
)
|
||||
|
||||
# Now we can safely call _discover_handlers (it won't find any class-level handlers)
|
||||
self._discover_handlers()
|
||||
|
||||
|
||||
@overload
|
||||
def executor(func: Callable[..., Any]) -> FunctionExecutor: ...
|
||||
|
||||
|
||||
@overload
|
||||
def executor(*, id: str | None = None) -> Callable[[Callable[..., Any]], FunctionExecutor]: ...
|
||||
|
||||
|
||||
def executor(
|
||||
func: Callable[..., Any] | None = None, *, id: str | None = None
|
||||
) -> Callable[[Callable[..., Any]], FunctionExecutor] | FunctionExecutor:
|
||||
"""Decorator that converts a function into a FunctionExecutor instance.
|
||||
|
||||
Supports both synchronous and asynchronous functions. Synchronous functions
|
||||
are executed in a thread pool to avoid blocking the event loop.
|
||||
|
||||
Usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# With arguments (async function):
|
||||
@executor(id="upper_case")
|
||||
async def to_upper(text: str, ctx: WorkflowContext[str]):
|
||||
await ctx.send_message(text.upper())
|
||||
|
||||
|
||||
# Without parentheses (sync function - runs in thread pool):
|
||||
@executor
|
||||
def process_data(data: str):
|
||||
# Process data without sending messages
|
||||
return data.upper()
|
||||
|
||||
|
||||
# Sync function with context (runs in thread pool):
|
||||
@executor
|
||||
def sync_with_context(data: int, ctx: WorkflowContext[int]):
|
||||
# Note: sync functions can still use context
|
||||
return data * 2
|
||||
|
||||
Returns:
|
||||
An Executor instance that can be wired into a Workflow.
|
||||
"""
|
||||
|
||||
def wrapper(func: Callable[..., Any]) -> FunctionExecutor:
|
||||
return FunctionExecutor(func, id=id)
|
||||
|
||||
# If func is provided, this means @executor was used without parentheses
|
||||
if func is not None:
|
||||
return wrapper(func)
|
||||
|
||||
# Otherwise, return the wrapper for @executor() or @executor(id="...")
|
||||
return wrapper
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._executor import RequestInfoExecutor
|
||||
|
||||
from ._edge import EdgeGroup
|
||||
from ._edge_runner import EdgeRunner, create_edge_runner
|
||||
from ._events import WorkflowCompletedEvent, WorkflowEvent
|
||||
from ._executor import Executor
|
||||
from ._runner_context import Message, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
from ._typing_utils import is_instance_of
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Runner:
|
||||
"""A class to run a workflow in Pregel supersteps."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
edge_groups: Sequence[EdgeGroup],
|
||||
executors: dict[str, Executor],
|
||||
shared_state: SharedState,
|
||||
ctx: RunnerContext,
|
||||
max_iterations: int = 100,
|
||||
workflow_id: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the runner with edges, shared state, and context.
|
||||
|
||||
Args:
|
||||
edge_groups: The edge groups of the workflow.
|
||||
executors: Map of executor IDs to executor instances.
|
||||
shared_state: The shared state for the workflow.
|
||||
ctx: The runner context for the workflow.
|
||||
max_iterations: The maximum number of iterations to run.
|
||||
workflow_id: The workflow ID for checkpointing.
|
||||
"""
|
||||
self._executors = executors
|
||||
self._edge_runners = [create_edge_runner(group, executors) for group in edge_groups]
|
||||
self._edge_runner_map = self._parse_edge_runners(self._edge_runners)
|
||||
self._ctx = ctx
|
||||
self._iteration = 0
|
||||
self._max_iterations = max_iterations
|
||||
self._shared_state = shared_state
|
||||
self._workflow_id = workflow_id
|
||||
self._running = False
|
||||
self._resumed_from_checkpoint = False # Track whether we resumed
|
||||
|
||||
# Set workflow ID in context if provided
|
||||
if workflow_id:
|
||||
self._ctx.set_workflow_id(workflow_id)
|
||||
|
||||
@property
|
||||
def context(self) -> RunnerContext:
|
||||
"""Get the workflow context."""
|
||||
return self._ctx
|
||||
|
||||
def mark_resumed(self, iteration: int | None = None, max_iterations: int | None = None) -> None:
|
||||
"""Mark the runner as having resumed from a checkpoint.
|
||||
|
||||
Optionally set the current iteration and max iterations.
|
||||
"""
|
||||
self._resumed_from_checkpoint = True
|
||||
if iteration is not None:
|
||||
self._iteration = iteration
|
||||
if max_iterations is not None:
|
||||
self._max_iterations = max_iterations
|
||||
|
||||
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:
|
||||
# Emit any events already produced prior to entering loop
|
||||
if await self._ctx.has_events():
|
||||
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
|
||||
if await self._ctx.has_messages() and self._ctx.has_checkpointing():
|
||||
if not self._resumed_from_checkpoint:
|
||||
logger.info("Creating checkpoint after initial execution")
|
||||
await self._create_checkpoint_if_enabled("after_initial_execution")
|
||||
else:
|
||||
logger.info("Skipping 'after_initial_execution' checkpoint because we resumed from a checkpoint")
|
||||
|
||||
# Initialize context with starting iteration state
|
||||
await self._update_context_with_shared_state()
|
||||
|
||||
while self._iteration < self._max_iterations:
|
||||
logger.info(f"Starting superstep {self._iteration + 1}")
|
||||
|
||||
# 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, but first surface any pending events
|
||||
try:
|
||||
await iteration_task
|
||||
except Exception:
|
||||
# Make sure failure-related events (like ExecutorFailedEvent) are surfaced
|
||||
if await self._ctx.has_events():
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
raise
|
||||
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}")
|
||||
|
||||
# Create checkpoint after each superstep iteration
|
||||
await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
|
||||
|
||||
if not await self._ctx.has_messages():
|
||||
break
|
||||
|
||||
if self._iteration >= self._max_iterations and await self._ctx.has_messages():
|
||||
raise RuntimeError(f"Runner did not converge after {self._max_iterations} iterations.")
|
||||
|
||||
logger.info(f"Workflow completed after {self._iteration} supersteps")
|
||||
self._iteration = 0
|
||||
self._resumed_from_checkpoint = False # Reset resume flag for next run
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
async def _run_iteration(self) -> None:
|
||||
async def _deliver_messages(source_executor_id: str, messages: list[Message]) -> None:
|
||||
"""Outer loop to concurrently deliver messages from all sources to their targets."""
|
||||
|
||||
# Special handling for SubWorkflowRequestInfo messages
|
||||
async def _deliver_sub_workflow_requests(messages: list[Message]) -> None:
|
||||
from ._executor import SubWorkflowRequestInfo
|
||||
|
||||
# Handle SubWorkflowRequestInfo messages - only process those not already targeted
|
||||
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):
|
||||
continue
|
||||
|
||||
if isinstance(msg.data, SubWorkflowRequestInfo):
|
||||
sub_workflow_messages.append(msg)
|
||||
|
||||
for message in sub_workflow_messages:
|
||||
# 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():
|
||||
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 (
|
||||
isinstance(registered_type, type)
|
||||
and is_instance_of(sub_request.data, registered_type)
|
||||
) or (
|
||||
isinstance(registered_type, str)
|
||||
and hasattr(sub_request.data, "__class__")
|
||||
and sub_request.data.__class__.__name__ == registered_type
|
||||
):
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
# Send directly to the intercepting executor
|
||||
logger.info(
|
||||
f"Sending sub-workflow request of type '{sub_request.data.__class__.__name__}' "
|
||||
f"from sub-workflow '{sub_request.sub_workflow_id}' "
|
||||
f"to executor '{executor.id}' for interception."
|
||||
)
|
||||
# Create WorkflowContext with trace context from message
|
||||
workflow_ctx: WorkflowContext[Any] = WorkflowContext(
|
||||
executor.id,
|
||||
[message.source_id],
|
||||
self._shared_state,
|
||||
self._ctx,
|
||||
trace_contexts=[message.trace_context] if message.trace_context else None,
|
||||
source_span_ids=[message.source_span_id] if message.source_span_id else None,
|
||||
)
|
||||
await executor.execute(sub_request, workflow_ctx)
|
||||
interceptor_found = True
|
||||
break
|
||||
if interceptor_found:
|
||||
break
|
||||
|
||||
if not interceptor_found:
|
||||
# No interceptor found - send directly to RequestInfoExecutor if available.
|
||||
|
||||
# Find the RequestInfoExecutor instance
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
|
||||
if request_info_executor:
|
||||
request_info_workflow_ctx: WorkflowContext[None] = WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[message.source_id],
|
||||
self._shared_state,
|
||||
self._ctx,
|
||||
trace_contexts=[message.trace_context] if message.trace_context else None,
|
||||
source_span_ids=[message.source_span_id] if message.source_span_id else None,
|
||||
)
|
||||
logger.info(
|
||||
f"Sending sub-workflow request of type '{sub_request.data.__class__.__name__}' "
|
||||
f"from sub-workflow '{sub_request.sub_workflow_id}' to RequestInfoExecutor "
|
||||
f"'{request_info_executor.id}'"
|
||||
)
|
||||
await request_info_executor.execute(sub_request, request_info_workflow_ctx)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Sub-workflow request of type '{sub_request.data.__class__.__name__}' "
|
||||
f"from sub-workflow '{sub_request.sub_workflow_id}' could not be handled: "
|
||||
f"no RequestInfoExecutor found in the workflow. Add a RequestInfoExecutor "
|
||||
f"to handle external requests or add an @intercepts_request handler."
|
||||
)
|
||||
|
||||
async def _deliver_message_inner(edge_runner: EdgeRunner, message: Message) -> bool:
|
||||
"""Inner loop to deliver a single message through an edge runner."""
|
||||
return await edge_runner.send_message(message, self._shared_state, self._ctx)
|
||||
|
||||
# Handle SubWorkflowRequestInfo messages specially
|
||||
await _deliver_sub_workflow_requests(messages)
|
||||
|
||||
# Filter out SubWorkflowRequestInfo messages from normal edge routing
|
||||
# since they were handled specially
|
||||
from ._executor import SubWorkflowRequestInfo
|
||||
|
||||
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):
|
||||
non_sub_workflow_messages.append(msg)
|
||||
continue
|
||||
|
||||
# Skip SubWorkflowRequestInfo messages (handled by special routing)
|
||||
if isinstance(msg.data, SubWorkflowRequestInfo):
|
||||
continue
|
||||
|
||||
non_sub_workflow_messages.append(msg)
|
||||
|
||||
associated_edge_runners = self._edge_runner_map.get(source_executor_id, [])
|
||||
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."
|
||||
)
|
||||
|
||||
messages = await self._ctx.drain_messages()
|
||||
tasks = [_deliver_messages(source_executor_id, messages) for source_executor_id, messages in messages.items()]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def _create_checkpoint_if_enabled(self, checkpoint_type: str) -> str | None:
|
||||
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
|
||||
if not self._ctx.has_checkpointing():
|
||||
return None
|
||||
|
||||
try:
|
||||
# Auto-snapshot executor states
|
||||
await self._auto_snapshot_executor_states()
|
||||
await self._update_context_with_shared_state()
|
||||
checkpoint_category = "initial" if checkpoint_type == "after_initial_execution" else "superstep"
|
||||
metadata = {
|
||||
"superstep": self._iteration,
|
||||
"checkpoint_type": checkpoint_category,
|
||||
}
|
||||
checkpoint_id = await self._ctx.create_checkpoint(metadata=metadata)
|
||||
logger.info(f"Created {checkpoint_type} checkpoint: {checkpoint_id}")
|
||||
return checkpoint_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create {checkpoint_type} checkpoint: {e}")
|
||||
return None
|
||||
|
||||
async def _auto_snapshot_executor_states(self) -> None:
|
||||
"""Populate executor state by calling snapshot hooks on executors if available.
|
||||
|
||||
Convention:
|
||||
- If an executor defines an async or sync method `snapshot_state(self) -> dict`, use it.
|
||||
- Else if it has a plain attribute `state` that is a dict, use that.
|
||||
Only JSON-serializable dicts should be provided by executors.
|
||||
"""
|
||||
for exec_id, executor in self._executors.items():
|
||||
state_dict: dict[str, Any] | None = None
|
||||
snapshot = getattr(executor, "snapshot_state", None)
|
||||
try:
|
||||
if callable(snapshot):
|
||||
maybe = snapshot()
|
||||
if asyncio.iscoroutine(maybe): # type: ignore[arg-type]
|
||||
maybe = await maybe # type: ignore[assignment]
|
||||
if isinstance(maybe, dict):
|
||||
state_dict = maybe # type: ignore[assignment]
|
||||
else:
|
||||
state_attr = getattr(executor, "state", None)
|
||||
if isinstance(state_attr, dict):
|
||||
state_dict = state_attr # type: ignore[assignment]
|
||||
except Exception as ex: # pragma: no cover
|
||||
logger.debug(f"Executor {exec_id} snapshot_state failed: {ex}")
|
||||
if state_dict is not None:
|
||||
try:
|
||||
await self._ctx.set_state(exec_id, state_dict)
|
||||
except Exception as ex: # pragma: no cover
|
||||
logger.debug(f"Failed to persist state for executor {exec_id}: {ex}")
|
||||
|
||||
async def _update_context_with_shared_state(self) -> None:
|
||||
if not self._ctx.has_checkpointing():
|
||||
return
|
||||
|
||||
try:
|
||||
current_state = await self._ctx.get_checkpoint_state()
|
||||
|
||||
shared_state_data = {}
|
||||
async with self._shared_state.hold():
|
||||
if hasattr(self._shared_state, "_state"):
|
||||
shared_state_data = dict(self._shared_state._state) # type: ignore[attr-defined]
|
||||
|
||||
current_state["shared_state"] = shared_state_data
|
||||
current_state["iteration_count"] = self._iteration
|
||||
current_state["max_iterations"] = self._max_iterations
|
||||
|
||||
await self._ctx.set_checkpoint_state(current_state)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update context with shared state: {e}")
|
||||
|
||||
async def restore_from_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
"""Restore workflow state from a checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The ID of the checkpoint to restore from
|
||||
|
||||
Returns:
|
||||
True if restoration was successful, False otherwise
|
||||
"""
|
||||
if not self._ctx.has_checkpointing():
|
||||
logger.warning("Context does not support checkpointing")
|
||||
return False
|
||||
|
||||
try:
|
||||
success = await self._ctx.restore_from_checkpoint(checkpoint_id)
|
||||
if not success:
|
||||
return False
|
||||
|
||||
await self._restore_shared_state_from_context()
|
||||
self.mark_resumed() # mark resumed; iteration/max already restored from context
|
||||
logger.info(f"Successfully restored workflow from checkpoint: {checkpoint_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restore from checkpoint {checkpoint_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _restore_shared_state_from_context(self) -> None:
|
||||
if not self._ctx.has_checkpointing():
|
||||
return
|
||||
|
||||
try:
|
||||
restored_state = await self._ctx.get_checkpoint_state()
|
||||
|
||||
shared_state_data = restored_state.get("shared_state", {})
|
||||
if shared_state_data and hasattr(self._shared_state, "_state"):
|
||||
async with self._shared_state.hold():
|
||||
self._shared_state._state.clear() # type: ignore[attr-defined]
|
||||
self._shared_state._state.update(shared_state_data) # type: ignore[attr-defined]
|
||||
|
||||
self._iteration = restored_state.get("iteration_count", 0)
|
||||
self._max_iterations = restored_state.get("max_iterations", self._max_iterations)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to restore shared state from context: {e}")
|
||||
|
||||
def _parse_edge_runners(self, edge_runners: list[EdgeRunner]) -> dict[str, list[EdgeRunner]]:
|
||||
"""Parse the edge runners of the workflow into a mapping where each source executor ID maps to its edge runners.
|
||||
|
||||
Args:
|
||||
edge_runners: A list of edge runners in the workflow.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping each source executor ID to a list of edge runners.
|
||||
"""
|
||||
parsed: defaultdict[str, list[EdgeRunner]] = defaultdict(list)
|
||||
for runner in edge_runners:
|
||||
# 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
|
||||
|
||||
def _find_request_info_executor(self) -> "RequestInfoExecutor | None":
|
||||
"""Find the RequestInfoExecutor instance in this workflow.
|
||||
|
||||
Returns:
|
||||
The RequestInfoExecutor instance if found, None otherwise.
|
||||
"""
|
||||
from ._executor import RequestInfoExecutor
|
||||
|
||||
for executor in self._executors.values():
|
||||
if isinstance(executor, RequestInfoExecutor):
|
||||
return executor
|
||||
return None
|
||||
|
||||
def _is_message_to_request_info_executor(self, msg: "Message") -> bool:
|
||||
"""Check if message targets any RequestInfoExecutor in this workflow.
|
||||
|
||||
Args:
|
||||
msg: The message to check.
|
||||
|
||||
Returns:
|
||||
True if the message targets a RequestInfoExecutor, False otherwise.
|
||||
"""
|
||||
from ._executor import RequestInfoExecutor
|
||||
|
||||
if not msg.target_id:
|
||||
return False
|
||||
|
||||
# Check all executors to see if target_id matches a RequestInfoExecutor
|
||||
for executor in self._executors.values():
|
||||
if executor.id == msg.target_id and isinstance(executor, RequestInfoExecutor):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,559 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
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 AgentRunUpdateEvent, WorkflowEvent
|
||||
from ._shared_state import SharedState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""A class representing a message in the workflow."""
|
||||
|
||||
data: Any
|
||||
source_id: str
|
||||
target_id: str | None = None
|
||||
|
||||
# OpenTelemetry trace context fields for message propagation
|
||||
# These are plural to support fan-in scenarios where multiple messages are aggregated
|
||||
trace_contexts: list[dict[str, str]] | None = None # W3C Trace Context headers from multiple sources
|
||||
source_span_ids: list[str] | None = None # Publishing span IDs for linking from multiple sources
|
||||
|
||||
# Backward compatibility properties
|
||||
@property
|
||||
def trace_context(self) -> dict[str, str] | None:
|
||||
"""Get the first trace context for backward compatibility."""
|
||||
return self.trace_contexts[0] if self.trace_contexts else None
|
||||
|
||||
@property
|
||||
def source_span_id(self) -> str | None:
|
||||
"""Get the first source span ID for backward compatibility."""
|
||||
return self.source_span_ids[0] if self.source_span_ids else None
|
||||
|
||||
|
||||
class CheckpointState(TypedDict):
|
||||
messages: dict[str, list[dict[str, Any]]]
|
||||
shared_state: dict[str, Any]
|
||||
executor_states: dict[str, dict[str, Any]]
|
||||
iteration_count: int
|
||||
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.
|
||||
|
||||
A single context that supports messaging, events, and optional checkpointing.
|
||||
If checkpoint storage is not configured, checkpoint methods may raise.
|
||||
"""
|
||||
|
||||
async def send_message(self, message: Message) -> None:
|
||||
"""Send a message from the executor to the context.
|
||||
|
||||
Args:
|
||||
message: The message to be sent.
|
||||
"""
|
||||
...
|
||||
|
||||
async def drain_messages(self) -> dict[str, list[Message]]:
|
||||
"""Drain all messages from the context.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping executor IDs to lists of messages.
|
||||
"""
|
||||
...
|
||||
|
||||
async def has_messages(self) -> bool:
|
||||
"""Check if there are any messages in the context.
|
||||
|
||||
Returns:
|
||||
True if there are messages, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def add_event(self, event: WorkflowEvent) -> None:
|
||||
"""Add an event to the execution context.
|
||||
|
||||
Args:
|
||||
event: The event to be added.
|
||||
"""
|
||||
...
|
||||
|
||||
async def drain_events(self) -> list[WorkflowEvent]:
|
||||
"""Drain all events from the context.
|
||||
|
||||
Returns:
|
||||
A list of events that were added to the context.
|
||||
"""
|
||||
...
|
||||
|
||||
async def has_events(self) -> bool:
|
||||
"""Check if there are any events in the context.
|
||||
|
||||
Returns:
|
||||
True if there are events, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
executor_id: The ID of the executor whose state is being set.
|
||||
state: The state data to be set for the executor.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_state(self, executor_id: str) -> dict[str, Any] | None:
|
||||
"""Get the state for a specific executor.
|
||||
|
||||
Args:
|
||||
executor_id: The ID of the executor whose state is being retrieved.
|
||||
|
||||
Returns:
|
||||
The state data for the executor, or None if not found.
|
||||
"""
|
||||
...
|
||||
|
||||
# Checkpointing capability
|
||||
def has_checkpointing(self) -> bool:
|
||||
"""Check if the context supports checkpointing.
|
||||
|
||||
Returns:
|
||||
True if checkpointing is supported, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
# Checkpointing APIs (optional, enabled by storage)
|
||||
def set_workflow_id(self, workflow_id: str) -> None:
|
||||
"""Set the workflow ID for the context."""
|
||||
...
|
||||
|
||||
def reset_for_new_run(self, workflow_shared_state: SharedState | None = None) -> None:
|
||||
"""Reset the context for a new workflow run."""
|
||||
...
|
||||
|
||||
async def create_checkpoint(self, metadata: dict[str, Any] | None = None) -> str:
|
||||
"""Create a checkpoint of the current workflow state.
|
||||
|
||||
Args:
|
||||
metadata: Optional metadata to associate with the checkpoint.
|
||||
"""
|
||||
...
|
||||
|
||||
async def restore_from_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
"""Restore the context from a checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The ID of the checkpoint to restore from.
|
||||
|
||||
Returns:
|
||||
True if the restoration was successful, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_checkpoint_state(self) -> CheckpointState:
|
||||
"""Get the current state of the context suitable for checkpointing."""
|
||||
...
|
||||
|
||||
async def set_checkpoint_state(self, state: CheckpointState) -> None:
|
||||
"""Set the state of the context from a checkpoint.
|
||||
|
||||
Args:
|
||||
state: The state data to set for the context.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class InProcRunnerContext:
|
||||
"""In-process execution context for local execution and optional checkpointing."""
|
||||
|
||||
def __init__(self, checkpoint_storage: CheckpointStorage | None = None):
|
||||
"""Initialize the in-process execution context.
|
||||
|
||||
Args:
|
||||
checkpoint_storage: Optional storage to enable checkpointing.
|
||||
"""
|
||||
self._messages: defaultdict[str, list[Message]] = defaultdict(list)
|
||||
# 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
|
||||
self._workflow_id: str | None = None
|
||||
self._shared_state: dict[str, Any] = {}
|
||||
self._executor_states: dict[str, dict[str, Any]] = {}
|
||||
self._iteration_count: int = 0
|
||||
self._max_iterations: int = 100
|
||||
|
||||
async def send_message(self, message: Message) -> None:
|
||||
self._messages[message.source_id].append(message)
|
||||
|
||||
async def drain_messages(self) -> dict[str, list[Message]]:
|
||||
messages = dict(self._messages)
|
||||
self._messages.clear()
|
||||
return messages
|
||||
|
||||
async def has_messages(self) -> bool:
|
||||
return bool(self._messages)
|
||||
|
||||
async def add_event(self, event: WorkflowEvent) -> None:
|
||||
"""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]:
|
||||
"""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 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
|
||||
|
||||
async def get_state(self, executor_id: str) -> dict[str, Any] | None:
|
||||
return self._executor_states.get(executor_id)
|
||||
|
||||
def has_checkpointing(self) -> bool:
|
||||
return self._checkpoint_storage is not None
|
||||
|
||||
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:
|
||||
self._messages.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
|
||||
if workflow_shared_state is not None and hasattr(workflow_shared_state, "_state"):
|
||||
workflow_shared_state._state.clear() # type: ignore[attr-defined]
|
||||
|
||||
async def create_checkpoint(self, metadata: dict[str, Any] | None = None) -> str:
|
||||
if not self._checkpoint_storage:
|
||||
raise ValueError("Checkpoint storage not configured")
|
||||
|
||||
wf_id = self._workflow_id or str(uuid.uuid4())
|
||||
self._workflow_id = wf_id
|
||||
state = await self.get_checkpoint_state()
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_id=wf_id,
|
||||
messages=state["messages"],
|
||||
shared_state=state.get("shared_state", {}),
|
||||
executor_states=state.get("executor_states", {}),
|
||||
iteration_count=state.get("iteration_count", 0),
|
||||
max_iterations=state.get("max_iterations", DEFAULT_MAX_ITERATIONS),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
checkpoint_id = await self._checkpoint_storage.save_checkpoint(checkpoint)
|
||||
logger.info(f"Created checkpoint {checkpoint_id} for workflow {wf_id}'")
|
||||
return checkpoint_id
|
||||
|
||||
async def restore_from_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
if not self._checkpoint_storage:
|
||||
raise ValueError("Checkpoint storage not configured")
|
||||
|
||||
checkpoint = await self._checkpoint_storage.load_checkpoint(checkpoint_id)
|
||||
if not checkpoint:
|
||||
logger.error(f"Checkpoint {checkpoint_id} not found")
|
||||
return False
|
||||
|
||||
state: CheckpointState = {
|
||||
"messages": checkpoint.messages,
|
||||
"shared_state": checkpoint.shared_state,
|
||||
"executor_states": checkpoint.executor_states,
|
||||
"iteration_count": checkpoint.iteration_count,
|
||||
"max_iterations": checkpoint.max_iterations,
|
||||
}
|
||||
await self.set_checkpoint_state(state)
|
||||
self._workflow_id = checkpoint.workflow_id
|
||||
logger.info(f"Restored state from checkpoint {checkpoint_id}'")
|
||||
return True
|
||||
|
||||
async def get_checkpoint_state(self) -> CheckpointState:
|
||||
serializable_messages: dict[str, list[dict[str, Any]]] = {}
|
||||
for source_id, message_list in self._messages.items():
|
||||
serializable_messages[source_id] = [
|
||||
{
|
||||
"data": _encode_checkpoint_value(msg.data),
|
||||
"source_id": msg.source_id,
|
||||
"target_id": msg.target_id,
|
||||
"trace_contexts": msg.trace_contexts,
|
||||
"source_span_ids": msg.source_span_ids,
|
||||
}
|
||||
for msg in message_list
|
||||
]
|
||||
return {
|
||||
"messages": serializable_messages,
|
||||
"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,
|
||||
}
|
||||
|
||||
async def set_checkpoint_state(self, state: CheckpointState) -> None:
|
||||
self._messages.clear()
|
||||
messages_data = state.get("messages", {})
|
||||
for source_id, message_list in messages_data.items():
|
||||
self._messages[source_id] = [
|
||||
Message(
|
||||
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"),
|
||||
source_span_ids=msg.get("source_span_ids"),
|
||||
)
|
||||
for msg in message_list
|
||||
]
|
||||
# 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)
|
||||
@@ -0,0 +1,187 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Sequential builder for agent/executor workflows with shared conversation context.
|
||||
|
||||
This module provides a high-level, agent-focused API to assemble a sequential
|
||||
workflow where:
|
||||
- Participants are a sequence of AgentProtocol instances or Executors
|
||||
- A shared conversation context (list[ChatMessage]) is passed along the chain
|
||||
- Agents append their assistant messages to the context
|
||||
- Custom executors can transform or summarize and return a refined context
|
||||
- The workflow completes with the final context produced by the last participant
|
||||
|
||||
Typical wiring:
|
||||
input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> ... -> participantN -> _CompleteWithConversation
|
||||
|
||||
Notes:
|
||||
- Participants can mix AgentProtocol and Executor objects
|
||||
- Agents are auto-wrapped by WorkflowBuilder as AgentExecutor
|
||||
- AgentExecutor produces AgentExecutorResponse; _ResponseToConversation converts this to list[ChatMessage]
|
||||
- Non-agent executors must define a handler that consumes `list[ChatMessage]` and sends back
|
||||
the updated `list[ChatMessage]` via their workflow context
|
||||
|
||||
Why include the small internal adapter executors?
|
||||
- Input normalization ("input-conversation"): ensures the workflow always starts with a
|
||||
`list[ChatMessage]` regardless of whether callers pass a `str`, a single `ChatMessage`,
|
||||
or a list. This keeps the first hop strongly typed and avoids boilerplate in participants.
|
||||
- Agent response adaptation ("to-conversation:<participant>"): agents (via AgentExecutor)
|
||||
emit `AgentExecutorResponse`. The adapter converts that to a `list[ChatMessage]`
|
||||
using `full_conversation` so original prompts aren't lost when chaining.
|
||||
- Explicit completion ("complete"): emits a `WorkflowCompletedEvent` with the final
|
||||
conversation list, giving a consistent terminal payload shape for both agents and
|
||||
custom executors.
|
||||
|
||||
These adapters are first-class executors by design so they are type-checked at edges,
|
||||
observable (ExecutorInvoke/Completed events), and easily testable/reusable. Their IDs are
|
||||
deterministic and self-describing (for example, "to-conversation:writer") to reduce event-log
|
||||
confusion and to mirror how the concurrent builder uses explicit dispatcher/aggregator nodes.
|
||||
""" # noqa: E501
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentProtocol, ChatMessage, Role
|
||||
|
||||
from ._events import WorkflowCompletedEvent
|
||||
from ._executor import (
|
||||
AgentExecutor,
|
||||
AgentExecutorResponse,
|
||||
Executor,
|
||||
handler,
|
||||
)
|
||||
from ._workflow import Workflow, WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _InputToConversation(Executor):
|
||||
"""Normalizes initial input into a list[ChatMessage] conversation."""
|
||||
|
||||
@handler
|
||||
async def from_str(self, prompt: str, ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
await ctx.send_message([ChatMessage(Role.USER, text=prompt)])
|
||||
|
||||
@handler
|
||||
async def from_message(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None: # type: ignore[name-defined]
|
||||
await ctx.send_message([message])
|
||||
|
||||
@handler
|
||||
async def from_messages(self, messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: # type: ignore[name-defined]
|
||||
# Make a copy to avoid mutation downstream
|
||||
await ctx.send_message(list(messages))
|
||||
|
||||
|
||||
class _ResponseToConversation(Executor):
|
||||
"""Converts AgentExecutorResponse to list[ChatMessage] conversation for chaining."""
|
||||
|
||||
@handler
|
||||
async def convert(self, response: AgentExecutorResponse, ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
# Always use full_conversation; AgentExecutor guarantees it is populated.
|
||||
if response.full_conversation is None: # Defensive: indicates a contract violation
|
||||
raise RuntimeError("AgentExecutorResponse.full_conversation missing. AgentExecutor must populate it.")
|
||||
await ctx.send_message(list(response.full_conversation))
|
||||
|
||||
|
||||
class _CompleteWithConversation(Executor):
|
||||
"""Terminates the workflow by emitting the final conversation context."""
|
||||
|
||||
@handler
|
||||
async def complete(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any]) -> None:
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=list(conversation)))
|
||||
|
||||
|
||||
class SequentialBuilder:
|
||||
r"""High-level builder for sequential agent/executor workflows with shared context.
|
||||
|
||||
- `participants([...])` accepts a list of AgentProtocol (recommended) or Executor
|
||||
- The workflow wires participants in order, passing a list[ChatMessage] down the chain
|
||||
- Agents append their assistant messages to the conversation
|
||||
- Custom executors can transform/summarize and return a list[ChatMessage]
|
||||
- The final output is the conversation produced by the last participant
|
||||
|
||||
Usage:
|
||||
```python
|
||||
from agent_framework import SequentialBuilder
|
||||
|
||||
workflow = SequentialBuilder().participants([agent1, agent2, summarizer_exec]).build()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._participants: list[AgentProtocol | Executor] = []
|
||||
|
||||
def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "SequentialBuilder":
|
||||
"""Define the ordered participants for this sequential workflow.
|
||||
|
||||
Accepts AgentProtocol instances (auto-wrapped as AgentExecutor) or Executor instances.
|
||||
Raises if empty or duplicates are provided for clarity.
|
||||
"""
|
||||
if not participants:
|
||||
raise ValueError("participants cannot be empty")
|
||||
|
||||
# Defensive duplicate detection
|
||||
seen_agent_ids: set[int] = set()
|
||||
seen_executor_ids: set[str] = set()
|
||||
for p in participants:
|
||||
if isinstance(p, Executor):
|
||||
if p.id in seen_executor_ids:
|
||||
raise ValueError(f"Duplicate executor participant detected: id '{p.id}'")
|
||||
seen_executor_ids.add(p.id)
|
||||
else:
|
||||
# Treat non-Executor as agent-like (AgentProtocol). Structural checks can be brittle at runtime.
|
||||
pid = id(p)
|
||||
if pid in seen_agent_ids:
|
||||
raise ValueError("Duplicate agent participant detected (same agent instance provided twice)")
|
||||
seen_agent_ids.add(pid)
|
||||
|
||||
self._participants = list(participants)
|
||||
return self
|
||||
|
||||
def build(self) -> Workflow:
|
||||
"""Build and validate the sequential workflow.
|
||||
|
||||
Wiring pattern:
|
||||
- _InputToConversation normalizes the initial input into list[ChatMessage]
|
||||
- For each participant in order:
|
||||
- If Agent (or AgentExecutor): pass conversation to the agent, then convert response
|
||||
to conversation via _ResponseToConversation
|
||||
- Else (custom Executor): pass conversation directly to the executor
|
||||
- _CompleteWithConversation emits WorkflowCompletedEvent with the final conversation
|
||||
"""
|
||||
if not self._participants:
|
||||
raise ValueError("No participants provided. Call .participants([...]) first.")
|
||||
|
||||
# Internal nodes
|
||||
input_conv = _InputToConversation(id="input-conversation")
|
||||
complete = _CompleteWithConversation(id="complete")
|
||||
|
||||
builder = WorkflowBuilder()
|
||||
builder.set_start_executor(input_conv)
|
||||
|
||||
# Start of the chain is the input normalizer
|
||||
prior: Executor | AgentProtocol = input_conv
|
||||
|
||||
for p in self._participants:
|
||||
# Agent-like branch: either explicitly an AgentExecutor or any non-AgentExecutor
|
||||
if not (isinstance(p, Executor) and not isinstance(p, AgentExecutor)):
|
||||
# input conversation -> (agent) -> response -> conversation
|
||||
builder.add_edge(prior, p)
|
||||
# Give the adapter a deterministic, self-describing id
|
||||
label: str
|
||||
label = p.id if isinstance(p, Executor) else getattr(p, "name", None) or p.__class__.__name__
|
||||
resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}")
|
||||
builder.add_edge(p, resp_to_conv)
|
||||
prior = resp_to_conv
|
||||
elif isinstance(p, Executor):
|
||||
# Custom executor operates on list[ChatMessage]
|
||||
builder.add_edge(prior, p)
|
||||
prior = p
|
||||
else: # pragma: no cover - defensive
|
||||
raise TypeError(f"Unsupported participant type: {type(p).__name__}")
|
||||
|
||||
# Terminate with the final conversation
|
||||
builder.add_edge(prior, complete)
|
||||
|
||||
return builder.build()
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SharedState:
|
||||
"""A class to manage shared state in a workflow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the shared state."""
|
||||
self._state: dict[str, Any] = {}
|
||||
self._shared_state_lock = asyncio.Lock()
|
||||
|
||||
async def set(self, key: str, value: Any) -> None:
|
||||
"""Set a value in the shared state."""
|
||||
async with self._shared_state_lock:
|
||||
await self.set_within_hold(key, value)
|
||||
|
||||
async def get(self, key: str) -> Any:
|
||||
"""Get a value from the shared state."""
|
||||
async with self._shared_state_lock:
|
||||
return await self.get_within_hold(key)
|
||||
|
||||
async def has(self, key: str) -> bool:
|
||||
"""Check if a key exists in the shared state."""
|
||||
async with self._shared_state_lock:
|
||||
return await self.has_within_hold(key)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete a key from the shared state."""
|
||||
async with self._shared_state_lock:
|
||||
await self.delete_within_hold(key)
|
||||
|
||||
@asynccontextmanager
|
||||
async def hold(self) -> AsyncIterator["SharedState"]:
|
||||
"""Context manager to hold the shared state lock for multiple operations.
|
||||
|
||||
Usage:
|
||||
async with shared_state.hold():
|
||||
await shared_state.set_within_hold("key", value)
|
||||
value = await shared_state.get_within_hold("key")
|
||||
"""
|
||||
async with self._shared_state_lock:
|
||||
yield self
|
||||
|
||||
# Unsafe methods that don't acquire locks (for use within hold() context)
|
||||
async def set_within_hold(self, key: str, value: Any) -> None:
|
||||
"""Set a value without acquiring the lock (unsafe - use within hold() context)."""
|
||||
self._state[key] = value
|
||||
|
||||
async def get_within_hold(self, key: str) -> Any:
|
||||
"""Get a value without acquiring the lock (unsafe - use within hold() context)."""
|
||||
if key not in self._state:
|
||||
raise KeyError(f"Key '{key}' not found in shared state.")
|
||||
return self._state[key]
|
||||
|
||||
async def has_within_hold(self, key: str) -> bool:
|
||||
"""Check if a key exists without acquiring the lock (unsafe - use within hold() context)."""
|
||||
return key in self._state
|
||||
|
||||
async def delete_within_hold(self, key: str) -> None:
|
||||
"""Delete a key without acquiring the lock (unsafe - use within hold() context)."""
|
||||
if key in self._state:
|
||||
del self._state[key]
|
||||
else:
|
||||
raise KeyError(f"Key '{key}' not found in shared state.")
|
||||
@@ -0,0 +1,310 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from opentelemetry.trace import Link, NoOpTracer, SpanKind, StatusCode, get_current_span, get_tracer
|
||||
from opentelemetry.trace.span import SpanContext
|
||||
from opentelemetry.util.types import Attributes
|
||||
|
||||
from agent_framework._pydantic import AFBaseSettings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
|
||||
class EdgeGroupDeliveryStatus(Enum):
|
||||
"""Enum for edge group delivery status values."""
|
||||
|
||||
DELIVERED = "delivered"
|
||||
DROPPED_TYPE_MISMATCH = "dropped type mismatch"
|
||||
DROPPED_TARGET_MISMATCH = "dropped target mismatch"
|
||||
DROPPED_CONDITION_FALSE = "dropped condition evaluated to false"
|
||||
EXCEPTION = "exception"
|
||||
BUFFERED = "buffered"
|
||||
|
||||
|
||||
# Span name constants
|
||||
_WORKFLOW_BUILD_SPAN = "workflow.build"
|
||||
_WORKFLOW_RUN_SPAN = "workflow.run"
|
||||
_EXECUTOR_PROCESS_SPAN = "executor.process"
|
||||
_MESSAGE_SEND_SPAN = "message.send"
|
||||
_EDGE_GROUP_PROCESS_SPAN = "edge_group.process"
|
||||
|
||||
|
||||
class WorkflowDiagnosticSettings(AFBaseSettings):
|
||||
"""Settings for workflow tracing diagnostics."""
|
||||
|
||||
env_prefix: ClassVar[str] = "AGENT_FRAMEWORK_WORKFLOW_"
|
||||
enable_otel: bool = False
|
||||
|
||||
@property
|
||||
def ENABLED(self) -> bool:
|
||||
return self.enable_otel
|
||||
|
||||
|
||||
class WorkflowTracer:
|
||||
"""Central tracing coordinator for workflow system.
|
||||
|
||||
Manages OpenTelemetry span creation and relationships for:
|
||||
- Workflow build spans (workflow.build)
|
||||
- Workflow execution spans (workflow.run)
|
||||
- Executor processing spans (executor.process)
|
||||
- Message sending spans (message.send)
|
||||
- Edge group processing spans (edge_group.process)
|
||||
|
||||
Implements span linking for causality without unwanted nesting.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.settings = WorkflowDiagnosticSettings()
|
||||
self.tracer = get_tracer("agent_framework") if self.settings.ENABLED else NoOpTracer()
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.settings.ENABLED
|
||||
|
||||
def create_workflow_run_span(self, workflow: "Workflow") -> Any:
|
||||
"""Create a workflow execution span."""
|
||||
attributes: dict[str, str | int] = {
|
||||
"workflow.id": workflow.id,
|
||||
}
|
||||
|
||||
return self.tracer.start_as_current_span(_WORKFLOW_RUN_SPAN, kind=SpanKind.INTERNAL, attributes=attributes)
|
||||
|
||||
def create_workflow_build_span(self) -> Any:
|
||||
"""Create a workflow build span."""
|
||||
return self.tracer.start_as_current_span(_WORKFLOW_BUILD_SPAN, kind=SpanKind.INTERNAL)
|
||||
|
||||
def create_processing_span(
|
||||
self,
|
||||
executor_id: str,
|
||||
executor_type: str,
|
||||
message_type: str,
|
||||
source_trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
) -> Any:
|
||||
"""Create an executor processing span with optional links to source spans.
|
||||
|
||||
Processing spans are created as children of the current workflow span and
|
||||
linked (not nested) to the source publishing spans for causality tracking.
|
||||
This supports multiple links for fan-in scenarios.
|
||||
"""
|
||||
# Create links to source spans for causality without nesting
|
||||
links = []
|
||||
if source_trace_contexts and source_span_ids:
|
||||
# Create links for all source spans (supporting fan-in with multiple sources)
|
||||
for trace_context, span_id in zip(source_trace_contexts, source_span_ids, strict=False):
|
||||
try:
|
||||
# Extract trace and span IDs from the trace context
|
||||
# This is a simplified approach - in production you'd want more robust parsing
|
||||
traceparent = trace_context.get("traceparent", "")
|
||||
if traceparent:
|
||||
# traceparent format: "00-{trace_id}-{parent_span_id}-{trace_flags}"
|
||||
parts = traceparent.split("-")
|
||||
if len(parts) >= 3:
|
||||
trace_id_hex = parts[1]
|
||||
# Use the source_span_id that was saved from the publishing span
|
||||
|
||||
# Create span context for linking
|
||||
span_context = SpanContext(
|
||||
trace_id=int(trace_id_hex, 16),
|
||||
span_id=int(span_id, 16),
|
||||
is_remote=True,
|
||||
)
|
||||
links.append(Link(span_context))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
# If linking fails, continue without link (graceful degradation)
|
||||
pass
|
||||
|
||||
return self.tracer.start_as_current_span(
|
||||
_EXECUTOR_PROCESS_SPAN,
|
||||
kind=SpanKind.INTERNAL,
|
||||
attributes={
|
||||
"executor.id": executor_id,
|
||||
"executor.type": executor_type,
|
||||
"message.type": message_type,
|
||||
},
|
||||
links=links,
|
||||
)
|
||||
|
||||
def create_sending_span(self, message_type: str, target_executor_id: str | None = None) -> Any:
|
||||
"""Create a message send span.
|
||||
|
||||
Sending spans are created as children of the current processing span
|
||||
to track message emission for distributed tracing.
|
||||
"""
|
||||
attributes: dict[str, str] = {
|
||||
"message.type": message_type,
|
||||
}
|
||||
if target_executor_id is not None:
|
||||
attributes["message.destination_executor_id"] = target_executor_id
|
||||
|
||||
return self.tracer.start_as_current_span(
|
||||
_MESSAGE_SEND_SPAN,
|
||||
kind=SpanKind.PRODUCER,
|
||||
attributes=attributes,
|
||||
)
|
||||
|
||||
def create_edge_group_processing_span(
|
||||
self,
|
||||
edge_group_type: str,
|
||||
edge_group_id: str | None = None,
|
||||
message_source_id: str | None = None,
|
||||
message_target_id: str | None = None,
|
||||
source_trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
) -> Any:
|
||||
"""Create an edge group processing span with optional links to source spans.
|
||||
|
||||
Edge group processing spans track the processing operations in edge runners
|
||||
before message delivery, including condition checking and routing decisions.
|
||||
Links to source spans provide causality tracking without unwanted nesting.
|
||||
|
||||
Args:
|
||||
edge_group_type: The type of the edge group (class name).
|
||||
edge_group_id: The unique ID of the edge group.
|
||||
message_source_id: The source ID of the message being processed.
|
||||
message_target_id: The target ID of the message being processed.
|
||||
source_trace_contexts: Optional trace contexts from source spans for linking.
|
||||
source_span_ids: Optional source span IDs for linking.
|
||||
"""
|
||||
attributes: dict[str, str] = {
|
||||
"edge_group.type": edge_group_type,
|
||||
}
|
||||
|
||||
if edge_group_id is not None:
|
||||
attributes["edge_group.id"] = edge_group_id
|
||||
if message_source_id is not None:
|
||||
attributes["message.source_id"] = message_source_id
|
||||
if message_target_id is not None:
|
||||
attributes["message.target_id"] = message_target_id
|
||||
|
||||
# Create links to source spans for causality without nesting
|
||||
links = []
|
||||
if source_trace_contexts and source_span_ids:
|
||||
# Create links for all source spans (supporting fan-in with multiple sources)
|
||||
for trace_context, span_id in zip(source_trace_contexts, source_span_ids, strict=False):
|
||||
try:
|
||||
# Extract trace and span IDs from the trace context
|
||||
# This is a simplified approach - in production you'd want more robust parsing
|
||||
traceparent = trace_context.get("traceparent", "")
|
||||
if traceparent:
|
||||
# traceparent format: "00-{trace_id}-{parent_span_id}-{trace_flags}"
|
||||
parts = traceparent.split("-")
|
||||
if len(parts) >= 3:
|
||||
trace_id_hex = parts[1]
|
||||
# Use the source_span_id that was saved from the publishing span
|
||||
|
||||
# Create span context for linking
|
||||
span_context = SpanContext(
|
||||
trace_id=int(trace_id_hex, 16),
|
||||
span_id=int(span_id, 16),
|
||||
is_remote=True,
|
||||
)
|
||||
links.append(Link(span_context))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
# If linking fails, continue without link (graceful degradation)
|
||||
pass
|
||||
|
||||
return self.tracer.start_as_current_span(
|
||||
_EDGE_GROUP_PROCESS_SPAN,
|
||||
kind=SpanKind.INTERNAL,
|
||||
attributes=attributes,
|
||||
links=links,
|
||||
)
|
||||
|
||||
def set_edge_group_span_attributes(self, delivered: bool, delivery_status: EdgeGroupDeliveryStatus) -> None:
|
||||
"""Set edge group span attributes for delivery status.
|
||||
|
||||
Args:
|
||||
delivered: Whether the message was delivered.
|
||||
delivery_status: The delivery status from EdgeGroupDeliveryStatus enum.
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
span.set_attributes({
|
||||
"edge_group.delivered": delivered,
|
||||
"edge_group.delivery_status": delivery_status.value,
|
||||
})
|
||||
|
||||
def add_workflow_event(self, event_name: str, attributes: Attributes | None = None) -> None:
|
||||
"""Add an event to the current workflow span.
|
||||
|
||||
Args:
|
||||
event_name: Name of the event (e.g., "workflow.started", "workflow.completed")
|
||||
attributes: Optional attributes to attach to the event
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
span.add_event(event_name, attributes)
|
||||
|
||||
def add_workflow_error_event(self, error: Exception, attributes: Attributes | None = None) -> None:
|
||||
"""Add an error event to the current workflow span.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
attributes: Optional additional attributes to attach to the event
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
event_attributes: dict[str, str | bool | int | float] = {
|
||||
"error.message": str(error),
|
||||
"error.type": type(error).__name__,
|
||||
}
|
||||
if attributes:
|
||||
# Safely merge attributes, ensuring type compatibility
|
||||
for key, value in attributes.items():
|
||||
if isinstance(value, (str, bool, int, float)):
|
||||
event_attributes[key] = value
|
||||
span.add_event("workflow.error", event_attributes)
|
||||
span.set_status(StatusCode.ERROR, str(error))
|
||||
|
||||
def set_workflow_build_span_attributes(self, workflow: "Workflow") -> None:
|
||||
"""Set workflow attributes on the current span.
|
||||
|
||||
Args:
|
||||
workflow: The workflow instance to extract attributes from
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
span.set_attributes({
|
||||
"workflow.id": workflow.id,
|
||||
"workflow.definition": workflow.model_dump_json(by_alias=True),
|
||||
})
|
||||
|
||||
def add_build_event(self, event_name: str, attributes: Attributes | None = None) -> None:
|
||||
"""Add an event to the current workflow build span.
|
||||
|
||||
Args:
|
||||
event_name: Name of the build event (e.g., "build.started", "build.validation_completed")
|
||||
attributes: Optional attributes to attach to the event
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
span.add_event(event_name, attributes)
|
||||
|
||||
def add_build_error_event(self, error: Exception, attributes: Attributes | None = None) -> None:
|
||||
"""Add an error event to the current workflow build span.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred during build
|
||||
attributes: Optional additional attributes to attach to the event
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
event_attributes: dict[str, str | bool | int | float] = {
|
||||
"build.error.message": str(error),
|
||||
"build.error.type": type(error).__name__,
|
||||
}
|
||||
if attributes:
|
||||
# Safely merge attributes, ensuring type compatibility
|
||||
for key, value in attributes.items():
|
||||
if isinstance(value, (str, bool, int, float)):
|
||||
event_attributes[key] = value
|
||||
span.add_event("build.error", event_attributes)
|
||||
span.set_status(StatusCode.ERROR, str(error))
|
||||
|
||||
|
||||
# Global workflow tracer instance
|
||||
workflow_tracer = WorkflowTracer()
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any, Union, get_args, get_origin
|
||||
|
||||
|
||||
def is_instance_of(data: Any, target_type: type) -> bool:
|
||||
"""Check if the data is an instance of the target type.
|
||||
|
||||
Args:
|
||||
data (Any): The data to check.
|
||||
target_type (type): The type to check against.
|
||||
|
||||
Returns:
|
||||
bool: True if data is an instance of target_type, False otherwise.
|
||||
"""
|
||||
# Case 0: target_type is Any - always return True
|
||||
if target_type is Any:
|
||||
return True
|
||||
|
||||
origin = get_origin(target_type)
|
||||
args = get_args(target_type)
|
||||
|
||||
# Case 1: origin is None, meaning target_type is not a generic type
|
||||
if origin is None:
|
||||
return isinstance(data, target_type)
|
||||
|
||||
# Case 2: target_type is Optional[T] or Union[T1, T2, ...]
|
||||
# Optional[T] is really just as Union[T, None]
|
||||
if origin is Union:
|
||||
return any(is_instance_of(data, arg) for arg in args)
|
||||
|
||||
# Case 3: target_type is a generic type
|
||||
if origin in [list, set]:
|
||||
return isinstance(data, origin) and all(is_instance_of(item, args[0]) for item in data) # type: ignore
|
||||
|
||||
# Case 4: target_type is a tuple
|
||||
if origin is tuple:
|
||||
if len(args) == 1 and args[0] is Ellipsis: # Tuple[...] case
|
||||
return isinstance(data, tuple)
|
||||
return (
|
||||
isinstance(data, tuple)
|
||||
and len(data) == len(args) # type: ignore
|
||||
and all(is_instance_of(item, arg) for item, arg in zip(data, args, strict=False)) # type: ignore
|
||||
)
|
||||
|
||||
# Case 5: target_type is a dict
|
||||
if origin is dict:
|
||||
return isinstance(data, dict) and all(
|
||||
is_instance_of(key, args[0]) and is_instance_of(value, args[1])
|
||||
for key, value in data.items() # type: ignore
|
||||
)
|
||||
|
||||
# Case 6: target_type is RequestResponse[T, U] - validate generic parameters
|
||||
if origin and hasattr(origin, "__name__") and origin.__name__ == "RequestResponse":
|
||||
if not isinstance(data, origin):
|
||||
return False
|
||||
# Validate generic parameters for RequestResponse[TRequest, TResponse]
|
||||
if len(args) >= 2:
|
||||
request_type, response_type = args[0], args[1]
|
||||
# Check if the original_request matches TRequest and data matches TResponse
|
||||
if (
|
||||
hasattr(data, "original_request")
|
||||
and data.original_request is not None
|
||||
and not is_instance_of(data.original_request, request_type)
|
||||
):
|
||||
return False
|
||||
if hasattr(data, "data") and data.data is not None and not is_instance_of(data.data, response_type):
|
||||
return False
|
||||
return True
|
||||
|
||||
# Case 7: Other custom generic classes - check origin type only
|
||||
# For generic classes, we check if data is an instance of the origin type
|
||||
# We don't validate the generic parameters at runtime since that's handled by type system
|
||||
if origin and hasattr(origin, "__name__"):
|
||||
return isinstance(data, origin)
|
||||
|
||||
# Fallback: if we reach here, we assume data is an instance of the target_type
|
||||
return isinstance(data, target_type)
|
||||
@@ -0,0 +1,809 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from enum import Enum
|
||||
from types import UnionType
|
||||
from typing import Any, Union, get_args, get_origin
|
||||
|
||||
from ._edge import Edge, EdgeGroup, FanInEdgeGroup
|
||||
from ._executor import Executor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_type_like(x: Any) -> bool:
|
||||
"""Check if a value is a type-like entity.
|
||||
|
||||
A "type-like" entry is either a class/type or a typing alias
|
||||
(e.g., list[str] has an origin and args).
|
||||
|
||||
Args:
|
||||
x: The value to check
|
||||
|
||||
Returns:
|
||||
True if the value is type-like, False otherwise
|
||||
"""
|
||||
return isinstance(x, type) or get_origin(x) is not None
|
||||
|
||||
|
||||
# region Enums and Base Classes
|
||||
class ValidationTypeEnum(Enum):
|
||||
"""Enumeration of workflow validation types."""
|
||||
|
||||
EDGE_DUPLICATION = "EDGE_DUPLICATION"
|
||||
TYPE_COMPATIBILITY = "TYPE_COMPATIBILITY"
|
||||
GRAPH_CONNECTIVITY = "GRAPH_CONNECTIVITY"
|
||||
HANDLER_OUTPUT_ANNOTATION = "HANDLER_OUTPUT_ANNOTATION"
|
||||
INTERCEPTOR_CONFLICT = "INTERCEPTOR_CONFLICT"
|
||||
|
||||
|
||||
class WorkflowValidationError(Exception):
|
||||
"""Base exception for workflow validation errors."""
|
||||
|
||||
def __init__(self, message: str, validation_type: ValidationTypeEnum):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.validation_type = validation_type
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.validation_type.value}] {self.message}"
|
||||
|
||||
|
||||
class EdgeDuplicationError(WorkflowValidationError):
|
||||
"""Exception raised when duplicate edges are detected in the workflow."""
|
||||
|
||||
def __init__(self, edge_id: str):
|
||||
super().__init__(
|
||||
message=f"Duplicate edge detected: {edge_id}. Each edge in the workflow must be unique.",
|
||||
validation_type=ValidationTypeEnum.EDGE_DUPLICATION,
|
||||
)
|
||||
self.edge_id = edge_id
|
||||
|
||||
|
||||
class TypeCompatibilityError(WorkflowValidationError):
|
||||
"""Exception raised when type incompatibility is detected between connected executors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_executor_id: str,
|
||||
target_executor_id: str,
|
||||
source_types: list[type[Any]],
|
||||
target_types: list[type[Any]],
|
||||
):
|
||||
# Use a placeholder for incompatible types - will be computed in WorkflowGraphValidator
|
||||
super().__init__(
|
||||
message=f"Type incompatibility between executors '{source_executor_id}' -> '{target_executor_id}'. "
|
||||
f"Source executor outputs types {[str(t) for t in source_types]} but target executor "
|
||||
f"can only handle types {[str(t) for t in target_types]}.",
|
||||
validation_type=ValidationTypeEnum.TYPE_COMPATIBILITY,
|
||||
)
|
||||
self.source_executor_id = source_executor_id
|
||||
self.target_executor_id = target_executor_id
|
||||
self.source_types = source_types
|
||||
self.target_types = target_types
|
||||
|
||||
|
||||
class GraphConnectivityError(WorkflowValidationError):
|
||||
"""Exception raised when graph connectivity issues are detected."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message, validation_type=ValidationTypeEnum.GRAPH_CONNECTIVITY)
|
||||
|
||||
|
||||
class HandlerOutputAnnotationError(WorkflowValidationError):
|
||||
"""Exception raised when a handler's WorkflowContext output annotation is invalid or missing."""
|
||||
|
||||
def __init__(self, executor_id: str, handler_name: str, reason: str):
|
||||
super().__init__(
|
||||
message=(
|
||||
"Invalid WorkflowContext output annotation in handler "
|
||||
f"'{handler_name}' of executor '{executor_id}': {reason}. "
|
||||
"Handlers must annotate their third parameter as WorkflowContext[T]. "
|
||||
"Use WorkflowContext[None] if the handler emits no messages."
|
||||
),
|
||||
validation_type=ValidationTypeEnum.HANDLER_OUTPUT_ANNOTATION,
|
||||
)
|
||||
self.executor_id = executor_id
|
||||
self.handler_name = handler_name
|
||||
|
||||
|
||||
class InterceptorConflictError(WorkflowValidationError):
|
||||
"""Exception raised when multiple executors intercept the same request type from the same sub-workflow."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message, validation_type=ValidationTypeEnum.INTERCEPTOR_CONFLICT)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Workflow Graph Validator
|
||||
class WorkflowGraphValidator:
|
||||
"""Validator for workflow graphs.
|
||||
|
||||
This validator performs multiple validation checks:
|
||||
1. Edge duplication validation
|
||||
2. Type compatibility validation between connected executors
|
||||
3. Graph connectivity validation
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._edges: list[Edge] = []
|
||||
self._executors: dict[str, Executor] = {}
|
||||
|
||||
# region Core Validation Methods
|
||||
def validate_workflow(
|
||||
self, edge_groups: Sequence[EdgeGroup], executors: dict[str, Executor], start_executor: Executor | str
|
||||
) -> None:
|
||||
"""Validate the entire workflow graph.
|
||||
|
||||
Args:
|
||||
edge_groups: list of edge groups in the workflow
|
||||
executors: Map of executor IDs to executor instances
|
||||
start_executor: The starting executor (can be instance or ID)
|
||||
|
||||
Raises:
|
||||
WorkflowValidationError: If any validation fails
|
||||
"""
|
||||
self._executors = executors
|
||||
self._edges = [edge for group in edge_groups for edge in group.edges]
|
||||
self._edge_groups = edge_groups
|
||||
|
||||
# If only the start executor exists, add it to the executor map
|
||||
# Handle the special case where the workflow consists of only a single executor and no edges.
|
||||
# In this scenario, the executor map will be empty because there are no edge groups to reference executors.
|
||||
# Adding the start executor to the map ensures that single-executor workflows (without any edges) are supported,
|
||||
# allowing validation and execution to proceed for workflows that do not require inter-executor communication.
|
||||
if not self._executors and start_executor and isinstance(start_executor, Executor):
|
||||
self._executors[start_executor.id] = start_executor
|
||||
|
||||
# Validate that start_executor exists in the graph
|
||||
# It should because we check for it in the WorkflowBuilder
|
||||
# but we do it here for completeness.
|
||||
start_executor_id = start_executor.id if isinstance(start_executor, Executor) else start_executor
|
||||
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()
|
||||
self._validate_type_compatibility()
|
||||
self._validate_graph_connectivity(start_executor_id)
|
||||
self._validate_self_loops()
|
||||
self._validate_handler_ambiguity()
|
||||
self._validate_dead_ends()
|
||||
self._validate_cycles()
|
||||
self._validate_interceptor_uniqueness()
|
||||
|
||||
def _validate_handler_output_annotations(self) -> None:
|
||||
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
|
||||
|
||||
Requirements:
|
||||
- WorkflowContext annotation must be present
|
||||
- T_Out must be provided; if no outputs, it must be None
|
||||
- T_Out elements must be valid types (class) or typing generics (e.g., list[str]);
|
||||
values like int() or 123 are invalid
|
||||
"""
|
||||
from ._workflow_context import WorkflowContext # Local import to avoid cycles
|
||||
|
||||
# Iterate over all registered executors in the workflow graph
|
||||
for executor_id, executor in self._executors.items():
|
||||
for attr_name in dir(executor.__class__):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
# Retrieve attributes without binding (so the first parameter remains 'self').
|
||||
# This ensures inspect.signature sees all three parameters: (self, message, ctx).
|
||||
attr = None
|
||||
from contextlib import suppress
|
||||
|
||||
with suppress(Exception):
|
||||
attr = inspect.getattr_static(executor.__class__, attr_name)
|
||||
if attr is None:
|
||||
continue
|
||||
# Consider only callables that were decorated with @handler
|
||||
if not callable(attr) or not hasattr(attr, "_handler_spec"):
|
||||
continue
|
||||
|
||||
handler_spec = attr._handler_spec # type: ignore[attr-defined]
|
||||
handler_name = handler_spec.get("name", attr_name)
|
||||
|
||||
try:
|
||||
# Inspect the function signature of the unbound function
|
||||
sig = inspect.signature(attr)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
params = list(sig.parameters.values())
|
||||
# Handlers must have exactly three parameters: (self, message, ctx)
|
||||
if len(params) != 3:
|
||||
continue
|
||||
|
||||
ctx_param = params[2]
|
||||
ctx_ann = ctx_param.annotation
|
||||
|
||||
# If ctx lacks an annotation entirely, fail fast with a clear message
|
||||
if ctx_ann is inspect.Parameter.empty:
|
||||
raise HandlerOutputAnnotationError(executor_id, handler_name, "missing type annotation for ctx")
|
||||
|
||||
# Validate that the ctx annotation is WorkflowContext[...] and is properly parameterized
|
||||
ctx_origin = get_origin(ctx_ann)
|
||||
if ctx_origin is None:
|
||||
# If it's exactly the WorkflowContext class, T_Out is missing (e.g., WorkflowContext)
|
||||
if ctx_ann is WorkflowContext:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
|
||||
)
|
||||
else:
|
||||
# The annotation is parameterized, but must be for WorkflowContext
|
||||
if ctx_origin is not WorkflowContext:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id, handler_name, f"ctx must be WorkflowContext[T], got {ctx_ann}"
|
||||
)
|
||||
|
||||
# Extract and validate T_Out
|
||||
type_args = get_args(ctx_ann)
|
||||
if not type_args:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
|
||||
)
|
||||
|
||||
t_out = type_args[0]
|
||||
|
||||
# Allow Any for T_Out (unspecified outputs). We accept this here and
|
||||
# skip type compatibility later, but still enforce shape validity elsewhere.
|
||||
if t_out is Any:
|
||||
continue
|
||||
|
||||
# Allow None (no outputs) explicitly declared
|
||||
if t_out is type(None):
|
||||
continue
|
||||
|
||||
# If T_Out is a union, validate each member (e.g., str | int)
|
||||
union_origin = get_origin(t_out)
|
||||
type_items: list[Any]
|
||||
type_items = list(get_args(t_out)) if union_origin in (Union, UnionType) else [t_out]
|
||||
|
||||
invalid = [x for x in type_items if not _is_type_like(x) and x is not type(None)]
|
||||
if invalid:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
f"T_Out contains invalid entries: {invalid}. Use proper types or typing generics",
|
||||
)
|
||||
|
||||
# Also validate instance-level handler specs if present
|
||||
if hasattr(executor, "_instance_handler_specs"):
|
||||
for spec in executor._instance_handler_specs:
|
||||
handler_name = spec.get("name", "unknown")
|
||||
ctx_ann = spec.get("ctx_annotation")
|
||||
|
||||
if ctx_ann is None:
|
||||
continue # Skip if no annotation stored
|
||||
|
||||
# Validate that the ctx annotation is WorkflowContext[...] and is properly parameterized
|
||||
ctx_origin = get_origin(ctx_ann)
|
||||
if ctx_origin is None:
|
||||
if ctx_ann is WorkflowContext:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
|
||||
)
|
||||
else:
|
||||
if ctx_origin is not WorkflowContext:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id, handler_name, f"ctx must be WorkflowContext[T], got {ctx_ann}"
|
||||
)
|
||||
|
||||
# Extract and validate T_Out
|
||||
type_args = get_args(ctx_ann)
|
||||
if not type_args:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
|
||||
)
|
||||
|
||||
t_out = type_args[0]
|
||||
|
||||
# Allow Any for T_Out (unspecified outputs)
|
||||
if t_out is Any:
|
||||
continue
|
||||
|
||||
# Allow None (no outputs) explicitly declared
|
||||
if t_out is type(None):
|
||||
continue
|
||||
|
||||
# If T_Out is a union, validate each member
|
||||
union_origin = get_origin(t_out)
|
||||
instance_type_items: list[Any]
|
||||
instance_type_items = list(get_args(t_out)) if union_origin in (Union, UnionType) else [t_out]
|
||||
|
||||
invalid = [x for x in instance_type_items if not _is_type_like(x) and x is not type(None)]
|
||||
if invalid:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
f"T_Out contains invalid entries: {invalid}. Use proper types or typing generics",
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Edge and Type Validation
|
||||
def _validate_edge_duplication(self) -> None:
|
||||
"""Validate that there are no duplicate edges in the workflow.
|
||||
|
||||
Raises:
|
||||
EdgeDuplicationError: If duplicate edges are found
|
||||
"""
|
||||
seen_edge_ids: set[str] = set()
|
||||
|
||||
for edge in self._edges:
|
||||
edge_id = edge.id
|
||||
if edge_id in seen_edge_ids:
|
||||
raise EdgeDuplicationError(edge_id)
|
||||
seen_edge_ids.add(edge_id)
|
||||
|
||||
def _validate_type_compatibility(self) -> None:
|
||||
"""Validate type compatibility between connected executors.
|
||||
|
||||
This checks that the output types of source executors are compatible
|
||||
with the input types expected by target executors.
|
||||
|
||||
Raises:
|
||||
TypeCompatibilityError: If type incompatibility is detected
|
||||
"""
|
||||
for edge_group in self._edge_groups:
|
||||
for edge in edge_group.edges:
|
||||
self._validate_edge_type_compatibility(edge, edge_group)
|
||||
|
||||
def _validate_edge_type_compatibility(self, edge: Edge, edge_group: EdgeGroup) -> None:
|
||||
"""Validate type compatibility for a specific edge.
|
||||
|
||||
This checks that the output types of the source executor are compatible
|
||||
with the input types expected by the target executor.
|
||||
|
||||
Args:
|
||||
edge: The edge to validate
|
||||
edge_group: The edge group containing this edge
|
||||
|
||||
Raises:
|
||||
TypeCompatibilityError: If type incompatibility is detected
|
||||
"""
|
||||
source_executor = self._executors[edge.source_id]
|
||||
target_executor = self._executors[edge.target_id]
|
||||
|
||||
# Get output types from source executor
|
||||
source_output_types = self._get_executor_output_types(source_executor)
|
||||
|
||||
# Get input types from target executor
|
||||
target_input_types = self._get_executor_input_types(target_executor)
|
||||
|
||||
# If either executor has no type information, log warning and skip validation
|
||||
# This allows for dynamic typing scenarios but warns about reduced validation coverage
|
||||
if not source_output_types or not target_input_types:
|
||||
# Suppress warnings for built-in workflow components where dynamic typing is expected
|
||||
try:
|
||||
from ._executor import RequestInfoExecutor, WorkflowExecutor # local import to avoid cycles
|
||||
|
||||
builtin_types = (RequestInfoExecutor, WorkflowExecutor)
|
||||
except Exception:
|
||||
builtin_types = tuple() # type: ignore[assignment]
|
||||
|
||||
if not source_output_types and not isinstance(source_executor, builtin_types):
|
||||
logger.warning(
|
||||
f"Executor '{source_executor.id}' has no output type annotations. "
|
||||
f"Type compatibility validation will be skipped for edges from this executor. "
|
||||
f"Consider adding WorkflowContext[T] generics in handlers for better validation."
|
||||
)
|
||||
if not target_input_types and not isinstance(target_executor, builtin_types):
|
||||
logger.warning(
|
||||
f"Executor '{target_executor.id}' has no input type annotations. "
|
||||
f"Type compatibility validation will be skipped for edges to this executor. "
|
||||
f"Consider adding type annotations to message handler parameters for better validation."
|
||||
)
|
||||
return
|
||||
|
||||
# Check if any source output type is compatible with any target input type
|
||||
compatible = False
|
||||
compatible_pairs: list[tuple[type[Any], type[Any]]] = []
|
||||
|
||||
for source_type in source_output_types:
|
||||
for target_type in target_input_types:
|
||||
if isinstance(edge_group, FanInEdgeGroup):
|
||||
# If the edge is part of an edge group, the target expects a list of data types
|
||||
if self._is_type_compatible(list[source_type], target_type): # type: ignore[valid-type]
|
||||
compatible = True
|
||||
compatible_pairs.append((list[source_type], target_type)) # type: ignore[valid-type]
|
||||
else:
|
||||
if self._is_type_compatible(source_type, target_type):
|
||||
compatible = True
|
||||
compatible_pairs.append((source_type, target_type))
|
||||
|
||||
# Log successful type compatibility for debugging
|
||||
if compatible:
|
||||
logger.debug(
|
||||
f"Type compatibility validated for edge '{source_executor.id}' -> '{target_executor.id}'. "
|
||||
f"Compatible type pairs: {[(str(s), str(t)) for s, t in compatible_pairs]}"
|
||||
)
|
||||
|
||||
if not compatible:
|
||||
# Enhanced error with more detailed information
|
||||
raise TypeCompatibilityError(
|
||||
source_executor.id,
|
||||
target_executor.id,
|
||||
source_output_types,
|
||||
target_input_types,
|
||||
)
|
||||
|
||||
def _get_executor_output_types(self, executor: Executor) -> list[type[Any]]:
|
||||
"""Extract output types from an executor's message handlers.
|
||||
|
||||
Args:
|
||||
executor: The executor to analyze
|
||||
|
||||
Returns:
|
||||
list of types that this executor can output
|
||||
"""
|
||||
output_types: list[type[Any]] = []
|
||||
|
||||
for attr_name in dir(executor.__class__):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
attr = getattr(executor.__class__, attr_name)
|
||||
if callable(attr) and hasattr(attr, "_handler_spec"):
|
||||
handler_spec = attr._handler_spec # type: ignore
|
||||
handler_output_types = handler_spec.get("output_types", [])
|
||||
output_types.extend(handler_output_types)
|
||||
except AttributeError:
|
||||
# Skip attributes that may not be accessible
|
||||
continue
|
||||
|
||||
# Also include intercepted request types as potential outputs
|
||||
# since @intercepts_request methods can forward requests
|
||||
if hasattr(executor, "_request_interceptors"):
|
||||
for request_type in executor._request_interceptors:
|
||||
if isinstance(request_type, type):
|
||||
output_types.append(request_type)
|
||||
|
||||
# Include output types from instance-level handler specs
|
||||
if hasattr(executor, "_instance_handler_specs"):
|
||||
for spec in executor._instance_handler_specs:
|
||||
handler_output_types = spec.get("output_types", [])
|
||||
output_types.extend(handler_output_types)
|
||||
|
||||
return output_types
|
||||
|
||||
def _get_executor_input_types(self, executor: Executor) -> list[type[Any]]:
|
||||
"""Extract input types from an executor's message handlers.
|
||||
|
||||
Args:
|
||||
executor: The executor to analyze
|
||||
|
||||
Returns:
|
||||
list of types that this executor can handle as input
|
||||
"""
|
||||
input_types: list[type[Any]] = []
|
||||
|
||||
# Access the private _handlers attribute to get input types
|
||||
if hasattr(executor, "_handlers"):
|
||||
input_types.extend(executor._handlers.keys()) # type: ignore
|
||||
|
||||
return input_types
|
||||
|
||||
# endregion
|
||||
|
||||
# region Graph Connectivity Validation
|
||||
def _validate_graph_connectivity(self, start_executor_id: str) -> None:
|
||||
"""Validate graph connectivity and detect potential issues.
|
||||
|
||||
This performs several checks:
|
||||
- Detects unreachable executors from the start node
|
||||
- Detects isolated executors (no incoming or outgoing edges)
|
||||
- Warns about potential infinite loops
|
||||
|
||||
Args:
|
||||
start_executor_id: The ID of the starting executor
|
||||
|
||||
Raises:
|
||||
GraphConnectivityError: If connectivity issues are detected
|
||||
"""
|
||||
# Build adjacency list for the graph
|
||||
graph: dict[str, list[str]] = defaultdict(list)
|
||||
all_executors = set(self._executors.keys())
|
||||
|
||||
for edge in self._edges:
|
||||
graph[edge.source_id].append(edge.target_id)
|
||||
|
||||
# Find reachable nodes from start
|
||||
reachable = self._find_reachable_nodes(graph, start_executor_id)
|
||||
|
||||
# Check for unreachable executors
|
||||
unreachable = all_executors - reachable
|
||||
if unreachable:
|
||||
raise GraphConnectivityError(
|
||||
f"The following executors are unreachable from the start executor '{start_executor_id}': "
|
||||
f"{sorted(unreachable)}. This may indicate a disconnected workflow graph."
|
||||
)
|
||||
|
||||
# Check for isolated executors (no edges)
|
||||
isolated_executors: list[str] = []
|
||||
for executor_id in all_executors:
|
||||
has_incoming = any(edge.target_id == executor_id for edge in self._edges)
|
||||
has_outgoing = any(edge.source_id == executor_id for edge in self._edges)
|
||||
|
||||
if not has_incoming and not has_outgoing and executor_id != start_executor_id:
|
||||
isolated_executors.append(executor_id)
|
||||
|
||||
if isolated_executors:
|
||||
raise GraphConnectivityError(
|
||||
f"The following executors are isolated (no incoming or outgoing edges): "
|
||||
f"{sorted(isolated_executors)}. Isolated executors will never be executed."
|
||||
)
|
||||
|
||||
def _find_reachable_nodes(self, graph: dict[str, list[str]], start: str) -> set[str]:
|
||||
"""Find all nodes reachable from the start node using DFS.
|
||||
|
||||
Args:
|
||||
graph: Adjacency list representation of the graph
|
||||
start: Starting node ID
|
||||
|
||||
Returns:
|
||||
Set of reachable node IDs
|
||||
"""
|
||||
visited: set[str] = set()
|
||||
stack = [start]
|
||||
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node not in visited:
|
||||
visited.add(node)
|
||||
stack.extend(graph[node])
|
||||
|
||||
return visited
|
||||
|
||||
# endregion
|
||||
|
||||
# region Additional Validation Scenarios
|
||||
def _validate_self_loops(self) -> None:
|
||||
"""Detect and log self-loops (edges from executor to itself).
|
||||
|
||||
Self-loops might indicate recursive processing which could be intentional
|
||||
but should be highlighted for review.
|
||||
"""
|
||||
self_loops = [edge for edge in self._edges if edge.source_id == edge.target_id]
|
||||
|
||||
for edge in self_loops:
|
||||
logger.warning(
|
||||
f"Self-loop detected: Executor '{edge.source_id}' connects to itself. "
|
||||
f"This may cause infinite recursion if not properly handled with conditions."
|
||||
)
|
||||
|
||||
def _validate_handler_ambiguity(self) -> None:
|
||||
"""Check for potential ambiguity in message handlers.
|
||||
|
||||
Warns when executors have multiple handlers that could handle the same type,
|
||||
which might lead to unexpected behavior.
|
||||
"""
|
||||
for executor_id, executor in self._executors.items():
|
||||
input_types = self._get_executor_input_types(executor)
|
||||
|
||||
# Check for duplicate input types
|
||||
seen_types: set[type[Any]] = set()
|
||||
duplicate_types: set[type[Any]] = set()
|
||||
|
||||
for input_type in input_types:
|
||||
if input_type in seen_types:
|
||||
duplicate_types.add(input_type)
|
||||
seen_types.add(input_type)
|
||||
|
||||
if duplicate_types:
|
||||
logger.warning(
|
||||
f"Executor '{executor_id}' has multiple handlers for the same input types: "
|
||||
f"{[str(t) for t in duplicate_types]}. This may lead to ambiguous message routing."
|
||||
)
|
||||
|
||||
def _validate_dead_ends(self) -> None:
|
||||
"""Identify executors that have no outgoing edges (potential dead ends).
|
||||
|
||||
These might be intentional final nodes or could indicate missing connections.
|
||||
"""
|
||||
executors_with_outgoing = {edge.source_id for edge in self._edges}
|
||||
all_executor_ids = set(self._executors.keys())
|
||||
dead_ends = all_executor_ids - executors_with_outgoing
|
||||
|
||||
if dead_ends:
|
||||
logger.info(
|
||||
f"Dead-end executors detected (no outgoing edges): {sorted(dead_ends)}. "
|
||||
f"Verify these are intended as final nodes in the workflow."
|
||||
)
|
||||
|
||||
def _validate_cycles(self) -> None:
|
||||
"""Detect cycles in the workflow graph.
|
||||
|
||||
Cycles might be intentional for iterative processing but should be flagged
|
||||
for review to ensure proper termination conditions exist.
|
||||
"""
|
||||
# Build adjacency list
|
||||
graph: dict[str, list[str]] = defaultdict(list)
|
||||
for edge in self._edges:
|
||||
graph[edge.source_id].append(edge.target_id)
|
||||
|
||||
# Use DFS to detect cycles
|
||||
white = set(self._executors.keys()) # Unvisited
|
||||
gray: set[str] = set() # Currently being processed
|
||||
black: set[str] = set() # Completely processed
|
||||
|
||||
def has_cycle(node: str) -> bool:
|
||||
if node in gray: # Back edge found - cycle detected
|
||||
return True
|
||||
if node in black: # Already processed
|
||||
return False
|
||||
|
||||
# Mark as being processed
|
||||
white.discard(node)
|
||||
gray.add(node)
|
||||
|
||||
# Visit neighbors
|
||||
for neighbor in graph[node]:
|
||||
if has_cycle(neighbor):
|
||||
return True
|
||||
|
||||
# Mark as completely processed
|
||||
gray.discard(node)
|
||||
black.add(node)
|
||||
return False
|
||||
|
||||
# Check for cycles starting from any unvisited node
|
||||
cycle_detected = False
|
||||
while white and not cycle_detected:
|
||||
start_node = next(iter(white))
|
||||
if has_cycle(start_node):
|
||||
cycle_detected = True
|
||||
|
||||
if cycle_detected:
|
||||
logger.warning(
|
||||
"Cycle detected in the workflow graph. "
|
||||
"Ensure proper termination conditions exist to prevent infinite loops."
|
||||
)
|
||||
|
||||
def _validate_interceptor_uniqueness(self) -> None:
|
||||
"""Validate that only one executor intercepts a given request type from a specific sub-workflow.
|
||||
|
||||
This prevents non-deterministic behavior where multiple executors could intercept
|
||||
the same request type from the same sub-workflow.
|
||||
"""
|
||||
from ._executor import WorkflowExecutor
|
||||
|
||||
# Find all WorkflowExecutor instances in the workflow
|
||||
workflow_executors: dict[str, WorkflowExecutor] = {}
|
||||
for executor_id, executor in self._executors.items():
|
||||
if isinstance(executor, WorkflowExecutor):
|
||||
workflow_executors[executor_id] = executor
|
||||
|
||||
# For each WorkflowExecutor, check which executors can intercept its requests
|
||||
for workflow_id, _workflow_executor in workflow_executors.items():
|
||||
# Map of request_type -> list of intercepting executor IDs
|
||||
interceptors_by_type: dict[type | str, list[str]] = {}
|
||||
|
||||
# Find all executors that have edges from this WorkflowExecutor
|
||||
# These are potential interceptors
|
||||
for edge in self._edges:
|
||||
if edge.source_id == workflow_id:
|
||||
target_executor = self._executors.get(edge.target_id)
|
||||
if target_executor and hasattr(target_executor, "_request_interceptors"):
|
||||
# Check what request types this executor intercepts
|
||||
for request_type, interceptor_list in target_executor._request_interceptors.items():
|
||||
# Check if any interceptor is scoped to this workflow or unscoped
|
||||
for interceptor_info in interceptor_list:
|
||||
from_workflow = interceptor_info.get("from_workflow")
|
||||
# If unscoped or specifically scoped to this workflow
|
||||
if from_workflow is None or from_workflow == workflow_id:
|
||||
if request_type not in interceptors_by_type:
|
||||
interceptors_by_type[request_type] = []
|
||||
interceptors_by_type[request_type].append(edge.target_id)
|
||||
|
||||
# Check for duplicates
|
||||
for request_type, executor_ids in interceptors_by_type.items():
|
||||
unique_executors = list(set(executor_ids)) # Remove duplicates from same executor
|
||||
if len(unique_executors) > 1:
|
||||
type_name = request_type.__name__ if isinstance(request_type, type) else str(request_type)
|
||||
raise InterceptorConflictError(
|
||||
f"Multiple executors intercept the same request type '{type_name}' "
|
||||
f"from sub-workflow '{workflow_id}': {', '.join(unique_executors)}. "
|
||||
f"Only one executor should intercept a given request type from a specific sub-workflow "
|
||||
f"to ensure deterministic behavior."
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Type Compatibility Utilities
|
||||
@staticmethod
|
||||
def _is_type_compatible(source_type: type[Any], target_type: type[Any]) -> bool:
|
||||
"""Check if source_type is compatible with target_type."""
|
||||
# Handle Any type
|
||||
if source_type is Any or target_type is Any:
|
||||
return True
|
||||
|
||||
# Handle exact match
|
||||
if source_type == target_type:
|
||||
return True
|
||||
|
||||
# Handle inheritance
|
||||
try:
|
||||
if inspect.isclass(source_type) and inspect.isclass(target_type):
|
||||
return issubclass(source_type, target_type)
|
||||
except TypeError:
|
||||
# Handle generic types that can't be used with issubclass
|
||||
pass
|
||||
|
||||
# Handle Union types
|
||||
source_origin = get_origin(source_type)
|
||||
target_origin = get_origin(target_type)
|
||||
|
||||
if target_origin in (Union, UnionType):
|
||||
target_args = get_args(target_type)
|
||||
return any(WorkflowGraphValidator._is_type_compatible(source_type, arg) for arg in target_args)
|
||||
|
||||
if source_origin in (Union, UnionType):
|
||||
source_args = get_args(source_type)
|
||||
return all(WorkflowGraphValidator._is_type_compatible(arg, target_type) for arg in source_args)
|
||||
|
||||
# Handle generic types
|
||||
if source_origin is not None and target_origin is not None and source_origin == target_origin:
|
||||
source_args = get_args(source_type)
|
||||
target_args = get_args(target_type)
|
||||
if len(source_args) == len(target_args):
|
||||
return all(
|
||||
WorkflowGraphValidator._is_type_compatible(s_arg, t_arg)
|
||||
for s_arg, t_arg in zip(source_args, target_args, strict=True)
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
def validate_workflow_graph(
|
||||
edge_groups: Sequence[EdgeGroup], executors: dict[str, Executor], start_executor: Executor | str
|
||||
) -> None:
|
||||
"""Convenience function to validate a workflow graph.
|
||||
|
||||
Args:
|
||||
edge_groups: list of edge groups in the workflow
|
||||
executors: Map of executor IDs to executor instances
|
||||
start_executor: The starting executor (can be instance or ID)
|
||||
|
||||
Raises:
|
||||
WorkflowValidationError: If any validation fails
|
||||
"""
|
||||
validator = WorkflowGraphValidator()
|
||||
validator.validate_workflow(edge_groups, executors, start_executor)
|
||||
@@ -0,0 +1,342 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from ._edge import FanInEdgeGroup
|
||||
from ._workflow import Workflow
|
||||
|
||||
# Import of WorkflowExecutor is performed lazily inside methods to avoid cycles
|
||||
|
||||
"""Workflow visualization module using graphviz."""
|
||||
|
||||
|
||||
class WorkflowViz:
|
||||
"""A class for visualizing workflows using graphviz."""
|
||||
|
||||
def __init__(self, workflow: Workflow):
|
||||
"""Initialize the WorkflowViz with a workflow.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to visualize.
|
||||
"""
|
||||
self._workflow = workflow
|
||||
|
||||
def to_digraph(self) -> str:
|
||||
"""Export the workflow as a DOT format digraph string.
|
||||
|
||||
Returns:
|
||||
A string representation of the workflow in DOT format.
|
||||
"""
|
||||
lines = ["digraph Workflow {"]
|
||||
lines.append(" rankdir=TD;") # Top to bottom layout
|
||||
lines.append(" node [shape=box, style=filled, fillcolor=lightblue];")
|
||||
lines.append(" edge [color=black, arrowhead=vee];")
|
||||
lines.append("")
|
||||
|
||||
# Emit the top-level workflow nodes/edges
|
||||
self._emit_workflow_digraph(self._workflow, lines, indent=" ")
|
||||
|
||||
# Emit sub-workflows hosted by WorkflowExecutor as nested clusters
|
||||
self._emit_sub_workflows_digraph(self._workflow, lines, indent=" ")
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def export(self, format: Literal["svg", "png", "pdf", "dot"] = "svg", filename: str | None = None) -> str:
|
||||
"""Export the workflow visualization to a file or return the file path.
|
||||
|
||||
Args:
|
||||
format: The output format. Supported formats: 'svg', 'png', 'pdf', 'dot'.
|
||||
filename: Optional filename to save the output. If None, creates a temporary file.
|
||||
|
||||
Returns:
|
||||
The path to the saved file.
|
||||
|
||||
Raises:
|
||||
ImportError: If graphviz is not installed.
|
||||
ValueError: If an unsupported format is specified.
|
||||
"""
|
||||
# Validate format first
|
||||
if format not in ["svg", "png", "pdf", "dot"]:
|
||||
raise ValueError(f"Unsupported format: {format}. Supported formats: svg, png, pdf, dot")
|
||||
|
||||
if format == "dot":
|
||||
content = self.to_digraph()
|
||||
if filename:
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return filename
|
||||
# Create temporary file for dot format
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".dot", delete=False, encoding="utf-8") as temp_file:
|
||||
temp_file.write(content)
|
||||
return temp_file.name
|
||||
|
||||
try:
|
||||
import graphviz # type: ignore
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"viz extra is required for export. Install it with: pip install agent-framework[viz]. "
|
||||
"You also need to install graphviz separately. E.g., sudo apt-get install graphviz on Debian/Ubuntu "
|
||||
"or brew install graphviz on macOS. See https://graphviz.org/download/ for details."
|
||||
) from e
|
||||
|
||||
# Create a temporary graphviz Source object
|
||||
dot_content = self.to_digraph()
|
||||
source = graphviz.Source(dot_content)
|
||||
|
||||
if filename:
|
||||
# Save to specified file
|
||||
output_path = Path(filename)
|
||||
if output_path.suffix and output_path.suffix[1:] != format:
|
||||
raise ValueError(f"File extension {output_path.suffix} doesn't match format {format}")
|
||||
|
||||
# Remove extension if present since graphviz.render() adds it
|
||||
base_name = str(output_path.with_suffix(""))
|
||||
source.render(base_name, format=format, cleanup=True)
|
||||
|
||||
# Return the actual filename with extension
|
||||
return f"{base_name}.{format}"
|
||||
# Create temporary file
|
||||
with tempfile.NamedTemporaryFile(suffix=f".{format}", delete=False) as temp_file:
|
||||
temp_path = Path(temp_file.name)
|
||||
base_name = str(temp_path.with_suffix(""))
|
||||
|
||||
source.render(base_name, format=format, cleanup=True)
|
||||
return f"{base_name}.{format}"
|
||||
|
||||
def save_svg(self, filename: str) -> str:
|
||||
"""Convenience method to save as SVG.
|
||||
|
||||
Args:
|
||||
filename: The filename to save the SVG file.
|
||||
|
||||
Returns:
|
||||
The path to the saved SVG file.
|
||||
"""
|
||||
return self.export(format="svg", filename=filename)
|
||||
|
||||
def save_png(self, filename: str) -> str:
|
||||
"""Convenience method to save as PNG.
|
||||
|
||||
Args:
|
||||
filename: The filename to save the PNG file.
|
||||
|
||||
Returns:
|
||||
The path to the saved PNG file.
|
||||
"""
|
||||
return self.export(format="png", filename=filename)
|
||||
|
||||
def save_pdf(self, filename: str) -> str:
|
||||
"""Convenience method to save as PDF.
|
||||
|
||||
Args:
|
||||
filename: The filename to save the PDF file.
|
||||
|
||||
Returns:
|
||||
The path to the saved PDF file.
|
||||
"""
|
||||
return self.export(format="pdf", filename=filename)
|
||||
|
||||
def to_mermaid(self) -> str:
|
||||
"""Export the workflow as a Mermaid flowchart string.
|
||||
|
||||
Returns:
|
||||
A string representation of the workflow in Mermaid flowchart syntax.
|
||||
"""
|
||||
|
||||
def _san(s: str) -> str:
|
||||
"""Sanitize an ID for Mermaid (alphanumeric and underscore, start with letter)."""
|
||||
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
|
||||
if not s2 or not s2[0].isalpha():
|
||||
s2 = f"n_{s2}"
|
||||
return s2
|
||||
|
||||
lines: list[str] = ["flowchart TD"]
|
||||
|
||||
# Emit top-level workflow
|
||||
self._emit_workflow_mermaid(self._workflow, lines, indent=" ")
|
||||
|
||||
# Emit sub-workflows as Mermaid subgraphs
|
||||
self._emit_sub_workflows_mermaid(self._workflow, lines, indent=" ")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# region Private helpers
|
||||
|
||||
def _fan_in_digest(self, target: str, sources: list[str]) -> str:
|
||||
sources_sorted = sorted(sources)
|
||||
return hashlib.sha256((target + "|" + "|".join(sources_sorted)).encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
def _compute_fan_in_descriptors(self, wf: Workflow | None = None) -> list[tuple[str, list[str], str]]:
|
||||
"""Return list of (node_id, sources, target) for fan-in groups.
|
||||
|
||||
node_id is DOT-oriented: fan_in::target::digest
|
||||
"""
|
||||
result: list[tuple[str, list[str], str]] = []
|
||||
workflow = wf or self._workflow
|
||||
for group in workflow.edge_groups:
|
||||
if isinstance(group, FanInEdgeGroup):
|
||||
target = group.target_executor_ids[0]
|
||||
sources = list(group.source_executor_ids)
|
||||
digest = self._fan_in_digest(target, sources)
|
||||
node_id = f"fan_in::{target}::{digest}"
|
||||
result.append((node_id, sorted(sources), target))
|
||||
return result
|
||||
|
||||
def _compute_normal_edges(self, wf: Workflow | None = None) -> list[tuple[str, str, bool]]:
|
||||
"""Return list of (source_id, target_id, is_conditional) for non-fan-in groups."""
|
||||
edges: list[tuple[str, str, bool]] = []
|
||||
workflow = wf or self._workflow
|
||||
for group in workflow.edge_groups:
|
||||
if isinstance(group, FanInEdgeGroup):
|
||||
continue
|
||||
for edge in group.edges:
|
||||
is_cond = getattr(edge, "_condition", None) is not None
|
||||
edges.append((edge.source_id, edge.target_id, is_cond))
|
||||
return edges
|
||||
|
||||
# endregion
|
||||
|
||||
# region Internal emitters (DOT)
|
||||
|
||||
def _emit_workflow_digraph(self, wf: Workflow, lines: list[str], indent: str, ns: str | None = None) -> None:
|
||||
"""Emit DOT nodes/edges for the given workflow.
|
||||
|
||||
If ns (namespace) is provided, node ids are prefixed with f"{ns}/" for uniqueness,
|
||||
but labels remain the original executor ids.
|
||||
"""
|
||||
|
||||
def map_id(x: str) -> str:
|
||||
return f"{ns}/{x}" if ns else x
|
||||
|
||||
# Nodes
|
||||
start_executor_id = wf.start_executor_id
|
||||
lines.append(
|
||||
f'{indent}"{map_id(start_executor_id)}" [fillcolor=lightgreen, label="{start_executor_id}\\n(Start)"];'
|
||||
)
|
||||
for executor_id in wf.executors:
|
||||
if executor_id != start_executor_id:
|
||||
lines.append(f'{indent}"{map_id(executor_id)}" [label="{executor_id}"];')
|
||||
|
||||
# Fan-in nodes
|
||||
fan_in_nodes = self._compute_fan_in_descriptors(wf)
|
||||
if fan_in_nodes:
|
||||
lines.append("")
|
||||
for node_id, _, _ in fan_in_nodes:
|
||||
lines.append(f'{indent}"{map_id(node_id)}" [shape=ellipse, fillcolor=lightgoldenrod, label="fan-in"];')
|
||||
|
||||
# Fan-in edges
|
||||
for node_id, sources, target in fan_in_nodes:
|
||||
for src in sources:
|
||||
lines.append(f'{indent}"{map_id(src)}" -> "{map_id(node_id)}";')
|
||||
lines.append(f'{indent}"{map_id(node_id)}" -> "{map_id(target)}";')
|
||||
|
||||
# Normal edges
|
||||
for src, tgt, is_cond in self._compute_normal_edges(wf):
|
||||
edge_attr = ' [style=dashed, label="conditional"]' if is_cond else ""
|
||||
lines.append(f'{indent}"{map_id(src)}" -> "{map_id(tgt)}"{edge_attr};')
|
||||
|
||||
def _emit_sub_workflows_digraph(self, wf: Workflow, lines: list[str], indent: str) -> None:
|
||||
"""Emit DOT subgraphs for any WorkflowExecutor instances found in the workflow."""
|
||||
# Lazy import to avoid any potential import cycles
|
||||
try:
|
||||
from ._executor import WorkflowExecutor # type: ignore
|
||||
except ImportError: # pragma: no cover - best-effort; if unavailable, skip subgraphs
|
||||
return
|
||||
|
||||
for exec_id, exec_obj in wf.executors.items():
|
||||
if isinstance(exec_obj, WorkflowExecutor) and hasattr(exec_obj, "workflow") and exec_obj.workflow:
|
||||
subgraph_id = f"cluster_{uuid.uuid5(uuid.NAMESPACE_OID, exec_id).hex[:8]}"
|
||||
lines.append(f"{indent}subgraph {subgraph_id} {{")
|
||||
lines.append(f'{indent} label="sub-workflow: {exec_id}";')
|
||||
lines.append(f"{indent} style=dashed;")
|
||||
|
||||
# Emit the nested workflow inside this cluster using a namespace
|
||||
ns = exec_id
|
||||
self._emit_workflow_digraph(exec_obj.workflow, lines, indent=f"{indent} ", ns=ns)
|
||||
|
||||
# Recurse into deeper nested sub-workflows
|
||||
self._emit_sub_workflows_digraph(exec_obj.workflow, lines, indent=f"{indent} ")
|
||||
|
||||
lines.append(f"{indent}}}")
|
||||
|
||||
# endregion
|
||||
|
||||
# region Internal emitters (Mermaid)
|
||||
|
||||
def _emit_workflow_mermaid(self, wf: Workflow, lines: list[str], indent: str, ns: str | None = None) -> None:
|
||||
def _san(s: str) -> str:
|
||||
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
|
||||
if not s2 or not s2[0].isalpha():
|
||||
s2 = f"n_{s2}"
|
||||
return s2
|
||||
|
||||
def map_id(x: str) -> str:
|
||||
if ns:
|
||||
return f"{_san(ns)}__{_san(x)}"
|
||||
return _san(x)
|
||||
|
||||
# Nodes
|
||||
start_executor_id = wf.start_executor_id
|
||||
lines.append(f'{indent}{map_id(start_executor_id)}["{start_executor_id} (Start)"];')
|
||||
for executor_id in wf.executors:
|
||||
if executor_id == start_executor_id:
|
||||
continue
|
||||
lines.append(f'{indent}{map_id(executor_id)}["{executor_id}"];')
|
||||
|
||||
# Fan-in nodes
|
||||
fan_in_nodes_dot = self._compute_fan_in_descriptors(wf)
|
||||
fan_in_nodes: list[tuple[str, list[str], str]] = []
|
||||
for dot_node_id, sources, target in fan_in_nodes_dot:
|
||||
digest = dot_node_id.split("::")[-1]
|
||||
base = f"{target}__{digest}"
|
||||
fan_node_id = f"fan_in__{_san(ns) + '__' if ns else ''}{_san(base)}"
|
||||
fan_in_nodes.append((fan_node_id, sources, target))
|
||||
|
||||
for fan_node_id, _, _ in fan_in_nodes:
|
||||
# Keep this line without trailing semicolon to match existing tests
|
||||
lines.append(f"{indent}{fan_node_id}((fan-in))")
|
||||
|
||||
# Fan-in edges
|
||||
for fan_node_id, sources, target in fan_in_nodes:
|
||||
for s in sources:
|
||||
lines.append(f"{indent}{map_id(s)} --> {fan_node_id};")
|
||||
lines.append(f"{indent}{fan_node_id} --> {map_id(target)};")
|
||||
|
||||
# Normal edges
|
||||
for src, tgt, is_cond in self._compute_normal_edges(wf):
|
||||
s = map_id(src)
|
||||
t = map_id(tgt)
|
||||
if is_cond:
|
||||
lines.append(f"{indent}{s} -. conditional .-> {t};")
|
||||
else:
|
||||
lines.append(f"{indent}{s} --> {t};")
|
||||
|
||||
def _emit_sub_workflows_mermaid(self, wf: Workflow, lines: list[str], indent: str) -> None:
|
||||
try:
|
||||
from ._executor import WorkflowExecutor # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
return
|
||||
|
||||
def _san(s: str) -> str:
|
||||
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
|
||||
if not s2 or not s2[0].isalpha():
|
||||
s2 = f"n_{s2}"
|
||||
return s2
|
||||
|
||||
for exec_id, exec_obj in wf.executors.items():
|
||||
if isinstance(exec_obj, WorkflowExecutor) and hasattr(exec_obj, "workflow") and exec_obj.workflow:
|
||||
sg_id = _san(exec_id)
|
||||
lines.append(f"{indent}subgraph {sg_id}")
|
||||
# Render nested workflow within this subgraph using namespacing
|
||||
self._emit_workflow_mermaid(exec_obj.workflow, lines, indent=f"{indent} ", ns=exec_id)
|
||||
# Recurse into deeper sub-workflows
|
||||
self._emit_sub_workflows_mermaid(exec_obj.workflow, lines, indent=f"{indent} ")
|
||||
lines.append(f"{indent}end")
|
||||
|
||||
# endregion
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from opentelemetry.propagate import inject
|
||||
|
||||
from ._events import WorkflowEvent
|
||||
from ._runner_context import Message, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
from ._telemetry import workflow_tracer
|
||||
|
||||
T_Out = TypeVar("T_Out")
|
||||
|
||||
|
||||
class WorkflowContext(Generic[T_Out]):
|
||||
"""Context for executors in a workflow.
|
||||
|
||||
This class is used to provide a way for executors to interact with the workflow
|
||||
context and shared state, while preventing direct access to the runtime context.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor_id: str,
|
||||
source_executor_ids: list[str],
|
||||
shared_state: SharedState,
|
||||
runner_context: RunnerContext,
|
||||
trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
):
|
||||
"""Initialize the executor context with the given workflow context.
|
||||
|
||||
Args:
|
||||
executor_id: The unique identifier of the executor that this context belongs to.
|
||||
source_executor_ids: The IDs of the source executors that sent messages to this executor.
|
||||
This is a list to support fan_in scenarios where multiple sources send aggregated
|
||||
messages to the same executor.
|
||||
shared_state: The shared state for the workflow.
|
||||
runner_context: The runner context that provides methods to send messages and events.
|
||||
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
|
||||
source_span_ids: Optional source span IDs from multiple sources for linking (not for nesting).
|
||||
"""
|
||||
self._executor_id = executor_id
|
||||
self._source_executor_ids = source_executor_ids
|
||||
self._runner_context = runner_context
|
||||
self._shared_state = shared_state
|
||||
|
||||
# Store trace contexts and source span IDs for linking (supporting multiple sources)
|
||||
self._trace_contexts = trace_contexts or []
|
||||
self._source_span_ids = source_span_ids or []
|
||||
|
||||
if not self._source_executor_ids:
|
||||
raise ValueError("source_executor_ids cannot be empty. At least one source executor ID is required.")
|
||||
|
||||
async def send_message(self, message: T_Out, target_id: str | None = None) -> None:
|
||||
"""Send a message to the workflow context.
|
||||
|
||||
Args:
|
||||
message: The message to send. This must conform to the output type(s) declared on this context.
|
||||
target_id: The ID of the target executor to send the message to.
|
||||
If None, the message will be sent to all target executors.
|
||||
"""
|
||||
# Create publishing span (inherits current trace context automatically)
|
||||
with workflow_tracer.create_sending_span(type(message).__name__, target_id) as span:
|
||||
# Create Message wrapper
|
||||
msg = Message(data=message, source_id=self._executor_id, target_id=target_id)
|
||||
|
||||
# Inject current trace context if tracing enabled
|
||||
if workflow_tracer.enabled and span and span.is_recording():
|
||||
trace_context: dict[str, str] = {}
|
||||
inject(trace_context) # Inject current trace context for message propagation
|
||||
|
||||
msg.trace_contexts = [trace_context]
|
||||
msg.source_span_ids = [format(span.get_span_context().span_id, "016x")]
|
||||
|
||||
await self._runner_context.send_message(msg)
|
||||
|
||||
async def add_event(self, event: WorkflowEvent) -> None:
|
||||
"""Add an event to the workflow context."""
|
||||
await self._runner_context.add_event(event)
|
||||
|
||||
async def get_shared_state(self, key: str) -> Any:
|
||||
"""Get a value from the shared state."""
|
||||
return await self._shared_state.get(key)
|
||||
|
||||
async def set_shared_state(self, key: str, value: Any) -> None:
|
||||
"""Set a value in the shared state."""
|
||||
await self._shared_state.set(key, value)
|
||||
|
||||
def get_source_executor_id(self) -> str:
|
||||
"""Get the ID of the source executor that sent the message to this executor.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If there are multiple source executors, this method raises an error.
|
||||
"""
|
||||
if len(self._source_executor_ids) > 1:
|
||||
raise RuntimeError(
|
||||
"Cannot get source executor ID when there are multiple source executors. "
|
||||
"Access the full list via the source_executor_ids property instead."
|
||||
)
|
||||
return self._source_executor_ids[0]
|
||||
|
||||
@property
|
||||
def source_executor_ids(self) -> list[str]:
|
||||
"""Get the IDs of the source executors that sent messages to this executor."""
|
||||
return self._source_executor_ids
|
||||
|
||||
@property
|
||||
def shared_state(self) -> SharedState:
|
||||
"""Get the shared state."""
|
||||
return self._shared_state
|
||||
|
||||
async def set_state(self, state: dict[str, Any]) -> None:
|
||||
"""Persist this executor's state into the checkpointable context.
|
||||
|
||||
Executors call this with a JSON-serializable dict capturing the minimal
|
||||
state needed to resume. It replaces any previously stored state.
|
||||
"""
|
||||
if hasattr(self._runner_context, "set_state"):
|
||||
await self._runner_context.set_state(self._executor_id, state) # type: ignore[arg-type]
|
||||
|
||||
async def get_state(self) -> dict[str, Any] | None:
|
||||
"""Retrieve previously persisted state for this executor, if any."""
|
||||
if hasattr(self._runner_context, "get_state"):
|
||||
return await self._runner_context.get_state(self._executor_id) # type: ignore[return-value]
|
||||
return None
|
||||
@@ -1,82 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
PACKAGE_NAME = "agent_framework_workflow"
|
||||
PACKAGE_EXTRA = "workflow"
|
||||
_IMPORTS = [
|
||||
"Executor",
|
||||
"FunctionExecutor",
|
||||
"executor",
|
||||
"WorkflowContext",
|
||||
"__version__",
|
||||
"events",
|
||||
"WorkflowBuilder",
|
||||
"ExecutorCompletedEvent",
|
||||
"ExecutorEvent",
|
||||
"ExecutorInvokeEvent",
|
||||
"RequestInfoEvent",
|
||||
"WorkflowCompletedEvent",
|
||||
"WorkflowEvent",
|
||||
"WorkflowStartedEvent",
|
||||
"AgentRunEvent",
|
||||
"AgentRunUpdateEvent",
|
||||
"handler",
|
||||
"AgentExecutor",
|
||||
"MagenticAgentDeltaEvent",
|
||||
"MagenticCallbackEvent",
|
||||
"MagenticCallbackMode",
|
||||
"MagenticAgentMessageEvent",
|
||||
"MagenticFinalResultEvent",
|
||||
"MagenticManagerBase",
|
||||
"MagenticOrchestratorMessageEvent",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"RequestInfoExecutor",
|
||||
"RequestInfoMessage",
|
||||
"WorkflowRunResult",
|
||||
"Workflow",
|
||||
"WorkflowAgent",
|
||||
"WorkflowViz",
|
||||
"FileCheckpointStorage",
|
||||
"ExecutorFailedEvent",
|
||||
"InMemoryCheckpointStorage",
|
||||
"CheckpointStorage",
|
||||
"WorkflowCheckpoint",
|
||||
"Case",
|
||||
"Default",
|
||||
"RequestResponse",
|
||||
"SubWorkflowRequestInfo",
|
||||
"SubWorkflowResponse",
|
||||
"WorkflowExecutor",
|
||||
"intercepts_request",
|
||||
"MagenticBuilder",
|
||||
"PlanReviewDecision",
|
||||
"PlanReviewReply",
|
||||
"PlanReviewRequest",
|
||||
"RequestInfoEvent",
|
||||
"StandardMagenticManager",
|
||||
"WorkflowStatusEvent",
|
||||
"WorkflowRunState",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowFailedEvent",
|
||||
"ConcurrentBuilder",
|
||||
"SequentialBuilder",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(PACKAGE_NAME), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_EXTRA}' extra is not installed, "
|
||||
f"please do `pip install agent-framework[{PACKAGE_EXTRA}]`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -1,111 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_workflow import (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
AgentRunEvent,
|
||||
AgentRunUpdateEvent,
|
||||
Case,
|
||||
CheckpointStorage,
|
||||
ConcurrentBuilder,
|
||||
Default,
|
||||
Executor,
|
||||
ExecutorCompletedEvent,
|
||||
ExecutorEvent,
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokeEvent,
|
||||
FileCheckpointStorage,
|
||||
FunctionExecutor,
|
||||
InMemoryCheckpointStorage,
|
||||
MagenticBuilder,
|
||||
MagenticCallbackEvent,
|
||||
MagenticCallbackMode,
|
||||
MagenticPlanReviewDecision,
|
||||
MagenticPlanReviewReply,
|
||||
MagenticPlanReviewRequest,
|
||||
MagenticProgressLedger,
|
||||
MagenticProgressLedgerItem,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
SequentialBuilder,
|
||||
StandardMagenticManager,
|
||||
SubWorkflowRequestInfo,
|
||||
SubWorkflowResponse,
|
||||
Workflow,
|
||||
WorkflowAgent,
|
||||
WorkflowBuilder,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowExecutor,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunResult,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
WorkflowViz,
|
||||
__version__,
|
||||
executor,
|
||||
handler,
|
||||
intercepts_request,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"AgentRunEvent",
|
||||
"AgentRunUpdateEvent",
|
||||
"Case",
|
||||
"CheckpointStorage",
|
||||
"ConcurrentBuilder",
|
||||
"Default",
|
||||
"Executor",
|
||||
"ExecutorCompletedEvent",
|
||||
"ExecutorEvent",
|
||||
"ExecutorFailedEvent",
|
||||
"ExecutorInvokeEvent",
|
||||
"FileCheckpointStorage",
|
||||
"FunctionExecutor",
|
||||
"InMemoryCheckpointStorage",
|
||||
"MagenticBuilder",
|
||||
"MagenticCallbackEvent",
|
||||
"MagenticCallbackMode",
|
||||
"MagenticPlanReviewDecision",
|
||||
"MagenticPlanReviewReply",
|
||||
"MagenticPlanReviewRequest",
|
||||
"MagenticProgressLedger",
|
||||
"MagenticProgressLedgerItem",
|
||||
"RequestInfoEvent",
|
||||
"RequestInfoExecutor",
|
||||
"RequestInfoMessage",
|
||||
"RequestResponse",
|
||||
"SequentialBuilder",
|
||||
"StandardMagenticManager",
|
||||
"SubWorkflowRequestInfo",
|
||||
"SubWorkflowResponse",
|
||||
"Workflow",
|
||||
"WorkflowAgent",
|
||||
"WorkflowBuilder",
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCompletedEvent",
|
||||
"WorkflowContext",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowExecutor",
|
||||
"WorkflowFailedEvent",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowStartedEvent",
|
||||
"WorkflowStatusEvent",
|
||||
"WorkflowViz",
|
||||
"__version__",
|
||||
"executor",
|
||||
"handler",
|
||||
"intercepts_request",
|
||||
]
|
||||
Reference in New Issue
Block a user