diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml
index 8f8d74c91f..f119d8710a 100644
--- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md
-2026-09-02-in-history-system-prompt-replacement.md: 14181a801ebe016f80460536ae6e49e2b3da2ab4
-2026-09-02-in-history-system-prompt-replacement.zh.md: 5ea641c7c03318eca6f6d68a1e0eba84460a3013
+2026-09-02-in-history-system-prompt-replacement.md: 1b54cd5033eadc575156704f2025ea14c0cd17b4
+2026-09-02-in-history-system-prompt-replacement.zh.md: 401a81b88dc328d0bf41057a8c0315f3e65450e6
diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md
index 14181a801e..1b54cd5033 100644
--- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md
+++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md
@@ -20,23 +20,23 @@ For a model route that declares the capability, the loop appends a new `system/m
`dsh-llm` defines `SystemPromptUpdate = 'in-history'` and carries it as an optional sibling field, `systemPromptUpdate`, on `LlmResolvedModelInfo` and `PreparedLlmCall`; `normalizeModelInfo` rejects any other value with an `LlmError` whose code is `INVALID_MODEL_INFO`. The DeepSeek adapter's catalog model (`DeepSeekCatalogModel.systemPromptUpdate`, validated by zod at load) and the replay provider's `ReplayModelConfig.systemPromptUpdate` declare it per model; absence means the model needs message 0 rewritten. No default catalog entry declares it; a deployment enables it through the `models` list in `cordis.yml`, and every `dsh-llm-pi-ai` route keeps the replace behaviour.
-The loop records the mode in the session: `RequestContext.systemPromptUpdate` joins provider, model, and capacity as a `request/context` field, logged whenever any of them differs from the latest snapshot. The decision reads `session.requestContext()?.systemPromptUpdate`, so a resumed loop instance applies the mode of the route it last requested with, and a route change takes effect from the first request after it is logged.
+The loop records the mode in the session: `RequestContext.systemPromptUpdate` joins provider, model, and capacity as a `request/context` field, logged whenever any of them differs from the latest snapshot. Admission reads `PreparedLlmCall.systemPromptUpdate` from the actual call prepared after `agent/request`; the preceding snapshot is not an admission input. First requests, resumed sessions, route changes, and same-route capability changes therefore use the capability of the bound adapter that will serve the call.
### The decision rule
-`SystemPromptProjection.project(rendered, { inHistory, startsSeries })` in `packages/core/agent-loop/src/runtime-context.ts` scans the surviving `system/message` nodes of the current surface on every call. With no surviving system node it appends when the rendered prompt is non-empty; when the latest system node already holds the rendered text it emits nothing. Otherwise:
+`SystemPromptProjection.project(rendered, { inHistory, startsSeries })` in `packages/core/agent-loop/src/runtime-context.ts` scans the surviving `system/message` nodes of the current surface on every call. It returns ordered per-node commits. With no surviving system node it appends a non-empty rendering. Effective text comes from the latest non-empty system node, falling back to the head; dormant empty tails neither supply effective text nor need another empty replacement. For a non-empty rendering on an incapable route, consolidation applies even when the effective text is unchanged. Otherwise matching effective text emits nothing. The operations are:
| Route capability | Prefix state | Operation |
|---|---|---|
-| none | any | replace the latest surviving system node (node 0 when no later one exists) |
-| `in-history` | the current request series continues | append a new `system/message` before the step's `user/message` events; no `request/header` is logged |
+| none | non-empty rendering, any prefix state | log an empty replacement for each non-empty later system node, then rewrite the first system node with the rendering if needed |
+| `in-history` | the current request series continues | append a new `system/message` before the step's `user/message` events; the append alone needs no `request/header` |
| `in-history` | a new series starts and node 0 is the only surviving system node | replace node 0 with the current prompt |
| `in-history` | a new series starts and a later system node survives | append a new `system/message`; node 0 stays as it is |
| `in-history` | the rendered prompt is empty | replace the latest surviving system node with empty content, which projects to no message |
-`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed. A provider or model swap alone is not a series start for this rule: the changed prompt is appended, which costs nothing because the route change already misses the cache. The third row exists because a series start already costs the cache; folding the prompt back into node 0 keeps the history short. The fourth row exists because the surface has no delete operation: replacing node 0 while a later system node survives would leave the model reading the later, stale node as authoritative. In-history mode never rewrites node 0 while any later system node survives.
+`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. The third row exists because a series start already costs the cache; folding the prompt back into node 0 keeps the history short. The fourth row exists because the surface has no delete operation: replacing node 0 while a later system node survives would leave the model reading the later, stale node as authoritative. In-history mode never rewrites node 0 while any later system node survives.
-`preStep` in `packages/core/agent-loop/src/agent.ts` projects the prompt after the `agent/pre-step` waterfall, so a compaction that runs inside that waterfall (`compaction-basic` with `auto: true`) is visible: when it shadows every later system node, node 0 is the only survivor and the changed prompt replaces it. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary.
+The first attempt admits the prompt after assembly, an accepted `agent/pre-step` decision, `step/start`, the `agent/request` waterfall, and `prepareCall()`. A rejected or empty first input opens no step. Neither async request phase commits the pending system prompt or accepted users, and cancellation during either commits neither. Admission then synchronously reconciles the prompt, appends the accepted user batch, logs header/context as needed, and derives and freezes the request before streaming through the same prepared call. Compaction inside `agent/pre-step` (`compaction-basic` with `auto: true`) is visible to reconciliation: when it shadows every later system node, node 0 is the only survivor and the changed prompt replaces it. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary.
### Presentation and accounting
@@ -62,6 +62,8 @@ Web presents an appended in-history node at its own position. `SystemPromptNode`
**Project the prompt before the `agent/pre-step` waterfall.** The projection would not see a compaction performed inside the waterfall, so a just-appended node could be shadowed in the same step and the request would carry node 0's stale prompt as the only system message. Projecting after the waterfall keeps the rule a pure function of the surface the request is built from. Rejected.
+**Use the preceding request context for admission.** It describes the previous call, not the adapter bound after request middleware. It can select the wrong prompt representation on the first call, after resume, or after a route or capability change. Resolving before prompt and user commits also keeps cancellation from admitting unsent content. Rejected.
+
**Treat a provider or model swap as a series start.** It would fold the prompt into node 0 on every route change, matching the tools case. The header already records the change and the cache misses either way, so the extra rule bought nothing but a special case in the loop. Rejected.
**Report every surviving system node in the breakdown's system figure.** Summing the nodes shows the retained prompt versions' cost directly, but a compaction claim that shadows a superseded version would then have to be split between the system and message figures. Moving the superseded prompt into the message figure on append keeps each claim a plain subtraction. Rejected.
@@ -70,12 +72,13 @@ Web presents an appended in-history node at its own position. `SystemPromptNode`
- A prompt change on a capable route keeps the provider prefix cache; the appended node costs its own tokens on every request in the series until compaction shadows it. A deployment whose prompt changes on most steps is better served by moving that fact into runtime context.
- The request head is not the only place a system prompt can live: readers of "what did the model see" fold the surface and take the latest system node, and the breakdown's system figure follows the same rule.
-- A `request/context` snapshot varies with the declared mode as well as the route, and the loop's decision depends on the latest one.
+- A `request/context` snapshot records the prepared route and declared mode; it describes admission rather than deciding it. Incapable-route consolidation is logged per system node, preserving intervening user, assistant, and tool history.
- The model contract is recorded as supplied. If a released model narrows it — for example honouring only the latest system message within a bounded window — the rule needs a re-baseline trigger beyond series starts.
- A proxy that rewrites or reorders system messages breaks the replacement semantics silently; the real-API e2e's cache-hit assertion is the detector.
## Testing
+- `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` covers seven cases: capable-to-incapable routing with changed or unchanged text, incapable-to-capable routing, resumed-route admission, cancellation in request middleware or preparation, and a concurrent selection change while the prepared route stays bound. The loop suite covers 389 cases; focused coverage of `src/agent.ts` and `src/runtime-context.ts` reaches 100% for statements, branches, functions, and lines.
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on a continuing series, the re-baseline of a lone node 0 at a series start, the append at a series start with a surviving later node, the empty-prompt rewrite, and the replace-only behaviour without the capability.
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` pins the appended node under an inherited header with `request/context` carrying `systemPromptUpdate`, the series-start fold into node 0, the compaction-driven re-baseline, and the tool-schema change re-baseline under a `change` header that starts a series.
- `packages/llm/llm/tests/service.spec.ts`, `packages/llm/llm-deepseek/tests/adapter.spec.ts`, and `packages/test-support/llm-replay/tests/llm-replay.spec.ts` pin the declared mode on resolved model info and the load-time rejection of any other value.
diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md
index 5ea641c7c0..401a81b88d 100644
--- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md
@@ -20,23 +20,23 @@ Status: implemented
`dsh-llm` 定义 `SystemPromptUpdate = 'in-history'`,并把它作为可选的并列字段 `systemPromptUpdate` 放在 `LlmResolvedModelInfo` 与 `PreparedLlmCall` 上;`normalizeModelInfo` 用代码为 `INVALID_MODEL_INFO` 的 `LlmError` 拒绝任何其他值。DeepSeek 适配器的目录模型(`DeepSeekCatalogModel.systemPromptUpdate`,加载时由 zod 校验)与回放提供者的 `ReplayModelConfig.systemPromptUpdate` 逐模型声明它;缺省表示该模型需要重写消息 0。没有默认目录条目声明它;部署方通过 `cordis.yml` 的 `models` 列表启用,所有 `dsh-llm-pi-ai` 路由保持替换行为。
-循环把该模式记录进会话:`RequestContext.systemPromptUpdate` 与 provider、model、容量并列成为 `request/context` 的字段,其中任一项与最新快照不同时就记录一次。决策读取 `session.requestContext()?.systemPromptUpdate`,因此恢复后的循环实例沿用它上次请求所用路由的模式,路由变更从记录之后的第一个请求起生效。
+循环把该模式记录进会话:`RequestContext.systemPromptUpdate` 与 provider、model、容量并列成为 `request/context` 的字段,其中任一项与最新快照不同时就记录一次。准入读取 `agent/request` 之后实际准备调用的 `PreparedLlmCall.systemPromptUpdate`;先前快照不是准入输入。因此首次请求、恢复的会话、路由变更以及同一路由的能力变更,都使用将服务该调用的绑定适配器的能力。
### 决策规则
-`packages/core/agent-loop/src/runtime-context.ts` 中的 `SystemPromptProjection.project(rendered, { inHistory, startsSeries })` 每次调用都扫描当前 surface 上存活的 `system/message` 节点。没有存活的系统节点时,渲染后的提示词非空即追加;最新系统节点已持有渲染文本时不产生任何事件。其余情况:
+`packages/core/agent-loop/src/runtime-context.ts` 中的 `SystemPromptProjection.project(rendered, { inHistory, startsSeries })` 每次调用都扫描当前 surface 上存活的 `system/message` 节点。它返回有序的逐节点提交。没有存活的系统节点时,追加非空渲染文本。有效文本取自最新的非空系统节点,没有时回退到头节点;未生效的空尾节点既不提供有效文本,也无需再次以空内容替换。不具备能力的路由面对非空渲染文本时,即使有效文本未变也执行归并。除此之外,有效文本相同时不产生事件。具体操作如下:
| 路由能力 | 前缀状态 | 操作 |
|---|---|---|
-| 无 | 任意 | 替换最新存活的系统节点(没有更后节点时即第 0 号节点) |
-| `in-history` | 当前请求序列延续 | 在该步骤的 `user/message` 事件之前追加新的 `system/message`;不记录 `request/header` |
+| 无 | 非空渲染文本,任意前缀状态 | 为每个非空的后续系统节点记录空内容替换,随后按需用渲染文本重写首个系统节点 |
+| `in-history` | 当前请求序列延续 | 在该步骤的 `user/message` 事件之前追加新的 `system/message`;仅追加本身不需要记录 `request/header` |
| `in-history` | 新序列开始且第 0 号节点是唯一存活的系统节点 | 用当前提示词替换第 0 号节点 |
| `in-history` | 新序列开始且有更后的系统节点存活 | 追加新的 `system/message`;第 0 号节点保持原样 |
| `in-history` | 渲染后的提示词为空 | 用空内容替换最新存活的系统节点,该节点投影为无消息 |
-`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、可见工具 schema 集合发生了变化。仅 provider 或 model 切换对本规则不算序列开始:变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。第三行存在,是因为序列开始已经付出了缓存代价;把提示词折回第 0 号节点能让历史保持简短。第四行存在,是因为 surface 没有删除操作:在更后的系统节点仍存活时替换第 0 号节点,会让模型把更后、已过时的节点当作权威。历史内模式在任何更后的系统节点存活期间永不重写第 0 号节点。
+`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、可见工具 schema 集合发生了变化。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。第三行存在,是因为序列开始已经付出了缓存代价;把提示词折回第 0 号节点能让历史保持简短。第四行存在,是因为 surface 没有删除操作:在更后的系统节点仍存活时替换第 0 号节点,会让模型把更后、已过时的节点当作权威。历史内模式在任何更后的系统节点存活期间永不重写第 0 号节点。
-`packages/core/agent-loop/src/agent.ts` 中的 `preStep` 在 `agent/pre-step` waterfall 之后投影提示词,因此在该 waterfall 内运行的压缩(`auto: true` 的 `compaction-basic`)对决策可见:当它遮蔽了所有更后的系统节点时,第 0 号节点成为唯一存活者,变更后的提示词替换它。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。
+首次尝试在组装、被接纳的 `agent/pre-step` 决策、`step/start`、`agent/request` waterfall 与 `prepareCall()` 之后才接纳提示词。被拒绝或为空的首次输入不打开步骤。两个异步请求阶段都不提交待处理的系统提示词与已接纳用户消息,在任一阶段取消都不会提交这两者。准入随后同步协调提示词、追加已接纳用户批次、按需记录 header/context、派生并冻结请求,再通过同一个已准备调用发起流式请求。`agent/pre-step` 内部的压缩(`auto: true` 的 `compaction-basic`)对协调过程可见:当它遮蔽了所有更后的系统节点时,第 0 号节点成为唯一存活者,变更后的提示词替换它。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。
### 呈现与记账
@@ -62,6 +62,8 @@ Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携
**在 `agent/pre-step` waterfall 之前投影提示词。** 投影将看不到在该 waterfall 内执行的压缩,刚追加的节点可能在同一步骤内被遮蔽,请求就会把第 0 号节点的过时提示词作为唯一的系统消息携带。在 waterfall 之后投影让规则保持为构建请求所用 surface 的纯函数。被否决。
+**用先前的请求上下文决定准入。** 它描述上一次调用,而非请求中间件之后绑定的适配器。在首次调用、恢复之后、路由或能力变更之后,它可能选错提示词表示。在提交提示词与用户消息之前解析,还能防止取消时接纳未发送的内容。被否决。
+
**把 provider 或 model 切换视为序列开始。** 它会在每次路由变更时把提示词折回第 0 号节点,与 tools 的情形一致。header 已经记录了该变更,缓存无论如何都会未命中,因此这条额外规则除了在循环中多一个特例之外没有任何收益。被否决。
**在明细的系统数字中报告所有存活的系统节点。** 对节点求和能直接显示被保留提示词版本的开销,但遮蔽某个已被取代版本的压缩认领就必须在系统数字与消息数字之间拆分。在追加时把被取代的提示词移入消息数字,让每次认领保持为简单的减法。被否决。
@@ -70,12 +72,13 @@ Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携
- 具备能力的路由上的提示词变更保住提供方前缀缓存;追加的节点在该序列的每个请求上付出自身的 token 开销,直到压缩遮蔽它。提示词在多数步骤都变化的部署,更适合把那个事实移入运行时上下文。
- 请求头部不是系统提示词唯一可能的位置:「模型看到了什么」的读者折叠 surface 并取最新的系统节点,明细的系统数字遵循同一规则。
-- `request/context` 快照随声明的模式与路由一起变化,循环的决策取决于最新一条。
+- `request/context` 快照记录已准备的路由与声明模式;它描述准入结果,而不决定准入。不具备能力的路由逐系统节点记录归并,保留其间的用户、assistant 与工具历史。
- 模型约定按所提供的内容记录。若发布的模型收窄了约定——例如只在有界窗口内兑现最新的系统消息——规则需要序列开始之外的重新基线化触发条件。
- 重写或重排系统消息的代理会静默破坏替换语义;真实 API e2e 的缓存命中断言是探测器。
## Testing
+- `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` 覆盖七种情形:文本变化或未变时从具备能力切换到不具备能力的路由、反向路由切换、恢复时的路由准入、请求中间件或准备阶段取消,以及已准备路由保持绑定时并发选择发生变化。循环测试集覆盖 389 个用例;`src/agent.ts` 与 `src/runtime-context.ts` 的聚焦覆盖率在语句、分支、函数和行四项均达到 100%。
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住序列延续时的追加、序列开始处对孤立第 0 号节点的重新基线化、序列开始处在有更后存活节点时的追加、空提示词的重写,以及不具备能力时只做替换的行为。
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` 钉住继承 header 下追加的节点及携带 `systemPromptUpdate` 的 `request/context`、序列开始时折回第 0 号节点、由压缩驱动的重新基线化,以及在开启序列的 `change` header 下由工具 schema 变更驱动的重新基线化。
- `packages/llm/llm/tests/service.spec.ts`、`packages/llm/llm-deepseek/tests/adapter.spec.ts` 与 `packages/test-support/llm-replay/tests/llm-replay.spec.ts` 钉住已解析模型信息上声明的模式,以及加载时对任何其他值的拒绝。
diff --git a/docs/agent-lifecycle.i18n.yaml b/docs/agent-lifecycle.i18n.yaml
index c31e77c7e5..390847ed54 100644
--- a/docs/agent-lifecycle.i18n.yaml
+++ b/docs/agent-lifecycle.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/agent-lifecycle.md
-agent-lifecycle.md: 6ae1e8f76f4450228d75f30e0c2d0184af69d010
-agent-lifecycle.zh.md: 5e078a948192cbae13397481f561266bba3e4615
+agent-lifecycle.md: 60252473f604a587e946880e87029a3caff6cfb7
+agent-lifecycle.zh.md: 8c6ef54f4950b5828a1ce76e97424b6f24686ba9
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index 6ae1e8f76f..60252473f6 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -28,14 +28,19 @@ sequenceDiagram
Driver->>Prompt: system-prompt/assemble waterfall
Driver->>Hooks: agent/pre-step waterfall
Hooks-->>Driver: authoritative reject or enter(messages)
- alt proposed step rejected or pre-step failed
+ alt proposed step rejected, first batch empty, or pre-step failed
Driver-->>Driver: claimed batch stays removed, the open turn spends no step
else enter proposed step
- Note over Driver,Prompt: project the rendered prompt against the surviving system/message nodes
Driver->>Session: step/start
- Driver->>Session: system/message when the rendered prompt changed: replace the latest system node, or append on an in-history route
+ Driver->>Hooks: agent/request waterfall
+ Driver->>LLM: prepareCall(config, signal)
+ Note over Driver,LLM: cancellation during either async phase commits neither system nor users
+ Note over Driver,Session: synchronous admission using the prepared call capability
+ Driver->>Session: system/message ordered per-node reconciliation
Driver->>Session: user/message per entered message
- Driver->>LLM: agent/request waterfall, then llm/stream waterfall
+ Driver->>Session: request/header and request/context as needed
+ Driver->>Driver: derive and freeze request from the log
+ Driver->>LLM: bound prepared call through llm/stream waterfall
LLM-->>Driver: StreamChunk*
Driver-->>SDK: agent/assistant-stream chunk*
alt final adapter or terminal in-band request failure
diff --git a/docs/agent-lifecycle.zh.md b/docs/agent-lifecycle.zh.md
index 5e078a9481..8c6ef54f49 100644
--- a/docs/agent-lifecycle.zh.md
+++ b/docs/agent-lifecycle.zh.md
@@ -30,14 +30,19 @@ sequenceDiagram
Driver->>Prompt: system-prompt/assemble waterfall
Driver->>Hooks: agent/pre-step waterfall
Hooks-->>Driver: authoritative reject or enter(messages)
- alt proposed step rejected or pre-step failed
+ alt proposed step rejected, first batch empty, or pre-step failed
Driver-->>Driver: claimed batch stays removed, the open turn spends no step
else enter proposed step
- Note over Driver,Prompt: project the rendered prompt against the surviving system/message nodes
Driver->>Session: step/start
- Driver->>Session: system/message when the rendered prompt changed: replace the latest system node, or append on an in-history route
+ Driver->>Hooks: agent/request waterfall
+ Driver->>LLM: prepareCall(config, signal)
+ Note over Driver,LLM: cancellation during either async phase commits neither system nor users
+ Note over Driver,Session: synchronous admission using the prepared call capability
+ Driver->>Session: system/message ordered per-node reconciliation
Driver->>Session: user/message per entered message
- Driver->>LLM: agent/request waterfall, then llm/stream waterfall
+ Driver->>Session: request/header and request/context as needed
+ Driver->>Driver: derive and freeze request from the log
+ Driver->>LLM: bound prepared call through llm/stream waterfall
LLM-->>Driver: StreamChunk*
Driver-->>SDK: agent/assistant-stream chunk*
alt final adapter or terminal in-band request failure
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index a38b015958..01080d6b7f 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: 83495210e4b3da04e7921abbc24a94fb070e4439
-architecture.zh.md: 4fb29be42aee34a69604caa9e6b6b0b041e3a3e0
+architecture.md: 5f0a1d0de2a998ec4abd2b172ff33e908d0cfdb9
+architecture.zh.md: 2a882ff816fa916d4cb1374894e37e645db5ce80
diff --git a/docs/architecture.md b/docs/architecture.md
index 83495210e4..5f0a1d0de2 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -81,12 +81,12 @@ turn/start
assemble prompt sections + tool schemas; project runtime context
-> agent/pre-step reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
- project the rendered prompt against the surviving system/message nodes
step/start
- append a changed prompt as system/message (replace the latest system node, or append on an in-history route)
- append entered messages as user/message
- derive model history from the log
- agent/request -> llm/stream -> agent/assistant-stream start
+ agent/request -> prepareCall (cancellation commits neither system nor users)
+ reconcile system/message using the prepared call capability
+ append entered messages as user/message; log request/header and request/context as needed
+ derive and freeze model history from the log
+ stream the bound prepared call -> llm/stream -> agent/assistant-stream start
agent/assistant-stream chunk*
assistant/message | assistant/attempt -> agent/assistant-stream end
tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
@@ -100,7 +100,7 @@ turn/end
Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
-`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. The rendered prompt is a `system/message` event on the surface: the first step appends it as surface node 0 before its user messages even when empty (no wire message), a later step whose rendered text differs replaces the latest surviving system node or, on a route whose `request/context` declares `systemPromptUpdate: 'in-history'` inside a continuing request series, appends the new text after the cached history, and the request carries no separate `system` field ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
+`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. The loop synchronously reconciles the prompt, appends users, logs header/context as needed, and derives and freezes the request before streaming the bound call. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: capable routes can append updates after the cached prefix; incapable routes consolidate non-empty prompt text at the first system node ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 4fb29be42a..2a882ff816 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -85,12 +85,12 @@ turn/start
assemble prompt sections + tool schemas; project runtime context
-> agent/pre-step reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
- project the rendered prompt against the surviving system/message nodes
step/start
- append a changed prompt as system/message (replace the latest system node, or append on an in-history route)
- append entered messages as user/message
- derive model history from the log
- agent/request -> llm/stream -> agent/assistant-stream start
+ agent/request -> prepareCall (cancellation commits neither system nor users)
+ reconcile system/message using the prepared call capability
+ append entered messages as user/message; log request/header and request/context as needed
+ derive and freeze model history from the log
+ stream the bound prepared call -> llm/stream -> agent/assistant-stream start
agent/assistant-stream chunk*
assistant/message | assistant/attempt -> agent/assistant-stream end
tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
@@ -104,7 +104,7 @@ turn/end
输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
-`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。渲染后的提示词是 surface 上的一个 `system/message` 事件:首个步骤在其用户消息之前把它作为 surface 第 0 号节点追加,即使提示词为空(不产生协议消息),渲染文本不同的后续步骤替换最新存活的系统节点,或者在 `request/context` 声明 `systemPromptUpdate: 'in-history'` 的路由上、同一请求序列延续期间把新文本追加到已缓存历史之后,请求不携带单独的 `system` 字段([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
+`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。循环同步协调提示词、追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。首个接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:具备能力的路由可在缓存前缀之后追加更新;不具备能力的路由将非空提示词文本归并到首个系统节点([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index 1570e0695c..1b6909d979 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 283205cdb9ce677acf2d04566173aaf4da3df05c
-event-producer-consumer.zh.md: e970526f63bb409901fe472aa0044c9fd2ffdcc5
+event-producer-consumer.md: 2f30b1ddeae04435939f41d95ab2f43a2750bc80
+event-producer-consumer.zh.md: dc09a5ee759bd1c49100cece28e93c4b704c8e40
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 283205cdb9..2f30b1ddea 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -9,19 +9,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:319`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:349`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:293`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index e970526f63..dc09a5ee75 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -11,19 +11,19 @@
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:319`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:349`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:293`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml
index c63dd0cdcd..77c9f2c7eb 100644
--- a/docs/subsystems/llm-streaming.i18n.yaml
+++ b/docs/subsystems/llm-streaming.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
-llm-streaming.md: 6a08c2055a0ad8ec575ba9e7ffe61272d863da80
-llm-streaming.zh.md: 47914c7402052aca8087ac0ce86a6e368fd42cec
+llm-streaming.md: b3a68c1fc894f8a99876e3ce01a9496bff58c0e6
+llm-streaming.zh.md: 6f8760154b983e845257434692494f7304507fd6
diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md
index 6a08c2055a..b3a68c1fc8 100644
--- a/docs/subsystems/llm-streaming.md
+++ b/docs/subsystems/llm-streaming.md
@@ -701,9 +701,9 @@ interface LlmDiscoveredModel {
The loop builds each request from logged state. `EpochHeader` records call config, marks the fields supplied by adapter defaults, and records the authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. The rendered prompt is derived history — the `system/message` at surface node 0, plus any later system node an `in-history` route appended — so the header and the derived history together make the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
-`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
+`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. On step admission, this waterfall and preparation run after assembly and `step/start` but before the system prompt and accepted user batch are committed; cancellation during either commits neither. The prepared capability governs prompt reconciliation, and the call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
-On the wire, a loop-built request is the derived history alone: the rendered prompt travels as the leading `system`-role message (surface node 0, a `system/message` event) and, on a route whose `request/context` declares `systemPromptUpdate: 'in-history'`, a changed prompt may follow the cached history as a later `system`-role message that the model reads as the effective prompt; the request's `system` field is unset — `GenerateOptions.system` serves direct one-shot callers such as title providers. The logged request ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request and rejects a loop request carrying a `system` field.
+On the wire, a loop-built request is the derived history alone: the rendered prompt travels as the leading `system`-role message (surface node 0, a `system/message` event) and, when the prepared call declares `systemPromptUpdate: 'in-history'`, a changed prompt may follow the cached history as a later `system`-role message that the model reads as the effective prompt; the request's `system` field is unset — `GenerateOptions.system` serves direct one-shot callers such as title providers. The logged request ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request and rejects a loop request carrying a `system` field.
FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution).
diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md
index 47914c7402..6f8760154b 100644
--- a/docs/subsystems/llm-streaming.zh.md
+++ b/docs/subsystems/llm-streaming.zh.md
@@ -707,9 +707,9 @@ interface LlmDiscoveredModel {
循环从已记录状态构建每个请求。`EpochHeader` 记录调用配置,标记由适配器默认值提供的字段,并通过完整的 `request/header` 快照记录权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。渲染后的提示词是派生历史——surface 第 0 号节点上的 `system/message`,加上 `in-history` 路由追加的任何后续系统节点——因此请求头与派生历史共同使请求可由会话日志重建。见 [session.md](session.zh.md#the-request-header-event-requestheader) 与[可重建性 Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。
-`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
+`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。步骤准入时,该 waterfall 与准备过程在组装和 `step/start` 之后、系统提示词与已接纳用户批次提交之前运行;在任一阶段取消都不会提交这两者。已准备调用的能力决定提示词协调,调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
-在协议中,循环构建的请求只有派生历史:渲染后的提示词作为开头的 `system` 角色消息(surface 第 0 号节点,即一个 `system/message` 事件)传输,并且在 `request/context` 声明 `systemPromptUpdate: 'in-history'` 的路由上,变化后的提示词可以作为后续的 `system` 角色消息跟在已缓存历史之后,由模型读作有效提示词;请求的 `system` 字段不设置——`GenerateOptions.system` 服务于标题提供方等直接单次调用方。已记录的请求会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。开发不变式针对每个循环构建的请求精确重算此等式,并拒绝携带 `system` 字段的循环请求。
+在协议中,循环构建的请求只有派生历史:渲染后的提示词作为开头的 `system` 角色消息(surface 第 0 号节点,即一个 `system/message` 事件)传输,并且当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,变化后的提示词可以作为后续的 `system` 角色消息跟在已缓存历史之后,由模型读作有效提示词;请求的 `system` 字段不设置——`GenerateOptions.system` 服务于标题提供方等直接单次调用方。已记录的请求会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。开发不变式针对每个循环构建的请求精确重算此等式,并拒绝携带 `system` 字段的循环请求。
FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。
diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml
index 0b633b1b25..f3f25452f4 100644
--- a/docs/subsystems/system-prompt.i18n.yaml
+++ b/docs/subsystems/system-prompt.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md
-system-prompt.md: 667501d58065c686c09ea2f0807473168bd1f886
-system-prompt.zh.md: ea3c06d6973183d47869e89ded71b90ed67b7351
+system-prompt.md: b48b5144c66a42d711f50265450f9e73382b57ec
+system-prompt.zh.md: e569bef629a00866867dd3520eafdbd5b19047c7
diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md
index 667501d580..b48b5144c6 100644
--- a/docs/subsystems/system-prompt.md
+++ b/docs/subsystems/system-prompt.md
@@ -39,7 +39,7 @@ interface ToolProviderResult {
## Prompt sections
-`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. Sections sort by ascending order and then code-unit name; repository contributors resolve the service-owned named allocation through `getSectionOrder()`. Runtime-context contributors resolve their independent allocation through `getContextOrder()`. One effective `complete` section becomes the sole prompt section after cooperative assembly. agent-loop renders the assembled sections with `renderPrompt` and commits the text as a `system/message` surface node — appended as surface node 0 on the first step, then replaced in place when the rendered text changes or, on a route whose `request/context` declares `systemPromptUpdate: 'in-history'`, appended after the cached history — so the prompt reaches the model as a message of derived history rather than as a request field ([decision](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../../packages/core/agent-loop/README.md#understand-the-implementation)).
+`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. Sections sort by ascending order and then code-unit name; repository contributors resolve the service-owned named allocation through `getSectionOrder()`. Runtime-context contributors resolve their independent allocation through `getContextOrder()`. One effective `complete` section becomes the sole prompt section after cooperative assembly. agent-loop renders the assembled sections with `renderPrompt` and commits the text as a `system/message` surface node — appended as surface node 0 on the first step, then replaced in place when the rendered text changes or, when the prepared call declares `systemPromptUpdate: 'in-history'`, appended after the cached history — so the prompt reaches the model as a message of derived history rather than as a request field ([decision](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../../packages/core/agent-loop/README.md#understand-the-implementation)).
```ts type-equiv
/** One contributed section of the system prompt (registry input). */
diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md
index ea3c06d697..e569bef629 100644
--- a/docs/subsystems/system-prompt.zh.md
+++ b/docs/subsystems/system-prompt.zh.md
@@ -39,7 +39,7 @@ interface ToolProviderResult {
## 提示词段落
-`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。各段先按 order 升序排列,再按名称的代码单元顺序排列;仓库贡献方通过 `getSectionOrder()` 解析服务持有的具名分配。Runtime-context 贡献方通过 `getContextOrder()` 解析独立分配。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。agent loop(智能体循环)用 `renderPrompt` 渲染组装后的各段,并把文本作为 `system/message` surface 节点提交——首个步骤作为 surface 第 0 号节点追加,之后在渲染文本变化时原地替换,或者在 `request/context` 声明 `systemPromptUpdate: 'in-history'` 的路由上追加到已缓存历史之后——因此提示词作为派生历史中的消息而不是请求字段到达模型([决策](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
+`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。各段先按 order 升序排列,再按名称的代码单元顺序排列;仓库贡献方通过 `getSectionOrder()` 解析服务持有的具名分配。Runtime-context 贡献方通过 `getContextOrder()` 解析独立分配。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。agent loop(智能体循环)用 `renderPrompt` 渲染组装后的各段,并把文本作为 `system/message` surface 节点提交——首个步骤作为 surface 第 0 号节点追加,之后在渲染文本变化时原地替换,或者当已准备调用声明 `systemPromptUpdate: 'in-history'` 时追加到已缓存历史之后——因此提示词作为派生历史中的消息而不是请求字段到达模型([决策](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
```ts type-equiv
/** One contributed section of the system prompt (registry input). */
diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml
index a7fe3f315a..a740e977f3 100644
--- a/packages/core/agent-loop/README.i18n.yaml
+++ b/packages/core/agent-loop/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
-README.md: 09854f2ce8fb990902a62b6e07537d401e1c3cda
-README.zh.md: 5afdfacff754f918835bd1b31fbbdcbd1b9b94e6
+README.md: c1cb4fe7f7bcf06f47272ad5a5d3894a6121bcf4
+README.zh.md: d387e44d8eef6693232512c2d84372f1e5c260a2
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index 09854f2ce8..c1cb4fe7f7 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -111,9 +111,9 @@ The loop is the production acquisition point for session write handles. When `ct
### Turn and step flow
-The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. The driver assembles and renders the prompt, projects runtime context, runs `agent/pre-step`, which decides what enters the step, and then projects the rendered text against the surviving `system/message` nodes (`SystemPromptProjection` in `runtime-context.ts`), so a compaction run by a pre-step listener is visible to the projection. An entered decision appends the pending `system/message` right after `step/start` and then its complete `user/message` batch before the driver can claim again, so log order is wire order; a rejected decision appends none. The request is `header.config`, `deriveMessages()`, and `header.tools`; the request carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
+The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. The driver assembles the prompt and tools, projects runtime context, and runs `agent/pre-step`. A rejected decision or empty first batch opens no step. After acceptance, `step/start` precedes the `agent/request` waterfall and `prepareCall()`; neither async phase sees the pending system prompt or accepted users committed to history, and cancellation during either commits neither. For the first attempt, the loop then synchronously reconciles the rendered prompt against the surviving `system/message` nodes using the prepared call capability, appends the accepted `user/message` batch, logs the header and context as needed, and derives and freezes the request before streaming through that bound prepared call. Pre-step compaction is visible to reconciliation, and system changes precede users in both log and wire order. The request is `header.config`, `deriveMessages()`, and `header.tools`; it carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
-The prompt decision rule: no system node on the surface appends even an empty prompt (reserving node 0 without a wire message); a latest system node whose text equals the rendered prompt yields nothing; when the latest `request/context` snapshot declares `systemPromptUpdate: 'in-history'`, a non-empty changed prompt is appended after the current history unless the step starts a new request series while only one system node survives; otherwise the changed prompt replaces the latest surviving system node in place. A step starts a series when the pre-step decision declares `startsRequestSeries`, when the surface replace generation changed since the last request (compaction or any replacement), or when the visible tool-schema set changed; a resumed session and a provider or model swap alone continue the series, so a prompt that changed across a restart is appended on an in-history route. A compaction that shadows every later system node leaves node 0 as the only survivor, which the changed prompt then replaces.
+Prompt admission uses the actual `prepareCall()` result, not the preceding `request/context`. With no system node, even an empty prompt is appended (reserving node 0 without a wire message). On an incapable route, a non-empty rendering is consolidated at the first system node: each non-empty later system node receives a logged per-node empty replacement, then the head is rewritten if needed. Dormant empty tails need no replacement and do not determine the effective text. On an `in-history` route, an unchanged effective prompt produces no event; a non-empty change appends unless a new request series starts with only one surviving system node, in which case that node is replaced. An empty rendering replaces the latest effective system node with empty content. A step starts a series when the pre-step decision declares `startsRequestSeries`, when the surface replace generation changed since the last request (compaction or any replacement), or when visible tool schemas changed. Resume and a provider or model swap alone continue the series; the prepared route still governs admission. Compaction that shadows all later system nodes leaves node 0 available for replacement at a series start.
### Failure and cancellation
@@ -152,7 +152,7 @@ System text and schemas are paid again on every step, and on an `in-history` rou
#### KV Cache effect
-Append-only only while system text, schemas, and earlier history remain byte-identical under the same provider and model route. An unchanged rendered prompt leaves the system nodes in place and keeps the cached prefix. A prompt change that replaces a system node in place makes the request differ from that node's first token — in full when the node is node 0 — so the provider prefix cache misses from there; on a route whose `request/context` declares `systemPromptUpdate: 'in-history'`, a prompt change inside a continuing request series is appended after the cached history, so the prefix through that history stays reusable. A schema or composition change invalidates reuse from the first altered request token.
+Append-only only while system text, schemas, and earlier history remain byte-identical under the same provider and model route. An unchanged rendered prompt keeps the cached prefix unless an incapable route must consolidate retained in-history system nodes. A prompt change that replaces a system node in place makes the request differ from that node's first token — in full when the node is node 0 — so the provider prefix cache misses from there; when the prepared call declares `systemPromptUpdate: 'in-history'`, a prompt change inside a continuing request series is appended after the cached history, so the prefix through that history stays reusable. A schema or composition change invalidates reuse from the first altered request token.
### Retained message history
diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md
index 5afdfacff7..d387e44d8e 100644
--- a/packages/core/agent-loop/README.zh.md
+++ b/packages/core/agent-loop/README.zh.md
@@ -111,9 +111,9 @@ const handle = await ctx.agents.create({
### 轮次与步骤流程
-驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。驱动器组装并渲染提示词、投影 runtime 上下文、运行决定什么进入该步骤的 `agent/pre-step`,然后再把渲染文本与存活的 `system/message` 节点比对投影(`runtime-context.ts` 中的 `SystemPromptProjection`),因此 pre-step 监听器执行的压缩对投影可见。进入步骤的决定会紧接 `step/start` 之后追加待提交的 `system/message`,随后在驱动器再次领取消息前追加完整的 `user/message` 批次,因此日志顺序即协议顺序;被拒绝的决定则不追加任何消息。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成;请求不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
+驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。驱动器组装提示词与工具、投影 runtime 上下文,并运行 `agent/pre-step`。被拒绝的决定或空的首批输入不打开步骤。接纳后,先记录 `step/start`,再运行 `agent/request` waterfall 与 `prepareCall()`;这两个异步阶段都看不到待提交的系统提示词与已接纳用户消息进入历史,在任一阶段取消都不会提交这两者。首次尝试时,循环随后依据已准备调用的能力,同步将渲染后的提示词与存活的 `system/message` 节点协调一致、追加已接纳的 `user/message` 批次、按需记录 header 与 context,再派生并冻结请求,通过该绑定的已准备调用发起流式请求。pre-step 压缩对协调过程可见,系统变更在日志与协议顺序中都先于用户消息。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成,不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
-提示词决策规则:surface 上没有系统节点时,即使提示词为空也追加(预留第 0 号节点,但不产生协议消息);最新系统节点的文本等于渲染后的提示词时,不产生任何事件;当最新的 `request/context` 快照声明 `systemPromptUpdate: 'in-history'` 时,变化后的非空提示词追加到当前历史之后,除非该步骤开启新的请求序列且只剩一个系统节点存活;其余情况下,变化后的提示词原地替换最新存活的系统节点。以下情形开启新序列:pre-step 决定声明 `startsRequestSeries`、surface 替换 generation 自上次请求以来发生变化(压缩或任何替换)、可见工具 schema 集合发生变化;恢复的会话以及单纯的提供方或模型切换都延续序列,因此在 `in-history` 路由上,跨重启发生变化的提示词会被追加。遮蔽了全部后续系统节点的压缩只留下第 0 号节点,变化后的提示词随即替换它。
+提示词准入依据实际的 `prepareCall()` 结果,而非先前的 `request/context`。没有系统节点时,即使提示词为空也追加(预留第 0 号节点,但不产生协议消息)。在不具备能力的路由上,非空渲染文本归并到首个系统节点:每个非空的后续系统节点分别收到有日志记录的空内容替换,随后按需重写头节点。未生效的空尾节点无需替换,也不决定有效文本。在 `in-history` 路由上,有效提示词不变时不产生事件;非空变更会追加,除非新请求序列开始且只剩一个系统节点存活,此时替换该节点。空渲染文本以空内容替换最新的有效系统节点。以下情况开启序列:pre-step 决定声明 `startsRequestSeries`、surface 替换 generation 自上次请求以来发生变化(压缩或任何替换)、可见工具 schema 变化。恢复与单纯的提供方或模型切换都延续序列;准入仍由已准备的路由决定。压缩遮蔽所有后续系统节点后,第 0 号节点即可在序列开始时被替换。
### 失败与取消
@@ -152,7 +152,7 @@ const handle = await ctx.agents.create({
#### KV Cache 影响
-只有在同一提供方与模型路由下,且系统文本、schema 与此前历史都保持逐字节一致时,请求才保持仅追加。渲染后的提示词未变时,系统节点保持原位,缓存前缀得以保留。原地替换某个系统节点的提示词变更会使请求从该节点的第一个 token 起就不同——该节点是第 0 号节点时则整个请求都不同——因此提供方前缀缓存从那里开始未命中;在 `request/context` 声明 `systemPromptUpdate: 'in-history'` 的路由上,同一请求序列延续期间的提示词变更会追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用。schema 或组合变更则从第一个改变的请求 token 起使复用失效。
+只有在同一提供方与模型路由下,且系统文本、schema 与此前历史都保持逐字节一致时,请求才保持仅追加。渲染后的提示词未变时,缓存前缀得以保留,除非不具备能力的路由必须归并保留的历史内系统节点。原地替换某个系统节点的提示词变更会使请求从该节点的第一个 token 起就不同——该节点是第 0 号节点时则整个请求都不同——因此提供方前缀缓存从那里开始未命中;当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,同一请求序列延续期间的提示词变更会追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用。schema 或组合变更则从第一个改变的请求 token 起使复用失效。
### 保留的消息历史
diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json
index c5da4c8ee9..2e1d0f54ae 100644
--- a/packages/core/agent-loop/package.json
+++ b/packages/core/agent-loop/package.json
@@ -51,6 +51,7 @@
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index 263d10e917..85685d6ab6 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -16,7 +16,7 @@ import type {
RequestErrorAction,
} from '@deepseek-ai/dsh-agent'
import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
-import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
+import type { GenerateOptions, LlmCallConfig, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import {
LlmError,
createAssistantMessage,
@@ -35,7 +35,6 @@ import type { Context } from '@deepseek-ai/cordis'
import { RuntimeContextProjection } from './runtime-context.ts'
import { AssistantStreamAttempt } from './assistant-stream.ts'
import { SystemPromptProjection } from './runtime-context.ts'
-import type { SystemPromptCommit } from './runtime-context.ts'
import { executeToolCalls } from './tool-calls.ts'
type Phase =
@@ -57,8 +56,6 @@ type PreparedStep =
messages: UserMessage[]
startsRequestSeries?: true
assembly: PromptAssembly
- /** The system-prompt surface operation this step commits, when the rendered prompt changed. */
- systemPrompt?: SystemPromptCommit
}
/** Remove adapter-derived values before plugins propose the next request config. */
@@ -258,16 +255,7 @@ export class ReactLoopAgent implements Agent {
)
signal.throwIfAborted()
if (decision.kind === 'reject') return decision
- // Decided after the waterfall: a listener may have compacted the surface or
- // declared a series start, both of which change where the prompt goes.
- const systemPrompt = this.systemPrompt.project(renderPrompt(assembly), {
- inHistory: this.session.requestContext()?.systemPromptUpdate === 'in-history',
- startsSeries: decision.startsRequestSeries === true
- || this.requestSurfaceGeneration !== undefined
- && this.requestSurfaceGeneration !== this.session.surface.replaceGeneration
- || this.toolsChanged(assembly.tools),
- })
- return { ...decision, assembly, ...systemPrompt === undefined ? {} : { systemPrompt } }
+ return { ...decision, assembly }
}
/** Whether the assembled tool schemas differ from the logged request header's. */
@@ -314,17 +302,9 @@ export class ReactLoopAgent implements Agent {
this.session.append('step/start', { turn, step })
phase.step = step
try {
- // The system node precedes the step's user messages so log order is wire order.
- if (decision.systemPrompt !== undefined) {
- const { message, intent } = decision.systemPrompt
- this.session.append('system/message', { turn, step, message }, intent)
- }
- for (const message of decision.messages) {
- this.session.append('user/message', message, { surfaceOp: 'append' })
- }
// max-tokens is sticky: once any step hits the ceiling, later steps
// that complete normally must not downgrade the turn outcome.
- const stepEnd = await this.step(decision.assembly, decision.startsRequestSeries === true)
+ const stepEnd = await this.step(decision)
// max-tokens stays sticky: a later completed step must not
// downgrade the turn outcome.
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
@@ -369,24 +349,34 @@ export class ReactLoopAgent implements Agent {
return true
}
- private async step(assembly: PromptAssembly, startsRequestSeries: boolean): Promise {
+ private async step(decision: Extract): Promise {
/* v8 ignore next -- private callers establish the running phase before executing a step */
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
const { turn, step, abort: { signal } } = this.phase
signal.throwIfAborted()
+ const { assembly } = decision
+ let firstAttempt = true
while (true) {
- const surfaceGeneration = this.session.surface.replaceGeneration
- const { request, preparedCall } = await this.buildRequest(
- turn,
- step,
- assembly.tools,
- this.session.deriveMessages(),
- startsRequestSeries,
- surfaceGeneration,
- signal,
- )
- startsRequestSeries = false
+ const { config, preparedCall } = await this.prepareRequest(turn, step, signal)
+ const startsRequestSeries = firstAttempt && decision.startsRequestSeries === true
+ if (firstAttempt) {
+ const commits = this.systemPrompt.project(renderPrompt(assembly), {
+ inHistory: preparedCall?.systemPromptUpdate === 'in-history',
+ startsSeries: startsRequestSeries
+ || this.requestSurfaceGeneration !== undefined
+ && this.requestSurfaceGeneration !== this.session.surface.replaceGeneration
+ || this.toolsChanged(assembly.tools),
+ })
+ for (const { message, intent } of commits) {
+ this.session.append('system/message', { turn, step, message }, intent)
+ }
+ for (const message of decision.messages) {
+ this.session.append('user/message', message, { surfaceOp: 'append' })
+ }
+ }
+ firstAttempt = false
+ const request = this.buildRequest(config, preparedCall, assembly.tools, startsRequestSeries, signal)
const live = new AssistantStreamAttempt(
this.session.id,
++this.assistantAttemptCounter,
@@ -507,19 +497,12 @@ export class ReactLoopAgent implements Agent {
}
}
- /**
- * Compose one frozen request and bind it to the adapter registration that
- * resolved its exact-model defaults.
- */
- private async buildRequest(
+ /** Resolve request config and bind its adapter before admitting model-visible input. */
+ private async prepareRequest(
turn: number,
step: number,
- tools: GenerateOptions['tools'] & object,
- boundaryMessages: Message[],
- startsRequestSeries: boolean,
- surfaceGeneration: number,
signal: AbortSignal,
- ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
+ ): Promise<{ config: LlmCallConfig; preparedCall?: PreparedLlmCall }> {
const { session } = this
// A loop instance starts from its declared route, restoring only an explicit
@@ -563,7 +546,19 @@ export class ReactLoopAgent implements Agent {
config = proposedConfig
}
signal.throwIfAborted()
+ return { config, ...preparedCall === undefined ? {} : { preparedCall } }
+ }
+ /** Log the resolved envelope and derive a frozen request from the admitted surface. */
+ private buildRequest(
+ config: LlmCallConfig,
+ preparedCall: PreparedLlmCall | undefined,
+ tools: GenerateOptions['tools'] & object,
+ startsRequestSeries: boolean,
+ signal: AbortSignal,
+ ): GenerateOptions {
+ const { session } = this
+ const surfaceGeneration = session.surface.replaceGeneration
const header = canonicalHeader({
config,
...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
@@ -605,11 +600,11 @@ export class ReactLoopAgent implements Agent {
const request = markAgentLoopRequest(deepFreeze({
...header.config,
- messages: boundaryMessages,
+ messages: session.deriveMessages(),
...header.tools !== undefined ? { tools: header.tools } : {},
sessionId: this.session.id,
signal,
}))
- return { request, ...preparedCall === undefined ? {} : { preparedCall } }
+ return request
}
}
diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts
index 51dcf5ac95..fc3de6a1d8 100644
--- a/packages/core/agent-loop/src/runtime-context.ts
+++ b/packages/core/agent-loop/src/runtime-context.ts
@@ -33,7 +33,7 @@ export interface SystemPromptCommit {
/** The request-series facts one prompt decision is made under. */
export interface SystemPromptDecisionInput {
- /** Whether the route that served the last request reads a later `system` message as the effective prompt. */
+ /** Whether the prepared route for this attempt reads a later `system` message as the effective prompt. */
inHistory: boolean
/**
* Whether this step's request starts a new model-message series: a pre-step
@@ -54,7 +54,8 @@ function eventsNewestFirst(session: Session): readonly SessionEvent[] {
* replaces the latest surviving system node in place, except on an
* `in-history` route while the request series continues, where it appends a
* new `system/message` after the cached history; a series start folds the
- * prompt back into node 0 when no later system node survives.
+ * prompt back into node 0 when no later system node survives. Incapable routes
+ * normalize nonempty prompts to the first node and empty all later active nodes.
*/
export class SystemPromptProjection {
constructor(private readonly session: Session) {}
@@ -74,25 +75,35 @@ export class SystemPromptProjection {
* Create an uncommitted system node when absent, even for an empty prompt, or changed.
* @param rendered - the fully rendered system prompt; `''` when none is active.
* @param input - the route capability and series facts for this step.
- * @returns the message and its surface intent, or `undefined` when no update is needed.
+ * @returns ordered per-node updates; an empty list means no update is needed.
*/
- project(rendered: string, input: SystemPromptDecisionInput): SystemPromptCommit | undefined {
+ project(rendered: string, input: SystemPromptDecisionInput): SystemPromptCommit[] {
const nodes = this.systemNodes()
- const latest = nodes.at(-1)
- if (latest === undefined) {
- return { message: createSystemMessage(rendered, SOURCE), intent: { surfaceOp: 'append' } }
+ const head = nodes[0]
+ if (head === undefined) {
+ return [{ message: createSystemMessage(rendered, SOURCE), intent: { surfaceOp: 'append' } }]
}
- if (latest.text === rendered) return
+ const latest = nodes.findLast(node => node.text.length > 0) ?? head
+ if (!input.inHistory && rendered.length > 0) {
+ const updates = nodes.slice(1).filter(node => node.text.length > 0)
+ .map(node => this.replace(node.seq, ''))
+ if (head.text !== rendered) updates.push(this.replace(head.seq, rendered))
+ return updates
+ }
+ if (latest.text === rendered) return []
const message = createSystemMessage(rendered, SOURCE)
// An empty node projects to no message, so clearing the prompt must rewrite
// the surviving node; a series start with one system node re-baselines it.
const append = input.inHistory
&& rendered.length > 0
&& (!input.startsSeries || nodes.length > 1)
- if (append) return { message, intent: { surfaceOp: 'append' } }
- const { seq } = latest
+ if (append) return [{ message, intent: { surfaceOp: 'append' } }]
+ return [this.replace(latest.seq, rendered)]
+ }
+
+ private replace(seq: SessionSeq, text: string): SystemPromptCommit {
return {
- message,
+ message: createSystemMessage(text, SOURCE),
intent: { surfaceOp: { op: 'replace', start: seq, end: seq }, sourceEventSeqs: [seq] },
}
}
diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts
index 836e77cbad..7e4d1a5d3a 100644
--- a/packages/core/agent-loop/tests/config-session-id.spec.ts
+++ b/packages/core/agent-loop/tests/config-session-id.spec.ts
@@ -445,7 +445,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(JsonlSessionPersistence, { root })
await ctx1.plugin(AgentLoop, { agents: [] })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
- const h1 = await ctx1.agents.create({ sessionId: SessionId('sticky-1') })
+ const h1 = await ctx1.agents.create({ sessionId: SessionId('sticky-1'), agentOptions: { provider: 'mock', model: 'mock' } })
h1.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } }))
await waitForIdle(ctx1, h1.agent)
await h1.dispose()
diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts
index 5542559de2..c8f4d59ba9 100644
--- a/packages/core/agent-loop/tests/resume.spec.ts
+++ b/packages/core/agent-loop/tests/resume.spec.ts
@@ -946,7 +946,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
it.skipIf(process.platform === 'win32')('a pending idle inject() survives persist + resume without a synthetic turn', async () => {
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
- const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
+ const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' }, agentOptions: { provider: 'mock', model: 'mock' } })).agent
a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
await waitForIdle(ctx1, a1)
a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background job 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } }))
@@ -965,7 +965,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const stored = await readStoredEvents(ctx2, SessionId('inject-sess'))
expect(stored.some(event => event.type === 'agent/inbox/spliced')).toBe(true)
expect(JSON.stringify(stored)).toContain('background job 42 finished')
- const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
+ const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess'), agentOptions: { provider: 'mock', model: 'mock' } })).agent
expect(JSON.stringify(a2.inbox.nextStep)).toContain('background job 42 finished')
a2.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
await waitForIdle(ctx2, a2)
diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts
new file mode 100644
index 0000000000..6d04697ffc
--- /dev/null
+++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts
@@ -0,0 +1,162 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import AgentRegistry, { installModelSelection, type Agent, type ModelSelectionRef } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import LlmRuntime, { createUserMessage, type GenerateOptions } from '@deepseek-ai/dsh-llm'
+import { toPiContext } from '@deepseek-ai/dsh-llm-pi-ai/src/context.ts'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import { MockAdapter, textResponse } from './mock-adapter.ts'
+
+const contexts: Context[] = []
+afterEach(async () => {
+ for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+})
+
+async function harness() {
+ const ctx = new Context()
+ contexts.push(ctx)
+ await ctx.plugin(LlmRuntime)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(SystemPrompt, { persona: '' })
+ await ctx.plugin(ToolRuntime)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ const capable = new MockAdapter(Array.from({ length: 8 }, () => textResponse('ok')))
+ capable.systemPromptUpdate = 'in-history'
+ const plain = new MockAdapter(Array.from({ length: 8 }, () => textResponse('ok')))
+ ctx.llm.registerAdapter(['capable'], capable)
+ ctx.llm.registerAdapter(['plain'], plain)
+ let prompt = 'prompt one'
+ ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({
+ ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }],
+ }))
+ const selection: ModelSelectionRef = { current: { provider: 'capable', model: 'model' }, assembled: undefined }
+ const agent = await ctx.agentLoop.create(SessionId('admission'), { provider: 'capable', model: 'model' })
+ installModelSelection(agent.ctx, selection)
+ ctx.on('llm/stream', (request, next) => {
+ // Rebuild from copied source events, not the live projection's cached state.
+ const subject = ctx.agents.get(request.sessionId!)!
+ const replay = Session.create(subject.id, subject.session.snapshotEvents())
+ expect(request.messages).toEqual(replay.deriveMessages())
+ expect(request.system).toBeUndefined()
+ return next()
+ })
+ return { ctx, agent, capable, plain, selection, setPrompt: (text: string) => { prompt = text } }
+}
+
+async function send(agent: Agent, text: string) {
+ agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
+ await agent.whenIdle()
+}
+
+function systemTexts(request: GenerateOptions) {
+ return request.messages.filter(message => message.role === 'system').map(message => message.content)
+}
+
+function expectPlain(request: GenerateOptions, prompt: string) {
+ expect(systemTexts(request)).toEqual([[{ type: 'text', text: prompt }]])
+ const converted = toPiContext(request)
+ expect(converted.systemPrompt).toBe(prompt)
+ expect(converted.messages.filter(message => message.role === 'user').map(message => message.content))
+ .not.toContain('prompt one')
+ expect(converted.messages.filter(message => message.role === 'user').map(message => message.content))
+ .not.toContain('prompt two')
+}
+
+describe('prepared-route prompt admission', () => {
+ it.each([false, true])('normalizes capable history on a plain route, changed=%s', async (changed) => {
+ const h = await harness()
+ await send(h.agent, 'first')
+ h.setPrompt('prompt two')
+ await send(h.agent, 'second')
+ expect(systemTexts(h.capable.requests[1]!)).toHaveLength(2)
+ h.selection.current = { provider: 'plain', model: 'model' }
+ if (changed) h.setPrompt('prompt three')
+ await send(h.agent, 'third')
+ expectPlain(h.plain.requests[0]!, changed ? 'prompt three' : 'prompt two')
+ const events = h.agent.session.snapshotEvents()
+ const replacements = events.filter(event => event.type === 'system/message' && event.surfaceOp !== 'append')
+ expect(replacements).toHaveLength(2)
+ expect(replacements[0]?.type === 'system/message' && replacements[0].data.message.content).toEqual([])
+ for (const event of replacements) {
+ expect(event.surfaceOp).toEqual({ op: 'replace', start: event.sourceEventSeqs?.[0], end: event.sourceEventSeqs?.[0] })
+ }
+ await send(h.agent, 'fourth')
+ expect(h.agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(4)
+ expectPlain(h.plain.requests[1]!, changed ? 'prompt three' : 'prompt two')
+ })
+
+ it('appends on the first capable request after a plain route', async () => {
+ const h = await harness()
+ h.selection.current = { provider: 'plain', model: 'model' }
+ await send(h.agent, 'first')
+ h.selection.current = { provider: 'capable', model: 'model' }
+ h.setPrompt('prompt two')
+ await send(h.agent, 'second')
+ expect(systemTexts(h.capable.requests[0]!)).toEqual([
+ [{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }],
+ ])
+ expect(h.capable.requests[0]!.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant', 'system', 'user'])
+ })
+
+ it('normalizes restored history under the resumed instance route', async () => {
+ const h = await harness()
+ await send(h.agent, 'first')
+ h.setPrompt('prompt two')
+ await send(h.agent, 'second')
+ const seed = h.agent.session.snapshotEvents()
+ const { agent: resumed } = await h.ctx.agents.create({ sessionId: SessionId('resumed'), agentOptions: { provider: 'plain', model: 'model' }, seed: [...seed] })
+ await send(resumed, 'resume')
+ expectPlain(h.plain.requests[0]!, 'prompt two')
+ expect(resumed.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1)?.data.reason).toBe('resume')
+ })
+
+ it.each(['request', 'prepare'] as const)('cancels during %s with a balanced empty step before input admission', async (stage) => {
+ const h = await harness()
+ const entered = Promise.withResolvers()
+ const release = Promise.withResolvers()
+ const observe = async () => {
+ const types = h.agent.session.snapshotEvents().map(event => event.type)
+ expect(types).toContain('step/start')
+ expect(types).not.toContain('system/message')
+ expect(types).not.toContain('user/message')
+ expect(types).not.toContain('request/header')
+ entered.resolve(undefined)
+ await release.promise
+ }
+ if (stage === 'request') h.ctx.on('agent/request', async (_payload, next) => { await observe(); return next() })
+ else {
+ const resolve = h.capable.resolveModel.bind(h.capable)
+ vi.spyOn(h.capable, 'resolveModel').mockImplementation(async (...args) => { await observe(); return resolve(...args) })
+ }
+ h.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'cancelled' }], source: { kind: 'user' } }))
+ await entered.promise
+ h.agent.cancel({ kind: 'user' })
+ release.resolve(undefined)
+ await h.agent.whenIdle()
+ expect(h.capable.requests).toHaveLength(0)
+ const types = h.agent.session.snapshotEvents().map(event => event.type)
+ expect(types.filter(type => type === 'step/start' || type === 'step/end')).toEqual(['step/start', 'step/end'])
+ expect(types.filter(type => ['system/message', 'user/message', 'request/header'].includes(type))).toEqual([])
+ })
+
+ it('keeps generic config changes and concurrent selection on the same prepared route', async () => {
+ const h = await harness()
+ await send(h.agent, 'first')
+ h.setPrompt('prompt two')
+ h.ctx.on('agent/request', async (_payload, next) => {
+ const config = await next()
+ h.selection.current = { provider: 'plain', model: 'model' }
+ return { ...config, temperature: 0.5, maxTokens: 100 }
+ })
+ await send(h.agent, 'second')
+ expect(h.plain.requests).toHaveLength(0)
+ expect(h.capable.requests[1]).toMatchObject({ provider: 'capable', temperature: 0.5, maxTokens: 100 })
+ expect(systemTexts(h.capable.requests[1]!)).toHaveLength(2)
+ expect(h.agent.session.snapshotEvents().filter(event => event.type === 'user/message')).toHaveLength(2)
+ })
+})
diff --git a/packages/core/agent-loop/tests/system-prompt-projection.spec.ts b/packages/core/agent-loop/tests/system-prompt-projection.spec.ts
index d45251490b..ae95838217 100644
--- a/packages/core/agent-loop/tests/system-prompt-projection.spec.ts
+++ b/packages/core/agent-loop/tests/system-prompt-projection.spec.ts
@@ -40,27 +40,27 @@ describe('SystemPromptProjection', () => {
const session = ctx.sessions.create(SessionId('system-prompt-fresh'))
const projection = new SystemPromptProjection(session)
- const first = projection.project('v1', REPLACING)
+ const first = projection.project('v1', REPLACING)[0]
expect(first?.intent).toEqual({ surfaceOp: 'append' })
expect(first?.message.role).toBe('system')
expect(first?.message.source).toEqual({ kind: 'plugin', plugin: SOURCE })
const head = commit(session, 1, first)
appendUser(session, 'hello')
- expect(projection.project('v1', REPLACING)).toBeUndefined()
- const second = projection.project('v2', REPLACING)
+ expect(projection.project('v1', REPLACING)[0]).toBeUndefined()
+ const second = projection.project('v2', REPLACING)[0]
expect(second?.intent).toEqual(replaceOf(head.seq))
const replaced = commit(session, 2, second)
expect(session.surface.nodes[0]).toBe(replaced.seq)
- expect(projection.project('v2', REPLACING)).toBeUndefined()
+ expect(projection.project('v2', REPLACING)[0]).toBeUndefined()
// An emptied prompt keeps the head node with empty content, which projects to no wire message.
- const emptied = projection.project('', REPLACING)
+ const emptied = projection.project('', REPLACING)[0]
expect(emptied?.message.content).toEqual([])
commit(session, 3, emptied)
expect(session.deriveMessages().map(message => message.role)).toEqual(['user'])
- expect(projection.project('', REPLACING)).toBeUndefined()
- expect(projection.project('v3', REPLACING)?.intent).toMatchObject({ surfaceOp: { op: 'replace' } })
+ expect(projection.project('', REPLACING)[0]).toBeUndefined()
+ expect(projection.project('v3', REPLACING)[0]?.intent).toMatchObject({ surfaceOp: { op: 'replace' } })
})
it('reserves an empty head before user history and replaces it when a prompt appears', async () => {
@@ -68,16 +68,16 @@ describe('SystemPromptProjection', () => {
try {
const session = ctx.sessions.create(SessionId('system-prompt-empty-head'))
const projection = new SystemPromptProjection(session)
- const first = projection.project('', REPLACING)
+ const first = projection.project('', REPLACING)[0]
expect(first?.intent).toEqual({ surfaceOp: 'append' })
expect(first?.message.content).toEqual([])
const head = session.append('system/message', { turn: 1, step: 1, message: first!.message }, first!.intent)
const user = appendUser(session, 'hello')
expect(session.surface.nodes).toEqual([head.seq, user.seq])
expect(session.deriveMessages().map(message => message.role)).toEqual(['user'])
- expect(projection.project('', REPLACING)).toBeUndefined()
+ expect(projection.project('', REPLACING)).toEqual([])
- const next = projection.project('Follow this guidance.', REPLACING)
+ const next = projection.project('Follow this guidance.', REPLACING)[0]
expect(next?.intent).toEqual({
surfaceOp: { op: 'replace', start: head.seq, end: head.seq },
sourceEventSeqs: [head.seq],
@@ -98,15 +98,15 @@ describe('SystemPromptProjection', () => {
const current = session.append('system/message', { turn: 2, step: 1, message: createSystemMessage('current', SOURCE) }, replaceOf(stale.seq))
const projection = new SystemPromptProjection(session)
- expect(projection.project('current', REPLACING)).toBeUndefined()
- expect(projection.project('next', REPLACING)?.intent).toEqual(replaceOf(current.seq))
+ expect(projection.project('current', REPLACING)[0]).toBeUndefined()
+ expect(projection.project('next', REPLACING)[0]?.intent).toEqual(replaceOf(current.seq))
const emptySession = ctx.sessions.create(SessionId('system-prompt-empty-replay'))
const empty = emptySession.append('system/message', { turn: 1, step: 1, message: createSystemMessage('', SOURCE) }, { surfaceOp: 'append' })
appendUser(emptySession, 'hello')
const emptyProjection = new SystemPromptProjection(emptySession)
- expect(emptyProjection.project('', REPLACING)).toBeUndefined()
- expect(emptyProjection.project('now present', REPLACING)?.intent).toEqual(replaceOf(empty.seq))
+ expect(emptyProjection.project('', REPLACING)[0]).toBeUndefined()
+ expect(emptyProjection.project('now present', REPLACING)[0]?.intent).toEqual(replaceOf(empty.seq))
})
it('appends again after a replacement shadowed a system node that was not the head', async () => {
@@ -114,37 +114,37 @@ describe('SystemPromptProjection', () => {
const session = ctx.sessions.create(SessionId('system-prompt-shadowed'))
const projection = new SystemPromptProjection(session)
appendUser(session, 'before any prompt')
- const late = projection.project('late prompt', REPLACING)
+ const late = projection.project('late prompt', REPLACING)[0]
expect(late?.intent).toEqual({ surfaceOp: 'append' })
const node = commit(session, 1, late)
expect(session.surface.nodes.indexOf(node.seq)).toBe(1)
- expect(projection.project('late prompt', REPLACING)).toBeUndefined()
+ expect(projection.project('late prompt', REPLACING)[0]).toBeUndefined()
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'plugin', plugin: 'test-compaction' },
}), replaceOf(node.seq))
- expect(projection.project('late prompt', REPLACING)?.intent).toEqual({ surfaceOp: 'append' })
+ expect(projection.project('late prompt', REPLACING)[0]?.intent).toEqual({ surfaceOp: 'append' })
})
it('appends a changed prompt after cached history on an in-history route while the series continues', async () => {
const ctx = await sessionStore()
const session = ctx.sessions.create(SessionId('system-prompt-in-history'))
const projection = new SystemPromptProjection(session)
- const head = commit(session, 1, projection.project('v1', CONTINUING))
+ const head = commit(session, 1, projection.project('v1', CONTINUING)[0])
appendUser(session, 'hello')
- expect(projection.project('v1', CONTINUING)).toBeUndefined()
- const update = projection.project('v2', CONTINUING)
+ expect(projection.project('v1', CONTINUING)[0]).toBeUndefined()
+ const update = projection.project('v2', CONTINUING)[0]
expect(update?.intent).toEqual({ surfaceOp: 'append' })
const appended = commit(session, 2, update)
expect(session.surface.nodes).toEqual([head.seq, expect.any(Number), appended.seq])
expect(session.deriveMessages().map(message => message.role)).toEqual(['system', 'user', 'system'])
- expect(projection.project('v2', CONTINUING)).toBeUndefined()
+ expect(projection.project('v2', CONTINUING)[0]).toBeUndefined()
// The effective prompt is the latest surviving node: a further change compares against it.
appendUser(session, 'more')
- const third = projection.project('v3', CONTINUING)
+ const third = projection.project('v3', CONTINUING)[0]
expect(third?.intent).toEqual({ surfaceOp: 'append' })
commit(session, 3, third)
expect(session.deriveMessages().flatMap(message => message.role === 'system' ? [message.content[0]] : []))
@@ -155,35 +155,35 @@ describe('SystemPromptProjection', () => {
const ctx = await sessionStore()
const session = ctx.sessions.create(SessionId('system-prompt-series-start'))
const projection = new SystemPromptProjection(session)
- const head = commit(session, 1, projection.project('v1', CONTINUING))
+ const head = commit(session, 1, projection.project('v1', CONTINUING)[0])
appendUser(session, 'hello')
// A new series already costs the cache, so the change folds into node 0.
- const rebased = projection.project('v2', NEW_SERIES)
+ const rebased = projection.project('v2', NEW_SERIES)[0]
expect(rebased?.intent).toEqual(replaceOf(head.seq))
const newHead = commit(session, 2, rebased)
expect(session.surface.nodes[0]).toBe(newHead.seq)
// With a mid-history node surviving, node 0 stays and the new prompt appends.
appendUser(session, 'again')
- commit(session, 3, projection.project('v3', CONTINUING))
- expect(projection.project('v4', NEW_SERIES)?.intent).toEqual({ surfaceOp: 'append' })
+ commit(session, 3, projection.project('v3', CONTINUING)[0])
+ expect(projection.project('v4', NEW_SERIES)[0]?.intent).toEqual({ surfaceOp: 'append' })
})
it('rewrites the surviving node when an in-history route clears the prompt or loses the capability', async () => {
const ctx = await sessionStore()
const session = ctx.sessions.create(SessionId('system-prompt-in-history-clear'))
const projection = new SystemPromptProjection(session)
- commit(session, 1, projection.project('v1', CONTINUING))
+ commit(session, 1, projection.project('v1', CONTINUING)[0])
appendUser(session, 'hello')
- const update = commit(session, 2, projection.project('v2', CONTINUING))
+ const update = commit(session, 2, projection.project('v2', CONTINUING)[0])
// An empty node projects to no wire message, so clearing must rewrite the effective node.
- const cleared = projection.project('', CONTINUING)
+ const cleared = projection.project('', CONTINUING)[0]
expect(cleared?.intent).toEqual(replaceOf(update.seq))
expect(cleared?.message.content).toEqual([])
- // A route without the capability replaces the latest surviving node in place.
- expect(projection.project('v3', REPLACING)?.intent).toEqual(replaceOf(update.seq))
+ // Incapable routes must not send older instructions through the converter as user text.
+ expect(projection.project('v3', REPLACING).map(commit => commit.message.content)).toEqual([[], [{ type: 'text', text: 'v3' }]])
})
})
diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml
index ee269ecc72..f9640c3460 100644
--- a/packages/core/agent/README.i18n.yaml
+++ b/packages/core/agent/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
-README.md: 37d5d24192ed09bfdeb5210cdd9fcf2c2bd058e2
-README.zh.md: bb56687edaa9debc937908ff26770bbce92a8a80
+README.md: a3842dd652b9fc5420ad7fa3c56c102647d588ac
+README.zh.md: 0fc31414bb4a9cda3d970fcc2306da488ac945c2
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index 37d5d24192..a3842dd652 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -82,7 +82,7 @@ The package is built on one separation: the public `Agent` surface and registry
### Step admission
-`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch contains the complete identified, frozen message batch. `startsRequestSeries: true` declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary.
+`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch contains the complete identified, frozen message batch. Acceptance does not commit it: after assembly and `step/start`, `agent/request` and `prepareCall()` resolve the route before the loop commits the system prompt and user batch. Cancellation during either async phase commits neither. `startsRequestSeries: true` declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary.
### Source map
diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md
index bb56687eda..0fc31414bb 100644
--- a/packages/core/agent/README.zh.md
+++ b/packages/core/agent/README.zh.md
@@ -82,7 +82,7 @@ await handle.agent.whenIdle()
### 步骤准入
-`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支包含完整、带标识且冻结的消息批次。`startsRequestSeries: true` 声明一个独立的模型消息序列;包装下游 enter 的监听器会保留该声明与批次,除非有意替换其中一项。领取会从 inbox 移除候选消息,领取后插入的消息则等待后续边界。
+`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支包含完整、带标识且冻结的消息批次。接纳不等于提交:组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析路由,循环随后才提交系统提示词与用户批次。在任一异步阶段取消都不会提交这两者。`startsRequestSeries: true` 声明一个独立的模型消息序列;包装下游 enter 的监听器会保留该声明与批次,除非有意替换其中一项。领取会从 inbox 移除候选消息,领取后插入的消息则等待后续边界。
### 源码地图
diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts
index 2b6931b257..874dc1dbaa 100644
--- a/packages/core/agent/src/runtime-types.ts
+++ b/packages/core/agent/src/runtime-types.ts
@@ -277,8 +277,12 @@ declare module '@deepseek-ai/cordis' {
/**
* Replace the frozen call configuration. `await next()` yields the config
* the machine would use (agent options on the first request, the logged
- * header afterwards); return a replacement to switch. Model-visible
- * content must use logged channels; this waterfall cannot mutate messages.
+ * header afterwards); return a replacement to switch. On step admission,
+ * this runs after assembly and `step/start`, before the system prompt and
+ * accepted user batch are committed. Cancellation here or during subsequent
+ * `prepareCall()` resolution commits neither. The prepared call capability
+ * governs prompt admission. Model-visible content must use logged channels;
+ * this waterfall cannot mutate messages.
* @param payload.agent - the agent making the model call.
* @param payload.turn - the open turn number.
* @param payload.step - the step whose request this is.
diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml
index d97ef6162f..af35c66357 100644
--- a/packages/core/session/README.i18n.yaml
+++ b/packages/core/session/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
-README.md: 5b4d8d777eb95f13f745815cf2e69f58b9025fc7
-README.zh.md: b344dff79e91b04466ef150d6dad384aa3afd0ae
+README.md: af2a76a99270148053eb7875cb1e506d687c39a8
+README.zh.md: 0d61ff746388b1527a059a45ab82789340524d8f
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index 5b4d8d777e..af2a76a992 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -47,7 +47,7 @@ session.append('user/message', { role: 'user', content: [{ type: 'text', text: '
session.deriveMessages() // the derived model history
```
-Surface events (`system/message`, `user/message`, `assistant/message`, `tool/result`) must declare how they join the ordered surface. An Assistant message embeds the exact compact provider stream that produced it; `assistant/attempt`, boundaries, and other log-only events never produce a message. `system/message` holds the rendered system prompt: the first one is surface node 0, a changed prompt replaces the latest system node in place or, on a route whose `request/context` declares `systemPromptUpdate: 'in-history'` inside a continuing request series, is appended after the cached history so the latest system node is the effective prompt; the surface fold rejects a replacement covering node 0 while it is a `system/message` unless the replacing event is itself a `system/message` over exactly that node, while later system nodes carry no protection and a compaction range may shadow them ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)).
+Surface events (`system/message`, `user/message`, `assistant/message`, `tool/result`) must declare how they join the ordered surface. An Assistant message embeds the exact compact provider stream that produced it; `assistant/attempt`, boundaries, and other log-only events never produce a message. `system/message` holds the rendered system prompt: the first one is surface node 0, the prepared call capability governs admission, with a non-empty rendering consolidated at the first system node on an incapable route or appended after cached history inside a continuing `in-history` series; empty system nodes project to no message; the surface fold rejects a replacement covering node 0 while it is a `system/message` unless the replacing event is itself a `system/message` over exactly that node, while later system nodes carry no protection and a compaction range may shadow them ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)).
### Read the log
@@ -105,7 +105,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads,
### The request header
-The loop logs a full canonical `request/header` snapshot (call config, adapter defaults, assembled tool schemas — the rendered system prompt is a `system/message` surface node, not header state) at each loop-instance boundary and on change; `foldRequestHeader(events)` reconstructs it by selecting the latest snapshot, making every conversation request a pure function of the log. Route metadata (`request/context`) is separate logged state appended only when the provider, model, capacity, or `systemPromptUpdate` mode differs; the loop reads the latest snapshot's mode when it decides how to commit a changed system prompt.
+The loop logs a full canonical `request/header` snapshot (call config, adapter defaults, assembled tool schemas — the rendered system prompt is a `system/message` surface node, not header state) at each loop-instance boundary and on change; `foldRequestHeader(events)` reconstructs it by selecting the latest snapshot, making every conversation request a pure function of the log. Route metadata (`request/context`) is separate logged state appended only when the provider, model, capacity, or `systemPromptUpdate` mode differs; it records the actual prepared call's mode after prompt and user admission, rather than supplying that admission decision.
diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md
index b344dff79e..0d61ff7463 100644
--- a/packages/core/session/README.zh.md
+++ b/packages/core/session/README.zh.md
@@ -47,7 +47,7 @@ session.append('user/message', { role: 'user', content: [{ type: 'text', text: '
session.deriveMessages() // the derived model history
```
-表层事件(`system/message`、`user/message`、`assistant/message`、`tool/result`)必须声明如何进入有序 surface。Assistant message 会嵌入产生它的精确紧凑 provider stream;`assistant/attempt`、边界与其他仅日志事件从不产生消息。`system/message` 承载渲染后的系统提示词:第一条是 surface 第 0 号节点,变化后的提示词原地替换最新的系统节点,或者在 `request/context` 声明 `systemPromptUpdate: 'in-history'` 的路由上、同一请求序列延续期间追加到已缓存历史之后,使最新的系统节点成为有效提示词;当第 0 号节点是 `system/message` 时,surface 折叠拒绝覆盖它的替换,除非替换事件本身是恰好覆盖该节点的 `system/message`,而后续系统节点不受保护,压缩范围可以遮蔽它们([决策](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md))。
+表层事件(`system/message`、`user/message`、`assistant/message`、`tool/result`)必须声明如何进入有序 surface。Assistant message 会嵌入产生它的精确紧凑 provider stream;`assistant/attempt`、边界与其他仅日志事件从不产生消息。`system/message` 承载渲染后的系统提示词:第一条是 surface 第 0 号节点,准入依据已准备调用的能力,不具备能力的路由将非空渲染文本归并到首个系统节点,延续中的 `in-history` 序列则在缓存历史之后追加;空系统节点不投影为消息;当第 0 号节点是 `system/message` 时,surface 折叠拒绝覆盖它的替换,除非替换事件本身是恰好覆盖该节点的 `system/message`,而后续系统节点不受保护,压缩范围可以遮蔽它们([决策](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md))。
### 读取日志
@@ -105,7 +105,7 @@ session.deriveMessages() // the derived model history
### 请求头
-循环在每个循环实例边界及变更时记录完整规范 `request/header` 快照(调用配置、适配器默认值、组装后的工具 schema——渲染后的系统提示词是 `system/message` surface 节点,不是 header 状态);`foldRequestHeader(events)` 通过选择最新快照来重建它,使每个对话请求都成为日志的纯函数。路由元数据(`request/context`)是独立的已记录状态,仅在提供方、模型、容量或 `systemPromptUpdate` 模式变化时追加;循环在决定如何提交变化后的系统提示词时读取最新快照的模式。
+循环在每个循环实例边界及变更时记录完整规范 `request/header` 快照(调用配置、适配器默认值、组装后的工具 schema——渲染后的系统提示词是 `system/message` surface 节点,不是 header 状态);`foldRequestHeader(events)` 通过选择最新快照来重建它,使每个对话请求都成为日志的纯函数。路由元数据(`request/context`)是独立的已记录状态,仅在提供方、模型、容量或 `systemPromptUpdate` 模式变化时追加;它在提示词与用户消息准入之后记录实际已准备调用的模式,而非提供准入决策。
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index 205c2b3499..1fc5202786 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -298,13 +298,12 @@ export interface SessionEventMap {
/**
* The rendered system prompt on the model-visible surface. The loop appends
* the first one as surface node 0 before the step's first `user/message`.
- * When the rendered prompt changes it replaces the latest system node
- * (`surfaceOp: { op: 'replace' }` over exactly that node) or, on a route
- * whose `request/context` declares `systemPromptUpdate: 'in-history'` and
- * inside a continuing request series, appends the changed prompt after the
- * cached history, so the latest system node is the effective prompt and
- * every request stays derived history. Empty `message.content` records "no
- * system prompt" and projects to no message.
+ * A prepared in-history route can append changed text after cached history.
+ * An incapable route normalizes nonempty prompts to the first system node
+ * and replaces later nonempty nodes with empty content, each through a logged
+ * per-node replacement. Empty later nodes are dormant and project to no
+ * message; the latest nonempty system node supplies the effective prompt.
+ * Empty head content records "no system prompt" when no later prompt is active.
*/
'system/message': { turn: number; step: number; message: SystemMessage }
/**
@@ -369,8 +368,8 @@ export interface SessionEventMap {
/**
* Route metadata for the next request, logged only when the route, capacity,
* or system prompt update mode changes. It does not participate in request
- * reconstruction or header equality; the loop reads the latest snapshot's
- * `systemPromptUpdate` when it decides how to commit a changed system prompt.
+ * reconstruction or header equality. Prompt admission uses the bound prepared
+ * call's capability, not this snapshot from an earlier request.
*/
'request/context': RequestContext
/**
diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml
index 0d7fe9dc47..9998c229a5 100644
--- a/packages/core/system-prompt/README.i18n.yaml
+++ b/packages/core/system-prompt/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
-README.md: 878c964792eed222441b504beaf35b4db7b98a0d
-README.zh.md: 89e9bd06d7d1af94f3ab98c9c19a15d0df29d1fe
+README.md: 6e2107c5a109cc4cbc9d8c40ff9b15bfaae9d01c
+README.zh.md: adc6f1303eeaad35b00a4bd520eb79d692e94248
diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md
index 878c964792..6e2107c5a1 100644
--- a/packages/core/system-prompt/README.md
+++ b/packages/core/system-prompt/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-system-prompt` assembles the system prompt and tool schemas the model receives before each step. Plugins contribute ordered prompt sections, dynamic runtime context, tool-schema providers, and named variables; the loop calls `assemble()` once per step, renders the result into the complete model prompt, and commits that text as a `system/message` surface node owned by its `SystemPromptProjection` — appended as surface node 0 on the first step, then replaced in place when the rendered text changes or, on a route whose `request/context` declares `systemPromptUpdate: 'in-history'`, appended after the cached history ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../agent-loop/README.md#understand-the-implementation)). The package provides the fixed harness identity and the global deployment persona, while an agent-scoped contribution shadows the global default for one agent. Config controls the harness identity opener, dynamic runtime context, the deployment persona, and an explicit model-facing tool order. Choose it when you need to add a prompt section, a prompt variable, or a tool-schema source — it is the assembly point all model-facing prose flows through.
+`dsh-system-prompt` assembles the system prompt and tool schemas the model receives before each step. Plugins contribute ordered prompt sections, dynamic runtime context, tool-schema providers, and named variables; the loop calls `assemble()` once per step, renders the result into the complete model prompt, and commits that text as a `system/message` surface node owned by its `SystemPromptProjection` — appended as surface node 0 on the first step, then replaced in place when the rendered text changes or, when the prepared call declares `systemPromptUpdate: 'in-history'`, appended after the cached history ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../agent-loop/README.md#understand-the-implementation)). The package provides the fixed harness identity and the global deployment persona, while an agent-scoped contribution shadows the global default for one agent. Config controls the harness identity opener, dynamic runtime context, the deployment persona, and an explicit model-facing tool order. Choose it when you need to add a prompt section, a prompt variable, or a tool-schema source — it is the assembly point all model-facing prose flows through.
## Table of Contents
@@ -144,7 +144,7 @@ Identity is a fixed per-request cost when enabled. Persona and plugin text are r
#### KV Cache effect
-Prefix-stable while identity, persona, variables, section text, and order render identically: an unchanged rendering leaves the system nodes untouched. On a route without `systemPromptUpdate`, any change replaces the latest system node with a new `system/message`, so the request differs from its first token and reuse is lost for the whole prefix; on a route whose `request/context` declares `systemPromptUpdate: 'in-history'`, the agent loop appends the changed prompt after the cached history inside a continuing request series, so the prefix through that history stays reusable ([decision rule](../agent-loop/README.md#understand-the-implementation)).
+Prefix-stable while identity, persona, variables, section text, and order render identically: an unchanged rendering leaves the system nodes untouched unless an incapable route must consolidate retained in-history prompts. Without `systemPromptUpdate`, non-empty prompt text is consolidated at the first system node through logged per-node replacements, so a head rewrite loses prefix reuse from its first changed token; when the prepared call declares `systemPromptUpdate: 'in-history'`, the agent loop appends the changed prompt after the cached history inside a continuing request series, so the prefix through that history stays reusable ([decision rule](../agent-loop/README.md#understand-the-implementation)).
### Tool schemas
diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md
index 89e9bd06d7..adc6f1303e 100644
--- a/packages/core/system-prompt/README.zh.md
+++ b/packages/core/system-prompt/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-system-prompt` 组装模型在每个步骤之前收到的系统提示词与工具 schema。插件贡献有序提示词段、动态 runtime 上下文、工具 schema 提供方与具名变量;循环每个步骤调用一次 `assemble()`,把结果渲染为完整模型提示词,并把该文本作为由其 `SystemPromptProjection` 拥有的 `system/message` surface 节点提交——首个步骤作为 surface 第 0 号节点追加,之后在渲染文本变化时原地替换,或者在 `request/context` 声明 `systemPromptUpdate: 'in-history'` 的路由上追加到已缓存历史之后([决策](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../agent-loop/README.zh.md#understand-the-implementation))。该包提供固定 harness 身份与全局部署 persona,而 agent 作用域的贡献会为单个 agent 遮蔽全局默认值。配置控制 harness 身份开场白、动态 runtime 上下文、部署 persona 与显式的面向模型工具顺序。需要添加提示词段、提示词变量或工具 schema 来源时请选择本包——它是所有面向模型文案流经的组装点。
+`dsh-system-prompt` 组装模型在每个步骤之前收到的系统提示词与工具 schema。插件贡献有序提示词段、动态 runtime 上下文、工具 schema 提供方与具名变量;循环每个步骤调用一次 `assemble()`,把结果渲染为完整模型提示词,并把该文本作为由其 `SystemPromptProjection` 拥有的 `system/message` surface 节点提交——首个步骤作为 surface 第 0 号节点追加,之后在渲染文本变化时原地替换,或者当已准备调用声明 `systemPromptUpdate: 'in-history'` 时追加到已缓存历史之后([决策](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../agent-loop/README.zh.md#understand-the-implementation))。该包提供固定 harness 身份与全局部署 persona,而 agent 作用域的贡献会为单个 agent 遮蔽全局默认值。配置控制 harness 身份开场白、动态 runtime 上下文、部署 persona 与显式的面向模型工具顺序。需要添加提示词段、提示词变量或工具 schema 来源时请选择本包——它是所有面向模型文案流经的组装点。
## 目录
@@ -144,7 +144,7 @@ You are an AI agent powered by DeepSeek Harness.
#### KV Cache 影响
-只要身份、persona、变量、段文本与顺序的渲染完全相同,前缀就保持稳定:渲染未变时系统节点保持不动。在没有 `systemPromptUpdate` 的路由上,任何变更都会用新的 `system/message` 替换最新的系统节点,因此请求从第一个 token 起就不同,整个前缀的复用都会丢失;在 `request/context` 声明 `systemPromptUpdate: 'in-history'` 的路由上,agent loop(智能体循环)会在同一请求序列延续期间把变化后的提示词追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用([决策规则](../agent-loop/README.zh.md#understand-the-implementation))。
+只要身份、persona、变量、段文本与顺序的渲染完全相同,前缀就保持稳定:渲染未变时系统节点保持不动,除非不具备能力的路由必须归并保留的历史内提示词。没有 `systemPromptUpdate` 时,非空提示词文本通过有日志记录的逐节点替换归并到首个系统节点,因此头节点重写会从首个变化的 token 起失去前缀复用;当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,agent loop(智能体循环)会在同一请求序列延续期间把变化后的提示词追加到已缓存历史之后,因此直到该历史末尾的前缀仍可复用([决策规则](../agent-loop/README.zh.md#understand-the-implementation))。
### 工具 schema
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3ca9020935..e302c0f210 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4447,6 +4447,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
+ '@deepseek-ai/dsh-llm-pi-ai':
+ specifier: workspace:^
+ version: link:../../llm/llm-pi-ai
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../scope
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 67b47b208c..3bc123ef9c 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -1327,14 +1327,19 @@ function renderLifecycle(): string {
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
' Hooks-->>Driver: authoritative reject or enter(messages)',
- ' alt proposed step rejected or pre-step failed',
+ ' alt proposed step rejected, first batch empty, or pre-step failed',
' Driver-->>Driver: claimed batch stays removed, the open turn spends no step',
' else enter proposed step',
- ' Note over Driver,Prompt: project the rendered prompt against the surviving system/message nodes',
` Driver->>Session: ${mermaidCode('step/start')}`,
- ` Driver->>Session: ${mermaidCode('system/message')} when the rendered prompt changed: replace the latest system node, or append on an in-history route`,
+ ` Driver->>Hooks: ${mermaidCode('agent/request')} waterfall`,
+ ' Driver->>LLM: prepareCall(config, signal)',
+ ' Note over Driver,LLM: cancellation during either async phase commits neither system nor users',
+ ' Note over Driver,Session: synchronous admission using the prepared call capability',
+ ` Driver->>Session: ${mermaidCode('system/message')} ordered per-node reconciliation`,
` Driver->>Session: ${mermaidCode('user/message')} per entered message`,
- ` Driver->>LLM: ${mermaidCode('agent/request')} waterfall, then ${mermaidCode('llm/stream')} waterfall`,
+ ` Driver->>Session: ${mermaidCode('request/header')} and ${mermaidCode('request/context')} as needed`,
+ ' Driver->>Driver: derive and freeze request from the log',
+ ` Driver->>LLM: bound prepared call through ${mermaidCode('llm/stream')} waterfall`,
' LLM-->>Driver: StreamChunk*',
` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} chunk*`,
' alt final adapter or terminal in-band request failure',