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/run.mjs
CHANGED
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* advisor/run.mjs — advisor execution: tool loop, provider resolution, and the review entry point.
|
|
3
|
-
* Message building lives in advisor.mjs
|
|
3
|
+
* Message building lives in advisor.mjs.
|
|
4
4
|
*/
|
|
5
5
|
import { chat } from "../provider/core.mjs"
|
|
6
|
-
import { findProvider } from "../config.mjs"
|
|
6
|
+
import { findProvider, specForModel } from "../config.mjs"
|
|
7
7
|
import { toOpenAISchema } from "../tools/index.mjs"
|
|
8
8
|
import { prepareAdvisorMessages } from "../advisor.mjs"
|
|
9
|
-
import {
|
|
9
|
+
import { appendCitationReport } from "./citations.mjs"
|
|
10
10
|
|
|
11
11
|
const MAX_ADVISOR_TURNS = 100
|
|
12
12
|
// Mechanical convergence cap: the protocol assumes up to 5 rounds suffice
|
|
13
13
|
// (full review, verify+fix cycles, strict verification). A 6th call means the
|
|
14
14
|
// model is looping — refuse it instead of burning tokens on a review that cannot
|
|
15
|
-
// converge.
|
|
16
|
-
//
|
|
17
|
-
// prompt-level efficiency target, distinct from this 100-round mechanical hard
|
|
18
|
-
// cap (loop guard). Keep both in sync when either changes.
|
|
15
|
+
// converge. Code AND design reviews share the 5-round budget (each advances
|
|
16
|
+
// _advisorRound in agent.mjs; the cap no longer exempts design).
|
|
19
17
|
export const MAX_ADVISOR_ROUNDS = 5
|
|
20
18
|
|
|
19
|
+
// NOTE: prompts/advisor-round{1,2,3}.md encourage the model to finish within
|
|
20
|
+
// ~30 tool turns — a prompt-level efficiency target, DISTINCT from the
|
|
21
|
+
// 100-turn mechanical hard cap (MAX_ADVISOR_TURNS above; pure runaway-loop
|
|
22
|
+
// guard). They serve different purposes; do NOT synchronize them.
|
|
23
|
+
|
|
24
|
+
// The live "[thinking…]" wait indicator shares its exact text with the TUI
|
|
25
|
+
// cleanup regex (agent-turn.mjs strips it before flushing to history) — keep
|
|
26
|
+
// them in lockstep.
|
|
27
|
+
export const ADVISOR_THINKING_PLACEHOLDER = "\n[thinking…]\n"
|
|
28
|
+
|
|
21
29
|
// Context window limits
|
|
22
|
-
const MAX_CONTEXT_TOKENS = 120_000 //
|
|
23
|
-
const TOOL_TIMEOUT_MS = 30_000 //
|
|
24
|
-
const REVIEW_TIMEOUT_MS = 300_000 //
|
|
30
|
+
const MAX_CONTEXT_TOKENS = 120_000 // Reserve headroom to avoid OOM
|
|
31
|
+
const TOOL_TIMEOUT_MS = 30_000 // single tool timeout
|
|
32
|
+
const REVIEW_TIMEOUT_MS = 300_000 // whole review timeout
|
|
33
|
+
const MAX_RESULT_CHARS = 12_000 // tool result truncation (line-aware)
|
|
34
|
+
const MAX_UNFIXED_DISPLAY = 10 // unfixed issues shown in the cap message
|
|
35
|
+
const MAX_KEY_FILES_IN_COMPACTION = 5 // files named in the compaction summary
|
|
25
36
|
|
|
26
37
|
/** Estimate token count from messages (rough: 1 token ≈ 4 chars) */
|
|
27
38
|
function estimateTokens(messages) {
|
|
@@ -32,61 +43,87 @@ function estimateTokens(messages) {
|
|
|
32
43
|
}, 0)
|
|
33
44
|
}
|
|
34
45
|
|
|
35
|
-
/** Compact early messages when context grows too large
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
/** Compact early messages when context grows too large — LOCAL trimming only
|
|
47
|
+
* (no LLM summarization). MUTATES in place (splice) so the caller's array
|
|
48
|
+
* reference stays valid — a reassignment would leave the caller's logging
|
|
49
|
+
* (tool-call count, token estimate) reading a stale array. */
|
|
50
|
+
function compactMessages(messages) {
|
|
51
|
+
// Keep: system prompt, last 20 messages (≈ 10 assistant+tool exchanges),
|
|
52
|
+
// user message — the rest is summarized.
|
|
53
|
+
if (messages.length <= 20) return
|
|
54
|
+
|
|
40
55
|
const system = messages[0]
|
|
41
56
|
const recent = messages.slice(-20)
|
|
42
57
|
const old = messages.slice(1, -20)
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}`
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
|
|
59
|
+
// Count actual tool messages (old.length counts user/assistant rows too)
|
|
60
|
+
const toolCount = old.filter((m) => m.role === "tool").length
|
|
61
|
+
const keyFiles = old
|
|
62
|
+
.filter((m) => m.role === "tool")
|
|
63
|
+
.map((m) => m.content?.split("\n")[0]?.slice(0, 50)) // first line of tool results typically names the file that was read/grepped
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.slice(0, MAX_KEY_FILES_IN_COMPACTION)
|
|
66
|
+
const filesPart = keyFiles.length > 0 ? ` Key files examined: ${keyFiles.join(", ")}` : ""
|
|
67
|
+
const summary = `Earlier exploration: ${toolCount} tool calls completed.${filesPart}`
|
|
68
|
+
|
|
69
|
+
messages.splice(0, messages.length,
|
|
70
|
+
system,
|
|
71
|
+
{ role: "user", content: `[Context compacted] ${summary}` },
|
|
72
|
+
...recent)
|
|
55
73
|
}
|
|
56
74
|
|
|
57
|
-
const {
|
|
75
|
+
const { readTool, globTool, grepTool, lsTool } = await import("../tools/index.mjs")
|
|
58
76
|
const { lspTool } = await import("../tools/lsp.mjs")
|
|
59
|
-
const {
|
|
77
|
+
const { codeSearchTool } = await import("../memory/code-sync.mjs")
|
|
60
78
|
|
|
61
|
-
/**
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Advisor tool set — ZERO git, read-only ONLY, every round. The change surface
|
|
81
|
+
* comes from the review scope (paths / _touchedFiles injected by the caller),
|
|
82
|
+
* never from git: git output misled reviews (committed fixes never show in
|
|
83
|
+
* `git diff HEAD`, so "no changes" was read as "not fixed") and the user
|
|
84
|
+
* mandate is full decoupling (7d49a52 + d3be613). The reviewer reads files
|
|
85
|
+
* and searches code; it never touches git and never writes.
|
|
86
|
+
* No round parameter — the set is constant across all rounds.
|
|
87
|
+
* @param {Object} agent — only used for the code index (agent.memory); the
|
|
88
|
+
* semantic code_search tool needs it. Without a memory, the set is 5 tools.
|
|
89
|
+
*/
|
|
90
|
+
function advisorToolsFor(agent) {
|
|
91
|
+
const search = agent?.memory ? codeSearchTool(agent.memory) : null
|
|
92
|
+
const tools = search
|
|
93
|
+
? [readTool, globTool, grepTool, lsTool, lspTool, search]
|
|
94
|
+
: [readTool, globTool, grepTool, lsTool, lspTool]
|
|
95
|
+
return { schemas: tools.map(toOpenAISchema), byName: new Map(tools.map((t) => [t.name, t])) }
|
|
74
96
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const ADVISOR_TOOL_SCHEMAS = ADVISOR_TOOLS.map(toOpenAISchema)
|
|
78
|
-
const ADVISOR_TOOL_BY_NAME = new Map(ADVISOR_TOOLS.map((t) => [t.name, t]))
|
|
97
|
+
// Test seam: the tool set is pure (agent.memory → code_search inclusion).
|
|
98
|
+
export { advisorToolsFor as _advisorToolsFor }
|
|
79
99
|
|
|
80
100
|
/** Compact one-line summary of tool args for panel progress lines.
|
|
81
101
|
* Picks the most identifying field; falls back to truncated JSON. */
|
|
82
102
|
function summarizeToolArgs(args) {
|
|
83
|
-
// e.g. "
|
|
103
|
+
// e.g. "read src/x.mjs", "grep foo src/", "ls docs" — action first when present
|
|
84
104
|
const parts = [args.action, args.path ?? args.pattern ?? args.command].filter((v) => v != null)
|
|
85
105
|
let s = parts.length > 0 ? parts.map(String).join(" ") : JSON.stringify(args)
|
|
86
106
|
s = s.replace(/\s+/g, " ").trim()
|
|
87
107
|
return s.length > 80 ? s.slice(0, 79) + "…" : s
|
|
88
108
|
}
|
|
89
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Render the ordered review timeline — thinking / tool progress / final text
|
|
112
|
+
* interleaved EXACTLY as emitted, so the persisted record shows the review
|
|
113
|
+
* process at its real positions. A summary appended at the end would lose the
|
|
114
|
+
* order (the user-visible "no tool calls in the advisor record" gap). The
|
|
115
|
+
* live "[thinking…]" placeholder is stripped (wait indicator, not content).
|
|
116
|
+
*/
|
|
117
|
+
function renderTimeline(timeline, tail = "") {
|
|
118
|
+
const body = timeline
|
|
119
|
+
.map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, "").trim())
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join("\n\n")
|
|
122
|
+
return [body, tail].filter(Boolean).join("\n\n")
|
|
123
|
+
}
|
|
124
|
+
// Test seam (mirrors _advisorToolsFor).
|
|
125
|
+
export { renderTimeline as _renderTimeline }
|
|
126
|
+
|
|
90
127
|
/**
|
|
91
128
|
* Run the advisor's tool loop: chat → execute tools → repeat.
|
|
92
129
|
* Stops when the model produces text without tool calls.
|
|
@@ -97,50 +134,85 @@ function summarizeToolArgs(args) {
|
|
|
97
134
|
*/
|
|
98
135
|
async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd) {
|
|
99
136
|
// Kind-tagged wrappers: the TUI panel colors reasoning / answer / tool progress differently.
|
|
100
|
-
|
|
137
|
+
// Every chunk is ALSO recorded into an ordered timeline — the persisted record
|
|
138
|
+
// must show the review process (thinking ↔ tool progress ↔ final text) at its
|
|
139
|
+
// real positions, not a summary appended at the end. Same-kind consecutive
|
|
140
|
+
// chunks merge (token streams); kind flips start a new entry.
|
|
141
|
+
const timeline = []
|
|
142
|
+
const record = (kind, text) => {
|
|
143
|
+
const last = timeline.at(-1)
|
|
144
|
+
if (last && last.kind === kind) last.text += text
|
|
145
|
+
else timeline.push({ kind, text })
|
|
146
|
+
}
|
|
147
|
+
const emit = (kind) => (text) => { record(kind, text); onOutput?.({ kind, text }) }
|
|
101
148
|
const onThink = emit("think")
|
|
102
149
|
const onText = emit("text")
|
|
150
|
+
const onTool = emit("tool")
|
|
151
|
+
const { schemas: toolSchemas, byName: toolByName } = advisorToolsFor(agent)
|
|
103
152
|
let turns = 0
|
|
104
153
|
const startTime = Date.now()
|
|
105
154
|
|
|
106
155
|
while (true) {
|
|
107
156
|
// Interrupted (Ctrl+I) — stop immediately instead of spinning a fresh uncancellable signal
|
|
108
|
-
if (signal?.aborted) return "Advisor: interrupted."
|
|
157
|
+
if (signal?.aborted) return renderTimeline(timeline, "Advisor: interrupted.")
|
|
109
158
|
|
|
110
159
|
// Check review timeout (5 minutes)
|
|
111
160
|
if (Date.now() - startTime > REVIEW_TIMEOUT_MS) {
|
|
112
|
-
return `Advisor: review timeout after ${Math.round(REVIEW_TIMEOUT_MS / 1000)}s. Partial results may be available. Try again with a narrower scope.`
|
|
161
|
+
return renderTimeline(timeline, `Advisor: review timeout after ${Math.round(REVIEW_TIMEOUT_MS / 1000)}s. Partial results may be available. Try again with a narrower scope.`)
|
|
113
162
|
}
|
|
114
163
|
|
|
115
164
|
if (++turns > MAX_ADVISOR_TURNS) {
|
|
116
|
-
return "Advisor: stopped after " + MAX_ADVISOR_TURNS + " tool rounds — the review appears to be looping. You may retry with a narrower scope."
|
|
165
|
+
return renderTimeline(timeline, "Advisor: stopped after " + MAX_ADVISOR_TURNS + " tool rounds — the review appears to be looping. You may retry with a narrower scope.")
|
|
117
166
|
}
|
|
118
167
|
|
|
119
168
|
// Check context window and compact if needed
|
|
120
169
|
const currentTokens = estimateTokens(messages)
|
|
121
170
|
if (currentTokens > MAX_CONTEXT_TOKENS * 0.8) {
|
|
122
|
-
|
|
123
|
-
|
|
171
|
+
onText(`\n[Context compacted: ${currentTokens} tokens → reducing to fit window]\n`)
|
|
172
|
+
compactMessages(messages)
|
|
124
173
|
if (estimateTokens(messages) > MAX_CONTEXT_TOKENS) {
|
|
125
|
-
|
|
174
|
+
// Report the POST-compaction count — the pre-compaction currentTokens
|
|
175
|
+
// is stale by the time compaction has run.
|
|
176
|
+
return renderTimeline(timeline, `Advisor: context window limit reached (${estimateTokens(messages)} tokens). Review incomplete — too many tool calls. Try a narrower scope.`)
|
|
126
177
|
}
|
|
127
178
|
}
|
|
128
179
|
|
|
180
|
+
// LLM generation silence: the reasoning phase produces no SSE bytes for
|
|
181
|
+
// seconds to tens of seconds (server-side prefill on large contexts, per
|
|
182
|
+
// tool-round LLM return). A placeholder keeps the panel visibly working.
|
|
183
|
+
// kind "think" (NOT "text"): the placeholder must land in the SAME buffer
|
|
184
|
+
// and position as the upcoming reasoning — a "text"-kind placeholder
|
|
185
|
+
// rendered BELOW the think block, and the reasoning stream appeared ABOVE
|
|
186
|
+
// it ("the stream runs back to the front"). Same buffer = same spot; the
|
|
187
|
+
// reasoning continues right where the placeholder sits.
|
|
188
|
+
onOutput?.({ kind: "think", text: ADVISOR_THINKING_PLACEHOLDER })
|
|
189
|
+
|
|
129
190
|
const response = await chat(provider, {
|
|
130
191
|
messages,
|
|
131
|
-
tools:
|
|
132
|
-
signal:
|
|
192
|
+
tools: toolSchemas,
|
|
193
|
+
// Pass the signal UNCONDITIONALLY: a signal aborted between the check
|
|
194
|
+
// above and here must still cancel the fetch. core.mjs composes
|
|
195
|
+
// AbortSignal.any([signal, timeout]) — an already-aborted signal makes
|
|
196
|
+
// the request fail immediately instead of ignoring the interrupt.
|
|
197
|
+
signal: signal ?? null,
|
|
133
198
|
onToken: onText,
|
|
134
199
|
onReasoning: onThink,
|
|
135
200
|
})
|
|
136
201
|
|
|
137
|
-
// No tool calls — this is the final review text
|
|
202
|
+
// No tool calls — this is the final review text. The final answer was
|
|
203
|
+
// already streamed into the timeline via onText; fall back to
|
|
204
|
+
// response.content only if nothing was recorded.
|
|
138
205
|
if (!response.toolCalls?.length) {
|
|
139
|
-
if (!response.content?.trim()) return "Advisor: (empty response — review was inconclusive)"
|
|
140
|
-
return response.content.trim()
|
|
206
|
+
if (!response.content?.trim()) return renderTimeline(timeline) || "Advisor: (empty response — review was inconclusive)"
|
|
207
|
+
return renderTimeline(timeline) || response.content.trim()
|
|
141
208
|
}
|
|
142
209
|
|
|
143
|
-
// Push assistant message with tool calls
|
|
210
|
+
// Push assistant message with tool calls. reasoning_content ECHO is
|
|
211
|
+
// mandatory for reasoningEcho:"required" providers (deepseek/kimi): the
|
|
212
|
+
// server stops returning reasoning_content on later rounds when the
|
|
213
|
+
// tool-call assistant history lacks it — the observed "reasoning stops
|
|
214
|
+
// after the first tool call, returns only at the final answer" symptom.
|
|
215
|
+
// Mirrors the main agent's push (agent.mjs).
|
|
144
216
|
messages.push({
|
|
145
217
|
role: "assistant",
|
|
146
218
|
content: response.content || null,
|
|
@@ -148,11 +220,14 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
148
220
|
id: tc.id, type: "function",
|
|
149
221
|
function: { name: tc.name, arguments: tc.arguments },
|
|
150
222
|
})),
|
|
223
|
+
...(response.reasoning && specForModel(provider.model).reasoningEcho === "required"
|
|
224
|
+
? { reasoning_content: response.reasoning }
|
|
225
|
+
: {}),
|
|
151
226
|
})
|
|
152
227
|
|
|
153
228
|
// Execute each tool call
|
|
154
229
|
for (const tc of response.toolCalls) {
|
|
155
|
-
const tool =
|
|
230
|
+
const tool = toolByName.get(tc.name)
|
|
156
231
|
let args = {}
|
|
157
232
|
let parseError = null
|
|
158
233
|
try {
|
|
@@ -167,10 +242,10 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
167
242
|
continue
|
|
168
243
|
}
|
|
169
244
|
|
|
170
|
-
|
|
245
|
+
onTool(`\n→ ${tc.name} ${summarizeToolArgs(args)}\n`)
|
|
171
246
|
let result
|
|
172
247
|
if (!tool) {
|
|
173
|
-
result = `Error: unknown tool "${tc.name}". Available: ${[...
|
|
248
|
+
result = `Error: unknown tool "${tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
|
|
174
249
|
} else {
|
|
175
250
|
// Execute with timeout (clear the timer when the tool wins the race —
|
|
176
251
|
// otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
|
|
@@ -179,13 +254,17 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
179
254
|
const timeoutPromise = new Promise((_, reject) => {
|
|
180
255
|
timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
|
|
181
256
|
})
|
|
257
|
+
let toolPromise
|
|
182
258
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
timeoutPromise,
|
|
186
|
-
])
|
|
259
|
+
toolPromise = tool.execute(args, { cwd, agent, onOutput, signal })
|
|
260
|
+
result = await Promise.race([toolPromise, timeoutPromise])
|
|
187
261
|
} finally {
|
|
188
262
|
clearTimeout(timeoutId)
|
|
263
|
+
// Timeout won → toolPromise is still pending; a later rejection
|
|
264
|
+
// would surface as an unhandled rejection. The race already
|
|
265
|
+
// consumed the result/error in the normal path, so this no-op
|
|
266
|
+
// catch only fires for the abandoned-tool case.
|
|
267
|
+
toolPromise?.catch(() => {})
|
|
189
268
|
}
|
|
190
269
|
} catch (e) {
|
|
191
270
|
const errorType = e.message.includes("timeout") ? "timeout"
|
|
@@ -198,7 +277,6 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
198
277
|
if (typeof result !== "string") result = JSON.stringify(result)
|
|
199
278
|
|
|
200
279
|
// Line-aware truncation: preserve line integrity
|
|
201
|
-
const MAX_RESULT_CHARS = 12_000
|
|
202
280
|
if (result.length > MAX_RESULT_CHARS) {
|
|
203
281
|
const lines = result.split("\n")
|
|
204
282
|
let truncated = ""
|
|
@@ -217,7 +295,7 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
217
295
|
result = (
|
|
218
296
|
truncated +
|
|
219
297
|
`\n… (truncated: ${remainingLines} more lines, ${result.length} chars total)\n` +
|
|
220
|
-
`To see more content, use: read(path, offset=${keptLines}, limit=200)`
|
|
298
|
+
`To see more content, use: read(path, offset=${keptLines + 1}, limit=200)`
|
|
221
299
|
)
|
|
222
300
|
}
|
|
223
301
|
|
|
@@ -227,13 +305,13 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
227
305
|
}
|
|
228
306
|
|
|
229
307
|
/** Resolve the advisor's provider: cfg.provider/model when set, otherwise the main agent's provider */
|
|
230
|
-
function resolveAdvisorProvider(agent) {
|
|
308
|
+
export function resolveAdvisorProvider(agent) {
|
|
231
309
|
const cfg = agent.config?.advisor
|
|
232
310
|
if (cfg?.provider) {
|
|
233
311
|
try {
|
|
234
312
|
const provider = findProvider(agent.providers ?? [agent.provider], cfg.provider)
|
|
235
313
|
const result = cfg.model ? { ...provider, model: cfg.model } : { ...provider }
|
|
236
|
-
if (cfg.thinking === null) result.thinking = undefined // explicitly off
|
|
314
|
+
if (cfg.thinking === null || cfg.thinking === false) result.thinking = undefined // explicitly off
|
|
237
315
|
else if (cfg.thinking !== undefined) result.thinking = cfg.thinking
|
|
238
316
|
if (cfg.reasoningEffort !== undefined) result.reasoningEffort = cfg.reasoningEffort
|
|
239
317
|
return result
|
|
@@ -244,24 +322,35 @@ function resolveAdvisorProvider(agent) {
|
|
|
244
322
|
}
|
|
245
323
|
const provider = { ...agent.provider }
|
|
246
324
|
if (cfg?.model) provider.model = cfg.model
|
|
247
|
-
|
|
325
|
+
// thinking off: null AND false both mean "explicitly off" — a raw `false`
|
|
326
|
+
// value is invalid for providers that expect undefined or an object.
|
|
327
|
+
if (cfg?.thinking === null || cfg?.thinking === false) provider.thinking = undefined
|
|
248
328
|
else if (cfg?.thinking !== undefined) provider.thinking = cfg.thinking
|
|
249
329
|
if (cfg?.reasoningEffort !== undefined) provider.reasoningEffort = cfg.reasoningEffort
|
|
250
330
|
return provider
|
|
251
331
|
}
|
|
252
332
|
|
|
253
333
|
/**
|
|
254
|
-
* Extract unfixed issues from prior review text
|
|
334
|
+
* Extract unfixed issues from prior review text (for the cap message).
|
|
335
|
+
* Input: an advisor review markdown table (`| # | … |` rows). A row counts as
|
|
336
|
+
* unfixed unless its line carries a resolved-status word (fixed/resolved/done/
|
|
337
|
+
* addressed/corrected, ✓/✔). Returns at most MAX_UNFIXED_DISPLAY plain
|
|
338
|
+
* (pipe-stripped) row strings.
|
|
255
339
|
*/
|
|
256
340
|
function extractUnfixedIssues(priorText) {
|
|
257
341
|
if (!priorText) return []
|
|
258
342
|
const lines = priorText.split("\n")
|
|
343
|
+
// Resolved-status words: fixed/resolved/done/addressed/corrected (+ ✓/✔).
|
|
344
|
+
// \b prevents "unfixed"/"prefixed" from matching "fixed".
|
|
345
|
+
const resolvedRe = /\b(?:fixed|resolved|done|addressed|corrected)\b|✓|✔/i
|
|
259
346
|
return lines
|
|
260
347
|
.filter((line) => /\|\s*\d+\s*\|/.test(line)) // 匹配表格行
|
|
261
|
-
.filter((line) =>
|
|
262
|
-
|
|
348
|
+
.filter((line) => !resolvedRe.test(line))
|
|
349
|
+
// Strip only the leading/trailing table pipes — inner pipes (escaped or
|
|
350
|
+
// in-cell content) stay intact instead of garbling the cap message.
|
|
351
|
+
.map((line) => line.trim().replace(/^\|/, "").replace(/\|$/, "").trim())
|
|
263
352
|
.filter(Boolean)
|
|
264
|
-
.slice(0,
|
|
353
|
+
.slice(0, MAX_UNFIXED_DISPLAY)
|
|
265
354
|
}
|
|
266
355
|
|
|
267
356
|
/**
|
|
@@ -282,11 +371,14 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
|
|
|
282
371
|
|
|
283
372
|
// Mechanical convergence cap — refuse further reviews once the protocol has run
|
|
284
373
|
// its rounds. _advisorRound counts completed advisor calls (incremented by the
|
|
285
|
-
// agent after each one), so >= MAX_ADVISOR_ROUNDS
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
374
|
+
// agent after each one — code AND design reviews alike), so >= MAX_ADVISOR_ROUNDS
|
|
375
|
+
// blocks the next call. 5 rounds max; after that the review is never pushed back
|
|
376
|
+
// (the caller decides: accept, manual re-check, or /new to reset).
|
|
377
|
+
if ((agent._advisorRound || 0) >= MAX_ADVISOR_ROUNDS) {
|
|
378
|
+
// Summarize unresolved items from the last review output for guidance
|
|
379
|
+
// (line-level status-word scan — no table-header parsing, decision 2026-08-08).
|
|
380
|
+
const prior = agent._lastAdvisorOutput
|
|
381
|
+
const unfixed = prior ? extractUnfixedIssues(prior) : []
|
|
290
382
|
|
|
291
383
|
let message = `Advisor: convergence cap reached after ${MAX_ADVISOR_ROUNDS} rounds.\n`
|
|
292
384
|
if (unfixed.length > 0) {
|
|
@@ -307,7 +399,30 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
|
|
|
307
399
|
|
|
308
400
|
try {
|
|
309
401
|
const result = await runAdvisorToolLoop(provider, messages, onOutput, signal, agent, advisorCwd)
|
|
310
|
-
|
|
402
|
+
|
|
403
|
+
// Host-verified citations (decision d698434): mechanically check every
|
|
404
|
+
// `file:line: content` reference in the review against the CURRENT file
|
|
405
|
+
// state. LLMs cannot self-enforce the evidence rule — the model may quote
|
|
406
|
+
// the prior table instead of re-reading (three consecutive false reports
|
|
407
|
+
// cited pre-fix line content). Unverified citations must not support a
|
|
408
|
+
// push-back; the parent agent sees the verification report.
|
|
409
|
+
let final = result
|
|
410
|
+
if (!result.trimStart().startsWith("Advisor:")) {
|
|
411
|
+
final = appendCitationReport(result, advisorCwd)
|
|
412
|
+
// Success path: keep the FULL review output for convergence rounds —
|
|
413
|
+
// round 2+ injects this verbatim and the model understands it (decision
|
|
414
|
+
// 2026-08-08: prior-table hard parsing removed; no phrase/header matching).
|
|
415
|
+
// Guard: only store outputs that actually carry a review — a markdown
|
|
416
|
+
// table row (`| a | b | c |`) or substantial prose (>200 chars). An
|
|
417
|
+
// empty or tool-progress-only reply must not become the "prior review"
|
|
418
|
+
// of round 2+.
|
|
419
|
+
const trimmed = final.trim()
|
|
420
|
+
const looksLikeReview = /\|.*\|.*\|/.test(trimmed) || trimmed.length >= 200
|
|
421
|
+
if (looksLikeReview) {
|
|
422
|
+
agent._lastAdvisorOutput = final
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
311
426
|
// Log review statistics for observability
|
|
312
427
|
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
|
313
428
|
const toolCallCount = messages.filter((m) => m.role === "tool").length
|
|
@@ -316,18 +431,7 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
|
|
|
316
431
|
kind: "text",
|
|
317
432
|
text: `\n[advisor] Review completed: ${elapsed}s, ${toolCallCount} tool calls, ~${Math.round(tokensUsed / 1000)}k tokens\n`,
|
|
318
433
|
})
|
|
319
|
-
|
|
320
|
-
// Only persist the session on success — timeout/interrupt/empty results
|
|
321
|
-
// would poison the next review call (the conversation is truncated mid-review,
|
|
322
|
-
// and the model picks up from a broken state, burning more rounds).
|
|
323
|
-
if (!result.trimStart().startsWith("Advisor:")) {
|
|
324
|
-
// Assign only for fresh sessions; re-assignment of the same reference on
|
|
325
|
-
// continued sessions is a no-op but communicating intent matters.
|
|
326
|
-
agent._advisorSession = reviewType === "design" ? null : messages
|
|
327
|
-
} else {
|
|
328
|
-
agent._advisorSession = null
|
|
329
|
-
}
|
|
330
|
-
return result
|
|
434
|
+
return final
|
|
331
435
|
} catch (e) {
|
|
332
436
|
if (e.name === "AbortError" && signal?.reason?.interrupt) throw e
|
|
333
437
|
|
|
@@ -346,7 +450,9 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
|
|
|
346
450
|
? "Reduce the scope (fewer files/paths) or use a model with larger context window."
|
|
347
451
|
: "You may retry or proceed to verify manually."
|
|
348
452
|
|
|
349
|
-
agent._advisorSession = null // failed review: don't keep a half-built conversation
|
|
350
453
|
return `Advisor: review failed (${errorType}) — ${e.message || "unknown error"}. ${retryAdvice}`
|
|
351
454
|
}
|
|
352
455
|
}
|
|
456
|
+
// Host-verified citations — moved to citations.mjs (kept re-exported here for
|
|
457
|
+
// import compatibility: tests and callers import from run.mjs).
|
|
458
|
+
export { extractCitations, verifyCitations, appendCitationReport } from "./citations.mjs"
|