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