thincoder 0.12.62 → 0.12.63
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 +9 -0
- package/README.md +13 -12
- package/bin/thincoder.mjs +53 -27
- package/package.json +6 -5
- package/src/acp/bridge.mjs +35 -15
- package/src/acp/client-caps.mjs +86 -0
- package/src/acp/ext.mjs +86 -0
- package/src/acp/handlers-session.mjs +240 -0
- package/src/acp/handlers-slots.mjs +196 -0
- package/src/acp/login.mjs +48 -0
- package/src/acp/session.mjs +6 -4
- package/src/acp.mjs +67 -379
- package/src/cli/distill-command.mjs +3 -3
- package/src/cli/make-agent.mjs +59 -17
- package/src/cli/memory-command.mjs +3 -3
- package/src/cli/permission.mjs +4 -48
- package/src/cli/setup-wizard.mjs +1 -1
- package/src/completions.mjs +3 -1
- package/src/crash-reports.mjs +1 -1
- package/src/distill.mjs +4 -4
- package/src/heap-watch.mjs +1 -1
- package/src/prompt-injections.mjs +20 -0
- package/src/tui/agent-turn.mjs +40 -9
- package/src/tui/cmd-advisor.mjs +5 -5
- package/src/tui/cmd-config.mjs +8 -8
- package/src/tui/cmd-eng.mjs +25 -9
- package/src/tui/cmd-mcp.mjs +9 -8
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +6 -5
- package/src/tui/cmd-reindex.mjs +1 -1
- package/src/tui/cmd-restore.mjs +2 -2
- package/src/tui/cmd-session.mjs +24 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +22 -9
- package/src/tui/config-helpers.mjs +1 -1
- package/src/tui/display-budget.mjs +33 -11
- package/src/tui/index.mjs +9 -9
- package/src/tui/interaction.mjs +16 -7
- package/src/tui/key-modes.mjs +9 -4
- package/src/tui/ledger-surface.mjs +26 -10
- package/src/tui/model-catalog.mjs +4 -4
- package/src/tui/model-picker.mjs +8 -7
- package/src/tui/mouse.mjs +11 -6
- package/src/tui/pickers.mjs +15 -2
- package/src/tui/render-conversation.mjs +1 -1
- package/src/tui/render-frame.mjs +10 -5
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/render-segments.mjs +3 -1
- package/src/tui/slash-commands.mjs +1 -1
- package/src/tui/startup.mjs +14 -14
- package/src/tui/subagent-blocks.mjs +20 -3
- package/src/tui/subagent-freeze.mjs +73 -2
- package/src/tui/suspension-drive.mjs +46 -22
- package/src/tui/tool-events.mjs +11 -8
- package/src/tui/wizard.mjs +3 -3
- package/src/abort-provenance.mjs +0 -116
- package/src/advisor/citations.mjs +0 -139
- package/src/advisor/compaction.mjs +0 -174
- package/src/advisor/convergence.mjs +0 -80
- package/src/advisor/history.mjs +0 -77
- package/src/advisor/loop.mjs +0 -293
- package/src/advisor/messages.mjs +0 -299
- package/src/advisor/project-context.mjs +0 -194
- package/src/advisor/repos.mjs +0 -150
- package/src/advisor/run.mjs +0 -293
- package/src/advisor/truncate.mjs +0 -57
- package/src/advisor.mjs +0 -290
- package/src/agent/completion.mjs +0 -146
- package/src/agent/dispatch.mjs +0 -489
- package/src/agent/helpers.mjs +0 -384
- package/src/agent/post-turn.mjs +0 -70
- package/src/agent/record-results.mjs +0 -174
- package/src/agent/relay-prefix.mjs +0 -39
- package/src/agent/run-stages.mjs +0 -244
- package/src/agent/setup-reminders.mjs +0 -69
- package/src/agent/setup.mjs +0 -354
- package/src/agent/spawn-child.mjs +0 -243
- package/src/agent-tools/advisor-async.mjs +0 -346
- package/src/agent-tools/advisor-settle.mjs +0 -231
- package/src/agent-tools/advisor.mjs +0 -260
- package/src/agent-tools/async-settle.mjs +0 -204
- package/src/agent-tools/batch-segment.mjs +0 -195
- package/src/agent-tools/consult.mjs +0 -473
- package/src/agent-tools/design-token.mjs +0 -117
- package/src/agent-tools/digest-budget.mjs +0 -76
- package/src/agent-tools/eng.mjs +0 -67
- package/src/agent-tools/escalate-async.mjs +0 -295
- package/src/agent-tools/goal.mjs +0 -119
- package/src/agent-tools/plan.mjs +0 -81
- package/src/agent-tools/read-history.mjs +0 -309
- package/src/agent-tools/recent-changes.mjs +0 -24
- package/src/agent-tools/review-streak.mjs +0 -93
- package/src/agent-tools/settings.mjs +0 -265
- package/src/agent-tools/skill.mjs +0 -47
- package/src/agent-tools/subagent-actions.mjs +0 -482
- package/src/agent-tools/subagent-async.mjs +0 -434
- package/src/agent-tools/subagent-panel.mjs +0 -160
- package/src/agent-tools/subagent-run.mjs +0 -205
- package/src/agent-tools/subagent-scheduler.mjs +0 -392
- package/src/agent-tools/subagent-spawn.mjs +0 -459
- package/src/agent-tools/subagent.mjs +0 -404
- package/src/agent-tools/task.mjs +0 -87
- package/src/agent-tools/timer.mjs +0 -46
- package/src/agent-tools/verify.mjs +0 -271
- package/src/agent-tools.mjs +0 -17
- package/src/agent.mjs +0 -417
- package/src/auto-think.mjs +0 -115
- package/src/config-migrate.mjs +0 -70
- package/src/config.mjs +0 -496
- package/src/context.mjs +0 -392
- package/src/conventions.mjs +0 -223
- package/src/embedding.mjs +0 -120
- package/src/escape.mjs +0 -152
- package/src/expand-home.mjs +0 -16
- package/src/explore-distill.mjs +0 -155
- package/src/generate-title.mjs +0 -88
- package/src/git/checkpoint.mjs +0 -448
- package/src/git/gitmem.mjs +0 -100
- package/src/hooks.mjs +0 -97
- package/src/ledger.mjs +0 -227
- package/src/log.mjs +0 -195
- package/src/markdown.mjs +0 -106
- package/src/mcp/helpers.mjs +0 -51
- package/src/mcp/transport-http.mjs +0 -248
- package/src/mcp/transport-stdio.mjs +0 -140
- package/src/mcp/transport-ws.mjs +0 -122
- package/src/mcp.mjs +0 -295
- package/src/memory/code-index.mjs +0 -219
- package/src/memory/code-sync.mjs +0 -415
- package/src/memory/core.mjs +0 -299
- package/src/memory/delete.mjs +0 -236
- package/src/memory/docs.mjs +0 -419
- package/src/memory/file-walk.mjs +0 -109
- package/src/memory/scan.mjs +0 -95
- package/src/memory/schema.mjs +0 -452
- package/src/memory.mjs +0 -21
- package/src/model-ref.mjs +0 -66
- package/src/model-specs.mjs +0 -179
- package/src/peer-domains.mjs +0 -265
- package/src/peer-instances.mjs +0 -231
- package/src/prompt-overlays.mjs +0 -82
- package/src/prompts/advisor-design.md +0 -41
- package/src/prompts/advisor-round1.md +0 -41
- package/src/prompts/advisor-round2.md +0 -46
- package/src/prompts/advisor-round3.md +0 -42
- package/src/prompts/common.md +0 -115
- package/src/prompts/consult-base.md +0 -19
- package/src/prompts/discipline-engineering.md +0 -258
- package/src/prompts/discipline-normal.md +0 -185
- package/src/prompts/persona-coder.md +0 -21
- package/src/prompts/persona-eng-coder.md +0 -37
- package/src/prompts/persona-eng-designer.md +0 -60
- package/src/prompts/persona-engineering.md +0 -55
- package/src/prompts/persona-explore.md +0 -15
- package/src/prompts/persona-normal.md +0 -27
- package/src/prompts/persona-plan.md +0 -26
- package/src/provider/anthropic.mjs +0 -225
- package/src/provider/core.mjs +0 -476
- package/src/provider/errors.mjs +0 -101
- package/src/provider/google.mjs +0 -257
- package/src/provider/index.mjs +0 -7
- package/src/provider/list-models.mjs +0 -93
- package/src/provider/normalize.mjs +0 -81
- package/src/provider/rate.mjs +0 -108
- package/src/provider/responses.mjs +0 -495
- package/src/provider/retry.mjs +0 -88
- package/src/provider/sse.mjs +0 -264
- package/src/proxy.mjs +0 -261
- package/src/rules.mjs +0 -53
- package/src/session-gc.mjs +0 -221
- package/src/session-guard.mjs +0 -59
- package/src/session-migrate.mjs +0 -48
- package/src/session-rename.mjs +0 -38
- package/src/session-segments.mjs +0 -100
- package/src/session-slots.mjs +0 -492
- package/src/session-store.mjs +0 -441
- package/src/session.mjs +0 -492
- package/src/skills.mjs +0 -153
- package/src/text-budget.mjs +0 -46
- package/src/token-ttl.mjs +0 -274
- package/src/tools/apply_patch.md +0 -15
- package/src/tools/bash.md +0 -37
- package/src/tools/bash.mjs +0 -268
- package/src/tools/checklist-sync.mjs +0 -181
- package/src/tools/checklist.md +0 -13
- package/src/tools/checklist.mjs +0 -299
- package/src/tools/delete.md +0 -13
- package/src/tools/edit-batch.mjs +0 -191
- package/src/tools/edit-diff.mjs +0 -348
- package/src/tools/edit.md +0 -30
- package/src/tools/execute.md +0 -21
- package/src/tools/execute.mjs +0 -228
- package/src/tools/fetch.md +0 -12
- package/src/tools/file.mjs +0 -469
- package/src/tools/file_ops.md +0 -17
- package/src/tools/get_current_time.md +0 -8
- package/src/tools/git-checkpoint.mjs +0 -143
- package/src/tools/git-ext.mjs +0 -173
- package/src/tools/git.md +0 -54
- package/src/tools/git.mjs +0 -356
- package/src/tools/glob-dialect.mjs +0 -130
- package/src/tools/glob.md +0 -11
- package/src/tools/grep.md +0 -19
- package/src/tools/hashline_edit.md +0 -14
- package/src/tools/index.mjs +0 -36
- package/src/tools/insert_after.md +0 -15
- package/src/tools/lint.md +0 -10
- package/src/tools/linter.mjs +0 -128
- package/src/tools/ls.md +0 -12
- package/src/tools/lsp.md +0 -10
- package/src/tools/lsp.mjs +0 -316
- package/src/tools/ops.mjs +0 -299
- package/src/tools/patch.mjs +0 -282
- package/src/tools/process.md +0 -10
- package/src/tools/question.md +0 -16
- package/src/tools/question.mjs +0 -26
- package/src/tools/read.md +0 -20
- package/src/tools/read_image.md +0 -8
- package/src/tools/repomap.mjs +0 -314
- package/src/tools/search.mjs +0 -236
- package/src/tools/shared.mjs +0 -446
- package/src/tools/tree.md +0 -14
- package/src/tools/tree.mjs +0 -66
- package/src/tools/wait_for.md +0 -22
- package/src/tools/web.mjs +0 -224
- package/src/tools/websearch.md +0 -16
- package/src/tools/write.md +0 -11
- 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
|
-
}
|