refactor agent pre-step inbox lifecycle
This commit is contained in:
@@ -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-06-11-event-sourced-sessions.md: 15ba7b23d5eae48e7dee2328b5924493d54aeeb0
|
||||
2026-06-11-event-sourced-sessions.zh.md: da3be5965a6900076f253cad065b847c6f5ce17e
|
||||
2026-06-11-event-sourced-sessions.md: 01f9628c1cfc000aca8654caf5edeff09411fdcc
|
||||
2026-06-11-event-sourced-sessions.zh.md: 4975d39399a3805d2bd22cca7d17a4f5108c7915
|
||||
|
||||
@@ -14,7 +14,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source
|
||||
|
||||
Appends are synchronous (the hot path never blocks on I/O); `session/event` is a sync notification; persistence plugins buffer write-behind and drain at the awaited `session/flush` checkpoint fired at every turn end.
|
||||
|
||||
Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records the message tool dispatch actually used. Regression tests pin that ordering.
|
||||
Ordering contract: the loop claims inbox messages before `agent/pre-step`, opens `step/start` only after an enter decision, then appends the returned `user/message` batch before request derivation. Provider output is assembled and appended as `assistant/message` before tool dispatch, so the durable log records the exact message the tools follow. Regression tests pin that ordering.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严
|
||||
|
||||
追加操作是同步的(热路径从不阻塞于 I/O);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。
|
||||
|
||||
顺序契约:agent loop(智能体循环)*先*追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall(瀑布式事件)在 `assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。
|
||||
顺序契约:agent loop(智能体循环)先领取 inbox 消息,再运行 `agent/pre-step`;只有 enter 决策才打开 `step/start`,随后在请求派生前追加返回的 `user/message` 批次。提供方输出组装并以 `assistant/message` 追加后才分派工具,因此持久日志记录工具实际遵循的确切消息。回归测试固定了这一顺序。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -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-06-11-microkernel-event-taxonomy.md: 8bf05b7deba5f054d4ec8ecf104c3b8798e42d4e
|
||||
2026-06-11-microkernel-event-taxonomy.zh.md: 4ff2ab632ca02e98137a15f19a7996a740a519b0
|
||||
2026-06-11-microkernel-event-taxonomy.md: 202595fed125966a5d77920536e7f4ee88f875fe
|
||||
2026-06-11-microkernel-event-taxonomy.zh.md: 899c96d86cb7e37d90df349ce5f3f932e0a72f95
|
||||
|
||||
@@ -12,10 +12,10 @@ The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic
|
||||
|
||||
Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes:
|
||||
|
||||
- **waterfall** (around-middleware) where plugins transform, veto, recover, or wrap: `agent/prompt-submit`, `agent/request`, `agent/request-error`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.
|
||||
- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` and `agent/post-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final.
|
||||
- **waterfall** (around-middleware) where plugins transform, veto, recover, or wrap: `agent/pre-step`, `agent/request`, `agent/request-error`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.
|
||||
- **serial** (awaited in listener order) for ordered checkpoints such as `agent/turn-stopping`.
|
||||
- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint.
|
||||
- **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors, and the contained immutable `tools/result` observation.
|
||||
- **emit** (synchronous fire-and-forget) for notifications: inbox transitions, lifecycle, errors, and the contained immutable `tools/result` observation. Durable session events own turn and step boundaries.
|
||||
|
||||
The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it.
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ Status: implemented
|
||||
|
||||
纯 Cordis 事件分类体系。agent loop(智能体循环)的扩展 seam 是带类型的事件,具有明确的分发模式:
|
||||
|
||||
- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决、恢复或包装:`agent/prompt-submit`、`agent/request`、`agent/request-error`、`agent/step-result`、`agent/turn-continuation`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。
|
||||
- **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 和 `agent/post-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。
|
||||
- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决、恢复或包装:`agent/pre-step`、`agent/request`、`agent/request-error`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。
|
||||
- **serial**(按监听器顺序依次 await):用于 `agent/turn-stopping` 等有序检查点。
|
||||
- **parallel**(await 扇出):每个监听器都必须获得独立执行的机会:`session/flush` 持久性检查点。
|
||||
- **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。
|
||||
- **emit**(同步 fire-and-forget):用于 inbox 转换、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。轮次与步骤边界由持久会话事件拥有。
|
||||
|
||||
事件词汇定义在接口包中(dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且自身可替换——外部不得依赖它。
|
||||
|
||||
|
||||
@@ -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-06-30-event-domain-semantics.md: 75c1cac11d1bfc9aa7fba9c523eab8c0475027e8
|
||||
2026-06-30-event-domain-semantics.zh.md: a412b8735b72274252473f3218e0d57d4f814bde
|
||||
2026-06-30-event-domain-semantics.md: 3127aa5593e199315fa88ba6cf0848636d1f2248
|
||||
2026-06-30-event-domain-semantics.zh.md: 161632083d5ce57d903f35a4095a1c6fbee867f5
|
||||
|
||||
@@ -21,7 +21,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/
|
||||
**Three domains, one job each, with a single boundary rule.**
|
||||
|
||||
- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and replay projections share one path.
|
||||
- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, and so are the token stream (`assistant/chunk`) and mid-turn steering (`steering/message`).
|
||||
- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, and errors. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, as are the token stream (`assistant/chunk`) and mid-turn steering (`steering/message`).
|
||||
- **`tools/*` — the tool registry + execution seam.**
|
||||
|
||||
**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit.
|
||||
|
||||
@@ -21,7 +21,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)
|
||||
**三个域,各司其职,以一条边界规则统一。**
|
||||
|
||||
- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与回放投影共享同一路径。
|
||||
- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可变更或否决;瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导)(`steering/message`)同理。
|
||||
- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。拦截 waterfall(瀑布式事件)(`agent/pre-step`、`agent/request`、`agent/request-error`)负责变换、拒绝或恢复;awaited `agent/turn-stopping` 观察停止边界;瞬态 emit 报告生命周期、状态、inbox 插入/领取/丢弃与错误。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(`steering/message`)同理。
|
||||
- **`tools/*`——工具注册表与执行 seam。**
|
||||
|
||||
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。
|
||||
|
||||
@@ -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-05-reconstructable-requests.md: 153d37a2faf2265134d5ff9e88f0bbfa275328e0
|
||||
2026-07-05-reconstructable-requests.zh.md: caf51c3065e416fd11aebc1c1d4dc2ee248e2e5c
|
||||
2026-07-05-reconstructable-requests.md: 2f559a3052b9fb84f788975a64799e4f020b0d3e
|
||||
2026-07-05-reconstructable-requests.zh.md: 8635b46979edbe7c0fa11203eacfeceec24adc54
|
||||
|
||||
@@ -22,11 +22,11 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
|
||||
|
||||
**Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree.
|
||||
|
||||
`EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
|
||||
`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
|
||||
|
||||
Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance before the generic `agent/pre-step` checkpoint and boundary snapshot. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written.
|
||||
Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start` and records the final message batch as `user/message` events. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed full header snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
|
||||
|
||||
**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
|
||||
**The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
|
||||
|
||||
**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. The loop records the exact frozen request through `markAgentLoopRequest()` in `dsh-llm`; the process-local identity lets the companion and other request observers recognize conversation work, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step.
|
||||
|
||||
@@ -47,9 +47,9 @@ Like MiniCode, the conversation advances append-only and resets only when model-
|
||||
## Consequences
|
||||
|
||||
- A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event.
|
||||
- Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()` and tool/prompt-submit `additionalContexts` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern).
|
||||
- Model-visible context uses logged message channels. `agent.inject()` and tool `additionalContexts` enter the inbox for a later claim, while `agent/pre-step` returns context that must settle with the current claimed batch. Each entered value is a durable sourced `user/message`, paid once and prefix-cached thereafter at the price of accumulating in history until compaction.
|
||||
- What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side.
|
||||
- The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam.
|
||||
- `agent/pre-step` is the current-request message seam; direct inbox mutation is the eventual later-request seam.
|
||||
- Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
|
||||
- Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated.
|
||||
- Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths.
|
||||
|
||||
@@ -22,11 +22,11 @@ Status: implemented
|
||||
|
||||
**消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。
|
||||
|
||||
`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
|
||||
`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
|
||||
|
||||
每个步骤重建提示词组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果在通用 `agent/pre-step` 检查点与边界快照之前被冻结并缓存于该循环实例。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。
|
||||
每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,并把最终消息批次记录为 `user/message` 事件。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的完整 header 快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
|
||||
|
||||
**`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step(agent, turn, step, signal)` 仍是当前请求所需内容的通用 seam。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。
|
||||
**已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。
|
||||
|
||||
**强制执行。** `dsh-agent-loop/invariant` 配套插件向 `ctx.invariants` 注册,并在被选用时通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环通过 `dsh-llm` 的 `markAgentLoopRequest()` 记录精确的冻结请求;这一进程内标识让配套插件和其他请求观察者识别对话工作,而直接的一次性调用无论其冻结形状或会话 id 如何都保持排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。
|
||||
|
||||
@@ -47,9 +47,9 @@ Status: implemented
|
||||
## 后果
|
||||
|
||||
- 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。
|
||||
- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且不可能在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()` 以及工具/prompt-submit 的 `additionalContexts`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。
|
||||
- 模型可见上下文使用已记录消息通道。`agent.inject()` 与工具 `additionalContexts` 进入 inbox,等待后续领取;必须与当前已领取批次一起结算的上下文由 `agent/pre-step` 返回。每个进入步骤的值都是带来源的持久 `user/message`,只付出一次代价并在后续成为可缓存前缀,代价是会在历史中累积直至压缩。
|
||||
- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。
|
||||
- `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。
|
||||
- `agent/pre-step` 是当前请求的消息 seam;直接修改 inbox 则是最终进入后续请求的 seam。
|
||||
- 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。
|
||||
- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。
|
||||
- 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md
|
||||
2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 04f11d0a2b33d1a2ddd9c782489622a4f9e76d13
|
||||
2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 981ed87864cc82821a411a3a0ac1f511e3ac514b
|
||||
2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 6fbd5e2c9d57da3f25c72c652ca50eb45b84323c
|
||||
2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 5f42a9926abc490274fbc0a180e46719166e441c
|
||||
|
||||
@@ -12,11 +12,11 @@ Successful calls are not the only pressure signal. A provider can reject a reque
|
||||
|
||||
## Decision
|
||||
|
||||
### Successful pressure moves to a durable post-step checkpoint
|
||||
### Successful pressure runs at the next pre-step boundary
|
||||
|
||||
`agent/pre-step` is narrowed to `(agent, turn, step, signal)`. It remains a generic serial checkpoint before `step/start`, but it carries no compaction-only prompt or prefix fields.
|
||||
`agent/pre-step` receives the exclusive claimed message batch plus `{ turn, step, signal }` and returns the final reject/enter decision. It carries no compaction-only prompt or prefix fields.
|
||||
|
||||
The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after assistant output, every dispatched or synthetic tool result, post-tool context, and steering are durable, but before `step/end`. This placement gives pressure policy the complete successful-call state without splitting an assistant tool call from its result. A propagated listener failure is an ordinary turn failure; it never enters model-request recovery. Compact-basic contains its expected operational failures as described below.
|
||||
Compact-basic wraps `agent/pre-step` before each proposed request. At a continuation boundary the preceding assistant output, every dispatched or synthetic tool result, post-tool context, and steering are already durable, so pressure policy sees the complete successful-call state without splitting an assistant tool call from its result. At the initial boundary a headerless session has no completed routed request and produces no pressure work. Compact-basic contains operational failures, warns, and delegates without rejecting the proposed step.
|
||||
|
||||
`dsh-compact-basic` reads the exact latest routed model from the durable request header only to establish that a completed route exists, then asks the singleton `ctx.tokenMeter` to measure the canonical logged envelope and current surface. It does not fall back to `AgentOptions.model` for automatic pressure. A headerless session has no completed routed request to assess and produces no work; any durable non-empty model name uses the same estimator. Operational measurement or summarization failures warn and continue from the latest durable surface: full history before any replacement, or the pruned surface if pruning already landed.
|
||||
|
||||
@@ -46,7 +46,7 @@ Unit tests cover the final-adapter normalization boundary, closed-turn retry num
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep provisional pre-step pressure and add more arguments** — rejected because later routing and request mutation remain outside any earlier snapshot, while generic lifecycle becomes coupled to one plugin.
|
||||
- **Add compaction-only fields to pre-step** — rejected because the canonical durable session and token meter already own the measurement input; the generic lifecycle need not carry a second envelope.
|
||||
- **Retry the same numbered step** — rejected because recovery appends durable events after the failed boundary. A new step preserves balanced nesting and reconstructability.
|
||||
- **Retry whenever `compactIfNeeded` returns a result** — rejected because a custom backend can report success without changing model-visible state. `replaceGeneration` is the authoritative proof.
|
||||
- **Let compact-basic parse provider wording** — rejected because classification belongs at adapters and must cover both thrown and in-band delivery.
|
||||
@@ -54,8 +54,8 @@ Unit tests cover the final-adapter normalization boundary, closed-turn retry num
|
||||
|
||||
## Consequences
|
||||
|
||||
Post-step pressure describes the completed routed request, including durable tool results and request-only prefix fields. Optional model-free pruning removes predictable tool-output bulk before summary selection and can independently create retry-worthy progress. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change.
|
||||
The next pre-step pressure check describes the preceding completed routed request, including durable tool results and newly claimed input. Optional model-free pruning removes predictable tool-output bulk before summary selection and can independently create retry-worthy progress. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change.
|
||||
|
||||
The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder remains oversized. The optional pruner can repair an otherwise indivisible tool pair when removable text-bearing tool-result content is the bulk.
|
||||
The cost is pressure work in the shared pre-step waterfall and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder remains oversized. The optional pruner can repair an otherwise indivisible tool pair when removable text-bearing tool-result content is the bulk.
|
||||
|
||||
This Agent Note supersedes only the pre-step automatic-trigger portion of the [compaction capability-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md). The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged.
|
||||
The [claimed pre-step lifecycle](2026-07-31-claimed-pre-step-inbox-lifecycle.md) supersedes this note's former post-step trigger. The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged.
|
||||
|
||||
@@ -12,11 +12,11 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
### 成功压力移动到持久 post-step 检查点
|
||||
### 成功压力在下一个 pre-step 边界运行
|
||||
|
||||
`agent/pre-step` 收窄为 `(agent, turn, step, signal)`。它仍是 `step/start` 之前的通用串行检查点,但不再携带压缩专用的提示词或前缀字段。
|
||||
`agent/pre-step` 接收独占的已领取消息批次与 `{ turn, step, signal }`,并返回最终 reject/enter 决策。它不携带压缩专用的提示词或前缀字段。
|
||||
|
||||
循环在 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都持久化之后、`step/end` 之前,触发等待式串行 `agent/post-step(agent, turn, step, signal)`。该位置让压力策略看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。向外传播的监听器失败属于普通 turn 失败,绝不会进入模型请求恢复;compact-basic 会按下文所述在内部处理其预期的操作性失败。
|
||||
Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步边界,前一条 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都已经持久化,因此压力策略能看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。初始边界上的无 header 会话尚无已完成路由请求,因此不执行压力工作。Compact-basic 会在内部处理操作性失败、发出警告并继续委托,不会 reject 拟议步骤。
|
||||
|
||||
`dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在完整路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并从最新持久表层继续:任何替换发生前使用完整历史;若剪枝已经落盘,则使用已剪枝表层。
|
||||
|
||||
@@ -46,7 +46,7 @@ Status: implemented
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **保留临时 pre-step 压力并增加更多参数**——不予采纳,因为后续路由与请求变换仍在更早快照之外,同时通用生命周期会耦合到单个插件。
|
||||
- **向 pre-step 增加压缩专用字段**——不予采纳,因为规范持久会话与 token meter 已拥有计量输入;通用生命周期不需要携带第二份信封。
|
||||
- **重试相同编号的 step**——不予采纳,因为恢复会在失败边界之后追加持久事件。新 step 保持边界配对与可重建性。
|
||||
- **只要 `compactIfNeeded` 返回结果就重试**——不予采纳,因为自定义后端可能报告成功却没有改变模型可见状态。`replaceGeneration` 才是权威证明。
|
||||
- **让 compact-basic 解析提供方措辞**——不予采纳,因为分类属于适配器,而且必须同时覆盖抛出式与带内交付。
|
||||
@@ -54,8 +54,8 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
Post-step 压力描述已完成的路由请求,包括持久工具结果与仅请求前缀字段。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。
|
||||
下一个 pre-step 的压力检查描述前一个已完成的路由请求,包括持久工具结果与新领取输入。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。
|
||||
|
||||
代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。
|
||||
代价是在共享 pre-step waterfall 中执行压力工作,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。
|
||||
|
||||
本 Agent Note 只取代[压缩能力接缝 Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中的 pre-step 自动触发部分。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。
|
||||
[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。
|
||||
|
||||
@@ -1,6 +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
|
||||
2026-07-12-agent-scope-runtime-design.md: 232fc02d66411b5ee8a21943795a3be4713bf238
|
||||
2026-07-12-agent-scope-runtime-design.zh.md: 39d558f8cde0183a3590d268aca36ea85e5f5c63
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
|
||||
2026-07-12-agent-scope-runtime-design.md: eea41a3f4bd9465e2e89cb8c67ff7ec412bade71
|
||||
2026-07-12-agent-scope-runtime-design.zh.md: 770712085e17f9de2a0fdb1fba077d2048d7cc0f
|
||||
|
||||
@@ -150,7 +150,7 @@ sequenceDiagram
|
||||
Every teardown request joins one memoized path. The order is:
|
||||
|
||||
1. Deactivate creation or driving and let synchronous publication finish.
|
||||
2. Stop and drain the driver, including idle injection flushes.
|
||||
2. Stop and drain the driver, discarding any injection that remains pending.
|
||||
3. Detach the agent.
|
||||
4. Detach the session.
|
||||
5. Dispose the agent scope.
|
||||
@@ -238,7 +238,7 @@ For a native call, the observer deletes the stage and commits its value only whe
|
||||
|
||||
For a Code Mode SDK call, the inner successful result records `{ parentToken, value }` rather than committing. The observer waits for the `run_code` execution whose token matches `parentToken` and commits only if that outer final result also succeeds. Program failure, runtime abort, or outer post-policy denial discards the pending value.
|
||||
|
||||
Once a value is pending or committed, a scoped monotonic guard denies later tool calls. After commit, the ordinary serial `agent/turn-stop` listener returns a stop decision after continuation and steering have already folded. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn.
|
||||
Once a value is pending or committed, a scoped monotonic guard denies later tool calls. The successful structured-output execution calls `exec.concludeTurn()`, so its own immutable result carries `concludesTurn: true` and the loop ends the tool loop at that step. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn.
|
||||
|
||||
Pure Code Mode's registry contribution omits `structured_output` from native wire schemas and exposes it through the generated SDK. The assembly waterfall may deliberately change that presentation; execution still validates against the child-scoped definition, and the listener owns the consistency of any alternate model-visible route it creates.
|
||||
|
||||
@@ -250,9 +250,9 @@ Prompt assembly is intentionally cooperative, but three execution facts need one
|
||||
|---|---|---|
|
||||
| Tool pre-policy | Deny monotonically | A later listener must not re-allow an already denied call |
|
||||
| Tool result | Observe the immutable committed outcome | Structured output must commit only the result that actually escaped the pipeline |
|
||||
| Turn continuation | Stop after ordinary continuation folding | A committed terminal output must end the turn |
|
||||
| Turn continuation | Conclude through the committed tool result | A committed terminal output must end the turn |
|
||||
|
||||
`ToolGuard` is the monotonic policy registry. Committed tool observation is the contained `tools/result` point described above. Terminal structured output listens on the ordinary serial `agent/turn-stop` fold after normal continuation and steering decisions; no public `strictSerial()` dispatcher is needed for the typed listener contract.
|
||||
`ToolGuard` is the monotonic policy registry. Committed tool observation is the contained `tools/result` point described above. Terminal structured output marks its own execution with `concludesTurn`, so terminality is data on the authoritative result rather than a separate hook decision.
|
||||
|
||||
### Skill and approval services trust typed callers
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ sequenceDiagram
|
||||
每个拆除请求加入一条记忆化路径。顺序为:
|
||||
|
||||
1. 停用创建或驱动,让同步发布完成。
|
||||
2. 停止并排空 driver,包括空闲注入刷新。
|
||||
2. 停止并排空 driver,丢弃仍处于待处理状态的注入。
|
||||
3. 分离 agent。
|
||||
4. 分离会话。
|
||||
5. Dispose agent 作用域。
|
||||
@@ -238,7 +238,7 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子
|
||||
|
||||
对于 Code Mode SDK 调用,内层成功结果记录 `{ parentToken, value }` 而非提交。观察者等待 token 匹配 `parentToken` 的 `run_code` 执行,仅在该外层最终结果也成功时才提交。程序失败、运行时中止或外层 post-policy 拒绝会丢弃待定值。
|
||||
|
||||
一旦值处于待定或已提交状态,作用域单调守卫拒绝后续工具调用。提交后,普通串行的 `agent/turn-stop` 监听器在 continuation 和 steering(中途引导)已折叠之后返回停止决策。Schema 验证失败仍然是普通的 `INVALID_ARGS` 工具错误,子级可以在同一轮次内重试。
|
||||
一旦值处于待定或已提交状态,作用域单调守卫拒绝后续工具调用。成功的结构化输出执行会调用 `exec.concludeTurn()`,因此其自身不可变结果携带 `concludesTurn: true`,循环在该步骤结束工具循环。Schema 验证失败仍然是普通的 `INVALID_ARGS` 工具错误,子级可以在同一轮次内重试。
|
||||
|
||||
纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。
|
||||
|
||||
@@ -250,9 +250,9 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子
|
||||
|---|---|---|
|
||||
| 工具 pre-policy | 单调拒绝 | 后续监听器不得重新允许已被拒绝的调用 |
|
||||
| 工具结果 | 观察不可变的已提交结果 | 结构化输出必须仅提交实际逃出流水线的结果 |
|
||||
| 轮次 continuation | 在普通 continuation 折叠之后停止 | 已提交的终端输出必须结束轮次 |
|
||||
| 轮次 continuation | 通过已提交工具结果终止 | 已提交的终端输出必须结束轮次 |
|
||||
|
||||
`ToolGuard` 是单调策略注册表。已提交的工具观察是上述被隔离的 `tools/result` 点。终端结构化输出监听普通串行的 `agent/turn-stop` 折叠,在正常 continuation 和 steering 决策之后;类型化的监听器契约不需要公开的 `strictSerial()` dispatcher。
|
||||
`ToolGuard` 是单调策略注册表。已提交的工具观察是上述被隔离的 `tools/result` 点。终端结构化输出在自身执行上标记 `concludesTurn`,因此终止性成为权威结果上的数据,而不是独立 hook 决策。
|
||||
|
||||
### Skill 和 approval 服务信任类型化调用方
|
||||
|
||||
|
||||
@@ -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-14-provider-routed-llm-adapters.md: 1bd9197667f6e49c5025c98b4a77500f78595c2b
|
||||
2026-07-14-provider-routed-llm-adapters.zh.md: 4d57f2cb33ac296500a4a19771ea493621ff93f6
|
||||
2026-07-14-provider-routed-llm-adapters.md: 27277280e423553f79d5a34f512b673413f495ff
|
||||
2026-07-14-provider-routed-llm-adapters.zh.md: 63a6aaf4453c0d8cfb6a23bfed4a94dacb054da0
|
||||
|
||||
@@ -40,7 +40,7 @@ pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` reje
|
||||
|
||||
Assistant messages carry provider-neutral provenance containing the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records this provenance and `deriveMessages()` returns it with the assistant message. User, system, context, and tool-result messages carry no assistant provenance. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload.
|
||||
|
||||
A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches it to the assistant provenance only when the post-`agent/step-result` content is structurally equal to the assembled provider output. A listener that rewrites content keeps the provider/model provenance but loses the now-stale replay state. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history.
|
||||
A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches that state to the assembled assistant provenance without exposing a response-rewrite hook. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history.
|
||||
|
||||
The pi-ai replay state is a versioned, minimal projection of its successful `AssistantMessage`: source API/provider/model, response id/model, stop reason, and index-aligned text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmService` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. An adapter receiving replay state with an unknown version or mismatched block shape fails explicitly; a different adapter receives only provider-neutral content and provenance.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定
|
||||
|
||||
助手消息携带提供方无关的来源信息,其中包含请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些来源信息,`deriveMessages()` 返回助手消息时也会包含这些信息。用户、system、context 与工具结果消息不携带助手来源信息。provider/model 字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。
|
||||
|
||||
成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。只有当 `agent/step-result` 处理后的内容与提供方组装输出在结构上相等时,agent loop 才会把回放状态附加到助手来源信息。监听器重写内容后,provider/model 来源信息仍会保留,但已经陈旧的回放状态会被移除。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。
|
||||
成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。agent loop 会把该状态附加到组装后的助手来源信息,不再暴露响应改写 hook。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。
|
||||
|
||||
pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包含源 API/provider/model、响应 ID/model、停止原因,以及按索引对齐的文本、thinking 和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmService` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。适配器收到未知版本或块形状不匹配的回放状态时会显式失败;其他适配器只能收到提供方无关的内容与来源信息。
|
||||
|
||||
|
||||
@@ -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-15-replay-token-meter-service.md: 3496364663c1f73b8161461d1a229b19d9730c6d
|
||||
2026-07-15-replay-token-meter-service.zh.md: 0bc4d9decac36bd5674cd0fb04f82fdcd277554e
|
||||
2026-07-15-replay-token-meter-service.md: c0f4b467ad0013dd4ac0a0301281b011ea8c261c
|
||||
2026-07-15-replay-token-meter-service.zh.md: 0d3d6c7ff9be035636d3a2cba63cb59d1489cdab
|
||||
|
||||
@@ -36,7 +36,7 @@ Automatic compaction uses one unified measurement for each threshold-and-retenti
|
||||
|
||||
Compact policy has service-wide defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level fields apply to every routed target; exact provider/model entries in `modelPolicies` partially override them. Pressure scales ratios against capacity resolved from the owning adapter, and `retainTokens` may replace `retainRatio`; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair.
|
||||
|
||||
Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope produced under the provider/model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement.
|
||||
Automatic pressure runs at `agent/pre-step` before request derivation and measures the canonical durable envelope produced under the provider/model actually selected by the preceding `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket
|
||||
|
||||
压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层字段适用于每个路由目标;`modelPolicies` 中的精确提供方/模型项可以部分覆盖这些字段。压力检查根据所属适配器解析的容量缩放比例,`retainTokens` 可以替代 `retainRatio`;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。
|
||||
|
||||
自动压力检查运行在 `agent/post-step`,并计量 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。
|
||||
自动压力检查在请求派生前运行于 `agent/pre-step`,并计量前一个 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -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-16-explicit-turn-cancellation.md: 15085a1da2cf183bace9957a4bedb3ea466aa472
|
||||
2026-07-16-explicit-turn-cancellation.zh.md: e945b0fea51bdbfee38048573c643b0fb8ecb685
|
||||
2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc
|
||||
2026-07-16-explicit-turn-cancellation.zh.md: 7132ec1d15ff708460a176bb9ba5b22e0f358577
|
||||
|
||||
@@ -16,11 +16,11 @@ Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind:
|
||||
|
||||
An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outcome. The terminal event records what happened to the turn, while the runtime signal identifies who requested cancellation; it does not duplicate `user` or `parent` into replay. Session seed/load rejects legacy aborted records with a reason or any other extra field, so replay cannot reintroduce caller-owned cancellation detail. The process-local `agent/cancel-requested` notification is not durable; a future audit requirement uses a separate durable control-request event so a request and its eventual outcome remain distinct. Durable events contain no stack, signal, error object, free-form cancellation text, or backend-private detail.
|
||||
|
||||
AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through prompt processing, prompt assembly, every step, model and tool execution, continuation, and `agent/turn-stop`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal.
|
||||
AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal.
|
||||
|
||||
The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
|
||||
|
||||
The explicit event signatures keep their positional form and place `signal` immediately before a waterfall's final `next`. Prompt submission, request configuration, step-result processing, continuation, and terminal stop join the pre-existing explicit signal seams for pre-step, session prefix, model generation, tool execution, approval, and subagent or workflow requests. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
|
||||
The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
|
||||
|
||||
`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam.
|
||||
|
||||
@@ -30,7 +30,7 @@ Cancellation remains cooperative. The loop checks interruption before and after
|
||||
|
||||
## Verification
|
||||
|
||||
Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at prompt submission, system-prompt assembly, session prefix, pre-step, request, model stream, step result, tool execution, continuation, and terminal stop; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle.
|
||||
Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle.
|
||||
|
||||
Initiator-scope tests assert that every hook still observes the exact Agent and no ambient turn signal, concurrent Agents retain independent identities and signals, and a nested child driver shadows only identity. Race tests cover idle cancellation, pre-run cancellation, replacement submission from a `running` listener, repeated cancellation, and cancel-versus-dispose quiescence.
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user
|
||||
|
||||
正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。Session seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 `agent/cancel-requested` 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。
|
||||
|
||||
AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖提示词处理、提示词组装、每个步骤、模型与工具执行、继续决策和 `agent/turn-stop`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。
|
||||
AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。
|
||||
|
||||
对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
|
||||
|
||||
显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
|
||||
显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
|
||||
|
||||
`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。
|
||||
|
||||
@@ -30,7 +30,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时
|
||||
|
||||
## 验证
|
||||
|
||||
契约测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在提示词提交、系统提示词组装、会话前缀、步骤前处理、请求、模型流、步骤结果、工具执行、继续决策和终止停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。
|
||||
契约测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。
|
||||
|
||||
发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的静止状态。
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.md: 4d0cbeff0c8a07362caa1ec18493267a9f0d2823
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 935a1a78a6bed451c1db646dec2ec5f4f5e87949
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.md: 4bc4303f636ea7ff268d0b3f2d0a65dfba954883
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 4dc725d5d03f1871c7fb5115577e3283bb395add
|
||||
|
||||
@@ -12,9 +12,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj
|
||||
|
||||
## Decision
|
||||
|
||||
**One primitive, three preset aliases.** The `Agent` interface's `send(message, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its complete `UserMessage` owns identity, role, model-facing `content`, and producer `source`; the complete `SendOptions` owns only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one message and fix the policy. `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. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller.
|
||||
**One primitive, three preset aliases.** The `Agent` interface's `send(message, target, wakeup)` covers the (`target` × `wakeup`) matrix. Its complete `UserMessage` owns identity, role, model-facing `content`, and producer `source`; the remaining arguments own only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one message and fix the policy. `wakeup` reserves a driver when the agent is idle; an already active driver receives no second reservation and can claim the input only if it reaches a later pre-step boundary. `next-turn`/no-wakeup (queue without waking) is 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 while prompt admission or a turn owns the next safe boundary, and appended directly outside that window. It bypasses the FIFOs entirely, while its required `UserMessage.source` preserves the caller's explicit provenance.
|
||||
**inject is a non-waking next-step delivery.** It always appends the complete message to the next-step inbox. The driver claims it at a later pre-step and records it only when the final decision returns it in the entering batch; idle injection remains pending until another delivery wakes the driver. Its required `UserMessage.source` preserves the caller's explicit provenance.
|
||||
|
||||
**context/message is gone.** Injected context is now a `user/message`; context producers supply the appropriate non-user `source` explicitly, and 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.
|
||||
|
||||
@@ -22,13 +22,13 @@ Separately, `context/message` and `user/message` had converged: the surface proj
|
||||
|
||||
**`send` does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing.
|
||||
|
||||
**Inbox lifecycle events carry occurrence identities.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/update` (a pending queued item was edited), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (pending items were dropped) carry an `InboxItem`: an occurrence-local `InboxItemId`, the accepted `UserMessage`, and the resolved `queued | steering` placement captured at acceptance. The occurrence identity lets observers and reconnect mirrors distinguish repeated sends of the same `MessageId` without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes one enqueue and exactly one terminal dequeue or discard; updates are non-terminal. The `dsh-agent` invariant companion asserts this FIFO conservation.
|
||||
**Inbox mutations have one durable projection and three minimal live notifications.** Every append, prepend, edit, remove, cancellation, and claim records normalized `agent/inbox/spliced` coordinates. Insertions emit `agent/inbox/inserted { message }`; ordinary removals carry durable `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; the loop's atomic `claim()` records pure deletion splices and then emits `agent/inbox/claimed { message, turn }`. `MessageId` is the sole occurrence identity and remains unique across both pending lists. The live payloads deliberately omit placement, outcome, and batch envelopes because the durable splice owns those facts.
|
||||
|
||||
**Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate.
|
||||
**Pre-step claims next-step input without making it a separate turn.** Steering and injection always enter the same next-step inbox; steering wakes the driver, while injection does not. At a turn boundary the driver atomically claims pending next-step input before one queued prompt, and between steps it claims only next-step input. Claiming records pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then rejects the proposed step or returns its complete entering batch. Rejection and listener failure leave the claimed batch removed; input arriving after the claim waits for a later boundary.
|
||||
|
||||
**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use the identified, frozen `UserMessage` directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. A queued message that becomes steering keeps the same message value in the outbox, while injected and tool-produced context each carry their own identified message. The [identified immutable message decision](2026-07-28-identified-immutable-message-values.md) supersedes this note's former `UserMessageData`/`AgentMessage` hierarchy and extends the representation to assistant and tool-result messages.
|
||||
**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use the identified, frozen `UserMessage` directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. Steering, injection, and tool-produced context each keep their identified messages in the next-step inbox. The [identified immutable message decision](2026-07-28-identified-immutable-message-values.md) supersedes this note's former `UserMessageData`/`AgentMessage` hierarchy and extends the representation to assistant and tool-result messages.
|
||||
|
||||
**Idle wakeup follows acceptance.** Before publishing enqueue, a waking queued send installs quiescence ownership and schedules driver admission for a microtask that runs after the id returns. Every send in one synchronous caller stack therefore resolves placement against the same pre-admission state, while reentrant cancellation or teardown cannot retire before the scheduled admission settles. Two idle `steer()` calls remain two FIFO turns instead of the first opening an admission window that captures the second.
|
||||
**Idle wakeup follows insertion.** A waking send reserves the driver and schedules pre-step processing for a microtask after the input enters its target inbox. Every send in one synchronous caller stack therefore enters before claiming starts, while reentrant cancellation or teardown cannot retire before the scheduled pre-step settles. Multiple idle `steer()` calls in that stack form one next-step batch.
|
||||
|
||||
**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).
|
||||
|
||||
@@ -36,14 +36,14 @@ Separately, `context/message` and `user/message` had converged: the surface proj
|
||||
|
||||
- **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. Plugin-produced injected context supplies its plugin source explicitly.
|
||||
- **A typed discriminant field on `UserMessage`** (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 resolved placement, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe.
|
||||
- **Derive inbox placement from agent status or the session log.** Rejected because `running` includes admission and settlement, while reconnect baselines need the original acceptance result even when the earlier turn boundary is absent. The producer already owns the exact routing decision.
|
||||
- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/inserted` is the live insertion signal, while claimed/discarded notifications describe exits and the durable splice retains placement.
|
||||
- **Derive inbox placement from agent status.** Rejected because `running` includes pre-step processing and settlement. The producer already supplies the exact target to the durable splice.
|
||||
|
||||
## 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 `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. 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.
|
||||
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 `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. 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 remains pending without opening a turn or running the model, then becomes `user/message` when a later waking delivery's pre-step returns it in the entering batch.
|
||||
|
||||
`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. The later [addressable queue operations](../feature/2026-07-29-addressable-queue-operations.md) decision adds live mutations over that occurrence identity without changing the one-message-per-turn or durable-message contracts.
|
||||
`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to claim: 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 insertion and exit publishes its matching live notification, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-message representation keeps durable splices and live events correlated without maintaining a second steering wrapper or allowing its data to diverge. The later [claimed pre-step inbox lifecycle](2026-07-31-claimed-pre-step-inbox-lifecycle.md) decision keeps live queue mutations addressed by `MessageId` and separates single-message lifecycle notifications from the durable whole-queue splice projection.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
|
||||
|
||||
## 决策
|
||||
|
||||
**一个原语,三个预设别名。** `Agent` 接口的 `send(message, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。完整的 `UserMessage` 持有标识、角色、模型可见 `content` 与生产方 `source`;完整的 `SendOptions` 只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一条消息并固定策略。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。
|
||||
**一个原语,三个预设别名。** `Agent` 接口的 `send(message, target, wakeup)` 覆盖 (`target` × `wakeup`) 矩阵。完整的 `UserMessage` 持有标识、角色、模型可见 `content` 与生产方 `source`;其余参数只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一条消息并固定策略。`wakeup` 会在 agent 空闲时保留一个驱动器;已经活跃的驱动器不会获得第二次保留,只有在抵达后续 pre-step 边界时才能领取该输入。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。
|
||||
|
||||
**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessage.source` 会保留调用方显式提供的来源信息。
|
||||
**inject 是不会唤醒的 next-step 投递。** 它始终把完整消息追加到 next-step inbox。驱动器会在后续 pre-step 领取它,并且只有最终决策把它放入进入步骤的批次时才会记录;空闲注入会保持待处理,直到其他投递唤醒驱动器。必填的 `UserMessage.source` 会保留调用方显式提供的来源信息。
|
||||
|
||||
**context/message 已移除。** 注入的上下文现在是一条 `user/message`;上下文生产方显式提供合适的非 `user` 类别 `source`,类型化 source 变体携带所有特定于领域的持久来源信息。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。
|
||||
|
||||
@@ -22,13 +22,13 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
|
||||
|
||||
**`send` 不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。
|
||||
|
||||
**Inbox 生命周期事件携带单次入队标识。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/update`(待处理的 queued 项被编辑)、`agent/inbox/dequeue`(驱动器认领一个项)和 `agent/inbox/discard`(待处理项被丢弃)都会携带一个 `InboxItem`:仅属于本次入队的 `InboxItemId`、已接受的 `UserMessage`,以及生产方在接受消息时捕获的已解析 `queued | steering` 放置方式。单次入队标识让观察方和重连镜像能够区分同一 `MessageId` 的多次发送,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每次 FIFO 入队都会发布一个 enqueue,并且恰好发布一个终态 dequeue 或 discard;update 不是终态。`dsh-agent` 的不变量配套断言这种 FIFO 守恒。
|
||||
**Inbox 变更只有一份持久投影和三种最小实时通知。** 每次 append、prepend、编辑、删除、取消与领取都会记录规范化的 `agent/inbox/spliced` 坐标。插入会发出 `agent/inbox/inserted { message }`;普通删除携带持久 `outcome: 'canceled'`,并发出 `agent/inbox/discarded { message }`;循环的原子 `claim()` 会记录纯删除 splice,随后发出 `agent/inbox/claimed { message, turn }`。`MessageId` 是唯一的单次出现标识,并在两个待处理列表间保持唯一。实时载荷刻意不携带 placement、outcome 或批次封套,因为这些事实由持久 splice 持有。
|
||||
|
||||
**准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。
|
||||
**pre-step 会领取 next-step 输入,但不会为它单独创建轮次。** steering 和注入始终进入同一个 next-step inbox;steering 会唤醒驱动器,注入则不会。在轮次边界,驱动器会原子领取待处理的 next-step 输入,再领取一条排队提示词;在步骤之间则只领取 next-step 输入。领取会记录纯删除 splice,并针对每条消息发出一次 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 会拒绝拟议步骤,或返回进入步骤的完整批次。reject 与监听器失败都会让已领取批次保持已删除;领取后才到达的输入会等待后续边界。
|
||||
|
||||
**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 `UserMessage`。循环把该值与私有路由状态存放在一起,不会将其标识、内容或来源复制到另一种公开形状中。一条成为 steering 的排队消息会在 outbox 中保留同一个消息值,而注入和工具产生的上下文则各自携带带标识的消息。[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)取代了本记录此前的 `UserMessageData`/`AgentMessage` 层级,并将这一表示扩展到 assistant 消息和工具结果消息。
|
||||
**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 `UserMessage`。循环把该值与私有路由状态存放在一起,不会将其标识、内容或来源复制到另一种公开形状中。steering、注入和工具产生的上下文都会在 next-step inbox 中保留各自带标识的消息。[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)取代了本记录此前的 `UserMessageData`/`AgentMessage` 层级,并将这一表示扩展到 assistant 消息和工具结果消息。
|
||||
|
||||
**空闲唤醒在接受之后发生。** 在发布 enqueue 前,一次会唤醒驱动器的排队发送会先取得完全停稳所有权,并把驱动器准入调度到一个会在该次发送返回 id 后运行的微任务中。因此,同一同步调用栈中的每次发送都会基于同一份准入前状态解析放置方式,而可重入的取消或拆除在已调度的准入结算前无法完成退役。空闲时的两次 `steer()` 调用会保留为两个 FIFO 轮次,而不会因第一次调用打开准入窗口而把第二次吸纳进去。
|
||||
**空闲唤醒在插入之后发生。** 会唤醒的发送会先保留驱动器,并在输入进入目标 inbox 后把 pre-step 处理调度到微任务。因此,同一同步调用栈中的每次发送都会在领取开始前进入 inbox,而可重入的取消或拆除在已调度的 pre-step 结算前无法完成退役。同一调用栈中多次空闲 `steer()` 会形成一个 next-step 批次。
|
||||
|
||||
**cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。
|
||||
|
||||
@@ -36,14 +36,14 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
|
||||
|
||||
- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。由插件产生的注入上下文会显式提供其 plugin 来源。
|
||||
- **在 `UserMessage` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。
|
||||
- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是带有已解析的放置方式,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。
|
||||
- **根据 agent 状态或会话日志推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖准入与结算,而重连基线即使缺少此前的轮次边界,也需要最初的接受结果。生产方已经拥有精确的路由决策。
|
||||
- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/inserted` 已经是实时插入信号,claimed/discarded 通知描述退出,而持久 splice 保留 placement。
|
||||
- **根据 agent 状态推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖 pre-step 处理与结算。生产方已经把精确目标写入持久 splice。
|
||||
|
||||
## 后果
|
||||
|
||||
投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。
|
||||
投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲注入会保持待处理,不打开轮次也不运行模型;后续会唤醒的投递在 pre-step 将其放入进入步骤的批次时,它才成为 `user/message`。
|
||||
|
||||
`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。后续的[可寻址队列操作](../feature/2026-07-29-addressable-queue-operations.md)决策在该单次入队标识上增加了实时变更,但不改变单消息单轮次或持久消息契约。
|
||||
`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可领取的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每次插入与退出都会发布对应的实时通知,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理消息的表示方式,使持久 splice 与实时事件保持可关联,既无需维护第二个 steering 包装层,也避免数据发生分歧。后续的[已领取 pre-step inbox 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)决策保留通过 `MessageId` 寻址的实时队列变更,并把单消息生命周期通知与持久的整体队列 splice 投影分离。
|
||||
|
||||
## 相关
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md
|
||||
2026-07-24-separate-context-injection-from-turn-execution.md: 83eb542cb78bf38042d79015153f55622fe46d43
|
||||
2026-07-24-separate-context-injection-from-turn-execution.zh.md: cd748e5cf9a9019427f862b3127d36256fc4e4f4
|
||||
2026-07-24-separate-context-injection-from-turn-execution.md: ef85a33c7479b59a079c42cd42711a650ebc904e
|
||||
2026-07-24-separate-context-injection-from-turn-execution.zh.md: ebcf5af6defdc56085bfd647f5134414cd22519b
|
||||
|
||||
@@ -20,27 +20,27 @@ Idle `inject()` exposed a second mismatch. Injection did not request model execu
|
||||
|
||||
A caller that owns context delivers an identified, frozen `UserMessage` through `inject()` and submits the direct message independently with `followup()` or `steer()`.
|
||||
|
||||
Prompt and tool extension points still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt and its returned additional contexts enter the new turn as separate messages; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the outbox after the corresponding tool results.
|
||||
An entering pre-step returns the complete `PreStepDecision.messages` batch for the request being finalized. Tool extension points still return `additionalContexts`, which enter the next-step inbox only after the corresponding tool results. These values are extension-point outputs, not attachments captured from a caller's inbox item.
|
||||
|
||||
Every additional context is an independent `user/message` whose `source` records provenance. There is no `context/message`, prompt-prefix placement, stable request delimiter, or prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`.
|
||||
|
||||
## Injection lifecycle
|
||||
|
||||
During prompt admission or an open turn, `inject()` stages context in the loop outbox. The private next-step acceptance window opens before `agent/prompt-submit` and closes before `turn/end`, so steering and context accepted for one boundary reach the same following request while a `turn/end` listener's late steering becomes a queued prompt. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: context accepted during an assistant tool-call batch appears only after that batch's complete ordered results.
|
||||
`inject()` always inserts context into the non-waking `next-step` inbox. A collecting or running driver claims it at the nearest later pre-step boundary. An idle driver leaves it pending until `followup()` or `steer()` supplies waking work; cancellation or disposal may discard it first.
|
||||
|
||||
Outside that window, `inject()` appends its `user/message` immediately. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model; persistence observes the append through `session/event`.
|
||||
The loop claims the current next-step batch before running `agent/pre-step`, so an injection that arrives after that claim may miss the request already being finalized. The next boundary claims it instead. An enter decision appends its returned messages inside the owning turn before the request consumes them. Context produced during an assistant tool-call batch therefore appears after that batch's complete ordered results.
|
||||
|
||||
If prompt admission blocks or fails, a caller-staged context-only batch appends immediately without a turn. Steering and context staged beside it remain in the outbox for a later admitted prompt; cancellation or disposal may discard them. Hook-produced `additionalContexts` never materialize because they belong to the rejected admission decision.
|
||||
If pre-step rejects or throws, its claimed injected context, steering, and queued prompt stay removed and no returned batch is appended. Messages inserted after that atomic claim are unaffected and remain pending.
|
||||
|
||||
The session invariant permits `user/message` between turns while continuing to require turn enclosure for core execution events, steering, assistant output, and tools. Merge-extensible event relations belong to their declaring plugin rather than a core default. Persistence, recovery, resume, fork, and compaction treat valid between-turn events as committed session history rather than an interrupted or discardable turn tail.
|
||||
The loop appends injected `user/message` events only from entered batches inside a turn. Core execution events, steering, assistant output, and tools remain turn-enclosed; merge-extensible event relations belong to their declaring plugin rather than a core default.
|
||||
|
||||
## Extension and caller semantics
|
||||
|
||||
`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements.
|
||||
The enter branch's `PreStepDecision.messages` is the complete batch for the proposed step. A waterfall listener that delegates with `next()` preserves downstream messages unless it intentionally replaces them; additions follow natural waterfall return order. Tool-result `additionalContexts` retain FIFO order and individual provenance.
|
||||
|
||||
Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. Outside a next-step acceptance window, a caller that invokes `inject(context)` and then `followup(prompt)` commits context independently; callers requiring all-or-nothing behavior use a domain-specific admission wrapper.
|
||||
Caller-driven injection and current-step context deliberately use different timing. `inject()` joins the next pre-step available and cannot promise that a request already being finalized will consume it. A listener that must affect that exact request returns the context in `PreStepDecision.messages`; downstream rejection or failure then prevents it from materializing.
|
||||
|
||||
Cross-session references use that domain composition: TUI prepares the snapshot, then either adds it to the prompt's admission decision outside an acceptance window or injects it beside steering during one. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules.
|
||||
Cross-session references use that domain composition: TUI prepares the snapshot, returns it from the idle direct message's pre-step beside that message, or injects it before waking steering during a running turn. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules.
|
||||
|
||||
This decision preserves the caller-owned framing decision from [unwrapped injected content](../simplification/2026-07-20-unwrap-injected-content-envelopes.md) and the one-item turn rule from [one send, one turn](../simplification/2026-07-17-one-send-one-turn.md). The later [standalone log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) applies the same execution-only meaning to plugin-owned records.
|
||||
|
||||
@@ -48,27 +48,27 @@ This decision preserves the caller-owned framing decision from [unwrapped inject
|
||||
|
||||
**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery.
|
||||
|
||||
**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers.
|
||||
**Keep a distinct `context/message` session event.** User-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers.
|
||||
|
||||
**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution.
|
||||
**Keep one-shot turns for idle injection.** This gives idle context an immediate durability boundary, but it makes turn counts and turn observers report work that never ran the model. Non-waking context instead remains pending until real waking work supplies a request.
|
||||
|
||||
**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content.
|
||||
|
||||
**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path.
|
||||
**Let prompt hooks call `inject()` instead of returning messages.** An injection may miss the request whose prompt is already being finalized and would escape a downstream block of that decision. Returning the complete message batch keeps current-request context under the waterfall's authority.
|
||||
|
||||
## Verification
|
||||
|
||||
- Delivery inputs and steering inbox records contain no attached contexts; `agent/inbox/enqueue` reports only the message plus its resolved queued-or-steering placement.
|
||||
- Delivery inputs and steering inbox records contain no attached contexts; `agent/inbox/inserted` reports only the inserted message, while the durable splice retains its target list.
|
||||
- `UserMessage` is the shared identified, frozen shape across prompt interception, tool execution, hook bridges, guards, and context producers.
|
||||
- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay.
|
||||
- Idle `inject()` appends one sourced `user/message` without a turn or model call.
|
||||
- Admission-time and active-turn injection drain at safe boundaries after complete tool-result batches and before the request that consumes them.
|
||||
- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; caller context alone falls back to an idle append, while a steering boundary remains available to retry.
|
||||
- Unit, persistence/resume, invariant, host/client queue, and TUI coverage pin event order, admission ownership, and reconnect classification.
|
||||
- Idle `inject()` queues one non-waking next-step item and appends nothing until a later waking delivery starts pre-step processing.
|
||||
- Collecting and active-turn injection is claimed at the nearest later pre-step boundary, after complete tool-result batches and before the request that consumes it.
|
||||
- Rejected or failed pre-step drops its claimed batch; input inserted after the claim remains pending.
|
||||
- Unit, persistence/resume, invariant, and TUI coverage pin event order, claim ownership, and durable replay.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One surface event is valid outside turns, so persistence scanning, crash repair, forking, compaction, and session queries distinguish execution enclosure from session history.
|
||||
- Idle injection is not model-visible until a later pre-step enters it and may be lost to cancellation or disposal.
|
||||
- Consecutive user-role messages replace one baked prompt message; provider adapters preserve that ordering.
|
||||
- Outside an acceptance window, `inject()` followed by a blocked `followup()` leaves context without its intended direct prompt unless the caller supplies domain-specific admission ownership.
|
||||
- Exact-current-request context must be returned from `agent/pre-step`; ordinary injection provides only nearest-later-boundary delivery.
|
||||
- The public delivery contract and inbox records remain small: no context attachment, context-placement metadata, prompt envelope, or duplicate durable event type.
|
||||
|
||||
@@ -20,27 +20,27 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入:
|
||||
|
||||
拥有上下文的调用方通过 `inject()` 交付带标识且冻结的 `UserMessage`,再独立使用 `followup()` 或 `steer()` 提交直接消息。
|
||||
|
||||
提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。获准的提示词及其返回的额外上下文会作为独立消息进入新轮次;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入 outbox。
|
||||
返回 enter 的 pre-step 会为正在最终确定的请求返回完整的 `PreStepDecision.messages` 批次。工具扩展点仍可返回 `additionalContexts`,这些上下文只会在对应工具结果之后进入 next-step inbox。这些值是扩展点的输出,而不是从调用方 inbox 条目捕获的附件。
|
||||
|
||||
每项额外上下文都是独立的 `user/message`,并由 `source` 记录来源。不再有 `context/message`、prompt-prefix 放置方式、稳定请求分隔符或提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文。
|
||||
|
||||
## 注入生命周期
|
||||
|
||||
提示词准入期间或轮次打开时,`inject()` 会将上下文暂存在 loop outbox 中。私有的 next-step 接受窗口在 `agent/prompt-submit` 前打开,并在 `turn/end` 前关闭,因此同一边界接受的 steering 和上下文会进入后续同一次请求,而 `turn/end` 监听器提交的晚到 steering 则成为排队提示词。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接受的上下文,只能出现在该批次所有有序结果之后。
|
||||
`inject()` 始终把上下文插入不会唤醒的 `next-step` inbox。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它。idle 驱动器会让它保持待处理,直至 `followup()` 或 `steer()` 提供可唤醒工作;在此之前,取消或 dispose(资源释放)可能将其丢弃。
|
||||
|
||||
在该窗口之外,`inject()` 会立即追加对应的 `user/message`。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型;持久化通过 `session/event` 观察这次追加。
|
||||
循环会先领取当前 next-step 批次,再运行 `agent/pre-step`,因此领取后到达的注入可能赶不上正在最终确定的请求,而由下一次边界领取。enter decision 返回的消息会在所属轮次内、消费它们的请求之前追加。在助手工具调用批次期间产生的上下文因此只会出现在该批次全部有序结果之后。
|
||||
|
||||
如果提示词准入被阻止或失败,调用方暂存的仅含上下文的批次会立即追加,且不产生轮次。steering 及与其一同暂存的上下文会留在 outbox 中,供后续获准提示词使用;取消或 dispose(资源释放)可能丢弃它们。钩子产生的 `additionalContexts` 属于被拒绝的准入决策,因此永远不会落入日志。
|
||||
如果 pre-step reject 或抛错,其已领取的注入上下文、steering 与排队提示词都会保持已删除,也不会追加返回批次。原子领取后插入的消息不受影响,继续保持待处理。
|
||||
|
||||
会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求核心执行事件、steering、助手输出和工具事件均受轮次边界约束。可合并扩展事件的关系由声明它们的插件拥有,而不是采用核心默认规则。持久化、恢复、resume、fork 和压缩会把合法的轮次间事件当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。
|
||||
loop 只会在轮次内从进入步骤的批次追加注入的 `user/message`。核心执行事件、steering、助手输出和工具事件仍受轮次边界约束;可合并扩展事件的关系由声明它们的插件拥有,而不是采用核心默认规则。
|
||||
|
||||
## 扩展点与调用方语义
|
||||
|
||||
`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。
|
||||
enter 分支的 `PreStepDecision.messages` 是拟议步骤的完整批次。waterfall(瀑布式事件)监听器调用 `next()` 委托时,会保留下游消息,除非有意替换;新增消息遵循 waterfall 的自然返回顺序。工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源。
|
||||
|
||||
调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。在 next-step 接受窗口之外,调用方执行 `inject(context)` 后再执行 `followup(prompt)` 时,会独立提交上下文;需要全有或全无语义的调用方应使用领域专用的准入包装层。
|
||||
调用方主动注入与当前步骤上下文刻意采用不同的时序。`inject()` 会加入下一个可用 pre-step,无法保证正在最终确定的请求会消费它。必须影响该请求的监听器在 `PreStepDecision.messages` 中返回上下文;下游 reject 或失败时,该上下文不会落入日志。
|
||||
|
||||
跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在接受窗口之外将其加入提示词准入决策,或在窗口期间将其注入到 steering 旁。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。
|
||||
跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在 idle 直接消息的 pre-step 中把快照与该消息一同返回,或在 running 轮次中先注入快照再唤醒 steering。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。
|
||||
|
||||
本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则。后续的[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)将同样的「轮次仅表示执行」语义应用于插件所属记录。
|
||||
|
||||
@@ -48,27 +48,27 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入:
|
||||
|
||||
**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。
|
||||
|
||||
**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。
|
||||
**保留独立的 `context/message` 会话事件。** 面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。
|
||||
|
||||
**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。
|
||||
**为空闲注入保留一次性轮次。** 这种方式会为空闲上下文提供即时持久边界,却让轮次计数与轮次观察方报告从未运行模型的工作。不会唤醒的上下文改为保持待处理,直至真实的可唤醒工作提供请求。
|
||||
|
||||
**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。
|
||||
|
||||
**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。
|
||||
**让提示词钩子调用 `inject()`,而不是返回消息。** 注入可能赶不上提示词正在最终确定的请求,也会逃逸下游对该 decision 的阻止。返回完整消息批次能让当前请求上下文继续受 waterfall 约束。
|
||||
|
||||
## 验证
|
||||
|
||||
- 投递输入与 steering 收件箱记录不包含附加上下文;`agent/inbox/enqueue` 只报告消息及其已解析的 queued 或 steering 放置方式。
|
||||
- 投递输入与 steering inbox 记录不包含附加上下文;`agent/inbox/inserted` 只报告插入消息,目标列表由持久 splice 保留。
|
||||
- `UserMessage` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的带标识且冻结的形状。
|
||||
- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。
|
||||
- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加一条带来源的 `user/message`。
|
||||
- 准入期间和活跃轮次中的注入会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。
|
||||
- 被阻止的提示词准入不会打开轮次,也不会追加提示词或钩子产生的额外上下文;仅有调用方上下文时会回退为空闲追加,而带 steering 的边界仍可重试。
|
||||
- 单元测试、持久化与 resume 测试、不变量测试、宿主/客户端队列测试和 TUI 覆盖会固定事件顺序、准入归属和重连分类。
|
||||
- idle 状态下的 `inject()` 会将一条不会唤醒的 next-step 消息排队;后续可唤醒投递开始 pre-step 处理前,日志不会追加任何内容。
|
||||
- collecting 和活跃轮次中的注入会在最近的后续 pre-step 边界领取,并位于完整工具结果批次之后、消费它的请求之前。
|
||||
- pre-step reject 或失败会丢弃其已领取批次;领取后插入的 inbox 工作继续保持待处理。
|
||||
- 单元测试、持久化与 resume 测试、不变量测试和 TUI 覆盖会固定事件顺序、领取归属和持久回放。
|
||||
|
||||
## 后果
|
||||
|
||||
- 一个表层事件可以合法位于轮次之外,因此持久化扫描、崩溃恢复、fork、压缩和会话查询需要区分执行封闭与会话历史。
|
||||
- idle 注入要到后续 pre-step 让它进入步骤后才会对模型可见,并可能因取消或 dispose 而丢失。
|
||||
- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器会保留这一顺序。
|
||||
- 在接受窗口之外,`inject()` 后跟一个被阻止的 `followup()` 会留下缺少预期直接提示词的上下文,除非调用方提供领域专用的准入归属。
|
||||
- 必须影响当前请求的上下文要从 `agent/pre-step` 返回;普通注入只保证由最近的后续边界交付。
|
||||
- 公共投递契约和收件箱记录保持精简:没有上下文附件、上下文放置元数据、提示词封套或重复的持久事件类型。
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: d19b256b834110d3cbb540cc0e039e61c693e98c
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1f88dd2065eaba7282ae3ed9e82d6872fdfb8497
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: f133fe15df8a75e8f43802282cca5ea1f50db3cc
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 0751295da8f6f4af77e061a7551087cb43452cb6
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)
|
||||
|
||||
> Scope: the client Agent scope (actx) and targeted events, the client/host materialization parity model, the blank-session bit and reuse (`connectWorkspace`), the per-session provisioning channel (`sessions.provide`), the read-only queue mirror (`session/queued`), and the host wire smalls that carry these capabilities (the summary `blank` column, the `host/session-added` frame field, and the `host/commands-changed` frame). The input state machine and the slash pipeline live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); the command business surfaces live in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md).
|
||||
> Scope: the client Agent scope (actx) and targeted events, the client/host materialization parity model, the blank-session bit and reuse (`connectWorkspace`), the per-session provisioning channel (`sessions.provide`), and the host wire smalls that carry these capabilities (the summary `blank` column, the `host/session-added` frame field, and the `host/commands-changed` frame). The input state machine and the slash pipeline live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); the command business surfaces live in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md).
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -101,7 +101,6 @@ Slot scope is the closed set `root | session-maybe | session`:
|
||||
|
||||
### The read-only queue mirror
|
||||
|
||||
- The MuxFrame `session/queued`: the Session holds a read-only inbox mirror (previews truncated; steering retired by source match). The host stamps the agent-loop's acceptance-time steering classification on live and replayed frames, so a reconnect baseline does not depend on replaying an earlier `turn/start`. Queue frames never enter history — pure stream state, cleared on reconnect and refilled from the new baseline; the never-instantiated window is buffered and replayed through the manager pendingBuffers.
|
||||
- Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue.
|
||||
|
||||
### Host wire smalls
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
[English](2026-07-25-web-client-session-scope-and-provide-channel.md) | 中文
|
||||
|
||||
> 范围:client Agent scope(actx)与定向事件、client/host 实体化对等模型、空会话 blank 位与复用(`connectWorkspace`)、per-session 供数通道(`sessions.provide`)、队列只读镜像(`session/queued`),以及承载这些能力的 host wire 小件(summary `blank` 列、`host/session-added` 帧字段、`host/commands-changed` 帧)。输入状态机与 slash 管线见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md);命令业务面见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。
|
||||
> 范围:client Agent scope(actx)与定向事件、client/host 实体化对等模型、空会话 blank 位与复用(`connectWorkspace`)、per-session 供数通道(`sessions.provide`),以及承载这些能力的 host wire 小件(summary `blank` 列、`host/session-added` 帧字段、`host/commands-changed` 帧)。输入状态机与 slash 管线见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md);命令业务面见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。
|
||||
|
||||
## 问题
|
||||
|
||||
@@ -101,7 +101,6 @@ slot scope 是闭集 `root | session-maybe | session`:
|
||||
|
||||
### 队列只读镜像
|
||||
|
||||
- MuxFrame `session/queued`:Session 持只读 inbox 镜像(预览截断、steering 按 source 匹配退休)。宿主会在实时和回放帧中标记 agent loop 接受消息时的 steering 分类,因此重连基线不依赖回放更早的 `turn/start`。queue 帧不进 history,纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。
|
||||
- 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。
|
||||
|
||||
### host wire 小件
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md
|
||||
2026-07-28-identified-immutable-message-values.md: 66c11cfddae2ce122e248032af6b0349dde8995e
|
||||
2026-07-28-identified-immutable-message-values.zh.md: c0ed3bd87b1dfc411896868a2e0f8014a6af0a22
|
||||
2026-07-28-identified-immutable-message-values.md: 89afc7cbd9b19d378f62529e353f21d47fb3e3cf
|
||||
2026-07-28-identified-immutable-message-values.zh.md: 39816b4968b5e1b8ed98a1d0b665cd5d418fa8ac
|
||||
|
||||
@@ -12,13 +12,13 @@ This made identity a routing side effect rather than a message invariant. Produc
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-llm` owns one `Message` value with required `id`, `role`, `content`, and `source`. `MessageId` is opaque and shared by user, assistant, and tool-result messages. A message receives its id at creation, before routing, prompt admission, durable append, or request projection. The same id survives every representation boundary.
|
||||
`@deepseek-ai/dsh-llm` owns one `Message` value with required `id`, `role`, `content`, and `source`. `MessageId` is opaque and shared by user, assistant, and tool-result messages. A message receives its id at creation, before inbox routing, claim, pre-step rewriting, durable append, or request projection. The same id survives every representation boundary.
|
||||
|
||||
`createMessage(input)` is the canonical role-generic creation boundary. It mints a `MessageId`, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. `createUserMessage({ content, source })` fixes the user role for prompt and context producers. `createAssistantMessage({ content, source })` fixes both the assistant role and the model source kind, so model-output producers supply only content and model provenance. All creation helpers exclude an input id so callers cannot accidentally present creation as import. `freezeMessage(message)` is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement.
|
||||
|
||||
The helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
|
||||
|
||||
The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Prompt admission receives that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id.
|
||||
The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Inbox claims and `agent/pre-step` receive that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id.
|
||||
|
||||
Durable message-producing events store complete messages. `user/message` stores its `UserMessage` directly; `assistant/message`, `tool/result`, and `steering/message` wrap their role-specialized message beside event-local position, usage, failure, or presentation facts. Session derivation returns those frozen values instead of reconstructing anonymous messages. Assistant assembly creates a model-sourced message when a response completes, and tool execution creates a tool-sourced message when a result is committed.
|
||||
|
||||
@@ -38,7 +38,7 @@ Any operation that changes only the representation of an existing semantic messa
|
||||
|
||||
Every message producer must choose creation or import explicitly, and tests construct complete values rather than partial content/source records. UUID generation moves outward to the first semantic creation point, so deterministic fixtures that provide an existing id use `freezeMessage()` instead of `createMessage()`.
|
||||
|
||||
Live inbox events, durable events, derived history, and model requests can correlate one message without content equality or envelope-specific ids. Prompt admission and UI attachment cleanup can compare `MessageId` before a turn exists. Deep freezing prevents a producer, hook, or observer from changing the value after identity is established.
|
||||
Live inbox events, durable events, derived history, and model requests can correlate one message without content equality or envelope-specific ids. Claim policy and UI attachment cleanup can compare `MessageId` before a turn exists. Deep freezing prevents a producer, hook, or observer from changing the value after identity is established.
|
||||
|
||||
The shared representation removes the old `UserMessageData`/`AgentMessage` split and folds provider provenance into typed message sources. Event envelopes still own facts that are not message semantics, such as turn and step position, token usage, internal tool failure identity, and presentation metadata.
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-llm` 持有唯一一种 `Message` 值,其 `id`、`role`、`content` 和 `source` 均为必填。`MessageId` 是不透明标识,由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id,早于路由、提示词准入、持久追加或请求投影。同一个 id 会跨越每个表示边界。
|
||||
`@deepseek-ai/dsh-llm` 持有唯一一种 `Message` 值,其 `id`、`role`、`content` 和 `source` 均为必填。`MessageId` 是不透明标识,由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id,早于 inbox 路由、领取、pre-step 改写、持久追加或请求投影。同一个 id 会跨越每个表示边界。
|
||||
|
||||
`createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将输入的角色、内容和来源与输入分离,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容和模型溯源信息。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把创建表达为导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与输入分离并深度冻结,不会生成替代标识。
|
||||
|
||||
这些辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整契约只依赖该词汇。`createToolResultMessage()` 与其他创建辅助函数同属此处:它使用同一个工具调用 id,将工具来源与确切的 user-role 工具结果块耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
|
||||
|
||||
`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。提示词准入会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。
|
||||
`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。inbox 领取和 `agent/pre-step` 会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。
|
||||
|
||||
产生持久消息的事件会存储完整消息。`user/message` 直接存储其 `UserMessage`;`assistant/message`、`tool/result` 和 `steering/message` 则将各自角色专用的消息与事件本地的位置、用量、失败或呈现事实包装在一起。会话派生会返回这些冻结值,而不是重建匿名消息。assistant 组装会在响应完成时创建模型来源的消息,工具执行会在提交结果时创建工具来源的消息。
|
||||
|
||||
@@ -38,7 +38,7 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则
|
||||
|
||||
每个消息生产方都必须显式选择创建或导入,测试也会构造完整值,而不是不完整的内容/来源记录。UUID 的生成会前移至最初的语义创建点,因此提供已有 id 的确定性 fixture 会使用 `freezeMessage()`,而不是 `createMessage()`。
|
||||
|
||||
实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。提示词准入和 UI 附件清理可以在轮次存在之前比较 `MessageId`。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。
|
||||
实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。领取策略和 UI 附件清理可以在轮次存在之前比较 `MessageId`。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。
|
||||
|
||||
共享表示移除了旧的 `UserMessageData`/`AgentMessage` 划分,并将提供方溯源信息纳入带类型的消息来源。事件封装仍持有不属于消息语义的事实,例如轮次与步骤位置、token 用量、内部工具失败标识和呈现元数据。
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
|
||||
2026-07-31-claimed-pre-step-inbox-lifecycle.md: a44f0cb906c9dd7a98b20a2f34fce902ac68a6d2
|
||||
2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 32fc435d3acef4d3cbd5edd57961cb098c1a1637
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Claim inbox input before one pre-step decision
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The loop previously split one step boundary across prompt preparation, prompt admission, and a serial step hook. Claimed input could be retained or discarded by an admission result, and live queue events carried shapes that duplicated durable inbox state. Plugins had to choose whether to mutate the inbox, rewrite a submitted batch, or append directly to session history, while observers could not rely on one exact ordering.
|
||||
|
||||
Occurrence-local inbox wrappers also duplicated the identity already carried by every `UserMessage`. They made insertion, editing, claiming, cancellation, reconnect projection, and step entry one combined protocol even though the append-only session already owned the durable queue projection.
|
||||
|
||||
## Decision
|
||||
|
||||
Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome. The loop then emits `agent/inbox/claimed { message, turn }` once per claimed message and awaits the single `agent/pre-step` waterfall with that exclusive batch and `{ turn, step, signal }`.
|
||||
|
||||
`PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no turn or step and leaves the claimed batch removed. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` seam.
|
||||
|
||||
The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, `update`, `remove`, and `splice` commit normalized splices. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. These live events add no placement, outcome, or batch fields.
|
||||
|
||||
The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change.
|
||||
|
||||
Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses that weaker timing deliberately: it prepends its current sourced context to `next-step`, replaces an exact still-pending predecessor, and does not alter the current pre-step decision.
|
||||
|
||||
The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep separate prepare and admit hooks.** This lets preparation mutate the inbox before claiming and admission rewrite afterward, but it creates two ordering surfaces for one boundary and makes cancellation ownership ambiguous.
|
||||
|
||||
**Let rejection requeue the claimed batch.** This preserves retry-like behavior but turns a veto into hidden queue mutation, duplicates later work unless every race is fenced, and prevents claim from being an atomic ownership transfer.
|
||||
|
||||
**Put placement and outcome on every live event.** Durable splices already own those facts. Repeating them on live notifications creates a second contract that can drift and is unnecessary for consumers holding the exact message identity.
|
||||
|
||||
## Verification
|
||||
|
||||
Agent-loop coverage pins claim-before-pre-step ordering, exact live event payloads, rejection before any turn, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, workspace-context delayed insertion and replacement, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new seam and payloads.
|
||||
|
||||
## Consequences
|
||||
|
||||
The loop has one awaited decision before each step and one ownership transfer for its input. Claimed messages never return to the inbox implicitly; later insertions remain independent. Live events are symmetrical with other inbox notifications without mirroring durable metadata, and plugins can choose exact-current-step rewriting or ordinary later inbox delivery explicitly.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note:在单一 pre-step 决策前领取 inbox 输入
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-claimed-pre-step-inbox-lifecycle.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
循环此前把一个步骤边界拆成提示词准备、提示词准入与串行 step hook。准入结果可以保留或丢弃已领取输入,实时队列事件还携带了与持久 inbox 状态重复的形状。插件不得不在修改 inbox、改写已提交批次与直接追加会话历史之间选择,而观察方无法依赖一套明确顺序。
|
||||
|
||||
单次出现专属的 inbox wrapper 也重复了每个 `UserMessage` 已有的标识。它把插入、编辑、领取、取消、重连投影与步骤进入合并成一套协议,但仅追加会话本就拥有持久队列投影。
|
||||
|
||||
## 决策
|
||||
|
||||
每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`。随后,循环针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并用该独占批次与 `{ turn, step, signal }` 等待唯一的 `agent/pre-step` waterfall(瀑布式事件)。
|
||||
|
||||
`PreStepDecision` 为 `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开轮次或步骤,并让已领取批次保持已删除。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare`、`agent/prompt-submit` 或 `agent/step` seam。
|
||||
|
||||
持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend`、`update`、`remove` 与 `splice` 会提交规范化 splice。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。这些实时事件不增加 placement、outcome 或批次字段。
|
||||
|
||||
两类事件表面服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced` 流;UI 编辑与移除经 `Inbox.splice()` 或其他 Inbox 变更方法进入,从而让同一投影记录所有变化。
|
||||
|
||||
必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 刻意采用较弱的时序:它把当前带来源上下文 prepend 到 `next-step`,替换仍在等待的确切前序消息,并且不修改当前 pre-step 决策。
|
||||
|
||||
已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现 wrapper 设计。现在由 `MessageId` 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留分离的 prepare 与 admit hook。** 这样准备阶段可以在领取前修改 inbox,准入阶段可以在领取后改写,但同一边界会出现两个顺序表面,取消归属也会变得模糊。
|
||||
|
||||
**reject 时把已领取批次重新入队。** 这看似保留重试行为,却会让否决隐式修改队列;若不为每个竞态加围栏,还会复制后续工作,并使 claim 无法成为原子所有权转移。
|
||||
|
||||
**在每个实时事件上携带 placement 与 outcome。** 持久 splice 已经拥有这些事实。实时通知重复它们会建立可能漂移的第二份契约,而持有确切消息标识的消费方并不需要这些字段。
|
||||
|
||||
## 验证
|
||||
|
||||
Agent-loop 覆盖固定先领取再 pre-step 的顺序、实时事件的确切载荷、打开任何轮次前的 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、workspace-context 的延后插入与替换、plan/goal/hook 行为、UI 清理、压缩、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 seam 与载荷。
|
||||
|
||||
## 后果
|
||||
|
||||
循环在每个步骤前只有一个需等待的决策,对输入也只有一次所有权转移。已领取消息不会隐式返回 inbox;后续插入保持独立。实时事件与其他 inbox 通知保持对称,但不镜像持久元数据;插件可以显式选择精确的当前步骤改写,或普通的后续 inbox 投递。
|
||||
Reference in New Issue
Block a user