thincoder 0.12.11 → 0.12.13
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/README.md +8 -0
- package/bin/thincoder.mjs +11 -1
- package/package.json +1 -1
- package/src/acp/bridge.mjs +229 -0
- package/src/acp/session.mjs +46 -0
- package/src/acp/transport.mjs +155 -0
- package/src/acp.mjs +335 -0
- package/src/advisor/citations.mjs +77 -0
- package/src/advisor/convergence.mjs +80 -0
- package/src/advisor/history.mjs +20 -55
- package/src/advisor/messages.mjs +182 -36
- package/src/advisor/repos.mjs +40 -0
- package/src/advisor/run.mjs +201 -95
- package/src/advisor.mjs +192 -99
- package/src/agent/completion.mjs +10 -10
- package/src/agent/dispatch.mjs +14 -0
- package/src/agent-tools/advisor.mjs +11 -10
- package/src/agent-tools/subagent.mjs +26 -8
- package/src/agent.mjs +25 -32
- package/src/config.mjs +13 -5
- package/src/context.mjs +32 -9
- package/src/prompts/advisor-round1.md +9 -3
- package/src/prompts/advisor-round2.md +10 -8
- package/src/prompts/advisor-round3.md +14 -14
- package/src/prompts/discipline.md +1 -1
- package/src/session.mjs +19 -0
- package/src/tools/file.mjs +30 -0
- package/src/tools/insert_after.md +1 -0
- package/src/tools/patch.mjs +4 -0
- package/src/tools/shared.mjs +68 -57
- package/src/tui/agent-turn.mjs +73 -121
- package/src/tui/index.mjs +1 -1
- package/src/tui/markdown.mjs +26 -8
- package/src/tui/render-conversation.mjs +90 -39
- package/src/tui/tool-summaries.mjs +113 -0
package/src/advisor.mjs
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* advisor.mjs — advisor system-prompt selection, follow-up building, session assembly.
|
|
3
3
|
* User-message building lives in advisor/messages.mjs; execution (tool loop, provider
|
|
4
|
-
* resolution, review entry) in advisor/run.mjs;
|
|
5
|
-
*
|
|
6
|
-
* inject NO git information); history extraction in advisor/history.mjs.
|
|
4
|
+
* resolution, review entry) in advisor/run.mjs; history extraction in advisor/history.mjs.
|
|
5
|
+
* repos.mjs still hosts the doc-file classifier (isDocFile) used by mutation tracking.
|
|
7
6
|
*
|
|
8
7
|
* The advisor runs as a read-only exploration sub-agent with tools
|
|
9
|
-
* (read, glob, grep, ls,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* (read, glob, grep, ls, lsp, code_search) — ZERO git, every round. The change
|
|
9
|
+
* surface comes from the review scope (paths / _touchedFiles), never from git;
|
|
10
|
+
* verification is `read`-only with quoted-line evidence (7d49a52, d3be613).
|
|
12
11
|
*
|
|
13
12
|
* Config:
|
|
14
13
|
* { advisor: { enabled: true, provider: "deepseek", model: "deepseek-chat" } }
|
|
@@ -16,9 +15,16 @@
|
|
|
16
15
|
*
|
|
17
16
|
* Convergence protocol:
|
|
18
17
|
* Round 1: full review → produces a numbered issue table.
|
|
19
|
-
* Agent responds with a response table per issue.
|
|
20
|
-
* Round 2: semi-convergence — verifies table + can flag obvious new issues.
|
|
18
|
+
* Agent responds with a response table per issue (fix claims).
|
|
19
|
+
* Round 2: semi-convergence — verifies the prior table + can flag obvious new issues.
|
|
21
20
|
* Round 3+: strict convergence — only checks the prior issue table.
|
|
21
|
+
* The prior issue table IS injected into rounds 2+ (decision 2026-08-05,
|
|
22
|
+
* reversed) — it is the ONLY complete verification list: the agent response
|
|
23
|
+
* table covers only issues the agent chose to answer, so skipped issues would
|
|
24
|
+
* silently escape convergence without it. The fix-claim table travels as a
|
|
25
|
+
* focus reference only. Restatement risk is handled mechanically:
|
|
26
|
+
* host-verified citations reject references that do not match the current
|
|
27
|
+
* disk state, and fresh sessions exclude old read data.
|
|
22
28
|
* Each round replaces the system prompt (ROUND1 → ROUND2 → ROUND3) so the
|
|
23
29
|
* round-1 full-scope mandate can't bleed into later rounds, plus a mechanical
|
|
24
30
|
* cap (MAX_ADVISOR_ROUNDS in run.mjs) refuses a 6th review call outright.
|
|
@@ -26,23 +32,23 @@
|
|
|
26
32
|
* file:line evidence for any unfixed/new finding — see docs/design/ADVISOR-CONVERGENCE.md.
|
|
27
33
|
*
|
|
28
34
|
* Session memory (agent._advisorSession):
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* issue/response tables in the main history.
|
|
35
|
+
* RETAINED for initialization compatibility but NEVER read (decision d698434):
|
|
36
|
+
* every review round builds a fresh [system, user] session — round 2+ must not
|
|
37
|
+
* reuse round 1's messages, because the old read outputs are the anchoring
|
|
38
|
+
* source of re-review false reports and a token sink. Convergence data (prior
|
|
39
|
+
* issue table + agent response table) travels via buildAdvisorFollowUp.
|
|
40
|
+
* The field is reset by runAgent; the write sites are harmless leftovers.
|
|
36
41
|
*
|
|
37
42
|
* Project customisation: .thincoder/advisor.md in the project root.
|
|
38
43
|
*/
|
|
39
44
|
import { readFileSync } from "node:fs"
|
|
40
45
|
import { join, dirname } from "node:path"
|
|
41
46
|
import { fileURLToPath } from "node:url"
|
|
42
|
-
import {
|
|
43
|
-
import { buildAdvisorUserMessage } from "./advisor/messages.mjs"
|
|
47
|
+
import { extractAgentResponseTable } from "./advisor/history.mjs"
|
|
48
|
+
import { buildAdvisorUserMessage, resolveScopeFiles } from "./advisor/messages.mjs"
|
|
49
|
+
import { buildConvergenceBody } from "./advisor/convergence.mjs"
|
|
44
50
|
// Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
|
|
45
|
-
export { ADVISOR_MD_PATH,
|
|
51
|
+
export { ADVISOR_MD_PATH, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
|
|
46
52
|
export { buildAdvisorUserMessage } from "./advisor/messages.mjs"
|
|
47
53
|
|
|
48
54
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
@@ -51,15 +57,29 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
51
57
|
// Prompt files — loaded at module init
|
|
52
58
|
// ────────────────────────────────────────
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
function loadPrompt(file, name) {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(join(__dirname, "prompts", file), "utf8")
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`${name} missing from the installation (prompts/${file}) — reinstall thincoder or restore the file`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ADVISOR_ROUND1 = loadPrompt("advisor-round1.md", "advisor-round1.md")
|
|
55
69
|
// ROUND2/3 are used whenever a convergence round (round 2+) is being built:
|
|
56
70
|
// in-run session continuation replaces the system prompt with them, and a
|
|
57
71
|
// rebuilt fresh session (e.g. after a failed review) also selects them via
|
|
58
72
|
// buildAdvisorSystemPrompt when _advisorRound > 0.
|
|
59
|
-
const ADVISOR_ROUND2 =
|
|
60
|
-
const ADVISOR_ROUND3 =
|
|
73
|
+
const ADVISOR_ROUND2 = loadPrompt("advisor-round2.md", "advisor-round2.md")
|
|
74
|
+
const ADVISOR_ROUND3 = loadPrompt("advisor-round3.md", "advisor-round3.md")
|
|
75
|
+
// Fallback when advisor-design.md is missing — keep in sync with the real
|
|
76
|
+
// file (table format + workflow steps).
|
|
77
|
+
const ADVISOR_DESIGN_FALLBACK = `You are an independent design reviewer for an engineering-mode project. Review the design document in the changes below. Evaluate: completeness, feasibility, clarity, scope, acceptance criteria. Read METHODOLOGY.md if provided. Produce a review table with | # | Category | Severity | Issue | Suggestion | format.`
|
|
61
78
|
let ADVISOR_DESIGN = ""
|
|
62
|
-
|
|
79
|
+
// Design review is OPTIONAL (engineering mode only) — silent fallback to the
|
|
80
|
+
// in-code constant is intentional, unlike the mandatory round prompts which
|
|
81
|
+
// must exist for every review (loadPrompt throws a descriptive error there).
|
|
82
|
+
try { ADVISOR_DESIGN = readFileSync(join(__dirname, "prompts", "advisor-design.md"), "utf8") } catch { /* fallback below */ }
|
|
63
83
|
|
|
64
84
|
// ────────────────────────────────────────
|
|
65
85
|
// System prompt building
|
|
@@ -68,15 +88,28 @@ try { ADVISOR_DESIGN = readFileSync(join(__dirname, "prompts", "advisor-design.m
|
|
|
68
88
|
/**
|
|
69
89
|
* Build the system prompt for an advisor review session.
|
|
70
90
|
* @param {Object} agent — the parent agent
|
|
71
|
-
* @param {Object|null} [
|
|
91
|
+
* @param {Object|null} [prior] — prior review output (full text; decision 2026-08-08)
|
|
72
92
|
* @param {string} [reviewType] — "design" for design review, undefined/"code" for code review
|
|
73
93
|
* @returns {string} the system prompt
|
|
74
94
|
*/
|
|
75
|
-
export function buildAdvisorSystemPrompt(agent,
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
export function buildAdvisorSystemPrompt(agent, prior, reviewType) {
|
|
96
|
+
// Round decision is DETERMINISTIC (decision 2026-08-08): _advisorRound > 0
|
|
97
|
+
// with a stored review output means convergence (round 2+); 0 means round 1.
|
|
98
|
+
// No prior-table parsing, no all-clear phrase matching — the round counter
|
|
99
|
+
// and the stored output are the only inputs. A restarted process has
|
|
100
|
+
// _advisorRound 0 → conservative full re-review.
|
|
101
|
+
const hasPrior = (agent._advisorRound || 0) > 0 && (prior ?? agent._lastAdvisorOutput)
|
|
102
|
+
// Design review: round 1 uses the dedicated design-review prompt (full scope +
|
|
103
|
+
// approval token); rounds 2+ converge like code reviews (verify agent fix claims).
|
|
104
|
+
if (reviewType === "design") {
|
|
105
|
+
if (!hasPrior) {
|
|
106
|
+
return ADVISOR_DESIGN || ADVISOR_DESIGN_FALLBACK
|
|
107
|
+
}
|
|
108
|
+
const round = (agent._advisorRound || 0) + 1
|
|
109
|
+
if (round === 2) return ADVISOR_ROUND2
|
|
110
|
+
return ADVISOR_ROUND3
|
|
111
|
+
}
|
|
112
|
+
if (!hasPrior) return ADVISOR_ROUND1
|
|
80
113
|
const round = (agent._advisorRound || 0) + 1
|
|
81
114
|
if (round === 2) return ADVISOR_ROUND2
|
|
82
115
|
return ADVISOR_ROUND3
|
|
@@ -92,93 +125,153 @@ export function buildAdvisorSystemPrompt(agent, _prior, reviewType) {
|
|
|
92
125
|
* Deliberately NO git information injected (no diff snapshot, no git context):
|
|
93
126
|
* git output misled re-reviews — committed fixes never show in `git diff HEAD`,
|
|
94
127
|
* so the model read "no changes" as "no fixes". Verification is `read`-only.
|
|
128
|
+
* NOTE: the caller (prepareAdvisorMessages) applies escapeLiteralEscapes to
|
|
129
|
+
* the return value — direct callers must do the same (the prior table and
|
|
130
|
+
* agent response can quote literal "\x"/"\u" sequences).
|
|
131
|
+
* @param {Object} agent — the parent agent (history used for the response table)
|
|
132
|
+
* @param {Object|null} prior — prior issue table (extracted from history when null)
|
|
133
|
+
* @param {string[]|null} [scopeFiles] — review surface for the no-response fallback (cwd-relative)
|
|
134
|
+
* @returns {string} the follow-up user message — or a plain "System reminder: …"
|
|
135
|
+
* fresh-review fallback (NO brackets — some OpenAI-compatible servers parse
|
|
136
|
+
* '['-prefixed content as structured data / expand escapes) when no prior
|
|
137
|
+
* review exists at all (caller misuse; the response-table extraction would
|
|
138
|
+
* otherwise scan history from index 0 and could match an unrelated stale table)
|
|
95
139
|
*/
|
|
96
|
-
export function buildAdvisorFollowUp(agent,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
140
|
+
export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
|
|
141
|
+
// Convergence follow-up REQUIRES a prior review record — the full output of
|
|
142
|
+
// the last review, injected VERBATIM (decision 2026-08-08: the model
|
|
143
|
+
// understands the review output; no table/header/phrase parsing). The caller
|
|
144
|
+
// usually passes it; fall back to the stored agent._lastAdvisorOutput.
|
|
145
|
+
const p = prior ?? agent._lastAdvisorOutput
|
|
146
|
+
if (!p) {
|
|
147
|
+
// Plain "System reminder:" prefix (no brackets) — same convention as the
|
|
148
|
+
// round-1 path (some OpenAI-compatible servers parse '['-prefixed content
|
|
149
|
+
// as structured data / expand escapes; see prepareAdvisorMessages).
|
|
150
|
+
return "System reminder: convergence follow-up requested without a prior review — perform a fresh full review."
|
|
151
|
+
}
|
|
152
|
+
// Convergence semantics require round >= 2 (round 1 is the full review, not
|
|
153
|
+
// verification). A direct caller with _advisorRound 0 would otherwise get a
|
|
154
|
+
// meaningless "Round 1 — Strict Verification".
|
|
155
|
+
if ((agent._advisorRound || 0) < 1) {
|
|
156
|
+
return "System reminder: convergence follow-up requested at round 1 — a full review is already in progress; no prior verification exists yet."
|
|
157
|
+
}
|
|
158
|
+
const noResponseFallback = scopeFiles?.length
|
|
159
|
+
? "(Agent did not provide a response table — perform a fresh review of: " + scopeFiles.slice(0, 10).join(", ") + ")"
|
|
160
|
+
: "(Agent did not provide a response table — perform a fresh full review; the review surface is unknown, ask the user for the file list)"
|
|
161
|
+
const response = extractAgentResponseTable(agent.history) || noResponseFallback
|
|
100
162
|
const round = (agent._advisorRound || 0) + 1
|
|
101
|
-
|
|
163
|
+
return buildConvergenceBody(p, response, round, scopeFiles)
|
|
164
|
+
}
|
|
102
165
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return
|
|
166
|
+
/**
|
|
167
|
+
* Resolve the review surface for the convergence fallback — moved to
|
|
168
|
+
* messages.mjs so the legacy path shares it (see there).
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Neutralize literal backslash escape sequences ("\x", "\u") that some
|
|
173
|
+
* OpenAI-compatible servers interpret inside message content ("unexpected end
|
|
174
|
+
* of hex escape" → 400 — observed 2026-08-06 when the conversation background
|
|
175
|
+
* quoted "\x" literals). Only sequences that would be INVALID when expanded
|
|
176
|
+
* are doubled ("\\x" → literal "\x" after server expansion); well-formed
|
|
177
|
+
* "\xNN" / "\uNNNN" pass through untouched (they expand to a byte/codepoint).
|
|
178
|
+
*/
|
|
179
|
+
export function escapeLiteralEscapes(text) {
|
|
180
|
+
// (?<!\\) — only a SINGLE backslash counts ("\\x" already doubles the
|
|
181
|
+
// escape and must pass through untouched); lookbehind is fine on Node 24.
|
|
182
|
+
// Known limitation (documented, accepted): an ODD backslash run of 3+ (e.g.
|
|
183
|
+
// "\\\x") leaves the trailing "\x" un-doubled — vanishingly rare in real
|
|
184
|
+
// conversation text, and the sequence is still valid JSON either way.
|
|
185
|
+
// The lookahead treats "\x" followed by AT LEAST 2 hex as valid (servers
|
|
186
|
+
// expand only the first two: "\x1b3" → ESC + "3"); only truncated runs
|
|
187
|
+
// ("\x" + <2 hex) are doubled.
|
|
188
|
+
text = String(text ?? "")
|
|
189
|
+
return text
|
|
190
|
+
.replace(/(?<!\\)\\(x)(?![0-9a-fA-F]{2})/g, "\\\\$1")
|
|
191
|
+
.replace(/(?<!\\)\\(u)(?![0-9a-fA-F]{4})/g, "\\\\$1")
|
|
127
192
|
}
|
|
128
193
|
|
|
194
|
+
|
|
129
195
|
/**
|
|
130
|
-
* Build
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review
|
|
196
|
+
* Build the advisor conversation for this run.
|
|
197
|
+
* EVERY call builds a fresh [system, user] session (decision d698434) — no
|
|
198
|
+
* session reuse across rounds: round 1 = full scope (ROUND1 prompt), rounds
|
|
199
|
+
* 2+ = convergence (ROUND2/ROUND3 prompt + fix-claims follow-up).
|
|
200
|
+
* @param {Object} agent — the parent agent
|
|
136
201
|
* @param {string} [reviewType] — "design" or "code" (default)
|
|
137
202
|
* @param {string|null} [designToken] — design-review approval token (design only)
|
|
138
203
|
* @param {string[]|null} [documents] — design review only: explicit list of doc paths to review (passed through to buildAdvisorUserMessage)
|
|
204
|
+
* @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review
|
|
139
205
|
*/
|
|
140
|
-
export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null) {
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
|
|
206
|
+
export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null, priorParam = null) {
|
|
207
|
+
// Deterministic convergence state (decision 2026-08-08): round 2+ requires
|
|
208
|
+
// _advisorRound > 0 AND a stored prior review output. No history parsing.
|
|
209
|
+
// priorParam (direct callers) wins over the stored output — same derivation
|
|
210
|
+
// as buildAdvisorSystemPrompt (single source of truth for round semantics).
|
|
211
|
+
const prior = (agent._advisorRound || 0) > 0 ? (priorParam ?? agent._lastAdvisorOutput) : null
|
|
212
|
+
|
|
213
|
+
// Design review round 1: the dedicated full-scope review with the approval
|
|
214
|
+
// token (an independent gate — it runs even when a prior review exists, e.g.
|
|
215
|
+
// after a failed design review). Fresh session.
|
|
216
|
+
if (reviewType === "design" && (agent._advisorRound || 0) === 0) {
|
|
144
217
|
return [
|
|
145
218
|
{ role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
|
|
146
|
-
{ role: "user", content: buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths) },
|
|
219
|
+
{ role: "user", content: escapeLiteralEscapes(buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)) },
|
|
147
220
|
]
|
|
148
221
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
222
|
+
|
|
223
|
+
// Every round is a FRESH session (decision d698434): round 2+ must NOT reuse
|
|
224
|
+
// round 1's messages — the old read outputs are the top anchoring source of
|
|
225
|
+
// re-review false reports (the model quoted pre-fix file content instead of
|
|
226
|
+
// re-reading) and a token sink. The agent response table (fix claims) is
|
|
227
|
+
// injected through buildAdvisorFollowUp instead; the system prompt carries
|
|
228
|
+
// the round (ROUND2/ROUND3) via buildAdvisorSystemPrompt.
|
|
229
|
+
// No prior review output: reset ONLY when this run made no code changes (user
|
|
230
|
+
// decision 2026-08-05: any loop that modified code must NOT reset — the
|
|
231
|
+
// advisor guard WILL push back, so the convergence round must keep advancing
|
|
232
|
+
// toward the cap; a run with no mutations has no push-back risk and a reset
|
|
233
|
+
// is safe). Deterministic runtime state (`_mutatedThisRun`) decides — never
|
|
234
|
+
// model output (phrases/table headers drift; three rounds of false reports
|
|
235
|
+
// proved it). Either way the message is a fresh full review (no prior output
|
|
236
|
+
// exists without a completed review) — only the round counter differs.
|
|
237
|
+
if (!prior || (agent._advisorRound || 0) === 0) {
|
|
238
|
+
if (!(agent._mutatedThisRun ?? false)) {
|
|
239
|
+
// New review cycle (first review, all-clear, or no code changes): reset
|
|
240
|
+
// the round so the cycle gets its own 5-round budget.
|
|
241
|
+
agent._advisorRound = 0
|
|
167
242
|
}
|
|
243
|
+
// Mutations exist → KEEP the round (cap keeps advancing through retries).
|
|
244
|
+
const user = buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)
|
|
245
|
+
return [
|
|
246
|
+
{ role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
|
|
247
|
+
{
|
|
248
|
+
role: "user",
|
|
249
|
+
// NOTE (2026-08-06): the leading prefix is a PLAIN "System reminder:",
|
|
250
|
+
// NOT "[System reminder: ...]" — some OpenAI-compatible servers try to
|
|
251
|
+
// parse content that STARTS with '[' as structured content (or expand
|
|
252
|
+
// escape sequences in it). A literal "\x" inside the conversation
|
|
253
|
+
// background (e.g. the parent agent quoting escape sequences) then
|
|
254
|
+
// fails server-side as "unexpected end of hex escape" → 400. Plain
|
|
255
|
+
// prefix keeps the review message a plain string everywhere.
|
|
256
|
+
// The whole content also passes through escapeLiteralEscapes (below)
|
|
257
|
+
// so literal "\x"/"\u" quoted by the parent agent can never form an
|
|
258
|
+
// invalid escape when the server expands them.
|
|
259
|
+
content: escapeLiteralEscapes(`System reminder: no prior issue table is being carried into this review (first review, app restart, or session clear) — start with a fresh full review.\n\n${user}`),
|
|
260
|
+
},
|
|
261
|
+
]
|
|
168
262
|
}
|
|
169
|
-
|
|
263
|
+
|
|
264
|
+
// Convergence rounds (2+): fresh [system(ROUND2/3), user(prior table + fix
|
|
265
|
+
// claims)]. buildAdvisorFollowUp carries BOTH the prior issue table (the
|
|
266
|
+
// only complete verification list — decision 2026-08-05, reversed) and the
|
|
267
|
+
// agent's fix-claim table (focus reference). buildAdvisorSystemPrompt
|
|
268
|
+
// selects ROUND2 for round 2, ROUND3 for rounds 3+ — a failed review retry
|
|
269
|
+
// keeps _advisorRound so the convergence prompt matches the attempt count.
|
|
270
|
+
// scopeFiles gives the fallback (agent gave no response table) a concrete
|
|
271
|
+
// review surface.
|
|
272
|
+
const scopeFiles = resolveScopeFiles(agent, paths)
|
|
273
|
+
return [
|
|
170
274
|
{ role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
|
|
171
|
-
{ role: "user", content:
|
|
275
|
+
{ role: "user", content: escapeLiteralEscapes(buildAdvisorFollowUp(agent, prior, scopeFiles)) },
|
|
172
276
|
]
|
|
173
|
-
// Fresh session. Only reset round if this is truly the first review.
|
|
174
|
-
// If _advisorRound > 0, there was a prior review that passed (all-clear).
|
|
175
|
-
if (!agent._advisorRound) agent._advisorRound = 0
|
|
176
|
-
if (!prior) {
|
|
177
|
-
// Tell the advisor why no prior issue table is present
|
|
178
|
-
session[1] = {
|
|
179
|
-
role: "user",
|
|
180
|
-
content: `[System reminder: no prior issue table is being carried into this review (first review, app restart, or session clear) — start with a fresh full review.]\n\n${session[1].content}`,
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return session
|
|
184
277
|
}
|
package/src/agent/completion.mjs
CHANGED
|
@@ -4,15 +4,9 @@
|
|
|
4
4
|
* Checks: pending tasks, verify guard, advisor guard.
|
|
5
5
|
* Returns { action: 'continue' | 'done', content?, guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { hasCodeMutations } from "../advisor/repos.mjs"
|
|
8
8
|
import { pushReal } from "../context.mjs"
|
|
9
|
-
|
|
10
|
-
/** True when this run mutated at least one CODE file. Mirrors agent.mjs:hasCodeMutations. */
|
|
11
|
-
function hasCodeMutations(agent) {
|
|
12
|
-
const files = agent._touchedFiles ?? []
|
|
13
|
-
if (files.length === 0) return agent._mutatedThisRun
|
|
14
|
-
return files.some((p) => /(?:^|[\\/])src[\\/]/.test(p) || !isDocFile(p))
|
|
15
|
-
}
|
|
9
|
+
import { MAX_ADVISOR_ROUNDS } from "../advisor/run.mjs"
|
|
16
10
|
|
|
17
11
|
const MAX_VERIFY_PUSHBACKS = 2
|
|
18
12
|
const MAX_VERIFY_RETRIES = 3
|
|
@@ -117,12 +111,18 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
|
|
|
117
111
|
}
|
|
118
112
|
|
|
119
113
|
// --- advisor guard: review of mutated files before completion ---
|
|
120
|
-
//
|
|
114
|
+
// Active by default when advisor.enabled is set (opt-out via guard: false),
|
|
115
|
+
// and NEVER in engineering mode.
|
|
121
116
|
const cfg = agent.config?.advisor
|
|
122
117
|
const advisorReview = cfg?.enabled && cfg?.guard !== false
|
|
123
118
|
if (depth === 0 && advisorReview && !agent.config?.agent?.engineering) {
|
|
119
|
+
// Cap sync: beyond MAX_ADVISOR_ROUNDS the advisor tool refuses to review
|
|
120
|
+
// (run.mjs convergence cap) — pushing back further would loop forever
|
|
121
|
+
// (fix → pushback → cap-refused call → fix …). The cap message from the
|
|
122
|
+
// last accepted review stands; the user decides manually.
|
|
124
123
|
if (agent._mutatedThisRun && !agent._calledAdvisorThisRun && hasCodeMutations(agent)
|
|
125
|
-
&& advisorPushbacks < MAX_ADVISOR_PUSHBACKS
|
|
124
|
+
&& advisorPushbacks < MAX_ADVISOR_PUSHBACKS
|
|
125
|
+
&& (agent._advisorRound || 0) < MAX_ADVISOR_ROUNDS) {
|
|
126
126
|
advisorPushbacks++
|
|
127
127
|
pushReal(agent, { role: "assistant", content: response.content })
|
|
128
128
|
agent.history.push({
|
package/src/agent/dispatch.mjs
CHANGED
|
@@ -152,6 +152,15 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
152
152
|
if (!item.tool?.readonly && item.args) {
|
|
153
153
|
snapshotForUndo(agent, item.toolCall.name, item.args, agent.cwd)
|
|
154
154
|
}
|
|
155
|
+
// M2 ACP: route fs tools through the client (IDE buffer / diff review).
|
|
156
|
+
// toolRouter returns { handled: true, result } to short-circuit execution.
|
|
157
|
+
if (callbacks.toolRouter) {
|
|
158
|
+
const routed = await callbacks.toolRouter(item.toolCall.name, item.args)
|
|
159
|
+
if (routed?.handled) {
|
|
160
|
+
callbacks.onToolResult?.(item.toolCall.name, routed.result)
|
|
161
|
+
return { ...item, result: routed.result, ok: true }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
155
164
|
const rawResult = await item.tool.execute(item.args, {
|
|
156
165
|
cwd: agent.cwd,
|
|
157
166
|
agent,
|
|
@@ -172,6 +181,11 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
172
181
|
} catch (error) {
|
|
173
182
|
// Persist to ~/.thincoder/tool-errors/ for post-mortem; only pass message to the model (stack traces confuse LLMs and may leak paths)
|
|
174
183
|
logToolError(item.toolCall.name, item.args, error)
|
|
184
|
+
// User interrupt (Ctrl+C / Ctrl+I) must propagate, not become a tool error:
|
|
185
|
+
// swallowing it here would make the parent keep looping while the user
|
|
186
|
+
// asked to stop — worst case with subagents, where the child runs its
|
|
187
|
+
// whole turn budget and the interrupt appears to do nothing.
|
|
188
|
+
if (signal?.aborted) throw error
|
|
175
189
|
runHooks("PostToolUseFailure", { agent, toolName: item.toolCall.name, toolArgs: item.args, error }).catch(() => {})
|
|
176
190
|
// Build contextual error: tool name + key args so the model can reason about what went wrong
|
|
177
191
|
const ctxParts = []
|
|
@@ -81,8 +81,8 @@ export const advisorTool = {
|
|
|
81
81
|
"Use type='code' (default) to review code changes after implementation — pass paths=[...] to specify which files or directories to review, or documents=[...] for acceptance criteria context. " +
|
|
82
82
|
"The advisor is an independent read-only sub-agent that explores the codebase, " +
|
|
83
83
|
"reads files, and traces callers via grep/lsp. " +
|
|
84
|
-
"For code review: round 1 does a full review, round 2 verifies the
|
|
85
|
-
"round 3+ strictly checks only the
|
|
84
|
+
"For code review: round 1 does a full review, round 2 verifies the agent's fix claims, " +
|
|
85
|
+
"round 3+ strictly checks only the fix claims — convergence, not divergence. " +
|
|
86
86
|
"For design review: single-pass review against methodology and requirements. " +
|
|
87
87
|
"Review criteria come from .thincoder/advisor.md (if present) or sensible defaults. " +
|
|
88
88
|
"After the review, you MUST produce a response table (see discipline rules for format). " +
|
|
@@ -94,7 +94,7 @@ export const advisorTool = {
|
|
|
94
94
|
paths: {
|
|
95
95
|
type: "array",
|
|
96
96
|
items: { type: "string" },
|
|
97
|
-
description: "Code files or directories to review (for code review). Required unless documents is provided. The advisor
|
|
97
|
+
description: "Code files or directories to review (for code review). Required unless documents is provided. The advisor reads the files/directories listed here — it has no git tool and never inspects diffs.",
|
|
98
98
|
},
|
|
99
99
|
documents: {
|
|
100
100
|
type: "array",
|
|
@@ -110,11 +110,13 @@ export const advisorTool = {
|
|
|
110
110
|
const agent = ctx.agent
|
|
111
111
|
const reviewType = args.type || "code"
|
|
112
112
|
const documents = args.documents || null
|
|
113
|
-
|
|
113
|
+
// Scope fallback: the runtime mutation record (zero git) covers guard-triggered
|
|
114
|
+
// reviews where the model did not pass explicit paths.
|
|
115
|
+
const paths = args.paths || (agent._touchedFiles?.length ? [...agent._touchedFiles] : null)
|
|
114
116
|
|
|
115
117
|
// Code review must have a scope — no implicit fallback.
|
|
116
118
|
if (reviewType !== "design" && !paths && !documents) {
|
|
117
|
-
return "Advisor: no review scope specified. Provide paths (files/directories to review) or documents (acceptance criteria
|
|
119
|
+
return "Advisor: no review scope specified. Provide paths (files/directories to review) or documents (acceptance criteria context)."
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
// Design review: validate that documents are in docs/ or are recognized doc files
|
|
@@ -129,11 +131,10 @@ export const advisorTool = {
|
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
// Design review:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
134
|
+
// Design review: NO round reset — design reviews share the 5-round convergence
|
|
135
|
+
// budget with code reviews (round advances in agent.mjs; cap in run.mjs).
|
|
136
|
+
// Session reset also removed: design rounds 2+ continue the advisor session
|
|
137
|
+
// like code reviews (fix claims + round-aware prompts).
|
|
137
138
|
|
|
138
139
|
// Generate the design token BEFORE the review and inject it into the advisor's prompt.
|
|
139
140
|
// The advisor (LLM) decides pass/fail itself and echoes the token only on approval —
|
|
@@ -180,7 +180,7 @@ export const subagentTool = {
|
|
|
180
180
|
? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args)
|
|
181
181
|
: null,
|
|
182
182
|
}
|
|
183
|
-
const childRunOpts =
|
|
183
|
+
const childRunOpts = buildChildRunOpts(ctx)
|
|
184
184
|
let report = await runAgent(child, input, childOpts, childRunOpts)
|
|
185
185
|
|
|
186
186
|
// Report too short = incomplete handoff: send back for expansion once (inspired by kimi-code's summaryPolicy: min 200 chars, retry 1 time).
|
|
@@ -203,6 +203,20 @@ export const subagentTool = {
|
|
|
203
203
|
},
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Child agent run options — the parent's abort signal MUST propagate to the
|
|
208
|
+
* child: without it, Ctrl+C aborts the parent's controller but the child keeps
|
|
209
|
+
* running its full turn budget (up to subagentTurns) while the parent awaits —
|
|
210
|
+
* the interrupt appears to do nothing.
|
|
211
|
+
*/
|
|
212
|
+
export function buildChildRunOpts(ctx) {
|
|
213
|
+
return {
|
|
214
|
+
depth: (ctx.depth ?? 0) + 1,
|
|
215
|
+
maxTurns: ctx.agent?.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS,
|
|
216
|
+
signal: ctx.signal ?? null,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
206
220
|
/**
|
|
207
221
|
* Merge an eng-coder child's mutations into the parent agent's bookkeeping.
|
|
208
222
|
* The parent must stay aware of delegated file changes: `_touchedFiles` enables
|
|
@@ -210,13 +224,20 @@ export const subagentTool = {
|
|
|
210
224
|
* pushback for review. Prior verify/advisor state is invalidated because it
|
|
211
225
|
* judged an older state.
|
|
212
226
|
*
|
|
213
|
-
* `_advisorRound` is reset
|
|
214
|
-
*
|
|
227
|
+
* `_advisorRound` is NOT reset: merged code enters the CURRENT convergence
|
|
228
|
+
* cycle. Resetting here would break the review→fix→re-review loop (the parent
|
|
229
|
+
* reviews, spawns an eng-coder to fix, merges, reviews again — every merge
|
|
230
|
+
* would restart at round 1 and the 5-round cap could never be reached).
|
|
231
|
+
* `_calledAdvisorThisRun` IS cleared so the merged code triggers a fresh
|
|
232
|
+
* advisor call (the guard demands review of new mutations).
|
|
215
233
|
*
|
|
216
234
|
* Returns true when mutations were merged (kept for future caller checks).
|
|
217
235
|
*/
|
|
218
236
|
export function mergeChildMutations(parent, child) {
|
|
219
|
-
|
|
237
|
+
// A child claiming mutations without any touched file is a misbehaving
|
|
238
|
+
// child (or a bookkeeping bug) — do not propagate an empty mutation claim
|
|
239
|
+
// to the parent's guard state.
|
|
240
|
+
if (!child._mutatedThisRun || !(child._touchedFiles?.length)) return false
|
|
220
241
|
parent._mutatedThisRun = true
|
|
221
242
|
for (const abs of child._touchedFiles ?? []) {
|
|
222
243
|
if (!parent._touchedFiles.includes(abs)) parent._touchedFiles.push(abs)
|
|
@@ -226,10 +247,7 @@ export function mergeChildMutations(parent, child) {
|
|
|
226
247
|
parent._verifiedThisRun = false
|
|
227
248
|
parent._verifyPassed = undefined
|
|
228
249
|
}
|
|
229
|
-
//
|
|
230
|
-
// _advisorRound reset ensures new code gets a full round-1 review;
|
|
231
|
-
// _advisorSession prevents cross-contamination between reviews.
|
|
232
|
-
parent._advisorRound = 0
|
|
250
|
+
// Stale session cleanup only — the round counter survives (see above).
|
|
233
251
|
parent._advisorSession = null
|
|
234
252
|
return true
|
|
235
253
|
}
|