thincoder 0.12.11 → 0.12.12
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/history.mjs +25 -6
- package/src/advisor/messages.mjs +76 -22
- package/src/advisor/run.mjs +185 -91
- package/src/advisor.mjs +205 -83
- package/src/agent/completion.mjs +9 -2
- 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 +5 -5
- package/src/prompts/advisor-round1.md +8 -2
- package/src/prompts/advisor-round2.md +6 -4
- package/src/prompts/advisor-round3.md +6 -4
- 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,13 +32,12 @@
|
|
|
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
|
*/
|
|
@@ -40,7 +45,7 @@ import { readFileSync } from "node:fs"
|
|
|
40
45
|
import { join, dirname } from "node:path"
|
|
41
46
|
import { fileURLToPath } from "node:url"
|
|
42
47
|
import { extractPriorIssueTable, extractAgentResponseTable } from "./advisor/history.mjs"
|
|
43
|
-
import { buildAdvisorUserMessage } from "./advisor/messages.mjs"
|
|
48
|
+
import { buildAdvisorUserMessage, buildConvergenceInstructions, resolveScopeFiles } from "./advisor/messages.mjs"
|
|
44
49
|
// Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
|
|
45
50
|
export { ADVISOR_MD_PATH, extractPriorIssueTable, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
|
|
46
51
|
export { buildAdvisorUserMessage } from "./advisor/messages.mjs"
|
|
@@ -51,15 +56,30 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
51
56
|
// Prompt files — loaded at module init
|
|
52
57
|
// ────────────────────────────────────────
|
|
53
58
|
|
|
54
|
-
|
|
59
|
+
function loadPrompt(file, name) {
|
|
60
|
+
try {
|
|
61
|
+
return readFileSync(join(__dirname, "prompts", file), "utf8")
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`${name} missing from the installation (prompts/${file}) — reinstall thincoder or restore the file`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ADVISOR_ROUND1 = loadPrompt("advisor-round1.md", "advisor-round1.md")
|
|
55
68
|
// ROUND2/3 are used whenever a convergence round (round 2+) is being built:
|
|
56
69
|
// in-run session continuation replaces the system prompt with them, and a
|
|
57
70
|
// rebuilt fresh session (e.g. after a failed review) also selects them via
|
|
58
71
|
// buildAdvisorSystemPrompt when _advisorRound > 0.
|
|
59
|
-
const ADVISOR_ROUND2 =
|
|
60
|
-
const ADVISOR_ROUND3 =
|
|
72
|
+
const ADVISOR_ROUND2 = loadPrompt("advisor-round2.md", "advisor-round2.md")
|
|
73
|
+
const ADVISOR_ROUND3 = loadPrompt("advisor-round3.md", "advisor-round3.md")
|
|
74
|
+
// Fallback when advisor-design.md is missing — keep in sync with the real
|
|
75
|
+
// file (table format + workflow steps; a drifted fallback would also break
|
|
76
|
+
// extractPriorIssueTable's DESIGN_TABLE_HEADER matching).
|
|
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,24 @@ 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 issue table (from extractPriorIssueTable)
|
|
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
|
-
// Design review: dedicated prompt
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
export function buildAdvisorSystemPrompt(agent, prior, reviewType) {
|
|
96
|
+
// Design review: round 1 uses the dedicated design-review prompt (full scope +
|
|
97
|
+
// approval token); rounds 2+ converge like code reviews (verify agent fix claims).
|
|
98
|
+
if (reviewType === "design") {
|
|
99
|
+
const p = prior ?? extractPriorIssueTable(agent.history)
|
|
100
|
+
if (!p || (agent._advisorRound || 0) === 0) {
|
|
101
|
+
return ADVISOR_DESIGN || ADVISOR_DESIGN_FALLBACK
|
|
102
|
+
}
|
|
103
|
+
const round = (agent._advisorRound || 0) + 1
|
|
104
|
+
if (round === 2) return ADVISOR_ROUND2
|
|
105
|
+
return ADVISOR_ROUND3
|
|
106
|
+
}
|
|
107
|
+
const p = prior ?? extractPriorIssueTable(agent.history)
|
|
108
|
+
if (!p || (agent._advisorRound || 0) === 0) return ADVISOR_ROUND1
|
|
80
109
|
const round = (agent._advisorRound || 0) + 1
|
|
81
110
|
if (round === 2) return ADVISOR_ROUND2
|
|
82
111
|
return ADVISOR_ROUND3
|
|
@@ -92,93 +121,186 @@ export function buildAdvisorSystemPrompt(agent, _prior, reviewType) {
|
|
|
92
121
|
* Deliberately NO git information injected (no diff snapshot, no git context):
|
|
93
122
|
* git output misled re-reviews — committed fixes never show in `git diff HEAD`,
|
|
94
123
|
* so the model read "no changes" as "no fixes". Verification is `read`-only.
|
|
124
|
+
* NOTE: the caller (prepareAdvisorMessages) applies escapeLiteralEscapes to
|
|
125
|
+
* the return value — direct callers must do the same (the prior table and
|
|
126
|
+
* agent response can quote literal "\x"/"\u" sequences).
|
|
127
|
+
* @param {Object} agent — the parent agent (history used for the response table)
|
|
128
|
+
* @param {Object|null} prior — prior issue table (extracted from history when null)
|
|
129
|
+
* @param {string[]|null} [scopeFiles] — review surface for the no-response fallback (cwd-relative)
|
|
130
|
+
* @returns {string} the follow-up user message — or a plain "System reminder: …"
|
|
131
|
+
* fresh-review fallback (NO brackets — some OpenAI-compatible servers parse
|
|
132
|
+
* '['-prefixed content as structured data / expand escapes) when no prior
|
|
133
|
+
* review exists at all (caller misuse; the response-table extraction would
|
|
134
|
+
* otherwise scan history from index 0 and could match an unrelated stale table)
|
|
95
135
|
*/
|
|
96
|
-
export function buildAdvisorFollowUp(agent,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
136
|
+
export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
|
|
137
|
+
// Convergence follow-up REQUIRES a prior review record. The caller usually
|
|
138
|
+
// passes it; fall back to extracting it from history (compat for direct
|
|
139
|
+
// callers/tests). If there is genuinely no prior review, a nullish table
|
|
140
|
+
// would make the response-table extraction scan from index 0 and possibly
|
|
141
|
+
// match an unrelated stale table — return a round-1-style message instead.
|
|
142
|
+
const p = prior ?? extractPriorIssueTable(agent.history)
|
|
143
|
+
if (!p) {
|
|
144
|
+
// Plain "System reminder:" prefix (no brackets) — same convention as the
|
|
145
|
+
// round-1 path (some OpenAI-compatible servers parse '['-prefixed content
|
|
146
|
+
// as structured data / expand escapes; see prepareAdvisorMessages).
|
|
147
|
+
return "System reminder: convergence follow-up requested without a prior review — perform a fresh full review."
|
|
148
|
+
}
|
|
149
|
+
// Convergence semantics require round >= 2 (round 1 is the full review, not
|
|
150
|
+
// verification). A direct caller with _advisorRound 0 would otherwise get a
|
|
151
|
+
// meaningless "Round 1 — Strict Verification".
|
|
152
|
+
if ((agent._advisorRound || 0) < 1) {
|
|
153
|
+
return "System reminder: convergence follow-up requested at round 1 — a full review is already in progress; no prior verification exists yet."
|
|
154
|
+
}
|
|
155
|
+
const noResponseFallback = scopeFiles?.length
|
|
156
|
+
? "(Agent did not provide a response table — perform a fresh review of: " + scopeFiles.slice(0, 10).join(", ") + ")"
|
|
157
|
+
: "(Agent did not provide a response table — perform a fresh full review; the review surface is unknown, ask the user for the file list)"
|
|
158
|
+
const response = extractAgentResponseTable(agent.history, p.sinceIdx) || noResponseFallback
|
|
100
159
|
const round = (agent._advisorRound || 0) + 1
|
|
101
160
|
const label = round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"
|
|
102
161
|
|
|
162
|
+
const reminder = round === 2
|
|
163
|
+
? "verify every item in the prior issue table and flag only obvious new issues introduced by the fixes"
|
|
164
|
+
: "strictly verify only the prior issue table — do NOT look for new issues"
|
|
103
165
|
const parts = [
|
|
104
166
|
`## Round ${round} — ${label}`,
|
|
105
167
|
"",
|
|
106
|
-
`[System reminder: this is round ${round} of the convergence protocol.
|
|
168
|
+
`[System reminder: this is round ${round} of the convergence protocol. ` +
|
|
169
|
+
`The system prompt for this round has already narrowed the review scope — follow it: ${reminder}.]`,
|
|
107
170
|
"",
|
|
108
|
-
|
|
109
|
-
|
|
171
|
+
// Prior issue table IS in the context (decision 2026-08-05, reversed):
|
|
172
|
+
// it is the ONLY complete verification list — the agent response table
|
|
173
|
+
// covers only issues the agent chose to answer, so issues the agent
|
|
174
|
+
// skipped would silently escape convergence. Restatement risk is handled
|
|
175
|
+
// mechanically: host-verified citations reject references that do not
|
|
176
|
+
// match the CURRENT disk state, and fresh sessions exclude old read data.
|
|
177
|
+
// The agent response table stays as a focus aid ("I fixed X"), not as the
|
|
178
|
+
// to-verify list.
|
|
179
|
+
"## Prior Issue Table (verify every item)",
|
|
180
|
+
p.text,
|
|
110
181
|
"",
|
|
111
|
-
"## Agent Response",
|
|
182
|
+
"## Agent Response (fix claims — reference only)",
|
|
112
183
|
response,
|
|
113
184
|
"",
|
|
114
185
|
"## Instructions",
|
|
115
|
-
round
|
|
116
|
-
? "Verify each item in the prior table. Flag any obvious NEW issues introduced by the fixes (crashes, data loss, logic errors — not style). Produce a verification table."
|
|
117
|
-
: "Strictly verify ONLY the items in the prior table against the CURRENT FILE STATE (use `read` — an empty diff does not mean the fixes are absent). Do NOT look for new issues.",
|
|
118
|
-
"",
|
|
119
|
-
"IMPORTANT: the prior issue table is HISTORY — always verify current file state with `read` before judging an item as fixed or unfixed.",
|
|
120
|
-
// Round-aware evidence rule: "New" entries only exist in round 2 (round 3+ forbids them).
|
|
121
|
-
`STALE-CONTEXT WARNING: only fresh \`read\` results describe the current state — never judge from earlier snapshots or from \`git diff\` (committed fixes never show in \`git diff HEAD\`). Read the files to verify. Any "Unfixed" entry${round === 2 ? ' (and any "New" entry)' : ""} MUST quote the exact line content from THIS round's \`read\` output (e.g. \`run.mjs:180: timeoutId = setTimeout(...)\`); line numbers alone are NOT evidence (they may come from the stale prior table). Uncited findings are unverified and will be ignored.`,
|
|
122
|
-
"",
|
|
123
|
-
"Do NOT re-read AGENTS.md / design docs. Verify fix status with `read` only — do not rely on git output: a clean working tree does not mean fixes are absent (they may be committed).",
|
|
186
|
+
...buildConvergenceInstructions(round, scopeFiles),
|
|
124
187
|
"",
|
|
125
188
|
]
|
|
126
189
|
return parts.join("\n")
|
|
127
190
|
}
|
|
128
191
|
|
|
129
192
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
*
|
|
193
|
+
* Resolve the review surface for the convergence fallback — moved to
|
|
194
|
+
* messages.mjs so the legacy path shares it (see there).
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Neutralize literal backslash escape sequences ("\x", "\u") that some
|
|
199
|
+
* OpenAI-compatible servers interpret inside message content ("unexpected end
|
|
200
|
+
* of hex escape" → 400 — observed 2026-08-06 when the conversation background
|
|
201
|
+
* quoted "\x" literals). Only sequences that would be INVALID when expanded
|
|
202
|
+
* are doubled ("\\x" → literal "\x" after server expansion); well-formed
|
|
203
|
+
* "\xNN" / "\uNNNN" pass through untouched (they expand to a byte/codepoint).
|
|
204
|
+
*/
|
|
205
|
+
export function escapeLiteralEscapes(text) {
|
|
206
|
+
// (?<!\\) — only a SINGLE backslash counts ("\\x" already doubles the
|
|
207
|
+
// escape and must pass through untouched); lookbehind is fine on Node 24.
|
|
208
|
+
// Known limitation (documented, accepted): an ODD backslash run of 3+ (e.g.
|
|
209
|
+
// "\\\x") leaves the trailing "\x" un-doubled — vanishingly rare in real
|
|
210
|
+
// conversation text, and the sequence is still valid JSON either way.
|
|
211
|
+
// The lookahead treats "\x" followed by AT LEAST 2 hex as valid (servers
|
|
212
|
+
// expand only the first two: "\x1b3" → ESC + "3"); only truncated runs
|
|
213
|
+
// ("\x" + <2 hex) are doubled.
|
|
214
|
+
text = String(text ?? "")
|
|
215
|
+
return text
|
|
216
|
+
.replace(/(?<!\\)\\(x)(?![0-9a-fA-F]{2})/g, "\\\\$1")
|
|
217
|
+
.replace(/(?<!\\)\\(u)(?![0-9a-fA-F]{4})/g, "\\\\$1")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build the advisor conversation for this run.
|
|
223
|
+
* EVERY call builds a fresh [system, user] session (decision d698434) — no
|
|
224
|
+
* session reuse across rounds: round 1 = full scope (ROUND1 prompt), rounds
|
|
225
|
+
* 2+ = convergence (ROUND2/ROUND3 prompt + fix-claims follow-up).
|
|
226
|
+
* @param {Object} agent — the parent agent
|
|
136
227
|
* @param {string} [reviewType] — "design" or "code" (default)
|
|
137
228
|
* @param {string|null} [designToken] — design-review approval token (design only)
|
|
138
229
|
* @param {string[]|null} [documents] — design review only: explicit list of doc paths to review (passed through to buildAdvisorUserMessage)
|
|
230
|
+
* @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review
|
|
139
231
|
*/
|
|
140
232
|
export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null) {
|
|
141
233
|
const prior = extractPriorIssueTable(agent.history)
|
|
142
|
-
|
|
143
|
-
|
|
234
|
+
|
|
235
|
+
// Design review round 1: the dedicated full-scope review with the approval
|
|
236
|
+
// token (an independent gate — it runs even when a prior table exists, e.g.
|
|
237
|
+
// after a failed design review). Fresh session.
|
|
238
|
+
if (reviewType === "design" && (agent._advisorRound || 0) === 0) {
|
|
144
239
|
return [
|
|
145
240
|
{ role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
|
|
146
|
-
{ role: "user", content: buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths) },
|
|
241
|
+
{ role: "user", content: escapeLiteralEscapes(buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)) },
|
|
147
242
|
]
|
|
148
243
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
244
|
+
|
|
245
|
+
// Every round is a FRESH session (decision d698434): round 2+ must NOT reuse
|
|
246
|
+
// round 1's messages — the old read outputs are the top anchoring source of
|
|
247
|
+
// re-review false reports (the model quoted pre-fix file content instead of
|
|
248
|
+
// re-reading) and a token sink. The agent response table (fix claims) is
|
|
249
|
+
// injected through buildAdvisorFollowUp instead; the system prompt carries
|
|
250
|
+
// the round (ROUND2/ROUND3) via buildAdvisorSystemPrompt.
|
|
251
|
+
// Guard matches buildAdvisorSystemPrompt's ROUND1 condition
|
|
252
|
+
// (`!prior || _advisorRound === 0`): a stale prior table with _advisorRound 0
|
|
253
|
+
// (history persists across runAgent calls) must yield a fresh round-1 review —
|
|
254
|
+
// ROUND1 system prompt + full-scope user message, never the convergence
|
|
255
|
+
// follow-up (which would contradict the ROUND1 system prompt). The
|
|
256
|
+
// _advisorRound===0 half was lost in the _mutatedThisRun refactor and is
|
|
257
|
+
// restored here (regression 67ac851 → 6e15a6b window).
|
|
258
|
+
// No prior table: reset ONLY when this run made no code changes (user
|
|
259
|
+
// decision 2026-08-05: any loop that modified code must NOT reset — the
|
|
260
|
+
// advisor guard WILL push back, so the convergence round must keep advancing
|
|
261
|
+
// toward the cap; a run with no mutations has no push-back risk and a reset
|
|
262
|
+
// is safe). Deterministic runtime state (`_mutatedThisRun`) decides — never
|
|
263
|
+
// model output (phrases/table headers drift; three rounds of false reports
|
|
264
|
+
// proved it). Either way the message is a fresh full review (no issue list
|
|
265
|
+
// exists without a prior table) — only the round counter differs.
|
|
266
|
+
if (!prior || (agent._advisorRound || 0) === 0) {
|
|
267
|
+
if (!(agent._mutatedThisRun ?? false)) {
|
|
268
|
+
// New review cycle (first review, all-clear, or no code changes): reset
|
|
269
|
+
// the round so the cycle gets its own 5-round budget.
|
|
270
|
+
agent._advisorRound = 0
|
|
167
271
|
}
|
|
272
|
+
// Mutations exist → KEEP the round (cap keeps advancing through retries).
|
|
273
|
+
const user = buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)
|
|
274
|
+
return [
|
|
275
|
+
{ role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
|
|
276
|
+
{
|
|
277
|
+
role: "user",
|
|
278
|
+
// NOTE (2026-08-06): the leading prefix is a PLAIN "System reminder:",
|
|
279
|
+
// NOT "[System reminder: ...]" — some OpenAI-compatible servers try to
|
|
280
|
+
// parse content that STARTS with '[' as structured content (or expand
|
|
281
|
+
// escape sequences in it). A literal "\x" inside the conversation
|
|
282
|
+
// background (e.g. the parent agent quoting escape sequences) then
|
|
283
|
+
// fails server-side as "unexpected end of hex escape" → 400. Plain
|
|
284
|
+
// prefix keeps the review message a plain string everywhere.
|
|
285
|
+
// The whole content also passes through escapeLiteralEscapes (below)
|
|
286
|
+
// so literal "\x"/"\u" quoted by the parent agent can never form an
|
|
287
|
+
// invalid escape when the server expands them.
|
|
288
|
+
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}`),
|
|
289
|
+
},
|
|
290
|
+
]
|
|
168
291
|
}
|
|
169
|
-
|
|
292
|
+
|
|
293
|
+
// Convergence rounds (2+): fresh [system(ROUND2/3), user(prior table + fix
|
|
294
|
+
// claims)]. buildAdvisorFollowUp carries BOTH the prior issue table (the
|
|
295
|
+
// only complete verification list — decision 2026-08-05, reversed) and the
|
|
296
|
+
// agent's fix-claim table (focus reference). buildAdvisorSystemPrompt
|
|
297
|
+
// selects ROUND2 for round 2, ROUND3 for rounds 3+ — a failed review retry
|
|
298
|
+
// keeps _advisorRound so the convergence prompt matches the attempt count.
|
|
299
|
+
// scopeFiles gives the fallback (agent gave no response table) a concrete
|
|
300
|
+
// review surface.
|
|
301
|
+
const scopeFiles = resolveScopeFiles(agent, paths)
|
|
302
|
+
return [
|
|
170
303
|
{ role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
|
|
171
|
-
{ role: "user", content:
|
|
304
|
+
{ role: "user", content: escapeLiteralEscapes(buildAdvisorFollowUp(agent, prior, scopeFiles)) },
|
|
172
305
|
]
|
|
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
306
|
}
|
package/src/agent/completion.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { isDocFile } from "../advisor/repos.mjs"
|
|
8
8
|
import { pushReal } from "../context.mjs"
|
|
9
|
+
import { MAX_ADVISOR_ROUNDS } from "../advisor/run.mjs"
|
|
9
10
|
|
|
10
11
|
/** True when this run mutated at least one CODE file. Mirrors agent.mjs:hasCodeMutations. */
|
|
11
12
|
function hasCodeMutations(agent) {
|
|
@@ -117,12 +118,18 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
// --- advisor guard: review of mutated files before completion ---
|
|
120
|
-
//
|
|
121
|
+
// Active by default when advisor.enabled is set (opt-out via guard: false),
|
|
122
|
+
// and NEVER in engineering mode.
|
|
121
123
|
const cfg = agent.config?.advisor
|
|
122
124
|
const advisorReview = cfg?.enabled && cfg?.guard !== false
|
|
123
125
|
if (depth === 0 && advisorReview && !agent.config?.agent?.engineering) {
|
|
126
|
+
// Cap sync: beyond MAX_ADVISOR_ROUNDS the advisor tool refuses to review
|
|
127
|
+
// (run.mjs convergence cap) — pushing back further would loop forever
|
|
128
|
+
// (fix → pushback → cap-refused call → fix …). The cap message from the
|
|
129
|
+
// last accepted review stands; the user decides manually.
|
|
124
130
|
if (agent._mutatedThisRun && !agent._calledAdvisorThisRun && hasCodeMutations(agent)
|
|
125
|
-
&& advisorPushbacks < MAX_ADVISOR_PUSHBACKS
|
|
131
|
+
&& advisorPushbacks < MAX_ADVISOR_PUSHBACKS
|
|
132
|
+
&& (agent._advisorRound || 0) < MAX_ADVISOR_ROUNDS) {
|
|
126
133
|
advisorPushbacks++
|
|
127
134
|
pushReal(agent, { role: "assistant", content: response.content })
|
|
128
135
|
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
|
}
|
package/src/agent.mjs
CHANGED
|
@@ -404,18 +404,18 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
404
404
|
if (toolCall.name === "verify") agent._verifiedThisRun = true
|
|
405
405
|
if (toolCall.name === "advisor") {
|
|
406
406
|
agent._calledAdvisorThisRun = true
|
|
407
|
-
//
|
|
408
|
-
//
|
|
407
|
+
// All advisor calls (code and design) share the 5-round convergence
|
|
408
|
+
// budget — each advances _advisorRound toward MAX_ADVISOR_ROUNDS.
|
|
409
409
|
// Always advance the round — the convergence protocol cares about
|
|
410
410
|
// how many reviews have run (round 1→2→3→4→5), not how many succeeded.
|
|
411
411
|
// A failed/interrupted review is still a review attempt and should use
|
|
412
412
|
// the next round's prompt on retry.
|
|
413
413
|
try {
|
|
414
|
-
|
|
415
|
-
if (advArgs.type !== "design") agent._advisorRound++
|
|
414
|
+
JSON.parse(toolCall.arguments || "{}")
|
|
416
415
|
} catch {
|
|
417
|
-
|
|
416
|
+
/* arguments unparseable — still counts as a review attempt */
|
|
418
417
|
}
|
|
418
|
+
agent._advisorRound++
|
|
419
419
|
}
|
|
420
420
|
if (FILE_MUTATORS.has(toolCall.name)) {
|
|
421
421
|
const args = JSON.parse(toolCall.arguments)
|
|
@@ -17,15 +17,21 @@ Budget rules:
|
|
|
17
17
|
- **Batch everything**: multiple `read` calls in one reply, multiple `grep` calls in one reply. Serializing tool calls wastes your round budget.
|
|
18
18
|
|
|
19
19
|
Rules:
|
|
20
|
-
- First judge the task from the conversation background: if the changes are clearly non-code
|
|
20
|
+
- First judge the task from the conversation background: if the changes are clearly non-code and cannot affect runtime behavior, reply immediately with the all-clear phrase — `"All clear — no code changes to review."` (the host recognizes it via the "all clear" / "no 🔴" / "review passed" / "no issues found" markers, matched case-insensitively) — do NOT spend tool calls exploring. This applies to static docs, README, and CHANGELOG files. Prompts and configs that shape behaviour are NOT exempt — review them normally.
|
|
21
|
+
- **Requirement fit**: check the implementation against what the user actually asked for — a review is not only about "is the code correct" but also "is this what the user wanted". Two comparisons:
|
|
22
|
+
- (a) **Claim vs implementation**: the implementer's stated intent (conversation background / response table / commit message) vs what the implementation actually does — claiming X but delivering Y is a gap.
|
|
23
|
+
- (b) **Expectation vs shape**: explicit user expectations in the background vs the delivered shape — "asked for A, got B" (e.g. "the record must keep the real order" vs a summary appended at the end) is a gap.
|
|
24
|
+
- **Known limit**: the conversation background only includes the last 3 user–assistant exchanges — older user expectations may not be visible. (a) is the primary check (needs only recent context); (b) is best-effort — check what the background shows, do NOT treat an invisible expectation as a gap.
|
|
25
|
+
- **Severity**: 🔴 = the user's explicit request was not fulfilled; 🟡 = fulfilled but in a suboptimal or misleading way. Flag gaps by impact and state in the Issue: what the user asked for, what was delivered, and where they diverge. Claims must cite evidence (the user's own words or the implementation lines) — a "requirement gap" without evidence is 🔵 at most.
|
|
21
26
|
- Reply in the same language as the conversation background.
|
|
22
27
|
- Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
|
|
23
28
|
- Output a Markdown table. This table becomes the sole basis for convergence in later rounds — be thorough.
|
|
24
29
|
| # | File | Severity | Issue | Suggestion |
|
|
25
30
|
|---|------|----------|-------|------------|
|
|
26
|
-
| 1 | src/
|
|
31
|
+
| 1 | src/example.mjs | 🔴 | ... | ... |
|
|
27
32
|
- Order by severity: 🔴 Critical · 🟡 Advisory · 🔵 Style.
|
|
28
33
|
- For each issue state: which file, what the problem is, why it is a problem, how to fix it.
|
|
29
34
|
- Cover everything now. Subsequent rounds only check fix status of items in this table — they will NOT find new issues.
|
|
30
35
|
- Stop calling tools once you are ready to produce the review table.
|
|
36
|
+
- **Host verification**: every `file:line: content` reference in your table is mechanically checked against the CURRENT file state by the host — quote exactly what `read` returned; a mismatch marks the finding unverified.
|
|
31
37
|
- **Pass/fail**: if there are NO 🔴 (Critical) issues, the review passes. 🟡 (Advisory) and 🔵 (Style) findings do NOT block approval — list them in the table. If there is ANY 🔴 issue, list it and do not claim the review passed.
|