Skip to content

♻️ fix(sdk): engine 收敛批——惰性 token 估算/stableSerialize 单源/双循环合并/低危积压六项 - #467

Merged
CavinHuang merged 8 commits into
mainfrom
worktree-agent-af090453fcb4c216f
Aug 23, 2026
Merged

♻️ fix(sdk): engine 收敛批——惰性 token 估算/stableSerialize 单源/双循环合并/低危积压六项#467
CavinHuang merged 8 commits into
mainfrom
worktree-agent-af090453fcb4c216f

Conversation

@CavinHuang

Copy link
Copy Markdown
Owner

概要

四个 sdk engine 域 issue 的收敛批次,每 issue 一个 commit。

Fixes #386 — getContextUsage 惰性化

  • 每 run finally 不再对全量 messages 逐 block JSON.stringify 估算 token:lastContextUsage 快照改为保留引擎引用 lastUsageEngine,估算延迟到宿主实际调用 getContextUsage() 时执行。
  • sidecar 对该 API 零调用,此前每轮 run 收尾都在白算约 150 行估算。
  • 测试:spy 断言 run 结束时估算次数为 0,按需调用才计算(agent.test.ts)。

Fixes #391 — stableSerialize 单源化

  • @lume/shared 新增 stableSerialize(递归排序键 + 过滤 undefined + 保序数组),黄金值测试钉死字节输出。
  • 迁移两份逐字等价副本:
    • packages/sdk/src/engine.ts 的 stableSerialize(repeat guard 签名)
    • apps/sidecar/src/services/agent/agent-submission-store.ts 的 stableStringify(payload hash)
  • submission store 副本原用 localeCompare 排序(依赖运行环境默认 locale,跨机器不确定),统一为码点序;对照实证实际 payload 键集上输出逐字节一致,现有测试全绿。
  • 浅排序/语义变体未迁(保持原样):
    • apps/sidecar/src/services/agent-runtime/permissions/permission-rules.ts:109 stableStringify——只排一层、不递归、不滤 undefined
    • apps/sidecar/src/services/memory-v2/claim.ts:257 stableStringify——单层 map 成 [key,value] 数组再 stringify
    • packages/sdk/src/plugins/permissions-hash.ts:98 canonicalize + :131 stableStringify——递归排序但返回对象、不过滤 undefined(两段式)
    • apps/sidecar/src/services/browser/browser-broker.ts:425 stableJson——递归但不滤 undefined

Fixes #393 — executeTools 双循环收敛

  • Skill 混批与 serial 两循环共享的软中断 break / guard-stop 占位 / 中断兜底 / events+toolsUsed 扇出提取为同函数作用域局部闭包 runSequentialItem,不新建模块。
  • repeatGuardStop 提升为跨阶段单一声明。
  • 消除"preCheck 仅在不执行时返回空 toolsUsed"的隐式不变量:guard 拒绝路径的空扇出改在 executeToolWithRepeatGuard 唯一出口显式构造,preCheck 返回类型收窄,concurrent 批删除恒空 push。
  • 纯重构行为等价,engine.test.ts 全量 61 pass 验证。

Fixes #413 — 低危积压六项

  1. session_state_changed 死事件整体下线:grep 实证 sidecar/desktop/web/shared 零非测试消费,删四处发射点 + 类型 + 导出,不再每轮持久化进 sessionMessages jsonl。
  2. SDKResultMessage.subtype 补引擎真实发射值 'error_completion_guard' 并删 | string;顺带修正 sidecar 测试 mock 中非法的 "error" subtype 为 error_during_execution。
  3. queuedSdkEvents 跨 run 泄漏:宿主中途放弃生成器时 finally 清空队列;尾部 drain 前置堵住最后窗口。补放弃/完整消费双向测试。
  4. natives countTokens 降级路径补 try/catch → null,对齐同文件其余 API 契约。
  5. nativeSearch 公开签名补 multiline 参数(toNativeSearchOptions 已支持)。
  6. findModelMeta("") 空串入口守卫,不再经 startsWith("") 恒真命中第一条注册表记录;补测试断言。

验证

  • bun run typecheck 六包全绿(shared/ui/agent-sdk/sidecar/web/desktop)
  • sdk engine+agent 108 pass;sdk 其余相邻文件 18 pass
  • sidecar agent-service + submission-store 33 pass
  • shared 全套 184 pass;natives 3 pass

🤖 Generated with Claude Code

TaTaLiao and others added 4 commits August 23, 2026 01:06
finally 只保留引擎引用,全量 messages JSON.stringify 估算改到宿主
实际调用 getContextUsage 时执行。sidecar 对该 API 零调用,此前每轮
run 收尾都在白算 ~150 行估算。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
递归排序键 + 过滤 undefined + 保序数组的 JSON 规范化序列化此前在
sdk engine(repeat guard 签名)与 sidecar submission store(payload
hash)各有一份手写实现。抽到 @lume/shared 单源,黄金值测试钉死
字节输出。

