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 }) } }