thincoder 0.12.54 → 0.12.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/bin/thincoder.mjs +17 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +1 -1
- package/src/advisor/messages.mjs +4 -2
- package/src/advisor/run.mjs +2 -2
- package/src/agent/dispatch.mjs +66 -26
- package/src/agent/helpers.mjs +13 -2
- package/src/agent/setup.mjs +14 -2
- package/src/agent/spawn-child.mjs +3 -1
- package/src/agent-tools/advisor.mjs +19 -9
- package/src/agent-tools/eng.mjs +2 -0
- package/src/agent-tools/subagent-check.mjs +107 -0
- package/src/agent-tools/subagent.mjs +205 -42
- package/src/agent.mjs +68 -3
- package/src/cli/make-agent.mjs +25 -0
- package/src/cli/memory-command.mjs +28 -7
- package/src/config.mjs +120 -8
- package/src/context.mjs +28 -7
- package/src/escape.mjs +76 -23
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/core.mjs +78 -10
- package/src/memory/docs.mjs +33 -7
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +23 -0
- package/src/prompts/discipline.md +15 -1
- package/src/prompts/engineering.md +62 -5
- package/src/prompts/main.md +1 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +48 -26
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session.mjs +15 -0
- package/src/tools/apply_patch.md +2 -0
- package/src/tools/bash.md +2 -2
- package/src/tools/edit-batch.mjs +104 -0
- package/src/tools/edit.md +3 -0
- package/src/tools/execute.md +4 -4
- package/src/tools/execute.mjs +14 -22
- package/src/tools/file.mjs +17 -55
- package/src/tools/file_ops.md +1 -1
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +8 -16
- package/src/tools/lint.md +1 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/patch.mjs +1 -1
- package/src/tools/shared.mjs +7 -20
- package/src/tui/agent-turn.mjs +3 -3
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +255 -114
- package/src/tui/index.mjs +25 -5
- package/src/tui/interaction.mjs +28 -1
- package/src/tui/key-handler.mjs +14 -2
- package/src/tui/mouse.mjs +1 -1
- package/src/tui/pickers.mjs +62 -4
- package/src/tui/render-frame.mjs +18 -10
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +4 -2
- package/src/tui/subagent-blocks.mjs +119 -4
- package/src/tui/tool-events.mjs +60 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
+
## [0.12.58] — 2026-09-02
|
|
2
|
+
|
|
3
|
+
### Changed
|
|
4
|
+
|
|
5
|
+
- **开发体验三项(用户需求批 2026-09-02,两端)**:
|
|
6
|
+
① **lint 零依赖化**(TOOLS.md §10.2):eslint 全套删除(devDependencies + eslint.config.mjs + 级联分支),lint = `node scripts/check-syntax.mjs`(node --check 遍历 src/test/bin/scripts 含自检,零依赖);CLI-LINT-REQUIREMENTS/TUNING.md 标记被取代;package-lock 再生(eslint 树移除)
|
|
7
|
+
② **工具作用域限制全部移除**(§10.1):resolveInCwd 去边界断言(信任模型 + 权限门禁为唯一防线,与 bash 一致);git workdir / execute scriptFile / file_ops 目录限制一并移除;工具描述与提示词 "confined to workspace" 措辞清理(两端 byte-identical)
|
|
8
|
+
③ **模型上下文长度可配置**(PROVIDER.md §15):`providers[].context`(K 单位,如 128 = 128K)覆盖 MODEL_SPECS——providerSpec 拷贝覆盖不污染共享 spec;压缩阈值(auto ×0.6)/ TOKEN 窗口 / 状态栏显示 / advisor 预算全链路跟随;数字字符串("128")两端统一归一;非法值忽略 + 警告一次;CLI /model 管理流 + VS Code settings.json 配置界面
|
|
9
|
+
|
|
10
|
+
## [0.12.57] — 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **subagent 异步化:真后台并行**(AGENT-LOOP.md §15,两端):subagent 工具加 `async: true`——spawn 立即返回 `{id, status:"running"}`,主会话可继续自己的回合;新增 `subagent_check` 工具(arrival order 先完成先取 / 带 id 等待 / n 递增校验防循环 / readonly);**槽位队列**:并发上限 4,超限入队(position 可见),running 完成即腾槽补位(不拒绝、不分批);回合收尾自动等待全部完成并注入报告(XML 转义 + 超长预览落盘);Ctrl+C 清空不注入、ContinueError 状态保留;async 仅 depth-0、后台撞 turn-cap 自动拒绝继续
|
|
15
|
+
- **approval 批确认**(AGENT-LOOP.md §16.1,两端):同批多个非只读工具一次合并询问(approve all / one by one / deny;deny 全批拒绝无二次询问;无 handler 回退逐项;onPermissionRequest 签名不变)
|
|
16
|
+
- **批量形态引导**(§16.2,数据驱动——真实使用 94.6% 单条 edit / apply_patch 0 次 / 35 例手工批量):edit 描述强化 edits 数组原子批量、apply_patch 补多文件新建场景、system.md 并行条款扩展批量句(两端 byte-identical)
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- 工程模式并发纪律上限 3 → 4(engineering.md + ENGINEERING-MODE.md FR8/决策③ 三处同步)
|
|
21
|
+
- VS Code 端同批对齐:escape v5 / UTF-16 安全截断 / 续写构造 / 压缩可见性(见 thincoder-vscode CHANGELOG 0.12.57)
|
|
22
|
+
|
|
23
|
+
## [0.12.56] — 2026-09-02
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **上下文压缩面板 + 压缩失败可见性**(CONTEXT-COMPACTION.md §7):压缩开始即弹"Compressing…"面板区块(复用子 agent 面板机制:耗时 ticker + summarizing N messages)→ 完成态 `Compressed: N tokens freed → summary (Xs)` 可折叠冻结;失败态显示错误文本,连续 3 次失败后 compressFallback 截断兜底并显示降级说明;摘要正文永不进面板/会话流;headless 回调缺省 no-op
|
|
28
|
+
- **DeepSeek prefix 续写 400 止损**(PROVIDER.md §14):续写请求精简历史(过滤 tool/assistant(tool_calls) 消息,保留 system + 最近 ≤8 条文本)——真机矩阵实证:thinking 模式 prefix 续写 + 工具链消息必 400(补不补 reasoning_content 分别报 Function call / reasoning_content 错误),纯文本历史 200;续写失败注入 `_warnings` 不再静默飞出;partial 模式不受影响
|
|
29
|
+
- **会话恢复 provider/model 缺失 → 模型重选**(SESSION.md §8):CLI 启动校验 provider/model/baseURL 缺失 → 不再崩溃退出;TUI 首帧弹模型选择(复用 picker),Esc 仍进 TUI + 提示行;headless 可读错误 + 退出码 1;判据仅空缺失(MODEL_SPECS 未知不判无效——自定义模型保护)
|
|
30
|
+
- **MCP save&test 确认问句废除**(MCP.md §5 变更段):探活成功直接保存(删 `Save? (Y/n)`);探活失败报错回表单且无任何保存通道(save-anyway 整个废除);取消仅剩表单 Esc
|
|
31
|
+
- **搜索工具优先级条款**(PROMPT-DECOUPLING.md):discipline.md + engineering.md 行为规则——有 MCP 搜索工具优先用 MCP、websearch 仅备用;websearch 连续 2 次垃圾即切;被墙站点走镜像路径;动手抓页面前先扫工具表(两端 prompts byte-identical)
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **hex-escape 400 真凶根治(escape.mjs v5)**:2026-09-02 实锤——`unexpected end of hex escape` 400 的毒源**不是字面 hex 转义序列**,而是 **doc_search 预览 slice 按 UTF-16 码元截断切断了 emoji 代理对**(🔴 → 孤立高代理 D83D)→ deepseek 严格 UTF-16 解码 400。两层修复:① 发送前净化(sanitizeLoneSurrogates:孤立代理 → U+FFFD,全字段)+ hex 转义 odd-run 修复(v1-v4 的 double/替换方向全错,本版为对象层面正解);② 源头 UTF-16 安全截断(setup.mjs doc_search 预览 + helpers.mjs offloadToolResult 截断点落高代理时向前收一个码元)。验证:真实会话 953 条(含孤立代理)重放 400→200,带 thinking:enabled 6/6 全 200
|
|
36
|
+
- **MCP 磁盘无 mcp 段时 remove/edit 崩溃**(code review #1/#2):persistRaw 建段守卫(`raw.mcp ??=`)——磁盘 mcp 段被整体删除而连接保留(T23 场景)时,remove 不再 TypeError、edit 不再静默丢("updated" 提示与落盘一致)
|
|
37
|
+
|
|
38
|
+
## [0.12.55] — 2026-09-01
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **`/mcp` edit/add 统一字段 picker 表单(v2)+ agent 代配闭环**(docs/design/MCP.md §5,CLI-only):① **edit = 字段选择表单**——picker 列可编辑字段行(`HTTP URL https://…` / `Token d90c26bb…` 打码 / `Headers 2 items`;stdio `Command/Args/Env`;name 不可改无行)+ 末行 `✓ Save & test`;选中字段只输入该字段新值(提示 `(current: …)`——空=不变、`-`=删可选字段、`k=`=删 header/env 项、required 字段拒绝 `-`)→ 回 picker 循环连改多字段(中间 Esc 回 picker 不丢已改值);**废除逐字段预填重问**(改 token 不再被迫路过 URL/headers);② **add 复用同一表单**——空 entry 起、必填字段 `(required)` 标注、Save 校验必填非空(未满足提示并停留表单不落盘)、headers/env 不选即跳过、add 的 name 可编辑(重复名检查);③ **保存前预览 + 探活 + 字段级重试合一**——`showPreview`(token 遮蔽)→ `probeMcpServer` 零副作用探活(`✓ N tools, Xms` / `✗ 错误`)→ `Save? (Y/n)`;探活失败 → `Save anyway? (y/N)` 显式 y,否则**回同一字段表单**只重输失败字段复 probe(**独立 retry 路径废除**,不重启流程;save-anyway 显式 y 保留);④ **表单文件拆分**——新增 `src/tui/cmd-mcp-form.mjs`(fieldPicker 机制独立文件;`cmd-mcp.mjs` 499→382 行,脱离 500 硬限压线);⑤ **列表即菜单**——主菜单 = server 行(●/○ 连接态 + tool 数)+ `+ Add server` + `↻ Refresh` + 顶部 agent 代配提示行;选中行 → per-server 子菜单(Edit/Test/Reconnect/Remove),"先选操作再选 server"双弹层与 View list 废除;⑥ **磁盘重读**——`config.mjs` 新增 `reloadMcpFromDisk()`(菜单打开边界 + Refresh):磁盘→内存仅替换 mcp 段;畸形 config.json 回退内存态 + `⚠ disk config unreadable` 提示行;disk 删除/变更的已连接 server 连接不断 + 行尾 `⚠ disk changed` 对账标记(persistRaw 落盘后重读幂等防环);⑦ AI 生成降 transport picker 末位(生成的 entry 同走预览+探活确认环,失败回表单补齐/修正);`/mcp edit|test|remove|connect <name>` 直达参数路径零改动
|
|
43
|
+
- **MCP Streamable POST 误判修复 + `/mcp edit`/`/mcp test` + token 一等字段**(docs/design/MCP.md §4,两端落地):① `httpTransport` 增 postOnly 标记——GET SSE 405 降级后的纯 POST 模式 `isAlive()` 不再因 `eventSource == null` 误判死(glm-websearch "reconnect failed after 4 attempts" 根因:降级后 isAlive 恒 false → ensureAlive 触发无意义重连循环;legacy SSE 流断仍正常 fireDead 重连不回归);② `/mcp edit [name]`:逐字段预填重问(空输入保留 / `-` 删除可选字段 / `k=` 删除单个 header 项),persistRaw 原位替换保数组序,保存后自动重连(config 指纹含 token,变更自动关旧连接);③ `/mcp test [name]`:probeMcpServer 一次性探活(initialize + tools/list 计时 → `OK — N tools, Xms` / 错误透传),零副作用(不进 session 表、不动 agent.tools、探完即关);④ token 一等字段:config 增 `token` 字段,connect 链自动合成 `Authorization: Bearer <token>`(显式 headers 优先,不写回 config);⑤ parseHeaders 改逗号分隔(`Authorization=Bearer abc, X-Foo=bar`——修复空格截断把 Bearer token 截成 "Bearer" 的缺陷);⑥ VS Code 同构:http transport 同款修复 + probeMcpServer 镜像 + 面板 [Edit]/[Test] 按钮(同一表单编辑预填,token 字段 + 逗号分隔 headers 提示)+ **面板 [Reconnect] 死按钮修复**(webview 发的 `reconnectMcp` 消息在路由拆分时丢失 case,按钮此前无效)
|
|
44
|
+
- **memory_delete 工具**(跨端):三层记忆条目删除——personal 行级删(embedding/FTS 随行)、project/team 文件级删;scope 与 id 前缀匹配校验,非法 scope 明确报错
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **multi-design 并行令牌(designId slots)**:eng-coder 子 agent 支持 `{designId, token}` 多槽并行 spawn——各设计独立令牌互不覆盖,复审不通过的设计不挤占既有槽(CLI 与 VS Code 镜像)
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **edit 数组形态同文件串行**:同一文件多条 edit 的 raw 域快照随条目推进,第二条不再漂移(编辑器 CRLF 路径 + 磁盘路径双修复)
|
|
53
|
+
- **MCP tools/list 分页超时约束**(MCP.md §4 评审 #8):每页同受 INIT_TIMEOUT_MS 约束——probe 延迟统计有界
|
|
54
|
+
- **MCP 握手失败 transport 泄漏**(评审 #7):GET SSE 降级成功但 POST initialize 失败时关闭 transport,不留悬挂流
|
|
55
|
+
- **hex-escape 毒载荷 400 根治(escape.mjs v3)**:v2 的 lookbehind 单字符判定与 hex 窗口越界缺陷在长会话(讨论转义主题)下漏中和 → deepseek 等网关二次解析报 "unexpected end of hex escape";v3 数反斜杠 run 奇偶 + 窗口越界修复(`\\x/\\u` 相邻双写)+ 孤立代理对(`\\uD83D` 无配对 strict JSON 解析拒绝)预 double;真实会话 74 处毒点全量中和为 0,11 个 case 锁定(含 v1 行为兼容回归)
|
|
56
|
+
|
|
1
57
|
## [0.12.54] — 2026-09-01
|
|
2
58
|
|
|
3
59
|
### Added
|
|
@@ -10,6 +66,7 @@
|
|
|
10
66
|
|
|
11
67
|
- **跨端会话共享一致性(会诊 4 模型收敛)**:sessionStart 打点(跨端同槽不再 F2 互轮转);F2 写前磁盘校验(同会话并发追加 → 轮转 .bak 保留);legacy transient 双端过滤;contextHistory 机读线判定(length>0);activeModel 双向;cwd 先行校验;newSession 死主清理落盘(deletions)
|
|
12
68
|
- **checkpoint cwdHash 归一化**:`sha1(normalizeCwd(cwd)).slice(0,12)`——CLI/VS Code 快照跨端互通(存量旧路径孤儿化不迁移)
|
|
69
|
+
- **操作并行化纪律提示词条款**(2026-09-01 用户需求):system.md "How you work — while coding" 段在既有并行条款后追加 "Parallelize aggressively"——独立只读调用一次发起多个(执行器批并行)、多文件编辑用 `edits` 数组(原子一次往返)、独立子代理/独立子项目一次 spawn 多个(F7 触发条件:不共享待改文件 + 无交叉依赖 + 各自有独立测试);明确不并行边界(同一文件写入/依赖链/bash 审批命令 = 审批风暴/同仓库并发 git/有状态操作)与收益判断(大操作并行、<1s 微操作不并行)。两端 system.md byte-identical,测试同步断言
|
|
13
70
|
|
|
14
71
|
### Fixed
|
|
15
72
|
|
package/bin/thincoder.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { join } from "node:path"
|
|
|
17
17
|
import { runAgent } from "../src/agent.mjs"
|
|
18
18
|
import { loadConfig, configPath } from "../src/config.mjs"
|
|
19
19
|
import { createMemory, syncDir } from "../src/memory.mjs"
|
|
20
|
-
import { assembleAgent, teamConfig, gitAuthor } from "../src/cli/make-agent.mjs"
|
|
20
|
+
import { assembleAgent, teamConfig, gitAuthor, validateProvider } from "../src/cli/make-agent.mjs"
|
|
21
21
|
import { memoryCommand } from "../src/cli/memory-command.mjs"
|
|
22
22
|
import { setupWizard } from "../src/cli/setup-wizard.mjs"
|
|
23
23
|
import { summarize, askPermission } from "../src/cli/permission.mjs"
|
|
@@ -79,6 +79,13 @@ switch (command) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
const agent = await assembleAgent()
|
|
82
|
+
// SESSION.md §8 D-S4(F4):headless 无 TUI —— 可读错误 + 退出码 1,不弹 UI、不崩溃
|
|
83
|
+
if (agent._providerInvalid) {
|
|
84
|
+
const prov = agent.activeProvider || "(未设置)"
|
|
85
|
+
console.error(`[error] 未配置有效 provider(activeProvider "${prov}":${agent._providerInvalidReason})。请运行 thincoder 进入 TUI 重新选择,或编辑 ${configPath}`)
|
|
86
|
+
exitSoon(1)
|
|
87
|
+
break
|
|
88
|
+
}
|
|
82
89
|
if (!agent.provider.apiKey) {
|
|
83
90
|
if (!process.stdin.isTTY) {
|
|
84
91
|
console.error(noKeyMessage())
|
|
@@ -219,6 +226,9 @@ switch (command) {
|
|
|
219
226
|
case "tui":
|
|
220
227
|
case undefined: {
|
|
221
228
|
const agent = await assembleAgent()
|
|
229
|
+
// SESSION.md §8 D-S1:TUI 路径在 startTUI 前清空无效 provider——空 provider 不流入 runAgent
|
|
230
|
+
// (崩溃源:chat() 缺 model → 网关 400 或 fetch("undefined/...") TypeError)
|
|
231
|
+
if (agent._providerInvalid) agent.provider = null
|
|
222
232
|
const config = loadConfig()
|
|
223
233
|
// 恢复上次的会话(同一项目目录);provider 按保存的名字切回(用户上次可能换过模型)
|
|
224
234
|
const { loadSession, applySession } = await import("../src/session.mjs")
|
|
@@ -226,11 +236,15 @@ switch (command) {
|
|
|
226
236
|
if (restored) {
|
|
227
237
|
const switched = applySession(agent, restored)
|
|
228
238
|
if (switched && agent.config?.agent?.compactThresholdAuto) {
|
|
229
|
-
// 压缩阈值跟模型走(与 TUI 切换 provider
|
|
239
|
+
// 压缩阈值跟模型走(与 TUI 切换 provider 时的处理一致);传 provider 对象——
|
|
240
|
+
// providers[].context 覆盖生效(PROVIDER.md §15 T-C2)
|
|
230
241
|
const { resolveCompactThreshold } = await import("../src/config.mjs")
|
|
231
|
-
agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider
|
|
242
|
+
agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
|
|
232
243
|
}
|
|
233
244
|
}
|
|
245
|
+
// D-S3 优先级补全:applySession 可能已用会话中的有效 provider 修复(config 无效 + 会话有效)——
|
|
246
|
+
// 修复后复验清除标记,仅当两者都无效才弹重选(validateProvider 幂等)
|
|
247
|
+
if (agent._providerInvalid) validateProvider(agent)
|
|
234
248
|
// MCP 连接失败在 TUI alt-buffer 下 stderr 不可见,注入为下一条 user 消息后的提醒
|
|
235
249
|
if (agent._mcpWarnings?.length) {
|
|
236
250
|
agent._pendingReminders = agent._pendingReminders ?? []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thincoder",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.58",
|
|
4
4
|
"description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -33,13 +33,9 @@
|
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "node --test \"test/*.test.mjs\"",
|
|
35
35
|
"prepublishOnly": "npm run lint && node --test \"test/*.mjs\"",
|
|
36
|
-
"lint": "
|
|
36
|
+
"lint": "node scripts/check-syntax.mjs",
|
|
37
37
|
"test:full": "node test/run-full.mjs"
|
|
38
38
|
},
|
|
39
39
|
"author": "liwei <liwei@51marine.com> (上海新舶)",
|
|
40
|
-
"license": "MIT"
|
|
41
|
-
"devDependencies": {
|
|
42
|
-
"eslint": "^9.0.0",
|
|
43
|
-
"@eslint/js": "^9.0.0"
|
|
44
|
-
}
|
|
40
|
+
"license": "MIT"
|
|
45
41
|
}
|
package/src/acp/bridge.mjs
CHANGED
|
@@ -73,7 +73,7 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
|
|
|
73
73
|
// D7 (AGENT-LOOP.md §7.2): strip ⟦ev⟧ event tokens (bare or prefixed variants) —
|
|
74
74
|
// they carry RS control characters and are a TUI display signal; structured ACP
|
|
75
75
|
// mapping (tool_call_update) is tracked separately in docs/TODO.md.
|
|
76
|
-
//
|
|
76
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
77
77
|
if (/^(?:[\w-]+#\d+\/)?⟦ev⟧(?:turn|approval)\x1e/.test(text)) return
|
|
78
78
|
update("agent_message_chunk", { content: { type: "text", text } })
|
|
79
79
|
},
|
package/src/advisor/messages.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, existsSync } from "node:fs"
|
|
7
7
|
import { resolve, join, relative, dirname, sep } from "node:path"
|
|
8
|
-
import {
|
|
8
|
+
import { providerSpec } from "../config.mjs"
|
|
9
9
|
import { findReviewRepos, collectRepoSnapshots, collectChangedFiles } from "./repos.mjs"
|
|
10
10
|
import { buildConvergenceBody, buildConvergenceInstructions } from "./convergence.mjs"
|
|
11
11
|
import { loadAdvisorMd, extractConversationBackground, extractAgentResponseTable } from "./history.mjs"
|
|
@@ -90,7 +90,9 @@ function injectProjectGuide(agent, parts, scopeFiles = []) {
|
|
|
90
90
|
}
|
|
91
91
|
// readFileSync succeeded — compute the budget OUTSIDE the try so a spec
|
|
92
92
|
// lookup failure can never masquerade as "no AGENTS.md".
|
|
93
|
-
|
|
93
|
+
// providerSpec: the project-guide budget follows the provider-level context
|
|
94
|
+
// override (PROVIDER.md §15 — advisor messages budget is context-based).
|
|
95
|
+
const ctx = providerSpec(agent.provider).context
|
|
94
96
|
const cap = Math.max(PROJECT_GUIDE_MIN, Math.floor(ctx * PROJECT_GUIDE_FRACTION))
|
|
95
97
|
const shown = text.length <= cap
|
|
96
98
|
? text
|
package/src/advisor/run.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Message building lives in advisor.mjs.
|
|
4
4
|
*/
|
|
5
5
|
import { chat } from "../provider/core.mjs"
|
|
6
|
-
import { findProvider,
|
|
6
|
+
import { findProvider, providerSpec } from "../config.mjs"
|
|
7
7
|
import { toOpenAISchema } from "../tools/index.mjs"
|
|
8
8
|
import { prepareAdvisorMessages } from "../advisor.mjs"
|
|
9
9
|
import { appendCitationReport } from "./citations.mjs"
|
|
@@ -221,7 +221,7 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
221
221
|
id: tc.id, type: "function",
|
|
222
222
|
function: { name: tc.name, arguments: tc.arguments },
|
|
223
223
|
})),
|
|
224
|
-
...(response.reasoning &&
|
|
224
|
+
...(response.reasoning && providerSpec(provider).reasoningEcho === "required"
|
|
225
225
|
? { reasoning_content: response.reasoning }
|
|
226
226
|
: {}),
|
|
227
227
|
})
|
package/src/agent/dispatch.mjs
CHANGED
|
@@ -46,7 +46,12 @@ function logToolError(toolName, args, error) {
|
|
|
46
46
|
*/
|
|
47
47
|
export async function executeToolCalls(agent, toolByName, toolCalls, callbacks, depth = 0, signal) {
|
|
48
48
|
// ---- Phase 1: serial preparation ----
|
|
49
|
+
// Pre-gates run per tool (parse/planMode/engineering gates); non-readonly tools
|
|
50
|
+
// that REACH the permission stage are collected into one batch — a single merged
|
|
51
|
+
// ask covers the whole toolCalls array (§16 D-B1, "approve all / one by one /
|
|
52
|
+
// deny"). Tools stopped by a pre-gate never join the batch (review #7).
|
|
49
53
|
const prepared = []
|
|
54
|
+
const permPending = [] // { toolCall, tool, args } — reached the permission stage
|
|
50
55
|
for (const toolCall of toolCalls) {
|
|
51
56
|
const tool = toolByName.get(toolCall.name)
|
|
52
57
|
let args
|
|
@@ -106,39 +111,74 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
:
|
|
114
|
-
? await (async () => {
|
|
115
|
-
// D2 (AGENT-LOOP.md §7.2): announce the wait BEFORE prompting — the TUI
|
|
116
|
-
// subagent block header flips to "等待审批" so a waiting child is visibly
|
|
117
|
-
// different from a stalled one. Depth>0 only (the parent TUI shows its own
|
|
118
|
-
// permission panel). turn n/max = the child's live turn counters.
|
|
119
|
-
if (depth > 0) {
|
|
120
|
-
callbacks.onToken?.(`⟦ev⟧approval\x1e${agent._currentTurn ?? 0}\x1e${agent._maxTurns ?? 0}\x1eapproval\x1e${String(toolCall.name).slice(0, 40)}`)
|
|
121
|
-
}
|
|
122
|
-
return await callbacks.onPermissionRequest(toolCall.name, args)
|
|
123
|
-
})()
|
|
124
|
-
: false
|
|
125
|
-
if (!allowed) {
|
|
126
|
-
prepared.push({ toolCall, tool, denied: true, reason: callbacks.onPermissionRequest ? "denied by user" : "no permission handler" })
|
|
114
|
+
// Readonly tools (and autoApprove — the short-circuit, unchanged for the
|
|
115
|
+
// whole batch too) skip the permission stage entirely.
|
|
116
|
+
if (tool.readonly || agent.autoApprove) {
|
|
117
|
+
if (!(await runHooks("PreToolUse", { agent, toolName: toolCall.name, toolArgs: args }))) {
|
|
118
|
+
prepared.push({ toolCall, tool, denied: true, reason: "blocked by PreToolUse hook" })
|
|
127
119
|
continue
|
|
128
120
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (!(await runHooks("PreToolUse", { agent, toolName: toolCall.name, toolArgs: args }))) {
|
|
133
|
-
prepared.push({ toolCall, tool, denied: true, reason: "blocked by PreToolUse hook" })
|
|
121
|
+
// Panel area abolished — all tools now stream inline via onToolOutput.
|
|
122
|
+
callbacks.onToolCall?.(toolCall.name, args, toolCall.id)
|
|
123
|
+
prepared.push({ toolCall, tool, args })
|
|
134
124
|
continue
|
|
135
125
|
}
|
|
126
|
+
permPending.push({ toolCall, tool, args })
|
|
127
|
+
}
|
|
136
128
|
|
|
137
|
-
|
|
129
|
+
// ---- Permission stage: one merged ask for the whole batch (§16 D-B1) ----
|
|
130
|
+
// >1 non-readonly tools in the same toolCalls array → a single
|
|
131
|
+
// onBatchPermissionRequest({ tools, count }) ask; verdicts:
|
|
132
|
+
// "approveAll" → batch-scope allowance (autoApprove style, NOT persistent)
|
|
133
|
+
// "deny" → the whole batch is rejected, no second ask
|
|
134
|
+
// "oneByOne" (or anything else / no handler) → the existing per-item
|
|
135
|
+
// onPermissionRequest channel, signature unchanged (NF-B1: ACP bridge /
|
|
136
|
+
// headless / old versions without the new callback are never harmed).
|
|
137
|
+
if (permPending.length > 0) {
|
|
138
|
+
let batchAllowed = null // true = approveAll, false = deny, null = per-item fallback
|
|
139
|
+
if (permPending.length > 1 && callbacks.onBatchPermissionRequest) {
|
|
140
|
+
const verdict = await callbacks.onBatchPermissionRequest({
|
|
141
|
+
tools: permPending.map((p) => ({ name: p.toolCall.name, args: p.args })),
|
|
142
|
+
count: permPending.length,
|
|
143
|
+
})
|
|
144
|
+
if (verdict === "approveAll") batchAllowed = true
|
|
145
|
+
else if (verdict === "deny") batchAllowed = false
|
|
146
|
+
// anything else (oneByOne/unknown) → fall through to the per-item channel
|
|
147
|
+
}
|
|
148
|
+
for (const p of permPending) {
|
|
149
|
+
let allowed
|
|
150
|
+
if (batchAllowed === true) allowed = true
|
|
151
|
+
else if (batchAllowed === false) allowed = false
|
|
152
|
+
else if (callbacks.onPermissionRequest) {
|
|
153
|
+
allowed = await (async () => {
|
|
154
|
+
// D2 (AGENT-LOOP.md §7.2): announce the wait BEFORE prompting — the TUI
|
|
155
|
+
// subagent block header flips to "等待审批" so a waiting child is visibly
|
|
156
|
+
// different from a stalled one. Depth>0 only (the parent TUI shows its own
|
|
157
|
+
// permission panel). turn n/max = the child's live turn counters.
|
|
158
|
+
if (depth > 0) {
|
|
159
|
+
callbacks.onToken?.(`⟦ev⟧approval\x1e${agent._currentTurn ?? 0}\x1e${agent._maxTurns ?? 0}\x1eapproval\x1e${String(p.toolCall.name).slice(0, 40)}`)
|
|
160
|
+
}
|
|
161
|
+
return await callbacks.onPermissionRequest(p.toolCall.name, p.args)
|
|
162
|
+
})()
|
|
163
|
+
} else allowed = false
|
|
164
|
+
if (!allowed) {
|
|
165
|
+
prepared.push({
|
|
166
|
+
toolCall: p.toolCall, tool: p.tool, denied: true,
|
|
167
|
+
reason: (callbacks.onPermissionRequest || batchAllowed === false) ? "denied by user" : "no permission handler",
|
|
168
|
+
})
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
138
171
|
|
|
139
|
-
|
|
172
|
+
// PreToolUse hooks: allow user scripts to gate tool execution
|
|
173
|
+
if (!(await runHooks("PreToolUse", { agent, toolName: p.toolCall.name, toolArgs: p.args }))) {
|
|
174
|
+
prepared.push({ toolCall: p.toolCall, tool: p.tool, denied: true, reason: "blocked by PreToolUse hook" })
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
140
177
|
|
|
141
|
-
|
|
178
|
+
// Panel area abolished — all tools now stream inline via onToolOutput.
|
|
179
|
+
callbacks.onToolCall?.(p.toolCall.name, p.args, p.toolCall.id)
|
|
180
|
+
prepared.push({ toolCall: p.toolCall, tool: p.tool, args: p.args })
|
|
181
|
+
}
|
|
142
182
|
}
|
|
143
183
|
|
|
144
184
|
// ---- Phase 2: order-preserving execution ----
|
package/src/agent/helpers.mjs
CHANGED
|
@@ -35,6 +35,17 @@ export const REPORT_CONTINUATION =
|
|
|
35
35
|
const TOOL_RESULT_OFFLOAD_LIMIT = 64 * 1024 // 65536 chars — offload only above 64K (2026-08-24)
|
|
36
36
|
const TOOL_RESULT_PREVIEW = 64 * 1024 // chars shown inline when offloaded (aligns with CLI/VS Code webview)
|
|
37
37
|
|
|
38
|
+
/** UTF-16 安全截断(2026-09-02 deepseek 400 根因):slice(0, N) 按码元切会把 emoji 代理对切成孤立
|
|
39
|
+
* 高代理(如 🔴=U+D83D+DD34 只剩 D83D)——deepseek 解析器严格 UTF-16 报 400
|
|
40
|
+
* "unexpected end of hex escape"。截断点落在高代理上时向前收一个码元。
|
|
41
|
+
* 与 setup.mjs 的 safeSliceUTF16 同语义(两处独立实现——escape.mjs 的 sanitizeLoneSurrogates 是发送兜底,此处是源头)。 */
|
|
42
|
+
function safeSliceUTF16(text, max) {
|
|
43
|
+
if (text.length <= max) return text
|
|
44
|
+
const cp = text.charCodeAt(max - 1)
|
|
45
|
+
if (cp >= 0xd800 && cp <= 0xdbff) return text.slice(0, max - 1)
|
|
46
|
+
return text.slice(0, max)
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/** Offload-dir write-time self-cleanup retention window (2026-08-21): files older than 3 days are deleted on the next offload. */
|
|
39
50
|
export const TMP_RETENTION_MS = 3 * 24 * 3600 * 1000
|
|
40
51
|
|
|
@@ -87,12 +98,12 @@ export async function offloadToolResult(text, callId, dir = join(configDir, "too
|
|
|
87
98
|
const file = join(dir, `${Date.now()}-${String(callId).replace(/[^a-zA-Z0-9_-]/g, "_")}.log`)
|
|
88
99
|
await writeFile(file, text, "utf8")
|
|
89
100
|
return (
|
|
90
|
-
text
|
|
101
|
+
safeSliceUTF16(text, TOOL_RESULT_PREVIEW) +
|
|
91
102
|
`\n\n[... output too large (${text.length} chars total), full content saved to: ${file}\n` +
|
|
92
103
|
`Page through it with the read tool (offset/limit) or sed -n 'START,ENDp' — do NOT re-run the tool blindly.]`
|
|
93
104
|
)
|
|
94
105
|
} catch {
|
|
95
|
-
return text
|
|
106
|
+
return safeSliceUTF16(text, TOOL_RESULT_OFFLOAD_LIMIT) + `\n\n[... truncated: ${text.length} chars total, offload to disk failed]`
|
|
96
107
|
}
|
|
97
108
|
}
|
|
98
109
|
|
package/src/agent/setup.mjs
CHANGED
|
@@ -22,6 +22,17 @@ import { fileURLToPath } from "node:url"
|
|
|
22
22
|
const DEFAULT_COMPACT_THRESHOLD = 100_000
|
|
23
23
|
const DOC_SEARCH_LIMIT = 5
|
|
24
24
|
const DOC_CHUNK_PREVIEW_LEN = 300
|
|
25
|
+
/** UTF-16 安全截断(2026-09-02 deepseek 400 根因):slice(0, N) 按码元切会把 emoji 代理对切成孤立
|
|
26
|
+
* 高代理(如 🔴=U+D83D+DD34 只剩 D83D)——deepseek 解析器严格 UTF-16 报 400
|
|
27
|
+
* "unexpected end of hex escape"。截断点落在高代理上时向前收一个码元。 */
|
|
28
|
+
function safeSliceUTF16(text, max) {
|
|
29
|
+
if (text.length <= max) return text
|
|
30
|
+
const end = max
|
|
31
|
+
// 截断点恰在高代理(D800-DBFF)上 → 收到高代理之前(不带它)
|
|
32
|
+
const cp = text.charCodeAt(end - 1)
|
|
33
|
+
if (cp >= 0xd800 && cp <= 0xdbff) return text.slice(0, end - 1)
|
|
34
|
+
return text.slice(0, end)
|
|
35
|
+
}
|
|
25
36
|
const MEMORY_SEARCH_LIMIT = 3
|
|
26
37
|
|
|
27
38
|
/** Build engineering-mode system prompt by reading METHODOLOGY.md and wrapping it in the engineering template */
|
|
@@ -114,7 +125,7 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
114
125
|
role: "user",
|
|
115
126
|
content:
|
|
116
127
|
`[Relevant documentation${more}:\n` +
|
|
117
|
-
docs.map((d) => `- ${d.path}${d.heading ? " > " + d.heading : ""}: <untrusted_doc_chunk>${escapeXml(d.content
|
|
128
|
+
docs.map((d) => `- ${d.path}${d.heading ? " > " + d.heading : ""}: <untrusted_doc_chunk>${escapeXml(safeSliceUTF16(d.content, DOC_CHUNK_PREVIEW_LEN))}</untrusted_doc_chunk>`).join("\n") +
|
|
118
129
|
"]",
|
|
119
130
|
transient: true,
|
|
120
131
|
})
|
|
@@ -169,6 +180,7 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
169
180
|
// eng-coder subagents get advisor for mandatory design review before coding
|
|
170
181
|
const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool } = await import("../agent-tools.mjs")
|
|
171
182
|
const { consultStartTool, consultCheckTool, consultStopTool } = await import("../agent-tools/consult.mjs")
|
|
183
|
+
const { subagentCheckTool } = await import("../agent-tools/subagent-check.mjs")
|
|
172
184
|
const { escalateTool } = await import("../agent-tools/escalate.mjs")
|
|
173
185
|
const { CONSULT_BASE } = await import("../agent.mjs")
|
|
174
186
|
// withPool: decorate consult_start/escalate descriptions with the CURRENT candidate pool
|
|
@@ -212,7 +224,7 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
212
224
|
const consultTools = consultModels.length
|
|
213
225
|
? [withPool(consultStartTool), consultCheckTool, consultStopTool, ...(engineering ? [] : [withPool(escalateTool)])]
|
|
214
226
|
: []
|
|
215
|
-
const depthOnly = depth === 0 ? [filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, advisorTool, ...consultTools]
|
|
227
|
+
const depthOnly = depth === 0 ? [filteredSubagent, subagentCheckTool, skillTool, goalTool, engTool, verifyTool, recentChangesTool, advisorTool, ...consultTools]
|
|
216
228
|
// Write-permission coder sub-agents (subagent role="coder" + escalate): the
|
|
217
229
|
// system prompt names verify (system.md) and advisor (discipline.md) — without them an
|
|
218
230
|
// escalate hit "unknown tool" and fell back to bash node --check / npm test to
|
|
@@ -51,7 +51,9 @@ export function makeRelay(parent, label, emit, model) {
|
|
|
51
51
|
// Single source for the event grammar branch lists (consult P3, 2026-08-30):
|
|
52
52
|
// stripEventToken (display) and stripEventTokensForCapture (capture) shared them
|
|
53
53
|
// literally — extending the event set meant touching both regexes.
|
|
54
|
-
|
|
54
|
+
// "done" = §15 D-A3 async-child completion event (emitted by the parent's
|
|
55
|
+
// turn-end collection, not by children — listed so the grammar stays honest).
|
|
56
|
+
const EVENT_PHASE = "turn|approval|done"
|
|
55
57
|
const EVENT_TYPE = "llm|tool|approval|done"
|
|
56
58
|
const WELL_FORMED_EVENT = new RegExp(`^${EVENT_SENTINEL}(${EVENT_PHASE})${RS}[^${RS}]*${RS}[^${RS}]*${RS}(${EVENT_TYPE})${RS}`)
|
|
57
59
|
export function stripEventToken(text) {
|
|
@@ -130,7 +130,12 @@ export const advisorTool = {
|
|
|
130
130
|
// Generate the design token BEFORE the review and inject it into the advisor's prompt.
|
|
131
131
|
// The advisor (LLM) decides pass/fail itself and echoes the token only on approval —
|
|
132
132
|
// the gate is a mechanical string match, not fragile semantics parsing.
|
|
133
|
+
// A random designId is minted for EVERY design-review call (2026-09-01 multi-design
|
|
134
|
+
// slots): on pass the token is stored in parent._engDesignTokens keyed by this id and
|
|
135
|
+
// the id is echoed to the parent; on failure the id is dropped — never stored, so it
|
|
136
|
+
// cannot clobber any other design's slot. Not a document anchor (rejected 2026-08-31).
|
|
133
137
|
const designToken = reviewType === "design" ? generateDesignToken(agent) : null
|
|
138
|
+
const designId = reviewType === "design" ? randomUUID() : null
|
|
134
139
|
const result = await runAdvisorReview(agent, reviewType, {
|
|
135
140
|
onOutput: ctx.onOutput,
|
|
136
141
|
signal: ctx.signal,
|
|
@@ -144,6 +149,11 @@ export const advisorTool = {
|
|
|
144
149
|
if (designToken && result && tokenPattern.test(result)) {
|
|
145
150
|
// Advisor echoed the token → review passed. Issue it to the parent for eng-coder.
|
|
146
151
|
// (session cleanup for design reviews is owned by runAdvisorReview)
|
|
152
|
+
// Multi-design slots (2026-09-01): store under this review's designId; the single
|
|
153
|
+
// `_engDesignToken` mirror stays for the legacy boolean gates (dispatch "has token",
|
|
154
|
+
// session persistence) — key decision ② of ENGINEERING-MODE.md §7 2026-09-01.
|
|
155
|
+
agent._engDesignTokens ??= new Map()
|
|
156
|
+
agent._engDesignTokens.set(designId, designToken)
|
|
147
157
|
agent._engDesignToken = designToken
|
|
148
158
|
// Unlock the dispatch design gate (dispatch.mjs) for eng-coder SELF-review:
|
|
149
159
|
// an eng-coder whose own design review passed may write files without the
|
|
@@ -156,16 +166,16 @@ export const advisorTool = {
|
|
|
156
166
|
if (agent._role === "eng-coder") agent._engDesignReviewed = true
|
|
157
167
|
// Strip the bracketed token so only ONE unambiguous format (plain UUID) reaches the main agent
|
|
158
168
|
const cleanResult = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
|
|
159
|
-
|
|
169
|
+
// designId rides the Approved block (review #1): the parent needs it to aim the FIRST
|
|
170
|
+
// eng-coder spawn when several designs live in the same session.
|
|
171
|
+
return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}\ndesignId: ${designId} (pass as the designId parameter when spawning eng-coder; optional while this session holds a single design)`
|
|
160
172
|
}
|
|
161
|
-
// Review failed (or advisor chose not to pass) →
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
const isCompletedReview = result !== null && !result.startsWith("Advisor:")
|
|
168
|
-
if (isCompletedReview) agent._engDesignToken = null
|
|
173
|
+
// Review failed (or advisor chose not to pass) → do NOT touch ANY slot (方案 ②, review #2:
|
|
174
|
+
// a failed RE-review leaves the previously approved token alive until TTL; the failed call's
|
|
175
|
+
// own designId was never stored, so there is nothing to clear). Isolation (2026-08-30,
|
|
176
|
+
// extended to the multi-slot Map 2026-09-01): a network glitch must not clear / other
|
|
177
|
+
// designs' slots must not be affected — only a COMPLETED non-passing review lands here,
|
|
178
|
+
// and it revokes nothing.
|
|
169
179
|
// Strip every dead token occurrence from the raw output so the main agent can't grab an invalid one
|
|
170
180
|
if (result) {
|
|
171
181
|
const stripped = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
|
package/src/agent-tools/eng.mjs
CHANGED
|
@@ -22,6 +22,7 @@ export const engTool = {
|
|
|
22
22
|
if (args.action === "exit") {
|
|
23
23
|
ctx.agent.config.agent.engineering = false
|
|
24
24
|
ctx.agent._engDesignToken = null // stale token from prior design review invalidated
|
|
25
|
+
ctx.agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
|
|
25
26
|
ctx.agent._engDesignReviewed = false // reset gate state
|
|
26
27
|
ctx.agent._advisorRound = 0 // reset convergence budget
|
|
27
28
|
ctx.agent._touchedFiles = [] // clear mutation tracking
|
|
@@ -49,6 +50,7 @@ export const engTool = {
|
|
|
49
50
|
}
|
|
50
51
|
ctx.agent.config.agent.engineering = true
|
|
51
52
|
ctx.agent._engDesignToken = null // off→on transition requires a fresh design review
|
|
53
|
+
ctx.agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
|
|
52
54
|
ctx.agent._lastEngState = true
|
|
53
55
|
ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
|
|
54
56
|
ctx.agent._pendingReminders.push(ENG_ON_REMINDER)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-check.mjs — async subagent 结果消费侧(AGENT-LOOP.md §15 D-A2)。
|
|
3
|
+
*
|
|
4
|
+
* 与 subagent.mjs 的生成侧(async 分支 + 槽位队列)分离,保持 subagent.mjs
|
|
5
|
+
* 在 500 行硬限内;本模块只承载 subagent_check 工具 + 其等待机制。
|
|
6
|
+
*
|
|
7
|
+
* 语义(D-A2):
|
|
8
|
+
* - id 缺省 → 按 ARRIVAL ORDER 返回下一个已完成的子代理(先完成先返回)
|
|
9
|
+
* - id 给定 → 阻塞到该 id 完成(queued 项先等启动再等完成)
|
|
10
|
+
* - n(必填)→ 1-based 递增读数,per-run 计数器(runAgent 非 resume 重置、
|
|
11
|
+
* turn-end 清空);乱序/重复 n 拒绝且不消费结果;超 MAX_ASYNC_CHECKS 上限
|
|
12
|
+
* 报错引导走回合收尾自动等待
|
|
13
|
+
* - 消费后从 map 删除——已消费 id 再查 = 与未知 id 同款错误(T12)
|
|
14
|
+
*/
|
|
15
|
+
import { MAX_ASYNC_CHECKS } from "./subagent.mjs"
|
|
16
|
+
|
|
17
|
+
/** Wait for an async entry to settle (or the parent signal to abort), parked on
|
|
18
|
+
* the agent's waiter list — the entry settle finally wakes every waiter (same
|
|
19
|
+
* pattern as consult_check's session waiters). Returns "aborted" on signal. */
|
|
20
|
+
function wakeOnAsyncSettle(agent, ctx) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
const i = (agent._asyncWaiters ?? []).indexOf(w)
|
|
24
|
+
if (i >= 0) agent._asyncWaiters.splice(i, 1)
|
|
25
|
+
ctx.signal?.removeEventListener("abort", onAbort)
|
|
26
|
+
}
|
|
27
|
+
const w = () => { cleanup(); resolve("settled") }
|
|
28
|
+
const onAbort = () => { cleanup(); resolve("aborted") }
|
|
29
|
+
;(agent._asyncWaiters ??= []).push(w)
|
|
30
|
+
if (ctx.signal) {
|
|
31
|
+
if (ctx.signal.aborted) { onAbort(); return }
|
|
32
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true })
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* subagent_check (AGENT-LOOP.md §15 D-A2) — fetch async subagent results.
|
|
39
|
+
* - id omitted → the next completed child in ARRIVAL order (first finished first)
|
|
40
|
+
* - id given → block until THAT child finishes (queued items wait for their start)
|
|
41
|
+
* - n (required) → 1-based read counter, strictly incrementing per run (loop
|
|
42
|
+
* guard); capped at MAX_ASYNC_CHECKS per turn — beyond that, use the turn-end
|
|
43
|
+
* auto-wait. Errors never consume results.
|
|
44
|
+
* Consumed entries are deleted from the map — a re-check of the same id is the
|
|
45
|
+
* same "unknown async subagent id" error (T12).
|
|
46
|
+
*/
|
|
47
|
+
export const subagentCheckTool = {
|
|
48
|
+
name: "subagent_check",
|
|
49
|
+
readonly: true,
|
|
50
|
+
description:
|
|
51
|
+
"Fetch the result of an async subagent (subagent with async:true). Spawn async children to keep working in your own turn while they run in the background, then collect their reports here. Multiple async children return in completion (arrival) order — the first finished is returned first, so fast results are handled immediately instead of waiting for the slowest. Blocks until the target finishes.\n" +
|
|
52
|
+
"When done is true, no more results are coming (all finished and consumed) — anything left arrives automatically at turn end.\n" +
|
|
53
|
+
"Parameters:\n" +
|
|
54
|
+
"- id (optional): the subagent id from the async spawn return. Omit to fetch the next completed child (arrival order).\n" +
|
|
55
|
+
"- n (required): 1-based read counter — pass 1 on the first check, 2 on the next, and so on. Consecutive checks must be distinct tool calls (loop detector); at most 3 checks per turn — use the turn-end auto-wait for the rest.",
|
|
56
|
+
parameters: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
id: { type: "string", description: "Optional subagent id (from the async spawn return). Omit = next completed child (arrival order)." },
|
|
60
|
+
n: { type: "number", description: "1-based read counter: 1 for the first check of the turn, incrementing with each subsequent check (loop detector — consecutive checks must be distinct tool calls)." },
|
|
61
|
+
},
|
|
62
|
+
required: ["n"],
|
|
63
|
+
},
|
|
64
|
+
async execute({ id, n }, ctx) {
|
|
65
|
+
const agent = ctx.agent
|
|
66
|
+
const map = agent._asyncSubagents ?? new Map()
|
|
67
|
+
// Strict 1-based incrementing read counter (D-A2, review #1): out-of-order /
|
|
68
|
+
// repeated n is rejected WITHOUT consuming a result (T14).
|
|
69
|
+
const lastN = agent._asyncCheckLastN ?? 0
|
|
70
|
+
if (!Number.isInteger(n) || n !== lastN + 1) {
|
|
71
|
+
return JSON.stringify({ status: "error", error: "invalid read counter — pass n = lastN+1" })
|
|
72
|
+
}
|
|
73
|
+
if (n > MAX_ASYNC_CHECKS) {
|
|
74
|
+
return JSON.stringify({ status: "error", error: "check limit exceeded — use turn-end auto-wait for the rest" })
|
|
75
|
+
}
|
|
76
|
+
agent._asyncCheckLastN = n
|
|
77
|
+
|
|
78
|
+
let target = null
|
|
79
|
+
if (id !== undefined && id !== null && String(id) !== "") {
|
|
80
|
+
target = map.get(String(id))
|
|
81
|
+
// Unknown OR already-consumed ids (consumed entries are deleted) — same error (T12).
|
|
82
|
+
if (!target) return JSON.stringify({ id: String(id), status: "error", error: `unknown async subagent id: ${id}` })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Block until the target settles (specific id / next completed in arrival order).
|
|
86
|
+
for (;;) {
|
|
87
|
+
if (target) {
|
|
88
|
+
if (target.done) break
|
|
89
|
+
const woke = await wakeOnAsyncSettle(agent, ctx)
|
|
90
|
+
if (woke === "aborted") return JSON.stringify({ done: true, stopped: true })
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const completed = [...map.values()].filter((e) => e.done)
|
|
94
|
+
if (completed.length > 0) {
|
|
95
|
+
target = completed.sort((a, b) => (a._settleSeq ?? 0) - (b._settleSeq ?? 0))[0]
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
if (map.size === 0) return JSON.stringify({ done: true })
|
|
99
|
+
const woke = await wakeOnAsyncSettle(agent, ctx)
|
|
100
|
+
if (woke === "aborted") return JSON.stringify({ done: true, stopped: true })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
map.delete(String(target.id))
|
|
104
|
+
if (target.error) return JSON.stringify({ id: String(target.id), status: "error", error: target.error })
|
|
105
|
+
return JSON.stringify({ id: String(target.id), role: target.role, status: "done", report: target.report ?? "" })
|
|
106
|
+
},
|
|
107
|
+
}
|