submission store 副本原用 localeCompare 排序,依赖运行环境默认
locale,跨机器不确定;统一为码点序后对实际 payload 键集(camelCase
标识符)输出逐字节一致,已对照实证。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Skill 混批与 serial 两个循环各自维护的软中断 break / guard-stop
占位 / 中断兜底 / events+toolsUsed 扇出提取为同函数作用域的局部
闭包 runSequentialItem,repeatGuardStop 提升为跨阶段单一声明。

顺带消除"preCheck 仅在不执行时返回空 toolsUsed"的隐式不变量:
guard 拒绝路径的空扇出改在 executeToolWithRepeatGuard 唯一出口
显式构造,preCheck 返回类型收窄,concurrent 批删除恒空 push。

纯重构,行为等价,靠现有 engine/abort 测试验证。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1. session_state_changed 全域零消费:删四处发射点、类型与导出,不再每轮持久化进 sessionMessages jsonl。
2. SDKResultMessage.subtype 补 'error_completion_guard' 并删 '| string';sidecar 测试 mock 的非法 "error" 改为真实发射值 error_during_execution。
3. 宿主中途放弃生成器时 finally 清空 queuedSdkEvents,迟到异步事件不再混入下一 run;尾部 drain 前置堵住最后窗口。
4. natives countTokens 降级路径补 catch→null,对齐同文件契约。
5. nativeSearch 公开签名补已支持的 multiline 参数。
6. findModelMeta("") 空串入口守卫,不再命中第一条注册表记录。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@CavinHuang

Copy link
Copy Markdown
Owner Author

Review:#386 / #391 / #393 / #413 收敛批

结论

可合。四个 commit 与四个 issue 一一对应,无 scope creep,文案合规(无来源性表述),CI 4/4 绿。逐条实证后未发现阻塞项;2 条 P3 跟进 + 若干观察记录如下。


发现

[P3] #386 —— lastUsageEngine 导致空闲期消息历史双份驻留

Agent.close()(agent.ts:1466)不清 lastUsageEngine(也不清 history/sessionMessages,后者系既有行为)。关键在于 QueryEngine.getMessages() 返回的是副本(engine.ts:2392 return [...this.messages]):改动前 finally 里 history = engine.getMessages() 后 engine 失去引用、其内部 messages 数组随之回收;改动后 engine 整体被 lastUsageEngine 钉住,空闲期同一份历史以「history 副本 + engine 内部活数组」双份驻留,长会话下近似翻倍。若宿主丢弃 Agent 则两者一同回收,无外部根泄漏;但 close() 语义上应释放资源。建议:close() 里置 this.lastUsageEngine = null,或让 getContextUsage 从 this.history 惰性计算而不保留引擎。

另注:每次 getContextUsage() 调用现在都重新全量 stringify(原先 O(1) 读缓存)——这是本 issue 的预期取舍(高频轮询方需自行缓存),仅记录。

[P3] #413③ —— 「放弃→迟到事件→下一 run」交错序列未完全封死

queuedSdkEvents 唯一 push 点是引擎配置的 onAsyncEvent 回调(agent.ts:983 附近),其中 executeSingleTooltoolCallActive=false 分支(engine.ts:2050 一带)专门承接工具返回之后才到达的后台 task_notification——该闭包在引擎存活期间任意时刻可触发,而 submitMessage 没有 finally 拆除此通道(generator 被 return 时后台任务不终止)。因此:放弃时已入队的事件会被 finally 清掉(✅ 本 PR 修的部分);放弃之后迟到的后台完成通知仍会入队并漏进下一 run 的流。此为优于 main 的改进而非回归(main 连已入队的都漏),残余窗口属既有行为;但 PR 文案「堵住最后窗口」易读作全封。建议 follow-up:run 代际标记(epoch/generation),onAsyncEvent 入队前校验代际。现有双向测试直接 push 队列模拟,未覆盖真实迟到回调路径。


观察记录(不计级)

  1. stableSerialize 黄金测试缺非 ASCII 键用例:stable-serialize.test.ts 五个用例全为 ASCII 键。码点序与 localeCompare 的分野恰在非 ASCII(如 {"é":1,"z":2} locale 下 é 排 z 前、码点序排 z 后),建议补一条钉死码点序,防将来有人「修回」localeCompare。
  2. submission-store 统一后的隐性语义差(超出文案所述排序口径):旧 store 版对顶层 undefined/函数返回 "null"、数组内嵌 undefined 元素也序列化为 "null";共享版采用 engine 语义(?? String(value)"undefined" 等)。实测 payload 顶层恒为对象字面量、数组元素为类型化对象/字符串,运行期不可达,仅记录在案。
  3. subtype mock 残留:sidecar runtime-core/mock-attempt.ts 三处 type: "result" 发射完全不带 subtype 字段(宽松类型下既有问题);本 PR 已顺手修正 agent-service.test.ts 的 "error" 非法值,mock-attempt 这处留待后续。
  4. executeTools 双循环中断兜底/扇出/stop 传递逻辑重复,可收敛 #393tools.find 从守卫检查后移到调用参数处求值——纯微 perf 差异,无行为影响。

交叉风险:与 PR #469(协议类型下沉单源)


