diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 7c3ed96243..0d217f098a 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e +2026-07-22-unified-send-and-coalesced-user-messages.md: dbd157ad4c81f278cf1417765816497b9e3568df +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: dea32f142c4ef005ff04d00ef8c8ae1ea19c9705 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 9eb355128b..dbd157ad4c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -8,23 +8,23 @@ English | [中文](2026-07-22-unified-send-and-coalesced-user-messages.zh.md) The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work. -Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried `source`/`meta` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). +Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried a non-user `source` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). ## Decision -**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts, meta })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller. +**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller. **inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position (deferred behind an executing tool batch), or a one-shot `injection` turn when idle. It bypasses the FIFOs entirely and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. -**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. +**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind. Typed source variants carry any domain-specific durable provenance. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. -**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. +**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` whose source carries the complete change; a positive round is an admitted continuation prompt. `decodeGoalEvent` takes a `user/message` and fails loud when goal-state content and its typed source disagree. **`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`. **Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, `target`/`wakeup`, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. -**cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). +**cancel gains keepInbox.** `cancel(cause, { keepInbox? })`; callers choose the cause explicitly, and `keepInbox: true` aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped). ## Alternatives considered @@ -36,7 +36,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. +`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 679c9100aa..dea32f142c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -8,23 +8,23 @@ Status: implemented agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`、`steer`、`inject`——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。`send` 和 `steer` 都会把一条冻结的 inbox 记录入队并发出 `agent/queued`;`inject` 则绕过 inbox,写入一条独立的 `context/message` 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让“排入一个轮次但不唤醒驱动器”无法表达,也让 `cancel()` 无从在保留排队工作的前提下中止一个轮次。 -另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带 `source`/`meta` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 +另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带非 user `source` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 ## 决策 -**一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source, contexts, meta })` 覆盖 (`target` × `wakeup`) 矩阵。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)是基类上的具体委托方法,因此具体驱动器只需实现一次 `send`,就能继承这些好用的预设。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`send` 默认使用 `{ target: 'next-turn', wakeup: true }`,因此此前每一次裸调用 `agent.send(content)` 都保持完全相同的行为。`next-turn`/no-wakeup(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。 +**一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source, contexts })` 覆盖 (`target` × `wakeup`) 矩阵。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)是基类上的具体委托方法,因此具体驱动器只需实现一次 `send`,就能继承这些好用的预设。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`send` 默认使用 `{ target: 'next-turn', wakeup: true }`,因此此前每一次裸调用 `agent.send(content)` 都保持完全相同的行为。`next-turn`/no-wakeup(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。 **inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:在当前日志位置追加的持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时的一次性 `injection` 轮次。它完全绕过 FIFO 队列,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 -**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 +**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别。类型化 source 变体携带所有特定于领域的持久 provenance。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 -**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 +**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,其 source 携带完整变更;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 接收一条 `user/message`,并在 goal 状态内容与其类型化 source 不一致时立即报错。 **`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。 **三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、`target`/`wakeup`、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 -**cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 +**cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 ## 考虑过的替代方案 @@ -36,7 +36,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 ## 相关 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index a0504893d9..05d530137c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -16,7 +16,7 @@ The canonical surface separates transformable policy, around-dispatch control, a - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. - `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired for the turn's single claimed queued message before the `user/message` append. The explicit turn signal is placed before the final `next`; `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`, while `block` appends a durable `prompt/blocked` and rejects that zero-step turn. -**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. It is not a `context/message`, so its type does not offer durable context metadata. +**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. Its narrower type does not carry attached contexts. ### The tool pipeline gives each phase one kind of authority diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml new file mode 100644 index 0000000000..2faf718836 --- /dev/null +++ b/docs/core-data-structures/core.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +core.md: 9d0df9f5b8f1b58366f3f54f1627cdcedc7bda57 +core.zh.md: 33c4267f7e2e40f1c48b3d6f07563c8db6817213 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b8f6898abe..9d0df9f5b8 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -1,5 +1,7 @@ # Core Data Structures +English | [中文](core.zh.md) + This folder catalogs the **data structures** of the DeepSeek Harness — what each core type represents, its literal shape, and where the full detail lives. It complements [architecture.md](../architecture.md), which describes *behavior* (the service map, the session/turn/step lifecycle, the event taxonomy); this page describes the *vocabulary* that behavior moves around. ## What counts as "core" @@ -397,8 +399,6 @@ interface SendOptions { * records them directly at its next checkpoint. */ contexts?: HookContext[] - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue } ``` @@ -428,9 +428,7 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t * message's enqueue, dequeue, and discard events. Source defaults are already * applied, so these are the exact values the item was accepted with. `steering` * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is - * durable model-hidden state that lands on the eventual `user/message`/ - * `steering/message`, not live-event routing data. + * claimed at a turn boundary. */ interface AgentMessage { /** The id `send` returned for this message. */ @@ -503,7 +501,7 @@ abstract class Agent { * Attached contexts share the same snapshot and ownership boundary. Invalid * input throws synchronously before any notification, enqueue, or append. * @param content - the model-facing content blocks to deliver. - * @param options - target queue, wakeup decision, source, contexts, and meta. + * @param options - target queue, wakeup decision, source, and contexts. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId @@ -559,7 +557,7 @@ abstract class Agent { * checkpoint. Disposal awaits idle checkpoints; flush failures report through * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. + * @param options - source and attached contexts. * @returns the accepted message's {@link AgentMessageId}. */ inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { @@ -580,7 +578,7 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. +Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input; typed source variants retain model-hidden domain provenance. Absent or `separate` placement becomes an injected `user/message`; `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -595,8 +593,6 @@ interface HookContext { * request delimiter to the same user-role message as its attached prompt. */ placement?: 'separate' | 'prompt-prefix' - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue } ``` @@ -617,7 +613,7 @@ type PromptDecision = | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context metadata — the typed `/goal` pattern): +`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no attached contexts — the typed `/goal` pattern): ```ts type-equiv /** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md new file mode 100644 index 0000000000..33c4267f7e --- /dev/null +++ b/docs/core-data-structures/core.zh.md @@ -0,0 +1,667 @@ +# 核心数据结构 + +[English](core.md) | 中文 + +本目录编目 DeepSeek Harness 的**数据结构**:每个核心类型代表什么、它的字面形状,以及完整细节在哪里。它与 [architecture.md](../architecture.md) 互补——后者描述*行为*(服务映射、会话/轮次/步骤生命周期、事件分类体系);本页描述行为所操作的*词汇*。 + +## 什么算"核心" + +harness 是一个微内核:一个极小的核心加上众多插件。大多数类型属于某一个插件或某一项能力。但有少数类型构成**主干**——agent loop(智能体循环)及其事件在*每一个*轮次中使用的语言,无论加载了哪些可选插件。这些就是"核心"。 + +精确地说,一个数据结构是**核心**的,当且仅当满足以下条件之一: + +1. 它流经 agent loop 主干——循环在每个轮次中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** +2. 它是插件作者面向某条流水线编写的唯一标题类型——`ToolDefinition`(每个工具*是什么*)。 + +其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节*。因此 `ToolDefinition` 是核心,但为它提供类型推导的 `ValueSchemaSpec`/`ParameterSchemaSpec` 机制、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 + +| 子页面 | 负责内容 | +|---|---| +| [llm-streaming.md](llm-streaming.md) | `StreamChunk` 协议格式(wire format)+ 适配器契约(adapter contract)、`BlockAssembler`、`LlmAdapter` seam | +| [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | +| [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | +| [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | +| [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | +| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、轮次封闭不变式 | +| [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | +| [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | +| [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | +| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | +| [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 | +| [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | +| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 | +| [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | +| [pty.md](pty.md) | 持久化终端 ID、后端/会话契约、发送就绪状态、有界读取与 owner 可见快照 | +| [sandbox.md](sandbox.md) | 每会话策略解析与进程约束 seam:文件效果模式、执行/提供方策略、`ConfinedArgv`、强制执行与故障关闭错误 | +| [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 | +| [filesystem.md](filesystem.md) | 文件系统 seam:`FsTarget`、读/写/编辑结果、观测到的文件状态、`FsErrorCode` | +| [lsp.md](lsp.md) | LSP 导航 seam:`LspQueryRequest`/`Result`、`LspProvider`/`Service`、四种操作、`LspError` | +| [skills.md](skills.md) | skill(技能)服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 | +| [compaction.md](compaction.md) | 压缩(compaction)seam:`compact/*` 会话事件、`CompactionResult`、`CompactService` 接口 | +| [subagent.md](subagent.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 | +| [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | +| [spill.md](spill.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | +| [workflow.md](workflow.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | + +> 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务使用生成的[服务目录](../cordis-catalog/services.md)。 + +## `…Map → derived-union` 模式 + +harness 中几乎所有可扩展的和类型都遵循同一形状:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包(package)。 + +```ts ignore-check +// The pattern, schematically: +interface ThingMap { + 'a': { kind: 'a'; /* … */ } + 'b': { kind: 'b'; /* … */ } +} +type ThingKind = keyof ThingMap // 'a' | 'b' +type Thing = ThingMap[keyof ThingMap] // the discriminated union + +// A plugin extends it without touching the source package: +declare module '@deepseek-ai/dsh-llm' { + interface ThingMap { + 'c': { kind: 'c'; /* … */ } + } +} +``` + +六个规范 map 使用此模式;插件作者扩展它们: + +| Map | 包 | 派生 | 目录 | +|---|---|---|---| +| `ContentBlockMap` | dsh-llm | `ContentBlock` | [下文](#content-blocks-and-messages) | +| `MessageSourceMap` | dsh-llm | `MessageSource` | [下文](#content-blocks-and-messages) | +| `FinishReasonMap` | dsh-llm | `FinishReason` | [下文](#the-model-request-and-result) | +| `TurnTriggerMap` | dsh-session | `TurnTrigger` | [session.md](session.md) | +| `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.md) | +| `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.md) | + +消费方最常 `switch` 的两个大型判别联合类型是:**`StreamChunk`**(流式协议)和 **`SessionEvent`**(日志条目)。按仓库约定,对标签做 `switch`——不要链式 `if`——这样每个分支都能窄化类型,拼错的标签会编译失败。 + +## 品牌化 ID + +跨越包边界的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 + +`Branded` 原语位于独立的纯类型包 [dsh-brand](../../packages/util/brand) 中(没有运行时代码,也不依赖 Harness 包),因此任何包都能品牌化其拥有的 id,而无需依赖无关的能力包。 + +源码:[`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) + +```ts type-equiv +/** A string carrying a compile-time-only brand `B`. */ +type Branded = string & { readonly [BRAND]: B } +``` + +两个核心 ID 是 `CallId`(关联工具调用及其结果;dsh-llm)和 `SessionId`(活跃 agent 与持久会话共享的标识;dsh-session)。能力包也会品牌化各自的 id,例如 [tasks.md](tasks.md) 中的 `TaskId`。 + + + +## 内容块与消息 + +一段对话由 `Message` 组成;一条消息是一个类型化**内容块**的数组。块的联合类型从 `ContentBlockMap` 派生。 + +源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) + +```ts type-equiv +/** + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. + */ +interface ContentBlockMap { + 'text': TextBlock + 'reasoning': ReasoningBlock + 'tool-call': ToolCallBlock + 'tool-result': ToolResultBlock +} +``` + +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`)、`ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。核心集仅限于每条交付路径都尊重的块——多模态内容(图像、音频等)没有核心块类型;需要的功能通过可合并扩展的 map 添加,同时提供适配器/UI/压缩支持。 + +`Message` 由角色和块组成。由循环派生的 assistant 消息携带其持久提供方/模型标识,以及可选的适配器私有回放元数据: + +```ts type-equiv +/** Provider ownership and adapter-private replay data for an assistant message. */ +interface AssistantProvenance { + /** Provider route that produced the message. */ + provider: string + /** Provider model id that produced the message. */ + model: string + /** + * Lossless-JSON adapter state needed to replay the provider response. + * `LlmService` exposes it to a target adapter only when that adapter instance + * currently owns both this historical provider and the target provider. + */ + replayState?: unknown +} +``` + +```ts type-equiv +/** + * A single message in a conversation history. Loop-derived assistant messages + * always carry provenance; callers may omit it on hand-built foreign history. + */ +interface Message { + role: 'system' | 'user' | 'assistant' + content: ContentBlock[] + /** Present only on assistant messages produced by a routed adapter. */ + provenance?: AssistantProvenance +} +``` + +消息来源本身也是一个可合并扩展的和类型: + +```ts type-equiv +/** + * Where a message (or injected content) came from. + * Merge-extensible sum type — plugins add their own `kind`s. + */ +interface MessageSourceMap { + user: { kind: 'user' } + plugin: { kind: 'plugin'; plugin: string } +} +``` + +## 流式输出 + +适配器发出原始**分片**协议;循环记录分片(回放保真度),同时将同一批分片送入 `BlockAssembler` 以重建块和消息。`StreamChunk` 是基于 `type` 的封闭判别联合——`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`。 + +完整联合类型、适配器契约(usage-before-finish、原始 JSON 工具参数、两条认可的错误路径)和 `BlockAssembler` 在 **[llm-streaming.md](llm-streaming.md)** 中。 + +## 模型请求 + +一次模型调用是一个完全组装好的 `GenerateOptions`。适配器以原始 `StreamChunk` 流作答;消费方用 `BlockAssembler` 组装它(见 [llm-streaming.md](llm-streaming.md))。 + +源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) + +提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。 + +```ts type-equiv +/** Display metadata for one registered provider route. */ +interface LlmProviderInfo { + /** Provider route key used by {@link GenerateOptions.provider}. */ + id: string + /** Human-readable provider name for selectors and diagnostics. */ + name: string +} +``` + +```ts type-equiv +/** One adapter-discovered model; catalog membership is advisory, not request validation. */ +interface LlmModelInfo { + /** Provider route that owns this model entry. */ + provider: string + /** Model id passed to {@link GenerateOptions.model}. */ + id: string + /** Human-readable model name for selectors. */ + name: string + /** Optional user-facing distinction from otherwise similar models. */ + description?: string +} +``` + +对正确性敏感的模型容量与参考目录分开查询,并归服务该确切路由的适配器所有。 + +```ts type-equiv +/** Provider-owned context capacity for one exact provider/model route. */ +interface LlmModelContext { + /** Maximum combined request and response context in tokens. */ + contextWindow: number +} +``` + +```ts type-equiv +/** A single model request, fully assembled. */ +interface GenerateOptions { + /** Registered provider route selecting the adapter instance. */ + provider: string + model: string + /** + * Ordered conversation messages, exactly as the provider sees them (after + * the `system` slot). A loop-built request assembles them as + * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a + * hand-built one-shot passes any list. + */ + messages: Message[] + /** System prompt text (adapters map to the provider's system slot). */ + system?: string + /** Tool schemas (adapters map to the provider's `tools` field). */ + tools?: ToolSchema[] + temperature?: number + maxTokens?: number + /** + * Stop sequences: generation halts as soon as the model produces any one of + * these strings (adapters map to the provider's stop field, e.g. OpenAI + * `stop`). The stop string itself is not included in the output. + */ + stop?: string[] + signal?: AbortSignal + /** + * Session identity stamped by the loop for listener routing. Adapters ignore + * it; replay uses it to keep concurrent parent and child cursors independent. + */ + sessionId?: Branded<'SessionId'> + /** + * Provider-neutral classification for an auxiliary model call. Adapters may + * map the purpose to model-hidden transport metadata or purpose-specific + * generation policy. Ordinary conversation requests leave it unset. + */ + purpose?: 'compaction' | 'session-title' +} +``` + +模型响应为何停止由可合并扩展的原因表示。提供方终态失败携带流式契约的 [`LlmFailure`](llm-streaming.md#llmfailure): + +```ts type-equiv +/** + * Why a model response stopped. + * Merge-extensible so adapters can surface provider-specific reasons. + */ +interface FinishReasonMap { + 'stop': { kind: 'stop' } + 'tool-calls': { kind: 'tool-calls' } + 'max-tokens': { kind: 'max-tokens' } + 'aborted': { kind: 'aborted'; failure: LlmFailure } + 'error': { kind: 'error'; failure: LlmFailure } +} +``` + +`FinishReason = FinishReasonMap[keyof FinishReasonMap]`。`TokenUsage`(逐调用计量,含不相交的缓存字段)详见 [llm-streaming.md](llm-streaming.md)。 + +`GenerateOptions.tools` 携带 `ToolSchema`——工具的 JSON Schema 描述,发送给模型。它声明在 dsh-llm(而非 dsh-tools)中,正是因为它是循环每一步组装请求的一部分: + +```ts type-equiv +/** + * JSON-schema description of a tool, as sent to the model. + * + * Declared here (not in dsh-tools) because it is part of {@link GenerateOptions}; + * dsh-tools' ToolDefinition and dsh-system-prompt's PromptAssembly both import + * it from this package. + */ +interface ToolSchema { + name: string + description: string + /** JSON Schema object for the arguments. */ + parameters: Record +} +``` + +面向模型的 `ToolSchema` 是协议格式;产出它的已注册 `ToolDefinition`(schema + `execute`)在 [tools.md](tools.md) 中。 + +### 请求信封:`LlmCallConfig` 与记录的 header + +循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 + +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 + +在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 + +FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。 + +```ts type-equiv +/** + * Provider + model + sampling scalars of one conversation's requests. Every field maps + * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests + * from the logged header rather than accepting these per call. + */ +interface LlmCallConfig { + provider: string + model: string + temperature?: number + maxTokens?: number + stop?: string[] +} +``` + +## 会话 + +`Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。事件词汇从 `SessionEventMap` 派生: + +源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) + +```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ +type SessionEvent = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } & (K extends SurfaceEventType ? { + /** + * Seq numbers of events that are provenance sources of this event + * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. + */ + sourceEventSeqs?: number[] + /** How this event entered the surface; absent for non-surface events. */ + surfaceOp?: SurfaceOp + } : object) +}[T] +``` + +十三种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 + +## Agent 句柄 + +`Agent` 是每个插件(UI、钩子、orchestrator)面向编程的 surface。具体实现为 dsh-agent-loop 包内部细节;循环外没有任何组件依赖它。 + +源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +/** + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — the item joins the active turn between steps as steering, + * or, when no turn is active, is promoted per its `wakeup` flag. + */ +type SendTarget = 'next-turn' | 'next-step' +``` + +```ts type-equiv +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. + */ +interface SendOptions { + /** Queue the item joins; defaults to `next-turn`. */ + target?: SendTarget + /** + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). Defaults to + * `true`. A `false` `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). + */ + wakeup?: boolean + source?: MessageSource + /** + * Model-facing contexts captured with this inbox item. A queued prompt exposes + * them through the default `agent/prompt-submit` allow decision, while steering + * records them directly at its next checkpoint. + */ + contexts?: HookContext[] +} +``` + +固定预设别名拥有 `target` 和 `wakeup`,因此只接受其余字段: + +```ts type-equiv +/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ +type AliasSendOptions = Omit +``` + +`send` 返回被接受消息的不透明 `AgentMessageId`,并在该消息的 `agent/inbox/*` 事件中保持稳定: + +```ts type-equiv +/** + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. + */ +type AgentMessageId = Branded<'AgentMessageId'> +``` + +`agent/inbox/*` 实时事件携带一条被接受的消息;注入绕过 FIFO,因此绝不会出现在这些事件中: + +```ts type-equiv +/** + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. + */ +interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + steering: boolean + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean +} +``` + +```ts type-equiv +/** Options for {@link Agent.cancel}. */ +interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean +} +``` + +```ts type-equiv +/** Stable runtime cause accepted by {@link Agent.cancel}. */ +type AgentCancelCause = + | { readonly kind: 'user' } + | { readonly kind: 'parent' } +``` + +`Agent` 是抽象类:具体驱动器实现抽象成员,而 `followup`/`steer`/`inject` 是共享的具体委托方法,它们都委托给覆盖(`target` × `wakeup`)矩阵的唯一抽象 `send`。 + +```ts type-equiv +/** + * Public agent handle; its concrete implementation is internal to + * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so + * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, + * {@link Agent.inject}) are shared concrete delegates over the single abstract + * {@link Agent.send} primitive; concrete drivers implement `send` once. + */ +abstract class Agent { + /** The single identity shared with {@link session}. */ + abstract readonly id: SessionId + /** The provider route and model this agent's requests use. */ + abstract readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + abstract readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + abstract readonly status: AgentStatus + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ + abstract readonly ctx: Context + + /** + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * Detaches, validates, and freezes one lossless-JSON item, then routes it: + * + * - `next-turn` (default) queues an item that becomes the sole ordinary + * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` submits steering into the active turn + * (idle falls back to a woken `next-turn`). + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: an open turn joins at the current log position + * (deferred behind an executing tool batch until it settles), and an idle + * inject records a one-shot turn with its own durability checkpoint. + * + * Attached contexts share the same snapshot and ownership boundary. Invalid + * input throws synchronously before any notification, enqueue, or append. + * @param content - the model-facing content blocks to deliver. + * @param options - target queue, wakeup decision, source, and contexts. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Idle + * cancellation is a no-op and does not arm later work. The active turn + * snapshots and freezes the required cause. + * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. + */ + abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void + + /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + abstract whenIdle(): Promise + + /** + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param content - the prompt content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. + */ + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) + } + + /** + * Submit steering into the running turn — the `next-step`/wakeup preset of + * {@link send}. An open turn records it at the next steering checkpoint before + * a request or continuation decision; policy may stop before another step. + * After turn close and its checkpoint, any remainder is queued for a later + * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. + * Idle steering falls back to a woken follow-up turn. + * @param content - the steering content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. + */ + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) + } + + /** + * Append detached model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins + * at the current log position unless the current tool batch is executing; + * then it waits FIFO until that batch settles and drains before turn close + * even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through + * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. + */ + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) + } +} +``` + +`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越轮次关闭、其持久化检查点以及连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 + +cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 + +[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 + +## 发起 Agent + +`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;其生命周期与边界规则由 [initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)规定。 + +## 拦截决策 + +每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex 钩子桥接层把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。提示词决策与工具后决策共享一种面向模型的上下文形状 `HookContext`,它必须携带 `source`(缺少 source 会默认成 `{kind:'user'}`,从而把插件上下文错标为用户提示词)。其中的 `content` 作为 user-role 输入逐字到达模型;类型化 source 变体保留对模型隐藏的领域 provenance。未指定放置方式或指定为 `separate` 时,上下文会成为一条注入的 `user/message`;`prompt-prefix` 放置方式可用于提示词和 steering 收件箱附件,会在同一条消息中把上下文置于最终生效的请求之前。两种决策都携带 `additionalContexts[]`,使每一项保留各自的 provenance 与放置方式。Continuation reason 则是 steering 消息,并有意使用更窄的 content/source 形状。 + +源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +/** Model-facing context injected by a listener or atomically attached to one inbox message. */ +interface HookContext { + content: ContentBlock[] + source: MessageSource + /** + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable + * request delimiter to the same user-role message as its attached prompt. + */ + placement?: 'separate' | 'prompt-prefix' +} +``` + +`agent/prompt-submit` 返回 `PromptDecision`(允许该轮次已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零步骤轮次): + +```ts type-equiv +/** + * Prompt interception result. `allow.content` replaces the prompt. Each + * `additionalContexts` entry follows its declared placement: separate context + * message by default, or a prefix inside the prompt's user-role message. + * `block` records a durable `prompt/blocked` and ends the claimed prompt's + * zero-step turn as rejected. An `allow` returned by a listener is + * authoritative: a listener wrapping `next()` preserves downstream `content` + * and `additionalContexts` unless it intentionally replaces them. + */ +type PromptDecision = + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'block'; reason: string } +``` + +`agent/turn-continuation` 返回 `ContinuationDecision`(步骤有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一轮次中下一个步骤的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): + +```ts type-equiv +/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ +type ContinuationDecision = + | { action: 'stop' } + | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } +``` + +`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、轮次信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: + +```ts type-equiv +/** Model-request failure with an optional machine-routable provider code. */ +type RequestError = Error & { code?: string } +``` + +它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的步骤,而 `fail` 在 `turn/end` 上保留结构化失败: + +```ts type-equiv +/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ +type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } +``` + +`agent/post-step` 会在 assistant 输出、真实或合成的工具结果、缓冲上下文与 steering 持久化之后、`step/end` 之前被 await。被取消的工具批次在排空后携带 aborted signal 到达这里;其签名为 `(agent, turn, step, signal)`,可回放事实保留在会话日志中,而不是瞬态 payload 中。 + +`agent/turn-stop` 返回仅停止的 `ContinuationStop` 子集或 `undefined`。循环在折叠普通决策、其 reason 和待处理 steering 之后调用此串行检查点;stop 是终态,会丢弃待处理的 steering。 + +```ts type-equiv +/** + * The terminal subset of {@link ContinuationDecision}. A listener on + * `agent/turn-stop` returns this to make the already-composed continuation + * outcome terminal; `undefined` abstains. + */ +type ContinuationStop = Extract +``` + +`agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): + +```ts type-equiv +/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ +type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' +``` + +`agent/session-prefix` 在每个循环实例中组合一次 `Message[]`。深度冻结的结果被记录在请求 header 中,并前置于每次派生历史,使其成为会话稳定开场白的归属。恢复的实例会重新组合;会话中途的变更使用仅追加的上下文通道。该 waterfall 直接返回内容,因为它是贡献而非决策。 + +## `ToolDefinition` + +唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的契约。 + +其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 形状,以及工具展示 UI 词汇在 **[tools.md](tools.md)** 中。 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml new file mode 100644 index 0000000000..55517ff4e5 --- /dev/null +++ b/docs/core-data-structures/session.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +session.md: f439b3cb681a4073bba76cfeb23e8711000da797 +session.zh.md: 8da3384900a1963de61552e3e72af8bbb1f509d6 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 30a6f897d4..f439b3cb68 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -1,5 +1,7 @@ # Sessions +English | [中文](session.zh.md) + The in-memory, event-sourced model of [dsh-session](../../packages/core/session). A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth for an agent's whole interaction history. The LLM message history is *derived* from the log, never stored separately; replay is re-derivation from the same events. How the log is made **durable** (the persistence seam, backends, crash recovery) is the sibling concern on [persistence.md](persistence.md). Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) @@ -14,7 +16,7 @@ The append-only event types. Merge-extensible: a plugin declares extra event typ * direct human prompt, a synthetic `agent.inject()` context, and mid-turn * steering all project into the model transcript as verbatim user-role content; * they are told apart by `source` (a non-`user` kind marks injected context), - * not by event type. `meta` carries durable model-hidden producer state. + * not by event type. */ interface PromptMessageData { /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ @@ -23,15 +25,6 @@ interface PromptMessageData { source: MessageSource /** Present only when prompt-prefix contexts were baked into `content`. */ envelope?: PromptMessageEnvelope - /** - * Opaque durable JSON state retained on the event but hidden from the model - * projection. It is the intended channel for a future framing directive (a - * producer declares the frame, a dedicated renderer applies it — see the - * deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - meta?: JsonValue } ``` @@ -123,7 +116,7 @@ interface SessionEventMap { } ``` -`PromptMessageData.content` is always the exact model-facing content. When attached context declares `prompt-prefix` placement, AgentLoop concatenates its blocks, a `## My request:` delimiter, and the effective direct prompt into that array. The optional model-hidden `envelope` retains `displayContent` plus ordered prefix-context source/metadata descriptors, so transcript, title, and re-reference consumers can present the human prompt without changing reconstructable history. `displayPromptContent()` performs that selection and falls back to `content` for ordinary and older events. +`PromptMessageData.content` is always the exact model-facing content. When attached context declares `prompt-prefix` placement, AgentLoop concatenates its blocks, a `## My request:` delimiter, and the effective direct prompt into that array. The optional model-hidden `envelope` retains `displayContent` plus ordered prefix-context sources, so transcript, title, and re-reference consumers can present the human prompt without changing reconstructable history. `displayPromptContent()` performs that selection and falls back to `content` for ordinary and older events. ### `OutOfBandSessionEventMap` — narrow late-append opt-in @@ -471,7 +464,7 @@ declare class Session { - `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata. - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. -- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. +- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position; provenance and domain data live in its typed source. - `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md new file mode 100644 index 0000000000..8da3384900 --- /dev/null +++ b/docs/core-data-structures/session.zh.md @@ -0,0 +1,557 @@ +# 会话 + +[English](session.md) | 中文 + +[dsh-session](../../packages/core/session) 的内存事件溯源模型。`Session` 是一份由类型化 `SessionEvent` 组成的**仅追加日志**,是 agent(智能体)完整交互历史的唯一真源。LLM(大语言模型)消息历史从日志*派生*而来,从不单独存储;回放即从同一组事件重新派生。日志如何实现**持久化**(持久化 seam、后端、崩溃恢复)是兄弟文档 [persistence.md](persistence.md) 的关注点。 + +源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) + +## `SessionEventMap`:事件词汇 + +仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[上下文压缩(context compaction) seam](compaction.md) 添加了 `compact/start` / `compact/summary` / `compact/end`,`@deepseek-ai/dsh-hook-protocol` 添加了仅记录日志的 `hook/invoked` / `hook/result` 溯源事件,用于钩子桥接。与 `compact/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.md)列举了所有成员(核心与合并扩展的),包含其 payload、surface 标记与声明位置。 + +```ts type-equiv +/** + * Shared payload for user, injected-context, and steering prompt messages. A + * direct human prompt, a synthetic `agent.inject()` context, and mid-turn + * steering all project into the model transcript as verbatim user-role content; + * they are told apart by `source` (a non-`user` kind marks injected context), + * not by event type. + */ +interface PromptMessageData { + /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ + content: ContentBlock[] + /** Producer provenance for the direct prompt. */ + source: MessageSource + /** Present only when prompt-prefix contexts were baked into `content`. */ + envelope?: PromptMessageEnvelope +} +``` + +```ts type-equiv +/** + * The merge-extensible, append-only source of truth for an agent interaction. + * Message history is derived from this log. Every event is lossless JSON and + * sequence numbers stay contiguous, including raw chunks, so persistence can + * store the canonical log verbatim. + */ +interface SessionEventMap { + /** + * Opens turn `turn`. `trigger` records what started it — one claimed queued + * message or an idle-time injection. The turn is the durability/replay + * boundary: every event sits between a `turn/start` and its matching + * `turn/end` (the turn-enclosure invariant). + */ + 'turn/start': { turn: number; trigger: TurnTrigger } + /** + * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. + */ + 'turn/end': { turn: number; reason: TurnEndReason } + /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ + 'step/start': { turn: number; step: number } + /** Closes step `step` of turn `turn`. */ + 'step/end': { turn: number; step: number } + /** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ + 'user/message': PromptMessageData + /** + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, and its turn runs zero steps. + */ + 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } + /** Raw stream chunk — token-level replay fidelity. */ + 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } + /** + * Assembled assistant message for one step (derived history uses this). + * Carries the step's `usage` when the adapter reported token accounting, so + * the model output and its accounting travel together (there is no separate + * usage record). `usage` is absent when the adapter reported none. + */ + 'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage } + /** + * The model requested one tool invocation: `name` with the raw `arguments` + * JSON string exactly as the model produced it (unparsed). `callId` pairs the + * call with its `tool/result`. + */ + 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + /** + * A completed tool call's model-facing result, optional internal failure + * identity, and optional tool-private `meta` presentation payload. `meta` is + * opaque to the core (the producing tool owns its shape and reads it back in + * `presentResult`) but MUST be JSON-serializable: `Session.append` + * runtime-validates all event data with `isJsonValue`, so a non-serializable + * `meta` is rejected at the source, and the durable log reproduces the + * identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). + */ + 'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { name: string; code: string } + meta?: JsonValue + } + /** Steering content injected between steps of a running turn. */ + 'steering/message': PromptMessageData & { turn: number } + /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ + 'todo/write': { todos: TodoItem[] } + /** + * Full header for the next request, appended inside its step before dispatch. + * It is log-only; the latest snapshot reconstructs the request header. + */ + 'request/header': { header: EpochHeader; reason: RequestHeaderReason } +} +``` + +`PromptMessageData.content` 始终是确切的模型可见内容。当附加上下文声明 `prompt-prefix` 放置方式时,AgentLoop 会依次把它的块、一个 `## My request:` 分隔符以及最终生效的直接提示词拼接进该数组。可选且对模型隐藏的 `envelope` 会保留 `displayContent`,以及按顺序排列的前缀上下文 source,使 transcript(文本记录)、标题与重新引用消费方无需改变可重建历史,就能呈现人类提示词。`displayPromptContent()` 负责该选择,并为普通事件和较早的事件回退到 `content`。 + +### `OutOfBandSessionEventMap`:受限的带外追加显式准入 + +仅属于 `SessionEventMap` 并不表示事件可以脱离 agent loop(智能体循环)的常规生命周期追加。事件所有方必须通过声明合并将同一键加入这个空标记映射,`ctx.sessions.appendOutOfBand()` 才会接受该事件;派生类型还会排除所有 surface 事件。被接受的更新会并入已打开的轮次;如果没有打开的轮次,系统则为它创建一个边界配平且已刷新完成的零步骤轮次。 + +```ts type-equiv +/** + * Marker map for plugin-owned log-only events accepted by + * `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key + * it adds to {@link SessionEventMap}; surface and lifecycle events stay + * ineligible unless their owner explicitly opts them into this narrow seam. + */ +interface OutOfBandSessionEventMap {} +``` + +### `TodoItem`:一条待办项 + +这是 `todo/write` 事件全量列表快照中的单元。它有意保持精简:一行 `content` 加一个三态 `status`(没有 id、优先级或 `activeForm`);列表在每次写入时整体替换,因此条目无需稳定标识,而这三个状态值恰好对应 ACP 的 `PlanEntryStatus`,所以 UI 桥接层可以将待办列表一一映射为 ACP `plan`(并合成 ACP 额外要求的优先级)。见 [todo_write Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md)。 + +```ts type-equiv +/** + * One entry in an agent's todo list — the unit of the `todo/write` + * {@link SessionEventMap} event's whole-list snapshot. + * + * Deliberately minimal: a human-readable `content` line and a three-state + * `status`. No id, priority, or `activeForm` — the list is replaced wholesale + * on every write (last-write-wins), so entries need no stable identity, and the + * status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a + * todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally + * requires). + */ +interface TodoItem { + /** What this task is — a short imperative line shown in the UI. */ + content: string + /** Lifecycle state. `in_progress` marks the single task being worked now. */ + status: 'pending' | 'in_progress' | 'completed' +} +``` + +### 请求头事件:`request/header` + +请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema + 会话前缀)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 + +```ts type-equiv +/** + * Logged request state outside derived history: call config, system prompt, + * tools, and prefix. The latest full `request/header` snapshot reconstructs it; + * canonical empty optional fields are absent. + */ +interface EpochHeader { + /** The conversation's call configuration (provider, model, and sampling scalars). */ + config: LlmCallConfig + /** Rendered system prompt text; absent for a system-less request. */ + system?: string + /** Assembled tool schemas; absent for a tool-less request. */ + tools?: ToolSchema[] + /** + * The session prefix: request-only messages sent BEFORE the entire derived + * history (the `agent/session-prefix` waterfall's product, composed once + * per loop instance and reused for every request it sends). Not session + * history — `deriveMessages()` never returns it — so the header is its + * only durable record; absent when the instance composed none. + */ + messagePrefix?: Message[] +} +``` + +规范形式:空系统提示词、空工具列表和空会话前缀都表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix + derived history`);每个 agent loop 实例只组合一次,并包含在该实例记录的每份完整快照中。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 + +## `SessionEvent`:一条日志条目 + +基于 `type` 的真正可辨识联合(而非独立的 `type`/`data` 联合),因此 `switch (event.type)` 能直接收窄 `event.data`,无需类型断言。`seq` 是日志中的单调递增位置(`seq = log.length`);`time` 为 epoch 毫秒。 + +```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ +type SessionEvent = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } & (K extends SurfaceEventType ? { + /** + * Seq numbers of events that are provenance sources of this event + * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. + */ + sourceEventSeqs?: number[] + /** How this event entered the surface; absent for non-surface events. */ + surfaceOp?: SurfaceOp + } : object) +}[T] +``` + +`SessionEventType = keyof SessionEventMap`。由于 `SessionEventMap` 可通过合并扩展,对 `SessionEvent` 的 switch 语句禁止使用 `assertNever`:插件添加的变体是合法的未知值;处理已知 case 后在 `default` 中放行。 + +对于 `assistant/message`,存在的 `sourceEventSeqs: []` 表示提供方流已知且完整地为空;字段缺失则表示旧格式或其他未记录溯源信息的情况。agent loop 会为每次成功的模型调用写入该字段;其他 surface 事件只要包含该字段,其列表就必须非空。 + +## Surface 类型 + +四种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`steering/message`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)。 + +### `SurfaceEventType`:事件类型中产生消息的子集 + +```ts type-equiv +/** + * The subset of {@link SessionEventType} values whose events produce LLM + * messages and are eligible to appear on the ordered surface. Only these + * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}. + */ +type SurfaceEventType = + | 'user/message' + | 'assistant/message' + | 'tool/result' + | 'steering/message' +``` + +### `SurfaceOp`:事件如何进入 surface + +```ts type-equiv +/** + * How a session event entered the ordered surface. Only valid on + * {@link SurfaceEventType} events. + * + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering + * messages. + * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` + * (inclusive) through `end` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `start === end` replaces a single + * node. The node's {@link SessionEvent.sourceEventSeqs} must include every + * shadowed surface node. Used by compaction and possible other manipulations. + */ +type SurfaceOp = + | 'append' + | { op: 'replace'; start: number; end: number } +``` + +`'append'` 是常规的尾部追加路径。`replace` 会遮蔽从 `start` 到 `end`(含两端)的 surface 条目(两者都必须是有效的 surface seq;`start === end` 时仅替换单个条目),并在原位置插入新事件。 + +### `SurfaceIntent`:`session.append()` 的参数 + +```ts type-equiv +/** + * Surface placement and provenance for {@link Session.append}. Required on + * message-producing events and forbidden on log-only events. + */ +interface SurfaceIntent { + surfaceOp: SurfaceOp + /** + * Complete known provenance source set. `assistant/message` may use a + * present empty array for a known empty provider stream; omission means its + * provenance was not recorded. Other surface events require a non-empty set + * when this field is present. + */ + sourceEventSeqs?: number[] +} +``` + +对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生历史的唯一来源)。非 surface 类型在编译期拒绝此参数。 + +此处适用相同的溯源区分:只有 `assistant/message` 可以携带存在但为空的 `sourceEventSeqs`;省略该字段并不表示其源流为空。 + +### `SessionSurface`:实时只读 surface 投影 + +`Session.surface` 返回会话稳定的 `SessionSurface` 视图。同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系和替换代次,但不能调用校验。 + +```ts type-equiv +/** Readonly live projection of the message-producing session events. */ +interface SessionSurface { + /** Current surface event sequences in model-visible order. */ + readonly nodes: readonly number[] + /** Monotonic count of committed positional replacements. */ + readonly replaceGeneration: number +} +``` + +### `SurfaceFoldReplacement` 与 `SurfaceFoldResult`:完整的 surface 回放 + +`foldSurface(events)` 返回一份独立的当前事件 seq 列表,以及每个声明的替换范围实际遮蔽的 seq。实时管理器复用同一套状态转换,但不保留替换历史。每提交一次替换,其 `replaceGeneration` 就递增一次,使增量消费方能够区分纯尾部增长与重写。 + +```ts type-equiv +/** One replacement operation observed while folding a session surface. */ +interface SurfaceFoldReplacement { + /** Seq of the event that replaced the prior surface range. */ + seq: number + /** Declared inclusive start seq of the replaced surface range. */ + start: number + /** Declared inclusive end seq of the replaced surface range. */ + end: number + /** Actual surface entries removed by the operation, in surface order. */ + shadowedSeqs: number[] +} +``` + +```ts type-equiv +/** Complete result of replaying the surface operations in a session log. */ +interface SurfaceFoldResult { + /** Current surface event sequences in model-visible order. */ + nodes: number[] + /** Replacement operations in event order. */ + replacements: SurfaceFoldReplacement[] +} +``` + +## `Session` public API + +去除方法体的声明与源码中的普通类保持同步,覆盖其公共构造函数、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 服务目录](../cordis-catalog/services.md#ctxsessions--sessionstore)记录。 + +```ts public-api +/** + * An event-sourced session: an append-only log of {@link SessionEvent}s. + * + * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Seeding with an existing event log replays/forks a session. + */ +declare class Session { + /** The ordered surface over this session's event log. */ + get surface(): SessionSurface; + /** + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so + * `session.header` is always present. Kept out of the event log — it is a + * storage concern, not replayable conversation state. + */ + readonly header: SessionHeader; + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId; + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); + /** + * An immutable snapshot of the append-only event log. The snapshot is reused + * until the next append; a previously returned array does not grow later. + * Events and their nested data are deep-frozen at acceptance, so neither a + * cast nor ordinary JavaScript can rewrite durable history. + */ + get events(): readonly SessionEvent[]; + /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ + get seq(): number; + /** + * Append one typed event to the log and synchronously notify observers via + * the store-owned, module-private publication hooks. The hot path never blocks + * on I/O — persistence plugins buffer asynchronously. Once the event enters + * the log, the append is committed: observer failures are logged and + * contained per listener, so they do not change the return value or prevent + * later listeners from observing the same accepted event. + * + * @param type - The event type (key of {@link SessionEventMap}). + * @param data - The event payload; must be JSON-serializable. + * @param opts - Surface metadata: `surfaceOp` controls how the event enters + * the ordered surface; `sourceEventSeqs` records provenance (the seq + * numbers of events this one derives from). REQUIRED for + * {@link SurfaceEventType} events (every message-producing event must + * declare how it joins the surface, the sole source of derived history) and + * rejected by the compiler for non-surface types like `turn/start` or + * `assistant/chunk`. + * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of + * `data` that entered the log, so reading `event.data` back sees the logged + * value, never the caller's still-mutable input. + * @throws if `data` or surface metadata is not losslessly JSON-serializable + * (BigInt, function, symbol, undefined, negative zero, non-finite number, + * circular reference, sparse array, or an exotic object such as + * Map/Set/Date/class instance), or when the candidate violates the + * canonical surface contract (marker shape and eligibility, unique + * earlier provenance, positional replacement validity, and complete + * shadowed-node coverage). One recursive pass reads, validates, and + * copies each nested value once, so a stateful getter cannot supply one value + * to validation and another to storage. The event log is the durable source + * of truth, so a bad event fails at the append site rather than later during + * a backend flush. A synchronous internal dispatch validation failure or an + * append reentered while this acceptance/publication boundary is open also + * rejects before the log changes. + */ + append( + type: T, + data: SessionEventMap[T], + ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] + ): SessionEvent; + /** + * The {@link EpochHeader} in force after the log's last header event — the + * header the NEXT request will be compared against — or undefined before + * the first `request/header` snapshot. The live, incrementally-maintained + * form of `foldRequestHeader(session.events)`: each header event is folded + * once, when first seen, so a per-step read costs O(new events). + * @returns the folded header, or undefined when no header event exists yet. + */ + requestHeader(): EpochHeader | undefined; + /** + * Derive the LLM message history by walking the ordered sequences of + * message-producing events maintained by `surfaceOp` markers. The + * surface is the single source of derived history: every message-producing + * append records its `surfaceOp`, so a raw event with no marker (a chunk, a + * turn boundary) is correctly absent, and a compaction `replace` deletes the + * shadowed nodes from the derivation. The projection rules are + * {@link deriveEventMessage}, folded per node. + * + * CACHED: each surface node is projected exactly once, when first seen — a + * call costs O(new nodes), and a surface rewrite (a `replace`; + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * a fresh snapshot per call (later appends never grow an array a caller + * already holds); the `Message` objects in it are SHARED and **deep-frozen**. + * Their content reuses the already frozen durable event data, so the cache + * needs no second deep clone and consumers still cannot mutate the log. + * @returns a fresh array of the shared, frozen derived history. + */ + deriveMessages(): Message[]; + /** + * Project a single event into the LLM message it derives to, or null when + * it produces none — a non-surface event (chunk, boundary, log-only record) + * or an empty-content assistant/message (which exists only to host usage). + * The per-node pure function {@link deriveMessages} folds over the surface; + * an external reconstructor (or the dev invariant) folds the same function + * over a log prefix's surface to rebuild the exact messages any request was + * built from (the reconstructability Agent Note). The returned message wrapper is + * fresh; its content reuses the logged event's already deep-frozen durable + * data, so changing the wrapper cannot rewrite the log and changing content + * throws. + * @param event - the event to project. + * @returns the derived message, or null when the event produces none. + */ + deriveEventMessage(event: SessionEvent): Message | null; +} +``` + +## 派生历史:`deriveMessages()` 与 `deriveEventMessage()` + +`Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则: + +- `user/message` → 一条携带确切 `content` 的 user 消息;可选 envelope 仅作为日志中的展示元数据保留。 +- `assistant/message` → 一条 assistant 消息,包含事件的提供方/模型溯源信息和可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 以承载用量和溯源信息,但无内容的 assistant 轮次不得进入提供方 transcript。 +- `tool/result` → 一条携带 `tool-result` 块的 user 消息。 +- `user/message`(注入的上下文,即非 `user` source)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`;provenance 与领域数据位于其类型化 source 中。 +- `steering/message` → 按时间顺序在相应位置生成一条携带确切 `content` 的 user-role 消息;可选 envelope 仅作为日志中的展示元数据保留。 + +其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。操作错误的步骤号记录在 `turn/end.reason`(`kind: 'error'`)中;如果是最终模型请求失败,其中包含规范化的 `LlmFailure` 事实,其他实时错误则包含消息/代码。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 + +## 活跃会话 fork API + +`ctx.sessions.create(id, { seed, meta })` 是底层的回放/fork 原语。对于普通的活跃会话 fork,`SessionStore` 暴露一个策略 API: + +- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `boundary` seq(含)为止的源事件(默认为当前最后一个事件),要求 boundary 事件必须是 `turn/end`,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据(`parentSession`、`seedLength` 及继承的 `cwd`)。 + +显式 `boundary` 允许调用者从之前完成的轮次 fork,即使源会话有更新的事件或正在进行的轮次。API 拒绝非 `turn/end` 的 boundary,而不是静默截断。更广泛的轮次封闭性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具时委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 + +## 轮次的触发原因:`TurnTriggerMap` + +```ts type-equiv +/** + * What started a turn. + * Merge-extensible sum type (same pattern as MessageSourceMap). + */ +interface TurnTriggerMap { + message: { kind: 'message'; source: MessageSource } + /** + * An out-of-band context injection (`agent.inject()`) made while the agent + * was idle. The loop wraps the injected `user/message` (a non-`user` source, + * plugin by default) in a one-shot turn (`turn/start` → `user/message` → + * `turn/end`) so every event in the log stays turn-enclosed — the + * durability/replay boundary is the turn, and a bare event between turns would + * otherwise be indistinguishable from a crash tail on reload. The trigger's + * `source` mirrors that message's producer. + */ + injection: { kind: 'injection'; source: MessageSource } +} +``` + +## 轮次的结束原因:`TurnEndReasonMap` + +`aborted` 有意作为一种粗粒度的持久结果:它只记录取消中断了实时轮次,不记录是哪个运行时调用方发起取消。仅属于运行时的调用方词汇由 [`AgentCancelCause`](core.md#the-agent-handle) 定义;未来若有审计需求,应新增独立的控制请求事件,而非让终止结果承载这一信息。 + +```ts type-equiv +/** + * Why a turn ended. Merge-extensible sum type. + */ +interface TurnEndReasonMap { + completed: { kind: 'completed' } + /** A cancellation request interrupted the live turn. */ + aborted: { kind: 'aborted' } + /** + * The turn failed: a step threw or the model reported a failure. `step` is the + * step number the failure occurred on (the operational error's location — the + * single durable record of an in-turn failure; live diagnostics also fire via + * `agent/error`). Final model-request failures retain their normalized facts + * as one `failure`; other turn failures retain their live Error message/code. + */ + error: { kind: 'error'; step: number } & ( + | { failure: LlmFailure; message?: never; code?: never } + | { message: string; code?: string; failure?: never } + ) + disposed: { kind: 'disposed' } + /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ + 'max-tokens': { kind: 'max-tokens' } + /** + * Policy blocked the turn's claimed prompt before the first step. The + * zero-step turn still records a balanced durable boundary and veto reason. + */ + rejected: { kind: 'rejected'; reason: string } + /** + * A persistence backend closed a crash-orphaned turn on reload. The loop never + * emits this marker, and the events recorded before the crash remain intact. + */ + interrupted: { kind: 'interrupted' } +} +``` + +`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`rejected` 表示一个零步骤轮次,其已认领的提示词被 `agent/prompt-submit` 钩子阻止(ACP(Agent Client Protocol)桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 + +## 轮次封闭不变式 + +每个会话事件都位于一个轮次**之内**(在 `turn/start` 和对应的 `turn/end` 之间)。loop 在 `turn/start` *之后*追加已排队的 `user/message` 事件;空闲时的 `agent.inject()` 会用一次性的 `injection` 轮次包住其 `user/message`;没有打开的轮次时,`appendOutOfBand()` 同样会用一个轮次包住符合条件的仅日志事件。这使轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为崩溃中断尾部,而不会丢失合法记录在轮次之间的上下文。可选的 `dsh-session/invariant` 配套插件通过 `ctx.invariants` 在开发环境中强制此不变式(消息事件若位于打开的轮次之外便会抛出)。见[轮次封闭不变式 Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 + +## 插件贡献的仅日志事件 + +插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 + +钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录:它注入的 `user/message` 已是持久证据,而且当时没有已打开的轮次可容纳该记录(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 + +## 持久性契约 + +持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端可选启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型,或破坏会话不变式配套插件所检查的轮次/步骤嵌套,会构成磁盘格式的破坏性变更。 + +消费此契约的后端见 [persistence.md](persistence.md)。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 973238d905..6ff655a76d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:399`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:358`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) ## Events @@ -150,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -166,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) ### `compact/*` @@ -329,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts) ### `request/*` @@ -343,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -399,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages 'steering/message': PromptMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) ### `step/*` @@ -410,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -419,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) ### `todo/*` @@ -432,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) ### `tool/*` @@ -449,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -503,7 +503,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) ### `turn/*` @@ -521,7 +521,7 @@ Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -537,7 +537,7 @@ Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) ### `user/*` @@ -556,4 +556,4 @@ Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/ 'user/message': PromptMessageData ``` -Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index a2bc25cbcc..a8a6fa755c 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -373,11 +373,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && typeof event.data.meta === 'object' - && event.data.meta !== null - && !Array.isArray(event.data.meta) - && event.data.meta.kind === 'workspace-instructions') + && event.data.source.kind === 'workspace-instructions') expect(dispatch).toBeDefined() expect(outerResult).toBeDefined() expect(workspaceContext).toBeDefined() diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1e06fad70d..d297798cbe 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -74,7 +74,6 @@ export interface ContextMessageNode { seq: number content: readonly ContentBlock[] source: unknown - meta?: unknown } /** A tool result paired (when in-window) with its call head. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index d10bcea074..a3ced753f3 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -43,7 +43,6 @@ function materializeNode( if (event.data.source.kind !== 'user') { return { kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, - meta: event.data.meta, } } return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 4bfe07d687..f068d8b8fd 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -43,7 +43,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) case 'context': return (
- +
) default: diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index be50356185..ad23f292c9 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -33,7 +33,7 @@ describe('MessageItem arms', () => { it('context and unknown nodes render their JSON rows', () => { const ctxView = render( - , + , ) expect(ctxView.getByText(/上下文注入/)).toBeTruthy() const unknownView = render( diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index b3a4b2013d..f48f9ae0a3 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -12,7 +12,7 @@ Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. -The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay. +The context uses a typed `{ kind: 'session-reference', ... }` source with `placement: 'prompt-prefix'`. That source records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and source for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay. ## Configuration diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 93e5173005..e79ad122a4 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -9,7 +9,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { DEFAULT_CANDIDATE_LIMIT, @@ -20,7 +20,7 @@ import { } from './config.ts' import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts' import { stringifyTagSafeJson } from './serialization.ts' -import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput } from './types.ts' +import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts' export type * from './types.ts' export type { Config, SessionReferenceErrorCode } from './config.ts' @@ -181,7 +181,7 @@ export class SessionReferenceService extends Service { const rendered = this.renderSources(prepared) const prompt = renderPrompt(rendered.map(source => source.data)) - const meta = { + const source: SessionReferenceSource = { kind: 'session-reference', version: 1, references: rendered.map((source, index) => ({ @@ -191,12 +191,11 @@ export class SessionReferenceService extends Service { ...source.stats, inputIndex: index, })), - } satisfies JsonValue + } const context: HookContext = { - source: { kind: 'plugin', plugin: 'session-reference' }, + source, content: [{ type: 'text', text: prompt }], placement: 'prompt-prefix', - meta, } return { content: acceptedContent, contexts: [context] } } diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 03176ee32a..ed4d662410 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -4,6 +4,30 @@ import type { HookContext } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' +/** Durable provenance for one prepared cross-session context. */ +export interface SessionReferenceSource { + kind: 'session-reference' + version: 1 + references: { + sessionId: string + label: string + capturedThroughSeq: number | null + compacted: boolean + originalMessages: number + retainedMessages: number + omittedMessages: number + omittedBytes: number + truncated: boolean + inputIndex: number + }[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'session-reference': SessionReferenceSource + } +} + /** One source session selected by a host. */ export interface SessionReferenceInput { /** Opaque source session identity. */ diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 937535b042..a2ba2e48eb 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -241,7 +241,7 @@ describe('session reference discovery and preparation', () => { expect(prepared.contexts).toHaveLength(1) const context = prepared.contexts[0] if (context?.content[0]?.type !== 'text') throw new Error('expected text context') - expect(context.source).toEqual({ kind: 'plugin', plugin: 'session-reference' }) + expect(context.source).toMatchObject({ kind: 'session-reference' }) expect(context.placement).toBe('prompt-prefix') expect(context.content[0].text).toContain('untrusted, read-only snapshot') expect(promptData(context.content[0].text)).toEqual([{ @@ -256,7 +256,7 @@ describe('session reference discovery and preparation', () => { { role: 'assistant', text: 'visible answer' }, ], }]) - expect(context.meta).toMatchObject({ + expect(context.source).toMatchObject({ kind: 'session-reference', version: 1, references: [{ @@ -354,7 +354,7 @@ describe('session reference discovery and preparation', () => { { sessionId: one.id, label: 'first' }, { sessionId: one.id, label: 'ignored duplicate' }, { sessionId: two.id }, - ])).resolves.toMatchObject({ contexts: [{ meta: { references: [{ label: 'first' }, { label: 'two' }] } }] }) + ])).resolves.toMatchObject({ contexts: [{ source: { references: [{ label: 'first' }, { label: 'two' }] } }] }) await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }])) .rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE')) await expect(ctx.sessionReferences.prepare(agent, content, [null as never])) @@ -432,7 +432,7 @@ describe('session reference discovery and preparation', () => { expect(context.content[0].text).toContain('checkpoint') expect(context.content[0].text).toContain('latest-') expect(context.content[0].text).toContain('omitted') - expect(context.meta).toMatchObject({ references: [{ truncated: true, compacted: true }] }) + expect(context.source).toMatchObject({ references: [{ truncated: true, compacted: true }] }) }) it('applies the full byte limit independently to each of three references', async () => { @@ -501,7 +501,6 @@ describe('session reference discovery and preparation', () => { displayContent: prepared.content, prefixContexts: [{ source: context.source, - ...context.meta === undefined ? {} : { meta: context.meta }, }], }, }, { surfaceOp: 'append' }) diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index a7245df91f..df35697159 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -46,9 +46,9 @@ The plugin owns the complete `` framing, and every `context/mes ## State And Refresh -Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. -An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only. +An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix. @@ -73,7 +73,7 @@ The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`. -Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata. +Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in the structured message source. ## Model Experience diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 364004049e..95430e6c27 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -99,7 +99,6 @@ export function apply(ctx: Context, config: Config): void { if (update !== undefined) { agent.inject(update.context.content, { source: update.context.source, - meta: update.context.meta, }) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) } diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index d9f56417b8..f6c99ea10e 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -15,7 +15,7 @@ export const name = 'workspace-context-invariant' export const inject = ['invariants'] /** - * No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata, + * No runtime invariant: replay intentionally tolerates unknown or malformed workspace sources, * while focused pipeline tests own its private pending/cache state transitions. */ const install: InvariantInstaller = () => {} diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 61db3f527b..ca1950d5c7 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -6,7 +6,7 @@ import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' -import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs' import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ResolvedConfig } from './config.ts' @@ -33,9 +33,20 @@ import { export const name = 'workspace-context' -const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) +/** Durable provenance and reconciliation facts for one workspace context. */ +export interface WorkspaceInstructionSource { + kind: 'workspace-instructions' + changes: WorkspaceInstructionChange[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'workspace-instructions': WorkspaceInstructionSource + } +} + /** Dynamic state waiting for the loop to append its returned context event. */ export interface PendingInstructionChange { change: WorkspaceInstructionChange @@ -70,20 +81,14 @@ export interface ReconciledInstructionContext { versionUpdates: InstructionVersionUpdate[] } -/** Plugin-owned context with required replay metadata. */ -export interface WorkspaceHookContext extends HookContext { - meta: JsonValue -} +/** Plugin-owned workspace context. */ +export type WorkspaceHookContext = HookContext function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext { - const serializedChanges: JsonValue[] = changes.map(change => ({ - action: change.action, - scope: change.scope, - path: change.path, - ...change.digest !== undefined ? { digest: change.digest } : {}, - })) - const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges } - return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta } + return { + content: [{ type: 'text', text }], + source: { kind: 'workspace-instructions', changes }, + } } /** @@ -103,20 +108,20 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } -function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE { +function isWorkspaceContextSource(source: unknown): source is WorkspaceInstructionSource { return typeof source === 'object' && source !== null - && 'kind' in source && source.kind === 'plugin' - && 'plugin' in source && source.plugin === name + && 'kind' in source && source.kind === 'workspace-instructions' + && 'changes' in source && Array.isArray(source.changes) } -function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } { +function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] { - if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return [] +function workspaceInstructionChanges(source: unknown): WorkspaceInstructionChange[] { + if (!isWorkspaceContextSource(source)) return [] const changes: WorkspaceInstructionChange[] = [] - for (const value of meta.changes) { + for (const value of source.changes) { if (!isRecord(value)) continue if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue @@ -146,7 +151,7 @@ function visibleInstructionChanges( const visible = new Map() for (const [seq, event] of agent.session.events.entries()) { if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue - const changes = workspaceInstructionChanges(event.data.meta) + const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { const waiting = pending.get(change.scope) if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { @@ -283,7 +288,7 @@ export function observeInstructionSessionEvent( switch (event.type) { case 'user/message': { if (!isWorkspaceContextSource(event.data.source)) return - for (const change of workspaceInstructionChanges(event.data.meta)) { + for (const change of workspaceInstructionChanges(event.data.source)) { const waiting = pending.get(change.scope) if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { pending.delete(change.scope) @@ -329,7 +334,7 @@ export function commitPendingInstructionContexts( const step = openStep(agent.session) for (const context of contexts ?? []) { if (!isWorkspaceContextSource(context.source)) continue - const changes = workspaceInstructionChanges(context.meta) + const changes = workspaceInstructionChanges(context.source) if (changes.length === 0) continue const pending = pendingChangesFor(agent.session, pendingBySession) for (const change of changes) { diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index f80ee5acde..ee7fb05ceb 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -108,11 +108,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode const events = [...live.agent.session.events] const update = events.find(event => event.type === 'user/message' - && typeof event.data.meta === 'object' - && event.data.meta !== null - && !Array.isArray(event.data.meta) - && event.data.meta.kind === 'workspace-instructions') - expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ + && event.data.source.kind === 'workspace-instructions') + expect(update?.type === 'user/message' && update.data.source).toMatchObject({ changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) const updateText = update?.type === 'user/message' diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 161466a8b8..3be1f9234b 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -184,7 +184,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, - ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, @@ -206,16 +205,14 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined { return result.additionalContexts?.find(context => - context.source.kind === 'plugin' && context.source.plugin === 'workspace-context') + context.source.kind === 'workspace-instructions') } function workspaceChangeContext(scope: string, digest: string): HookContext { return { content: [{ type: 'text', text: `instructions for ${scope}` }], - source: { kind: 'plugin', plugin: 'workspace-context' }, - meta: { + source: { kind: 'workspace-instructions', - version: 1, changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }], }, } @@ -227,7 +224,6 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H lastSeq = agent.session.append('user/message', { content: context.content, source: context.source, - ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }).seq } return lastSeq @@ -930,7 +926,7 @@ describe('workspace context request injection', () => { kind: 'accept' as const, })) expect(accepted.kind).toBe('accept') - expect(workspaceContextOf(accepted)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) + expect(workspaceContextOf(accepted)?.source).toMatchObject({ kind: 'workspace-instructions' }) expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule') } finally { await ctx.fiber.dispose() @@ -1050,7 +1046,7 @@ describe('workspace context request injection', () => { callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md') @@ -1079,7 +1075,7 @@ describe('workspace context request injection', () => { callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md') @@ -1810,19 +1806,18 @@ describe('dynamic nested workspace context injection', () => { }) expect(result.isError).toBe(false) - expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', - version: 1, changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), }], }) - const meta = workspaceContextOf(result)?.meta - const firstChange = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes) - ? meta.changes[0] + const source = workspaceContextOf(result)?.source + const firstChange = source?.kind === 'workspace-instructions' + ? source.changes[0] : undefined const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange) ? firstChange.digest @@ -1901,9 +1896,9 @@ describe('dynamic nested workspace context injection', () => { agent: stubAgent(root), }) - const meta = workspaceContextOf(result)?.meta - const changes = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes) - ? meta.changes + const source = workspaceContextOf(result)?.source + const changes = source?.kind === 'workspace-instructions' + ? source.changes : [] expect(changes).toEqual(expect.arrayContaining([ expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }), @@ -2122,7 +2117,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(changed)?.meta).toMatchObject({ + expect(workspaceContextOf(changed)?.source).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) @@ -2168,7 +2163,7 @@ describe('dynamic nested workspace context injection', () => { }) // Removing one candidate only removes its own scope; the sibling scope is untouched. - expect(workspaceContextOf(removed)?.meta).toMatchObject({ + expect(workspaceContextOf(removed)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) @@ -2196,7 +2191,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) const text = blocksText(workspaceContextOf(result)?.content) @@ -2276,7 +2271,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(converged)?.meta).toMatchObject({ + expect(workspaceContextOf(converged)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }], }) expect(blocksText(workspaceContextOf(converged)?.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`) @@ -2310,7 +2305,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(converged)?.meta).toMatchObject({ + expect(workspaceContextOf(converged)?.source).toMatchObject({ changes: [ { action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }, { action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }, @@ -2347,9 +2342,8 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(removed)?.meta).toEqual({ + expect(workspaceContextOf(removed)?.source).toEqual({ kind: 'workspace-instructions', - version: 1, changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ @@ -2394,7 +2388,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(removed)?.meta).toMatchObject({ + expect(workspaceContextOf(removed)?.source).toMatchObject({ changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`) @@ -2433,7 +2427,7 @@ describe('dynamic nested workspace context injection', () => { callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) - expect(workspaceContextOf(restored)?.meta).toMatchObject({ + expect(workspaceContextOf(restored)?.source).toMatchObject({ changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`) @@ -2537,7 +2531,7 @@ describe('dynamic nested workspace context injection', () => { await composeBaselinePrefix(ctx, resumed) const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') - expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ + expect(update?.type === 'user/message' && update.data.source).toMatchObject({ changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume') @@ -2691,31 +2685,23 @@ describe('dynamic nested workspace context injection', () => { { type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' }, { type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' }, ], - source: { kind: 'plugin', plugin: 'workspace-context' }, - meta: { + source: { kind: 'workspace-instructions', - version: 1, changes: [ null, { action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') }, { action: 'set', scope: 'pkg', path: 42 }, { action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 42 }, ], - }, + } as never, }, { surfaceOp: 'append' }) agent.session.append('user/message', { content: [{ type: 'text', text: 'stale metadata version' }], - source: { kind: 'plugin', plugin: 'workspace-context' }, - meta: { kind: 'workspace-instructions', version: 0, changes: [] }, + source: { kind: 'workspace-instructions', changes: 'invalid' } as never, }, { surfaceOp: 'append' }) agent.session.append('user/message', { content: [{ type: 'text', text: 'foreign plugin context' }], source: { kind: 'plugin', plugin: 'other' }, - meta: { - kind: 'workspace-instructions', - version: 1, - changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 'spoof' }], - }, }, { surfaceOp: 'append' }) const result = await ctx.tools.execute({ @@ -2884,8 +2870,8 @@ describe('dynamic nested workspace context injection', () => { }) expect(blocksText(result.content)).toContain('downstream replacement') expect(result.additionalContexts).toHaveLength(2) - expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.meta).toMatchObject({ + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) + expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) @@ -3246,7 +3232,6 @@ describe('workspace context pending state', () => { const otherWorkspaceEvent = agent.session.append('user/message', { content: otherContext.content, source: otherContext.source, - ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions) expect(pending.get(agent.session)?.has('pkg')).toBe(true) @@ -3255,7 +3240,6 @@ describe('workspace context pending state', () => { const confirmed = agent.session.append('user/message', { content: context.content, source: context.source, - ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, confirmed, pending, versions) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 150c4880b3..857583d16e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -72,7 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', - jsDoc: '/**\n * Create an owned agent on a caller-supplied session id.\n * @param ownerCtx - caller context that structurally owns the transaction.\n * @param options - identities, session seed/metadata, loop options, setup, and cancellation.\n * @returns the published handle.\n */', + jsDoc: '/**\n * Create an owned agent on a caller-supplied session id.\n * @param ownerCtx - caller context that structurally owns the lifecycle.\n * @param options - identities, session seed/metadata, loop options, setup, and cancellation.\n * @returns the published handle.\n */', }, { signature: 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', @@ -857,8 +857,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/cancel-requested', mode: 'emit', signature: '\'agent/cancel-requested\'(this: Scoped, agent: Agent, cause: AgentCancelCause): void', - jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - resolved typed cancellation cause, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active turn is aborted.', + jsDoc: '/**\n * Effective broad cancellation was requested, before queued/outbox work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - resolved typed cancellation cause, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted.', }, { name: 'agent/created', @@ -878,9 +878,16 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/error', mode: 'emit', signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void', - jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A step or turn errored. The machine reports a failure here (plus the\n * logger) even when the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, + { + name: 'agent/idle', + mode: 'emit', + signature: '\'agent/idle\'(this: Scoped, agent: Agent, turn: number, reason: IdleReason): void', + jsDoc: '/**\n * One turn closed: its `turn/end` and durability flush are already\n * committed. `reason` says why — recovery consumers observe an `error`\n * reason, repair (edit the log, wait, resummon), and call\n * {@link Agent.retry}; UI consumers key turn-done presentation off it.\n * Emitted per turn, including cancelled and failed ones.\n * @param agent - the agent whose turn closed.\n * @param turn - the closed turn number.\n * @param reason - why the turn ended, with live error facts when it failed.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One turn closed: its `turn/end` and durability flush are already committed.', + }, { name: 'agent/inbox/dequeue', mode: 'emit', @@ -899,51 +906,23 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', - }, - { - name: 'agent/post-step', - mode: 'serial', - signature: '\'agent/post-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * Awaited serial checkpoint after the response, real or synthetic tool\n * results, injected context, and steering are durable but before `step/end`.\n * A cancelled tool batch reaches this checkpoint with an aborted signal.\n * @param agent - the agent whose step is settling.\n * @param turn - the open turn number.\n * @param step - the open step number.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', - summary: 'Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`.', - }, - { - name: 'agent/pre-step', - mode: 'serial', - signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * Awaited serial checkpoint before `step/start`; appends land outside the\n * pending step and are included when the loop derives request history.\n * `signal` cancels listener work.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent opening the step.\n * @param turn - the open turn number.\n * @param step - the pending step number.\n * @param signal - the turn abort signal.\n * @mode serial\n */', - summary: 'Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history.', + jsDoc: '/**\n * A frozen item entered the queued or steering inbox.\n * @param agent - the owning agent.\n * @param message - accepted routing data and correlation identity.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'A frozen item entered the queued or steering inbox.', }, { name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default, including contexts\n * captured with the queued item. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it for another turn.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.', }, { name: 'agent/request', mode: 'waterfall', - signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Replace the frozen call configuration. Model-visible content must use\n * logged channels; this seam cannot mutate messages. Injection here joins\n * the next request because the current step boundary is already fixed.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param config - the config the loop would use (frozen); return a replacement to switch.\n * @param signal - the current turn\'s explicit abort signal; ambient\n * initiator identity does not imply liveness or cancellation authority.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Replace the frozen call configuration.', }, - { - name: 'agent/request-error', - mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Recover a model-request failure after its failed step has closed.', - }, - { - name: 'agent/session-prefix', - mode: 'waterfall', - signature: '\'agent/session-prefix\'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Compose request-only messages placed before derived history. The frozen\n * result is computed once per loop instance, logged on its anchoring request\n * header, and reused so the provider prefix remains stable. Interrupted\n * composition is discarded. Composition precedes the first `agent/pre-step`\n * and request boundary, so listener appends join the current request.\n * Changing context belongs in history; contributors should prepend to\n * `await next()` to preserve registration order.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent whose session prefix is being composed.\n * @param prefix - the frozen seed; return an extended replacement.\n * @param signal - the current turn\'s explicit abort signal.\n * @mode waterfall\n */', - summary: 'Compose request-only messages placed before derived history.', - }, { name: 'agent/session-start', mode: 'emit', @@ -959,25 +938,18 @@ export const EVENT_API: readonly EventApiEntry[] = [ summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).', }, { - name: 'agent/step-result', - mode: 'waterfall', - signature: '\'agent/step-result\'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Waterfall: post-process the assembled assistant {@link Message} before\n * tool dispatch (validation, content rewriting, …).\n * @param agent - the agent that received the step\'s response.\n * @param turn - the open turn number.\n * @param step - the step that produced the message.\n * @param message - the assistant message as assembled from the stream.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).', - }, - { - name: 'agent/turn-continuation', - mode: 'waterfall', - signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Override whether the turn continues. The default continues after tool\n * calls or steering and stops otherwise; a continue reason becomes steering.\n * @param agent - the agent deciding whether to run another step.\n * @param turn - the turn being continued or stopped.\n * @param defaultDecision - what the loop would do absent an override.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Override whether the turn continues.', - }, - { - name: 'agent/turn-stop', + name: 'agent/step', mode: 'serial', - signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined', - jsDoc: '/**\n * Monotonic terminal-stop checkpoint after continuation and steering are\n * folded; a stop remains authoritative through turn close and flush:\n * steering queued in that window is discarded, while ordinary sends survive.\n * @param agent - the agent whose composed continuation outcome may be stopped.\n * @param turn - the turn at its terminal-stop checkpoint.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', - summary: 'Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.', + signature: '\'agent/step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', + jsDoc: '/**\n * Awaited serial checkpoint before EVERY request of a turn is built (the\n * first as well as each post-tools continuation). The single "between\n * steps" seam: inject context, steer, or edit the session log here — the\n * request\'s history derives from the log right after this settles.\n * @param agent - the agent about to send a request.\n * @param turn - the open turn number.\n * @param step - the step number about to open.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + summary: 'Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation).', + }, + { + name: 'agent/stopping', + mode: 'serial', + signature: '\'agent/stopping\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void', + jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).', }, { name: 'approval/request', @@ -1181,7 +1153,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n}', + declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n abstract retry(): void;\n}', }, { name: 'AgentCancelCause', @@ -1205,7 +1177,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentStatus', - declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', + declaration: 'export type AgentStatus = \'idle\' | \'running\';', }, { name: 'AliasSendOptions', @@ -1521,7 +1493,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'HookContext', - declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}', + declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n}', }, { name: 'InvariantFailure', @@ -1613,7 +1585,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptMessageData', - declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}', + declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}', }, { name: 'PromptMessageEnvelope', @@ -1621,7 +1593,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptPrefixContext', - declaration: 'export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n}', + declaration: 'export interface PromptPrefixContext {\n source: MessageSource;\n}', }, { name: 'PromptSection', @@ -1753,7 +1725,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SendOptions', - declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', + declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n}', }, { name: 'SendTarget', @@ -2133,7 +2105,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionFailure', - declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', + declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n readonly concludesTurn?: never;\n}', }, { name: 'ToolExecutionInput', @@ -2149,7 +2121,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionSuccess', - declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', + declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n readonly concludesTurn?: true;\n}', }, { name: 'ToolExecutionToken', @@ -2189,7 +2161,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolRunContext', - declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n}', + declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n concludeTurn(): void;\n}', }, { name: 'ToolSchema', @@ -2261,7 +2233,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TurnTriggerMap', - declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}', + declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}', }, { name: 'UserInteractionProvider', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 735eeba228..3498e69d9a 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -1,8 +1,9 @@ /** * The concrete Agent, in the naive-agent shape: the agent IS the machine. * Two inboxes — `queued` (prompts, one turn each) and `outbox` (steering + - * injected context, taken whole at every step boundary) — and one `run()` - * per turn: intake the prompt, then step until the model owes no response. + * injected context, taken whole at every step boundary). `kick()` admits and + * records one queued prompt; `start()` then steps until the model owes no + * response. * * The session log IS the transcript: every take appends, every step re-derives * (`session.deriveMessages()`), so editing history between steps is naturally @@ -38,7 +39,7 @@ import type { ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message, MessageSource, } from '@deepseek-ai/dsh-llm' import { canonicalHeader, headerEquals, snapshotJsonValue } from '@deepseek-ai/dsh-session' -import type { JsonValue, PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { PromptMessageData, Session, SessionId, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' @@ -50,7 +51,6 @@ interface QueuedMessage { source: MessageSource contexts: HookContext[] wakeup: boolean - meta?: JsonValue } /** Input awaiting the next step boundary. */ @@ -58,6 +58,13 @@ type OutboxItem = | ({ kind: 'steering' } & QueuedMessage) | { kind: 'context'; context: HookContext } +/** Mutable settlement facts shared by one turn's intake and step loop. */ +interface TurnState { + turn: number + step: number + reason: TurnEndReason +} + /** Build one live inbox event payload from an accepted message. */ function inboxMessage(message: QueuedMessage, steering: boolean): AgentMessage { return { @@ -96,7 +103,6 @@ function preparePromptMessage( displayContent: content, prefixContexts: prefixContexts.map(context => ({ source: context.source, - ...context.meta === undefined ? {} : { meta: context.meta }, })), }, }, @@ -211,7 +217,6 @@ export class ReactLoopAgent extends Agent { source: options.source ?? { kind: 'user' }, contexts: options.contexts ?? [], wakeup, - ...options.meta === undefined ? {} : { meta: options.meta }, }) if (steering) this.outbox.push({ kind: 'steering', ...accepted }) else this.queued.push(accepted) @@ -225,7 +230,6 @@ export class ReactLoopAgent extends Agent { const context = this.accept({ content, source: options.source ?? { kind: 'plugin', plugin: '' }, - ...options.meta === undefined ? {} : { meta: options.meta }, }) if (this.turnAbort !== undefined) { this.outbox.push({ kind: 'context', context }) @@ -280,7 +284,7 @@ export class ReactLoopAgent extends Agent { */ retry(): void { if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`) - this.start() + this.launch({ kind: 'retry' }, (state, signal) => this.start(state, signal)) } /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */ @@ -294,18 +298,52 @@ export class ReactLoopAgent extends Agent { // The machine. // ------------------------------------------------------------------------- - /** Claim the next queued prompt and open a run on it, when nothing is driving. */ + /** Claim, admit, and record the next queued prompt before starting its step loop. */ private kick(): void { if (this.turnAbort !== undefined || !this.queued.some(message => message.wakeup)) return const message = this.queued.shift() if (message !== undefined) { emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false)) - this.start(message) + this.launch({ kind: 'message', source: message.source }, async (state, signal) => { + const decision = await this.loopCtx.waterfall( + agentCarrier(this), 'agent/prompt-submit', this, message.content, message.source, signal, + () => Promise.resolve({ + kind: 'allow', + ...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts }, + }), + ) + signal.throwIfAborted() + + if (decision.kind === 'block') { + this.session.append('prompt/blocked', { + content: message.content, + source: message.source, + reason: decision.reason, + }) + state.reason = { kind: 'rejected', reason: decision.reason } + return + } + + const prepared = preparePromptMessage( + decision.content ?? message.content, + message.source, + decision.additionalContexts ?? [], + ) + this.session.append('user/message', prepared.data, { surfaceOp: 'append' }) + for (const context of prepared.separateContexts) { + this.outbox.push({ kind: 'context', context: this.accept(context) }) + } + await this.start(state, signal) + }, true) } } - /** Open one `run()` — on a claimed prompt, or promptless for a retry. */ - private start(prompt?: QueuedMessage): void { + /** Own one turn from its durable opening through settlement and idle handoff. */ + private launch( + trigger: TurnTrigger, + work: (state: TurnState, signal: AbortSignal) => Promise, + deferOpen = false, + ): void { const controller = new AbortController() this.turnAbort = controller if (!this.busy) { @@ -314,94 +352,57 @@ export class ReactLoopAgent extends Agent { } // The whole run inherits this agent as its process-local initiator so // tools, the llm service, and nested factories can attribute their work. - this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt, controller)) + this.done = this.loopCtx.agents.withInitiator(this, async () => { + const signal = controller.signal + const state: TurnState = { + turn: ++this.lastTurn, + step: 0, + reason: { kind: 'completed' }, + } + let idle: IdleReason = { kind: 'completed' } + + try { + // A queued claim keeps its established pre-turn cancellation window: + // send() returns before the durable turn opens, while retry starts now. + if (deferOpen) await Promise.resolve() + signal.throwIfAborted() + this.session.append('turn/start', { turn: state.turn, trigger }) + this.turnOpen = true + signal.throwIfAborted() + await work(state, signal) + } catch (error: unknown) { + ({ reason: state.reason, idle } = this.settle(state.turn, state.step, error, signal)) + } finally { + if (this.turnAbort === controller) this.turnAbort = undefined + try { + this.closeTurn(state.turn, state.step, state.reason) + } catch (error: unknown) { + // A rejected boundary append (a pre-commit validation veto) must not + // kill the machine or strand its running interval: report and move on — the + // idle tail below still runs and the next turn still opens. + const err = toError(error) + this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${state.turn} failed: ${errorChain(err)}`) + emitAgentEvent(this.loopCtx, this, 'agent/error', state.turn, state.step, err) + } + this.idle(state.turn, idle) + } + }) } - /** - * One `run()` is one turn: prompt intake (submit waterfall), the durable - * turn boundary, then the naive step loop until the model owes no response. - * Every failure funnels to the single catch — {@link settle} classifies it - * once (interruption beats error) — and the finally always closes the owed - * boundaries and runs the idle tail, which opens the next run while work - * remains. - */ - private async run(prompt: QueuedMessage | undefined, controller: AbortController): Promise { - const signal = controller.signal - const turn = ++this.lastTurn - let idle: IdleReason = { kind: 'completed' } - let reason: TurnEndReason = { kind: 'completed' } - let step = 0 - - try { - // Intake precedes the turn: the submit decision belongs to the prompt, - // not the turn (a retry opens a turn with no prompt at all). A failed - // intake leaves no durable trace — nothing entered the conversation. - const decision = prompt === undefined - ? undefined - : await this.loopCtx.waterfall( - agentCarrier(this), 'agent/prompt-submit', this, prompt.content, prompt.source, signal, - () => Promise.resolve({ - kind: 'allow', - ...prompt.contexts.length === 0 ? {} : { additionalContexts: prompt.contexts }, - }), - ) + /** Run the naive step loop after retry or admitted prompt intake has prepared the turn. */ + private async start(state: TurnState, signal: AbortSignal): Promise { + while (true) { + state.step += 1 + const { owes, maxTokens } = await this.step(state.turn, state.step, signal) + if (maxTokens) state.reason = { kind: 'max-tokens' } + // The naive rule, data-driven: run another step while the model is + // owed a response. On a would-stop boundary, `agent/stopping` gives + // listeners one chance to object — by steering, not by voting — and + // the outbox is re-read: data decides, so listener order cannot. + if (owes || this.outbox.some(item => item.kind === 'steering')) continue + await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, state.turn, signal) signal.throwIfAborted() - - this.session.append('turn/start', { - turn, - trigger: prompt === undefined ? { kind: 'retry' } : { kind: 'message', source: prompt.source }, - }) - this.turnOpen = true - signal.throwIfAborted() - - if (prompt !== undefined && decision?.kind === 'block') { - // The audit record stays turn-enclosed: a zero-step rejected turn. - this.session.append('prompt/blocked', { content: prompt.content, source: prompt.source, reason: decision.reason }) - reason = { kind: 'rejected', reason: decision.reason } - } else { - if (prompt !== undefined && decision?.kind === 'allow') { - const prepared = preparePromptMessage( - decision.content ?? prompt.content, - prompt.source, - decision.additionalContexts ?? [], - ) - this.session.append('user/message', { - ...prepared.data, - ...prompt.meta === undefined ? {} : { meta: prompt.meta }, - }, { surfaceOp: 'append' }) - for (const context of prepared.separateContexts) { - this.outbox.push({ kind: 'context', context: this.accept(context) }) - } - } - while (true) { - step += 1 - const { owes, maxTokens } = await this.step(turn, step, signal) - if (maxTokens) reason = { kind: 'max-tokens' } - // The naive rule, data-driven: run another step while the model is - // owed a response. On a would-stop boundary, `agent/stopping` gives - // listeners one chance to object — by steering, not by voting — and - // the outbox is re-read: data decides, so listener order cannot. - if (owes || this.outbox.some(item => item.kind === 'steering')) continue - await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal) - signal.throwIfAborted() - if (!this.outbox.some(item => item.kind === 'steering')) break - } - } - } catch (error: unknown) { - ({ reason, idle } = this.settle(turn, step, error, signal)) - } finally { - if (this.turnAbort === controller) this.turnAbort = undefined - try { - this.closeTurn(turn, step, reason) - } catch (error: unknown) { - // A rejected boundary append (a pre-commit validation veto) must not - // kill the machine or strand its running interval: report and move on — the - // idle tail below still runs and the next turn still opens. - const err = toError(error) - this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`) - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err) - } - this.idle(turn, idle) + if (!this.outbox.some(item => item.kind === 'steering')) break } } @@ -569,12 +570,8 @@ export class ReactLoopAgent extends Agent { let steered = false for (const item of this.outbox.splice(0)) { if (item.kind === 'context') { - const { content, source, meta } = item.context - this.session.append('user/message', { - content, - source, - ...meta === undefined ? {} : { meta }, - }, { surfaceOp: 'append' }) + const { content, source } = item.context + this.session.append('user/message', { content, source }, { surfaceOp: 'append' }) continue } steered = true @@ -583,15 +580,10 @@ export class ReactLoopAgent extends Agent { this.session.append('steering/message', { turn, ...prepared.data, - ...item.meta === undefined ? {} : { meta: item.meta }, }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { - const { content, source, meta } = context - this.session.append('user/message', { - content, - source, - ...meta === undefined ? {} : { meta }, - }, { surfaceOp: 'append' }) + const { content, source } = context + this.session.append('user/message', { content, source }, { surfaceOp: 'append' }) } } return steered diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index b96dd48dbe..52ddbb3a61 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -92,14 +92,12 @@ describe('agent/prompt-submit', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const meta = { kind: 'prompt-context', version: 1 } ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'allow', additionalContexts: [{ content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' }, - meta, }], })) @@ -112,7 +110,6 @@ describe('agent/prompt-submit', () => { expect(userMsg).toBeDefined() expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) - expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta) const sent = JSON.stringify(adapter.requests[0]!.messages) expect(sent).toContain('extra ctx') }) @@ -133,7 +130,6 @@ describe('agent/prompt-submit', () => { content: [{ type: 'text', text: 'untrusted prefix' }], source: { kind: 'plugin', plugin: 'prefix' }, placement: 'prompt-prefix', - meta: { kind: 'prefix-card' }, }], }) await waitForIdle(ctx, agent) @@ -151,7 +147,6 @@ describe('agent/prompt-submit', () => { displayContent: [{ type: 'text', text: 'rewritten request' }], prefixContexts: [{ source: { kind: 'plugin', plugin: 'prefix' }, - meta: { kind: 'prefix-card' }, }], }, }) @@ -616,7 +611,6 @@ describe('tool additionalContexts buffering across a step', () => { additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' }, - meta: { callId: exec.callId }, }], })) @@ -638,7 +632,6 @@ describe('tool additionalContexts buffering across a step', () => { .flatMap(e => (e.type === 'user/message' ? e.data.content : [])) .map(b => (b.type === 'text' ? b.text : '')) expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) - expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) }) it('appends multiple contexts deferred by one composite tool after its outer result', async () => { @@ -647,8 +640,8 @@ describe('tool additionalContexts buffering across a step', () => { ctx.tools.register(defineContentToolFixture({ name: 'composite', description: 'composite', parameters: {}, async execute(_args, exec) { - exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } }) - exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, meta: { order: 2 } }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' } }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' } }) return [{ type: 'text', text: 'outer result' }] }, })) @@ -666,7 +659,6 @@ describe('tool additionalContexts buffering across a step', () => { { kind: 'plugin', plugin: 'a' }, { kind: 'plugin', plugin: 'b' }, ]) - expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 396abec100..6b5da72ff5 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -398,26 +398,20 @@ describe('agent loop', () => { expect(flat).not.toContain('