refactor(agent-loop): unify pending message representation

This commit is contained in:
_Kerman
2026-07-24 17:23:33 +08:00
parent 76d0e450ce
commit 1d9662a020
20 changed files with 116 additions and 166 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-22-unified-send-and-coalesced-user-messages.md: 359ea62b77685a267b7a8c36e58929f67569d92f 2026-07-22-unified-send-and-coalesced-user-messages.md: 2fd77df51ebaaad2fa0bbe17d3f8493148b761bc
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d4a0f661900a10a0b74ebdb67077881b6a9d24ee 2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 2d4b678b6b3efb4c9ca68c79d8cadaf13b3eac04

View File

@@ -12,9 +12,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj
## Decision ## Decision
**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. **One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source })` 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' }`. **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 while a turn is open and appended directly between turns while 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. 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'`. **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'`.
@@ -24,19 +24,21 @@ Separately, `context/message` and `user/message` had converged: the surface proj
**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 type their `AgentMessage` payload with only the accepted message's returned `id`, content, and source, so a caller can correlate a queued item with its lifecycle without depending on driver routing state. 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. **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 type their `AgentMessage` payload with only the accepted message's returned `id`, content, and source, so a caller can correlate a queued item with its lifecycle without depending on driver routing state. 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.
**One accepted message keeps one representation.** Durable user-role input uses `UserMessageData { content, source }`; `AdditionalContext` names that same shape, public `AgentMessage` extends it with the correlation `id`, and the loop-private `PendingMessage` extends that with `wakeup`. A queued message that becomes steering enters the outbox as the same `PendingMessage` object, while injected and tool-produced context enters as plain `UserMessageData`. The outbox therefore stores their union directly instead of wrapping steering beside a duplicate copy of its content and source. Provider-native assistant messages remain adapter-owned output types and do not participate in this input hierarchy.
**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). **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 ## Alternatives considered
- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead. - **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead.
- **A typed discriminant field on `PromptMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. - **A typed discriminant field on `UserMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact.
- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the added `target`/`wakeup` facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. - **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the added `target`/`wakeup` facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe.
## Consequences ## Consequences
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`. 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. An idle injection appends `user/message` between turns without opening a turn or running the model.
`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. `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. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. `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 ## Related

View File

@@ -12,9 +12,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
## 决策 ## 决策
**一个原语,三个预设别名。** `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(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。 **一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source })` 覆盖 (`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' }`。 **inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;轮次打开时,它会延迟到执行中的工具批处理之后,空闲时则直接追加在两个轮次之间。它完全绕过 FIFO 队列,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。
**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别。类型化 source 变体携带所有特定于领域的持久 provenance。对外接口、派生逻辑和 `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'`。
@@ -24,19 +24,21 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都将各自的 `AgentMessage` 载荷类型限定为仅包含被接受消息所返回的 `id`、内容和来源;调用方因此可以把一个排队项与其生命周期关联起来,而无需依赖驱动器的路由状态。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 **三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都将各自的 `AgentMessage` 载荷类型限定为仅包含被接受消息所返回的 `id`、内容和来源;调用方因此可以把一个排队项与其生命周期关联起来,而无需依赖驱动器的路由状态。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。
**一条已接受消息只保留一种表示。** 持久的用户角色输入使用 `UserMessageData { content, source }`;同一形状也以 `AdditionalContext` 命名,公开的 `AgentMessage` 在此基础上增加用于关联的 `id`,循环私有的 `PendingMessage` 再增加 `wakeup`。一条成为 steering 的排队消息会以同一个 `PendingMessage` 对象进入 outbox,而注入和工具产生的上下文则以普通 `UserMessageData` 进入。因此,outbox 直接存储这两种类型的联合,而不再把 steering 与一份重复的内容和来源副本包装在一起。提供方原生的助手消息仍是适配器拥有的输出类型,不参与这套输入层级。
**cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 **cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。
## 考虑过的替代方案 ## 考虑过的替代方案
- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。 - **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。
- **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 - **在 `UserMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。
- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了 `target`/`wakeup` 的事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 - **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了 `target`/`wakeup` 的事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。
## 后果 ## 后果
投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。
`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 `wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。
## 相关 ## 相关

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
architecture.md: 978bb5ab45358dd93c86e54e0417318008ea8edd architecture.md: 164b019855b371172d40564af447d6359f5e4ff6
architecture.zh.md: 452c79c1054543dec0d16b88e8ac1d70e30b66f0 architecture.zh.md: 586dc8a52afb9487df3f801cfd07fcca4afa4cf9

View File

@@ -112,7 +112,7 @@ idle inject:
Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
Tool-time context—including active-turn `inject()` and post-tool `additionalContexts`—settles after results. Steering drains at the same boundary and requests another step. Idle `inject()` instead appends context immediately without changing turn numbering; persistence owns its eager drain. Tool-time context—including active-turn `inject()` and post-tool `additionalContexts`—settles after results. Accepted steering drains from the same pending item at that boundary and requests another step. Idle `inject()` instead appends context immediately without changing turn numbering; persistence owns its eager drain.
Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)).

View File

@@ -112,7 +112,7 @@ idle inject:
每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
工具执行阶段的上下文,包括活跃轮次内的 `inject()` 和工具执行后的 `additionalContexts`,会在结果记录完毕后落定。steering(中途引导)会在同一边界排空,并请求再执行一个步骤。空闲状态下的 `inject()` 则会立即追加上下文,且不改变轮次编号;持久化层独立负责由此产生的即时排空。 工具执行阶段的上下文,包括活跃轮次内的 `inject()` 和工具执行后的 `additionalContexts`,会在结果记录完毕后落定。已接受的 steering(中途引导)会在同一边界从同一个待处理项排空,并请求再执行一个步骤。空闲状态下的 `inject()` 则会立即追加上下文,且不改变轮次编号;持久化层独立负责由此产生的即时排空。
裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
core.md: 49cb29339e4cab922a8ce1bba50d25ca9d94dab3 core.md: 36d65522201bddfd491a4f4f26b4f4b3303e4a77
core.zh.md: 45aaf95dbacf86efd13792d886979d88cc1f7730 core.zh.md: f065a67a2f279944ba77d25076e4b190d09f6cd0

View File

@@ -428,11 +428,9 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t
* message's enqueue, dequeue, and discard events. Source defaults are already * message's enqueue, dequeue, and discard events. Source defaults are already
* applied, so these are the exact values the item was accepted with. * applied, so these are the exact values the item was accepted with.
*/ */
interface AgentMessage { interface AgentMessage extends UserMessageData {
/** The id `send` returned for this message. */ /** The id `send` returned for this message. */
id: AgentMessageId id: AgentMessageId
content: ContentBlock[]
source: MessageSource
} }
``` ```
@@ -571,38 +569,26 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above,
## Interception decisions ## 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; 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. 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 `AdditionalContext`, the same `UserMessageData` content/source shape used by durable user-role input. Each `additionalContexts` entry becomes a separate injected `user/message`, preserving its provenance. Continuation reasons are steering messages and use the same content/source base.
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
```ts type-equiv ```ts type-equiv
/** Model-facing context injected by a listener or atomically attached to one inbox message. */ /** Additional model-facing context produced beside a prompt or tool result. */
interface HookContext { type AdditionalContext = UserMessageData
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` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContexts` — or record `prompt/blocked` and end that zero-step turn as `rejected`): `agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContexts` — or record `prompt/blocked` and end that zero-step turn as `rejected`):
```ts type-equiv ```ts type-equiv
/** /**
* Prompt interception result. `allow.content` replaces the prompt. Each * Prompt interception result. `allow.content` replaces the prompt, while
* `additionalContexts` entry follows its declared placement: separate context * `additionalContexts` appends model-facing context before the turn starts.
* message by default, or a prefix inside the prompt's user-role message. * An `allow` returned by a listener is authoritative: a listener wrapping
* `block` records a durable `prompt/blocked` and ends the claimed prompt's * `next()` preserves both fields unless it intentionally replaces them.
* 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 = type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: AdditionalContext[] }
| { kind: 'block'; reason: string } | { kind: 'block'; reason: string }
``` ```

View File

@@ -430,11 +430,9 @@ type AgentMessageId = Branded<'AgentMessageId'>
* message's enqueue, dequeue, and discard events. Source defaults are already * message's enqueue, dequeue, and discard events. Source defaults are already
* applied, so these are the exact values the item was accepted with. * applied, so these are the exact values the item was accepted with.
*/ */
interface AgentMessage { interface AgentMessage extends UserMessageData {
/** The id `send` returned for this message. */ /** The id `send` returned for this message. */
id: AgentMessageId id: AgentMessageId
content: ContentBlock[]
source: MessageSource
} }
``` ```
@@ -573,38 +571,26 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancella
## 拦截决策 ## 拦截决策
每个 `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 形状。 每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex 钩子桥接层把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。提示词决策与工具后决策共享 `AdditionalContext`,它与持久用户角色输入使用相同的 `UserMessageData` content/source 形状。每个 `additionalContexts` 项都会成为一条单独注入的 `user/message`,并保留其 provenance。Continuation reason 则是 steering 消息,并使用同一个 content/source 基础类型。
源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
```ts type-equiv ```ts type-equiv
/** Model-facing context injected by a listener or atomically attached to one inbox message. */ /** Additional model-facing context produced beside a prompt or tool result. */
interface HookContext { type AdditionalContext = UserMessageData
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` 结束这个零步骤轮次): `agent/prompt-submit` 返回 `PromptDecision`(允许该轮次已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零步骤轮次):
```ts type-equiv ```ts type-equiv
/** /**
* Prompt interception result. `allow.content` replaces the prompt. Each * Prompt interception result. `allow.content` replaces the prompt, while
* `additionalContexts` entry follows its declared placement: separate context * `additionalContexts` appends model-facing context before the turn starts.
* message by default, or a prefix inside the prompt's user-role message. * An `allow` returned by a listener is authoritative: a listener wrapping
* `block` records a durable `prompt/blocked` and ends the claimed prompt's * `next()` preserves both fields unless it intentionally replaces them.
* 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 = type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: AdditionalContext[] }
| { kind: 'block'; reason: string } | { kind: 'block'; reason: string }
``` ```

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
session.md: 6401a1dd4111ab157138e8fed53754c41db7022c session.md: a6bd10000158bbb148aab984789846c0177747f4
session.zh.md: e3573c2836b598bcad11835de80b10c59386917c session.zh.md: 4532e2f46006cc637e39ad0dc1dc239d284a3ec8

View File

@@ -12,19 +12,17 @@ The append-only event types. Merge-extensible: a plugin declares extra event typ
```ts type-equiv ```ts type-equiv
/** /**
* Shared payload for user, injected-context, and steering prompt messages. A * Shared payload for user, injected-context, and steering messages. A
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn * direct human prompt, a synthetic `agent.inject()` context, and mid-turn
* steering all project into the model transcript as verbatim user-role content; * 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), * they are told apart by `source` (a non-`user` kind marks injected context),
* not by event type. * not by event type.
*/ */
interface PromptMessageData { interface UserMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */ /** Exact model-facing blocks. */
content: ContentBlock[] content: ContentBlock[]
/** Producer provenance for the direct prompt. */ /** Producer provenance. */
source: MessageSource source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
} }
``` ```
@@ -37,10 +35,7 @@ interface PromptMessageData {
*/ */
interface SessionEventMap { interface SessionEventMap {
/** /**
* Opens turn `turn`. `trigger` records what started it — one claimed queued * Opens turn `turn`. `trigger` records what started the model loop.
* 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 } 'turn/start': { turn: number; trigger: TurnTrigger }
/** /**
@@ -59,11 +54,10 @@ interface SessionEventMap {
* (the queued message claimed for this turn), a synthetic `agent.inject()` * (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron * context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three * notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` (with a non-`user` kind marking * project their `content` verbatim; `source` tells them apart. An idle
* injected context) is the only channel that tells them apart. An idle * injection may append this event between turns without running the model.
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
*/ */
'user/message': PromptMessageData 'user/message': UserMessageData
/** /**
* Durable record of a prompt veto and its reason. It is log-only: the blocked * 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 never enters the model-visible surface, and its turn runs zero steps.
@@ -105,7 +99,7 @@ interface SessionEventMap {
meta?: JsonValue meta?: JsonValue
} }
/** Steering content injected between steps of a running turn. */ /** Steering content injected between steps of a running turn. */
'steering/message': PromptMessageData & { turn: number } 'steering/message': UserMessageData & { turn: number }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] } 'todo/write': { todos: TodoItem[] }
/** /**
@@ -116,7 +110,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 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. `UserMessageData` is the durable `content` and `source` base shared by ordinary prompts, injected context, and steering. Live inbox events extend the same shape with an `AgentMessageId`; the loop adds only driver-owned routing state while an item remains pending.
### `OutOfBandSessionEventMap` — narrow late-append opt-in ### `OutOfBandSessionEventMap` — narrow late-append opt-in

View File

@@ -12,19 +12,17 @@
```ts type-equiv ```ts type-equiv
/** /**
* Shared payload for user, injected-context, and steering prompt messages. A * Shared payload for user, injected-context, and steering messages. A
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn * direct human prompt, a synthetic `agent.inject()` context, and mid-turn
* steering all project into the model transcript as verbatim user-role content; * 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), * they are told apart by `source` (a non-`user` kind marks injected context),
* not by event type. * not by event type.
*/ */
interface PromptMessageData { interface UserMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */ /** Exact model-facing blocks. */
content: ContentBlock[] content: ContentBlock[]
/** Producer provenance for the direct prompt. */ /** Producer provenance. */
source: MessageSource source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
} }
``` ```
@@ -37,10 +35,7 @@ interface PromptMessageData {
*/ */
interface SessionEventMap { interface SessionEventMap {
/** /**
* Opens turn `turn`. `trigger` records what started it — one claimed queued * Opens turn `turn`. `trigger` records what started the model loop.
* 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 } 'turn/start': { turn: number; trigger: TurnTrigger }
/** /**
@@ -59,11 +54,10 @@ interface SessionEventMap {
* (the queued message claimed for this turn), a synthetic `agent.inject()` * (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron * context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three * notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` (with a non-`user` kind marking * project their `content` verbatim; `source` tells them apart. An idle
* injected context) is the only channel that tells them apart. An idle * injection may append this event between turns without running the model.
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
*/ */
'user/message': PromptMessageData 'user/message': UserMessageData
/** /**
* Durable record of a prompt veto and its reason. It is log-only: the blocked * 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 never enters the model-visible surface, and its turn runs zero steps.
@@ -105,7 +99,7 @@ interface SessionEventMap {
meta?: JsonValue meta?: JsonValue
} }
/** Steering content injected between steps of a running turn. */ /** Steering content injected between steps of a running turn. */
'steering/message': PromptMessageData & { turn: number } 'steering/message': UserMessageData & { turn: number }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] } 'todo/write': { todos: TodoItem[] }
/** /**
@@ -116,7 +110,7 @@ interface SessionEventMap {
} }
``` ```
`PromptMessageData.content` 始终是确切的模型可见内容。当附加上下文声明 `prompt-prefix` 放置方式时,AgentLoop 会依次把它的块、一个 `## My request:` 分隔符以及最终生效的直接提示词拼接进该数组。可选且对模型隐藏的 `envelope` 会保留 `displayContent`,以及按顺序排列的前缀上下文 source,使 transcript(文本记录)、标题与重新引用消费方无需改变可重建历史,就能呈现人类提示词。`displayPromptContent()` 负责该选择,并为普通事件和较早的事件回退到 `content`。 `UserMessageData` 是普通提示词、注入上下文和 steering 共享的持久基础类型,由 `content` 与 `source` 构成。实时 inbox 事件在同一形状上扩展 `AgentMessageId`;消息仍处于待处理状态时,循环只添加驱动器拥有的路由状态。
### `OutOfBandSessionEventMap`:受限的带外追加显式准入 ### `OutOfBandSessionEventMap`:受限的带外追加显式准入

View File

@@ -78,7 +78,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}[T] }[T]
``` ```
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) Sources: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:334`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts)
## Events ## 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) Types: [StreamChunk](core-data-structures/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts)
#### `assistant/message` — surface #### `assistant/message` — surface
@@ -166,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts)
### `compact/*` ### `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) Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts)
### `request/*` ### `request/*`
@@ -343,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/
'request/header': { header: EpochHeader; reason: RequestHeaderReason } 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
``` ```
Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts)
### `sandbox/*` ### `sandbox/*`
@@ -377,7 +377,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s
Types: [SessionTitleEventData](core-data-structures/session-title.md) Types: [SessionTitleEventData](core-data-structures/session-title.md)
Source: [`packages/session-title/session-title/src/index.ts:96`](../packages/session-title/session-title/src/index.ts) Source: [`packages/session-title/session-title/src/index.ts:95`](../packages/session-title/session-title/src/index.ts)
#### `session/title-llm-request` — log-only #### `session/title-llm-request` — log-only
@@ -396,10 +396,10 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages
```ts persistence-catalog ```ts persistence-catalog
/** Steering content injected between steps of a running turn. */ /** Steering content injected between steps of a running turn. */
'steering/message': PromptMessageData & { turn: number } 'steering/message': UserMessageData & { turn: number }
``` ```
Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts)
### `step/*` ### `step/*`
@@ -410,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/
'step/end': { turn: number; step: number } 'step/end': { turn: number; step: number }
``` ```
Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts)
#### `step/start` — log-only #### `step/start` — log-only
@@ -419,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/
'step/start': { turn: number; step: number } 'step/start': { turn: number; step: number }
``` ```
Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts)
### `todo/*` ### `todo/*`
@@ -432,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/
Types: [TodoItem](core-data-structures/session.md) Types: [TodoItem](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts)
### `tool/*` ### `tool/*`
@@ -449,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/
Types: [CallId](core-data-structures/core.md) Types: [CallId](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts)
#### `tool/code-dispatch` — log-only #### `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) Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts)
### `turn/*` ### `turn/*`
@@ -521,23 +521,20 @@ Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/
Types: [TurnEndReason](core-data-structures/session.md) Types: [TurnEndReason](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/types.ts)
#### `turn/start` — log-only #### `turn/start` — log-only
```ts persistence-catalog ```ts persistence-catalog
/** /**
* Opens turn `turn`. `trigger` records what started it — one claimed queued * Opens turn `turn`. `trigger` records what started the model loop.
* 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 } 'turn/start': { turn: number; trigger: TurnTrigger }
``` ```
Types: [TurnTrigger](core-data-structures/session.md) Types: [TurnTrigger](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:211`](../packages/core/session/src/types.ts)
### `user/*` ### `user/*`
@@ -549,11 +546,10 @@ Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/
* (the queued message claimed for this turn), a synthetic `agent.inject()` * (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron * context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three * notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` (with a non-`user` kind marking * project their `content` verbatim; `source` tells them apart. An idle
* injected context) is the only channel that tells them apart. An idle * injection may append this event between turns without running the model.
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
*/ */
'user/message': PromptMessageData 'user/message': UserMessageData
``` ```
Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -906,7 +906,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/inbox/enqueue', name: 'agent/inbox/enqueue',
mode: 'emit', mode: 'emit',
signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void', signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void',
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 */', jsDoc: '/**\n * A frozen item entered the queued or steering inbox.\n * @param agent - the owning agent.\n * @param message - accepted content, source, 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.', summary: 'A frozen item entered the queued or steering inbox.',
}, },
{ {
@@ -1153,7 +1153,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
export const TYPE_API: readonly TypeApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [
{ {
name: 'AdditionalContext', name: 'AdditionalContext',
declaration: 'export interface AdditionalContext {\n content: ContentBlock[];\n source: MessageSource;\n}', declaration: 'export type AdditionalContext = UserMessageData;',
}, },
{ {
name: 'Agent', name: 'Agent',
@@ -1583,10 +1583,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PromptAssembly', name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
}, },
{
name: 'PromptMessageData',
declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n}',
},
{ {
name: 'PromptSection', name: 'PromptSection',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
@@ -1737,7 +1733,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'SessionEventMap', name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}', declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': UserMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}',
}, },
{ {
name: 'SessionEventMetadataFilter', name: 'SessionEventMetadataFilter',
@@ -2231,6 +2227,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'UserInteractionProvider', name: 'UserInteractionProvider',
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}', declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
}, },
{
name: 'UserMessageData',
declaration: 'export interface UserMessageData {\n content: ContentBlock[];\n source: MessageSource;\n}',
},
{ {
name: 'WebFetchBody', name: 'WebFetchBody',
declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};', declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};',

View File

@@ -12,6 +12,7 @@ import { Agent, AgentMessageId, agentCarrier, agentInterruptReasonOf, assembleCo
import { createScope } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope'
import type { import type {
AgentMessage,
AgentMessageId as AgentMessageIdType, AgentMessageId as AgentMessageIdType,
CancelOptions, CancelOptions,
AgentInterruptReason, AgentInterruptReason,
@@ -25,28 +26,19 @@ import {
BlockAssembler, LlmError, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest, BlockAssembler, LlmError, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm' } from '@deepseek-ai/dsh-llm'
import type { import type {
ContentBlock, GenerateOptions, LlmCallConfig, Message, MessageSource, ContentBlock, GenerateOptions, LlmCallConfig, Message,
} from '@deepseek-ai/dsh-llm' } from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { PromptMessageData, Session, SessionId, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import type { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts' import { executeToolCalls } from './tool-calls.ts'
/** One message waiting in the queued or steering inbox. */ /** One message waiting in the queued or steering inbox. */
interface PendingMessage { interface PendingMessage extends AgentMessage {
id: AgentMessageIdType
content: ContentBlock[]
source: MessageSource
wakeup: boolean wakeup: boolean
} }
/** Model-facing input awaiting the next step boundary. */
interface OutboxItem extends PromptMessageData {
/** Present only when this input is a live inbox item. */
steering?: PendingMessage
}
function withoutToolCalls(message: Message): Message { function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') } return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
} }
@@ -59,7 +51,7 @@ export class ReactLoopAgent extends Agent {
/** Prompts awaiting individual turns. */ /** Prompts awaiting individual turns. */
private queued: PendingMessage[] = [] private queued: PendingMessage[] = []
/** Input taken into the session log at step boundaries. */ /** Input taken into the session log at step boundaries. */
private outbox: OutboxItem[] = [] private outbox: (UserMessageData | PendingMessage)[] = []
/** Whether observers see a running interval; consecutive turns share it. */ /** Whether observers see a running interval; consecutive turns share it. */
private busy = false private busy = false
@@ -120,7 +112,7 @@ export class ReactLoopAgent extends Agent {
wakeup, wakeup,
} }
if (steering) { if (steering) {
this.outbox.push({ content: message.content, source: message.source, steering: message }) this.outbox.push(message)
} else { } else {
this.queued.push(message) this.queued.push(message)
} }
@@ -145,7 +137,7 @@ export class ReactLoopAgent extends Agent {
if (!options.keepInbox) { if (!options.keepInbox) {
const discarded = [ const discarded = [
...this.queued, ...this.queued,
...this.outbox.map(item => item.steering).filter(steering => steering !== undefined), ...this.outbox.filter((item): item is PendingMessage => 'id' in item),
] ]
// Clear before abort observers run: replacement work belongs to the next turn. // Clear before abort observers run: replacement work belongs to the next turn.
this.queued.length = 0 this.queued.length = 0
@@ -246,7 +238,7 @@ export class ReactLoopAgent extends Agent {
step += 1 step += 1
const { continueTurn, maxTokens } = await this.step(turn, step, signal) const { continueTurn, maxTokens } = await this.step(turn, step, signal)
if (maxTokens) reason = { kind: 'max-tokens' } if (maxTokens) reason = { kind: 'max-tokens' }
if (continueTurn || this.outbox.some(item => item.steering !== undefined)) continue if (continueTurn || this.outbox.some(item => 'id' in item)) continue
await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal) await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal)
signal.throwIfAborted() signal.throwIfAborted()
if (!this.drainOutbox(turn)) break if (!this.drainOutbox(turn)) break
@@ -438,14 +430,17 @@ export class ReactLoopAgent extends Agent {
private drainOutbox(turn: number): boolean { private drainOutbox(turn: number): boolean {
let steered = false let steered = false
for (const item of this.outbox.splice(0)) { for (const item of this.outbox.splice(0)) {
const { steering: message, ...data } = item if (!('id' in item)) {
if (message === undefined) { this.session.append('user/message', item, { surfaceOp: 'append' })
this.session.append('user/message', data, { surfaceOp: 'append' })
continue continue
} }
steered = true steered = true
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message) emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item)
this.session.append('steering/message', { turn, ...data }, { surfaceOp: 'append' }) this.session.append(
'steering/message',
{ turn, content: item.content, source: item.source },
{ surfaceOp: 'append' },
)
} }
return steered return steered
} }

View File

@@ -9,7 +9,7 @@ import type { Context } from 'cordis'
import type { Branded } from '@deepseek-ai/dsh-brand' import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm' import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionId, UserMessageData } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' { declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext { interface AssembleContext {
@@ -85,11 +85,9 @@ export function AgentMessageId(id: string): AgentMessageId {
* message's enqueue, dequeue, and discard events. Source defaults are already * message's enqueue, dequeue, and discard events. Source defaults are already
* applied, so these are the exact values the item was accepted with. * applied, so these are the exact values the item was accepted with.
*/ */
export interface AgentMessage { export interface AgentMessage extends UserMessageData {
/** The id `send` returned for this message. */ /** The id `send` returned for this message. */
id: AgentMessageId id: AgentMessageId
content: ContentBlock[]
source: MessageSource
} }
/** Options for {@link Agent.cancel}. */ /** Options for {@link Agent.cancel}. */
@@ -111,10 +109,7 @@ export interface CancelOptions {
export type AgentStatus = 'idle' | 'running' export type AgentStatus = 'idle' | 'running'
/** Additional model-facing context produced beside a prompt or tool result. */ /** Additional model-facing context produced beside a prompt or tool result. */
export interface AdditionalContext { export type AdditionalContext = UserMessageData
content: ContentBlock[]
source: MessageSource
}
/** /**
* Prompt interception result. `allow.content` replaces the prompt, while * Prompt interception result. `allow.content` replaces the prompt, while

View File

@@ -185,13 +185,13 @@ export interface EpochHeader {
export type RequestHeaderReason = 'initial' | 'resume' | 'change' export type RequestHeaderReason = 'initial' | 'resume' | 'change'
/** /**
* Shared payload for user, injected-context, and steering prompt messages. A * Shared payload for user, injected-context, and steering messages. A
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn * direct human prompt, a synthetic `agent.inject()` context, and mid-turn
* steering all project into the model transcript as verbatim user-role content; * 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), * they are told apart by `source` (a non-`user` kind marks injected context),
* not by event type. * not by event type.
*/ */
export interface PromptMessageData { export interface UserMessageData {
/** Exact model-facing blocks. */ /** Exact model-facing blocks. */
content: ContentBlock[] content: ContentBlock[]
/** Producer provenance. */ /** Producer provenance. */
@@ -228,7 +228,7 @@ export interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart. An idle * project their `content` verbatim; `source` tells them apart. An idle
* injection may append this event between turns without running the model. * injection may append this event between turns without running the model.
*/ */
'user/message': PromptMessageData 'user/message': UserMessageData
/** /**
* Durable record of a prompt veto and its reason. It is log-only: the blocked * 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 never enters the model-visible surface, and its turn runs zero steps.
@@ -270,7 +270,7 @@ export interface SessionEventMap {
meta?: JsonValue meta?: JsonValue
} }
/** Steering content injected between steps of a running turn. */ /** Steering content injected between steps of a running turn. */
'steering/message': PromptMessageData & { turn: number } 'steering/message': UserMessageData & { turn: number }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] } 'todo/write': { todos: TodoItem[] }
/** /**

View File

@@ -108,7 +108,7 @@
}, },
{ {
"doc": "docs/core-data-structures/core.md", "doc": "docs/core-data-structures/core.md",
"symbol": "HookContext", "symbol": "AdditionalContext",
"source": "packages/core/agent/src/types.ts" "source": "packages/core/agent/src/types.ts"
}, },
{ {
@@ -310,7 +310,7 @@
}, },
{ {
"doc": "docs/core-data-structures/session.md", "doc": "docs/core-data-structures/session.md",
"symbol": "PromptMessageData", "symbol": "UserMessageData",
"source": "packages/core/session/src/types.ts" "source": "packages/core/session/src/types.ts"
}, },
{ {