Skip to main content

Module actor

Module actor 

Source
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 commands

This 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:

requestwhile a run is in flight
Canceldelivered at once — the token is cancelled; the loop aborts
Interrupt / Wakeforwarded to the run’s channels at once
SetModel / SetEffort / Compactdeferred, applied when the run ends
another Submitdeferred, 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§

SessionActor
Spawns the actor task that owns an Agent.
SessionClosed
Returned when a SessionHandle::send targets a session that has ended.
SessionHandle
Handle to a running SessionActor.

Constants§

EVENT_BUFFER
Default outbox buffer: how far a subscriber may lag before it starts losing events (broadcast semantics — the slowest reader drops, it never blocks the run).