docs: trim generated prose
This commit is contained in:
@@ -155,11 +155,8 @@ export class ReactLoopAgent implements Agent {
|
||||
private setStatus(status: AgentStatus): void {
|
||||
if (this._status === status || this._status === 'disposed') return
|
||||
this._status = status
|
||||
// Release quiescence waiters on a transition OUT of running BEFORE emitting
|
||||
// (the disposer handles the disposed transition separately). Settling first
|
||||
// means a throwing `agent/status` subscriber cannot starve a `whenIdle()`
|
||||
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
|
||||
// not hang on one bad listener).
|
||||
// Release quiescence waiters on a transition OUT of running before emitting (the disposer
|
||||
// handles the disposed transition separately).
|
||||
if (status !== 'running') this.settleIdleWaiters()
|
||||
try {
|
||||
this.loopCtx.emit(this.carrier, 'agent/status', this, status)
|
||||
@@ -220,25 +217,15 @@ export class ReactLoopAgent implements Agent {
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
// turn-enclosed (the durability/replay boundary is the turn).
|
||||
const turn = lastTurnNumber(this.session) + 1
|
||||
// Once turn/start enters the log, a turn/end is OWED no matter what — even
|
||||
// if a throwing `session/event` listener escapes from the turn/start append
|
||||
// (Session.append pushes the event BEFORE notifying listeners) or the
|
||||
// context/message append throws (non-serializable content, throwing
|
||||
// listener). The finally re-checks the log via isTurnOpen() and closes the
|
||||
// turn if one was actually opened, so the log never carries a permanently
|
||||
// open injection turn that would corrupt later turns/replay. (If the
|
||||
// turn/start append throws BEFORE pushing — non-serializable trigger, which
|
||||
// can't happen for our fixed trigger — no turn was opened and none is owed.)
|
||||
// Once turn/start enters the log, a turn/end is OWED no matter what — even if a throwing
|
||||
// `session/event` listener escapes from the turn/start append (Session.append pushes the
|
||||
// event before notifying listeners) or the context/message append throws (non-serializable
|
||||
// content, throwing listener).
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
|
||||
} finally {
|
||||
// Close the turn if turn/start made it into the log. Contain a throwing
|
||||
// turn/end listener: Session.append pushes before notifying, so a throw
|
||||
// here still leaves turn/end in the log (the turn is balanced) — swallow
|
||||
// it so it neither replaces the original exception nor skips the flush
|
||||
// decision below. (It surfaces through the flush path is not needed; the
|
||||
// turn-balance contract is what matters and it holds.)
|
||||
// Close the turn if turn/start made it into the log.
|
||||
if (isTurnOpen(this.session)) {
|
||||
try {
|
||||
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
@@ -247,25 +234,12 @@ export class ReactLoopAgent implements Agent {
|
||||
// so the turn is balanced; the throw is the listener's bug.
|
||||
}
|
||||
}
|
||||
// Decide the durability checkpoint from the LOG, not a flag: a turn was
|
||||
// recorded iff this turn's turn/start is logged (it may have been closed
|
||||
// by a throwing-listener turn/end above, which still counts). A
|
||||
// `turnRecorded` boolean set after append('turn/end') would be skipped by
|
||||
// a throwing turn/end listener, losing the flush for a balanced in-memory
|
||||
// turn (crash before the next turn/dispose would drop the idle injection).
|
||||
// Decide the durability checkpoint from the LOG, not a flag: a turn was recorded iff this
|
||||
// turn's turn/start is logged (it may have been closed by a throwing-listener turn/end
|
||||
// above, which still counts).
|
||||
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
// Checkpoint the one-shot turn for durability, exactly as the loop does at
|
||||
// every turn/end. The loop is NOT running (we are idle), so nothing else
|
||||
// will flush this turn. Fire-and-forget with error containment: inject()
|
||||
// is synchronous, and a persistence backend failing must not throw into
|
||||
// the caller (e.g. a tool-bash task-done callback). Disposal still drains
|
||||
// independently, so a slow flush is safe. The task is tracked until it
|
||||
// settles: driver disposal awaits every pending idle-injection checkpoint
|
||||
// before unregistering the agent or detaching the session. A flush failure
|
||||
// is reported via agent/error (step 0 — the idle-injection convention,
|
||||
// there is no real step) AND the logger, mirroring the loop's post-turn/end
|
||||
// flush path so plugins monitoring agent/error see idle-injection
|
||||
// persistence failures too. A throwing agent/error listener is contained.
|
||||
// Checkpoint the one-shot turn for durability, exactly as the loop does at every
|
||||
// turn/end.
|
||||
if (turnRecorded) {
|
||||
// Through the store's flush (the carrier owner), never a raw parallel.
|
||||
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
|
||||
@@ -279,10 +253,8 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
})
|
||||
this.pendingIdleFlushes.add(flush)
|
||||
// Attach the same retirement callback to both settlement arms so even a
|
||||
// logger failure in the catch above cannot become an unhandled rejection.
|
||||
// Teardown uses allSettled for the same reason: a reporting failure must
|
||||
// not strand ownership.
|
||||
// Attach the same retirement callback to both settlement arms so even a logger failure
|
||||
// in the catch above cannot become an unhandled rejection.
|
||||
const retire = (): void => { this.pendingIdleFlushes.delete(flush) }
|
||||
void flush.then(retire, retire)
|
||||
}
|
||||
@@ -291,15 +263,8 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
cancel(reason?: string): void {
|
||||
this.assertDriveEnabled('cancel')
|
||||
// Arm-gate: only mark a cancellation when there is actually work to cancel —
|
||||
// a running turn, an in-flight step, or queued/steering work. An idle cancel
|
||||
// with nothing pending is a true no-op; arming the marker then would wrongly
|
||||
// drop the NEXT legitimate prompt (the marker is consumed only at the loop's
|
||||
// turn-decision points, which an idle parked loop does not reach until woken
|
||||
// by a real send()). Note the gate canNOT be `status === 'running'` alone:
|
||||
// the pre-step window (a send() queued but the loop not yet flipped to
|
||||
// running) has status `idle` with `hasQueued` true, and the marker exists
|
||||
// precisely to cover it.
|
||||
// Arm-gate: only mark a cancellation when there is actually work to cancel — a running
|
||||
// turn, an in-flight step, or queued/steering work.
|
||||
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
|
||||
this.cancelRequested = true
|
||||
// Capture the resolved reason for the marker-only windows (pre-step /
|
||||
@@ -307,10 +272,8 @@ export class ReactLoopAgent implements Agent {
|
||||
// below; the marker path reads it via the LoopHandle's cancelReason().
|
||||
this.cancelReason = reason ?? 'cancelled'
|
||||
}
|
||||
// Drop all pending queued + steering work (un-started prompts never run; the
|
||||
// cancelled turn's steering is not re-enqueued). Cleared directly even when
|
||||
// the loop is parked in waitForQueued — there is no turn to stop and nothing
|
||||
// left for the parked loop to run, so no wake is needed.
|
||||
// Drop all pending queued + steering work (un-started prompts never run; the cancelled
|
||||
// turn's steering is not re-enqueued).
|
||||
this.#inbox.clear()
|
||||
// Interrupt an in-flight step immediately (the running turn observes the
|
||||
// abort and ends `aborted`). The marker covers the windows where no step is
|
||||
@@ -319,29 +282,15 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the agent has reached quiescence after settling out of
|
||||
* `running`. If it is already disposed, awaits {@link done} (the loop-exit
|
||||
* promise) — `agent/status('disposed')` fires in the disposer BEFORE the
|
||||
* driver loop has unwound, so it is NOT itself a quiescence signal. If it is
|
||||
* idle AND has no queued work, resolves immediately. Otherwise queues an
|
||||
* internal waiter (see {@link idleWaiters}) released on the next
|
||||
* running→idle/disposed transition, resolving on `idle` directly (the turn
|
||||
* fully ended) or chaining {@link done} on `disposed` (wait for the loop to
|
||||
* actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner
|
||||
* quiescence-observation hook, distinct from teardown (a lifecycle owner stops
|
||||
* and unregisters via `AgentHandle.dispose()`, whose driver boundary awaits
|
||||
* both {@link done} and outstanding idle-injection flushes, not through this).
|
||||
* Resolve once the agent has reached quiescence after settling out of `running`.
|
||||
*/
|
||||
whenIdle(): Promise<void> {
|
||||
if (this._status === 'disposed') return this.done
|
||||
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
|
||||
// Register an internal waiter (resolved by settleIdleWaiters on the next
|
||||
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
|
||||
// a concurrent fiber disposal runs this agent's listener disposers, which
|
||||
// could remove a `ctx.on` waiter before the `disposed` transition fires and
|
||||
// hang the promise. On disposal the disposer settles the waiter AND we chain
|
||||
// `done` here for true loop-exit quiescence (status flips to disposed before
|
||||
// the loop unwinds); a plain idle transition resolves directly.
|
||||
// running→idle/disposed transition), not an effect-scoped `ctx.on` listener: a concurrent
|
||||
// fiber disposal runs this agent's listener disposers, which could remove a `ctx.on` waiter
|
||||
// before the `disposed` transition fires and hang the promise.
|
||||
return new Promise<void>((resolve) => {
|
||||
this.idleWaiters.push(() => {
|
||||
resolve(this._status === 'disposed' ? this.done : undefined)
|
||||
@@ -370,12 +319,10 @@ export class ReactLoopAgent implements Agent {
|
||||
isCancelled: () => this.cancelRequested,
|
||||
cancelReason: () => this.cancelReason,
|
||||
clearCancel: () => { this.cancelRequested = false },
|
||||
// Settle whenIdle() waiters WITHOUT a status transition — the pre-step
|
||||
// cancel-skip path drops the about-to-run turn and re-parks without ever
|
||||
// flipping running→idle, so a waiter registered in the pre-step window
|
||||
// (status idle, hasQueued was true) would otherwise hang. This 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 whenIdle() waiters WITHOUT a status transition — the pre-step cancel-skip path
|
||||
// drops the about-to-run turn and re-parks without ever flipping running→idle, so a
|
||||
// waiter registered in the pre-step window (status idle, hasQueued was true) would
|
||||
// otherwise hang.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
})
|
||||
// The disposer must be infallible: it runs inside the fiber's LIFO
|
||||
@@ -404,10 +351,6 @@ export class ReactLoopAgent implements Agent {
|
||||
// final lifecycle backstop for anything outside those boundaries.
|
||||
await Promise.allSettled([this.done])
|
||||
// No new inject() can start after the synchronous disposed transition.
|
||||
// Loop because settled tasks retire themselves in promise reactions that
|
||||
// may run beside this continuation; either the set is empty or this waits
|
||||
// the exact remaining quiescence boundary. allSettled keeps a failure in
|
||||
// error reporting from skipping the registry/session/scope disposers.
|
||||
while (this.pendingIdleFlushes.size > 0) {
|
||||
await Promise.allSettled([...this.pendingIdleFlushes])
|
||||
}
|
||||
|
||||
@@ -42,17 +42,8 @@ export interface Config {
|
||||
/** Optional workspace cwd for the config-created fresh session. */
|
||||
cwd?: string
|
||||
/**
|
||||
* If set, the config agent RESUMES this persisted session id instead of
|
||||
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
|
||||
* cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
|
||||
* demo can continue a prior conversation without code changes. Requires a
|
||||
* `dsh-session-persistence` backend; the resume is deferred until that
|
||||
* service is available (via `ctx.inject`) and the loaded session's events
|
||||
* seed the live session so history continues.
|
||||
*
|
||||
* The schema accepts a plain string at runtime (cordis.yml values are
|
||||
* untyped); the brand is compile-time only — the config format is the
|
||||
* boundary where an id enters, so the TYPE declares the brand here.
|
||||
* If set, the config agent RESUMES this persisted session id instead of starting a fresh
|
||||
* `${id}-session-<uuid>`.
|
||||
*/
|
||||
resumeSessionId?: SessionId
|
||||
})[]
|
||||
@@ -75,11 +66,9 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
private pendingAgentIds = new Set<AgentId>()
|
||||
private pendingSessionIds = new Set<SessionId>()
|
||||
|
||||
// The schema validates plain strings (cordis.yml config values are untyped at
|
||||
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
|
||||
// because the config format is the boundary where an id enters. The brand is a
|
||||
// zero-cost compile-time cast, so the runtime schema stays string-based and we
|
||||
// assert the branded view once here — the single schema boundary.
|
||||
// The schema validates plain strings (cordis.yml config values are untyped at runtime); the
|
||||
// {@link Config} TYPE declares the branded `id`/`resumeSessionId` because the config format
|
||||
// is the boundary where an id enters.
|
||||
static Config = z.object({
|
||||
agents: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
@@ -94,25 +83,12 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// Provide the agent-creation factory to the registry (effect-scoped: the
|
||||
// slot is cleared on dispose).
|
||||
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
||||
// The prompt variables the shipped loop provides, registered once. The
|
||||
// sections themselves (`harness:identity`, `deployment:persona`) belong to
|
||||
// dsh-system-prompt — they must survive a swapped loop plugin — but
|
||||
// `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives:
|
||||
// it assembles with `{ agent }` each step (loop.ts), and the variables
|
||||
// project the agent's configured model and its session workspace from that
|
||||
// context. A provider returns undefined when the fact is absent
|
||||
// (renderPrompt then rejects a persona that claims it — fail loud).
|
||||
// The prompt variables the shipped loop provides, registered once.
|
||||
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
|
||||
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
|
||||
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
if (resumeSessionId !== undefined && resumeSessionId !== '') {
|
||||
// Resume a prior session instead of starting fresh. resume() needs
|
||||
// `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml
|
||||
// lists the backend later). `ctx.inject(['sessionPersistence'], cb)`
|
||||
// runs `cb` with a child ctx once the service exists; the child reads
|
||||
// the persistence and hands it to resumeWith (which uses this.ctx — the
|
||||
// parent — for sessions/registry, all in AgentLoop's static inject). A
|
||||
// failed resume is contained + logged: startup must not crash.
|
||||
// Wait for a late persistence service before resuming the configured session.
|
||||
ctx.effect(() => {
|
||||
const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
|
||||
@@ -120,10 +96,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
||||
})
|
||||
})
|
||||
// Return the EXACT child-fiber disposer. Cordis moves a returned
|
||||
// effect into this labeled owner's teardown tree by function
|
||||
// identity; a wrapper would leave the child as a concurrent sibling
|
||||
// and could discard its async quiescence promise.
|
||||
// Return the exact child-fiber disposer.
|
||||
return fiber.dispose
|
||||
}, `agentLoop.resume(${id})`)
|
||||
} else {
|
||||
@@ -133,54 +106,32 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-driven create: an agent on a FRESH, non-colliding session id per run
|
||||
* (`${id}-session-<uuid>`). Used for `cordis.yml`-configured agents and as
|
||||
* the shared core for the programmatic factory {@link createAgent}.
|
||||
*
|
||||
* Why a per-run id, not a fixed `${id}-session`: once a durable persistence
|
||||
* backend is loaded, a fixed id collides on the second run — the backend
|
||||
* refuses to re-create an id whose log already exists on disk (the SessionId
|
||||
* is the identity). A fresh id means each run is a new session.
|
||||
*
|
||||
* TODO(demo): each run starting a brand-new session is fine for demos but is
|
||||
* NOT real conversation continuity. A production config-driven agent needs a
|
||||
* deliberate resume-or-create policy (resume the prior session if one exists,
|
||||
* else start fresh) or an explicit caller-chosen session id — revisit when the
|
||||
* UI/ACP path owns session selection.
|
||||
* @param id - the agent id; also seeds the generated session id.
|
||||
* @param options - loop options (model, limits, …); defaults applied per option.
|
||||
* @param meta - optional session metadata for the fresh session.
|
||||
* @returns the running agent, owned by the calling fiber (no handle).
|
||||
* Create a config-driven agent with a unique session id for this run.
|
||||
* @param id - agent id and generated-session prefix.
|
||||
* @param options - loop options.
|
||||
* @param meta - optional fresh-session metadata.
|
||||
* @returns running agent owned by the calling fiber.
|
||||
*/
|
||||
// TODO(demo): define a production resume-or-create policy for config-driven agents.
|
||||
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
|
||||
this.assertAgentIdFree(id)
|
||||
// Config/programmatic path: prepare the session and let start() fold its
|
||||
// lifecycle into the agent's composite effect (so a fiber unload tears the
|
||||
// session + agent down as one ordered chain, capturing the loop's closing
|
||||
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
|
||||
// The calling fiber owns the prepared session and agent lifecycle.
|
||||
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
|
||||
const { agent } = this.start(id, options, session, 'startup')
|
||||
return agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic factory create ({@link AgentFactory}): an agent on a
|
||||
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
|
||||
* metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The
|
||||
* ACP bridge uses this so the client-generated session id becomes the
|
||||
* live/persisted session id; the in-process FORK subagent backend passes a
|
||||
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
|
||||
* starts with the parent's context. Returns an {@link AgentHandle} the owner
|
||||
* disposes to tear down exactly this agent.
|
||||
* Programmatic factory create ({@link AgentFactory}): an agent on a caller-supplied
|
||||
* `sessionId` (not `${id}-session`), with optional session metadata (validated `cwd`,
|
||||
* lineage) and an optional `seed` event prefix.
|
||||
*
|
||||
* @param options - agent id, caller-supplied session id, optional seed/meta,
|
||||
* and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
*/
|
||||
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
// Snapshot every caller-owned field before the first async setup boundary.
|
||||
// The callback itself is an identity capability; all data fields are
|
||||
// detached so caller mutation cannot drift a reserved/published identity or
|
||||
// the options the accepted agent observes.
|
||||
const agentId = options.agentId
|
||||
const sessionId = options.sessionId
|
||||
const setup = options.setup
|
||||
@@ -201,35 +152,17 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the
|
||||
* session log + metadata via `ctx.sessionPersistence`, reconstructs the live
|
||||
* session with the loaded events (so `lastTurnNumber`/`deriveMessages`
|
||||
* continue), and starts a fresh agent on it. The live session id is the
|
||||
* resumed id, NOT `${agentId}-session`.
|
||||
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the session log +
|
||||
* metadata via `ctx.sessionPersistence`, reconstructs the live session with the loaded
|
||||
* events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on it.
|
||||
* The live session id is the resumed id, not `${agentId}-session`.
|
||||
*
|
||||
* Requires `ctx.sessionPersistence`; rejects with a clear error if it is not
|
||||
* configured. NOT hard-injected (that would make non-persistent demos pend
|
||||
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
|
||||
* by the time this runs the service exists.
|
||||
* @param options - the persisted session id to reload, plus agent id/options.
|
||||
* @returns the handle for the agent resumed on the reconstructed session.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct
|
||||
// global-store lookup keyed by the isolate symbol — NOT
|
||||
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
|
||||
// `sessionPersistence` (injecting it would pend non-persistent demos
|
||||
// forever). The `ctx.<name>` property proxy resolves a service by an
|
||||
// ancestor-only walk of the current fiber's parent chain; from AgentLoop's
|
||||
// own fiber (which lacks the inject) that walk never reaches the sibling
|
||||
// backend fiber and throws "cannot get property … without inject". Worse,
|
||||
// when the call arrives via a traceable shadow (e.g. the ACP bridge child
|
||||
// fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
|
||||
// at the shadow's origin fiber and fails the same way. `ctx.get(name)`
|
||||
// sidesteps the fiber walk entirely (a store lookup by the global isolate
|
||||
// key), so resume works from any caller fiber. It is strict by default: a
|
||||
// backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
|
||||
// and we reject below, rather than handing back an unusable handle.
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct global-store lookup
|
||||
// keyed by the isolate symbol — not `this.ctx.sessionPersistence`.
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
@@ -257,13 +190,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
|
||||
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
|
||||
let observingOwner = true
|
||||
// Resume must observe its caller from BEFORE persistence I/O begins. The
|
||||
// full agent lifecycle does not exist until load returns, so without this
|
||||
// sentinel a never-settling backend outlives owner disposal and holds both
|
||||
// public identities forever. `this.ctx.effect` retains the traceable caller
|
||||
// ownership used by startOwned's lifecycle effect. Install it before even
|
||||
// reserving the ids: an inactive owner cannot leak a reservation if effect
|
||||
// registration fails.
|
||||
// Resume must observe its caller from before persistence I/O begins.
|
||||
const disposeLoadSentinel = this.ctx.effect(() => () => {
|
||||
if (!observingOwner) return
|
||||
markOwnerDisposed()
|
||||
@@ -306,11 +233,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// Manual handoff/removal must not return transactionSettled: awaiting
|
||||
// that promise from inside this transaction would deadlock it. If the
|
||||
// owner already triggered cleanup, this idempotent second disposal is a
|
||||
// no-op and the owner's first cleanup remains parked on the shared
|
||||
// settlement promise.
|
||||
// Manual handoff/removal must not return transactionSettled: awaiting that promise from
|
||||
// inside this transaction would deadlock it.
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
} finally {
|
||||
@@ -360,11 +284,9 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
publish: (source: SessionStartSource) => void
|
||||
disposeAgent: () => Promise<void>
|
||||
} {
|
||||
// When creation is invoked through an agent scope (subagents), the owner
|
||||
// agent's disposed status flips synchronously at handle teardown—earlier
|
||||
// than Cordis reaches nested scope effects. Include that signal in the
|
||||
// pre-publication liveness check so a same-turn parent dispose cannot race
|
||||
// an already-fulfilled setup promise into briefly publishing a child.
|
||||
// When creation is invoked through an agent scope (subagents), the owner agent's disposed
|
||||
// status flips synchronously at handle teardown—earlier than Cordis reaches nested scope
|
||||
// effects.
|
||||
const ownerAgent = this.ctx.agent
|
||||
const ownerFiber = this.ctx.fiber
|
||||
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
|
||||
@@ -458,22 +380,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The
|
||||
* handle's `dispose()` runs the composite effect's disposer (see
|
||||
* {@link start}) — which stops the loop, awaits its exit and outstanding
|
||||
* idle-injection flushes, unregisters the agent, and detaches the session, in
|
||||
* that order.
|
||||
* The same composite effect is what a fiber unload disposes, so both teardown
|
||||
* triggers honor the ordering identically.
|
||||
*
|
||||
* `dispose()` is MEMOIZED: the underlying cordis effect disposer is
|
||||
* single-shot (a second call returns immediately because the effect's epoch is
|
||||
* already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated
|
||||
* `dispose()` calls would otherwise resolve before the first call's
|
||||
* loop + flush quiescence boundary completed. Memoizing the promise makes
|
||||
* every caller observe that SAME boundary, honoring the
|
||||
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
|
||||
* helper).
|
||||
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent.
|
||||
*/
|
||||
private async startOwned(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
@@ -491,11 +398,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}),
|
||||
])
|
||||
// Cordis begins a fiber unload synchronously but invokes nested effect
|
||||
// disposers from its next microtask. Give that already-started unload one
|
||||
// checkpoint to deactivate this lifecycle before publication; otherwise
|
||||
// an immediately fulfilled setup continuation can outrun its owner's
|
||||
// same-turn dispose and briefly publish an already-doomed child.
|
||||
// Cordis begins a fiber unload synchronously but invokes nested effect disposers from its
|
||||
// next microtask.
|
||||
await Promise.resolve()
|
||||
if (!lifecycle.active()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
|
||||
@@ -51,20 +51,8 @@ function assertContinuationStop(value: unknown): asserts value is ContinuationSt
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Map a model-call {@link FinishReason} to the step error it should raise, or `undefined` when
|
||||
* the step completed normally.
|
||||
*/
|
||||
function finishError(finish: FinishReason): CodedError | undefined {
|
||||
switch (finish.kind) {
|
||||
@@ -93,17 +81,8 @@ function errorData(err: CodedError): { message: string; code?: string } {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`".
|
||||
* The turn-end contribution of a step's *successful* finish, or `undefined` when the step
|
||||
* finished ordinarily (a plain `completed`).
|
||||
*/
|
||||
function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
switch (finish.kind) {
|
||||
@@ -161,66 +140,15 @@ export interface LoopHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent loop. One invocation drives one agent for its whole lifetime:
|
||||
* 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'|'request/header-delta') ⟵ the header event this request owes the
|
||||
* log (initial/resume anchor, delta, fallback)
|
||||
* 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
|
||||
* ```
|
||||
* @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 transmission bookkeeping: whether this loop instance has anchored the log's
|
||||
// header fold yet (its first request logs a 'initial'/'resume' request/header snapshot).
|
||||
const transmission = createTransmissionLog()
|
||||
|
||||
const { session } = agent
|
||||
@@ -233,19 +161,8 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
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).
|
||||
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the idle wait but
|
||||
// before we flip to `running`.
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
@@ -256,18 +173,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).
|
||||
// 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`.
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
@@ -276,21 +183,14 @@ 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.
|
||||
// Re-derive turn numbers because idle injection can advance 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.
|
||||
// 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.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
|
||||
try {
|
||||
@@ -298,21 +198,12 @@ 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 the cancel marker UNCONDITIONALLY here, after the turn returns and before the next
|
||||
// iteration's idle wait.
|
||||
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.
|
||||
// Steering that arrived too late to join an ordinary turn (turn-end listeners, flush)
|
||||
// becomes queued input so it is never stranded.
|
||||
for (const message of handle.inbox.drainSteering()) {
|
||||
if (!terminalStopped) handle.inbox.enqueue(message)
|
||||
}
|
||||
@@ -326,10 +217,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.
|
||||
// --- Pre-turn.
|
||||
const queued = handle.inbox.drainQueued()
|
||||
const first = queued[0]
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
@@ -342,29 +230,19 @@ async function runTurn(
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
|
||||
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
|
||||
// are durable session events only — there is no agent/* step emit to mirror
|
||||
// them (see the agent event-domain rule). A throwing step/end session-event
|
||||
// listener must not abort finalization and strand the turn open (turn/end
|
||||
// balance > notifying one bad listener); it is contained and surfaced as a
|
||||
// turn error below.
|
||||
// Close the open step exactly once (idempotent via stepOpen).
|
||||
const closeStep = (): boolean => {
|
||||
if (!stepOpen) return false
|
||||
stepOpen = false
|
||||
// Session.append pushes step/end BEFORE notifying session/event listeners,
|
||||
// so a throwing listener leaves step/end in the log (balance holds) but
|
||||
// would otherwise abort finalization. Contain it and surface it as a turn
|
||||
// error below.
|
||||
// Preserve step balance even when an event listener throws after append.
|
||||
let failure: unknown
|
||||
try {
|
||||
session.append('step/end', { turn, step })
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
// A throwing step/end session-event listener surfaces as a turn error via
|
||||
// failTurn (idempotent). This prevents a throwing listener from producing a
|
||||
// silent "completed" turn when the step itself succeeded, AND keeps
|
||||
// finalization going when closeStep runs from the outer catch.
|
||||
// A throwing step/end session-event listener surfaces as a turn error via failTurn
|
||||
// (idempotent).
|
||||
if (failure !== undefined) {
|
||||
failTurn(toError(failure))
|
||||
return true
|
||||
@@ -372,21 +250,16 @@ async function runTurn(
|
||||
return 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 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).
|
||||
const failTurn = (err: CodedError): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
// The turn is always still open here: the only failure that can reach
|
||||
// failTurn once turn/end is appended would be a throwing turn-boundary
|
||||
// listener, and turn boundaries are durable session events with no agent/*
|
||||
// mirror to throw. A throwing `turn/end` session-event listener is already
|
||||
// contained inside closeTurn (append pushes before notifying, so the
|
||||
// boundary is durable). So set the error reason for closeTurn to append.
|
||||
// The turn is always still open here: the only failure that can reach failTurn once
|
||||
// turn/end is appended would be a throwing turn-boundary listener, and turn boundaries are
|
||||
// durable session events with no agent/* mirror to throw.
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
try {
|
||||
events.emit('agent/error', turn, step, err)
|
||||
@@ -396,18 +269,11 @@ async function runTurn(
|
||||
}
|
||||
}
|
||||
|
||||
// Close the turn. Called exactly once per turn — the normal loop exit and the
|
||||
// outer catch are mutually exclusive paths, and this never throws (the append
|
||||
// is contained below), so there is no re-entry to guard against (unlike
|
||||
// closeStep, which the cancel branches and the outer catch can both reach).
|
||||
// Turn boundaries are durable session events only — there is no agent/* turn
|
||||
// emit to mirror them (see the agent event-domain rule).
|
||||
// Close the turn.
|
||||
const closeTurn = (): void => {
|
||||
// Session.append pushes turn/end BEFORE notifying session/event listeners,
|
||||
// so a throwing listener leaves turn/end in the log (the turn is balanced)
|
||||
// but would otherwise escape — from the outer catch it would propagate to
|
||||
// the runLoop backstop. Contain it: the boundary is durable either way, and
|
||||
// finalization must not abort on a bad listener.
|
||||
// Session.append pushes turn/end before notifying session/event listeners, so a throwing
|
||||
// listener leaves turn/end in the log (the turn is balanced) but would otherwise escape —
|
||||
// from the outer catch it would propagate to the runLoop backstop.
|
||||
try {
|
||||
session.append('turn/end', { turn, reason })
|
||||
} catch (error: unknown) {
|
||||
@@ -416,16 +282,10 @@ async function runTurn(
|
||||
}
|
||||
|
||||
try {
|
||||
// --- Turn boundary. Once turn/start is appended, a turn/end is owed no
|
||||
// matter what throws below; the catch + closeTurn guarantee it (the catch
|
||||
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
|
||||
// listener — append pushes before notifying — still gets its turn/end).
|
||||
// --- Turn boundary.
|
||||
session.append('turn/start', { turn, trigger })
|
||||
// Each drained queued message runs the `agent/prompt-submit` waterfall before
|
||||
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
|
||||
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
|
||||
// throws) is caught below and the turn still closes.
|
||||
// Each drained queued message runs the `agent/prompt-submit` waterfall before it becomes a
|
||||
// `user/message` — a hook can rewrite the prompt or block it.
|
||||
let anyAllowed = false
|
||||
// Seeded with a floor (only observable if the batch were empty, which
|
||||
// runTurn never allows — it is called with ≥1 queued message); each `block`
|
||||
@@ -439,13 +299,7 @@ async function runTurn(
|
||||
)
|
||||
if (decision.kind === 'block') {
|
||||
lastBlockReason = decision.reason
|
||||
// Record the veto durably: `PromptDecision.reason` is the durable record
|
||||
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
|
||||
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
|
||||
// blocked, another allowed) does not end `rejected` at all — so without
|
||||
// this append a blocked prompt would vanish from the log whenever any
|
||||
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
|
||||
// place of the `user/message` this prompt would have become.
|
||||
// Log each veto because turn/end cannot represent every blocked prompt.
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
|
||||
continue
|
||||
}
|
||||
@@ -461,11 +315,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 ends as a balanced zero-step rejected turn.
|
||||
if (!anyAllowed) {
|
||||
reason = { kind: 'rejected', reason: lastBlockReason }
|
||||
break
|
||||
@@ -476,56 +326,29 @@ async function runTurn(
|
||||
// the request.
|
||||
drainSteering(agent, handle.inbox, turn)
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
// async listener whose effect fires before we block — always has an armed
|
||||
// abort to cancel against. isDisposed below covers disposal, which does
|
||||
// NOT set the cancel marker. Cleared on every exit path below.
|
||||
// The step's AbortController exists before any async pre-step work so a dispose() or
|
||||
// cancel() — in a synchronous turn-start listener or an async listener whose effect fires
|
||||
// before we block — always has an armed abort to cancel against. isDisposed below covers
|
||||
// disposal, which does not set the cancel marker.
|
||||
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 the system prompt for this step.
|
||||
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).
|
||||
// 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().
|
||||
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 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.
|
||||
if (transmission.sessionPrefix === undefined) {
|
||||
const emptyPrefix: Message[] = deepFreeze([])
|
||||
const composed = await events.waterfall(
|
||||
@@ -533,16 +356,9 @@ 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.
|
||||
// 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.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
@@ -551,19 +367,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.
|
||||
// Run compaction between steps so its surface events remain outside step brackets.
|
||||
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.
|
||||
@@ -573,29 +377,20 @@ 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 a step/start session/event listener, an
|
||||
// agent/request-window inject(), any concurrent task — lands after the
|
||||
// boundary and joins the NEXT request. An external reconstructor
|
||||
// recovers these exact messages by folding the surface over
|
||||
// events[0..stepStartSeq).
|
||||
// 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.
|
||||
const boundaryMessages = session.deriveMessages()
|
||||
|
||||
// Mark the step open BEFORE the append: Session.append pushes the event
|
||||
// to the log before notifying session/event listeners, so a THROWING
|
||||
// step/start listener leaves step/start in the log. Setting stepOpen first
|
||||
// means the outer catch's closeStep() then appends the balancing step/end
|
||||
// (turn stays enclosed) instead of stranding an open step under turn/end.
|
||||
// Mark the step open before the append: Session.append pushes the event to the log before
|
||||
// notifying session/event listeners, so a THROWING step/start listener leaves step/start
|
||||
// in the log.
|
||||
stepOpen = true
|
||||
session.append('step/start', { turn, step })
|
||||
|
||||
// Cancel landing in the step-start window: a synchronous `session/event`
|
||||
// step/start listener can cancel after the step is already open. Check
|
||||
// AFTER the step/start append and before `runStep`: drop the step, end the
|
||||
// turn accordingly. closeStep balances the already-appended step/start.
|
||||
// Cancel landing in the step-start window: a synchronous `session/event` step/start
|
||||
// listener can cancel after the step is already open.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
@@ -630,13 +425,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-tokens once any step reports it.
|
||||
const stepReason = stepFinishReason(stepOutcome.finish)
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
@@ -672,10 +461,8 @@ async function runTurn(
|
||||
// the next iteration's drain records it.
|
||||
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 runs only after the extensible continuation waterfall, its optional
|
||||
// reason, and late steering have all been folded.
|
||||
let terminalStop = false
|
||||
try {
|
||||
const stop = await events.strictSerial('agent/turn-stop', turn)
|
||||
@@ -689,19 +476,13 @@ 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.
|
||||
// A continuation reason or listener may have queued steering before the terminal
|
||||
// checkpoint.
|
||||
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.
|
||||
// A turn-scoped marker catches cancellation between step controllers.
|
||||
if (handle.isCancelled()) {
|
||||
reason = { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
@@ -718,29 +499,10 @@ async function runTurn(
|
||||
closeTurn()
|
||||
} catch (error: unknown) {
|
||||
// Decide whether this turn was ever opened from the LOG, not a flag.
|
||||
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||
// so a throwing listener on the `turn/start` append leaves turn/start in the
|
||||
// log even though execution never reached the lines after that append.
|
||||
// Gating on a "turn started" boolean would skip turn/end and leave a
|
||||
// permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We
|
||||
// check the log for THIS turn's turn/start: present means a turn/end is owed
|
||||
// and the normal-exit `closeTurn()` did NOT run (we are here because a throw
|
||||
// preceded it — the two `closeTurn()` sites are on mutually exclusive paths),
|
||||
// so this catch appends turn/end with the disposed/error reason chosen below.
|
||||
// `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
|
||||
// already in a step branch, so running it again is a safe no-op. Absent
|
||||
// turn/start means the append threw BEFORE its push (a non-serializable
|
||||
// trigger — impossible for our fixed trigger); nothing was opened, so rethrow
|
||||
// to the runLoop backstop.
|
||||
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.)
|
||||
// Choose the close reason.
|
||||
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
reason = { kind: 'disposed' }
|
||||
} else {
|
||||
@@ -755,13 +517,8 @@ async function runTurn(
|
||||
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 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.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
|
||||
try {
|
||||
@@ -803,27 +560,17 @@ 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 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.
|
||||
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.
|
||||
// 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).
|
||||
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`)
|
||||
@@ -845,11 +592,9 @@ 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.
|
||||
// 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.
|
||||
const request: GenerateOptions = deepFreeze({
|
||||
model: header.config.model,
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||
@@ -873,23 +618,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 terminal error chunks into the same failure path as thrown adapter 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.
|
||||
// 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).
|
||||
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.
|
||||
@@ -908,14 +646,7 @@ async function runStep(
|
||||
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).
|
||||
// Do not append an assistant message without content or usage; omit empty provenance too.
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
session.append(
|
||||
'assistant/message',
|
||||
@@ -928,11 +659,7 @@ async function runStep(
|
||||
// ToolRegistry.execute converts tool failures (including aborts) into
|
||||
// isError results, so abort is re-checked around every call here.
|
||||
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).
|
||||
// Per-step buffer of `additionalContext` attached by tools/post-execute listeners.
|
||||
const pendingContext: HookContext[] = []
|
||||
for (const call of toolCalls) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
@@ -944,12 +671,7 @@ 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): arguments cannot change after their audit and history events are logged.
|
||||
const result = await ctx.tools.execute({
|
||||
callId: call.id,
|
||||
name: call.name,
|
||||
@@ -959,12 +681,10 @@ 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.
|
||||
// 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.
|
||||
callId: call.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
@@ -1008,13 +728,9 @@ 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 a turn is currently open in the session log (a `turn/start` with no matching later
|
||||
* `turn/end`).
|
||||
*
|
||||
* @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,12 +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 deltas from there.
|
||||
*
|
||||
* 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).
|
||||
* @module dsh-agent-loop/request-log
|
||||
*/
|
||||
|
||||
@@ -37,22 +32,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 four
|
||||
* 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 and the delta round-trips (`applyHeaderDelta` on the baseline
|
||||
* reproduces the header exactly) → a `request/header-delta`.
|
||||
* 4. It differs and the delta encoding cannot express the change (a pure tool
|
||||
* reordering) → a full snapshot with reason `'fallback'`; deltas are an
|
||||
* encoding optimization, never a correctness dependency.
|
||||
* Append whatever header event this request owes the log, so folding the log reproduces the
|
||||
* header the request was built under. Exactly one of four things happens.
|
||||
*
|
||||
* @param session - the session whose log explains the request.
|
||||
* @param state - this loop instance's bookkeeping (mutated on first log).
|
||||
|
||||
Reference in New Issue
Block a user