Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id
# Conflicts: # docs/cordis-catalog/events.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # packages/core/agent/src/types.ts
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* The agent loop driver: one `runLoop()` invocation drives one agent for its
|
||||
* whole lifetime. Error-contained at the turn level — a throwing plugin ends
|
||||
* the turn, never kills the loop. See the JSDoc on `runLoop()` for the full
|
||||
* lifecycle pseudo-code.
|
||||
*
|
||||
* Drives one agent across queued durable turns. Turn failures are contained so
|
||||
* later work can run; the session log, not this driver, owns conversation state.
|
||||
* See docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
|
||||
* @module dsh-agent-loop/loop
|
||||
*/
|
||||
|
||||
@@ -25,33 +23,12 @@ import type { Inbox } from './inbox.ts'
|
||||
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
||||
type CodedError = Error & { code?: string }
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary thrown value into a coded Error. A real Error passes
|
||||
* through (its `code`, if any, is preserved by {@link errorData}); a non-Error
|
||||
* throw is wrapped in a {@link HarnessError} with code `UNKNOWN` and the
|
||||
* original value chained as `cause`, so a bad throw still carries a routable
|
||||
* code instead of degrading to a bare message.
|
||||
*/
|
||||
/** Normalize thrown values while preserving an existing error code. */
|
||||
function toError(error: unknown): CodedError {
|
||||
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a model-call {@link FinishReason} to the step error it should raise, or
|
||||
* `undefined` when the step completed normally.
|
||||
*
|
||||
* Adapters report provider/transport failures one of two sanctioned ways (see
|
||||
* the StreamChunk contract in dsh-llm): throw from `stream()` (handled by the
|
||||
* caller's try/catch), OR end the stream with a finish-error/aborted chunk
|
||||
* (the only option for adapters that can't throw mid-stream, e.g.
|
||||
* library-backed ones). This translates the latter into a thrown step error
|
||||
* so the turn ends error/aborted (the failure recorded on `turn/end.reason`),
|
||||
* never as a normal `completed` assistant message.
|
||||
*
|
||||
* `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so
|
||||
* the switch handles the known terminal-failure kinds and treats every other
|
||||
* kind — `stop`, `tool-calls`, `max-tokens`, future additions — as success.
|
||||
*/
|
||||
/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
|
||||
function finishError(finish: FinishReason): CodedError | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error': {
|
||||
@@ -78,19 +55,7 @@ function errorData(err: CodedError): { message: string; code?: string } {
|
||||
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn-end contribution of a step's *successful* finish, or `undefined`
|
||||
* when the step finished ordinarily (a plain `completed`).
|
||||
*
|
||||
* {@link finishError} has already converted `error`/`aborted` finishes into
|
||||
* thrown step errors, so the finishes that reach here are `stop`,
|
||||
* `tool-calls`, `max-tokens`, or a future merge-extensible kind. Only
|
||||
* `max-tokens` carries forward as a distinct {@link TurnEndReason}: a step that
|
||||
* hit the output-token ceiling ended the turn cut-short rather than by the
|
||||
* model's choice. `stop`/`tool-calls`/unknown kinds contribute nothing beyond
|
||||
* the default `completed`. {@link runTurn} applies this with the rule "any
|
||||
* `max-tokens` step in the turn makes the turn end `max-tokens`".
|
||||
*/
|
||||
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
|
||||
function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'max-tokens':
|
||||
@@ -103,11 +68,7 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambient handles the loop driver receives from the agent. Decouples the
|
||||
* pure function `runLoop` from the mutable ReactLoopAgent fields, making the
|
||||
* loop testable without a real agent.
|
||||
*/
|
||||
/** Mutable agent controls supplied to the loop driver. */
|
||||
export interface LoopHandle {
|
||||
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||
readonly inbox: Inbox
|
||||
@@ -116,122 +77,37 @@ export interface LoopHandle {
|
||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
||||
disposed: Promise<void>
|
||||
isDisposed(): boolean
|
||||
/**
|
||||
* Whether a `cancel()` is pending for the current turn. The driver checks this
|
||||
* at every decision point where a turn could start or continue (right after
|
||||
* the idle wait, after the `running` flip, before each step, and at the
|
||||
* continuation gate) and drops the about-to-run / continuing turn. Reset once
|
||||
* per loop iteration via {@link clearCancel} after the turn returns, so the
|
||||
* marker governs exactly one cancellation and never leaks to a later prompt.
|
||||
*/
|
||||
/** Whether cancellation is pending for the current loop iteration. */
|
||||
isCancelled(): boolean
|
||||
/**
|
||||
* The resolved reason for the pending cancel (`reason ?? 'cancelled'`), read
|
||||
* by the marker branches (pre-step / continuation) so a turn dropped where no
|
||||
* `AbortController` carries the reason still records the caller's
|
||||
* `cancel(reason)` value — matching the mid-step abort path. Only meaningful
|
||||
* when {@link isCancelled} is true.
|
||||
*/
|
||||
/** Resolved pending-cancellation reason; meaningful only while {@link isCancelled} is true. */
|
||||
cancelReason(): string
|
||||
/** Clear the cancel marker (called once per iteration after the turn returns). */
|
||||
clearCancel(): void
|
||||
/**
|
||||
* Settle pending `whenIdle()` waiters WITHOUT a status transition. Used by the
|
||||
* pre-step cancel-skip path: it drops the about-to-run turn and re-parks at the
|
||||
* idle wait, so no `running→idle` transition fires to settle a `whenIdle()`
|
||||
* waiter that was registered in the pre-step window — this settles it directly
|
||||
* (it emits no `agent/status`, so an ACP `agent/status` listener never sees a
|
||||
* spurious idle that would resolve a freshly-queued prompt as cancelled).
|
||||
*/
|
||||
/** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */
|
||||
settleIdle(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent loop. One invocation drives one agent for its whole lifetime:
|
||||
*
|
||||
* ```
|
||||
* create agent → emit agent/session-start(source) ⟵ once, before turn 1
|
||||
* forever:
|
||||
* wait for queued messages (idle)
|
||||
* TURN (error-contained — a throwing plugin ends the turn, never the loop):
|
||||
* 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror)
|
||||
* allow → session('user/message'…) (+ inject additionalContext) | block → drop
|
||||
* every prompt blocked → 'turn/end'(rejected), 0 steps
|
||||
* STEP loop:
|
||||
* drain steering → session('steering/message') ⟵ catches late steering
|
||||
* assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble
|
||||
* (scope-filtered; scoped sections/tools join); renderPrompt
|
||||
* (persona section + {{variables}}) IS the full prompt
|
||||
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
|
||||
* session prefix; logged on the header, never
|
||||
* session history (scope-filtered, fused dispatch)
|
||||
* await events.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
|
||||
* pressure gates see the prefix the request carries
|
||||
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
|
||||
* session('step/start') same sync frame, strictly before step/start
|
||||
* config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches
|
||||
* session('request/header') ⟵ the header event this request owes the
|
||||
* log (initial/resume anchor or changed snapshot)
|
||||
* req = freeze({header..., messages: prefix+boundary, sessionId, signal})
|
||||
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req)
|
||||
* session('assistant/chunk')
|
||||
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
|
||||
* session('assistant/message' {content, usage?}) session records what actually ran
|
||||
* each tool-call in msg (sequential, abort-checked):
|
||||
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
|
||||
* → dispatch → tools/post-execute
|
||||
* session('tool/result')
|
||||
* append buffered post-execute additionalContext → session('context/message')(s)
|
||||
* drain steering → session('steering/message')
|
||||
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
|
||||
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
|
||||
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
|
||||
* recorded as next-step steering
|
||||
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
|
||||
* terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary
|
||||
* continuation and steering folding
|
||||
* if terminal: discard pending steering and break
|
||||
* if action==stop: break
|
||||
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
|
||||
* await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier)
|
||||
* re-enqueue leftover steering as queued ⟵ steering is never stranded
|
||||
* idle (emit agent/status) unless more queued
|
||||
* ```
|
||||
* Drive queued batches as durable turns until disposal. Plugin failures end the
|
||||
* current turn without terminating the driver.
|
||||
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
|
||||
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
|
||||
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
|
||||
*/
|
||||
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
|
||||
// Per-instance transmission bookkeeping: whether THIS loop instance has
|
||||
// anchored the log's header fold yet (its first request logs a
|
||||
// 'initial'/'resume' request/header snapshot). Everything else the request
|
||||
// needs is read from the session log itself — the loop holds no
|
||||
// conversation state (the reconstructability RFC).
|
||||
// Per-instance prefix and request-header state; conversation history remains in the session log.
|
||||
const transmission = createTransmissionLog()
|
||||
|
||||
const { session } = agent
|
||||
// The fused agent-subject dispatcher: every agent/* dispatch below carries
|
||||
// the agent's scope (an `agent.ctx` listener hears only this agent) with
|
||||
// the subject injected — one spelling, checked by the dev invariants.
|
||||
// Fused subject and scope carrier for every agent event below.
|
||||
const events = agentEvents(ctx, agent)
|
||||
|
||||
while (!handle.isDisposed()) {
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
|
||||
// idle wait but before we flip to `running`. The cancelled queued/steering
|
||||
// work is already cleared by `cancel()`. Clear the marker, then:
|
||||
// - if NOTHING new is queued, drop the about-to-run turn and re-park,
|
||||
// settling any `whenIdle()` waiter DIRECTLY (no running→idle transition
|
||||
// fires here to settle it) and WITHOUT emitting `agent/status` (an ACP
|
||||
// listener must not see a spurious idle that resolves a freshly-queued
|
||||
// prompt as cancelled);
|
||||
// - if a NEW prompt was queued AFTER the cancel (a send() that raced in
|
||||
// before the loop resumed), the marker was for the cancelled work only —
|
||||
// fall through and run the new prompt's turn. Do NOT settle waiters here:
|
||||
// a whenIdle() waiter must wait for that new turn's running→idle, not
|
||||
// resolve before it runs (the quiescence contract).
|
||||
// Cancellation between wake and `running` skips only the cancelled work;
|
||||
// a replacement prompt still runs and owns the eventual idle transition.
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
@@ -242,18 +118,8 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
|
||||
handle.setStatus('running')
|
||||
|
||||
// Pre-step cancel (window 2): `setStatus('running')` emits `agent/status`
|
||||
// SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the
|
||||
// check above and `runTurn`. Mirror window 1: clear the marker, then
|
||||
// - if NOTHING new is queued, drop the about-to-run turn and transition
|
||||
// back to `idle` (`running` was already emitted, so a real idle
|
||||
// transition balances the status AND settles `whenIdle()` waiters);
|
||||
// - if a NEW prompt was queued AFTER the cancel (a `running` listener that
|
||||
// cancels then sends), the marker was for the cancelled work only — fall
|
||||
// through and run the new prompt's turn (status is already `running`), so
|
||||
// a `whenIdle()` waiter resolves on THAT turn's running→idle, not before
|
||||
// it runs. Settling here would resolve quiescence while the replacement
|
||||
// is still queued and unrun (the same early-resolve race window 1 fixes).
|
||||
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
||||
// status only when no replacement prompt was queued by that listener.
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
@@ -262,24 +128,13 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
}
|
||||
}
|
||||
|
||||
// Re-derive the turn number from the log each iteration (do NOT keep a local
|
||||
// counter): an idle `agent.inject()` can append its own one-shot turn while
|
||||
// the loop waits above, so the next real turn must continue from whatever
|
||||
// turn number is actually last in the log — a stale counter would collide.
|
||||
// Idle injection can add a turn, so derive the next number from the log.
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
|
||||
} catch (error: unknown) {
|
||||
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
|
||||
// before turn/start) — no turn/start was appended, so no turn is open and
|
||||
// none is owed. A session `error` here would land outside any turn (after
|
||||
// the previous turn/end), where the persistence backend drops it as a
|
||||
// crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
|
||||
// driver survives and moves on.
|
||||
// Acceptance and internal dispatch validation can reject before
|
||||
// turn/start commits. Report that supported pre-turn failure without
|
||||
// inventing a turn/end for a turn that never opened.
|
||||
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
|
||||
try {
|
||||
@@ -287,21 +142,10 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
|
||||
}
|
||||
|
||||
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and
|
||||
// before the next iteration's idle wait. NOT gated on the idle transition
|
||||
// below: a `send()` that lands during the cancelled turn's flush window makes
|
||||
// `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset
|
||||
// would never fire and the stale marker would wrongly drop that next prompt's
|
||||
// turn. Resetting per iteration scopes the marker to exactly the turn that was
|
||||
// cancelled.
|
||||
// Reset per iteration, including when a prompt arrives during the flush window.
|
||||
handle.clearCancel()
|
||||
|
||||
// Steering that arrived too late to join an ordinary turn (turn-end
|
||||
// listeners, flush) becomes queued input so it is never stranded. A
|
||||
// terminal-stop owner is the deliberate exception: discard the steering
|
||||
// again after the close + flush window so terminal policy cannot be undone
|
||||
// after its in-turn drain. Ordinary queued sends live in a separate FIFO and
|
||||
// remain untouched.
|
||||
// Late steering becomes queued input unless terminal policy stopped the turn.
|
||||
for (const message of handle.inbox.drainSteering()) {
|
||||
if (!terminalStopped) handle.inbox.enqueue(message)
|
||||
}
|
||||
@@ -315,10 +159,7 @@ async function runTurn(
|
||||
): Promise<boolean> {
|
||||
const { session } = agent
|
||||
|
||||
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
|
||||
// turn/start has not been appended — so it propagates to runLoop's backstop
|
||||
// untouched. The queued messages are drained here but appended AFTER
|
||||
// turn/start (below), so every event in the log lives inside a turn.
|
||||
// Drain before opening the turn, but append only after `turn/start`.
|
||||
const queued = handle.inbox.drainQueued()
|
||||
const first = queued[0]
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
@@ -331,28 +172,17 @@ async function runTurn(
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
|
||||
// Close the open step exactly once (idempotent via stepOpen). Post-commit
|
||||
// session/event observers are contained by Session; a pre-commit validator
|
||||
// failure still escapes so the outer recovery path may retry the boundary or
|
||||
// fail loudly without pretending an uncommitted step/end exists.
|
||||
// Close the committed step once; pre-commit validation failure still escapes.
|
||||
const closeStep = (): void => {
|
||||
if (!stepOpen) return
|
||||
session.append('step/end', { turn, step })
|
||||
stepOpen = false
|
||||
}
|
||||
|
||||
// Record a step/turn failure exactly once: set the error reason (carrying the
|
||||
// failing `step` — the durable failure lives entirely on turn/end.reason, there
|
||||
// is no separate session error event) and emit agent/error (contained — trap: a
|
||||
// throwing agent/error listener must not re-escape and strand the turn).
|
||||
// Disposal and abort set `reason` directly without calling this (they are not
|
||||
// failures).
|
||||
// Record the durable turn failure once and contain the live error notification.
|
||||
const failTurn = (err: CodedError): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
// The turn is still open here. Post-commit observers cannot escape append,
|
||||
// and a pre-commit turn/end veto leaves no closing boundary to overwrite.
|
||||
// Set the reason that the next successful closeTurn will append.
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
try {
|
||||
events.emit('agent/error', turn, step, err)
|
||||
@@ -362,9 +192,7 @@ async function runTurn(
|
||||
}
|
||||
}
|
||||
|
||||
// Close the turn. Post-commit observer failures are contained by Session;
|
||||
// pre-commit validation failures escape to recovery instead of being mistaken
|
||||
// for a committed boundary. Turn boundaries are durable session events only.
|
||||
// Pre-commit validation failure escapes rather than masquerading as a committed boundary.
|
||||
const closeTurn = (): void => {
|
||||
session.append('turn/end', { turn, reason })
|
||||
}
|
||||
@@ -414,11 +242,7 @@ async function runTurn(
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// A fully-blocked batch (every prompt vetoed by prompt-submit) opens a
|
||||
// zero-step turn that ends `rejected`: break BEFORE the first step so the
|
||||
// boundary stays balanced (turn/start → turn/end) and the block is a
|
||||
// durable in-turn fact. `anyAllowed` never changes inside the loop, so this
|
||||
// only ever fires on the first iteration.
|
||||
// A fully blocked batch closes its zero-step turn as rejected.
|
||||
if (!anyAllowed) {
|
||||
reason = { kind: 'rejected', reason: lastBlockReason }
|
||||
break
|
||||
@@ -437,48 +261,20 @@ async function runTurn(
|
||||
const abort = new AbortController()
|
||||
handle.setAbort(abort)
|
||||
|
||||
// Assemble the system prompt for this step. Done HERE (before step/start)
|
||||
// because the pre-step seam needs it: compaction measures token pressure
|
||||
// against the system prompt (it counts toward the budget). runStep reuses
|
||||
// this same assembly for the request, so the prompt is assembled once per
|
||||
// step. renderPrompt IS the full prompt — the persona is the order-0
|
||||
// section (owned by dsh-system-prompt) and `{{variable}}`
|
||||
// interpolation happens in the render, so there is no separate join.
|
||||
// Assemble once before pre-step so pressure checks and the request share the same prompt.
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
const fullSystemPrompt = renderPrompt(assembly)
|
||||
|
||||
// Interruption landing after assembly: dispose() or cancel() in a
|
||||
// turn-start listener (or a listener whose promise resolved before the
|
||||
// await above) arms either handle.isDisposed() or handle.isCancelled().
|
||||
// The Abort was created first, so any concurrent abort also lands on it.
|
||||
// Drop the about-to-start step WITHOUT running the seam — no step is open
|
||||
// yet, so end the turn accordingly (disposed wins for an unambiguous
|
||||
// reason).
|
||||
// Cancellation or disposal during assembly ends the turn before any step opens.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
|
||||
// Compose the session prefix ONCE per loop instance, lazily before the
|
||||
// instance's first pre-step: request-only messages placed in front of
|
||||
// the ENTIRE derived history on every request this instance sends. It
|
||||
// MUST precede the pre-step seam so compaction gates on THIS instance's
|
||||
// prefix — reading a previous instance's logged prefix would let a
|
||||
// resumed/forked instance whose contributor grew skip compaction and
|
||||
// ship an over-window first request. The result is deep-cloned
|
||||
// (decoupled from listener-held references), deep-frozen, and cached on
|
||||
// the transmission bookkeeping, so reuse is structural — the prefix
|
||||
// cannot change mid-session and the provider prefix cache holds by
|
||||
// construction (resume = a new instance = a recompose, anchored by its
|
||||
// 'resume' snapshot). The prefix is not session history — the header
|
||||
// event in runStep is its only durable record
|
||||
// (EpochHeader.messagePrefix). The frozen empty seed serves both the
|
||||
// listener chain and the no-listener fallback: a contribution is a
|
||||
// RETURNED extension of `await next()`, never an in-place push. This
|
||||
// runs OUTSIDE the step, before the boundary snapshot: a composing
|
||||
// listener's session append lands before the boundary and joins the
|
||||
// CURRENT request.
|
||||
// Compose the request-only prefix once per loop instance before pressure
|
||||
// checks. It precedes all derived history and is recorded only in the
|
||||
// request header, not as session history.
|
||||
if (transmission.sessionPrefix === undefined) {
|
||||
const emptyPrefix: Message[] = deepFreeze([])
|
||||
const composed = await events.waterfall(
|
||||
@@ -486,16 +282,7 @@ async function runTurn(
|
||||
() => Promise.resolve(emptyPrefix),
|
||||
)
|
||||
|
||||
// Interruption landing during prefix composition: mirror the assembly
|
||||
// window above — drop the about-to-start step without running the
|
||||
// seam, and DISCARD the composition instead of caching it. An
|
||||
// abort-aware listener may have returned a degraded fallback under
|
||||
// the firing signal; committing it would ship a prefix no request
|
||||
// ever used (and no header ever logged) on this instance's next real
|
||||
// request. The next turn recomposes under a live signal — the cache
|
||||
// only ever holds a fully composed prefix. The cache-hit path needs
|
||||
// no such check: nothing awaits between the assembly check above and
|
||||
// the pre-step seam.
|
||||
// Never cache an interrupted composition; the next turn recomposes it.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
@@ -504,19 +291,7 @@ async function runTurn(
|
||||
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
|
||||
}
|
||||
|
||||
// Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
|
||||
// step: after `turn/start` (and the prior step's close) but before
|
||||
// `step/start`, so a compaction's log-only `compact/*` records and its
|
||||
// replacement node land cleanly outside any step (honest structure that
|
||||
// crash-safety relies on — a dangling `compact/start` sits before the
|
||||
// synthetic `turn/end` repair appends). Serial (awaited, in order, no
|
||||
// veto): each listener completes its surface mutation before the next, so
|
||||
// concurrent listeners cannot interleave their `session.append`s. A
|
||||
// throwing listener escapes to the outer catch, which closes the (not-yet-
|
||||
// open) step as a no-op and ends the turn via failTurn — a broken
|
||||
// pre-step plugin ends the turn, not the loop. The composed session
|
||||
// prefix rides along so token-pressure listeners count everything the
|
||||
// request will actually carry.
|
||||
// Await surface mutations outside the step; pressure checks receive the pending prefix.
|
||||
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
|
||||
|
||||
// Interruption landing during the pre-step seam: do not open an empty step.
|
||||
@@ -526,16 +301,8 @@ async function runTurn(
|
||||
break
|
||||
}
|
||||
|
||||
// The reconstruction boundary (the reconstructability RFC): the request's
|
||||
// messages are snapshotted HERE, in the same synchronous frame as the
|
||||
// step/start append directly below — so the snapshot is exactly the
|
||||
// derivation over the log prefix strictly before step/start's seq.
|
||||
// Anything appended later by the request-window inject seam or a
|
||||
// concurrent task lands after the boundary and joins the NEXT request.
|
||||
// session/event itself is observe-only: append reentrancy is rejected
|
||||
// until the current callback list drains. An external reconstructor
|
||||
// recovers these exact messages by folding the surface over
|
||||
// events[0..stepStartSeq).
|
||||
// Snapshot the exact log prefix before step/start: the reconstruction
|
||||
// boundary. Appends after this synchronous snapshot join the next request.
|
||||
const boundaryMessages = session.deriveMessages()
|
||||
|
||||
session.append('step/start', { turn, step })
|
||||
@@ -582,13 +349,7 @@ async function runTurn(
|
||||
break
|
||||
}
|
||||
|
||||
// The successful step's finish reason carries forward: a `max-tokens`
|
||||
// step makes the whole turn end `max-tokens` (the ACP RFC's rule "any
|
||||
// max-tokens step surfaces as max-tokens"). `stepFinishReason` returns
|
||||
// `max-tokens` or `undefined`, so a later ordinary step never resets a
|
||||
// max-tokens turn back to completed, and a never-truncated turn keeps the
|
||||
// default `completed`. The disposal/abort/error branches above and the
|
||||
// continuation-window disposal check below override this — they win.
|
||||
// Preserve max-token completion unless a later disposal, abort, or error wins.
|
||||
const stepReason = stepFinishReason(stepOutcome.finish)
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
@@ -610,24 +371,16 @@ async function runTurn(
|
||||
break
|
||||
}
|
||||
|
||||
// A forced `continue` may carry model-facing context: record it as
|
||||
// next-STEP steering (the steering channel), so the continued turn's next
|
||||
// iteration drains it before its request — the typed twin of the /goal
|
||||
// step/end-steer pattern.
|
||||
// A continuation reason becomes next-step steering.
|
||||
if (decision.action === 'continue' && decision.reason) {
|
||||
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
|
||||
}
|
||||
let shouldContinue = decision.action === 'continue'
|
||||
|
||||
// Steering from step/end session-event or continuation listeners (the
|
||||
// /goal pattern) demands the model see it — it overrides a stop decision;
|
||||
// the next iteration's drain records it.
|
||||
// Pending steering overrides an ordinary stop.
|
||||
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
|
||||
|
||||
// Terminal policy runs only AFTER the extensible continuation waterfall,
|
||||
// its optional reason, and late steering have all been folded. Unlike the
|
||||
// waterfall, this serial seam is monotonic: the first stop bail wins, and
|
||||
// no later listener or steering override can resurrect the turn.
|
||||
// Terminal policy is monotonic and runs after ordinary continuation folding.
|
||||
let terminalStop = false
|
||||
try {
|
||||
const stop = await events.serial('agent/turn-stop', turn)
|
||||
@@ -640,19 +393,12 @@ async function runTurn(
|
||||
}
|
||||
if (terminalStop) {
|
||||
terminalStopped = true
|
||||
// A continuation reason or listener may have queued steering before the
|
||||
// terminal checkpoint. Discard only steering (never ordinary queued
|
||||
// prompts) so it cannot become a next step or be re-enqueued as a fresh
|
||||
// turn by runLoop's late-steering fallback.
|
||||
// Terminal stop discards steering but preserves ordinary queued prompts.
|
||||
handle.inbox.drainSteering()
|
||||
shouldContinue = false
|
||||
}
|
||||
|
||||
// A cancel that landed during the continuation window — after the step's
|
||||
// AbortController was cleared (setAbort(undefined)) but before the next
|
||||
// step starts — has no controller to observe it, so the turn-scoped marker
|
||||
// ends the turn here. cancel() also cleared the steering FIFO, so the
|
||||
// override above did not re-arm continuation.
|
||||
// The marker catches cancellation after the step controller was cleared.
|
||||
if (handle.isCancelled()) {
|
||||
reason = { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
@@ -668,19 +414,11 @@ async function runTurn(
|
||||
// Normal / inline-error loop exit: close the turn.
|
||||
closeTurn()
|
||||
} catch (error: unknown) {
|
||||
// Decide whether this turn opened from the LOG, not a speculative flag. A
|
||||
// pre-commit validator or acceptance failure leaves no turn/start and owes
|
||||
// no turn/end, so it propagates to runLoop's backstop. Once turn/start is
|
||||
// present, this path balances any committed step and records the failure.
|
||||
// Close only a turn whose start committed to the log.
|
||||
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
if (!turnStartLogged) throw error
|
||||
closeStep()
|
||||
// Choose the close reason. Disposal wins only if no error was already
|
||||
// reported: a turn disposed mid-step sets reason=disposed in the step-error
|
||||
// branch (without reporting an error), so preserve disposed rather than
|
||||
// overwrite it. Otherwise a mid-step throw on a live agent is a real
|
||||
// failure → failTurn. (errorReported is mutated only inside the failTurn
|
||||
// closure, which the analyzer can't follow, hence the inline lint-disable.)
|
||||
// Preserve an established disposal reason; otherwise report the failure.
|
||||
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
reason = { kind: 'disposed' }
|
||||
} else {
|
||||
@@ -689,19 +427,11 @@ async function runTurn(
|
||||
closeTurn()
|
||||
}
|
||||
|
||||
// Durability checkpoint: persistence plugins drain write-behind buffers.
|
||||
// A failing persistence plugin is reported but doesn't kill the agent.
|
||||
// Through the store's flush (the carrier owner), never a raw parallel.
|
||||
// Flush through the store-owned durability checkpoint without killing the driver on failure.
|
||||
try {
|
||||
await ctx.sessions.flush(session)
|
||||
} catch (error: unknown) {
|
||||
// The turn is already closed (turn/end appended above) and flush must run
|
||||
// AFTER turn/end to be a checkpoint — so there is no in-turn position left
|
||||
// for a session `error` event. Appending one here would land it after the
|
||||
// last turn/end, where the persistence backend treats it as a crash tail
|
||||
// and drops it on resume (the turn-enclosure RFC: every event is turn-enclosed). Report
|
||||
// the failure via agent/error + the logger only; persistence keeps the
|
||||
// buffered events for the next flush/dispose, so nothing is lost.
|
||||
// The turn is closed, so report the failed flush live rather than append outside a turn.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
|
||||
try {
|
||||
@@ -722,13 +452,12 @@ function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boole
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
/** One step: build the request from the boundary snapshot + the step's
|
||||
* header → compose the session prefix if this instance has none yet → log
|
||||
* the header event the request owes → stream model → record → execute
|
||||
* tools. The caller assembles the
|
||||
* system prompt, fires the `agent/pre-step` seam, snapshots the derivation,
|
||||
* and opens the step BEFORE calling this, so `boundaryMessages` is exactly
|
||||
* the surface prefix at step/start and already reflects any compaction. */
|
||||
/**
|
||||
* Run one committed step: transform call config, log the request header, build
|
||||
* the request from the cached prefix plus the step-boundary snapshot, stream and
|
||||
* record the response, then execute tools. The caller has already assembled the
|
||||
* prompt, run `agent/pre-step`, snapshotted history, and opened the step.
|
||||
*/
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
events: AgentEventDispatch,
|
||||
@@ -743,40 +472,23 @@ async function runStep(
|
||||
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
|
||||
const { session, options } = agent
|
||||
|
||||
// Seed the call config: the first request of THIS loop instance seeds from
|
||||
// current AgentOptions — explicit options always win over the logged
|
||||
// baseline, which is what keeps fork model-overrides and resume-time
|
||||
// reconfiguration correct. Later steps seed from the log's folded header,
|
||||
// which by then is exactly what this instance last logged.
|
||||
// One deep-cloned, frozen seed serves BOTH the listener chain and the
|
||||
// no-listener fallback: structuredClone decouples it from the session's
|
||||
// cached header fold (a raw reference would let a delegating listener
|
||||
// mutate the fold in place and silently skip the delta log), and the freeze
|
||||
// makes in-place shaping unrepresentable — a switch is a RETURNED
|
||||
// replacement, which the header event below records.
|
||||
// Seed the first request from agent options and later requests from the logged header;
|
||||
// detach and freeze so listeners must return an attributable replacement.
|
||||
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
|
||||
? session.requestHeader()!.config
|
||||
: { model: options.model ?? '' }))
|
||||
|
||||
// Shape the call config: listeners return a replacement to switch model or
|
||||
// sampling (the seed is frozen — content shaping is not expressible here;
|
||||
// model-visible content flows through the log channels). The header event
|
||||
// below records whatever the request ACTUALLY uses, so a listener's switch
|
||||
// is a logged, reconstructable fact, never silent drift.
|
||||
// Listener replacements are recorded in the request header before dispatch.
|
||||
const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig))
|
||||
if (!config.model) {
|
||||
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
||||
}
|
||||
|
||||
// The session prefix was composed (once per instance) before this step's
|
||||
// pre-step seam — the caller guarantees it, so the cache is always set here.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
|
||||
const sessionPrefix = transmission.sessionPrefix!
|
||||
|
||||
// The request header (the log's request/header snapshots): canonical form,
|
||||
// recorded before dispatch so the log always explains the request —
|
||||
// including the session prefix, which no other event carries.
|
||||
// Record the canonical header, including the otherwise-unlogged prefix, before dispatch.
|
||||
const header = canonicalHeader({
|
||||
config,
|
||||
...system ? { system } : {},
|
||||
@@ -785,11 +497,7 @@ async function runStep(
|
||||
})
|
||||
recordRequestHeader(session, transmission, header)
|
||||
|
||||
// Build and freeze: the request is a pure function of (boundary snapshot,
|
||||
// logged header) — llm/stream listeners and adapters read it, mutation
|
||||
// throws. sessionId + frozen is the loop-built marker the dev invariant
|
||||
// keys on. Message order: header.messagePrefix, then the boundary
|
||||
// snapshot — the reconstruction equation the invariant recomputes.
|
||||
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
|
||||
const request: GenerateOptions = deepFreeze({
|
||||
model: header.config.model,
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||
@@ -813,26 +521,16 @@ async function runStep(
|
||||
assembler.push(chunk)
|
||||
}
|
||||
|
||||
// Adapters report provider/transport failures one of two sanctioned ways
|
||||
// (see the StreamChunk contract in dsh-llm): throw from stream() — already
|
||||
// handled by the caller's try/catch — OR end the stream with a
|
||||
// finish-error/aborted chunk. finishError() maps the latter to the step
|
||||
// error to raise (turn ends error/aborted, not a normal completed message).
|
||||
// Normalize failure finish chunks into the same path as thrown stream errors.
|
||||
const stepError = finishError(assembler.finish)
|
||||
if (stepError) throw stepError
|
||||
|
||||
if (assembler.finish.kind === 'max-tokens') {
|
||||
let message: Message = withoutToolCalls(assembler.message())
|
||||
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)))
|
||||
// Fire the assistant/message when there is content OR usage: a max-tokens
|
||||
// step can be cut off with empty content but still carry token accounting,
|
||||
// and assistant/message is the only host for usage (there is no standalone
|
||||
// usage event). An empty-content assistant/message is skipped by
|
||||
// deriveMessages(), so hosting usage on it never injects a spurious assistant
|
||||
// turn into derived history.
|
||||
// Preserve usage even when max-token truncation produced no content.
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
// A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is
|
||||
// never empty here — pass the provenance unconditionally.
|
||||
// The finish chunk guarantees non-empty provenance here.
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
|
||||
@@ -842,20 +540,11 @@ async function runStep(
|
||||
return { hadToolCalls: false, finish: assembler.finish }
|
||||
}
|
||||
|
||||
// The step-result waterfall runs BEFORE the session append so the log (the
|
||||
// source of truth for derived history and replay) records the message that
|
||||
// tool dispatch actually uses.
|
||||
// Record the post-waterfall message that tool dispatch uses.
|
||||
let message: Message = assembler.message()
|
||||
message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))
|
||||
|
||||
// Same content-or-usage guard as the max-tokens branch: a step that finishes
|
||||
// with neither assembled content nor usage (e.g. a bare `stop` finish that
|
||||
// streamed nothing) records no assistant/message — an empty-content message
|
||||
// exists only to host usage, and deriveMessages() skips it either way, so
|
||||
// appending one with no usage would be a pure trace-only row.
|
||||
//
|
||||
// sourceEventSeqs records the assistant/chunk provenance, but is omitted when
|
||||
// no chunks streamed (the surface invariant rejects an empty sourceEventSeqs).
|
||||
// Empty messages exist only to carry usage; omit empty provenance.
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
session.append(
|
||||
'assistant/message',
|
||||
@@ -864,15 +553,9 @@ async function runStep(
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tool execution (sequential; parallel execution is a TODO) ---
|
||||
// ToolRegistry.execute converts tool failures (including aborts) into
|
||||
// isError results, so abort is re-checked around every call here.
|
||||
// Tool execution stays sequential; recheck abort around each normalized result.
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
// Per-step buffer of `additionalContext` attached by tools/post-execute
|
||||
// listeners. Appended as context/message(s) only AFTER every tool/result for
|
||||
// the step, so a multi-call step keeps tool-call/result adjacency
|
||||
// (interleaving context between a call's result and the next call's would
|
||||
// break the pairing the next model request relies on).
|
||||
// Buffer context until all results are appended to preserve call/result adjacency.
|
||||
const pendingContext: HookContext[] = []
|
||||
for (const call of toolCalls) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
@@ -884,12 +567,8 @@ async function runStep(
|
||||
} catch {
|
||||
parsedArguments = call.arguments
|
||||
}
|
||||
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
|
||||
// `arguments` — tool/call (the audit record) and assistant/message (the
|
||||
// model-history source) are logged BEFORE execute, and live consumers (ACP,
|
||||
// tool-bash presentation) read the pre-execution args, so an execution-only
|
||||
// rewrite would desync the UI from what ran. Designing that consistently is
|
||||
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
|
||||
// TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned;
|
||||
// see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md.
|
||||
const result = await ctx.tools.execute({
|
||||
callId: call.id,
|
||||
name: call.name,
|
||||
@@ -899,33 +578,24 @@ async function runStep(
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn, step,
|
||||
// The correlation id MUST be the loop's authoritative call.id (the
|
||||
// model-transcript id that deriveMessages turns into toolCallId), NOT
|
||||
// result.callId — a post-execute waterfall listener returning a
|
||||
// mismatched id would otherwise orphan the call↔result pairing in the
|
||||
// next model request. A listener-internal id, if ever needed, belongs in
|
||||
// a separate diagnostic field, never overloaded onto callId.
|
||||
// Correlation comes from the immutable execution input; the result does
|
||||
// not duplicate this authoritative transcript identity.
|
||||
callId: call.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
// The tool's private presentation payload (e.g. a result-time diff),
|
||||
// persisted so a UI bridge reproduces the card on replay.
|
||||
// Persist tool-owned presentation data for replay.
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
|
||||
// Buffer (don't append yet) any post-execute additionalContext for this call.
|
||||
if (result.additionalContext) pendingContext.push(result.additionalContext)
|
||||
// signal CAN flip during the await above (abort() inside a tool);
|
||||
// the analyzer can't see through the await boundary.
|
||||
// The signal may flip while the tool is awaited.
|
||||
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
// Append buffered post-execute context AFTER every tool/result, preserving
|
||||
// tool-call/result adjacency across the whole batch. inject() appends into the
|
||||
// open turn (a context/message at its chronological position).
|
||||
// Append buffered context after the complete result batch.
|
||||
for (const context of pendingContext) {
|
||||
agent.inject(context.content, { source: context.source })
|
||||
}
|
||||
@@ -948,13 +618,8 @@ export function lastTurnNumber(session: Session): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a turn is currently open in the session log (a `turn/start` with no
|
||||
* matching later `turn/end`). Decided from the LOG, not agent status: status
|
||||
* can be `running` while no turn is open (an `agent/status` listener firing
|
||||
* before `turn/start`, or the post-`turn/end` flush window before status
|
||||
* returns to idle), so status is not a reliable open-turn signal. Used by
|
||||
* `inject()` to choose between appending into an open turn vs. wrapping the
|
||||
* injection in its own one-shot turn (the turn-enclosure RFC).
|
||||
* Whether the session log has an unmatched `turn/start`. Agent status is not
|
||||
* sufficient during pre-start and post-end windows.
|
||||
* @param session - the session whose log is inspected.
|
||||
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
|
||||
*/
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* Per-loop-instance transmission bookkeeping for the reconstructability
|
||||
* contract: which header event to append before a request so the session log
|
||||
* always explains the request (the reconstructability RFC). The loop is
|
||||
* otherwise transmission-stateless — the comparison baseline is the log's own
|
||||
* folded header (`Session.requestHeader()`), so resume and fork need no
|
||||
* special path: a fresh loop instance simply logs a `'resume'` snapshot on
|
||||
* its first request and full changed-header snapshots from there.
|
||||
* Per-loop-instance request-header bookkeeping for reconstructability. The
|
||||
* comparison baseline is folded from the session log; a fresh instance anchors
|
||||
* it with an initial/resume snapshot and later logs full changed snapshots.
|
||||
*
|
||||
* @module dsh-agent-loop/request-log
|
||||
*/
|
||||
@@ -37,18 +33,8 @@ export function createTransmissionLog(): TransmissionLog {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append whatever header event this request owes the log, so folding the log
|
||||
* reproduces the header the request was built under. Exactly one of three
|
||||
* things happens:
|
||||
*
|
||||
* 1. This loop instance has not logged a header yet → a full `request/header`
|
||||
* snapshot anchors the fold: reason `'initial'` when the log has no header
|
||||
* events at all (a new conversation), `'resume'` when it does (process
|
||||
* restart, fork seed — the boundary itself is a recorded fact, so the
|
||||
* snapshot is appended even when nothing changed).
|
||||
* 2. The header equals the folded baseline → nothing; the log already
|
||||
* explains this request.
|
||||
* 3. It differs → a full snapshot with reason `'change'`.
|
||||
* Append the full header snapshot owed by this request: initial/resume for the
|
||||
* instance's first request, nothing when unchanged, or change otherwise.
|
||||
*
|
||||
* @param session - the session whose log explains the request.
|
||||
* @param state - this loop instance's bookkeeping (mutated on first log).
|
||||
|
||||
@@ -1,46 +1,6 @@
|
||||
/**
|
||||
* Agent interface and event taxonomy. Every plugin programs against the
|
||||
* `Agent` handle defined here; the concrete implementation lives in
|
||||
* `@deepseek-ai/dsh-agent-loop`.
|
||||
*
|
||||
* Merge-extensible: `AgentOptions` supports declaration merging for
|
||||
* plugin-specific creation options.
|
||||
*
|
||||
* ## Event-domain semantics (the boundary rule)
|
||||
*
|
||||
* The harness has three event domains, each with one job:
|
||||
*
|
||||
* - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT
|
||||
* log. Owns `SessionEventMap`; every entry is JSON-only (no live objects).
|
||||
* One `session/event` emit per append, plus the `session/flush` parallel
|
||||
* durability checkpoint. Answers "what happened, durably/replayably." A
|
||||
* consumer that wants the live transcript subscribes here.
|
||||
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
|
||||
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
|
||||
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
|
||||
* `agent/turn-continuation` waterfalls and the serial `agent/pre-step` /
|
||||
* `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits
|
||||
* (`agent/status`, `agent/error`, `agent/created`/
|
||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
|
||||
* they are durable `session/event` records. Answers "right now, with the agent
|
||||
* object — intercept or observe."
|
||||
* - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution.
|
||||
*
|
||||
* **The rule:** a durable, replayable fact is a SessionEvent; a live
|
||||
* interception or a transient/live-object signal is an `agent`/`tools` Cordis
|
||||
* event. A turn/step boundary is a durable fact: it lives in the session log
|
||||
* and is read off the `session/event` feed — it is NOT mirrored as an `agent/*`
|
||||
* emit. A consumer that needs the `Agent` handle (or its short id) at a boundary
|
||||
* looks up the agent directly by the event's session id.
|
||||
* See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md`
|
||||
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
|
||||
*
|
||||
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
|
||||
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision;
|
||||
* the terminal serial `agent/turn-stop` returns the stop-only subset. The
|
||||
* convention is pinned by
|
||||
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
|
||||
* Public agent types and live-runtime events. Durable transcript facts and
|
||||
* turn/step boundaries remain `@deepseek-ai/dsh-session` events.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/types
|
||||
*/
|
||||
@@ -53,38 +13,18 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
/**
|
||||
* The agent this assembly is for. The agent loop passes it on every
|
||||
* per-step assembly (via its `assembleContextFor(agent)` helper, which
|
||||
* also sets the `scope` field to the same agent — the layer selector
|
||||
* `dsh-system-prompt` reads); variable providers project per-agent facts
|
||||
* from it (`options.model` → `{{model}}`, `session.header.cwd` →
|
||||
* `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics)
|
||||
* has no agent — providers must tolerate its absence. Never set `agent`
|
||||
* without `scope`: the assembly would silently miss the agent's scoped
|
||||
* sections/tools (the dev invariants flag it).
|
||||
*/
|
||||
/** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */
|
||||
agent?: Agent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options an agent is created with. The persona is NOT here: the
|
||||
* dsh-system-prompt config supplies the global default, and a scoped
|
||||
* `deployment:persona` section may override it for one agent.
|
||||
* Merge-extensible: plugins declare extra fields via declaration merging.
|
||||
*/
|
||||
/** Merge-extensible agent creation options. Persona belongs to system-prompt sections. */
|
||||
export interface AgentOptions {
|
||||
/** Model name (must have a registered adapter at call time). */
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An
|
||||
* absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content
|
||||
* must label itself here or its message is recorded as a user prompt (see
|
||||
* {@link HookContext} on why that label is load-bearing).
|
||||
*/
|
||||
/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */
|
||||
export interface SendOptions {
|
||||
source?: MessageSource
|
||||
}
|
||||
@@ -97,54 +37,22 @@ export interface SendOptions {
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
/**
|
||||
* Model-facing context an interception listener wants the agent to SEE on the
|
||||
* next request — the canonical shape behind every "inject extra context"
|
||||
* decision ({@link PromptDecision}, {@link PostToolDecision},
|
||||
* {@link ContinuationDecision}). It is `agent.inject()`ed as a
|
||||
* `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()`
|
||||
* defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin
|
||||
* context as a user prompt and corrupt derived history. A bridge sets
|
||||
* `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not
|
||||
* optional — the label is load-bearing, never defaulted here.
|
||||
*/
|
||||
/** Model-facing context injected by a listener; `source` prevents plugin text from being labeled as user input. */
|
||||
export interface HookContext {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns
|
||||
* for ONE drained queued message, before it becomes a `user/message`. Maps onto
|
||||
* the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`.
|
||||
*
|
||||
* - `allow` proceeds with the prompt; optional `content` REPLACES the prompt
|
||||
* bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a
|
||||
* separate `context/message` the next request also sees.
|
||||
* - `block` drops the prompt (it never becomes a `user/message`); `reason` is
|
||||
* the durable record of why. The loop appends a `prompt/blocked` session event
|
||||
* (carrying the original content, source, and `reason`) in place of the
|
||||
* dropped `user/message`, so the veto survives replay even in a MIXED batch
|
||||
* where a sibling prompt is allowed. A batch whose EVERY prompt is blocked
|
||||
* additionally opens a zero-step turn that ends with {@link TurnEndReason}
|
||||
* `rejected` (so the boundary stays balanced and a UI can render "blocked by
|
||||
* hook").
|
||||
* Prompt interception result. `allow.content` replaces the prompt and
|
||||
* `additionalContext` becomes a separate context message. `block` records a
|
||||
* durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'block'; reason: string }
|
||||
|
||||
/**
|
||||
* The decision an {@link Agent} `agent/turn-continuation` waterfall listener
|
||||
* returns. The loop computes the default (`continue` when the step had tool
|
||||
* calls or steering was injected, else `stop`); listeners override it to
|
||||
* force-continue (`/goal`, `/loop`) or force-stop (budget guards).
|
||||
*
|
||||
* A `continue` may carry a `reason`: model-facing context recorded as next-STEP
|
||||
* steering within the SAME turn (the loop enqueues it through the steering
|
||||
* channel, so the continued turn's next step sees it). This is the typed twin of
|
||||
* the existing "steer from a step/end listener" `/goal` pattern.
|
||||
*/
|
||||
/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */
|
||||
export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: HookContext }
|
||||
@@ -156,48 +64,22 @@ export type ContinuationDecision =
|
||||
*/
|
||||
export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
|
||||
|
||||
/**
|
||||
* Why an agent's session lifecycle began, carried by `agent/session-start`. A
|
||||
* bridge keys its SessionStart hook's matcher on this (Claude Code's
|
||||
* `startup`/`resume`/`clear`/`compact` source set). `startup` = a fresh create
|
||||
* (including a seeded/forked create — a seed is NOT a resume); `resume` = a
|
||||
* persisted session reloaded via `ctx.agents.resume()`. `clear`/`compact` are
|
||||
* driven by those subsystems (compact = `TODO(compaction)`).
|
||||
*/
|
||||
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
|
||||
/**
|
||||
* The agent handle — the surface every plugin (UI, hooks, orchestrators)
|
||||
* programs against. The concrete implementation lives in
|
||||
* `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop
|
||||
* package should depend on the implementation.
|
||||
*/
|
||||
/** Public agent handle; the concrete driver belongs to `@deepseek-ai/dsh-agent-loop`. */
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
readonly options: AgentOptions
|
||||
readonly session: Session
|
||||
readonly status: AgentStatus
|
||||
/**
|
||||
* The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent).
|
||||
* Registrations through it — tools, prompt sections/variables, event
|
||||
* listeners, restrictions — are visible to THIS agent only and unwind when
|
||||
* the agent is disposed; `agent.ctx.on('agent/…')` listeners fire only for
|
||||
* this agent's dispatches (zero self-filtering). Service resolution through
|
||||
* it flows through the loop plugin's dependency surface — handing out
|
||||
* `agent.ctx` hands out that capability. Live for exactly the agent's
|
||||
* lifetime: registrations after disposal throw Cordis's INACTIVE_EFFECT.
|
||||
*/
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
* Queue a user message. Starts a turn when idle; otherwise waits for the next
|
||||
* turn. Content and the resolved source are accepted as one detached,
|
||||
* deeply-frozen lossless-JSON record before notification or enqueue, so
|
||||
* caller or `agent/queued` listener in-place mutation cannot change later
|
||||
* log/model input. Throws synchronously when either value is not losslessly
|
||||
* JSON-serializable; `agent/prompt-submit` may still return an explicit
|
||||
* replacement.
|
||||
* Queue detached, frozen lossless-JSON input; starts a turn when idle.
|
||||
* Invalid input throws synchronously before notification or enqueue.
|
||||
*/
|
||||
send(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
@@ -209,317 +91,137 @@ export interface Agent {
|
||||
steer(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
/**
|
||||
* Inject in-session context (file-change notices, skill content, cron
|
||||
* notifications, …): appends a `context/message` session event the next model
|
||||
* request sees at its chronological position, rendered as tagged synthetic
|
||||
* context rather than a user prompt. Does not run the model.
|
||||
*
|
||||
* Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn;
|
||||
* an inject while idle wraps its `context/message` in a one-shot `injection`
|
||||
* turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for
|
||||
* durability, so every event stays inside a turn and a persistence backend
|
||||
* never loses a between-turn notice. The idle checkpoint is fire-and-forget
|
||||
* from this synchronous method, but lifecycle disposal awaits it before
|
||||
* unregistering the agent or detaching its session. A failing flush is
|
||||
* reported via `agent/error` (step `0`) and the logger, never thrown into the
|
||||
* caller.
|
||||
*
|
||||
* Live-adapter review has validated the tagged-envelope rendering against
|
||||
* current DeepSeek behavior; provider-specific mismatches belong in that
|
||||
* adapter, not in the canonical session vocabulary.
|
||||
* Append model-facing context without running the model. Idle injection uses
|
||||
* a one-shot turn and durability checkpoint, while injection during an open
|
||||
* turn joins it at the current log position. Disposal awaits idle checkpoints;
|
||||
* flush failures are reported through `agent/error`, not thrown to the caller.
|
||||
*/
|
||||
inject(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
/**
|
||||
* Cancel ALL pending work for the agent. `cancel()`:
|
||||
*
|
||||
* - clears the queued FIFO (un-started prompts never run) and the steering
|
||||
* FIFO (steering for the cancelled turn is dropped, not re-enqueued);
|
||||
* - aborts the in-flight step if one is running (the turn ends `aborted`);
|
||||
* - drops a turn that is about to start (a `cancel()` landing in the
|
||||
* pre-step window — after a `send()` queued but before the loop flips to
|
||||
* `running`, or after `running` is emitted but before the first step) so
|
||||
* that queued prompt does not run and cannot be batched into the cancelled
|
||||
* turn.
|
||||
*
|
||||
* After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state.
|
||||
* `cancel()` on an idle agent with nothing queued or running is a safe no-op
|
||||
* — it does NOT arm anything that would drop a later legitimate prompt.
|
||||
* Clear queued and steering work, including work waiting to start, and abort
|
||||
* the active step. The supplied reason is preserved across pre-step and active
|
||||
* cancellation windows, and `whenIdle()` resolves after cancellation reaches
|
||||
* quiescence. Idle cancellation is a no-op and does not arm a later cancel.
|
||||
*/
|
||||
cancel(reason?: string): void
|
||||
|
||||
/**
|
||||
* Resolve once the agent has reached quiescence after settling out of
|
||||
* `running`, or immediately if it is already idle with no queued work. A
|
||||
* non-owner's quiescence-observation hook: a consumer that does NOT own the
|
||||
* agent's lifecycle awaits this to proceed only after queued/running work has
|
||||
* fully stopped, rather than returning while the driver is still streaming or
|
||||
* about to start a queued turn — without itself tearing the agent down. (A
|
||||
* lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the
|
||||
* loop-exit promise directly as part of stopping and unregistering. So this is
|
||||
* for a non-owning observer — e.g. a test awaiting a turn to settle, or a
|
||||
* monitor — that wants the settle signal but must not dispose the agent.)
|
||||
*
|
||||
* "Quiescence", not merely "status changed": a disposed agent emits
|
||||
* `agent/status('disposed')` from inside its disposer, BEFORE the driver loop
|
||||
* has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop
|
||||
* to actually exit (the implementation chains the loop-exit promise), not just
|
||||
* observe the status flip. A mid-step disposal that never reaches `idle` still
|
||||
* unblocks the await this way.
|
||||
*/
|
||||
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||
whenIdle(): Promise<void>
|
||||
|
||||
// Subagent delegation is realized on top of this interface by the
|
||||
// `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates
|
||||
// the child through `ctx.agents.create` (fork seeds the child Session with a
|
||||
// balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn
|
||||
// starts fresh) and drives it as an ordinary Agent handle, so steer() and
|
||||
// event subscription work uniformly. See docs/core-data-structures/subagent.md.
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
// ---- lifecycle (emit) ----
|
||||
/**
|
||||
* An agent's fully composed scoped world was published in the
|
||||
* {@link AgentRegistry}. Its session is already live in the session store.
|
||||
* Setup is composition-only by contract; the subsequent
|
||||
* `agent/session-start` boundary is the first supported place to inject or
|
||||
* queue startup work. A synchronous listener throw
|
||||
* vetoes publication and rollback emits the matching disposal edges;
|
||||
* returned-promise rejection is observed and logged but cannot
|
||||
* retroactively veto this synchronous boundary. A synchronous listener
|
||||
* that requests the advanced registry detach does not remove the entry
|
||||
* immediately: removal and the paired `agent/disposed` edge wait until the
|
||||
* creation dispatch unwinds, so no later creation listener observes a
|
||||
* disposal that preceded its own creation callback.
|
||||
* A fully configured agent and live session were published. Setup is
|
||||
* composition-only; `agent/session-start` is the first startup-driving seam.
|
||||
* Synchronous listener failure vetoes publication, while returned-promise
|
||||
* rejection is reported. Detach requested during dispatch waits until every
|
||||
* creation listener has observed the stable entry.
|
||||
* @param agent - the newly registered agent with its live session and completed setup.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* An agent was removed from the registry. The concrete AgentLoop lifecycle
|
||||
* emits this only after its driver and any in-flight turn reach quiescence;
|
||||
* a custom agent registered through the public registry owns its own driver
|
||||
* contract, which the registry cannot infer. Ordered teardown may still be
|
||||
* detaching the session and unwinding scoped registrations when this runs.
|
||||
* An agent left the registry; AgentLoop emits this after driver quiescence
|
||||
* but before session detachment and scoped-registration unwind. Custom
|
||||
* registry users own their driver-ordering contract.
|
||||
* @param agent - the exact agent removed from the registry.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
|
||||
* lifecycle off this transition, never off a status you just requested —
|
||||
* `send()` does not flip status to `running` before it returns.
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
|
||||
* not enter `running` synchronously; drive lifecycle from this event.
|
||||
* @param agent - the agent whose status flipped.
|
||||
* @param status - the status just entered (the transition's destination).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* A message entered the agent's inbox (queued or steering). Content and the
|
||||
* resolved source are the detached, deeply-frozen values retained by the
|
||||
* inbox. `source` has defaults applied and is not the caller's raw options.
|
||||
* Detached, frozen content entered the agent's inbox. Source defaults have
|
||||
* already been applied, so these are the exact values retained for the log.
|
||||
* @param agent - the agent whose inbox received the message.
|
||||
* @param content - the accepted content blocks retained by the inbox.
|
||||
* @param info - the accepted source plus whether it entered as steering.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
|
||||
|
||||
// ---- session lifecycle (emit) ----
|
||||
/**
|
||||
* The agent's session lifecycle began, fired once before its first turn.
|
||||
* `source` says why ({@link SessionStartSource}: fresh startup, a resumed
|
||||
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): a
|
||||
* listener cannot veto by returning a decision or throwing. A listener that
|
||||
* wants to seed context does so via `agent.inject()` (a `context/message` the
|
||||
* first request sees). A lifecycle owner can still dispose its structural
|
||||
* ownership edge during this notification; publication rechecks liveness and
|
||||
* then aborts before the driver starts.
|
||||
* The session lifecycle began, once before the first turn. Use
|
||||
* `agent.inject()` to seed model-facing context. This is a notification, not
|
||||
* a veto; disposal requested by a lifecycle owner is rechecked before the
|
||||
* driver starts.
|
||||
* @param agent - the agent whose session lifecycle began.
|
||||
* @param source - why the session started (fresh startup, resume, …).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
|
||||
|
||||
// Turn and step boundaries are NOT mirrored as agent/* emits: a consumer
|
||||
// that needs them reads the durable `turn/start`/`turn/end`/`step/start`/
|
||||
// `step/end` session events off the `session/event` feed (the session log is
|
||||
// the live transcript feed). See the module doc's three-domain rule and the
|
||||
// "remove agent boundary mirror events" RFC.
|
||||
// Turn and step boundaries are durable session events, not agent events.
|
||||
|
||||
// ---- step/request extension seams (serial + waterfall) ----
|
||||
/**
|
||||
* Awaited pre-step surface-mutation checkpoint, fired once per step AFTER
|
||||
* `turn/start` (and after the prior step closed) but BEFORE this step's
|
||||
* `step/start` — so anything a listener appends lands OUTSIDE the step,
|
||||
* between `turn/start`/`step/end` and the upcoming `step/start`. `step` is
|
||||
* the number of the step about to start. The loop awaits
|
||||
* `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then
|
||||
* opens the step and derives the request history ONCE from whatever the
|
||||
* surface now holds. This is where compaction belongs: it mutates the session
|
||||
* surface in place (shadowing an older range with a summary node) with its
|
||||
* log-only `compact/*` records cleanly outside any step, and the single
|
||||
* subsequent derive reflects the mutation — so there is no double-derive and
|
||||
* no listener can see (or be expected to act on) an assembled `messages`
|
||||
* array that does not exist yet.
|
||||
*
|
||||
* Serial (awaited in registration order), not a waterfall: a listener
|
||||
* mutates the surface as a side effect; there is nothing to transform, but
|
||||
* the loop must wait for the mutation to complete before opening the step
|
||||
* and deriving. Cordis `serial` bails early if a listener returns a bail
|
||||
* value; this event is typed and documented as `void`, so listeners must not
|
||||
* return a semantic veto value. `fullSystemPrompt` is the assembled prompt a
|
||||
* listener needs to measure pressure (the system prompt counts toward the
|
||||
* budget), and `sessionPrefix` is the instance's composed
|
||||
* {@link agent/session-prefix} product for the same reason — every request
|
||||
* carries it in front of the derived history, and it is composed BEFORE
|
||||
* this seam fires precisely so a pressure gate counts the prefix the
|
||||
* request will actually send (never a stale logged one). `signal` cancels
|
||||
* any in-flight work a listener starts (e.g. a
|
||||
* summarization model call).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @param agent - the agent about to open the step.
|
||||
* @param turn - the already-open turn this step belongs to.
|
||||
* @param step - the number of the step about to start.
|
||||
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
|
||||
* @param sessionPrefix - the instance's frozen session prefix, for the same measurement.
|
||||
* @param signal - aborts in-flight listener work when the turn is torn down.
|
||||
* Awaited serial checkpoint for session-surface mutation after prompt
|
||||
* assembly and before `step/start`; appends land outside the pending step.
|
||||
* The loop derives history once afterward, so compaction records and
|
||||
* replacements are included without rewriting an assembled request. The
|
||||
* prompt and prefix are the exact pressure inputs for that request, and
|
||||
* `signal` cancels listener work.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - the agent opening the step.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the pending step number.
|
||||
* @param fullSystemPrompt - the assembled prompt.
|
||||
* @param sessionPrefix - the frozen request prefix.
|
||||
* @param signal - the turn abort signal.
|
||||
* @mode serial
|
||||
*/
|
||||
// TODO: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic
|
||||
// per-step seam — compaction
|
||||
// is their only consumer, so a wide event carries payloads just one listener
|
||||
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
|
||||
// prompt provider, or move token-pressure measurement behind a
|
||||
// compaction-specific seam instead of the shared pre-step checkpoint.
|
||||
// TODO: Move prompt-pressure inputs behind a compaction-specific seam if no second consumer appears.
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Waterfall: decide what happens to ONE drained queued message before it
|
||||
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
|
||||
* attaching `additionalContext`) or block it. Fires inside the already-open
|
||||
* turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook.
|
||||
* Call `next()` to delegate to the default (allow unchanged), or return a
|
||||
* {@link PromptDecision} without calling `next()` to short-circuit.
|
||||
* Allow, rewrite, or block one drained prompt before it becomes a user
|
||||
* message. Call `next()` for the unchanged default.
|
||||
* @param agent - the agent draining its inbox.
|
||||
* @param content - the drained message's blocks, as queued.
|
||||
* @param source - the message's resolved source.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
/**
|
||||
* Waterfall: shape the step's call configuration — model switching,
|
||||
* sampling overrides — by returning a replacement {@link LlmCallConfig}
|
||||
* (the frozen seed is the config the loop would otherwise use). Config is
|
||||
* ALL a listener shapes here: every request is a pure function of the
|
||||
* session log (the reconstructability RFC), so model-visible content
|
||||
* flows through the log channels — `inject()`, steering, prompt-submit
|
||||
* `additionalContext`, prompt sections via `system-prompt/assemble`, or
|
||||
* the header-logged session prefix via {@link agent/session-prefix}
|
||||
* — never through request mutation, and the loop records whatever config
|
||||
* the request actually uses as a `request/header` event before dispatch.
|
||||
* The step's messages are already snapshotted when this fires (the
|
||||
* `step/start` boundary): an `inject()` from a listener here lands in the
|
||||
* log but joins the NEXT request. For surface mutation that must precede
|
||||
* the snapshot (compaction), use {@link agent/pre-step}. Call `next()` to
|
||||
* delegate, or return an {@link LlmCallConfig} without it to
|
||||
* short-circuit.
|
||||
* Replace the frozen call configuration. Model-visible content must use
|
||||
* logged channels; this seam cannot mutate messages. Injection here joins
|
||||
* the next request because the current step boundary is already fixed.
|
||||
* @param agent - the agent making the model call.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step whose request this is.
|
||||
* @param config - the config the loop would use (frozen); return a replacement to switch.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Waterfall: compose the SESSION PREFIX — request-only messages placed in
|
||||
* front of the ENTIRE derived history (directly after the provider's
|
||||
* system slot) on every request this loop instance sends. Fired ONCE per
|
||||
* loop instance, lazily before its first step's {@link agent/pre-step}
|
||||
* seam — BEFORE the pre-step so a token-pressure gate (compaction) counts
|
||||
* the prefix this instance will actually send, never a previous
|
||||
* instance's logged one. The composed
|
||||
* result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the
|
||||
* instance's anchoring `'initial'`/`'resume'` header snapshot, and reused
|
||||
* verbatim for every subsequent request — never recomputed mid-session,
|
||||
* so the provider prefix cache holds by construction (a process restart
|
||||
* or `ctx.agents.resume()` is a new instance: it recomposes, and any
|
||||
* drift lands attributably on the `'resume'` snapshot). Composition runs
|
||||
* outside the step, before the boundary snapshot: a composing listener's
|
||||
* session append joins the CURRENT request's derived history. A
|
||||
* composition interrupted by a cancel/dispose landing inside the
|
||||
* waterfall is discarded — never cached, logged, or sent — and the next
|
||||
* turn recomposes under a live signal, so an abort-aware listener's
|
||||
* degraded fallback cannot leak into later requests.
|
||||
*
|
||||
* This is the home for session-stable openers the model must always see
|
||||
* but that must NOT become durable history — a skills catalog, an
|
||||
* AGENTS.md digest, a workspace baseline: `Session.deriveMessages()`
|
||||
* never returns the prefix, and the header events are its only durable
|
||||
* record, so the request stays reconstructable from the log. Content
|
||||
* that CHANGES mid-session belongs in the append-only history channels
|
||||
* instead — `agent.inject()`, a `tools/post-execute` decision's
|
||||
* `additionalContext`, prompt-submit `additionalContext` — each a
|
||||
* durable `context/message` paid once and prefix-cached thereafter.
|
||||
*
|
||||
* The seed is a frozen empty list; a contributing listener returns a NEW
|
||||
* array — never an in-place push. The canonical contribution is a
|
||||
* PREPEND, `[mine, ...await next()]`: the waterfall unwinds
|
||||
* innermost-first (the LAST-registered listener's `next()` resolves
|
||||
* first), so prepending yields registration order on the wire, and every
|
||||
* plugin using it composes deterministically. The append form
|
||||
* `[...await next(), mine]` is legal but places a contribution AFTER
|
||||
* every later-registered plugin's — reverse registration order when all
|
||||
* contributors append. Call `next()` to
|
||||
* delegate, or return a list without it to short-circuit.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Compose request-only messages placed before derived history. The frozen
|
||||
* result is computed once per loop instance, logged on its anchoring request
|
||||
* header, and reused so the provider prefix remains stable. Interrupted
|
||||
* composition is discarded. Composition precedes the first `agent/pre-step`
|
||||
* and request boundary, so listener appends join the current request and
|
||||
* pressure accounting sees the composed prefix. Changing context belongs in
|
||||
* history; contributors should prepend to `await next()` to preserve registration order.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - the agent whose session prefix is being composed.
|
||||
* @param prefix - the frozen empty seed; return an extended replacement to contribute.
|
||||
* @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down.
|
||||
* @param prefix - the frozen seed; return an extended replacement.
|
||||
* @param signal - aborts composition when the step is torn down.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
@@ -530,47 +232,27 @@ declare module 'cordis' {
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step that produced the message.
|
||||
* @param message - the assistant message as assembled from the stream.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
/**
|
||||
* Waterfall: override the turn-continuation decision via a typed
|
||||
* {@link ContinuationDecision}. The loop's `defaultDecision` is `continue`
|
||||
* when the step had tool calls or steering was injected, else `stop`.
|
||||
* Listeners force-continue (`/goal`, `/loop` — optionally attaching a
|
||||
* `reason` recorded as next-step steering) or force-stop (budget guards).
|
||||
* Call `next()` to delegate to the default, or return a decision to override.
|
||||
* Override whether the turn continues. The default continues after tool
|
||||
* calls or steering and stops otherwise; a continue reason becomes steering.
|
||||
* @param agent - the agent deciding whether to run another step.
|
||||
* @param turn - the turn being continued or stopped.
|
||||
* @param defaultDecision - what the loop would do absent an override.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
/**
|
||||
* Serial terminal-stop checkpoint after the ordinary
|
||||
* `agent/turn-continuation` waterfall, any `continue.reason`, and the
|
||||
* pending-steering continuation override have been folded. A listener
|
||||
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
|
||||
* to abstain. Terminal stop is monotonic: listener order and steering
|
||||
* cannot resume the turn, and pending steering is discarded rather than
|
||||
* becoming another step or turn.
|
||||
* Monotonic terminal-stop checkpoint after continuation and steering are
|
||||
* folded; a stop remains authoritative through turn close and flush:
|
||||
* steering queued in that window is discarded, while ordinary sends survive.
|
||||
* @param agent - the agent whose composed continuation outcome may be stopped.
|
||||
* @param turn - the turn at its terminal-stop checkpoint.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
|
||||
@@ -583,11 +265,7 @@ declare module 'cordis' {
|
||||
* @param turn - the turn in which the failure surfaced.
|
||||
* @param step - the step at which the failure surfaced.
|
||||
* @param error - the failure, verbatim.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void
|
||||
|
||||
@@ -75,7 +75,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
### Extension points
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The constructor reads each seed entry once and uses the same one-pass lossless-JSON snapshot and exact surface-metadata shape checks as `append`, then enforces contiguous seqs and deep-freezes every accepted record; a stateful caller, exotic nested value, marker-less or malformed surface event, metadata on a non-surface event, or retained seed reference therefore cannot silently change the reconstructed history. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
|
||||
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous log and rebuilds its surface. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
/**
|
||||
* Tool-pairing balance over a session's SURFACE: is a given cut point in the
|
||||
* surface a safe edge for a collapsed region (e.g. compaction)?
|
||||
*
|
||||
* The invariant a consumer needs: a collapsed region must never separate an
|
||||
* `assistant/message`'s `tool-call` blocks from their answering `tool/result`s
|
||||
* — that would leave the rehydrated transcript with a dangling tool-call or an
|
||||
* orphaned tool-result, which every provider rejects. (This is the
|
||||
* compaction-time mirror of the crash-recovery imbalance that
|
||||
* {@link interruptedTurnClosers} repairs on load.) Steps were once used as a
|
||||
* proxy for this bracketing, but a compaction REWRITES the surface — it lands a
|
||||
* replacement node at a high log seq whose SURFACE position is the head — so a
|
||||
* scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The
|
||||
* pairing the invariant actually protects lives in the surface nodes' own
|
||||
* content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels
|
||||
* with the node through any reshaping, so alignment is decided over the surface
|
||||
* directly.
|
||||
*
|
||||
* A **cut** is a gap between two adjacent surface nodes (named by the node it
|
||||
* sits immediately before), or the after-tail gap (`null`). Walking the surface
|
||||
* head→tail and assigning each node a delta — `+1` per `tool-call` block on an
|
||||
* `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a
|
||||
* cut is the number of still-unanswered tool calls before it. A cut is
|
||||
* **balanced** when that depth is `0`. A region `[start..end]` is safe to
|
||||
* collapse iff BOTH its edges are balanced cuts: the cut before `start` and the
|
||||
* cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an
|
||||
* inter-step `steering/message`, an injection `context/message`) carry no
|
||||
* pairing, contribute `0`, and so are free boundaries — exactly as before, but
|
||||
* now as a consequence of the balance rather than a special case. An open
|
||||
* trailing step (an assistant whose `tool/result`s have not landed yet) keeps
|
||||
* the depth positive through the tail, so no cut inside it is balanced — the
|
||||
* old explicit open-step check falls out of the same counter.
|
||||
*
|
||||
* Tool-pairing balance over a session surface. Compaction changes surface
|
||||
* positions, so safe cuts are derived from tool-call/result content on the
|
||||
* surface rather than step markers in the append-only log.
|
||||
* @module @deepseek-ai/dsh-session/tool-pairing
|
||||
*/
|
||||
|
||||
@@ -56,33 +27,14 @@ function nodeDelta(event: SessionEvent): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the surface prefix ending at the given cut has BALANCED tool-call /
|
||||
* tool-result brackets — i.e. every `tool-call` block on the surface before the
|
||||
* cut has its answering `tool/result` before the cut too, so the cut is a safe
|
||||
* edge for a collapsed region (it cannot split an assistant↔result pair).
|
||||
* Check that a surface cut does not split a tool call from its result. A region
|
||||
* is safe to collapse only when both edge cuts return true.
|
||||
*
|
||||
* `nodes` is the surface sequence list in head→tail order (e.g.
|
||||
* `session.surface.nodes`); `events` is the session log, used to look each
|
||||
* event up by sequence. `beforeSeq` names the cut by the surface event it
|
||||
* sits immediately before; the after-tail cut (the whole surface) is `null`,
|
||||
* as is any `beforeSeq` not present on the surface.
|
||||
*
|
||||
* A region `[start..end]` is collapsible iff both edges are balanced cuts: call
|
||||
* `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and
|
||||
* `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s
|
||||
* surface successor (`nodes[index + 1]`), or `null` when `end` is the tail —
|
||||
* for the cut after `end`.
|
||||
*
|
||||
* @param nodes - surface event sequences in head→tail order.
|
||||
* @param events - the session log each sequence indexes into.
|
||||
* @param beforeSeq - names the cut (the node it sits immediately before);
|
||||
* `null` — or any seq not on the surface — means the after-tail cut.
|
||||
* @returns true when every `tool-call` before the cut is answered before it
|
||||
* (the unanswered-call depth at the cut is zero).
|
||||
* @throws if the surface prefix drives the unanswered-call depth negative — a
|
||||
* `tool/result` with no preceding open `tool-call` on the surface. That is a
|
||||
* corrupt surface (a structural invariant violation), surfaced loudly here
|
||||
* rather than silently mis-classifying a boundary.
|
||||
* @param nodes - surface event sequence numbers in head-to-tail order.
|
||||
* @param events - the session log indexed by those sequence numbers.
|
||||
* @param beforeSeq - event immediately after the cut; null or an absent seq means after-tail.
|
||||
* @returns whether every call before the cut is answered before it.
|
||||
* @throws if a result appears without a preceding open call.
|
||||
*/
|
||||
export function isToolPairingBalanced(
|
||||
nodes: readonly number[],
|
||||
@@ -99,7 +51,6 @@ export function isToolPairingBalanced(
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
|
||||
}
|
||||
}
|
||||
// Reached the after-tail cut (beforeSeq === null, or a seq not on the
|
||||
// surface): the whole-surface prefix is balanced iff depth returned to 0.
|
||||
// A missing cut sequence means the after-tail boundary.
|
||||
return depth === 0
|
||||
}
|
||||
|
||||
@@ -14,33 +14,17 @@ export function SessionId(id: string): SessionId {
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-disk session format version, stamped into every newly-written
|
||||
* {@link SessionHeader} and enforced by every persistence backend on load. The
|
||||
* single source of truth for the version — write sites and the load-time check
|
||||
* all read it.
|
||||
*
|
||||
* It is **`0`** deliberately: while the harness is unreleased the on-disk format
|
||||
* is **unstable / pre-release, with no compatibility implied**. Breaking changes
|
||||
* to the persisted {@link SessionEventMap} shape (folding fields onto an event,
|
||||
* removing a variant, …) happen freely and do NOT bump this — v0 absorbs all
|
||||
* pre-release churn, and a backend simply REJECTS any log not at v0 (there is no
|
||||
* migration; no persisted user data exists to preserve). A real, monotonically
|
||||
* bumped version policy begins at the first tagged release, when a specific
|
||||
* format boundary becomes worth distinguishing.
|
||||
* The on-disk session format version, stamped into every newly-written {@link SessionHeader}
|
||||
* and enforced by every persistence backend on load. The single source of truth for the
|
||||
* version — write sites and the load-time check all read it.
|
||||
* While the harness is unreleased it is pinned at `0`: no compatibility is
|
||||
* implied, incompatible logs are rejected, and no migration is provided. A
|
||||
* monotonic version policy starts with the first tagged release.
|
||||
*/
|
||||
export const SESSION_FORMAT_VERSION = 0
|
||||
|
||||
/**
|
||||
* Immutable session metadata — written once at creation and never rewritten.
|
||||
* {@link Session} enforces that contract at runtime: it validates and detaches
|
||||
* the accepted scalar fields, requires this header's id to match the session
|
||||
* id, and deep-freezes the published record.
|
||||
*
|
||||
* Kept SEPARATE from the event log deliberately: format-version, cwd, and
|
||||
* lineage are storage concerns, not conversation events, so they stay out of
|
||||
* {@link SessionEventMap} and never reach `deriveMessages()`. Every reference
|
||||
* system (pi's `version: 3` header, Codex's `SessionMeta`, Claude Code's tail
|
||||
* metadata) writes such a header.
|
||||
* Immutable validated storage metadata, kept outside the conversation event log.
|
||||
*/
|
||||
export interface SessionHeader {
|
||||
/**
|
||||
@@ -58,13 +42,8 @@ export interface SessionHeader {
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
readonly parentSession?: SessionId
|
||||
/**
|
||||
* How many leading events were INHERITED via a seed rather than produced by
|
||||
* this session — the seed boundary. Set when a fork seeds a child with a
|
||||
* prefix of the parent's log (= the seeded prefix length); absent/0 means the
|
||||
* session produced all its own events. Persisted so a reload reconstructs the
|
||||
* boundary instead of re-deriving it from the full stored log, and so a replay
|
||||
* harness can skip the inherited prefix when deriving the child's OWN script
|
||||
* (the seeded events are the parent's, not this child's model calls).
|
||||
* How many leading events were inherited through a seed. Persisting this
|
||||
* boundary lets resume and replay distinguish parent history from child work.
|
||||
*/
|
||||
readonly seedLength?: number
|
||||
}
|
||||
@@ -78,17 +57,8 @@ export interface CreateSessionOptions {
|
||||
/** Events to seed the new session with (replay/fork). */
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/**
|
||||
* Creation metadata. The store reads this plain record and each accepted
|
||||
* field once, then fills in `version`/`id` and defaults
|
||||
* `createdAt` to now; the caller supplies the storage-level fields (validated
|
||||
* absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and
|
||||
* — when reconstructing a persisted session — the original `createdAt` to
|
||||
* preserve it).
|
||||
*
|
||||
* `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction
|
||||
* (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full
|
||||
* length, not the original boundary — the caller must pass the persisted
|
||||
* boundary back. A fresh fork passes its actual seeded-prefix length.
|
||||
* Storage metadata read once before publication. `seedLength` is explicit
|
||||
* because a resumed seed contains the full stored log, not only its inherited prefix.
|
||||
*/
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
@@ -119,21 +89,7 @@ export interface TurnTriggerMap {
|
||||
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
|
||||
/**
|
||||
* Why a turn ended.
|
||||
* Merge-extensible sum type.
|
||||
*
|
||||
* `max-tokens` mirrors the model-call `FinishReasonMap` variant (DeepSeek's
|
||||
* `length`): the turn ended because a step hit the output-token ceiling, not
|
||||
* because the model chose to stop. The agent-loop surfaces it via the rule
|
||||
* "any `max-tokens` step in the turn makes the turn end `max-tokens`" (a
|
||||
* continuation plugin can run further steps after one, but the cut-short fact
|
||||
* still wins). It is distinct from `completed` so a consumer (e.g. the ACP
|
||||
* bridge mapping to `StopReason: 'max_tokens'`) can tell a clean stop from a
|
||||
* truncated one. The next variants to add — when an adapter/loop first emits
|
||||
* them — are `refusal` and `max_turn_requests` (both named by the ACP RFC as ACP
|
||||
* stop reasons); no current adapter produces a `refusal` finish (unknown
|
||||
* DeepSeek finish reasons collapse to `error`), so it is deliberately omitted
|
||||
* until one does.
|
||||
* Why a turn ended. Merge-extensible sum type.
|
||||
*/
|
||||
export interface TurnEndReasonMap {
|
||||
completed: { kind: 'completed' }
|
||||
@@ -146,26 +102,16 @@ export interface TurnEndReasonMap {
|
||||
*/
|
||||
error: { kind: 'error'; step: number; message: string; code?: string }
|
||||
disposed: { kind: 'disposed' }
|
||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
/**
|
||||
* The turn's entire prompt batch was BLOCKED before any step ran — every
|
||||
* drained queued message was vetoed by an `agent/prompt-submit` listener (a
|
||||
* hook). The turn still opened (so the boundary stays balanced and the block
|
||||
* is a durable in-turn fact), but ran zero steps. `reason` carries the block
|
||||
* message from the vetoing decision. Distinct from `aborted` (a user-driven
|
||||
* cancel) and `error` (a failure): the prompt was rejected by policy, not
|
||||
* interrupted or broken. A UI renders it as "prompt blocked by hook".
|
||||
* Policy blocked every prompt before the first step. The zero-step turn still
|
||||
* records a balanced durable boundary and the veto reason.
|
||||
*/
|
||||
rejected: { kind: 'rejected'; reason: string }
|
||||
/**
|
||||
* The turn never ended on its own: the process crashed mid-turn and a
|
||||
* persistence backend later closed the orphaned (open) turn on reload so the
|
||||
* log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no
|
||||
* loop ever emits this. Its events are real (they were durably appended before
|
||||
* the crash) and are PRESERVED, not discarded: a single turn can be huge in a
|
||||
* long-horizon task (many steps, large tool output), so truncating it would
|
||||
* lose real work. The marker records that the turn was cut short, not that the
|
||||
* model completed it. See the session-persistence RFC.
|
||||
* A persistence backend closed a crash-orphaned turn on reload. The loop never
|
||||
* emits this marker, and the events recorded before the crash remain intact.
|
||||
*/
|
||||
interrupted: { kind: 'interrupted' }
|
||||
}
|
||||
@@ -192,14 +138,9 @@ export interface TodoItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* The request header: everything about an LLM request besides its derived
|
||||
* message history — the call configuration plus the rendered system prompt,
|
||||
* tool schemas, and the session prefix. Logged session state (the
|
||||
* reconstructability RFC): each changed header is logged as a full
|
||||
* {@link SessionEventMap} `request/header` snapshot, and taking the latest
|
||||
* snapshot (`foldRequestHeader`) reconstructs the header any request used.
|
||||
* Canonical form: an empty system prompt, an empty tool list, and an empty
|
||||
* prefix are ABSENT fields, matching how requests are built.
|
||||
* Logged request state outside derived history: call config, system prompt,
|
||||
* tools, and prefix. The latest full `request/header` snapshot reconstructs it;
|
||||
* canonical empty optional fields are absent.
|
||||
*/
|
||||
export interface EpochHeader {
|
||||
/** The conversation's call configuration (model + sampling scalars). */
|
||||
@@ -227,24 +168,10 @@ export interface EpochHeader {
|
||||
export type RequestHeaderReason = 'initial' | 'resume' | 'change'
|
||||
|
||||
/**
|
||||
* The session event vocabulary — the append-only source of truth for an
|
||||
* agent's whole interaction history. The LLM message history is *derived*
|
||||
* from this log; nothing else is authoritative. Replay = re-derive from the
|
||||
* same events; trace/telemetry = subscribe to the log.
|
||||
*
|
||||
* Merge-extensible: plugins declare extra event types via declaration merging
|
||||
* (e.g. the compaction plugin adds `'compact/start'`, `'compact/summary'`,
|
||||
* `'compact/end'`).
|
||||
*
|
||||
* Durability contract (what a persistence backend relies on): the durable log
|
||||
* persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay
|
||||
* contiguous (`seq = log.length`), so chunks cannot be filtered out of the
|
||||
* canonical log. All `event.data` must be JSON-serializable — `Session.append`
|
||||
* (and the seed path in the constructor) enforces this at the source (throwing
|
||||
* on non-serializable data), so a bad event never enters the log and
|
||||
* `session.events` always equals what a backend can persist. Adding a new event
|
||||
* type that carries non-serializable data, or that breaks the turn/step nesting
|
||||
* the invariants plugin checks, is a breaking change to the on-disk format.
|
||||
* The merge-extensible, append-only source of truth for an agent interaction.
|
||||
* Message history is derived from this log. Every event is lossless JSON and
|
||||
* sequence numbers stay contiguous, including raw chunks, so persistence can
|
||||
* store the canonical log verbatim.
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
@@ -267,14 +194,8 @@ export interface SessionEventMap {
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable
|
||||
* record of a blocked prompt and why. Appended in place of the `user/message`
|
||||
* the prompt would have become, so the block survives replay even in a MIXED
|
||||
* batch where another queued prompt is allowed (there the turn does not end
|
||||
* `rejected`, so the boundary reason alone would not preserve it). `content`
|
||||
* is the original prompt the listener rejected; `reason` is the veto text
|
||||
* ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
|
||||
* blocked prompt produces no LLM message and never reaches `deriveMessages()`.
|
||||
* Durable record of a prompt veto and its reason. It is log-only: the blocked
|
||||
* prompt never enters the model-visible surface, including in a mixed batch.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
@@ -310,30 +231,11 @@ export interface SessionEventMap {
|
||||
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* The agent's whole todo list, carried as a full snapshot and replaced
|
||||
* wholesale on each write — the current list is the most recent `todo/write`
|
||||
* (last-write-wins on replay, no fold). Appended by an owning agent via
|
||||
* `session.append('todo/write', { todos })`.
|
||||
*
|
||||
* NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches
|
||||
* `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface —
|
||||
* it is durable, replayable UI state, distinct from the conversation history.
|
||||
* It is a `SessionEventMap` member riding the existing `session/event` emit,
|
||||
* not a first-class Cordis `interface Events` notification, so it has no
|
||||
* cordis-catalog row.
|
||||
*/
|
||||
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
|
||||
'todo/write': { todos: TodoItem[] }
|
||||
/**
|
||||
* Full snapshot of the {@link EpochHeader} the NEXT request is built under,
|
||||
* with the {@link RequestHeaderReason} it was recorded whole. Appended by
|
||||
* the loop inside the step, before dispatch, on a loop instance's first
|
||||
* request-building step (`'initial'`/`'resume'`) or when a later request's
|
||||
* header changes (`'change'`); always records what the request actually used,
|
||||
* post-`agent/request`. Reconstruction reads the latest snapshot. NOT a
|
||||
* {@link SurfaceEventType}: it produces no LLM message — it is the request
|
||||
* envelope, logged so every request is a pure function of the session log
|
||||
* (the reconstructability RFC).
|
||||
* Full header for the next request, appended inside its step before dispatch.
|
||||
* It is log-only; the latest snapshot reconstructs the request header.
|
||||
*/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
}
|
||||
@@ -381,16 +283,8 @@ export type SurfaceOp =
|
||||
| { op: 'replace'; start: number; end: number }
|
||||
|
||||
/**
|
||||
* Surface metadata passed to {@link Session.append}.
|
||||
* `surfaceOp` controls how the event enters the ordered surface;
|
||||
* `sourceEventSeqs` records the seq numbers of events that are provenance
|
||||
* sources of this one (e.g. the `assistant/chunk` seqs behind an
|
||||
* `assistant/message`, or the shadowed nodes behind a compaction replacement).
|
||||
*
|
||||
* Required for {@link SurfaceEventType} events — every message-producing event
|
||||
* MUST declare how it enters the surface, because the surface is the sole
|
||||
* source of derived history. Non-surface event types (`turn/start`,
|
||||
* `assistant/chunk`, `error`, …) cannot carry surface metadata.
|
||||
* Surface placement and provenance for {@link Session.append}. Required on
|
||||
* message-producing events and forbidden on log-only events.
|
||||
*/
|
||||
export interface SurfaceIntent {
|
||||
surfaceOp: SurfaceOp
|
||||
|
||||
@@ -4,24 +4,8 @@ import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
|
||||
import type { SessionEvent } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Unit coverage for the tool-pairing balance check. It decides whether a CUT in
|
||||
* the surface (a gap before a given surface node, or the after-tail gap) is a
|
||||
* safe edge for a collapsed region (compaction): a region must never split an
|
||||
* `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced
|
||||
* when no unanswered tool-call sits before it on the surface. Nodes belonging to
|
||||
* no step (pre-step user message, inter-step steering, injection context) are
|
||||
* pairing-neutral, so their cuts are free boundaries.
|
||||
*
|
||||
* The fixtures are built through a real {@link Session} so the ordered surface
|
||||
* sequence list is derived exactly as production does — including the non-monotonic
|
||||
* surface a `replace` op leaves (a compaction checkpoint at a high log seq
|
||||
* sitting at the surface head), which is the case the abandoned log-position
|
||||
* scan mis-classified.
|
||||
*
|
||||
* Builders mirror the agent loop's real append order: queued user messages land
|
||||
* BEFORE `step/start`; within a step the order is `assistant/message` then
|
||||
* `tool/result`(s); injection turns are a bare `turn/start → context/message →
|
||||
* turn/end` with no step.
|
||||
* Tool-pairing cut coverage over real session surfaces, including replacement
|
||||
* nodes whose surface order differs from append-log order.
|
||||
*/
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
@@ -182,10 +166,8 @@ describe('isToolPairingBalanced — multiple tool calls in one assistant message
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
|
||||
// A background task-done inject() lands a context/message INSIDE an open step,
|
||||
// between the assistant (with a tool-call) and its tool/result. It is
|
||||
// pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is
|
||||
// still open across it) — it is NOT a free boundary in this position.
|
||||
// The injected context is pairing-neutral, but both adjacent cuts remain
|
||||
// unbalanced because the tool call is still open across them.
|
||||
function midStepInjection(): Session {
|
||||
const s = new Session(SessionId('mid-inject'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -236,11 +218,8 @@ describe('isToolPairingBalanced on an injection turn (no step)', () => {
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
|
||||
// The case the log-position scan got wrong. After a compaction, a replacement
|
||||
// user/message lands at a HIGH log seq but sits at the SURFACE head, beside
|
||||
// the still-open step whose events follow it in the log. It carries no
|
||||
// tool-call/result pair (just summarized prose), so it must be a balanced cut
|
||||
// on BOTH sides regardless of its log neighbours.
|
||||
// A replacement checkpoint has a high log seq but sits at the surface head;
|
||||
// its cuts are balanced regardless of later raw-log neighbors.
|
||||
function checkpointHeadedSession(): Session {
|
||||
const s = new Session(SessionId('checkpoint'))
|
||||
// A closed turn with a tool step → surface [u1, asst(call), result].
|
||||
@@ -275,9 +254,8 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
|
||||
const s = checkpointHeadedSession()
|
||||
const nodes = s.surface.nodes
|
||||
const checkpointSeq = nodes[0]!
|
||||
// The checkpoint heads the surface, yet a surface node (the open step's
|
||||
// assistant) follows it in LOG order — the exact split between surface
|
||||
// position and log position that the log-position scan tripped on.
|
||||
// The checkpoint heads the surface while the open step's assistant follows
|
||||
// it in append-log order.
|
||||
const laterSurfaceInLog = s.events.find(
|
||||
e => e.seq > checkpointSeq && nodes.includes(e.seq),
|
||||
)
|
||||
@@ -291,10 +269,7 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
|
||||
})
|
||||
|
||||
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
|
||||
// This is the exact assertion the log-position scan failed: the forward log
|
||||
// scan from the checkpoint reached the open step's assistant/message and
|
||||
// wrongly reported mid-step. The surface balance sees a neutral node whose
|
||||
// following cut closes no open call.
|
||||
// The neutral checkpoint closes no open call at its following surface cut.
|
||||
const s = checkpointHeadedSession()
|
||||
expect(endBalanced(s, s.surface.nodes[0]!)).toBe(true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user