已核实(逐条实证)

  1. [P2][sdk/engine] 每 run finally 全量 JSON.stringify 估算 token,唯一消费者是宿主零调用的 getContextUsage #386 消费面:grep 全仓,getContextUsage/lastContextUsage 非 test 消费仅在 packages/sdk 内部(agent.ts 自身 + query-controller.ts 转发 + README 文档),desktop/web/sidecar 零直接调用;auto-compact 阈值判断走 engine 内部自有用量状态,不经 agent 快照。无数值漂移(run 结束后 engine.messages 不再变化,惰性计算与原快照等价;连续两 run 时引用被最新 engine 覆盖,无 stale 读)。
  2. stableSerialize 多份手写规范化序列化副本应抽公共 util #391 字节一致论证成立:hashAgentSubmission payload 顶层 13 键(threadId/userMessage/messageParts/messageAttachments/commentAttachments/browserAttachments/modelRef/channelId/modelId/permissionMode/thinkingLevel/workspaceId/messageMetadata)+ 嵌套键全为代码定义的 ASCII 驼峰,不存在 localeCompare 与码点序会分叉的大小写镜像对;持久化 hash 跨部署稳定,去重回执不受影响。
  3. executeTools 双循环中断兜底/扇出/stop 传递逻辑重复,可收敛 #393 行为等价:main 上混批分支自包含且无条件提前 return(两个循环共用分支内局部 repeatGuardStop,永不落入后续并发/串行阶段)→ 统一为函数级单变量无跨分支效应;concurrent 批删除的 events.push(...blocked.events) 中 blocked.events 恒为 [](preCheck 唯一构造点);中断兜底的 abortedPendingToolCalls.push + createInterruptedToolResult + 空 events/toolsUsed 逐字段一致;事件扇出顺序不变;preCheck 收窄零信息损失(events/toolsUsed 本就是常量空数组)。
  4. [P3][sdk/shared/natives] 低危积压批次 #413① 死事件session_state_changed 全域 grep 仅 engine.ts 四处发射 + types.ts 定义 + engine.test.ts,desktop/web/shared/sidecar 零非测试消费;init 成为首个事件后无任何消费方依赖先前首事件身份。
  5. [P3][sdk/shared/natives] 低危积压批次 #413② 联合完整性:engine 全部 result 发射点枚举(手动压缩路径 success/error_during_execution、provider 错误 error_during_execution、收尾七元选择链)恰为 7 值,与新联合逐一对应,无遗漏发射值;web lifecycle-event-adapter 的 error_completion_guard 是 stopReason 面、字面量一致。
  6. [P3][sdk/shared/natives] 低危积压批次 #413:findModelMeta("") 原经 startsWith("") 恒真命中首条记录属实,守卫 + 测试到位;natives toNativeSearchOptions 本就支持 multiline(index.ts:262/329),公开签名补齐正确;countTokens catch→null 与同文件其余 API 契约一致。

#386 为惰性估算保留了 QueryEngine 引用,但 Agent 的关闭路径未清它,
engine 连同内部 messages 历史双份驻留到 Agent 生命周期结束。close()
现在置空该引用,getContextUsage 落回零值安全路径,不再触发估算。
executeSingleTool 的后台 task_notification 闭包可在宿主放弃迭代之后
迟到触发,#413③ 的 finally 清空与 drain 前置都堵不住这个窗口。引入
asyncEventEpoch:run 的 finally 递增,各 run 的 onAsyncEvent 闭包捕获
创建时的代际,入队前比对,过期代际直接丢弃——run 结束后任何迟到
事件都不再存活到下一 run 的 drain 窗口。
中文键/emoji 键/大小写混合键 {aZ,Az,az,AZ} 正是 localeCompare 与
码点序的分叉点;黄金值钉死排序字节输出,防止未来误改为 locale 排序。
@CavinHuang

Copy link
Copy Markdown
Owner Author

review 三处已修(969a4ea1 / 22e8134 / 4677563):

  1. P3-1 引用滞留Agent.close() 置空 lastUsageEngine,run 间空闲期不再驻留 engine 及其内部 messages 历史;close 后调 getContextUsage 落回零值安全路径,不触发估算(新增测试断言 spy 零调用 + totalTokens 为 0)。
  2. P3-2 迟到事件残窗:引入 run 代际标记 asyncEventEpoch——run 的 finally 递增,各 run 的 onAsyncEvent 闭包捕获创建时的代际,入队前比对,过期直接丢弃。相比只在 run 开始递增,finally 递增把「run 结束 → 下一次 run 启动」之间的窗口也一并关死。测试直呼捕获到的通知闭包模拟迟到回调,断言事件不入队、下一 run 开头无残留。
  3. 观察项补测:stableSerialize 黄金值补非 ASCII 键(中文键、emoji 键、{aZ,Az,az,AZ} 大小写混合),钉住码点序口径(localeCompare 分叉点)。

bun run typecheck 六包绿;agent.test.ts 49/49、task_notification 管道回归(bash/agent-tool.parallel/engine)98 过 0 挂、stable-serialize 6/6。

@CavinHuang
CavinHuang merged commit 7704ccc into main Aug 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants