thincoder 0.12.62 → 0.12.64

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.
Files changed (231) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +46 -49
  3. package/bin/thincoder.mjs +68 -34
  4. package/package.json +7 -6
  5. package/src/acp/bridge.mjs +35 -15
  6. package/src/acp/client-caps.mjs +86 -0
  7. package/src/acp/ext.mjs +86 -0
  8. package/src/acp/handlers-session.mjs +257 -0
  9. package/src/acp/handlers-slots.mjs +196 -0
  10. package/src/acp/login.mjs +48 -0
  11. package/src/acp/session.mjs +6 -4
  12. package/src/acp.mjs +67 -379
  13. package/src/cli/distill-command.mjs +3 -3
  14. package/src/cli/make-agent.mjs +60 -17
  15. package/src/cli/memory-command.mjs +3 -3
  16. package/src/cli/permission.mjs +4 -48
  17. package/src/cli/setup-wizard.mjs +1 -1
  18. package/src/completions.mjs +3 -1
  19. package/src/crash-reports.mjs +1 -1
  20. package/src/distill.mjs +4 -4
  21. package/src/heap-watch.mjs +1 -1
  22. package/src/prompt-injections.mjs +20 -0
  23. package/src/tui/agent-turn.mjs +40 -9
  24. package/src/tui/cmd-advisor.mjs +5 -5
  25. package/src/tui/cmd-config.mjs +8 -8
  26. package/src/tui/cmd-eng.mjs +35 -9
  27. package/src/tui/cmd-mcp.mjs +9 -8
  28. package/src/tui/cmd-model.mjs +1 -1
  29. package/src/tui/cmd-new.mjs +6 -5
  30. package/src/tui/cmd-plan.mjs +9 -0
  31. package/src/tui/cmd-reindex.mjs +1 -1
  32. package/src/tui/cmd-restore.mjs +2 -2
  33. package/src/tui/cmd-session.mjs +24 -1
  34. package/src/tui/cmd-skills.mjs +1 -1
  35. package/src/tui/cmd-think.mjs +22 -9
  36. package/src/tui/config-helpers.mjs +1 -1
  37. package/src/tui/display-budget.mjs +33 -11
  38. package/src/tui/index.mjs +18 -10
  39. package/src/tui/interaction.mjs +16 -7
  40. package/src/tui/key-modes.mjs +9 -4
  41. package/src/tui/ledger-surface.mjs +22 -59
  42. package/src/tui/model-catalog.mjs +4 -4
  43. package/src/tui/model-picker.mjs +8 -7
  44. package/src/tui/mouse.mjs +11 -6
  45. package/src/tui/pickers.mjs +15 -2
  46. package/src/tui/render-conversation.mjs +1 -1
  47. package/src/tui/render-frame.mjs +15 -6
  48. package/src/tui/render-loop.mjs +1 -1
  49. package/src/tui/render-segments.mjs +3 -1
  50. package/src/tui/slash-commands.mjs +1 -1
  51. package/src/tui/startup.mjs +14 -14
  52. package/src/tui/subagent-blocks.mjs +20 -3
  53. package/src/tui/subagent-freeze.mjs +73 -2
  54. package/src/tui/suspension-drive.mjs +57 -23
  55. package/src/tui/tool-events.mjs +11 -8
  56. package/src/tui/tui-lifecycle.mjs +9 -0
  57. package/src/tui/wizard.mjs +3 -3
  58. package/src/tui/wrapped-spawn.mjs +6 -2
  59. package/src/abort-provenance.mjs +0 -116
  60. package/src/advisor/citations.mjs +0 -139
  61. package/src/advisor/compaction.mjs +0 -174
  62. package/src/advisor/convergence.mjs +0 -80
  63. package/src/advisor/history.mjs +0 -77
  64. package/src/advisor/loop.mjs +0 -293
  65. package/src/advisor/messages.mjs +0 -299
  66. package/src/advisor/project-context.mjs +0 -194
  67. package/src/advisor/repos.mjs +0 -150
  68. package/src/advisor/run.mjs +0 -293
  69. package/src/advisor/truncate.mjs +0 -57
  70. package/src/advisor.mjs +0 -290
  71. package/src/agent/completion.mjs +0 -146
  72. package/src/agent/dispatch.mjs +0 -489
  73. package/src/agent/helpers.mjs +0 -384
  74. package/src/agent/post-turn.mjs +0 -70
  75. package/src/agent/record-results.mjs +0 -174
  76. package/src/agent/relay-prefix.mjs +0 -39
  77. package/src/agent/run-stages.mjs +0 -244
  78. package/src/agent/setup-reminders.mjs +0 -69
  79. package/src/agent/setup.mjs +0 -354
  80. package/src/agent/spawn-child.mjs +0 -243
  81. package/src/agent-tools/advisor-async.mjs +0 -346
  82. package/src/agent-tools/advisor-settle.mjs +0 -231
  83. package/src/agent-tools/advisor.mjs +0 -260
  84. package/src/agent-tools/async-settle.mjs +0 -204
  85. package/src/agent-tools/batch-segment.mjs +0 -195
  86. package/src/agent-tools/consult.mjs +0 -473
  87. package/src/agent-tools/design-token.mjs +0 -117
  88. package/src/agent-tools/digest-budget.mjs +0 -76
  89. package/src/agent-tools/eng.mjs +0 -67
  90. package/src/agent-tools/escalate-async.mjs +0 -295
  91. package/src/agent-tools/goal.mjs +0 -119
  92. package/src/agent-tools/plan.mjs +0 -81
  93. package/src/agent-tools/read-history.mjs +0 -309
  94. package/src/agent-tools/recent-changes.mjs +0 -24
  95. package/src/agent-tools/review-streak.mjs +0 -93
  96. package/src/agent-tools/settings.mjs +0 -265
  97. package/src/agent-tools/skill.mjs +0 -47
  98. package/src/agent-tools/subagent-actions.mjs +0 -482
  99. package/src/agent-tools/subagent-async.mjs +0 -434
  100. package/src/agent-tools/subagent-panel.mjs +0 -160
  101. package/src/agent-tools/subagent-run.mjs +0 -205
  102. package/src/agent-tools/subagent-scheduler.mjs +0 -392
  103. package/src/agent-tools/subagent-spawn.mjs +0 -459
  104. package/src/agent-tools/subagent.mjs +0 -404
  105. package/src/agent-tools/task.mjs +0 -87
  106. package/src/agent-tools/timer.mjs +0 -46
  107. package/src/agent-tools/verify.mjs +0 -271
  108. package/src/agent-tools.mjs +0 -17
  109. package/src/agent.mjs +0 -417
  110. package/src/auto-think.mjs +0 -115
  111. package/src/config-migrate.mjs +0 -70
  112. package/src/config.mjs +0 -496
  113. package/src/context.mjs +0 -392
  114. package/src/conventions.mjs +0 -223
  115. package/src/embedding.mjs +0 -120
  116. package/src/escape.mjs +0 -152
  117. package/src/expand-home.mjs +0 -16
  118. package/src/explore-distill.mjs +0 -155
  119. package/src/generate-title.mjs +0 -88
  120. package/src/git/checkpoint.mjs +0 -448
  121. package/src/git/gitmem.mjs +0 -100
  122. package/src/hooks.mjs +0 -97
  123. package/src/ledger.mjs +0 -227
  124. package/src/log.mjs +0 -195
  125. package/src/markdown.mjs +0 -106
  126. package/src/mcp/helpers.mjs +0 -51
  127. package/src/mcp/transport-http.mjs +0 -248
  128. package/src/mcp/transport-stdio.mjs +0 -140
  129. package/src/mcp/transport-ws.mjs +0 -122
  130. package/src/mcp.mjs +0 -295
  131. package/src/memory/code-index.mjs +0 -219
  132. package/src/memory/code-sync.mjs +0 -415
  133. package/src/memory/core.mjs +0 -299
  134. package/src/memory/delete.mjs +0 -236
  135. package/src/memory/docs.mjs +0 -419
  136. package/src/memory/file-walk.mjs +0 -109
  137. package/src/memory/scan.mjs +0 -95
  138. package/src/memory/schema.mjs +0 -452
  139. package/src/memory.mjs +0 -21
  140. package/src/model-ref.mjs +0 -66
  141. package/src/model-specs.mjs +0 -179
  142. package/src/peer-domains.mjs +0 -265
  143. package/src/peer-instances.mjs +0 -231
  144. package/src/prompt-overlays.mjs +0 -82
  145. package/src/prompts/advisor-design.md +0 -41
  146. package/src/prompts/advisor-round1.md +0 -41
  147. package/src/prompts/advisor-round2.md +0 -46
  148. package/src/prompts/advisor-round3.md +0 -42
  149. package/src/prompts/common.md +0 -115
  150. package/src/prompts/consult-base.md +0 -19
  151. package/src/prompts/discipline-engineering.md +0 -258
  152. package/src/prompts/discipline-normal.md +0 -185
  153. package/src/prompts/persona-coder.md +0 -21
  154. package/src/prompts/persona-eng-coder.md +0 -37
  155. package/src/prompts/persona-eng-designer.md +0 -60
  156. package/src/prompts/persona-engineering.md +0 -55
  157. package/src/prompts/persona-explore.md +0 -15
  158. package/src/prompts/persona-normal.md +0 -27
  159. package/src/prompts/persona-plan.md +0 -26
  160. package/src/provider/anthropic.mjs +0 -225
  161. package/src/provider/core.mjs +0 -476
  162. package/src/provider/errors.mjs +0 -101
  163. package/src/provider/google.mjs +0 -257
  164. package/src/provider/index.mjs +0 -7
  165. package/src/provider/list-models.mjs +0 -93
  166. package/src/provider/normalize.mjs +0 -81
  167. package/src/provider/rate.mjs +0 -108
  168. package/src/provider/responses.mjs +0 -495
  169. package/src/provider/retry.mjs +0 -88
  170. package/src/provider/sse.mjs +0 -264
  171. package/src/proxy.mjs +0 -261
  172. package/src/rules.mjs +0 -53
  173. package/src/session-gc.mjs +0 -221
  174. package/src/session-guard.mjs +0 -59
  175. package/src/session-migrate.mjs +0 -48
  176. package/src/session-rename.mjs +0 -38
  177. package/src/session-segments.mjs +0 -100
  178. package/src/session-slots.mjs +0 -492
  179. package/src/session-store.mjs +0 -441
  180. package/src/session.mjs +0 -492
  181. package/src/skills.mjs +0 -153
  182. package/src/text-budget.mjs +0 -46
  183. package/src/token-ttl.mjs +0 -274
  184. package/src/tools/apply_patch.md +0 -15
  185. package/src/tools/bash.md +0 -37
  186. package/src/tools/bash.mjs +0 -268
  187. package/src/tools/checklist-sync.mjs +0 -181
  188. package/src/tools/checklist.md +0 -13
  189. package/src/tools/checklist.mjs +0 -299
  190. package/src/tools/delete.md +0 -13
  191. package/src/tools/edit-batch.mjs +0 -191
  192. package/src/tools/edit-diff.mjs +0 -348
  193. package/src/tools/edit.md +0 -30
  194. package/src/tools/execute.md +0 -21
  195. package/src/tools/execute.mjs +0 -228
  196. package/src/tools/fetch.md +0 -12
  197. package/src/tools/file.mjs +0 -469
  198. package/src/tools/file_ops.md +0 -17
  199. package/src/tools/get_current_time.md +0 -8
  200. package/src/tools/git-checkpoint.mjs +0 -143
  201. package/src/tools/git-ext.mjs +0 -173
  202. package/src/tools/git.md +0 -54
  203. package/src/tools/git.mjs +0 -356
  204. package/src/tools/glob-dialect.mjs +0 -130
  205. package/src/tools/glob.md +0 -11
  206. package/src/tools/grep.md +0 -19
  207. package/src/tools/hashline_edit.md +0 -14
  208. package/src/tools/index.mjs +0 -36
  209. package/src/tools/insert_after.md +0 -15
  210. package/src/tools/lint.md +0 -10
  211. package/src/tools/linter.mjs +0 -128
  212. package/src/tools/ls.md +0 -12
  213. package/src/tools/lsp.md +0 -10
  214. package/src/tools/lsp.mjs +0 -316
  215. package/src/tools/ops.mjs +0 -299
  216. package/src/tools/patch.mjs +0 -282
  217. package/src/tools/process.md +0 -10
  218. package/src/tools/question.md +0 -16
  219. package/src/tools/question.mjs +0 -26
  220. package/src/tools/read.md +0 -20
  221. package/src/tools/read_image.md +0 -8
  222. package/src/tools/repomap.mjs +0 -314
  223. package/src/tools/search.mjs +0 -236
  224. package/src/tools/shared.mjs +0 -446
  225. package/src/tools/tree.md +0 -14
  226. package/src/tools/tree.mjs +0 -66
  227. package/src/tools/wait_for.md +0 -22
  228. package/src/tools/web.mjs +0 -224
  229. package/src/tools/websearch.md +0 -16
  230. package/src/tools/write.md +0 -11
  231. package/src/traces/trace-store.mjs +0 -355
