7.6 KiB
Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message
Status: implemented
English | 中文
Problem
The agent's public driving surface had grown three near-parallel verbs — send, steer, inject — each with its own options type, its own live event story, and its own durable event. send and steer both queued a frozen inbox record and emitted agent/queued; inject bypassed the inbox and wrote a separate context/message durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left cancel() with no way to abort a turn while preserving queued work.
Separately, context/message and user/message had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried a non-user source and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were context/message, admitted rounds were user/message).
Decision
One primitive, three preset aliases. The Agent interface's send(input, { target, wakeup }) covers the (target × wakeup) matrix. Its UserMessageData input owns the inseparable model-facing content and producer source; the complete SendOptions owns only routing policy. followup (next-turn/wakeup), steer (next-step/wakeup), and inject (next-step/no-wakeup) each accept that one input and fix the policy. wakeup means "make the model run": wake a parked driver for a next-turn item, or force a continuation for a running next-step item. next-turn/no-wakeup (queue without waking) is representable with no alias and no current caller.
inject keeps its mechanism. The next-step/no-wakeup path is exactly the old inject: durable model-facing context appended at the current log position, deferred behind an executing tool batch while a turn is open and appended directly between turns while idle. It bypasses the FIFOs entirely, while its required UserMessageData.source preserves the caller's explicit provenance.
context/message is gone. Injected context is now a user/message; context producers supply the appropriate non-user source explicitly, and typed source variants carry any domain-specific durable provenance. The surface, derivation, and SurfaceEventType drop context/message; consumers that need "is this a human prompt?" read source.kind === 'user' instead of the event type.
Goal replay disambiguates by round, not type. A goal state change is a round-zero goal-sourced user/message whose source carries the complete change; a positive round is an admitted continuation prompt. decodeGoalEvent takes a user/message and fails loud when goal-state content and its typed source disagree.
send returns an id. send (and the aliases) return an opaque branded AgentMessageId for the accepted message; send's previous return was void.
Three inbox events replace agent/queued. agent/inbox/enqueue (an item entered a FIFO), agent/inbox/dequeue (the driver claimed one), and agent/inbox/discard (cancel() dropped pending items) each type their AgentMessage payload with only the accepted message's returned id, content, and source, so a caller can correlate a queued item with its lifecycle without depending on driver routing state. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an agent/stopping listener, so the ledger stays balanced with its later dequeue or discard. The dsh-agent invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative.
One accepted message keeps one representation. Durable user-role input and additional model-facing context both use UserMessageData { content, source } directly; public AgentMessage extends it with the correlation id, and the loop-private PendingMessage extends that with wakeup. A queued message that becomes steering enters the outbox as the same PendingMessage object, while injected and tool-produced context enters as plain UserMessageData. The outbox therefore stores their union directly instead of wrapping steering beside a duplicate copy of its content and source. Provider-native assistant messages remain adapter-owned output types and do not participate in this input hierarchy.
cancel gains keepInbox. cancel(cause, { keepInbox? }); callers choose the cause explicitly, and keepInbox: true aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped).
Alternatives considered
- A dedicated
MessageSourcekindcontextfor injected content. Rejected becausepluginalready means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Plugin-produced injected context supplies its plugin source explicitly. - A typed discriminant field on
UserMessageData(e.g.origin: 'prompt' | 'context') to replace the event-type split. Rejected in favor ofsource, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. - Keeping
agent/queuedalongside the inbox events. Rejected as a mirror:agent/inbox/enqueueis the same enqueue-time signal with the addedtarget/wakeupfacts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe.
Consequences
The delivery surface is now one primitive plus three self-documenting presets, and the (target × wakeup) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a source test. The Agent contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. The goal fold's channel split moved from event type to source.round, and every consumer that filtered context/message now filters user/message by source. An idle injection appends user/message between turns without opening a turn or running the model.
wakeup is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone next-turn/no-wakeup item stays parked at idle and rides along the next waking send, and whenIdle/cancel settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge.
Related
- one-send-one-turn — the one-claimed-message-per-turn rule this builds on.
- remove-agent-steering-mirror — the precedent for collapsing a mirrored live event.
- explicit-turn-cancellation — the cancel-cause signal
keepInboxextends.