diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 37e6ddc6dc..b8f6898abe 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -512,13 +512,13 @@ abstract class Agent { * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause - * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm - * later work. The active turn snapshots and freezes the cause. + * `whenIdle()` resolves after cancellation reaches quiescence. Idle + * cancellation is a no-op and does not arm later work. The active turn + * snapshots and freezes the required cause. * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void + abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ abstract whenIdle(): Promise diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index fcf9e36efc..739f56cda0 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -156,7 +156,7 @@ export function apply(ctx: Context, config: Config): void { } const resolvedTimeZone = formatter.resolvedOptions().timeZone - ctx.on('agent/pre-step', ( + ctx.on('agent/step', ( agent: Agent, turn: number, step: number, diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 5bd858a8a0..364004049e 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -1,7 +1,7 @@ /** * Workspace instruction loader for AGENTS.md-compatible files. * - * Baseline instructions are frozen into `agent/session-prefix`; successful fs + * Baseline instructions enter durable context before the first request; successful fs * tool touches reconcile nested, changed, and removed instructions through * `tools/post-execute` for the next model request. Plugin lifecycle reads use * the optional `ctx.fs` provider, so providerless products mount it as a no-op. @@ -11,7 +11,6 @@ import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { Message } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, type ResolvedConfig } from './config.ts' import { loadBaselineInstructionSet } from './files.ts' @@ -50,6 +49,7 @@ export function apply(ctx: Context, config: Config): void { const baselineInstructionStates = new WeakMap>() const instructionVersions: InstructionVersionCache = new WeakMap() const pendingVersionUpdates = new Map() + const baselineLoaded = new WeakSet() const pendingByParent = new Map => { - const rest = await next() - if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest + ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { + if (baselineLoaded.has(agent.session)) return + if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { + baselineLoaded.add(agent.session) + return + } const fileSystem = ctx.get('fs') - if (fileSystem === undefined) return rest + if (fileSystem === undefined) { + baselineLoaded.add(agent.session) + return + } /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = agent.session.header.cwd ?? process.cwd() const instructions = await loadBaselineInstructionSet({ @@ -97,8 +103,11 @@ export function apply(ctx: Context, config: Config): void { }) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) } - if (instructions === undefined || instructions.rendered.text.length === 0) return rest - return [workspaceContextMessage(instructions.rendered.text), ...rest] + if (instructions !== undefined && instructions.rendered.text.length > 0) { + const baselineMessage = workspaceContextMessage(instructions.rendered.text) + agent.inject(baselineMessage.content, { source: { kind: 'plugin', plugin: 'workspace-context' } }) + } + baselineLoaded.add(agent.session) }) ctx.on('tools/post-execute', async ( diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 103967feee..735eeba228 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -14,18 +14,20 @@ * @module dsh-agent-loop/agent */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { Agent, AgentMessageId, agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { - Agent, + AgentMessage, + AgentMessageId as AgentMessageIdType, + CancelOptions, AgentInterruptReason, AgentOptions, AgentStatus, HookContext, IdleReason, - InjectOptions, PromptDecision, SendOptions, } from '@deepseek-ai/dsh-agent' @@ -36,16 +38,19 @@ import type { ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message, MessageSource, } from '@deepseek-ai/dsh-llm' import { canonicalHeader, headerEquals, snapshotJsonValue } from '@deepseek-ai/dsh-session' -import type { PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { JsonValue, PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' /** A prompt waiting for a turn of its own. */ interface QueuedMessage { + id: AgentMessageIdType content: ContentBlock[] source: MessageSource contexts: HookContext[] + wakeup: boolean + meta?: JsonValue } /** Input awaiting the next step boundary. */ @@ -53,6 +58,18 @@ type OutboxItem = | ({ kind: 'steering' } & QueuedMessage) | { kind: 'context'; context: HookContext } +/** Build one live inbox event payload from an accepted message. */ +function inboxMessage(message: QueuedMessage, steering: boolean): AgentMessage { + return { + id: message.id, + content: message.content, + source: message.source, + contexts: message.contexts, + steering, + wakeup: message.wakeup, + } +} + const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = { type: 'text', text: '\n\n## My request:\n', @@ -118,13 +135,13 @@ function withoutToolCalls(message: Message): Message { * history in, one assistant message out, loop until a reply owes no tool call. * One `run()` drains the work queue, one turn per unit. */ -export class ReactLoopAgent implements Agent { +export class ReactLoopAgent extends Agent { /** Prompts awaiting a turn of their own: one dequeued per turn, FIFO. */ private queued: QueuedMessage[] = [] /** Taken whole at every step boundary; caller-editable until taken (taken = entered the log). */ private outbox: OutboxItem[] = [] - /** Whether `run()` is driving a turn right now — the single activity truth. */ + /** Whether observers see one running drain interval; queued turns share it. */ private busy = false /** The active turn's abort owner; rotated per turn, aborted by {@link cancel}. */ private turnAbort: AbortController | undefined @@ -152,13 +169,14 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { + super() this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 // The scope is keyed by this agent — an opaque identity, fine mid-construction. this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx.extend({ agent: this }) } - /** Pure activity: whether a run is driving right now. */ + /** Last activity state published to observers. */ get status(): AgentStatus { return this.busy ? 'running' : 'idle' } @@ -176,47 +194,40 @@ export class ReactLoopAgent implements Agent { // Public driving verbs. // ------------------------------------------------------------------------- - /** Queue a prompt: one turn of its own, FIFO. */ - send(content: ContentBlock[], options: SendOptions): void { - const accepted = this.accept({ content, source: options.source, contexts: options.contexts ?? [] }) - this.queued.push(accepted) - emitAgentEvent(this.loopCtx, this, 'agent/queued', accepted.content, { - source: accepted.source, - contexts: accepted.contexts, - steering: false, - }) - this.kick() - } + /** Accept and route one unified send item. */ + send(content: ContentBlock[], options: SendOptions = {}): AgentMessageIdType { + const id = AgentMessageId(randomUUID()) + const target = options.target ?? 'next-turn' + const wakeup = options.wakeup ?? true + if (target === 'next-step' && !wakeup) { + this.injectContext(content, options) + return id + } - /** Steer the running turn: taken at the next step boundary. With no turn running, falls back to {@link send}. */ - steer(content: ContentBlock[], options: SendOptions): void { - // `busy` (a turn is actually running), not status: status stays `running` - // across chained turns and through the agent/idle report, where steering - // has no live turn to join and must become a prompt of its own. - if (!this.busy) { this.send(content, options); return } - const accepted = this.accept({ content, source: options.source, contexts: options.contexts ?? [] }) - this.outbox.push({ kind: 'steering', ...accepted }) - emitAgentEvent(this.loopCtx, this, 'agent/queued', accepted.content, { - source: accepted.source, - contexts: accepted.contexts, - steering: true, - }) - } - - /** - * Stage model-facing context without running the model: it rides along with - * whatever runs next (the next step of the running turn, or the next turn). - * While the agent is idle the context is committed immediately as a one-shot - * turn. Appending IS the durable write — persistence drains eagerly on - * every append and owns the write chain end to end. - */ - inject(content: ContentBlock[], options: InjectOptions): void { - const context = this.accept({ + const steering = target === 'next-step' && this.turnAbort !== undefined + const accepted = this.accept({ + id, content, - source: options.source, + source: options.source ?? { kind: 'user' }, + contexts: options.contexts ?? [], + wakeup, ...options.meta === undefined ? {} : { meta: options.meta }, }) - if (this.busy) { + if (steering) this.outbox.push({ kind: 'steering', ...accepted }) + else this.queued.push(accepted) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', inboxMessage(accepted, steering)) + if (!steering && wakeup) this.kick() + return id + } + + /** Stage non-waking context at the next step boundary, or in an idle one-shot turn. */ + private injectContext(content: ContentBlock[], options: SendOptions): void { + const context = this.accept({ + content, + source: options.source ?? { kind: 'plugin', plugin: '' }, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + if (this.turnAbort !== undefined) { this.outbox.push({ kind: 'context', context }) return } @@ -227,7 +238,7 @@ export class ReactLoopAgent implements Agent { try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source: context.source } }) opened = true - this.session.append('context/message', context, { surfaceOp: 'append' }) + this.session.append('user/message', context, { surfaceOp: 'append' }) } finally { // Close only a turn whose start committed; a pre-commit veto escapes. if (opened) this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) @@ -241,16 +252,23 @@ export class ReactLoopAgent implements Agent { * `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose, * all owned by the factory. */ - cancel(cause: AgentInterruptReason = { kind: 'user' }): void { + cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void { if (this.turnAbort !== undefined || this.queued.length > 0 || this.outbox.length > 0) { // Observe-only: coordination consumers update their state before the // inboxes clear; listener failures are contained by the dispatcher. - emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) + if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) + } + if (!options.keepInbox) { + const steering = this.outbox.flatMap(item => item.kind === 'steering' ? [item] : []) + const discarded = [ + ...this.queued.map(message => inboxMessage(message, false)), + ...steering.map(message => inboxMessage(message, true)), + ] + // Clear before abort observers run: replacement work belongs to the next turn. + this.queued.length = 0 + this.outbox.length = 0 + if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) } - // Clear before abort observers run: a replacement enqueued by an observer - // belongs to the next turn. - this.queued.length = 0 - this.outbox.length = 0 this.turnAbort?.abort(Object.freeze({ kind: cause.kind })) } @@ -261,15 +279,15 @@ export class ReactLoopAgent implements Agent { * @throws while a turn is running — there is nothing to retry yet. */ retry(): void { - if (this.busy) throw new Error(`agent "${this.id}" cannot retry while busy`) + if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`) this.start() } - /** Resolve at idle quiescence: no run driving and no prompt waiting. */ + /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */ async whenIdle(): Promise { // `done` is replaced per run, so re-reading it each lap follows chained // turns; a run failure still counts as quiescence for the waiter. - while (this.busy || this.queued.length > 0) await this.done.catch(() => undefined) + while (this.turnAbort !== undefined || this.queued.some(message => message.wakeup)) await this.done.catch(() => undefined) } // ------------------------------------------------------------------------- @@ -278,18 +296,25 @@ export class ReactLoopAgent implements Agent { /** Claim the next queued prompt and open a run on it, when nothing is driving. */ private kick(): void { - if (this.busy) return + if (this.turnAbort !== undefined || !this.queued.some(message => message.wakeup)) return const message = this.queued.shift() - if (message !== undefined) this.start(message) + if (message !== undefined) { + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false)) + this.start(message) + } } - /** Open one `run()` — on a claimed prompt, or promptless for a retry. The caller has checked `busy`. */ + /** Open one `run()` — on a claimed prompt, or promptless for a retry. */ private start(prompt?: QueuedMessage): void { - this.busy = true - emitAgentEvent(this.loopCtx, this, 'agent/status', 'running') + const controller = new AbortController() + this.turnAbort = controller + if (!this.busy) { + this.busy = true + emitAgentEvent(this.loopCtx, this, 'agent/status', 'running') + } // The whole run inherits this agent as its process-local initiator so // tools, the llm service, and nested factories can attribute their work. - this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt)) + this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt, controller)) } /** @@ -300,9 +325,7 @@ export class ReactLoopAgent implements Agent { * boundaries and runs the idle tail, which opens the next run while work * remains. */ - private async run(prompt?: QueuedMessage): Promise { - const controller = new AbortController() - this.turnAbort = controller + private async run(prompt: QueuedMessage | undefined, controller: AbortController): Promise { const signal = controller.signal const turn = ++this.lastTurn let idle: IdleReason = { kind: 'completed' } @@ -342,7 +365,10 @@ export class ReactLoopAgent implements Agent { prompt.source, decision.additionalContexts ?? [], ) - this.session.append('user/message', prepared.data, { surfaceOp: 'append' }) + this.session.append('user/message', { + ...prepared.data, + ...prompt.meta === undefined ? {} : { meta: prompt.meta }, + }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { this.outbox.push({ kind: 'context', context: this.accept(context) }) } @@ -369,7 +395,7 @@ export class ReactLoopAgent implements Agent { this.closeTurn(turn, step, reason) } catch (error: unknown) { // A rejected boundary append (a pre-commit validation veto) must not - // kill the machine or leave `busy` stuck: report and move on — the + // kill the machine or strand its running interval: report and move on — the // idle tail below still runs and the next turn still opens. const err = toError(error) this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`) @@ -544,7 +570,7 @@ export class ReactLoopAgent implements Agent { for (const item of this.outbox.splice(0)) { if (item.kind === 'context') { const { content, source, meta } = item.context - this.session.append('context/message', { + this.session.append('user/message', { content, source, ...meta === undefined ? {} : { meta }, @@ -552,11 +578,16 @@ export class ReactLoopAgent implements Agent { continue } steered = true + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(item, true)) const prepared = preparePromptMessage(item.content, item.source, item.contexts) - this.session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) + this.session.append('steering/message', { + turn, + ...prepared.data, + ...item.meta === undefined ? {} : { meta: item.meta }, + }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { const { content, source, meta } = context - this.session.append('context/message', { + this.session.append('user/message', { content, source, ...meta === undefined ? {} : { meta }, @@ -607,31 +638,30 @@ export class ReactLoopAgent implements Agent { } /** - * The turn boundary's tail (naive `idle()`): the machine is no longer busy, + * The turn boundary's tail (naive `idle()`): no turn owner remains, * the idle report fires (a listener may synchronously `retry()` or `send()` * here — both are legal now), leftover steering becomes queued prompts, and * the next run opens while the queue is non-empty; otherwise the machine * parks. */ private idle(turn: number, idle: IdleReason): void { - this.busy = false - // Status mirrors busy faithfully: chained turns pulse idle → running, - // which is honest — a listener really can act in this window. - emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle') // Requeue BEFORE the idle report so earlier-arrived steering keeps its // FIFO position ahead of anything a listener send()s synchronously. for (const item of this.outbox.splice(0)) { - if (item.kind === 'steering') this.queued.push({ - content: item.content, - source: item.source, - contexts: item.contexts, - }) - else this.outbox.push(item) + if (item.kind === 'context') { + this.outbox.push(item) + continue + } + const { kind: _kind, ...message } = item + this.queued.push(message) } emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle) - // A synchronous idle listener may retry()/send(), flipping busy back. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (this.busy) return // a listener already re-opened - if (this.queued.length > 0) this.kick() + // A synchronous idle listener may retry()/send(), installing a new owner. + if (this.turnAbort !== undefined) return // a listener already re-opened + if (this.queued.some(message => message.wakeup)) this.kick() + else { + this.busy = false + emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle') + } } } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 3a7af2871f..fcace76767 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -71,7 +71,7 @@ describe('Agent.cancel()', () => { }) send(agent, 'drop me') - agent.cancel() + agent.cancel({ kind: 'user' }) await new Promise(resolve => setTimeout(resolve, 30)) agent.cancel({ kind: 'parent' }) @@ -403,7 +403,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) - agent.cancel() + agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'aborted' }]) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index e88703b8f8..396abec100 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index ebb756b139..66e0d8e347 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -60,7 +60,7 @@ The handle every plugin programs against: - `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. - `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. -- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. +- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index c00a440e78..9444f8f883 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,11 +1,7 @@ /** - * Agent-scoped dispatch helpers. An agent-subject event travels with the - * agent's scope carrier as `thisArg` (so scoped listeners filter to their own - * agent) and the agent itself as the first argument. Composable seams are - * plain `ctx.waterfall(carrier, name, agent, …, next)` calls at the machine's - * call sites — concrete event names type-check against the real Cordis - * overloads, so no generic wrapper (and none of its casts) is needed. The one - * helper here is {@link emitAgentEvent}: a contained fire-and-forget emit. + * Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the + * fused dispatcher so subject and scope key cannot diverge; registry lifecycle + * code instead captures one stable carrier for both edges. * @module @deepseek-ai/dsh-agent/dispatch */ @@ -15,6 +11,11 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' import type { Agent } from './types.ts' +/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */ +type Params = F extends (...args: infer P) => unknown ? P : never +/** Extract the return type from an event handler type. */ +type Return = F extends (...args: never[]) => infer R ? R : never + /** * The event names whose subject is an agent: handler parameters start with an * `Agent` AND the handler declares a `Scoped` `this` (the scope-carrier @@ -29,46 +30,102 @@ export type AgentSubjectEvent = { }[keyof Events] /** The event arguments AFTER the injected agent subject. */ -type Tail = Events[K] extends (...args: infer P) => unknown - ? P extends [Agent, ...infer R] ? R : never - : never +type Tail = Params extends [Agent, ...infer R] ? R : never /** - * The scope carrier for an agent-subject dispatch: the agent fused as both - * the carrier key and the event subject, so the two cannot diverge. Pass it - * as the `thisArg` of `ctx.serial` / `ctx.waterfall` for agent events. - * @param agent - the subject agent. - * @returns the fused carrier. + * The fused dispatcher {@link agentEvents} returns: each method dispatches the + * named agent-subject event with the agent's scope carrier as `thisArg` and + * the agent itself injected as the first event argument. */ +export interface AgentEventDispatch { + /** + * Fire-and-forget notification in the agent's scope. Every listener is + * invoked; synchronous throws and returned-promise rejections are logged and + * contained per listener, so a notification cannot veto lifecycle progress + * or starve a later observer. + * @param name - the agent-subject event to emit. + * @param rest - the event's arguments after the injected agent. + */ + emit(name: K, ...rest: Tail): void + /** + * Awaited in-order dispatch (Cordis `serial`) in the agent's scope. + * @param name - the agent-subject event to dispatch. + * @param rest - the event's arguments after the injected agent. + * @returns the serial chain's result (the first bail value, if any). + */ + serial(name: K, ...rest: Tail): Promise>> + /** + * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The + * declared event parameters already end with the `next` callback, so `rest` + * is exactly the event's arguments after the injected agent — the final + * element being the innermost `next` (the default the listener chain wraps). + * @param name - the agent-subject event to dispatch. + * @param rest - the event's arguments after the injected agent. + * @returns the waterfall's composed result. + */ + waterfall(name: K, ...rest: Tail): Return +} + +/** Return the fused scope carrier for one agent subject. */ export function agentCarrier(agent: Agent): Scoped { return scopeTarget(agent, agent) } /** - * Fire-and-forget notification in the agent's scope. Every listener is - * invoked; synchronous throws and returned-promise rejections are logged and - * contained per listener, so a notification cannot veto lifecycle progress or - * starve a later observer. (Raw Cordis `emit` maps callbacks unguarded — one - * synchronous throw would starve the rest and escape into the caller.) + * Build a dispatcher that couples the agent subject to its scope carrier. * @param ctx - the context to dispatch through (any context of the app). * @param agent - the subject agent; also the scope-carrier key. - * @param name - the agent-subject event to emit. - * @param rest - the event's arguments after the injected agent. + * @returns the fused dispatcher. */ -export function emitAgentEvent(ctx: Context, agent: Agent, name: K, ...rest: Tail): void { - const args: unknown[] = [agentCarrier(agent), name, agent, ...rest] - for (const callback of ctx.events.dispatch('emit', args)) { - try { - const returned: unknown = callback(...args) - void Promise.resolve(returned).catch((error: unknown) => { - ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`) - }) - } catch (error: unknown) { - ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`) - } +export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { + const carrier = agentCarrier(agent) + // The ordinary dispatch methods forward through Cordis' variadic mixins. The + // fused (carrier, name, agent, ...rest) tuple is provably a valid argument + // list for the matching thisArg overload, but TypeScript cannot relate the + // generic Tail spread back to that overload's conditional parameter + // tuple — hence one contained, shape-preserving cast per method. + return { + emit(name, ...rest) { + // Cordis emit invokes callbacks through Array.map: one synchronous throw + // starves later listeners, and returned promises are discarded. Agent + // notifications are non-vetoing, so resolve the same filtered callback + // set ourselves and contain both failure modes independently. + const args: unknown[] = [carrier, name, agent, ...rest] + const callbacks = ctx.events.dispatch('emit', args) + for (const callback of callbacks) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`) + }) + } catch (error: unknown) { + ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`) + } + } + }, + async serial(name, ...rest) { + // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function + const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise + return await serial(carrier, name, agent, ...rest) + }, + waterfall(name, ...rest) { + // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function + const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never + return waterfall(carrier, name, agent, ...rest) + }, } } +/** Emit one contained agent notification without allocating a retained dispatcher. */ +export function emitAgentEvent( + ctx: Context, + agent: Agent, + name: K, + ...rest: Tail +): void { + agentEvents(ctx, agent).emit(name, ...rest) +} + /** * Build the prompt assembly context with agent and scope set together, so * agent-scoped prompt and tool contributions cannot be silently omitted. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 21ea0ba7c1..38dab02993 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -17,8 +17,8 @@ import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export { agentInterruptReasonOf } from './cancellation.ts' export * from './llm-target.ts' -export { agentCarrier, assembleContextFor, emitAgentEvent } from './dispatch.ts' -export type { AgentSubjectEvent } from './dispatch.ts' +export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' +export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 2292c81f89..d3b29ef6c2 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -23,6 +23,7 @@ */ import type { Context } from 'cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' @@ -42,47 +43,125 @@ export interface AgentOptions { model?: string } -/** One queued prompt or steering item and its atomic model-facing context. */ -export interface SendOptions { - /** Explicit producer attribution; callers may not inherit human authority by omission. */ - source: MessageSource - /** Context snapshotted with this item and admitted at the same boundary. */ - contexts?: HookContext[] -} +/** + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — the item joins the active turn between steps as steering, + * or, when no turn is active, is promoted per its `wakeup` flag. + */ +export type SendTarget = 'next-turn' | 'next-step' -/** Options for synthetic context injection. */ -export interface InjectOptions { - /** Explicit producer attribution. */ - source: MessageSource - /** Opaque durable state omitted from the model projection. */ +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. + */ +export interface SendOptions { + /** Queue the item joins; defaults to `next-turn`. */ + target?: SendTarget + /** + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). Defaults to + * `true`. A `false` `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). + */ + wakeup?: boolean + source?: MessageSource + /** + * Model-facing contexts captured with this inbox item. A queued prompt exposes + * them through the default `agent/prompt-submit` allow decision, while steering + * records them directly at its next checkpoint. + */ + contexts?: HookContext[] + /** Opaque JSON state retained on the durable message but hidden from the model. */ meta?: JsonValue } +/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ +export type AliasSendOptions = Omit + /** - * An agent's ACTIVITY state, emitted on every transition as `agent/status`: - * `idle` (parked, waiting for queued work) or `running` (the machine is - * draining work). Lifecycle is a separate axis: an agent leaving its host is - * announced by `agent/disposed` and observable as `ctx.agents.get(id)` no - * longer returning it — not as a status value. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. + */ +export type AgentMessageId = Branded<'AgentMessageId'> + +/** + * Brand a string as an {@link AgentMessageId}. + * @param id - the generated message id. + * @returns the same string, branded; no validation is performed. + */ +export function AgentMessageId(id: string): AgentMessageId { + return id as AgentMessageId +} + +/** + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is + * durable model-hidden state that lands on the eventual `user/message`/ + * `steering/message`, not live-event routing data. + */ +export interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + steering: boolean + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean +} + +/** Options for {@link Agent.cancel}. */ +export interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean +} + +/** + * An agent's lifecycle state, emitted on every transition as `agent/status`: + * `idle` (parked, waiting for queued work), `running` (the driver is draining + * work and may be closing or checkpointing a turn), `disposed` (terminal — no + * transition leaves it, and `send`/`followup`/`steer`/`inject` throw). */ export type AgentStatus = 'idle' | 'running' -/** Model-facing context injected by a listener or atomically attached to one inbox item. */ +/** Model-facing context injected by a listener or atomically attached to one inbox message. */ export interface HookContext { content: ContentBlock[] source: MessageSource - /** `prompt-prefix` bakes this context into its prompt; absent/`separate` records an independent message. */ + /** + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable + * request delimiter to the same user-role message as its attached prompt. + */ placement?: 'separate' | 'prompt-prefix' - /** Opaque durable state omitted from the model projection. */ + /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } /** * Prompt interception result. `allow.content` replaces the prompt. Each - * `additionalContexts` entry follows its declared placement. `block` records - * a durable `prompt/blocked` and ends the claimed prompt's zero-step turn as - * rejected. A listener wrapping `next()` preserves downstream fields unless - * it intentionally replaces them. + * `additionalContexts` entry follows its declared placement: separate context + * message by default, or a prefix inside the prompt's user-role message. + * `block` records a durable `prompt/blocked` and ends the claimed prompt's + * zero-step turn as rejected. An `allow` returned by a listener is + * authoritative: a listener wrapping `next()` preserves downstream `content` + * and `additionalContexts` unless it intentionally replaces them. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } @@ -110,47 +189,98 @@ export type AgentCancelCause = /** Runtime reason carried by the signal that controls one live turn. */ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } -/** Public live-agent handle; driving methods have no contract after disposal. */ -export interface Agent { +/** Public live-agent handle with aliases over the unified delivery primitive. */ +export abstract class Agent { /** The single identity shared with {@link session}. */ - readonly id: SessionId - readonly options: AgentOptions - readonly session: Session - readonly status: AgentStatus + abstract readonly id: SessionId + /** The provider route and model this agent's requests use. */ + abstract readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + abstract readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + abstract readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - readonly ctx: Context + abstract readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON prompt. Each claimed prompt is - * the sole ordinary message in its FIFO-ordered turn; the next claimed - * prompt waits for that turn's checkpoint. - * Invalid input throws synchronously before notification or enqueue. + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * Detaches, validates, and freezes one lossless-JSON item, then routes it: + * + * - `next-turn` (default) queues an item that becomes the sole ordinary + * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` submits steering into the active turn + * (idle falls back to a woken `next-turn`). + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: an open turn joins at the current log position + * (deferred behind an executing tool batch until it settles), and an idle + * inject records a one-shot turn with its own durability checkpoint. + * + * Attached contexts share the same snapshot and ownership boundary. Invalid + * input throws synchronously before any notification, enqueue, or append. + * @param content - the model-facing content blocks to deliver. + * @param options - target queue, wakeup decision, source, contexts, and meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - send(content: ContentBlock[], options: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId /** - * Submit steering while the agent is `running`: it enters the outbox and is - * taken whole at the next step boundary, before the next request. Steering - * left over when the turn closes queues for a turn of its own. When idle, - * delegates to {@link send}. + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause + * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm + * later work. The active turn snapshots and freezes the cause. + * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. */ - steer(content: ContentBlock[], options: SendOptions): void + abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void + + /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + abstract whenIdle(): Promise /** - * Stage detached model-facing context without running the model: it enters - * the outbox and rides along with whatever runs next — the next step of the - * running turn (never between a tool-call batch and its results), or the - * next turn when idle. + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param content - the prompt content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options: InjectOptions): void + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) + } /** - * Clear all queued and outbox work and abort the active turn. An effective - * call first emits `agent/cancel-requested` with the resolved typed cause; - * the first cause wins for the active turn. Omission means `{ kind: 'user' }`. - * Idle cancellation is a no-op and does not arm later work. + * Submit steering into the running turn — the `next-step`/wakeup preset of + * {@link send}. An open turn records it at the next steering checkpoint before + * a request or continuation decision; policy may stop before another step. + * After turn close and its checkpoint, any remainder is queued for a later + * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. + * Idle steering falls back to a woken follow-up turn. + * @param content - the steering content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - cancel(cause?: AgentInterruptReason): void + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) + } + + /** + * Append detached model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins + * at the current log position unless the current tool batch is executing; + * then it waits FIFO until that batch settles and drains before turn close + * even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through + * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}. + */ + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) + } /** * Re-open a turn on the current session log without a new prompt — the @@ -160,10 +290,7 @@ export interface Agent { * `agent/idle` listener is legal — the machine is already idle there. * @throws while a turn is running because there is nothing to retry yet. */ - retry(): void - - /** Resolve at idle quiescence; disposal waits for machine exit rather than only the status transition. */ - whenIdle(): Promise + abstract retry(): void } declare module 'cordis' { @@ -181,7 +308,7 @@ declare module 'cordis' { */ 'agent/created'(this: Scoped, agent: Agent): void /** - * An agent left the registry; AgentLoop emits this after machine quiescence + * An agent left the registry; AgentLoop emits this after driver quiescence * but before session detachment and scoped-registration unwind. Custom * registry users own their driver-ordering contract. * @param agent - the exact agent removed from the registry. @@ -190,7 +317,7 @@ declare module 'cordis' { */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * Agent activity changed (`idle` ⇄ `running`). `send()` does + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does * not enter `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). @@ -199,15 +326,33 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * Detached, frozen content entered the agent's inbox (prompt queue or - * steering outbox). These are the exact values retained for the log. - * @param agent - the agent whose inbox received the message. - * @param content - the accepted content blocks retained by the inbox. - * @param info - the accepted source, contexts, and steering classification. + * A frozen item entered the queued or steering inbox. + * @param agent - the owning agent. + * @param message - accepted routing data and correlation identity. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void + 'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage): void + /** + * The driver claimed one item out of the inbox: a queued item at a turn + * boundary, or steering drained between steps. Fires after the item leaves + * its FIFO and before it becomes a durable message. + * @param agent - the agent whose inbox item was claimed. + * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/dequeue'(this: Scoped, agent: Agent, message: AgentMessage): void + /** + * `cancel()` (without `keepInbox`) dropped pending inbox items without + * delivering them. Fires once per effective clearing call with every + * discarded item, after `agent/cancel-requested` and before the abort. + * @param agent - the agent whose inbox was cleared. + * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/discard'(this: Scoped, agent: Agent, messages: AgentMessage[]): void /** * Effective broad cancellation was requested, before queued/outbox work * is cleared or the active turn is aborted. This observe-only notification @@ -217,12 +362,14 @@ declare module 'cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentInterruptReason): void + 'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentCancelCause): void + + // ---- session lifecycle (emit) ---- /** * The session lifecycle began, once before the first turn. Use * `agent.inject()` to seed model-facing context. This is a notification, not * a veto; disposal requested by a lifecycle owner is rechecked before the - * machine starts. + * driver starts. * @param agent - the agent whose session lifecycle began. * @param source - why the session started (fresh startup, resume, …). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 017245aed2..d1b6220210 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -82,6 +82,8 @@ export interface CreateSessionOptions { */ export interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } + /** Recovery turn reopened over the repaired current session log. */ + retry: { kind: 'retry' } /** * An out-of-band context injection (`agent.inject()`) made while the agent * was idle. The loop wraps the injected `user/message` (a non-`user` source, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 2c67c72359..c397b362d9 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1415,12 +1415,15 @@ export class ToolRegistry extends Service { content: result.content, ...result.meta !== undefined ? { meta: result.meta } : {}, ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, - ...result.concludesTurn === true ? { concludesTurn: true as const } : {}, } if (result.isError) { return materializePresentation({ isError: true as const, error: result.error, ...presentation }) } - const detached = materializePresentation({ isError: false as const, ...presentation }) + const detached = materializePresentation({ + isError: false as const, + ...presentation, + ...result.concludesTurn === true ? { concludesTurn: true as const } : {}, + }) return deepFreeze({ ...detached, value: result.value }) } } diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index a4883a0f7c..35522c9372 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -106,7 +106,7 @@ export function apply(ctx: Context): void { /** Read only when the exact Agent remains live. */ function currentGoal(state: DriverState): GoalView | undefined { - if (ctx.agents.get(state.agent.id) !== state.agent || state.agent.status === 'disposed') return undefined + if (ctx.agents.get(state.agent.id) !== state.agent) return undefined return ctx.goals.get(state.agent) } @@ -297,10 +297,6 @@ export function apply(ctx: Context): void { }) ctx.on('agent/status', (agent, status) => { const state = stateFor(agent) - if (status === 'disposed') { - state.stopping = true - return - } if (status === 'idle') { state.competingQueued = false requestDrive(state) diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index e4700452fa..b234e6f4ab 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -346,7 +346,7 @@ export class GoalService extends Service { /** Enforce exact live-agent identity rather than trusting a matching id. */ private assertLive(agent: Agent): void { - if (this.ctx.agents.get(agent.id) !== agent || agent.status === 'disposed') { + if (this.ctx.agents.get(agent.id) !== agent) { throw new GoalError(`agent "${agent.id}" is not live in this registry`, 'GOAL_AGENT_NOT_LIVE') } } diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 2d674912cd..0858394f5a 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -455,7 +455,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { sessionId }, })) } - agent.cancel() + agent.cancel({ kind: 'user' }) return Promise.resolve(ok(request, { accepted: true as const })) }, }, @@ -542,7 +542,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/session-removed', sessionId: session.id })) }), ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { - if (status === 'disposed') return queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' })) }), ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => { diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index 0dcd2045a9..9108a29c14 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -52,8 +52,8 @@ function abortedBeforeDispatchResult(): ToolExecutionResult { /** * Install semantic checkpoint listeners. Loop-built model calls checkpoint the * logged request before adapter dispatch; top-level tool calls checkpoint their - * recorded call before the tool body; post-step checkpoints retain the complete - * response/result batch. Nested tool dispatches reuse the durable outer call. + * recorded call before the tool body; the next request boundary checkpoints + * the preceding response/result batch. Nested tool dispatches reuse the durable outer call. * * Checkpoint failures are fail-closed at the model and tool side-effect * boundaries: the downstream adapter or tool body is not invoked. @@ -74,5 +74,7 @@ export function apply(ctx: Context): void { return next() }) - ctx.on('agent/post-step', (agent): Promise => ctx.sessions.flush(agent.session)) + // Before each request, persist everything committed by the preceding step; + // the first step's call is an intentional no-op beyond any prompt intake. + ctx.on('agent/step', (agent): Promise => ctx.sessions.flush(agent.session)) } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index f5fbd1dff5..ae9439e060 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -1,11 +1,12 @@ /** - * Session-prefix skill catalog and model-facing `skill` loader tool. + * Durable session skill catalog and model-facing `skill` loader tool. * * @module @deepseek-ai/dsh-tool-skill */ import type { Context } from 'cordis' import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { assertNever, type Message } from '@deepseek-ai/dsh-llm' import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' @@ -115,12 +116,19 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. - ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise => { - if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next() + const catalogLoaded = new WeakSet() + ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { + if (catalogLoaded.has(agent.session)) return + if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) { + catalogLoaded.add(agent.session) + return + } const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal }) - const rest = await next() - if (skills.length === 0) return rest - return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest] + if (skills.length > 0) { + const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength) + agent.inject(catalog.content, { source: { kind: 'plugin', plugin: 'dsh-tool-skill' } }) + } + catalogLoaded.add(agent.session) }) } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 4ca297263a..74ab4a2256 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2519,7 +2519,7 @@ export function createTuiChat( } const dispatchMessage = (content: ContentBlock[], contexts: HookContext[]): void => { - if (agent.status === 'disposed') { + if (disposed) { appendNotice(`Agent "${agent.id}" is disposed.`, 'error') } else if (agent.status === 'running') { agent.steer(content, { source: { kind: 'user' }, contexts }) diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 329fad33a7..a71638bb1a 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -235,8 +235,8 @@ export class ApprovalService extends Service { }) }) - // Visibility layer 2: the boundary narrator. pre-step runs after prompt - // assembly but before the request history is derived, so the notice is + // Visibility layer 2: the boundary narrator. agent/step runs before the + // request history is derived, so the notice is // seen by THIS step's request: idle-time flip-flops coalesce at the // turn's first step (net-zero → nothing), and a mid-turn switch is // narrated no later than the next step. What each session was last told @@ -246,7 +246,7 @@ export class ApprovalService extends Service { // switch by the user; otherwise the configured default moved under the // session (operator/config). const narrated = new WeakMap() - ctx.on('agent/pre-step', (agent) => { + ctx.on('agent/step', (agent) => { const session = agent.session const events = session.events let overrideIndex = -1