@@ -1,195 +0,0 @@
1
- /**
2
- * agent-tools/batch-segment.mjs — 批次档段写入工具(ENGINEERING-MODE.md §2.20 · FR22 F1-F7)。
3
- *
4
- * 规则、守卫与 fail-closed 清单的**权威正文在 §2.20.1**(本文件不重述——D2 单一权威源);
5
- * 实现要点(正文未定死、由代码承载):
6
- * - 身份判据 = 评审实例绑定(`review` 形态)/ `agent._role`;
7
- * - append-only:在段尾(下一条 `## §N` 或 EOF)插入,既有行字节不变;
8
- * - 来源戳 `### 轮次 N(评审子代理)`(仅 §3)——N = §3 内该形态行计数 + 1,**调用方自带**
9
- * 的同名标题行被丢弃(否则伪造戳会污染 N 计数——§2.20.8 #5);
10
- * - 凭证剥除:含凭证形态的行剥掉该子串,剥后为空则整行丢弃(「零命中」+「其余逐字保留」);
11
- * - 路径门禁:`resolveBatchDocPath`(评审侧「若传则须可读」);工具内再查一次可读性。
12
- *
13
- * 导出面:`batchSegmentTool` · `resolveBatchDocPath` · `batchDocForReview`(异步评审实例键取绑定)。
14
- */
15
- import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
16
- import { resolve } from "node:path"
17
-
18
- /** text 单次上限(§2.20.1——超出引导分段追加,不承诺"不新盖戳")。 */
19
- export const MAX_TEXT_CHARS = 20000
20
- /** 身份 → 可写段号(§2.20.1 段白名单)。 */
21
- export const SEGMENT_BY_ROLE = { "eng-designer": 2, "eng-coder": 5 }
22
- /** 工具写入的轮次节标题形态(N 计数口径 = 该形态行;`### 轮次与发现(…)` 骨架行不匹配)。 */
23
- const ROUND_HEADING_RE = /^### 轮次 \d+(评审子代理)/
24
- /** 段标题定位(`## §N` 独立标题——`## §20` 不误命中 §2)。 */
25
- const sectionHeaderRe = (seg) => new RegExp(`^## §${seg}(?=\\s|$)`, "m")
26
- /** 凭证形态(§2.7 冒号态):`[DESIGN-TOKEN:…]` 与 `designId: …`——本节自有正则。 */
27
- const CRED_RE = /\[DESIGN-TOKEN:[^\]]*\]|designId\s*:\s*\S+/g
28
- const CRED_TEST_RE = /\[DESIGN-TOKEN:[^\]]*\]|designId\s*:\s*\S+/
29
-
30
- /** 可读文件判据(存在且为文件——目录/缺失同判不可读)。 */
31
- function readableFile(abs) {
32
- try { return existsSync(abs) && statSync(abs).isFile() } catch { return false }
33
- }
34
-
35
- /**
36
- * 批次档路径门禁(评审侧 §2.20.2 口径 = **「若传则须可读」**):空/非字符串/不可读 → throw。
37
- * 非空且可读 → 返回绝对路径(`\` 归一——照 `files`/`batchDoc` spawn 门先例)。
38
- */
39
- export function resolveBatchDocPath(cwd, given) {
40
- const raw = typeof given === "string" ? given.trim() : ""
41
- if (!raw) {
42
- throw new Error("batchDoc must be a non-empty path to the batch record (ENGINEERING-MODE.md §2.20.2) — pass the batch record currently in flight, or omit the parameter entirely when no batch record is in flight.")
43
- }
44
- const abs = resolve(cwd ?? process.cwd(), raw.replace(/\\/g, "/"))
45
- if (!readableFile(abs)) {
46
- throw new Error(`batchDoc is not a readable file: ${raw} — pass the path of the batch record currently in flight (a path that resolves to an existing file), or omit the parameter when no batch record is in flight.`)
47
- }
48
- return abs
49
- }
50
-
51
- /**
52
- * 设计评审的实例绑定解析(§2.20.2/§2.20.3——batch_segment 的唯一路径来源):
53
- * - 同步路径:调用方(advisor 工具)把实例绑定显式放进 callbacks(**带 batchDoc 键**,
54
- * 未绑定即 null)——以此为准,不再回看池条目(防同步/异步混跑时串档);
55
- * - 异步路径:本评审所在**池条目**的 `run.batchDoc`(文档集 = 实例键,各评审各取各条)。
56
- */
57
- export function batchDocForReview(agent, documents, callbacks = null) {
58
- if (callbacks && "batchDoc" in callbacks) return callbacks.batchDoc ?? null
59
- const key = JSON.stringify([...(documents ?? [])].sort())
60
- for (const e of agent?._asyncAdvisors?.values?.() ?? []) {
61
- if (e.status === "running" && e.reviewType === "design" && JSON.stringify([...(e.documents ?? [])].sort()) === key) {
62
- return e.run?.batchDoc ?? null
63
- }
64
- }
65
- return null
66
- }
67
-
68
- /** 段号解析:`§2` / `2` / `§2 批次任务` 均可;无法解析 → null。 */
69
- function segmentNumber(raw) {
70
- const m = /^§?\s*(\d+)/.exec(String(raw ?? "").trim())
71
- return m ? Number(m[1]) : null
72
- }
73
-
74
- /** 身份 → 可写段号;无写权身份 → null。评审实例绑定优先(评审者的身份即"设计评审"。) */
75
- function allowedSegment(agent, review) {
76
- if (review) return 3
77
- return SEGMENT_BY_ROLE[agent?._role] ?? null
78
- }
79
-
80
- /**
81
- * 凭证剥除 + 伪造轮次标题丢弃(§2.20.1 F6/AC30/AC34)。
82
- * 逐行处理:含凭证形态的行 → 剥除该子串,剥后为空则整行丢弃(其余内容逐字保留)。
83
- * `dropStamp`(仅 §3 目标段):调用方自带的 `### 轮次 N(评审子代理)` 行被丢弃——
84
- * 工具生成才是唯一来源戳(否则伪造戳会污染 N 计数)。
85
- */
86
- function sanitizeText(text, dropStamp) {
87
- const kept = []
88
- for (const line of text.replace(/\r\n?/g, "\n").split("\n")) {
89
- if (dropStamp && ROUND_HEADING_RE.test(line)) continue
90
- if (!CRED_TEST_RE.test(line)) { kept.push(line); continue }
91
- const cleaned = line.replace(CRED_RE, "").replace(/[ \t]+$/, "")
92
- if (cleaned.trim()) kept.push(cleaned)
93
- }
94
- return kept.join("\n")
95
- }
96
-
97
- /** §3 内工具写入的轮次行计数(N = 计数 + 1;收窄口径——只数工具戳形态)。 */
98
- function roundCount(src, headerMatch, endIdx) {
99
- const body = src.slice(headerMatch.index + headerMatch[0].length, endIdx)
100
- return body.split("\n").filter((l) => ROUND_HEADING_RE.test(l)).length
101
- }
102
-
103
- /** 段尾定位 + 插入(append-only:只在段尾插入,既有字节不变;返回新全文 + 本轮 N)。 */
104
- function insertIntoSection(src, seg, text) {
105
- const hdr = sectionHeaderRe(seg).exec(src)
106
- if (!hdr) {
107
- throw new Error(`batch_segment: the bound batch record has no "## §${seg}" section header — the six-section skeleton is written by the record's creator before any segment write (§1.12). Fix: ask the parent/creator to add the "## §${seg} …" heading (with the template's sub-headings) first, then call batch_segment again. Nothing was written.`)
108
- }
109
- const nextRe = /^## §\d/gm
110
- nextRe.lastIndex = hdr.index + hdr[0].length
111
- const next = nextRe.exec(src)
112
- const endIdx = next ? next.index : src.length
113
- // 来源戳(仅 §3):工具生成标题 + N——调用方写不进自己的标题(sanitize 已丢弃同名行)。
114
- const roundN = seg === 3 ? roundCount(src, hdr, endIdx) + 1 : 0
115
- const payload = (seg === 3 ? `### 轮次 ${roundN}(评审子代理)\n\n` : "") + text
116
- const before = src.slice(0, endIdx)
117
- const after = src.slice(endIdx)
118
- const eol = src.includes("\r\n") ? "\r\n" : "\n"
119
- const lead = before === "" ? "" : before.endsWith(eol + eol) ? "" : before.endsWith(eol) ? eol : eol + eol
120
- // 尾随 eol 恒加(块自成行);后面还有段标题时再补一个空行(保持模板的空行分隔)。
121
- const written = before + lead + payload.replaceAll("\n", eol) + eol + (after === "" ? "" : eol) + after
122
- return { written, roundN }
123
- }
124
-
125
- /**
126
- * 批次档段写入工具工厂。
127
- * @param {string|null} batchDoc — 绑定的目标档(spawn:`child._batchDoc`;评审:实例键)
128
- * @param {{review?: boolean}} [opts] — review=true 表示"设计评审实例"形态(写 §3 + 工具盖戳)
129
- */
130
- export function batchSegmentTool(batchDoc = null, { review = false } = {}) {
131
- const own = review ? "§3" : null
132
- return {
133
- name: "batch_segment",
134
- description:
135
- "Append your own section of the batch record (ENGINEERING-MODE.md §2.20 — 一段一作者). " +
136
- "There is NO path parameter: the target record is bound to you (at spawn for eng-designer/eng-coder, per review instance for a design review) and your identity fixes the section you may write " +
137
- "(eng-designer → §2, design review → §3, eng-coder → §5) — a write outside your own section is refused. " +
138
- "Append-only: the text lands at the end of your section; existing lines are never rewritten or deleted. " +
139
- "Credential values are stripped mechanically before writing (never write a token or designId value — §2.7). " +
140
- "A design review's append is stamped by the tool with a `### 轮次 N(评审子代理)` heading — N is tool-counted; do not write your own heading (it would be dropped). " +
141
- "Failures are hard and visible (no silent fallback): if the write is refused or fails, say so in your report — “§× 未写入”.",
142
- parameters: {
143
- type: "object",
144
- properties: {
145
- segment: {
146
- type: "string",
147
- description: `The batch-record section you are writing${own ? ` — yours is ${own}` : ""}. Declares the section number only; your identity decides what is actually writable (§2.20.1).`,
148
- },
149
- text: {
150
- type: "string",
151
- description: "The markdown to append (verbatim — findings table + VERDICT + counts for a design review §3). Limit 20000 characters per call; longer content is refused — split it into multiple calls (each call becomes its own section, N continues).",
152
- },
153
- },
154
- required: ["segment", "text"],
155
- },
156
- async execute(args, ctx) {
157
- const agent = ctx?.agent ?? {}
158
- const seg = allowedSegment(agent, review)
159
- if (seg === null) {
160
- throw new Error("batch_segment: no segment is writable by this caller — the channel exists for eng-designer (§2), eng-coder (§5) and design reviews bound to a batch record (§3); the parent agent writes §1/§4/§6 through ordinary document writes (ENGINEERING-MODE.md §2.20.1/§2.20.3).")
161
- }
162
- const n = segmentNumber(args?.segment)
163
- if (n === null) {
164
- throw new Error(`batch_segment: unknown segment ${JSON.stringify(args?.segment ?? null)} — pass the section number you write (e.g. "§${seg}").`)
165
- }
166
- if (n !== seg) {
167
- throw new Error(`batch_segment: §${n} is not yours to write — this caller writes §${seg} only (一段一作者: eng-designer → §2, design review → §3, eng-coder → §5; ENGINEERING-MODE.md §2.20.1).`)
168
- }
169
- if (!batchDoc) {
170
- throw new Error("batch_segment: no batch record is bound to this caller — there is no path parameter by design (the target arrives via the spawn binding / the review instance key, ENGINEERING-MODE.md §2.20.2). Report the section as not written.")
171
- }
172
- const abs = resolve(agent.cwd ?? process.cwd(), String(batchDoc).replace(/\\/g, "/"))
173
- if (!readableFile(abs)) {
174
- throw new Error(`batch_segment: the bound batch record is not a readable file: ${abs} — nothing was written (§2.20.1 fail-closed). Check the record still exists, then report the section as not written.`)
175
- }
176
- if (typeof args?.text !== "string") {
177
- throw new Error("batch_segment: text must be a string (the markdown to append).")
178
- }
179
- if (args.text.length > MAX_TEXT_CHARS) {
180
- throw new Error(`batch_segment: text is ${args.text.length} characters — the limit is ${MAX_TEXT_CHARS} per call. Split it into multiple calls: each call becomes its own section and the round number N continues (分段追加——每次调用各成节、N 顺延; ENGINEERING-MODE.md §2.20.1).`)
181
- }
182
- if (/^## §\d/m.test(args.text)) {
183
- throw new Error("batch_segment: the text contains a section header line matching `^## §N` — that would break section location and the append-only guarantee. Rewrite it (escape the heading, or drop the leading `## §N`), then call again (§2.20.1 骨架保护). Nothing was written.")
184
- }
185
- const body = sanitizeText(args.text, seg === 3).replace(/^\n+/, "").replace(/\s+$/, "")
186
- if (!body.trim()) {
187
- throw new Error("batch_segment: nothing to append — the text is empty after credential stripping (credential values never reach the record; ENGINEERING-MODE.md §2.7/§2.20.1).")
188
- }
189
- const src = readFileSync(abs, "utf8")
190
- const { written, roundN } = insertIntoSection(src, seg, body)
191
- writeFileSync(abs, written)
192
- return `batch_segment: appended ${body.length} characters to §${seg} of the batch record${roundN ? ` (### 轮次 ${roundN}(评审子代理))` : ""}.`
193
- },
194
- }
195
- }
@@ -1,473 +0,0 @@
1
- /**
2
- * consult.mjs — multi-model consultation ("会诊", docs/design/CONSULTATION.md). CLI port.
3
- *
4
- * Two tools (AGENT-LOOP.md §25 D-R17a — R17, 2026-09-06): consult_start
5
- * (non-blocking spawn) / consult_stop (cancel a running session). consult_check
6
- * was RETIRED with the digest auto-injection: the mechanism does ZERO judging —
7
- * when every model of a session settles (pending 0), the session moves to the
8
- * pending single container (`_pendingAsyncResults` +role "consult"——
9
- * ASYNC-RESULT-CONTAINER.md D2——升格完整 entry) and the NEXT run start (user turn
10
- * or digest auto-turn) injects the full verdict text ("[System reminder:
11
- * consultation #id finished — N replies …]" — per-model status annotations on
12
- * partial/full failures) for the main agent to judge and act on in the digestion
13
- * round. A cancelled session (consult_stop) never reaches the stream.
14
- *
15
- * CLI adaptation (vs the VS Code plugin): the child runner is CLI's runAgent
16
- * (runAgent(child, input, callbacks, opts) — an agent object, not provider+cwd);
17
- * children are built with createAgent({ role: "consult", readonly tools,
18
- * CONSULT_BASE overlay }); activity streams to the parent TUI via the relay
19
- * prefix `consult#<childRelayN>/` (one per consultant child — the shared subagent
20
- * relay channel; the child relay number is NOT the session id — sessions key
21
- * their own `_consultIdCounter`), not onSubagent/onToolPanel.
22
- * Each child settles its own TUI block with a ⟦ev⟧done event at settle (R17 —
23
- * the old in-turn check consumption is gone).
24
- */
25
- import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
26
- import { resolveChildProvider } from "./subagent.mjs"
27
- import { pushReal } from "../context.mjs"
28
- import { offloadToolResult, escapeXml } from "../agent/helpers.mjs"
29
- import { logEvent, errText } from "../log.mjs"
30
- import { deathLine } from "../abort-provenance.mjs"
31
- import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
32
- // TUI-OOM-ROOTCAUSE §23.3.1:子代理人读线窗口常量(单源——store 零依赖)。
33
- import { RECORD_WINDOW_MESSAGES } from "../session-store.mjs"
34
- // ASYNC-RESULT-CONTAINER.md D2/D3/D6:pending 单容器停靠 + settle 公共收尾 + child signal 单点
35
- import { buildChildSignal, settleAsyncEntry } from "./async-settle.mjs"
36
- import { digestBudgetOver, persistOverflowReport } from "./digest-budget.mjs" // B5(群 B 批 §22 D-DG2):digest 注入预算单源
37
-
38
- // Named consult defaults (consult P2, 2026-08-30).
39
- const CONSULT_TIMEOUT_MS = 600_000 // default consult lifecycle timeout
40
- const CONSULT_TURNS = 40 // default per-child turn cap
41
-
42
-
43
- function consultLabel(m) {
44
- return `${m.provider}:${m.model}`
45
- }
46
-
47
- /** Narrow the configured consultModels pool to a requested subset.
48
- * Each selector is "provider:model", a bare provider name, or a bare model name
49
- * (case-insensitive). A trailing " (effort)" suffix is tolerated (round2 复核
50
- * 对齐 escalate 动作(subagent action:"escalate"):withPool 列表会带 " (high)" 后缀,模型照抄应可匹配).
51
- * Returns { models, error } — error set when a selector matches
52
- * nothing (surface the typo rather than silently dropping it). Absent/empty selectors
53
- * → the full pool. */
54
- function selectConsultModels(pool, selectors) {
55
- if (selectors == null || (Array.isArray(selectors) && selectors.length === 0)) return { models: pool, error: null }
56
- const list = Array.isArray(selectors) ? selectors : [selectors] // coerce a bare string → [string]
57
- const selected = []
58
- const seen = new Set()
59
- const unknowns = []
60
- for (const raw of list) {
61
- const s = String(raw).replace(/\s+\([^)]*\)\s*$/, "").trim().toLowerCase()
62
- const matches = pool.filter((m) =>
63
- consultLabel(m).toLowerCase() === s ||
64
- String(m.provider ?? "").toLowerCase() === s ||
65
- String(m.model ?? "").toLowerCase() === s,
66
- )
67
- if (matches.length === 0) unknowns.push(String(raw))
68
- else for (const m of matches) {
69
- const key = consultLabel(m)
70
- if (!seen.has(key)) { seen.add(key); selected.push(m) }
71
- }
72
- }
73
- if (unknowns.length > 0) {
74
- return { models: null, error: `unknown consult model selector(s): ${unknowns.join(", ")} — choose from: ${pool.map(consultLabel).join(", ")}` }
75
- }
76
- return { models: selected, error: null }
77
- }
78
-
79
- /** Read-only tool injected into consultation children (via createAgent's tools).
80
- * Lets the consultant pull the main agent's conversation history on demand —
81
- * the failure trail is first-class evidence, not a retelling. */
82
- export function makeMainHistoryTool(parentAgent) {
83
- return {
84
- name: "main_history",
85
- readonly: true,
86
- description:
87
- "Read the main agent's conversation history — what has been tried, the exact errors, recent context. " +
88
- "Use it to ground your analysis in the actual failure trail instead of guessing.\n" +
89
- "Parameters:\n" +
90
- "- limit: Number of recent messages to return (default 20, max 100)",
91
- parameters: {
92
- type: "object",
93
- properties: { limit: { type: "number", description: "Recent messages (default 20, max 100)" } },
94
- },
95
- async execute({ limit }) {
96
- const n = Math.min(Math.max(limit ?? 20, 1), 100)
97
- const h = parentAgent?.history ?? []
98
- const slice = h.slice(-n)
99
- if (slice.length === 0) return "(empty history)"
100
- const render = (m) => {
101
- let content
102
- if (typeof m.content === "string") content = m.content
103
- else if (Array.isArray(m.content)) {
104
- content = m.content.map((part) => {
105
- if (part?.type === "image_url" || part?.type === "image") return "[image omitted]"
106
- if (part?.type === "text") return part.text ?? ""
107
- return JSON.stringify(part)
108
- }).join("\n")
109
- } else content = m.content == null ? "" : JSON.stringify(m.content)
110
- const calls = Array.isArray(m.tool_calls)
111
- ? m.tool_calls.map((c) => `[tool: ${c.function?.name ?? c.name}(${String(c.function?.arguments ?? c.args ?? "").slice(0, 200)})]`).join("\n")
112
- : ""
113
- return `--- [${m.role}] ---\n${content}${calls ? "\n" + calls : ""}`
114
- }
115
- const BUDGET = 60_000 // per-consult token budget (tokens)
116
- let out = ""
117
- for (let i = slice.length - 1; i >= 0; i--) {
118
- const line = render(slice[i])
119
- if (out.length + line.length > BUDGET) {
120
- // A single message over the whole budget: truncate IT (it is the newest
121
- // and most relevant) instead of dropping everything with a misleading
122
- // "earlier messages trimmed" note. Older accumulation still trims.
123
- if (out === "") { out = line.slice(0, BUDGET) + "\n(… truncated — single message exceeded budget " + BUDGET + " chars)"; break }
124
- out = `(earlier messages trimmed — budget ${BUDGET} chars)\n\n` + out
125
- break
126
- }
127
- out = out ? line + "\n\n" + out : line
128
- }
129
- return out
130
- },
131
- }
132
- }
133
-
134
- /**
135
- * Full-session settle routing (R17 — AGENT-LOOP.md §25 D-R17a): a session whose
136
- * pending count reached 0 has no more replies coming — the session leaves
137
- * `_consultSessions` and, unless it was cancelled (consult_stop / turn-end
138
- * abort), moves into the pending single container (`_pendingAsyncResults` +role
139
- * "consult"——ASYNC-RESULT-CONTAINER.md D2——升格完整 entry:同 subagent/advisor/
140
- * escalate 的 `{id, role, report, done, ...}` 形态)whose report carries the full
141
- * per-model verdict text (composed here — all settle states are known,
142
- * partial/full failures annotated per model). The entry is injected at the next
143
- * run start (user turn or digest auto-turn — agent.mjs); the suspension driver
144
- * is woken (settle-event parity with the async pools) so an idle settle still
145
- * triggers the digestion round (T-R17j).
146
- * D3:公共收尾统一走 settleAsyncEntry 共享 helper(四族同机制)——consult 族参数:
147
- * 无池(会话池无条目——settle 即出池)、无 ctx(无 TUI 冻结事件——子块各自 settle 时
148
- * 已冻结)、无 onAccounting;helper 按 role "consult" 恒停靠 pending(非挂起期也停靠)。
149
- * Cancelled sessions produce no digest (T-R17c).
150
- */
151
- function sessionSettled(agent, session) {
152
- agent?._consultSessions?.delete(String(session.id))
153
- if (session.stopped) return // cancelled — no digest (T-R17c)
154
- const entry = {
155
- id: String(session.id),
156
- role: "consult",
157
- report: composeConsultDigest(session),
158
- error: null, done: true, status: "done", cancelled: false,
159
- relayPrefix: null, startedAt: null, _settle: null, _settleSeq: 0,
160
- }
161
- settleAsyncEntry(agent, entry, { pool: null, ctx: null })
162
- }
163
-
164
- /** Digest body for a fully-settled session — title + one annotated line per
165
- * reply (failed replies marked per-model — round2 #7; full text is injected
166
- * verbatim and may be >64K → offloaded with a preview at injection). */
167
- export function composeConsultDigest(session) {
168
- const replies = session.replies ?? []
169
- const parts = replies.map((r) =>
170
- r.failed
171
- ? `- [${r.model}] (failed): ${r.reply}`
172
- : `- [${r.model}]: ${r.reply}`)
173
- const counts = `${replies.length} of ${session.total} models replied (${session.failed} failed)`
174
- return `[System reminder: consultation #${session.id} finished — ${counts}]\n${parts.join("\n")}`
175
- }
176
-
177
- /**
178
- * Inject one settled consult family entry into the parent history as a
179
- * user-role reminder (run-start injection — agent.mjs; same shape rules as
180
- * injectAsyncResult: XML-escaped, >64K offloaded with preview + path). Consumed
181
- * = the caller splices the entry out of the pending single container
182
- * (_pendingAsyncResults——ASYNC-RESULT-CONTAINER.md D2——role 分发注入)。
183
- */
184
- export async function injectConsultResult(agent, entry) {
185
- const full = String(entry?.report ?? "(no consultation result)")
186
- // §22 D-DG3(群 B 批 B5):计入预算的 raw = 报告正文(标签行不计)——首行标签拆出;超限
187
- // 路径保留族标签行(与 VSC 镜像同形);落盘内容 = raw(与其余三族同口径)。
188
- const at = full.indexOf("\n")
189
- const label = at === -1 ? "" : full.slice(0, at + 1)
190
- const raw = at === -1 ? full : full.slice(at + 1)
191
- const over = digestBudgetOver(agent, raw.length)
192
- const saved = over ? await persistOverflowReport(raw, { tag: `consult-${entry?.id ?? "session"}` }) : null
193
- const preview = saved ? `${label}${saved}` : await offloadToolResult(full, `consult-${entry?.id ?? "session"}`)
194
- pushReal(agent, {
195
- role: "user",
196
- content: escapeXml(preview),
197
- })
198
- }
199
-
200
- function settleChild(agent, session, id, label, ok, payload, emitDone) {
201
- if (ok) {
202
- session.received++
203
- session.replies.push({ model: label, reply: payload })
204
- } else if (session.stopped) {
205
- session.terminated = (session.terminated ?? 0) + 1
206
- } else {
207
- session.failed++
208
- session.replies.push({ model: label, reply: `(consultation failed: ${payload})`, failed: true })
209
- }
210
- session.pending--
211
- emitDone?.() // per-child TUI block freeze at settle (R17 — child's activity card is done)
212
- if (session.pending === 0) sessionSettled(agent, session)
213
- }
214
-
215
- async function runConsultChild(ctx, session, id, m, problem, ctrl) {
216
- const agent = ctx.agent
217
- const timeoutMs = agent?.config?.agent?.consultTimeoutMs ?? CONSULT_TIMEOUT_MS
218
- let timedOut = false
219
- const armWatchdog = () => {
220
- const t = setTimeout(() => {
221
- timedOut = true
222
- // §20.3 站点 #12 信号面(第 24 批):watchdog 中止带 timeout reason
223
- try { ctrl.abort({ abortTrigger: "timeout", abortDetail: "consult-watchdog" }) } catch { /* already settled */ }
224
- }, timeoutMs)
225
- t.unref?.()
226
- return t
227
- }
228
- let watchdog = armWatchdog()
229
- const label = consultLabel(m)
230
- // LOGGING(LOGGING.md):child:*(consult)——logSettle 在函数作用域声明(外层 catch
231
- // 覆盖 spawn 前失败路径);spawn 事件在 relay 建立后发射(logArmed 翻转——provider/
232
- // 创建失败 = 从未启动,不落子事件、错误仅经 settleChild 进会话)。
233
- let childLogId = null
234
- let logT0 = 0
235
- let logArmed = false
236
- let logDone = false
237
- const logSettle = (kind, payload) => {
238
- if (!logArmed || logDone || !childLogId) return
239
- logDone = true
240
- const ms = Date.now() - logT0
241
- const base = { role: "consult", id: childLogId, ms }
242
- if (kind === "ok" || kind === "partial") logEvent("child:done", { ...base, kind })
243
- else logEvent("child:error", { ...base, err: errText(payload, 200) })
244
- }
245
- // R17: relay prefix assigned before the child runner arms — the per-child TUI
246
- // block freeze emits only when a block actually exists (relay established).
247
- let relayPrefix = null
248
- const settle = (ok, payload) => settleChild(agent, session, id, label, ok, payload, relayPrefix
249
- ? () => ctx.callbacks?.onToken?.(`${relayPrefix}⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
250
- : null)
251
- try {
252
- // Provider resolution: consultModels entries are { provider, model, effort? } — resolve
253
- // via the subagent's provider resolver ("provider:model" handles cross-provider picks).
254
- const provider = resolveChildProvider(agent, `${m.provider}:${m.model}`)
255
- if (!ensureChildApiKey(provider)) {
256
- // resolveChildProvider may still lack a key; fail loudly like the plugin precheck
257
- // (settleChild turns this message into a clear failed reply instead of a raw 401)
258
- throw new Error(`consult model ${label} has no API key — check providers[${m.provider}].apiKey in config.json`)
259
- }
260
- // Clamp the pool's effort to the model's reasoningEffortEnum — an out-of-enum
261
- // value makes provider/core throw on EVERY chat call (candidate dies on takeoff).
262
- // Symmetric with the escalate action (subagent action:"escalate"); 2026-08-16
263
- // a real consult died on qwen3.8-max effort "high" (enum is xhigh/medium/low).
264
- // Out-of-enum: DROP the effort entirely (the provider preset default may ALSO be
265
- // out-of-enum for this override model).
266
- clampEffort(provider, m.model, m.effort)
267
-
268
- // Read-only consultant: filter the parent tool set down to readonly tools + main_history.
269
- const allowed = readonlyToolNames(agent.tools ?? [])
270
- const tools = [
271
- ...(agent.tools ?? []).filter((t) => allowed.has(t.name)),
272
- makeMainHistoryTool(agent),
273
- ]
274
-
275
- const child = createAgent({
276
- provider,
277
- tools,
278
- config: agent.config,
279
- cwd: agent.cwd,
280
- memory: agent.memory,
281
- // No overlay: setup.mjs already selects CONSULT_BASE as the base prompt for
282
- // role "consult" (overlay + base would concatenate it twice).
283
- role: "consult",
284
- })
285
- // TUI-OOM-ROOTCAUSE §23.3.1:子代理人读线窗口(四处创建点同置)——consult 会话跨回合驻留,
286
- // 不置窗则 _fullHistory 仍无界(F-O1 覆盖 depth>0 全部创建点)
287
- child._historyWindow = RECORD_WINDOW_MESSAGES
288
-
289
- // Activity relay via the unified spawn-child pipeline (§7.2 D3): `consult#<subId>/`
290
- // prefix (same channel subagent uses — parallel consultants stay independent) +
291
- // onToolOutput passthrough so the consultant's tool output lands in its TUI block.
292
- relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
293
- // LOGGING:arm(spawn 事件——relay 建立后;子内事件归属 _logId)
294
- childLogId = relayPrefix.slice(0, -1)
295
- child._logId = childLogId
296
- logT0 = Date.now()
297
- logArmed = true
298
- logEvent("child:spawn", { role: "consult", id: childLogId, kind: "consult" })
299
- const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
300
- let declined = false // review #1: guard against double-settle when onDeclined fired
301
-
302
- // Turn-cap continue loop (TURN-CAP-CONTINUE.md) via runWithContinue (§7.2 D3): hitting
303
- // the cap asks the user via the SAME y/n panel the main agent uses — unlimited
304
- // continues, each with a fresh turn budget AND a re-armed wall-clock watchdog (a
305
- // continue is a fresh budget, the clock restarts too). Parallel consultants serialize
306
- // their prompts through a session-level queue. Declined / headless → failed reply
307
- // (partial diagnosis).
308
- const runner = ctx.runAgent ?? runAgent
309
- try {
310
- const result = await runWithContinue(
311
- (childAgent, input, cbs, opts) => runner(childAgent, input, cbs, opts),
312
- child, "# Problem\n" + problem,
313
- childCallbacks,
314
- { depth: 1, maxTurns: agent?.config?.agent?.consultTurns ?? CONSULT_TURNS, signal: ctrl.signal },
315
- {
316
- askContinue: (e) => {
317
- if (!ctx.onPermissionRequest) return Promise.resolve(false)
318
- const ask = () => ctx.onPermissionRequest("continue", { turns: e.turn, agent: label })
319
- session.continueQueue = (session.continueQueue ?? Promise.resolve()).then(ask, ask)
320
- return session.continueQueue.then((go) => {
321
- if (go) {
322
- clearTimeout(watchdog)
323
- timedOut = false // fresh budget → fresh clock
324
- watchdog = armWatchdog()
325
- }
326
- return go
327
- })
328
- },
329
- onDeclined: (e) => {
330
- declined = true
331
- settle(false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
332
- logSettle("partial", null)
333
- return undefined
334
- },
335
- },
336
- )
337
- // Review #1 fix: onDeclined already settled this child as a failed reply —
338
- // settling again here would push a phantom empty success reply and decrement
339
- // `pending` twice (negative pending would re-enter the settle routing).
340
- if (!declined) {
341
- settle(true, String(result ?? ""))
342
- logSettle("ok", null)
343
- }
344
- } catch (e) {
345
- // Runner errors (incl. the watchdog's abort) settle as a failed reply — the
346
- // continue/declined paths are already handled inside runWithContinue.
347
- const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : deathLine(e, ctrl?.signal)
348
- settle(false, note)
349
- logSettle("error", note)
350
- }
351
- } catch (e) {
352
- // Errors BEFORE the runner (provider resolution, createAgent) or a throwing
353
- // continue-prompt settle as failed replies — the runner's own errors are already
354
- // handled inside the loop above. relayPrefix is null on these paths — no TUI
355
- // block was ever opened, so no freeze event is emitted.
356
- const line = deathLine(e, ctrl?.signal)
357
- settle(false, line)
358
- logSettle("error", line)
359
- } finally {
360
- clearTimeout(watchdog)
361
- }
362
- }
363
-
364
- /** Turn-end / session-end abort cleanup (R17 — call sites: the Ctrl+C abort
365
- * branches of finalizeAgentTurn and the suspension driver). Consultation
366
- * sessions are now cross-turn background work (like async subagents): a NORMAL
367
- * turn end keeps them alive — this runs only when the user stops everything:
368
- * every leftover session is marked stopped (its settles never reach the digest
369
- * stream — T-R17c) and its controllers aborted. */
370
- export function cleanupConsultSessions(agent) {
371
- for (const s of agent._consultSessions?.values() ?? []) {
372
- s.stopped = true
373
- for (const c of s.controllers ?? []) { try { c.abort({ abortTrigger: "stop", abortDetail: "consult-cleanup" }) } catch { /* already settled */ } }
374
- }
375
- agent._consultSessions?.clear()
376
- }
377
-
378
- export const consultStartTool = {
379
- name: "consult_start",
380
- readonly: false,
381
- sideEffectExempt: true,
382
- description:
383
- "Start a parallel multi-model consultation (会诊) for a hard problem you are stuck on (repeated failures, no headway). " +
384
- "Call it directly when the user asks for 会诊 / consult — an explicit user request applies even if you are not 'stuck'. " +
385
- "Several configured models (agent.consultModels) analyze the same problem INDEPENDENTLY and in parallel. " +
386
- "Non-blocking: returns immediately with a consult id; the consultants keep running in the background across turns. " +
387
- "When EVERY model has replied (or failed), the full verdict text is delivered to you automatically — as a system " +
388
- "reminder at the next run start, or digested on its own while the session is idle — judge and adopt each opinion " +
389
- "yourself with your own tools (opinions are suggestions, not gates). To stop a session early (user changed their " +
390
- "mind / wants the tokens back), call consult_stop(id) — a stopped session delivers no digest.\n" +
391
- "Parameters:\n" +
392
- "- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
393
- "Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.\n" +
394
- "- models (optional): subset of agent.consultModels to run — an array of \"provider:model\", bare provider, or bare model names (case-insensitive). Omit to run all.",
395
- parameters: {
396
- type: "object",
397
- properties: {
398
- problem: { type: "string", description: "Problem brief (symptom + failure trail + entry files)" },
399
- models: { type: "array", items: { type: "string" }, description: 'Optional subset of agent.consultModels to run (default: all). Each entry is "provider:model", a bare provider name, or a bare model name (case-insensitive).' },
400
- },
401
- required: ["problem"],
402
- },
403
- async execute({ problem, models }, ctx) {
404
- if (typeof problem !== "string" || !problem.trim()) return "Error: problem is required and must be a non-empty string"
405
- const agent = ctx.agent
406
- if (!agent) return "Error: consult requires an agent context"
407
- const pool = agent.config?.agent?.consultModels ?? []
408
- if (!Array.isArray(pool) || pool.length === 0)
409
- return "Consultation is not configured — add agent.consultModels ([{ provider, model }], up to 5) to ~/.thincoder/config.json"
410
- if (pool.length > 5) return `Error: consultModels supports at most 5 models (got ${pool.length})`
411
-
412
- // `models` (optional) narrows the pool to a subset; absent/empty → run the whole pool.
413
- const picked = selectConsultModels(pool, models)
414
- if (picked.error) return picked.error
415
- const run = picked.models
416
-
417
- agent._consultSessions ??= new Map()
418
- const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
419
- const session = {
420
- id, controllers: [], replies: [], pending: 0,
421
- failed: 0, terminated: 0, stopped: false, received: 0, total: run.length,
422
- models: run.map(consultLabel),
423
- }
424
- agent._consultSessions.set(id, session)
425
-
426
- for (const m of run) {
427
- session.pending++
428
- const ctrl = new AbortController()
429
- session.controllers.push(ctrl)
430
- // D6 buildChildSignal 单点(ASYNC-RESULT-CONTAINER.md D5——consult 补 _sessionSignal
431
- // 兜底:挂起会话内的 consult children 持会话 signal,digest 自身 Ctrl+C 不误伤)。
432
- const baseSignal = buildChildSignal(agent, ctx)
433
- if (baseSignal) {
434
- // §20.3 站点 #10(第 24 批):hop 逐跳保 reason
435
- if (baseSignal.aborted) ctrl.abort(baseSignal.reason)
436
- else baseSignal.addEventListener("abort", () => ctrl.abort(baseSignal.reason), { once: true })
437
- }
438
- // Fire and forget — each child settles itself into the session; the session
439
- // routes to the pending single container when every child has settled (R17).
440
- runConsultChild(ctx, session, id, m, problem, ctrl)
441
- }
442
- return JSON.stringify({ id, models: session.models })
443
- },
444
- }
445
-
446
- export const consultStopTool = {
447
- name: "consult_stop",
448
- readonly: false,
449
- sideEffectExempt: true,
450
- description:
451
- "Cancel a still-running consultation session (会诊) — the user changed their mind, the problem resolved, or you want the tokens back. " +
452
- "Aborts every consultant that is still running; a stopped session delivers NO digest (R17 — its already-collected partial replies are dropped). " +
453
- "Sessions that finished on their own are no longer cancellable — their verdict text is delivered automatically.\n" +
454
- "Returns JSON {abandoned: <pending count>, cancelled: true} — or {error: \"unknown consult id\"} (already finished/cancelled).\n" +
455
- "Parameters:\n" +
456
- "- id (required): the consult id from consult_start",
457
- parameters: {
458
- type: "object",
459
- properties: {
460
- id: { type: "string", description: "Consult id" },
461
- },
462
- required: ["id"],
463
- },
464
- async execute({ id }, ctx) {
465
- const s = ctx.agent?._consultSessions?.get(String(id))
466
- if (!s) return JSON.stringify({ error: "unknown consult id" })
467
- const abandoned = s.pending
468
- s.stopped = true
469
- // §20.3 站点 #11(第 24 批):会话级停 = stop
470
- for (const c of s.controllers) { try { c.abort({ abortTrigger: "stop", abortDetail: "consult-stop" }) } catch { /* already settled */ } }
471
- return JSON.stringify({ abandoned, cancelled: true })
472
- },
473
- }