thincoder 0.12.60 → 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 +24 -1
- package/README.md +8 -6
- package/bin/thincoder.mjs +27 -122
- package/package.json +3 -2
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +38 -17
- 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 +36 -134
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +124 -329
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +3 -2
- package/src/agent/completion.mjs +1 -1
- package/src/agent/dispatch.mjs +47 -12
- package/src/agent/helpers.mjs +71 -13
- package/src/agent/record-results.mjs +13 -5
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +21 -6
- package/src/agent/setup-reminders.mjs +16 -9
- package/src/agent/setup.mjs +92 -128
- package/src/agent/spawn-child.mjs +26 -9
- package/src/agent-tools/advisor-async.mjs +70 -180
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +69 -20
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +23 -10
- package/src/agent-tools/design-token.mjs +14 -1
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +3 -3
- package/src/agent-tools/escalate-async.mjs +16 -13
- package/src/agent-tools/read-history.mjs +13 -3
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +130 -17
- package/src/agent-tools/subagent-actions.mjs +15 -6
- package/src/agent-tools/subagent-async.mjs +66 -14
- package/src/agent-tools/subagent-panel.mjs +22 -15
- package/src/agent-tools/subagent-run.mjs +9 -6
- package/src/agent-tools/subagent-scheduler.mjs +57 -8
- package/src/agent-tools/subagent-spawn.mjs +63 -16
- package/src/agent-tools/subagent.mjs +175 -49
- package/src/agent-tools/verify.mjs +13 -34
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +38 -21
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +23 -7
- package/src/cli/memory-command.mjs +2 -2
- package/src/cli/setup-wizard.mjs +29 -9
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +132 -63
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +7 -2
- package/src/expand-home.mjs +16 -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 +70 -31
- package/src/memory/delete.mjs +2 -0
- package/src/memory/docs.mjs +10 -6
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/schema.mjs +15 -3
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/prompt-overlays.mjs +73 -16
- package/src/prompts/advisor-design.md +18 -8
- package/src/prompts/advisor-round1.md +7 -1
- package/src/prompts/advisor-round2.md +13 -2
- package/src/prompts/advisor-round3.md +13 -2
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +2 -0
- 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 +13 -32
- package/src/provider/errors.mjs +26 -1
- 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/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-slots.mjs +7 -2
- package/src/session.mjs +59 -38
- package/src/token-ttl.mjs +2 -1
- package/src/tools/{system.mjs → bash.mjs} +6 -243
- package/src/tools/file.mjs +30 -10
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +15 -34
- package/src/tools/index.mjs +4 -2
- package/src/tools/ops.mjs +20 -7
- package/src/tools/question.md +1 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -1
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/tui/agent-turn.mjs +32 -13
- 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 +108 -37
- package/src/tui/cmd-eng.mjs +11 -27
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/index.mjs +38 -37
- 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 +47 -10
- package/src/tui/pickers.mjs +28 -410
- package/src/tui/render-frame.mjs +32 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +5 -19
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/subagent-blocks.mjs +69 -90
- package/src/tui/subagent-children.mjs +50 -64
- package/src/tui/subagent-freeze.mjs +40 -43
- package/src/tui/subagent-panel.mjs +21 -66
- package/src/tui/suspension-drive.mjs +28 -82
- package/src/tui/tool-args.mjs +4 -2
- package/src/tui/tool-display.mjs +4 -3
- package/src/tui/tool-events.mjs +23 -16
- package/src/tui/tui-lifecycle.mjs +18 -5
- package/src/tui/wizard.mjs +52 -18
- package/src/tui/wrapped-spawn.mjs +38 -0
- package/src/prompts/coder.md +0 -13
- package/src/prompts/discipline.md +0 -84
- package/src/prompts/eng-coder.md +0 -19
- package/src/prompts/engineering-sub.md +0 -14
- package/src/prompts/engineering.md +0 -87
- package/src/prompts/explore.md +0 -12
- package/src/prompts/main.md +0 -34
- package/src/prompts/methodology-template.md +0 -38
- package/src/prompts/plan.md +0 -9
- package/src/prompts/system.md +0 -44
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
* a host fact: every `file:line: content` reference in a review is mechanically
|
|
5
5
|
* checked against the CURRENT disk state; mismatches mark the finding
|
|
6
6
|
* unverified and cannot support a push-back.
|
|
7
|
+
*
|
|
8
|
+
* 第 11 批(C / F14 / §14.5):解析候选 = cwd + **评审对象声明范围派生根**(声明仓根 /
|
|
9
|
+
* 声明文件目录 / 声明目录——纯路径派生,零扫描、零 git);命中判据三条件全中(围栏内 ∧
|
|
10
|
+
* 可读 ∧ 目标行含引文内容)⇒ 零新增假命中。失败原因三分(file unreadable /
|
|
11
|
+
* content mismatch @ path / path traversal)——父侧不再人肉复核。
|
|
7
12
|
*/
|
|
8
13
|
import { readFileSync, realpathSync } from "node:fs"
|
|
9
|
-
import { resolve, sep } from "node:path"
|
|
14
|
+
import { resolve, relative, dirname, extname, sep, isAbsolute, join } from "node:path"
|
|
10
15
|
|
|
11
16
|
// `file:line: content` citations — the file group is narrowed to source/config
|
|
12
17
|
// extensions so URLs (`example.com:8080: …`) don't become false-positive
|
|
@@ -25,48 +30,105 @@ export function extractCitations(text) {
|
|
|
25
30
|
return out
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
/** 候选解析根(§14.5——纯路径派生,候选顺序 = cwd → 各声明路径的派生根):
|
|
34
|
+
* 声明仓根(`cwd/<segs[0]>`)与 声明文件目录 / 声明目录本身。声明在 cwd 之外 → 不派生
|
|
35
|
+
* (relative 越出 cwd 或跨盘符——候选与声明脱节即假命中面)。 */
|
|
36
|
+
function citationRoots(cwd, scope) {
|
|
37
|
+
const base = resolve(cwd)
|
|
38
|
+
const roots = [base]
|
|
39
|
+
for (const s of Array.isArray(scope) ? scope : []) {
|
|
40
|
+
if (typeof s !== "string" || !s.trim()) continue
|
|
41
|
+
const abs = resolve(base, s)
|
|
42
|
+
const rel = relative(base, abs)
|
|
43
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) continue
|
|
44
|
+
const segs = rel.split(/[\\/]/).filter(Boolean)
|
|
45
|
+
if (segs.length === 0) continue
|
|
46
|
+
const repoRoot = join(base, segs[0])
|
|
47
|
+
const declaredDir = extname(abs) ? dirname(abs) : abs
|
|
48
|
+
for (const r of [repoRoot, declaredDir]) if (!roots.includes(r)) roots.push(r)
|
|
49
|
+
}
|
|
50
|
+
return roots
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 单引文解析(§14.5):按候选顺序试 `resolve(root, file)`;三条件全中才算命中。
|
|
54
|
+
* @returns {{matched: true, root: string, resolved: string}|{matched: false, reason: string}} */
|
|
55
|
+
function resolveCitation(citation, roots, base) {
|
|
56
|
+
const fence = base + sep
|
|
57
|
+
let mismatch = null
|
|
58
|
+
let traversal = false
|
|
59
|
+
for (const root of roots) {
|
|
60
|
+
let real
|
|
61
|
+
try {
|
|
62
|
+
// Path confinement: citation paths are LLM-generated — never trust them.
|
|
63
|
+
// A hallucinated "../config.json" would otherwise read (and leak via the
|
|
64
|
+
// report) files outside the project, including API-key configs.
|
|
65
|
+
// realpathSync resolves symlinks too — a link inside the project that
|
|
66
|
+
// points outside must not pass the prefix check.
|
|
67
|
+
real = realpathSync(resolve(root, citation.file))
|
|
68
|
+
} catch {
|
|
69
|
+
continue // 该候选无此文件(或不可解析)——试下一候选
|
|
70
|
+
}
|
|
71
|
+
if (!real.startsWith(fence)) { traversal = true; continue }
|
|
72
|
+
let line = ""
|
|
73
|
+
try {
|
|
74
|
+
line = readFileSync(real, "utf8").split("\n")[citation.line - 1] ?? ""
|
|
75
|
+
} catch {
|
|
76
|
+
continue // 存在但读不了(目录等)——按未命中处理,试下一候选
|
|
77
|
+
}
|
|
78
|
+
if (line.includes(citation.content)) {
|
|
79
|
+
return { matched: true, root, resolved: relative(base, real).split(sep).join("/") }
|
|
80
|
+
}
|
|
81
|
+
mismatch ??= real
|
|
82
|
+
}
|
|
83
|
+
if (mismatch) return { matched: false, reason: `content mismatch @ ${relative(base, mismatch).split(sep).join("/")}` }
|
|
84
|
+
if (traversal) return { matched: false, reason: "path traversal" }
|
|
85
|
+
return { matched: false, reason: "file unreadable" }
|
|
86
|
+
}
|
|
87
|
+
|
|
28
88
|
/**
|
|
29
89
|
* Mechanically verify citations against the CURRENT file state: read the file,
|
|
30
90
|
* take the exact line, check it CONTAINS the quoted content. Reports
|
|
31
91
|
* N/M matched + the mismatches. Unverified citations cannot support a
|
|
32
92
|
* push-back — the evidence rule becomes a host fact, not a prompt wish.
|
|
93
|
+
* @param {string} text — the review text
|
|
94
|
+
* @param {string} cwd — the agent's working directory (workspace root)
|
|
95
|
+
* @param {{scope?: string[]}} [opts] — scope = 评审对象声明路径(documents + paths);
|
|
96
|
+
* 省略 ⇒ 旧行为(仅 cwd 候选——签名向后兼容)。
|
|
33
97
|
*/
|
|
34
|
-
export function verifyCitations(text, cwd) {
|
|
98
|
+
export function verifyCitations(text, cwd, opts = {}) {
|
|
35
99
|
const citations = extractCitations(text)
|
|
36
100
|
const matched = []
|
|
37
101
|
const failed = []
|
|
38
|
-
const
|
|
102
|
+
const base = resolve(cwd)
|
|
103
|
+
const roots = citationRoots(cwd, opts?.scope)
|
|
39
104
|
for (const c of citations) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const resolved = realpathSync(resolve(cwd, c.file))
|
|
47
|
-
if (!resolved.startsWith(root)) {
|
|
48
|
-
failed.push({ ...c, reason: "path traversal" })
|
|
49
|
-
continue
|
|
50
|
-
}
|
|
51
|
-
const line = readFileSync(resolved, "utf8").split("\n")[c.line - 1] ?? ""
|
|
52
|
-
if (line.includes(c.content)) matched.push(c)
|
|
53
|
-
else failed.push(c)
|
|
54
|
-
} catch {
|
|
55
|
-
failed.push({ ...c, reason: "file unreadable" })
|
|
105
|
+
const r = resolveCitation(c, roots, base)
|
|
106
|
+
if (r.matched) {
|
|
107
|
+
// 命中根记录(§14.5):经派生根解析(非 cwd 直解)的命中在报告中注明解析路径。
|
|
108
|
+
matched.push(r.root === base ? c : { ...c, root: r.root, resolved: r.resolved })
|
|
109
|
+
} else {
|
|
110
|
+
failed.push({ ...c, reason: r.reason })
|
|
56
111
|
}
|
|
57
112
|
}
|
|
58
113
|
return { total: citations.length, matched, failed }
|
|
59
114
|
}
|
|
60
115
|
|
|
61
116
|
/** Append the verification report to the review text (visible to the parent agent). */
|
|
62
|
-
export function appendCitationReport(text, cwd) {
|
|
63
|
-
const { total, matched, failed } = verifyCitations(text, cwd)
|
|
117
|
+
export function appendCitationReport(text, cwd, opts = {}) {
|
|
118
|
+
const { total, matched, failed } = verifyCitations(text, cwd, opts)
|
|
64
119
|
if (total === 0) return text // no citations — nothing to verify
|
|
65
120
|
const lines = [
|
|
66
121
|
"",
|
|
67
122
|
"---",
|
|
68
123
|
`[host-verified] ${matched.length}/${total} citations match current file state.`,
|
|
69
124
|
]
|
|
125
|
+
// 命中根透明(F14/§14.5):经声明范围派生根解析的命中逐条注明解析路径(cwd 直解的不列
|
|
126
|
+
// ——零噪音;列的正是修复前会被误报为 unreadable 的裸路径引用)。
|
|
127
|
+
const derived = matched.filter((c) => c.resolved)
|
|
128
|
+
if (derived.length > 0) {
|
|
129
|
+
lines.push("Citations resolved via the declared review scope (bare path — resolved root noted):")
|
|
130
|
+
for (const c of derived.slice(0, 10)) lines.push(`- ${c.file}:${c.line} → ${c.resolved}`)
|
|
131
|
+
}
|
|
70
132
|
if (failed.length > 0) {
|
|
71
133
|
lines.push("Citations that do NOT match the current file state (treat their claims as unverified):")
|
|
72
134
|
for (const f of failed.slice(0, 10)) {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advisor/compaction.mjs — advisor review support (split out of advisor/run.mjs,
|
|
3
|
+
* 第 11 批 — run.mjs was 498/500 硬帽): context trimming + the review's resource
|
|
4
|
+
* limits + the terminal-state guards + the review-text assembler.
|
|
5
|
+
*
|
|
6
|
+
* estimateTokens / compactMessages moved VERBATIM (the only edit is the `pinned`
|
|
7
|
+
* re-attach — F13/§14.4 #3); renderTimeline moved verbatim too, so the tail
|
|
8
|
+
* GENERATOR and the tail CLASSIFIER stay in one file with the assembler they
|
|
9
|
+
* feed (§14.3 谓词 ↔ §14.6 结构化尾——同族单源)。拆分线 = 行数硬帽实测(见批次档 §5)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { providerSpec } from "../config.mjs" // 第 25 批:预算派生(与 loop.mjs:11 同源导入)
|
|
13
|
+
// B4(群 B 批,CLI §18.3——F32):CJK 加权单源(provider/rate.mjs 叶子向无环)
|
|
14
|
+
import { estimateText } from "../provider/rate.mjs"
|
|
15
|
+
|
|
16
|
+
export const MAX_ADVISOR_TURNS = 100
|
|
17
|
+
// NOTE: prompts/advisor-round{1,2,3}.md encourage the model to finish within
|
|
18
|
+
// ~30 tool turns — a prompt-level efficiency target, DISTINCT from the
|
|
19
|
+
// 100-turn mechanical hard cap (MAX_ADVISOR_TURNS above; pure runaway-loop
|
|
20
|
+
// guard). They serve different purposes; do NOT synchronize them.
|
|
21
|
+
|
|
22
|
+
// Context window limits
|
|
23
|
+
// 上下文预算(第 25 批——120K 硬编码退场):预算跟随评审模型窗口(providerSpec:
|
|
24
|
+
// 模型规格表 × provider 级 context 覆盖)。头寸用途 = chars/4 估算误差 + 响应/协议开销
|
|
25
|
+
// (内存不构成约束——设计 §16.4);判死线仍是宿主机自限线,服务端窗口约束不变。
|
|
26
|
+
export const CONTEXT_LIMIT_RATIO = 0.8 // 判死线 = 窗口 × 0.8
|
|
27
|
+
const COMPACT_TRIGGER_RATIO = 0.8 // 压缩触发 = 判死线 × 0.8(既有关系零改)
|
|
28
|
+
|
|
29
|
+
/** 评审上下文预算(纯函数——两档阈值可机测;provider 为 null 时退化默认规格)。 */
|
|
30
|
+
export function advisorContextBudget(provider) {
|
|
31
|
+
const limit = Math.floor(providerSpec(provider).context * CONTEXT_LIMIT_RATIO)
|
|
32
|
+
return { limit, compactAt: Math.floor(limit * COMPACT_TRIGGER_RATIO) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const TOOL_TIMEOUT_MS = 30_000 // single tool timeout
|
|
36
|
+
export const REVIEW_TIMEOUT_MS = 600_000 // whole review timeout (10 minutes)
|
|
37
|
+
export const MAX_RESULT_CHARS = 64 * 1024 // tool result truncation (line-aware; 64K, aligned with main offload limit)
|
|
38
|
+
const MAX_KEY_FILES_IN_COMPACTION = 5 // files named in the compaction summary
|
|
39
|
+
|
|
40
|
+
/** Estimate token count from messages(B4——群 B 批 CLI §18.3:扁平 chars/4 改 `estimateText`
|
|
41
|
+
* 加权式——ASCII/4 + 非 ASCII/1;纯 ASCII 与旧式逐值相等;CJK 低估 ~3-4× 修正;
|
|
42
|
+
* walker(content / tool_calls 两源)与计数口径零改)。 */
|
|
43
|
+
export function estimateTokens(messages) {
|
|
44
|
+
return messages.reduce((sum, msg) => {
|
|
45
|
+
const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content || "")
|
|
46
|
+
const toolCalls = msg.tool_calls ? JSON.stringify(msg.tool_calls) : ""
|
|
47
|
+
return sum + estimateText(content + toolCalls)
|
|
48
|
+
}, 0)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Compact early messages when context grows too large — LOCAL trimming only
|
|
52
|
+
* (no LLM summarization). MUTATES in place (splice) so the caller's array
|
|
53
|
+
* reference stays valid — a reassignment would leave the caller's logging
|
|
54
|
+
* (tool-call count, token estimate) reading a stale array. */
|
|
55
|
+
export function compactMessages(messages, pinned = null) {
|
|
56
|
+
// Keep: system prompt, last 20 messages (≈ 10 assistant+tool exchanges),
|
|
57
|
+
// user message — the rest is summarized.
|
|
58
|
+
if (messages.length <= 20) return
|
|
59
|
+
|
|
60
|
+
const system = messages[0]
|
|
61
|
+
const recent = messages.slice(-20)
|
|
62
|
+
const old = messages.slice(1, -20)
|
|
63
|
+
|
|
64
|
+
// Count actual tool messages (old.length counts user/assistant rows too)
|
|
65
|
+
const toolCount = old.filter((m) => m.role === "tool").length
|
|
66
|
+
const keyFiles = old
|
|
67
|
+
.filter((m) => m.role === "tool")
|
|
68
|
+
.map((m) => m.content?.split("\n")[0]?.slice(0, 50)) // first line of tool results typically names the file that was read/grepped
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.slice(0, MAX_KEY_FILES_IN_COMPACTION)
|
|
71
|
+
const filesPart = keyFiles.length > 0 ? ` Key files examined: ${keyFiles.join(", ")}` : ""
|
|
72
|
+
const summary = `Earlier exploration: ${toolCount} tool calls completed.${filesPart}`
|
|
73
|
+
|
|
74
|
+
// F13(第 11 批 §14.4 #3):压缩丢掉的正是**首条 user 消息**(评审简报,含 token)——
|
|
75
|
+
// pinned 由评审参数构建(非模型输出),在本次压缩动作内作为一条 user 消息重挂(幂等可读:
|
|
76
|
+
// 重复压缩允许重复挂回,不做存在性判定)。
|
|
77
|
+
const pin = pinned ? [{ role: "user", content: pinned }] : []
|
|
78
|
+
messages.splice(0, messages.length,
|
|
79
|
+
system,
|
|
80
|
+
{ role: "user", content: `[Context compacted] ${summary}` },
|
|
81
|
+
...pin,
|
|
82
|
+
...recent)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
// 不完整判定族(A / F16 共用单谓词——§14.3;六 kind = 宿主尾族)
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/** 宿主尾族六 kind 的块首行逐字前缀(§14.3 表)。变量段(token 数 / 秒数 / 工具轮数)
|
|
90
|
+
* 不入前缀——取各尾的固定字面部分;`review_failed` = run.mjs catch 的字符串 resolve
|
|
91
|
+
* 形态(不 throw),其余五条 = renderTimeline 尾(loop.mjs)。 */
|
|
92
|
+
const ADVISOR_INCOMPLETE_PREFIXES = [
|
|
93
|
+
["context_limit", "Advisor: context window limit"],
|
|
94
|
+
["turn_cap", "Advisor: stopped after"],
|
|
95
|
+
["timeout", "Advisor: review timeout"],
|
|
96
|
+
["empty", "Advisor: empty response"],
|
|
97
|
+
["interrupted", "Advisor: interrupted."],
|
|
98
|
+
["review_failed", "Advisor: review failed"],
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
/** 单谓词(三消费点同源:design 结算 / code 完成守卫 / 报告提示)——**块首行扫描**(按空行
|
|
102
|
+
* 分块,逐块取首行 trim 后测前缀;时间线与尾以空行相接,六条尾均以块首行形态落地)。
|
|
103
|
+
* 负向精度(§14.3 修正轮):引文中同串的**非块首形态**(围栏内行 / 表格行 / 引用行)不判
|
|
104
|
+
* incomplete;块首裸行引用同串的残余误报方向安全(fail-closed——多付一轮重跑,如实登记)。
|
|
105
|
+
* @returns {string|null} kind 或 null */
|
|
106
|
+
export function advisorIncompleteMarker(text) {
|
|
107
|
+
for (const block of String(text ?? "").split(/\n\s*\n/)) {
|
|
108
|
+
const first = block.split("\n").find((l) => l.trim() !== "")
|
|
109
|
+
if (!first) continue
|
|
110
|
+
const line = first.trim()
|
|
111
|
+
for (const [kind, prefix] of ADVISOR_INCOMPLETE_PREFIXES) {
|
|
112
|
+
if (line.startsWith(prefix)) return kind
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// 预算提示 + 结构化超时尾(D / F15——§14.6 #2/#3)
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/** 0.75 一次性预算提示判定(纯函数——阈值两侧可机测;每场评审至多一次)。 */
|
|
123
|
+
export function shouldBudgetNudge(elapsedMs, budgetMs, nudged) {
|
|
124
|
+
return !nudged && Number.isFinite(budgetMs) && budgetMs > 0 && elapsedMs >= budgetMs * 0.75
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 预算提示文案(逐字——§14.6 #2;由循环注入一条 user 消息)。 */
|
|
128
|
+
export function budgetNudgeText(elapsedMs, budgetMs) {
|
|
129
|
+
const secs = (ms) => Math.round(ms / 100) / 10
|
|
130
|
+
const pct = Math.round((elapsedMs / budgetMs) * 100)
|
|
131
|
+
return `⏳ review budget: ~${pct}% consumed (${secs(elapsedMs)}s of ${secs(budgetMs)}s). Converge now: emit your findings table for the evidence you have verified, mark anything you could not verify explicitly as \`unverified\` (unverified evidence must not support a pass), and emit your verdict line.`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 结构化超时尾(§14.6 #3):族前缀 `Advisor: review timeout after {S}s.` 逐字保持
|
|
135
|
+
* (判定族字面依赖);其后 = 机读统计(rounds / tool calls / review text produced)
|
|
136
|
+
* + 可执行恢复指引(narrower scope / 调预算)。 */
|
|
137
|
+
export function timeoutTail(timeoutMs, rounds, toolCalls, producedText) {
|
|
138
|
+
const s = Math.round(timeoutMs / 1000)
|
|
139
|
+
return [
|
|
140
|
+
`Advisor: review timeout after ${s}s. Review incomplete — the wall-clock budget was exhausted; partial findings (if any) are above.`,
|
|
141
|
+
`- rounds: ${rounds} · tool calls: ${toolCalls} · review text produced: ${producedText ? "yes" : "no"}`,
|
|
142
|
+
`- budget: ${s}s (agent.advisor.timeoutMs) — re-run with a narrower scope (split the review across fewer documents) or raise the budget.`,
|
|
143
|
+
].join("\n")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
// 评审文本装配(loop 的尾经此与时间线合流——与尾族同文件:生成 / 判定 / 装配单源)
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
// The live "[thinking…]" wait indicator shares its exact text with the TUI
|
|
151
|
+
// cleanup regex (agent-turn.mjs strips it before flushing to history) — keep
|
|
152
|
+
// them in lockstep.
|
|
153
|
+
export const ADVISOR_THINKING_PLACEHOLDER = "\n[thinking…]\n"
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Tool-call progress line summary delegates to the single source describeToolArgs
|
|
157
|
+
* (../tui/tool-args.mjs) — the same function main-agent tool blocks and subagent
|
|
158
|
+
* blocks use. 2026-08-31: replaced the local picker (action/path/pattern/command-only)
|
|
159
|
+
* so advisor progress lines show the quoted-path forms everywhere else.
|
|
160
|
+
*/
|
|
161
|
+
/**
|
|
162
|
+
* Render the ordered review timeline — thinking / tool progress / final text
|
|
163
|
+
* interleaved EXACTLY as emitted, so the persisted record shows the review
|
|
164
|
+
* process at its real positions. A summary appended at the end would lose the
|
|
165
|
+
* order (the user-visible "no tool calls in the advisor record" gap). The
|
|
166
|
+
* live "[thinking…]" placeholder is stripped (wait indicator, not content).
|
|
167
|
+
*/
|
|
168
|
+
export function renderTimeline(timeline, tail = "") {
|
|
169
|
+
const body = timeline
|
|
170
|
+
.map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, "").trim())
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
.join("\n\n")
|
|
173
|
+
return [body, tail].filter(Boolean).join("\n\n")
|
|
174
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advisor/loop.mjs — advisor tool loop: chat → execute tools → repeat, plus the
|
|
3
|
+
* review timeline (split out of advisor/run.mjs, 第 11 批 — run.mjs was 498/500
|
|
4
|
+
* 硬帽;拆分保持既有 import 面:run.mjs 继续 re-export 本文件导出)。
|
|
5
|
+
*
|
|
6
|
+
* 第 11 批(F15/§14.6):每次 chat 调用携带硬墙信号(`AbortSignal.any([signal,
|
|
7
|
+
* AbortSignal.timeout(remaining)])`;墙判定绑信号状态——抛错 / partial 两形态同判),
|
|
8
|
+
* 并按 0.75 一次性预算提示 + 结构化超时尾收尾;守卫与限额函数在 compaction.mjs。
|
|
9
|
+
*/
|
|
10
|
+
import { chat } from "../provider/core.mjs"
|
|
11
|
+
import { providerSpec } from "../config.mjs"
|
|
12
|
+
import { toOpenAISchema } from "../tools/index.mjs"
|
|
13
|
+
import { describeToolArgs } from "../tui/tool-args.mjs"
|
|
14
|
+
import { truncateAdvisorResult } from "./truncate.mjs"
|
|
15
|
+
import { batchSegmentTool } from "../agent-tools/batch-segment.mjs"
|
|
16
|
+
import {
|
|
17
|
+
estimateTokens, compactMessages, shouldBudgetNudge, budgetNudgeText, timeoutTail, renderTimeline,
|
|
18
|
+
MAX_ADVISOR_TURNS, advisorContextBudget, TOOL_TIMEOUT_MS, REVIEW_TIMEOUT_MS, MAX_RESULT_CHARS,
|
|
19
|
+
ADVISOR_THINKING_PLACEHOLDER,
|
|
20
|
+
} from "./compaction.mjs"
|
|
21
|
+
|
|
22
|
+
const { readTool, globTool, grepTool, lsTool } = await import("../tools/index.mjs")
|
|
23
|
+
const { lspTool } = await import("../tools/lsp.mjs")
|
|
24
|
+
const { codeSearchTool } = await import("../memory/code-sync.mjs")
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Advisor tool set — ZERO git, read-only ONLY, every round. The change surface
|
|
28
|
+
* comes from the review scope (paths / _touchedFiles injected by the caller),
|
|
29
|
+
* never from git: git output misled reviews (committed fixes never show in
|
|
30
|
+
* `git diff HEAD`, so "no changes" was read as "not fixed") and the user
|
|
31
|
+
* mandate is full decoupling (7d49a52 + d3be613). The reviewer reads files
|
|
32
|
+
* and searches code; it never touches git and never writes.
|
|
33
|
+
* No round parameter — the set is constant across all rounds.
|
|
34
|
+
* @param {Object} agent — only used for the code index (agent.memory); the
|
|
35
|
+
* semantic code_search tool needs it. Without a memory, the set is 5 tools.
|
|
36
|
+
*/
|
|
37
|
+
function advisorToolsFor(agent, reviewType = "code", batchDoc = null) {
|
|
38
|
+
const search = agent?.memory ? codeSearchTool(agent.memory) : null
|
|
39
|
+
const tools = search
|
|
40
|
+
? [readTool, globTool, grepTool, lsTool, lspTool, search]
|
|
41
|
+
: [readTool, globTool, grepTool, lsTool, lspTool]
|
|
42
|
+
// §2.20.3(第 4 批):**只有绑定了批次档的设计评审**额外拿到写通道——代码评审工具集
|
|
43
|
+
// 逐字节不变(零 git + 只读不变量,§2.20.8 #1);未绑定 → 不挂载(fail-closed)。
|
|
44
|
+
if (reviewType === "design" && batchDoc) tools.push(batchSegmentTool(batchDoc, { review: true }))
|
|
45
|
+
return { schemas: tools.map(toOpenAISchema), byName: new Map(tools.map((t) => [t.name, t])) }
|
|
46
|
+
}
|
|
47
|
+
// Test seam: the tool set is pure (agent.memory → code_search inclusion).
|
|
48
|
+
export { advisorToolsFor, advisorToolsFor as _advisorToolsFor }
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run the advisor's tool loop: chat → execute tools → repeat.
|
|
52
|
+
* Stops when the model produces text without tool calls.
|
|
53
|
+
*
|
|
54
|
+
* Progress lines (→ tool args) are emitted via onOutput between model bursts so
|
|
55
|
+
* the panel keeps moving while the advisor explores — otherwise the panel sits
|
|
56
|
+
* frozen through every tool-call phase and the review appears to have stalled.
|
|
57
|
+
*
|
|
58
|
+
* @param {string|null} [pinned] — 第 11 批:压缩定锚简报(评审参数构建——F13/§14.4 #3)。
|
|
59
|
+
* @param {{now?: Function, chat?: Function}} [seams] — 测试缝(默认 Date.now / chat——
|
|
60
|
+
* 生产调用不传,默认回退零行为变)。
|
|
61
|
+
*/
|
|
62
|
+
async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd, toolsOverride = null, reviewType = "code", batchDoc = null, pinned = null, seams = {}) {
|
|
63
|
+
const now = seams.now ?? Date.now
|
|
64
|
+
const chatCall = seams.chat ?? chat
|
|
65
|
+
// 第 11 批硬墙 / 预算 / 尾:实现注解见下方各点;守卫函数与 renderTimeline 在 compaction.mjs。
|
|
66
|
+
// Kind-tagged wrappers: the TUI panel colors reasoning / answer / tool progress differently.
|
|
67
|
+
// Every chunk is ALSO recorded into an ordered timeline — the persisted record
|
|
68
|
+
// must show the review process (thinking ↔ tool progress ↔ final text) at its
|
|
69
|
+
// real positions, not a summary appended at the end. Same-kind consecutive
|
|
70
|
+
// chunks merge (token streams); kind flips start a new entry.
|
|
71
|
+
const timeline = []
|
|
72
|
+
const record = (kind, text) => {
|
|
73
|
+
const last = timeline.at(-1)
|
|
74
|
+
if (last && last.kind === kind) last.text += text
|
|
75
|
+
else timeline.push({ kind, text })
|
|
76
|
+
}
|
|
77
|
+
const emit = (kind) => (text) => { record(kind, text); onOutput?.({ kind, text }) }
|
|
78
|
+
const onThink = emit("think")
|
|
79
|
+
const onText = emit("text")
|
|
80
|
+
const onTool = emit("tool")
|
|
81
|
+
// toolsOverride = test seam (T-TS8/9): the real advisor tool set, or a mock
|
|
82
|
+
// set with controllable timing/errors.
|
|
83
|
+
const { schemas: toolSchemas, byName: toolByName } = toolsOverride ?? advisorToolsFor(agent, reviewType, batchDoc)
|
|
84
|
+
let turns = 0
|
|
85
|
+
let toolCallCount = 0
|
|
86
|
+
let reviewTextProduced = false
|
|
87
|
+
let budgetNudged = false
|
|
88
|
+
const startTime = now()
|
|
89
|
+
// 第 25 批(§16.3):上下文预算跟随评审模型窗口——`providerSpec`(模型规格表 × provider 级
|
|
90
|
+
// context 覆盖)派生;函数体内、while 轮次外一次性(provider 全场不变),两档消费见下守卫。
|
|
91
|
+
const budget = advisorContextBudget(provider)
|
|
92
|
+
|
|
93
|
+
while (true) {
|
|
94
|
+
// Interrupted (Ctrl+I) — stop immediately instead of spinning a fresh uncancellable signal
|
|
95
|
+
if (signal?.aborted) return renderTimeline(timeline, "Advisor: interrupted.")
|
|
96
|
+
|
|
97
|
+
// Check review timeout (10 minutes by default; agent.advisor.timeoutMs overrides)
|
|
98
|
+
// 运行时校验(设计评审 #1,2026-08-24):手写 config.json 的非法值(0/负数/字符串)
|
|
99
|
+
// 不得静默禁用或立即触发超时——非法一律回退默认。
|
|
100
|
+
const cfg = agent.config?.advisor?.timeoutMs
|
|
101
|
+
const timeoutMs = (Number.isFinite(cfg) && cfg > 0) ? cfg : REVIEW_TIMEOUT_MS
|
|
102
|
+
const elapsed = now() - startTime
|
|
103
|
+
const remaining = timeoutMs - elapsed
|
|
104
|
+
// 硬墙(§14.6 #1):预算用尽 → 结构化超时尾(首行 = 判定族 timeout 前缀)。
|
|
105
|
+
if (remaining <= 0) {
|
|
106
|
+
return renderTimeline(timeline, timeoutTail(timeoutMs, turns, toolCallCount, reviewTextProduced))
|
|
107
|
+
}
|
|
108
|
+
// 0.75 一次性预算提示(§14.6 #2——同一检查点、每场评审至多一次):注入一条 user 消息
|
|
109
|
+
// 促模型在墙前收敛产出(不改语义判据、不碰提示词面)。
|
|
110
|
+
if (shouldBudgetNudge(elapsed, timeoutMs, budgetNudged)) {
|
|
111
|
+
budgetNudged = true
|
|
112
|
+
messages.push({ role: "user", content: budgetNudgeText(elapsed, timeoutMs) })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (++turns > MAX_ADVISOR_TURNS) {
|
|
116
|
+
return renderTimeline(timeline, "Advisor: stopped after " + MAX_ADVISOR_TURNS + " tool rounds — the review appears to be looping. You may retry with a narrower scope.")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check context window and compact if needed
|
|
120
|
+
const currentTokens = estimateTokens(messages)
|
|
121
|
+
if (currentTokens > budget.compactAt) {
|
|
122
|
+
onText(`\n[Context compacted: ${currentTokens} tokens → reducing to fit window]\n`)
|
|
123
|
+
compactMessages(messages, pinned)
|
|
124
|
+
if (estimateTokens(messages) > budget.limit) {
|
|
125
|
+
// Report the POST-compaction count — the pre-compaction currentTokens
|
|
126
|
+
// is stale by the time compaction has run.
|
|
127
|
+
return renderTimeline(timeline, `Advisor: context window limit reached (${estimateTokens(messages)} tokens). Review incomplete — too many tool calls. Try a narrower scope.`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// LLM generation silence: the reasoning phase produces no SSE bytes for
|
|
132
|
+
// seconds to tens of seconds (server-side prefill on large contexts, per
|
|
133
|
+
// tool-round LLM return). A placeholder keeps the panel visibly working.
|
|
134
|
+
// kind "think" (NOT "text"): the placeholder must land in the SAME buffer
|
|
135
|
+
// and position as the upcoming reasoning — a "text"-kind placeholder
|
|
136
|
+
// rendered BELOW the think block, and the reasoning stream appeared ABOVE
|
|
137
|
+
// it ("the stream runs back to the front"). Same buffer = same spot; the
|
|
138
|
+
// reasoning continues right where the placeholder sits.
|
|
139
|
+
onOutput?.({ kind: "think", text: ADVISOR_THINKING_PLACEHOLDER })
|
|
140
|
+
|
|
141
|
+
// 硬墙(§14.6 #1):单次请求信号 = 用户信号 × 本调用 deadline(remaining)。复合信号
|
|
142
|
+
// 无条件传入(上层检查与本调用之间的中止仍必须取消请求——已 aborted 的 composite 使请求
|
|
143
|
+
// 立即失败);此处改正了原指向 provider/core.mjs 组合 AbortSignal 的陈旧注释(§14.10 #3)。
|
|
144
|
+
const callSignal = signal
|
|
145
|
+
? AbortSignal.any([signal, AbortSignal.timeout(remaining)])
|
|
146
|
+
: AbortSignal.timeout(remaining)
|
|
147
|
+
let response
|
|
148
|
+
try {
|
|
149
|
+
response = await chatCall(provider, {
|
|
150
|
+
messages,
|
|
151
|
+
tools: toolSchemas,
|
|
152
|
+
signal: callSignal,
|
|
153
|
+
onToken: (t) => { if (String(t ?? "").trim()) reviewTextProduced = true; onText(t) },
|
|
154
|
+
onReasoning: onThink,
|
|
155
|
+
// LOGGING(vscode advisor/run.mjs parity——按 stage 可 grep)
|
|
156
|
+
// §18.6 D-TR4:轨迹元数据增补——kind=advisor(评审独立于子代理——T-TR2);role
|
|
157
|
+
// 透出调用方角色(eng-coder 内嵌评审时为 "eng-coder");session/cwd 供轨迹对回;
|
|
158
|
+
// traces 开关沿 agent.config(D-TR6)。
|
|
159
|
+
logCtx: {
|
|
160
|
+
stage: "advisor",
|
|
161
|
+
role: agent?._role ?? null,
|
|
162
|
+
kind: "advisor",
|
|
163
|
+
session: agent?._sessionStart ?? null,
|
|
164
|
+
cwd,
|
|
165
|
+
traces: agent?.config?.traces?.enabled !== false,
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
} catch (e) {
|
|
169
|
+
// 墙判定绑信号状态(§14.6 #1——非异常名):① 用户信号已中止 ⇒ 原样上抛(中断语义
|
|
170
|
+
// 零变);② 复合信号已中止(墙触发)而用户信号未中止 ⇒ 结构化超时尾(形态①:抛错;
|
|
171
|
+
// AbortError / TimeoutError 两名兜底——AbortSignal.timeout 的 reason 是 TimeoutError
|
|
172
|
+
// DOMException);③ 其余错误原样上抛(runAdvisorReview 的失败分类不变)。
|
|
173
|
+
if (signal?.aborted) throw e
|
|
174
|
+
if (callSignal.aborted || e?.name === "AbortError" || e?.name === "TimeoutError") {
|
|
175
|
+
return renderTimeline(timeline, timeoutTail(timeoutMs, turns, toolCallCount, reviewTextProduced))
|
|
176
|
+
}
|
|
177
|
+
throw e
|
|
178
|
+
}
|
|
179
|
+
if (signal?.aborted) return renderTimeline(timeline, "Advisor: interrupted.")
|
|
180
|
+
// 形态②(§14.6 #1):不抛错而返回 partial(流已有内容时中断以 partial:true 透传)——
|
|
181
|
+
// 不得按普通结果收尾:墙触发(复合信号已中止)同判。
|
|
182
|
+
if (callSignal.aborted && response?.partial) {
|
|
183
|
+
return renderTimeline(timeline, timeoutTail(timeoutMs, turns, toolCallCount, reviewTextProduced))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// No tool calls — this is the final review text. The final answer was
|
|
187
|
+
// already streamed into the timeline via onText; fall back to
|
|
188
|
+
// response.content only if nothing was recorded.
|
|
189
|
+
if (!response.toolCalls?.length) {
|
|
190
|
+
if (!response.content?.trim()) return renderTimeline(timeline) || "Advisor: empty response — review was inconclusive"
|
|
191
|
+
return renderTimeline(timeline) || response.content.trim()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Push assistant message with tool calls. reasoning_content ECHO is
|
|
195
|
+
// mandatory for reasoningEcho:"required" providers (deepseek/kimi): the
|
|
196
|
+
// server stops returning reasoning_content on later rounds when the
|
|
197
|
+
// tool-call assistant history lacks it — the observed "reasoning stops
|
|
198
|
+
// after the first tool call, returns only at the final answer" symptom.
|
|
199
|
+
// Mirrors the main agent's push (agent.mjs).
|
|
200
|
+
messages.push({
|
|
201
|
+
role: "assistant",
|
|
202
|
+
content: response.content || null,
|
|
203
|
+
tool_calls: response.toolCalls.map((tc) => ({
|
|
204
|
+
id: tc.id, type: "function",
|
|
205
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
206
|
+
})),
|
|
207
|
+
...(response.reasoning && providerSpec(provider).reasoningEcho === "required"
|
|
208
|
+
? { reasoning_content: response.reasoning }
|
|
209
|
+
: {}),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// B1 (AGENT-LOOP.md §18.7 D-TS7): the SAME LLM reply's multiple read-only
|
|
213
|
+
// tool calls run in PARALLEL (Promise.all) — results are backfilled in
|
|
214
|
+
// toolCalls order (Promise.all preserves the input order → tool_call_id
|
|
215
|
+
// never mismatches); each tool's timeout/error is captured independently
|
|
216
|
+
// (the existing TOOL_TIMEOUT stays — one failing tool does not block the
|
|
217
|
+
// others); progress lines are emitted in toolCalls order. The read-only
|
|
218
|
+
// tool set has no side effects — no sequencing/serialization needed.
|
|
219
|
+
// Scope note (round1 review #10): B1 is ONLY in-loop tool parallelism — it
|
|
220
|
+
// does NOT solve the TODO "platform execution: advisor parallel calls are
|
|
221
|
+
// actually serial" mystery (docs/TODO.md — LOGGING evidence item), which
|
|
222
|
+
// concerns multiple advisor CALLS observed as serial, not one reply's
|
|
223
|
+
// tool calls.
|
|
224
|
+
const parsed = response.toolCalls.map((tc) => {
|
|
225
|
+
const tool = toolByName.get(tc.name)
|
|
226
|
+
let args = {}
|
|
227
|
+
let parseError = null
|
|
228
|
+
try {
|
|
229
|
+
args = JSON.parse(tc.arguments || "{}")
|
|
230
|
+
} catch (e) {
|
|
231
|
+
parseError = `Error: invalid JSON in tool arguments: ${e.message}\nRaw arguments: ${(tc.arguments || "").slice(0, 200)}`
|
|
232
|
+
}
|
|
233
|
+
return { tc, tool, args, parseError }
|
|
234
|
+
})
|
|
235
|
+
toolCallCount += parsed.length
|
|
236
|
+
// Progress lines first, in toolCalls order (emitted before the parallel
|
|
237
|
+
// run — display order is independent of completion order).
|
|
238
|
+
for (const p of parsed) {
|
|
239
|
+
if (p.parseError) continue // parse-error tools get no progress line (legacy behavior)
|
|
240
|
+
const argsLine = describeToolArgs(p.tc.name, p.args)
|
|
241
|
+
onTool(`\n→ ${p.tc.name}${argsLine ? " " + argsLine : ""}\n`)
|
|
242
|
+
}
|
|
243
|
+
// Every tool runs CONCURRENTLY; each result/error lands in its own slot —
|
|
244
|
+
// Promise.all preserves input order, so index i always matches parsed[i].
|
|
245
|
+
const executed = await Promise.all(parsed.map(async (p) => {
|
|
246
|
+
// Parse failure → error to model immediately (no execution)
|
|
247
|
+
if (p.parseError) return p.parseError
|
|
248
|
+
if (!p.tool) return `Error: unknown tool "${p.tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
|
|
249
|
+
// Execute with timeout (clear the timer when the tool wins the race —
|
|
250
|
+
// otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
|
|
251
|
+
try {
|
|
252
|
+
let timeoutId
|
|
253
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
254
|
+
timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
|
|
255
|
+
})
|
|
256
|
+
let toolPromise
|
|
257
|
+
try {
|
|
258
|
+
toolPromise = p.tool.execute(p.args, { cwd, agent, onOutput, signal })
|
|
259
|
+
return await Promise.race([toolPromise, timeoutPromise])
|
|
260
|
+
} finally {
|
|
261
|
+
clearTimeout(timeoutId)
|
|
262
|
+
// Timeout won → toolPromise is still pending; a later rejection
|
|
263
|
+
// would surface as an unhandled rejection. The race already
|
|
264
|
+
// consumed the result/error in the normal path, so this no-op
|
|
265
|
+
// catch only fires for the abandoned-tool case.
|
|
266
|
+
toolPromise?.catch(() => {})
|
|
267
|
+
}
|
|
268
|
+
} catch (e) {
|
|
269
|
+
const errorType = e.message.includes("timeout") ? "timeout"
|
|
270
|
+
: e.message.includes("ENOENT") ? "file_not_found"
|
|
271
|
+
: e.message.includes("permission") ? "permission_denied"
|
|
272
|
+
: "execution_error"
|
|
273
|
+
return `Error (${errorType}): ${e.message}`
|
|
274
|
+
}
|
|
275
|
+
}))
|
|
276
|
+
|
|
277
|
+
// Backfill in toolCalls order (executed[i] ↔ parsed[i]); per-result
|
|
278
|
+
// non-string serialization + dual-end line-aware truncation stay per-tool
|
|
279
|
+
// (DUAL-END-TRUNCATION F-2 — truncate.mjs: head ≈60% + tail ≈40% — keep the
|
|
280
|
+
// tail verdicts; ≤ MAX_RESULT_CHARS results pass through untouched).
|
|
281
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
282
|
+
let result = executed[i]
|
|
283
|
+
if (typeof result !== "string") result = JSON.stringify(result)
|
|
284
|
+
|
|
285
|
+
result = truncateAdvisorResult(result, MAX_RESULT_CHARS)
|
|
286
|
+
|
|
287
|
+
messages.push({ role: "tool", tool_call_id: parsed[i].tc.id, content: result })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Test seam (T-TS8/9): the tool loop itself — toolsOverride injects a mock tool
|
|
292
|
+
// set with controllable timing/errors (the real set comes from advisorToolsFor).
|
|
293
|
+
export { runAdvisorToolLoop, runAdvisorToolLoop as _runAdvisorToolLoop }
|