MessageNode to pydantic-graph Step Adapter Design¶
Status: Accepted — this design has been implemented in the AgentPool codebase. See
docs/explanation/for the current architecture documentation.
Status: Design Draft
Date: 2026-06-03
Scope: Interface design only — no implementation
1. Executive Summary¶
This document designs the adapter layer between AgentPool's MessageNode abstraction and pydantic-graph's Step / Graph execution model. The goal is to enable AgentPool to leverage pydantic-graph v2's execution engine (fork/join, step-by-step iteration, persistence) while preserving the existing MessageNode public API (run(), run_stream(), run_message(), connect_to()).
Chosen Approach: Option A — MessageNode wraps a Step internally.
A MessageNode retains its role as the user-facing unit of computation but internally delegates execution to a pydantic-graph Step. Node-level methods (run_stream()) drive a single-node graph via Graph.iter(). Connection-level methods (connect_to()) compile connected nodes into a pydantic-graph workflow.
2. Interface Analysis¶
2.1 AgentPool MessageNode (Current)¶
class MessageNode[TDeps, TResult](ABC):
# Signals
message_received = Signal[ChatMessage[Any]]()
message_sent = Signal[ChatMessage[Any]]()
# Lifecycle
async def __aenter__(self) -> Self: ...
async def __aexit__(self, ...): ...
# Execution
@abstractmethod
async def run(self, *prompts: Any, **kwargs: Any) -> ChatMessage[TResult]: ...
async def run_message(self, message: ChatMessage[Any], **kwargs: Any) -> ChatMessage[TResult]: ...
@abstractmethod
def run_iter(self, *prompts: Any, **kwargs: Any) -> AsyncIterator[ChatMessage[Any]]: ...
async def run_stream(self, *prompts: PromptCompatible, ...) -> AsyncIterator[RichAgentStreamEvent[TResult]]: ...
# Connections
def connect_to(self, target: MessageNode | ProcessorCallback | Sequence[...], ...) -> Talk | TeamTalk: ...
def __rshift__(self, other): ... # syntactic sugar for connect_to
# Context & State
def get_context(self, data: Any = None, ...) -> NodeContext: ...
@property
def storage(self) -> StorageManager | None: ...
Key characteristics:
- Stateful instance: Holds conversation history, tools, MCP servers, event managers
- Signals: message_received / message_sent for loose coupling
- Streaming: run_stream() yields RichAgentStreamEvent tokens
- Connections: Runtime wiring via ConnectionManager and Talk
- Async context manager: Requires async with for initialization
2.2 pydantic-graph BaseNode (Legacy v1)¶
class BaseNode(ABC, Generic[StateT, DepsT, NodeRunEndT]):
@abstractmethod
async def run(self, ctx: GraphRunContext[StateT, DepsT]) -> BaseNode | End[NodeRunEndT]: ...
@classmethod
def get_node_id(cls) -> str: ...
Key characteristics:
- Pure transition function: Returns the next node to execute
- No signals: Events are implicit in the graph topology
- No streaming: run() is a single-shot async method
- Stateless class-level logic: Node identity is the class, not the instance
2.3 pydantic-graph Step / StepContext (v2)¶
@dataclass(init=False)
class StepContext(Generic[StateT, DepsT, InputT]):
_state: StateT
_deps: DepsT
_inputs: InputT
@property
def state(self) -> StateT: ...
@property
def deps(self) -> DepsT: ...
@property
def inputs(self) -> InputT: ...
class StepFunction(Protocol[StateT, DepsT, InputT, OutputT]):
def __call__(self, ctx: StepContext[StateT, DepsT, InputT]) -> Awaitable[OutputT]: ...
@dataclass(init=False)
class Step(Generic[StateT, DepsT, InputT, OutputT]):
id: NodeID
_call: StepFunction[StateT, DepsT, InputT, OutputT]
label: str | None
def as_node(self, inputs: InputT | None = None) -> StepNode: ...
Key characteristics:
- Function-centric: A Step is a callable + metadata, not a stateful object
- Typed context: StepContext provides state, deps, and inputs
- Composable: Steps are wired together by a GraphBuilder, not by the steps themselves
- Fork/Join: GraphBuilder supports Fork, Join, Decision, and Map for parallel execution
2.4 pydantic-graph Graph / GraphRun (v2)¶
@dataclass(init=False)
class Graph(Generic[StateT, DepsT, InputT, OutputT]):
async def run(self, *, state: StateT = None, deps: DepsT = None, inputs: InputT = None) -> OutputT: ...
@asynccontextmanager
async def iter(self, *, state: StateT = None, deps: DepsT = None, inputs: InputT = None) -> AsyncIterator[GraphRun]: ...
class GraphRun(Generic[StateT, DepsT, OutputT]):
async def next(self, value: EndMarker | Sequence[GraphTaskRequest] | None = None) -> EndMarker | Sequence[GraphTask]: ...
@property
def output(self) -> OutputT | None: ...
Key characteristics:
- Immutable graph definition: Built once via GraphBuilder, then executed
- Step-by-step iteration: Graph.iter() yields a GraphRun that can be driven manually
- Task-based execution: Internally uses GraphTask objects with fork stacks for parallelism
- Error recovery: ErrorMarker allows callers to intercept exceptions and redirect execution
3. Option Evaluation¶
3.1 Option A: MessageNode wraps a Step internally¶
Design: Each MessageNode owns an internal Step that encapsulates its core execution logic. MessageNode.run() constructs a single-node Graph and runs it. MessageNode.run_stream() uses Graph.iter() to drive execution step-by-step, yielding stream events from the internal state. connect_to() builds a pydantic-graph workflow from the connected topology.
class MessageNode[TDeps, TResult](ABC):
@property
@abstractmethod
def _step(self) -> Step[AgentPoolState, TDeps, ChatMessage[Any], ChatMessage[TResult]]: ...
async def run(self, *prompts: Any, **kwargs: Any) -> ChatMessage[TResult]:
graph = self._build_single_node_graph()
state = AgentPoolState(node=self, prompts=prompts, kwargs=kwargs)
result = await graph.run(state=state, deps=self._get_deps(), inputs=None)
return result
async def run_stream(self, *prompts: PromptCompatible, ...) -> AsyncIterator[RichAgentStreamEvent[TResult]]:
graph = self._build_single_node_graph()
state = AgentPoolState(node=self, prompts=prompts, kwargs=kwargs)
async with graph.iter(state=state, deps=self._get_deps(), inputs=None) as graph_run:
async for _ in graph_run:
# Yield events captured from the node's internal event queue
while event := self._event_queue.get_nowait():
yield event
Pros:
- Preserves 100% of the existing MessageNode public API
- Step is the v2 primitive — aligns with pydantic-graph's future direction
- Graph.iter() naturally maps to run_stream()'s step-by-step yielding
- Connection topology can be compiled to a GraphBuilder workflow
- Signals remain on MessageNode (user-facing); Step is purely execution logic
Cons:
- Slight overhead: single-node graph construction per run() call
- Requires an adapter layer to bridge StepContext to AgentContext
- Need to map MessageNode state (conversation, tools) to Graph state
Verdict: ✅ Recommended
3.2 Option B: MessageNode IS a BaseNode (survives v2)¶
Design: MessageNode inherits from BaseNode and implements run(ctx: GraphRunContext) -> BaseNode | End. This makes every agent/team a valid node in a pydantic-graph v1 graph. The existing run(), run_stream() etc. are preserved as convenience wrappers.
class MessageNode[TDeps, TResult](BaseNode[AgentPoolState, TDeps, ChatMessage[TResult]], ABC):
async def run(self, ctx: GraphRunContext[AgentPoolState, TDeps]) -> BaseNode | End[ChatMessage[TResult]]:
# Execute the node, then return the next node (from connections) or End
result = await self._execute(ctx.state.prompts)
if next_node := self._resolve_next_node(result):
return next_node
return End(result)
Pros:
- Direct integration with pydantic-graph v1 (legacy Graph.run())
- No wrapper overhead
- BaseNode.get_node_id() provides node identity for graph diagrams
Cons:
- BaseNode is the legacy v1 API; v2 is Step-centric
- BaseNode.run() returns the next node — this conflicts with MessageNode.run() returning the result
- BaseNode is stateless-at-class-level; MessageNode is stateful-per-instance. This impedance mismatch is severe: pydantic-graph v1 expects MyNode() (fresh instance) each time, but MessageNode holds conversation state.
- Does not leverage v2 GraphBuilder fork/join/decision capabilities
- Signals (message_received, message_sent) have no equivalent in BaseNode
- run_stream() cannot be expressed in BaseNode terms (no streaming in v1)
Verdict: ❌ Rejected — Legacy API mismatch, stateful/stateless impedance, no streaming
3.3 Option C: MessageNode becomes a factory that produces Step instances¶
Design: MessageNode is reconceptualized as a configuration factory. It no longer has run() directly; instead, it configures and returns a Step instance that is then wired into a GraphBuilder by the caller (e.g., AgentPool).
class MessageNode[TDeps, TResult](ABC):
@abstractmethod
def build_step(self) -> Step[AgentPoolState, TDeps, ChatMessage[Any], ChatMessage[TResult]]: ...
# run(), run_stream(), etc. are REMOVED from MessageNode
# Execution happens only via Graph.run() / Graph.iter()
Pros:
- Cleanest alignment with pydantic-graph v2 architecture
- Step is the true unit of execution; MessageNode is purely declarative
- Forces all execution through the graph engine, enabling full optimization
Cons:
- Breaking change: Removes agent.run("prompt") — the primary public API
- Breaks agent >> other_agent connection syntax (no run_message() on the node)
- Breaks async with Agent(...) as agent: context manager pattern
- Signals would need to be attached to the GraphRun or StepContext, not the node
- Every existing user of AgentPool would need to rewrite their code
- The MessageNode abstraction loses its identity as an executable unit
Verdict: ❌ Rejected — Too disruptive; breaks the core "node as executable unit" abstraction
4. Chosen Approach: Option A (Detailed Design)¶
4.1 Core Philosophy¶
MessageNoderemains the user-facing executable unit. Internally, it delegates execution to a pydantic-graphStepand drives it viaGraph.iter(). TheStepis a private implementation detail; users continue to interact withMessageNodeexactly as before.
4.2 Type Signatures¶
AgentPoolState — Graph State¶
from dataclasses import dataclass, field
from typing import Any
from agentpool.messaging import ChatMessage
@dataclass
class AgentPoolState:
"""Shared state passed through the pydantic-graph execution.
Holds the input prompts, node reference, and a conduit for
streaming events back to the caller.
"""
node: MessageNode[Any, Any]
"""The MessageNode being executed."""
prompts: tuple[Any, ...]
"""Input prompts for this execution."""
kwargs: dict[str, Any] = field(default_factory=dict)
"""Additional keyword arguments passed to run()."""
# Streaming conduit
event_queue: asyncio.Queue[RichAgentStreamEvent[Any]] = field(
default_factory=asyncio.Queue
)
"""Queue for streaming events from the Step back to run_stream()."""
# Result capture
result: ChatMessage[Any] | None = None
"""Final result populated by the Step upon completion."""
MessageNode._step property¶
class MessageNode[TDeps, TResult](ABC):
@property
@abstractmethod
def _step(self) -> Step[AgentPoolState, TDeps, ChatMessage[Any], ChatMessage[TResult]]:
"""Return the pydantic-graph Step representing this node's execution logic.
The Step's call function receives a StepContext with:
- state: AgentPoolState (prompts, event queue, node reference)
- deps: TDeps (node dependencies, e.g., database connections)
- inputs: ChatMessage[Any] (the input message, or None for root runs)
The Step is responsible for:
1. Emitting streaming events to state.event_queue
2. Returning the final ChatMessage[TResult]
3. Routing the result to connected nodes via state.node.connections
Returns:
A Step configured with this node's execution logic.
"""
...
BaseAgent._step implementation (example)¶
class BaseAgent[TDeps = None, TResult = str](MessageNode[TDeps, TResult]):
@property
def _step(self) -> Step[AgentPoolState, TDeps, ChatMessage[Any], ChatMessage[TResult]]:
async def _execute(ctx: StepContext[AgentPoolState, TDeps, ChatMessage[Any]]) -> ChatMessage[TResult]:
state = ctx.state
node = state.node
assert isinstance(node, BaseAgent)
# Reconstruct the run_stream() logic inside the Step
# 1. Emit message_received signal
user_msg = ChatMessage.user_prompt(message=state.prompts)
await node.message_received.emit(user_msg)
# 2. Stream events via the event queue
async for event in node._stream_events_internal(
prompts=state.prompts,
user_msg=user_msg,
deps=ctx.deps,
):
await state.event_queue.put(event)
# 3. Capture final result
final_message = node._last_result # set by _stream_events_internal
state.result = final_message
# 4. Emit message_sent signal
await node.message_sent.emit(final_message)
# 5. Route to connected nodes
await node.connections.route_message(final_message)
return final_message
return Step(
id=NodeID(self.name),
call=_execute,
label=self.description or self.name,
)
Single-node Graph builder¶
class MessageNode[TDeps, TResult](ABC):
def _build_single_node_graph(self) -> Graph[AgentPoolState, TDeps, ChatMessage[Any], ChatMessage[TResult]]:
"""Build a graph containing only this node's Step.
The graph has:
- start_node -> this node's Step -> end_node
"""
builder = GraphBuilder[
AgentPoolState, TDeps, ChatMessage[Any], ChatMessage[TResult]
](name=f"single_{self.name}")
step = self._step
builder.add_edge(builder.start_node, step)
builder.add_edge(step, builder.end_node)
return builder.build()
run() implementation¶
class MessageNode[TDeps, TResult](ABC):
async def run(self, *prompts: Any, **kwargs: Any) -> ChatMessage[TResult]:
"""Execute node with prompts via pydantic-graph.
Constructs a single-node graph and runs it to completion.
"""
graph = self._build_single_node_graph()
state = AgentPoolState(node=self, prompts=prompts, kwargs=kwargs)
deps = self._get_deps()
return await graph.run(state=state, deps=deps, inputs=None)
run_stream() implementation¶
class MessageNode[TDeps, TResult](ABC):
async def run_stream(
self,
*prompts: PromptCompatible,
store_history: bool = True,
message_id: str | None = None,
session_id: str | None = None,
parent_session_id: str | None = None,
parent_id: str | None = None,
message_history: MessageHistory | None = None,
input_provider: InputProvider | None = None,
wait_for_connections: bool | None = None,
deps: TDeps | None = None,
event_handlers: Sequence[AnyEventHandlerType] | None = None,
depth: int = 0,
) -> AsyncIterator[RichAgentStreamEvent[TResult]]:
"""Run agent with streaming output via pydantic-graph Graph.iter().
Uses Graph.iter() to drive execution step-by-step, yielding
RichAgentStreamEvent tokens from the node's internal event queue.
"""
graph = self._build_single_node_graph()
state = AgentPoolState(
node=self,
prompts=prompts,
kwargs={
"store_history": store_history,
"message_id": message_id,
"session_id": session_id,
"parent_session_id": parent_session_id,
"parent_id": parent_id,
"message_history": message_history,
"input_provider": input_provider,
"wait_for_connections": wait_for_connections,
"deps": deps,
"event_handlers": event_handlers,
"depth": depth,
},
)
async with graph.iter(state=state, deps=deps, inputs=None) as graph_run:
async for _task in graph_run:
# Drain the event queue after each graph step
while not state.event_queue.empty():
try:
event = state.event_queue.get_nowait()
yield event
except asyncio.QueueEmpty:
break
# Yield any remaining events after graph completion
while not state.event_queue.empty():
try:
event = state.event_queue.get_nowait()
yield event
except asyncio.QueueEmpty:
break
4.3 Lifecycle Diagram¶
sequenceDiagram
participant User
participant MN as MessageNode
participant S as Step
participant GB as GraphBuilder
participant G as Graph
participant GR as GraphRun
participant CM as ConnectionManager
Note over User,CM: Single-node execution (run / run_stream)
User->>MN: run("prompt")
MN->>MN: _build_single_node_graph()
MN->>S: _step (property)
S-->>MN: Step(id="agent_name", call=_execute)
MN->>GB: add_edge(start_node, step)<br/>add_edge(step, end_node)
GB-->>MN: Graph
MN->>G: graph.run(state=AgentPoolState, deps=deps)
G->>GR: iter()
GR->>S: step.call(StepContext)
S->>MN: message_received.emit()
S->>MN: _stream_events_internal()
S->>MN: message_sent.emit()
S->>CM: route_message(result)
S-->>GR: ChatMessage[TResult]
GR-->>G: End(result)
G-->>MN: ChatMessage[TResult]
MN-->>User: ChatMessage[TResult]
Note over User,CM: Streaming execution (run_stream)
User->>MN: run_stream("prompt")
MN->>MN: _build_single_node_graph()
MN->>G: graph.iter(...)
G-->>MN: GraphRun
loop async for _ in graph_run
GR->>S: step.call(StepContext)
S->>MN: _stream_events_internal()
Note right of S: Events pushed to<br/>state.event_queue
MN->>MN: drain event_queue
MN-->>User: yield RichAgentStreamEvent
S-->>GR: ChatMessage[TResult]
end
GR-->>G: End(result)
MN-->>User: (stream complete)
4.4 Signal Mapping¶
AgentPool MessageNode signals must be bridged to pydantic-graph GraphRun events.
| AgentPool Signal | pydantic-graph Equivalent | Mapping Strategy |
|---|---|---|
message_received |
None (explicit in StepContext.inputs) |
Emitted by the Step at the start of execution. The input message is already in ctx.inputs, but the signal is preserved for backward compatibility and for listeners outside the graph. |
message_sent |
GraphRun completion (EndMarker) |
Emitted by the Step after the result is produced. In Graph.iter() terms, this fires when the step yields its output and the graph transitions toward End. |
run_failed |
ErrorMarker |
When the Step raises an exception, pydantic-graph v2 yields an ErrorMarker. The adapter catches this and emits run_failed before re-raising or allowing recovery. |
agent_reset |
Graph state reset | AgentReset is a node-level lifecycle event, not tied to graph execution. It remains on MessageNode and is not mapped to graph events. |
state_updated |
None | Model/state changes are node-level concerns. Preserved as-is. |
interrupted |
CancelScope cancellation |
When a node is interrupted, the adapter cancels the GraphRun's CancelScope, which propagates to all active tasks in the fork. |
Detailed Signal Mapping¶
# message_received -> Step start
async def _execute(ctx: StepContext[AgentPoolState, TDeps, ChatMessage[Any]]) -> ChatMessage[TResult]:
state = ctx.state
node = state.node
# Reconstruct the input message from prompts
user_msg = ChatMessage.user_prompt(message=state.prompts)
# Emit the signal (preserved for backward compatibility)
await node.message_received.emit(user_msg)
# ... execute ...
# message_sent -> Step completion (before returning)
final_message = await _execute_core(...)
await node.message_sent.emit(final_message)
return final_message
# run_failed -> ErrorMarker recovery
# In run_stream(), when Graph.iter() yields an ErrorMarker:
async for marker in graph_run:
if isinstance(marker, ErrorMarker):
failed_event = BaseAgent.RunFailedEvent(
agent_name=self.name,
message="Agent stream failed",
exception=marker.error,
)
await self.run_failed.emit(failed_event)
# Allow pydantic-graph's error recovery or re-raise
raise marker.error
4.5 run_stream() to Graph.iter() Mapping¶
run_stream() is the most complex mapping because it must yield RichAgentStreamEvent tokens during execution, not just at the end.
| AgentPool Concept | pydantic-graph Concept | Notes |
|---|---|---|
run_stream() |
Graph.iter() + manual driving |
Graph.iter() yields a GraphRun. Async-iterating over GraphRun drives execution step-by-step. |
RichAgentStreamEvent yield |
state.event_queue drain |
The Step pushes events to AgentPoolState.event_queue. run_stream() drains this queue after each graph step. |
| Session management | GraphRun state |
session_id, parent_session_id are stored in AgentPoolState.kwargs and handled by the Step. |
Prompt queuing (queue_prompt) |
GraphRun.override_next() |
When a tool calls queue_prompt(), the adapter calls graph_run.override_next() to inject a new task with the queued prompts. |
| Injection manager | GraphRun.next(value) |
inject_prompt() can be mapped to sending a new GraphTaskRequest to graph_run.next(). |
| Tool execution events | Step internal streaming | Tool start/complete events are emitted by the Step during _stream_events_internal() and captured in the event queue. |
| Connection routing | Step post-processing | After the Step returns, it calls node.connections.route_message(result) before returning to the graph. |
# run_stream() mapped to Graph.iter() — detailed flow
async def run_stream(self, *prompts, **kwargs) -> AsyncIterator[RichAgentStreamEvent[TResult]]:
graph = self._build_single_node_graph()
state = AgentPoolState(node=self, prompts=prompts, kwargs=kwargs)
async with graph.iter(state=state, deps=kwargs.get("deps"), inputs=None) as graph_run:
# Drive the graph step by step
while True:
try:
marker = await graph_run.next()
except StopAsyncIteration:
break
if isinstance(marker, ErrorMarker):
# Map to run_failed signal, then re-raise
await self.run_failed.emit(...)
raise marker.error
# After each step, drain the event queue
while not state.event_queue.empty():
event = await asyncio.wait_for(state.event_queue.get(), timeout=0.01)
yield event
# Drain any final events
while not state.event_queue.empty():
yield state.event_queue.get_nowait()
4.6 connect_to() Handling¶
connect_to() creates runtime connections between MessageNodes via ConnectionManager and Talk. In pydantic-graph terms, this is graph topology.
Strategy: Compile connections to GraphBuilder edges¶
When AgentPool loads agents and their connections from YAML, it can compile the connection topology into a GraphBuilder workflow:
class AgentPool:
def _compile_connections_to_graph(self) -> Graph[AgentPoolState, Any, Any, Any] | None:
"""Compile all MessageNode connections into a pydantic-graph workflow.
This is an optional optimization: if connections form a DAG,
they can be executed via pydantic-graph's fork/join engine
instead of AgentPool's ConnectionManager.
"""
from pydantic_graph.graph_builder import GraphBuilder
builder = GraphBuilder[AgentPoolState, Any, Any, Any](name="agent_pool_workflow")
# Add all agent steps as nodes
for name, node in self._registry.items():
builder.add(node._step)
# Add edges from connections
for name, node in self._registry.items():
for talk in node.connections.get_connections():
for target in talk.targets:
builder.add_edge(
node._step,
target._step,
label=talk.connection_type,
)
return builder.build()
Runtime ConnectionManager (preserved)¶
Not all connections can be statically compiled (e.g., dynamic connect_to() calls, conditional filters). For these, the existing ConnectionManager + Talk system remains:
class MessageNode:
async def run(self, *prompts, **kwargs) -> ChatMessage[TResult]:
result = await self._run_via_graph(prompts, kwargs)
# After graph execution, route to connected nodes
# This preserves the existing ConnectionManager behavior
await self.connections.route_message(result)
return result
Hybrid approach¶
- Static connections (from YAML config): Compiled to
GraphBuilderedges when the pool starts - Dynamic connections (runtime
connect_to()): Handled byConnectionManageras before - Fork/Join teams: A parallel
Teamcan be expressed as aFork+JoininGraphBuilder - Sequential teams: A
TeamRun(chain) is a simple sequence of edges
# Team parallel -> Fork + Join
team = agent1 & agent2 & agent3
# Compiled to:
# fork -> [agent1, agent2, agent3] -> join -> result
# Team sequential -> Edge chain
team = agent1 | agent2 | agent3
# Compiled to:
# agent1 -> agent2 -> agent3 -> end
5. Migration Path¶
Phase 1: Internal Step wrapper (no public API change)¶
- Add
_stepabstract property toMessageNode - Implement
_stepinBaseAgent(default implementation delegates to existing_stream_events) - Add
_build_single_node_graph()toMessageNode - Update
run()andrun_stream()to useGraph.run()/Graph.iter()internally - All existing tests pass without modification
Phase 2: Graph-based team execution¶
- Teach
AgentPoolto compile static connections toGraphBuilder - Implement
TeamasFork+Join - Implement
TeamRunas edge chain - Add opt-in flag:
use_graph_engine: truein YAML config
Phase 3: Full pydantic-graph integration¶
- Deprecate
ConnectionManagerruntime routing for static topologies - Use pydantic-graph persistence for state snapshots
- Leverage
GraphRun.next()for interactive debugging - Generate mermaid diagrams from compiled graphs
6. Open Questions¶
- ContextVar isolation:
_current_run_ctx_varis aContextVar. Does pydantic-graph's task-group execution preserve ContextVar values acrossForkbranches? - Signal emission across Forks: If
agent1 & agent2runs in parallel viaFork, domessage_sentsignals from both agents need to be collected beforeJoin? - Error recovery: pydantic-graph v2's
ErrorMarkerallows recovery viaoverride_next(). How does this map to AgentPool'srun_failedsignal + retry logic? - Performance: Single-node graph construction per
run()call adds overhead. Should graphs be cached per node? - Storage integration:
AgentPoolStateholds anevent_queue. Should this be replaced with pydantic-graph's persistence layer for durability?
7. Appendix: Complete Type Signatures¶
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass, field
from typing import Any, Generic
from pydantic_graph.graph_builder import Graph, GraphBuilder
from pydantic_graph.step import Step, StepContext
from typing_extensions import TypeVar
from agentpool.agents.events import RichAgentStreamEvent
from agentpool.messaging import ChatMessage, MessageNode
StateT = TypeVar("StateT")
DepsT = TypeVar("DepsT")
InputT = TypeVar("InputT")
OutputT = TypeVar("OutputT")
@dataclass
class AgentPoolState:
"""State object passed through pydantic-graph execution."""
node: MessageNode[Any, Any]
prompts: tuple[Any, ...]
kwargs: dict[str, Any] = field(default_factory=dict)
event_queue: asyncio.Queue[RichAgentStreamEvent[Any]] = field(
default_factory=asyncio.Queue
)
result: ChatMessage[Any] | None = None
class MessageNode[TDeps, TResult]:
"""Base class for all message processing nodes (adapted for pydantic-graph)."""
@property
@abstractmethod
def _step(self) -> Step[AgentPoolState, TDeps, ChatMessage[Any], ChatMessage[TResult]]:
"""Return the pydantic-graph Step for this node."""
...
def _build_single_node_graph(self) -> Graph[AgentPoolState, TDeps, ChatMessage[Any], ChatMessage[TResult]]:
"""Build a single-node graph wrapping this node's Step."""
...
async def run(self, *prompts: Any, **kwargs: Any) -> ChatMessage[TResult]:
"""Execute via pydantic-graph Graph.run()."""
...
async def run_stream(
self, *prompts: Any, **kwargs: Any
) -> AsyncIterator[RichAgentStreamEvent[TResult]]:
"""Execute via pydantic-graph Graph.iter() with event streaming."""
...
async def run_message(
self, message: ChatMessage[Any], **kwargs: Any
) -> ChatMessage[TResult]:
"""Execute with a ChatMessage input."""
...