Session Orchestration¶
AgentPool sessions are managed by SessionPool and SessionController. SessionPool holds all active sessions. SessionController routes incoming requests and tracks active runs.
Unified Request Entry Point¶
SessionController.receive_request() is the single entry point for all incoming prompts:
- If the session is idle, it creates a
RunHandleand starts execution. - If the session has an active run, it routes based on priority.
"asap"injects into the active turn immediately."when_idle"queues the message for the next turn.
Protocol handlers should subscribe to the EventBus before calling receive_request(), since the method is fire-and-forget. All events stream through the bus.
When creating a new RunHandle, SessionController wires up two M2 lifecycle dimensions:
-
ProtocolTrigger— Anasyncio.Queue-based trigger that bridges protocol handlers to the RunLoop. Protocol handlers calltrigger.deliver(content)to enqueue prompts. The RunLoop drains them in its idle/wake loop viatrigger.poll(). -
ProtocolChannel— A bidirectionalCommChannelthat publishes events to theEventBus(so protocol servers can consume them) and maintains a feedback queue for steer/followup messages fromSessionController.StateUpdateevents are journaled but NOT published to the EventBus, preserving backward compatibility.
For standalone execution (no EventBus), RunHandle.__post_init__ falls back to DirectChannel and ImmediateTrigger, which deliver events to an internal asyncio.Queue that start() drains directly.
Dual Queue Architecture¶
AgentPool uses two queue systems, with the M2 lifecycle adding a third channel-based path.
Native agents rely on PydanticAI's PendingMessageDrainCapability. PydanticAI auto-injects this capability outermost. It handles message queuing at two hook points:
before_model_requestdrains"asap"messages immediately before the model call.after_node_rundrains"when_idle"messages after the current node finishes.
Native agents drive execution through RunExecutor, which calls agent_run.next(node) in a loop. The bare async for node in agent_run: pattern does not fire after_node_run hooks, so "when_idle" messages would never drain. RunExecutor avoids this by using explicit next() calls.
M2 RunLoop CommChannel (protocol-server sessions): When SessionController creates a RunHandle, it injects a ProtocolChannel as the CommChannel dimension. ProtocolChannel publishes events to the EventBus and maintains a feedback queue. SessionController calls channel.deliver_feedback(feedback) for steer/followup messages. The RunLoop drains feedback via channel.recv() in its idle/wake loop.
DirectChannel (standalone execution): When no EventBus is available, RunHandle.__post_init__() creates a DirectChannel that publishes events to an internal asyncio.Queue. The start() generator drains this queue via get_nowait(). Standalone execution also uses ImmediateTrigger (single-prompt delivery) and relies on RunHandle._message_queue for queued prompts between turns.
RunHandle Lifecycle (RunLoop)¶
In M2, RunHandle IS the RunLoop. It is modified in-place with dimension injection via six optional fields (all set in __post_init__):
@dataclass
class RunHandle:
run_id: str
session_id: str
agent_type: str
agent: BaseAgent[Any, Any] | None
event_bus: EventBus | None
session: SessionState | None
run_ctx: AgentRunContext
# Lifecycle dimensions (M2) — defaults set in __post_init__
_trigger_source: TriggerSource | None = None # ImmediateTrigger("")
_journal: Journal | None = None # MemoryJournal()
_snapshot_store: SnapshotStore | None = None # MemorySnapshotStore()
_comm_channel: CommChannel | None = None # DirectChannel(journal)
_event_transport: EventTransport | None = None # InProcessTransport()
_lifecycle_session_id: str = "default"
_run_state: RunState = RunState.IDLE
_state_lock: asyncio.Lock # guards state transitions
_recover_strategy: str = "mark_interrupted"
__post_init__() initializes any dimension left as None to the default in-memory implementation. The journal is injected into the CommChannel so that the channel can persist events. When SessionController creates a RunHandle for a protocol server session, it passes ProtocolTrigger and ProtocolChannel explicitly, bypassing the defaults.
State Machine: RunHandle._run_state transitions through RunState.IDLE, RUNNING, and DONE. Transitions are guarded by _state_lock (an asyncio.Lock). The on_state_change() observer is called on the CommChannel on every transition.
Lifecycle Flow (equivalent to the old states):
- IDLE —
RunHandlecreated,_run_state = IDLE. Thestart()async generator enters the idle/wake loop. - RUNNING —
start()receives a prompt and creates aTurn. It sets_run_state = RUNNINGand yields events fromturn.execute(). - DONE —
close()is called,_run_state = DONE, and the generator terminates.
Between turns, the handle goes idle and waits on _idle_event. Messages arrive via two paths:
- steer() — Injects a message into the active turn mid-execution (routes to PydanticAI's PendingMessageDrainCapability).
- followup() — Queues a prompt for the next turn. Wakes the idle event.
Crash Recovery: When a durable journal and snapshot store are configured, start() calls journal.resume(snapshot_store) at the beginning:
If resume_result.is_inflight is True:
- "mark_interrupted" strategy: Marks the interrupted Turn's turn_id on _recovered_inflight_turn_id. The RunLoop continues from idle. The interrupted Turn's partial output is preserved in the journal but not re-executed.
- "retry" strategy: Same detection, but the caller can check run_handle.recovered_tool_executions to see which tools already completed and skip them during re-execution.
During recovery, events since the last snapshot are replayed through the CommChannel with _replaying = True (which skips journaling to avoid duplicating entries).
Legacy methods (complete(), fail(), checkpoint()) remain for backward compatibility but emit no dimension-driven behavior.
Session State Machine Mapping¶
SessionData.status (persisted, survives crashes) and RunState (transient, in-memory) are separate state machines with a formal mapping defined in SessionStateMapper (src/agentpool/sessions/state_mapper.py).
| Scenario | SessionData.status | RunState | Valid? |
|---|---|---|---|
| Idle, no active turn | active |
IDLE |
Yes |
| Idle, no RunHandle | active |
None (no handle) |
Yes |
| Turn executing | active |
RUNNING |
Yes |
| Closed | closed |
DONE |
Yes |
| Closed, no RunHandle | closed |
None (no handle) |
Yes |
| Checkpointed (post-checkpoint) | checkpointed |
None (no handle) |
Yes (do NOT reconcile) |
| Resuming (RunHandle being created) | resuming |
IDLE |
Yes |
| Resuming (crash before RunHandle created) | resuming |
None (no handle) |
No -> reconcile to active |
| Active + crash left no RunHandle | active |
None (no handle) |
Yes |
Invariant checker (SessionStateMapper.check_invariant()) validates consistency at Turn boundaries (snapshot save points). Carve-outs:
- checkpointed + no RunHandle = valid — do NOT reconcile (normal post-checkpoint state).
- resuming + no RunHandle = reconcile to active (resume failed, session can accept new prompts).
- Other mismatches: log warning + reconcile SessionData.status to match RunState (in-memory is authoritative for transient state).
Event Mapping (Native Agents)¶
RunExecutor maps PydanticAI node-level events to AgentPool EventBus events:
| PydanticAI Node Event | AgentPool EventBus Event |
|---|---|
AgentRun created |
RunStartedEvent |
ModelRequestNode start |
PartStartEvent |
ModelRequestNode text chunks |
PartDeltaEvent |
ModelRequestNode end |
PartEndEvent |
FunctionToolCallEvent |
ToolCallStartEvent |
FunctionToolResultEvent |
ToolCallCompleteEvent |
EndNode |
StreamCompleteEvent |
| Run cancelled | StreamCompleteEvent(cancelled=True) |
The RunExecutor runs PydanticAI iteration in a background task and pushes events into an async queue. The consumer drains this queue and yields RichAgentStreamEvent tokens. This preserves CancelScope safety: cancelling the consumer does not immediately tear down the PydanticAI run.
M2 Dual Publishing Paths: Events in the RunLoop are now published via two paths:
event_bus.publish(session_id, event)— Backward-compatible path, used by both the oldRunHandle.start()code andRunExecutor. Protocol serverProtocolEventConsumerMixininstances subscribe to the EventBus and convert events for their respective protocols.comm_channel.publish(event)— The M2 CommChannel path.ProtocolChannel.publish()journals the event (append or upsert) and then publishes to the EventBus.DirectChannel.publish()journals the event and enqueues it to an internalasyncio.Queue.
When the CommChannel is a ProtocolChannel, start() avoids double-publishing by NOT calling event_bus.publish() directly (detected via _channel_publishes_to_event_bus). StateUpdate events are journaled but NOT published to the EventBus, since they are internal lifecycle signals that protocol servers do not need to receive.
PromptInjectionManager¶
PromptInjectionManager serves two purposes depending on the agent type.
For all agents, inject() and consume() handle tool result augmentation. When a tool finishes, after_tool_execute hooks call consume() to inject additional context into the conversation. If no tool runs, flush_pending_to_queue() moves unconsumed injections into the queued prompts.
For non-native agents, queue() and pop_queued() also handle follow-up prompts after a turn ends. The manual queue system drains these through _post_turn_injections and _post_turn_prompts.
For native agents, the follow-up prompt queue (queue() / pop_queued()) is replaced by PydanticAI's PendingMessageDrainCapability. inject() / consume() remain in use for tool augmentation.