Reuse only loop-proven message freezes
仅复用循环已证明的消息冻结
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
English
Problem
Long tool conversations repeatedly traverse immutable history while constructing requests. The backend continuation baseline 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 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 from run 34017868081 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. The historical 70 ms M4 expectation rounds above both optimized medians; applying the shared 2× CI scale and 1.25× headroom produced the 175 ms budget used in the table. These remain local reference measurements, not hosted-runner expectations. The explicit hosted calibration below owns the enforced request-history budget; 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 freeze-evidence 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.
Standard hosted CI calibration
The standard two-CPU ubuntu-24.04 lane runs Node 24.20.0. Run 34033336380, job 101487280801 measures the optimized request path in Azure eastus: 183.355397, 184.468253, 185.042397, 182.160790, 182.924728 ms; median 183.355397 ms. Every sample completes the same 40 requests and 13,923 events. All five exceed the historical 175 ms budget without changing the WeakSet implementation or workload.
A second hosted run of the same request implementation, run 34033336246, job 101487216170, records 145.644577, 144.204300, 143.072572, 145.985903, 146.834474 ms; median 145.644577 ms. It uses the same Ubuntu image and Node version but a different worker in Azure westus3. This faster run does not replace the eastus evidence or establish why the workers differ. The older self-hosted VM-7-113-ubuntu-ci-9 run with Node 24.18.1 (run 34021903421, job 101456015028) records 110.025154, 119.958978, 108.266860, 107.557950, 108.538902 ms; median 108.538902 ms. Its runner and Node version do not calibrate the standard hosted lane.
The current request-history median limit is 297 ms. It is the largest integer within a 25% increase from the initial 238 ms limit: floor(238 × 1.25) = 297, an increase of 24.79%. This allowance belongs only to agent-continuation/request-history; the shared time scale, variance headroom, other time limits, memory limits, sample count, and workload remain unchanged.
The standard GitHub Actions ubuntu-24.04 runner group reports the same image 20260831.293.1 and Node 24.20.0 for the two release measurements below. The workers differ (1000050430 and 1000050689), but their hardware and resource conditions are not established by the logs. The request-history runtime and workload are identical between the two heads: 800 historical turns, four tools per historical turn, 40 live requests, and 13,925 final events. The earlier reference run supplies another slow observation.
| Hosted measurement | Request-history raw totals (ms) | Median (ms) |
|---|---|---|
| PR #3797 merge (run 34233932940, job 102086694864) | 152.649609, 154.616595, 144.588531, 144.261013, 154.017377 | 152.649609 |
| PR #3799 merge (run 34235890227, job 102095345914) | 246.876615, 246.881047, 272.370218, 265.796833, 272.507507 | 265.796833 |
| Integration reference (run 34232504298, job 102084171717) | 279.689489, 297.792849, 263.178391, 252.660292, 251.267361 | 263.178391 |
The first two jobs also differ across tool continuation (483.877/698.657 ms), catalog (612.127/1009.367 ms), and profile continuation (2438.362/3854.950 ms). These observations establish broad hosted execution-time variation; they do not identify a hardware fault or a runtime regression. Among these four continuation scenarios, only request history crosses its limit in the slower release run.
A bounded profile of the PR #3799 merge on Apple M4 Pro / Node 24.19.0 retains five fresh-process totals: 70.198916, 67.432250, 65.151208, 66.473292, 71.049667 ms; median 67.432250 ms. Every sample completes the same 40 requests and 13,925 events. Sampling attributes 38.082 ms inclusive time to adapter dispatch, including 10.878 ms of required file-content traversal; system-node scanning takes 4.127 ms, while the one-time restored-event reversal takes 0.291 ms outside the timed turns. Removing the latter cannot explain the observed turn cost. Caching projected content or system nodes adds immutability or invalidation obligations beyond this bounded allowance. Runtime code is unchanged.
Deterministic controls call the timed case's assertRequestHistoryBudget. They accept the recorded 185.042397 ms maximum and the two slower hosted medians, while rejecting a synthetic 310 ms median from 308, 310, 312, 311, 309 ms inputs. The slower-host acceptance control reproduces 265.796833 > 238 before the allowance; the complete owner file passes 11 tests at 297 ms. Replaying recorded values validates the assertion, not a new hosted run. The historical 250 ms synthetic case and the 246.130875 ms original M4 measurement fit this allowance and are no longer rejection controls; original/optimized M4 measurements remain evidence of the freeze implementation's gain.
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 exercise shallow-frozen restored roots with mutable descendants, wrapper identity and mutability, successful-only freeze evidence, 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, 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.
中文
问题
长工具对话在构造请求时反复遍历不可变历史。后端续跑基线在一次 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(大语言模型)的文件、图像和回放投影保留各自的冻结,因为它们新生成的值没有循环本地证明。可重建请求决策继续拥有可观察的不可变性与基于日志的请求重建规则。
测量证据
Apple M4 Pro、macOS arm64、Node 24.19.0;worktree 使用独立依赖和构建产物。负对照重新构建运行 34017868081 中精确的父版本 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%。历史 M4 期望值 70 ms 向上取整并高于两次优化版中位数;应用共享 2× CI 系数和 1.25× 余量,得到表中使用的 175 ms 预算。这些仍是本地参考测量,而非托管运行器期望值。下文的显式托管校准拥有实际执行的请求历史预算;本文不改变其他场景或内存预算。
首个优化版时段还测量冷启动工具续跑:总耗时 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,但不属于校准证据。独占原版行替代该比较,没有删除受污染样本或通过取平均掩盖它们。
标准托管 CI 校准
标准双 CPU ubuntu-24.04 测试通道运行 Node 24.20.0。运行 34033336380、任务 101487280801 在 Azure eastus 上测量优化请求路径:183.355397, 184.468253, 185.042397, 182.160790, 182.924728 ms;中位数 183.355397 ms。每个样本都完成相同的 40 个请求和 13,923 个事件。在 WeakSet 实现与工作负载未变的情况下,全部五个样本均超过历史 175 ms 预算。
相同请求实现的另一次托管运行,运行 34033336246、任务 101487216170,记录了 145.644577, 144.204300, 143.072572, 145.985903, 146.834474 ms;中位数 145.644577 ms。它使用相同的 Ubuntu 镜像和 Node 版本,但运行于 Azure westus3 的另一台工作机。较快的运行不能替代 eastus 证据,也不能证明工作机差异的原因。较早的自托管 VM-7-113-ubuntu-ci-9 运行使用 Node 24.18.1(运行 34021903421、任务 101456015028),记录了 110.025154, 119.958978, 108.266860, 107.557950, 108.538902 ms;中位数 108.538902 ms。其运行器和 Node 版本不能校准标准托管通道。
当前请求历史中位数上限为 297 ms。这是在最初 238 ms 上限基础上增加不超过 25% 的最大整数:floor(238 × 1.25) = 297,增加 24.79%。该余量仅属于 agent-continuation/request-history;共享时间系数、波动余量、其他时间上限、内存上限、采样次数与工作负载均保持不变。
下面两次 release 测量都来自标准 GitHub Actions ubuntu-24.04 运行器组,记录相同镜像 20260831.293.1 与 Node 24.20.0。工作机不同(1000050430 与 1000050689),但日志没有证明其硬件及资源条件。两个 head 的请求历史运行时代码和工作负载一致:800 个历史轮次、每个历史轮次四个工具、40 个实时请求、最终 13,925 个事件。较早的参考运行提供另一次较慢观测。
| 托管测量 | 请求历史原始总耗时(ms) | 中位数(ms) |
|---|---|---|
| PR #3797 merge (run 34233932940, job 102086694864) | 152.649609, 154.616595, 144.588531, 144.261013, 154.017377 | 152.649609 |
| PR #3799 merge (run 34235890227, job 102095345914) | 246.876615, 246.881047, 272.370218, 265.796833, 272.507507 | 265.796833 |
| 集成参考 (run 34232504298, job 102084171717) | 279.689489, 297.792849, 263.178391, 252.660292, 251.267361 | 263.178391 |
前两次任务的工具续跑(483.877/698.657 ms)、catalog(612.127/1009.367 ms)和 profile 续跑(2438.362/3854.950 ms)也存在差异。这些观测证明托管执行时间存在广泛波动,不能据此断言硬件故障或运行时回归。在上述四个续跑场景中,较慢的 release 运行仅请求历史超过其上限。
对 PR #3799 合并结果 在 Apple M4 Pro / Node 24.19.0 上做的有界性能分析保留五次新进程总耗时:70.198916, 67.432250, 65.151208, 66.473292, 71.049667 ms;中位数 67.432250 ms。每个样本均完成相同的 40 个请求和 13,925 个事件。采样将适配器分派的包含后代耗时记为 38.082 ms,其中必需的文件内容遍历占 10.878 ms;system 节点扫描占 4.127 ms,一次性的恢复事件倒序则在计时轮次之外占 0.291 ms。删除后者不能解释观测到的轮次耗时。缓存投影内容或 system 节点会引入超出本次有界余量调整的不可变性或失效管理义务。运行时代码保持不变。
确定性对照调用计时场景使用的 assertRequestHistoryBudget。它们接受已记录的 185.042397 ms 最大值和两次较慢托管中位数,同时拒绝由 308, 310, 312, 311, 309 ms 输入得到的合成 310 ms 中位数。较慢运行器接受对照在增加余量前复现 265.796833 > 238;完整所属文件在 297 ms 下通过 11 个测试。回放已记录数值验证的是断言,而非新的托管运行。历史合成 250 ms 场景与原版 M4 的 246.130875 ms 测量符合该余量,不再作为拒绝对照;原版/优化版 M4 测量仍保留为冻结实现收益的证据。
考虑过的替代方案
Object.isFrozen 为真时立即返回。 已冻结根对象不能证明其后代已冻结。在共享辅助函数中使用此捷径会削弱所有调用方,包括恢复与投影路径。
信任所有 Session 消息或缓存消息 id。 恢复明确允许拥有独立所有权的未冻结数据;替换操作可保留 id,同时改变对象身份与内容。只有对该精确对象完成遍历才能证明请求要求。
保留强引用 Set 或共享全局证明缓存。 强引用会延长旧历史的生命周期。全局缓存将所有权扩大到 agent(智能体)之外,对同一循环的重复请求并无必要。
移除下游投影冻结。 投影后的文件/图像/回放消息是拥有独立所有权的不同值。优化它们需要独立证据,不能由规范历史已冻结推导出来。
影响
请求构造仍扫描消息身份并分配新数组,但避免递归遍历已证明的历史。每个循环都为恢复历史支付一次完整遍历成本。本地 header 仍是每次请求的成本。消息值、请求标记、先前请求快照、取消及 SDK 序列化输出保持现有行为。
聚焦测试覆盖具有可变后代的浅冻结恢复根对象、包装对象身份与可变性、仅成功遍历的证明、重复请求、同 id 压缩替换、新循环、嵌套工具 schema、适配器与 NO_ADAPTER stop 数组、持有的旧请求以及实时取消。重建与取消测试集覆盖相邻循环语义。性能测量采用不变的续跑工作负载,而非缩小的合成微基准。
验证运行了 646 个 agent loop 与 LLM 测试,agent.ts 的语句、分支、函数和行覆盖率均为 100%。无密钥 TypeScript SDK bash-tool 与 multi-turn 快照通过重新构建的库执行并通过。Python sdk-minimal 与 sdk-snapshot 检查使用独立打包的 node24-macos-arm64 可执行文件并通过。两个 SDK 均无需修改期望输出。打包部署暂时移除了工作区依赖链接;执行冻结 lockfile 安装可在源码检查前恢复它们,无需修改受版本管理的依赖文件。
现行不可变性、消息身份、可观察状态机和后端基线说明仍各自具有价值;没有说明被完全取代或归档。本文专门规定请求冻结机制,并与可重建性所属说明交叉链接。