From aaa42d5844fd9691042c7923f5630b3011fe48a0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 24 Jul 2026 11:46:06 +0800 Subject: [PATCH 001/211] refactor(agent-loop): simplify message machine --- packages/core/agent-loop/src/agent.ts | 990 ++++++++++-------- packages/core/agent-loop/src/cancellation.ts | 31 - packages/core/agent-loop/src/inbox.ts | 141 --- packages/core/agent-loop/src/index.ts | 544 ++++------ packages/core/agent-loop/src/invariant.ts | 2 +- packages/core/agent-loop/src/loop.ts | 825 --------------- packages/core/agent-loop/src/request-log.ts | 55 - packages/core/agent-loop/src/tool-calls.ts | 28 +- packages/core/agent-loop/tests/MIGRATION.md | 86 ++ packages/core/agent-loop/tests/agent.spec.ts | 68 +- .../tests/contract-regressions.spec.ts | 158 +-- packages/core/agent-loop/tests/inbox.spec.ts | 130 --- .../core/agent-loop/tests/invariant.spec.ts | 11 +- packages/core/agent-loop/tests/loop.spec.ts | 56 +- .../core/agent-loop/tests/tool-calls.spec.ts | 29 +- packages/core/agent/src/dispatch.ts | 112 +- packages/core/agent/src/index.ts | 4 +- packages/core/agent/src/invariant.ts | 3 - packages/core/agent/src/llm-target.ts | 2 +- packages/core/agent/src/types.ts | 482 +++------ packages/core/tools/src/index.ts | 19 + packages/goal/tool-goal/src/index.ts | 28 +- .../subagent-inprocess/src/structured.ts | 17 +- packages/ui/acp/src/index.ts | 2 +- packages/ui/tui/src/index.ts | 4 +- 25 files changed, 1205 insertions(+), 2622 deletions(-) delete mode 100644 packages/core/agent-loop/src/cancellation.ts delete mode 100644 packages/core/agent-loop/src/inbox.ts delete mode 100644 packages/core/agent-loop/src/loop.ts delete mode 100644 packages/core/agent-loop/src/request-log.ts create mode 100644 packages/core/agent-loop/tests/MIGRATION.md delete mode 100644 packages/core/agent-loop/tests/inbox.spec.ts diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index baa2e3f08d..103967feee 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -1,471 +1,637 @@ /** - * The concrete Agent implementation: ReactLoopAgent plus its inbox. Everything - * observable happens through session events and the agent/* event taxonomy — - * plugins never need this class. + * The concrete Agent, in the naive-agent shape: the agent IS the machine. + * Two inboxes — `queued` (prompts, one turn each) and `outbox` (steering + + * injected context, taken whole at every step boundary) — and one `run()` + * per turn: intake the prompt, then step until the model owes no response. + * + * The session log IS the transcript: every take appends, every step re-derives + * (`session.deriveMessages()`), so editing history between steps is naturally + * legal — recovery is "observe the error idle, repair the log, retry()". + * Because the outbox is only ever taken at a step boundary, nothing can land + * between an assistant tool-call batch and its results; wire adjacency needs + * no dedicated machinery. * * @module dsh-agent-loop/agent */ -import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' -import { Agent } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' -import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' -import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' -import { Inbox, agentMessage, type InboxMessage } from './inbox.ts' -import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' +import { agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { createScope } from '@deepseek-ai/dsh-scope' +import type { Scope } from '@deepseek-ai/dsh-scope' +import type { + Agent, + AgentInterruptReason, + AgentOptions, + AgentStatus, + HookContext, + IdleReason, + InjectOptions, + PromptDecision, + SendOptions, +} from '@deepseek-ai/dsh-agent' +import { + BlockAssembler, HarnessError, LlmError, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest, +} from '@deepseek-ai/dsh-llm' +import type { + ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message, MessageSource, +} from '@deepseek-ai/dsh-llm' +import { canonicalHeader, headerEquals, snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' +import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tools' +import { executeToolCalls } from './tool-calls.ts' -/** Sessions already claimed by a concrete driver construction. */ -const claimedDriverSessions = new WeakSet() - -/** Module-private driver entry: its symbol is absent from the package surface. */ -const startDriver = Symbol('dsh.agent-loop.start-driver') - -/** Module-private quiescent stop, valid both before and after driver start. */ -const stopDriver = Symbol('dsh.agent-loop.stop-driver') - -/** Module-private context binding for the mutually referential agent scope. */ -const bindContext = Symbol('dsh.agent-loop.bind-context') - -/** Module-private publication marker. */ -const publishAgent = Symbol('dsh.agent-loop.publish-agent') - -/** Factory-owned controls that can operate only on the agent created with them. */ -export interface PreparedReactLoopAgent { - /** The unpublished concrete agent. */ - agent: ReactLoopAgent - /** Mark the agent public so teardown emits its status lifecycle. */ - markPublished(): void - /** Stop the prepared instance even when publication has not started its loop. */ - dispose(): Promise | void - /** - * Start its driver after publication and session-start notification. - * The returned disposer reaches quiescence for both the loop and every - * fire-and-forget idle-injection flush the agent started. - */ - startDriver(): () => Promise | void +/** A prompt waiting for a turn of its own. */ +interface QueuedMessage { + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] } -/** - * Construct an unpublished concrete agent with instance-bound lifecycle - * controls. Only those paired controls can publish or start this instance. - * @param ctx - the agent-loop service context used for driving and events. - * @param id - the concrete agent identity. - * @param options - loop options for the agent. - * @param session - the prepared session the agent will own. - * @param maxParallelToolCalls - resolved in-flight cap for this agent. - * @returns the agent and closures bound only to that exact instance. - */ -export function prepareReactLoopAgent( - ctx: Context, - id: SessionId, - options: AgentOptions, - session: Session, - maxParallelToolCalls: number, -): PreparedReactLoopAgent { - if (claimedDriverSessions.has(session)) { - throw new Error(`session "${session.id}" already has a concrete agent driver`) - } - const agent = new ReactLoopAgent(ctx, id, options, session, maxParallelToolCalls) - claimedDriverSessions.add(session) - const dispose = () => agent[stopDriver]() +/** Input awaiting the next step boundary. */ +type OutboxItem = + | ({ kind: 'steering' } & QueuedMessage) + | { kind: 'context'; context: HookContext } + +const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = { + type: 'text', + text: '\n\n## My request:\n', +} + +/** Bake prompt-prefix contexts into one reconstructable prompt event. */ +function preparePromptMessage( + content: ContentBlock[], + source: MessageSource, + contexts: readonly HookContext[], +): { data: PromptMessageData; separateContexts: HookContext[] } { + const prefixContexts = contexts.filter(context => context.placement === 'prompt-prefix') + const separateContexts = contexts.filter(context => context.placement !== 'prompt-prefix') + if (prefixContexts.length === 0) return { data: { content, source }, separateContexts } return { - agent, - markPublished: () => { agent[publishAgent]() }, - dispose, - startDriver: () => { - agent[startDriver]() - return dispose + data: { + content: [ + ...prefixContexts.flatMap(context => context.content), + PROMPT_PREFIX_REQUEST_DELIMITER, + ...content, + ], + source, + envelope: { + displayContent: content, + prefixContexts: prefixContexts.map(context => ({ + source: context.source, + ...context.meta === undefined ? {} : { meta: context.meta }, + })), + }, }, + separateContexts, } } -/** - * Install the concrete agent's scope context exactly once. Construction and - * scope minting are mutually referential (the scope key is the agent), so the - * factory performs this one post-construction binding before setup receives - * the unpublished agent. The module-private binding rejects a second bind. - * @param agent - the unpublished concrete agent to bind. - * @param ctx - its fully extended agent scope context. - */ -export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void { - agent[bindContext](ctx) + +/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */ +export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const) + +/** Normalize thrown values while preserving an existing error code. */ +function toError(error: unknown): Error & { code?: string } { + return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) } +/** Rebuild the live {@link LlmError} for serializable provider facts; `cause` keeps the foreign original. */ +function llmError(facts: LlmFailure, cause?: Error): LlmError { + return new LlmError(facts.message, facts.code, { + ...facts.status === undefined ? {} : { status: facts.status }, + ...facts.providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs: facts.providerRetryAfterMs }, + ...facts.requestId === undefined ? {} : { requestId: facts.requestId }, + ...cause === undefined ? {} : { cause }, + }) +} + +function withoutToolCalls(message: Message): Message { + return { ...message, content: message.content.filter(block => block.type !== 'tool-call') } +} + +// --------------------------------------------------------------------------- +// The agent. +// --------------------------------------------------------------------------- + /** - * The concrete {@link Agent} implementation owned by the agent-loop plugin. - * - * Owns the inbox (queued + steering FIFOs), turn cancellation, and - * the loop driver. Everything observable happens through session events and - * the agent/* event taxonomy — plugins never need this class. + * The concrete {@link Agent}: the classic naive agent loop — whole derived + * history in, one assistant message out, loop until a reply owes no tool call. + * One `run()` drains the work queue, one turn per unit. */ -export class ReactLoopAgent extends Agent { - /** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */ - readonly #inbox = new Inbox() +export class ReactLoopAgent implements Agent { + /** Prompts awaiting a turn of their own: one dequeued per turn, FIFO. */ + private queued: QueuedMessage[] = [] + /** Taken whole at every step boundary; caller-editable until taken (taken = entered the log). */ + private outbox: OutboxItem[] = [] - /** - * The agent's scope context ({@link Agent.ctx}), wired by the factory right - * after the scope is minted — before the agent is registered, announced, or - * driven, so no consumer can observe it unset. Definite-assignment (`!`) - * expresses that two-phase construction: the agent object and its scope - * context are mutually referential (the scope is keyed BY this agent), so - * neither can exist strictly before the other. - */ - private boundContext: Context | undefined - - /** The agent's scoped composition context, bound once by its factory. */ - get ctx(): Context { - if (this.boundContext === undefined) throw new Error(`agent "${this.id}" context is not bound`) - return this.boundContext - } - - private _status: AgentStatus = 'idle' - /** Active turn owner from pre-running publication through durability settlement. */ - private turnCancellation: TurnCancellation | undefined - /** Whether runLoop has been installed into {@link done}. */ - private driverStarted = false - /** Whether registry publication began and status disposal is externally visible. */ - private published = false - /** Cause-less marker for queued work cancelled before the driver installs a turn owner. */ - private preRunCancelled = false - private disposed: Promise - private resolveDisposed!: () => void - /** Resolves when the driver loop has fully exited (tests/disposal). */ + /** Whether `run()` is driving a turn right now — the single activity truth. */ + private busy = false + /** The active turn's abort owner; rotated per turn, aborted by {@link cancel}. */ + private turnAbort: AbortController | undefined + /** Resolves when the current `run()` has fully exited (quiescence for waiters and teardown). */ done: Promise = Promise.resolve() + + /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */ + readonly scope: Scope + /** The agent's scoped composition context ({@link Agent.ctx}). */ + readonly ctx: Context + /** - * Pending {@link whenIdle} waiters, resolved by {@link settleIdleWaiters} when - * the agent next settles out of `running`. Kept as internal agent state (NOT - * an effect-scoped `ctx.on` listener) so a concurrent fiber disposal — which - * runs the agent's own listeners' disposers — cannot drop the waiter before - * the `disposed` transition fires and leave the promise hanging. + * The last turn number this machine (or the seeded log) opened. The machine + * is the session's only turn author, so after the one seed scan below it + * simply counts. */ - private idleWaiters: (() => void)[] = [] - /** Maximum parallel-safe calls allowed in one step. */ - private readonly maxParallelToolCalls: number - /** - * Durability checkpoints started by idle {@link inject} calls. `inject()` is - * synchronous, so it cannot await them itself; the driver disposer drains - * this set before the lifecycle unregisters the agent or detaches its session. - */ - private pendingIdleFlushes = new Set>() - /** Whether the current step is executing an assistant tool-call batch. */ - private toolBatchActive = false - /** Open-turn injections waiting for the active assistant tool-call batch to close. */ - private deferredInjections: HookContext[] = [] + private lastTurn: number + /** Whether the machine owes the log a `turn/end` / `step/end` right now. */ + private turnOpen = false + private stepOpen = false constructor( private loopCtx: Context, public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, - maxParallelToolCalls: number, ) { - super() - this.maxParallelToolCalls = maxParallelToolCalls - const { promise, resolve } = Promise.withResolvers() - this.disposed = promise - this.resolveDisposed = resolve + this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 + // The scope is keyed by this agent — an opaque identity, fine mid-construction. + this.scope = createScope(loopCtx, this) + this.ctx = this.scope.ctx.extend({ agent: this }) } + /** Pure activity: whether a run is driving right now. */ get status(): AgentStatus { - return this._status + return this.busy ? 'running' : 'idle' } - private setStatus(status: AgentStatus): void { - if (this._status === status || this._status === 'disposed') return - this._status = status - // Settle first so a throwing status listener cannot starve quiescence waiters. - if (status !== 'running') this.settleIdleWaiters() - agentEvents(this.loopCtx, this).emit('agent/status', status) - } - - /** - * Resolve and clear all pending {@link whenIdle} waiters. Called on a - * running→idle transition (from {@link setStatus}) and on disposal (from the - * internal driver disposer, which chains `done` for true loop-exit quiescence). - */ - private settleIdleWaiters(): void { - const waiters = this.idleWaiters - this.idleWaiters = [] - for (const resolve of waiters) resolve() - } - - /** - * Accept one public message payload as a detached record. Lossless-JSON - * materialization reads every nested field once; deep freeze prevents later - * caller mutation before an inbox or deferred-injection queue drains it. - */ - private acceptMessage( - id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions, - ): InboxMessage { - const contexts = options?.contexts ?? [] - const accepted = snapshotJsonValue({ - id, content, source, contexts, wakeup, - ...options?.meta !== undefined ? { meta: options.meta } : {}, - }) + /** Detach and freeze one public payload; rejects non-lossless-JSON input synchronously. */ + private accept(value: T): T { + const accepted = snapshotJsonValue(value) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') } return deepFreeze(accepted) } - /** Detach one context before it can outlive its caller in the active-batch FIFO. */ - private acceptContext(context: HookContext): HookContext { - const accepted = snapshotJsonValue(context) - if (accepted === undefined) { - throw new TypeError('agent context must be losslessly JSON-serializable') - } - return deepFreeze(accepted) + // ------------------------------------------------------------------------- + // Public driving verbs. + // ------------------------------------------------------------------------- + + /** Queue a prompt: one turn of its own, FIFO. */ + send(content: ContentBlock[], options: SendOptions): void { + const accepted = this.accept({ content, source: options.source, contexts: options.contexts ?? [] }) + this.queued.push(accepted) + emitAgentEvent(this.loopCtx, this, 'agent/queued', accepted.content, { + source: accepted.source, + contexts: accepted.contexts, + steering: false, + }) + this.kick() } - /** Reject a driving operation once teardown has synchronously closed the agent. */ - private assertNotDisposed(): void { - if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) - } - - send(content: ContentBlock[], options?: SendOptions): AgentMessageId { - this.assertNotDisposed() - const id = AgentMessageId(randomUUID()) - const target = options?.target ?? 'next-turn' - const wakeup = options?.wakeup ?? true - // next-step/no-wakeup is injection: durable context without running the model. - if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return id } - // next-step/wakeup is steering into the running turn; idle falls back to a - // woken follow-up turn (there is no active turn to attach to). - const steering = target === 'next-step' && this._status === 'running' - const source = options?.source ?? { kind: 'user' } - const accepted = this.acceptMessage(id, content, source, wakeup, options) - if (steering) { - this.#inbox.steer(accepted) - } else { - this.#inbox.enqueue(accepted, wakeup) - } - agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', agentMessage(accepted, steering)) - return id - } - - /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ - private injectContext(content: ContentBlock[], options?: SendOptions): void { - const source = options?.source ?? { kind: 'plugin', plugin: '' } - const context = { - content, - source, - ...options?.meta !== undefined ? { meta: options.meta } : {}, - } - if (isTurnOpen(this.session)) { - const accepted = this.acceptContext(context) - // Provider protocols require every assistant tool-call batch to be - // followed only by its tool results. Historical interrupted batches do - // not own new context; only the currently executing batch may defer it. - if (this.toolBatchActive) { - this.deferredInjections.push(accepted) - return - } - this.session.append('user/message', accepted, { surfaceOp: 'append' }) - return - } - // 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 even if the message - // append fails acceptance or pre-commit validation. The finally re-checks - // the log and closes only a turn that actually opened; post-commit observers - // are contained by Session and cannot create a false append failure. - try { - this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - this.session.append('user/message', context, { surfaceOp: 'append' }) - } finally { - // Close the turn if turn/start made it into the log. A pre-commit veto - // must escape rather than being mistaken for a committed turn/end. - if (isTurnOpen(this.session)) { - this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) - } - // Decide the durability checkpoint from the log: an accepted one-shot - // turn must be flushed even when its message append was the failing step. - const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) - // Keep inject() synchronous: report checkpoint failures live instead of - // rejecting the caller, and track the task so disposal still drains it. - 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) => { - const rendered = errorChain(error) - const err = error instanceof Error ? error : new Error(rendered) - this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) - agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) - }) - this.pendingIdleFlushes.add(flush) - // Retire on either settlement path. - const retire = (): void => { this.pendingIdleFlushes.delete(flush) } - void flush.then(retire, retire) - } - } - } - - /** Append deferred open-turn injections after the loop closes a tool-result batch. */ - private drainDeferredInjections(): void { - const pending = this.deferredInjections.splice(0) - for (const accepted of pending) { - this.session.append('user/message', accepted, { surfaceOp: 'append' }) - } - } - - /** - * Run one tool-call batch and drain its deferred context before settlement. - * The loop-owned acceptor remains valid after public disposal begins because - * the interrupted turn stays open until this batch settles. - */ - private async withToolBatch( - run: (acceptContext: (context: HookContext) => void) => Promise, - ): Promise { - this.toolBatchActive = true - const acceptContext = (context: HookContext): void => { - this.deferredInjections.push(this.acceptContext(context)) - } - try { - return await run(acceptContext) - } finally { - this.toolBatchActive = false - this.drainDeferredInjections() - } - } - - cancel(cause?: AgentCancelCause, options?: CancelOptions): void { - const resolvedCause = cause ?? { kind: 'user' } - const keepInbox = options?.keepInbox ?? false - const cancellation = this.turnCancellation - // keepInbox preserves pending work, so un-started items must not arm the - // pre-run cancel path that would otherwise drop the next queued turn. - const preRun = !keepInbox && cancellation === undefined - && (this.#inbox.hasQueued || this.#inbox.hasSteering) - if (cancellation !== undefined || preRun) { - if (preRun) this.preRunCancelled = true - // Coordination consumers must update their own state before this call - // clears the inbox or aborts the turn. Notification failures are - // contained by the fused dispatcher and cannot veto cancellation. - agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) - } - if (!keepInbox) { - // Snapshot before clearing so the discard notification carries the exact - // dropped items; a replacement synchronously enqueued by an - // `agent/cancel-requested` observer belongs to the next turn, not here. - const discarded = this.#inbox.pending() - // Clear work already present before abort observers run. - this.#inbox.clear() - if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) - agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) - } - // No idle-waiter settle here: a `whenIdle` waiter exists only while the - // agent is `running` or a waking item is queued, and neither is left - // quiescent by clearing the inbox — a lone quiet item takes `whenIdle`'s - // fast path (no waiter), a waking item keeps the woken driver running, - // and a running agent owns its own idle transition (including the - // post-turn flush window). - } - cancellation?.request(resolvedCause) - } - - /** - * Resolve immediately when idle with no queued work, on the next quiescent - * idle transition otherwise, or after driver exit when already disposed. - * This observes quiescence; it does not own teardown. - */ - whenIdle(): Promise { - if (this._status === 'disposed') return this.done - // A lone quiet (`wakeup:false`) queued item leaves the agent quiescent — the - // driver stays parked — so gate on hasWakingQueued, not hasQueued. - if (this._status !== 'running' && !this.#inbox.hasWakingQueued) return Promise.resolve() - // Agent-owned waiters survive concurrent fiber disposal. - return new Promise((resolve) => { - this.idleWaiters.push(() => { - resolve(this._status === 'disposed' ? this.done : undefined) - }) + /** Steer the running turn: taken at the next step boundary. With no turn running, falls back to {@link send}. */ + steer(content: ContentBlock[], options: SendOptions): void { + // `busy` (a turn is actually running), not status: status stays `running` + // across chained turns and through the agent/idle report, where steering + // has no live turn to join and must become a prompt of its own. + if (!this.busy) { this.send(content, options); return } + const accepted = this.accept({ content, source: options.source, contexts: options.contexts ?? [] }) + this.outbox.push({ kind: 'steering', ...accepted }) + emitAgentEvent(this.loopCtx, this, 'agent/queued', accepted.content, { + source: accepted.source, + contexts: accepted.contexts, + steering: true, }) } - /** Bind the mutually referential scope context once. */ - private [bindContext](ctx: Context): void { - if (this.boundContext !== undefined) throw new Error(`agent "${this.id}" context is already bound`) - this.boundContext = ctx - } - - /** Mark that public lifecycle publication began. */ - private [publishAgent](): void { - this.published = true + /** + * Stage model-facing context without running the model: it rides along with + * whatever runs next (the next step of the running turn, or the next turn). + * While the agent is idle the context is committed immediately as a one-shot + * turn. Appending IS the durable write — persistence drains eagerly on + * every append and owns the write chain end to end. + */ + inject(content: ContentBlock[], options: InjectOptions): void { + const context = this.accept({ + content, + source: options.source, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + if (this.busy) { + this.outbox.push({ kind: 'context', context }) + return + } + // Idle: wrap the injection in a one-shot turn so every event stays + // turn-enclosed (the durability/replay boundary is the turn). + const turn = ++this.lastTurn + let opened = false + try { + this.session.append('turn/start', { turn, trigger: { kind: 'injection', source: context.source } }) + opened = true + this.session.append('context/message', context, { surfaceOp: 'append' }) + } finally { + // Close only a turn whose start committed; a pre-commit veto escapes. + if (opened) this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } } /** - * Start the driver loop. The prepared controller already owns its stable - * disposer, so teardown can mark the agent disposed even in the narrow - * publication window before this method runs. + * Clear all pending work and abort the active turn; the first cause wins. + * The cause is signal payload for observers and the durable turn/end + * classification — it selects no machine behavior. Teardown is just + * `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose, + * all owned by the factory. */ - [startDriver](): void { - if (this._status === 'disposed') return - this.driverStarted = true - this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, { - inbox: this.#inbox, - maxParallelToolCalls: this.maxParallelToolCalls, - setStatus: (status) => { this.setStatus(status) }, - installTurnCancellation: () => { - const cancellation = new TurnCancellation() - this.turnCancellation = cancellation - return cancellation + cancel(cause: AgentInterruptReason = { kind: 'user' }): void { + if (this.turnAbort !== undefined || this.queued.length > 0 || this.outbox.length > 0) { + // Observe-only: coordination consumers update their state before the + // inboxes clear; listener failures are contained by the dispatcher. + emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) + } + // Clear before abort observers run: a replacement enqueued by an observer + // belongs to the next turn. + this.queued.length = 0 + this.outbox.length = 0 + this.turnAbort?.abort(Object.freeze({ kind: cause.kind })) + } + + /** + * Re-open a turn on the current session log without a new prompt — the + * recovery verb after an error idle (naive `retry()`): repair the history + * (edit the log, wait out a rate limit), then run again, right now. + * @throws while a turn is running — there is nothing to retry yet. + */ + retry(): void { + if (this.busy) throw new Error(`agent "${this.id}" cannot retry while busy`) + this.start() + } + + /** Resolve at idle quiescence: no run driving and no prompt waiting. */ + async whenIdle(): Promise { + // `done` is replaced per run, so re-reading it each lap follows chained + // turns; a run failure still counts as quiescence for the waiter. + while (this.busy || this.queued.length > 0) await this.done.catch(() => undefined) + } + + // ------------------------------------------------------------------------- + // The machine. + // ------------------------------------------------------------------------- + + /** Claim the next queued prompt and open a run on it, when nothing is driving. */ + private kick(): void { + if (this.busy) return + const message = this.queued.shift() + if (message !== undefined) this.start(message) + } + + /** Open one `run()` — on a claimed prompt, or promptless for a retry. The caller has checked `busy`. */ + private start(prompt?: QueuedMessage): void { + this.busy = true + emitAgentEvent(this.loopCtx, this, 'agent/status', 'running') + // The whole run inherits this agent as its process-local initiator so + // tools, the llm service, and nested factories can attribute their work. + this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt)) + } + + /** + * One `run()` is one turn: prompt intake (submit waterfall), the durable + * turn boundary, then the naive step loop until the model owes no response. + * Every failure funnels to the single catch — {@link settle} classifies it + * once (interruption beats error) — and the finally always closes the owed + * boundaries and runs the idle tail, which opens the next run while work + * remains. + */ + private async run(prompt?: QueuedMessage): Promise { + const controller = new AbortController() + this.turnAbort = controller + const signal = controller.signal + const turn = ++this.lastTurn + let idle: IdleReason = { kind: 'completed' } + let reason: TurnEndReason = { kind: 'completed' } + let step = 0 + + try { + // Intake precedes the turn: the submit decision belongs to the prompt, + // not the turn (a retry opens a turn with no prompt at all). A failed + // intake leaves no durable trace — nothing entered the conversation. + const decision = prompt === undefined + ? undefined + : await this.loopCtx.waterfall( + agentCarrier(this), 'agent/prompt-submit', this, prompt.content, prompt.source, signal, + () => Promise.resolve({ + kind: 'allow', + ...prompt.contexts.length === 0 ? {} : { additionalContexts: prompt.contexts }, + }), + ) + signal.throwIfAborted() + + this.session.append('turn/start', { + turn, + trigger: prompt === undefined ? { kind: 'retry' } : { kind: 'message', source: prompt.source }, + }) + this.turnOpen = true + signal.throwIfAborted() + + if (prompt !== undefined && decision?.kind === 'block') { + // The audit record stays turn-enclosed: a zero-step rejected turn. + this.session.append('prompt/blocked', { content: prompt.content, source: prompt.source, reason: decision.reason }) + reason = { kind: 'rejected', reason: decision.reason } + } else { + if (prompt !== undefined && decision?.kind === 'allow') { + const prepared = preparePromptMessage( + decision.content ?? prompt.content, + prompt.source, + decision.additionalContexts ?? [], + ) + this.session.append('user/message', prepared.data, { surfaceOp: 'append' }) + for (const context of prepared.separateContexts) { + this.outbox.push({ kind: 'context', context: this.accept(context) }) + } + } + while (true) { + step += 1 + const { owes, maxTokens } = await this.step(turn, step, signal) + if (maxTokens) reason = { kind: 'max-tokens' } + // The naive rule, data-driven: run another step while the model is + // owed a response. On a would-stop boundary, `agent/stopping` gives + // listeners one chance to object — by steering, not by voting — and + // the outbox is re-read: data decides, so listener order cannot. + if (owes || this.outbox.some(item => item.kind === 'steering')) continue + await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal) + signal.throwIfAborted() + if (!this.outbox.some(item => item.kind === 'steering')) break + } + } + } catch (error: unknown) { + ({ reason, idle } = this.settle(turn, step, error, signal)) + } finally { + if (this.turnAbort === controller) this.turnAbort = undefined + try { + this.closeTurn(turn, step, reason) + } catch (error: unknown) { + // A rejected boundary append (a pre-commit validation veto) must not + // kill the machine or leave `busy` stuck: report and move on — the + // idle tail below still runs and the next turn still opens. + const err = toError(error) + this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`) + emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err) + } + this.idle(turn, idle) + } + } + + /** + * One whole step: the `agent/step` seam, take the outbox, derive the + * history, one request, its tool calls — bracketed by the durable + * step/start / step/end pair. The naive core: whole history in, one + * assistant message out. + */ + private async step(turn: number, step: number, signal: AbortSignal): Promise<{ owes: boolean; maxTokens: boolean }> { + const { session } = this + + // The single between-steps seam: listeners inject, steer, or edit the log + // here; the request derives from the log after this settles. + await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal) + signal.throwIfAborted() + + // Take the outbox whole — same-boundary steering and context leave in + // this request together. + this.drainOutbox(turn) + + // Assemble the system prompt fresh each step (it may depend on log state). + const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) + signal.throwIfAborted() + const system = renderPrompt(assembly) + + // Snapshot the exact log prefix: the reconstruction boundary. Appends + // after this synchronous snapshot join the next request. + const boundaryMessages = session.deriveMessages() + + session.append('step/start', { turn, step }) + this.stepOpen = true + signal.throwIfAborted() + + const request = await this.buildRequest(turn, step, assembly.tools, system, boundaryMessages, signal) + + // --- Model call (streaming-first; raw chunks are the replay record) --- + const assembler = new BlockAssembler() + const chunkSeqs: number[] = [] + const stream = this.loopCtx.llm.stream(request) + try { + for await (const chunk of stream) { + signal.throwIfAborted() + const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) + chunkSeqs.push(chunkEvent.seq) + assembler.push(chunk) + } + } catch (error: unknown) { + // Normalize a final-adapter failure into the one model-error type; the + // foreign original stays on `cause` for the rendered chain. + const facts = llmFailureOf(stream, error) + if (facts !== undefined && error instanceof Error) throw llmError(facts, error) + throw error + } + signal.throwIfAborted() + + // Failure finish chunks take the same path as thrown stream errors. + const finish = assembler.finish + if (finish.kind === 'error' || finish.kind === 'aborted') throw llmError(finish.failure) + + // Truncated (max-tokens) output cannot owe tool calls. + const assembled = assembler.finish.kind === 'max-tokens' + ? withoutToolCalls(assembler.message()) + : assembler.message() + + session.append( + 'assistant/message', + { + turn, + step, + content: assembled.content, + provenance: { + provider: request.provider, + model: request.model, + ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, + }, + ...assembler.usage === undefined ? {} : { usage: assembler.usage }, }, - clearTurnCancellation: (cancellation) => { - /* v8 ignore else -- the driver clears only the exact owner returned by its latest install. */ - if (this.turnCancellation === cancellation) this.turnCancellation = undefined - }, - disposed: this.disposed, - isDisposed: () => this._status === 'disposed', - isPreRunCancelled: () => this.preRunCancelled, - clearPreRunCancel: () => { this.preRunCancelled = false }, - withToolBatch: run => this.withToolBatch(run), - // Pre-run cancellation settles queued-work waiters before publishing idle. - settleIdle: () => { this.settleIdleWaiters() }, + { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + ) + + // Dispatch may overlap; policy, durable results, and result context stay + // model-ordered. Tool-produced context rides the outbox like any other + // injection, so it lands after the batch's results — adjacency-safe. + const toolCalls = assembled.content.filter(block => block.type === 'tool-call') + let concluded = false + if (toolCalls.length > 0) { + ({ concluded } = await executeToolCalls( + this.loopCtx, turn, step, toolCalls, signal, + context => this.outbox.push({ kind: 'context', context: this.accept(context) }), + )) + } + + // Steering/context that arrived during streaming or tool execution lands + // inside the step (after the batch's results — adjacency-safe). + const steered = this.drainOutbox(turn) + session.append('step/end', { turn, step }) + this.stepOpen = false + // Owed: live tool calls none of which concluded the turn, or steering. + return { + owes: (toolCalls.length > 0 && !concluded) || steered, + maxTokens: finish.kind === 'max-tokens', + } + } + + /** + * Compose one frozen request: the `agent/request` config waterfall, the + * canonical logged header, then the header plus the boundary snapshot, + * byte-for-byte. + */ + private async buildRequest( + turn: number, + step: number, + tools: GenerateOptions['tools'] & object, + system: string, + boundaryMessages: Message[], + signal: AbortSignal, + ): Promise { + const { session } = this + + // Seed from the logged header when the log has one (the log is the + // truth, across resumes too), else from agent options; freeze so + // listeners must return a replacement. + const seedConfig: LlmCallConfig = deepFreeze(structuredClone( + session.requestHeader()?.config + ?? { provider: this.options.provider ?? '', model: this.options.model ?? '' })) + const config = await this.loopCtx.waterfall( + agentCarrier(this), 'agent/request', this, turn, step, signal, + () => Promise.resolve(seedConfig), + ) + signal.throwIfAborted() + if (!config.provider || !config.model) { + throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`) + } + + const header = canonicalHeader({ + config, + ...system ? { system } : {}, + ...tools.length > 0 ? { tools } : {}, + }) + // Log the header the request will ACTUALLY use, only when it differs + // from the folded baseline — reconstruction folds the log, so an + // unchanged header needs no new snapshot. + const baseline = session.requestHeader() + if (baseline === undefined || !headerEquals(baseline, header)) { + session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'change' }) + } + + return markAgentLoopRequest(deepFreeze({ + provider: header.config.provider, + model: header.config.model, + messages: boundaryMessages, + ...header.system !== undefined ? { system: header.system } : {}, + ...header.tools !== undefined ? { tools: header.tools } : {}, + ...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {}, + ...header.config.maxTokens !== undefined ? { maxTokens: header.config.maxTokens } : {}, + ...header.config.stop !== undefined ? { stop: header.config.stop } : {}, + sessionId: session.id, + signal, })) } - /** - * Quiescent stop shared by pre-start rollback and live teardown. It marks the - * agent disposed synchronously, contains an unexpected loop rejection, and - * drains every idle-injection flush before resolving. - */ - private [stopDriver](): Promise | void { - if (this._status !== 'disposed') { - this._status = 'disposed' - this.resolveDisposed() - // Release whenIdle waiters BEFORE the (guarded) event emit — they are - // internal state that must settle even if a listener throws below. Each - // waiter chains `done`, so it resolves only once the loop actually exits. - this.settleIdleWaiters() - this.turnCancellation?.request(DISPOSED_INTERRUPT_REASON) - // An unpublished rollback has no public status lifecycle to announce. - // Once publication begins, disposed is part of the agent/status contract. - if (this.published) { - agentEvents(this.loopCtx, this).emit('agent/status', 'disposed') + /** Take the outbox whole into the log: committed from here. Returns whether steering was taken. */ + private drainOutbox(turn: number): boolean { + let steered = false + for (const item of this.outbox.splice(0)) { + if (item.kind === 'context') { + const { content, source, meta } = item.context + this.session.append('context/message', { + content, + source, + ...meta === undefined ? {} : { meta }, + }, { surfaceOp: 'append' }) + continue + } + steered = true + const prepared = preparePromptMessage(item.content, item.source, item.contexts) + this.session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) + for (const context of prepared.separateContexts) { + const { content, source, meta } = context + this.session.append('context/message', { + content, + source, + ...meta === undefined ? {} : { meta }, + }, { surfaceOp: 'append' }) } } - // Before runLoop starts there is normally nothing asynchronous to drain; - // keep publication rollback synchronous so create() cannot throw while its - // session/agent entries are still briefly live. A session-start listener - // may have called inject(), however, so preserve - // its durability checkpoint as a real quiescence boundary. - if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return - return this.drainDriver() + return steered } - /** Await the loop (when started) and every outstanding idle flush. */ - private async drainDriver(): Promise { - // An unexpected driver rejection must not skip registry/session/scope - // cleanup. The normal loop contains turn failures itself; allSettled is the - // final lifecycle backstop for anything outside those boundaries. - await Promise.allSettled([this.done]) - // Repeat because settled flushes retire in adjacent promise reactions; - // allSettled keeps reporting failures from skipping ownership teardown. - while (this.pendingIdleFlushes.size > 0) { - await Promise.allSettled([...this.pendingIdleFlushes]) + /** + * The single settlement funnel: classify one turn failure (interruption + * beats error) into the durable turn/end reason and the live idle report. + */ + private settle(turn: number, step: number, error: unknown, signal: AbortSignal): { reason: TurnEndReason; idle: IdleReason } { + const interrupt = agentInterruptReasonOf(signal) + if (interrupt !== undefined) { + return { reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' }, idle: { kind: 'aborted' } } + } + if (error instanceof LlmError) { + emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) + // The durable record renders the full cause chain: turn/end is the one + // durable trace of the failure, so a wrapper message alone would lose + // the transport detail the log exists to keep. + const rendered = errorChain(error) + return { + reason: { kind: 'error', step, failure: { ...error.failure, ...rendered === '' ? {} : { message: rendered } } }, + idle: { kind: 'error', error, failure: error.failure }, + } + } + const err = toError(error) + emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err) + return { + reason: { kind: 'error', step, message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }, + idle: { kind: 'error', error: err }, } } + + /** Close the owed boundaries, exactly once per turn. Durability is persistence's own eager concern. */ + private closeTurn(turn: number, step: number, reason: TurnEndReason): void { + if (this.stepOpen) { + this.stepOpen = false + this.session.append('step/end', { turn, step }) + } + if (this.turnOpen) { + this.turnOpen = false + this.session.append('turn/end', { turn, reason }) + } + } + + /** + * The turn boundary's tail (naive `idle()`): the machine is no longer busy, + * the idle report fires (a listener may synchronously `retry()` or `send()` + * here — both are legal now), leftover steering becomes queued prompts, and + * the next run opens while the queue is non-empty; otherwise the machine + * parks. + */ + private idle(turn: number, idle: IdleReason): void { + this.busy = false + // Status mirrors busy faithfully: chained turns pulse idle → running, + // which is honest — a listener really can act in this window. + emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle') + // Requeue BEFORE the idle report so earlier-arrived steering keeps its + // FIFO position ahead of anything a listener send()s synchronously. + for (const item of this.outbox.splice(0)) { + if (item.kind === 'steering') this.queued.push({ + content: item.content, + source: item.source, + contexts: item.contexts, + }) + else this.outbox.push(item) + } + emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle) + // A synchronous idle listener may retry()/send(), flipping busy back. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (this.busy) return // a listener already re-opened + if (this.queued.length > 0) this.kick() + } } diff --git a/packages/core/agent-loop/src/cancellation.ts b/packages/core/agent-loop/src/cancellation.ts deleted file mode 100644 index c3f5430a20..0000000000 --- a/packages/core/agent-loop/src/cancellation.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** Turn-scoped cancellation ownership for the concrete AgentLoop driver. @module dsh-agent-loop/cancellation */ - -import type { AgentCancelCause } from '@deepseek-ai/dsh-agent' - -/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */ -export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const) - -/** - * Owns the single controller shared by every asynchronous boundary of one turn. - * The first request wins because a later caller must not rewrite the cause - * observed by earlier listeners. - */ -export class TurnCancellation { - readonly #controller = new AbortController() - - /** The explicit signal passed through this turn's execution boundaries. */ - get signal(): AbortSignal { - return this.#controller.signal - } - - /** - * Abort the turn once. - * @param reason - a typed caller cause or lifecycle disposal marker. - * @returns whether this request established the signal reason. - */ - request(reason: AgentCancelCause | typeof DISPOSED_INTERRUPT_REASON): boolean { - if (this.signal.aborted) return false - this.#controller.abort(Object.freeze({ kind: reason.kind })) - return true - } -} diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts deleted file mode 100644 index 3cb82944e4..0000000000 --- a/packages/core/agent-loop/src/inbox.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Per-agent message inbox: queued and steering FIFOs. Purely an in-memory - * mechanism of the loop driver — the public surface is `Agent.send()` and its - * fixed-preset aliases. - * - * @module dsh-agent-loop/inbox - */ - -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { JsonValue } from '@deepseek-ai/dsh-session' -import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent' - -/** One message waiting in an agent's inbox; `id` is the value `send` returned. */ -export interface InboxMessage { - id: AgentMessageId - content: ContentBlock[] - source: MessageSource - contexts: HookContext[] - /** Whether the item is marked to wake the driver or force a continuation. */ - wakeup: boolean - /** Opaque durable JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue -} - -/** - * Build the `agent/inbox/*` event payload for one inbox item. - * @param message - the accepted inbox record. - * @param steering - whether the item is in the steering FIFO (`next-step`). - * @returns the live-event message for enqueue/dequeue/discard. - */ -export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage { - return { id: message.id, content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } -} - -/** - * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO - * (drained between steps of a running turn). Purely an in-memory mechanism of - * the loop — the public surface is `Agent.send()` and its aliases. - */ -export class Inbox { - private queuedMessages: InboxMessage[] = [] - private steeringMessages: InboxMessage[] = [] - private wakeup: (() => void) | undefined - - /** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */ - get hasQueued(): boolean { - return this.queuedMessages.length > 0 - } - - /** - * True while a queued message wants to wake the driver — the "should the loop - * run" signal read by the idle wait's fast path, the loop's idle-publish - * check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this - * false, so the driver stays parked until a waking send (or a waking item - * ahead of it in FIFO order) drives the loop; the quiet item then rides along. - */ - get hasWakingQueued(): boolean { - return this.queuedMessages.some(message => message.wakeup) - } - - /** True while steering messages are pending — read by cancellation and the loop's stop-override check. */ - get hasSteering(): boolean { - return this.steeringMessages.length > 0 - } - - /** - * Add a message to the queued FIFO, waking a parked {@link waitForQueued} - * unless the item opted out. A non-waking item still runs once any woken - * item or later wakeup drives the parked loop. - * @param message - the message to queue for the next turn start. - * @param wake - whether to wake a parked idle wait (default true). - */ - enqueue(message: InboxMessage, wake = true): void { - this.queuedMessages.push(message) - if (wake) this.wakeup?.() - } - - /** - * Add a message to the steering FIFO. Deliberately no wakeup: steering is - * drained between steps of a running turn, never by the idle wait — - * `Agent.steer()` on an idle agent falls back to a woken follow-up instead. - * @param message - the message to inject between steps of the running turn. - */ - steer(message: InboxMessage): void { - this.steeringMessages.push(message) - } - - /** - * Remove the oldest queued message for one turn start. - * @returns the oldest message, or `undefined` when the queued FIFO is empty. - */ - dequeueQueued(): InboxMessage | undefined { - return this.queuedMessages.shift() - } - - /** - * Drain all steering messages (between steps). - * @returns the drained messages in arrival order; the steering FIFO is left empty. - */ - drainSteering(): InboxMessage[] { - return this.steeringMessages.splice(0) - } - - /** - * Snapshot the pending items (queued then steering, FIFO order) without - * removing them — the discard notification's payload source. - * @returns the pending items paired with whether each is steering. - */ - pending(): { message: InboxMessage; steering: boolean }[] { - return [ - ...this.queuedMessages.map(message => ({ message, steering: false })), - ...this.steeringMessages.map(message => ({ message, steering: true })), - ] - } - - /** - * Discard all pending messages (queued + steering) without delivering them — - * used by `cancel()`, which drops un-started work rather than draining it into - * a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away. - */ - clear(): void { - this.queuedMessages.length = 0 - this.steeringMessages.length = 0 - } - - /** - * Wait until a queued message arrives or `cancel` resolves. - * @param cancel - a promise whose resolution abandons the wait without a - * message (the driver loop passes the agent's disposed promise so a parked - * loop can exit). - */ - waitForQueued(cancel: Promise): Promise { - if (this.hasWakingQueued) return Promise.resolve() - const { promise, resolve } = Promise.withResolvers() - this.wakeup = resolve - void cancel.then(resolve) - return promise.finally(() => { - if (this.wakeup === resolve) this.wakeup = undefined - }) - } -} diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index bdaa3f2401..79659bbec7 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -8,9 +8,7 @@ import { Context, FiberState, Service } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' -import { createScope } from '@deepseek-ai/dsh-scope' -import type { Scope } from '@deepseek-ai/dsh-scope' -import { agentEvents } from '@deepseek-ai/dsh-agent' +import { emitAgentEvent } from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory, @@ -26,12 +24,7 @@ import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { - bindReactLoopAgentContext, - prepareReactLoopAgent, - ReactLoopAgent, -} from './agent.ts' -import type { PreparedReactLoopAgent } from './agent.ts' +import { DISPOSED_INTERRUPT_REASON, ReactLoopAgent } from './agent.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' /** Fiber states that cannot own or serve a new lifecycle. */ @@ -41,31 +34,43 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) -/** Factory-level ownership of every preparing or live transaction. */ +/** Factory-level ownership: live agent teardowns plus config startup work. */ class FactoryOwnership { private accepting = true + private readonly teardown = new AbortController() private readonly inactive = Promise.withResolvers() - private transactions = new Set() + private readonly liveAgents = new Set<() => Promise>() private startupTasks = new Set>() constructor(private readonly fiber: Context['fiber']) {} + /** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */ + get signal(): AbortSignal { + return this.teardown.signal + } + isActive(): boolean { return this.accepting && !INACTIVE_STATES.has(this.fiber.state) } - track(transaction: AgentCreationTransaction): () => void { - this.transactions.add(transaction) - return () => { this.transactions.delete(transaction) } + /** Track one live agent's shared teardown until it has run. */ + track(dispose: () => Promise): () => void { + this.liveAgents.add(dispose) + return () => { this.liveAgents.delete(dispose) } } - /** Join config startup work that begins before an agent transaction exists. */ + /** Join config startup work that begins before an agent exists. */ trackStartup(task: Promise): void { this.startupTasks.add(task) const forget = () => { this.startupTasks.delete(task) } void task.then(forget, forget) } + /** Join one public create/resume continuation; factory dispose awaits its settlement. */ + trackWrapper(task: Promise): void { + this.trackStartup(task.then(() => undefined, () => undefined)) + } + /** Resolve `task`, or stop waiting when factory teardown begins. */ async waitWhileActive(task: Promise): Promise { await Promise.race([task, this.inactive.promise]) @@ -73,19 +78,29 @@ class FactoryOwnership { async dispose(): Promise { this.accepting = false + this.teardown.abort(new Error('agent loop is not active')) this.inactive.resolve() - const reason = new Error('agent loop is not active') await Promise.all([ - ...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)), + ...[...this.liveAgents].map(dispose => dispose()), ...this.startupTasks, ]) } } -/** Build the public cancellation error while preserving a caller-supplied cause. */ -function signalAbortError(id: SessionId, signal: AbortSignal): Error { - if (signal.reason instanceof Error) return signal.reason - return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) +/** Await `operation`, or throw the signal's reason as soon as it aborts. */ +async function raceAbort(operation: PromiseLike | T, signal: AbortSignal, id: SessionId): Promise { + const toAbortError = (): Error => signal.reason instanceof Error + ? signal.reason + : new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) + if (signal.aborted) throw toAbortError() + const aborted = Promise.withResolvers() + const listener = (): void => { aborted.reject(toAbortError()) } + signal.addEventListener('abort', listener, { once: true }) + try { + return await Promise.race([Promise.resolve(operation), aborted.promise]) + } finally { + signal.removeEventListener('abort', listener) + } } /** Resolve the deployment-wide scheduler cap at the owning config boundary. */ @@ -97,243 +112,15 @@ function resolveMaxParallelToolCalls(value: number | undefined): number { return maxParallelToolCalls } -/** - * Caller-owned create/resume transaction through rollback-covered publication - * and quiescent teardown. Resources remain private until the final registry - * entry arbitrates identity. - */ -class AgentCreationTransaction { - private active = true - private failure: Error | undefined - private readonly deactivation = Promise.withResolvers() - private readonly publication = Promise.withResolvers() - private readonly torndown = Promise.withResolvers() - private readonly wrapperCompletion = Promise.withResolvers() - private preparing: Promise | undefined - private driver: PreparedReactLoopAgent | undefined - private scope: Scope | undefined - private session: Session | undefined - private lifecycleDispose: (() => Promise | void) | undefined - private detachSession: (() => void) | undefined - private detachAgent: (() => void) | undefined - private publishing = false - private cleanupTask: Promise | undefined - private ownerFollowing = true - private readonly ownerDispose: () => Promise | void - private readonly untrackFactory: () => void - private readonly abortListener: (() => void) | undefined - readonly ownerAgent: Context['agent'] - readonly ownerFiber: Context['fiber'] - - constructor( - private readonly loopCtx: Context, - private readonly ownerCtx: Context, - private readonly ownership: FactoryOwnership, - readonly id: SessionId, - signal?: AbortSignal, - ) { - ownerCtx.fiber.assertActive() - this.ownerAgent = ownerCtx.agent - this.ownerFiber = ownerCtx.fiber - if (!ownership.isActive()) throw new Error('agent loop is not active') - this.ownerDispose = ownerCtx.effect(() => () => { - if (!this.ownerFollowing) return - return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) - }, `agentLoop.owner(${id})`) - this.untrackFactory = ownership.track(this) - if (signal === undefined) { - this.abortListener = undefined - } else { - this.abortListener = () => { - /* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */ - void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => { - this.loopCtx.logger.error(error) - }) - } - signal.addEventListener('abort', this.abortListener, { once: true }) - if (signal.aborted) this.deactivate(signalAbortError(id, signal)) - } - this.signal = signal - } - - private readonly signal: AbortSignal | undefined - - /** Whether caller, provider, and optional parent-agent ownership remain live. */ - isActive(): boolean { - return this.active - && this.ownership.isActive() - && this.ownerFiber.uid !== null - && !INACTIVE_STATES.has(this.ownerFiber.state) - && this.ownerAgent?.status !== 'disposed' - } - - /** Fail synchronously at every real lifecycle boundary after deactivation. */ - assertActive(): void { - if (this.isActive()) return - if (!this.ownership.isActive()) throw new Error('agent loop is not active') - throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`) - } - - /** Race an external async operation against structural/signal deactivation. */ - async waitFor(operation: PromiseLike | T): Promise { - this.assertActive() - return await Promise.race([ - Promise.resolve(operation), - this.deactivation.promise.then(() => { - /* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */ - throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`) - }), - ]) - } - - /** Construct the driver and scope, then install their complete ordered lifecycle. */ - prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent { - this.assertActive() - const gate = Promise.withResolvers() - this.preparing = gate.promise - try { - this.session = session - const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls) - this.driver = driver - const agent = driver.agent - const scope = createScope(this.loopCtx, agent) - this.scope = scope - bindReactLoopAgentContext(agent, scope.ctx.extend({ agent })) - this.installLifecycle(scope, driver) - this.assertActive() - return agent - } catch (error: unknown) { - if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) { - throw this.failure ?? this.disposalReason() - } - throw error - } finally { - gate.resolve() - this.preparing = undefined - } - } - - /** Register the exact scope disposer inside the ordered transaction effect. */ - private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void { - this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) { - // First yielded, disposed last. - yield () => { this.finish() } - yield scope.rawDispose - yield () => { - this.detachSession?.() - this.detachSession = undefined - } - yield () => { - this.detachAgent?.() - this.detachAgent = undefined - } - // Last yielded, disposed first. - yield () => { - this.deactivate(this.disposalReason()) - if (this.publishing) { - return this.publication.promise.then(() => driver.dispose()) - } - return driver.dispose() - } - }.bind(this), `agentLoop.lifecycle(${this.id})`) - } - - /** Publish the exact prepared objects and start the driver. */ - publish(source: SessionStartSource): AgentHandle { - this.assertActive() - const driver = this.driver - /* v8 ignore next -- publish() is private and every caller invokes prepare() first. */ - if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`) - const agent = driver.agent - const session = this.session - /* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */ - if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`) - this.publishing = true - try { - this.detachSession = agent.ctx.sessions.enter(session) - this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent) - - agent.ctx.sessions.announce(session) - this.assertActive() - this.loopCtx.agents.announce(agent) - this.assertActive() - - driver.markPublished() - agentEvents(this.loopCtx, agent).emit('agent/session-start', source) - this.assertActive() - driver.startDriver() - return { agent, dispose: () => this.dispose() } - } finally { - this.publishing = false - this.publication.resolve() - } - } - - /** Mark the transaction inactive exactly once and wake load/setup races. */ - private deactivate(reason: Error): void { - if (!this.active) return - this.active = false - this.failure = reason - this.deactivation.resolve() - } - - /** Choose the structural cause when an owner/factory effect starts teardown first. */ - private disposalReason(): Error { - if (this.failure !== undefined) return this.failure - if (!this.ownership.isActive()) return new Error('agent loop is not active') - if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') { - return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`) - } - return new Error(`agent "${this.id}" lifecycle disposed`) - } - - /** Complete ownership bookkeeping after every resource reached quiescence. */ - private finish(): void { - this.untrackFactory() - this.ownerFollowing = false - void this.ownerDispose() - this.torndown.resolve() - } - - /** - * Deactivate and quiesce this transaction. The promise is memoized because - * Cordis effect disposers are single-shot while handles promise shared - * quiescence to every racing owner. - */ - dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise { - this.deactivate(reason) - return (this.cleanupTask ??= (async () => { - if (this.preparing !== undefined) await this.preparing - if (this.lifecycleDispose !== undefined) { - await this.lifecycleDispose() - await this.torndown.promise - return - } - try { - await this.driver?.dispose() - } finally { - try { - await this.scope?.dispose() - } finally { - this.finish() - } - } - })()) - } - - /** Mark the public create/resume continuation settled and detach its creation-only signal. */ - finishWrapper(): void { - if (this.signal !== undefined && this.abortListener !== undefined) { - this.signal.removeEventListener('abort', this.abortListener) - } - this.wrapperCompletion.resolve() - } - - /** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */ - async disposeForFactory(reason: Error): Promise { - await this.dispose(reason) - await this.wrapperCompletion.promise - } +/** Prepared-but-unpublished agent resources sharing one memoized teardown. */ +interface PreparedAgent { + agent: ReactLoopAgent + /** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */ + signal: AbortSignal + /** Enter registries, announce, notify session-start, and start the machine. */ + publish(source: SessionStartSource): AgentHandle + /** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */ + dispose(): Promise } declare module 'cordis' { @@ -376,6 +163,9 @@ export interface Config { })[] } +/** Agent-loop configuration after defaults and load-time validation. */ +type ResolvedConfig = Config & { maxParallelToolCalls: number } + /** Reject self-contained identity conflicts before any configured agent starts. */ function validateConfiguredAgents(agents: Config['agents']): void { const exactIdentities = new Map() @@ -409,18 +199,21 @@ export class AgentLoop extends Service implements AgentFactory { cwd: z.string(), resumeSessionId: z.string(), })).default([]), - }) as unknown as z + }) as z + /** Validated configuration owned by the agent-loop service. */ + readonly config: ResolvedConfig private readonly ownership: FactoryOwnership - /** Resolved concurrency cap for every driver created by this factory. */ - private readonly maxParallelToolCalls: number /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */ private readonly runtime: { ctx: Context } - constructor(ctx: Context, public config: Config) { + constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') - validateConfiguredAgents(config.agents) - this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls) + this.config = { + ...config, + maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls), + } + validateConfiguredAgents(this.config.agents) this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()') @@ -429,7 +222,7 @@ export class AgentLoop extends Service implements AgentFactory { ctx.systemPrompt.variable('model', context => context.agent?.options.model) ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) - for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) { + for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) { const meta = cwd === undefined ? {} : { cwd } if (resumeSessionId === undefined || resumeSessionId === '') { const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`) @@ -499,10 +292,11 @@ export class AgentLoop extends Service implements AgentFactory { this.create(sessionId, agentOptions, meta) } - /** Wait for an already-disposed same-id lifecycle to finish registry teardown. */ + /** Wait for a draining same-id lifecycle to finish registry teardown. */ private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise { - const current = ownerCtx.agents.get(sessionId) - if (current?.status !== 'disposed') return + // Only an id still occupying a registry needs waiting for; a live healthy + // occupant is a collision the create/resume below will surface itself. + if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) return const released = Promise.withResolvers() const checkReleased = (): void => { @@ -521,6 +315,118 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** + * Construct the driver, scope, and one memoized reverse teardown for a new + * agent. The teardown is registered with the factory and the owner fiber + * BEFORE publication, so a mid-setup unload rolls everything back; `signal` + * fuses caller cancellation with lifecycle teardown for setup awaits. + */ + private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent { + ownerCtx.fiber.assertActive() + if (!this.ownership.isActive()) throw new Error('agent loop is not active') + if (callerSignal?.aborted) { + throw callerSignal.reason instanceof Error + ? callerSignal.reason + : new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason }) + } + const loopCtx = this.runtime.ctx + + // Deactivation fuses three owners, each with its own reason: the caller's + // cancellation signal, the owner fiber's unload, and factory teardown. + // It is registered BEFORE any resource exists, over mutable slots, so an + // unload arriving while the scope is still minting finds a working + // disposer instead of a leak. + const abort = new AbortController() + const onCallerAbort = (): void => { + abort.abort(callerSignal?.reason instanceof Error + ? callerSignal.reason + : new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason })) + } + const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) } + callerSignal?.addEventListener('abort', onCallerAbort, { once: true }) + this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true }) + + let machine: ReactLoopAgent | undefined + let detachSession: (() => void) | undefined + let detachAgent: (() => void) | undefined + let disposing: Promise | undefined + // Reverse teardown, memoized so every racing owner awaits one quiescence: + // stop the machine, leave the registries, unwind the scope, release + // bookkeeping. + const dispose = (): Promise => (disposing ??= (async () => { + abort.abort(new Error(`agent "${id}" lifecycle disposed`)) + callerSignal?.removeEventListener('abort', onCallerAbort) + this.ownership.signal.removeEventListener('abort', onFactoryTeardown) + try { + // Disposal IS a disposed-cause cancel followed by quiescence. New work + // sent after this point is the sender's bug — the registries are about + // to drop the agent, so nothing should still hold it. + if (machine !== undefined) { + machine.cancel(DISPOSED_INTERRUPT_REASON) + await Promise.allSettled([machine.done]) + await machine.scope.dispose() + } + } finally { + try { + detachAgent?.() + detachSession?.() + } finally { + untrack() + void unfollowOwner() + } + } + })()) + const untrack = this.ownership.track(dispose) + let unfollowOwner: () => Promise | void + try { + unfollowOwner = ownerCtx.effect(() => () => { + // Owner disposal starts teardown but must not await its own disposer. + if (disposing === undefined) { + abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) + void dispose() + } + }, `agentLoop.lifecycle(${id})`) + } catch (error: unknown) { + untrack() + callerSignal?.removeEventListener('abort', onCallerAbort) + this.ownership.signal.removeEventListener('abort', onFactoryTeardown) + throw error + } + + const assertLive = (): void => { + if (!abort.signal.aborted) return + throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason)) + } + try { + const agent = machine = new ReactLoopAgent(loopCtx, id, options, session) + assertLive() + + return { + agent, + signal: abort.signal, + publish: (source) => { + assertLive() + detachSession = agent.ctx.sessions.enter(session) + detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent) + agent.ctx.sessions.announce(session) + assertLive() + loopCtx.agents.announce(agent) + assertLive() + // A synchronous announce/session-start listener may have started + // teardown; the machine is already live (send() works from the + // session-start seam), so only the liveness recheck is owed. + emitAgentEvent(loopCtx, agent, 'agent/session-start', source) + assertLive() + return { agent, dispose } + }, + dispose, + } + } catch (error: unknown) { + void dispose() + throw error + } + } + /** * Create an agent and session under one caller-supplied identity, owned by * the accessing fiber. Constructor-driven config calls mint a fresh combined @@ -531,51 +437,39 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published running agent. */ create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent { - const loopCtx = this.runtime.ctx - const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) + const session = this.runtime.ctx.sessions.prepare(id, { meta }) + const prepared = this.prepare(this.ctx, id, options, session) try { - const session = loopCtx.sessions.prepare(id, { meta }) - const agent = transaction.prepare(options, session, this.maxParallelToolCalls) - transaction.publish('startup') - return agent + return prepared.publish('startup').agent } catch (error: unknown) { - void transaction.dispose(error instanceof Error ? error : new Error(String(error))) + void prepared.dispose() throw error - } finally { - transaction.finishWrapper() } } /** * Create an owned agent on a caller-supplied session id. - * @param ownerCtx - caller context that structurally owns the transaction. + * @param ownerCtx - caller context that structurally owns the lifecycle. * @param options - identities, session seed/metadata, loop options, setup, and cancellation. * @returns the published handle. */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { - const agentOptions = options.agentOptions ?? {} - const transaction = new AgentCreationTransaction( - this.runtime.ctx, - ownerCtx, - this.ownership, - options.sessionId, - options.signal, - ) - try { - const session = this.runtime.ctx.sessions.prepare(options.sessionId, { - ...options.seed === undefined ? {} : { seed: options.seed }, - ...options.meta === undefined ? {} : { meta: options.meta }, - }) - const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) - await transaction.waitFor(options.setup?.(agent.ctx)) - transaction.assertActive() - return transaction.publish('startup') - } catch (error: unknown) { - await transaction.dispose(error instanceof Error ? error : new Error(String(error))) - throw error - } finally { - transaction.finishWrapper() - } + const session = this.runtime.ctx.sessions.prepare(options.sessionId, { + ...options.seed === undefined ? {} : { seed: options.seed }, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal) + const published = (async () => { + try { + await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId) + return prepared.publish('startup') + } catch (error: unknown) { + await prepared.dispose() + throw error + } + })() + this.ownership.trackWrapper(published) + return published } /** @@ -593,36 +487,38 @@ export class AgentLoop extends Service implements AgentFactory { } /** Resume through an explicit persistence handle used by the deferred config path. */ - private async resumeWith( + private resumeWith( ownerCtx: Context, persistence: SessionPersistence, options: ResumeAgentOptions, ): Promise { - const agentOptions = options.agentOptions ?? {} - const transaction = new AgentCreationTransaction( - this.runtime.ctx, - ownerCtx, - this.ownership, - options.resumeSessionId, - options.signal, - ) - try { - const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId)) - transaction.assertActive() - const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, { + const id = options.resumeSessionId + const published = (async () => { + // The load may outlive its owner: race it against caller cancellation, + // owner-fiber unload, and factory teardown so a never-settling backend + // cannot pin the identity. + const fused = AbortSignal.any([ + ...options.signal === undefined ? [] : [options.signal], + this.ownership.signal, + ]) + const loaded = await raceAbort(persistence.load(id), fused, id) + ownerCtx.fiber.assertActive() + if (!this.ownership.isActive()) throw new Error('agent loop is not active') + const session = this.runtime.ctx.sessions.prepare(id, { seed: loaded.events, meta: loaded.meta, }) - const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) - await transaction.waitFor(options.setup?.(agent.ctx)) - transaction.assertActive() - return transaction.publish('resume') - } catch (error: unknown) { - await transaction.dispose(error instanceof Error ? error : new Error(String(error))) - throw error - } finally { - transaction.finishWrapper() - } + const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal) + try { + await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + return prepared.publish('resume') + } catch (error: unknown) { + await prepared.dispose() + throw error + } + })() + this.ownership.trackWrapper(published) + return published } } diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index 0b67850015..5d96efc70c 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -48,7 +48,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary)), ) - const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()] + const expected = rebuilt.deriveMessages() if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts deleted file mode 100644 index 4324deca8d..0000000000 --- a/packages/core/agent-loop/src/loop.ts +++ /dev/null @@ -1,825 +0,0 @@ -/** - * 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 .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md. - * @module dsh-agent-loop/loop - */ - -import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' -import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm' -import { isDeepStrictEqual } from 'node:util' -import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' -import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' -import { canonicalHeader } from '@deepseek-ai/dsh-session' -import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' -import { createTransmissionLog, recordRequestHeader } from './request-log.ts' -import type { TransmissionLog } from './request-log.ts' -import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tools' -import { executeToolCalls } from './tool-calls.ts' -import { agentMessage, type Inbox, type InboxMessage } from './inbox.ts' -import type { TurnCancellation } from './cancellation.ts' - -/** Normalize thrown values while preserving an existing error code. */ -function toError(error: unknown): RequestError { - return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) -} - -/** Distinguishes final model-request failures from failures in later step processing. */ -class TerminalModelRequestFailure extends Error { - constructor( - readonly requestError: RequestError, - readonly failure: LlmFailure, - ) { - super(failure.message, { cause: requestError }) - this.name = 'TerminalModelRequestFailure' - } -} - -/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */ -function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined { - switch (finish.kind) { - case 'error': - case 'aborted': { - const facts = finish.failure - const error = new LlmError(facts.message, facts.code, { - ...facts.status === undefined ? {} : { status: facts.status }, - ...facts.providerRetryAfterMs === undefined - ? {} - : { providerRetryAfterMs: facts.providerRetryAfterMs }, - ...facts.requestId === undefined ? {} : { requestId: facts.requestId }, - }) - return { error, failure: error.failure } - } - // stop / tool-calls / max-tokens / plugin-added kinds → not a failure. - default: - return undefined - } -} - -/** - * Build the `{ message, code? }` part of an error payload, omitting the - * `code` key entirely when absent (exactOptionalPropertyTypes-correct). - * The durable message renders the full cause chain: `turn/end` is the single - * durable record of an in-turn failure, so a wrapper message alone (e.g. - * `fetch failed`) would lose the diagnosis the session log exists to keep. - */ -function errorData(err: RequestError): { message: string; code?: string } { - return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} } -} - -/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */ -function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure { - const message = errorChain(err) - return { ...failure, message: message === '' ? failure.message : message } -} - -/** 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': - return { kind: 'max-tokens' } - // stop / tool-calls / plugin-added kinds → no turn-end contribution - // beyond the default `completed`. FinishReason is merge-extensible, so a - // default (not assertNever) handles unknown kinds as ordinary success. - default: - return undefined - } -} - -/** Internal control-flow sentinel; durable classification comes only from the turn signal. */ -const TURN_INTERRUPTED = new Error('turn interrupted') - -const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = { - type: 'text', - text: '\n\n## My request:\n', -} - -interface PreparedPromptMessage { - data: PromptMessageData - separateContexts: HookContext[] -} - -/** Bake declared prefix contexts into one reconstructable prompt message. */ -function preparePromptMessage( - content: ContentBlock[], - source: PromptMessageData['source'], - contexts: readonly HookContext[], -): PreparedPromptMessage { - const prefixContexts = contexts.filter(context => context.placement === 'prompt-prefix') - const separateContexts = contexts.filter(context => context.placement !== 'prompt-prefix') - if (prefixContexts.length === 0) return { data: { content, source }, separateContexts } - return { - data: { - content: [ - ...prefixContexts.flatMap(context => context.content), - PROMPT_PREFIX_REQUEST_DELIMITER, - ...content, - ], - source, - envelope: { - displayContent: content, - prefixContexts: prefixContexts.map(context => ({ - source: context.source, - ...context.meta === undefined ? {} : { meta: context.meta }, - })), - }, - }, - separateContexts, - } -} - -/** Stop at an explicit cooperative boundary without stringifying the runtime reason. */ -function interruptionCheckpoint(signal: AbortSignal): void { - if (signal.aborted) throw TURN_INTERRUPTED -} - -/** Classify a supported turn interruption, with lifecycle disposal taking precedence. */ -function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): TurnEndReason | undefined { - if (handle.isDisposed()) return { kind: 'disposed' } - const reason = agentInterruptReasonOf(signal) - if (reason === undefined) return undefined - switch (reason.kind) { - case 'user': - case 'parent': - return { kind: 'aborted' } - /* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above. */ - case 'disposed': - return { kind: 'disposed' } - /* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons. */ - default: - return assertNever(reason, 'AgentInterruptReason') - } -} - -/** 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 - /** Maximum parallel-safe calls allowed in one step. */ - readonly maxParallelToolCalls: number - setStatus(status: 'idle' | 'running'): void - /** Install a fresh active-turn owner before the running notification. */ - installTurnCancellation(): TurnCancellation - /** Clear only the exact owner whose turn reached its terminal event boundary. */ - clearTurnCancellation(cancellation: TurnCancellation): void - /** Resolves when the agent is disposed — unblocks the idle wait. */ - disposed: Promise - isDisposed(): boolean - /** Whether queued work was cancelled before an active turn owner existed. */ - isPreRunCancelled(): boolean - /** Clear the cause-less pre-run marker without affecting replacement work. */ - clearPreRunCancel(): void - /** Settle idle waiters before pre-running cancellation publishes idle. */ - settleIdle(): void - /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ - readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise -} - -/** - * Drive queued messages as independent durable turns until disposal. Plugin - * failures end the current turn without terminating the driver. The caller - * establishes the `ctx.agents.withInitiator()` boundary before entry; package-private - * orchestration recovers that exact Agent and captures its Session locally. - * @param ctx - the plugin context the loop reaches its initiating Agent, - * events (agent/…, session/flush), and services (systemPrompt, llm, tools) - * through. - * @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state. - * @throws when no initiating Agent is active. - */ -export async function runLoop(ctx: Context, handle: LoopHandle): Promise { - const agent = ctx.agents.requireInitiator() - // Per-instance prefix and request-header state; conversation history remains in the session log. - const transmission = createTransmissionLog() - - const { session } = agent - // Fused subject and scope carrier for every agent event below. - const events = agentEvents(ctx, agent) - - while (!handle.isDisposed()) { - // An idle listener can enqueue and cancel replacement work before the next - // wait is installed. Consume that empty marker before parking the driver. - // A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on - // hasWakingQueued, not hasQueued. - if (handle.isPreRunCancelled()) { - handle.clearPreRunCancel() - if (!handle.inbox.hasWakingQueued) { - handle.settleIdle() - handle.setStatus('idle') - continue - } - } - - await handle.inbox.waitForQueued(handle.disposed) - if (handle.isDisposed()) break - - // Cancellation between wake and `running` skips only the cancelled work; - // a replacement prompt still runs before the eventual idle transition. - if (handle.isPreRunCancelled()) { - handle.clearPreRunCancel() - if (!handle.inbox.hasWakingQueued) { - // Settle before publishing idle: the already-idle path has no status - // transition, while an idle listener can register waiters for new work. - handle.settleIdle() - handle.setStatus('idle') - continue - } - } - - let cancellation = handle.installTurnCancellation() - handle.setStatus('running') - if (handle.isDisposed()) { - handle.clearTurnCancellation(cancellation) - break - } - - // A synchronous `running` listener can cancel before `runTurn`; balance the - // status only when no waking replacement prompt was queued by that listener - // (a lone quiet item parks at idle rather than driving a turn). - if (cancellation.signal.aborted) { - handle.clearTurnCancellation(cancellation) - if (!handle.inbox.hasWakingQueued) { - handle.setStatus('idle') - continue - } - cancellation = handle.installTurnCancellation() - } - - // 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, handle, turn, transmission, cancellation) - } catch (error: unknown) { - // 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: ${errorChain(err)}`) - try { - events.emit('agent/error', turn, 0, err) - } catch { /* contained: a throwing agent/error listener must not kill the driver */ } - } finally { - handle.clearTurnCancellation(cancellation) - } - - // Late steering (arriving after runTurn returns, e.g. during the post-turn - // flush) becomes queued input — unless terminal policy stopped the turn, in - // which case it is dropped and must publish a discard so its enqueue is - // still matched (the invariant only catches a NEGATIVE count, not a leak). - const lateSteering = handle.inbox.drainSteering() - if (terminalStopped) { - if (lateSteering.length > 0) { - events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true))) - } - } else { - for (const message of lateSteering) handle.inbox.enqueue(message) - } - - // Park at idle unless a waking item still wants the model to run; a lone - // quiet (`wakeup:false`) item stays queued but does not keep the loop busy. - if (!handle.inbox.hasWakingQueued) handle.setStatus('idle') - } -} - -async function runTurn( - ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog, - cancellation: TurnCancellation, -): Promise { - const agent = ctx.agents.requireInitiator() - const { session } = agent - const { signal } = cancellation - const drainSteering = (): boolean => { - const messages = handle.inbox.drainSteering() - for (const message of messages) { - events.emit('agent/inbox/dequeue', agentMessage(message, true)) - const prepared = preparePromptMessage(message.content, message.source, message.contexts) - session.append('steering/message', { - turn, ...prepared.data, - ...message.meta === undefined ? {} : { meta: message.meta }, - }, { surfaceOp: 'append' }) - for (const context of prepared.separateContexts) { - session.append('user/message', { - content: context.content, - source: context.source, - ...context.meta === undefined ? {} : { meta: context.meta }, - }, { surfaceOp: 'append' }) - } - } - return messages.length > 0 - } - - // Claim one queued message before opening its turn, but append it only after `turn/start`. - const message = handle.inbox.dequeueQueued() - /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ - if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') - events.emit('agent/inbox/dequeue', agentMessage(message, false)) - const trigger: TurnTrigger = { kind: 'message', source: message.source } - - let reason: TurnEndReason = { kind: 'completed' } - let step = 0 - let requestFailureHistory: readonly LlmFailure[] = Object.freeze([]) - let stepOpen = false - let errorReported = false - let terminalStopped = false - - // 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 the durable turn failure once and contain the live error notification. - const failTurn = (err: RequestError, failure?: LlmFailure): void => { - if (errorReported) return - errorReported = true - reason = failure === undefined - ? { kind: 'error', step, ...errorData(err) } - : { kind: 'error', step, failure: durableFailure(err, failure) } - try { - events.emit('agent/error', turn, step, err) - } catch { - // contained: the error is already captured on `reason`; a throwing - // agent/error listener must not prevent the turn from closing. - } - } - - // Retire cancellation authority before publishing the terminal event. The - // following durability flush is quiescent turn work, but no longer part of - // the cancellable turn lifetime. - const closeTurn = (): void => { - handle.clearTurnCancellation(cancellation) - session.append('turn/end', { turn, reason }) - } - - try { - // --- Turn boundary. Once turn/start is appended, a turn/end is owed no - // matter what throws below; the catch + closeTurn guarantee it. A pre-commit - // veto leaves no turn/start in the log and therefore owes no turn/end. - session.append('turn/start', { turn, trigger }) - interruptionCheckpoint(signal) - // The claimed 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. - const promptDecision = await events.waterfall( - 'agent/prompt-submit', message.content, message.source, signal, - () => Promise.resolve({ - kind: 'allow', - ...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts }, - }), - ) - interruptionCheckpoint(signal) - if (promptDecision.kind === 'block') { - session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason }) - reason = { kind: 'rejected', reason: promptDecision.reason } - } else { - // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. - const content = promptDecision.content ?? message.content - const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? []) - session.append('user/message', { - ...prepared.data, - ...message.meta === undefined ? {} : { meta: message.meta }, - }, { surfaceOp: 'append' }) - // Separate contexts still enter THIS turn through inject(). Prefix - // contexts are already baked into the user/message with their durable - // display envelope, so appending them again would duplicate model input. - for (const context of prepared.separateContexts) { - agent.inject(context.content, { - source: context.source, - ...context.meta !== undefined ? { meta: context.meta } : {}, - }) - } - } - - while (true) { - // A blocked prompt closes its zero-step turn as rejected. - if (promptDecision.kind === 'block') break - step += 1 - - // Steering from the previous round's continuation listeners joins before - // the request. - drainSteering() - - // Assemble once before pre-step so listener work and the request share one prompt value. - const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal)) - interruptionCheckpoint(signal) - const fullSystemPrompt = renderPrompt(assembly) - - // Compose the request-only prefix once per loop instance before the first - // request boundary. 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( - 'agent/session-prefix', emptyPrefix, signal, - () => Promise.resolve(emptyPrefix), - ) - // Never cache an interrupted composition; the next turn recomposes it. - interruptionCheckpoint(signal) - transmission.sessionPrefix = deepFreeze(structuredClone(composed)) - } - - // Await surface mutations outside the step before snapshotting history. - await events.serial('agent/pre-step', turn, step, signal) - interruptionCheckpoint(signal) - - // 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 }) - // Only a committed step/start creates a balancing obligation. A - // pre-commit veto throws before this assignment; post-commit observers - // are contained inside Session.append(). - stepOpen = true - - // A synchronous step/start observer can cancel after the step opened. - interruptionCheckpoint(signal) - - let stepOutcome: - | { hadToolCalls: boolean; finish: FinishReason } - | { requestError: RequestError; failure: LlmFailure } - | { error: RequestError } - try { - stepOutcome = await runStep( - ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal) - } catch (error: unknown) { - if (error instanceof TerminalModelRequestFailure) { - stepOutcome = { requestError: error.requestError, failure: error.failure } - } else { - stepOutcome = { error: toError(error) } - } - } - - if ('requestError' in stepOutcome) { - // Recovery observes a balanced failed step and the original provider - // error while the failed step's signal remains the active owner. - closeStep() - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted !== undefined) { - reason = interrupted - break - } - - const defaultDecision: RequestErrorDecision = { action: 'fail' } - let recoveryDecision: RequestErrorDecision = defaultDecision - try { - recoveryDecision = await events.waterfall( - 'agent/request-error', turn, step, stepOutcome.requestError, - stepOutcome.failure, requestFailureHistory, signal, - () => Promise.resolve(defaultDecision), - ) - } catch (recoveryError: unknown) { - ctx.logger.warn( - `agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`, - ) - } - // Cancellation and disposal always win over either a recovery decision - // or a recovery-listener failure. - const recoveryInterrupted = interruptionTurnEndReason(handle, signal) - if (recoveryInterrupted !== undefined) { - reason = recoveryInterrupted - break - } - switch (recoveryDecision.action) { - case 'retry': - requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure]) - continue - case 'fail': - failTurn(stepOutcome.requestError, stepOutcome.failure) - break - /* v8 ignore next -- closed-union exhaustiveness guard */ - default: - assertNever(recoveryDecision, 'agent request-error decision') - } - break - } - - if ('error' in stepOutcome) { - // Steering that arrived during the failed step stays in the inbox — - // runLoop re-enqueues it as a queued message, so an abort-then-steer - // starts a fresh turn instead of being silently consumed. - closeStep() - const { error } = stepOutcome - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(error) - else reason = interrupted - break - } - - requestFailureHistory = Object.freeze([]) - - // Preserve max-token completion unless a later disposal, abort, or error wins. - const stepReason = stepFinishReason(stepOutcome.finish) - if (stepReason) reason = stepReason - - // Steering that arrived during streaming/tool execution. - const steered = drainSteering() - - try { - await events.serial('agent/post-step', turn, step, signal) - } catch (error: unknown) { - stepOutcome = { error: toError(error) } - } - - if ('error' in stepOutcome) { - closeStep() - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(stepOutcome.error) - else reason = interrupted - break - } - - const postStepInterrupted = interruptionTurnEndReason(handle, signal) - if (postStepInterrupted !== undefined) { - reason = postStepInterrupted - closeStep() - break - } - - closeStep() - - const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' } - let decision: ContinuationDecision - try { - decision = await events.waterfall( - 'agent/turn-continuation', turn, defaultDecision, signal, - () => Promise.resolve(defaultDecision), - ) - interruptionCheckpoint(signal) - } catch (error: unknown) { - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(toError(error)) - else reason = interrupted - break - } - - // A continuation reason becomes next-step steering. Publish the same - // enqueue event a public steer would, so the inbox ledger stays balanced - // (every FIFO entry has a matching enqueue before its dequeue/discard). - if (decision.action === 'continue' && decision.reason) { - // Detach and freeze the listener-owned reason like a public steer, so an - // enqueue listener or the producer cannot mutate the durable/model-visible - // steering message before it drains. - const item: InboxMessage = deepFreeze({ - id: AgentMessageId(randomUUID()), - content: structuredClone(decision.reason.content), - source: structuredClone(decision.reason.source), - contexts: [], wakeup: true, - }) - handle.inbox.steer(item) - events.emit('agent/inbox/enqueue', agentMessage(item, true)) - } - let shouldContinue = decision.action === 'continue' - - // Pending steering overrides an ordinary stop. - if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true - - // Terminal policy is monotonic and runs after ordinary continuation folding. - let terminalStop = false - try { - const stop = await events.serial('agent/turn-stop', turn, signal) - interruptionCheckpoint(signal) - terminalStop = stop !== undefined - } catch (error: unknown) { - // A broken terminal policy is an ordinary continuation failure: fail - // this turn closed while leaving the driver alive for later turns. - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(toError(error)) - else reason = interrupted - break - } - if (terminalStop) { - terminalStopped = true - // Terminal stop discards steering but preserves ordinary queued prompts. - // Publish a discard for every dropped steering item so the enqueue ⇒ - // dequeue-or-discard ledger stays balanced (the outstanding-count - // invariant and correlation consumers must not be left with dangling ids). - const dropped = handle.inbox.drainSteering() - if (dropped.length > 0) { - events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true))) - } - shouldContinue = false - } - - if (!shouldContinue) break - } - - // Normal / inline-error loop exit: close the turn. - closeTurn() - } catch (error: unknown) { - // 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() - const interrupted = interruptionTurnEndReason(handle, signal) - if (interrupted === undefined) failTurn(toError(error)) - else reason = interrupted - closeTurn() - } - - // 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 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}: ${errorChain(err)}`) - try { - events.emit('agent/error', turn, step, err) - } catch { - // contained: a throwing agent/error listener must not escape the loop. - } - } - return terminalStopped -} - -/** - * 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, - handle: LoopHandle, - turn: number, - step: number, - assembly: PromptAssembly, - system: string, - boundaryMessages: Message[], - transmission: TransmissionLog, - signal: AbortSignal, -): Promise<{ hadToolCalls: boolean; finish: FinishReason }> { - const agent = ctx.agents.requireInitiator() - const { session, options } = agent - - // 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 - : { provider: options.provider ?? '', model: options.model ?? '' })) - - // Listener replacements are recorded in the request header before dispatch. - const config = await events.waterfall( - 'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig), - ) - interruptionCheckpoint(signal) - if (!config.provider || !config.model) { - throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`) - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call - const sessionPrefix = transmission.sessionPrefix! - - // Record the canonical header, including the otherwise-unlogged prefix, before dispatch. - const header = canonicalHeader({ - config, - ...system ? { system } : {}, - ...assembly.tools.length > 0 ? { tools: assembly.tools } : {}, - ...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {}, - }) - recordRequestHeader(session, transmission, header) - - // Freeze the logged header plus boundary snapshot; the prefix precedes derived history. - const request: GenerateOptions = markAgentLoopRequest(deepFreeze({ - provider: header.config.provider, - model: header.config.model, - messages: [...header.messagePrefix ?? [], ...boundaryMessages], - ...header.system !== undefined ? { system: header.system } : {}, - ...header.tools !== undefined ? { tools: header.tools } : {}, - ...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {}, - ...header.config.maxTokens !== undefined ? { maxTokens: header.config.maxTokens } : {}, - ...header.config.stop !== undefined ? { stop: header.config.stop } : {}, - sessionId: session.id, - signal, - })) - - // --- Model call (streaming-first; raw chunks are the replay record) --- - const assembler = new BlockAssembler() - const chunkSeqs: number[] = [] - const stream = ctx.llm.stream(request) - try { - for await (const chunk of stream) { - interruptionCheckpoint(signal) - const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) - chunkSeqs.push(chunkEvent.seq) - assembler.push(chunk) - } - } catch (error: unknown) { - const failure = llmFailureOf(stream, error) - if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure) - throw error - } - interruptionCheckpoint(signal) - - // Normalize failure finish chunks into the same path as thrown stream errors. - const stepError = finishError(assembler.finish) - if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure) - - const recordAssistantMessage = ( - assembledContent: ContentBlock[], - message: Message, - preserveReplayState = true, - ): void => { - session.append( - 'assistant/message', - { - turn, - step, - content: message.content, - provenance: assistantProvenance( - header.config, - assembler.replayState, - preserveReplayState && isDeepStrictEqual(message.content, assembledContent), - ), - ...assembler.usage === undefined ? {} : { usage: assembler.usage }, - }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, - ) - } - - // A rejected result still records the successful provider call without retaining rejected output. - const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise => { - try { - const processed = await events.waterfall( - 'agent/step-result', turn, step, message, signal, () => Promise.resolve(message), - ) - interruptionCheckpoint(signal) - return processed - } catch (error: unknown) { - recordAssistantMessage(assembledContent, { ...message, content: [] }, false) - throw error - } - } - - if (assembler.finish.kind === 'max-tokens') { - const assembled = assembler.message() - const assembledContent = structuredClone(assembled.content) - let message: Message = withoutToolCalls(assembled) - message = withoutToolCalls(await processStepResult(assembledContent, message)) - // Preserve usage even when max-token truncation produced no content. - recordAssistantMessage(assembledContent, message) - return { hadToolCalls: false, finish: assembler.finish } - } - - // Record the post-waterfall message that tool dispatch uses. - const assembled = assembler.message() - const assembledContent = structuredClone(assembled.content) - let message: Message = assembled - message = await processStepResult(assembledContent, message) - - // Every successful call records its completion anchor, including explicit - // empty chunk provenance for a contentless, usage-less provider response. - recordAssistantMessage(assembledContent, message) - - // Dispatch may overlap; policy, durable results, and result context stay model-ordered. - const toolCalls = message.content.filter(block => block.type === 'tool-call') - if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish } - return handle.withToolBatch(async (acceptContext) => { - await executeToolCalls( - ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext, - ) - return { hadToolCalls: true, finish: assembler.finish } - }) -} - -/** Build durable assistant provenance, dropping replay state after any content rewrite. */ -function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable { - return { - provider: config.provider, - model: config.model, - ...contentUnchanged && replayState !== undefined ? { replayState } : {}, - } -} - -function withoutToolCalls(message: Message): Message { - return { ...message, content: message.content.filter(block => block.type !== 'tool-call') } -} - -/** - * The last turn number in a (possibly seeded) session log, or 0. - * @param session - the session whose log is scanned for the latest `turn/start`. - * @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one). - */ -export function lastTurnNumber(session: Session): number { - const lastStart = session.events.findLast(event => event.type === 'turn/start') - return lastStart?.data.turn ?? 0 -} - -/** - * 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. - */ -export function isTurnOpen(session: Session): boolean { - const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end') - return last?.type === 'turn/start' -} diff --git a/packages/core/agent-loop/src/request-log.ts b/packages/core/agent-loop/src/request-log.ts deleted file mode 100644 index ea6141fea9..0000000000 --- a/packages/core/agent-loop/src/request-log.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 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 - */ - -import { headerEquals } from '@deepseek-ai/dsh-session' -import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' -import type { Message } from '@deepseek-ai/dsh-llm' - -/** Per-loop-instance bookkeeping: whether THIS instance has logged a header yet. */ -export interface TransmissionLog { - /** True once this loop instance appended its anchoring `request/header` snapshot. */ - loggedHeader: boolean - /** - * The instance's composed session prefix (the `agent/session-prefix` - * waterfall's deep-frozen product), cached on the instance's first - * request-building step and reused verbatim for every request it sends — - * the structural guarantee that the prefix never changes mid-session. - * `undefined` until composed. - */ - sessionPrefix?: Message[] -} - -/** - * Fresh bookkeeping for a newly-started loop instance. - * @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot. - */ -export function createTransmissionLog(): TransmissionLog { - return { loggedHeader: false } -} - -/** - * 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). - * @param header - the canonical header the request will ACTUALLY use - * (post-`agent/request`). - */ -export function recordRequestHeader(session: Session, state: TransmissionLog, header: EpochHeader): void { - if (!state.loggedHeader) { - session.append('request/header', { header, reason: session.requestHeader() === undefined ? 'initial' : 'resume' }) - state.loggedHeader = true - return - } - // This instance logged a snapshot, so the fold is necessarily defined. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const baseline = session.requestHeader()! - if (headerEquals(baseline, header)) return - session.append('request/header', { header, reason: 'change' }) -} diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index b6e83d7b23..3855fcf073 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -32,13 +32,16 @@ interface Slot { interface GroupOutcome { consumed: number aborted: boolean + /** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */ + concluded: boolean } /** * Schedule one assistant step's tool calls by their live concurrency mode. * Started calls receive ordered results. Abort drains them, records synthetic * results for unstarted calls, and returns with the signal still aborted after - * accepting started-call context into the batch FIFO owned by the caller. + * accepting started-call context through the caller-supplied acceptor (the + * machine stages it on its outbox for the next step boundary). * The committed step's AgentLoop driver boundary supplies the initiating Agent * that becomes each explicit {@link ToolExecutionInput.agent}. * @@ -47,8 +50,7 @@ interface GroupOutcome { * @param step - current step number. * @param toolCalls - assistant calls in model order. * @param signal - abort signal shared by the step. - * @param maxParallel - validated in-flight cap. - * @param acceptContext - accepts committed result context into the active batch. + * @param acceptContext - accepts committed result context for the next step boundary. */ export async function executeToolCalls( ctx: Context, @@ -56,9 +58,8 @@ export async function executeToolCalls( step: number, toolCalls: ToolCallBlock[], signal: AbortSignal, - maxParallel: number, acceptContext: (context: HookContext) => void, -): Promise { +): Promise<{ concluded: boolean }> { const agent = ctx.agents.requireInitiator() const { session } = agent @@ -75,6 +76,7 @@ export async function executeToolCalls( })) let next = 0 + let concluded = false while (next < planned.length) { // Commit before classifying again so registry changes affect unstarted calls. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition @@ -82,14 +84,16 @@ export async function executeToolCalls( const mode = ctx.tools.executionMode(first.exec).kind const group = mode === 'parallel' ? planned.slice(next) : [first] const outcome = await runGroup( - ctx, turn, step, group, mode, signal, maxParallel, acceptContext, + ctx, turn, step, group, mode, signal, acceptContext, ) next += outcome.consumed + concluded ||= outcome.concluded if (outcome.aborted) { for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block) - return + return { concluded } } } + return { concluded } } /** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */ @@ -116,10 +120,10 @@ async function runGroup( group: PlannedCall[], mode: ToolExecutionMode['kind'], signal: AbortSignal, - maxParallel: number, acceptContext: (context: HookContext) => void, ): Promise { const { session } = ctx.agents.requireInitiator() + const { maxParallelToolCalls } = ctx.agentLoop.config const slots: (Slot | undefined)[] = group.map(() => undefined) // Started slots retain their tool/call seq for result provenance. const callSeqs: number[] = group.map(() => -1) @@ -127,6 +131,7 @@ async function runGroup( let committed = 0 let started = 0 let aborted: boolean = signal.aborted + let concluded = false // `committed` advances only across contiguous model-order slots. const commitReady = async (): Promise => { @@ -140,6 +145,7 @@ async function runGroup( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!) for (const context of result.additionalContexts ?? []) acceptContext(context) + concluded ||= result.concludesTurn === true committed++ } } @@ -174,7 +180,7 @@ async function runGroup( } const fillPool = async (): Promise => { - while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) { + while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) { // Re-read later modes after ordered commits so registry changes can create a barrier. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition const nextCall = group[nextToStart]! @@ -206,11 +212,11 @@ async function runGroup( // Started calls and accepted context settle first; every remaining model // call then receives an ordered synthetic result before the turn aborts. for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block) - return { consumed: group.length, aborted: true } + return { consumed: group.length, aborted: true, concluded } } /* v8 ignore next -- unreachable: a non-aborted group commits every started call */ if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls') - return { consumed: started, aborted: false } + return { consumed: started, aborted: false, concluded } } /** Append the durable call/result pair for a model call skipped after cancellation. */ diff --git a/packages/core/agent-loop/tests/MIGRATION.md b/packages/core/agent-loop/tests/MIGRATION.md new file mode 100644 index 0000000000..c6de100340 --- /dev/null +++ b/packages/core/agent-loop/tests/MIGRATION.md @@ -0,0 +1,86 @@ +# Agent-loop test migration guide (naive-machine contract) + +The loop was rewritten in the naive-agent shape. `packages/core/agent-loop/src/agent.ts` +is the single source of truth — read it before migrating a spec. Key changes: + +## Event seams (old → new) + +| Old seam | Replacement | +|---|---| +| `agent/pre-step` (serial, before step/start) | `agent/step` (serial, before EVERY request derives; same position) | +| `agent/post-step` (serial, after tools, before step/end) | REMOVED — use `agent/step` of the next step, or `agent/idle` after the turn | +| `agent/session-prefix` (waterfall, request-only prefix) | REMOVED — requests carry no unlogged prefix; durable context via `agent.inject()` at `agent/session-start` | +| `agent/step-result` (waterfall, rewrite assistant msg) | REMOVED — the assembled message is recorded as-is | +| `agent/request-error` (waterfall, retry/fail decision) | REMOVED — observe `agent/idle` with `reason.kind === 'error'`, repair, then `agent.retry()` | +| `agent/turn-continuation` (waterfall, ContinuationDecision) | `agent/continue` (waterfall of `boolean`; handler `(agent, turn, signal, next)`) | +| `agent/turn-stop` (serial, terminal stop) | REMOVED — `agent/continue` returning `false` stops the turn | +| `agent/request` `(agent, turn, step, config, signal, next)` | `(agent, turn, step, signal, next)` — the config comes only from `await next()` | +| `agent/prompt-submit` | unchanged | + +New emit: `agent/idle (agent, turn, reason: IdleReason)` fires once per closed turn +(after turn/end + flush, with `busy` already false, so listeners may synchronously +`retry()`/`send()`). `IdleReason = completed | aborted | { kind: 'error', error, failure? }`. + +## Verb semantics + +- `send()` — unchanged (queued FIFO, one turn each). +- `steer()` while running — enters the outbox; taken whole at the next step + boundary. Steering left when the turn closes becomes a queued prompt. + There is NO terminal-stop discard of steering anymore. +- `inject()` while the machine is busy — enters the outbox (a `context/message` + appears at the NEXT step boundary, not immediately). While idle — writes a + one-shot turn (`turn/start(injection)` + `context/message` + `turn/end`) and + requests a flush. Enclosure is decided by `busy`, NOT by scanning the log for + an open turn. +- `retry()` — NEW verb: re-opens a turn on the current log with trigger + `{ kind: 'retry' }`. Throws while busy ("cannot retry while busy") and after + disposal. Legal from a synchronous `agent/idle` listener. +- `cancel()` — unchanged surface. No more "pre-run cancelled" bookkeeping: + clearing the queue before a run starts simply means no run starts. + +## Machine shape (timing-sensitive tests) + +- `kick()` runs SYNCHRONOUSLY from `send()` when idle: status flips to + `running` inside the `send()` call. There is no parked driver loop, no + waitForQueued, no microtask collection window. +- One `run()` = one turn. The idle tail (`idle()`) runs after turn/end + + flush: it sets `busy=false`, emits `agent/idle`, requeues leftover steering, + then either kicks the next turn or settles `whenIdle` waiters and flips + status to `idle`. Status stays `running` continuously across queued turns. +- `step/end` is appended INSIDE the step (after tools + the in-step outbox + drain), before `agent/continue` runs. The old `post-step → step/end` + window no longer exists. +- Request messages = `session.deriveMessages()` snapshot taken right before + `step/start` — no `messagePrefix`. `request/header` events no longer carry + a `messagePrefix` field. +- Provider/model config waterfall (`agent/request`) runs INSIDE the step + (after step/start), seeded from agent options (first request) or the folded + logged header (later requests). +- The assembled assistant message is recorded verbatim (with replayState when + present); there is no rewrite path and no "content-less anchor on rejection". +- A model failure (thrown by the adapter or a failure finish chunk) closes the + turn: balanced step/end + turn/end `{ kind:'error', step, failure }` + + `agent/error` emit + `agent/idle` `{ kind:'error', error, failure }`. + There are no in-turn recovery steps. +- Cancellation classification: signal reason `user`/`parent` → turn/end + `aborted`; disposal → `disposed`. IdleReason for both is `aborted`. +- A blocked prompt (`prompt-submit` → block) records `prompt/blocked`, closes + a zero-step turn `rejected` in turn/end, and emits `agent/idle` + `{ kind: 'completed' }` (rejection is a policy outcome, not an error). +- Accept-validation error message is now + "agent message content and source must be losslessly JSON-serializable". +- `dispose()` (the prepared disposer / factory teardown) returns `undefined` + when the machine is not busy — do not `.resolves` it unconditionally; use + `await Promise.resolve(dispose())`. + +## What to do with tests of removed seams + +- Rewrite the scenario against the nearest new seam when the protected + behavior still exists (e.g. turn-stop tests → `agent/continue` returning + false; request-error retry tests → `agent/idle` + `retry()` flows). +- Delete tests whose subject no longer exists at all (session-prefix + reconstruction, step-result rewrite provenance, post-step ordering windows, + pre-run-cancel bookkeeping). Do not keep zombie tests alive by weakening + their assertions. +- Keep the durable-log invariants strong: balanced turn/step boundaries, + ordered tool call/result pairs, header change tracking — those still hold. diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 608f1bad60..b3e2c2bb15 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -5,7 +5,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -57,12 +57,12 @@ describe('Agent', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('exclusive-driver')) const prepared = prepareReactLoopAgent( - ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, ) expect(() => prepared.agent.ctx).toThrow('context is not bound') expect(() => prepareReactLoopAgent( - ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, )) .toThrow('already has a concrete agent driver') @@ -78,56 +78,11 @@ describe('Agent', () => { expect(agent.options).toBe(options) expect(agent.id).toBe('owned-bindings') expect(agent.session.id).toBe(agent.id) - expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/) + expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/) await ctx.fiber.dispose() }) - it('send() throws after disposal', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) - }, { inject: ['agentLoop'] })) - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - await fiber.dispose() - await driverDone(agent) - - expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') - }) - - it('steer() throws after disposal', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) - }, { inject: ['agentLoop'] })) - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - await fiber.dispose() - await driverDone(agent) - - expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') - }) - - it('inject() throws after disposal', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - let agent!: Agent - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) - }, { inject: ['agentLoop'] })) - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - await fiber.dispose() - await driverDone(agent) - - expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') - }) - it('inject() decides enclosure from the LOG (open turn), not agent status', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -277,14 +232,15 @@ describe('Agent', () => { await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(SessionId('test')) const prepared = prepareReactLoopAgent( - ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, ) const { agent } = prepared // Start the loop to get the disposer; the agent waits for messages // (idle, never-resolving cancel), so it will stay idle. prepared.markPublished() - const dispose = prepared.startDriver() + prepared.start() + const dispose = prepared.dispose // First dispose const firstDisposal = dispose() @@ -301,12 +257,13 @@ describe('Agent', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('pre-start-dispose')) const prepared = prepareReactLoopAgent( - ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, ) await prepared.dispose() expect(prepared.agent.status).toBe('disposed') - const dispose = prepared.startDriver() + prepared.start() + const dispose = prepared.dispose await dispose() await expect(prepared.agent.done).resolves.toBeUndefined() expect(prepared.agent.session.events).toEqual([]) @@ -402,11 +359,12 @@ describe('Agent', () => { ctx.llm.registerAdapter(['mock'], adapter) const session = ctx.sessions.create(SessionId('bare')) const prepared = prepareReactLoopAgent( - ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, ) const { agent } = prepared prepared.markPublished() - const dispose = prepared.startDriver() + prepared.start() + const dispose = prepared.dispose agent.send([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 24eccf5cbd..5d62355b35 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -3,9 +3,9 @@ import { Context } from 'cordis' import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent, type ContinuationDecision, type HookContext } from '@deepseek-ai/dsh-agent' -import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' +import ToolRegistry, { defineTool, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' @@ -60,7 +60,7 @@ describe('session log records what agent/step-result actually produced', () => { const adapter = new MockAdapter([original, textResponse('done')]) const ctx = await harness(adapter) const executed: string[] = [] - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'injected-tool', description: '', parameters: {}, @@ -229,7 +229,7 @@ describe('abort during tool execution ends the turn', () => { const ctx = await harness(adapter) const executed: string[] = [] const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'aborter', description: '', parameters: {}, @@ -250,7 +250,7 @@ describe('abort during tool execution ends the turn', () => { source: { kind: 'plugin', plugin: 'abort-test' }, }], })) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'second', description: '', parameters: {}, @@ -275,9 +275,7 @@ describe('abort during tool execution ends the turn', () => { order.push(`tool/result:${event.data.callId}:${outcome}`) break } - // Injected context is a plugin-sourced user/message; the direct human - // prompt (user source) is not tracked in this ordering. - case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break + case 'context/message': order.push('context/message'); break case 'steering/message': order.push('steering/message'); break case 'step/end': order.push('step/end'); break case 'turn/end': { @@ -334,7 +332,7 @@ describe('abort during tool execution ends the turn', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'aborter', description: '', parameters: {}, @@ -356,14 +354,13 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] - const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || isInjected(event) + .filter(event => event.type === 'tool/result' || event.type === 'context/message' || event.type === 'step/end' || event.type === 'turn/end') - .map(event => isInjected(event) ? 'context/message' : event.type)) + .map(event => event.type)) .toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end']) expect(events - .filter(isInjected) + .filter(event => event.type === 'context/message') .map(event => event.data.content)) .toEqual([ [{ type: 'text', text: 'accepted before abort' }], @@ -381,7 +378,7 @@ describe('abort during tool execution ends the turn', () => { ] satisfies StreamChunk[]]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'first', description: '', parameters: {}, @@ -389,7 +386,7 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'first done' }] }, })) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'aborter', description: '', parameters: {}, @@ -413,13 +410,12 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] - const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || isInjected(event) + .filter(event => event.type === 'tool/result' || event.type === 'context/message' || event.type === 'step/end' || event.type === 'turn/end') - .map(event => isInjected(event) ? 'context/message' : event.type)) + .map(event => event.type)) .toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end']) - expect(events.find(isInjected)?.data.content) + expect(events.find(event => event.type === 'context/message')?.data.content) .toEqual([{ type: 'text', text: 'accepted after first result' }]) }) @@ -431,7 +427,7 @@ describe('abort during tool execution ends the turn', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'waiter', description: '', parameters: {}, @@ -460,7 +456,7 @@ describe('abort during tool execution ends the turn', () => { await fiber.dispose() expect(agent.session.events - .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user') + .filter(event => event.type === 'context/message') .map(event => event.data.content)) .toEqual([ [{ type: 'text', text: 'accepted before disposal' }], @@ -483,7 +479,7 @@ describe('abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'aborter', description: '', parameters: {}, @@ -492,7 +488,7 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'done' }] }, })) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'second', description: '', parameters: {}, @@ -511,7 +507,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) - expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content) + expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) .toEqual([{ type: 'text', text: 'new turn context' }]) expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context') }) @@ -767,11 +763,11 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }]) }) - it('agent/inbox/enqueue carries the resolved source; steering/message records its source', async () => { + it('agent/queued carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'noop', description: '', parameters: {}, @@ -781,14 +777,14 @@ describe('adapter registration, routing, and accepted-input ownership', () => { }, })) - const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = [] - ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering })) + const queuedSources: { source: MessageSource; steering: boolean }[] = [] + ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info)) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible await waitForIdle(ctx, agent) - expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, contexts: [], steering: false }) - expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, contexts: [], steering: true }) + expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false }) + expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true }) // The drain appends the durable steering/message with the caller's source // intact — the log, not a transient emit, is where consumers read it. const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : []) @@ -803,39 +799,24 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const source = { kind: 'plugin' as const, plugin: 'accepted-source' } let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined - let notifiedContexts: HookContext[] | undefined - ctx.on('agent/inbox/enqueue', (subject, info) => { + ctx.on('agent/queued', (subject, acceptedContent, info) => { if (subject !== agent || info.steering) return // Retain the exact notification references: cloning here would test the // listener's copy rather than the event/inbox ownership boundary. - notifiedContent = info.content + notifiedContent = acceptedContent notifiedSource = info.source - notifiedContexts = info.contexts }) - const contexts: HookContext[] = [{ - content: [{ type: 'text', text: 'accepted-context' }], - source: { kind: 'plugin', plugin: 'context-source' }, - meta: { version: 1 }, - }] - agent.send(content, { source, contexts }) + agent.send(content, { source }) content[0]!.text = 'caller-mutated-send' source.plugin = 'caller-mutated-source' - contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-context' } await waitForIdle(ctx, agent) expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-send' }]) expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' }) - expect(notifiedContexts).toEqual([{ - content: [{ type: 'text', text: 'accepted-context' }], - source: { kind: 'plugin', plugin: 'context-source' }, - meta: { version: 1 }, - }]) expect(Object.isFrozen(notifiedContent)).toBe(true) expect(Object.isFrozen(notifiedContent?.[0])).toBe(true) expect(Object.isFrozen(notifiedSource)).toBe(true) - expect(Object.isFrozen(notifiedContexts)).toBe(true) - expect(Object.isFrozen(notifiedContexts?.[0]?.content)).toBe(true) const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) expect(recorded).toContainEqual({ content: [{ type: 'text', text: 'accepted-send' }], @@ -843,9 +824,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { }) const request = JSON.stringify(adapter.requests[0]!.messages) expect(request).toContain('accepted-send') - expect(request).toContain('accepted-context') expect(request).not.toContain('caller-mutated-send') - expect(request).not.toContain('caller-mutated-context') }) it('running steer() owns content and source before notification and delivery', async () => { @@ -854,7 +833,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const release = Promise.withResolvers() - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'gate', description: '', parameters: {}, @@ -866,12 +845,10 @@ describe('adapter registration, routing, and accepted-input ownership', () => { })) let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined - let notifiedContexts: HookContext[] | undefined - ctx.on('agent/inbox/enqueue', (subject, info) => { + ctx.on('agent/queued', (subject, acceptedContent, info) => { if (subject !== agent || !info.steering) return - notifiedContent = info.content + notifiedContent = acceptedContent notifiedSource = info.source - notifiedContexts = info.contexts }) agent.send([{ type: 'text', text: 'start' }]) @@ -879,86 +856,27 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(agent.status).toBe('running') const content = [{ type: 'text' as const, text: 'accepted-steer' }] const source = { kind: 'plugin' as const, plugin: 'accepted-source' } - const contexts: HookContext[] = [ - { - content: [{ type: 'text', text: 'accepted-steering-prefix' }], - source: { kind: 'plugin', plugin: 'steering-prefix' }, - placement: 'prompt-prefix', - }, - { - content: [{ type: 'text', text: 'accepted-steering-context' }], - source: { kind: 'plugin', plugin: 'steering-context' }, - meta: { kind: 'separate-card' }, - }, - { - content: [{ type: 'text', text: 'accepted-steering-context-without-meta' }], - source: { kind: 'plugin', plugin: 'steering-context-without-meta' }, - }, - ] - agent.steer(content, { source, contexts }) + agent.steer(content, { source }) content[0]!.text = 'caller-mutated-steer' source.plugin = 'caller-mutated-source' - contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-steering-prefix' } - contexts[0]!.placement = 'separate' - contexts[1]!.content[0] = { type: 'text', text: 'caller-mutated-steering-context' } - contexts[2]!.content[0] = { type: 'text', text: 'caller-mutated-steering-context-without-meta' } const idle = waitForIdle(ctx, agent) release.resolve(undefined) await idle expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }]) expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' }) - expect(notifiedContexts).toEqual([ - { - content: [{ type: 'text', text: 'accepted-steering-prefix' }], - source: { kind: 'plugin', plugin: 'steering-prefix' }, - placement: 'prompt-prefix', - }, - { - content: [{ type: 'text', text: 'accepted-steering-context' }], - source: { kind: 'plugin', plugin: 'steering-context' }, - meta: { kind: 'separate-card' }, - }, - { - content: [{ type: 'text', text: 'accepted-steering-context-without-meta' }], - source: { kind: 'plugin', plugin: 'steering-context-without-meta' }, - }, - ]) expect(Object.isFrozen(notifiedContent)).toBe(true) expect(Object.isFrozen(notifiedContent?.[0])).toBe(true) expect(Object.isFrozen(notifiedSource)).toBe(true) - expect(Object.isFrozen(notifiedContexts)).toBe(true) const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : []) expect(recorded).toContainEqual({ turn: 1, - content: [ - { type: 'text', text: 'accepted-steering-prefix' }, - { type: 'text', text: '\n\n## My request:\n' }, - { type: 'text', text: 'accepted-steer' }, - ], + content: [{ type: 'text', text: 'accepted-steer' }], source: { kind: 'plugin', plugin: 'accepted-source' }, - envelope: { - displayContent: [{ type: 'text', text: 'accepted-steer' }], - prefixContexts: [{ - source: { kind: 'plugin', plugin: 'steering-prefix' }, - }], - }, }) const request = JSON.stringify(adapter.requests[1]!.messages) expect(request).toContain('accepted-steer') - expect(request).toContain('accepted-steering-prefix') - expect(request).toContain('accepted-steering-context') - expect(request).toContain('accepted-steering-context-without-meta') expect(request).not.toContain('caller-mutated-steer') - expect(request).not.toContain('caller-mutated-steering-prefix') - expect(request).not.toContain('caller-mutated-steering-context') - expect(request).not.toContain('caller-mutated-steering-context-without-meta') - - const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message') - const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context') - expect(steeringIndex).toBeGreaterThanOrEqual(0) - expect(contextIndex).toBe(steeringIndex + 1) }) }) @@ -983,11 +901,11 @@ describe('turn numbering continues across seeded sessions', () => { const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) const prepared = prepareReactLoopAgent( - ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, ) const forked = prepared.agent prepared.markPublished() - ctx2.effect(() => prepared.startDriver()) + ctx2.effect(() => { prepared.start(); return prepared.dispose }) const turns: number[] = [] ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) @@ -1501,7 +1419,7 @@ describe('tool result call identity', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'echo', description: 'echo', parameters: { x: { type: 'number' } }, diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts deleted file mode 100644 index 791eae3bda..0000000000 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { AgentMessageId } from '@deepseek-ai/dsh-agent' -import { Inbox } from '../src/inbox.ts' - -function message(text: string) { - return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } -} - -function resolverPair() { - let r!: () => void - const p = new Promise((resolve) => { r = resolve }) - return { promise: p, resolve: r } -} - -describe('Inbox', () => { - it('dequeues one queued message at a time in FIFO order', () => { - const inbox = new Inbox() - inbox.enqueue(message('first')) - inbox.enqueue(message('second')) - expect(inbox.hasQueued).toBe(true) - - expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' }) - expect(inbox.hasQueued).toBe(true) - expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' }) - expect(inbox.hasQueued).toBe(false) - expect(inbox.dequeueQueued()).toBeUndefined() - }) - - it('enqueue(msg, false) queues without waking a parked waiter', async () => { - const inbox = new Inbox() - let woke = false - const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true }) - inbox.enqueue(message('quiet'), false) - // The item is queued, but the parked waiter was not resolved by it. - expect(inbox.hasQueued).toBe(true) - await Promise.resolve() - expect(woke).toBe(false) - // A later waking enqueue resolves the same waiter. - inbox.enqueue(message('loud')) - await waiter - expect(woke).toBe(true) - }) - - it('pending() snapshots queued then steering without removing them', () => { - const inbox = new Inbox() - inbox.enqueue(message('q')) - inbox.steer(message('s')) - const pending = inbox.pending() - expect(pending.map(p => p.steering)).toEqual([false, true]) - // Snapshot does not drain the FIFOs. - expect(inbox.hasQueued).toBe(true) - expect(inbox.hasSteering).toBe(true) - }) - - it('pushes and drains steering messages separately from queued', () => { - const inbox = new Inbox() - inbox.steer(message('steer')) - expect(inbox.hasQueued).toBe(false) - expect(inbox.hasSteering).toBe(true) - - const steering = inbox.drainSteering() - expect(steering).toHaveLength(1) - expect(inbox.hasSteering).toBe(false) - }) - - it('waitForQueued returns immediately when a queued message is already present', async () => { - const inbox = new Inbox() - inbox.enqueue(message('ready')) - - const started = Date.now() - await inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel - expect(Date.now() - started).toBeLessThan(50) - }) - - it('waitForQueued resolves when a message is enqueued', async () => { - const inbox = new Inbox() - const waiter = inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel - // enqueue after starting the wait - setTimeout(() => { inbox.enqueue(message('wake')) }, 5) - await waiter - }) - - it('waitForQueued resolves when the cancel promise resolves', async () => { - const inbox = new Inbox() - const { promise, resolve } = resolverPair() - const waiter = inbox.waitForQueued(promise) - resolve() - await waiter - }) - - it('waitForQueued overwrites the previous wakeup callback (only the latest waiter is notified)', async () => { - const inbox = new Inbox() - const { promise: p1, resolve: r1 } = resolverPair() - - void inbox.waitForQueued(new Promise(() => {})) // first call, never resolved - void inbox.waitForQueued(p1) // second call overwrites wakeup - - // Cancelling the latest waiter clears the shared callback; enqueue must neither - // wake the stale waiter nor fail on the cleared callback. - r1() - await p1 - - inbox.enqueue(message('hey')) - }) - - it('clears wakeup in finally handler when enqueue resolves', async () => { - const inbox = new Inbox() - void inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel - // The wakeup is set. Now trigger it via enqueue → wakeup() calls resolve, - // promise resolves, finally clears wakeup because wakeup === resolve. - inbox.enqueue(message('wake')) - // No explicit await needed — enqueue is synchronous, and the microtask - // (finally) runs. The key coverage hit is finally with wakeup === resolve. - }) - - it('finally handler does not clear wakeup when a different waiter overwrote it', async () => { - // A stale waiter's finally must not clear the replacement waiter. - const inbox = new Inbox() - const { promise: c1, resolve: r1 } = resolverPair() - - void inbox.waitForQueued(c1) // wakeup = resolve1, c1.then(resolve1) - void inbox.waitForQueued(new Promise(() => {})) // wakeup = resolve2, cancel never resolves - - r1() - await c1 - - // The replacement remains registered and is resolved by enqueue. - inbox.enqueue(message('hey')) - }) -}) diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index cb0dcd2384..0c439bc7e2 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -47,15 +47,14 @@ describe('request-reconstruction invariant', () => { expect(() => { dispatch(ctx, options) }).not.toThrow() }) - it('requires the folded session prefix ahead of derived history', async () => { + it('requires the messages to equal the boundary derivation exactly (no unlogged prefix)', async () => { const { ctx, session, boundary } = await requestSetup() - const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } - session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' }) - expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) }) - .not.toThrow() + const extra = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) + .not.toThrow() + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([extra, ...boundary]), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) - expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, extra]), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8d3016d872..e88703b8f8 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, TurnEndReason, type JsonValue } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -89,7 +89,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'echo', description: 'echo back', parameters: { text: { type: 'string' } }, @@ -118,27 +118,22 @@ describe('agent loop', () => { const types = agent.session.events.map(e => e.type) expect(types).toContain('tool/call') expect(types).toContain('tool/result') - const durableResult = agent.session.events.find(event => event.type === 'tool/result') - expect(durableResult?.type === 'tool/result' && 'value' in durableResult.data).toBe(false) }) - it('persists presentation metadata projected from the canonical value', async () => { + it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'), textResponse('done'), ]) const ctx = await harness(adapter) + // A tool that returns the { content, meta } object form: the loop must + // persist `meta` on the tool/result event so a UI reproduces the card on replay. ctx.tools.register(defineTool({ name: 'writer', description: 'writes a file', parameters: { path: { type: 'string' } }, - output: { - schema: { type: 'string' }, - render: () => [{ type: 'text', text: 'ok' }], - presentationMeta: (_args, value) => ({ diffs: [{ path: value, oldText: null, newText: 'x' }] }), - }, async execute() { - return 'a.txt' + return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } } }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -157,7 +152,7 @@ describe('agent loop', () => { // projecting this agent's configured model, so the model knows its own name. const ctx = await harness(adapter, 'You are a test agent on {{model}}.') ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' }) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'noop', description: 'does nothing', parameters: {}, @@ -253,7 +248,7 @@ describe('agent loop', () => { ['BigInt', { n: 1n }], ['Map', new Map([['key', 'value']])], ['class instance', new (class ResultMeta { x = 1 })()], - ])('rejects non-JSON presentation metadata (%s) before the durable result commit', async (_kind, meta) => { + ])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => { const adapter = new MockAdapter([ toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'), textResponse('recovered'), @@ -263,12 +258,7 @@ describe('agent loop', () => { name: 'bad-meta', description: 'returns invalid durable metadata', parameters: {}, - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - presentationMeta: () => meta as unknown as JsonValue, - }, - execute: () => Promise.resolve('apparent success'), + execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }), })) const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' }) @@ -281,16 +271,15 @@ describe('agent loop', () => { expect(result.data.callId).toBe('bad-meta-call') expect(result.data.isError).toBe(true) expect(result.data.meta).toBeUndefined() - expect(result.data.error).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' }) expect(result.data.content).toEqual([{ type: 'text', - text: 'Error: tool "bad-meta" returned invalid output: output.presentationMeta returned non-lossless JSON', + text: 'Error: tool result must be losslessly JSON-serializable', }]) } // The normalized failure was durably logged and fed back to the model; the // turn continued normally instead of failing after an apparent success. expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('output.presentationMeta returned non-lossless JSON') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable') }) it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => { @@ -337,7 +326,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'slow', description: '', parameters: {}, @@ -443,7 +432,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let visibleDuringTool = false const meta = { kind: 'deferred-test', version: 1 } - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'noticer', description: 'injects a notice', parameters: {}, @@ -505,7 +494,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'invalid-injector', description: 'attempts an invalid context injection', parameters: {}, @@ -513,7 +502,7 @@ describe('agent loop', () => { expect(() => { agent.inject([{ type: 'text', text: 'invalid' }], { source: { kind: 'plugin', plugin: 'test' }, - meta: { bigint: 1n } as never, + meta: { bigint: 1n }, }) }).toThrow('agent context must be losslessly JSON-serializable') return [{ type: 'text', text: 'rejected invalid context' }] @@ -574,7 +563,7 @@ describe('agent loop', () => { it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })]) const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -622,7 +611,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'echo', description: 'echo', parameters: {}, async execute() { return [{ type: 'text', text: 'echoed' }] }, })) @@ -814,7 +803,7 @@ describe('agent loop', () => { ]]) const ctx = await harness(adapter) let executions = 0 - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -854,7 +843,7 @@ describe('agent loop', () => { { type: 'finish', reason: { kind: 'max-tokens' } }, ]]) const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -941,7 +930,7 @@ describe('agent loop', () => { textResponse('continued after tool call'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -1224,7 +1213,6 @@ describe('agent loop', () => { expect(agent.status).toBe('disposed') expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() - expect(() => { send(agent, 'too late') }).toThrow('disposed') }) it('creates agents from config on startup', async () => { @@ -1273,7 +1261,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 6597a3710e..e4c035275a 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -9,9 +9,9 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmService from '@deepseek-ai/dsh-llm' -import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { @@ -61,7 +61,7 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC function gatedTool(name: string, parallel: boolean) { const gates = new Map void>() const started: string[] = [] - const tool = defineContentToolFixture({ + const tool = defineTool({ name, description: `gated ${name}`, parameters: { id: { type: 'string', required: true } }, @@ -123,12 +123,12 @@ describe('tool-call scheduler: grouping and barriers', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } }, isConcurrencySafe: () => true, async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] }, })) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } }, async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, })) @@ -150,14 +150,14 @@ describe('tool-call scheduler: grouping and barriers', () => { ]) const ctx = await harness(adapter) const replacement = gatedExclusiveTool('x') - const disposeSafe = ctx.tools.register(defineContentToolFixture({ + const disposeSafe = ctx.tools.register(defineTool({ name: 'x', description: 'initially safe', parameters: { id: { type: 'string', required: true } }, isConcurrencySafe: () => true, async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] }, })) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'replace', description: 'replace x', parameters: { id: { type: 'string', required: true } }, @@ -280,7 +280,8 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - expect(() => new AgentLoop(ctx, { agents: [] })).not.toThrow() + const loop = new AgentLoop(ctx, { agents: [] }) + expect(loop.config.maxParallelToolCalls).toBe(DEFAULT_MAX_PARALLEL_TOOL_CALLS) await ctx.fiber.dispose() }) @@ -539,14 +540,10 @@ describe('tool-call scheduler: abort handling', () => { .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) - expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({ - callId: e.data.callId, - isError: e.data.isError, - errorInfo: e.data.error, - }))) + expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data)) .toEqual([ - { callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, - { callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, + expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), + expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), ]) const settled = events(agent).filter(e => e.type === 'tool/result' || (e.type === 'user/message' && e.data.source.kind === 'plugin')) @@ -570,7 +567,7 @@ describe('tool-call scheduler: abort handling', () => { const gated = gatedParallelTool('p') const exclusive: string[] = [] ctx.tools.register(gated.tool) - ctx.tools.register(defineContentToolFixture({ + ctx.tools.register(defineTool({ name: 'x', description: 'exclusive', parameters: { id: { type: 'string', required: true } }, diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index 8ce018c16b..c00a440e78 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,7 +1,11 @@ /** - * Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the - * fused dispatcher so subject and scope key cannot diverge; registry lifecycle - * code instead captures one stable carrier for both edges. + * Agent-scoped dispatch helpers. An agent-subject event travels with the + * agent's scope carrier as `thisArg` (so scoped listeners filter to their own + * agent) and the agent itself as the first argument. Composable seams are + * plain `ctx.waterfall(carrier, name, agent, …, next)` calls at the machine's + * call sites — concrete event names type-check against the real Cordis + * overloads, so no generic wrapper (and none of its casts) is needed. The one + * helper here is {@link emitAgentEvent}: a contained fire-and-forget emit. * @module @deepseek-ai/dsh-agent/dispatch */ @@ -11,11 +15,6 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' import type { Agent } from './types.ts' -/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */ -type Params = F extends (...args: infer P) => unknown ? P : never -/** Extract the return type from an event handler type. */ -type Return = F extends (...args: never[]) => infer R ? R : never - /** * The event names whose subject is an agent: handler parameters start with an * `Agent` AND the handler declares a `Scoped` `this` (the scope-carrier @@ -30,84 +29,43 @@ export type AgentSubjectEvent = { }[keyof Events] /** The event arguments AFTER the injected agent subject. */ -type Tail = Params extends [Agent, ...infer R] ? R : never +type Tail = Events[K] extends (...args: infer P) => unknown + ? P extends [Agent, ...infer R] ? R : never + : never /** - * The fused dispatcher {@link agentEvents} returns: each method dispatches the - * named agent-subject event with the agent's scope carrier as `thisArg` and - * the agent itself injected as the first event argument. + * The scope carrier for an agent-subject dispatch: the agent fused as both + * the carrier key and the event subject, so the two cannot diverge. Pass it + * as the `thisArg` of `ctx.serial` / `ctx.waterfall` for agent events. + * @param agent - the subject agent. + * @returns the fused carrier. */ -export interface AgentEventDispatch { - /** - * Fire-and-forget notification in the agent's scope. Every listener is - * invoked; synchronous throws and returned-promise rejections are logged and - * contained per listener, so a notification cannot veto lifecycle progress - * or starve a later observer. - * @param name - the agent-subject event to emit. - * @param rest - the event's arguments after the injected agent. - */ - emit(name: K, ...rest: Tail): void - /** - * Awaited in-order dispatch (Cordis `serial`) in the agent's scope. - * @param name - the agent-subject event to dispatch. - * @param rest - the event's arguments after the injected agent. - * @returns the serial chain's result (the first bail value, if any). - */ - serial(name: K, ...rest: Tail): Promise>> - /** - * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The - * declared event parameters already end with the `next` callback, so `rest` - * is exactly the event's arguments after the injected agent — the final - * element being the innermost `next` (the default the listener chain wraps). - * @param name - the agent-subject event to dispatch. - * @param rest - the event's arguments after the injected agent. - * @returns the waterfall's composed result. - */ - waterfall(name: K, ...rest: Tail): Return +export function agentCarrier(agent: Agent): Scoped { + return scopeTarget(agent, agent) } /** - * Build a dispatcher that couples the agent subject to its scope carrier. + * Fire-and-forget notification in the agent's scope. Every listener is + * invoked; synchronous throws and returned-promise rejections are logged and + * contained per listener, so a notification cannot veto lifecycle progress or + * starve a later observer. (Raw Cordis `emit` maps callbacks unguarded — one + * synchronous throw would starve the rest and escape into the caller.) * @param ctx - the context to dispatch through (any context of the app). * @param agent - the subject agent; also the scope-carrier key. - * @returns the fused dispatcher. + * @param name - the agent-subject event to emit. + * @param rest - the event's arguments after the injected agent. */ -export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { - const carrier: Scoped = scopeTarget(agent, agent) - // The ordinary dispatch methods forward through Cordis' variadic mixins. The - // fused (carrier, name, agent, ...rest) tuple is provably a valid argument - // list for the matching thisArg overload, but TypeScript cannot relate the - // generic Tail spread back to that overload's conditional parameter - // tuple — hence one contained, shape-preserving cast per method. - return { - emit(name, ...rest) { - // Cordis emit invokes callbacks through Array.map: one synchronous throw - // starves later listeners, and returned promises are discarded. Agent - // notifications are non-vetoing, so resolve the same filtered callback - // set ourselves and contain both failure modes independently. - const args: unknown[] = [carrier, name, agent, ...rest] - const callbacks = ctx.events.dispatch('emit', args) - for (const callback of callbacks) { - try { - const returned: unknown = callback(...args) - void Promise.resolve(returned).catch((error: unknown) => { - ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`) - }) - } catch (error: unknown) { - ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`) - } - } - }, - async serial(name, ...rest) { - // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function - const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise - return await serial(carrier, name, agent, ...rest) - }, - waterfall(name, ...rest) { - // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function - const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never - return waterfall(carrier, name, agent, ...rest) - }, +export function emitAgentEvent(ctx: Context, agent: Agent, name: K, ...rest: Tail): void { + const args: unknown[] = [agentCarrier(agent), name, agent, ...rest] + for (const callback of ctx.events.dispatch('emit', args)) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`) + }) + } catch (error: unknown) { + ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`) + } } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index b939bd6e34..21ea0ba7c1 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -17,8 +17,8 @@ import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export { agentInterruptReasonOf } from './cancellation.ts' export * from './llm-target.ts' -export { agentEvents, assembleContextFor } from './dispatch.ts' -export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' +export { agentCarrier, assembleContextFor, emitAgentEvent } from './dispatch.ts' +export type { AgentSubjectEvent } from './dispatch.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index a5a7725707..5051ac4e31 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -19,9 +19,6 @@ const install: InvariantInstaller = (ctx, fail) => { if (previous === status) { fail(`agent/status repeated ${status} (no-op transition)`) } - if (previous === 'disposed') { - fail(`agent/status left terminal state disposed → ${status}`) - } lastStatus.set(agent, status) }, { global: true }) diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/llm-target.ts index 18287a3ff5..3409811f34 100644 --- a/packages/core/agent/src/llm-target.ts +++ b/packages/core/agent/src/llm-target.ts @@ -49,7 +49,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR }) const disposeRequest = agentCtx.on( 'agent/request', - async (_agent, _turn, _step, _config, _signal, next): Promise => { + async (_agent, _turn, _step, _signal, next): Promise => { const resolved = await next() const selected = target.assembled return selected === undefined ? resolved : { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d542c39810..2292c81f89 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -2,13 +2,29 @@ * Public agent types and live-runtime events. Durable transcript facts and * turn/step boundaries remain `@deepseek-ai/dsh-session` events. * + * The agent is a naive message machine over the session log: prompts queue + * (one turn each), steering/context ride the outbox (taken whole at every + * step boundary), and the log re-derives the request history each step — so + * "edit history between steps" needs no dedicated seam. The extension surface + * is deliberately small: + * + * - `agent/prompt-submit` (waterfall): veto/rewrite a claimed prompt. + * - `agent/request` (waterfall): replace the call config per request. + * - `agent/step` (serial): awaited before every request is built — inject + * context, steer, or edit the log here; the request derives after it. + * - `agent/stopping` (serial): the turn is about to close — steer to object. + * - a tool result carrying `concludesTurn` ends the turn at its step (data, + * not a hook): the terminal-tool pattern. + * - `agent/idle` (emit): one per turn close, carrying why it ended. Error + * recovery is a consumer loop: observe an error idle, fix (edit the log, + * wait out a rate limit), then `agent.retry()`. + * * @module @deepseek-ai/dsh-agent/types */ import type { Context } from 'cordis' -import type { Branded } from '@deepseek-ai/dsh-brand' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { @@ -26,147 +42,62 @@ export interface AgentOptions { model?: string } -/** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — the item joins the active turn between steps as steering, - * or, when no turn is active, is promoted per its `wakeup` flag. - */ -export type SendTarget = 'next-turn' | 'next-step' - -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * - * An omitted source attests direct human input as `{ kind: 'user' }` and may - * authorize policy consumers, so non-human producers must label their content. - */ +/** One queued prompt or steering item and its atomic model-facing context. */ export interface SendOptions { - /** Queue the item joins; defaults to `next-turn`. */ - target?: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). Defaults to - * `true`. A `false` `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup?: boolean - source?: MessageSource - /** - * Model-facing contexts captured with this inbox item. A queued prompt exposes - * them through the default `agent/prompt-submit` allow decision, while steering - * records them directly at its next checkpoint. - */ + /** Explicit producer attribution; callers may not inherit human authority by omission. */ + source: MessageSource + /** Context snapshotted with this item and admitted at the same boundary. */ contexts?: HookContext[] - /** Opaque JSON state retained on the durable message but hidden from the model. */ +} + +/** Options for synthetic context injection. */ +export interface InjectOptions { + /** Explicit producer attribution. */ + source: MessageSource + /** Opaque durable state omitted from the model projection. */ meta?: JsonValue } -/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ -export type AliasSendOptions = Omit - /** - * Opaque id assigned to one accepted {@link Agent.send} message; returned by - * `send` and carried on its `agent/inbox/*` events for correlation. + * An agent's ACTIVITY state, emitted on every transition as `agent/status`: + * `idle` (parked, waiting for queued work) or `running` (the machine is + * draining work). Lifecycle is a separate axis: an agent leaving its host is + * announced by `agent/disposed` and observable as `ctx.agents.get(id)` no + * longer returning it — not as a status value. */ -export type AgentMessageId = Branded<'AgentMessageId'> +export type AgentStatus = 'idle' | 'running' -/** - * Brand a string as an {@link AgentMessageId}. - * @param id - the generated message id. - * @returns the same string, branded; no validation is performed. - */ -export function AgentMessageId(id: string): AgentMessageId { - return id as AgentMessageId -} - -/** - * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live - * events. `id` is the value `send` returned to the caller, stable across this - * message's enqueue, dequeue, and discard events. Source defaults are already - * applied, so these are the exact values the item was accepted with. `steering` - * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is - * durable model-hidden state that lands on the eventual `user/message`/ - * `steering/message`, not live-event routing data. - */ -export interface AgentMessage { - /** The id `send` returned for this message. */ - id: AgentMessageId - content: ContentBlock[] - source: MessageSource - contexts: HookContext[] - /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ - steering: boolean - /** Whether the item is marked to wake the driver or force a continuation. */ - wakeup: boolean -} - -/** Options for {@link Agent.cancel}. */ -export interface CancelOptions { - /** - * Preserve queued and steering inbox items instead of discarding them. The - * active turn is still aborted, but un-started and pending work survives for a - * later turn and no `agent/inbox/discard` fires. - */ - keepInbox?: boolean -} - -/** - * An agent's lifecycle state, emitted on every transition as `agent/status`: - * `idle` (parked, waiting for queued work), `running` (the driver is draining - * work and may be closing or checkpointing a turn), `disposed` (terminal — no - * transition leaves it, and `send`/`followup`/`steer`/`inject` throw). - */ -export type AgentStatus = 'idle' | 'running' | 'disposed' - -/** Model-facing context injected by a listener or atomically attached to one inbox message. */ +/** Model-facing context injected by a listener or atomically attached to one inbox item. */ export interface HookContext { content: ContentBlock[] source: MessageSource - /** - * Model placement. Absent or `separate` records an independent injected - * `user/message`; `prompt-prefix` prepends this context and a stable - * request delimiter to the same user-role message as its attached prompt. - */ + /** `prompt-prefix` bakes this context into its prompt; absent/`separate` records an independent message. */ placement?: 'separate' | 'prompt-prefix' - /** Opaque JSON state retained in the session event but hidden from the model. */ + /** Opaque durable state omitted from the model projection. */ meta?: JsonValue } /** * Prompt interception result. `allow.content` replaces the prompt. Each - * `additionalContexts` entry follows its declared placement: separate context - * message by default, or a prefix inside the prompt's user-role message. - * `block` records a durable `prompt/blocked` and ends the claimed prompt's - * zero-step turn as rejected. An `allow` returned by a listener is - * authoritative: a listener wrapping `next()` preserves downstream `content` - * and `additionalContexts` unless it intentionally replaces them. + * `additionalContexts` entry follows its declared placement. `block` records + * a durable `prompt/blocked` and ends the claimed prompt's zero-step turn as + * rejected. A listener wrapping `next()` preserves downstream fields unless + * it intentionally replaces them. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; reason: string } -/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ -export type ContinuationDecision = - | { action: 'stop' } - | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } - -/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ -export type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } - -/** Model-request failure with an optional machine-routable provider code. */ -export type RequestError = Error & { code?: string } - /** - * The terminal subset of {@link ContinuationDecision}. A listener on - * `agent/turn-stop` returns this to make the already-composed continuation - * outcome terminal; `undefined` abstains. + * Why a turn ended, reported live on `agent/idle` right after the turn's + * durable `turn/end` and flush. `error` carries the live Error (and, for + * model-request failures, the adapter-normalized facts) so a recovery + * consumer can decide to repair and {@link Agent.retry}. */ -export type ContinuationStop = Extract +export type IdleReason = + | { kind: 'completed' } + | { kind: 'aborted' } + | { kind: 'error'; error: Error; failure?: LlmFailure } /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' @@ -179,104 +110,60 @@ export type AgentCancelCause = /** Runtime reason carried by the signal that controls one live turn. */ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } -/** - * Public agent handle; its concrete implementation is internal to - * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so - * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, - * {@link Agent.inject}) are shared concrete delegates over the single abstract - * {@link Agent.send} primitive; concrete drivers implement `send` once. - */ -export abstract class Agent { +/** Public live-agent handle; driving methods have no contract after disposal. */ +export interface Agent { /** The single identity shared with {@link session}. */ - abstract readonly id: SessionId - /** The provider route and model this agent's requests use. */ - abstract readonly options: AgentOptions - /** The live session this agent drives; its log is the durable source of truth. */ - abstract readonly session: Session - /** The current lifecycle state, mirrored on every `agent/status` transition. */ - abstract readonly status: AgentStatus + readonly id: SessionId + readonly options: AgentOptions + readonly session: Session + readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - abstract readonly ctx: Context + readonly ctx: Context /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * Detaches, validates, and freezes one lossless-JSON item, then routes it: - * - * - `next-turn` (default) queues an item that becomes the sole ordinary - * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` submits steering into the active turn - * (idle falls back to a woken `next-turn`). - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: an open turn joins at the current log position - * (deferred behind an executing tool batch until it settles), and an idle - * inject records a one-shot turn with its own durability checkpoint. - * - * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before any notification, enqueue, or append. - * @param content - the model-facing content blocks to deliver. - * @param options - target queue, wakeup decision, source, contexts, and meta. - * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + * Queue one detached, frozen lossless-JSON prompt. Each claimed prompt is + * the sole ordinary message in its FIFO-ordered turn; the next claimed + * prompt waits for that turn's checkpoint. + * Invalid input throws synchronously before notification or enqueue. */ - abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId + send(content: ContentBlock[], options: SendOptions): void /** - * Clear queued and steering work — unless `keepInbox` — and abort the active - * turn. An effective call first emits `agent/cancel-requested` with the - * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause - * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm - * later work. The active turn snapshots and freezes the cause. - * @param cause - the stable caller intent carried by the current turn signal. - * @param options - cancellation options; `keepInbox` preserves pending work. + * Submit steering while the agent is `running`: it enters the outbox and is + * taken whole at the next step boundary, before the next request. Steering + * left over when the turn closes queues for a turn of its own. When idle, + * delegates to {@link send}. */ - abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void - - /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - abstract whenIdle(): Promise + steer(content: ContentBlock[], options: SendOptions): void /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. - * @param content - the prompt content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. + * Stage detached model-facing context without running the model: it enters + * the outbox and rides along with whatever runs next — the next step of the + * running turn (never between a tool-call batch and its results), or the + * next turn when idle. */ - followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-turn', wakeup: true }) - } + inject(content: ContentBlock[], options: InjectOptions): void /** - * Submit steering into the running turn — the `next-step`/wakeup preset of - * {@link send}. An open turn records it at the next steering checkpoint before - * a request or continuation decision; policy may stop before another step. - * After turn close and its checkpoint, any remainder is queued for a later - * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. - * Idle steering falls back to a woken follow-up turn. - * @param content - the steering content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. + * Clear all queued and outbox work and abort the active turn. An effective + * call first emits `agent/cancel-requested` with the resolved typed cause; + * the first cause wins for the active turn. Omission means `{ kind: 'user' }`. + * Idle cancellation is a no-op and does not arm later work. */ - steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: true }) - } + cancel(cause?: AgentInterruptReason): void /** - * Append detached model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins - * at the current log position unless the current tool batch is executing; - * then it waits FIFO until that batch settles and drains before turn close - * even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through - * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. - * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}. + * Re-open a turn on the current session log without a new prompt — the + * recovery verb. After an `agent/idle` error, a consumer repairs (edits the + * log, waits out a rate limit) and calls this; the machine immediately runs + * another turn over the repaired history. Calling it synchronously from an + * `agent/idle` listener is legal — the machine is already idle there. + * @throws while a turn is running because there is nothing to retry yet. */ - inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: false }) - } + retry(): void + + /** Resolve at idle quiescence; disposal waits for machine exit rather than only the status transition. */ + whenIdle(): Promise } declare module 'cordis' { @@ -294,7 +181,7 @@ declare module 'cordis' { */ 'agent/created'(this: Scoped, agent: Agent): void /** - * An agent left the registry; AgentLoop emits this after driver quiescence + * An agent left the registry; AgentLoop emits this after machine 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. @@ -303,7 +190,7 @@ declare module 'cordis' { */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does + * Agent activity changed (`idle` ⇄ `running`). `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). @@ -312,39 +199,17 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * A detached, frozen item entered the agent's inbox (queued or steering - * FIFO). Source defaults are already applied, so `message` holds the exact - * accepted values. This is the enqueue-time live signal; the durable record - * is the eventual `user/message`/`steering/message`. Injection - * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. - * @param agent - the agent whose inbox received the item. - * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). + * Detached, frozen content entered the agent's inbox (prompt queue or + * steering outbox). 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, contexts, and steering classification. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage): void + 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void /** - * The driver claimed one item out of the inbox: a queued item at a turn - * boundary, or steering drained between steps. Fires after the item leaves - * its FIFO and before it becomes a durable message. - * @param agent - the agent whose inbox item was claimed. - * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`). - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ - 'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void - /** - * `cancel()` (without `keepInbox`) dropped pending inbox items without - * delivering them. Fires once per effective clearing call with every - * discarded item, after `agent/cancel-requested` and before the abort. - * @param agent - the agent whose inbox was cleared. - * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ - 'agent/inbox/discard'(this: Scoped, agent: Agent, messages: AgentMessage[]): void - /** - * Effective broad cancellation was requested, before queued/steering work + * Effective broad cancellation was requested, before queued/outbox work * is cleared or the active turn is aborted. This observe-only notification * cannot veto cancellation; listener failures are contained. * @param agent - the agent whose current work is being cancelled. @@ -352,14 +217,12 @@ declare module 'cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentCancelCause): void - - // ---- session lifecycle (emit) ---- + 'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentInterruptReason): void /** * 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. + * machine 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`): agent-scoped listeners receive only that agent. @@ -367,29 +230,12 @@ declare module 'cordis' { */ 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void - // Turn and step boundaries are durable session events, not agent events. - - // ---- step/request extension seams (serial + waterfall) ---- - /** - * Awaited serial checkpoint before `step/start`; appends land outside the - * pending step and are included when the loop derives request history. - * `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 signal - the turn abort signal. - * @mode serial - */ - 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void + // ---- the machine's extension seams ---- /** * Allow, rewrite, or block one claimed prompt before it becomes a user - * message. Call `next()` for the unchanged default. A listener wrapping a - * downstream `allow` must preserve its `content` and `additionalContexts` - * unless it intentionally replaces them. The signal controls only this turn; - * listeners may cooperate with it but must not retain it to control another - * turn. Steering messages do not dispatch this event; they join an open turn - * at a steering checkpoint. + * message. Call `next()` for the unchanged default, including contexts + * captured with the queued item. The signal controls only this turn; + * listeners may cooperate with it but must not retain it for another turn. * @param agent - the agent whose turn claimed the message. * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. @@ -399,100 +245,64 @@ declare module 'cordis' { */ 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise): Promise /** - * 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. + * Awaited serial checkpoint before EVERY request of a turn is built (the + * first as well as each post-tools continuation). The single "between + * steps" seam: inject context, steer, or edit the session log here — the + * request's history derives from the log right after this settles. + * @param agent - the agent about to send a request. + * @param turn - the open turn number. + * @param step - the step number about to open. + * @param signal - the turn abort signal. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode serial + */ + 'agent/step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void + /** + * Replace the frozen call configuration. `await next()` yields the config + * the machine would use (agent options on the first request, the logged + * header afterwards); return a replacement to switch. Model-visible + * content must use logged channels; this seam cannot mutate messages. * @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. - * @param signal - the current turn's explicit abort signal; ambient - * initiator identity does not imply liveness or cancellation authority. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall - */ - 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise - /** - * 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. - * 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 seed; return an extended replacement. - * @param signal - the current turn's explicit abort signal. - * @mode waterfall - */ - 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise - /** - * Waterfall: post-process the assembled assistant {@link Message} before - * tool dispatch (validation, content rewriting, …). - * @param agent - the agent that received the step's response. - * @param turn - the open turn number. - * @param step - the step that produced the message. - * @param message - the assistant message as assembled from the stream. * @param signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall + * @mode compose */ - 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise + 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise /** - * Awaited serial checkpoint after the response, real or synthetic tool - * results, injected context, and steering are durable but before `step/end`. - * A cancelled tool batch reaches this checkpoint with an aborted signal. - * @param agent - the agent whose step is settling. - * @param turn - the open turn number. - * @param step - the open step number. - * @param signal - the turn abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode serial - */ - 'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void - /** - * Recover a model-request failure after its failed step has closed. `retry` - * opens a new numbered step; `fail` preserves the original request error. - * Call `next()` to delegate to the next recovery listener or the default. - * @param agent - the agent whose request failed. - * @param turn - the open turn number. - * @param step - the failed step number. - * @param error - the original model-request failure. - * @param failure - serializable facts normalized at the final adapter boundary. - * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence. - * @param signal - the turn abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall - */ - 'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise - /** - * 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. - * @param signal - the current turn's explicit abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall - */ - 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise - /** - * 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. + * The turn is about to close: the model owes no response (no live tool + * calls, no fresh steering). Awaited before the boundary commits — a + * listener that objects steers (`agent.steer(...)`) and the machine + * re-reads its inbox: fresh steering runs another step, none closes the + * turn. Data decides, so listener order cannot change the outcome. The + * inverse control (stop a tool loop early) is data too: a tool result + * carrying `concludesTurn` ends the turn at its step. + * @param agent - the agent whose turn is at its stop boundary. + * @param turn - the turn about to close. * @param signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ - 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined + 'agent/stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void + /** + * One turn closed: its `turn/end` and durability flush are already + * committed. `reason` says why — recovery consumers observe an `error` + * reason, repair (edit the log, wait, resummon), and call + * {@link Agent.retry}; UI consumers key turn-done presentation off it. + * Emitted per turn, including cancelled and failed ones. + * @param agent - the agent whose turn closed. + * @param turn - the closed turn number. + * @param reason - why the turn ended, with live error facts when it failed. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/idle'(this: Scoped, agent: Agent, turn: number, reason: IdleReason): void // ---- error notifications (emit) ---- /** - * A step or turn errored. The loop reports a failure here (plus the logger) - * even when the error has no in-turn position for a session `error` event. + * A step or turn errored. The machine reports a failure here (plus the + * logger) even when the error has no in-turn position for a durable record. * @param agent - the agent whose turn errored. * @param turn - the turn in which the failure surfaced. * @param step - the step at which the failure surfaced. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 468944fa2f..2c67c72359 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -307,6 +307,8 @@ export interface ToolRunContext extends ToolExecution { * are emitted in call order. */ deferContext(context: HookContext): void + /** Mark a successful final result as terminal for the current agent turn. */ + concludeTurn(): void } /** Registry-owned live execution object; public pipeline views stay readonly. */ @@ -439,6 +441,8 @@ export interface ToolExecutionSuccess { readonly error?: never readonly meta?: JsonValue readonly additionalContexts?: HookContext[] + /** The agent loop stops after committing this successful result batch. */ + readonly concludesTurn?: true } /** Failed canonical tool execution; failures never carry a successful value. */ @@ -449,6 +453,7 @@ export interface ToolExecutionFailure { readonly content: ContentBlock[] readonly meta?: JsonValue readonly additionalContexts?: HookContext[] + readonly concludesTurn?: never } /** The discriminated, execution-local outcome of one tool call. */ @@ -648,6 +653,10 @@ export class ToolRegistry extends Service { /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ private deferredContexts = new WeakMap() + /** Successful executions whose tool body declared the current turn complete. */ + private concludingExecutions = new WeakSet() + /** Enclosing transport tokens marked terminal by a successful nested call. */ + private concludingParents = new Set() /** Original caller cancellation, kept outside the wrapper-mutable execution object. */ private cancellationStates = new WeakMap() /** Definition-owned final content transform snapshotted before policy begins. */ @@ -969,6 +978,8 @@ export class ToolRegistry extends Service { const signal = exec.signal const definition = this.get(name, agent) const finalizeContent = definition?.finalizeContent?.bind(definition) + const concludingExecutions = this.concludingExecutions + const concludingParents = this.concludingParents const base = { token, callId, @@ -979,6 +990,10 @@ export class ToolRegistry extends Service { deferContext(context: HookContext): void { deferredContexts.push(context) }, + concludeTurn(): void { + if (parent === undefined) concludingExecutions.add(this as unknown as ToolExecution) + else concludingParents.add(parent) + }, } try { const detached = snapshotJsonValue(exec.arguments) @@ -1192,6 +1207,7 @@ export class ToolRegistry extends Service { finalResult = this.materializeFinalResult(toolErrorResult(error)) } this.notifyResult(exec, finalResult) + this.concludingParents.delete(exec.token) return finalResult } @@ -1362,11 +1378,13 @@ export class ToolRegistry extends Service { } meta = snapshotProjection(tool.name, 'presentationMeta', projected) } + const concludesTurn = this.concludingExecutions.has(exec) || this.concludingParents.has(exec.token) return this.markCanonical(exec, this.materializeFinalResult({ isError: false, value, content, ...meta !== undefined ? { meta } : {}, + ...concludesTurn ? { concludesTurn: true as const } : {}, }) as ToolExecutionSuccess) } @@ -1397,6 +1415,7 @@ export class ToolRegistry extends Service { content: result.content, ...result.meta !== undefined ? { meta: result.meta } : {}, ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + ...result.concludesTurn === true ? { concludesTurn: true as const } : {}, } if (result.isError) { return materializePresentation({ isError: true as const, error: result.error, ...presentation }) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 009a00376f..904411dbfe 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -6,7 +6,6 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -18,7 +17,6 @@ import { goalToolExecution, requireDirectHuman, } from './authority.ts' -import type { GoalToolExecution } from './authority.ts' export const name = 'tool-goal' export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] @@ -174,30 +172,9 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } } -/** Remember whether one autonomous terminal report should stop this turn. */ -function observeMutation( - terminalTurns: WeakMap, - execution: GoalToolExecution, - autonomousTerminal: boolean, -): void { - if (!autonomousTerminal) { - terminalTurns.delete(execution.agent) - return - } - terminalTurns.set(execution.agent, execution.start.data.turn) -} - /** Register the three Codex-shaped goal tools and their shared policy section. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) - // A stale entry cannot match a later loop turn because turn numbers increase - // monotonically within the agent's fixed session. - const terminalTurns = new WeakMap() - ctx.on('agent/turn-stop', (agent, turn) => { - if (terminalTurns.get(agent) !== turn) return undefined - terminalTurns.delete(agent) - return { action: 'stop' } - }) ctx.systemPrompt.section({ name: 'tool:goal', order: 114, @@ -238,7 +215,6 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) - observeMutation(terminalTurns, execution, false) return Promise.resolve(goalValue(goal)) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -280,7 +256,6 @@ export function apply(ctx: Context, config: Config): void { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } const goal = ctx.goals.edit(execution.agent, ref, replacements) - observeMutation(terminalTurns, execution, false) return Promise.resolve(goalValue(goal)) } if (args.action === 'pause' || args.action === 'resume') { @@ -294,7 +269,6 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) - observeMutation(terminalTurns, execution, false) return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) @@ -325,7 +299,7 @@ export function apply(ctx: Context, config: Config): void { code: 'model-reported', message: args.blocked_reason as string, }) - observeMutation(terminalTurns, execution, authority.kind === 'goal-round') + if (authority.kind === 'goal-round') exec.concludeTurn() return Promise.resolve(goalValue(goal)) }, presentCall: args => present( diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 522d36e2d9..6bf5efbd35 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -5,15 +5,14 @@ * contribution is ordinary reconstructed request state. * * Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also - * waits for the enclosing `run_code` result. The terminal turn-stop and monotonic tool guard - * then prevent later listeners or calls from reopening a completed structured run. + * waits for the enclosing `run_code` result. The terminal result marker and monotonic tool + * guard prevent later calls from reopening a completed structured run. * @module @deepseek-ai/dsh-subagent-inprocess/structured */ import type { Context } from 'cordis' -import type { ContinuationStop } from '@deepseek-ai/dsh-agent' import type { ToolSchema } from '@deepseek-ai/dsh-llm' -import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import type { ToolExecution, ToolRunContext } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools' /** The model-facing tool name a structured child must call to finish. */ @@ -83,7 +82,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch }, render: () => [{ type: 'text', text: 'Structured output recorded.' }], }, - execute(args: unknown, exec: ToolExecution): Promise<{ recorded: true }> { + execute(args: unknown, exec: ToolRunContext): Promise<{ recorded: true }> { const violations = validateJsonSchemaValue(schema, args) // ToolArgsError → isError result with INVALID_ARGS: the model retries // within the same turn, exactly like a schema-validated defineTool call. @@ -92,6 +91,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch // waterfalls may still turn the success into an error. ToolRegistry has // already frozen model-bound arguments at the actual input boundary. staged.set(exec, { value: args }) + exec.concludeTurn() return Promise.resolve({ recorded: true }) }, }) @@ -102,13 +102,6 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch text: STRUCTURED_OUTPUT_INSTRUCTION, }) - // Stop the child's turn once its output is captured. This monotonic serial - // checkpoint runs after the ordinary continuation waterfall, its reason, - // and late-steering folding, so no ordering trick can resume a finished run. - childCtx.on('agent/turn-stop', function (this: unknown, _agent, _turn, _signal): ContinuationStop | undefined { - return captured === undefined ? undefined : { action: 'stop' } - }) - // Terminal WITHIN the step. Guards run after the whole pre-execute // waterfall and compose monotonically (deny or abstain, never allow), so a // later prepended listener cannot resurrect dispatch. Calls that precede diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 3330c582b4..2837baa12f 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1088,7 +1088,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // produces an error stop reason). const stopReason = await new Promise((resolve, reject) => { rec.inflight = { resolve, reject, turn: undefined } - rec.agent.send(preparedContent, { contexts: preparedContexts }) + rec.agent.send(preparedContent, { source: { kind: 'user' }, contexts: preparedContexts }) }) return { stopReason } }, diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index aef8ffc403..4ca297263a 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2522,9 +2522,9 @@ export function createTuiChat( if (agent.status === 'disposed') { appendNotice(`Agent "${agent.id}" is disposed.`, 'error') } else if (agent.status === 'running') { - agent.steer(content, { contexts }) + agent.steer(content, { source: { kind: 'user' }, contexts }) } else { - agent.send(content, { contexts }) + agent.send(content, { source: { kind: 'user' }, contexts }) } } From 7d5c8b12c0fe93949f99968e93ab6480a2bd8ac1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 24 Jul 2026 13:18:57 +0800 Subject: [PATCH 002/211] fix(agent-loop): preserve unified send contracts --- docs/core-data-structures/core.md | 8 +- packages/context/time-context/src/index.ts | 2 +- .../context/workspace-context/src/index.ts | 25 +- packages/core/agent-loop/src/agent.ts | 196 +++++++------ packages/core/agent-loop/tests/cancel.spec.ts | 4 +- packages/core/agent-loop/tests/loop.spec.ts | 2 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/dispatch.ts | 123 +++++--- packages/core/agent/src/index.ts | 4 +- packages/core/agent/src/types.ts | 277 ++++++++++++++---- packages/core/session/src/types.ts | 2 + packages/core/tools/src/index.ts | 7 +- packages/goal/goal-session/src/index.ts | 6 +- packages/goal/goal/src/index.ts | 2 +- packages/host/runtime/src/api-proxy.ts | 3 +- .../session-checkpoint-policy/src/index.ts | 8 +- packages/skill/tool-skill/src/index.ts | 20 +- packages/ui/tui/src/index.ts | 2 +- packages/ui/user-approval/src/index.ts | 6 +- 19 files changed, 476 insertions(+), 223 deletions(-) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 37e6ddc6dc..b8f6898abe 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -512,13 +512,13 @@ abstract class Agent { * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause - * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm - * later work. The active turn snapshots and freezes the cause. + * `whenIdle()` resolves after cancellation reaches quiescence. Idle + * cancellation is a no-op and does not arm later work. The active turn + * snapshots and freezes the required cause. * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void + abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ abstract whenIdle(): Promise diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index fcf9e36efc..739f56cda0 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -156,7 +156,7 @@ export function apply(ctx: Context, config: Config): void { } const resolvedTimeZone = formatter.resolvedOptions().timeZone - ctx.on('agent/pre-step', ( + ctx.on('agent/step', ( agent: Agent, turn: number, step: number, diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 5bd858a8a0..364004049e 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -1,7 +1,7 @@ /** * Workspace instruction loader for AGENTS.md-compatible files. * - * Baseline instructions are frozen into `agent/session-prefix`; successful fs + * Baseline instructions enter durable context before the first request; successful fs * tool touches reconcile nested, changed, and removed instructions through * `tools/post-execute` for the next model request. Plugin lifecycle reads use * the optional `ctx.fs` provider, so providerless products mount it as a no-op. @@ -11,7 +11,6 @@ import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { Message } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, type ResolvedConfig } from './config.ts' import { loadBaselineInstructionSet } from './files.ts' @@ -50,6 +49,7 @@ export function apply(ctx: Context, config: Config): void { const baselineInstructionStates = new WeakMap>() const instructionVersions: InstructionVersionCache = new WeakMap() const pendingVersionUpdates = new Map() + const baselineLoaded = new WeakSet() const pendingByParent = new Map => { - const rest = await next() - if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest + ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { + if (baselineLoaded.has(agent.session)) return + if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { + baselineLoaded.add(agent.session) + return + } const fileSystem = ctx.get('fs') - if (fileSystem === undefined) return rest + if (fileSystem === undefined) { + baselineLoaded.add(agent.session) + return + } /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = agent.session.header.cwd ?? process.cwd() const instructions = await loadBaselineInstructionSet({ @@ -97,8 +103,11 @@ export function apply(ctx: Context, config: Config): void { }) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) } - if (instructions === undefined || instructions.rendered.text.length === 0) return rest - return [workspaceContextMessage(instructions.rendered.text), ...rest] + if (instructions !== undefined && instructions.rendered.text.length > 0) { + const baselineMessage = workspaceContextMessage(instructions.rendered.text) + agent.inject(baselineMessage.content, { source: { kind: 'plugin', plugin: 'workspace-context' } }) + } + baselineLoaded.add(agent.session) }) ctx.on('tools/post-execute', async ( diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 103967feee..735eeba228 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -14,18 +14,20 @@ * @module dsh-agent-loop/agent */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { Agent, AgentMessageId, agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { - Agent, + AgentMessage, + AgentMessageId as AgentMessageIdType, + CancelOptions, AgentInterruptReason, AgentOptions, AgentStatus, HookContext, IdleReason, - InjectOptions, PromptDecision, SendOptions, } from '@deepseek-ai/dsh-agent' @@ -36,16 +38,19 @@ import type { ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message, MessageSource, } from '@deepseek-ai/dsh-llm' import { canonicalHeader, headerEquals, snapshotJsonValue } from '@deepseek-ai/dsh-session' -import type { PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { JsonValue, PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' /** A prompt waiting for a turn of its own. */ interface QueuedMessage { + id: AgentMessageIdType content: ContentBlock[] source: MessageSource contexts: HookContext[] + wakeup: boolean + meta?: JsonValue } /** Input awaiting the next step boundary. */ @@ -53,6 +58,18 @@ type OutboxItem = | ({ kind: 'steering' } & QueuedMessage) | { kind: 'context'; context: HookContext } +/** Build one live inbox event payload from an accepted message. */ +function inboxMessage(message: QueuedMessage, steering: boolean): AgentMessage { + return { + id: message.id, + content: message.content, + source: message.source, + contexts: message.contexts, + steering, + wakeup: message.wakeup, + } +} + const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = { type: 'text', text: '\n\n## My request:\n', @@ -118,13 +135,13 @@ function withoutToolCalls(message: Message): Message { * history in, one assistant message out, loop until a reply owes no tool call. * One `run()` drains the work queue, one turn per unit. */ -export class ReactLoopAgent implements Agent { +export class ReactLoopAgent extends Agent { /** Prompts awaiting a turn of their own: one dequeued per turn, FIFO. */ private queued: QueuedMessage[] = [] /** Taken whole at every step boundary; caller-editable until taken (taken = entered the log). */ private outbox: OutboxItem[] = [] - /** Whether `run()` is driving a turn right now — the single activity truth. */ + /** Whether observers see one running drain interval; queued turns share it. */ private busy = false /** The active turn's abort owner; rotated per turn, aborted by {@link cancel}. */ private turnAbort: AbortController | undefined @@ -152,13 +169,14 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { + super() this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 // The scope is keyed by this agent — an opaque identity, fine mid-construction. this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx.extend({ agent: this }) } - /** Pure activity: whether a run is driving right now. */ + /** Last activity state published to observers. */ get status(): AgentStatus { return this.busy ? 'running' : 'idle' } @@ -176,47 +194,40 @@ export class ReactLoopAgent implements Agent { // Public driving verbs. // ------------------------------------------------------------------------- - /** Queue a prompt: one turn of its own, FIFO. */ - send(content: ContentBlock[], options: SendOptions): void { - const accepted = this.accept({ content, source: options.source, contexts: options.contexts ?? [] }) - this.queued.push(accepted) - emitAgentEvent(this.loopCtx, this, 'agent/queued', accepted.content, { - source: accepted.source, - contexts: accepted.contexts, - steering: false, - }) - this.kick() - } + /** Accept and route one unified send item. */ + send(content: ContentBlock[], options: SendOptions = {}): AgentMessageIdType { + const id = AgentMessageId(randomUUID()) + const target = options.target ?? 'next-turn' + const wakeup = options.wakeup ?? true + if (target === 'next-step' && !wakeup) { + this.injectContext(content, options) + return id + } - /** Steer the running turn: taken at the next step boundary. With no turn running, falls back to {@link send}. */ - steer(content: ContentBlock[], options: SendOptions): void { - // `busy` (a turn is actually running), not status: status stays `running` - // across chained turns and through the agent/idle report, where steering - // has no live turn to join and must become a prompt of its own. - if (!this.busy) { this.send(content, options); return } - const accepted = this.accept({ content, source: options.source, contexts: options.contexts ?? [] }) - this.outbox.push({ kind: 'steering', ...accepted }) - emitAgentEvent(this.loopCtx, this, 'agent/queued', accepted.content, { - source: accepted.source, - contexts: accepted.contexts, - steering: true, - }) - } - - /** - * Stage model-facing context without running the model: it rides along with - * whatever runs next (the next step of the running turn, or the next turn). - * While the agent is idle the context is committed immediately as a one-shot - * turn. Appending IS the durable write — persistence drains eagerly on - * every append and owns the write chain end to end. - */ - inject(content: ContentBlock[], options: InjectOptions): void { - const context = this.accept({ + const steering = target === 'next-step' && this.turnAbort !== undefined + const accepted = this.accept({ + id, content, - source: options.source, + source: options.source ?? { kind: 'user' }, + contexts: options.contexts ?? [], + wakeup, ...options.meta === undefined ? {} : { meta: options.meta }, }) - if (this.busy) { + if (steering) this.outbox.push({ kind: 'steering', ...accepted }) + else this.queued.push(accepted) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', inboxMessage(accepted, steering)) + if (!steering && wakeup) this.kick() + return id + } + + /** Stage non-waking context at the next step boundary, or in an idle one-shot turn. */ + private injectContext(content: ContentBlock[], options: SendOptions): void { + const context = this.accept({ + content, + source: options.source ?? { kind: 'plugin', plugin: '' }, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + if (this.turnAbort !== undefined) { this.outbox.push({ kind: 'context', context }) return } @@ -227,7 +238,7 @@ export class ReactLoopAgent implements Agent { try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source: context.source } }) opened = true - this.session.append('context/message', context, { surfaceOp: 'append' }) + this.session.append('user/message', context, { surfaceOp: 'append' }) } finally { // Close only a turn whose start committed; a pre-commit veto escapes. if (opened) this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) @@ -241,16 +252,23 @@ export class ReactLoopAgent implements Agent { * `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose, * all owned by the factory. */ - cancel(cause: AgentInterruptReason = { kind: 'user' }): void { + cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void { if (this.turnAbort !== undefined || this.queued.length > 0 || this.outbox.length > 0) { // Observe-only: coordination consumers update their state before the // inboxes clear; listener failures are contained by the dispatcher. - emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) + if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) + } + if (!options.keepInbox) { + const steering = this.outbox.flatMap(item => item.kind === 'steering' ? [item] : []) + const discarded = [ + ...this.queued.map(message => inboxMessage(message, false)), + ...steering.map(message => inboxMessage(message, true)), + ] + // Clear before abort observers run: replacement work belongs to the next turn. + this.queued.length = 0 + this.outbox.length = 0 + if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) } - // Clear before abort observers run: a replacement enqueued by an observer - // belongs to the next turn. - this.queued.length = 0 - this.outbox.length = 0 this.turnAbort?.abort(Object.freeze({ kind: cause.kind })) } @@ -261,15 +279,15 @@ export class ReactLoopAgent implements Agent { * @throws while a turn is running — there is nothing to retry yet. */ retry(): void { - if (this.busy) throw new Error(`agent "${this.id}" cannot retry while busy`) + if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`) this.start() } - /** Resolve at idle quiescence: no run driving and no prompt waiting. */ + /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */ async whenIdle(): Promise { // `done` is replaced per run, so re-reading it each lap follows chained // turns; a run failure still counts as quiescence for the waiter. - while (this.busy || this.queued.length > 0) await this.done.catch(() => undefined) + while (this.turnAbort !== undefined || this.queued.some(message => message.wakeup)) await this.done.catch(() => undefined) } // ------------------------------------------------------------------------- @@ -278,18 +296,25 @@ export class ReactLoopAgent implements Agent { /** Claim the next queued prompt and open a run on it, when nothing is driving. */ private kick(): void { - if (this.busy) return + if (this.turnAbort !== undefined || !this.queued.some(message => message.wakeup)) return const message = this.queued.shift() - if (message !== undefined) this.start(message) + if (message !== undefined) { + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false)) + this.start(message) + } } - /** Open one `run()` — on a claimed prompt, or promptless for a retry. The caller has checked `busy`. */ + /** Open one `run()` — on a claimed prompt, or promptless for a retry. */ private start(prompt?: QueuedMessage): void { - this.busy = true - emitAgentEvent(this.loopCtx, this, 'agent/status', 'running') + const controller = new AbortController() + this.turnAbort = controller + if (!this.busy) { + this.busy = true + emitAgentEvent(this.loopCtx, this, 'agent/status', 'running') + } // The whole run inherits this agent as its process-local initiator so // tools, the llm service, and nested factories can attribute their work. - this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt)) + this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt, controller)) } /** @@ -300,9 +325,7 @@ export class ReactLoopAgent implements Agent { * boundaries and runs the idle tail, which opens the next run while work * remains. */ - private async run(prompt?: QueuedMessage): Promise { - const controller = new AbortController() - this.turnAbort = controller + private async run(prompt: QueuedMessage | undefined, controller: AbortController): Promise { const signal = controller.signal const turn = ++this.lastTurn let idle: IdleReason = { kind: 'completed' } @@ -342,7 +365,10 @@ export class ReactLoopAgent implements Agent { prompt.source, decision.additionalContexts ?? [], ) - this.session.append('user/message', prepared.data, { surfaceOp: 'append' }) + this.session.append('user/message', { + ...prepared.data, + ...prompt.meta === undefined ? {} : { meta: prompt.meta }, + }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { this.outbox.push({ kind: 'context', context: this.accept(context) }) } @@ -369,7 +395,7 @@ export class ReactLoopAgent implements Agent { this.closeTurn(turn, step, reason) } catch (error: unknown) { // A rejected boundary append (a pre-commit validation veto) must not - // kill the machine or leave `busy` stuck: report and move on — the + // kill the machine or strand its running interval: report and move on — the // idle tail below still runs and the next turn still opens. const err = toError(error) this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`) @@ -544,7 +570,7 @@ export class ReactLoopAgent implements Agent { for (const item of this.outbox.splice(0)) { if (item.kind === 'context') { const { content, source, meta } = item.context - this.session.append('context/message', { + this.session.append('user/message', { content, source, ...meta === undefined ? {} : { meta }, @@ -552,11 +578,16 @@ export class ReactLoopAgent implements Agent { continue } steered = true + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(item, true)) const prepared = preparePromptMessage(item.content, item.source, item.contexts) - this.session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) + this.session.append('steering/message', { + turn, + ...prepared.data, + ...item.meta === undefined ? {} : { meta: item.meta }, + }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { const { content, source, meta } = context - this.session.append('context/message', { + this.session.append('user/message', { content, source, ...meta === undefined ? {} : { meta }, @@ -607,31 +638,30 @@ export class ReactLoopAgent implements Agent { } /** - * The turn boundary's tail (naive `idle()`): the machine is no longer busy, + * The turn boundary's tail (naive `idle()`): no turn owner remains, * the idle report fires (a listener may synchronously `retry()` or `send()` * here — both are legal now), leftover steering becomes queued prompts, and * the next run opens while the queue is non-empty; otherwise the machine * parks. */ private idle(turn: number, idle: IdleReason): void { - this.busy = false - // Status mirrors busy faithfully: chained turns pulse idle → running, - // which is honest — a listener really can act in this window. - emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle') // Requeue BEFORE the idle report so earlier-arrived steering keeps its // FIFO position ahead of anything a listener send()s synchronously. for (const item of this.outbox.splice(0)) { - if (item.kind === 'steering') this.queued.push({ - content: item.content, - source: item.source, - contexts: item.contexts, - }) - else this.outbox.push(item) + if (item.kind === 'context') { + this.outbox.push(item) + continue + } + const { kind: _kind, ...message } = item + this.queued.push(message) } emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle) - // A synchronous idle listener may retry()/send(), flipping busy back. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (this.busy) return // a listener already re-opened - if (this.queued.length > 0) this.kick() + // A synchronous idle listener may retry()/send(), installing a new owner. + if (this.turnAbort !== undefined) return // a listener already re-opened + if (this.queued.some(message => message.wakeup)) this.kick() + else { + this.busy = false + emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle') + } } } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 3a7af2871f..fcace76767 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -71,7 +71,7 @@ describe('Agent.cancel()', () => { }) send(agent, 'drop me') - agent.cancel() + agent.cancel({ kind: 'user' }) await new Promise(resolve => setTimeout(resolve, 30)) agent.cancel({ kind: 'parent' }) @@ -403,7 +403,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) - agent.cancel() + agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'aborted' }]) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index e88703b8f8..396abec100 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index ebb756b139..66e0d8e347 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -60,7 +60,7 @@ The handle every plugin programs against: - `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. - `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. -- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. +- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index c00a440e78..9444f8f883 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,11 +1,7 @@ /** - * Agent-scoped dispatch helpers. An agent-subject event travels with the - * agent's scope carrier as `thisArg` (so scoped listeners filter to their own - * agent) and the agent itself as the first argument. Composable seams are - * plain `ctx.waterfall(carrier, name, agent, …, next)` calls at the machine's - * call sites — concrete event names type-check against the real Cordis - * overloads, so no generic wrapper (and none of its casts) is needed. The one - * helper here is {@link emitAgentEvent}: a contained fire-and-forget emit. + * Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the + * fused dispatcher so subject and scope key cannot diverge; registry lifecycle + * code instead captures one stable carrier for both edges. * @module @deepseek-ai/dsh-agent/dispatch */ @@ -15,6 +11,11 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' import type { Agent } from './types.ts' +/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */ +type Params = F extends (...args: infer P) => unknown ? P : never +/** Extract the return type from an event handler type. */ +type Return = F extends (...args: never[]) => infer R ? R : never + /** * The event names whose subject is an agent: handler parameters start with an * `Agent` AND the handler declares a `Scoped` `this` (the scope-carrier @@ -29,46 +30,102 @@ export type AgentSubjectEvent = { }[keyof Events] /** The event arguments AFTER the injected agent subject. */ -type Tail = Events[K] extends (...args: infer P) => unknown - ? P extends [Agent, ...infer R] ? R : never - : never +type Tail = Params extends [Agent, ...infer R] ? R : never /** - * The scope carrier for an agent-subject dispatch: the agent fused as both - * the carrier key and the event subject, so the two cannot diverge. Pass it - * as the `thisArg` of `ctx.serial` / `ctx.waterfall` for agent events. - * @param agent - the subject agent. - * @returns the fused carrier. + * The fused dispatcher {@link agentEvents} returns: each method dispatches the + * named agent-subject event with the agent's scope carrier as `thisArg` and + * the agent itself injected as the first event argument. */ +export interface AgentEventDispatch { + /** + * Fire-and-forget notification in the agent's scope. Every listener is + * invoked; synchronous throws and returned-promise rejections are logged and + * contained per listener, so a notification cannot veto lifecycle progress + * or starve a later observer. + * @param name - the agent-subject event to emit. + * @param rest - the event's arguments after the injected agent. + */ + emit(name: K, ...rest: Tail): void + /** + * Awaited in-order dispatch (Cordis `serial`) in the agent's scope. + * @param name - the agent-subject event to dispatch. + * @param rest - the event's arguments after the injected agent. + * @returns the serial chain's result (the first bail value, if any). + */ + serial(name: K, ...rest: Tail): Promise>> + /** + * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The + * declared event parameters already end with the `next` callback, so `rest` + * is exactly the event's arguments after the injected agent — the final + * element being the innermost `next` (the default the listener chain wraps). + * @param name - the agent-subject event to dispatch. + * @param rest - the event's arguments after the injected agent. + * @returns the waterfall's composed result. + */ + waterfall(name: K, ...rest: Tail): Return +} + +/** Return the fused scope carrier for one agent subject. */ export function agentCarrier(agent: Agent): Scoped { return scopeTarget(agent, agent) } /** - * Fire-and-forget notification in the agent's scope. Every listener is - * invoked; synchronous throws and returned-promise rejections are logged and - * contained per listener, so a notification cannot veto lifecycle progress or - * starve a later observer. (Raw Cordis `emit` maps callbacks unguarded — one - * synchronous throw would starve the rest and escape into the caller.) + * Build a dispatcher that couples the agent subject to its scope carrier. * @param ctx - the context to dispatch through (any context of the app). * @param agent - the subject agent; also the scope-carrier key. - * @param name - the agent-subject event to emit. - * @param rest - the event's arguments after the injected agent. + * @returns the fused dispatcher. */ -export function emitAgentEvent(ctx: Context, agent: Agent, name: K, ...rest: Tail): void { - const args: unknown[] = [agentCarrier(agent), name, agent, ...rest] - for (const callback of ctx.events.dispatch('emit', args)) { - try { - const returned: unknown = callback(...args) - void Promise.resolve(returned).catch((error: unknown) => { - ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`) - }) - } catch (error: unknown) { - ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`) - } +export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { + const carrier = agentCarrier(agent) + // The ordinary dispatch methods forward through Cordis' variadic mixins. The + // fused (carrier, name, agent, ...rest) tuple is provably a valid argument + // list for the matching thisArg overload, but TypeScript cannot relate the + // generic Tail spread back to that overload's conditional parameter + // tuple — hence one contained, shape-preserving cast per method. + return { + emit(name, ...rest) { + // Cordis emit invokes callbacks through Array.map: one synchronous throw + // starves later listeners, and returned promises are discarded. Agent + // notifications are non-vetoing, so resolve the same filtered callback + // set ourselves and contain both failure modes independently. + const args: unknown[] = [carrier, name, agent, ...rest] + const callbacks = ctx.events.dispatch('emit', args) + for (const callback of callbacks) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`) + }) + } catch (error: unknown) { + ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`) + } + } + }, + async serial(name, ...rest) { + // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function + const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise + return await serial(carrier, name, agent, ...rest) + }, + waterfall(name, ...rest) { + // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function + const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never + return waterfall(carrier, name, agent, ...rest) + }, } } +/** Emit one contained agent notification without allocating a retained dispatcher. */ +export function emitAgentEvent( + ctx: Context, + agent: Agent, + name: K, + ...rest: Tail +): void { + agentEvents(ctx, agent).emit(name, ...rest) +} + /** * Build the prompt assembly context with agent and scope set together, so * agent-scoped prompt and tool contributions cannot be silently omitted. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 21ea0ba7c1..38dab02993 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -17,8 +17,8 @@ import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export { agentInterruptReasonOf } from './cancellation.ts' export * from './llm-target.ts' -export { agentCarrier, assembleContextFor, emitAgentEvent } from './dispatch.ts' -export type { AgentSubjectEvent } from './dispatch.ts' +export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' +export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 2292c81f89..d3b29ef6c2 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -23,6 +23,7 @@ */ import type { Context } from 'cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' @@ -42,47 +43,125 @@ export interface AgentOptions { model?: string } -/** One queued prompt or steering item and its atomic model-facing context. */ -export interface SendOptions { - /** Explicit producer attribution; callers may not inherit human authority by omission. */ - source: MessageSource - /** Context snapshotted with this item and admitted at the same boundary. */ - contexts?: HookContext[] -} +/** + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — the item joins the active turn between steps as steering, + * or, when no turn is active, is promoted per its `wakeup` flag. + */ +export type SendTarget = 'next-turn' | 'next-step' -/** Options for synthetic context injection. */ -export interface InjectOptions { - /** Explicit producer attribution. */ - source: MessageSource - /** Opaque durable state omitted from the model projection. */ +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. + */ +export interface SendOptions { + /** Queue the item joins; defaults to `next-turn`. */ + target?: SendTarget + /** + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). Defaults to + * `true`. A `false` `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). + */ + wakeup?: boolean + source?: MessageSource + /** + * Model-facing contexts captured with this inbox item. A queued prompt exposes + * them through the default `agent/prompt-submit` allow decision, while steering + * records them directly at its next checkpoint. + */ + contexts?: HookContext[] + /** Opaque JSON state retained on the durable message but hidden from the model. */ meta?: JsonValue } +/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ +export type AliasSendOptions = Omit + /** - * An agent's ACTIVITY state, emitted on every transition as `agent/status`: - * `idle` (parked, waiting for queued work) or `running` (the machine is - * draining work). Lifecycle is a separate axis: an agent leaving its host is - * announced by `agent/disposed` and observable as `ctx.agents.get(id)` no - * longer returning it — not as a status value. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. + */ +export type AgentMessageId = Branded<'AgentMessageId'> + +/** + * Brand a string as an {@link AgentMessageId}. + * @param id - the generated message id. + * @returns the same string, branded; no validation is performed. + */ +export function AgentMessageId(id: string): AgentMessageId { + return id as AgentMessageId +} + +/** + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is + * durable model-hidden state that lands on the eventual `user/message`/ + * `steering/message`, not live-event routing data. + */ +export interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + steering: boolean + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean +} + +/** Options for {@link Agent.cancel}. */ +export interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean +} + +/** + * An agent's lifecycle state, emitted on every transition as `agent/status`: + * `idle` (parked, waiting for queued work), `running` (the driver is draining + * work and may be closing or checkpointing a turn), `disposed` (terminal — no + * transition leaves it, and `send`/`followup`/`steer`/`inject` throw). */ export type AgentStatus = 'idle' | 'running' -/** Model-facing context injected by a listener or atomically attached to one inbox item. */ +/** Model-facing context injected by a listener or atomically attached to one inbox message. */ export interface HookContext { content: ContentBlock[] source: MessageSource - /** `prompt-prefix` bakes this context into its prompt; absent/`separate` records an independent message. */ + /** + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable + * request delimiter to the same user-role message as its attached prompt. + */ placement?: 'separate' | 'prompt-prefix' - /** Opaque durable state omitted from the model projection. */ + /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } /** * Prompt interception result. `allow.content` replaces the prompt. Each - * `additionalContexts` entry follows its declared placement. `block` records - * a durable `prompt/blocked` and ends the claimed prompt's zero-step turn as - * rejected. A listener wrapping `next()` preserves downstream fields unless - * it intentionally replaces them. + * `additionalContexts` entry follows its declared placement: separate context + * message by default, or a prefix inside the prompt's user-role message. + * `block` records a durable `prompt/blocked` and ends the claimed prompt's + * zero-step turn as rejected. An `allow` returned by a listener is + * authoritative: a listener wrapping `next()` preserves downstream `content` + * and `additionalContexts` unless it intentionally replaces them. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } @@ -110,47 +189,98 @@ export type AgentCancelCause = /** Runtime reason carried by the signal that controls one live turn. */ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } -/** Public live-agent handle; driving methods have no contract after disposal. */ -export interface Agent { +/** Public live-agent handle with aliases over the unified delivery primitive. */ +export abstract class Agent { /** The single identity shared with {@link session}. */ - readonly id: SessionId - readonly options: AgentOptions - readonly session: Session - readonly status: AgentStatus + abstract readonly id: SessionId + /** The provider route and model this agent's requests use. */ + abstract readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + abstract readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + abstract readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - readonly ctx: Context + abstract readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON prompt. Each claimed prompt is - * the sole ordinary message in its FIFO-ordered turn; the next claimed - * prompt waits for that turn's checkpoint. - * Invalid input throws synchronously before notification or enqueue. + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * Detaches, validates, and freezes one lossless-JSON item, then routes it: + * + * - `next-turn` (default) queues an item that becomes the sole ordinary + * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` submits steering into the active turn + * (idle falls back to a woken `next-turn`). + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: an open turn joins at the current log position + * (deferred behind an executing tool batch until it settles), and an idle + * inject records a one-shot turn with its own durability checkpoint. + * + * Attached contexts share the same snapshot and ownership boundary. Invalid + * input throws synchronously before any notification, enqueue, or append. + * @param content - the model-facing content blocks to deliver. + * @param options - target queue, wakeup decision, source, contexts, and meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - send(content: ContentBlock[], options: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId /** - * Submit steering while the agent is `running`: it enters the outbox and is - * taken whole at the next step boundary, before the next request. Steering - * left over when the turn closes queues for a turn of its own. When idle, - * delegates to {@link send}. + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause + * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm + * later work. The active turn snapshots and freezes the cause. + * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. */ - steer(content: ContentBlock[], options: SendOptions): void + abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void + + /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + abstract whenIdle(): Promise /** - * Stage detached model-facing context without running the model: it enters - * the outbox and rides along with whatever runs next — the next step of the - * running turn (never between a tool-call batch and its results), or the - * next turn when idle. + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param content - the prompt content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options: InjectOptions): void + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) + } /** - * Clear all queued and outbox work and abort the active turn. An effective - * call first emits `agent/cancel-requested` with the resolved typed cause; - * the first cause wins for the active turn. Omission means `{ kind: 'user' }`. - * Idle cancellation is a no-op and does not arm later work. + * Submit steering into the running turn — the `next-step`/wakeup preset of + * {@link send}. An open turn records it at the next steering checkpoint before + * a request or continuation decision; policy may stop before another step. + * After turn close and its checkpoint, any remainder is queued for a later + * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. + * Idle steering falls back to a woken follow-up turn. + * @param content - the steering content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - cancel(cause?: AgentInterruptReason): void + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) + } + + /** + * Append detached model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins + * at the current log position unless the current tool batch is executing; + * then it waits FIFO until that batch settles and drains before turn close + * even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through + * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}. + */ + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) + } /** * Re-open a turn on the current session log without a new prompt — the @@ -160,10 +290,7 @@ export interface Agent { * `agent/idle` listener is legal — the machine is already idle there. * @throws while a turn is running because there is nothing to retry yet. */ - retry(): void - - /** Resolve at idle quiescence; disposal waits for machine exit rather than only the status transition. */ - whenIdle(): Promise + abstract retry(): void } declare module 'cordis' { @@ -181,7 +308,7 @@ declare module 'cordis' { */ 'agent/created'(this: Scoped, agent: Agent): void /** - * An agent left the registry; AgentLoop emits this after machine quiescence + * 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. @@ -190,7 +317,7 @@ declare module 'cordis' { */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * Agent activity changed (`idle` ⇄ `running`). `send()` does + * 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). @@ -199,15 +326,33 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * Detached, frozen content entered the agent's inbox (prompt queue or - * steering outbox). 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, contexts, and steering classification. + * A frozen item entered the queued or steering inbox. + * @param agent - the owning agent. + * @param message - accepted routing data and correlation identity. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void + 'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage): void + /** + * The driver claimed one item out of the inbox: a queued item at a turn + * boundary, or steering drained between steps. Fires after the item leaves + * its FIFO and before it becomes a durable message. + * @param agent - the agent whose inbox item was claimed. + * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void + /** + * `cancel()` (without `keepInbox`) dropped pending inbox items without + * delivering them. Fires once per effective clearing call with every + * discarded item, after `agent/cancel-requested` and before the abort. + * @param agent - the agent whose inbox was cleared. + * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/discard'(this: Scoped, agent: Agent, messages: AgentMessage[]): void /** * Effective broad cancellation was requested, before queued/outbox work * is cleared or the active turn is aborted. This observe-only notification @@ -217,12 +362,14 @@ declare module 'cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentInterruptReason): void + 'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentCancelCause): void + + // ---- session lifecycle (emit) ---- /** * 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 - * machine starts. + * 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`): agent-scoped listeners receive only that agent. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 017245aed2..d1b6220210 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -82,6 +82,8 @@ export interface CreateSessionOptions { */ export interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } + /** Recovery turn reopened over the repaired current session log. */ + retry: { kind: 'retry' } /** * An out-of-band context injection (`agent.inject()`) made while the agent * was idle. The loop wraps the injected `user/message` (a non-`user` source, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 2c67c72359..c397b362d9 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1415,12 +1415,15 @@ export class ToolRegistry extends Service { content: result.content, ...result.meta !== undefined ? { meta: result.meta } : {}, ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, - ...result.concludesTurn === true ? { concludesTurn: true as const } : {}, } if (result.isError) { return materializePresentation({ isError: true as const, error: result.error, ...presentation }) } - const detached = materializePresentation({ isError: false as const, ...presentation }) + const detached = materializePresentation({ + isError: false as const, + ...presentation, + ...result.concludesTurn === true ? { concludesTurn: true as const } : {}, + }) return deepFreeze({ ...detached, value: result.value }) } } diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index a4883a0f7c..35522c9372 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -106,7 +106,7 @@ export function apply(ctx: Context): void { /** Read only when the exact Agent remains live. */ function currentGoal(state: DriverState): GoalView | undefined { - if (ctx.agents.get(state.agent.id) !== state.agent || state.agent.status === 'disposed') return undefined + if (ctx.agents.get(state.agent.id) !== state.agent) return undefined return ctx.goals.get(state.agent) } @@ -297,10 +297,6 @@ export function apply(ctx: Context): void { }) ctx.on('agent/status', (agent, status) => { const state = stateFor(agent) - if (status === 'disposed') { - state.stopping = true - return - } if (status === 'idle') { state.competingQueued = false requestDrive(state) diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index e4700452fa..b234e6f4ab 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -346,7 +346,7 @@ export class GoalService extends Service { /** Enforce exact live-agent identity rather than trusting a matching id. */ private assertLive(agent: Agent): void { - if (this.ctx.agents.get(agent.id) !== agent || agent.status === 'disposed') { + if (this.ctx.agents.get(agent.id) !== agent) { throw new GoalError(`agent "${agent.id}" is not live in this registry`, 'GOAL_AGENT_NOT_LIVE') } } diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 2d674912cd..0858394f5a 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -455,7 +455,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { sessionId }, })) } - agent.cancel() + agent.cancel({ kind: 'user' }) return Promise.resolve(ok(request, { accepted: true as const })) }, }, @@ -542,7 +542,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/session-removed', sessionId: session.id })) }), ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { - if (status === 'disposed') return queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' })) }), ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => { diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index 0dcd2045a9..9108a29c14 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -52,8 +52,8 @@ function abortedBeforeDispatchResult(): ToolExecutionResult { /** * Install semantic checkpoint listeners. Loop-built model calls checkpoint the * logged request before adapter dispatch; top-level tool calls checkpoint their - * recorded call before the tool body; post-step checkpoints retain the complete - * response/result batch. Nested tool dispatches reuse the durable outer call. + * recorded call before the tool body; the next request boundary checkpoints + * the preceding response/result batch. Nested tool dispatches reuse the durable outer call. * * Checkpoint failures are fail-closed at the model and tool side-effect * boundaries: the downstream adapter or tool body is not invoked. @@ -74,5 +74,7 @@ export function apply(ctx: Context): void { return next() }) - ctx.on('agent/post-step', (agent): Promise => ctx.sessions.flush(agent.session)) + // Before each request, persist everything committed by the preceding step; + // the first step's call is an intentional no-op beyond any prompt intake. + ctx.on('agent/step', (agent): Promise => ctx.sessions.flush(agent.session)) } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index f5fbd1dff5..ae9439e060 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -1,11 +1,12 @@ /** - * Session-prefix skill catalog and model-facing `skill` loader tool. + * Durable session skill catalog and model-facing `skill` loader tool. * * @module @deepseek-ai/dsh-tool-skill */ import type { Context } from 'cordis' import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { assertNever, type Message } from '@deepseek-ai/dsh-llm' import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' @@ -115,12 +116,19 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. - ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise => { - if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next() + const catalogLoaded = new WeakSet() + ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { + if (catalogLoaded.has(agent.session)) return + if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) { + catalogLoaded.add(agent.session) + return + } const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal }) - const rest = await next() - if (skills.length === 0) return rest - return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest] + if (skills.length > 0) { + const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength) + agent.inject(catalog.content, { source: { kind: 'plugin', plugin: 'dsh-tool-skill' } }) + } + catalogLoaded.add(agent.session) }) } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 4ca297263a..74ab4a2256 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2519,7 +2519,7 @@ export function createTuiChat( } const dispatchMessage = (content: ContentBlock[], contexts: HookContext[]): void => { - if (agent.status === 'disposed') { + if (disposed) { appendNotice(`Agent "${agent.id}" is disposed.`, 'error') } else if (agent.status === 'running') { agent.steer(content, { source: { kind: 'user' }, contexts }) diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 329fad33a7..a71638bb1a 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -235,8 +235,8 @@ export class ApprovalService extends Service { }) }) - // Visibility layer 2: the boundary narrator. pre-step runs after prompt - // assembly but before the request history is derived, so the notice is + // Visibility layer 2: the boundary narrator. agent/step runs before the + // request history is derived, so the notice is // seen by THIS step's request: idle-time flip-flops coalesce at the // turn's first step (net-zero → nothing), and a mid-turn switch is // narrated no later than the next step. What each session was last told @@ -246,7 +246,7 @@ export class ApprovalService extends Service { // switch by the user; otherwise the configured default moved under the // session (operator/config). const narrated = new WeakMap() - ctx.on('agent/pre-step', (agent) => { + ctx.on('agent/step', (agent) => { const session = agent.session const events = session.events let overrideIndex = -1 From 5c7505b208561b00781707cafa0f461907306bc4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 24 Jul 2026 14:05:33 +0800 Subject: [PATCH 003/211] refactor(agent): remove message metadata channel --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 12 +- ...ied-send-and-coalesced-user-messages.zh.md | 12 +- .../feature/2026-06-30-interception-seams.md | 2 +- docs/core-data-structures/core.i18n.yaml | 6 + docs/core-data-structures/core.md | 18 +- docs/core-data-structures/core.zh.md | 667 ++++++++++++++++++ docs/core-data-structures/session.i18n.yaml | 6 + docs/core-data-structures/session.md | 17 +- docs/core-data-structures/session.zh.md | 557 +++++++++++++++ docs/persistence-catalog.md | 28 +- .../headless-agent/tests/code-mode.e2e.ts | 6 +- .../src/client/sessions/conversation.ts | 1 - .../src/client/sessions/fold-adapter.ts | 1 - .../src/client/chat/MessageItem.tsx | 2 +- .../tests/chat-branch-tails.spec.tsx | 2 +- packages/context/session-reference/README.md | 2 +- .../context/session-reference/src/index.ts | 11 +- .../context/session-reference/src/types.ts | 24 + .../tests/session-reference.spec.ts | 9 +- packages/context/workspace-context/README.md | 6 +- .../context/workspace-context/src/index.ts | 1 - .../workspace-context/src/invariant.ts | 2 +- .../context/workspace-context/src/state.ts | 53 +- .../tests/workspace-context.e2e.ts | 7 +- .../tests/workspace-context.spec.ts | 70 +- .../cordis/tool-cordis/src/api-catalog.ts | 102 +-- packages/core/agent-loop/src/agent.ts | 212 +++--- .../agent-loop/tests/interception.spec.ts | 12 +- packages/core/agent-loop/tests/loop.spec.ts | 41 +- packages/core/agent/README.md | 4 +- packages/core/agent/src/types.ts | 12 +- .../core/scope/src/scoped-events.generated.ts | 10 +- packages/core/session/README.md | 2 +- packages/core/session/src/types.ts | 13 +- packages/core/session/tests/session.spec.ts | 12 +- packages/core/tools/tests/tools.spec.ts | 3 +- .../command-goal/tests/command-goal.spec.ts | 1 - .../goal/goal-session/tests/invariant.spec.ts | 3 +- packages/goal/goal/README.md | 6 +- packages/goal/goal/src/fold.ts | 38 +- packages/goal/goal/src/index.ts | 12 +- packages/goal/goal/src/render.ts | 2 +- packages/goal/goal/src/runtime.ts | 2 +- packages/goal/goal/src/types.ts | 6 +- packages/goal/goal/tests/goal.e2e.ts | 4 +- packages/goal/goal/tests/goal.spec.ts | 31 +- packages/goal/goal/tests/invariant.spec.ts | 5 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 1 - .../hooks-claude/tests/coverage-cases.ts | 4 - .../hooks/hooks-codex/tests/coverage-cases.ts | 4 - packages/ui/tui/src/index.ts | 10 +- .../tui/tests/session-reference.snapshot.ts | 5 +- packages/ui/tui/tests/tui.spec.ts | 28 +- scripts/gen-cordis-catalog.ts | 1 + 55 files changed, 1610 insertions(+), 502 deletions(-) create mode 100644 docs/core-data-structures/core.i18n.yaml create mode 100644 docs/core-data-structures/core.zh.md create mode 100644 docs/core-data-structures/session.i18n.yaml create mode 100644 docs/core-data-structures/session.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 7c3ed96243..0d217f098a 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e +2026-07-22-unified-send-and-coalesced-user-messages.md: dbd157ad4c81f278cf1417765816497b9e3568df +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: dea32f142c4ef005ff04d00ef8c8ae1ea19c9705 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 9eb355128b..dbd157ad4c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -8,23 +8,23 @@ English | [中文](2026-07-22-unified-send-and-coalesced-user-messages.zh.md) The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work. -Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried `source`/`meta` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). +Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried a non-user `source` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). ## Decision -**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts, meta })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller. +**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller. **inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position (deferred behind an executing tool batch), or a one-shot `injection` turn when idle. It bypasses the FIFOs entirely and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. -**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. +**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind. Typed source variants carry any domain-specific durable provenance. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. -**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. +**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` whose source carries the complete change; a positive round is an admitted continuation prompt. `decodeGoalEvent` takes a `user/message` and fails loud when goal-state content and its typed source disagree. **`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`. **Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, `target`/`wakeup`, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. -**cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). +**cancel gains keepInbox.** `cancel(cause, { keepInbox? })`; callers choose the cause explicitly, and `keepInbox: true` aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped). ## Alternatives considered @@ -36,7 +36,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. +`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 679c9100aa..dea32f142c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -8,23 +8,23 @@ Status: implemented agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`、`steer`、`inject`——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。`send` 和 `steer` 都会把一条冻结的 inbox 记录入队并发出 `agent/queued`;`inject` 则绕过 inbox,写入一条独立的 `context/message` 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让“排入一个轮次但不唤醒驱动器”无法表达,也让 `cancel()` 无从在保留排队工作的前提下中止一个轮次。 -另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带 `source`/`meta` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 +另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带非 user `source` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 ## 决策 -**一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source, contexts, meta })` 覆盖 (`target` × `wakeup`) 矩阵。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)是基类上的具体委托方法,因此具体驱动器只需实现一次 `send`,就能继承这些好用的预设。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`send` 默认使用 `{ target: 'next-turn', wakeup: true }`,因此此前每一次裸调用 `agent.send(content)` 都保持完全相同的行为。`next-turn`/no-wakeup(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。 +**一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source, contexts })` 覆盖 (`target` × `wakeup`) 矩阵。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)是基类上的具体委托方法,因此具体驱动器只需实现一次 `send`,就能继承这些好用的预设。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`send` 默认使用 `{ target: 'next-turn', wakeup: true }`,因此此前每一次裸调用 `agent.send(content)` 都保持完全相同的行为。`next-turn`/no-wakeup(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。 **inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:在当前日志位置追加的持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时的一次性 `injection` 轮次。它完全绕过 FIFO 队列,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 -**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 +**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别。类型化 source 变体携带所有特定于领域的持久 provenance。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 -**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 +**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,其 source 携带完整变更;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 接收一条 `user/message`,并在 goal 状态内容与其类型化 source 不一致时立即报错。 **`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。 **三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、`target`/`wakeup`、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 -**cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 +**cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 ## 考虑过的替代方案 @@ -36,7 +36,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 ## 相关 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index a0504893d9..05d530137c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -16,7 +16,7 @@ The canonical surface separates transformable policy, around-dispatch control, a - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. - `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired for the turn's single claimed queued message before the `user/message` append. The explicit turn signal is placed before the final `next`; `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`, while `block` appends a durable `prompt/blocked` and rejects that zero-step turn. -**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. It is not a `context/message`, so its type does not offer durable context metadata. +**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. Its narrower type does not carry attached contexts. ### The tool pipeline gives each phase one kind of authority diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml new file mode 100644 index 0000000000..2faf718836 --- /dev/null +++ b/docs/core-data-structures/core.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +core.md: 9d0df9f5b8f1b58366f3f54f1627cdcedc7bda57 +core.zh.md: 33c4267f7e2e40f1c48b3d6f07563c8db6817213 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b8f6898abe..9d0df9f5b8 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -1,5 +1,7 @@ # Core Data Structures +English | [中文](core.zh.md) + This folder catalogs the **data structures** of the DeepSeek Harness — what each core type represents, its literal shape, and where the full detail lives. It complements [architecture.md](../architecture.md), which describes *behavior* (the service map, the session/turn/step lifecycle, the event taxonomy); this page describes the *vocabulary* that behavior moves around. ## What counts as "core" @@ -397,8 +399,6 @@ interface SendOptions { * records them directly at its next checkpoint. */ contexts?: HookContext[] - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue } ``` @@ -428,9 +428,7 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t * message's enqueue, dequeue, and discard events. Source defaults are already * applied, so these are the exact values the item was accepted with. `steering` * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is - * durable model-hidden state that lands on the eventual `user/message`/ - * `steering/message`, not live-event routing data. + * claimed at a turn boundary. */ interface AgentMessage { /** The id `send` returned for this message. */ @@ -503,7 +501,7 @@ abstract class Agent { * Attached contexts share the same snapshot and ownership boundary. Invalid * input throws synchronously before any notification, enqueue, or append. * @param content - the model-facing content blocks to deliver. - * @param options - target queue, wakeup decision, source, contexts, and meta. + * @param options - target queue, wakeup decision, source, and contexts. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId @@ -559,7 +557,7 @@ abstract class Agent { * checkpoint. Disposal awaits idle checkpoints; flush failures report through * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. + * @param options - source and attached contexts. * @returns the accepted message's {@link AgentMessageId}. */ inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { @@ -580,7 +578,7 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. +Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input; typed source variants retain model-hidden domain provenance. Absent or `separate` placement becomes an injected `user/message`; `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -595,8 +593,6 @@ interface HookContext { * request delimiter to the same user-role message as its attached prompt. */ placement?: 'separate' | 'prompt-prefix' - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue } ``` @@ -617,7 +613,7 @@ type PromptDecision = | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context metadata — the typed `/goal` pattern): +`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no attached contexts — the typed `/goal` pattern): ```ts type-equiv /** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md new file mode 100644 index 0000000000..33c4267f7e --- /dev/null +++ b/docs/core-data-structures/core.zh.md @@ -0,0 +1,667 @@ +# 核心数据结构 + +[English](core.md) | 中文 + +本目录编目 DeepSeek Harness 的**数据结构**:每个核心类型代表什么、它的字面形状,以及完整细节在哪里。它与 [architecture.md](../architecture.md) 互补——后者描述*行为*(服务映射、会话/轮次/步骤生命周期、事件分类体系);本页描述行为所操作的*词汇*。 + +## 什么算"核心" + +harness 是一个微内核:一个极小的核心加上众多插件。大多数类型属于某一个插件或某一项能力。但有少数类型构成**主干**——agent loop(智能体循环)及其事件在*每一个*轮次中使用的语言,无论加载了哪些可选插件。这些就是"核心"。 + +精确地说,一个数据结构是**核心**的,当且仅当满足以下条件之一: + +1. 它流经 agent loop 主干——循环在每个轮次中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** +2. 它是插件作者面向某条流水线编写的唯一标题类型——`ToolDefinition`(每个工具*是什么*)。 + +其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节*。因此 `ToolDefinition` 是核心,但为它提供类型推导的 `ValueSchemaSpec`/`ParameterSchemaSpec` 机制、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 + +| 子页面 | 负责内容 | +|---|---| +| [llm-streaming.md](llm-streaming.md) | `StreamChunk` 协议格式(wire format)+ 适配器契约(adapter contract)、`BlockAssembler`、`LlmAdapter` seam | +| [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | +| [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | +| [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | +| [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | +| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、轮次封闭不变式 | +| [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | +| [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | +| [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | +| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | +| [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 | +| [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | +| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 | +| [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | +| [pty.md](pty.md) | 持久化终端 ID、后端/会话契约、发送就绪状态、有界读取与 owner 可见快照 | +| [sandbox.md](sandbox.md) | 每会话策略解析与进程约束 seam:文件效果模式、执行/提供方策略、`ConfinedArgv`、强制执行与故障关闭错误 | +| [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 | +| [filesystem.md](filesystem.md) | 文件系统 seam:`FsTarget`、读/写/编辑结果、观测到的文件状态、`FsErrorCode` | +| [lsp.md](lsp.md) | LSP 导航 seam:`LspQueryRequest`/`Result`、`LspProvider`/`Service`、四种操作、`LspError` | +| [skills.md](skills.md) | skill(技能)服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 | +| [compaction.md](compaction.md) | 压缩(compaction)seam:`compact/*` 会话事件、`CompactionResult`、`CompactService` 接口 | +| [subagent.md](subagent.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 | +| [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | +| [spill.md](spill.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | +| [workflow.md](workflow.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | + +> 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务使用生成的[服务目录](../cordis-catalog/services.md)。 + +## `…Map → derived-union` 模式 + +harness 中几乎所有可扩展的和类型都遵循同一形状:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包(package)。 + +```ts ignore-check +// The pattern, schematically: +interface ThingMap { + 'a': { kind: 'a'; /* … */ } + 'b': { kind: 'b'; /* … */ } +} +type ThingKind = keyof ThingMap // 'a' | 'b' +type Thing = ThingMap[keyof ThingMap] // the discriminated union + +// A plugin extends it without touching the source package: +declare module '@deepseek-ai/dsh-llm' { + interface ThingMap { + 'c': { kind: 'c'; /* … */ } + } +} +``` + +六个规范 map 使用此模式;插件作者扩展它们: + +| Map | 包 | 派生 | 目录 | +|---|---|---|---| +| `ContentBlockMap` | dsh-llm | `ContentBlock` | [下文](#content-blocks-and-messages) | +| `MessageSourceMap` | dsh-llm | `MessageSource` | [下文](#content-blocks-and-messages) | +| `FinishReasonMap` | dsh-llm | `FinishReason` | [下文](#the-model-request-and-result) | +| `TurnTriggerMap` | dsh-session | `TurnTrigger` | [session.md](session.md) | +| `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.md) | +| `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.md) | + +消费方最常 `switch` 的两个大型判别联合类型是:**`StreamChunk`**(流式协议)和 **`SessionEvent`**(日志条目)。按仓库约定,对标签做 `switch`——不要链式 `if`——这样每个分支都能窄化类型,拼错的标签会编译失败。 + +## 品牌化 ID + +跨越包边界的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 + +`Branded` 原语位于独立的纯类型包 [dsh-brand](../../packages/util/brand) 中(没有运行时代码,也不依赖 Harness 包),因此任何包都能品牌化其拥有的 id,而无需依赖无关的能力包。 + +源码:[`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) + +```ts type-equiv +/** A string carrying a compile-time-only brand `B`. */ +type Branded = string & { readonly [BRAND]: B } +``` + +两个核心 ID 是 `CallId`(关联工具调用及其结果;dsh-llm)和 `SessionId`(活跃 agent 与持久会话共享的标识;dsh-session)。能力包也会品牌化各自的 id,例如 [tasks.md](tasks.md) 中的 `TaskId`。 + + + +## 内容块与消息 + +一段对话由 `Message` 组成;一条消息是一个类型化**内容块**的数组。块的联合类型从 `ContentBlockMap` 派生。 + +源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) + +```ts type-equiv +/** + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. + */ +interface ContentBlockMap { + 'text': TextBlock + 'reasoning': ReasoningBlock + 'tool-call': ToolCallBlock + 'tool-result': ToolResultBlock +} +``` + +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`)、`ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。核心集仅限于每条交付路径都尊重的块——多模态内容(图像、音频等)没有核心块类型;需要的功能通过可合并扩展的 map 添加,同时提供适配器/UI/压缩支持。 + +`Message` 由角色和块组成。由循环派生的 assistant 消息携带其持久提供方/模型标识,以及可选的适配器私有回放元数据: + +```ts type-equiv +/** Provider ownership and adapter-private replay data for an assistant message. */ +interface AssistantProvenance { + /** Provider route that produced the message. */ + provider: string + /** Provider model id that produced the message. */ + model: string + /** + * Lossless-JSON adapter state needed to replay the provider response. + * `LlmService` exposes it to a target adapter only when that adapter instance + * currently owns both this historical provider and the target provider. + */ + replayState?: unknown +} +``` + +```ts type-equiv +/** + * A single message in a conversation history. Loop-derived assistant messages + * always carry provenance; callers may omit it on hand-built foreign history. + */ +interface Message { + role: 'system' | 'user' | 'assistant' + content: ContentBlock[] + /** Present only on assistant messages produced by a routed adapter. */ + provenance?: AssistantProvenance +} +``` + +消息来源本身也是一个可合并扩展的和类型: + +```ts type-equiv +/** + * Where a message (or injected content) came from. + * Merge-extensible sum type — plugins add their own `kind`s. + */ +interface MessageSourceMap { + user: { kind: 'user' } + plugin: { kind: 'plugin'; plugin: string } +} +``` + +## 流式输出 + +适配器发出原始**分片**协议;循环记录分片(回放保真度),同时将同一批分片送入 `BlockAssembler` 以重建块和消息。`StreamChunk` 是基于 `type` 的封闭判别联合——`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`。 + +完整联合类型、适配器契约(usage-before-finish、原始 JSON 工具参数、两条认可的错误路径)和 `BlockAssembler` 在 **[llm-streaming.md](llm-streaming.md)** 中。 + +## 模型请求 + +一次模型调用是一个完全组装好的 `GenerateOptions`。适配器以原始 `StreamChunk` 流作答;消费方用 `BlockAssembler` 组装它(见 [llm-streaming.md](llm-streaming.md))。 + +源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) + +提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。 + +```ts type-equiv +/** Display metadata for one registered provider route. */ +interface LlmProviderInfo { + /** Provider route key used by {@link GenerateOptions.provider}. */ + id: string + /** Human-readable provider name for selectors and diagnostics. */ + name: string +} +``` + +```ts type-equiv +/** One adapter-discovered model; catalog membership is advisory, not request validation. */ +interface LlmModelInfo { + /** Provider route that owns this model entry. */ + provider: string + /** Model id passed to {@link GenerateOptions.model}. */ + id: string + /** Human-readable model name for selectors. */ + name: string + /** Optional user-facing distinction from otherwise similar models. */ + description?: string +} +``` + +对正确性敏感的模型容量与参考目录分开查询,并归服务该确切路由的适配器所有。 + +```ts type-equiv +/** Provider-owned context capacity for one exact provider/model route. */ +interface LlmModelContext { + /** Maximum combined request and response context in tokens. */ + contextWindow: number +} +``` + +```ts type-equiv +/** A single model request, fully assembled. */ +interface GenerateOptions { + /** Registered provider route selecting the adapter instance. */ + provider: string + model: string + /** + * Ordered conversation messages, exactly as the provider sees them (after + * the `system` slot). A loop-built request assembles them as + * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a + * hand-built one-shot passes any list. + */ + messages: Message[] + /** System prompt text (adapters map to the provider's system slot). */ + system?: string + /** Tool schemas (adapters map to the provider's `tools` field). */ + tools?: ToolSchema[] + temperature?: number + maxTokens?: number + /** + * Stop sequences: generation halts as soon as the model produces any one of + * these strings (adapters map to the provider's stop field, e.g. OpenAI + * `stop`). The stop string itself is not included in the output. + */ + stop?: string[] + signal?: AbortSignal + /** + * Session identity stamped by the loop for listener routing. Adapters ignore + * it; replay uses it to keep concurrent parent and child cursors independent. + */ + sessionId?: Branded<'SessionId'> + /** + * Provider-neutral classification for an auxiliary model call. Adapters may + * map the purpose to model-hidden transport metadata or purpose-specific + * generation policy. Ordinary conversation requests leave it unset. + */ + purpose?: 'compaction' | 'session-title' +} +``` + +模型响应为何停止由可合并扩展的原因表示。提供方终态失败携带流式契约的 [`LlmFailure`](llm-streaming.md#llmfailure): + +```ts type-equiv +/** + * Why a model response stopped. + * Merge-extensible so adapters can surface provider-specific reasons. + */ +interface FinishReasonMap { + 'stop': { kind: 'stop' } + 'tool-calls': { kind: 'tool-calls' } + 'max-tokens': { kind: 'max-tokens' } + 'aborted': { kind: 'aborted'; failure: LlmFailure } + 'error': { kind: 'error'; failure: LlmFailure } +} +``` + +`FinishReason = FinishReasonMap[keyof FinishReasonMap]`。`TokenUsage`(逐调用计量,含不相交的缓存字段)详见 [llm-streaming.md](llm-streaming.md)。 + +`GenerateOptions.tools` 携带 `ToolSchema`——工具的 JSON Schema 描述,发送给模型。它声明在 dsh-llm(而非 dsh-tools)中,正是因为它是循环每一步组装请求的一部分: + +```ts type-equiv +/** + * JSON-schema description of a tool, as sent to the model. + * + * Declared here (not in dsh-tools) because it is part of {@link GenerateOptions}; + * dsh-tools' ToolDefinition and dsh-system-prompt's PromptAssembly both import + * it from this package. + */ +interface ToolSchema { + name: string + description: string + /** JSON Schema object for the arguments. */ + parameters: Record +} +``` + +面向模型的 `ToolSchema` 是协议格式;产出它的已注册 `ToolDefinition`(schema + `execute`)在 [tools.md](tools.md) 中。 + +### 请求信封:`LlmCallConfig` 与记录的 header + +循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 + +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 + +在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 + +FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。 + +```ts type-equiv +/** + * Provider + model + sampling scalars of one conversation's requests. Every field maps + * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests + * from the logged header rather than accepting these per call. + */ +interface LlmCallConfig { + provider: string + model: string + temperature?: number + maxTokens?: number + stop?: string[] +} +``` + +## 会话 + +`Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。事件词汇从 `SessionEventMap` 派生: + +源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) + +```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ +type SessionEvent = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } & (K extends SurfaceEventType ? { + /** + * Seq numbers of events that are provenance sources of this event + * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. + */ + sourceEventSeqs?: number[] + /** How this event entered the surface; absent for non-surface events. */ + surfaceOp?: SurfaceOp + } : object) +}[T] +``` + +十三种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 + +## Agent 句柄 + +`Agent` 是每个插件(UI、钩子、orchestrator)面向编程的 surface。具体实现为 dsh-agent-loop 包内部细节;循环外没有任何组件依赖它。 + +源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +/** + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — the item joins the active turn between steps as steering, + * or, when no turn is active, is promoted per its `wakeup` flag. + */ +type SendTarget = 'next-turn' | 'next-step' +``` + +```ts type-equiv +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. + */ +interface SendOptions { + /** Queue the item joins; defaults to `next-turn`. */ + target?: SendTarget + /** + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). Defaults to + * `true`. A `false` `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). + */ + wakeup?: boolean + source?: MessageSource + /** + * Model-facing contexts captured with this inbox item. A queued prompt exposes + * them through the default `agent/prompt-submit` allow decision, while steering + * records them directly at its next checkpoint. + */ + contexts?: HookContext[] +} +``` + +固定预设别名拥有 `target` 和 `wakeup`,因此只接受其余字段: + +```ts type-equiv +/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ +type AliasSendOptions = Omit +``` + +`send` 返回被接受消息的不透明 `AgentMessageId`,并在该消息的 `agent/inbox/*` 事件中保持稳定: + +```ts type-equiv +/** + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. + */ +type AgentMessageId = Branded<'AgentMessageId'> +``` + +`agent/inbox/*` 实时事件携带一条被接受的消息;注入绕过 FIFO,因此绝不会出现在这些事件中: + +```ts type-equiv +/** + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. + */ +interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + steering: boolean + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean +} +``` + +```ts type-equiv +/** Options for {@link Agent.cancel}. */ +interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean +} +``` + +```ts type-equiv +/** Stable runtime cause accepted by {@link Agent.cancel}. */ +type AgentCancelCause = + | { readonly kind: 'user' } + | { readonly kind: 'parent' } +``` + +`Agent` 是抽象类:具体驱动器实现抽象成员,而 `followup`/`steer`/`inject` 是共享的具体委托方法,它们都委托给覆盖(`target` × `wakeup`)矩阵的唯一抽象 `send`。 + +```ts type-equiv +/** + * Public agent handle; its concrete implementation is internal to + * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so + * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, + * {@link Agent.inject}) are shared concrete delegates over the single abstract + * {@link Agent.send} primitive; concrete drivers implement `send` once. + */ +abstract class Agent { + /** The single identity shared with {@link session}. */ + abstract readonly id: SessionId + /** The provider route and model this agent's requests use. */ + abstract readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + abstract readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + abstract readonly status: AgentStatus + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ + abstract readonly ctx: Context + + /** + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * Detaches, validates, and freezes one lossless-JSON item, then routes it: + * + * - `next-turn` (default) queues an item that becomes the sole ordinary + * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` submits steering into the active turn + * (idle falls back to a woken `next-turn`). + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: an open turn joins at the current log position + * (deferred behind an executing tool batch until it settles), and an idle + * inject records a one-shot turn with its own durability checkpoint. + * + * Attached contexts share the same snapshot and ownership boundary. Invalid + * input throws synchronously before any notification, enqueue, or append. + * @param content - the model-facing content blocks to deliver. + * @param options - target queue, wakeup decision, source, and contexts. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Idle + * cancellation is a no-op and does not arm later work. The active turn + * snapshots and freezes the required cause. + * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. + */ + abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void + + /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + abstract whenIdle(): Promise + + /** + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param content - the prompt content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. + */ + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) + } + + /** + * Submit steering into the running turn — the `next-step`/wakeup preset of + * {@link send}. An open turn records it at the next steering checkpoint before + * a request or continuation decision; policy may stop before another step. + * After turn close and its checkpoint, any remainder is queued for a later + * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. + * Idle steering falls back to a woken follow-up turn. + * @param content - the steering content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. + */ + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) + } + + /** + * Append detached model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins + * at the current log position unless the current tool batch is executing; + * then it waits FIFO until that batch settles and drains before turn close + * even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through + * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. + */ + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) + } +} +``` + +`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越轮次关闭、其持久化检查点以及连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 + +cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 + +[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 + +## 发起 Agent + +`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;其生命周期与边界规则由 [initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)规定。 + +## 拦截决策 + +每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex 钩子桥接层把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。提示词决策与工具后决策共享一种面向模型的上下文形状 `HookContext`,它必须携带 `source`(缺少 source 会默认成 `{kind:'user'}`,从而把插件上下文错标为用户提示词)。其中的 `content` 作为 user-role 输入逐字到达模型;类型化 source 变体保留对模型隐藏的领域 provenance。未指定放置方式或指定为 `separate` 时,上下文会成为一条注入的 `user/message`;`prompt-prefix` 放置方式可用于提示词和 steering 收件箱附件,会在同一条消息中把上下文置于最终生效的请求之前。两种决策都携带 `additionalContexts[]`,使每一项保留各自的 provenance 与放置方式。Continuation reason 则是 steering 消息,并有意使用更窄的 content/source 形状。 + +源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +/** Model-facing context injected by a listener or atomically attached to one inbox message. */ +interface HookContext { + content: ContentBlock[] + source: MessageSource + /** + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable + * request delimiter to the same user-role message as its attached prompt. + */ + placement?: 'separate' | 'prompt-prefix' +} +``` + +`agent/prompt-submit` 返回 `PromptDecision`(允许该轮次已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零步骤轮次): + +```ts type-equiv +/** + * Prompt interception result. `allow.content` replaces the prompt. Each + * `additionalContexts` entry follows its declared placement: separate context + * message by default, or a prefix inside the prompt's user-role message. + * `block` records a durable `prompt/blocked` and ends the claimed prompt's + * zero-step turn as rejected. An `allow` returned by a listener is + * authoritative: a listener wrapping `next()` preserves downstream `content` + * and `additionalContexts` unless it intentionally replaces them. + */ +type PromptDecision = + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'block'; reason: string } +``` + +`agent/turn-continuation` 返回 `ContinuationDecision`(步骤有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一轮次中下一个步骤的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): + +```ts type-equiv +/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ +type ContinuationDecision = + | { action: 'stop' } + | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } +``` + +`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、轮次信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: + +```ts type-equiv +/** Model-request failure with an optional machine-routable provider code. */ +type RequestError = Error & { code?: string } +``` + +它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的步骤,而 `fail` 在 `turn/end` 上保留结构化失败: + +```ts type-equiv +/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ +type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } +``` + +`agent/post-step` 会在 assistant 输出、真实或合成的工具结果、缓冲上下文与 steering 持久化之后、`step/end` 之前被 await。被取消的工具批次在排空后携带 aborted signal 到达这里;其签名为 `(agent, turn, step, signal)`,可回放事实保留在会话日志中,而不是瞬态 payload 中。 + +`agent/turn-stop` 返回仅停止的 `ContinuationStop` 子集或 `undefined`。循环在折叠普通决策、其 reason 和待处理 steering 之后调用此串行检查点;stop 是终态,会丢弃待处理的 steering。 + +```ts type-equiv +/** + * The terminal subset of {@link ContinuationDecision}. A listener on + * `agent/turn-stop` returns this to make the already-composed continuation + * outcome terminal; `undefined` abstains. + */ +type ContinuationStop = Extract +``` + +`agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): + +```ts type-equiv +/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ +type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' +``` + +`agent/session-prefix` 在每个循环实例中组合一次 `Message[]`。深度冻结的结果被记录在请求 header 中,并前置于每次派生历史,使其成为会话稳定开场白的归属。恢复的实例会重新组合;会话中途的变更使用仅追加的上下文通道。该 waterfall 直接返回内容,因为它是贡献而非决策。 + +## `ToolDefinition` + +唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的契约。 + +其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 形状,以及工具展示 UI 词汇在 **[tools.md](tools.md)** 中。 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml new file mode 100644 index 0000000000..55517ff4e5 --- /dev/null +++ b/docs/core-data-structures/session.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +session.md: f439b3cb681a4073bba76cfeb23e8711000da797 +session.zh.md: 8da3384900a1963de61552e3e72af8bbb1f509d6 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 30a6f897d4..f439b3cb68 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -1,5 +1,7 @@ # Sessions +English | [中文](session.zh.md) + The in-memory, event-sourced model of [dsh-session](../../packages/core/session). A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth for an agent's whole interaction history. The LLM message history is *derived* from the log, never stored separately; replay is re-derivation from the same events. How the log is made **durable** (the persistence seam, backends, crash recovery) is the sibling concern on [persistence.md](persistence.md). Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) @@ -14,7 +16,7 @@ The append-only event types. Merge-extensible: a plugin declares extra event typ * direct human prompt, a synthetic `agent.inject()` context, and mid-turn * steering all project into the model transcript as verbatim user-role content; * they are told apart by `source` (a non-`user` kind marks injected context), - * not by event type. `meta` carries durable model-hidden producer state. + * not by event type. */ interface PromptMessageData { /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ @@ -23,15 +25,6 @@ interface PromptMessageData { source: MessageSource /** Present only when prompt-prefix contexts were baked into `content`. */ envelope?: PromptMessageEnvelope - /** - * Opaque durable JSON state retained on the event but hidden from the model - * projection. It is the intended channel for a future framing directive (a - * producer declares the frame, a dedicated renderer applies it — see the - * deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - meta?: JsonValue } ``` @@ -123,7 +116,7 @@ interface SessionEventMap { } ``` -`PromptMessageData.content` is always the exact model-facing content. When attached context declares `prompt-prefix` placement, AgentLoop concatenates its blocks, a `## My request:` delimiter, and the effective direct prompt into that array. The optional model-hidden `envelope` retains `displayContent` plus ordered prefix-context source/metadata descriptors, so transcript, title, and re-reference consumers can present the human prompt without changing reconstructable history. `displayPromptContent()` performs that selection and falls back to `content` for ordinary and older events. +`PromptMessageData.content` is always the exact model-facing content. When attached context declares `prompt-prefix` placement, AgentLoop concatenates its blocks, a `## My request:` delimiter, and the effective direct prompt into that array. The optional model-hidden `envelope` retains `displayContent` plus ordered prefix-context sources, so transcript, title, and re-reference consumers can present the human prompt without changing reconstructable history. `displayPromptContent()` performs that selection and falls back to `content` for ordinary and older events. ### `OutOfBandSessionEventMap` — narrow late-append opt-in @@ -471,7 +464,7 @@ declare class Session { - `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata. - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. -- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. +- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position; provenance and domain data live in its typed source. - `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md new file mode 100644 index 0000000000..8da3384900 --- /dev/null +++ b/docs/core-data-structures/session.zh.md @@ -0,0 +1,557 @@ +# 会话 + +[English](session.md) | 中文 + +[dsh-session](../../packages/core/session) 的内存事件溯源模型。`Session` 是一份由类型化 `SessionEvent` 组成的**仅追加日志**,是 agent(智能体)完整交互历史的唯一真源。LLM(大语言模型)消息历史从日志*派生*而来,从不单独存储;回放即从同一组事件重新派生。日志如何实现**持久化**(持久化 seam、后端、崩溃恢复)是兄弟文档 [persistence.md](persistence.md) 的关注点。 + +源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) + +## `SessionEventMap`:事件词汇 + +仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[上下文压缩(context compaction) seam](compaction.md) 添加了 `compact/start` / `compact/summary` / `compact/end`,`@deepseek-ai/dsh-hook-protocol` 添加了仅记录日志的 `hook/invoked` / `hook/result` 溯源事件,用于钩子桥接。与 `compact/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.md)列举了所有成员(核心与合并扩展的),包含其 payload、surface 标记与声明位置。 + +```ts type-equiv +/** + * Shared payload for user, injected-context, and steering prompt messages. A + * direct human prompt, a synthetic `agent.inject()` context, and mid-turn + * steering all project into the model transcript as verbatim user-role content; + * they are told apart by `source` (a non-`user` kind marks injected context), + * not by event type. + */ +interface PromptMessageData { + /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ + content: ContentBlock[] + /** Producer provenance for the direct prompt. */ + source: MessageSource + /** Present only when prompt-prefix contexts were baked into `content`. */ + envelope?: PromptMessageEnvelope +} +``` + +```ts type-equiv +/** + * 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. + */ +interface SessionEventMap { + /** + * Opens turn `turn`. `trigger` records what started it — one claimed queued + * message or an idle-time injection. The turn is the durability/replay + * boundary: every event sits between a `turn/start` and its matching + * `turn/end` (the turn-enclosure invariant). + */ + 'turn/start': { turn: number; trigger: TurnTrigger } + /** + * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. + */ + 'turn/end': { turn: number; reason: TurnEndReason } + /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ + 'step/start': { turn: number; step: number } + /** Closes step `step` of turn `turn`. */ + 'step/end': { turn: number; step: number } + /** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ + 'user/message': PromptMessageData + /** + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, and its turn runs zero steps. + */ + 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } + /** Raw stream chunk — token-level replay fidelity. */ + 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } + /** + * Assembled assistant message for one step (derived history uses this). + * Carries the step's `usage` when the adapter reported token accounting, so + * the model output and its accounting travel together (there is no separate + * usage record). `usage` is absent when the adapter reported none. + */ + 'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage } + /** + * The model requested one tool invocation: `name` with the raw `arguments` + * JSON string exactly as the model produced it (unparsed). `callId` pairs the + * call with its `tool/result`. + */ + 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + /** + * A completed tool call's model-facing result, optional internal failure + * identity, and optional tool-private `meta` presentation payload. `meta` is + * opaque to the core (the producing tool owns its shape and reads it back in + * `presentResult`) but MUST be JSON-serializable: `Session.append` + * runtime-validates all event data with `isJsonValue`, so a non-serializable + * `meta` is rejected at the source, and the durable log reproduces the + * identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). + */ + 'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { name: string; code: string } + meta?: JsonValue + } + /** Steering content injected between steps of a running turn. */ + 'steering/message': PromptMessageData & { turn: number } + /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ + 'todo/write': { todos: TodoItem[] } + /** + * 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 } +} +``` + +`PromptMessageData.content` 始终是确切的模型可见内容。当附加上下文声明 `prompt-prefix` 放置方式时,AgentLoop 会依次把它的块、一个 `## My request:` 分隔符以及最终生效的直接提示词拼接进该数组。可选且对模型隐藏的 `envelope` 会保留 `displayContent`,以及按顺序排列的前缀上下文 source,使 transcript(文本记录)、标题与重新引用消费方无需改变可重建历史,就能呈现人类提示词。`displayPromptContent()` 负责该选择,并为普通事件和较早的事件回退到 `content`。 + +### `OutOfBandSessionEventMap`:受限的带外追加显式准入 + +仅属于 `SessionEventMap` 并不表示事件可以脱离 agent loop(智能体循环)的常规生命周期追加。事件所有方必须通过声明合并将同一键加入这个空标记映射,`ctx.sessions.appendOutOfBand()` 才会接受该事件;派生类型还会排除所有 surface 事件。被接受的更新会并入已打开的轮次;如果没有打开的轮次,系统则为它创建一个边界配平且已刷新完成的零步骤轮次。 + +```ts type-equiv +/** + * Marker map for plugin-owned log-only events accepted by + * `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key + * it adds to {@link SessionEventMap}; surface and lifecycle events stay + * ineligible unless their owner explicitly opts them into this narrow seam. + */ +interface OutOfBandSessionEventMap {} +``` + +### `TodoItem`:一条待办项 + +这是 `todo/write` 事件全量列表快照中的单元。它有意保持精简:一行 `content` 加一个三态 `status`(没有 id、优先级或 `activeForm`);列表在每次写入时整体替换,因此条目无需稳定标识,而这三个状态值恰好对应 ACP 的 `PlanEntryStatus`,所以 UI 桥接层可以将待办列表一一映射为 ACP `plan`(并合成 ACP 额外要求的优先级)。见 [todo_write Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md)。 + +```ts type-equiv +/** + * One entry in an agent's todo list — the unit of the `todo/write` + * {@link SessionEventMap} event's whole-list snapshot. + * + * Deliberately minimal: a human-readable `content` line and a three-state + * `status`. No id, priority, or `activeForm` — the list is replaced wholesale + * on every write (last-write-wins), so entries need no stable identity, and the + * status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a + * todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally + * requires). + */ +interface TodoItem { + /** What this task is — a short imperative line shown in the UI. */ + content: string + /** Lifecycle state. `in_progress` marks the single task being worked now. */ + status: 'pending' | 'in_progress' | 'completed' +} +``` + +### 请求头事件:`request/header` + +请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema + 会话前缀)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 + +```ts type-equiv +/** + * 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. + */ +interface EpochHeader { + /** The conversation's call configuration (provider, model, and sampling scalars). */ + config: LlmCallConfig + /** Rendered system prompt text; absent for a system-less request. */ + system?: string + /** Assembled tool schemas; absent for a tool-less request. */ + tools?: ToolSchema[] + /** + * The session prefix: request-only messages sent BEFORE the entire derived + * history (the `agent/session-prefix` waterfall's product, composed once + * per loop instance and reused for every request it sends). Not session + * history — `deriveMessages()` never returns it — so the header is its + * only durable record; absent when the instance composed none. + */ + messagePrefix?: Message[] +} +``` + +规范形式:空系统提示词、空工具列表和空会话前缀都表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix + derived history`);每个 agent loop 实例只组合一次,并包含在该实例记录的每份完整快照中。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 + +## `SessionEvent`:一条日志条目 + +基于 `type` 的真正可辨识联合(而非独立的 `type`/`data` 联合),因此 `switch (event.type)` 能直接收窄 `event.data`,无需类型断言。`seq` 是日志中的单调递增位置(`seq = log.length`);`time` 为 epoch 毫秒。 + +```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ +type SessionEvent = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } & (K extends SurfaceEventType ? { + /** + * Seq numbers of events that are provenance sources of this event + * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. + */ + sourceEventSeqs?: number[] + /** How this event entered the surface; absent for non-surface events. */ + surfaceOp?: SurfaceOp + } : object) +}[T] +``` + +`SessionEventType = keyof SessionEventMap`。由于 `SessionEventMap` 可通过合并扩展,对 `SessionEvent` 的 switch 语句禁止使用 `assertNever`:插件添加的变体是合法的未知值;处理已知 case 后在 `default` 中放行。 + +对于 `assistant/message`,存在的 `sourceEventSeqs: []` 表示提供方流已知且完整地为空;字段缺失则表示旧格式或其他未记录溯源信息的情况。agent loop 会为每次成功的模型调用写入该字段;其他 surface 事件只要包含该字段,其列表就必须非空。 + +## Surface 类型 + +四种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`steering/message`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)。 + +### `SurfaceEventType`:事件类型中产生消息的子集 + +```ts type-equiv +/** + * The subset of {@link SessionEventType} values whose events produce LLM + * messages and are eligible to appear on the ordered surface. Only these + * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}. + */ +type SurfaceEventType = + | 'user/message' + | 'assistant/message' + | 'tool/result' + | 'steering/message' +``` + +### `SurfaceOp`:事件如何进入 surface + +```ts type-equiv +/** + * How a session event entered the ordered surface. Only valid on + * {@link SurfaceEventType} events. + * + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering + * messages. + * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` + * (inclusive) through `end` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `start === end` replaces a single + * node. The node's {@link SessionEvent.sourceEventSeqs} must include every + * shadowed surface node. Used by compaction and possible other manipulations. + */ +type SurfaceOp = + | 'append' + | { op: 'replace'; start: number; end: number } +``` + +`'append'` 是常规的尾部追加路径。`replace` 会遮蔽从 `start` 到 `end`(含两端)的 surface 条目(两者都必须是有效的 surface seq;`start === end` 时仅替换单个条目),并在原位置插入新事件。 + +### `SurfaceIntent`:`session.append()` 的参数 + +```ts type-equiv +/** + * Surface placement and provenance for {@link Session.append}. Required on + * message-producing events and forbidden on log-only events. + */ +interface SurfaceIntent { + surfaceOp: SurfaceOp + /** + * Complete known provenance source set. `assistant/message` may use a + * present empty array for a known empty provider stream; omission means its + * provenance was not recorded. Other surface events require a non-empty set + * when this field is present. + */ + sourceEventSeqs?: number[] +} +``` + +对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生历史的唯一来源)。非 surface 类型在编译期拒绝此参数。 + +此处适用相同的溯源区分:只有 `assistant/message` 可以携带存在但为空的 `sourceEventSeqs`;省略该字段并不表示其源流为空。 + +### `SessionSurface`:实时只读 surface 投影 + +`Session.surface` 返回会话稳定的 `SessionSurface` 视图。同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系和替换代次,但不能调用校验。 + +```ts type-equiv +/** Readonly live projection of the message-producing session events. */ +interface SessionSurface { + /** Current surface event sequences in model-visible order. */ + readonly nodes: readonly number[] + /** Monotonic count of committed positional replacements. */ + readonly replaceGeneration: number +} +``` + +### `SurfaceFoldReplacement` 与 `SurfaceFoldResult`:完整的 surface 回放 + +`foldSurface(events)` 返回一份独立的当前事件 seq 列表,以及每个声明的替换范围实际遮蔽的 seq。实时管理器复用同一套状态转换,但不保留替换历史。每提交一次替换,其 `replaceGeneration` 就递增一次,使增量消费方能够区分纯尾部增长与重写。 + +```ts type-equiv +/** One replacement operation observed while folding a session surface. */ +interface SurfaceFoldReplacement { + /** Seq of the event that replaced the prior surface range. */ + seq: number + /** Declared inclusive start seq of the replaced surface range. */ + start: number + /** Declared inclusive end seq of the replaced surface range. */ + end: number + /** Actual surface entries removed by the operation, in surface order. */ + shadowedSeqs: number[] +} +``` + +```ts type-equiv +/** Complete result of replaying the surface operations in a session log. */ +interface SurfaceFoldResult { + /** Current surface event sequences in model-visible order. */ + nodes: number[] + /** Replacement operations in event order. */ + replacements: SurfaceFoldReplacement[] +} +``` + +## `Session` public API + +去除方法体的声明与源码中的普通类保持同步,覆盖其公共构造函数、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 服务目录](../cordis-catalog/services.md#ctxsessions--sessionstore)记录。 + +```ts public-api +/** + * An event-sourced session: an append-only log of {@link SessionEvent}s. + * + * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Seeding with an existing event log replays/forks a session. + */ +declare class Session { + /** The ordered surface over this session's event log. */ + get surface(): SessionSurface; + /** + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so + * `session.header` is always present. Kept out of the event log — it is a + * storage concern, not replayable conversation state. + */ + readonly header: SessionHeader; + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId; + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); + /** + * An immutable snapshot of the append-only event log. The snapshot is reused + * until the next append; a previously returned array does not grow later. + * Events and their nested data are deep-frozen at acceptance, so neither a + * cast nor ordinary JavaScript can rewrite durable history. + */ + get events(): readonly SessionEvent[]; + /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ + get seq(): number; + /** + * Append one typed event to the log and synchronously notify observers via + * the store-owned, module-private publication hooks. The hot path never blocks + * on I/O — persistence plugins buffer asynchronously. Once the event enters + * the log, the append is committed: observer failures are logged and + * contained per listener, so they do not change the return value or prevent + * later listeners from observing the same accepted event. + * + * @param type - The event type (key of {@link SessionEventMap}). + * @param data - The event payload; must be JSON-serializable. + * @param opts - Surface metadata: `surfaceOp` controls how the event enters + * the ordered surface; `sourceEventSeqs` records provenance (the seq + * numbers of events this one derives from). REQUIRED for + * {@link SurfaceEventType} events (every message-producing event must + * declare how it joins the surface, the sole source of derived history) and + * rejected by the compiler for non-surface types like `turn/start` or + * `assistant/chunk`. + * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of + * `data` that entered the log, so reading `event.data` back sees the logged + * value, never the caller's still-mutable input. + * @throws if `data` or surface metadata is not losslessly JSON-serializable + * (BigInt, function, symbol, undefined, negative zero, non-finite number, + * circular reference, sparse array, or an exotic object such as + * Map/Set/Date/class instance), or when the candidate violates the + * canonical surface contract (marker shape and eligibility, unique + * earlier provenance, positional replacement validity, and complete + * shadowed-node coverage). One recursive pass reads, validates, and + * copies each nested value once, so a stateful getter cannot supply one value + * to validation and another to storage. The event log is the durable source + * of truth, so a bad event fails at the append site rather than later during + * a backend flush. A synchronous internal dispatch validation failure or an + * append reentered while this acceptance/publication boundary is open also + * rejects before the log changes. + */ + append( + type: T, + data: SessionEventMap[T], + ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] + ): SessionEvent; + /** + * The {@link EpochHeader} in force after the log's last header event — the + * header the NEXT request will be compared against — or undefined before + * the first `request/header` snapshot. The live, incrementally-maintained + * form of `foldRequestHeader(session.events)`: each header event is folded + * once, when first seen, so a per-step read costs O(new events). + * @returns the folded header, or undefined when no header event exists yet. + */ + requestHeader(): EpochHeader | undefined; + /** + * Derive the LLM message history by walking the ordered sequences of + * message-producing events maintained by `surfaceOp` markers. The + * surface is the single source of derived history: every message-producing + * append records its `surfaceOp`, so a raw event with no marker (a chunk, a + * turn boundary) is correctly absent, and a compaction `replace` deletes the + * shadowed nodes from the derivation. The projection rules are + * {@link deriveEventMessage}, folded per node. + * + * CACHED: each surface node is projected exactly once, when first seen — a + * call costs O(new nodes), and a surface rewrite (a `replace`; + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * a fresh snapshot per call (later appends never grow an array a caller + * already holds); the `Message` objects in it are SHARED and **deep-frozen**. + * Their content reuses the already frozen durable event data, so the cache + * needs no second deep clone and consumers still cannot mutate the log. + * @returns a fresh array of the shared, frozen derived history. + */ + deriveMessages(): Message[]; + /** + * Project a single event into the LLM message it derives to, or null when + * it produces none — a non-surface event (chunk, boundary, log-only record) + * or an empty-content assistant/message (which exists only to host usage). + * The per-node pure function {@link deriveMessages} folds over the surface; + * an external reconstructor (or the dev invariant) folds the same function + * over a log prefix's surface to rebuild the exact messages any request was + * built from (the reconstructability Agent Note). The returned message wrapper is + * fresh; its content reuses the logged event's already deep-frozen durable + * data, so changing the wrapper cannot rewrite the log and changing content + * throws. + * @param event - the event to project. + * @returns the derived message, or null when the event produces none. + */ + deriveEventMessage(event: SessionEvent): Message | null; +} +``` + +## 派生历史:`deriveMessages()` 与 `deriveEventMessage()` + +`Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则: + +- `user/message` → 一条携带确切 `content` 的 user 消息;可选 envelope 仅作为日志中的展示元数据保留。 +- `assistant/message` → 一条 assistant 消息,包含事件的提供方/模型溯源信息和可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 以承载用量和溯源信息,但无内容的 assistant 轮次不得进入提供方 transcript。 +- `tool/result` → 一条携带 `tool-result` 块的 user 消息。 +- `user/message`(注入的上下文,即非 `user` source)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`;provenance 与领域数据位于其类型化 source 中。 +- `steering/message` → 按时间顺序在相应位置生成一条携带确切 `content` 的 user-role 消息;可选 envelope 仅作为日志中的展示元数据保留。 + +其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。操作错误的步骤号记录在 `turn/end.reason`(`kind: 'error'`)中;如果是最终模型请求失败,其中包含规范化的 `LlmFailure` 事实,其他实时错误则包含消息/代码。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 + +## 活跃会话 fork API + +`ctx.sessions.create(id, { seed, meta })` 是底层的回放/fork 原语。对于普通的活跃会话 fork,`SessionStore` 暴露一个策略 API: + +- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `boundary` seq(含)为止的源事件(默认为当前最后一个事件),要求 boundary 事件必须是 `turn/end`,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据(`parentSession`、`seedLength` 及继承的 `cwd`)。 + +显式 `boundary` 允许调用者从之前完成的轮次 fork,即使源会话有更新的事件或正在进行的轮次。API 拒绝非 `turn/end` 的 boundary,而不是静默截断。更广泛的轮次封闭性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具时委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 + +## 轮次的触发原因:`TurnTriggerMap` + +```ts type-equiv +/** + * What started a turn. + * Merge-extensible sum type (same pattern as MessageSourceMap). + */ +interface TurnTriggerMap { + message: { kind: 'message'; source: MessageSource } + /** + * An out-of-band context injection (`agent.inject()`) made while the agent + * was idle. The loop wraps the injected `user/message` (a non-`user` source, + * plugin by default) in a one-shot turn (`turn/start` → `user/message` → + * `turn/end`) so every event in the log stays turn-enclosed — the + * durability/replay boundary is the turn, and a bare event between turns would + * otherwise be indistinguishable from a crash tail on reload. The trigger's + * `source` mirrors that message's producer. + */ + injection: { kind: 'injection'; source: MessageSource } +} +``` + +## 轮次的结束原因:`TurnEndReasonMap` + +`aborted` 有意作为一种粗粒度的持久结果:它只记录取消中断了实时轮次,不记录是哪个运行时调用方发起取消。仅属于运行时的调用方词汇由 [`AgentCancelCause`](core.md#the-agent-handle) 定义;未来若有审计需求,应新增独立的控制请求事件,而非让终止结果承载这一信息。 + +```ts type-equiv +/** + * Why a turn ended. Merge-extensible sum type. + */ +interface TurnEndReasonMap { + completed: { kind: 'completed' } + /** A cancellation request interrupted the live turn. */ + aborted: { kind: 'aborted' } + /** + * The turn failed: a step threw or the model reported a failure. `step` is the + * step number the failure occurred on (the operational error's location — the + * single durable record of an in-turn failure; live diagnostics also fire via + * `agent/error`). Final model-request failures retain their normalized facts + * as one `failure`; other turn failures retain their live Error message/code. + */ + error: { kind: 'error'; step: number } & ( + | { failure: LlmFailure; message?: never; code?: never } + | { message: string; code?: string; failure?: never } + ) + disposed: { kind: 'disposed' } + /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ + 'max-tokens': { kind: 'max-tokens' } + /** + * Policy blocked the turn's claimed prompt before the first step. The + * zero-step turn still records a balanced durable boundary and veto reason. + */ + rejected: { kind: 'rejected'; reason: string } + /** + * 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' } +} +``` + +`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`rejected` 表示一个零步骤轮次,其已认领的提示词被 `agent/prompt-submit` 钩子阻止(ACP(Agent Client Protocol)桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 + +## 轮次封闭不变式 + +每个会话事件都位于一个轮次**之内**(在 `turn/start` 和对应的 `turn/end` 之间)。loop 在 `turn/start` *之后*追加已排队的 `user/message` 事件;空闲时的 `agent.inject()` 会用一次性的 `injection` 轮次包住其 `user/message`;没有打开的轮次时,`appendOutOfBand()` 同样会用一个轮次包住符合条件的仅日志事件。这使轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为崩溃中断尾部,而不会丢失合法记录在轮次之间的上下文。可选的 `dsh-session/invariant` 配套插件通过 `ctx.invariants` 在开发环境中强制此不变式(消息事件若位于打开的轮次之外便会抛出)。见[轮次封闭不变式 Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 + +## 插件贡献的仅日志事件 + +插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 + +钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录:它注入的 `user/message` 已是持久证据,而且当时没有已打开的轮次可容纳该记录(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 + +## 持久性契约 + +持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端可选启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型,或破坏会话不变式配套插件所检查的轮次/步骤嵌套,会构成磁盘格式的破坏性变更。 + +消费此契约的后端见 [persistence.md](persistence.md)。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 973238d905..6ff655a76d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:399`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:358`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) ## Events @@ -150,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -166,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) ### `compact/*` @@ -329,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts) ### `request/*` @@ -343,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -399,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages 'steering/message': PromptMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) ### `step/*` @@ -410,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -419,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) ### `todo/*` @@ -432,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) ### `tool/*` @@ -449,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -503,7 +503,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) ### `turn/*` @@ -521,7 +521,7 @@ Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -537,7 +537,7 @@ Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) ### `user/*` @@ -556,4 +556,4 @@ Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/ 'user/message': PromptMessageData ``` -Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index a2bc25cbcc..a8a6fa755c 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -373,11 +373,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && typeof event.data.meta === 'object' - && event.data.meta !== null - && !Array.isArray(event.data.meta) - && event.data.meta.kind === 'workspace-instructions') + && event.data.source.kind === 'workspace-instructions') expect(dispatch).toBeDefined() expect(outerResult).toBeDefined() expect(workspaceContext).toBeDefined() diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1e06fad70d..d297798cbe 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -74,7 +74,6 @@ export interface ContextMessageNode { seq: number content: readonly ContentBlock[] source: unknown - meta?: unknown } /** A tool result paired (when in-window) with its call head. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index d10bcea074..a3ced753f3 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -43,7 +43,6 @@ function materializeNode( if (event.data.source.kind !== 'user') { return { kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, - meta: event.data.meta, } } return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 4bfe07d687..f068d8b8fd 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -43,7 +43,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) case 'context': return (
- +
) default: diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index be50356185..ad23f292c9 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -33,7 +33,7 @@ describe('MessageItem arms', () => { it('context and unknown nodes render their JSON rows', () => { const ctxView = render( - , + , ) expect(ctxView.getByText(/上下文注入/)).toBeTruthy() const unknownView = render( diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index b3a4b2013d..f48f9ae0a3 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -12,7 +12,7 @@ Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. -The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay. +The context uses a typed `{ kind: 'session-reference', ... }` source with `placement: 'prompt-prefix'`. That source records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and source for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay. ## Configuration diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 93e5173005..e79ad122a4 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -9,7 +9,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { DEFAULT_CANDIDATE_LIMIT, @@ -20,7 +20,7 @@ import { } from './config.ts' import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts' import { stringifyTagSafeJson } from './serialization.ts' -import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput } from './types.ts' +import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts' export type * from './types.ts' export type { Config, SessionReferenceErrorCode } from './config.ts' @@ -181,7 +181,7 @@ export class SessionReferenceService extends Service { const rendered = this.renderSources(prepared) const prompt = renderPrompt(rendered.map(source => source.data)) - const meta = { + const source: SessionReferenceSource = { kind: 'session-reference', version: 1, references: rendered.map((source, index) => ({ @@ -191,12 +191,11 @@ export class SessionReferenceService extends Service { ...source.stats, inputIndex: index, })), - } satisfies JsonValue + } const context: HookContext = { - source: { kind: 'plugin', plugin: 'session-reference' }, + source, content: [{ type: 'text', text: prompt }], placement: 'prompt-prefix', - meta, } return { content: acceptedContent, contexts: [context] } } diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 03176ee32a..ed4d662410 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -4,6 +4,30 @@ import type { HookContext } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' +/** Durable provenance for one prepared cross-session context. */ +export interface SessionReferenceSource { + kind: 'session-reference' + version: 1 + references: { + sessionId: string + label: string + capturedThroughSeq: number | null + compacted: boolean + originalMessages: number + retainedMessages: number + omittedMessages: number + omittedBytes: number + truncated: boolean + inputIndex: number + }[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'session-reference': SessionReferenceSource + } +} + /** One source session selected by a host. */ export interface SessionReferenceInput { /** Opaque source session identity. */ diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 937535b042..a2ba2e48eb 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -241,7 +241,7 @@ describe('session reference discovery and preparation', () => { expect(prepared.contexts).toHaveLength(1) const context = prepared.contexts[0] if (context?.content[0]?.type !== 'text') throw new Error('expected text context') - expect(context.source).toEqual({ kind: 'plugin', plugin: 'session-reference' }) + expect(context.source).toMatchObject({ kind: 'session-reference' }) expect(context.placement).toBe('prompt-prefix') expect(context.content[0].text).toContain('untrusted, read-only snapshot') expect(promptData(context.content[0].text)).toEqual([{ @@ -256,7 +256,7 @@ describe('session reference discovery and preparation', () => { { role: 'assistant', text: 'visible answer' }, ], }]) - expect(context.meta).toMatchObject({ + expect(context.source).toMatchObject({ kind: 'session-reference', version: 1, references: [{ @@ -354,7 +354,7 @@ describe('session reference discovery and preparation', () => { { sessionId: one.id, label: 'first' }, { sessionId: one.id, label: 'ignored duplicate' }, { sessionId: two.id }, - ])).resolves.toMatchObject({ contexts: [{ meta: { references: [{ label: 'first' }, { label: 'two' }] } }] }) + ])).resolves.toMatchObject({ contexts: [{ source: { references: [{ label: 'first' }, { label: 'two' }] } }] }) await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }])) .rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE')) await expect(ctx.sessionReferences.prepare(agent, content, [null as never])) @@ -432,7 +432,7 @@ describe('session reference discovery and preparation', () => { expect(context.content[0].text).toContain('checkpoint') expect(context.content[0].text).toContain('latest-') expect(context.content[0].text).toContain('omitted') - expect(context.meta).toMatchObject({ references: [{ truncated: true, compacted: true }] }) + expect(context.source).toMatchObject({ references: [{ truncated: true, compacted: true }] }) }) it('applies the full byte limit independently to each of three references', async () => { @@ -501,7 +501,6 @@ describe('session reference discovery and preparation', () => { displayContent: prepared.content, prefixContexts: [{ source: context.source, - ...context.meta === undefined ? {} : { meta: context.meta }, }], }, }, { surfaceOp: 'append' }) diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index a7245df91f..df35697159 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -46,9 +46,9 @@ The plugin owns the complete `` framing, and every `context/mes ## State And Refresh -Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. -An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only. +An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix. @@ -73,7 +73,7 @@ The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`. -Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata. +Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in the structured message source. ## Model Experience diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 364004049e..95430e6c27 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -99,7 +99,6 @@ export function apply(ctx: Context, config: Config): void { if (update !== undefined) { agent.inject(update.context.content, { source: update.context.source, - meta: update.context.meta, }) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) } diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index d9f56417b8..f6c99ea10e 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -15,7 +15,7 @@ export const name = 'workspace-context-invariant' export const inject = ['invariants'] /** - * No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata, + * No runtime invariant: replay intentionally tolerates unknown or malformed workspace sources, * while focused pipeline tests own its private pending/cache state transitions. */ const install: InvariantInstaller = () => {} diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 61db3f527b..ca1950d5c7 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -6,7 +6,7 @@ import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' -import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs' import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ResolvedConfig } from './config.ts' @@ -33,9 +33,20 @@ import { export const name = 'workspace-context' -const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) +/** Durable provenance and reconciliation facts for one workspace context. */ +export interface WorkspaceInstructionSource { + kind: 'workspace-instructions' + changes: WorkspaceInstructionChange[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'workspace-instructions': WorkspaceInstructionSource + } +} + /** Dynamic state waiting for the loop to append its returned context event. */ export interface PendingInstructionChange { change: WorkspaceInstructionChange @@ -70,20 +81,14 @@ export interface ReconciledInstructionContext { versionUpdates: InstructionVersionUpdate[] } -/** Plugin-owned context with required replay metadata. */ -export interface WorkspaceHookContext extends HookContext { - meta: JsonValue -} +/** Plugin-owned workspace context. */ +export type WorkspaceHookContext = HookContext function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext { - const serializedChanges: JsonValue[] = changes.map(change => ({ - action: change.action, - scope: change.scope, - path: change.path, - ...change.digest !== undefined ? { digest: change.digest } : {}, - })) - const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges } - return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta } + return { + content: [{ type: 'text', text }], + source: { kind: 'workspace-instructions', changes }, + } } /** @@ -103,20 +108,20 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } -function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE { +function isWorkspaceContextSource(source: unknown): source is WorkspaceInstructionSource { return typeof source === 'object' && source !== null - && 'kind' in source && source.kind === 'plugin' - && 'plugin' in source && source.plugin === name + && 'kind' in source && source.kind === 'workspace-instructions' + && 'changes' in source && Array.isArray(source.changes) } -function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } { +function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] { - if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return [] +function workspaceInstructionChanges(source: unknown): WorkspaceInstructionChange[] { + if (!isWorkspaceContextSource(source)) return [] const changes: WorkspaceInstructionChange[] = [] - for (const value of meta.changes) { + for (const value of source.changes) { if (!isRecord(value)) continue if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue @@ -146,7 +151,7 @@ function visibleInstructionChanges( const visible = new Map() for (const [seq, event] of agent.session.events.entries()) { if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue - const changes = workspaceInstructionChanges(event.data.meta) + const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { const waiting = pending.get(change.scope) if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { @@ -283,7 +288,7 @@ export function observeInstructionSessionEvent( switch (event.type) { case 'user/message': { if (!isWorkspaceContextSource(event.data.source)) return - for (const change of workspaceInstructionChanges(event.data.meta)) { + for (const change of workspaceInstructionChanges(event.data.source)) { const waiting = pending.get(change.scope) if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { pending.delete(change.scope) @@ -329,7 +334,7 @@ export function commitPendingInstructionContexts( const step = openStep(agent.session) for (const context of contexts ?? []) { if (!isWorkspaceContextSource(context.source)) continue - const changes = workspaceInstructionChanges(context.meta) + const changes = workspaceInstructionChanges(context.source) if (changes.length === 0) continue const pending = pendingChangesFor(agent.session, pendingBySession) for (const change of changes) { diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index f80ee5acde..ee7fb05ceb 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -108,11 +108,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode const events = [...live.agent.session.events] const update = events.find(event => event.type === 'user/message' - && typeof event.data.meta === 'object' - && event.data.meta !== null - && !Array.isArray(event.data.meta) - && event.data.meta.kind === 'workspace-instructions') - expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ + && event.data.source.kind === 'workspace-instructions') + expect(update?.type === 'user/message' && update.data.source).toMatchObject({ changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) const updateText = update?.type === 'user/message' diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 161466a8b8..3be1f9234b 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -184,7 +184,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, - ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, @@ -206,16 +205,14 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined { return result.additionalContexts?.find(context => - context.source.kind === 'plugin' && context.source.plugin === 'workspace-context') + context.source.kind === 'workspace-instructions') } function workspaceChangeContext(scope: string, digest: string): HookContext { return { content: [{ type: 'text', text: `instructions for ${scope}` }], - source: { kind: 'plugin', plugin: 'workspace-context' }, - meta: { + source: { kind: 'workspace-instructions', - version: 1, changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }], }, } @@ -227,7 +224,6 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H lastSeq = agent.session.append('user/message', { content: context.content, source: context.source, - ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }).seq } return lastSeq @@ -930,7 +926,7 @@ describe('workspace context request injection', () => { kind: 'accept' as const, })) expect(accepted.kind).toBe('accept') - expect(workspaceContextOf(accepted)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) + expect(workspaceContextOf(accepted)?.source).toMatchObject({ kind: 'workspace-instructions' }) expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule') } finally { await ctx.fiber.dispose() @@ -1050,7 +1046,7 @@ describe('workspace context request injection', () => { callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md') @@ -1079,7 +1075,7 @@ describe('workspace context request injection', () => { callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md') @@ -1810,19 +1806,18 @@ describe('dynamic nested workspace context injection', () => { }) expect(result.isError).toBe(false) - expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', - version: 1, changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), }], }) - const meta = workspaceContextOf(result)?.meta - const firstChange = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes) - ? meta.changes[0] + const source = workspaceContextOf(result)?.source + const firstChange = source?.kind === 'workspace-instructions' + ? source.changes[0] : undefined const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange) ? firstChange.digest @@ -1901,9 +1896,9 @@ describe('dynamic nested workspace context injection', () => { agent: stubAgent(root), }) - const meta = workspaceContextOf(result)?.meta - const changes = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes) - ? meta.changes + const source = workspaceContextOf(result)?.source + const changes = source?.kind === 'workspace-instructions' + ? source.changes : [] expect(changes).toEqual(expect.arrayContaining([ expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }), @@ -2122,7 +2117,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(changed)?.meta).toMatchObject({ + expect(workspaceContextOf(changed)?.source).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) @@ -2168,7 +2163,7 @@ describe('dynamic nested workspace context injection', () => { }) // Removing one candidate only removes its own scope; the sibling scope is untouched. - expect(workspaceContextOf(removed)?.meta).toMatchObject({ + expect(workspaceContextOf(removed)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) @@ -2196,7 +2191,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) const text = blocksText(workspaceContextOf(result)?.content) @@ -2276,7 +2271,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(converged)?.meta).toMatchObject({ + expect(workspaceContextOf(converged)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }], }) expect(blocksText(workspaceContextOf(converged)?.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`) @@ -2310,7 +2305,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(converged)?.meta).toMatchObject({ + expect(workspaceContextOf(converged)?.source).toMatchObject({ changes: [ { action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }, { action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }, @@ -2347,9 +2342,8 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(removed)?.meta).toEqual({ + expect(workspaceContextOf(removed)?.source).toEqual({ kind: 'workspace-instructions', - version: 1, changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ @@ -2394,7 +2388,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(removed)?.meta).toMatchObject({ + expect(workspaceContextOf(removed)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) @@ -2433,7 +2427,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(restored)?.meta).toMatchObject({ + expect(workspaceContextOf(restored)?.source).toMatchObject({ changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) @@ -2537,7 +2531,7 @@ describe('dynamic nested workspace context injection', () => { await composeBaselinePrefix(ctx, resumed) const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') - expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ + expect(update?.type === 'user/message' && update.data.source).toMatchObject({ changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume') @@ -2691,31 +2685,23 @@ describe('dynamic nested workspace context injection', () => { { type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' }, { type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' }, ], - source: { kind: 'plugin', plugin: 'workspace-context' }, - meta: { + source: { kind: 'workspace-instructions', - version: 1, changes: [ null, { action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') }, { action: 'set', scope: 'pkg', path: 42 }, { action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 42 }, ], - }, + } as never, }, { surfaceOp: 'append' }) agent.session.append('user/message', { content: [{ type: 'text', text: 'stale metadata version' }], - source: { kind: 'plugin', plugin: 'workspace-context' }, - meta: { kind: 'workspace-instructions', version: 0, changes: [] }, + source: { kind: 'workspace-instructions', changes: 'invalid' } as never, }, { surfaceOp: 'append' }) agent.session.append('user/message', { content: [{ type: 'text', text: 'foreign plugin context' }], source: { kind: 'plugin', plugin: 'other' }, - meta: { - kind: 'workspace-instructions', - version: 1, - changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 'spoof' }], - }, }, { surfaceOp: 'append' }) const result = await ctx.tools.execute({ @@ -2884,8 +2870,8 @@ describe('dynamic nested workspace context injection', () => { }) expect(blocksText(result.content)).toContain('downstream replacement') expect(result.additionalContexts).toHaveLength(2) - expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) @@ -3246,7 +3232,6 @@ describe('workspace context pending state', () => { const otherWorkspaceEvent = agent.session.append('user/message', { content: otherContext.content, source: otherContext.source, - ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions) expect(pending.get(agent.session)?.has('pkg')).toBe(true) @@ -3255,7 +3240,6 @@ describe('workspace context pending state', () => { const confirmed = agent.session.append('user/message', { content: context.content, source: context.source, - ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, confirmed, pending, versions) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 150c4880b3..857583d16e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -72,7 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', - jsDoc: '/**\n * Create an owned agent on a caller-supplied session id.\n * @param ownerCtx - caller context that structurally owns the transaction.\n * @param options - identities, session seed/metadata, loop options, setup, and cancellation.\n * @returns the published handle.\n */', + jsDoc: '/**\n * Create an owned agent on a caller-supplied session id.\n * @param ownerCtx - caller context that structurally owns the lifecycle.\n * @param options - identities, session seed/metadata, loop options, setup, and cancellation.\n * @returns the published handle.\n */', }, { signature: 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', @@ -857,8 +857,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/cancel-requested', mode: 'emit', signature: '\'agent/cancel-requested\'(this: Scoped, agent: Agent, cause: AgentCancelCause): void', - jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - resolved typed cancellation cause, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active turn is aborted.', + jsDoc: '/**\n * Effective broad cancellation was requested, before queued/outbox work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - resolved typed cancellation cause, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted.', }, { name: 'agent/created', @@ -878,9 +878,16 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/error', mode: 'emit', signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void', - jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A step or turn errored. The machine reports a failure here (plus the\n * logger) even when the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, + { + name: 'agent/idle', + mode: 'emit', + signature: '\'agent/idle\'(this: Scoped, agent: Agent, turn: number, reason: IdleReason): void', + jsDoc: '/**\n * One turn closed: its `turn/end` and durability flush are already\n * committed. `reason` says why — recovery consumers observe an `error`\n * reason, repair (edit the log, wait, resummon), and call\n * {@link Agent.retry}; UI consumers key turn-done presentation off it.\n * Emitted per turn, including cancelled and failed ones.\n * @param agent - the agent whose turn closed.\n * @param turn - the closed turn number.\n * @param reason - why the turn ended, with live error facts when it failed.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One turn closed: its `turn/end` and durability flush are already committed.', + }, { name: 'agent/inbox/dequeue', mode: 'emit', @@ -899,51 +906,23 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', - }, - { - name: 'agent/post-step', - mode: 'serial', - signature: '\'agent/post-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * Awaited serial checkpoint after the response, real or synthetic tool\n * results, injected context, and steering are durable but before `step/end`.\n * A cancelled tool batch reaches this checkpoint with an aborted signal.\n * @param agent - the agent whose step is settling.\n * @param turn - the open turn number.\n * @param step - the open step number.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', - summary: 'Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`.', - }, - { - name: 'agent/pre-step', - mode: 'serial', - signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * Awaited serial checkpoint before `step/start`; appends land outside the\n * pending step and are included when the loop derives request history.\n * `signal` cancels listener work.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent opening the step.\n * @param turn - the open turn number.\n * @param step - the pending step number.\n * @param signal - the turn abort signal.\n * @mode serial\n */', - summary: 'Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history.', + jsDoc: '/**\n * A frozen item entered the queued or steering inbox.\n * @param agent - the owning agent.\n * @param message - accepted routing data and correlation identity.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'A frozen item entered the queued or steering inbox.', }, { name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default, including contexts\n * captured with the queued item. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it for another turn.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.', }, { name: 'agent/request', mode: 'waterfall', - signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Replace the frozen call configuration. Model-visible content must use\n * logged channels; this seam cannot mutate messages. Injection here joins\n * the next request because the current step boundary is already fixed.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param config - the config the loop would use (frozen); return a replacement to switch.\n * @param signal - the current turn\'s explicit abort signal; ambient\n * initiator identity does not imply liveness or cancellation authority.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Replace the frozen call configuration.', }, - { - name: 'agent/request-error', - mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Recover a model-request failure after its failed step has closed.', - }, - { - name: 'agent/session-prefix', - mode: 'waterfall', - signature: '\'agent/session-prefix\'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Compose request-only messages placed before derived history. The frozen\n * result is computed once per loop instance, logged on its anchoring request\n * header, and reused so the provider prefix remains stable. Interrupted\n * composition is discarded. Composition precedes the first `agent/pre-step`\n * and request boundary, so listener appends join the current request.\n * Changing context belongs in history; contributors should prepend to\n * `await next()` to preserve registration order.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent whose session prefix is being composed.\n * @param prefix - the frozen seed; return an extended replacement.\n * @param signal - the current turn\'s explicit abort signal.\n * @mode waterfall\n */', - summary: 'Compose request-only messages placed before derived history.', - }, { name: 'agent/session-start', mode: 'emit', @@ -959,25 +938,18 @@ export const EVENT_API: readonly EventApiEntry[] = [ summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).', }, { - name: 'agent/step-result', - mode: 'waterfall', - signature: '\'agent/step-result\'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Waterfall: post-process the assembled assistant {@link Message} before\n * tool dispatch (validation, content rewriting, …).\n * @param agent - the agent that received the step\'s response.\n * @param turn - the open turn number.\n * @param step - the step that produced the message.\n * @param message - the assistant message as assembled from the stream.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).', - }, - { - name: 'agent/turn-continuation', - mode: 'waterfall', - signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Override whether the turn continues. The default continues after tool\n * calls or steering and stops otherwise; a continue reason becomes steering.\n * @param agent - the agent deciding whether to run another step.\n * @param turn - the turn being continued or stopped.\n * @param defaultDecision - what the loop would do absent an override.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Override whether the turn continues.', - }, - { - name: 'agent/turn-stop', + name: 'agent/step', mode: 'serial', - signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined', - jsDoc: '/**\n * Monotonic terminal-stop checkpoint after continuation and steering are\n * folded; a stop remains authoritative through turn close and flush:\n * steering queued in that window is discarded, while ordinary sends survive.\n * @param agent - the agent whose composed continuation outcome may be stopped.\n * @param turn - the turn at its terminal-stop checkpoint.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', - summary: '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.', + signature: '\'agent/step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', + jsDoc: '/**\n * Awaited serial checkpoint before EVERY request of a turn is built (the\n * first as well as each post-tools continuation). The single "between\n * steps" seam: inject context, steer, or edit the session log here — the\n * request\'s history derives from the log right after this settles.\n * @param agent - the agent about to send a request.\n * @param turn - the open turn number.\n * @param step - the step number about to open.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + summary: 'Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation).', + }, + { + name: 'agent/stopping', + mode: 'serial', + signature: '\'agent/stopping\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void', + jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).', }, { name: 'approval/request', @@ -1181,7 +1153,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n}', + declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n abstract retry(): void;\n}', }, { name: 'AgentCancelCause', @@ -1205,7 +1177,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentStatus', - declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', + declaration: 'export type AgentStatus = \'idle\' | \'running\';', }, { name: 'AliasSendOptions', @@ -1521,7 +1493,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'HookContext', - declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}', + declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n}', }, { name: 'InvariantFailure', @@ -1613,7 +1585,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptMessageData', - declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}', + declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}', }, { name: 'PromptMessageEnvelope', @@ -1621,7 +1593,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptPrefixContext', - declaration: 'export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n}', + declaration: 'export interface PromptPrefixContext {\n source: MessageSource;\n}', }, { name: 'PromptSection', @@ -1753,7 +1725,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SendOptions', - declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', + declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n}', }, { name: 'SendTarget', @@ -2133,7 +2105,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionFailure', - declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', + declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n readonly concludesTurn?: never;\n}', }, { name: 'ToolExecutionInput', @@ -2149,7 +2121,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionSuccess', - declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', + declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n readonly concludesTurn?: true;\n}', }, { name: 'ToolExecutionToken', @@ -2189,7 +2161,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolRunContext', - declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n}', + declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n concludeTurn(): void;\n}', }, { name: 'ToolSchema', @@ -2261,7 +2233,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TurnTriggerMap', - declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}', + declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}', }, { name: 'UserInteractionProvider', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 735eeba228..3498e69d9a 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -1,8 +1,9 @@ /** * The concrete Agent, in the naive-agent shape: the agent IS the machine. * Two inboxes — `queued` (prompts, one turn each) and `outbox` (steering + - * injected context, taken whole at every step boundary) — and one `run()` - * per turn: intake the prompt, then step until the model owes no response. + * injected context, taken whole at every step boundary). `kick()` admits and + * records one queued prompt; `start()` then steps until the model owes no + * response. * * The session log IS the transcript: every take appends, every step re-derives * (`session.deriveMessages()`), so editing history between steps is naturally @@ -38,7 +39,7 @@ import type { ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message, MessageSource, } from '@deepseek-ai/dsh-llm' import { canonicalHeader, headerEquals, snapshotJsonValue } from '@deepseek-ai/dsh-session' -import type { JsonValue, PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { PromptMessageData, Session, SessionId, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' @@ -50,7 +51,6 @@ interface QueuedMessage { source: MessageSource contexts: HookContext[] wakeup: boolean - meta?: JsonValue } /** Input awaiting the next step boundary. */ @@ -58,6 +58,13 @@ type OutboxItem = | ({ kind: 'steering' } & QueuedMessage) | { kind: 'context'; context: HookContext } +/** Mutable settlement facts shared by one turn's intake and step loop. */ +interface TurnState { + turn: number + step: number + reason: TurnEndReason +} + /** Build one live inbox event payload from an accepted message. */ function inboxMessage(message: QueuedMessage, steering: boolean): AgentMessage { return { @@ -96,7 +103,6 @@ function preparePromptMessage( displayContent: content, prefixContexts: prefixContexts.map(context => ({ source: context.source, - ...context.meta === undefined ? {} : { meta: context.meta }, })), }, }, @@ -211,7 +217,6 @@ export class ReactLoopAgent extends Agent { source: options.source ?? { kind: 'user' }, contexts: options.contexts ?? [], wakeup, - ...options.meta === undefined ? {} : { meta: options.meta }, }) if (steering) this.outbox.push({ kind: 'steering', ...accepted }) else this.queued.push(accepted) @@ -225,7 +230,6 @@ export class ReactLoopAgent extends Agent { const context = this.accept({ content, source: options.source ?? { kind: 'plugin', plugin: '' }, - ...options.meta === undefined ? {} : { meta: options.meta }, }) if (this.turnAbort !== undefined) { this.outbox.push({ kind: 'context', context }) @@ -280,7 +284,7 @@ export class ReactLoopAgent extends Agent { */ retry(): void { if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`) - this.start() + this.launch({ kind: 'retry' }, (state, signal) => this.start(state, signal)) } /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */ @@ -294,18 +298,52 @@ export class ReactLoopAgent extends Agent { // The machine. // ------------------------------------------------------------------------- - /** Claim the next queued prompt and open a run on it, when nothing is driving. */ + /** Claim, admit, and record the next queued prompt before starting its step loop. */ private kick(): void { if (this.turnAbort !== undefined || !this.queued.some(message => message.wakeup)) return const message = this.queued.shift() if (message !== undefined) { emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false)) - this.start(message) + this.launch({ kind: 'message', source: message.source }, async (state, signal) => { + const decision = await this.loopCtx.waterfall( + agentCarrier(this), 'agent/prompt-submit', this, message.content, message.source, signal, + () => Promise.resolve({ + kind: 'allow', + ...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts }, + }), + ) + signal.throwIfAborted() + + if (decision.kind === 'block') { + this.session.append('prompt/blocked', { + content: message.content, + source: message.source, + reason: decision.reason, + }) + state.reason = { kind: 'rejected', reason: decision.reason } + return + } + + const prepared = preparePromptMessage( + decision.content ?? message.content, + message.source, + decision.additionalContexts ?? [], + ) + this.session.append('user/message', prepared.data, { surfaceOp: 'append' }) + for (const context of prepared.separateContexts) { + this.outbox.push({ kind: 'context', context: this.accept(context) }) + } + await this.start(state, signal) + }, true) } } - /** Open one `run()` — on a claimed prompt, or promptless for a retry. */ - private start(prompt?: QueuedMessage): void { + /** Own one turn from its durable opening through settlement and idle handoff. */ + private launch( + trigger: TurnTrigger, + work: (state: TurnState, signal: AbortSignal) => Promise, + deferOpen = false, + ): void { const controller = new AbortController() this.turnAbort = controller if (!this.busy) { @@ -314,94 +352,57 @@ export class ReactLoopAgent extends Agent { } // The whole run inherits this agent as its process-local initiator so // tools, the llm service, and nested factories can attribute their work. - this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt, controller)) + this.done = this.loopCtx.agents.withInitiator(this, async () => { + const signal = controller.signal + const state: TurnState = { + turn: ++this.lastTurn, + step: 0, + reason: { kind: 'completed' }, + } + let idle: IdleReason = { kind: 'completed' } + + try { + // A queued claim keeps its established pre-turn cancellation window: + // send() returns before the durable turn opens, while retry starts now. + if (deferOpen) await Promise.resolve() + signal.throwIfAborted() + this.session.append('turn/start', { turn: state.turn, trigger }) + this.turnOpen = true + signal.throwIfAborted() + await work(state, signal) + } catch (error: unknown) { + ({ reason: state.reason, idle } = this.settle(state.turn, state.step, error, signal)) + } finally { + if (this.turnAbort === controller) this.turnAbort = undefined + try { + this.closeTurn(state.turn, state.step, state.reason) + } catch (error: unknown) { + // A rejected boundary append (a pre-commit validation veto) must not + // kill the machine or strand its running interval: report and move on — the + // idle tail below still runs and the next turn still opens. + const err = toError(error) + this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${state.turn} failed: ${errorChain(err)}`) + emitAgentEvent(this.loopCtx, this, 'agent/error', state.turn, state.step, err) + } + this.idle(state.turn, idle) + } + }) } - /** - * One `run()` is one turn: prompt intake (submit waterfall), the durable - * turn boundary, then the naive step loop until the model owes no response. - * Every failure funnels to the single catch — {@link settle} classifies it - * once (interruption beats error) — and the finally always closes the owed - * boundaries and runs the idle tail, which opens the next run while work - * remains. - */ - private async run(prompt: QueuedMessage | undefined, controller: AbortController): Promise { - const signal = controller.signal - const turn = ++this.lastTurn - let idle: IdleReason = { kind: 'completed' } - let reason: TurnEndReason = { kind: 'completed' } - let step = 0 - - try { - // Intake precedes the turn: the submit decision belongs to the prompt, - // not the turn (a retry opens a turn with no prompt at all). A failed - // intake leaves no durable trace — nothing entered the conversation. - const decision = prompt === undefined - ? undefined - : await this.loopCtx.waterfall( - agentCarrier(this), 'agent/prompt-submit', this, prompt.content, prompt.source, signal, - () => Promise.resolve({ - kind: 'allow', - ...prompt.contexts.length === 0 ? {} : { additionalContexts: prompt.contexts }, - }), - ) + /** Run the naive step loop after retry or admitted prompt intake has prepared the turn. */ + private async start(state: TurnState, signal: AbortSignal): Promise { + while (true) { + state.step += 1 + const { owes, maxTokens } = await this.step(state.turn, state.step, signal) + if (maxTokens) state.reason = { kind: 'max-tokens' } + // The naive rule, data-driven: run another step while the model is + // owed a response. On a would-stop boundary, `agent/stopping` gives + // listeners one chance to object — by steering, not by voting — and + // the outbox is re-read: data decides, so listener order cannot. + if (owes || this.outbox.some(item => item.kind === 'steering')) continue + await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, state.turn, signal) signal.throwIfAborted() - - this.session.append('turn/start', { - turn, - trigger: prompt === undefined ? { kind: 'retry' } : { kind: 'message', source: prompt.source }, - }) - this.turnOpen = true - signal.throwIfAborted() - - if (prompt !== undefined && decision?.kind === 'block') { - // The audit record stays turn-enclosed: a zero-step rejected turn. - this.session.append('prompt/blocked', { content: prompt.content, source: prompt.source, reason: decision.reason }) - reason = { kind: 'rejected', reason: decision.reason } - } else { - if (prompt !== undefined && decision?.kind === 'allow') { - const prepared = preparePromptMessage( - decision.content ?? prompt.content, - prompt.source, - decision.additionalContexts ?? [], - ) - this.session.append('user/message', { - ...prepared.data, - ...prompt.meta === undefined ? {} : { meta: prompt.meta }, - }, { surfaceOp: 'append' }) - for (const context of prepared.separateContexts) { - this.outbox.push({ kind: 'context', context: this.accept(context) }) - } - } - while (true) { - step += 1 - const { owes, maxTokens } = await this.step(turn, step, signal) - if (maxTokens) reason = { kind: 'max-tokens' } - // The naive rule, data-driven: run another step while the model is - // owed a response. On a would-stop boundary, `agent/stopping` gives - // listeners one chance to object — by steering, not by voting — and - // the outbox is re-read: data decides, so listener order cannot. - if (owes || this.outbox.some(item => item.kind === 'steering')) continue - await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal) - signal.throwIfAborted() - if (!this.outbox.some(item => item.kind === 'steering')) break - } - } - } catch (error: unknown) { - ({ reason, idle } = this.settle(turn, step, error, signal)) - } finally { - if (this.turnAbort === controller) this.turnAbort = undefined - try { - this.closeTurn(turn, step, reason) - } catch (error: unknown) { - // A rejected boundary append (a pre-commit validation veto) must not - // kill the machine or strand its running interval: report and move on — the - // idle tail below still runs and the next turn still opens. - const err = toError(error) - this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`) - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err) - } - this.idle(turn, idle) + if (!this.outbox.some(item => item.kind === 'steering')) break } } @@ -569,12 +570,8 @@ export class ReactLoopAgent extends Agent { let steered = false for (const item of this.outbox.splice(0)) { if (item.kind === 'context') { - const { content, source, meta } = item.context - this.session.append('user/message', { - content, - source, - ...meta === undefined ? {} : { meta }, - }, { surfaceOp: 'append' }) + const { content, source } = item.context + this.session.append('user/message', { content, source }, { surfaceOp: 'append' }) continue } steered = true @@ -583,15 +580,10 @@ export class ReactLoopAgent extends Agent { this.session.append('steering/message', { turn, ...prepared.data, - ...item.meta === undefined ? {} : { meta: item.meta }, }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { - const { content, source, meta } = context - this.session.append('user/message', { - content, - source, - ...meta === undefined ? {} : { meta }, - }, { surfaceOp: 'append' }) + const { content, source } = context + this.session.append('user/message', { content, source }, { surfaceOp: 'append' }) } } return steered diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index b96dd48dbe..52ddbb3a61 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -92,14 +92,12 @@ describe('agent/prompt-submit', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const meta = { kind: 'prompt-context', version: 1 } ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'allow', additionalContexts: [{ content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' }, - meta, }], })) @@ -112,7 +110,6 @@ describe('agent/prompt-submit', () => { expect(userMsg).toBeDefined() expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) - expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta) const sent = JSON.stringify(adapter.requests[0]!.messages) expect(sent).toContain('extra ctx') }) @@ -133,7 +130,6 @@ describe('agent/prompt-submit', () => { content: [{ type: 'text', text: 'untrusted prefix' }], source: { kind: 'plugin', plugin: 'prefix' }, placement: 'prompt-prefix', - meta: { kind: 'prefix-card' }, }], }) await waitForIdle(ctx, agent) @@ -151,7 +147,6 @@ describe('agent/prompt-submit', () => { displayContent: [{ type: 'text', text: 'rewritten request' }], prefixContexts: [{ source: { kind: 'plugin', plugin: 'prefix' }, - meta: { kind: 'prefix-card' }, }], }, }) @@ -616,7 +611,6 @@ describe('tool additionalContexts buffering across a step', () => { additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' }, - meta: { callId: exec.callId }, }], })) @@ -638,7 +632,6 @@ describe('tool additionalContexts buffering across a step', () => { .flatMap(e => (e.type === 'user/message' ? e.data.content : [])) .map(b => (b.type === 'text' ? b.text : '')) expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) - expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) }) it('appends multiple contexts deferred by one composite tool after its outer result', async () => { @@ -647,8 +640,8 @@ describe('tool additionalContexts buffering across a step', () => { ctx.tools.register(defineContentToolFixture({ name: 'composite', description: 'composite', parameters: {}, async execute(_args, exec) { - exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } }) - exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, meta: { order: 2 } }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' } }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' } }) return [{ type: 'text', text: 'outer result' }] }, })) @@ -666,7 +659,6 @@ describe('tool additionalContexts buffering across a step', () => { { kind: 'plugin', plugin: 'a' }, { kind: 'plugin', plugin: 'b' }, ]) - expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 396abec100..6b5da72ff5 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -398,26 +398,20 @@ describe('agent loop', () => { expect(flat).not.toContain('