thincoder 0.8.11 → 0.8.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 +27 -0
- package/bin/thincoder.mjs +115 -0
- package/package.json +1 -1
- package/src/advisor.mjs +105 -0
- package/src/agent/dispatch.mjs +35 -0
- package/src/agent/setup.mjs +9 -10
- package/src/agent-tools/subagent.mjs +1 -1
- package/src/agent-tools/timer.mjs +41 -0
- package/src/agent-tools/verify.mjs +165 -56
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +128 -21
- package/src/auto-think.mjs +83 -0
- package/src/cli/make-agent.mjs +9 -0
- package/src/config.mjs +18 -18
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/git/checkpoint.mjs +2 -1
- package/src/git/gitmem.mjs +8 -2
- package/src/markdown.mjs +1 -1
- package/src/mcp/transport-http.mjs +11 -4
- package/src/memory/code-index.mjs +2 -2
- package/src/memory/code-sync.mjs +92 -35
- package/src/memory/core.mjs +10 -1
- package/src/memory/docs.mjs +25 -28
- package/src/memory/schema.mjs +16 -3
- package/src/prompts/coder.md +7 -4
- package/src/prompts/discipline.md +47 -15
- package/src/prompts/main.md +15 -11
- package/src/prompts/system.md +33 -7
- package/src/provider/core.mjs +142 -15
- package/src/provider/index.mjs +1 -1
- package/src/rules.mjs +53 -0
- package/src/session.mjs +9 -3
- package/src/tools/file.mjs +114 -5
- package/src/tools/hashline_edit.md +12 -0
- package/src/tools/index.mjs +6 -4
- package/src/tools/linter.md +13 -0
- package/src/tools/linter.mjs +146 -0
- package/src/tools/patch.mjs +7 -3
- package/src/tools/read.md +3 -2
- package/src/tools/repomap.mjs +19 -10
- package/src/tools/shared.mjs +7 -0
- package/src/tools/system.mjs +18 -4
- package/src/tui/agent-turn.mjs +17 -2
- package/src/tui/ansi.mjs +5 -0
- package/src/tui/cmd-advisor.mjs +68 -0
- package/src/tui/cmd-think.mjs +36 -10
- package/src/tui/index.mjs +167 -54
- package/src/tui/key-handler.mjs +36 -1
- package/src/tui/layout.mjs +6 -4
- package/src/tui/pickers.mjs +15 -15
- package/src/tui/render-frame.mjs +240 -167
- package/src/tui/slash-commands.mjs +3 -0
- package/src/tools/repomap-parse.mjs +0 -168
|
@@ -1,25 +1,59 @@
|
|
|
1
1
|
import { repairHistory, listWorkDir } from "../agent.mjs"
|
|
2
|
-
import { execSync, spawn } from "node:child_process"
|
|
2
|
+
import { execSync, spawn, spawnSync } from "node:child_process"
|
|
3
3
|
import { readFileSync, existsSync } from "node:fs"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Source module → test file mapping. Heuristic: the FIRST path component
|
|
8
|
+
* after src/ determines the module. Map it to the test file that imports from it.
|
|
9
|
+
* Modules without dedicated tests map to null.
|
|
10
|
+
*/
|
|
11
|
+
const MODULE_TO_TEST = {
|
|
12
|
+
tools: "test/tools.test.mjs",
|
|
13
|
+
"agent-tools": "test/tools.test.mjs",
|
|
14
|
+
agent: "test/agent.test.mjs",
|
|
15
|
+
memory: "test/memory.test.mjs",
|
|
16
|
+
tui: "test/tui.test.mjs",
|
|
17
|
+
provider: "test/integration-provider.mjs",
|
|
18
|
+
config: "test/integration-provider.mjs",
|
|
19
|
+
skills: "test/tools.test.mjs",
|
|
20
|
+
distill: "test/tools.test.mjs",
|
|
21
|
+
markdown: "test/agent.test.mjs",
|
|
22
|
+
mcp: null,
|
|
23
|
+
prompts: null,
|
|
24
|
+
context: null,
|
|
25
|
+
session: null,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract module name from a source path.
|
|
30
|
+
* "src/tools/bash.mjs" → "tools", "src/agent.mjs" → "agent", "src/agent/helpers.mjs" → "agent"
|
|
31
|
+
*/
|
|
32
|
+
function moduleName(srcPath) {
|
|
33
|
+
const rel = srcPath.replace(/^src[/\\]/, "")
|
|
34
|
+
const firstSlash = rel.search(/[/\\]/)
|
|
35
|
+
if (firstSlash === -1) return rel.replace(/\.mjs$/, "")
|
|
36
|
+
return rel.slice(0, firstSlash)
|
|
37
|
+
}
|
|
38
|
+
|
|
6
39
|
/**
|
|
7
40
|
* verify tool: pre-completion self-check. When called:
|
|
8
41
|
* 1. git diff --stat — changed file list
|
|
9
42
|
* 2. node --check — syntax check all changed .mjs/.js files
|
|
10
|
-
* 3.
|
|
11
|
-
* 4.
|
|
12
|
-
*
|
|
43
|
+
* 3. Related tests — run test files that cover the changed modules (default)
|
|
44
|
+
* 4. npm test — run ALL project tests (only when full=true)
|
|
45
|
+
* 5. task list + self-review checklist
|
|
46
|
+
* Default runs syntax checks + related tests; full=true runs the entire test suite.
|
|
13
47
|
* Agent must not say "done" before verify passes. Fix-verify loop at most MAX_VERIFY_RETRIES rounds.
|
|
14
48
|
*/
|
|
15
49
|
export const verifyTool = {
|
|
16
50
|
name: "verify",
|
|
17
51
|
description:
|
|
18
|
-
"Run a pre-completion self-check. By default runs syntax checks on changed files, shows git diff and task list, and displays a self-review checklist. Set full=true to
|
|
52
|
+
"Run a pre-completion self-check. By default runs syntax checks on changed files AND any test files related to the changed modules, shows git diff and task list, and displays a self-review checklist. Set full=true to run the project's full test suite (npm test) instead of just related tests. Call this BEFORE declaring any coding task complete — do not say 'done' until verify passes.",
|
|
19
53
|
parameters: {
|
|
20
54
|
type: "object",
|
|
21
55
|
properties: {
|
|
22
|
-
full: { type: "boolean", description: "
|
|
56
|
+
full: { type: "boolean", description: "Run the full test suite (npm test) instead of just related tests. Default false — use sparingly, per the testing discipline rules." },
|
|
23
57
|
},
|
|
24
58
|
},
|
|
25
59
|
readonly: true,
|
|
@@ -57,11 +91,13 @@ export const verifyTool = {
|
|
|
57
91
|
const abs = join(cwd, f)
|
|
58
92
|
if (!existsSync(abs)) continue // skip deleted files
|
|
59
93
|
try {
|
|
60
|
-
|
|
94
|
+
const result = spawnSync("node", ["--check", abs], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 })
|
|
95
|
+
if (result.status !== 0) throw result
|
|
61
96
|
lines.push(` ✓ ${f}`)
|
|
62
97
|
} catch (e) {
|
|
63
98
|
syntaxFailed = true
|
|
64
|
-
const
|
|
99
|
+
const errOutput = (e.stderr || e.stdout || e.message || "").toString()
|
|
100
|
+
const errMsg = errOutput.split("\n").slice(0, 3).join("\n")
|
|
65
101
|
lines.push(` ✗ ${f} — syntax error`)
|
|
66
102
|
lines.push(` ${errMsg.replace(/\n/g, "\n ")}`)
|
|
67
103
|
}
|
|
@@ -69,53 +105,91 @@ export const verifyTool = {
|
|
|
69
105
|
if (!syntaxFailed) lines.push(" All syntax checks passed.")
|
|
70
106
|
}
|
|
71
107
|
|
|
72
|
-
// 3.
|
|
108
|
+
// 3. Identify related test files for changed source modules
|
|
109
|
+
const srcFiles = changedFiles.filter((f) => /^src[/\\].+\.mjs$/i.test(f) && existsSync(join(cwd, f)))
|
|
110
|
+
const modules = [...new Set(srcFiles.map(moduleName))]
|
|
111
|
+
const relatedTests = [...new Set(modules.map((m) => MODULE_TO_TEST[m]).filter(Boolean))]
|
|
112
|
+
|
|
113
|
+
// 4. Run tests
|
|
114
|
+
const pkgPath = join(cwd, "package.json")
|
|
115
|
+
const hasTestScript = existsSync(pkgPath) && (() => { try { return !!JSON.parse(readFileSync(pkgPath, "utf8")).scripts?.test } catch { return false } })()
|
|
116
|
+
|
|
73
117
|
if (args.full) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
118
|
+
// Full mode: run the entire test suite
|
|
119
|
+
if (hasTestScript) {
|
|
120
|
+
lines.push("")
|
|
121
|
+
lines.push("Tests (full suite):")
|
|
122
|
+
const result = await runTestSuite(cwd, ctx)
|
|
123
|
+
if (result.passed) {
|
|
124
|
+
lines.push("✓ All tests passed.")
|
|
125
|
+
ctx.agent._verifyPassed = !syntaxFailed
|
|
126
|
+
} else {
|
|
127
|
+
lines.push("✗ Tests FAILED. Review the output above, fix the issues, then run verify again.")
|
|
128
|
+
ctx.agent._verifyPassed = false
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
lines.push("")
|
|
132
|
+
lines.push("Tests: no test script in package.json — skipped.")
|
|
133
|
+
ctx.agent._verifyPassed = !syntaxFailed
|
|
134
|
+
}
|
|
135
|
+
} else if (relatedTests.length > 0) {
|
|
136
|
+
// Default mode: run only related test files
|
|
137
|
+
lines.push("")
|
|
138
|
+
lines.push(`Related tests (${relatedTests.length} file(s) for modules: ${modules.join(", ")}):`)
|
|
139
|
+
let anyTestFailed = false
|
|
140
|
+
for (const testFile of relatedTests) {
|
|
141
|
+
const abs = join(cwd, testFile)
|
|
142
|
+
if (!existsSync(abs)) {
|
|
143
|
+
lines.push(` ? ${testFile} — file not found, skipping`)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const result = await runTestFile(cwd, testFile, ctx)
|
|
148
|
+
if (result.passed) {
|
|
149
|
+
lines.push(` ✓ ${testFile}`)
|
|
97
150
|
} else {
|
|
98
|
-
|
|
99
|
-
lines.push(
|
|
100
|
-
|
|
151
|
+
anyTestFailed = true
|
|
152
|
+
lines.push(` ✗ ${testFile} — FAILED`)
|
|
153
|
+
lines.push(` ${result.tail.replace(/\n/g, "\n ")}`)
|
|
101
154
|
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
anyTestFailed = true
|
|
157
|
+
lines.push(` ✗ ${testFile} — error: ${e.message}`)
|
|
102
158
|
}
|
|
103
|
-
}
|
|
104
|
-
|
|
159
|
+
}
|
|
160
|
+
if (anyTestFailed) {
|
|
161
|
+
lines.push("")
|
|
162
|
+
lines.push("✗ Related tests FAILED. Review the output above, fix the issues, then run verify again.")
|
|
163
|
+
ctx.agent._verifyPassed = false
|
|
164
|
+
} else {
|
|
165
|
+
lines.push(" All related tests passed.")
|
|
166
|
+
ctx.agent._verifyPassed = !syntaxFailed
|
|
105
167
|
}
|
|
106
168
|
} else {
|
|
107
|
-
//
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
169
|
+
// No related tests found for the changed modules
|
|
170
|
+
const uncovered = modules.filter((m) => MODULE_TO_TEST[m] === null)
|
|
171
|
+
const untested = modules.filter((m) => !(m in MODULE_TO_TEST))
|
|
172
|
+
lines.push("")
|
|
173
|
+
if (uncovered.length > 0) {
|
|
174
|
+
lines.push(`Related tests: NONE for module(s) ${uncovered.join(", ")} — these modules have no dedicated test file.`)
|
|
175
|
+
lines.push("ACTION REQUIRED: write a test that covers the change you just made.")
|
|
176
|
+
lines.push("Do NOT proceed to 'done' — a test file is required before this change is complete.")
|
|
177
|
+
} else if (untested.length > 0) {
|
|
178
|
+
lines.push(`Related tests: NONE for module(s) ${untested.join(", ")} — unknown module, no test mapping exists.`)
|
|
179
|
+
lines.push("ACTION REQUIRED: determine which test file covers this code and add it to MODULE_TO_TEST, or write a new test.")
|
|
180
|
+
} else if (srcFiles.length === 0) {
|
|
181
|
+
lines.push("Related tests: no source .mjs files changed — nothing to test.")
|
|
182
|
+
ctx.agent._verifyPassed = !syntaxFailed
|
|
183
|
+
} else {
|
|
184
|
+
lines.push("Related tests: none matched. Run verify with full=true to run the full suite.")
|
|
185
|
+
ctx.agent._verifyPassed = !syntaxFailed
|
|
117
186
|
}
|
|
118
|
-
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Show full-suite hint when not run
|
|
190
|
+
if (!args.full && hasTestScript) {
|
|
191
|
+
lines.push("")
|
|
192
|
+
lines.push("Note: full test suite not run. Use verify full=true to run ALL tests before committing.")
|
|
119
193
|
}
|
|
120
194
|
|
|
121
195
|
// 4. Task list
|
|
@@ -151,10 +225,49 @@ export const verifyTool = {
|
|
|
151
225
|
},
|
|
152
226
|
}
|
|
153
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Run a single test file with node --test, no maxBuffer limit.
|
|
230
|
+
* Returns { passed: boolean, tail: string } — the last few lines of output.
|
|
231
|
+
*/
|
|
232
|
+
function runTestFile(cwd, testPath, ctx) {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const child = spawn("node", ["--test", testPath], {
|
|
235
|
+
cwd, stdio: ["ignore", "pipe", "pipe"],
|
|
236
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
237
|
+
})
|
|
238
|
+
let stdout = ""
|
|
239
|
+
let stderr = ""
|
|
240
|
+
child.stdout.on("data", (d) => {
|
|
241
|
+
const s = d.toString()
|
|
242
|
+
stdout += s
|
|
243
|
+
ctx.callbacks?.onToolOutput?.("verify", s)
|
|
244
|
+
})
|
|
245
|
+
child.stderr.on("data", (d) => {
|
|
246
|
+
const s = d.toString()
|
|
247
|
+
stderr += s
|
|
248
|
+
ctx.callbacks?.onToolOutput?.("verify", s)
|
|
249
|
+
})
|
|
250
|
+
const timer = setTimeout(() => {
|
|
251
|
+
child.kill("SIGKILL")
|
|
252
|
+
reject(new Error(`Test ${testPath} timed out after 120s`))
|
|
253
|
+
}, 120000)
|
|
254
|
+
child.on("error", (e) => {
|
|
255
|
+
clearTimeout(timer)
|
|
256
|
+
reject(e)
|
|
257
|
+
})
|
|
258
|
+
child.on("close", (code) => {
|
|
259
|
+
clearTimeout(timer)
|
|
260
|
+
const output = (stdout + stderr).trim()
|
|
261
|
+
const tail = output.split("\n").slice(-8).join("\n")
|
|
262
|
+
resolve({ passed: code === 0, tail })
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
154
267
|
/**
|
|
155
268
|
* Run npm test via spawn, no maxBuffer limit.
|
|
156
269
|
* Test output is streamed through ctx.callbacks.onToolOutput (TUI can display progress in real time).
|
|
157
|
-
*
|
|
270
|
+
* Returns { passed: boolean, tail: string }.
|
|
158
271
|
*/
|
|
159
272
|
function runTestSuite(cwd, ctx) {
|
|
160
273
|
return new Promise((resolve, reject) => {
|
|
@@ -187,13 +300,9 @@ function runTestSuite(cwd, ctx) {
|
|
|
187
300
|
})
|
|
188
301
|
child.on("close", (code) => {
|
|
189
302
|
clearTimeout(timer)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
err.stdout = stdout
|
|
194
|
-
err.stderr = stderr
|
|
195
|
-
reject(err)
|
|
196
|
-
}
|
|
303
|
+
const output = (stdout + stderr).trim()
|
|
304
|
+
const tail = output.split("\n").slice(-8).join("\n")
|
|
305
|
+
resolve({ passed: code === 0, tail })
|
|
197
306
|
})
|
|
198
307
|
})
|
|
199
308
|
}
|
package/src/agent-tools.mjs
CHANGED
|
@@ -10,3 +10,4 @@ export { skillTool } from "./agent-tools/skill.mjs"
|
|
|
10
10
|
export { goalTool } from "./agent-tools/goal.mjs"
|
|
11
11
|
export { verifyTool } from "./agent-tools/verify.mjs"
|
|
12
12
|
export { recentChangesTool } from "./agent-tools/recent-changes.mjs"
|
|
13
|
+
export { timerTool } from "./agent-tools/timer.mjs"
|
package/src/agent.mjs
CHANGED
|
@@ -64,6 +64,7 @@ export function createAgent({
|
|
|
64
64
|
_mutatedThisRun: false, _verifiedThisRun: false, _verifyPassed: undefined,
|
|
65
65
|
_touchedFiles: [], _verifyRetries: 0,
|
|
66
66
|
_pendingReminders: [],
|
|
67
|
+
_pendingTimers: [],
|
|
67
68
|
_sessionStart: sessionStart,
|
|
68
69
|
_lastPromptTokens: null, _usageAtLen: null,
|
|
69
70
|
_compressFailures: 0,
|
|
@@ -91,7 +92,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
91
92
|
const lastRole = agent.history.at(-1)?.role
|
|
92
93
|
if (lastRole === "user" || lastRole === "tool") {
|
|
93
94
|
try {
|
|
94
|
-
if (await compressIfNeeded(agent, threshold)) {
|
|
95
|
+
if (await compressIfNeeded(agent, threshold, callbacks)) {
|
|
95
96
|
agent._compressFailures = 0
|
|
96
97
|
recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
|
|
97
98
|
callbacks.onCompress?.()
|
|
@@ -111,13 +112,74 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
const messages = [{ role: "system", content: systemPrompt }, ...agent.history]
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
let response
|
|
116
|
+
|
|
117
|
+
// Auto-think: classify task difficulty and set reasoning effort before the real prompt.
|
|
118
|
+
// Runs only on turn 0 of user input; failure is silent — falls back to current setting.
|
|
119
|
+
if (agent.config?.agent?.autoThink && turn === 0) {
|
|
120
|
+
const { classifyAndApply } = await import("./auto-think.mjs")
|
|
121
|
+
await classifyAndApply(agent, turn).catch(() => {})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
response = await chat(agent.provider, {
|
|
126
|
+
messages, tools: toolSchemas,
|
|
127
|
+
onToken: callbacks.onToken,
|
|
128
|
+
onReasoning: callbacks.onReasoning,
|
|
129
|
+
onWait: callbacks.onWait,
|
|
130
|
+
signal,
|
|
131
|
+
streamRules: agent.config.agent?.streamRules ?? [],
|
|
132
|
+
})
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// User interrupt (Ctrl+I): controller.abort({ interrupt: true, message: "…" }).
|
|
135
|
+
// Inject the message into history and let the outer loop recreate the controller.
|
|
136
|
+
if (e.name === "AbortError" && signal?.reason?.interrupt) {
|
|
137
|
+
agent.history.push({
|
|
138
|
+
role: "user",
|
|
139
|
+
content: `[User interrupt: ${signal.reason.message}]`,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
throw e
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Stream rule triggered mid-generation (action: "abort"): halt current output,
|
|
146
|
+
// inject rule's message as a reminder, and retry from the same context.
|
|
147
|
+
if (response.ruleTriggered) {
|
|
148
|
+
if (response.content) {
|
|
149
|
+
agent.history.push({ role: "assistant", content: response.content })
|
|
150
|
+
}
|
|
151
|
+
const label = response.ruleName ? ` — stream rule "${response.ruleName}"` : ""
|
|
152
|
+
agent.history.push({
|
|
153
|
+
role: "user",
|
|
154
|
+
content: `[System reminder${label}: ${response.ruleMessage}]`,
|
|
155
|
+
})
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Stream rule warnings (action: "warn"): the stream completed, but one or more
|
|
160
|
+
// non-interrupting rules matched. Inject warnings after the turn so the model
|
|
161
|
+
// sees them before its next response — without aborting mid-generation.
|
|
162
|
+
if (response._warnings?.length) {
|
|
163
|
+
const deDuplicated = [...new Map(response._warnings.map(w => [w.name || w.pattern, w])).values()]
|
|
164
|
+
agent.history.push({
|
|
165
|
+
role: "user",
|
|
166
|
+
content: `[System reminder — stream rule warnings from your last response:\n${deDuplicated.map(w => `- ${w.name || w.pattern}: ${w.message}`).join("\n")}]`,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// User interrupted mid-generation (Ctrl+I): the SSE stream was aborted while content
|
|
171
|
+
// was partially generated. Commit partial output + inject user message, then signal
|
|
172
|
+
// the outer loop to recreate the controller and resume.
|
|
173
|
+
if (response.interrupted) {
|
|
174
|
+
if (response.content) {
|
|
175
|
+
agent.history.push({ role: "assistant", content: response.content })
|
|
176
|
+
}
|
|
177
|
+
agent.history.push({
|
|
178
|
+
role: "user",
|
|
179
|
+
content: `[User interrupt: ${response.interruptMessage}]`,
|
|
180
|
+
})
|
|
181
|
+
throw Object.assign(new Error("User interrupted"), { name: "AbortError" })
|
|
182
|
+
}
|
|
121
183
|
|
|
122
184
|
if (response.usage) {
|
|
123
185
|
callbacks.onUsage?.(response.usage)
|
|
@@ -127,6 +189,21 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
127
189
|
}
|
|
128
190
|
}
|
|
129
191
|
|
|
192
|
+
// Warn on abnormal finish reasons — the model stopped for a reason other than
|
|
193
|
+
// "stop" or "tool_calls", meaning the response may be incomplete or truncated.
|
|
194
|
+
if (response.finishReason && response.finishReason !== "stop" && response.finishReason !== "tool_calls") {
|
|
195
|
+
const reasonMap = {
|
|
196
|
+
length: "output token limit reached after exhausting continuations",
|
|
197
|
+
insufficient_system_resource: "provider inference resources exhausted — consider retrying or switching models",
|
|
198
|
+
content_filter: "response blocked by provider content filtering",
|
|
199
|
+
}
|
|
200
|
+
const detail = reasonMap[response.finishReason] || `unknown reason "${response.finishReason}"`
|
|
201
|
+
agent.history.push({
|
|
202
|
+
role: "user",
|
|
203
|
+
content: `[System reminder: the previous turn ended abnormally — ${detail}. The assistant response that follows may be incomplete.]`,
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
130
207
|
if (response.toolCalls.length === 0) {
|
|
131
208
|
if (!response.content) {
|
|
132
209
|
throw new Error(
|
|
@@ -211,11 +288,20 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
211
288
|
if (parsed.images?.length) {
|
|
212
289
|
// tool message first — closes the tool_call pairing (OpenAI API requires tool result immediately after assistant with tool_calls)
|
|
213
290
|
agent.history.push({ role: "tool", tool_call_id: toolCall.id, content: parsed.text })
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
291
|
+
if (specForModel(agent.provider.model).multimodal) {
|
|
292
|
+
// then inject multimodal user message with base64 images for the model to actually "see" them on the next turn
|
|
293
|
+
agent.history.push({
|
|
294
|
+
role: "user",
|
|
295
|
+
content: [{ type: "text", text: parsed.text }, ...parsed.images],
|
|
296
|
+
})
|
|
297
|
+
} else {
|
|
298
|
+
// Non-vision model: image parts must never enter history — text-only APIs 400 on them on EVERY
|
|
299
|
+
// subsequent request, poisoning the conversation. (read_image itself already refuses; this is defense-in-depth.)
|
|
300
|
+
agent.history.push({
|
|
301
|
+
role: "user",
|
|
302
|
+
content: `[System reminder: the image returned by ${toolCall.name} was NOT injected — model ${agent.provider.model} does not support image input. Do not call ${toolCall.name} again under this provider; verify visual output programmatically instead.]`,
|
|
303
|
+
})
|
|
304
|
+
}
|
|
219
305
|
continue
|
|
220
306
|
}
|
|
221
307
|
} catch { /* Parse failure doesn't affect normal tool messages */ }
|
|
@@ -231,21 +317,31 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
231
317
|
const abs = join(agent.cwd, p)
|
|
232
318
|
agent._touchedFiles.push(abs)
|
|
233
319
|
if (agent.memory) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
await _reindexFile(agent.memory, agent.cwd, abs)
|
|
240
|
-
} catch (e) { /* Index failure doesn't block agent, surface in TUI as pending reminder */
|
|
241
|
-
agent._pendingReminders.push(`[System reminder: background indexing failed for ${toolCall.name} on ${abs}: ${e.message}. This does not affect your work — the code index will catch up on next reindex.]`)
|
|
320
|
+
// Fire-and-forget: don't block the agent loop on indexing.
|
|
321
|
+
// Reuses a single cached import; errors surface as pending reminders on next turn.
|
|
322
|
+
if (!_reindexFile) {
|
|
323
|
+
const mod = await import("./memory.mjs")
|
|
324
|
+
_reindexFile = mod.reindexFile
|
|
242
325
|
}
|
|
326
|
+
_reindexFile(agent.memory, agent.cwd, abs).catch((e) => {
|
|
327
|
+
agent._pendingReminders.push(`[System reminder: background indexing failed for ${toolCall.name} on ${abs}: ${e.message}. This does not affect your work — the code index will catch up on next reindex.]`)
|
|
328
|
+
})
|
|
243
329
|
}
|
|
244
330
|
}
|
|
245
331
|
}
|
|
246
332
|
}
|
|
247
333
|
}
|
|
248
334
|
|
|
335
|
+
// Expired timers — inject reminders when thinking budget is up
|
|
336
|
+
if (agent._pendingTimers.length > 0) {
|
|
337
|
+
const now = Date.now()
|
|
338
|
+
const expired = agent._pendingTimers.filter((t) => t.expiresAt <= now)
|
|
339
|
+
agent._pendingTimers = agent._pendingTimers.filter((t) => t.expiresAt > now)
|
|
340
|
+
for (const t of expired) {
|
|
341
|
+
agent.history.push({ role: "user", content: `[System reminder: ⏰ timer — ${t.message}]` })
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
249
345
|
// Pending reminders
|
|
250
346
|
if (agent._pendingReminders.length > 0) {
|
|
251
347
|
for (const reminder of agent._pendingReminders) {
|
|
@@ -290,6 +386,17 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
290
386
|
}
|
|
291
387
|
|
|
292
388
|
callbacks.onTurnEnd?.(agent, turn)
|
|
389
|
+
|
|
390
|
+
// Advisor: automated code review after each tool-execution turn.
|
|
391
|
+
// Runs asynchronously — failure is silent, main loop continues regardless.
|
|
392
|
+
if (agent.config?.advisor?.enabled) {
|
|
393
|
+
const { runAdvisor } = await import("./advisor.mjs")
|
|
394
|
+
const note = await runAdvisor(agent)
|
|
395
|
+
if (note) {
|
|
396
|
+
agent.history.push({ role: "user", content: note })
|
|
397
|
+
callbacks.onAdvisor?.(note)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
293
400
|
}
|
|
294
401
|
|
|
295
402
|
throw new ContinueError(maxTurns)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-think.mjs — automatic difficulty classification for reasoning effort.
|
|
3
|
+
*
|
|
4
|
+
* When enabled, before each user-facing turn a cheap classification call determines
|
|
5
|
+
* the task difficulty, then maps it to the model's reasoning effort. This replaces
|
|
6
|
+
* manual /think toggling with per-prompt automatic selection.
|
|
7
|
+
*
|
|
8
|
+
* Config:
|
|
9
|
+
* { agent: { autoThink: true } }
|
|
10
|
+
*
|
|
11
|
+
* Mechanism:
|
|
12
|
+
* 1. Take the last user message from history
|
|
13
|
+
* 2. Send a minimal classification prompt (expects one-word reply)
|
|
14
|
+
* 3. Map difficulty → reasoningEffort using the model's valid effort enum
|
|
15
|
+
* 4. Set agent.provider.reasoningEffort before the real chat() call
|
|
16
|
+
*/
|
|
17
|
+
import { chat } from "./provider/core.mjs"
|
|
18
|
+
import { specForModel } from "./config.mjs"
|
|
19
|
+
|
|
20
|
+
const CLASSIFY_PROMPT = `Classify this coding task's difficulty: low, medium, or high.
|
|
21
|
+
|
|
22
|
+
- low — trivial: rename, typo, formatting, one-liner, direct question
|
|
23
|
+
- medium — localized: small feature, straightforward bug fix, moderate change
|
|
24
|
+
- high — complex: multi-file, debugging, design decisions, large refactor
|
|
25
|
+
|
|
26
|
+
Reply with exactly one word.`
|
|
27
|
+
|
|
28
|
+
const EFFORT_MAP = {
|
|
29
|
+
low: ["low", "minimal", "none", "low"],
|
|
30
|
+
medium: ["high", "medium", "high"],
|
|
31
|
+
high: ["max", "max", "xhigh", "max"],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Classify the difficulty of the user's prompt and adjust reasoning effort.
|
|
36
|
+
* Only runs on the first turn (turn === 0) of a user message.
|
|
37
|
+
* Returns the resolved level or null if auto-thinking is disabled or classification fails.
|
|
38
|
+
* @param {object} agent
|
|
39
|
+
* @param {number} turn
|
|
40
|
+
* @returns {Promise<string|null>}
|
|
41
|
+
*/
|
|
42
|
+
export async function classifyAndApply(agent, turn) {
|
|
43
|
+
if (!agent.config?.agent?.autoThink) return null
|
|
44
|
+
if (turn !== 0) return null // Only classify on the first turn of user input
|
|
45
|
+
|
|
46
|
+
const spec = specForModel(agent.provider.model)
|
|
47
|
+
const validEfforts = spec.reasoningEffortEnum
|
|
48
|
+
if (!validEfforts) return null // Model doesn't support reasoning effort
|
|
49
|
+
|
|
50
|
+
// Get the last user message (should be the most recent history entry or the input)
|
|
51
|
+
const lastUser = [...agent.history].reverse().find(m => m.role === "user")
|
|
52
|
+
if (!lastUser) return null
|
|
53
|
+
const prompt = typeof lastUser.content === "string" ? lastUser.content : ""
|
|
54
|
+
|
|
55
|
+
// Classification call: use same provider, minimal tokens, no tools, no streaming
|
|
56
|
+
let level
|
|
57
|
+
try {
|
|
58
|
+
const classifierProvider = { ...agent.provider, maxTokens: 10 }
|
|
59
|
+
const response = await chat(classifierProvider, {
|
|
60
|
+
messages: [
|
|
61
|
+
{ role: "system", content: CLASSIFY_PROMPT },
|
|
62
|
+
{ role: "user", content: prompt.slice(0, 2000) },
|
|
63
|
+
],
|
|
64
|
+
tools: [],
|
|
65
|
+
signal: AbortSignal.timeout(5_000),
|
|
66
|
+
})
|
|
67
|
+
const word = (response.content ?? "").trim().toLowerCase()
|
|
68
|
+
if (word.startsWith("low")) level = "low"
|
|
69
|
+
else if (word.startsWith("medium") || word.startsWith("med")) level = "medium"
|
|
70
|
+
else if (word.startsWith("high")) level = "high"
|
|
71
|
+
else return null // Unparseable
|
|
72
|
+
} catch {
|
|
73
|
+
return null // Classification failure → fall back to current setting
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Map difficulty to the closest valid reasoning effort
|
|
77
|
+
const candidates = EFFORT_MAP[level] || EFFORT_MAP.medium
|
|
78
|
+
const matched = candidates.find(e => validEfforts.includes(e))
|
|
79
|
+
if (!matched) return null
|
|
80
|
+
|
|
81
|
+
agent.provider.reasoningEffort = matched
|
|
82
|
+
return matched
|
|
83
|
+
}
|
package/src/cli/make-agent.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { loadConfig, configDir } from "../config.mjs"
|
|
|
5
5
|
import { createMemory, memoryTools, syncDir, codeSearchTool, docSearchTool } from "../memory.mjs"
|
|
6
6
|
import { repoOutlineTool } from "../tools/repomap.mjs"
|
|
7
7
|
import { builtinTools } from "../tools/index.mjs"
|
|
8
|
+
import { discoverRules } from "../rules.mjs"
|
|
8
9
|
|
|
9
10
|
/** Assemble an agent with memory, MCP tools, and code/doc indices attached (sync all layers, then return) */
|
|
10
11
|
export async function assembleAgent() {
|
|
@@ -18,6 +19,14 @@ export async function assembleAgent() {
|
|
|
18
19
|
memory.embedder = createEmbedder(config.embedding)
|
|
19
20
|
}
|
|
20
21
|
const cwd = process.cwd()
|
|
22
|
+
// Merge project-level rules (.thincoder/rules/*.md) into config; file rules take priority (first),
|
|
23
|
+
// config.json rules append (deduped by pattern). Users can override with explicit config rules.
|
|
24
|
+
const fileRules = discoverRules(cwd)
|
|
25
|
+
if (fileRules.length) {
|
|
26
|
+
const filePatterns = new Set(fileRules.map(r => r.pattern))
|
|
27
|
+
const configRules = (config.agent?.streamRules || []).filter(r => !filePatterns.has(r.pattern))
|
|
28
|
+
config.agent.streamRules = [...fileRules, ...configRules]
|
|
29
|
+
}
|
|
21
30
|
// code/doc indices isolated by origin (project root dir): search only scoped to this project
|
|
22
31
|
memory.codeOrigin = cwd
|
|
23
32
|
// Project layer: sync .thincoder/memory/ dir to index on startup (sync if present, skip otherwise)
|