Răsfoiți Sursa

perf(agent-loop): reuse proven message freezes per agent

Tianyi Cui 5 zile în urmă
părinte
comite
73edce1ae7

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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/architecture/2026-07-05-reconstructable-requests.md
-2026-07-05-reconstructable-requests.md: bc8ba640c400b18598f18aa303f2bd1b5c5b9cdc
-2026-07-05-reconstructable-requests.zh.md: de1802aac83f1e0980172d2d541a093f2d729e4a
+2026-07-05-reconstructable-requests.md: bca93a60bf07484d73f1faf50359b72a0d00b9a3
+2026-07-05-reconstructable-requests.zh.md: c9d2a4a5d05456df8b0bd065bade8a41dd7e4e84

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md

@@ -24,7 +24,7 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
 
 `EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
 
-Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
+Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and freezes it while leaving `AbortSignal` live. The [request-freeze provenance decision](../simplification/2026-09-06-agent-request-freeze-provenance.md) owns reuse of completed message freezes and per-request local header freezing. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
 
 **The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md

@@ -24,7 +24,7 @@ Status: implemented
 
 `EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
 
-每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
+每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结来源证明决策](../simplification/2026-09-06-agent-request-freeze-provenance.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
 
 **已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。
 

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md
+2026-09-06-agent-request-freeze-provenance.md: bfefe39a0c481250d45318c02199cbd947c4eb4f
+2026-09-06-agent-request-freeze-provenance.zh.md: 4a333c845d11e2e1cbe8ef2325f92f56f48b203d

+ 55 - 0
.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md

@@ -0,0 +1,55 @@
+# Agent Note: Reuse only loop-proven message freezes
+
+Status: implemented
+
+English | [中文](2026-09-06-agent-request-freeze-provenance.zh.md)
+
+## Problem
+
+Long tool conversations repeatedly traverse immutable history while constructing requests. The [backend continuation baseline](../testing/2026-09-06-backend-continuation-performance.md) attributes 132.876 ms of sampled CPU self time to `buildRequest`'s `deepFreeze` during a 211.300 ms request-history operation. Skipping all frozen roots is unsafe: restore adopts independently owned graphs without freezing them, and a shallow-frozen message can still contain mutable content.
+
+## Decision
+
+Each `ReactLoopAgent` owns a private WeakSet<Message> containing only identities whose complete `deepFreeze` call succeeded in that instance. Every unseen message is deep-frozen in place, then added. Later requests reuse that proof. A fresh loop proves each identity again; equal message ids do not establish object identity. Weak references add no ownership of compacted history.
+
+The loop deep-freezes the small local canonical header on every request. `canonicalHeader` shares nested values, and `Session.append` freezes a separate snapshot: neither operation proves the local tools or a `NO_ADAPTER` fallback's stop array immutable. The loop separately freezes its fresh messages array and request envelope, retains `markAgentLoopRequest`, and leaves the live `AbortSignal` mutable. Restored message identity and containing event-wrapper mutability remain unchanged.
+
+This specializes request construction, not Session ownership or general `deepFreeze` behavior. `Session.deriveMessages` and `fromRestore` remain unchanged. LLM file, image, and replay projections retain their own freezes because their newly produced values have no loop-local proof. The [reconstructable-request decision](../architecture/2026-07-05-reconstructable-requests.md) continues to own observable immutability and logged request reconstruction.
+
+## Measurement evidence
+
+Apple M4 Pro, macOS arm64, Node 24.19.0; independent worktree dependencies and built artifacts. The exact parent Agent source at 1dc3296eba is rebuilt for the negative control, then the optimized source is restored and rebuilt. Each row retains all five fresh-process totals in sampling order; all timings are milliseconds. Exclusive slots do not overlap repository builds or sibling benchmarks.
+
+| Implementation and UTC interval (2026-09-06) | Request-history raw totals | Median | 175 ms verdict |
+|---|---|---:|---|
+| Optimized, 07:15:40–07:15:51 | 65.737375, 67.292833, 68.035208, 65.380417, 67.919167 | 67.292833 | Pass |
+| Original, 07:17:06–07:17:10 | 249.050708, 238.275291, 242.172084, 250.093166, 246.130875 | 246.130875 | Fail |
+| Optimized repeat, 07:18:17–07:18:20 | 66.693500, 67.402083, 68.665000, 66.642083, 66.609125 | 66.693500 | Pass |
+
+The same 800-turn, four-tools-per-historical-turn history and 40 live requests complete in every sample: 13,923 events, no live tool calls. The repeat median is 72.9% below the isolated original. A 70 ms source expectation rounds above both optimized medians; the existing 2× CI scale and 1.25× headroom produce 175 ms. This is local calibration, not proof that the shared scale fits every CI runner; the required CI lane owns runner validation. No other case or memory budget changes here.
+
+The first optimized slot also measures cold tool continuation: totals 185.839958, 185.235583, 185.865917, 189.213459, 185.279417; median 185.839958 ms. Every sample completes 40 requests and 160 tool calls with 14,143 events. Retained heap samples are 22.591591, 22.590355, 22.594795, 22.591743, 22.594681 MiB, below the unchanged 28.75 MiB budget. The earlier baseline's approximately 22.295 MiB highlights the small provenance-table cost; weak keys prevent the table itself retaining replaced messages.
+
+The same slot's shipped SDK profile completes 100 turns, 200 requests, and 800 real reads per sample. Totals are 1428.555292, 1160.396333, 1139.843500, 1135.834750, 1155.890334 ms; median 1155.890334 ms. The first sample includes 461.829250 ms boot time versus 164–169 ms for the others and is retained, not discarded. Provider serialization, network time, and browser rendering remain excluded as specified by the baseline owner.
+
+An earlier original-code run at 06:58:28 UTC overlaps a sibling build because of scheduling-message latency: totals 264.269792, 282.442000, 365.836334, 293.172791, 288.719500 ms; median 288.719500 ms. It also fails 175 ms but is not calibration evidence. The isolated original row replaces that comparison, without removing or averaging away the contaminated samples.
+
+## Alternatives considered
+
+**Return immediately for `Object.isFrozen`.** A frozen root does not prove its descendants frozen. Applying this shortcut to the shared helper would weaken every caller, including restore and projection paths.
+
+**Trust every Session message or cache message ids.** Restore explicitly permits owned unfrozen data; replacements can preserve an id while changing identity and content. Only completed traversal of that exact object proves the request's requirement.
+
+**Retain a strong Set or share a global proof cache.** Strong references extend old history lifetime. Global caching expands ownership beyond the Agent and is unnecessary for repeated requests from one loop.
+
+**Remove downstream projection freezes.** Projected file/image/replay messages are distinct values with separate ownership. Optimizing them requires their own evidence and is not implied by freezing canonical history.
+
+## Consequences
+
+Request construction still scans message identities and allocates a fresh array; it avoids recursively traversing already-proven history. Each loop pays one complete traversal for restored history. Local headers remain a per-request cost. Message values, request markers, previous request snapshots, cancellation, and serialized SDK outputs keep their existing behavior.
+
+The [focused tests](../../../../packages/core/agent-loop/tests/request-freeze.spec.ts) exercise shallow-frozen restored roots with mutable descendants, wrapper identity and mutability, successful-only provenance, repeated requests, same-id compaction replacements, a fresh loop, nested tool schemas, adapter and `NO_ADAPTER` stop arrays, held requests, and live cancellation. Reconstruction and cancellation suites cover adjacent loop semantics. Performance measurements use the unchanged [continuation workload](../../../../benchmarks/agent-continuation/workload.ts), not a smaller synthetic microbenchmark.
+
+Validation runs 646 Agent-loop and LLM tests with 100% statement, branch, function, and line coverage of agent.ts. Keyless TypeScript SDK bash-tool and multi-turn snapshots pass against rebuilt libraries. Python sdk-minimal and sdk-snapshot checks pass against an independently packaged node24-macos-arm64 executable. Neither SDK requires an expected-output change. The packaging deploy temporarily removes workspace dependency links; a frozen-lockfile install restores them before source checks, without a tracked dependency change.
+
+The active immutability, message-identity, observable-state-machine, and backend-baseline notes remain independently useful; none is fully superseded or archived. This note specializes the request-freezing mechanism and cross-links its reconstructability owner.

+ 55 - 0
.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md

@@ -0,0 +1,55 @@
+# Agent Note: 仅复用循环已证明的消息冻结
+
+Status: implemented
+
+[English](2026-09-06-agent-request-freeze-provenance.md) | 中文
+
+## 问题
+
+长工具对话在构造请求时反复遍历不可变历史。[后端续跑基线](../testing/2026-09-06-backend-continuation-performance.zh.md)在一次 211.300 ms 的请求历史操作中,将 132.876 ms 的采样 CPU 自耗时归因于 `buildRequest` 的 `deepFreeze`。跳过所有已冻结根对象并不安全:恢复操作会接管独立拥有的对象图而不冻结它们,浅冻结消息仍可能包含可变内容。
+
+## 决策
+
+每个 `ReactLoopAgent` 拥有私有 WeakSet<Message>,其中只记录该实例中完整 `deepFreeze` 调用成功的对象身份。每个未见消息先原地深冻结,再加入集合。后续请求复用该证明。新循环会重新证明每个对象;消息 id 相等不能证明对象身份相同。弱引用不会增加对已压缩历史的所有权。
+
+循环每次请求都深冻结较小的本地规范化 header。`canonicalHeader` 共享嵌套值,`Session.append` 冻结的是独立快照:两者都不能证明本地 tools 或 `NO_ADAPTER` 回退中的 stop 数组不可变。循环分别冻结新消息数组与请求封装,保留 `markAgentLoopRequest`,并保持实时 `AbortSignal` 可变。恢复消息的对象身份及其外围事件包装对象的可变性保持不变。
+
+该决策专门优化请求构造,不改变 Session 所有权或通用 `deepFreeze` 行为。`Session.deriveMessages` 与 `fromRestore` 保持不变。LLM(大语言模型)的文件、图像和回放投影保留各自的冻结,因为它们新生成的值没有循环本地证明。[可重建请求决策](../architecture/2026-07-05-reconstructable-requests.zh.md)继续拥有可观察的不可变性与基于日志的请求重建规则。
+
+## 测量证据
+
+Apple M4 Pro、macOS arm64、Node 24.19.0;worktree 使用独立依赖和构建产物。负对照重新构建 1dc3296eba 中精确的父版本 Agent 源码,随后恢复并重新构建优化源码。每行按采样顺序保留全部五个新进程总耗时;时间单位均为毫秒。独占时段不与仓库构建或其他基准重叠。
+
+| 实现与 UTC 时段(2026-09-06) | 请求历史原始总耗时 | 中位数 | 175 ms 判定 |
+|---|---|---:|---|
+| 优化版,07:15:40–07:15:51 | 65.737375, 67.292833, 68.035208, 65.380417, 67.919167 | 67.292833 | 通过 |
+| 原版,07:17:06–07:17:10 | 249.050708, 238.275291, 242.172084, 250.093166, 246.130875 | 246.130875 | 失败 |
+| 优化版复测,07:18:17–07:18:20 | 66.693500, 67.402083, 68.665000, 66.642083, 66.609125 | 66.693500 | 通过 |
+
+每个样本都完成相同的 800 轮历史(每个历史轮次四个工具)和 40 个实时请求:13,923 个事件,无实时工具调用。复测中位数比独占原版低 72.9%。70 ms 的源码期望值向上取整并高于两次优化版中位数;现有 2× CI 系数和 1.25× 余量得到 175 ms。这是本地校准,不能证明共享系数适合所有 CI 运行器;必跑 CI 测试负责验证运行器。本文不改变其他场景或内存预算。
+
+首个优化版时段还测量冷启动工具续跑:总耗时 185.839958, 185.235583, 185.865917, 189.213459, 185.279417;中位数 185.839958 ms。每个样本都完成 40 个请求、160 个工具调用和 14,143 个事件。保留堆样本为 22.591591, 22.590355, 22.594795, 22.591743, 22.594681 MiB,低于不变的 28.75 MiB 预算。先前基线约 22.295 MiB,显示了证明表的小额成本;弱键防止表本身保留已替换消息。
+
+同一时段的随产品发布 SDK profile 每个样本都完成 100 轮、200 个请求和 800 次真实读取。总耗时为 1428.555292, 1160.396333, 1139.843500, 1135.834750, 1155.890334 ms;中位数 1155.890334 ms。首个样本包含 461.829250 ms 启动时间,其他样本为 164–169 ms;首个样本被保留而非丢弃。供应商序列化、网络时间和浏览器渲染仍按基线所属说明排除。
+
+较早的原版运行始于 06:58:28 UTC,因调度消息延迟而与其他构建重叠:总耗时 264.269792, 282.442000, 365.836334, 293.172791, 288.719500 ms;中位数 288.719500 ms。它也超过 175 ms,但不属于校准证据。独占原版行替代该比较,没有删除受污染样本或通过取平均掩盖它们。
+
+## 考虑过的替代方案
+
+**`Object.isFrozen` 为真时立即返回。** 已冻结根对象不能证明其后代已冻结。在共享辅助函数中使用此捷径会削弱所有调用方,包括恢复与投影路径。
+
+**信任所有 Session 消息或缓存消息 id。** 恢复明确允许拥有独立所有权的未冻结数据;替换操作可保留 id,同时改变对象身份与内容。只有对该精确对象完成遍历才能证明请求要求。
+
+**保留强引用 Set 或共享全局证明缓存。** 强引用会延长旧历史的生命周期。全局缓存将所有权扩大到 agent(智能体)之外,对同一循环的重复请求并无必要。
+
+**移除下游投影冻结。** 投影后的文件/图像/回放消息是拥有独立所有权的不同值。优化它们需要独立证据,不能由规范历史已冻结推导出来。
+
+## 影响
+
+请求构造仍扫描消息身份并分配新数组,但避免递归遍历已证明的历史。每个循环都为恢复历史支付一次完整遍历成本。本地 header 仍是每次请求的成本。消息值、请求标记、先前请求快照、取消及 SDK 序列化输出保持现有行为。
+
+[聚焦测试](../../../../packages/core/agent-loop/tests/request-freeze.spec.ts)覆盖具有可变后代的浅冻结恢复根对象、包装对象身份与可变性、仅成功遍历的证明、重复请求、同 id 压缩替换、新循环、嵌套工具 schema、适配器与 `NO_ADAPTER` stop 数组、持有的旧请求以及实时取消。重建与取消测试集覆盖相邻循环语义。性能测量采用不变的[续跑工作负载](../../../../benchmarks/agent-continuation/workload.ts),而非缩小的合成微基准。
+
+验证运行了 646 个 agent loop 与 LLM 测试,agent.ts 的语句、分支、函数和行覆盖率均为 100%。无密钥 TypeScript SDK bash-tool 与 multi-turn 快照通过重新构建的库执行并通过。Python sdk-minimal 与 sdk-snapshot 检查使用独立打包的 node24-macos-arm64 可执行文件并通过。两个 SDK 均无需修改期望输出。打包部署暂时移除了工作区依赖链接;执行冻结 lockfile 安装可在源码检查前恢复它们,无需修改受版本管理的依赖文件。
+
+现行不可变性、消息身份、可观察状态机和后端基线说明仍各自具有价值;没有说明被完全取代或归档。本文专门规定请求冻结机制,并与可重建性所属说明交叉链接。

+ 2 - 2
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: bbd6a7e09b6af2fe5e90acab33ad220d3f1b62d1
-architecture.zh.md: b05670f8f715c5c3dd5c8cdd76ec81e9d426ace6
+architecture.md: fcb9c1e59b60dc059ab66b64ac26acd3c9157c96
+architecture.zh.md: d7a0a3833ebf9397837967065249d7fe1650d707

+ 2 - 0
docs/architecture.md

@@ -100,6 +100,8 @@ Input reaches the driver through one inbox. Some messages wake it immediately; i
 
 `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 loop sends immutable requests while keeping cancellation live. It reuses message-freeze provenance only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules.
+
 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).
 
 ## Session log

+ 2 - 0
docs/architecture.zh.md

@@ -104,6 +104,8 @@ turn/end
 
 `agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。
 
+循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。
+
 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
 
 ## 会话日志

+ 2 - 2
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: c5985f585f92f38cb27ae1385b1052dbce1d9b9d
-README.zh.md: 615cdd5a6e3e71ae129d782403f09d0309913929
+README.md: 55966a4b0eed0c1cc3484314e809de79341072de
+README.zh.md: 1d27bf3743f54d8fe66a58e75565fc85d94ae1ed

+ 2 - 0
packages/core/agent-loop/README.md

@@ -90,6 +90,8 @@ The package is the one concrete implementation of the public `Agent` contract. I
 
 After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`.
 
+The loop deep-freezes each derived message identity on its first request and reuses that proof only within the same agent. Restored messages keep their identity; request construction does not freeze their containing event wrappers. Each request freezes its local canonical header, fresh message array, and envelope while leaving the cancellation signal live. The [request-freeze decision](../../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md) explains ownership and measurement.
+
 ### Source map
 
 | File | Role |

+ 2 - 0
packages/core/agent-loop/README.zh.md

@@ -90,6 +90,8 @@ const handle = await ctx.agents.create({
 
 `agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。
 
+循环在每个派生消息对象首次进入请求时执行深冻结,并且仅在同一 agent 内复用该证明。恢复的消息保留对象身份;构造请求不会冻结包含消息的事件包装对象。每个请求都会冻结本地规范化 header、新消息数组和请求封装,同时保留取消信号的可变性。[请求冻结决策](../../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md)解释了所有权与测量依据。
+
 ### 源码地图
 
 | 文件 | 职责 |

+ 13 - 2
packages/core/agent-loop/src/agent.ts

@@ -87,6 +87,8 @@ export class ReactLoopAgent implements Agent {
   /** Process-local revision of assistant frames for this attached Session. */
   private assistantStreamRevision = 0
   private assistantAttemptCounter = 0
+  /** Identities fully frozen by this loop; weak references do not retain replaced history. */
+  private readonly frozenMessages = new WeakSet<Message>()
 
   constructor(
     private loopCtx: Context,
@@ -483,7 +485,8 @@ export class ReactLoopAgent implements Agent {
 
   /**
    * Compose one frozen request and bind it to the adapter registration that
-   * resolved its exact-model defaults.
+   * resolved its exact-model defaults. Message identities retain their first
+   * successful deep freeze; each local header is frozen afresh. The signal stays live.
    */
   private async buildRequest(
     turn: number,
@@ -576,7 +579,15 @@ export class ReactLoopAgent implements Agent {
     }
     signal.throwIfAborted()
 
-    const request = markAgentLoopRequest(deepFreeze({
+    // canonicalHeader is shallow; append logs a detached snapshot, not these local values.
+    deepFreeze(header)
+    for (const message of boundaryMessages) {
+      if (this.frozenMessages.has(message)) continue
+      deepFreeze(message)
+      this.frozenMessages.add(message)
+    }
+    Object.freeze(boundaryMessages)
+    const request = markAgentLoopRequest(Object.freeze({
       ...header.config,
       messages: boundaryMessages,
       ...header.system !== undefined ? { system: header.system } : {},

+ 234 - 0
packages/core/agent-loop/tests/request-freeze.spec.ts

@@ -0,0 +1,234 @@
+/** Request immutability through the real loop, including adopted restore graphs. */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
+import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import * as values from '@deepseek-ai/dsh-util-values'
+import { ReactLoopAgent } from '../src/agent.ts'
+import { MockAdapter, textResponse } from './mock-adapter.ts'
+
+const cleanups: (() => Promise<void>)[] = []
+afterEach(async () => {
+  try {
+    for (const cleanup of cleanups.reverse()) await cleanup()
+  } finally {
+    cleanups.length = 0
+    vi.restoreAllMocks()
+  }
+})
+
+async function harness(adapter?: MockAdapter): Promise<Context> {
+  const ctx = new Context()
+  cleanups.push(() => ctx.fiber.dispose())
+  await mountAgentLoopTestDependencies(ctx)
+  await ctx.plugin(SessionProjectionRegistry)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  if (adapter) ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter))
+  return ctx
+}
+
+async function send(agent: Agent, text: string): Promise<void> {
+  agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
+  await agent.whenIdle()
+}
+
+function expectFrozen(value: unknown): void {
+  if (value === null || typeof value !== 'object' || value instanceof AbortSignal) return
+  expect(Object.isFrozen(value)).toBe(true)
+  for (const child of Object.values(value)) expectFrozen(child)
+}
+
+describe('loop-owned request freezing', () => {
+  it('adopts restored identities, freezes nested messages at dispatch, and leaves event wrappers mutable', async () => {
+    const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three'), textResponse('four')]))
+    const id = SessionId('restored-freeze')
+    const seed = Session.create(id)
+    seed.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'restored user' }], source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    seed.append('assistant/message', {
+      turn: 1, step: 1,
+      message: createAssistantMessage({
+        content: [{ type: 'text', text: 'restored assistant' }],
+        source: { provider: 'mock', model: 'mock', replayState: { nested: ['opaque'] } },
+      }),
+      stream: [],
+    }, { surfaceOp: 'append' })
+    const events = structuredClone(seed.snapshotEvents())
+    const userEvent = events.find(event => event.type === 'user/message')!
+    const assistantEvent = events.find(event => event.type === 'assistant/message')!
+    Object.freeze(userEvent.data)
+    const freeze = vi.spyOn(values, 'deepFreeze')
+    const session = Session.fromRestore(id, events, {
+      id, version: SESSION_FORMAT_VERSION, createdAt: 1, cwd: '/test', isSeeded: false,
+    }, SessionLogOffset(0), 'detached')
+    const before = session.deriveMessages()
+    expect(before[0]).toBe(userEvent.data)
+    expect(before[1]).toBe(assistantEvent.data.message)
+    expect(Object.isFrozen(before)).toBe(false)
+    expect(Object.isFrozen(userEvent.data.content)).toBe(false)
+    expect(Object.isFrozen(assistantEvent.data.message)).toBe(false)
+    ctx.effect(() => ctx.sessions.enter(session))
+    const agent = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session)
+    cleanups.push(async () => {
+      agent.cancel({ kind: 'disposed' })
+      await agent.whenIdle()
+      await agent.scope.dispose()
+    })
+    const requests: GenerateOptions[] = []
+    const errors: unknown[] = []
+    ctx.on('agent/error', ({ error }) => { errors.push(error) })
+    ctx.on('llm/stream', (request, next) => {
+      expect(isAgentLoopRequest(request)).toBe(true)
+      expectFrozen(request)
+      requests.push(request)
+      return next()
+    })
+    await send(agent, 'first')
+    expect(errors).toEqual([])
+    expect(requests).toHaveLength(1)
+    const first = requests[0]!
+    expect(first.messages[0]).toBe(before[0])
+    expect(first.messages[1]).toBe(before[1])
+    expect(Object.isFrozen(userEvent)).toBe(false)
+    expect(Object.isFrozen(assistantEvent.data)).toBe(false)
+    expect(Object.isFrozen(assistantEvent.data.stream)).toBe(false)
+    userEvent.time += 1
+    assistantEvent.data.stream.push({ type: 'chunk', time: 2, chunk: { type: 'finish', reason: { kind: 'stop' } } })
+    before.pop()
+    const held = JSON.stringify(first.messages)
+    await send(agent, 'second')
+    expect(requests).toHaveLength(2)
+    expect(requests[1]!.messages).not.toBe(first.messages)
+    expect(requests[1]!.messages[0]).toBe(first.messages[0])
+    expect(requests[1]!.messages.length).toBeGreaterThan(first.messages.length)
+    const nodes = session.surface.nodes
+    const replacement = session.append('user/message', {
+      ...userEvent.data, content: [{ type: 'text', text: 'compacted' }],
+    }, {
+      surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
+      sourceEventSeqs: [nodes[0]!, nodes[1]!],
+    })
+    await send(agent, 'third')
+    expect(requests).toHaveLength(3)
+    expect(requests[2]!.messages[0]).toBe(replacement.data)
+    expect(requests[2]!.messages[0]!.id).toBe(first.messages[0]!.id)
+    expect(requests[2]!.messages[0]).not.toBe(first.messages[0])
+    expect(JSON.stringify(first.messages)).toBe(held)
+    expect(Object.isFrozen(session.deriveMessages())).toBe(false)
+    expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1)
+    expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1)
+    const resumed = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session)
+    cleanups.push(async () => {
+      resumed.cancel({ kind: 'disposed' })
+      await resumed.whenIdle()
+      await resumed.scope.dispose()
+    })
+    await send(resumed, 'fresh loop')
+    expect(requests).toHaveLength(4)
+    expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(2)
+  })
+
+  it('retries freezing an identity whose previous traversal failed', async () => {
+    const ctx = await harness(new MockAdapter([textResponse('done')]))
+    const agent = await ctx.agentLoop.create(SessionId('freeze-failure'), { provider: 'mock', model: 'mock' })
+    const message = agent.session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'history' }], source: { kind: 'user' },
+    }), { surfaceOp: 'append' }).data
+    const realFreeze = values.deepFreeze
+    let traversals = 0
+    vi.spyOn(values, 'deepFreeze').mockImplementation((value) => {
+      if (value === message && ++traversals === 1) throw new Error('freeze traversal failed')
+      return realFreeze(value)
+    })
+    const errors: unknown[] = []
+    const requests: GenerateOptions[] = []
+    ctx.on('agent/error', ({ error }) => { errors.push(error) })
+    ctx.on('llm/stream', (request, next) => { requests.push(request); return next() })
+    await send(agent, 'failed turn')
+    expect(errors).toEqual([new Error('freeze traversal failed')])
+    expect(requests).toHaveLength(0)
+    await send(agent, 'retry turn')
+    expect(requests).toHaveLength(1)
+    expect(traversals).toBe(2)
+    expect(requests[0]!.messages[0]).toBe(message)
+    expectFrozen(requests[0])
+  })
+
+  it.each([true, false])('freezes each local header with an adapter present: %s', async (registered) => {
+    const adapter = registered ? new MockAdapter([textResponse('one'), textResponse('two')]) : undefined
+    const ctx = await harness(adapter)
+    const schemas: ToolSchema[][] = []
+    const stops: string[][] = []
+    ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
+      const assembly = await next()
+      const tools: ToolSchema[] = [{ name: 'nested', description: 'test', parameters: {
+        type: 'object', properties: { value: { type: 'array', items: { type: 'string', enum: ['a', 'b'] } } },
+      } }]
+      schemas.push(tools)
+      return { ...assembly, tools }
+    })
+    ctx.on('agent/request', async (_payload, next) => {
+      const config = await next()
+      const stop = ['stop']
+      stops.push(stop)
+      return { ...config, stop }
+    })
+    const requests: GenerateOptions[] = []
+    const errors: unknown[] = []
+    ctx.on('agent/error', ({ error }) => { errors.push(error) })
+    ctx.on('llm/stream', (request, next) => {
+      expect(isAgentLoopRequest(request)).toBe(true)
+      expectFrozen(request)
+      requests.push(request)
+      return registered ? next() : (async function* () { yield* textResponse('virtual') })()
+    })
+    const agent = await ctx.agentLoop.create(SessionId('headers'), { provider: 'mock', model: 'mock' })
+    await send(agent, 'first')
+    await send(agent, 'second')
+    expect(errors).toEqual([])
+    expect(requests).toHaveLength(2)
+    for (const [index, request] of requests.entries()) {
+      expect(request.tools).toBe(schemas[index])
+      expectFrozen(schemas[index])
+      expect(() => request.stop!.push('mutate')).toThrow(TypeError)
+      if (!registered) expect(request.stop).toBe(stops[index])
+    }
+    expect(agent.session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1)
+    expect(agent.session.requestHeader()!.tools).not.toBe(requests[0]!.tools)
+    expect(agent.session.requestHeader()!.config.stop).not.toBe(requests[0]!.stop)
+  })
+
+  it('keeps the live request signal mutable and observes cancellation after dispatch', async () => {
+    const ctx = await harness(new MockAdapter(['hang']))
+    const agent = await ctx.agentLoop.create(SessionId('cancel-freeze'), { provider: 'mock', model: 'mock' })
+    const started = Promise.withResolvers<GenerateOptions>()
+    ctx.on('llm/stream', (request, next) => { started.resolve(request); return next() })
+    agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
+    try {
+      const request = await started.promise
+      expect(Object.isFrozen(request)).toBe(true)
+      expect(Object.isFrozen(request.signal)).toBe(false)
+      expect(request.signal!.aborted).toBe(false)
+      const aborted = Promise.withResolvers<undefined>()
+      request.signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
+      agent.cancel({ kind: 'user' })
+      await aborted.promise
+      await agent.whenIdle()
+      expect(request.signal!.aborted).toBe(true)
+      expect(request.signal!.reason).toEqual({ kind: 'user' })
+      expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
+        type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } },
+      })
+    } finally {
+      agent.cancel({ kind: 'disposed' })
+      await agent.whenIdle()
+    }
+  })
+})