thincoder 0.12.59 → 0.12.61
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 +62 -4
- package/README.md +10 -8
- package/bin/thincoder.mjs +99 -133
- package/package.json +6 -4
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +45 -21
- package/src/acp.mjs +6 -1
- package/src/advisor/citations.mjs +83 -21
- package/src/advisor/compaction.mjs +174 -0
- package/src/advisor/loop.mjs +293 -0
- package/src/advisor/messages.mjs +59 -137
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +156 -359
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +27 -7
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +145 -27
- package/src/agent/helpers.mjs +107 -13
- package/src/agent/record-results.mjs +55 -11
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +242 -0
- package/src/agent/setup-reminders.mjs +69 -0
- package/src/agent/setup.mjs +107 -127
- package/src/agent/spawn-child.mjs +55 -13
- package/src/agent-tools/advisor-async.mjs +346 -0
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +167 -116
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +139 -107
- package/src/agent-tools/design-token.mjs +117 -0
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +24 -29
- package/src/agent-tools/escalate-async.mjs +289 -0
- package/src/agent-tools/read-history.mjs +166 -32
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +137 -34
- package/src/agent-tools/subagent-actions.mjs +180 -133
- package/src/agent-tools/subagent-async.mjs +184 -177
- package/src/agent-tools/subagent-panel.mjs +160 -0
- package/src/agent-tools/subagent-run.mjs +205 -0
- package/src/agent-tools/subagent-scheduler.mjs +100 -27
- package/src/agent-tools/subagent-spawn.mjs +453 -0
- package/src/agent-tools/subagent.mjs +256 -578
- package/src/agent-tools/verify.mjs +119 -292
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +89 -205
- package/src/cli/distill-command.mjs +12 -6
- package/src/cli/make-agent.mjs +26 -8
- package/src/cli/memory-command.mjs +4 -3
- package/src/cli/permission.mjs +2 -2
- package/src/cli/setup-wizard.mjs +42 -17
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +180 -63
- package/src/context.mjs +5 -147
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +128 -0
- package/src/distill.mjs +11 -11
- package/src/expand-home.mjs +16 -0
- package/src/explore-distill.mjs +155 -0
- package/src/generate-title.mjs +1 -1
- package/src/hooks.mjs +7 -3
- package/src/memory/code-index.mjs +9 -3
- package/src/memory/code-sync.mjs +72 -32
- package/src/memory/core.mjs +6 -193
- package/src/memory/delete.mjs +236 -0
- package/src/memory/docs.mjs +68 -54
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/schema.mjs +15 -3
- package/src/memory.mjs +3 -1
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/peer-domains.mjs +265 -0
- package/src/peer-instances.mjs +231 -0
- package/src/prompt-overlays.mjs +82 -0
- package/src/prompts/advisor-design.md +18 -75
- package/src/prompts/advisor-round1.md +14 -67
- package/src/prompts/advisor-round2.md +15 -51
- package/src/prompts/advisor-round3.md +15 -51
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +5 -23
- package/src/prompts/discipline-engineering.md +217 -0
- package/src/prompts/discipline-normal.md +179 -0
- package/src/prompts/persona-coder.md +21 -0
- package/src/prompts/persona-eng-coder.md +37 -0
- package/src/prompts/persona-eng-designer.md +55 -0
- package/src/prompts/persona-engineering.md +54 -0
- package/src/prompts/persona-explore.md +15 -0
- package/src/prompts/persona-normal.md +27 -0
- package/src/prompts/persona-plan.md +26 -0
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +18 -98
- package/src/provider/errors.mjs +101 -0
- package/src/provider/google.mjs +5 -6
- package/src/provider/index.mjs +2 -1
- package/src/provider/list-models.mjs +93 -0
- package/src/provider/rate.mjs +2 -1
- package/src/provider/responses.mjs +5 -3
- package/src/provider/retry.mjs +8 -45
- package/src/provider/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-gc.mjs +214 -0
- package/src/session-guard.mjs +47 -0
- package/src/session-rename.mjs +38 -0
- package/src/session-slots.mjs +188 -60
- package/src/session.mjs +104 -124
- package/src/token-ttl.mjs +274 -0
- package/src/tools/{system.mjs → bash.mjs} +19 -221
- package/src/tools/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/edit-batch.mjs +109 -10
- package/src/tools/edit-diff.mjs +110 -27
- package/src/tools/edit.md +17 -12
- package/src/tools/execute.mjs +31 -4
- package/src/tools/file.mjs +41 -16
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +23 -34
- package/src/tools/glob-dialect.mjs +130 -0
- package/src/tools/glob.md +3 -3
- package/src/tools/grep.md +1 -1
- package/src/tools/index.mjs +9 -8
- package/src/tools/ops.mjs +188 -3
- package/src/tools/patch.mjs +3 -3
- package/src/tools/question.md +4 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -2
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/tools/shared.mjs +14 -13
- package/src/tools/wait_for.md +22 -0
- package/src/tui/agent-turn.mjs +36 -228
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +7 -1
- package/src/tui/cmd-advisor.mjs +3 -2
- package/src/tui/cmd-config.mjs +142 -30
- package/src/tui/cmd-eng.mjs +28 -40
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +8 -2
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-think.mjs +10 -10
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/cmd-upgrade.mjs +19 -4
- package/src/tui/config-helpers.mjs +28 -16
- package/src/tui/distill-cmd.mjs +1 -1
- package/src/tui/index.mjs +40 -38
- package/src/tui/interaction.mjs +3 -3
- package/src/tui/key-handler.mjs +61 -17
- package/src/tui/key-modes.mjs +86 -8
- package/src/tui/layout.mjs +18 -10
- package/src/tui/model-catalog.mjs +89 -0
- package/src/tui/model-picker.mjs +498 -0
- package/src/tui/mouse.mjs +52 -9
- package/src/tui/pickers.mjs +28 -392
- package/src/tui/render-frame.mjs +32 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +12 -9
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/startup.mjs +4 -0
- package/src/tui/subagent-blocks.mjs +106 -295
- package/src/tui/subagent-children.mjs +162 -0
- package/src/tui/subagent-freeze.mjs +169 -0
- package/src/tui/subagent-panel.mjs +24 -31
- package/src/tui/suspension-drive.mjs +297 -0
- package/src/tui/tool-args.mjs +7 -5
- package/src/tui/tool-display.mjs +143 -0
- package/src/tui/tool-events.mjs +56 -185
- package/src/tui/tui-lifecycle.mjs +46 -4
- package/src/tui/update-notice.mjs +4 -0
- package/src/tui/wizard.mjs +61 -21
- package/src/tui/wrapped-spawn.mjs +38 -0
- package/src/prompts/coder.md +0 -56
- package/src/prompts/discipline.md +0 -102
- package/src/prompts/eng-coder.md +0 -44
- package/src/prompts/engineering-sub.md +0 -34
- package/src/prompts/engineering.md +0 -340
- package/src/prompts/explore.md +0 -21
- package/src/prompts/main.md +0 -56
- package/src/prompts/methodology-template.md +0 -58
- package/src/prompts/plan.md +0 -16
- package/src/prompts/system.md +0 -63
- package/src/tools/pdf-parse-text.mjs +0 -497
- package/src/tools/pdf-parse-xref.mjs +0 -499
- package/src/tools/pdf.mjs +0 -155
- package/src/tools/read_pdf.md +0 -21
|
@@ -0,0 +1,195 @@
|
|
|
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,20 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* consult.mjs — multi-model consultation ("会诊", docs/design/CONSULTATION.md). CLI port.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
14
|
*
|
|
8
15
|
* CLI adaptation (vs the VS Code plugin): the child runner is CLI's runAgent
|
|
9
16
|
* (runAgent(child, input, callbacks, opts) — an agent object, not provider+cwd);
|
|
10
17
|
* children are built with createAgent({ role: "consult", readonly tools,
|
|
11
18
|
* CONSULT_BASE overlay }); activity streams to the parent TUI via the relay
|
|
12
|
-
* prefix `consult#<
|
|
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).
|
|
13
24
|
*/
|
|
14
25
|
import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
|
|
15
26
|
import { resolveChildProvider } from "./subagent.mjs"
|
|
27
|
+
import { pushReal } from "../context.mjs"
|
|
28
|
+
import { offloadToolResult, escapeXml } from "../agent/helpers.mjs"
|
|
16
29
|
import { logEvent, errText } from "../log.mjs"
|
|
30
|
+
import { deathLine } from "../abort-provenance.mjs"
|
|
17
31
|
import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
|
|
32
|
+
// ASYNC-RESULT-CONTAINER.md D2/D3/D6:pending 单容器停靠 + settle 公共收尾 + child signal 单点
|
|
33
|
+
import { buildChildSignal, settleAsyncEntry } from "./async-settle.mjs"
|
|
34
|
+
import { digestBudgetOver, persistOverflowReport } from "./digest-budget.mjs" // B5(群 B 批 §22 D-DG2):digest 注入预算单源
|
|
18
35
|
|
|
19
36
|
// Named consult defaults (consult P2, 2026-08-30).
|
|
20
37
|
const CONSULT_TIMEOUT_MS = 600_000 // default consult lifecycle timeout
|
|
@@ -112,13 +129,73 @@ export function makeMainHistoryTool(parentAgent) {
|
|
|
112
129
|
}
|
|
113
130
|
}
|
|
114
131
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Full-session settle routing (R17 — AGENT-LOOP.md §25 D-R17a): a session whose
|
|
134
|
+
* pending count reached 0 has no more replies coming — the session leaves
|
|
135
|
+
* `_consultSessions` and, unless it was cancelled (consult_stop / turn-end
|
|
136
|
+
* abort), moves into the pending single container (`_pendingAsyncResults` +role
|
|
137
|
+
* "consult"——ASYNC-RESULT-CONTAINER.md D2——升格完整 entry:同 subagent/advisor/
|
|
138
|
+
* escalate 的 `{id, role, report, done, ...}` 形态)whose report carries the full
|
|
139
|
+
* per-model verdict text (composed here — all settle states are known,
|
|
140
|
+
* partial/full failures annotated per model). The entry is injected at the next
|
|
141
|
+
* run start (user turn or digest auto-turn — agent.mjs); the suspension driver
|
|
142
|
+
* is woken (settle-event parity with the async pools) so an idle settle still
|
|
143
|
+
* triggers the digestion round (T-R17j).
|
|
144
|
+
* D3:公共收尾统一走 settleAsyncEntry 共享 helper(四族同机制)——consult 族参数:
|
|
145
|
+
* 无池(会话池无条目——settle 即出池)、无 ctx(无 TUI 冻结事件——子块各自 settle 时
|
|
146
|
+
* 已冻结)、无 onAccounting;helper 按 role "consult" 恒停靠 pending(非挂起期也停靠)。
|
|
147
|
+
* Cancelled sessions produce no digest (T-R17c).
|
|
148
|
+
*/
|
|
149
|
+
function sessionSettled(agent, session) {
|
|
150
|
+
agent?._consultSessions?.delete(String(session.id))
|
|
151
|
+
if (session.stopped) return // cancelled — no digest (T-R17c)
|
|
152
|
+
const entry = {
|
|
153
|
+
id: String(session.id),
|
|
154
|
+
role: "consult",
|
|
155
|
+
report: composeConsultDigest(session),
|
|
156
|
+
error: null, done: true, status: "done", cancelled: false,
|
|
157
|
+
relayPrefix: null, startedAt: null, _settle: null, _settleSeq: 0,
|
|
158
|
+
}
|
|
159
|
+
settleAsyncEntry(agent, entry, { pool: null, ctx: null })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Digest body for a fully-settled session — title + one annotated line per
|
|
163
|
+
* reply (failed replies marked per-model — round2 #7; full text is injected
|
|
164
|
+
* verbatim and may be >64K → offloaded with a preview at injection). */
|
|
165
|
+
export function composeConsultDigest(session) {
|
|
166
|
+
const replies = session.replies ?? []
|
|
167
|
+
const parts = replies.map((r) =>
|
|
168
|
+
r.failed
|
|
169
|
+
? `- [${r.model}] (failed): ${r.reply}`
|
|
170
|
+
: `- [${r.model}]: ${r.reply}`)
|
|
171
|
+
const counts = `${replies.length} of ${session.total} models replied (${session.failed} failed)`
|
|
172
|
+
return `[System reminder: consultation #${session.id} finished — ${counts}]\n${parts.join("\n")}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Inject one settled consult family entry into the parent history as a
|
|
177
|
+
* user-role reminder (run-start injection — agent.mjs; same shape rules as
|
|
178
|
+
* injectAsyncResult: XML-escaped, >64K offloaded with preview + path). Consumed
|
|
179
|
+
* = the caller splices the entry out of the pending single container
|
|
180
|
+
* (_pendingAsyncResults——ASYNC-RESULT-CONTAINER.md D2——role 分发注入)。
|
|
181
|
+
*/
|
|
182
|
+
export async function injectConsultResult(agent, entry) {
|
|
183
|
+
const full = String(entry?.report ?? "(no consultation result)")
|
|
184
|
+
// §22 D-DG3(群 B 批 B5):计入预算的 raw = 报告正文(标签行不计)——首行标签拆出;超限
|
|
185
|
+
// 路径保留族标签行(与 VSC 镜像同形);落盘内容 = raw(与其余三族同口径)。
|
|
186
|
+
const at = full.indexOf("\n")
|
|
187
|
+
const label = at === -1 ? "" : full.slice(0, at + 1)
|
|
188
|
+
const raw = at === -1 ? full : full.slice(at + 1)
|
|
189
|
+
const over = digestBudgetOver(agent, raw.length)
|
|
190
|
+
const saved = over ? await persistOverflowReport(raw, { tag: `consult-${entry?.id ?? "session"}` }) : null
|
|
191
|
+
const preview = saved ? `${label}${saved}` : await offloadToolResult(full, `consult-${entry?.id ?? "session"}`)
|
|
192
|
+
pushReal(agent, {
|
|
193
|
+
role: "user",
|
|
194
|
+
content: escapeXml(preview),
|
|
195
|
+
})
|
|
119
196
|
}
|
|
120
197
|
|
|
121
|
-
function settleChild(session, id, label, ok, payload) {
|
|
198
|
+
function settleChild(agent, session, id, label, ok, payload, emitDone) {
|
|
122
199
|
if (ok) {
|
|
123
200
|
session.received++
|
|
124
201
|
session.replies.push({ model: label, reply: payload })
|
|
@@ -129,7 +206,8 @@ function settleChild(session, id, label, ok, payload) {
|
|
|
129
206
|
session.replies.push({ model: label, reply: `(consultation failed: ${payload})`, failed: true })
|
|
130
207
|
}
|
|
131
208
|
session.pending--
|
|
132
|
-
|
|
209
|
+
emitDone?.() // per-child TUI block freeze at settle (R17 — child's activity card is done)
|
|
210
|
+
if (session.pending === 0) sessionSettled(agent, session)
|
|
133
211
|
}
|
|
134
212
|
|
|
135
213
|
async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
@@ -139,7 +217,8 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
139
217
|
const armWatchdog = () => {
|
|
140
218
|
const t = setTimeout(() => {
|
|
141
219
|
timedOut = true
|
|
142
|
-
|
|
220
|
+
// §20.3 站点 #12 信号面(第 24 批):watchdog 中止带 timeout reason
|
|
221
|
+
try { ctrl.abort({ abortTrigger: "timeout", abortDetail: "consult-watchdog" }) } catch { /* already settled */ }
|
|
143
222
|
}, timeoutMs)
|
|
144
223
|
t.unref?.()
|
|
145
224
|
return t
|
|
@@ -161,6 +240,12 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
161
240
|
if (kind === "ok" || kind === "partial") logEvent("child:done", { ...base, kind })
|
|
162
241
|
else logEvent("child:error", { ...base, err: errText(payload, 200) })
|
|
163
242
|
}
|
|
243
|
+
// R17: relay prefix assigned before the child runner arms — the per-child TUI
|
|
244
|
+
// block freeze emits only when a block actually exists (relay established).
|
|
245
|
+
let relayPrefix = null
|
|
246
|
+
const settle = (ok, payload) => settleChild(agent, session, id, label, ok, payload, relayPrefix
|
|
247
|
+
? () => ctx.callbacks?.onToken?.(`${relayPrefix}⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
|
|
248
|
+
: null)
|
|
164
249
|
try {
|
|
165
250
|
// Provider resolution: consultModels entries are { provider, model, effort? } — resolve
|
|
166
251
|
// via the subagent's provider resolver ("provider:model" handles cross-provider picks).
|
|
@@ -199,7 +284,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
199
284
|
// Activity relay via the unified spawn-child pipeline (§7.2 D3): `consult#<subId>/`
|
|
200
285
|
// prefix (same channel subagent uses — parallel consultants stay independent) +
|
|
201
286
|
// onToolOutput passthrough so the consultant's tool output lands in its TUI block.
|
|
202
|
-
|
|
287
|
+
relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
|
|
203
288
|
// LOGGING:arm(spawn 事件——relay 建立后;子内事件归属 _logId)
|
|
204
289
|
childLogId = relayPrefix.slice(0, -1)
|
|
205
290
|
child._logId = childLogId
|
|
@@ -238,7 +323,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
238
323
|
},
|
|
239
324
|
onDeclined: (e) => {
|
|
240
325
|
declined = true
|
|
241
|
-
|
|
326
|
+
settle(false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
|
|
242
327
|
logSettle("partial", null)
|
|
243
328
|
return undefined
|
|
244
329
|
},
|
|
@@ -246,44 +331,43 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
246
331
|
)
|
|
247
332
|
// Review #1 fix: onDeclined already settled this child as a failed reply —
|
|
248
333
|
// settling again here would push a phantom empty success reply and decrement
|
|
249
|
-
// `pending` twice (negative pending
|
|
250
|
-
// unreachable → permanent block until user abort).
|
|
334
|
+
// `pending` twice (negative pending would re-enter the settle routing).
|
|
251
335
|
if (!declined) {
|
|
252
|
-
|
|
336
|
+
settle(true, String(result ?? ""))
|
|
253
337
|
logSettle("ok", null)
|
|
254
338
|
}
|
|
255
339
|
} catch (e) {
|
|
256
340
|
// Runner errors (incl. the watchdog's abort) settle as a failed reply — the
|
|
257
341
|
// continue/declined paths are already handled inside runWithContinue.
|
|
258
|
-
const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.
|
|
259
|
-
|
|
342
|
+
const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : deathLine(e, ctrl?.signal)
|
|
343
|
+
settle(false, note)
|
|
260
344
|
logSettle("error", note)
|
|
261
345
|
}
|
|
262
346
|
} catch (e) {
|
|
263
347
|
// Errors BEFORE the runner (provider resolution, createAgent) or a throwing
|
|
264
348
|
// continue-prompt settle as failed replies — the runner's own errors are already
|
|
265
|
-
// handled inside the loop above.
|
|
266
|
-
|
|
267
|
-
|
|
349
|
+
// handled inside the loop above. relayPrefix is null on these paths — no TUI
|
|
350
|
+
// block was ever opened, so no freeze event is emitted.
|
|
351
|
+
const line = deathLine(e, ctrl?.signal)
|
|
352
|
+
settle(false, line)
|
|
353
|
+
logSettle("error", line)
|
|
268
354
|
} finally {
|
|
269
355
|
clearTimeout(watchdog)
|
|
270
356
|
}
|
|
271
357
|
}
|
|
272
358
|
|
|
273
|
-
/** Turn-end cleanup (
|
|
274
|
-
*
|
|
359
|
+
/** Turn-end / session-end abort cleanup (R17 — call sites: the Ctrl+C abort
|
|
360
|
+
* branches of finalizeAgentTurn and the suspension driver). Consultation
|
|
361
|
+
* sessions are now cross-turn background work (like async subagents): a NORMAL
|
|
362
|
+
* turn end keeps them alive — this runs only when the user stops everything:
|
|
363
|
+
* every leftover session is marked stopped (its settles never reach the digest
|
|
364
|
+
* stream — T-R17c) and its controllers aborted. */
|
|
275
365
|
export function cleanupConsultSessions(agent) {
|
|
276
366
|
for (const s of agent._consultSessions?.values() ?? []) {
|
|
277
367
|
s.stopped = true
|
|
278
|
-
for (const c of s.controllers ?? []) { try { c.abort() } catch { /* already settled */ } }
|
|
279
|
-
for (const w of s.waiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
|
|
368
|
+
for (const c of s.controllers ?? []) { try { c.abort({ abortTrigger: "stop", abortDetail: "consult-cleanup" }) } catch { /* already settled */ } }
|
|
280
369
|
}
|
|
281
370
|
agent._consultSessions?.clear()
|
|
282
|
-
// NOTE: deliberately void (consult P3, 2026-08-30). The { stopped: true } marker
|
|
283
|
-
// only reaches the TUI via the consult_stop TOOL return (onToolResult freezes
|
|
284
|
-
// blocks on tool calls) — cleanup runs from the turn finally, where the block
|
|
285
|
-
// freeze is owned by freezeAllSubTasks + sweepToolBlocks, so a return here is
|
|
286
|
-
// dead weight. Blocks still get frozen on interrupt via that sweep.
|
|
287
371
|
}
|
|
288
372
|
|
|
289
373
|
export const consultStartTool = {
|
|
@@ -294,8 +378,11 @@ export const consultStartTool = {
|
|
|
294
378
|
"Start a parallel multi-model consultation (会诊) for a hard problem you are stuck on (repeated failures, no headway). " +
|
|
295
379
|
"Call it directly when the user asks for 会诊 / consult — an explicit user request applies even if you are not 'stuck'. " +
|
|
296
380
|
"Several configured models (agent.consultModels) analyze the same problem INDEPENDENTLY and in parallel. " +
|
|
297
|
-
"Non-blocking: returns immediately with a consult id
|
|
298
|
-
"
|
|
381
|
+
"Non-blocking: returns immediately with a consult id; the consultants keep running in the background across turns. " +
|
|
382
|
+
"When EVERY model has replied (or failed), the full verdict text is delivered to you automatically — as a system " +
|
|
383
|
+
"reminder at the next run start, or digested on its own while the session is idle — judge and adopt each opinion " +
|
|
384
|
+
"yourself with your own tools (opinions are suggestions, not gates). To stop a session early (user changed their " +
|
|
385
|
+
"mind / wants the tokens back), call consult_stop(id) — a stopped session delivers no digest.\n" +
|
|
299
386
|
"Parameters:\n" +
|
|
300
387
|
"- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
|
|
301
388
|
"Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.\n" +
|
|
@@ -325,7 +412,7 @@ export const consultStartTool = {
|
|
|
325
412
|
agent._consultSessions ??= new Map()
|
|
326
413
|
const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
|
|
327
414
|
const session = {
|
|
328
|
-
id, controllers: [], replies: [], pending: 0,
|
|
415
|
+
id, controllers: [], replies: [], pending: 0,
|
|
329
416
|
failed: 0, terminated: 0, stopped: false, received: 0, total: run.length,
|
|
330
417
|
models: run.map(consultLabel),
|
|
331
418
|
}
|
|
@@ -335,102 +422,47 @@ export const consultStartTool = {
|
|
|
335
422
|
session.pending++
|
|
336
423
|
const ctrl = new AbortController()
|
|
337
424
|
session.controllers.push(ctrl)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
425
|
+
// D6 buildChildSignal 单点(ASYNC-RESULT-CONTAINER.md D5——consult 补 _sessionSignal
|
|
426
|
+
// 兜底:挂起会话内的 consult children 持会话 signal,digest 自身 Ctrl+C 不误伤)。
|
|
427
|
+
const baseSignal = buildChildSignal(agent, ctx)
|
|
428
|
+
if (baseSignal) {
|
|
429
|
+
// §20.3 站点 #10(第 24 批):hop 逐跳保 reason
|
|
430
|
+
if (baseSignal.aborted) ctrl.abort(baseSignal.reason)
|
|
431
|
+
else baseSignal.addEventListener("abort", () => ctrl.abort(baseSignal.reason), { once: true })
|
|
341
432
|
}
|
|
342
|
-
// Fire and forget — each child settles itself into the session
|
|
433
|
+
// Fire and forget — each child settles itself into the session; the session
|
|
434
|
+
// routes to the pending single container when every child has settled (R17).
|
|
343
435
|
runConsultChild(ctx, session, id, m, problem, ctrl)
|
|
344
436
|
}
|
|
345
437
|
return JSON.stringify({ id, models: session.models })
|
|
346
438
|
},
|
|
347
439
|
}
|
|
348
440
|
|
|
349
|
-
export const consultCheckTool = {
|
|
350
|
-
name: "consult_check",
|
|
351
|
-
readonly: true,
|
|
352
|
-
description:
|
|
353
|
-
"Read the NEXT consultation reply (whichever model answered first). Blocks until a reply arrives or all models " +
|
|
354
|
-
"have settled. The reply is raw and unjudged — verify/adopt it with your own tools. When done is true, no more " +
|
|
355
|
-
"replies are coming.\n" +
|
|
356
|
-
"Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
|
|
357
|
-
"Replies arrive in arrival order: call it repeatedly (n = 1, 2, 3, …) until done is true.\n" +
|
|
358
|
-
"Returns JSON: {reply, model, failedReply, received, failed, terminated, total, done} for a reply — or {done: true, received, failed, total} when none are left.\n" +
|
|
359
|
-
"Parameters:\n" +
|
|
360
|
-
"- id (required): the consult id from consult_start\n" +
|
|
361
|
-
"- n (required): the 1-based read number for this consult — pass 1 on the first check, 2 on the next, and so on. It exists so consecutive checks are distinct tool calls (loop detectors) and the transcript reads as a sequence.",
|
|
362
|
-
parameters: {
|
|
363
|
-
type: "object",
|
|
364
|
-
properties: {
|
|
365
|
-
id: { type: "string", description: "Consult id" },
|
|
366
|
-
n: { type: "number", description: "1-based read number: 1 for the first check, incrementing with each subsequent check of the same consult" },
|
|
367
|
-
},
|
|
368
|
-
required: ["id", "n"],
|
|
369
|
-
},
|
|
370
|
-
async execute({ id, n: _n }, ctx) {
|
|
371
|
-
const s = ctx.agent?._consultSessions?.get(String(id))
|
|
372
|
-
if (!s) return JSON.stringify({ error: "unknown consult id" })
|
|
373
|
-
const abortAll = () => { for (const c of s.controllers) { try { c.abort() } catch { /* noop */ } } }
|
|
374
|
-
if (ctx.signal?.aborted) abortAll()
|
|
375
|
-
|
|
376
|
-
for (;;) {
|
|
377
|
-
if (s.replies.length > 0) {
|
|
378
|
-
const r = s.replies.shift()
|
|
379
|
-
return JSON.stringify({
|
|
380
|
-
reply: r.reply, model: r.model, failedReply: r.failed === true,
|
|
381
|
-
received: s.received,
|
|
382
|
-
failed: s.failed,
|
|
383
|
-
terminated: s.terminated ?? 0, total: s.total,
|
|
384
|
-
done: s.replies.length === 0 && s.pending === 0,
|
|
385
|
-
})
|
|
386
|
-
}
|
|
387
|
-
if (s.pending === 0) {
|
|
388
|
-
return JSON.stringify({ done: true, received: s.received, failed: s.failed, total: s.total })
|
|
389
|
-
}
|
|
390
|
-
const stopped = await new Promise((resolve) => {
|
|
391
|
-
function cleanup() {
|
|
392
|
-
const i = s.waiters.indexOf(w)
|
|
393
|
-
if (i >= 0) s.waiters.splice(i, 1)
|
|
394
|
-
ctx.signal?.removeEventListener("abort", onAbort)
|
|
395
|
-
}
|
|
396
|
-
function w() { cleanup(); resolve(false) }
|
|
397
|
-
function onAbort() { cleanup(); abortAll(); resolve(true) }
|
|
398
|
-
s.waiters.push(w)
|
|
399
|
-
if (ctx.signal) {
|
|
400
|
-
if (ctx.signal.aborted) { onAbort(); return }
|
|
401
|
-
ctx.signal.addEventListener("abort", onAbort, { once: true })
|
|
402
|
-
}
|
|
403
|
-
})
|
|
404
|
-
if (stopped) return JSON.stringify({ done: true, stopped: true, received: s.received, failed: s.failed, total: s.total })
|
|
405
|
-
}
|
|
406
|
-
},
|
|
407
|
-
}
|
|
408
|
-
|
|
409
441
|
export const consultStopTool = {
|
|
410
442
|
name: "consult_stop",
|
|
411
443
|
readonly: false,
|
|
412
444
|
sideEffectExempt: true,
|
|
413
445
|
description:
|
|
414
|
-
"
|
|
415
|
-
"
|
|
416
|
-
"
|
|
446
|
+
"Cancel a still-running consultation session (会诊) — the user changed their mind, the problem resolved, or you want the tokens back. " +
|
|
447
|
+
"Aborts every consultant that is still running; a stopped session delivers NO digest (R17 — its already-collected partial replies are dropped). " +
|
|
448
|
+
"Sessions that finished on their own are no longer cancellable — their verdict text is delivered automatically.\n" +
|
|
449
|
+
"Returns JSON {abandoned: <pending count>, cancelled: true} — or {error: \"unknown consult id\"} (already finished/cancelled).\n" +
|
|
417
450
|
"Parameters:\n" +
|
|
418
|
-
"- id (required): the consult id from consult_start
|
|
419
|
-
"- n (required): incrementing call number for this consult (next value after the last consult_check/consult_stop) — keeps repeated calls distinct.",
|
|
451
|
+
"- id (required): the consult id from consult_start",
|
|
420
452
|
parameters: {
|
|
421
453
|
type: "object",
|
|
422
454
|
properties: {
|
|
423
455
|
id: { type: "string", description: "Consult id" },
|
|
424
|
-
n: { type: "number", description: "Incrementing call number for this consult (see consult_check)" },
|
|
425
456
|
},
|
|
426
|
-
required: ["id"
|
|
457
|
+
required: ["id"],
|
|
427
458
|
},
|
|
428
|
-
async execute({ id
|
|
459
|
+
async execute({ id }, ctx) {
|
|
429
460
|
const s = ctx.agent?._consultSessions?.get(String(id))
|
|
430
461
|
if (!s) return JSON.stringify({ error: "unknown consult id" })
|
|
431
462
|
const abandoned = s.pending
|
|
432
463
|
s.stopped = true
|
|
433
|
-
|
|
434
|
-
|
|
464
|
+
// §20.3 站点 #11(第 24 批):会话级停 = stop
|
|
465
|
+
for (const c of s.controllers) { try { c.abort({ abortTrigger: "stop", abortDetail: "consult-stop" }) } catch { /* already settled */ } }
|
|
466
|
+
return JSON.stringify({ abandoned, cancelled: true })
|
|
435
467
|
},
|
|
436
468
|
}
|