@thincoder/core 0.9.1
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/abort-provenance.mjs +116 -0
- package/advisor/citations.mjs +139 -0
- package/advisor/compaction.mjs +174 -0
- package/advisor/convergence.mjs +80 -0
- package/advisor/history.mjs +77 -0
- package/advisor/loop.mjs +288 -0
- package/advisor/messages.mjs +299 -0
- package/advisor/notice.mjs +141 -0
- package/advisor/project-context.mjs +197 -0
- package/advisor/repos.mjs +150 -0
- package/advisor/run.mjs +190 -0
- package/advisor/truncate.mjs +57 -0
- package/advisor.mjs +281 -0
- package/agent/child-marks.mjs +24 -0
- package/agent/completion.mjs +145 -0
- package/agent/dispatch.mjs +493 -0
- package/agent/family-tools.mjs +174 -0
- package/agent/helpers.mjs +412 -0
- package/agent/post-turn.mjs +70 -0
- package/agent/record-results.mjs +174 -0
- package/agent/relay-prefix.mjs +39 -0
- package/agent/run-stages.mjs +244 -0
- package/agent/setup-reminders.mjs +199 -0
- package/agent/setup.mjs +234 -0
- package/agent/spawn-child.mjs +258 -0
- package/agent/suspension.mjs +240 -0
- package/agent/write-gate.mjs +87 -0
- package/agent-tools/advisor-async.mjs +481 -0
- package/agent-tools/advisor-settle.mjs +240 -0
- package/agent-tools/advisor.mjs +280 -0
- package/agent-tools/async-discard.mjs +143 -0
- package/agent-tools/async-settle.mjs +299 -0
- package/agent-tools/batch-segment.mjs +265 -0
- package/agent-tools/child-permission.mjs +45 -0
- package/agent-tools/consult.mjs +471 -0
- package/agent-tools/design-token.mjs +117 -0
- package/agent-tools/digest-budget.mjs +76 -0
- package/agent-tools/eng.mjs +102 -0
- package/agent-tools/escalate-async.mjs +302 -0
- package/agent-tools/goal.mjs +119 -0
- package/agent-tools/panel-blocks.mjs +24 -0
- package/agent-tools/parent-channel.mjs +231 -0
- package/agent-tools/plan.mjs +86 -0
- package/agent-tools/read-history.mjs +309 -0
- package/agent-tools/recent-changes.mjs +24 -0
- package/agent-tools/review-facts.mjs +31 -0
- package/agent-tools/settings.mjs +268 -0
- package/agent-tools/skill.mjs +63 -0
- package/agent-tools/spawn-gates.mjs +109 -0
- package/agent-tools/subagent-actions.mjs +496 -0
- package/agent-tools/subagent-async.mjs +456 -0
- package/agent-tools/subagent-panel.mjs +160 -0
- package/agent-tools/subagent-run.mjs +208 -0
- package/agent-tools/subagent-scheduler.mjs +446 -0
- package/agent-tools/subagent-spawn.mjs +478 -0
- package/agent-tools/subagent.mjs +419 -0
- package/agent-tools/task.mjs +87 -0
- package/agent-tools/timer.mjs +46 -0
- package/agent-tools/verify.mjs +295 -0
- package/agent-tools.mjs +23 -0
- package/agent.mjs +430 -0
- package/auto-think.mjs +115 -0
- package/compress-form.mjs +24 -0
- package/config-io.mjs +277 -0
- package/config-migrate.mjs +178 -0
- package/config-presets.mjs +49 -0
- package/config.mjs +419 -0
- package/context.mjs +495 -0
- package/conventions.mjs +223 -0
- package/embedding.mjs +120 -0
- package/escape.mjs +152 -0
- package/expand-home.mjs +16 -0
- package/explore-distill.mjs +152 -0
- package/generate-title.mjs +123 -0
- package/git/checkpoint.mjs +448 -0
- package/git/gitmem.mjs +100 -0
- package/history-window.mjs +179 -0
- package/hooks.mjs +108 -0
- package/i18n.mjs +106 -0
- package/index-bin.mjs +48 -0
- package/index-discover.mjs +176 -0
- package/ledger-cmd.mjs +209 -0
- package/ledger-db.mjs +86 -0
- package/ledger-surface.mjs +76 -0
- package/ledger.mjs +202 -0
- package/log.mjs +195 -0
- package/manifest.mjs +338 -0
- package/markdown.mjs +106 -0
- package/mcp/helpers.mjs +51 -0
- package/mcp/transport-http.mjs +248 -0
- package/mcp/transport-stdio.mjs +140 -0
- package/mcp/transport-ws.mjs +122 -0
- package/mcp.mjs +295 -0
- package/memory/code-index.mjs +219 -0
- package/memory/code-sync.mjs +427 -0
- package/memory/core.mjs +318 -0
- package/memory/delete.mjs +242 -0
- package/memory/docs.mjs +431 -0
- package/memory/file-walk.mjs +109 -0
- package/memory/origin.mjs +24 -0
- package/memory/scan.mjs +177 -0
- package/memory/schema.mjs +460 -0
- package/memory.mjs +21 -0
- package/model-ref.mjs +66 -0
- package/model-specs.mjs +277 -0
- package/package.json +32 -0
- package/peer-domains.mjs +265 -0
- package/peer-instances.mjs +178 -0
- package/permission.mjs +79 -0
- package/process-probe.mjs +315 -0
- package/prompt-files.mjs +113 -0
- package/prompt-overlays.mjs +78 -0
- package/prompts/advisor-design.md +43 -0
- package/prompts/advisor-round1.md +41 -0
- package/prompts/advisor-round2.md +46 -0
- package/prompts/advisor-round3.md +42 -0
- package/prompts/common.md +158 -0
- package/prompts/consult-base.md +19 -0
- package/prompts/discipline-engineering.md +123 -0
- package/prompts/discipline-normal.md +206 -0
- package/prompts/persona-coder.md +21 -0
- package/prompts/persona-eng-coder.md +41 -0
- package/prompts/persona-eng-designer.md +80 -0
- package/prompts/persona-engineering.md +160 -0
- package/prompts/persona-explore.md +15 -0
- package/prompts/persona-normal.md +35 -0
- package/prompts/persona-plan.md +27 -0
- package/provider/anthropic.mjs +225 -0
- package/provider/core.mjs +491 -0
- package/provider/errors.mjs +101 -0
- package/provider/google.mjs +257 -0
- package/provider/index.mjs +7 -0
- package/provider/list-models.mjs +163 -0
- package/provider/normalize.mjs +81 -0
- package/provider/rate.mjs +168 -0
- package/provider/responses.mjs +495 -0
- package/provider/retry.mjs +88 -0
- package/provider/sse.mjs +264 -0
- package/provider/wait-status.mjs +59 -0
- package/proxy.mjs +274 -0
- package/rules.mjs +53 -0
- package/session-gc.mjs +248 -0
- package/session-guard.mjs +59 -0
- package/session-lifecycle.mjs +305 -0
- package/session-migrate.mjs +48 -0
- package/session-rename.mjs +38 -0
- package/session-segments.mjs +99 -0
- package/session-slot-write.mjs +168 -0
- package/session-slots-manifest.mjs +264 -0
- package/session-slots.mjs +298 -0
- package/session-store.mjs +441 -0
- package/session.mjs +244 -0
- package/skills.mjs +234 -0
- package/text-budget.mjs +79 -0
- package/token-ttl.mjs +285 -0
- package/tool-docs/apply_patch.md +15 -0
- package/tool-docs/bash.md +38 -0
- package/tool-docs/delete.md +13 -0
- package/tool-docs/edit.md +30 -0
- package/tool-docs/execute.md +21 -0
- package/tool-docs/fetch.md +12 -0
- package/tool-docs/file_ops.md +17 -0
- package/tool-docs/get_current_time.md +8 -0
- package/tool-docs/git.md +54 -0
- package/tool-docs/glob.md +11 -0
- package/tool-docs/grep.md +19 -0
- package/tool-docs/hashline_edit.md +14 -0
- package/tool-docs/insert_after.md +15 -0
- package/tool-docs/lint.md +10 -0
- package/tool-docs/ls.md +12 -0
- package/tool-docs/lsp.md +10 -0
- package/tool-docs/process.md +10 -0
- package/tool-docs/question.md +16 -0
- package/tool-docs/read.md +20 -0
- package/tool-docs/read_image.md +8 -0
- package/tool-docs/tree.md +14 -0
- package/tool-docs/wait_for.md +22 -0
- package/tool-docs/websearch.md +16 -0
- package/tool-docs/write.md +11 -0
- package/tools/bash.mjs +276 -0
- package/tools/edit-batch.mjs +204 -0
- package/tools/edit-diff.mjs +388 -0
- package/tools/exec-run.mjs +43 -0
- package/tools/execute.mjs +243 -0
- package/tools/file.mjs +464 -0
- package/tools/git-checkpoint.mjs +143 -0
- package/tools/git-ext.mjs +173 -0
- package/tools/git.mjs +415 -0
- package/tools/glob-dialect.mjs +130 -0
- package/tools/index.mjs +76 -0
- package/tools/linter.mjs +120 -0
- package/tools/lsp.mjs +335 -0
- package/tools/ops.mjs +293 -0
- package/tools/patch.mjs +290 -0
- package/tools/question.mjs +26 -0
- package/tools/repomap.mjs +314 -0
- package/tools/search.mjs +248 -0
- package/tools/shared.mjs +467 -0
- package/tools/tree.mjs +81 -0
- package/tools/web.mjs +224 -0
- package/tools/write-path.mjs +191 -0
- package/traces/trace-store.mjs +303 -0
- package/undo-stack.mjs +47 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/completion.mjs — handle model response with no tool calls
|
|
3
|
+
*
|
|
4
|
+
* Checks: pending tasks, verify guard, advisor guard.
|
|
5
|
+
* Returns { action: 'continue' | 'done', content?, guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
6
|
+
*/
|
|
7
|
+
import { hasCodeMutations } from "../advisor/repos.mjs"
|
|
8
|
+
import { pushReal } from "../context.mjs"
|
|
9
|
+
import { advisorReviewPending, effectiveAdvisorRound } from "../agent-tools/advisor-async.mjs"
|
|
10
|
+
|
|
11
|
+
const MAX_VERIFY_PUSHBACKS = 2
|
|
12
|
+
const MAX_VERIFY_RETRIES = 3
|
|
13
|
+
const MAX_ADVISOR_PUSHBACKS = 3
|
|
14
|
+
const MAX_EMPTY_RETRIES = 2
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Handle a model turn with zero tool calls. May push back (verify/advisor/pending tasks)
|
|
18
|
+
* or accept the completion.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} agent
|
|
21
|
+
* @param {object} response - chat response with .content, .toolCalls
|
|
22
|
+
* @param {number} depth - agent nesting depth (0 = top-level)
|
|
23
|
+
* @param {number} turn - current turn index
|
|
24
|
+
* @param {number} guardPushbacks - verify guard pushback count (mutated)
|
|
25
|
+
* @param {boolean} honestReminderInjected - whether exhausted-verify reminder was already sent (mutated)
|
|
26
|
+
* @param {number} advisorPushbacks - advisor guard pushback count (mutated)
|
|
27
|
+
* @param {object} callbacks - { onTurnEnd }
|
|
28
|
+
*/
|
|
29
|
+
export function handleCompletion(agent, response, depth, turn, guardPushbacks, honestReminderInjected, advisorPushbacks, callbacks) {
|
|
30
|
+
if (!response.content) {
|
|
31
|
+
// Transient empty response (reasoning exhausted / output truncated): instead of
|
|
32
|
+
// aborting the whole turn, inject a reminder and let the model respond again.
|
|
33
|
+
// Bounded — after MAX_EMPTY_RETRIES consecutive empties, surface the original error.
|
|
34
|
+
const retries = agent._emptyRetries ?? 0
|
|
35
|
+
if (retries < MAX_EMPTY_RETRIES) {
|
|
36
|
+
agent._emptyRetries = retries + 1
|
|
37
|
+
agent.history.push({
|
|
38
|
+
role: "user",
|
|
39
|
+
content: "[System reminder: your last response was empty — the provider returned no content (likely reasoning was exhausted or output was truncated). Respond again, continuing your work from where you left off.]",
|
|
40
|
+
})
|
|
41
|
+
callbacks.onTurnEnd?.(agent, turn)
|
|
42
|
+
return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
43
|
+
}
|
|
44
|
+
throw new Error(
|
|
45
|
+
"LLM returned empty response (likely reasoning exhausted or output truncated). " +
|
|
46
|
+
"Try lowering reasoning effort if this persists (/think in TUI). " +
|
|
47
|
+
`Provider: ${agent.provider.model}`
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Pending tasks: remind the model ONCE before it declares itself done.
|
|
52
|
+
// Deliberately capped at one pushback (reported pain: unbounded looping when a
|
|
53
|
+
// pending item can't be resolved). After the single reminder the model is free
|
|
54
|
+
// to finish — updating the task list (task tool) resets the budget, so a fresh
|
|
55
|
+
// list state earns one fresh reminder.
|
|
56
|
+
if (depth === 0 && agent.tasks.some((t) => t.status === "pending") && (agent._taskPushbacks ?? 0) < 1) {
|
|
57
|
+
agent._taskPushbacks = (agent._taskPushbacks ?? 0) + 1
|
|
58
|
+
const pending = agent.tasks.filter((t) => t.status === "pending").map((t) => t.title).join(", ")
|
|
59
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
60
|
+
agent.history.push({
|
|
61
|
+
role: "user",
|
|
62
|
+
content: `[System reminder: you still have pending tasks: ${pending}. Update their status with the task tool before finishing — if they're done, mark them done; if they're not applicable, remove them. (This is your only reminder — if you choose not to, finish anyway.)]`,
|
|
63
|
+
})
|
|
64
|
+
callbacks.onTurnEnd?.(agent, turn)
|
|
65
|
+
return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- verify guard: push model to verify mutated files before completion ---
|
|
69
|
+
// OPT-IN ONLY (verifyGuard: true). Engineering mode is excluded because it
|
|
70
|
+
// uses flow-driven review, not per-turn mechanical pushback (ENGINEERING-MODE.md §2.3).
|
|
71
|
+
// Backward compat: also accept root-level verifyGuard
|
|
72
|
+
const verifyGuard = agent.config?.agent?.verifyGuard ?? agent.config?.verifyGuard
|
|
73
|
+
if (depth === 0 && verifyGuard === true && !agent.config?.agent?.engineering) {
|
|
74
|
+
// Not verified yet → pushback to run verify
|
|
75
|
+
if (agent._mutatedThisRun && !agent._verifiedThisRun && hasCodeMutations(agent) && guardPushbacks < MAX_VERIFY_PUSHBACKS) {
|
|
76
|
+
guardPushbacks++
|
|
77
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
78
|
+
agent.history.push({
|
|
79
|
+
role: "user",
|
|
80
|
+
content: "[System reminder: you modified files in this run but have not verified the changes. Before finishing: run the project's verification yourself (per its AGENTS.md test method), then call verify declaring the outcome via verification.status. verify mechanically gates on your declaration. If verification is genuinely impossible here, say so explicitly in your reply.]",
|
|
81
|
+
})
|
|
82
|
+
callbacks.onTurnEnd?.(agent, turn)
|
|
83
|
+
return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
84
|
+
}
|
|
85
|
+
// Verified but not passed → pushback to fix/complete (up to MAX_VERIFY_RETRIES).
|
|
86
|
+
// _verifyPassed === false means the declaration was failed, skipped without a
|
|
87
|
+
// reason, or not declared — not necessarily that a test failed.
|
|
88
|
+
if (agent._verifiedThisRun && agent._verifyPassed === false && agent._verifyRetries < MAX_VERIFY_RETRIES) {
|
|
89
|
+
agent._verifyRetries++
|
|
90
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
91
|
+
agent.history.push({
|
|
92
|
+
role: "user",
|
|
93
|
+
content: `[System reminder: (retry ${agent._verifyRetries}/${MAX_VERIFY_RETRIES}) verify was not passed — either your verification declared failed, was skipped without a reason, or was not declared. Fix or complete your verification, then call verify again declaring the outcome. If you cannot fix after ${MAX_VERIFY_RETRIES} attempts, explain honestly what's blocking you.]`,
|
|
94
|
+
})
|
|
95
|
+
callbacks.onTurnEnd?.(agent, turn)
|
|
96
|
+
return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
97
|
+
}
|
|
98
|
+
// Exhausted retries — inject honesty reminder once
|
|
99
|
+
if (agent._verifiedThisRun && agent._verifyPassed === false && agent._verifyRetries >= MAX_VERIFY_RETRIES) {
|
|
100
|
+
if (honestReminderInjected) {
|
|
101
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
102
|
+
return { action: "done", content: response.content, guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
103
|
+
}
|
|
104
|
+
honestReminderInjected = true
|
|
105
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
106
|
+
agent.history.push({
|
|
107
|
+
role: "user",
|
|
108
|
+
content: `[System reminder: ${MAX_VERIFY_RETRIES} verify attempts exhausted. You have not passed verification. Either state explicitly that your verification could not be completed, or run verify again once it is. If your verification could not be completed, say so explicitly in your reply to the user — state what you tried and what you believe is blocking you, and do not present the work as complete; the user needs to know it is unfinished.]`,
|
|
109
|
+
})
|
|
110
|
+
callbacks.onTurnEnd?.(agent, turn)
|
|
111
|
+
return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- advisor guard: review of mutated files before completion ---
|
|
116
|
+
// OPT-IN ONLY (advisor.guard === true, default OFF — 2026-08-21 semantic
|
|
117
|
+
// refactor), and NEVER in engineering mode. The advisor tool itself is always
|
|
118
|
+
// available; this guard only controls whether completion is pushed back.
|
|
119
|
+
const cfg = agent.config?.advisor
|
|
120
|
+
const advisorReview = cfg?.guard === true
|
|
121
|
+
if (depth === 0 && advisorReview && !agent.config?.agent?.engineering) {
|
|
122
|
+
// §11.2 D-24b (T-24b4 — guard timing): an async review that is still in flight
|
|
123
|
+
// (or queued in the advisor pool) means the review was launched — the guard
|
|
124
|
+
// does NOT push back while it is pending (未决不算未评审); once it settles
|
|
125
|
+
// non-stale it marks _calledAdvisorThisRun, and a STALE settle leaves the
|
|
126
|
+
// mark unset so the guard pushes back here again (fix #2 — no silent skip).
|
|
127
|
+
const pending = advisorReviewPending(agent)
|
|
128
|
+
// rounds 仅作提醒文案显示(撤 cap——轮次不是终止判据;ADVISOR-CONVERGENCE.md §3.1)。
|
|
129
|
+
const rounds = effectiveAdvisorRound(agent)
|
|
130
|
+
if (!pending && agent._mutatedThisRun && !agent._calledAdvisorThisRun && hasCodeMutations(agent)
|
|
131
|
+
&& advisorPushbacks < MAX_ADVISOR_PUSHBACKS) {
|
|
132
|
+
advisorPushbacks++
|
|
133
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
134
|
+
agent.history.push({
|
|
135
|
+
role: "user",
|
|
136
|
+
content: `[System reminder: you changed code in this run and MUST get an advisor review before finishing (round ${rounds + 1}). Call the \`advisor\` tool now. This is required, not optional — do not skip it even if you believe the changes are trivial — the review will be quick either way. After the review, produce a response table for every issue found (see discipline rules for format).]`,
|
|
137
|
+
})
|
|
138
|
+
callbacks.onTurnEnd?.(agent, turn)
|
|
139
|
+
return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
144
|
+
return { action: "done", content: response.content, guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
145
|
+
}
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/dispatch.mjs — two-phase tool call execution
|
|
3
|
+
*/
|
|
4
|
+
import { logEvent, errText, headText } from "../log.mjs"
|
|
5
|
+
import { offloadToolResult, FILE_MUTATORS } from "./helpers.mjs"
|
|
6
|
+
import { runHooks } from "../hooks.mjs"
|
|
7
|
+
import { snapshotForUndo } from "../undo-stack.mjs"
|
|
8
|
+
import { isCodePath, loadConventions } from "../conventions.mjs"
|
|
9
|
+
// R10 L3 (MULTI-INSTANCE-COLLAB §2a.5 D-L3b):写工具钩子——peerCollabNote(执行前冲突
|
|
10
|
+
// 检测——软提示不阻止)+ recordPeerWrites(成功后累积本回合写足迹——回合末 flush)。
|
|
11
|
+
import { PEER_WRITE_TOOLS, peerCollabNote, recordPeerWrites } from "../peer-domains.mjs"
|
|
12
|
+
import { writeFileSync, mkdirSync, existsSync } from "node:fs"
|
|
13
|
+
import { join, resolve, relative } from "node:path"
|
|
14
|
+
import { homedir } from "node:os"
|
|
15
|
+
// §29 fix A(AGENT-LOOP.md §29——2026-09-07):FILE_MUTATORS 的 mutation-seq 记账从
|
|
16
|
+
// 批后提交(record-results noteMutations)移到执行成功即刻——唯一记账点(取代批后段
|
|
17
|
+
// + agent.mjs 中断分支记账——不双计)——同消息 [写 + async advisor launch] 时 launch 前
|
|
18
|
+
// 完成的写在 launchSeq 之前落地 → settle 不再误判 stale(§29 症状根因)。
|
|
19
|
+
import { noteMutations } from "../agent-tools/advisor-async.mjs"
|
|
20
|
+
import { anyLiveDesignSlot } from "../token-ttl.mjs"
|
|
21
|
+
// M4 写权门禁(模块设计 §2.1#2):冻结窗口判据组装(被审文件集 = 声明文档集 + 批次档
|
|
22
|
+
// 的合流点)落 write-gate.mjs 单一权威源——本档只 import 消费(KD-M4-4 拆分点)。
|
|
23
|
+
import { freezeWindowConflict } from "./write-gate.mjs"
|
|
24
|
+
|
|
25
|
+
const ERRORS_DIR = join(homedir(), ".thincoder", "tool-errors")
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Persist a tool error to ~/.thincoder/tool-errors/YYYY-MM-DD/HHmmss-toolName.log
|
|
29
|
+
* Only called for actual execution failures and malformed invocations.
|
|
30
|
+
* Skipped for intentional denials (plan mode, user reject).
|
|
31
|
+
*/
|
|
32
|
+
function logToolError(toolName, args, error) {
|
|
33
|
+
try {
|
|
34
|
+
const now = new Date()
|
|
35
|
+
const ymd = now.toISOString().slice(0, 10)
|
|
36
|
+
const ts = now.toISOString().replace(/:/g, "").replace(/\..+/, "").replace("T", "-")
|
|
37
|
+
const dir = join(ERRORS_DIR, ymd)
|
|
38
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
39
|
+
const file = join(dir, `${ts}-${toolName.replace(/[/\\]/g, "_")}.log`)
|
|
40
|
+
const entry = [
|
|
41
|
+
`time: ${now.toISOString()}`,
|
|
42
|
+
`tool: ${toolName}`,
|
|
43
|
+
`args: ${JSON.stringify(args, null, 2).slice(0, 2000)}${JSON.stringify(args, null, 2).length > 2000 ? "… (truncated)" : ""}`,
|
|
44
|
+
`error: ${error?.message ?? String(error)}`,
|
|
45
|
+
error?.stack ? `stack:\n${error.stack}` : "",
|
|
46
|
+
].filter(Boolean).join("\n") + "\n"
|
|
47
|
+
writeFileSync(file, entry, "utf8")
|
|
48
|
+
} catch {
|
|
49
|
+
// Log failure itself must not crash the agent
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* §19 action-level classification (AGENT-LOOP.md §19 D-M1): the merged subagent
|
|
55
|
+
* tool expresses spawn (side effect) and status (read-only query) through
|
|
56
|
+
* its `action` parameter — the tool-level readonly flag can no longer express both.
|
|
57
|
+
* dispatch Phase-1/Phase-2 classifies per action: status behaves as readonly
|
|
58
|
+
* (planMode pass / no permission ask / batchable), spawn keeps its non-readonly
|
|
59
|
+
* gates, escalate runs non-readonly AND serially (the retired escalate tool had no
|
|
60
|
+
* parallel flag — zero behavior change under the merged surface).
|
|
61
|
+
* §19.5 cancel (19.5.2b round2 #4): CONTROL-class exemption — cancel only
|
|
62
|
+
* stops, never starts. isSubagentControlAction feeds the SAME two gate sites as
|
|
63
|
+
* readonly (planMode pass / no permission ask — never joins a batch approval
|
|
64
|
+
* group / no handler → not denied — digest 内 cancel 放行).
|
|
65
|
+
* §19.6 panel (round1 #5): view 面归只读类(同 status——planMode 放行、免
|
|
66
|
+
* 审批、可批并行);freeze 面归控制类(同 cancel——planMode 放行、免权限审批、
|
|
67
|
+
* 批审批不入组、digest 内放行)。freeze 存在(非空 key)即控制类——否则只读类。
|
|
68
|
+
*/
|
|
69
|
+
function isSubagentReadonlyAction(toolName, args) {
|
|
70
|
+
// §6 memory 工具面重构(MEMORY.md §6 D-M5):memory search/list 是只读动作——与
|
|
71
|
+
// subagent status 同分类(planMode 放行/免审批——Phase-2 批并行只认工具级
|
|
72
|
+
// readonly/parallel,memory 无 parallel → 按非只读串行,见 MEMORY.md §6.4 实现注)。
|
|
73
|
+
// 动作级判定——不能按工具名(同一 memory 工具的 put/delete/clear 保持侧效门)。
|
|
74
|
+
if (toolName === "memory") {
|
|
75
|
+
const action = args?.action
|
|
76
|
+
return action === "search" || action === "list"
|
|
77
|
+
}
|
|
78
|
+
// SETTINGS-TOOL.md(2026-09-05):settings list/get 是只读动作(memory search/list 同分类——
|
|
79
|
+
// planMode 放行/免审批);set 保持侧效门。
|
|
80
|
+
if (toolName === "settings") {
|
|
81
|
+
const action = args?.action
|
|
82
|
+
return action === "list" || action === "get"
|
|
83
|
+
}
|
|
84
|
+
if (toolName !== "subagent" || !args || typeof args !== "object") return false
|
|
85
|
+
const action = args.action
|
|
86
|
+
// §19.8: check 动作已删除——只读面仅剩 status(planMode 放行/免权限审批/可批并行)
|
|
87
|
+
if (action === "status") return true
|
|
88
|
+
// SUBAGENT-OBSERVE-SEND:observe = readonly 查询(同 status——digest/planMode 放行)
|
|
89
|
+
if (action === "observe") return true
|
|
90
|
+
// §19.6 panel view 面(freeze 缺省/空 = 视图请求——readonly;非空 freeze 归控制类)
|
|
91
|
+
if (action === "panel" && (args.freeze === undefined || args.freeze === null || String(args.freeze) === "")) return true
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
function isSubagentControlAction(toolName, args) {
|
|
95
|
+
if (toolName !== "subagent") return false
|
|
96
|
+
if (args?.action === "cancel") return true
|
|
97
|
+
// SUBAGENT-OBSERVE-SEND:send = 控制类豁免(同 cancel——父回合内显式调用即授权——
|
|
98
|
+
// 写子输入队列属父对子轻量引导,非产品代码写——免审批、planMode 放行、digest 内放行)
|
|
99
|
+
if (args?.action === "send") return true
|
|
100
|
+
// §19.6 panel freeze 面(D-P3 门控在 executor——只读/控制分类在此)
|
|
101
|
+
if (args?.action === "panel" && args.freeze !== undefined && args.freeze !== null && String(args.freeze) !== "") return true
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* §2.6 token 链终消费制(2026-09-07——评审 #7d dispatch 分类):consume-design =
|
|
106
|
+
* 非只读控制动作——planMode 拒绝(不入 readonly/control 豁免——与其他非只读动作同门)、
|
|
107
|
+
* 免权限审批、不入批审批分组(无文件写——控制类直行——只停既有状态不起新副作用)。
|
|
108
|
+
* 与 cancel 的不同:cancel 是控制类豁免(planMode 放行),consume-design 按设计
|
|
109
|
+
* planMode 拒绝——故不并入 isSubagentControlAction,单独谓词只接权限豁免位。
|
|
110
|
+
*/
|
|
111
|
+
function isSubagentConsumeDesignAction(toolName, args) {
|
|
112
|
+
return toolName === "subagent" && args?.action === "consume-design"
|
|
113
|
+
}
|
|
114
|
+
function isSubagentEscalateAction(toolName, args) {
|
|
115
|
+
return toolName === "subagent" && args?.action === "escalate"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* §29 fix A — 唯一记账点:FILE_MUTATORS 工具执行成功即刻记 mutation seq(abs 路径)。
|
|
120
|
+
* 取代 record-results 批后段 + agent.mjs 中断分支的 noteMutations(不双计——中断+同批
|
|
121
|
+
* launch 场景 seq 单计,AGENT-LOOP.md §29 T-A1i)。调用时机 = 写执行成功(非 Error 前缀
|
|
122
|
+
* 结果——recordPeerWrites 同款门);routed(M2 ACP 客户端执行)成功同样记账。
|
|
123
|
+
*/
|
|
124
|
+
function noteExecutedMutation(agent, tool, args) {
|
|
125
|
+
let paths
|
|
126
|
+
try {
|
|
127
|
+
paths = tool.touchedPaths ? tool.touchedPaths(args ?? {}) : [args?.path]
|
|
128
|
+
} catch { return }
|
|
129
|
+
const abs = (paths ?? [])
|
|
130
|
+
.filter((p) => typeof p === "string" && p)
|
|
131
|
+
.map((p) => resolve(agent.cwd, p))
|
|
132
|
+
if (abs.length > 0) noteMutations(agent, abs)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Two-phase execution:
|
|
137
|
+
* Phase 1 (serial): parse args one by one + planMode check + permission confirmation (side-effecting tools)
|
|
138
|
+
* Phase 2 (order-preserving): strictly preserve model call order — consecutive readonly/parallel tools run as concurrent batches,
|
|
139
|
+
* side-effecting tools run serially in their original position (if a batch writes-then-reads the same file, the read must see the post-write content).
|
|
140
|
+
* Returns a results array in call order (each entry has an ok flag indicating success/failure).
|
|
141
|
+
*/
|
|
142
|
+
export async function executeToolCalls(agent, toolByName, toolCalls, callbacks, depth = 0, signal) {
|
|
143
|
+
// ---- Phase 1: serial preparation ----
|
|
144
|
+
// Pre-gates run per tool (parse/planMode/engineering gates); non-readonly tools
|
|
145
|
+
// that REACH the permission stage are collected into one batch — a single merged
|
|
146
|
+
// ask covers the whole toolCalls array (§16 D-B1, "approve all / one by one /
|
|
147
|
+
// deny"). Tools stopped by a pre-gate never join the batch (review #7).
|
|
148
|
+
const prepared = []
|
|
149
|
+
const permPending = [] // { toolCall, tool, args } — reached the permission stage
|
|
150
|
+
for (const toolCall of toolCalls) {
|
|
151
|
+
const tool = toolByName.get(toolCall.name)
|
|
152
|
+
let args
|
|
153
|
+
try {
|
|
154
|
+
args = JSON.parse(toolCall.arguments || "{}")
|
|
155
|
+
} catch {
|
|
156
|
+
logToolError(toolCall.name, { arguments: toolCall.arguments }, new Error("Invalid JSON arguments"))
|
|
157
|
+
prepared.push({ toolCall, tool: null, error: `Invalid tool arguments JSON: ${toolCall.arguments}` })
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!tool) {
|
|
162
|
+
logToolError(toolCall.name, {}, new Error(`Unknown tool: ${toolCall.name}`))
|
|
163
|
+
prepared.push({ toolCall, tool: null, error: `Unknown tool: ${toolCall.name}` })
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (agent.planMode && !tool.readonly && !isSubagentReadonlyAction(toolCall.name, args) && !isSubagentControlAction(toolCall.name, args)) {
|
|
168
|
+
prepared.push({ toolCall, tool, denied: true, reason: "plan mode" })
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Engineering coder hard gate: no file modification before the design review passed.
|
|
173
|
+
// The design review is the eng-coder's mandatory pre-coding gate — advisor(type="design")
|
|
174
|
+
// must run (and be accepted) before the first write/edit/apply_patch/hashline_edit/insert_after/delete.
|
|
175
|
+
if (agent._role === "eng-coder" && agent.config?.agent?.engineering
|
|
176
|
+
&& !agent._engDesignReviewed && FILE_MUTATORS.has(toolCall.name)) {
|
|
177
|
+
prepared.push({
|
|
178
|
+
toolCall, tool, denied: true,
|
|
179
|
+
reason: "engineering design gate",
|
|
180
|
+
hint: "Call advisor with type='design' to review the design document before any file modification. If the review found issues, report them to the parent agent.",
|
|
181
|
+
})
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Engineering mode PARENT gate: the parent agent must not touch code files
|
|
186
|
+
// before the design review passed. Signaled by a live design slot (design-review
|
|
187
|
+
// approval — persists in the session slot, survives across turns; _engDesignReviewed
|
|
188
|
+
// is eng-coder-only and reset per run). Exemptions = the non-code classes of
|
|
189
|
+
// src/conventions.mjs (documentation and temp scratch files) — writing a design
|
|
190
|
+
// document IS the design step. Anything inside a declared code segment (default:
|
|
191
|
+
// src — incl. src/prompts/*.md) is product code, not documentation, and needs a
|
|
192
|
+
// design token. The project can declare its own code paths (.thincoder/
|
|
193
|
+
// conventions.json) so a non-src layout is not silently exempted. Mechanically
|
|
194
|
+
// blocks "talk then code".
|
|
195
|
+
// DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):资格判据 = 权威槽"任一活槽存在"
|
|
196
|
+
// (anyLiveDesignSlot——查内存 Map,miss 回读槽文件——单值镜像 `_engDesignToken`
|
|
197
|
+
// 已退役,门禁不再读镜像——AC4)。
|
|
198
|
+
if (agent.config?.agent?.engineering && depth === 0
|
|
199
|
+
&& !anyLiveDesignSlot(agent)
|
|
200
|
+
&& FILE_MUTATORS.has(toolCall.name)) {
|
|
201
|
+
const paths = tool.touchedPaths ? tool.touchedPaths(args) : [args.path]
|
|
202
|
+
const conv = loadConventions(agent.cwd)
|
|
203
|
+
// Unknown/missing paths (non-string, e.g. no path argument) are treated
|
|
204
|
+
// as code — cannot tell what they touch, so block conservatively. Known
|
|
205
|
+
// paths go through the single shared classifier: a declared code segment
|
|
206
|
+
// (default: "src") at ANY depth, else anything that is not documentation.
|
|
207
|
+
const touchesCode = paths.some((p) => typeof p !== "string" || isCodePath(p, conv))
|
|
208
|
+
if (touchesCode) {
|
|
209
|
+
// Undeclared project → point at the declaration file (§4.3 降级可见契约).
|
|
210
|
+
const convNote = conv.declared
|
|
211
|
+
? ""
|
|
212
|
+
: ` — this path was classified as product code by the default conventions (code paths: ${conv.codePaths.join(", ")}); declare project conventions in .thincoder/conventions.json to adjust.`
|
|
213
|
+
prepared.push({
|
|
214
|
+
toolCall, tool, denied: true,
|
|
215
|
+
reason: "engineering design gate",
|
|
216
|
+
hint: `Engineering mode: write the design document first(location per your project's document conventions), then call advisor with type='design' to review it, and wait for user approval. Implementation is done by eng-coder subagents.${convNote}`,
|
|
217
|
+
})
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// E(第 11 批·F17/§14.14 E-3d):D5 冻结窗口写前拦截——设计评审在途(点火 → 结算)期间,
|
|
223
|
+
// 父侧对被审文件集(声明文档集 + 批次档)的写入会被拒绝:在途写使本轮结算 stale——pass 轮
|
|
224
|
+
// = token 直接丢失(实证:第 10 批 id=20 整轮作废)。工具面 = FILE_MUTATORS(与变更记账
|
|
225
|
+
// 同集——不记入日志的写面既不判 stale 也不拦);判据与 reviewIsStale 同源、单一权威源 =
|
|
226
|
+
// agent/write-gate.mjs 的 freezeWindowConflict(声明文档集腿 = inflightDesignReviewConflict
|
|
227
|
+
// 同 docAbs / 同 normAbs;批次档腿 = run.batchDoc——M4 合流点,仅扫 running 未取消的设计条目)。
|
|
228
|
+
// 位置:只读 / autoApprove 短路之前——审批不得绕过冻结;拒绝 = 可见 denied + 逃生门
|
|
229
|
+
// (先 cancel → 改 → 重发)。
|
|
230
|
+
if (FILE_MUTATORS.has(toolCall.name)) {
|
|
231
|
+
let touched = []
|
|
232
|
+
try { touched = tool.touchedPaths ? tool.touchedPaths(args) : [args.path] } catch { touched = [] }
|
|
233
|
+
const absPaths = (touched ?? [])
|
|
234
|
+
.filter((p) => typeof p === "string" && p)
|
|
235
|
+
.map((p) => resolve(agent.cwd, p))
|
|
236
|
+
const conflict = freezeWindowConflict(agent, absPaths)
|
|
237
|
+
if (conflict) {
|
|
238
|
+
prepared.push({
|
|
239
|
+
toolCall, tool, denied: true,
|
|
240
|
+
reason: "d5 freeze window",
|
|
241
|
+
hint: `write refused — design review #${conflict.id} is in flight over ${relative(agent.cwd, conflict.path)} (D5 freeze window). A write now would settle it stale — no token for a pass (the round is lost). Wait for the report, or cancel the review first (subagent action:'cancel' id:'${conflict.id}') and re-launch after the change.`,
|
|
242
|
+
})
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Readonly tools (and autoApprove — the short-circuit, unchanged for the
|
|
248
|
+
// whole batch too) skip the permission stage entirely.
|
|
249
|
+
// §18 D-E3 task-domain authorization (spawn-time): an eng-coder child's
|
|
250
|
+
// tools skip the permission ASK stage exactly like autoApprove — granted by
|
|
251
|
+
// the parent spawn (approved design + task = authorization; subagent.mjs
|
|
252
|
+
// sets _engTaskAuthorized on the child). Everything EARLIER in Phase 1
|
|
253
|
+
// (JSON parse / unknown tool / planMode / design-token gates) ran unchanged
|
|
254
|
+
// — the exemption never widens what reaches this stage (round4 #3, T-E14).
|
|
255
|
+
// PreToolUse hooks still run below. Non-eng-coder children keep the manual
|
|
256
|
+
// parent ask (human in the loop).
|
|
257
|
+
if (tool.readonly || isSubagentReadonlyAction(toolCall.name, args) || isSubagentControlAction(toolCall.name, args) || isSubagentConsumeDesignAction(toolCall.name, args) || agent.autoApprove || agent._engTaskAuthorized) {
|
|
258
|
+
if (!(await runHooks("PreToolUse", { agent, toolName: toolCall.name, toolArgs: args }))) {
|
|
259
|
+
prepared.push({ toolCall, tool, denied: true, reason: "blocked by PreToolUse hook" })
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
// Panel area abolished — all tools now stream inline via onToolOutput.
|
|
263
|
+
callbacks.onToolCall?.(toolCall.name, args, toolCall.id)
|
|
264
|
+
prepared.push({ toolCall, tool, args })
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
permPending.push({ toolCall, tool, args })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- Permission stage: one merged ask for the whole batch (§16 D-B1) ----
|
|
271
|
+
// >1 non-readonly tools in the same toolCalls array → a single
|
|
272
|
+
// onBatchPermissionRequest({ tools, count }) ask; verdicts:
|
|
273
|
+
// "approveAll" → batch-scope allowance (autoApprove style, NOT persistent)
|
|
274
|
+
// "deny" → the whole batch is rejected, no second ask
|
|
275
|
+
// "oneByOne" (or anything else / no handler) → the existing per-item
|
|
276
|
+
// onPermissionRequest channel, signature unchanged (NF-B1: ACP bridge /
|
|
277
|
+
// headless / old versions without the new callback are never harmed).
|
|
278
|
+
if (permPending.length > 0) {
|
|
279
|
+
let batchAllowed = null // true = approveAll, false = deny, null = per-item fallback
|
|
280
|
+
if (permPending.length > 1 && callbacks.onBatchPermissionRequest) {
|
|
281
|
+
const verdict = await callbacks.onBatchPermissionRequest({
|
|
282
|
+
tools: permPending.map((p) => ({ name: p.toolCall.name, args: p.args })),
|
|
283
|
+
count: permPending.length,
|
|
284
|
+
})
|
|
285
|
+
if (verdict === "approveAll") batchAllowed = true
|
|
286
|
+
else if (verdict === "deny") batchAllowed = false
|
|
287
|
+
// anything else (oneByOne/unknown) → fall through to the per-item channel
|
|
288
|
+
}
|
|
289
|
+
for (const p of permPending) {
|
|
290
|
+
let allowed
|
|
291
|
+
if (batchAllowed === true) allowed = true
|
|
292
|
+
else if (batchAllowed === false) allowed = false
|
|
293
|
+
else if (callbacks.onPermissionRequest) {
|
|
294
|
+
allowed = await (async () => {
|
|
295
|
+
// D2 (AGENT-LOOP.md §7.2): announce the wait BEFORE prompting — the TUI
|
|
296
|
+
// subagent block header flips to "等待审批" so a waiting child is visibly
|
|
297
|
+
// different from a stalled one. Depth>0 only (the parent TUI shows its own
|
|
298
|
+
// permission panel). turn n/max = the child's live turn counters.
|
|
299
|
+
if (depth > 0) {
|
|
300
|
+
callbacks.onToken?.(`⟦ev⟧approval\x1e${agent._currentTurn ?? 0}\x1e${agent._maxTurns ?? 0}\x1eapproval\x1e${String(p.toolCall.name).slice(0, 40)}`)
|
|
301
|
+
}
|
|
302
|
+
return await callbacks.onPermissionRequest(p.toolCall.name, p.args)
|
|
303
|
+
})()
|
|
304
|
+
} else allowed = false
|
|
305
|
+
if (!allowed) {
|
|
306
|
+
prepared.push({
|
|
307
|
+
toolCall: p.toolCall, tool: p.tool, denied: true,
|
|
308
|
+
reason: (callbacks.onPermissionRequest || batchAllowed === false) ? "denied by user" : "no permission handler",
|
|
309
|
+
})
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// PreToolUse hooks: allow user scripts to gate tool execution
|
|
314
|
+
if (!(await runHooks("PreToolUse", { agent, toolName: p.toolCall.name, toolArgs: p.args }))) {
|
|
315
|
+
prepared.push({ toolCall: p.toolCall, tool: p.tool, denied: true, reason: "blocked by PreToolUse hook" })
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Panel area abolished — all tools now stream inline via onToolOutput.
|
|
320
|
+
callbacks.onToolCall?.(p.toolCall.name, p.args, p.toolCall.id)
|
|
321
|
+
prepared.push({ toolCall: p.toolCall, tool: p.tool, args: p.args })
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---- Phase 2: order-preserving execution ----
|
|
326
|
+
const runOne = async (item) => {
|
|
327
|
+
if (item.error) return { ...item, result: `Error: ${item.error}`, ok: false }
|
|
328
|
+
if (item.denied) {
|
|
329
|
+
const reason = item.reason === "plan mode"
|
|
330
|
+
? "Error: plan mode is active — only read-only tools are allowed. Exit plan mode first."
|
|
331
|
+
: item.reason === "engineering design gate"
|
|
332
|
+
? `Error: design review required before any file modification. ${item.hint}`
|
|
333
|
+
: item.reason === "d5 freeze window"
|
|
334
|
+
? `Error: ${item.hint}`
|
|
335
|
+
: item.reason === "denied by user"
|
|
336
|
+
? "Error: permission denied by user"
|
|
337
|
+
: item.reason === "blocked by PreToolUse hook"
|
|
338
|
+
? "Error: blocked by PreToolUse hook"
|
|
339
|
+
: "Error: no permission handler configured — this tool requires user approval but the current context doesn't support interaction (e.g. subagent or non-TUI mode)"
|
|
340
|
+
return { ...item, result: reason, ok: false }
|
|
341
|
+
}
|
|
342
|
+
// 2026-08-31 工具顺手度(用户批准"做吧"):dispatch 拦截工具执行期间的
|
|
343
|
+
// console.log/console.error——工具的探查/调试输出(原本只到终端、模型看不到)
|
|
344
|
+
// 收集后附在工具结果后回显给模型。bash 工具的输出走子进程回显(onOutput),
|
|
345
|
+
// 不走 dispatch console——拦截安全。嵌套 dispatch(subagent)各自拦截/恢复,
|
|
346
|
+
// 捕获分离(父恢复原始后子的拦截期间父捕获停止、子恢复后父继续)——正确。
|
|
347
|
+
// 声明在 try 之外:catch 块(异常路径)也要访问(报错前的探查输出回显)。
|
|
348
|
+
const capturedConsole = []
|
|
349
|
+
// LOGGING(LOGGING.md):tool:* 事件——仅真实执行(pre-gate 拦截项在下方早退分支不入事件)。
|
|
350
|
+
// 参数值永不落盘(NF-L3——工具事件不记 args);child=子代理 id(agent._logId,spawn 时 stamp)。
|
|
351
|
+
const toolT0 = Date.now()
|
|
352
|
+
const toolName = item.toolCall.name
|
|
353
|
+
logEvent("tool:call", { tool: toolName, child: agent?._logId })
|
|
354
|
+
try {
|
|
355
|
+
// R10 L3 (MULTI-INSTANCE-COLLAB §2a.5 D-L3b):结构化写工具执行前查 conflicts
|
|
356
|
+
// (命中他实例 hot 域 → 结果附软提示——决策⑥ A 不阻止;一次目录 stat——N3 度量);
|
|
357
|
+
// 足迹累积(D-L3a——"检测+记录一次完成")延后到执行成功(实际写过的文件)。
|
|
358
|
+
const isPeerWriteTool = PEER_WRITE_TOOLS.has(toolName)
|
|
359
|
+
const peerNote = isPeerWriteTool ? peerCollabNote(agent.cwd, item.tool, item.args) : null
|
|
360
|
+
// Snapshot for undo before side-effect tools (setupOutputPanel already fired in Phase 1)
|
|
361
|
+
if (!item.tool?.readonly && item.args) {
|
|
362
|
+
snapshotForUndo(agent, item.toolCall.name, item.args, agent.cwd)
|
|
363
|
+
}
|
|
364
|
+
// M2 ACP: route fs tools through the client (IDE buffer / diff review).
|
|
365
|
+
// toolRouter returns { handled: true, result } to short-circuit execution.
|
|
366
|
+
if (callbacks.toolRouter) {
|
|
367
|
+
const routed = await callbacks.toolRouter(item.toolCall.name, item.args)
|
|
368
|
+
if (routed?.handled) {
|
|
369
|
+
const routedOk = !String(routed.result).startsWith("Error:")
|
|
370
|
+
const routedResult = peerNote && routedOk ? `${routed.result}\n${peerNote}` : routed.result
|
|
371
|
+
if (isPeerWriteTool && routedOk) recordPeerWrites(agent, item.tool, item.args)
|
|
372
|
+
// §29 fix A:routed 写成功(客户端执行)同样执行期即刻记账(唯一记账点)
|
|
373
|
+
if (routedOk && FILE_MUTATORS.has(toolName)) noteExecutedMutation(agent, item.tool, item.args)
|
|
374
|
+
callbacks.onToolResult?.(item.toolCall.name, routedResult, item.toolCall.id)
|
|
375
|
+
logEvent("tool:done", { tool: toolName, ms: Date.now() - toolT0, head: headText(routedResult, 200), child: agent?._logId })
|
|
376
|
+
return { ...item, result: routedResult, ok: true }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const origConsoleLog = console.log
|
|
380
|
+
const origConsoleErr = console.error
|
|
381
|
+
console.log = (...a) => capturedConsole.push(a.map(String).join(" "))
|
|
382
|
+
console.error = (...a) => capturedConsole.push("[err] " + a.map(String).join(" "))
|
|
383
|
+
let rawResult
|
|
384
|
+
// ctx 对象提升为变量(§7.2.3):subagent 阻塞 execute 返回前在 ctx 上留
|
|
385
|
+
// _subagentKey(relayPrefix 去尾)——runOne 在 execute 返回后读它作 onToolResult
|
|
386
|
+
// 第 4 参(普通工具/错误路径无此字段——undefined 兼容既有签名)。每次工具调用
|
|
387
|
+
// 独立 ctx——并行同名工具(批并行 runOne)各自带自己的 key,互不串扰。
|
|
388
|
+
const toolCtx = {
|
|
389
|
+
cwd: agent.cwd,
|
|
390
|
+
agent,
|
|
391
|
+
depth,
|
|
392
|
+
signal,
|
|
393
|
+
callbacks,
|
|
394
|
+
// §11.2 D-24b: per-call id — the advisor tool marker-keys its launch so
|
|
395
|
+
// recordToolResults can split async-ack accounting from sync settles.
|
|
396
|
+
_toolCallId: item.toolCall.id,
|
|
397
|
+
onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk, item.toolCall.id),
|
|
398
|
+
onQuestion: callbacks.onQuestion,
|
|
399
|
+
onPermissionRequest: callbacks.onPermissionRequest,
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
// SUBAGENT-OBSERVE-SEND D1(评审 #1):in-flight 当前工具记账——工具执行期间在
|
|
403
|
+
// agent 上留 _inflightTools Set(子代理 observe 从 dispatch 状态读——卡在长工具
|
|
404
|
+
// 调用时 history 无新回合、恰需此信号);finally 清除。批并行工具同入 Set(observe
|
|
405
|
+
// 如实返回多个在跑工具)。主会话同样记账——无害(无人读)。
|
|
406
|
+
const inflight = agent._inflightTools ?? (agent._inflightTools = new Set())
|
|
407
|
+
inflight.add(toolName)
|
|
408
|
+
try {
|
|
409
|
+
rawResult = await item.tool.execute(item.args, toolCtx)
|
|
410
|
+
} finally {
|
|
411
|
+
inflight.delete(toolName)
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
console.log = origConsoleLog
|
|
415
|
+
console.error = origConsoleErr
|
|
416
|
+
}
|
|
417
|
+
if (rawResult === undefined) throw new Error(`Tool "${item.toolCall.name}" returned undefined — all tools must return a string value`)
|
|
418
|
+
const raw = String(rawResult)
|
|
419
|
+
// 写成功(非 "Error:" 字符串结果)→ 足迹计入本回合集合(flush 在 finalizeAgentTurn)
|
|
420
|
+
if (isPeerWriteTool && !raw.startsWith("Error:")) {
|
|
421
|
+
recordPeerWrites(agent, item.tool, item.args)
|
|
422
|
+
}
|
|
423
|
+
// §29 fix A:FILE_MUTATORS 执行成功即刻记账(唯一记账点——取代 record-results 批后
|
|
424
|
+
// 段 + agent.mjs 中断分支——不双计)——同批 launch 前的写在 launchSeq 之前落地 →
|
|
425
|
+
// async advisor settle 不误判 stale(同批 launch 后写仍保守 stale——T-A2/T-24b9)。
|
|
426
|
+
if (FILE_MUTATORS.has(toolName) && !raw.startsWith("Error:")) {
|
|
427
|
+
noteExecutedMutation(agent, item.tool, item.args)
|
|
428
|
+
}
|
|
429
|
+
// Multimodal tools keep the raw result (base64 images ride the multimodal
|
|
430
|
+
// channel); everything else offloads oversized text to disk. Flag-driven, not
|
|
431
|
+
// name-driven (consult P3, 2026-08-30).
|
|
432
|
+
const result = item.tool?.multimodal ? raw : await offloadToolResult(raw, item.toolCall.id)
|
|
433
|
+
// 2026-08-31:工具执行期间捕获的 console 输出附在结果后回显(模型视野)
|
|
434
|
+
const resultWithConsole = capturedConsole.length > 0
|
|
435
|
+
? `${result}\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
|
|
436
|
+
: result
|
|
437
|
+
// R10 L3:冲突软提示附在工具结果末尾(模型可见——不阻止写)
|
|
438
|
+
const resultForModel = peerNote && !raw.startsWith("Error:")
|
|
439
|
+
? `${resultWithConsole}\n${peerNote}`
|
|
440
|
+
: resultWithConsole
|
|
441
|
+
callbacks.onToolResult?.(item.toolCall.name, resultForModel, item.toolCall.id, toolCtx._subagentKey)
|
|
442
|
+
// PostToolUse hooks: fire-and-forget (result not awaited on hook failure)
|
|
443
|
+
runHooks("PostToolUse", { agent, toolName: item.toolCall.name, toolArgs: item.args, result: raw }).catch(() => {})
|
|
444
|
+
logEvent("tool:done", { tool: toolName, ms: Date.now() - toolT0, head: headText(resultForModel, 200), child: agent?._logId })
|
|
445
|
+
return { ...item, result: resultForModel, ok: true }
|
|
446
|
+
} catch (error) {
|
|
447
|
+
// Persist to ~/.thincoder/tool-errors/ for post-mortem; only pass message to the model (stack traces confuse LLMs and may leak paths)
|
|
448
|
+
logToolError(item.toolCall.name, item.args, error)
|
|
449
|
+
// User interrupt (Ctrl+C / Ctrl+I) must propagate, not become a tool error:
|
|
450
|
+
// swallowing it here would make the parent keep looping while the user
|
|
451
|
+
// asked to stop — worst case with subagents, where the child runs its
|
|
452
|
+
// whole turn budget and the interrupt appears to do nothing.
|
|
453
|
+
if (signal?.aborted) throw error
|
|
454
|
+
// LOGGING(2026-09-03 code review #4):中止先于事件——用户停不落 tool:error
|
|
455
|
+
//(vscode execute-tools parity;阻塞子代理 child:error 同款抑制)
|
|
456
|
+
logEvent("tool:error", { tool: toolName, ms: Date.now() - toolT0, err: errText(error, 200), child: agent?._logId })
|
|
457
|
+
runHooks("PostToolUseFailure", { agent, toolName: item.toolCall.name, toolArgs: item.args, error }).catch(() => {})
|
|
458
|
+
// Build contextual error: tool name + key args so the model can reason about what went wrong
|
|
459
|
+
const ctxParts = []
|
|
460
|
+
if (item.args.path) ctxParts.push(`path=${item.args.path}`)
|
|
461
|
+
if (item.args.pattern) ctxParts.push(`pattern=${item.args.pattern}`)
|
|
462
|
+
if (item.args.command) ctxParts.push(`cmd=${item.args.command.slice(0, 80)}`)
|
|
463
|
+
const ctx = ctxParts.length > 0 ? ` [${ctxParts.join(", ")}]` : ""
|
|
464
|
+
// 2026-08-31:异常路径同样回显捕获的 console(工具报错前的探查输出最有价值)
|
|
465
|
+
const consolePart = capturedConsole.length > 0
|
|
466
|
+
? `\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
|
|
467
|
+
: ""
|
|
468
|
+
return { ...item, result: `Error: ${error.message}${ctx}${consolePart}`, ok: false }
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const results = []
|
|
473
|
+
let batch = []
|
|
474
|
+
const flush = async () => {
|
|
475
|
+
if (batch.length === 0) return
|
|
476
|
+
results.push(...await Promise.all(batch.map(runOne)))
|
|
477
|
+
batch = []
|
|
478
|
+
}
|
|
479
|
+
for (const item of prepared) {
|
|
480
|
+
// escalate action keeps the retired escalate tool's serial placement (no
|
|
481
|
+
// parallel flag): it flushes the batch and runs alone in call order (§19 —
|
|
482
|
+
// spawn stays parallel; status classifies as readonly and batch freely).
|
|
483
|
+
if (item.tool && !item.tool.readonly
|
|
484
|
+
&& (!item.tool.parallel || isSubagentEscalateAction(item.tool.name, item.args))) {
|
|
485
|
+
await flush()
|
|
486
|
+
results.push(await runOne(item))
|
|
487
|
+
} else {
|
|
488
|
+
batch.push(item)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
await flush()
|
|
492
|
+
return results
|
|
493
|
+
}
|