Expand description
In-process session actor — one mailbox, one stream.
SessionActor turns an Agent into a task with an inbox and a
subscribable outbox, so a caller drives the session entirely through
Requests and observes it through AgentEvents — never by holding the
Agent itself:
caller (REPL / TUI / future peer session — all identical here)
│ send(Request) ▲ subscribe() → recv()
▼ │ (own cursor per subscriber)
inbox: mpsc::Unbounded<Request> outbox: broadcast<AgentEvent>
│ ▲ events.send(ev)
└───────────► SessionActor task ◄─────────┘
owns the Agent; serves one Request at a time;
`deferred: VecDeque<Request>` parks mid-run commandsThis is the seam the whole protocol design hangs on. A REPL, a TUI, and
(later) a peer session are all just callers that hold a SessionHandle;
they differ only in how they render the stream and which address they hold,
not in the mechanism they speak. Today the inbox and outbox are in-process
channels; the shapes are chosen so that a socket (Frames over NDJSON,
stage S2) can replace the transport without changing this API.
§Flow
serve() — no run in flight: pull ONE Request, service it, repeat.
deferred non-empty? ──yes──► take from deferred (FIFO, order kept)
│ no
▼
inbox.recv().await ──► match Request:
Submit {..} ─► run(...) → enters the in-run map below
Notify {..} ─► agent.notify(tagged) → append, no turn
Interrupt {..} ─► agent.steer(tagged) → queued for the next turn
Wake {..} ─► run(tagged) → a turn even when idle
Cancel ─► (no-op — nothing is running)
SetModel {..} ─► agent.set_model(..)
SetEffort {..} ─► agent.set_effort(..)
Compact {..} ─► agent.compact(..).await
GetHistory ─► reply with the context
Unknown ─► (ignored)run() — the Agent is borrowed for the whole run, so it is reached only
through clones taken before the borrow.
steer = agent.steer_sender()
follow_up = agent.follow_up_sender()
cancel = agent.cancel_token()
loop { tokio::select! { biased; ... } } ← biased: inbox polled FIRST
├─ Some(req) = inbox.recv() ─► Cancel ─► cancel.cancel() ┐ to the
│ Steer ─► steer.send(msg) │ live run
│ FollowUp ─► follow_up.send(msg) │
│ other ─► deferred.push_back(req) ┘ waits
├─ result = &mut run ──────► run finished → break
└─ Some(ev) = sink_rx.recv() ► events.send(ev) (fan-out to subscribers)
}
then: drain leftover sink_rx → events.send ; on Err(run) → Error event§Delivery rules
A run holds the agent’s &mut for its whole life, so the actor splits the
inbox by what each request needs:
| request | while a run is in flight |
|---|---|
Cancel | delivered at once — the token is cancelled; the loop aborts |
Interrupt / Wake | forwarded to the run’s channels at once |
SetModel / SetEffort / Compact | deferred, applied when the run ends |
another Submit | deferred, becomes the next run |
The interactive path therefore never waits on a run. The deferred set is
exactly the requests that need &mut Agent — you cannot swap the model or
compact the context out from under a run — and deferring them is also the
documented meaning: set_model/set_effort “take effect on the next run”.
One caveat lives in the kernel, not here — the actor hands a Cancel over at
once, but the loop is what acts on it, and its latency depends on where the
run is:
where the run is when Cancel arrives when it stops
--------------------------------------------- -----------------------
model streaming (loop races the token) immediately
between turns (checked at turn start) immediately
inside a tool call (no race on the future) when the tool returns ** unless the tool watches ToolContext::cancel itself (e.g. bash kills
its child) — then it can stop early.
§Requests and replies
send is fire-and-forget; ask awaits a reply. The reply is an
AgentEvent reply variant — AgentEvent::History for GetHistory,
AgentEvent::Ack/AgentEvent::Error for SetModel/SetEffort/
AgentEvent::Compaction for Compact — correlated in-process by a
one-shot channel. Replies are never fanned out on the event stream; the wire
(S2) will carry the same variants correlated by the envelope’s reply_to.
Scope so far: every request variant that maps onto an existing Agent
method is serviced. Still to come (S1b): pointing the CLI at a handle.
Structs§
- Session
Actor - Spawns the actor task that owns an
Agent. - Session
Closed - Returned when a
SessionHandle::sendtargets a session that has ended. - Session
Handle - Handle to a running
SessionActor.
Constants§
- EVENT_
BUFFER - Default outbox buffer: how far a subscriber may lag before it starts losing
events (
broadcastsemantics — the slowest reader drops, it never blocks the run).