From e025d366cbf7e93159a252f1e72f9c6e9761dfe3 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 21 Nov 2025 17:20:25 +0900 Subject: [PATCH 1/7] Wip: workflow composibility design --- .../00XX-workflow_composability_design.md | 245 +++++++++++++++ .../agent_framework/_workflows/__init__.py | 4 +- .../agent_framework/_workflows/__init__.pyi | 4 +- .../agent_framework/_workflows/_concurrent.py | 14 +- .../agent_framework/_workflows/_sequential.py | 14 +- .../agent_framework/_workflows/_workflow.py | 18 +- .../_workflows/_workflow_builder.py | 289 +++++++++++++++++- .../tests/workflow/test_connect_fragments.py | 105 +++++++ .../composition/composed_branching_any.py | 87 ++++++ .../composition/composed_custom_chains.py | 70 +++++ .../composed_custom_to_concurrent.py | 112 +++++++ .../composition/composed_custom_types.py | 101 ++++++ .../composed_sequential_concurrent.py | 125 ++++++++ 13 files changed, 1179 insertions(+), 9 deletions(-) create mode 100644 docs/decisions/00XX-workflow_composability_design.md create mode 100644 python/packages/core/tests/workflow/test_connect_fragments.py create mode 100644 python/samples/getting_started/workflows/composition/composed_branching_any.py create mode 100644 python/samples/getting_started/workflows/composition/composed_custom_chains.py create mode 100644 python/samples/getting_started/workflows/composition/composed_custom_to_concurrent.py create mode 100644 python/samples/getting_started/workflows/composition/composed_custom_types.py create mode 100644 python/samples/getting_started/workflows/composition/composed_sequential_concurrent.py diff --git a/docs/decisions/00XX-workflow_composability_design.md b/docs/decisions/00XX-workflow_composability_design.md new file mode 100644 index 0000000000..6f62ad94e6 --- /dev/null +++ b/docs/decisions/00XX-workflow_composability_design.md @@ -0,0 +1,245 @@ +--- +# These are optional elements. Feel free to remove any of them. +status: in-progress +contact: moonbox3 +date: 2025-11-21 {YYYY-MM-DD when the decision was last updated} +deciders: bentho, taochen, jacob, victor, peter +consulted: eduard, dmytro, gil, mark +informed: team +--- + +# Workflow Composability Design + +## Objective +- Enable fluent composition of workflow builders so downstream edges can be attached without rewriting high-level patterns. +- Preserve strict type compatibility between node outputs and downstream inputs (ChatMessage chains today, other payloads later). +- Reuse existing primitives (WorkflowBuilder, WorkflowExecutor, Workflow.as_agent) rather than inventing new one-off constructs. +- Keep checkpointing, request/response handling, and observability semantics intact across composed graphs. + +## Current State +- High-level builders (ConcurrentBuilder, SequentialBuilder, group chat variants) emit a finished Workflow; the graph is immutable and cannot be extended directly. +- WorkflowExecutor already wraps a Workflow as an Executor; composition is possible but requires manual wrapping and does not provide fluent sugar on builders. +- Workflow.as_agent is convenient for agent-based chaining but forces list[ChatMessage] inputs and loses internal workflow outputs unless they are ChatMessages. +- Type compatibility is enforced by WorkflowBuilder during validation, but only within a single builder instance; cross-workflow composition relies on developers hand-wiring compatible adapters. + +## Requirements +- Compose multiple workflows (built from any builder) as first-class nodes inside a parent graph. +- Allow attaching extra edges before or after high-level builder wiring (e.g., add a post-aggregator executor to ConcurrentBuilder). +- Maintain type safety: downstream inputs must match upstream outputs; provide adapters for deliberate type reshaping. +- Namespacing and ID hygiene to avoid collisions when merging graphs. +- Preserve existing behaviors: checkpoint support, request_info propagation, and event traces must survive composition. + +## Option 1: WorkflowExecutor-Centric Composition Helpers +- Add a fluent creation path for WorkflowExecutor to remove manual boilerplate: + - `Workflow.as_executor(id: str | None = None, *, allow_direct_output: bool = False, adapter: Callable[[Any], Any] | None = None) -> WorkflowExecutor` + - `WorkflowBuilder.add_workflow(source_workflow: Workflow, *, id: str | None = None, allow_direct_output: bool = False, adapter: Callable[[Any], Any] | None = None) -> Self` +- Behavior: + - `allow_direct_output=False` keeps current semantics (sub-workflow outputs become messages to downstream nodes). `True` forwards sub-workflow outputs as workflow outputs; parent edges receive nothing. + - Optional `adapter` transforms sub-workflow outputs before routing (e.g., list[ChatMessage] -> MyAnalysisSummary). Adapter runs inside the WorkflowExecutor to preserve type validation. + - Input type compatibility is checked using existing is_type_compatible between parent edge source and sub-workflow start executor types; adapter output types are validated before send_message. +- Example: + +```python +analysis = ( + ConcurrentBuilder() + .participants([operation, compliance]) + .build() + .as_executor(id="analysis", adapter=pick_latest_assistant) +) + +workflow = ( + WorkflowBuilder() + .add_edge(orchestrator, analysis) + .add_edge(analysis, aggregator_executor) + .set_start_executor(orchestrator) + .build() +) +``` +- Pros: minimal code surface; reuses WorkflowExecutor and existing validation. Cons: maintains nested execution boundary (double superstep scheduling, separate checkpoint lineage), and post-build graph edits are still indirect. + +## Option 2: Inline Fragments With Builder Merge (connect-first API) +- Keep fragment inlining but make connection verbs explicit and fluent via `.connect(...)`. +- `WorkflowConnection`: + - `builder`: WorkflowBuilder holding the fragment wiring. + - `entry`: canonical entry executor id. + - `exits`: executor ids that are safe to target (default terminal outputs or last adapters). + - `contract`: input/output type sets plus semantics tags for validation (reuses Option 3 contracts if available). +- Production: + - High-level builders expose `.as_connection()` returning connection metadata without building a Workflow. + - Built workflows expose `.as_connection()` by cloning topology into a new builder (immutable source). +- Composition API (renamed for clarity): + - Single verb: `connect(...)` handles both merge and wiring. No `add_fragment`. + - Accepted inputs: + - `connect(fragment_or_workflow, *, prefix=None, source=None, target=None, adapter=None)` + - `connect(source_executor_id_or_port, target_executor_id_or_port, *, adapter=None)` + - If `fragment_or_workflow` is provided: + - Merge its builder into the caller with optional `prefix` to avoid ID collisions. + - Return a handle with `.start` and `.outputs` to allow chaining. + - If `source` is provided, wire `source -> fragment.start`. + - If `target` is provided, wire `fragment.outputs[0] -> target` (or all outputs if specified). + - `FragmentHandle.start` alias for entry; `FragmentHandle.outputs` alias for exits to keep names concise in chaining. + - Optional chaining: `builder.connect(orchestrator, analysis.start).connect(analysis.outputs[0], aggregator)`. +- Naming shift: prefer `connect` over `add_edge` for user-facing fluent APIs; keep `add_edge` under the hood for compatibility. +- Type safety: + - `connect` enforces compatibility between source output types and target input types (or fragment contract). + - Allow optional `adapter` param to inject a converter executor inline if strict types differ (compatible with Option 3 registry). +- Example: + +```python +analysis = ( + ConcurrentBuilder() + .participants([operation, compliance]) + .as_connection() +) + +builder = WorkflowBuilder() +analysis_handle = builder.connect(analysis, prefix="analysis") # merge + handle +builder.connect(orchestrator, analysis_handle.start) +builder.connect(analysis_handle.outputs[0], aggregator_executor) +workflow = builder.set_start_executor(orchestrator).build() +``` +- Pros: single workflow boundary, explicit connect vocabulary, compatibility with port semantics later. Cons: still needs ID renaming during merge and clear immutability rules for fragments. + +### Fluent pattern illustration with connect-only (option 2) + +```python +normalize_connection = ( + WorkflowBuilder() + .add_edge(Normalize(id="normalize"), Enrich(id="enrich")) + .set_start_executor("normalize") + .as_connection() +) + +summarize_connection = ( + WorkflowBuilder() + .add_edge(Summarize(id="summarize"), Publish(id="publish")) + .set_start_executor("summarize") + .as_connection() +) + +builder = WorkflowBuilder() +normalize_handle = builder.add_connection(normalize_connection, prefix="prep") +summarize_handle = builder.add_connection(summarize_connection, prefix="summary") +builder.connect(normalize_handle.output_points[0], summarize_handle.start_id) +builder.set_start_executor(normalize_handle.start_id) + +workflow = builder.build() +print("Outputs:") +async for event in workflow.run_stream(" Hello Composition "): + if isinstance(event, WorkflowOutputEvent): + print(event.data) +``` + +## Option 3: Typed Adapters and Contracts +- Provide first-class adapter executors to bridge mismatched but intentionally compatible types instead of ad-hoc callbacks: + - `builder.add_adapter(source, target, adapter_fn)` sugar that injects a small Executor running adapter_fn; validated via is_type_compatible on adapter outputs. + - Offer canned adapters for common shapes: `ConversationToText`, `TextToConversation`, `MessagesToStructured[T]`, mirroring existing _InputToConversation/_ResponseToConversation patterns. +- Expose explicit type contracts on fragments/workflows: + - `WorkflowContract` capturing `input_types`, `output_types`, and optional `output_semantics` (e.g., “conversation”, “agent_response”, “request_message”). + - Composition helpers use contracts to fail fast or select the right canned adapter. +- Pros: predictable type-safe bridges and better error messages. Cons: adds small surface area but aligns with existing adapter executors already used inside SequentialBuilder. + +## Option 4: Port-Based Interfaces and Extension Points +- Elevate executor I/O to named ports with declared types, making composition addressable: + - Executors expose `ports: dict[str, PortSpec]` where PortSpec includes direction (in/out), type set, and optional semantics tag (`conversation`, `aggregate`, `request`, `control`). + - Builders produce a `WorkflowPorts` manifest identifying exposed ports (entry, exit, extension points) instead of only start/terminal nodes. + - New APIs: `builder.connect(source=(node_id, "out:conversation"), target=(node_id, "in:conversation"))` with validation on port types/semantics. +- High-level builders declare explicit extension points: + - ConcurrentBuilder exposes `dispatch_out`, `fan_in_in`, `aggregator_out`. + - SequentialBuilder exposes `input_normalizer_out`, `final_conversation_out`. + - Group chat exposes manager in/out, participant in/out. +- Composition uses ports rather than raw executor IDs, enabling fluent “attach after aggregator” semantics without cloning graphs or nesting: + +```python +concurrent = ConcurrentBuilder().participants([...]).build_ports() +builder = WorkflowBuilder() +analysis = builder.inline(concurrent, prefix="analysis") +builder.connect(analysis.port("aggregator_out"), summarizer.port("in")) +builder.connect(orchestrator.port("out"), analysis.port("entry")) +``` +- Pros: explicit extension surface, strong type+semantics validation, avoids nested runner overhead. Cons: requires port metadata on executors and new connect API; existing builder wiring must annotate ports without breaking current ID behavior. + +## Option 5: Automatic Converter Insertion via Registry +- Introduce a registry of safe converters between message shapes (e.g., `list[ChatMessage] -> str`, `AgentExecutorResponse -> list[ChatMessage]`, `Any -> ChatMessage` with Role defaults). +- WorkflowBuilder gains optional auto-convert mode: + - On edge validation failure, consult registry for a single-step converter; if found, auto-insert a lightweight adapter executor (generated with stable ID naming). + - Developers can opt into explicit converter selection: `builder.add_edge(a, b, allow_auto_adapter=True)` or `with_auto_adapters(converter_registry=...)`. +- Registry seeded with built-in converters mirroring current adapters; users can register domain-specific converters with precedence and safety labels (lossless vs lossy). +- Pros: reduces composition friction when combining workflows with slightly different output shapes; preserves validation rigor with explicit control. Cons: auto-insertion can obscure graph shape unless surfaced in observability; must keep deterministic ordering to avoid non-reproducible builds. + +## Option 6: Graph Rewrite Pipeline (Plan-Time Rewriters) +- Treat built graphs as IR and apply rewriting passes before final Workflow creation: + - Passes can inline sub-workflows, insert adapters, rename IDs for collision avoidance, or hoist extension points. + - Rewriters operate on an intermediate `WorkflowPlan` (edge groups + metadata + port semantics) produced by all builders. + - Users can register rewrites or select presets: `WorkflowBuilder().with_rewriters([InlineSingleUseWorkflows, InsertCheckpointHooks])`. +- Composition story: + - Build parent plan with placeholder nodes referencing child plans (from any builder). + - Run rewrite pipeline to inline or wrap depending on policy (performance vs isolation). + - Final validation executes on rewritten plan only. +- Pros: maximizes flexibility; enables policy-driven composition (e.g., inline small subgraphs, nest large ones); keeps public API compact by centralizing transformation. Cons: higher conceptual load; needs deterministic, debuggable rewrite tracing to avoid “hidden magic.” + +## Option 7: Declarative Contracts and Compilation Targets +- Define a declarative DSL (JSON/YAML/Python builders) for workflow composition with explicit schema contracts: + - Contracts specify allowed input/output schemas (Pydantic-like), conversion policies, checkpoint scopes, and observability hints. + - Compilation targets: + - `inline` target produces a single Workflow (like Option 2 but driven by declarative spec). + - `nested` target produces WorkflowExecutor-wrapped subgraphs (Option 1) when isolation or fault domains require boundaries. + - Builders emit contract metadata during `build_contract()`; composition compiles contracts into a concrete topology via selected target. +- Pros: clear separation between design-time contract authoring and runtime topology; can later feed into codegen or docs. Cons: adds a contract layer and a compiler; risk of over-abstraction if not scoped tightly. + +### Feasibility, gotchas, trade-offs for Option 1 +- Isolation vs overhead: nested WorkflowExecutor keeps boundaries but adds superstep indirection and separate checkpoint lineage; may complicate observability and latency. +- Type visibility: adapters help but nested workflows obscure internal types; without contract export, parent validation sees only outer signatures. +- Request propagation: SubWorkflowRequestMessage semantics differ from inlining; callers must choose boundaries carefully. +- Adapter risks: inline callbacks can hide lossy conversions; add explicit adapter typing and trace visibility. + +### Feasibility, gotchas, trade-offs for Option 2 +- ID hygiene: merging fragments demands deterministic prefixing and collision detection. Need stable rules (e.g., `prefix::original_id`) and clear errors when user-provided prefixes collide. +- Mutability: fragments must be immutable; reusing a fragment handle across builders should clone to avoid shared state mutation. Otherwise silent edge duplication risk. +- Exit disambiguation: multi-exit fragments require explicit target selection; defaulting to `outputs[0]` is dangerous if ordering changes. Enforce explicit output selection when len(outputs) > 1. +- Type contracts: connect must validate fragment contracts. If no compatible exit/input pairing exists, fail fast with actionable diagnostics listing expected vs provided types. +- Adapters: optional adapter insertion is helpful but must be observable. Emit trace breadcrumbs and deterministic IDs for auto-inserted adapters to keep debugging sane. +- Checkpointing: merged graphs must reconcile checkpoint storage precedence (parent vs fragment). Graph signature hashing must include prefixed IDs to keep validation aligned with saved checkpoints. +- RequestInfo behavior: inlined fragments drop the WorkflowExecutor boundary; if downstream relies on SubWorkflowRequestMessage semantics, behavior changes. Document when to inline vs wrap. +- Validation cost: large merges can inflate validation time; consider incremental validation caches keyed by fragment signature + prefix. +- Debuggability: connect sugar expands to multiple edges; provide graph inspection/dump after connect so users can audit the final topology. + +### Feasibility, gotchas, trade-offs for Option 3 +- Adapter sprawl: registry needs governance to avoid conflicting converters; precedence and safety (lossless vs lossy) must be enforced. +- Hidden rewrites: auto-inserted adapters change graph shape; must emit diagnostics and keep deterministic IDs. +- Schema drift: typed adapters rely on accurate annotations; missing or broad types (Any) reduce safety. + +### Feasibility, gotchas, trade-offs for Option 4 +- Port taxonomy: requires consistent semantics tagging across executors; retrofitting existing nodes needs care. +- Ergonomics: port-qualified connect can be verbose; needs sensible defaults for single-port executors. +- Validation: port type sets vs executor type sets must stay in sync; risk of divergence if one side changes without the other. + +### Feasibility, gotchas, trade-offs for Option 5 +- Auto-conversion noise: over-eager adapters could mask type mismatches; require opt-in or warnings on insertion. +- Order/determinism: converter selection must be deterministic; ambiguity between multiple valid converters needs resolution strategy. +- Observability: injected adapters must appear in traces/visualizations to avoid debugging blind spots. + +### Feasibility, gotchas, trade-offs for Option 6 +- Rewrite complexity: transformation pipeline can become opaque; needs tracing of applied passes and resulting graphs. +- Determinism: rewrites must be stable across runs; otherwise checkpoint signatures and tests break. +- Debug/authoring: users need tooling to inspect IR before/after rewrites; without it, “magic” feels brittle. + +### Feasibility, gotchas, trade-offs for Option 7 +- DSL scope: risk of over-abstraction; contracts must stay aligned with runtime capabilities to avoid split-brain specs. +- Compilation targets: inline vs nested strategies need clear selection rules; inconsistent outcomes hurt predictability. +- Tooling cost: contract authoring, validation, and codegen add maintenance overhead; ensure payoff justifies complexity. + +## Recommendation +- Option 2: Code included for connect-first Option 2 with `.as_connection()` and typed `ConnectionHandle`/`ConnectionPoint`, plus samples and tests. + +### Optional Add-ons +- Stage 2: Harden type contracts and adapters (Option 3) on top of connections: registry for converters, explicit adapter insertion toggles, richer diagnostics. +- Stage 3: Add port semantics (Option 4) to label connection points and reduce ambiguities for multi-exit/multi-input executors. +- Stage 4: Revisit WorkflowExecutor sugar (Option 1) for cases where isolation boundaries are preferred over inlining; keep the API minimal and adapter-aware. + +## Compatibility and Behavior Notes +- Checkpointing: WorkflowExecutor already supports checkpoints via wrapped workflow. Connection merge must carry over checkpoint storage configuration when cloning, but runtime checkpoint overrides should still flow through parent run() parameters. +- RequestInfo propagation: WorkflowExecutor currently surfaces SubWorkflowRequestMessage; connection merge must ensure request edges remain intact and reachable after ID renaming. +- Observability: retain executor IDs that describe provenance; id_prefix in connection merge prevents collisions while keeping names interpretable in traces. +- Streaming semantics: nested workflows already stream through WorkflowExecutor; merged fragments rely on existing superstep scheduling so no change is needed. +- Backward compatibility: existing builder APIs remain valid; new helpers are additive. diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 18dd674a92..82a4eac2f7 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -96,7 +96,7 @@ ) from ._viz import WorkflowViz from ._workflow import Workflow, WorkflowRunResult -from ._workflow_builder import WorkflowBuilder +from ._workflow_builder import ConnectionHandle, WorkflowBuilder, WorkflowConnection from ._workflow_context import WorkflowContext from ._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor @@ -163,6 +163,7 @@ "ValidationTypeEnum", "Workflow", "WorkflowAgent", + "WorkflowConnection", "WorkflowBuilder", "WorkflowCheckpoint", "WorkflowCheckpointSummary", @@ -186,4 +187,5 @@ "handler", "response_handler", "validate_workflow_graph", + "ConnectionHandle", ] diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index c9f8c6cb62..2df5abed1e 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -93,7 +93,7 @@ from ._validation import ( ) from ._viz import WorkflowViz from ._workflow import Workflow, WorkflowRunResult -from ._workflow_builder import WorkflowBuilder +from ._workflow_builder import ConnectionHandle, WorkflowBuilder, WorkflowConnection from ._workflow_context import WorkflowContext from ._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor @@ -159,6 +159,7 @@ __all__ = [ "ValidationTypeEnum", "Workflow", "WorkflowAgent", + "WorkflowConnection", "WorkflowBuilder", "WorkflowCheckpoint", "WorkflowCheckpointSummary", @@ -182,4 +183,5 @@ __all__ = [ "handler", "response_handler", "validate_workflow_graph", + "ConnectionHandle", ] diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index 6b3e1ac05e..a7b4c671d6 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -15,7 +15,7 @@ from ._executor import Executor, handler from ._message_utils import normalize_messages_input from ._workflow import Workflow -from ._workflow_builder import WorkflowBuilder +from ._workflow_builder import WorkflowBuilder, WorkflowConnection from ._workflow_context import WorkflowContext logger = logging.getLogger(__name__) @@ -296,6 +296,11 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Concurre self._checkpoint_storage = checkpoint_storage return self + def as_connection(self) -> WorkflowConnection: + """Expose the concurrent wiring as a reusable connection.""" + builder = self._build_workflow_builder() + return builder.as_connection() + def build(self) -> Workflow: r"""Build and validate the concurrent workflow. @@ -318,6 +323,11 @@ def build(self) -> Workflow: workflow = ConcurrentBuilder().participants([agent1, agent2]).build() """ + builder = self._build_workflow_builder() + return builder.build() + + def _build_workflow_builder(self) -> WorkflowBuilder: + """Internal helper to construct the workflow builder for this concurrent workflow.""" if not self._participants: raise ValueError("No participants provided. Call .participants([...]) first.") @@ -332,4 +342,4 @@ def build(self) -> Workflow: if self._checkpoint_storage is not None: builder = builder.with_checkpointing(self._checkpoint_storage) - return builder.build() + return builder diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index 38fbc53c04..804d996cef 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -53,7 +53,7 @@ ) from ._message_utils import normalize_messages_input from ._workflow import Workflow -from ._workflow_builder import WorkflowBuilder +from ._workflow_builder import WorkflowBuilder, WorkflowConnection from ._workflow_context import WorkflowContext logger = logging.getLogger(__name__) @@ -157,6 +157,11 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Sequenti self._checkpoint_storage = checkpoint_storage return self + def as_connection(self) -> WorkflowConnection: + """Expose the sequential wiring as a reusable connection.""" + builder = self._build_workflow_builder() + return builder.as_connection() + def build(self) -> Workflow: """Build and validate the sequential workflow. @@ -168,6 +173,11 @@ def build(self) -> Workflow: - Else (custom Executor): pass conversation directly to the executor - _EndWithConversation yields the final conversation and the workflow becomes idle """ + builder = self._build_workflow_builder() + return builder.build() + + def _build_workflow_builder(self) -> WorkflowBuilder: + """Internal helper to construct the workflow builder for this sequential workflow.""" if not self._participants: raise ValueError("No participants provided. Call .participants([...]) first.") @@ -205,4 +215,4 @@ def build(self) -> Workflow: if self._checkpoint_storage is not None: builder = builder.with_checkpointing(self._checkpoint_storage) - return builder.build() + return builder diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index a14542b2a6..80c8f39262 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import copy import functools import hashlib import json @@ -8,7 +9,7 @@ import sys import uuid from collections.abc import AsyncIterable, Awaitable, Callable -from typing import Any +from typing import Any, TYPE_CHECKING from ..observability import OtelAttr, capture_exception, create_workflow_span from ._agent import WorkflowAgent @@ -35,6 +36,9 @@ from ._runner_context import RunnerContext from ._shared_state import SharedState +if TYPE_CHECKING: + from ._workflow_builder import WorkflowConnection + if sys.version_info >= (3, 11): pass # pragma: no cover else: @@ -841,3 +845,15 @@ def as_agent(self, name: str | None = None) -> WorkflowAgent: from ._agent import WorkflowAgent return WorkflowAgent(workflow=self, name=name) + + def as_connection(self, prefix: str | None = None) -> "WorkflowConnection": + """Convert this workflow into a reusable connection for composition.""" + # Import lazily to avoid circular dependency at module import time + from ._workflow_builder import WorkflowBuilder + + builder = WorkflowBuilder(max_iterations=self.max_iterations, name=self.name, description=self.description) + builder._edge_groups = copy.deepcopy(self.edge_groups) + builder._executors = {eid: copy.deepcopy(executor) for eid, executor in self.executors.items()} + builder._start_executor = self.start_executor_id + connection = builder.as_connection() + return connection.with_prefix(prefix) if prefix else connection diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index 70f8747ec9..d70801918d 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -1,15 +1,17 @@ # Copyright (c) Microsoft. All rights reserved. +import copy import logging import sys from collections.abc import Callable, Sequence -from typing import Any +from dataclasses import dataclass +from typing import Any, overload from .._agents import AgentProtocol from ..observability import OtelAttr, capture_exception, create_workflow_span from ._agent_executor import AgentExecutor from ._checkpoint import CheckpointStorage -from ._const import DEFAULT_MAX_ITERATIONS +from ._const import DEFAULT_MAX_ITERATIONS, INTERNAL_SOURCE_ID from ._edge import ( Case, Default, @@ -36,6 +38,204 @@ logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class ConnectionPoint: + """Describes an executor endpoint with its output types.""" + + id: str + output_types: list[type[Any]] + workflow_output_types: list[type[Any]] + + +@dataclass(frozen=True) +class ConnectionHandle: + """Reference to a merged connection's entry/exit points with type metadata.""" + + start_id: str + start_input_types: list[type[Any]] + output_points: list[ConnectionPoint] + + +@dataclass +class WorkflowConnection: + """Encapsulates a workflow connection that can be merged into another builder.""" + + builder: "WorkflowBuilder" + entry: str + exits: list[str] + start_input_types: list[type[Any]] | None = None + exit_points: list[ConnectionPoint] | None = None + + def clone(self) -> "WorkflowConnection": + """Return a deep copy of this connection to avoid shared state.""" + builder_clone = _clone_builder_for_connection(self.builder) + return WorkflowConnection( + builder=builder_clone, + entry=self.entry, + exits=list(self.exits), + start_input_types=list(self.start_input_types or []), + exit_points=[ConnectionPoint(p.id, list(p.output_types), list(p.workflow_output_types)) for p in self.exit_points or []], + ) + + def with_prefix(self, prefix: str | None) -> "WorkflowConnection": + """Return a prefixed copy to avoid executor ID collisions.""" + if not prefix: + return self.clone() + builder_clone = _clone_builder_for_connection(self.builder) + mapping = _prefix_executor_ids(builder_clone, prefix) + entry = mapping.get(self.entry, self.entry) + exits = [mapping.get(e, e) for e in self.exits] + return WorkflowConnection( + builder=builder_clone, + entry=entry, + exits=exits, + start_input_types=list(self.start_input_types or []), + exit_points=[ + ConnectionPoint( + id=mapping.get(p.id, p.id), + output_types=list(p.output_types), + workflow_output_types=list(p.workflow_output_types), + ) + for p in self.exit_points or [] + ], + ) + + +def _clone_builder_for_connection(builder: "WorkflowBuilder") -> "WorkflowBuilder": + """Deep copy a builder so connections remain immutable when merged.""" + clone = WorkflowBuilder( + max_iterations=builder._max_iterations, + name=builder._name, + description=builder._description, + ) + clone._checkpoint_storage = builder._checkpoint_storage + clone._edge_groups = copy.deepcopy(builder._edge_groups) + clone._executors = {eid: copy.deepcopy(executor) for eid, executor in builder._executors.items()} + clone._start_executor = ( + builder._start_executor if isinstance(builder._start_executor, str) else builder._start_executor.id + ) + clone._agent_wrappers = {} + return clone + + +def _get_executor_input_types(executors: dict[str, Executor], executor_id: str) -> list[type[Any]]: + """Return input types for a given executor id.""" + exec_obj = executors.get(executor_id) + if exec_obj is None: + raise ValueError(f"Unknown executor id '{executor_id}' when deriving connection types.") + return list(exec_obj.input_types) + + +def _prefix_executor_ids(builder: "WorkflowBuilder", prefix: str) -> dict[str, str]: + """Apply a deterministic prefix to executor ids and update edge references.""" + mapping: dict[str, str] = {} + for executor_id in builder._executors: + mapping[executor_id] = f"{prefix}/{executor_id}" + + # Update executors and remap keys + updated_executors: dict[str, Executor] = {} + for original_id, executor in builder._executors.items(): + prefixed_id = mapping[original_id] + executor.id = prefixed_id + updated_executors[prefixed_id] = executor + builder._executors = updated_executors + + # Update start executor reference + start_id = builder._start_executor.id if isinstance(builder._start_executor, Executor) else builder._start_executor + builder._start_executor = mapping.get(start_id, start_id) + + builder._edge_groups = [_remap_edge_group_ids(group, mapping, prefix) for group in builder._edge_groups] + + return mapping + + +def _remap_edge_group_ids(group: EdgeGroup, mapping: dict[str, str], prefix: str) -> EdgeGroup: + """Remap executor ids inside an edge group.""" + remapped = copy.deepcopy(group) + remapped.id = f"{prefix}/{group.id}" + + for edge in remapped.edges: + # Adjust internal sources before generic mapping + for original, new in mapping.items(): + internal_source = INTERNAL_SOURCE_ID(original) + if edge.source_id == internal_source: + edge.source_id = INTERNAL_SOURCE_ID(new) + if edge.source_id in mapping: + edge.source_id = mapping[edge.source_id] + if edge.target_id in mapping: + edge.target_id = mapping[edge.target_id] + + if isinstance(remapped, FanOutEdgeGroup): + remapped._target_ids = [mapping.get(t, t) for t in remapped._target_ids] # type: ignore[attr-defined] + + if isinstance(remapped, SwitchCaseEdgeGroup): + new_targets: list[str] = [] + for idx, target in enumerate(remapped._target_ids): # type: ignore[attr-defined] + remapped._target_ids[idx] = mapping.get(target, target) # type: ignore[attr-defined] + new_targets.append(remapped._target_ids[idx]) # type: ignore[attr-defined] + remapped.cases = [ # type: ignore[attr-defined] + _remap_switch_case(case, mapping) for case in remapped.cases # type: ignore[attr-defined] + ] + return remapped + + +def _remap_switch_case( + case: SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault, + mapping: dict[str, str], +) -> SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault: + """Remap switch-case targets to prefixed ids.""" + case.target_id = mapping.get(case.target_id, case.target_id) + return case + + +def _derive_exit_ids(edge_groups: list[EdgeGroup], executors: dict[str, Executor]) -> list[str]: + """Infer exit executors as those without downstream edges or only targeting terminal nodes.""" + outgoing: dict[str, list[str]] = {} + for group in edge_groups: + for edge in group.edges: + if edge.source_id not in executors: + continue + outgoing.setdefault(edge.source_id, []).append(edge.target_id) + + exits: list[str] = [] + for executor_id, executor in executors.items(): + targets = outgoing.get(executor_id, []) + if not targets: + exits.append(executor_id) + continue + target_are_terminal = True + for target in targets: + target_exec = executors.get(target) + if target_exec is None: + target_are_terminal = False + break + if not target_exec.workflow_output_types: + target_are_terminal = False + break + if target_are_terminal: + exits.append(executor_id) + + return exits + + +def _derive_exit_points(edge_groups: list[EdgeGroup], executors: dict[str, Executor]) -> list[ConnectionPoint]: + """Return connection points (id + output types) for exit executors.""" + exit_ids = _derive_exit_ids(edge_groups, executors) + points: list[ConnectionPoint] = [] + for executor_id in exit_ids: + exec_obj = executors.get(executor_id) + if exec_obj is None: + continue + points.append( + ConnectionPoint( + id=executor_id, + output_types=list(exec_obj.output_types), + workflow_output_types=list(exec_obj.workflow_output_types), + ) + ) + return points + + class WorkflowBuilder: """A builder class for constructing workflows. @@ -103,6 +303,91 @@ def __init__( # Agents auto-wrapped by builder now always stream incremental updates. + def as_connection(self) -> WorkflowConnection: + """Render this builder as a reusable connection without finalising into a Workflow.""" + if not self._start_executor: + raise ValueError("Starting executor must be set before calling as_connection().") + + # Validate to ensure we don't emit malformed fragments + validate_workflow_graph( + self._edge_groups, + self._executors, + self._start_executor, + ) + + clone = _clone_builder_for_connection(self) + start_exec = clone._start_executor + entry_id = start_exec.id if isinstance(start_exec, Executor) else start_exec + entry_types = _get_executor_input_types(clone._executors, entry_id) + exit_points = _derive_exit_points(clone._edge_groups, clone._executors) + exit_ids = [p.id for p in exit_points] + return WorkflowConnection( + builder=clone, + entry=entry_id, + start_input_types=entry_types, + exit_points=exit_points, + exits=exit_ids, + ) + + Endpoint = Executor | AgentProtocol | ConnectionHandle | ConnectionPoint | str + + def add_connection(self, connection: WorkflowConnection, *, prefix: str | None = None) -> ConnectionHandle: + """Merge a connection into this builder and return a handle for wiring.""" + return self._merge_connection(connection, prefix=prefix) + + def connect(self, source: Endpoint, target: Endpoint, /) -> Self: + """Connect two endpoints (executors, connection points, or executor ids).""" + src_id = self._normalize_endpoint(source) + tgt_id = self._normalize_endpoint(target) + self._edge_groups.append(SingleEdgeGroup(src_id, tgt_id)) # type: ignore[arg-type] + return self + + def _merge_connection(self, fragment: WorkflowConnection, *, prefix: str | None) -> ConnectionHandle: + """Merge a connection into this builder, returning a handle to its connection points.""" + prepared = fragment.with_prefix(prefix) if prefix else fragment.clone() + + # Detect collisions before mutating state + for executor_id in prepared.builder._executors: + if executor_id in self._executors: + raise ValueError( + f"Executor id '{executor_id}' already exists in builder. " + "Provide a prefix when connecting the fragment." + ) + + # Merge executor map and edge groups + self._executors.update(prepared.builder._executors) + self._edge_groups.extend(prepared.builder._edge_groups) + + start_id = ( + prepared.builder._start_executor.id + if isinstance(prepared.builder._start_executor, Executor) + else prepared.builder._start_executor + ) + start_types = prepared.start_input_types or _get_executor_input_types(prepared.builder._executors, start_id) + exit_points = prepared.exit_points or _derive_exit_points(prepared.builder._edge_groups, prepared.builder._executors) + handle = ConnectionHandle( + start_id=start_id, + start_input_types=start_types, + output_points=exit_points, + ) + return handle + + def _normalize_endpoint(self, endpoint: Executor | AgentProtocol | ConnectionHandle | str) -> str: + """Resolve a connect endpoint to an executor id, adding executors when provided.""" + if isinstance(endpoint, ConnectionHandle): + return endpoint.start_id + if isinstance(endpoint, ConnectionPoint): + return endpoint.id + if isinstance(endpoint, str): + if endpoint not in self._executors: + raise ValueError(f"Unknown executor id '{endpoint}' in connect().") + return endpoint + executor = self._maybe_wrap_agent(endpoint) # type: ignore[arg-type] + executor_id = executor.id + if executor_id not in self._executors: + self._add_executor(executor) + return executor_id + def _add_executor(self, executor: Executor) -> str: """Add an executor to the map and return its ID.""" existing = self._executors.get(executor.id) diff --git a/python/packages/core/tests/workflow/test_connect_fragments.py b/python/packages/core/tests/workflow/test_connect_fragments.py new file mode 100644 index 0000000000..7338f3d630 --- /dev/null +++ b/python/packages/core/tests/workflow/test_connect_fragments.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Any + +import pytest + +from agent_framework import ( + ConnectionHandle, + Executor, + Workflow, + WorkflowBuilder, + WorkflowConnection, + WorkflowContext, + handler, +) + + +class _Source(Executor): + @handler + async def start(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(text) + + +class _Upper(Executor): + @handler + async def elevate(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(text.upper()) + + +class _AppendBang(Executor): + @handler + async def punctuate(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(f"{text}!") + + +class _Sink(Executor): + @handler + async def finish(self, text: str, ctx: WorkflowContext[Any, str]) -> None: + await ctx.yield_output(text) + + +async def test_connect_merges_fragments_and_runs() -> None: + connection_one = ( + WorkflowBuilder() + .add_edge(_Source(id="src"), _Upper(id="up")) + .set_start_executor("src") + .as_connection() + ) + + connection_two = ( + WorkflowBuilder() + .add_edge(_Source(id="forward"), _AppendBang(id="bang")) + .set_start_executor("forward") + .as_connection() + ) + + sink = _Sink(id="sink") + builder = WorkflowBuilder() + handle_one = builder.add_connection(connection_one, prefix="f1") + handle_two = builder.add_connection(connection_two, prefix="f2") + builder.connect(handle_one.output_points[0], handle_two.start_id) + builder.connect(handle_two.output_points[0], sink) + builder.set_start_executor(handle_one.start_id) + + workflow: Workflow = builder.build() + result = await workflow.run("hello") + + assert result.get_outputs() == ["HELLO!"] + assert any(exec_id.startswith("f1/") for exec_id in workflow.executors) + assert any(exec_id.startswith("f2/") for exec_id in workflow.executors) + + +async def test_connect_detects_id_collision() -> None: + connection = ( + WorkflowBuilder() + .add_edge(_Source(id="dup"), _Upper(id="dup_upper")) + .set_start_executor("dup") + .as_connection() + ) + + builder = WorkflowBuilder() + builder.add_edge(_Source(id="dup"), _Sink(id="terminal")) + builder.set_start_executor("dup") + + with pytest.raises(ValueError): + builder.add_connection(connection) + + +async def test_workflow_as_connection_round_trip() -> None: + inner = ( + WorkflowBuilder() + .add_edge(_Source(id="inner_src"), _Sink(id="inner_sink")) + .set_start_executor("inner_src") + .build() + ) + + connection = inner.as_connection(prefix="wrapped") + outer = WorkflowBuilder() + handle = outer.add_connection(connection) + outer.set_start_executor(handle.start_id) + workflow = outer.build() + + result = await workflow.run("pipeline") + assert result.get_outputs() == ["pipeline"] + assert any(exec_id.startswith("wrapped/") for exec_id in workflow.executors) diff --git a/python/samples/getting_started/workflows/composition/composed_branching_any.py b/python/samples/getting_started/workflows/composition/composed_branching_any.py new file mode 100644 index 0000000000..31bfd97fee --- /dev/null +++ b/python/samples/getting_started/workflows/composition/composed_branching_any.py @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Route to mutually exclusive branches (B or C) and wire both exits to the next stage.""" + +import asyncio + +from agent_framework import ( + Executor, + WorkflowBuilder, + WorkflowContext, + WorkflowOutputEvent, + handler, +) + + +class Router(Executor): + @handler + async def route(self, text: str, ctx: WorkflowContext[str]) -> None: + # Fan-out selection will choose the branch; we just forward the text. + await ctx.send_message(text) + + +class BranchUpper(Executor): + @handler + async def upper(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(text.upper()) + + +class BranchLower(Executor): + @handler + async def lower(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(text.lower()) + + +class NextStage(Executor): + @handler + async def consume(self, text: str, ctx: WorkflowContext[str, str]) -> None: + await ctx.yield_output(f"next:{text}") + + +def select_branch(text: str, targets: list[str]) -> list[str]: + # Route to exactly one branch based on content + return [targets[0]] if text.endswith("!") else [targets[1]] # toggle destination + + +async def main() -> None: + # Build the branching connection: A -> (B | C) mutually exclusive via selection_func. + branch_conn = ( + WorkflowBuilder() + .add_multi_selection_edge_group( + Router(id="router"), + [BranchUpper(id="upper"), BranchLower(id="lower")], + selection_func=select_branch, + ) + .set_start_executor("router") + .as_connection() + ) + + # Downstream connection that expects str inputs + next_conn = WorkflowBuilder().set_start_executor(NextStage(id="next")).as_connection() + + builder = WorkflowBuilder() + branch_handle = builder.add_connection(branch_conn, prefix="branch") + next_handle = builder.add_connection(next_conn, prefix="down") + + # Wire both branch exits to the next connection start; only the active branch fires. + for out_point in branch_handle.output_points: + builder.connect(out_point, next_handle.start_id) + + builder.set_start_executor(branch_handle.start_id) + + workflow = builder.build() + print("Outputs:") + async for event in workflow.run_stream("RouteMe!"): + if isinstance(event, WorkflowOutputEvent): + print(event.data) + + """ + Sample output: + + Outputs: + next:ROUTEME! + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/composition/composed_custom_chains.py b/python/samples/getting_started/workflows/composition/composed_custom_chains.py new file mode 100644 index 0000000000..45b81c6939 --- /dev/null +++ b/python/samples/getting_started/workflows/composition/composed_custom_chains.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Compose two custom WorkflowBuilder connections with `connect` and stream outputs.""" + +import asyncio + +from agent_framework import Executor, WorkflowBuilder, WorkflowContext, WorkflowOutputEvent, handler + + +class Normalize(Executor): + @handler + async def normalize(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(text.strip().lower()) + + +class Enrich(Executor): + @handler + async def enrich(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(f"{text} :: enriched") + + +class Summarize(Executor): + @handler + async def summarize(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(f"summary({text})") + + +class Publish(Executor): + @handler + async def publish(self, text: str, ctx: WorkflowContext[str, str]) -> None: + await ctx.yield_output(text) + + +async def main() -> None: + normalize_connection = ( + WorkflowBuilder() + .add_edge(Normalize(id="normalize"), Enrich(id="enrich")) + .set_start_executor("normalize") + .as_connection() + ) + + summarize_connection = ( + WorkflowBuilder() + .add_edge(Summarize(id="summarize"), Publish(id="publish")) + .set_start_executor("summarize") + .as_connection() + ) + + builder = WorkflowBuilder() + normalize_handle = builder.add_connection(normalize_connection, prefix="prep") + summarize_handle = builder.add_connection(summarize_connection, prefix="summary") + builder.connect(normalize_handle.output_points[0], summarize_handle.start_id) + builder.set_start_executor(normalize_handle.start_id) + + workflow = builder.build() + print("Outputs:") + async for event in workflow.run_stream(" Hello Composition "): + if isinstance(event, WorkflowOutputEvent): + print(event.data) + + """ + Sample output: + + Outputs: + summary(hello composition :: enriched) + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/composition/composed_custom_to_concurrent.py b/python/samples/getting_started/workflows/composition/composed_custom_to_concurrent.py new file mode 100644 index 0000000000..b79aea3647 --- /dev/null +++ b/python/samples/getting_started/workflows/composition/composed_custom_to_concurrent.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Connect a custom workflow fragment into a concurrent pattern.""" + +import asyncio +from typing import cast + +from agent_framework import ( + AgentExecutorRequest, + AgentExecutorResponse, + AgentRunResponse, + ChatMessage, + ConcurrentBuilder, + Executor, + Role, + WorkflowBuilder, + WorkflowContext, + WorkflowOutputEvent, + handler, +) + + +class Intake(Executor): + @handler + async def accept(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(f"INTAKE: {text}") + + +class Screen(Executor): + @handler + async def screen(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(f"{text} [SCREENED]") + + +class ReviewWorker(Executor): + @handler + async def review( + self, + request: AgentExecutorRequest, + ctx: WorkflowContext[AgentExecutorResponse], + ) -> None: + latest = request.messages[-1].text if request.messages else "" + reply = ChatMessage(role=Role.ASSISTANT, text=f"{self.id} approval: {latest}") + await ctx.send_message( + AgentExecutorResponse( + executor_id=self.id, + agent_run_response=AgentRunResponse(messages=[reply]), + full_conversation=list(request.messages) + [reply], + ) + ) + + +class Aggregator(Executor): + @handler + async def combine(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[list[ChatMessage]]) -> None: + combined: list[ChatMessage] = [] + for result in results: + combined.extend(result.full_conversation or []) + await ctx.send_message(combined) + + +class Emit(Executor): + @handler + async def emit( + self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage], list[ChatMessage]] + ) -> None: + await ctx.yield_output(conversation) + + +async def main() -> None: + intake_fragment = ( + WorkflowBuilder() + .add_edge(Intake(id="intake"), Screen(id="screen")) + .set_start_executor("intake") + .as_connection() + ) + + concurrent_fragment = ( + ConcurrentBuilder() + .participants([ReviewWorker(id="policy"), ReviewWorker(id="risk")]) + .with_aggregator(Aggregator(id="combine")) + .as_connection() + ) + + builder = WorkflowBuilder() + intake_handle = builder.add_connection(intake_fragment, prefix="intake") + concurrent_handle = builder.add_connection(concurrent_fragment, prefix="review") + builder.connect(intake_handle.output_points[0], concurrent_handle.start_id) + builder.connect(concurrent_handle.output_points[0], Emit(id="emit")) + builder.set_start_executor(intake_handle.start_id) + + workflow = builder.build() + print("Outputs:") + async for event in workflow.run_stream("Order 123"): + if isinstance(event, WorkflowOutputEvent): + msgs = cast(list[ChatMessage], event.data) + for message in msgs: + print(f"- {message.role.value}: {message.text}") + + """ + Sample Output: + + Outputs: + - user: INTAKE: Order 123 [SCREENED] + - assistant: review/policy approval: INTAKE: Order 123 [SCREENED] + - user: INTAKE: Order 123 [SCREENED] + - assistant: review/risk approval: INTAKE: Order 123 [SCREENED] + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/composition/composed_custom_types.py b/python/samples/getting_started/workflows/composition/composed_custom_types.py new file mode 100644 index 0000000000..6e46ca04c9 --- /dev/null +++ b/python/samples/getting_started/workflows/composition/composed_custom_types.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Demonstrates connecting typed workflow segments with custom data models.""" + +import asyncio +from dataclasses import dataclass + +from agent_framework import Executor, WorkflowBuilder, WorkflowContext, WorkflowOutputEvent, handler + + +@dataclass +class Task: + id: str + text: str + + +@dataclass +class EnrichedTask: + id: str + text: str + tags: list[str] + + +@dataclass +class Decision: + id: str + approved: bool + reason: str + + +class Ingest(Executor): + @handler + async def create(self, text: str, ctx: WorkflowContext[Task]) -> None: + await ctx.send_message(Task(id="t1", text=text)) + + +class Tagger(Executor): + @handler + async def tag(self, task: Task, ctx: WorkflowContext[EnrichedTask]) -> None: + tags = ["long"] if len(task.text) > 10 else ["short"] + await ctx.send_message(EnrichedTask(id=task.id, text=task.text, tags=tags)) + + +class Reviewer(Executor): + @handler + async def review(self, enriched: EnrichedTask, ctx: WorkflowContext[Decision]) -> None: + approved = "short" in enriched.tags + reason = "auto-approve short tasks" if approved else "needs manual review" + await ctx.send_message(Decision(id=enriched.id, approved=approved, reason=reason)) + + +class Publish(Executor): + @handler + async def publish(self, decision: Decision, ctx: WorkflowContext[Decision, Decision]) -> None: + await ctx.yield_output(decision) + + +async def main() -> None: + # Connection A: string -> Task -> EnrichedTask + prep = ( + WorkflowBuilder() + .add_edge(Ingest(id="ingest"), Tagger(id="tagger")) + .set_start_executor("ingest") + .as_connection() + ) + + # Connection B: EnrichedTask -> Decision -> publish + review = ( + WorkflowBuilder() + .add_edge(Reviewer(id="reviewer"), Publish(id="publish")) + .set_start_executor("reviewer") + .as_connection() + ) + + builder = WorkflowBuilder() + prep_handle = builder.add_connection(prep, prefix="prep") + review_handle = builder.add_connection(review, prefix="rev") + + # Wire using typed connection points (no raw ids): + # - prep_handle.output_points[0] describes the exit executor AND its output types (EnrichedTask here) + # - review_handle.start_id refers to the entry executor whose input types include EnrichedTask + builder.connect(prep_handle.output_points[0], review_handle.start_id) + builder.set_start_executor(prep_handle.start_id) + + workflow = builder.build() + print("Outputs:") + async for event in workflow.run_stream("Process this short task"): + if isinstance(event, WorkflowOutputEvent): + decision = event.data + print(decision) + + """ + Sample Output: + + Outputs: + Decision(id='t1', approved=False, reason='needs manual review') + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/composition/composed_sequential_concurrent.py b/python/samples/getting_started/workflows/composition/composed_sequential_concurrent.py new file mode 100644 index 0000000000..abde2529fc --- /dev/null +++ b/python/samples/getting_started/workflows/composition/composed_sequential_concurrent.py @@ -0,0 +1,125 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Demonstrates composing Sequential and Concurrent builders with `connect`.""" + +import asyncio +from typing import cast + +from agent_framework import ( + AgentExecutorRequest, + AgentExecutorResponse, + AgentRunResponse, + ChatMessage, + ConcurrentBuilder, + Executor, + Role, + SequentialBuilder, + WorkflowBuilder, + WorkflowContext, + WorkflowOutputEvent, + handler, +) + + +class DraftExecutor(Executor): + @handler + async def draft(self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: + updated = list(conversation) + updated.append(ChatMessage(role=Role.ASSISTANT, text="Drafted response")) + await ctx.send_message(updated) + + +class RefineExecutor(Executor): + @handler + async def refine(self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: + updated = list(conversation) + updated.append(ChatMessage(role=Role.ASSISTANT, text="Refined draft")) + await ctx.send_message(updated) + + +class ReviewWorker(Executor): + @handler + async def review( + self, + request: AgentExecutorRequest, + ctx: WorkflowContext[AgentExecutorResponse], + ) -> None: + latest = request.messages[-1].text if request.messages else "" + reply = ChatMessage(role=Role.ASSISTANT, text=f"{self.id} reviewed: {latest}") + await ctx.send_message( + AgentExecutorResponse( + executor_id=self.id, + agent_run_response=AgentRunResponse(messages=[reply]), + full_conversation=list(request.messages) + [reply], + ) + ) + + +class Aggregator(Executor): + @handler + async def combine(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[list[ChatMessage]]) -> None: + combined: list[ChatMessage] = [] + for result in results: + combined.extend(result.full_conversation or []) + await ctx.send_message(combined) + + +class Emit(Executor): + @handler + async def emit( + self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage], list[ChatMessage]] + ) -> None: + await ctx.yield_output(conversation) + + +async def main() -> None: + sequential_fragment = ( + SequentialBuilder().participants([DraftExecutor(id="draft"), RefineExecutor(id="refine")]).as_connection() + ) + + concurrent_fragment = ( + ConcurrentBuilder() + .participants([ReviewWorker(id="legal"), ReviewWorker(id="brand")]) + .with_aggregator(Aggregator(id="collect")) + .as_connection() + ) + + builder = WorkflowBuilder() + seq_handle = builder.add_connection(sequential_fragment, prefix="seq") + concurrent_handle = builder.add_connection(concurrent_fragment, prefix="conc") + + # Wire sequential output into concurrent, then terminate with emit: + # - sequential fragment outputs a list[ChatMessage] conversation -> feed into concurrent start + # - concurrent aggregator emits list[ChatMessage] results -> send to emit to yield workflow output + builder.connect(seq_handle.output_points[0], concurrent_handle.start_id) + builder.connect(concurrent_handle.output_points[0], Emit(id="emit")) + builder.set_start_executor(seq_handle.start_id) + + workflow = builder.build() + print("Outputs:") + async for event in workflow.run_stream("Start"): + if isinstance(event, WorkflowOutputEvent): + msgs = cast(list[ChatMessage], event.data) + for message in msgs: + print(f"- {message.role.value}: {message.text}") + + """ + Sample Output: + + Outputs: + - user: Start + - assistant: Drafted response + - assistant: Refined draft + - user: Start + - assistant: Drafted response + - assistant: Refined draft + - assistant: conc/legal reviewed: Refined draft + - user: Start + - assistant: Drafted response + - assistant: Refined draft + - assistant: conc/brand reviewed: Refined draft + """ + + +if __name__ == "__main__": + asyncio.run(main()) From 46cfe3d4e60905854dcd3d80552dd02bf9b20852 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 24 Nov 2025 10:32:55 +0900 Subject: [PATCH 2/7] Other orchestrations sample POC --- .../agent_framework/_workflows/_group_chat.py | 20 +++++++++++++++---- .../agent_framework/_workflows/_handoff.py | 17 +++++++++++----- .../agent_framework/_workflows/_magentic.py | 13 +++++++++++- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 84859a4f0c..35e782cf50 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -39,7 +39,7 @@ from ._executor import Executor, handler from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, wrap_participant from ._workflow import Workflow -from ._workflow_builder import WorkflowBuilder +from ._workflow_builder import WorkflowBuilder, WorkflowConnection from ._workflow_context import WorkflowContext logger = logging.getLogger(__name__) @@ -1152,6 +1152,11 @@ def _build_participant_specs(self) -> dict[str, GroupChatParticipantSpec]: ) return specs + def as_connection(self) -> WorkflowConnection: + """Expose the group chat wiring as a reusable connection.""" + builder = self._build_workflow_builder() + return builder.as_connection() + def build(self) -> Workflow: """Build and validate the group chat workflow. @@ -1191,6 +1196,11 @@ def build(self) -> Workflow: async for message in workflow.run("Solve this problem collaboratively"): print(message.text) """ + builder = self._build_workflow_builder() + return builder.build() + + def _build_workflow_builder(self) -> WorkflowBuilder: + """Internal helper to construct the workflow builder for this group chat workflow.""" # Manager is only required when using the default orchestrator factory # Custom factories (e.g., MagenticBuilder) provide their own orchestrator with embedded manager if self._manager is None and self._orchestrator_factory == _default_orchestrator_factory: @@ -1215,10 +1225,12 @@ def build(self) -> Workflow: orchestrator_factory=self._orchestrator_factory, interceptors=self._interceptors, checkpoint_storage=self._checkpoint_storage, + return_builder=True, ) - if not isinstance(result, Workflow): - raise TypeError("Expected Workflow from assemble_group_chat_workflow") - return result + if not (isinstance(result, tuple) and len(result) == 2): + raise TypeError("Expected (WorkflowBuilder, orchestrator) from assemble_group_chat_workflow") + builder, _ = result + return builder # endregion diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index d18bc59562..7b6dc3dd7b 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -48,7 +48,7 @@ from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, sanitize_identifier from ._request_info_mixin import response_handler from ._workflow import Workflow -from ._workflow_builder import WorkflowBuilder +from ._workflow_builder import WorkflowBuilder, WorkflowConnection from ._workflow_context import WorkflowContext if sys.version_info >= (3, 12): @@ -1307,6 +1307,11 @@ def enable_return_to_previous(self, enabled: bool = True) -> "HandoffBuilder": self._return_to_previous = enabled return self + def as_connection(self) -> WorkflowConnection: + """Expose the handoff wiring as a reusable connection.""" + builder = self._build_workflow_builder() + return builder.as_connection() + def build(self) -> Workflow: """Construct the final Workflow instance from the configured builder. @@ -1362,6 +1367,11 @@ def build(self) -> Workflow: After calling build(), the builder instance should not be reused. Create a new builder if you need to construct another workflow with different configuration. """ + builder = self._build_workflow_builder() + return builder.build() + + def _build_workflow_builder(self) -> WorkflowBuilder: + """Internal helper to construct the workflow builder for this handoff workflow.""" if not self._executors: raise ValueError("No participants provided. Call participants([...]) first.") if self._starting_agent_id is None: @@ -1446,7 +1456,6 @@ def _handoff_orchestrator_factory(_: _GroupChatConfig) -> Executor: participant_aliases=self._aliases, participant_executors=self._executors, ) - result = assemble_group_chat_workflow( wiring=wiring, participant_factory=_default_participant_factory, @@ -1463,9 +1472,7 @@ def _handoff_orchestrator_factory(_: _GroupChatConfig) -> Executor: builder = builder.set_start_executor(input_node) builder = builder.add_edge(input_node, starting_executor) builder = builder.add_edge(coordinator, user_gateway) - builder = builder.add_edge(user_gateway, coordinator) - - return builder.build() + return builder.add_edge(user_gateway, coordinator) def _resolve_to_id(self, candidate: str | AgentProtocol | Executor) -> str: """Resolve a participant reference into a concrete executor identifier.""" diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index ea6fb259a6..faabef0656 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -42,6 +42,7 @@ from ._participant_utils import GroupChatParticipantSpec, participant_description from ._request_info_mixin import response_handler from ._workflow import Workflow, WorkflowRunResult +from ._workflow_builder import WorkflowConnection from ._workflow_context import WorkflowContext if sys.version_info >= (3, 11): @@ -2157,6 +2158,16 @@ async def plan(self, context: MagenticContext) -> ChatMessage: def build(self) -> Workflow: """Build a Magentic workflow with the orchestrator and all agent executors.""" + group_builder = self._build_group_chat_builder() + return group_builder.build() + + def as_connection(self) -> WorkflowConnection: + """Expose the Magentic wiring as a reusable connection.""" + group_builder = self._build_group_chat_builder() + return group_builder.as_connection() + + def _build_group_chat_builder(self) -> GroupChatBuilder: + """Internal helper to construct the underlying group chat builder.""" if not self._participants: raise ValueError("No participants added to Magentic workflow") @@ -2204,7 +2215,7 @@ def _participant_factory( if self._checkpoint_storage is not None: group_builder = group_builder.with_checkpointing(self._checkpoint_storage) - return group_builder.build() + return group_builder def start_with_string(self, task: str) -> "MagenticWorkflow": """Build a Magentic workflow and return a wrapper with convenience methods for string tasks. From 989e60265f26821a3242ebf7551d8eb024c21862 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 24 Nov 2025 14:50:27 +0900 Subject: [PATCH 3/7] Updates to design and POC code --- .../00XX-workflow_composability_design.md | 73 ++++++++++--------- .../agent_framework/_workflows/_concurrent.py | 4 +- .../agent_framework/_workflows/_group_chat.py | 4 +- .../agent_framework/_workflows/_handoff.py | 4 +- .../agent_framework/_workflows/_magentic.py | 4 +- .../agent_framework/_workflows/_sequential.py | 4 +- .../agent_framework/_workflows/_workflow.py | 4 +- .../_workflows/_workflow_builder.py | 67 ++++++++++++++++- .../tests/workflow/test_connect_fragments.py | 23 +++--- .../composition/composed_branching_any.py | 13 ++-- .../composition/composed_custom_chains.py | 22 ++---- .../composed_custom_to_concurrent.py | 18 ++--- .../composition/composed_custom_types.py | 32 +++----- .../composed_sequential_concurrent.py | 15 ++-- 14 files changed, 162 insertions(+), 125 deletions(-) diff --git a/docs/decisions/00XX-workflow_composability_design.md b/docs/decisions/00XX-workflow_composability_design.md index 6f62ad94e6..d7603241d2 100644 --- a/docs/decisions/00XX-workflow_composability_design.md +++ b/docs/decisions/00XX-workflow_composability_design.md @@ -16,9 +16,15 @@ informed: team - Reuse existing primitives (WorkflowBuilder, WorkflowExecutor, Workflow.as_agent) rather than inventing new one-off constructs. - Keep checkpointing, request/response handling, and observability semantics intact across composed graphs. +## Terminology +- Workflow: an immutable, built artifact that can be executed or wrapped (e.g., by WorkflowExecutor). Composition should not mutate an existing Workflow. +- WorkflowBuilder: the mutable authoring surface for wiring executors, merging graphs, and validating types. All composition logic lives here. +- High-level builders: convenience builders (ConcurrentBuilder, SequentialBuilder, group chat variants) that internally use WorkflowBuilder; they will share a base mixin that provides `.as_connection()` so the composition API is consistent. + ## Current State - High-level builders (ConcurrentBuilder, SequentialBuilder, group chat variants) emit a finished Workflow; the graph is immutable and cannot be extended directly. - WorkflowExecutor already wraps a Workflow as an Executor; composition is possible but requires manual wrapping and does not provide fluent sugar on builders. +- Sub-workflows today are therefore possible via WorkflowExecutor, but the nested boundary brings double superstep scheduling, separate checkpoints, and manual edge wiring; there is no inline-merge path that keeps a single workflow boundary. - Workflow.as_agent is convenient for agent-based chaining but forces list[ChatMessage] inputs and loses internal workflow outputs unless they are ChatMessages. - Type compatibility is enforced by WorkflowBuilder during validation, but only within a single builder instance; cross-workflow composition relies on developers hand-wiring compatible adapters. @@ -37,6 +43,7 @@ informed: team - `allow_direct_output=False` keeps current semantics (sub-workflow outputs become messages to downstream nodes). `True` forwards sub-workflow outputs as workflow outputs; parent edges receive nothing. - Optional `adapter` transforms sub-workflow outputs before routing (e.g., list[ChatMessage] -> MyAnalysisSummary). Adapter runs inside the WorkflowExecutor to preserve type validation. - Input type compatibility is checked using existing is_type_compatible between parent edge source and sub-workflow start executor types; adapter output types are validated before send_message. +- Intent: this is strictly sugar over the existing `WorkflowExecutor(workflow, id=..., ...)` pattern; edges are still attached with `add_edge(...)` and no new lifecycle semantics are introduced. - Example: ```python @@ -58,44 +65,36 @@ workflow = ( - Pros: minimal code surface; reuses WorkflowExecutor and existing validation. Cons: maintains nested execution boundary (double superstep scheduling, separate checkpoint lineage), and post-build graph edits are still indirect. ## Option 2: Inline Fragments With Builder Merge (connect-first API) -- Keep fragment inlining but make connection verbs explicit and fluent via `.connect(...)`. -- `WorkflowConnection`: +- Keep fragment inlining but make connection verbs explicit and fluent via `.add_workflow(...)` for fragments and `.connect(...)` for edges. +- `WorkflowConnection` (thin wrapper, mainly for cases where the source builder is not directly available): - `builder`: WorkflowBuilder holding the fragment wiring. - `entry`: canonical entry executor id. - `exits`: executor ids that are safe to target (default terminal outputs or last adapters). - `contract`: input/output type sets plus semantics tags for validation (reuses Option 3 contracts if available). - Production: - - High-level builders expose `.as_connection()` returning connection metadata without building a Workflow. - - Built workflows expose `.as_connection()` by cloning topology into a new builder (immutable source). + - High-level builders expose `.as_connection(prefix: str | None = None)` returning connection metadata without building a Workflow. Prefix defaults to the builder's `name` if set. + - Built workflows expose `.as_connection(prefix: str | None = None)` by cloning topology into a new builder (immutable source); prefix defaults to the workflow `name`. + - All high-level builders share the same mixin so `.as_connection()` exists regardless of the concrete builder type. - Composition API (renamed for clarity): - - Single verb: `connect(...)` handles both merge and wiring. No `add_fragment`. - - Accepted inputs: - - `connect(fragment_or_workflow, *, prefix=None, source=None, target=None, adapter=None)` - - `connect(source_executor_id_or_port, target_executor_id_or_port, *, adapter=None)` - - If `fragment_or_workflow` is provided: - - Merge its builder into the caller with optional `prefix` to avoid ID collisions. - - Return a handle with `.start` and `.outputs` to allow chaining. - - If `source` is provided, wire `source -> fragment.start`. - - If `target` is provided, wire `fragment.outputs[0] -> target` (or all outputs if specified). - - `FragmentHandle.start` alias for entry; `FragmentHandle.outputs` alias for exits to keep names concise in chaining. - - Optional chaining: `builder.connect(orchestrator, analysis.start).connect(analysis.outputs[0], aggregator)`. -- Naming shift: prefer `connect` over `add_edge` for user-facing fluent APIs; keep `add_edge` under the hood for compatibility. -- Type safety: - - `connect` enforces compatibility between source output types and target input types (or fragment contract). - - Allow optional `adapter` param to inject a converter executor inline if strict types differ (compatible with Option 3 registry). + - `handle = builder.add_workflow(fragment: WorkflowBuilder | Workflow | WorkflowConnection, *, prefix=None)` merges the fragment into the caller and returns a handle with `.start`/`.outputs`. If `prefix` is omitted, the fragment's `name` (or class name) is used to avoid collisions. `add_workflow` calls `.as_connection(prefix)` internally so callers rarely invoke `.as_connection()` themselves. + - `builder.connect(source_executor_id_or_port, target_executor_id_or_port, *, adapter=None)` wires two existing nodes/ports together; use the handle returned from `add_workflow` to address fragment entry/exit points. + - Internally this is still the existing `add_edge` machinery plus a builder merge; there are no new edge types or runner semantics. +- Defaults and safety: + - Reusing the same fragment multiple times yields independent cloned builders so immutability is preserved. + - Multi-exit fragments require explicit `handle.outputs[...]` selection; no implicit first-exit wiring. + - Collision avoidance uses `prefix or fragment.name` with a deterministic fallback (`fragment-{n}`) when no name is present. + - Connection handles freeze only entry/exit metadata; the graph remains a WorkflowBuilder to avoid duplicating builder APIs. +- Caller knowledge: + - When you control both builders, you can rely only on the surfaced `start`/`outputs` handle and continue to use `add_edge`/`connect`; internal executor IDs remain encapsulated. + - For hosted/pre-built workflows where the builder is hidden, the connection exposes the public entry/exit surface and can later map to different endpoints when hosted. - Example: ```python -analysis = ( - ConcurrentBuilder() - .participants([operation, compliance]) - .as_connection() -) - +analysis_builder = ConcurrentBuilder().participants([operation, compliance]) builder = WorkflowBuilder() -analysis_handle = builder.connect(analysis, prefix="analysis") # merge + handle -builder.connect(orchestrator, analysis_handle.start) -builder.connect(analysis_handle.outputs[0], aggregator_executor) +analysis = builder.add_workflow(analysis_builder) # prefix defaults to analysis_builder.name/class +builder.connect(orchestrator, analysis.start) +builder.connect(analysis.outputs[0], aggregator_executor) workflow = builder.set_start_executor(orchestrator).build() ``` - Pros: single workflow boundary, explicit connect vocabulary, compatibility with port semantics later. Cons: still needs ID renaming during merge and clear immutability rules for fragments. @@ -118,10 +117,10 @@ summarize_connection = ( ) builder = WorkflowBuilder() -normalize_handle = builder.add_connection(normalize_connection, prefix="prep") -summarize_handle = builder.add_connection(summarize_connection, prefix="summary") -builder.connect(normalize_handle.output_points[0], summarize_handle.start_id) -builder.set_start_executor(normalize_handle.start_id) +normalize_handle = builder.add_workflow(normalize_connection, prefix="prep") +summarize_handle = builder.add_workflow(summarize_connection, prefix="summary") +builder.connect(normalize_handle.outputs[0], summarize_handle.start) +builder.set_start_executor(normalize_handle.start) workflow = builder.build() print("Outputs:") @@ -137,6 +136,7 @@ async for event in workflow.run_stream(" Hello Composition "): - Expose explicit type contracts on fragments/workflows: - `WorkflowContract` capturing `input_types`, `output_types`, and optional `output_semantics` (e.g., “conversation”, “agent_response”, “request_message”). - Composition helpers use contracts to fail fast or select the right canned adapter. +- Note: this remains sugar for “add a new executor that transforms the data”; the value is deterministic naming, validation, and observability of these adapters instead of one-off inline callables. - Pros: predictable type-safe bridges and better error messages. Cons: adds small surface area but aligns with existing adapter executors already used inside SequentialBuilder. ## Option 4: Port-Based Interfaces and Extension Points @@ -149,6 +149,9 @@ async for event in workflow.run_stream(" Hello Composition "): - SequentialBuilder exposes `input_normalizer_out`, `final_conversation_out`. - Group chat exposes manager in/out, participant in/out. - Composition uses ports rather than raw executor IDs, enabling fluent “attach after aggregator” semantics without cloning graphs or nesting: + - Hosted/remote workflows could expose their ports to let callers bind different endpoints per input/output when instantiating a hosted instance. + - Scope control: port specs are metadata carried by builders/manifest; low-level executors stay unchanged unless explicitly annotated, keeping the API surface small for non-port users. + - Port metadata is derived from builder-declared wiring (or optional static executor annotations) rather than a new runtime interface on Executor implementations. ```python concurrent = ConcurrentBuilder().participants([...]).build_ports() @@ -230,7 +233,7 @@ builder.connect(orchestrator.port("out"), analysis.port("entry")) - Tooling cost: contract authoring, validation, and codegen add maintenance overhead; ensure payoff justifies complexity. ## Recommendation -- Option 2: Code included for connect-first Option 2 with `.as_connection()` and typed `ConnectionHandle`/`ConnectionPoint`, plus samples and tests. +- Option 2: proceed with `add_workflow(...)` + `connect(...)`, default prefixing from workflow/builder name, shared `.as_connection()` mixin on all high-level builders, and typed `ConnectionHandle`/`ConnectionPoint` handles. Samples and tests cover both builder and workflow inputs. ### Optional Add-ons - Stage 2: Harden type contracts and adapters (Option 3) on top of connections: registry for converters, explicit adapter insertion toggles, richer diagnostics. @@ -240,6 +243,6 @@ builder.connect(orchestrator.port("out"), analysis.port("entry")) ## Compatibility and Behavior Notes - Checkpointing: WorkflowExecutor already supports checkpoints via wrapped workflow. Connection merge must carry over checkpoint storage configuration when cloning, but runtime checkpoint overrides should still flow through parent run() parameters. - RequestInfo propagation: WorkflowExecutor currently surfaces SubWorkflowRequestMessage; connection merge must ensure request edges remain intact and reachable after ID renaming. -- Observability: retain executor IDs that describe provenance; id_prefix in connection merge prevents collisions while keeping names interpretable in traces. +- Observability: retain executor IDs that describe provenance; prefixing via fragment `name` keeps traces readable while avoiding collisions. - Streaming semantics: nested workflows already stream through WorkflowExecutor; merged fragments rely on existing superstep scheduling so no change is needed. -- Backward compatibility: existing builder APIs remain valid; new helpers are additive. +- Backward compatibility: existing builder APIs remain valid; `add_workflow`/`connect` are additive and degrade to explicit `add_edge` usage when desired. diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index a7b4c671d6..ec45e21098 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -296,10 +296,10 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Concurre self._checkpoint_storage = checkpoint_storage return self - def as_connection(self) -> WorkflowConnection: + def as_connection(self, prefix: str | None = None) -> WorkflowConnection: """Expose the concurrent wiring as a reusable connection.""" builder = self._build_workflow_builder() - return builder.as_connection() + return builder.as_connection(prefix=prefix) def build(self) -> Workflow: r"""Build and validate the concurrent workflow. diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 35e782cf50..9dd857e4a0 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -1152,10 +1152,10 @@ def _build_participant_specs(self) -> dict[str, GroupChatParticipantSpec]: ) return specs - def as_connection(self) -> WorkflowConnection: + def as_connection(self, prefix: str | None = None) -> WorkflowConnection: """Expose the group chat wiring as a reusable connection.""" builder = self._build_workflow_builder() - return builder.as_connection() + return builder.as_connection(prefix=prefix) def build(self) -> Workflow: """Build and validate the group chat workflow. diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 7b6dc3dd7b..d31c795c1f 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -1307,10 +1307,10 @@ def enable_return_to_previous(self, enabled: bool = True) -> "HandoffBuilder": self._return_to_previous = enabled return self - def as_connection(self) -> WorkflowConnection: + def as_connection(self, prefix: str | None = None) -> WorkflowConnection: """Expose the handoff wiring as a reusable connection.""" builder = self._build_workflow_builder() - return builder.as_connection() + return builder.as_connection(prefix=prefix) def build(self) -> Workflow: """Construct the final Workflow instance from the configured builder. diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index faabef0656..1bcbacd2c9 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -2161,10 +2161,10 @@ def build(self) -> Workflow: group_builder = self._build_group_chat_builder() return group_builder.build() - def as_connection(self) -> WorkflowConnection: + def as_connection(self, prefix: str | None = None) -> WorkflowConnection: """Expose the Magentic wiring as a reusable connection.""" group_builder = self._build_group_chat_builder() - return group_builder.as_connection() + return group_builder.as_connection(prefix=prefix) def _build_group_chat_builder(self) -> GroupChatBuilder: """Internal helper to construct the underlying group chat builder.""" diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index 804d996cef..4ba374a5a5 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -157,10 +157,10 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Sequenti self._checkpoint_storage = checkpoint_storage return self - def as_connection(self) -> WorkflowConnection: + def as_connection(self, prefix: str | None = None) -> WorkflowConnection: """Expose the sequential wiring as a reusable connection.""" builder = self._build_workflow_builder() - return builder.as_connection() + return builder.as_connection(prefix=prefix) def build(self) -> Workflow: """Build and validate the sequential workflow. diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 80c8f39262..f9d87e9edf 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -855,5 +855,5 @@ def as_connection(self, prefix: str | None = None) -> "WorkflowConnection": builder._edge_groups = copy.deepcopy(self.edge_groups) builder._executors = {eid: copy.deepcopy(executor) for eid, executor in self.executors.items()} builder._start_executor = self.start_executor_id - connection = builder.as_connection() - return connection.with_prefix(prefix) if prefix else connection + effective_prefix = prefix if prefix is not None else self.name + return builder.as_connection(prefix=effective_prefix) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index d70801918d..241a91e4c9 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -55,6 +55,16 @@ class ConnectionHandle: start_input_types: list[type[Any]] output_points: list[ConnectionPoint] + @property + def start(self) -> str: + """Alias for start_id to match the fluent connect API.""" + return self.start_id + + @property + def outputs(self) -> list[ConnectionPoint]: + """Alias for output_points to match the fluent connect API.""" + return self.output_points + @dataclass class WorkflowConnection: @@ -295,6 +305,7 @@ def __init__( self._max_iterations: int = max_iterations self._name: str | None = name self._description: str | None = description + self._fragment_counter: int = 0 # Maps underlying AgentProtocol object id -> wrapped Executor so we reuse the same wrapper # across set_start_executor / add_edge calls. Without this, unnamed agents (which receive # random UUID based executor ids) end up wrapped multiple times, giving different ids for @@ -303,7 +314,7 @@ def __init__( # Agents auto-wrapped by builder now always stream incremental updates. - def as_connection(self) -> WorkflowConnection: + def as_connection(self, prefix: str | None = None) -> WorkflowConnection: """Render this builder as a reusable connection without finalising into a Workflow.""" if not self._start_executor: raise ValueError("Starting executor must be set before calling as_connection().") @@ -321,16 +332,25 @@ def as_connection(self) -> WorkflowConnection: entry_types = _get_executor_input_types(clone._executors, entry_id) exit_points = _derive_exit_points(clone._edge_groups, clone._executors) exit_ids = [p.id for p in exit_points] - return WorkflowConnection( + connection = WorkflowConnection( builder=clone, entry=entry_id, start_input_types=entry_types, exit_points=exit_points, exits=exit_ids, ) + return connection.with_prefix(prefix) if prefix else connection Endpoint = Executor | AgentProtocol | ConnectionHandle | ConnectionPoint | str + def add_workflow( + self, fragment: "WorkflowBuilder | Workflow | WorkflowConnection", *, prefix: str | None = None + ) -> ConnectionHandle: + """Merge a builder/workflow/connection and return a handle for wiring.""" + effective_prefix = self._derive_prefix(fragment, prefix) + connection = self._to_connection(fragment, prefix=effective_prefix) + return self._merge_connection(connection, prefix=None) + def add_connection(self, connection: WorkflowConnection, *, prefix: str | None = None) -> ConnectionHandle: """Merge a connection into this builder and return a handle for wiring.""" return self._merge_connection(connection, prefix=prefix) @@ -372,7 +392,48 @@ def _merge_connection(self, fragment: WorkflowConnection, *, prefix: str | None) ) return handle - def _normalize_endpoint(self, endpoint: Executor | AgentProtocol | ConnectionHandle | str) -> str: + def _derive_prefix( + self, fragment: "WorkflowBuilder | Workflow | WorkflowConnection", explicit: str | None + ) -> str: + """Choose a stable prefix from explicit input, fragment name, or a deterministic fallback.""" + if explicit: + return explicit + + name: str | None = None + if isinstance(fragment, WorkflowConnection): + name = fragment.builder._name # Accessing private name to avoid duplicating state + elif isinstance(fragment, WorkflowBuilder): + name = fragment._name + elif isinstance(fragment, Workflow): + name = fragment.name + + if name: + return name + + class_name = type(fragment).__name__ + if class_name not in {"WorkflowBuilder", "Workflow", "WorkflowConnection"}: + return class_name + + # Fall back to a deterministic suffix when no name exists or is too generic. + self._fragment_counter += 1 + return f"fragment-{self._fragment_counter}" + + def _to_connection( + self, fragment: "WorkflowBuilder | Workflow | WorkflowConnection", *, prefix: str + ) -> WorkflowConnection: + """Normalize a fragment to a WorkflowConnection, applying a prefix for collision safety.""" + if isinstance(fragment, WorkflowConnection): + return fragment.with_prefix(prefix) + if isinstance(fragment, WorkflowBuilder): + return fragment.as_connection(prefix=prefix) + if isinstance(fragment, Workflow): + return fragment.as_connection(prefix=prefix) + raise TypeError( + "add_workflow expects a WorkflowBuilder, Workflow, or WorkflowConnection; " + f"got {type(fragment).__name__}." + ) + + def _normalize_endpoint(self, endpoint: Executor | AgentProtocol | ConnectionHandle | ConnectionPoint | str) -> str: """Resolve a connect endpoint to an executor id, adding executors when provided.""" if isinstance(endpoint, ConnectionHandle): return endpoint.start_id diff --git a/python/packages/core/tests/workflow/test_connect_fragments.py b/python/packages/core/tests/workflow/test_connect_fragments.py index 7338f3d630..8d8637070a 100644 --- a/python/packages/core/tests/workflow/test_connect_fragments.py +++ b/python/packages/core/tests/workflow/test_connect_fragments.py @@ -56,11 +56,11 @@ async def test_connect_merges_fragments_and_runs() -> None: sink = _Sink(id="sink") builder = WorkflowBuilder() - handle_one = builder.add_connection(connection_one, prefix="f1") - handle_two = builder.add_connection(connection_two, prefix="f2") - builder.connect(handle_one.output_points[0], handle_two.start_id) - builder.connect(handle_two.output_points[0], sink) - builder.set_start_executor(handle_one.start_id) + handle_one = builder.add_workflow(connection_one, prefix="f1") + handle_two = builder.add_workflow(connection_two, prefix="f2") + builder.connect(handle_one.outputs[0], handle_two.start) + builder.connect(handle_two.outputs[0], sink) + builder.set_start_executor(handle_one.start) workflow: Workflow = builder.build() result = await workflow.run("hello") @@ -70,7 +70,7 @@ async def test_connect_merges_fragments_and_runs() -> None: assert any(exec_id.startswith("f2/") for exec_id in workflow.executors) -async def test_connect_detects_id_collision() -> None: +async def test_connect_detects_id_collision_with_raw_connection() -> None: connection = ( WorkflowBuilder() .add_edge(_Source(id="dup"), _Upper(id="dup_upper")) @@ -87,17 +87,16 @@ async def test_connect_detects_id_collision() -> None: async def test_workflow_as_connection_round_trip() -> None: - inner = ( - WorkflowBuilder() + inner_builder = ( + WorkflowBuilder(name="wrapped") .add_edge(_Source(id="inner_src"), _Sink(id="inner_sink")) .set_start_executor("inner_src") - .build() ) + inner = inner_builder.build() - connection = inner.as_connection(prefix="wrapped") outer = WorkflowBuilder() - handle = outer.add_connection(connection) - outer.set_start_executor(handle.start_id) + handle = outer.add_workflow(inner) + outer.set_start_executor(handle.start) workflow = outer.build() result = await workflow.run("pipeline") diff --git a/python/samples/getting_started/workflows/composition/composed_branching_any.py b/python/samples/getting_started/workflows/composition/composed_branching_any.py index 31bfd97fee..cb4b421144 100644 --- a/python/samples/getting_started/workflows/composition/composed_branching_any.py +++ b/python/samples/getting_started/workflows/composition/composed_branching_any.py @@ -53,21 +53,20 @@ async def main() -> None: selection_func=select_branch, ) .set_start_executor("router") - .as_connection() ) # Downstream connection that expects str inputs - next_conn = WorkflowBuilder().set_start_executor(NextStage(id="next")).as_connection() + next_conn = WorkflowBuilder().set_start_executor(NextStage(id="next")) builder = WorkflowBuilder() - branch_handle = builder.add_connection(branch_conn, prefix="branch") - next_handle = builder.add_connection(next_conn, prefix="down") + branch_handle = builder.add_workflow(branch_conn, prefix="branch") + next_handle = builder.add_workflow(next_conn, prefix="down") # Wire both branch exits to the next connection start; only the active branch fires. - for out_point in branch_handle.output_points: - builder.connect(out_point, next_handle.start_id) + for out_point in branch_handle.outputs: + builder.connect(out_point, next_handle.start) - builder.set_start_executor(branch_handle.start_id) + builder.set_start_executor(branch_handle.start) workflow = builder.build() print("Outputs:") diff --git a/python/samples/getting_started/workflows/composition/composed_custom_chains.py b/python/samples/getting_started/workflows/composition/composed_custom_chains.py index 45b81c6939..9333d86ac1 100644 --- a/python/samples/getting_started/workflows/composition/composed_custom_chains.py +++ b/python/samples/getting_started/workflows/composition/composed_custom_chains.py @@ -32,25 +32,19 @@ async def publish(self, text: str, ctx: WorkflowContext[str, str]) -> None: async def main() -> None: - normalize_connection = ( - WorkflowBuilder() - .add_edge(Normalize(id="normalize"), Enrich(id="enrich")) - .set_start_executor("normalize") - .as_connection() + normalize_connection = WorkflowBuilder().add_edge(Normalize(id="normalize"), Enrich(id="enrich")).set_start_executor( + "normalize" ) - summarize_connection = ( - WorkflowBuilder() - .add_edge(Summarize(id="summarize"), Publish(id="publish")) - .set_start_executor("summarize") - .as_connection() + summarize_connection = WorkflowBuilder().add_edge(Summarize(id="summarize"), Publish(id="publish")).set_start_executor( + "summarize" ) builder = WorkflowBuilder() - normalize_handle = builder.add_connection(normalize_connection, prefix="prep") - summarize_handle = builder.add_connection(summarize_connection, prefix="summary") - builder.connect(normalize_handle.output_points[0], summarize_handle.start_id) - builder.set_start_executor(normalize_handle.start_id) + normalize_handle = builder.add_workflow(normalize_connection, prefix="prep") + summarize_handle = builder.add_workflow(summarize_connection, prefix="summary") + builder.connect(normalize_handle.outputs[0], summarize_handle.start) + builder.set_start_executor(normalize_handle.start) workflow = builder.build() print("Outputs:") diff --git a/python/samples/getting_started/workflows/composition/composed_custom_to_concurrent.py b/python/samples/getting_started/workflows/composition/composed_custom_to_concurrent.py index b79aea3647..46e3a149f4 100644 --- a/python/samples/getting_started/workflows/composition/composed_custom_to_concurrent.py +++ b/python/samples/getting_started/workflows/composition/composed_custom_to_concurrent.py @@ -68,26 +68,20 @@ async def emit( async def main() -> None: - intake_fragment = ( - WorkflowBuilder() - .add_edge(Intake(id="intake"), Screen(id="screen")) - .set_start_executor("intake") - .as_connection() - ) + intake_fragment = WorkflowBuilder().add_edge(Intake(id="intake"), Screen(id="screen")).set_start_executor("intake") concurrent_fragment = ( ConcurrentBuilder() .participants([ReviewWorker(id="policy"), ReviewWorker(id="risk")]) .with_aggregator(Aggregator(id="combine")) - .as_connection() ) builder = WorkflowBuilder() - intake_handle = builder.add_connection(intake_fragment, prefix="intake") - concurrent_handle = builder.add_connection(concurrent_fragment, prefix="review") - builder.connect(intake_handle.output_points[0], concurrent_handle.start_id) - builder.connect(concurrent_handle.output_points[0], Emit(id="emit")) - builder.set_start_executor(intake_handle.start_id) + intake_handle = builder.add_workflow(intake_fragment, prefix="intake") + concurrent_handle = builder.add_workflow(concurrent_fragment, prefix="review") + builder.connect(intake_handle.outputs[0], concurrent_handle.start) + builder.connect(concurrent_handle.outputs[0], Emit(id="emit")) + builder.set_start_executor(intake_handle.start) workflow = builder.build() print("Outputs:") diff --git a/python/samples/getting_started/workflows/composition/composed_custom_types.py b/python/samples/getting_started/workflows/composition/composed_custom_types.py index 6e46ca04c9..6379bf08cb 100644 --- a/python/samples/getting_started/workflows/composition/composed_custom_types.py +++ b/python/samples/getting_started/workflows/composition/composed_custom_types.py @@ -56,31 +56,21 @@ async def publish(self, decision: Decision, ctx: WorkflowContext[Decision, Decis async def main() -> None: - # Connection A: string -> Task -> EnrichedTask - prep = ( - WorkflowBuilder() - .add_edge(Ingest(id="ingest"), Tagger(id="tagger")) - .set_start_executor("ingest") - .as_connection() - ) - - # Connection B: EnrichedTask -> Decision -> publish - review = ( - WorkflowBuilder() - .add_edge(Reviewer(id="reviewer"), Publish(id="publish")) - .set_start_executor("reviewer") - .as_connection() - ) + # Fragment A: string -> Task -> EnrichedTask + prep = WorkflowBuilder().add_edge(Ingest(id="ingest"), Tagger(id="tagger")).set_start_executor("ingest") + + # Fragment B: EnrichedTask -> Decision -> publish + review = WorkflowBuilder().add_edge(Reviewer(id="reviewer"), Publish(id="publish")).set_start_executor("reviewer") builder = WorkflowBuilder() - prep_handle = builder.add_connection(prep, prefix="prep") - review_handle = builder.add_connection(review, prefix="rev") + prep_handle = builder.add_workflow(prep, prefix="prep") + review_handle = builder.add_workflow(review, prefix="rev") # Wire using typed connection points (no raw ids): - # - prep_handle.output_points[0] describes the exit executor AND its output types (EnrichedTask here) - # - review_handle.start_id refers to the entry executor whose input types include EnrichedTask - builder.connect(prep_handle.output_points[0], review_handle.start_id) - builder.set_start_executor(prep_handle.start_id) + # - prep_handle.outputs[0] describes the exit executor AND its output types (EnrichedTask here) + # - review_handle.start refers to the entry executor whose input types include EnrichedTask + builder.connect(prep_handle.outputs[0], review_handle.start) + builder.set_start_executor(prep_handle.start) workflow = builder.build() print("Outputs:") diff --git a/python/samples/getting_started/workflows/composition/composed_sequential_concurrent.py b/python/samples/getting_started/workflows/composition/composed_sequential_concurrent.py index abde2529fc..1f79563cbb 100644 --- a/python/samples/getting_started/workflows/composition/composed_sequential_concurrent.py +++ b/python/samples/getting_started/workflows/composition/composed_sequential_concurrent.py @@ -73,27 +73,24 @@ async def emit( async def main() -> None: - sequential_fragment = ( - SequentialBuilder().participants([DraftExecutor(id="draft"), RefineExecutor(id="refine")]).as_connection() - ) + sequential_fragment = SequentialBuilder().participants([DraftExecutor(id="draft"), RefineExecutor(id="refine")]) concurrent_fragment = ( ConcurrentBuilder() .participants([ReviewWorker(id="legal"), ReviewWorker(id="brand")]) .with_aggregator(Aggregator(id="collect")) - .as_connection() ) builder = WorkflowBuilder() - seq_handle = builder.add_connection(sequential_fragment, prefix="seq") - concurrent_handle = builder.add_connection(concurrent_fragment, prefix="conc") + seq_handle = builder.add_workflow(sequential_fragment, prefix="seq") + concurrent_handle = builder.add_workflow(concurrent_fragment, prefix="conc") # Wire sequential output into concurrent, then terminate with emit: # - sequential fragment outputs a list[ChatMessage] conversation -> feed into concurrent start # - concurrent aggregator emits list[ChatMessage] results -> send to emit to yield workflow output - builder.connect(seq_handle.output_points[0], concurrent_handle.start_id) - builder.connect(concurrent_handle.output_points[0], Emit(id="emit")) - builder.set_start_executor(seq_handle.start_id) + builder.connect(seq_handle.outputs[0], concurrent_handle.start) + builder.connect(concurrent_handle.outputs[0], Emit(id="emit")) + builder.set_start_executor(seq_handle.start) workflow = builder.build() print("Outputs:") From b74d5eb78b2675224ba0e57380d5f59be6985d34 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 24 Nov 2025 15:00:30 +0900 Subject: [PATCH 4/7] Fix import order --- .../agent_framework/_workflows/__init__.py | 4 +-- .../agent_framework/_workflows/__init__.pyi | 4 +-- .../agent_framework/_workflows/_workflow.py | 2 +- .../_workflows/_workflow_builder.py | 26 ++++++++++--------- .../tests/workflow/test_connect_fragments.py | 12 ++------- 5 files changed, 21 insertions(+), 27 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 82a4eac2f7..ff49e4d9db 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -112,6 +112,7 @@ "Case", "CheckpointStorage", "ConcurrentBuilder", + "ConnectionHandle", "Default", "Edge", "EdgeDuplicationError", @@ -163,10 +164,10 @@ "ValidationTypeEnum", "Workflow", "WorkflowAgent", - "WorkflowConnection", "WorkflowBuilder", "WorkflowCheckpoint", "WorkflowCheckpointSummary", + "WorkflowConnection", "WorkflowContext", "WorkflowErrorDetails", "WorkflowEvent", @@ -187,5 +188,4 @@ "handler", "response_handler", "validate_workflow_graph", - "ConnectionHandle", ] diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index 2df5abed1e..76861fa38f 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -109,6 +109,7 @@ __all__ = [ "Case", "CheckpointStorage", "ConcurrentBuilder", + "ConnectionHandle", "Default", "Edge", "EdgeDuplicationError", @@ -159,10 +160,10 @@ __all__ = [ "ValidationTypeEnum", "Workflow", "WorkflowAgent", - "WorkflowConnection", "WorkflowBuilder", "WorkflowCheckpoint", "WorkflowCheckpointSummary", + "WorkflowConnection", "WorkflowContext", "WorkflowErrorDetails", "WorkflowEvent", @@ -183,5 +184,4 @@ __all__ = [ "handler", "response_handler", "validate_workflow_graph", - "ConnectionHandle", ] diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index f9d87e9edf..78b745973a 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -9,7 +9,7 @@ import sys import uuid from collections.abc import AsyncIterable, Awaitable, Callable -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ..observability import OtelAttr, capture_exception, create_workflow_span from ._agent import WorkflowAgent diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index 241a91e4c9..0ed8324568 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -5,7 +5,7 @@ import sys from collections.abc import Callable, Sequence from dataclasses import dataclass -from typing import Any, overload +from typing import Any from .._agents import AgentProtocol from ..observability import OtelAttr, capture_exception, create_workflow_span @@ -84,7 +84,10 @@ def clone(self) -> "WorkflowConnection": entry=self.entry, exits=list(self.exits), start_input_types=list(self.start_input_types or []), - exit_points=[ConnectionPoint(p.id, list(p.output_types), list(p.workflow_output_types)) for p in self.exit_points or []], + exit_points=[ + ConnectionPoint(p.id, list(p.output_types), list(p.workflow_output_types)) + for p in self.exit_points or [] + ], ) def with_prefix(self, prefix: str | None) -> "WorkflowConnection": @@ -184,7 +187,8 @@ def _remap_edge_group_ids(group: EdgeGroup, mapping: dict[str, str], prefix: str remapped._target_ids[idx] = mapping.get(target, target) # type: ignore[attr-defined] new_targets.append(remapped._target_ids[idx]) # type: ignore[attr-defined] remapped.cases = [ # type: ignore[attr-defined] - _remap_switch_case(case, mapping) for case in remapped.cases # type: ignore[attr-defined] + _remap_switch_case(case, mapping) + for case in remapped.cases # type: ignore[attr-defined] ] return remapped @@ -208,7 +212,7 @@ def _derive_exit_ids(edge_groups: list[EdgeGroup], executors: dict[str, Executor outgoing.setdefault(edge.source_id, []).append(edge.target_id) exits: list[str] = [] - for executor_id, executor in executors.items(): + for executor_id, _ in executors.items(): targets = outgoing.get(executor_id, []) if not targets: exits.append(executor_id) @@ -384,17 +388,16 @@ def _merge_connection(self, fragment: WorkflowConnection, *, prefix: str | None) else prepared.builder._start_executor ) start_types = prepared.start_input_types or _get_executor_input_types(prepared.builder._executors, start_id) - exit_points = prepared.exit_points or _derive_exit_points(prepared.builder._edge_groups, prepared.builder._executors) - handle = ConnectionHandle( + exit_points = prepared.exit_points or _derive_exit_points( + prepared.builder._edge_groups, prepared.builder._executors + ) + return ConnectionHandle( start_id=start_id, start_input_types=start_types, output_points=exit_points, ) - return handle - def _derive_prefix( - self, fragment: "WorkflowBuilder | Workflow | WorkflowConnection", explicit: str | None - ) -> str: + def _derive_prefix(self, fragment: "WorkflowBuilder | Workflow | WorkflowConnection", explicit: str | None) -> str: """Choose a stable prefix from explicit input, fragment name, or a deterministic fallback.""" if explicit: return explicit @@ -429,8 +432,7 @@ def _to_connection( if isinstance(fragment, Workflow): return fragment.as_connection(prefix=prefix) raise TypeError( - "add_workflow expects a WorkflowBuilder, Workflow, or WorkflowConnection; " - f"got {type(fragment).__name__}." + f"add_workflow expects a WorkflowBuilder, Workflow, or WorkflowConnection; got {type(fragment).__name__}." ) def _normalize_endpoint(self, endpoint: Executor | AgentProtocol | ConnectionHandle | ConnectionPoint | str) -> str: diff --git a/python/packages/core/tests/workflow/test_connect_fragments.py b/python/packages/core/tests/workflow/test_connect_fragments.py index 8d8637070a..ff15373438 100644 --- a/python/packages/core/tests/workflow/test_connect_fragments.py +++ b/python/packages/core/tests/workflow/test_connect_fragments.py @@ -5,11 +5,9 @@ import pytest from agent_framework import ( - ConnectionHandle, Executor, Workflow, WorkflowBuilder, - WorkflowConnection, WorkflowContext, handler, ) @@ -41,10 +39,7 @@ async def finish(self, text: str, ctx: WorkflowContext[Any, str]) -> None: async def test_connect_merges_fragments_and_runs() -> None: connection_one = ( - WorkflowBuilder() - .add_edge(_Source(id="src"), _Upper(id="up")) - .set_start_executor("src") - .as_connection() + WorkflowBuilder().add_edge(_Source(id="src"), _Upper(id="up")).set_start_executor("src").as_connection() ) connection_two = ( @@ -72,10 +67,7 @@ async def test_connect_merges_fragments_and_runs() -> None: async def test_connect_detects_id_collision_with_raw_connection() -> None: connection = ( - WorkflowBuilder() - .add_edge(_Source(id="dup"), _Upper(id="dup_upper")) - .set_start_executor("dup") - .as_connection() + WorkflowBuilder().add_edge(_Source(id="dup"), _Upper(id="dup_upper")).set_start_executor("dup").as_connection() ) builder = WorkflowBuilder() From 457962a1cad46e9b6718095130f0d411396ce48d Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 3 Dec 2025 16:18:24 +0900 Subject: [PATCH 5/7] Design updates --- .../00XX-workflow_composability_design.md | 151 ++- .../agent_framework/_workflows/__init__.py | 33 +- .../agent_framework/_workflows/__init__.pyi | 187 ---- .../_workflows/_type_adapters.py | 870 ++++++++++++++++++ .../_workflows/_workflow_builder.py | 762 ++++++++++++++- .../tests/workflow/test_connect_fragments.py | 173 ++++ .../core/tests/workflow/test_type_adapters.py | 316 +++++++ .../composition/composed_merge_api.py | 209 +++++ .../composition/composed_with_adapters.py | 212 +++++ 9 files changed, 2702 insertions(+), 211 deletions(-) delete mode 100644 python/packages/core/agent_framework/_workflows/__init__.pyi create mode 100644 python/packages/core/agent_framework/_workflows/_type_adapters.py create mode 100644 python/packages/core/tests/workflow/test_type_adapters.py create mode 100644 python/samples/getting_started/workflows/composition/composed_merge_api.py create mode 100644 python/samples/getting_started/workflows/composition/composed_with_adapters.py diff --git a/docs/decisions/00XX-workflow_composability_design.md b/docs/decisions/00XX-workflow_composability_design.md index d7603241d2..de53dbbc1f 100644 --- a/docs/decisions/00XX-workflow_composability_design.md +++ b/docs/decisions/00XX-workflow_composability_design.md @@ -17,9 +17,85 @@ informed: team - Keep checkpointing, request/response handling, and observability semantics intact across composed graphs. ## Terminology -- Workflow: an immutable, built artifact that can be executed or wrapped (e.g., by WorkflowExecutor). Composition should not mutate an existing Workflow. -- WorkflowBuilder: the mutable authoring surface for wiring executors, merging graphs, and validating types. All composition logic lives here. -- High-level builders: convenience builders (ConcurrentBuilder, SequentialBuilder, group chat variants) that internally use WorkflowBuilder; they will share a base mixin that provides `.as_connection()` so the composition API is consistent. + +This section defines the core concepts precisely. Understanding these distinctions is critical for effective composition. + +### Workflow vs WorkflowBuilder vs WorkflowConnection + +| Concept | Mutability | Purpose | When to Use | +|---------|-----------|---------|-------------| +| **Workflow** | Immutable | A built, executable workflow artifact | After `builder.build()` when you need to run or wrap the workflow | +| **WorkflowBuilder** | Mutable | The authoring surface for constructing workflow graphs | When adding executors, edges, or composing fragments | +| **WorkflowConnection** | Immutable (snapshot) | Composition metadata wrapper around a builder | Advanced cases where you need pre-computed entry/exit metadata | + +**WorkflowBuilder** is the primary authoring interface. It accumulates executors and edge groups, then produces an immutable **Workflow** via `build()`. During composition, fragments are merged at the builder level to maintain a single, unified graph. + +**WorkflowConnection** is a thin wrapper that captures: +- The underlying builder (cloned to preserve immutability) +- Entry point executor ID +- Exit point executor IDs and their types +- Optional named outputs for multi-exit fragments + +Most users should **never interact with WorkflowConnection directly**. The `add_workflow()` method accepts builders directly and handles connection creation internally. + +### High-Level Builders + +High-level builders (ConcurrentBuilder, SequentialBuilder, GroupChatBuilder, etc.) are convenience APIs that internally use WorkflowBuilder. They all share a common mixin providing: +- `.as_connection(prefix=None)` - Extract composition metadata without building +- `.build()` - Produce an immutable Workflow + +### ConnectionHandle vs ConnectionPoint + +After merging a fragment, you receive a **ConnectionHandle** with: +- `.start` / `.start_id` - The entry point executor ID +- `.outputs` - An accessor for exit points (supports both `[index]` and `["name"]` access) +- `.source_builder` - Reference to the merged builder (for advanced introspection) + +Each exit point is a **ConnectionPoint** containing: +- `.id` - The executor ID (prefixed after merge) +- `.name` - Optional semantic name (e.g., "summary", "errors") +- `.output_types` - Types this executor sends downstream +- `.workflow_output_types` - Types this executor yields as workflow outputs + +### API Selection Guide + +**Use `add_workflow()` when you want:** +- Simple, high-level composition +- Automatic prefix derivation from fragment name +- Type-safe ConnectionHandle for wiring + +**Use `merge()` when you want:** +- Lower-level control over graph merging +- Direct access to executor IDs after merge +- Manual edge wiring with `add_edge()` + +**Use `connect()` / `connect_checked()` when you want:** +- Explicit edge creation between endpoints +- Type validation at wire time (connect_checked) +- Adapter insertion for type mismatches + +### Code Examples + +```python +# High-level API (recommended for most cases) +builder = WorkflowBuilder() +handle = builder.add_workflow(concurrent_analysis) +builder.connect(data_source, handle.start) +builder.connect(handle.outputs[0], aggregator) + +# Lower-level API (when you need direct control) +builder = WorkflowBuilder() +builder.merge(concurrent_analysis, prefix="analysis") +builder.add_edge("analysis/analyzer", "analysis/aggregator") + +# Type-checked connection with adapter +from agent_framework._workflows import TextToConversation +builder.connect_checked( + text_producer, + chat_consumer, + adapter=TextToConversation(), +) +``` ## Current State - High-level builders (ConcurrentBuilder, SequentialBuilder, group chat variants) emit a finished Workflow; the graph is immutable and cannot be extended directly. @@ -139,6 +215,75 @@ async for event in workflow.run_stream(" Hello Composition "): - Note: this remains sugar for “add a new executor that transforms the data”; the value is deterministic naming, validation, and observability of these adapters instead of one-off inline callables. - Pros: predictable type-safe bridges and better error messages. Cons: adds small surface area but aligns with existing adapter executors already used inside SequentialBuilder. +### Implemented Type Adapters (Stage 2) + +The following type adapters are now implemented in `_type_adapters.py`: + +**Base Classes:** +- `TypeAdapter[TInput, TOutput]` - Abstract base class for all adapters +- `FunctionAdapter` - Quick adapter wrapping a lambda or function + +**Built-in Adapters:** + +| Adapter | From Type | To Type | Purpose | +|---------|-----------|---------|---------| +| `TextToConversation` | `str` | `list[ChatMessage]` | Wrap text in a single-message conversation | +| `ConversationToText` | `list[ChatMessage]` | `str` | Extract text from conversation messages | +| `SingleMessageExtractor` | `list[ChatMessage]` | `ChatMessage` | Extract one message from conversation | +| `MessageWrapper` | `ChatMessage` | `list[ChatMessage]` | Wrap single message in a list | +| `ListToItem[T]` | `list[T]` | `T` | Extract single item from list | +| `ItemToList[T]` | `T` | `list[T]` | Wrap item in a list | +| `IdentityAdapter` | `T` | `T` | Pass-through for debugging/validation | + +**Factory Functions:** +- `json_serializer(input_type)` - Create adapter that serializes to JSON +- `json_deserializer(output_type)` - Create adapter that deserializes from JSON +- `struct_to_dict_adapter(input_type)` - Convert structured objects to dicts +- `dict_to_struct_adapter(output_type)` - Convert dicts to structured objects + +**Automatic Adapter Discovery:** +- `find_adapter(source_type, target_type)` - Find a built-in adapter for type pair + +**Type Validation Methods (on WorkflowBuilder):** +- `validate_edge_types(source_id, target_id)` - Check type compatibility +- `connect_checked(source, target, adapter=None)` - Connect with type validation + +**Usage Examples:** + +```python +from agent_framework._workflows import ( + WorkflowBuilder, + TextToConversation, + ConversationToText, + find_adapter, +) + +# Manual adapter insertion +builder = WorkflowBuilder() +builder.connect_checked( + text_producer, + chat_consumer, + adapter=TextToConversation(), +) + +# Custom adapter via FunctionAdapter +from agent_framework._workflows import FunctionAdapter + +custom_adapter = FunctionAdapter( + fn=lambda data, ctx: {"processed": data}, + _input_type=str, + _output_type=dict, +) + +# Automatic adapter suggestion on type mismatch +try: + builder.connect_checked(text_producer, chat_consumer) +except TypeError as e: + print(e) + # "Type mismatch: 'text_producer' outputs [str] but 'chat_consumer' + # expects [list[ChatMessage]]. Suggested: TextToConversation()" +``` + ## Option 4: Port-Based Interfaces and Extension Points - Elevate executor I/O to named ports with declared types, making composition addressable: - Executors expose `ports: dict[str, PortSpec]` where PortSpec includes direction (in/out), type set, and optional semantics tag (`conversation`, `aggregate`, `request`, `control`). diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index ff49e4d9db..0df5f079d5 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -86,6 +86,22 @@ ) from ._sequential import SequentialBuilder from ._shared_state import SharedState +from ._type_adapters import ( + ConversationToText, + FunctionAdapter, + IdentityAdapter, + ItemToList, + ListToItem, + MessageWrapper, + SingleMessageExtractor, + TextToConversation, + TypeAdapter, + dict_to_struct_adapter, + find_adapter, + json_deserializer, + json_serializer, + struct_to_dict_adapter, +) from ._validation import ( EdgeDuplicationError, GraphConnectivityError, @@ -96,7 +112,7 @@ ) from ._viz import WorkflowViz from ._workflow import Workflow, WorkflowRunResult -from ._workflow_builder import ConnectionHandle, WorkflowBuilder, WorkflowConnection +from ._workflow_builder import ConnectionHandle, MergeResult, WorkflowBuilder, WorkflowConnection from ._workflow_context import WorkflowContext from ._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor @@ -113,6 +129,7 @@ "CheckpointStorage", "ConcurrentBuilder", "ConnectionHandle", + "ConversationToText", "Default", "Edge", "EdgeDuplicationError", @@ -124,6 +141,7 @@ "FanInEdgeGroup", "FanOutEdgeGroup", "FileCheckpointStorage", + "FunctionAdapter", "FunctionExecutor", "GraphConnectivityError", "GroupChatBuilder", @@ -131,8 +149,11 @@ "GroupChatStateSnapshot", "HandoffBuilder", "HandoffUserInputRequest", + "IdentityAdapter", "InMemoryCheckpointStorage", "InProcRunnerContext", + "ItemToList", + "ListToItem", "MagenticAgentDeltaEvent", "MagenticAgentMessageEvent", "MagenticBuilder", @@ -144,7 +165,9 @@ "MagenticPlanReviewReply", "MagenticPlanReviewRequest", "ManagerDirectiveModel", + "MergeResult", "Message", + "MessageWrapper", "OrchestrationState", "RequestInfoEvent", "Runner", @@ -152,6 +175,7 @@ "SequentialBuilder", "SharedState", "SingleEdgeGroup", + "SingleMessageExtractor", "StandardMagenticManager", "SubWorkflowRequestMessage", "SubWorkflowResponseMessage", @@ -160,6 +184,8 @@ "SwitchCaseEdgeGroup", "SwitchCaseEdgeGroupCase", "SwitchCaseEdgeGroupDefault", + "TextToConversation", + "TypeAdapter", "TypeCompatibilityError", "ValidationTypeEnum", "Workflow", @@ -183,9 +209,14 @@ "WorkflowValidationError", "WorkflowViz", "create_edge_runner", + "dict_to_struct_adapter", "executor", + "find_adapter", "get_checkpoint_summary", "handler", + "json_deserializer", + "json_serializer", "response_handler", + "struct_to_dict_adapter", "validate_workflow_graph", ] diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi deleted file mode 100644 index 76861fa38f..0000000000 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ /dev/null @@ -1,187 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from ._agent import WorkflowAgent -from ._agent_executor import ( - AgentExecutor, - AgentExecutorRequest, - AgentExecutorResponse, -) -from ._checkpoint import ( - CheckpointStorage, - FileCheckpointStorage, - InMemoryCheckpointStorage, - WorkflowCheckpoint, -) -from ._checkpoint_summary import WorkflowCheckpointSummary, get_checkpoint_summary -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, - ExecutorInvokedEvent, - RequestInfoEvent, - SuperStepCompletedEvent, - SuperStepStartedEvent, - WorkflowErrorDetails, - WorkflowEvent, - WorkflowEventSource, - WorkflowFailedEvent, - WorkflowLifecycleEvent, - WorkflowOutputEvent, - WorkflowRunState, - WorkflowStartedEvent, - WorkflowStatusEvent, -) -from ._executor import ( - Executor, - handler, -) -from ._function_executor import FunctionExecutor, executor -from ._group_chat import ( - DEFAULT_MANAGER_INSTRUCTIONS, - DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT, - GroupChatBuilder, - GroupChatDirective, - GroupChatStateSnapshot, -) -from ._handoff import HandoffBuilder, HandoffUserInputRequest -from ._magentic import ( - MagenticAgentDeltaEvent, - MagenticAgentMessageEvent, - MagenticBuilder, - MagenticContext, - MagenticFinalResultEvent, - MagenticManagerBase, - MagenticOrchestratorMessageEvent, - MagenticPlanReviewDecision, - MagenticPlanReviewReply, - MagenticPlanReviewRequest, - StandardMagenticManager, -) -from ._orchestration_state import OrchestrationState -from ._request_info_mixin import response_handler -from ._runner import Runner -from ._runner_context import ( - InProcRunnerContext, - Message, - RunnerContext, -) -from ._sequential import SequentialBuilder -from ._shared_state import SharedState -from ._validation import ( - EdgeDuplicationError, - GraphConnectivityError, - TypeCompatibilityError, - ValidationTypeEnum, - WorkflowValidationError, - validate_workflow_graph, -) -from ._viz import WorkflowViz -from ._workflow import Workflow, WorkflowRunResult -from ._workflow_builder import ConnectionHandle, WorkflowBuilder, WorkflowConnection -from ._workflow_context import WorkflowContext -from ._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor - -__all__ = [ - "DEFAULT_MANAGER_INSTRUCTIONS", - "DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT", - "DEFAULT_MAX_ITERATIONS", - "AgentExecutor", - "AgentExecutorRequest", - "AgentExecutorResponse", - "AgentRunEvent", - "AgentRunUpdateEvent", - "Case", - "CheckpointStorage", - "ConcurrentBuilder", - "ConnectionHandle", - "Default", - "Edge", - "EdgeDuplicationError", - "Executor", - "ExecutorCompletedEvent", - "ExecutorEvent", - "ExecutorFailedEvent", - "ExecutorInvokedEvent", - "FanInEdgeGroup", - "FanOutEdgeGroup", - "FileCheckpointStorage", - "FunctionExecutor", - "GraphConnectivityError", - "GroupChatBuilder", - "GroupChatDirective", - "GroupChatStateSnapshot", - "HandoffBuilder", - "HandoffUserInputRequest", - "InMemoryCheckpointStorage", - "InProcRunnerContext", - "MagenticAgentDeltaEvent", - "MagenticAgentMessageEvent", - "MagenticBuilder", - "MagenticContext", - "MagenticFinalResultEvent", - "MagenticManagerBase", - "MagenticOrchestratorMessageEvent", - "MagenticPlanReviewDecision", - "MagenticPlanReviewReply", - "MagenticPlanReviewRequest", - "Message", - "OrchestrationState", - "RequestInfoEvent", - "Runner", - "RunnerContext", - "SequentialBuilder", - "SharedState", - "SingleEdgeGroup", - "StandardMagenticManager", - "SubWorkflowRequestMessage", - "SubWorkflowResponseMessage", - "SuperStepCompletedEvent", - "SuperStepStartedEvent", - "SwitchCaseEdgeGroup", - "SwitchCaseEdgeGroupCase", - "SwitchCaseEdgeGroupDefault", - "TypeCompatibilityError", - "ValidationTypeEnum", - "Workflow", - "WorkflowAgent", - "WorkflowBuilder", - "WorkflowCheckpoint", - "WorkflowCheckpointSummary", - "WorkflowConnection", - "WorkflowContext", - "WorkflowErrorDetails", - "WorkflowEvent", - "WorkflowEventSource", - "WorkflowExecutor", - "WorkflowFailedEvent", - "WorkflowLifecycleEvent", - "WorkflowOutputEvent", - "WorkflowRunResult", - "WorkflowRunState", - "WorkflowStartedEvent", - "WorkflowStatusEvent", - "WorkflowValidationError", - "WorkflowViz", - "create_edge_runner", - "executor", - "get_checkpoint_summary", - "handler", - "response_handler", - "validate_workflow_graph", -] diff --git a/python/packages/core/agent_framework/_workflows/_type_adapters.py b/python/packages/core/agent_framework/_workflows/_type_adapters.py new file mode 100644 index 0000000000..8f1d70a2fe --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_type_adapters.py @@ -0,0 +1,870 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Type adapter utilities for workflow composition. + +This module provides infrastructure for transforming data between incompatible types +when composing workflows. When two workflows cannot be directly connected because their +input/output types do not align, a type adapter bridges the gap by performing an +explicit, type-safe transformation. + +Design Philosophy +----------------- +The adapter pattern here follows the Gang of Four "Adapter" pattern, adapted for +dataflow graphs. Key principles: + +1. **Explicit over Implicit**: Rather than performing silent coercion (which hides bugs), + adapters make type transformations visible in the workflow graph. + +2. **Composable**: Adapters are themselves executors, meaning they appear as nodes in + the workflow graph and can be inspected, debugged, and traced. + +3. **Bidirectional When Possible**: Some adapters provide both forward and reverse + transformations, enabling reuse across composition boundaries. + +4. **Zero Overhead for Compatible Types**: When source and target types are already + compatible, no adapter is needed - this module only handles mismatches. + +Usage Patterns +-------------- +There are three levels of adapter usage: + +**Level 1: Built-in Adapters (90% of cases)** + +For common transformations like str <-> list[ChatMessage], use the provided adapters: + +.. code-block:: python + + from agent_framework._workflows._type_adapters import TextToConversation + + adapter = TextToConversation() + # Use in workflow: + builder.add_executor("adapt", adapter) + builder.add_edge(["text_producer"], "adapt") + builder.add_edge(["adapt"], "chat_consumer") + +**Level 2: Custom Adapters (9% of cases)** + +For domain-specific transformations, subclass TypeAdapter: + +.. code-block:: python + + from agent_framework._workflows._type_adapters import TypeAdapter + + + class CustomerToProfile(TypeAdapter[Customer, UserProfile]): + input_type = Customer + output_type = UserProfile + + async def adapt(self, value: Customer, ctx: WorkflowContext) -> UserProfile: + return UserProfile( + name=value.full_name, + email=value.contact_email, + tier=value.subscription_level, + ) + +**Level 3: Inline Lambda Adapters (1% of cases)** + +For one-off transformations, use FunctionAdapter: + +.. code-block:: python + + from agent_framework._workflows._type_adapters import FunctionAdapter + + adapter = FunctionAdapter( + input_type=dict, + output_type=str, + fn=lambda d, ctx: json.dumps(d), + ) + +Type Safety Guarantees +---------------------- +Adapters provide compile-time (via type checkers) and runtime type safety: + +- Input types are validated before the adapter runs +- Output types are validated after the adapter runs (in debug mode) +- Type mismatches produce clear error messages with source/target info + +See Also: +-------- +- _workflow_builder.connect : The method that may require adapters +- _typing_utils.is_type_compatible : Used for type compatibility checking +- Option 3 in the composability ADR : Detailed design rationale +""" + +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, field +from typing import Any, Generic, TypeVar, cast, get_origin + +from agent_framework import ChatMessage, Role + +from ._executor import Executor, handler +from ._workflow_context import WorkflowContext + +__all__ = [ + "ConversationToText", + "FunctionAdapter", + "IdentityAdapter", + "ItemToList", + "ListToItem", + "MessageWrapper", + "SingleMessageExtractor", + "TextToConversation", + "TypeAdapter", + "dict_to_struct_adapter", + "find_adapter", + "json_deserializer", + "json_serializer", + "struct_to_dict_adapter", +] + + +# Type variables for generic adapter signatures +TInput = TypeVar("TInput") +TOutput = TypeVar("TOutput") + + +@dataclass +class TypeAdapter(ABC, Executor, Generic[TInput, TOutput]): + """Abstract base class for type adapters used in workflow composition. + + A TypeAdapter is a specialized Executor that transforms data from one type to another. + It serves as the explicit, type-safe bridge between workflow components that have + incompatible input/output signatures. + + Subclass Contract + ----------------- + Subclasses MUST: + - Define `input_type` class attribute with the expected input type + - Define `output_type` class attribute with the produced output type + - Implement `adapt()` to perform the transformation + + Subclasses MAY: + - Override `validate_input()` for custom input validation + - Override `validate_output()` for custom output validation + - Define additional fields for configuration + + Thread Safety + ------------- + TypeAdapter instances should be stateless or use immutable configuration. + The `adapt()` method may be called concurrently from multiple workflow branches. + + Example: + .. code-block:: python + + class TemperatureConverter(TypeAdapter[Celsius, Fahrenheit]): + input_type = Celsius + output_type = Fahrenheit + + async def adapt(self, value: Celsius, ctx: WorkflowContext) -> Fahrenheit: + return Fahrenheit(value.degrees * 9 / 5 + 32) + + Attributes: + id: Unique identifier for this adapter (inherited from Executor) + input_type: Class attribute defining the expected input type + output_type: Class attribute defining the produced output type + name: Optional human-readable name for debugging + strict_validation: If True, perform runtime type checks (default: True) + """ + + id: str = field(default_factory=lambda: f"adapter-{id(object())}") + input_type: type[TInput] = field(default=object, init=False, repr=False) # type: ignore[assignment] + output_type: type[TOutput] = field(default=object, init=False, repr=False) # type: ignore[assignment] + + name: str | None = field(default=None, repr=True) + strict_validation: bool = field(default=True, repr=False) + + def __post_init__(self) -> None: + """Initialize the Executor base class with the id.""" + Executor.__init__(self, id=self.id, defer_discovery=True) + self._discover_handlers() + + @abstractmethod + async def adapt(self, value: TInput, ctx: WorkflowContext) -> TOutput: + """Transform the input value to the output type. + + This is the core transformation logic that subclasses must implement. + The method receives a single input value (already validated if strict_validation + is enabled) and must return a value of the output type. + + Args: + value: The input value to transform, guaranteed to match input_type + ctx: The workflow context providing access to shared state, history, etc. + + Returns: + The transformed value, which must match output_type + + Raises: + Any exception is propagated and will terminate the workflow branch. + For recoverable errors, consider returning a Result type instead. + """ + ... + + def validate_input(self, value: Any) -> TInput: + """Validate and potentially coerce the input value. + + Override this method to implement custom input validation or coercion. + The default implementation performs an isinstance check when + strict_validation is enabled. + + Args: + value: The raw input value from the upstream executor + + Returns: + The validated (and potentially coerced) input value + + Raises: + TypeError: If the value is not compatible with input_type + """ + if self.strict_validation: + # Handle parameterized generics like list[ChatMessage] + check_type = get_origin(self.input_type) or self.input_type + if not isinstance(value, check_type): + type_name = getattr(self.input_type, "__name__", str(self.input_type)) + raise TypeError( + f"TypeAdapter {self.name or self.__class__.__name__} expected input of type " + f"{type_name}, got {type(value).__name__}" + ) + return cast(TInput, value) + + def validate_output(self, value: Any) -> TOutput: + """Validate the output value after transformation. + + Override this method to implement custom output validation. + The default implementation performs an isinstance check when + strict_validation is enabled. + + Args: + value: The output value from the adapt() method + + Returns: + The validated output value + + Raises: + TypeError: If the value is not compatible with output_type + """ + if self.strict_validation: + # Handle parameterized generics like list[ChatMessage] + check_type = get_origin(self.output_type) or self.output_type + if not isinstance(value, check_type): + type_name = getattr(self.output_type, "__name__", str(self.output_type)) + raise TypeError( + f"TypeAdapter {self.name or self.__class__.__name__} produced output of type " + f"{type(value).__name__}, expected {type_name}" + ) + return cast(TOutput, value) + + # Executor interface implementation + @property + def input_types(self) -> list[type[Any]]: + """Return the input types accepted by this adapter. + + This implements the Executor interface, allowing the adapter to be + used as a node in the workflow graph. + """ + return [self.input_type] + + @property + def output_types(self) -> list[type[Any]]: + """Return the output types produced by this adapter. + + This implements the Executor interface, allowing downstream nodes + to know what type to expect. + """ + return [self.output_type] + + @handler + async def handle_input(self, data: Any, ctx: WorkflowContext[Any, Any]) -> None: + """Handle input data and send the adapted output. + + This method implements the Executor interface via the handler decorator, + wrapping the adapt() method with validation. + + Args: + data: The input data (may be a sequence if multiple inputs) + ctx: The workflow context + """ + # Handle sequence inputs (adapters typically expect single values) + value: Any + if isinstance(data, Sequence) and not isinstance(data, (str, bytes)): + # Multiple inputs - use first if single, otherwise pass all + seq = cast(Sequence[Any], data) + value = seq[0] if len(seq) == 1 else cast(Any, data) + else: + value = data + + validated_input = self.validate_input(value) + raw_output = await self.adapt(validated_input, ctx) + validated_output = self.validate_output(raw_output) + + await ctx.send_message(validated_output) # type: ignore[arg-type] + + +@dataclass +class FunctionAdapter(TypeAdapter[TInput, TOutput]): + """Adapter that wraps a simple function for one-off transformations. + + Use this when you need a quick adapter without creating a full subclass. + For reusable adapters, prefer creating a TypeAdapter subclass. + + The function can be synchronous or asynchronous: + - Sync: `fn=lambda x, ctx: x.upper()` + - Async: `fn=async def(x, ctx): await fetch(x)` + + Example: + .. code-block:: python + + adapter = FunctionAdapter( + input_type=str, + output_type=int, + fn=lambda s, ctx: int(s), + name="str_to_int", + ) + + Attributes: + fn: The transformation function (sync or async) + input_type: The expected input type + output_type: The produced output type + """ + + fn: Callable[[TInput, WorkflowContext], TOutput | Awaitable[TOutput]] = field( + default=None, # type: ignore[assignment] + repr=False, + ) + + # Override the inherited class-level defaults with instance fields + _input_type: type[TInput] = field(default=object, repr=False) # type: ignore[assignment] + _output_type: type[TOutput] = field(default=object, repr=False) # type: ignore[assignment] + + def __post_init__(self) -> None: + """Initialize type fields from instance parameters.""" + if self.fn is None: + raise ValueError("FunctionAdapter requires a transformation function 'fn'") + # Set the class-level type attributes from instance fields + object.__setattr__(self, "input_type", self._input_type) + object.__setattr__(self, "output_type", self._output_type) + # Call parent's __post_init__ to initialize Executor + super().__post_init__() + + async def adapt(self, value: TInput, ctx: WorkflowContext) -> TOutput: + """Apply the wrapped function to transform the value.""" + result = self.fn(value, ctx) + if isinstance(result, Awaitable): + return cast(TOutput, await result) + return cast(TOutput, result) + + +# ============================================================================= +# Built-in Adapters: String <-> Conversation +# ============================================================================= + + +@dataclass +class TextToConversation(TypeAdapter[str, list[ChatMessage]]): + """Convert a plain text string to a conversation (list of ChatMessage). + + This is one of the most common adapters needed when composing workflows. + Many LLM-based workflows expect list[ChatMessage] input, but text processing + workflows produce plain strings. + + Configuration + ------------- + role: The role to assign to the created message (default: "user") + author_name: Optional author name for the message + + Example: + .. code-block:: python + + adapter = TextToConversation(role=Role.ASSISTANT) + result = await adapter.adapt("Hello!", ctx) + assert result == [ChatMessage(role=Role.ASSISTANT, text="Hello!")] + """ + + input_type: type[str] = field(default=str, init=False, repr=False) + output_type: type[list[ChatMessage]] = field(default=list, init=False, repr=False) # type: ignore[assignment] + + role: Role | str = field(default_factory=lambda: Role.USER) + author_name: str | None = field(default=None) + + @property + def output_types(self) -> list[type[Any]]: + """Return the parameterized output type list[ChatMessage].""" + return [list[ChatMessage]] # type: ignore[list-item] + + async def adapt(self, value: str, ctx: WorkflowContext) -> list[ChatMessage]: + """Convert the string to a single-message conversation.""" + role = self.role if isinstance(self.role, Role) else Role(self.role) + return [ChatMessage(role=role, text=value, author_name=self.author_name)] + + +@dataclass +class ConversationToText(TypeAdapter[list[ChatMessage], str]): + r"""Convert a conversation (list of ChatMessage) to a plain text string. + + This adapter extracts text from conversation messages, useful when + a downstream workflow expects plain text input. + + Configuration: + separator: String to join multiple messages (default: "\n\n") + include_roles: If True, prefix each message with its role (default: False) + last_only: If True, only extract the last message (default: False) + + Example: + .. code-block:: python + + adapter = ConversationToText(last_only=True) + messages = [ + ChatMessage(role=Role.USER, text="Hello"), + ChatMessage(role=Role.ASSISTANT, text="Hi there!"), + ] + result = await adapter.adapt(messages, ctx) + assert result == "Hi there!" + """ + + input_type: type[list[ChatMessage]] = field(default=list, init=False, repr=False) # type: ignore[assignment] + output_type: type[str] = field(default=str, init=False, repr=False) + + separator: str = field(default="\n\n") + include_roles: bool = field(default=False) + last_only: bool = field(default=False) + + @property + def input_types(self) -> list[type[Any]]: + """Return the parameterized input type list[ChatMessage].""" + return [list[ChatMessage]] # type: ignore[list-item] + + async def adapt(self, value: list[ChatMessage], ctx: WorkflowContext) -> str: + """Extract text from conversation messages.""" + if not value: + return "" + + if self.last_only: + msg = value[-1] + if self.include_roles: + return f"{msg.role.value}: {msg.text or ''}" + return msg.text or "" + + parts: list[str] = [] + for msg in value: + text = msg.text or "" + if self.include_roles: + parts.append(f"{msg.role.value}: {text}") + else: + parts.append(text) + + return self.separator.join(parts) + + +# ============================================================================= +# Built-in Adapters: Single <-> List +# ============================================================================= + + +@dataclass +class SingleMessageExtractor(TypeAdapter[list[ChatMessage], ChatMessage]): + """Extract a single message from a conversation. + + Useful when you need to pass a single ChatMessage to a downstream executor + that doesn't accept lists. + + Configuration + ------------- + index: Which message to extract (default: -1, meaning last message) + Negative indices work like Python list indexing. + + Raises: + ------ + IndexError: If the index is out of bounds + + Example: + .. code-block:: python + + adapter = SingleMessageExtractor(index=0) # Get first message + messages = [ChatMessage(role=Role.USER, text="First")] + result = await adapter.adapt(messages, ctx) + assert result.text == "First" + """ + + input_type: type[list[ChatMessage]] = field(default=list, init=False, repr=False) # type: ignore[assignment] + output_type: type[ChatMessage] = field(default=ChatMessage, init=False, repr=False) + + index: int = field(default=-1) + + async def adapt(self, value: list[ChatMessage], ctx: WorkflowContext) -> ChatMessage: + """Extract the message at the configured index.""" + if not value: + raise IndexError( + f"SingleMessageExtractor cannot extract message at index {self.index} from empty conversation" + ) + try: + return value[self.index] + except IndexError as e: + raise IndexError( + f"SingleMessageExtractor index {self.index} out of range for conversation with {len(value)} messages" + ) from e + + +@dataclass +class MessageWrapper(TypeAdapter[ChatMessage, list[ChatMessage]]): + """Wrap a single ChatMessage in a list. + + The inverse of SingleMessageExtractor. Use when an upstream produces + a single message but downstream expects a conversation. + + Example: + .. code-block:: python + + adapter = MessageWrapper() + msg = ChatMessage(role=Role.USER, text="Hello") + result = await adapter.adapt(msg, ctx) + assert result == [msg] + """ + + input_type: type[ChatMessage] = field(default=ChatMessage, init=False, repr=False) + output_type: type[list[ChatMessage]] = field(default=list, init=False, repr=False) # type: ignore[assignment] + + async def adapt(self, value: ChatMessage, ctx: WorkflowContext) -> list[ChatMessage]: + """Wrap the message in a list.""" + return [value] + + +@dataclass +class ListToItem(TypeAdapter[list[TInput], TInput]): + """Extract a single item from a list of any type. + + Generic version of SingleMessageExtractor that works with any list type. + + Configuration + ------------- + index: Which item to extract (default: -1, meaning last item) + + Type Parameters + --------------- + TInput: The type of items in the list + + Example: + .. code-block:: python + + adapter = ListToItem[str](index=0) + result = await adapter.adapt(["a", "b", "c"], ctx) + assert result == "a" + """ + + input_type: type[list[TInput]] = field(default=list, init=False, repr=False) # type: ignore[assignment] + output_type: type[TInput] = field(default=object, init=False, repr=False) # type: ignore[assignment] + + index: int = field(default=-1) + + async def adapt(self, value: list[TInput], ctx: WorkflowContext) -> TInput: + """Extract the item at the configured index.""" + if not value: + raise IndexError(f"ListToItem cannot extract item at index {self.index} from empty list") + try: + return value[self.index] + except IndexError as e: + raise IndexError(f"ListToItem index {self.index} out of range for list with {len(value)} items") from e + + +@dataclass +class ItemToList(TypeAdapter[TInput, list[TInput]]): + """Wrap a single item in a list. + + Generic version of MessageWrapper that works with any type. + + Type Parameters + --------------- + TInput: The type of the item to wrap + + Example: + .. code-block:: python + + adapter = ItemToList[str]() + result = await adapter.adapt("hello", ctx) + assert result == ["hello"] + """ + + input_type: type[TInput] = field(default=object, init=False, repr=False) # type: ignore[assignment] + output_type: type[list[TInput]] = field(default=list, init=False, repr=False) # type: ignore[assignment] + + async def adapt(self, value: TInput, ctx: WorkflowContext) -> list[TInput]: + """Wrap the item in a list.""" + return [value] + + +# ============================================================================= +# Built-in Adapters: Identity and Passthrough +# ============================================================================= + + +@dataclass +class IdentityAdapter(TypeAdapter[TInput, TInput]): + """Pass-through adapter that performs no transformation. + + Useful for: + - Debugging: Insert into workflow to log values + - Type narrowing: Validate type without changing value + - Graph structure: Create explicit waypoints in complex graphs + + The adapter validates that input matches the expected type but + returns the value unchanged. + + Example: + .. code-block:: python + + adapter = IdentityAdapter(input_type=str, output_type=str, name="checkpoint") + result = await adapter.adapt("unchanged", ctx) + assert result == "unchanged" + """ + + async def adapt(self, value: TInput, ctx: WorkflowContext) -> TInput: + """Return the value unchanged.""" + return value + + +# ============================================================================= +# Factory Functions for Common Patterns +# ============================================================================= + + +def json_serializer( + input_type: type[TInput] = object, # type: ignore[assignment] + *, + indent: int | None = None, + ensure_ascii: bool = True, +) -> FunctionAdapter[TInput, str]: + """Create an adapter that serializes objects to JSON strings. + + This factory creates a FunctionAdapter configured for JSON serialization. + It handles dataclasses, dicts, and any object with a `to_dict()` method. + + Args: + input_type: The type of objects to serialize (default: object) + indent: JSON indentation level (default: None for compact) + ensure_ascii: If True, escape non-ASCII characters (default: True) + + Returns: + A FunctionAdapter that serializes to JSON + + Example: + .. code-block:: python + + adapter = json_serializer(MyDataclass, indent=2) + json_str = await adapter.adapt(my_obj, ctx) + """ + import json + from dataclasses import asdict, is_dataclass + + def serialize(value: TInput, ctx: WorkflowContext) -> str: + if is_dataclass(value) and not isinstance(value, type): + data = asdict(value) + elif hasattr(value, "to_dict"): + data = value.to_dict() # type: ignore[union-attr] + else: + data = value + + return json.dumps(data, indent=indent, ensure_ascii=ensure_ascii) + + return FunctionAdapter( + fn=serialize, + _input_type=input_type, + _output_type=str, + name=f"json_serializer<{input_type.__name__}>", + ) + + +def json_deserializer( + output_type: type[TOutput], + *, + strict: bool = True, +) -> FunctionAdapter[str, TOutput]: + """Create an adapter that deserializes JSON strings to objects. + + This factory creates a FunctionAdapter configured for JSON deserialization. + For dataclasses, it attempts to construct the type from the parsed dict. + + Args: + output_type: The type to deserialize into + strict: If True, raise on missing required fields (default: True) + + Returns: + A FunctionAdapter that deserializes from JSON + + Example: + .. code-block:: python + + adapter = json_deserializer(MyDataclass) + obj = await adapter.adapt('{"name": "test"}', ctx) + """ + import json + from dataclasses import fields, is_dataclass + + def deserialize(value: str, ctx: WorkflowContext) -> TOutput: + data = json.loads(value) + + if is_dataclass(output_type): + # Filter dict to only include valid fields + valid_fields = {f.name for f in fields(output_type)} + filtered = {k: v for k, v in data.items() if k in valid_fields} + + if strict: + required_fields = {f.name for f in fields(output_type) if f.default is f.default_factory} + missing = required_fields - filtered.keys() + if missing: + raise ValueError(f"Missing required fields for {output_type.__name__}: {missing}") + + return output_type(**filtered) # type: ignore[return-value] + + if isinstance(data, dict) and hasattr(output_type, "from_dict"): + return output_type.from_dict(data) # type: ignore[return-value,union-attr] + + return cast(TOutput, data) + + return FunctionAdapter( + fn=deserialize, + _input_type=str, + _output_type=output_type, + name=f"json_deserializer<{output_type.__name__}>", + ) + + +def struct_to_dict_adapter( + input_type: type[TInput], +) -> FunctionAdapter[TInput, dict[str, Any]]: + """Create an adapter that converts structured objects to dictionaries. + + Works with dataclasses, objects with `to_dict()`, and objects with `__dict__`. + + Args: + input_type: The type of structured object to convert + + Returns: + A FunctionAdapter that converts to dict + + Example: + .. code-block:: python + + adapter = struct_to_dict_adapter(MyDataclass) + d = await adapter.adapt(my_obj, ctx) + assert isinstance(d, dict) + """ + from dataclasses import asdict, is_dataclass + + def to_dict(value: TInput, ctx: WorkflowContext) -> dict[str, Any]: + if is_dataclass(value) and not isinstance(value, type): + return asdict(value) + if hasattr(value, "to_dict"): + return value.to_dict() # type: ignore[union-attr,return-value] + if hasattr(value, "__dict__"): + return dict(value.__dict__) # type: ignore[arg-type] + raise TypeError(f"Cannot convert {type(value).__name__} to dict") + + return FunctionAdapter( + fn=to_dict, + _input_type=input_type, + _output_type=dict, # type: ignore[arg-type] + name=f"struct_to_dict<{input_type.__name__}>", + ) + + +def dict_to_struct_adapter( + output_type: type[TOutput], + *, + strict: bool = False, +) -> FunctionAdapter[dict[str, Any], TOutput]: + """Create an adapter that converts dictionaries to structured objects. + + Args: + output_type: The type to construct from the dict + strict: If True, raise on extra keys not in the type (default: False) + + Returns: + A FunctionAdapter that converts from dict + + Example: + .. code-block:: python + + adapter = dict_to_struct_adapter(MyDataclass) + obj = await adapter.adapt({"name": "test"}, ctx) + assert isinstance(obj, MyDataclass) + """ + from dataclasses import fields, is_dataclass + + def from_dict(value: dict[str, Any], ctx: WorkflowContext) -> TOutput: + if is_dataclass(output_type): + valid_fields = {f.name for f in fields(output_type)} + if strict: + extra = set(value.keys()) - valid_fields + if extra: + raise ValueError(f"Unexpected fields for {output_type.__name__}: {extra}") + filtered = {k: v for k, v in value.items() if k in valid_fields} + return output_type(**filtered) # type: ignore[return-value] + + if hasattr(output_type, "from_dict"): + return output_type.from_dict(value) # type: ignore[return-value,union-attr] + + return output_type(**value) # type: ignore[return-value] + + return FunctionAdapter( + fn=from_dict, + _input_type=dict, # type: ignore[arg-type] + _output_type=output_type, + name=f"dict_to_struct<{output_type.__name__}>", + ) + + +# ============================================================================= +# Adapter Discovery and Registration +# ============================================================================= + + +def find_adapter( + source_type: type[Any], + target_type: type[Any], +) -> TypeAdapter[Any, Any] | None: + """Find a built-in adapter for the given type pair. + + This function searches the built-in adapters for one that can transform + from source_type to target_type. Returns None if no suitable adapter exists. + + This is useful for automatic adapter insertion when connecting workflows + with mismatched types. + + Args: + source_type: The output type of the upstream executor + target_type: The input type of the downstream executor + + Returns: + A suitable TypeAdapter instance, or None if no built-in adapter matches + + Example: + .. code-block:: python + + adapter = find_adapter(str, list[ChatMessage]) + assert isinstance(adapter, TextToConversation) + """ + from typing import get_args, get_origin + + # str -> list[ChatMessage] + if source_type is str: + target_origin = get_origin(target_type) + target_args = get_args(target_type) + if target_origin is list and target_args and target_args[0] is ChatMessage: + return TextToConversation() + + # list[ChatMessage] -> str or ChatMessage + source_origin = get_origin(source_type) + source_args = get_args(source_type) + is_chat_list = source_origin is list and source_args and source_args[0] is ChatMessage + if is_chat_list and target_type is str: + return ConversationToText() + if is_chat_list and target_type is ChatMessage: + return SingleMessageExtractor() + + # ChatMessage -> list[ChatMessage] + if source_type is ChatMessage: + target_origin = get_origin(target_type) + target_args = get_args(target_type) + if target_origin is list and target_args and target_args[0] is ChatMessage: + return MessageWrapper() + + return None diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index 0ed8324568..358db1a50b 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -26,6 +26,7 @@ ) from ._executor import Executor from ._runner_context import InProcRunnerContext +from ._typing_utils import is_type_compatible from ._validation import validate_workflow_graph from ._workflow import Workflow @@ -40,41 +41,307 @@ @dataclass(frozen=True) class ConnectionPoint: - """Describes an executor endpoint with its output types.""" + """Describes an executor endpoint with its output types. + + ConnectionPoint represents a named exit point from a workflow fragment, + carrying full type information for downstream validation. Each point + corresponds to an executor that produces outputs which can flow to + subsequent stages. + + Type Information: + - output_types: Types this executor sends via ctx.send_message() + - workflow_output_types: Types this executor yields via ctx.yield_output() + + These are derived from the executor's handler signatures and used for + compile-time validation when connecting fragments. + + Attributes: + id: The executor ID (potentially prefixed during composition). + name: Optional semantic name for easier reference in multi-output + fragments (e.g., "summary", "errors", "analysis"). + output_types: List of types sent to downstream executors. + workflow_output_types: List of types yielded as workflow outputs. + """ id: str output_types: list[type[Any]] workflow_output_types: list[type[Any]] + name: str | None = None + + +class OutputPointsAccessor: + """Provides both indexed and named access to connection output points. + + This accessor enables two access patterns for multi-output fragments: + - Indexed: handle.outputs[0], handle.outputs[1] + - Named: handle.outputs["summary"], handle.outputs["errors"] + + Named access requires that ConnectionPoints have their name attribute set. + + Example: + .. code-block:: python + + handle = builder.add_workflow(multi_exit_fragment) + # Access by index (always works) + builder.connect(handle.outputs[0], next_stage) + # Access by name (when points are named) + builder.connect(handle.outputs["summary"], summary_stage) + """ + + def __init__(self, points: list[ConnectionPoint]) -> None: + self._points = points + self._by_name: dict[str, ConnectionPoint] = {} + for p in points: + if p.name: + self._by_name[p.name] = p + + def __getitem__(self, key: int | str) -> ConnectionPoint: + """Access output points by index or name. + + Args: + key: Integer index or string name of the output point. + + Returns: + The ConnectionPoint at the specified index or with the specified name. + + Raises: + IndexError: If integer index is out of range. + KeyError: If string name is not found. + TypeError: If key is neither int nor str. + """ + if isinstance(key, int): + return self._points[key] + if isinstance(key, str): + if key not in self._by_name: + available = list(self._by_name.keys()) if self._by_name else "none (outputs are unnamed)" + raise KeyError(f"No output named '{key}'. Available names: {available}") + return self._by_name[key] + raise TypeError(f"Output key must be int or str, got {type(key).__name__}") + + def __len__(self) -> int: + return len(self._points) + + def __iter__(self): + return iter(self._points) + + @property + def names(self) -> list[str]: + """Return list of available output names.""" + return list(self._by_name.keys()) + + +class MergeResult: + """Result of a merge() operation providing access to prefixed executor IDs. + + MergeResult eliminates the need to manually construct prefixed IDs after + merging a fragment. It maps original executor IDs to their prefixed versions, + supporting both attribute and dictionary access patterns. + + Access Patterns: + - Attribute: result.ingest -> "prefix/ingest" + - Dictionary: result["ingest"] -> "prefix/ingest" + - Iteration: for original, prefixed in result.items() + + Example: + .. code-block:: python + + # Merge a fragment and get the ID mapping + ids = builder.merge(ingest_fragment, prefix="in") + + # Access prefixed IDs via attribute (if valid Python identifier) + builder.add_edge(ids.ingest, ids.process) + + # Or via dictionary access (works for any ID) + builder.add_edge(ids["ingest"], ids["process"]) + + # List all mappings + for original, prefixed in ids.items(): + print(f"{original} -> {prefixed}") + """ + + def __init__(self, mapping: dict[str, str], prefix: str) -> None: + self._mapping = mapping + self._prefix = prefix + + def __getattr__(self, name: str) -> str: + if name.startswith("_"): + raise AttributeError(name) + if name not in self._mapping: + available = list(self._mapping.keys())[:10] + raise AttributeError( + f"No executor with original id '{name}'. " + f"Available: {available}{'...' if len(self._mapping) > 10 else ''}" + ) + return self._mapping[name] + + def __getitem__(self, key: str) -> str: + if key not in self._mapping: + available = list(self._mapping.keys())[:10] + raise KeyError( + f"No executor with original id '{key}'. " + f"Available: {available}{'...' if len(self._mapping) > 10 else ''}" + ) + return self._mapping[key] + + def __contains__(self, key: str) -> bool: + return key in self._mapping + + def __iter__(self): + return iter(self._mapping.values()) + + def __len__(self) -> int: + return len(self._mapping) + + def items(self): + """Return iterator of (original_id, prefixed_id) pairs.""" + return self._mapping.items() + + def keys(self): + """Return original (unprefixed) executor IDs.""" + return self._mapping.keys() + + def values(self): + """Return prefixed executor IDs.""" + return self._mapping.values() + + @property + def prefix(self) -> str: + """Return the prefix used for this merge.""" + return self._prefix + + def __repr__(self) -> str: + return f"MergeResult(prefix={self._prefix!r}, ids={list(self._mapping.keys())})" @dataclass(frozen=True) class ConnectionHandle: - """Reference to a merged connection's entry/exit points with type metadata.""" + """Reference to a merged fragment's entry/exit points with full type metadata. + + ConnectionHandle is the primary interface returned by add_workflow() and merge(). + It provides everything needed to wire the fragment into a larger workflow: + + Entry Point: + - start: The executor ID that receives input to this fragment + - start_input_types: Types the entry executor accepts + + Exit Points: + - outputs: Accessor supporting both indexed and named access to exits + - output_points: Raw list of ConnectionPoint objects + + Graph Metadata: + - source_builder: Reference to the (cloned) builder for advanced access + + Design Philosophy: + ConnectionHandle deliberately exposes only the "surface area" of a fragment - + its entry and exit points. Internal executor IDs remain encapsulated, + encouraging composition via the public interface rather than internal wiring. + + Example: + .. code-block:: python + + # Add a fragment and get its handle + analysis = builder.add_workflow(concurrent_analysis, prefix="analysis") + + # Wire using the handle's entry point + builder.connect(data_source, analysis.start) + + # Wire using indexed outputs + builder.connect(analysis.outputs[0], aggregator) + + # Or use named outputs if the fragment defines them + builder.connect(analysis.outputs["summary"], report_generator) + + Attributes: + start_id: Entry executor ID for incoming messages. + start_input_types: Types accepted by the entry executor. + output_points: List of exit points with type information. + source_builder: The WorkflowBuilder backing this fragment (for advanced use). + """ start_id: str start_input_types: list[type[Any]] output_points: list[ConnectionPoint] + source_builder: "WorkflowBuilder | None" = None @property def start(self) -> str: - """Alias for start_id to match the fluent connect API.""" + """Entry point executor ID for connecting upstream sources.""" return self.start_id @property - def outputs(self) -> list[ConnectionPoint]: - """Alias for output_points to match the fluent connect API.""" - return self.output_points + def outputs(self) -> OutputPointsAccessor: + """Exit points supporting both indexed and named access. + + Returns: + OutputPointsAccessor providing [index] and ["name"] access patterns. + """ + return OutputPointsAccessor(self.output_points) @dataclass class WorkflowConnection: - """Encapsulates a workflow connection that can be merged into another builder.""" + """Encapsulates a workflow fragment for composition into another builder. + + WorkflowConnection is an intermediate representation used during composition. + It wraps a WorkflowBuilder along with metadata about its entry and exit points, + enabling the composition machinery to correctly wire fragments together. + + When to Use WorkflowConnection Directly: + Most users should NOT need to interact with WorkflowConnection directly. + Instead, use add_workflow() which accepts builders, workflows, or connections: + + >>> # Preferred: pass builders directly to add_workflow() + >>> handle = parent_builder.add_workflow(child_builder) + + WorkflowConnection is useful when: + 1. You need to pre-compute connection metadata for reuse + 2. You're building custom composition utilities + 3. You want explicit control over cloning and prefixing + + Relationship to WorkflowBuilder: + WorkflowConnection is NOT a replacement for WorkflowBuilder. It's a + thin wrapper that adds composition metadata. The actual graph structure + lives in the wrapped builder. Think of it as a "view" of a builder + prepared for composition. + + Immutability Contract: + WorkflowConnection.clone() and .with_prefix() always return new instances + with deep-copied builders. This ensures that: + 1. The original builder/connection is never mutated + 2. Multiple compositions of the same fragment remain independent + 3. Prefix operations are safely isolated + + Attributes: + builder: The WorkflowBuilder holding the fragment's graph structure. + entry: Executor ID that serves as the fragment's input entry point. + exits: List of executor IDs that can connect to downstream stages. + start_input_types: Types accepted by the entry executor. + exit_points: Full ConnectionPoint metadata for each exit (types + names). + output_names: Optional mapping from semantic names to exit executor IDs. + + Example: + .. code-block:: python + + # Create a reusable connection from a builder + data_pipeline = ( + WorkflowBuilder(name="pipeline") + .add_chain([Ingest(id="ingest"), Transform(id="transform"), Load(id="load")]) + .set_start_executor("ingest") + ) + conn = data_pipeline.as_connection() + + # The connection can be reused with different prefixes + parent = WorkflowBuilder() + pipeline_a = parent.add_workflow(conn, prefix="region_a") + pipeline_b = parent.add_workflow(conn, prefix="region_b") + """ builder: "WorkflowBuilder" entry: str exits: list[str] start_input_types: list[type[Any]] | None = None exit_points: list[ConnectionPoint] | None = None + output_names: dict[str, str] | None = None def clone(self) -> "WorkflowConnection": """Return a deep copy of this connection to avoid shared state.""" @@ -85,9 +352,10 @@ def clone(self) -> "WorkflowConnection": exits=list(self.exits), start_input_types=list(self.start_input_types or []), exit_points=[ - ConnectionPoint(p.id, list(p.output_types), list(p.workflow_output_types)) + ConnectionPoint(p.id, list(p.output_types), list(p.workflow_output_types), name=p.name) for p in self.exit_points or [] ], + output_names=dict(self.output_names) if self.output_names else None, ) def with_prefix(self, prefix: str | None) -> "WorkflowConnection": @@ -98,6 +366,10 @@ def with_prefix(self, prefix: str | None) -> "WorkflowConnection": mapping = _prefix_executor_ids(builder_clone, prefix) entry = mapping.get(self.entry, self.entry) exits = [mapping.get(e, e) for e in self.exits] + # Remap output_names to prefixed IDs + remapped_names: dict[str, str] | None = None + if self.output_names: + remapped_names = {name: mapping.get(eid, eid) for name, eid in self.output_names.items()} return WorkflowConnection( builder=builder_clone, entry=entry, @@ -108,9 +380,11 @@ def with_prefix(self, prefix: str | None) -> "WorkflowConnection": id=mapping.get(p.id, p.id), output_types=list(p.output_types), workflow_output_types=list(p.workflow_output_types), + name=p.name, ) for p in self.exit_points or [] ], + output_names=remapped_names, ) @@ -347,25 +621,463 @@ def as_connection(self, prefix: str | None = None) -> WorkflowConnection: Endpoint = Executor | AgentProtocol | ConnectionHandle | ConnectionPoint | str + # ========================================================================= + # COMPOSITION APIs - Graph merging and connection + # ========================================================================= + + def merge( + self, + other: "WorkflowBuilder | Workflow | WorkflowConnection", + *, + prefix: str | None = None, + ) -> MergeResult: + """Merge another builder's graph into this one, returning an ID mapping. + + This is the simplest composition primitive. It copies all executors and edges + from the source into this builder, applying a prefix to avoid ID collisions. + The returned MergeResult maps original executor IDs to their prefixed versions, + eliminating the need to manually construct prefixed IDs. + + Unlike add_workflow(), merge() does NOT automatically determine entry/exit + points - it gives you full control over the resulting graph topology. + + When to Use merge() vs add_workflow(): + - Use merge() when you need low-level control and know the internal + structure of the builder you're merging + - Use add_workflow() when you want to treat the fragment as a black box + with well-defined entry and exit points + + ID Collision Handling: + If prefix is None, the method attempts to derive a prefix from the + fragment's name. If no name exists, a collision will raise ValueError. + Always provide an explicit prefix when merging unnamed builders. + + Args: + other: A WorkflowBuilder, Workflow, or WorkflowConnection to merge. + prefix: Prefix applied to all executor IDs from the merged graph. + If None, attempts to use the fragment's name property. + + Returns: + MergeResult mapping original IDs to prefixed IDs. Supports both + attribute access (result.executor_name) and dictionary access + (result["executor-name"]). + + Raises: + ValueError: If executor ID collision occurs without a prefix. + + Example: + .. code-block:: python + + # Create two separate workflow builders + data_prep = ( + WorkflowBuilder(name="prep") + .add_chain([Ingest(id="ingest"), Clean(id="clean")]) + .set_start_executor("ingest") + ) + analysis = ( + WorkflowBuilder(name="analysis") + .add_edge(Analyze(id="analyze"), Report(id="report")) + .set_start_executor("analyze") + ) + + # Merge and wire using the returned ID mapping + builder = WorkflowBuilder() + prep = builder.merge(data_prep, prefix="prep") + analysis = builder.merge(analysis_builder, prefix="analysis") + + # Access prefixed IDs via attribute or dictionary + builder.add_edge(prep.clean, analysis.analyze) + # Or: builder.add_edge(prep["clean"], analysis["analyze"]) + builder.set_start_executor(prep.ingest) + """ + effective_prefix = self._derive_prefix(other, prefix) + + # Capture original IDs before prefixing + if isinstance(other, WorkflowConnection): + original_ids = list(other.builder._executors.keys()) + elif isinstance(other, (WorkflowBuilder, Workflow)): + original_ids = list(other._executors.keys()) + else: + original_ids = [] + + connection = self._to_connection(other, prefix=effective_prefix) + prepared = connection.clone() # Already prefixed by _to_connection + + # Detect collisions before mutating state + for executor_id in prepared.builder._executors: + if executor_id in self._executors: + raise ValueError( + f"Executor id '{executor_id}' already exists in builder. Provide a different prefix when merging." + ) + + # Merge executor map and edge groups + self._executors.update(prepared.builder._executors) + self._edge_groups.extend(prepared.builder._edge_groups) + + # Build mapping from original IDs to prefixed IDs + mapping = {orig: f"{effective_prefix}/{orig}" for orig in original_ids} + return MergeResult(mapping, effective_prefix) + def add_workflow( self, fragment: "WorkflowBuilder | Workflow | WorkflowConnection", *, prefix: str | None = None ) -> ConnectionHandle: - """Merge a builder/workflow/connection and return a handle for wiring.""" + """Merge a workflow fragment and return a handle for wiring. + + This is the primary composition API. It merges the fragment's graph into + this builder and returns a ConnectionHandle with type-safe entry and exit + points. You can then use connect() to wire the fragment to other executors. + + The method accepts any composable type: + - WorkflowBuilder: Unbuilt builder (recommended for composition) + - Workflow: Built, immutable workflow (cloned during merge) + - WorkflowConnection: Pre-computed connection metadata + + No Need for as_connection(): + add_workflow() internally calls as_connection() when needed, so you + rarely need to call it yourself. Simply pass your builder directly: + + >>> # Preferred - pass builder directly + >>> handle = parent.add_workflow(child_builder) + >>> + >>> # Equivalent but more verbose + >>> handle = parent.add_workflow(child_builder.as_connection()) + + Prefix Derivation: + If prefix is None, it's derived automatically: + 1. From the fragment's name property if set + 2. From the fragment's class name if a custom subclass + 3. From a counter-based fallback ("fragment-1", "fragment-2", etc.) + + Args: + fragment: The workflow fragment to merge. + prefix: Explicit prefix for executor IDs. If None, derived from + the fragment's name or class. + + Returns: + ConnectionHandle with .start (entry point) and .outputs (exit points). + + Example: + .. code-block:: python + + # Create fragments + concurrent = ConcurrentBuilder().participants([analyzer_a, analyzer_b]) + + # Compose into a larger workflow + builder = WorkflowBuilder() + analysis = builder.add_workflow(concurrent, prefix="analysis") + + # Wire using the handle + builder.connect(data_source, analysis.start) + builder.connect(analysis.outputs[0], aggregator) + builder.set_start_executor(data_source) + """ effective_prefix = self._derive_prefix(fragment, prefix) connection = self._to_connection(fragment, prefix=effective_prefix) return self._merge_connection(connection, prefix=None) def add_connection(self, connection: WorkflowConnection, *, prefix: str | None = None) -> ConnectionHandle: - """Merge a connection into this builder and return a handle for wiring.""" + """Merge a WorkflowConnection and return a handle for wiring. + + This is a lower-level API that accepts a pre-computed WorkflowConnection. + Most users should prefer add_workflow() which accepts any composable type. + + Args: + connection: A WorkflowConnection with pre-computed entry/exit metadata. + prefix: Optional additional prefix. Note that if the connection was + already created with a prefix, this adds another layer. + + Returns: + ConnectionHandle for wiring the merged fragment. + """ return self._merge_connection(connection, prefix=prefix) def connect(self, source: Endpoint, target: Endpoint, /) -> Self: - """Connect two endpoints (executors, connection points, or executor ids).""" + """Connect two endpoints with a directed edge. + + This is the composition wiring primitive. It creates an edge from source + to target, where both can be: + - Executor instances (added to graph if not present) + - Agent instances (auto-wrapped in AgentExecutor) + - ConnectionHandle (uses .start_id as the endpoint) + - ConnectionPoint (uses .id as the endpoint) + - String executor IDs (must already exist in graph) + + Type Validation: + Currently, type compatibility is validated at build() time rather than + connect() time. Future versions may add eager type checking. + + Args: + source: The source endpoint (output producer). + target: The target endpoint (input consumer). + + Returns: + Self for method chaining. + + Raises: + ValueError: If a string ID is used but not found in the executor map. + TypeError: If an endpoint type is not supported. + + Example: + .. code-block:: python + + # Connect executors directly + builder.connect(producer, consumer) + + # Connect using handles from add_workflow() + fragment = builder.add_workflow(some_builder) + builder.connect(data_source, fragment.start) + builder.connect(fragment.outputs[0], sink) + + # Connect by ID (after merge) + builder.connect("prep/clean", "analysis/analyze") + """ src_id = self._normalize_endpoint(source) tgt_id = self._normalize_endpoint(target) self._edge_groups.append(SingleEdgeGroup(src_id, tgt_id)) # type: ignore[arg-type] return self + def connect_checked( + self, + source: "Endpoint", + target: "Endpoint", + /, + *, + adapter: "Executor | None" = None, + ) -> Self: + """Connect two endpoints with type validation, optionally inserting an adapter. + + This is the type-safe variant of connect(). It validates that the source's + output types are compatible with the target's input types BEFORE creating + the edge. If types are incompatible and no adapter is provided, it raises + a TypeError with guidance on which adapter to use. + + Type Validation: + The method checks if ANY source output type is compatible with ANY + target input type. This is permissive by design - the runtime will + route messages based on actual types. + + Automatic Adapter Suggestions: + When types are incompatible, the error message suggests built-in + adapters that could bridge the gap (e.g., TextToConversation for + str -> list[ChatMessage]). + + Args: + source: The source endpoint (output producer). + target: The target endpoint (input consumer). + adapter: Optional adapter executor to insert between source and target. + When provided, the connection becomes: source -> adapter -> target. + + Returns: + Self for method chaining. + + Raises: + TypeError: If types are incompatible and no adapter is provided. + ValueError: If a string ID is not found in the executor map. + + Example: + .. code-block:: python + + # Direct connection with type checking + builder.connect_checked(producer, consumer) + + # Insert an adapter for type conversion + from agent_framework._workflows._type_adapters import TextToConversation + + builder.connect_checked( + text_producer, + chat_consumer, + adapter=TextToConversation(), + ) + + # Error with guidance + builder.connect_checked(str_producer, chat_consumer) + # TypeError: Type mismatch: 'str_producer' outputs [str] but 'chat_consumer' + # expects [list[ChatMessage]]. Consider inserting a TypeAdapter. + # Suggested: TextToConversation() + """ + src_id = self._normalize_endpoint(source) + tgt_id = self._normalize_endpoint(target) + + if adapter is not None: + # With adapter: source -> adapter -> target + adapter_id = self._normalize_endpoint(adapter) + + # Validate source -> adapter + self.validate_edge_types(src_id, adapter_id, raise_on_mismatch=True) + + # Validate adapter -> target + self.validate_edge_types(adapter_id, tgt_id, raise_on_mismatch=True) + + # Create the two edges + self._edge_groups.append(SingleEdgeGroup(src_id, adapter_id)) # type: ignore[arg-type] + self._edge_groups.append(SingleEdgeGroup(adapter_id, tgt_id)) # type: ignore[arg-type] + else: + # Direct connection: validate and create single edge + is_compatible, error_msg = self.validate_edge_types(src_id, tgt_id, raise_on_mismatch=False) + if not is_compatible: + # Try to suggest an adapter + suggestion = self._suggest_adapter(src_id, tgt_id) + if suggestion: + error_msg = f"{error_msg}\nSuggested: {suggestion}" + raise TypeError(error_msg) + + self._edge_groups.append(SingleEdgeGroup(src_id, tgt_id)) # type: ignore[arg-type] + + return self + + def _suggest_adapter(self, source_id: str, target_id: str) -> str | None: + """Suggest a built-in adapter for type mismatch.""" + from ._type_adapters import find_adapter + + source_exec = self._executors.get(source_id) + target_exec = self._executors.get(target_id) + if not source_exec or not target_exec: + return None + + # Check each output/input pair for a matching adapter + for src_type in source_exec.output_types: + for tgt_type in target_exec.input_types: + adapter = find_adapter(src_type, tgt_type) + if adapter: + return f"{adapter.__class__.__name__}()" + return None + + def get_executor(self, executor_id: str) -> Executor: + """Retrieve an executor by ID from this builder. + + This is useful after merge() when you need type-safe access to executors + from merged fragments for wiring with add_edge(). + + Args: + executor_id: The executor ID (potentially prefixed after merge). + + Returns: + The Executor instance with the given ID. + + Raises: + KeyError: If no executor with the given ID exists. + + Example: + .. code-block:: python + + builder = WorkflowBuilder() + builder.merge(fragment_a, prefix="a") + builder.merge(fragment_b, prefix="b") + + # Access merged executors for wiring + builder.add_edge(builder.get_executor("a/output"), builder.get_executor("b/input")) + """ + if executor_id not in self._executors: + available = list(self._executors.keys()) + raise KeyError( + f"No executor with id '{executor_id}'. " + f"Available executors: {available[:10]}{'...' if len(available) > 10 else ''}" + ) + return self._executors[executor_id] + + def get_executors(self) -> dict[str, Executor]: + """Return a copy of the executor map. + + Returns: + Dictionary mapping executor IDs to Executor instances. + """ + return dict(self._executors) + + def validate_edge_types( + self, + source_id: str, + target_id: str, + *, + raise_on_mismatch: bool = True, + ) -> tuple[bool, str | None]: + """Validate type compatibility between two executors. + + This method checks whether the output types of the source executor + are compatible with the input types of the target executor using + the is_type_compatible function from _typing_utils. + + Type Compatibility Rules: + - Exact match: int -> int (compatible) + - Subtype: ChildClass -> ParentClass (compatible) + - Union member: str -> str | int (compatible) + - List covariance: list[ChildClass] -> list[ParentClass] (compatible) + + Note: + This performs eager (connect-time) validation. The workflow graph + validation at build() time performs more comprehensive checks. + + Args: + source_id: The ID of the source executor. + target_id: The ID of the target executor. + raise_on_mismatch: If True, raise TypeError on incompatibility. + If False, return (False, error_message) instead. + + Returns: + A tuple of (is_compatible, error_message). error_message is None + if compatible. + + Raises: + TypeError: If raise_on_mismatch is True and types are incompatible. + KeyError: If either executor ID is not found. + + Example: + .. code-block:: python + + builder.validate_edge_types("text_producer", "chat_consumer") + # Returns: (False, "Type mismatch: text_producer outputs [str] ...") + + # With raise_on_mismatch + builder.validate_edge_types("producer", "consumer", raise_on_mismatch=True) + # Raises: TypeError: Type mismatch: ... + """ + if source_id not in self._executors: + raise KeyError(f"Source executor '{source_id}' not found in builder.") + if target_id not in self._executors: + raise KeyError(f"Target executor '{target_id}' not found in builder.") + + source_exec = self._executors[source_id] + target_exec = self._executors[target_id] + + source_outputs = source_exec.output_types + target_inputs = target_exec.input_types + + # Check if any source output type is compatible with any target input type + # This is a permissive check - at least one path must exist + for src_type in source_outputs: + for tgt_type in target_inputs: + if is_type_compatible(src_type, tgt_type): + return (True, None) + + # No compatible path found - build error message + src_type_names = [t.__name__ if hasattr(t, "__name__") else str(t) for t in source_outputs] + tgt_type_names = [t.__name__ if hasattr(t, "__name__") else str(t) for t in target_inputs] + + error_msg = ( + f"Type mismatch: '{source_id}' outputs {src_type_names} " + f"but '{target_id}' expects {tgt_type_names}. " + f"Consider inserting a TypeAdapter to bridge these types." + ) + + if raise_on_mismatch: + raise TypeError(error_msg) + + return (False, error_msg) + + @property + def name(self) -> str | None: + """The name of this builder, used for prefix derivation.""" + return self._name + + @name.setter + def name(self, value: str | None) -> None: + """Set the name of this builder.""" + self._name = value + + @property + def executor_ids(self) -> list[str]: + """List of all executor IDs currently in this builder.""" + return list(self._executors.keys()) + def _merge_connection(self, fragment: WorkflowConnection, *, prefix: str | None) -> ConnectionHandle: """Merge a connection into this builder, returning a handle to its connection points.""" prepared = fragment.with_prefix(prefix) if prefix else fragment.clone() @@ -395,6 +1107,7 @@ def _merge_connection(self, fragment: WorkflowConnection, *, prefix: str | None) start_id=start_id, start_input_types=start_types, output_points=exit_points, + source_builder=prepared.builder, ) def _derive_prefix(self, fragment: "WorkflowBuilder | Workflow | WorkflowConnection", explicit: str | None) -> str: @@ -570,8 +1283,8 @@ def add_agent( def add_edge( self, - source: Executor | AgentProtocol, - target: Executor | AgentProtocol, + source: Endpoint, + target: Endpoint, condition: Callable[[Any], bool] | None = None, ) -> Self: """Add a directed edge between two executors. @@ -579,9 +1292,16 @@ def add_edge( The output types of the source and the input types of the target must be compatible. Messages sent by the source executor will be routed to the target executor. + Supported endpoint types: + - Executor instances (added to graph if not present) + - Agent instances (auto-wrapped in AgentExecutor) + - ConnectionHandle (uses .start_id as the endpoint) + - ConnectionPoint (uses .id as the endpoint) + - String executor IDs (must already exist in graph) + Args: - source: The source executor of the edge. - target: The target executor of the edge. + source: The source executor or endpoint of the edge. + target: The target executor or endpoint of the edge. condition: An optional condition function that determines whether the edge should be traversed based on the message type. @@ -624,12 +1344,14 @@ def only_large_numbers(msg: int) -> bool: .set_start_executor("a") .build() ) + + # Connect by string ID (after merge) + builder.merge(fragment_a, prefix="a") + builder.merge(fragment_b, prefix="b") + builder.add_edge("a/output", "b/input") """ - # TODO(@taochen): Support executor factories for lazy initialization - source_exec = self._maybe_wrap_agent(source) - target_exec = self._maybe_wrap_agent(target) - source_id = self._add_executor(source_exec) - target_id = self._add_executor(target_exec) + source_id = self._normalize_endpoint(source) + target_id = self._normalize_endpoint(target) self._edge_groups.append(SingleEdgeGroup(source_id, target_id, condition)) # type: ignore[call-arg] return self diff --git a/python/packages/core/tests/workflow/test_connect_fragments.py b/python/packages/core/tests/workflow/test_connect_fragments.py index ff15373438..281a0f181b 100644 --- a/python/packages/core/tests/workflow/test_connect_fragments.py +++ b/python/packages/core/tests/workflow/test_connect_fragments.py @@ -94,3 +94,176 @@ async def test_workflow_as_connection_round_trip() -> None: result = await workflow.run("pipeline") assert result.get_outputs() == ["pipeline"] assert any(exec_id.startswith("wrapped/") for exec_id in workflow.executors) + + +# ============================================================================= +# MergeResult tests +# ============================================================================= + + +async def test_merge_returns_merge_result_with_id_mapping() -> None: + """merge() returns MergeResult that maps original IDs to prefixed IDs.""" + fragment = WorkflowBuilder(name="frag").add_edge(_Source(id="src"), _Upper(id="up")).set_start_executor("src") + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="p") + + # Attribute access + assert result.src == "p/src" + assert result.up == "p/up" + + # Dict access + assert result["src"] == "p/src" + assert result["up"] == "p/up" + + # Prefix property + assert result.prefix == "p" + + +async def test_merge_result_attribute_access_with_valid_identifiers() -> None: + """MergeResult supports attribute access for valid Python identifiers.""" + fragment = WorkflowBuilder(name="frag").set_start_executor(_Source(id="my_executor")) + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="test") + + assert result.my_executor == "test/my_executor" + + +async def test_merge_result_dict_access_for_invalid_identifiers() -> None: + """MergeResult dict access works for IDs that aren't valid Python identifiers.""" + fragment = WorkflowBuilder(name="frag").set_start_executor(_Source(id="my-executor")) + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="test") + + # Dict access works for hyphenated IDs + assert result["my-executor"] == "test/my-executor" + + +async def test_merge_result_raises_attribute_error_for_unknown_id() -> None: + """MergeResult raises AttributeError for unknown executor IDs.""" + fragment = WorkflowBuilder(name="frag").set_start_executor(_Source(id="src")) + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="p") + + with pytest.raises(AttributeError, match="No executor with original id 'unknown'"): + _ = result.unknown + + +async def test_merge_result_raises_key_error_for_unknown_id() -> None: + """MergeResult raises KeyError for unknown executor IDs via dict access.""" + fragment = WorkflowBuilder(name="frag").set_start_executor(_Source(id="src")) + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="p") + + with pytest.raises(KeyError, match="No executor with original id 'unknown'"): + _ = result["unknown"] + + +async def test_merge_result_contains_check() -> None: + """MergeResult supports 'in' operator for checking ID existence.""" + fragment = WorkflowBuilder(name="frag").add_edge(_Source(id="src"), _Upper(id="up")).set_start_executor("src") + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="p") + + assert "src" in result + assert "up" in result + assert "unknown" not in result + + +async def test_merge_result_iteration_yields_prefixed_ids() -> None: + """Iterating MergeResult yields prefixed IDs.""" + fragment = WorkflowBuilder(name="frag").add_edge(_Source(id="src"), _Upper(id="up")).set_start_executor("src") + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="p") + + prefixed_ids = list(result) + assert "p/src" in prefixed_ids + assert "p/up" in prefixed_ids + + +async def test_merge_result_items_yields_original_and_prefixed_pairs() -> None: + """MergeResult.items() yields (original_id, prefixed_id) pairs.""" + fragment = WorkflowBuilder(name="frag").add_edge(_Source(id="src"), _Upper(id="up")).set_start_executor("src") + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="p") + + items = dict(result.items()) + assert items["src"] == "p/src" + assert items["up"] == "p/up" + + +async def test_merge_result_keys_yields_original_ids() -> None: + """MergeResult.keys() yields original (unprefixed) IDs.""" + fragment = WorkflowBuilder(name="frag").add_edge(_Source(id="src"), _Upper(id="up")).set_start_executor("src") + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="p") + + keys = list(result.keys()) + assert "src" in keys + assert "up" in keys + + +async def test_merge_result_len() -> None: + """MergeResult supports len() to get executor count.""" + fragment = WorkflowBuilder(name="frag").add_edge(_Source(id="src"), _Upper(id="up")).set_start_executor("src") + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="p") + + assert len(result) == 2 + + +async def test_merge_result_used_with_add_edge() -> None: + """MergeResult IDs can be used directly with add_edge().""" + frag_a = WorkflowBuilder(name="a").set_start_executor(_Source(id="src")) + frag_b = WorkflowBuilder(name="b").add_edge(_Upper(id="up"), _Sink(id="sink")).set_start_executor("up") + + builder = WorkflowBuilder() + a = builder.merge(frag_a, prefix="a") + b = builder.merge(frag_b, prefix="b") + + # Wire using MergeResult IDs + builder.add_edge(a.src, b.up) + builder.set_start_executor(a.src) + + workflow = builder.build() + result = await workflow.run("hello") + assert result.get_outputs() == ["HELLO"] + + +async def test_merge_result_used_with_get_executor() -> None: + """MergeResult IDs can be used with get_executor() for type-safe access.""" + fragment = WorkflowBuilder(name="frag").add_edge(_Source(id="src"), _Sink(id="sink")).set_start_executor("src") + + builder = WorkflowBuilder() + ids = builder.merge(fragment, prefix="p") + + # Get executor using MergeResult ID + src_exec = builder.get_executor(ids.src) + sink_exec = builder.get_executor(ids["sink"]) + + assert src_exec.id == "p/src" + assert sink_exec.id == "p/sink" + assert src_exec.input_types == [str] + + +async def test_merge_result_repr() -> None: + """MergeResult has a useful repr.""" + fragment = WorkflowBuilder(name="frag").add_edge(_Source(id="src"), _Upper(id="up")).set_start_executor("src") + + builder = WorkflowBuilder() + result = builder.merge(fragment, prefix="test") + + repr_str = repr(result) + assert "MergeResult" in repr_str + assert "test" in repr_str + assert "src" in repr_str + assert "up" in repr_str diff --git a/python/packages/core/tests/workflow/test_type_adapters.py b/python/packages/core/tests/workflow/test_type_adapters.py new file mode 100644 index 0000000000..1588880d10 --- /dev/null +++ b/python/packages/core/tests/workflow/test_type_adapters.py @@ -0,0 +1,316 @@ +# Copyright (c) Microsoft. All rights reserved. + +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agent_framework import ( + ChatMessage, + ConversationToText, + FunctionAdapter, + Role, + TextToConversation, + TypeAdapter, + WorkflowContext, +) + + +@dataclass +class IntToStrAdapter(TypeAdapter[int, str]): + """Test adapter that converts int to str.""" + + input_type: type[int] = int + output_type: type[str] = str + + async def adapt(self, value: int, ctx: WorkflowContext) -> str: + return str(value * 2) + + +class TestTypeAdapterBase: + """Tests for TypeAdapter base class.""" + + async def test_custom_adapter_transforms_value(self) -> None: + """Test that a custom adapter correctly transforms values.""" + adapter = IntToStrAdapter(id="int_to_str") + ctx = MagicMock(spec=WorkflowContext) + + result = await adapter.adapt(42, ctx) + + assert result == "84" + assert isinstance(result, str) + + async def test_adapter_has_correct_input_types(self) -> None: + """Test that adapter reports correct input types.""" + adapter = IntToStrAdapter(id="test") + + assert adapter.input_types == [int] + + async def test_adapter_has_correct_output_types(self) -> None: + """Test that adapter reports correct output types.""" + adapter = IntToStrAdapter(id="test") + + assert adapter.output_types == [str] + + async def test_adapter_validates_input_type(self) -> None: + """Test that adapter validates input type when strict_validation is True.""" + adapter = IntToStrAdapter(id="test", strict_validation=True) + + with pytest.raises(TypeError, match="expected input of type"): + adapter.validate_input("not an int") + + async def test_adapter_validates_output_type(self) -> None: + """Test that adapter validates output type when strict_validation is True.""" + adapter = IntToStrAdapter(id="test", strict_validation=True) + + with pytest.raises(TypeError, match="produced output of type"): + adapter.validate_output(123) # Expected str, got int + + async def test_adapter_skips_validation_when_disabled(self) -> None: + """Test that adapter skips validation when strict_validation is False.""" + adapter = IntToStrAdapter(id="test", strict_validation=False) + + # Should not raise even with wrong types + result_in = adapter.validate_input("not an int") + result_out = adapter.validate_output(123) + + assert result_in == "not an int" + assert result_out == 123 + + async def test_adapter_auto_generates_id(self) -> None: + """Test that adapter auto-generates ID if not provided.""" + adapter = IntToStrAdapter() + + assert adapter.id is not None + assert adapter.id.startswith("adapter-") + + +class TestTextToConversation: + """Tests for TextToConversation adapter.""" + + async def test_converts_text_to_conversation(self) -> None: + """Test basic text to conversation conversion.""" + adapter = TextToConversation(id="t2c") + ctx = MagicMock(spec=WorkflowContext) + + result = await adapter.adapt("Hello, world!", ctx) + + assert len(result) == 1 + assert result[0].text == "Hello, world!" + assert result[0].role == Role.USER + + async def test_uses_custom_role(self) -> None: + """Test that custom role is applied.""" + adapter = TextToConversation(id="t2c", role=Role.ASSISTANT) + ctx = MagicMock(spec=WorkflowContext) + + result = await adapter.adapt("Response text", ctx) + + assert result[0].role == Role.ASSISTANT + + async def test_uses_string_role(self) -> None: + """Test that string role is converted properly.""" + adapter = TextToConversation(id="t2c", role="system") + ctx = MagicMock(spec=WorkflowContext) + + result = await adapter.adapt("System message", ctx) + + assert result[0].role == Role.SYSTEM + + async def test_includes_author_name(self) -> None: + """Test that author_name is included.""" + adapter = TextToConversation(id="t2c", author_name="TestUser") + ctx = MagicMock(spec=WorkflowContext) + + result = await adapter.adapt("Message", ctx) + + assert result[0].author_name == "TestUser" + + async def test_output_types_returns_parameterized_list(self) -> None: + """Test that output_types returns list[ChatMessage].""" + adapter = TextToConversation(id="t2c") + + output_types = adapter.output_types + + assert len(output_types) == 1 + assert output_types[0] == list[ChatMessage] + + +class TestConversationToText: + """Tests for ConversationToText adapter.""" + + async def test_converts_conversation_to_text(self) -> None: + """Test basic conversation to text conversion.""" + adapter = ConversationToText(id="c2t") + ctx = MagicMock(spec=WorkflowContext) + messages = [ + ChatMessage(role=Role.USER, text="Hello"), + ChatMessage(role=Role.ASSISTANT, text="Hi there!"), + ] + + result = await adapter.adapt(messages, ctx) + + assert result == "Hello\n\nHi there!" + + async def test_uses_custom_separator(self) -> None: + """Test that custom separator is used.""" + adapter = ConversationToText(id="c2t", separator=" | ") + ctx = MagicMock(spec=WorkflowContext) + messages = [ + ChatMessage(role=Role.USER, text="A"), + ChatMessage(role=Role.ASSISTANT, text="B"), + ] + + result = await adapter.adapt(messages, ctx) + + assert result == "A | B" + + async def test_includes_roles(self) -> None: + """Test that roles are included when requested.""" + adapter = ConversationToText(id="c2t", include_roles=True) + ctx = MagicMock(spec=WorkflowContext) + messages = [ + ChatMessage(role=Role.USER, text="Hello"), + ] + + result = await adapter.adapt(messages, ctx) + + assert result == "user: Hello" + + async def test_last_only_extracts_last_message(self) -> None: + """Test that last_only returns only the last message.""" + adapter = ConversationToText(id="c2t", last_only=True) + ctx = MagicMock(spec=WorkflowContext) + messages = [ + ChatMessage(role=Role.USER, text="First"), + ChatMessage(role=Role.ASSISTANT, text="Last"), + ] + + result = await adapter.adapt(messages, ctx) + + assert result == "Last" + + async def test_handles_empty_conversation(self) -> None: + """Test that empty conversation returns empty string.""" + adapter = ConversationToText(id="c2t") + ctx = MagicMock(spec=WorkflowContext) + + result = await adapter.adapt([], ctx) + + assert result == "" + + async def test_input_types_returns_parameterized_list(self) -> None: + """Test that input_types returns list[ChatMessage].""" + adapter = ConversationToText(id="c2t") + + input_types = adapter.input_types + + assert len(input_types) == 1 + assert input_types[0] == list[ChatMessage] + + +class TestFunctionAdapter: + """Tests for FunctionAdapter.""" + + async def test_sync_function_transforms_value(self) -> None: + """Test that sync function adapter works.""" + adapter: FunctionAdapter[str, int] = FunctionAdapter( + id="str_to_int", + fn=lambda s, ctx: len(s), + _input_type=str, + _output_type=int, + ) + ctx = MagicMock(spec=WorkflowContext) + + result = await adapter.adapt("hello", ctx) + + assert result == 5 + + async def test_async_function_transforms_value(self) -> None: + """Test that async function adapter works.""" + + async def async_transform(s: str, ctx: Any) -> int: + return len(s) * 2 + + adapter: FunctionAdapter[str, int] = FunctionAdapter( + id="async_str_to_int", + fn=async_transform, + _input_type=str, + _output_type=int, + ) + ctx = MagicMock(spec=WorkflowContext) + + result = await adapter.adapt("hello", ctx) + + assert result == 10 + + async def test_requires_fn_parameter(self) -> None: + """Test that FunctionAdapter requires fn parameter.""" + with pytest.raises(ValueError, match="requires a transformation function"): + FunctionAdapter(id="no_fn", _input_type=str, _output_type=int) + + async def test_inherits_id_from_type_adapter(self) -> None: + """Test that FunctionAdapter properly inherits id handling.""" + adapter: FunctionAdapter[str, str] = FunctionAdapter( + id="custom_id", + fn=lambda s, ctx: s.upper(), + _input_type=str, + _output_type=str, + ) + + assert adapter.id == "custom_id" + + async def test_auto_generates_id(self) -> None: + """Test that FunctionAdapter auto-generates id if not provided.""" + adapter: FunctionAdapter[str, str] = FunctionAdapter( + fn=lambda s, ctx: s.upper(), + _input_type=str, + _output_type=str, + ) + + assert adapter.id is not None + assert adapter.id.startswith("adapter-") + + +class TestAdapterHandler: + """Tests for adapter handler integration.""" + + async def test_handler_calls_adapt(self) -> None: + """Test that the handler method calls adapt correctly.""" + adapter = IntToStrAdapter(id="test") + ctx = MagicMock(spec=WorkflowContext) + ctx.send_message = AsyncMock() + + await adapter.handle_input(42, ctx) + + ctx.send_message.assert_called_once_with("84") + + async def test_handler_unwraps_single_element_sequence(self) -> None: + """Test that handler unwraps single-element sequences.""" + adapter = IntToStrAdapter(id="test") + ctx = MagicMock(spec=WorkflowContext) + ctx.send_message = AsyncMock() + + await adapter.handle_input([42], ctx) + + ctx.send_message.assert_called_once_with("84") + + async def test_handler_passes_sequence_for_multiple_elements(self) -> None: + """Test that handler passes full sequence for multiple elements.""" + + @dataclass + class ListToStrAdapter(TypeAdapter[list[int], str]): + input_type: type[list[int]] = list + output_type: type[str] = str + + async def adapt(self, value: list[int], ctx: WorkflowContext) -> str: + return ",".join(str(v) for v in value) + + adapter = ListToStrAdapter(id="test", strict_validation=False) + ctx = MagicMock(spec=WorkflowContext) + ctx.send_message = AsyncMock() + + await adapter.handle_input([1, 2, 3], ctx) + + ctx.send_message.assert_called_once_with("1,2,3") diff --git a/python/samples/getting_started/workflows/composition/composed_merge_api.py b/python/samples/getting_started/workflows/composition/composed_merge_api.py new file mode 100644 index 0000000000..a8dcdd5964 --- /dev/null +++ b/python/samples/getting_started/workflows/composition/composed_merge_api.py @@ -0,0 +1,209 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from dataclasses import dataclass + +from agent_framework import ( + Executor, + WorkflowBuilder, + WorkflowContext, + WorkflowOutputEvent, + handler, +) + +"""Demonstrates the merge() API for low-level workflow composition. + +This sample shows the difference between add_workflow() and merge(): +- add_workflow(): Returns a ConnectionHandle for wiring via .start/.outputs +- merge(): Merges graphs directly, caller uses connect() with known IDs + +Use merge() when you: +1. Know the internal executor IDs of the fragment +2. Want direct control over edge creation +3. Are building composition utilities + +Use add_workflow() when you: +1. Want encapsulated composition via handles +2. Don't need to know internal IDs +3. Prefer the simpler API +""" + + +@dataclass +class Document: + id: str + content: str + + +@dataclass +class ProcessedDocument: + id: str + content: str + word_count: int + + +@dataclass +class EnrichedDocument: + id: str + content: str + word_count: int + summary: str + + +class Ingest(Executor): + @handler + async def ingest(self, text: str, ctx: WorkflowContext[Document]) -> None: + await ctx.send_message(Document(id="doc-1", content=text)) + + +class Process(Executor): + @handler + async def process(self, doc: Document, ctx: WorkflowContext[ProcessedDocument]) -> None: + await ctx.send_message( + ProcessedDocument( + id=doc.id, + content=doc.content, + word_count=len(doc.content.split()), + ) + ) + + +class Enrich(Executor): + @handler + async def enrich(self, doc: ProcessedDocument, ctx: WorkflowContext[EnrichedDocument]) -> None: + summary = doc.content[:50] + "..." if len(doc.content) > 50 else doc.content + await ctx.send_message( + EnrichedDocument( + id=doc.id, + content=doc.content, + word_count=doc.word_count, + summary=summary, + ) + ) + + +class Publish(Executor): + @handler + async def publish( + self, + doc: EnrichedDocument, + ctx: WorkflowContext[EnrichedDocument, EnrichedDocument], + ) -> None: + print(f" Publishing: {doc.id} ({doc.word_count} words)") + await ctx.yield_output(doc) + + +async def main() -> None: + """Demonstrate merge() vs add_workflow() composition.""" + + # ========================================================================== + # Fragment definitions (reusable workflow pieces) + # ========================================================================== + ingest_fragment = WorkflowBuilder(name="ingest").set_start_executor(Ingest(id="ingest")) + + process_fragment = ( + WorkflowBuilder(name="process") + .add_edge(Process(id="process"), Enrich(id="enrich")) + .set_start_executor("process") + ) + + publish_fragment = WorkflowBuilder(name="publish").set_start_executor(Publish(id="publish")) + + # ========================================================================== + # Option A: Using add_workflow() with ConnectionHandles + # ========================================================================== + print("Option A: add_workflow() with ConnectionHandles") + print("-" * 50) + + builder_a = WorkflowBuilder() + + # add_workflow returns handles - we use .start and .outputs[0] + ingest_handle = builder_a.add_workflow(ingest_fragment, prefix="a") + process_handle = builder_a.add_workflow(process_fragment, prefix="b") + publish_handle = builder_a.add_workflow(publish_fragment, prefix="c") + + # Wire using handles (encapsulated - no need to know internal IDs) + builder_a.connect(ingest_handle.outputs[0], process_handle.start) + builder_a.connect(process_handle.outputs[0], publish_handle.start) + builder_a.set_start_executor(ingest_handle.start) + + print(" Executor IDs (encapsulated via handles):") + print(f" Ingest start: {ingest_handle.start}") + print(f" Process start: {process_handle.start}") + print(f" Publish start: {publish_handle.start}") + + workflow_a = builder_a.build() + async for event in workflow_a.run_stream("Sample document content for add_workflow demo"): + if isinstance(event, WorkflowOutputEvent): + print(f" Output: {event.data}") + print() + + # ========================================================================== + # Option B: Using merge() with direct ID access + # ========================================================================== + print("Option B: merge() with direct ID access") + print("-" * 50) + + builder_b = WorkflowBuilder() + + # merge() returns MergeResult - maps original IDs to prefixed IDs + ingest_ids = builder_b.merge(ingest_fragment, prefix="in") + proc_ids = builder_b.merge(process_fragment, prefix="proc") + pub_ids = builder_b.merge(publish_fragment, prefix="out") + + # Print available executor IDs after merge + print(" Available executor IDs after merge:") + for eid in sorted(builder_b.executor_ids): + print(f" - {eid}") + + # Wire using MergeResult - no need to know the "/" delimiter! + # Attribute access: ids.executor_name -> "prefix/executor_name" + builder_b.add_edge(ingest_ids.ingest, proc_ids.process) + builder_b.add_edge(proc_ids.enrich, pub_ids.publish) + builder_b.set_start_executor(ingest_ids.ingest) + + workflow_b = builder_b.build() + async for event in workflow_b.run_stream("Sample document content for merge demo"): + if isinstance(event, WorkflowOutputEvent): + print(f" Output: {event.data}") + print() + + # ========================================================================== + # Option C: Using merge() with get_executor() for type safety + # ========================================================================== + print("Option C: merge() with get_executor() for type safety") + print("-" * 50) + + builder_c = WorkflowBuilder() + + # Merge fragments - MergeResult also supports dictionary access + i = builder_c.merge(ingest_fragment, prefix="i") + p = builder_c.merge(process_fragment, prefix="p") + o = builder_c.merge(publish_fragment, prefix="o") + + # Use get_executor() with MergeResult IDs for full Executor access + # This provides type information and better error messages + ingest_exec = builder_c.get_executor(i["ingest"]) # dict access works too + process_exec = builder_c.get_executor(p.process) + enrich_exec = builder_c.get_executor(p.enrich) + publish_exec = builder_c.get_executor(o.publish) + + print(" Retrieved executors:") + print(f" ingest: {ingest_exec.id} (input types: {ingest_exec.input_types})") + print(f" process: {process_exec.id} (input types: {process_exec.input_types})") + print(f" enrich: {enrich_exec.id} (output types: {enrich_exec.output_types})") + print(f" publish: {publish_exec.id} (input types: {publish_exec.input_types})") + + # Wire using executor objects + builder_c.add_edge(ingest_exec, process_exec) + builder_c.add_edge(enrich_exec, publish_exec) + builder_c.set_start_executor(ingest_exec) + + workflow_c = builder_c.build() + async for event in workflow_c.run_stream("Sample document content for get_executor demo"): + if isinstance(event, WorkflowOutputEvent): + print(f" Output: {event.data}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/composition/composed_with_adapters.py b/python/samples/getting_started/workflows/composition/composed_with_adapters.py new file mode 100644 index 0000000000..f325e0c6a6 --- /dev/null +++ b/python/samples/getting_started/workflows/composition/composed_with_adapters.py @@ -0,0 +1,212 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Demonstrates type adapters for bridging incompatible workflow types. + +This sample shows how to use TypeAdapter classes to connect workflows +that have mismatched input/output types. It demonstrates: +1. Using built-in adapters (TextToConversation) +2. Using connect_checked() for type validation +3. Custom adapters via FunctionAdapter +""" + +import asyncio +from typing import Any + +from agent_framework import ( + ChatMessage, + Executor, + FunctionAdapter, + Role, + TextToConversation, + WorkflowBuilder, + WorkflowContext, + WorkflowOutputEvent, + handler, +) + +# ============================================================================= +# Text-based executors (work with str) +# ============================================================================= + + +class TextNormalizer(Executor): + """Normalizes text input: strips whitespace and lowercases.""" + + @handler + async def normalize(self, text: str, ctx: WorkflowContext[str]) -> None: + normalized = text.strip().lower() + await ctx.send_message(normalized) + + +class TextClassifier(Executor): + """Classifies text and returns a label string.""" + + @handler + async def classify(self, text: str, ctx: WorkflowContext[str]) -> None: + if "help" in text: + label = "support_request" + elif "buy" in text or "order" in text: + label = "sales_inquiry" + else: + label = "general" + await ctx.send_message(label) + + +# ============================================================================= +# Conversation-based executors (work with list[ChatMessage]) +# ============================================================================= + + +class ConversationAnalyzer(Executor): + """Analyzes a conversation and produces a summary.""" + + @handler + async def analyze( + self, + conversation: list[ChatMessage], + ctx: WorkflowContext[list[ChatMessage]], + ) -> None: + text_content = " ".join(msg.text or "" for msg in conversation) + word_count = len(text_content.split()) + summary_text = f"Analysis: {word_count} words, {len(conversation)} messages" + await ctx.send_message([ChatMessage(role=Role.ASSISTANT, text=summary_text)]) + + +class ResponseGenerator(Executor): + """Generates a response based on conversation context.""" + + @handler + async def generate( + self, + conversation: list[ChatMessage], + ctx: WorkflowContext[list[ChatMessage], list[ChatMessage]], + ) -> None: + # Extract the last user message + user_messages = [m for m in conversation if m.role == Role.USER] + last_user = user_messages[-1].text if user_messages else "nothing" + response = ChatMessage( + role=Role.ASSISTANT, + text=f"Thank you for your message about: {last_user}", + ) + result = list(conversation) + [response] + await ctx.yield_output(result) + + +async def main() -> None: + """Demonstrate type adapters in workflow composition.""" + # Fragment 1: Text processing pipeline (str -> str) + text_pipeline = ( + WorkflowBuilder(name="text_pipeline") + .add_edge(TextNormalizer(id="normalize"), TextClassifier(id="classify")) + .set_start_executor("normalize") + ) + + # Fragment 2: Conversation processing (list[ChatMessage] -> list[ChatMessage]) + conversation_pipeline = ( + WorkflowBuilder(name="conversation") + .add_edge(ConversationAnalyzer(id="analyze"), ResponseGenerator(id="respond")) + .set_start_executor("analyze") + ) + + # ========================================================================== + # Option A: Manual adapter insertion via connect() + # ========================================================================== + print("Option A: Manual adapter insertion") + print("-" * 40) + + builder_a = WorkflowBuilder() + text_handle = builder_a.add_workflow(text_pipeline) + conv_handle = builder_a.add_workflow(conversation_pipeline) + + # Text pipeline outputs str, but conversation pipeline expects list[ChatMessage] + # We need to insert an adapter between them + text_to_conv = TextToConversation(id="adapter", role=Role.USER) + + # Wire: text_pipeline -> adapter -> conversation_pipeline + # connect() accepts Executor instances and adds them to the graph + builder_a.connect(text_handle.outputs[0], text_to_conv) + builder_a.connect(text_to_conv, conv_handle.start) + builder_a.set_start_executor(text_handle.start) + + workflow_a = builder_a.build() + async for event in workflow_a.run_stream(" HELP me with my ORDER "): + if isinstance(event, WorkflowOutputEvent) and event.data: + for msg in event.data: + print(f" {msg.role.value}: {msg.text}") + print() + + # ========================================================================== + # Option B: Type-checked connection with adapter + # ========================================================================== + print("Option B: connect_checked() with adapter") + print("-" * 40) + + builder_b = WorkflowBuilder() + text_handle_b = builder_b.add_workflow(text_pipeline, prefix="txt") + conv_handle_b = builder_b.add_workflow(conversation_pipeline, prefix="conv") + + # Use connect_checked to validate types and insert adapter in one step + # Note: The adapter parameter automatically inserts the adapter between source and target + try: + # This would fail without adapter (type mismatch: str vs list[ChatMessage]) + builder_b.connect_checked(text_handle_b.outputs[0], conv_handle_b.start) + except TypeError as e: + print(f" Expected error: {e}") + print() + + # Now do it correctly with adapter + builder_b.connect_checked( + text_handle_b.outputs[0], + conv_handle_b.start, + adapter=TextToConversation(id="txt_to_conv"), + ) + builder_b.set_start_executor(text_handle_b.start) + + workflow_b = builder_b.build() + print(" Running with adapter:") + async for event in workflow_b.run_stream(" BUY something please "): + if isinstance(event, WorkflowOutputEvent) and event.data: + for msg in event.data: + print(f" {msg.role.value}: {msg.text}") + print() + + # ========================================================================== + # Option C: Custom adapter with FunctionAdapter + # ========================================================================== + print("Option C: Custom FunctionAdapter") + print("-" * 40) + + # Create a custom adapter that adds metadata + def custom_transform(text: str, ctx: Any) -> list[ChatMessage]: + return [ + ChatMessage(role=Role.SYSTEM, text="[Processed by custom pipeline]"), + ChatMessage(role=Role.USER, text=text), + ] + + custom_adapter: FunctionAdapter[str, list[ChatMessage]] = FunctionAdapter( + id="custom_adapter", + fn=custom_transform, + _input_type=str, + _output_type=list[ChatMessage], # type: ignore[arg-type] + name="custom_text_to_conv", + ) + + builder_c = WorkflowBuilder() + text_handle_c = builder_c.add_workflow(text_pipeline, prefix="txt") + conv_handle_c = builder_c.add_workflow(conversation_pipeline, prefix="conv") + + # Wire using connect() with executor instances + builder_c.connect(text_handle_c.outputs[0], custom_adapter) + builder_c.connect(custom_adapter, conv_handle_c.start) + builder_c.set_start_executor(text_handle_c.start) + + workflow_c = builder_c.build() + print(" Running with custom adapter:") + async for event in workflow_c.run_stream(" General inquiry about pricing "): + if isinstance(event, WorkflowOutputEvent) and event.data: + for msg in event.data: + print(f" {msg.role.value}: {msg.text}") + + +if __name__ == "__main__": + asyncio.run(main()) From 798ad2411dee5d8f1c039fc4598b987505f265b7 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 4 Dec 2025 07:49:08 +0900 Subject: [PATCH 6/7] Updates --- .../_workflows/_type_adapters.py | 15 ++--- .../_workflows/_workflow_builder.py | 63 ++++++++++++------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_type_adapters.py b/python/packages/core/agent_framework/_workflows/_type_adapters.py index 8f1d70a2fe..7140169c22 100644 --- a/python/packages/core/agent_framework/_workflows/_type_adapters.py +++ b/python/packages/core/agent_framework/_workflows/_type_adapters.py @@ -288,8 +288,8 @@ async def handle_input(self, data: Any, ctx: WorkflowContext[Any, Any]) -> None: value: Any if isinstance(data, Sequence) and not isinstance(data, (str, bytes)): # Multiple inputs - use first if single, otherwise pass all - seq = cast(Sequence[Any], data) - value = seq[0] if len(seq) == 1 else cast(Any, data) + seq: Sequence[Any] = data + value = seq[0] if len(seq) == 1 else data else: value = data @@ -350,8 +350,8 @@ async def adapt(self, value: TInput, ctx: WorkflowContext) -> TOutput: """Apply the wrapped function to transform the value.""" result = self.fn(value, ctx) if isinstance(result, Awaitable): - return cast(TOutput, await result) - return cast(TOutput, result) + return await result # type: ignore[return-value] + return result # type: ignore[return-value] # ============================================================================= @@ -655,6 +655,7 @@ def json_serializer( from dataclasses import asdict, is_dataclass def serialize(value: TInput, ctx: WorkflowContext) -> str: + data: Any if is_dataclass(value) and not isinstance(value, type): data = asdict(value) elif hasattr(value, "to_dict"): @@ -715,7 +716,7 @@ def deserialize(value: str, ctx: WorkflowContext) -> TOutput: return output_type(**filtered) # type: ignore[return-value] if isinstance(data, dict) and hasattr(output_type, "from_dict"): - return output_type.from_dict(data) # type: ignore[return-value,union-attr] + return output_type.from_dict(data) # type: ignore[return-value,union-attr,no-any-return,attr-defined] return cast(TOutput, data) @@ -753,7 +754,7 @@ def to_dict(value: TInput, ctx: WorkflowContext) -> dict[str, Any]: if is_dataclass(value) and not isinstance(value, type): return asdict(value) if hasattr(value, "to_dict"): - return value.to_dict() # type: ignore[union-attr,return-value] + return value.to_dict() # type: ignore[union-attr,return-value,no-any-return] if hasattr(value, "__dict__"): return dict(value.__dict__) # type: ignore[arg-type] raise TypeError(f"Cannot convert {type(value).__name__} to dict") @@ -800,7 +801,7 @@ def from_dict(value: dict[str, Any], ctx: WorkflowContext) -> TOutput: return output_type(**filtered) # type: ignore[return-value] if hasattr(output_type, "from_dict"): - return output_type.from_dict(value) # type: ignore[return-value,union-attr] + return output_type.from_dict(value) # type: ignore[return-value,union-attr,no-any-return,attr-defined] return output_type(**value) # type: ignore[return-value] diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index 358db1a50b..ff2f057d84 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -3,9 +3,9 @@ import copy import logging import sys -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass -from typing import Any +from typing import Any, TypeAlias from .._agents import AgentProtocol from ..observability import OtelAttr, capture_exception, create_workflow_span @@ -121,7 +121,7 @@ def __getitem__(self, key: int | str) -> ConnectionPoint: def __len__(self) -> int: return len(self._points) - def __iter__(self): + def __iter__(self) -> Iterator[ConnectionPoint]: return iter(self._points) @property @@ -186,23 +186,23 @@ def __getitem__(self, key: str) -> str: def __contains__(self, key: str) -> bool: return key in self._mapping - def __iter__(self): + def __iter__(self) -> Iterator[str]: return iter(self._mapping.values()) def __len__(self) -> int: return len(self._mapping) - def items(self): + def items(self) -> Iterator[tuple[str, str]]: """Return iterator of (original_id, prefixed_id) pairs.""" - return self._mapping.items() + return iter(self._mapping.items()) - def keys(self): + def keys(self) -> Iterator[str]: """Return original (unprefixed) executor IDs.""" - return self._mapping.keys() + return iter(self._mapping.keys()) - def values(self): + def values(self) -> Iterator[str]: """Return prefixed executor IDs.""" - return self._mapping.values() + return iter(self._mapping.values()) @property def prefix(self) -> str: @@ -398,9 +398,12 @@ def _clone_builder_for_connection(builder: "WorkflowBuilder") -> "WorkflowBuilde clone._checkpoint_storage = builder._checkpoint_storage clone._edge_groups = copy.deepcopy(builder._edge_groups) clone._executors = {eid: copy.deepcopy(executor) for eid, executor in builder._executors.items()} - clone._start_executor = ( - builder._start_executor if isinstance(builder._start_executor, str) else builder._start_executor.id - ) + if builder._start_executor is None: + clone._start_executor = None + elif isinstance(builder._start_executor, str): + clone._start_executor = builder._start_executor + else: + clone._start_executor = builder._start_executor.id clone._agent_wrappers = {} return clone @@ -428,8 +431,13 @@ def _prefix_executor_ids(builder: "WorkflowBuilder", prefix: str) -> dict[str, s builder._executors = updated_executors # Update start executor reference - start_id = builder._start_executor.id if isinstance(builder._start_executor, Executor) else builder._start_executor - builder._start_executor = mapping.get(start_id, start_id) + if builder._start_executor is None: + pass # Keep as None + elif isinstance(builder._start_executor, Executor): + start_id = builder._start_executor.id + builder._start_executor = mapping.get(start_id, start_id) + else: + builder._start_executor = mapping.get(builder._start_executor, builder._start_executor) builder._edge_groups = [_remap_edge_group_ids(group, mapping, prefix) for group in builder._edge_groups] @@ -524,6 +532,10 @@ def _derive_exit_points(edge_groups: list[EdgeGroup], executors: dict[str, Execu return points +# Type alias for workflow composition endpoints +Endpoint: TypeAlias = "Executor | AgentProtocol | ConnectionHandle | ConnectionPoint | str" + + class WorkflowBuilder: """A builder class for constructing workflows. @@ -606,7 +618,9 @@ def as_connection(self, prefix: str | None = None) -> WorkflowConnection: clone = _clone_builder_for_connection(self) start_exec = clone._start_executor - entry_id = start_exec.id if isinstance(start_exec, Executor) else start_exec + if start_exec is None: + raise ValueError("Starting executor must be set before calling as_connection().") + entry_id: str = start_exec.id if isinstance(start_exec, Executor) else start_exec entry_types = _get_executor_input_types(clone._executors, entry_id) exit_points = _derive_exit_points(clone._edge_groups, clone._executors) exit_ids = [p.id for p in exit_points] @@ -619,8 +633,6 @@ def as_connection(self, prefix: str | None = None) -> WorkflowConnection: ) return connection.with_prefix(prefix) if prefix else connection - Endpoint = Executor | AgentProtocol | ConnectionHandle | ConnectionPoint | str - # ========================================================================= # COMPOSITION APIs - Graph merging and connection # ========================================================================= @@ -695,8 +707,10 @@ def merge( # Capture original IDs before prefixing if isinstance(other, WorkflowConnection): original_ids = list(other.builder._executors.keys()) - elif isinstance(other, (WorkflowBuilder, Workflow)): + elif isinstance(other, WorkflowBuilder): original_ids = list(other._executors.keys()) + elif isinstance(other, Workflow): + original_ids = list(other.executors.keys()) else: original_ids = [] @@ -1094,10 +1108,13 @@ def _merge_connection(self, fragment: WorkflowConnection, *, prefix: str | None) self._executors.update(prepared.builder._executors) self._edge_groups.extend(prepared.builder._edge_groups) - start_id = ( - prepared.builder._start_executor.id - if isinstance(prepared.builder._start_executor, Executor) - else prepared.builder._start_executor + start_executor = prepared.builder._start_executor + if start_executor is None: + raise ValueError("Merged fragment must have a start executor set.") + start_id: str = ( + start_executor.id + if isinstance(start_executor, Executor) + else start_executor ) start_types = prepared.start_input_types or _get_executor_input_types(prepared.builder._executors, start_id) exit_points = prepared.exit_points or _derive_exit_points( From 9b3f8476b0bcd9a53a5e5f840cc39014c7391d33 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 4 Dec 2025 07:49:29 +0900 Subject: [PATCH 7/7] Updates again --- .../core/agent_framework/_workflows/_workflow_builder.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index ff2f057d84..07283162e3 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -1111,11 +1111,7 @@ def _merge_connection(self, fragment: WorkflowConnection, *, prefix: str | None) start_executor = prepared.builder._start_executor if start_executor is None: raise ValueError("Merged fragment must have a start executor set.") - start_id: str = ( - start_executor.id - if isinstance(start_executor, Executor) - else start_executor - ) + start_id: str = start_executor.id if isinstance(start_executor, Executor) else start_executor start_types = prepared.start_input_types or _get_executor_input_types(prepared.builder._executors, start_id) exit_points = prepared.exit_points or _derive_exit_points( prepared.builder._edge_groups, prepared.builder._executors