heartcode 0.1.6 → 0.1.7
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/SKILL.md +24 -10
- package/bin/heartcode.js +70 -30
- package/package.json +1 -1
- package/prompts/agent.md +15 -30
- package/prompts/guided.md +6 -6
- package/src/acp.js +3 -3
- package/src/agent.js +168 -68
- package/src/client.js +162 -81
- package/src/context.js +2 -2
- package/src/director.js +8 -5
- package/src/edit.js +5 -6
- package/src/format.js +18 -9
- package/src/guided.js +27 -55
- package/src/headless.js +18 -11
- package/src/hooks.js +1 -1
- package/src/mcp.js +24 -8
- package/src/memory.js +6 -4
- package/src/probe.js +6 -3
- package/src/settings.js +19 -2
- package/src/tools.js +138 -41
- package/src/usage.js +9 -4
package/src/agent.js
CHANGED
|
@@ -4,50 +4,62 @@ import { log } from './log.js'
|
|
|
4
4
|
import { prompt, userInstructions } from './prompts.js'
|
|
5
5
|
import { newChat } from './sessions.js'
|
|
6
6
|
import path from 'node:path'
|
|
7
|
-
import { est, Client } from './client.js'
|
|
7
|
+
import { est, Client, writtenBy } from './client.js'
|
|
8
8
|
import { runGuided } from './guided.js'
|
|
9
|
-
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput, OUTPUTS } from './tools.js'
|
|
9
|
+
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput, OUTPUTS, imageUrl, MAX_IMAGE, failed } from './tools.js'
|
|
10
10
|
import { loadHooks, runHooks } from './hooks.js'
|
|
11
11
|
import { takeConfigErrors } from './config.js'
|
|
12
12
|
import { c } from './ui.js'
|
|
13
13
|
|
|
14
|
-
const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
15
|
-
|
|
16
14
|
// Images for a helper (the agent tool's "images"), as data: URLs: image URLs (fetched), data: URLs,
|
|
17
15
|
// or files: in the project, saved screenshots (OUTPUTS), or a path the user gave in a message.
|
|
18
16
|
async function loadImages(list, s, signal) {
|
|
17
|
+
// a list written as text (some models send it so): JSON, or "[a.png, b.png]", or one per line
|
|
18
|
+
if (typeof list === 'string') {
|
|
19
|
+
try {
|
|
20
|
+
list = JSON.parse(list)
|
|
21
|
+
} catch {
|
|
22
|
+
list = list.replace(/^\s*\[|\]\s*$/g, '').split(/[,\n]/).map((x) => x.trim().replace(/^["'`]|["'`]$/g, ''))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
19
25
|
const out = []
|
|
20
|
-
for (const x of [].concat(list ?? []).map((v) => String(v).trim()).filter(Boolean)) {
|
|
26
|
+
for (const x of [].concat(list ?? []).filter((v) => v != null).map((v) => String(v).trim()).filter(Boolean)) {
|
|
21
27
|
if (x.startsWith('data:image/')) {
|
|
22
28
|
out.push(x)
|
|
23
29
|
} else if (/^https?:\/\//i.test(x)) {
|
|
24
30
|
const res = await fetch(x, { signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]) })
|
|
25
31
|
const type = res.headers.get('content-type')?.split(';')[0].trim() ?? ''
|
|
26
32
|
if (!res.ok || !type.startsWith('image/')) throw new Error(`${x} is not an image (${res.status}${type ? `, ${type}` : ''})`)
|
|
27
|
-
|
|
33
|
+
const data = Buffer.from(await res.arrayBuffer())
|
|
34
|
+
if (data.length > MAX_IMAGE) throw new Error(`${x} is too big to send (${Math.round(data.length / 1e6)} MB; at most ${MAX_IMAGE / 1e6} MB)`)
|
|
35
|
+
out.push(`data:${type};base64,${data.toString('base64')}`)
|
|
28
36
|
} else {
|
|
29
37
|
const abs = path.resolve(s.root, x.replace(/^~(?=\/)/, os.homedir()))
|
|
30
|
-
const ext = path.extname(abs).slice(1).toLowerCase()
|
|
31
|
-
if (!MIME[ext]) throw new Error(`${x} is not an image file (png, jpg, gif, webp)`)
|
|
32
|
-
if (!fs.existsSync(abs)) throw new Error(`${x} does not exist`)
|
|
33
38
|
const inside = (dir) => abs.startsWith(dir + path.sep)
|
|
34
|
-
if (!inside(s.root) && !inside(OUTPUTS) && !s.toolImages?.has(abs) && !s.chat.turns.some((t) => t.task.includes(x) || t.task.includes(abs))) throw new Error(`${x} is outside the project; only images in it, saved screenshots and ones the user named can be sent`)
|
|
35
|
-
out.push(
|
|
39
|
+
if (fs.existsSync(abs) && !inside(s.root) && !inside(OUTPUTS) && !s.toolImages?.has(abs) && !s.chat.turns.some((t) => t.task.includes(x) || t.task.includes(abs))) throw new Error(`${x} is outside the project; only images in it, saved screenshots and ones the user named can be sent`)
|
|
40
|
+
out.push(imageUrl(abs, x))
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
return out
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
// An image the message
|
|
42
|
-
// ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there.
|
|
43
|
-
|
|
46
|
+
// An image the message gives is attached: "@shot.png", or a full path (/Users/me/Downloads/a.png,
|
|
47
|
+
// ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there. A file only named
|
|
48
|
+
// ("rename logo.png") isn't: the model reads it if it needs to. The path stays in the text. Too big
|
|
49
|
+
// to send: left out, and the message says so.
|
|
50
|
+
const IMAGE_PATH = /(^|\s)(@)?(?:(["'])([^"'\n]+?\.(?:png|jpe?g|gif|webp))\3|((?:\\ |[^\s"'])+?\.(?:png|jpe?g|gif|webp)))(?=$|[\s,;:!?).])/gi
|
|
44
51
|
export function extractImages(task, root) {
|
|
45
52
|
const images = []
|
|
46
|
-
const text = task.replace(IMAGE_PATH, (m, pre, q, quoted, bare) => {
|
|
53
|
+
const text = task.replace(IMAGE_PATH, (m, pre, at, q, quoted, bare) => {
|
|
47
54
|
const file = (quoted ?? bare.replace(/\\ /g, ' ')).replace(/^~(?=\/)/, os.homedir())
|
|
55
|
+
if (!at && !path.isAbsolute(file)) return m
|
|
48
56
|
const abs = path.resolve(root, file)
|
|
49
57
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return m
|
|
50
|
-
|
|
58
|
+
try {
|
|
59
|
+
images.push(imageUrl(abs, file))
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return `${pre}${file} (${e.message})`
|
|
62
|
+
}
|
|
51
63
|
return `${pre}${file}`
|
|
52
64
|
})
|
|
53
65
|
return { text, images }
|
|
@@ -166,8 +178,9 @@ export class Session {
|
|
|
166
178
|
let { text, images } = extractImages(task, this.root)
|
|
167
179
|
images = [...images, ...extra]
|
|
168
180
|
if (images.length && !this.strat.vision) {
|
|
169
|
-
const
|
|
170
|
-
|
|
181
|
+
const fails = (t) => this.failures?.[`${t.provider} ${t.model}`] ?? 0
|
|
182
|
+
const seers = (this.team?.() ?? []).filter((t) => t.images).sort((a, b) => fails(a) - fails(b)) // your models that can: they describe them
|
|
183
|
+
if (seers.length) text += await this.describeImages(images, seers, text, signal)
|
|
171
184
|
else this.ui.warn(`${this.client.model} can't read images (per its probe); ignoring ${images.length} image(s)`)
|
|
172
185
|
images = []
|
|
173
186
|
}
|
|
@@ -175,7 +188,6 @@ export class Session {
|
|
|
175
188
|
const answer = this.strat.actions === 'tools' ? await runNative(this, text, images, signal) : await runGuided(this, text, images, signal)
|
|
176
189
|
log('task', 'answer:', answer)
|
|
177
190
|
this.history.push({ task: text, answer })
|
|
178
|
-
if (this.memory && this.strat.actions === 'tools' && !this.appendSystem) await this.afterTask(text, answer, signal) // a program's instructions (--append-system-prompt) aren't the user's to remember
|
|
179
191
|
return answer
|
|
180
192
|
}
|
|
181
193
|
|
|
@@ -197,13 +209,16 @@ export class Session {
|
|
|
197
209
|
return `error: ${e.message}`
|
|
198
210
|
}
|
|
199
211
|
const name = (t) => `${t.provider} ${t.model}`
|
|
200
|
-
const self = { provider: this.client.provider, model: this.client.model, images: this.strat.vision, self: true }
|
|
212
|
+
const self = { provider: this.client.provider, model: this.client.model, images: this.strat.vision, tools: this.strat.actions === 'tools', self: true }
|
|
201
213
|
const all = [self, ...(this.team?.() ?? [])]
|
|
202
214
|
const want = String(args.model ?? '').trim()
|
|
203
215
|
const first = !want ? self : (all.find((t) => name(t) === want) ?? all.find((t) => t.model === want || want.endsWith(` ${t.model}`)))
|
|
204
216
|
if (!first) return `error: no model "${want}". Yours: ${all.map(name).join(', ')}`
|
|
217
|
+
// the one asked for first (if it can do the job), then the others that can: those that call
|
|
218
|
+
// tools first, then the ones that failed least this session, in your list's order
|
|
205
219
|
const able = (t) => !images.length || t.images
|
|
206
|
-
const
|
|
220
|
+
const fails = (t) => this.failures?.[name(t)] ?? 0
|
|
221
|
+
const order = [first, ...all.filter((t) => t !== first).sort((a, b) => b.tools - a.tools || fails(a) - fails(b))].filter(able)
|
|
207
222
|
if (!order.length) return 'error: none of your models can see images'
|
|
208
223
|
const failed = []
|
|
209
224
|
for (const [i, t] of order.entries()) {
|
|
@@ -214,45 +229,95 @@ export class Session {
|
|
|
214
229
|
if (e.name === 'AbortError' || signal?.aborted) throw e
|
|
215
230
|
failed.push(`${t.model}: ${clip(e.message, 150)}`)
|
|
216
231
|
}
|
|
217
|
-
if (answer) return t === first ? answer : `(${name(t)}
|
|
218
|
-
if (failed.length < i + 1)
|
|
219
|
-
|
|
232
|
+
if (answer) return t === first ? answer : `(Answered by ${name(t)}, not ${name(first)}${failed.length ? `: ${failed.join('; ')}` : ''})\n\n${answer}`
|
|
233
|
+
if (failed.length < i + 1) {
|
|
234
|
+
failed.push(`${t.model}: no reply`)
|
|
235
|
+
if (this.failures) this.failures[name(t)] = fails(t) + 1 // (a failed request is counted where it fails: engine's onFail)
|
|
236
|
+
}
|
|
220
237
|
if (order[i + 1]) this.ui.warn(`${t.model} couldn't do it (${failed.at(-1).slice(t.model.length + 2)}); handing it to ${order[i + 1].model}`)
|
|
221
238
|
}
|
|
222
239
|
return `error: none of your models that can do it managed: ${failed.join('; ')}`
|
|
223
240
|
}
|
|
224
241
|
|
|
225
|
-
// One helper on one model (t: this one, or another of yours)
|
|
242
|
+
// One helper on one model (t: this one, or another of yours), with its own connection (a helper's
|
|
243
|
+
// failure moves no one else on to another model) that gives up after a few tries (then the next
|
|
244
|
+
// model gets the job).
|
|
226
245
|
async #helper(t, task, images, description, id, signal) {
|
|
227
246
|
let { client, profile, strat } = this
|
|
228
|
-
if (
|
|
247
|
+
if (this.engineFor) {
|
|
229
248
|
;({ client, profile, strat } = await this.engineFor(t))
|
|
230
249
|
const was = client.retryIf
|
|
231
|
-
client.retryIf = (e, attempt) => attempt < 3 && (!was || was(e, attempt))
|
|
250
|
+
client.retryIf = (e, attempt) => attempt < 3 && (!was || was(e, attempt))
|
|
232
251
|
}
|
|
233
252
|
const label = clip(`${client.model}: ${String(description ?? '').trim() || task.split('\n')[0]}`, 60)
|
|
234
253
|
const helper = new Session({ client, profile, strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
|
|
235
254
|
Object.assign(helper, { isSub: true, mcp: this.mcp, skills: this.skills, hooks: this.hooks, allowTools: this.allowTools, denyTools: this.denyTools, maxTurns: this.maxTurns, autoCompact: this.autoCompact, appendSystem: this.appendSystem, fileCache: this.fileCache, noFormat: this.noFormat, noLsp: this.noLsp })
|
|
236
255
|
helper.track = (rel, before) => this.track(rel, before)
|
|
256
|
+
helper.mcpGate = async () => (await this.#gate(helper), () => {})
|
|
237
257
|
if (this.onEvent) helper.onEvent = (e) => this.onEvent({ ...e, parent: id, helper: label })
|
|
238
258
|
log('agent', `helper "${label}" (${images.length} image(s)): ${task}`)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
259
|
+
try {
|
|
260
|
+
const answer = await (strat.actions === 'tools' ? runNative : runGuided)(helper, task, images, signal)
|
|
261
|
+
log('agent', `helper "${label}" done`, answer)
|
|
262
|
+
return answer
|
|
263
|
+
} finally {
|
|
264
|
+
this.#release(helper)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The MCP servers (a browser…) are one helper's from its first MCP call to its end: helpers
|
|
269
|
+
// working at the same time don't drive the same browser; the others (and this conversation)
|
|
270
|
+
// wait their turn. One turn for all of them, so two helpers can't each hold one the other waits on.
|
|
271
|
+
// ponytail: helpers that use different servers wait for each other too; a turn per server needs
|
|
272
|
+
// a fixed order of taking them (or waiting without holding) to stay deadlock-free.
|
|
273
|
+
// Returns what ends the turn: a helper's lasts until it ends (#helper), this conversation's until
|
|
274
|
+
// its call is done.
|
|
275
|
+
async mcpGate() {
|
|
276
|
+
await this.#gate(this)
|
|
277
|
+
return () => this.#release(this)
|
|
278
|
+
}
|
|
279
|
+
async #gate(who) {
|
|
280
|
+
while (this.mcpTurn && this.mcpTurn.who !== who) await this.mcpTurn.done
|
|
281
|
+
if (!this.mcpTurn) {
|
|
282
|
+
let release
|
|
283
|
+
const done = new Promise((r) => (release = r))
|
|
284
|
+
this.mcpTurn = { who, done, release }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
#release(who) {
|
|
288
|
+
if (this.mcpTurn?.who !== who) return
|
|
289
|
+
const { release } = this.mcpTurn
|
|
290
|
+
this.mcpTurn = null
|
|
291
|
+
release()
|
|
242
292
|
}
|
|
243
293
|
|
|
244
|
-
// Images for a model that can't see them:
|
|
245
|
-
|
|
246
|
-
|
|
294
|
+
// Images for a model that can't see them: your models that can describe each, for the task (the
|
|
295
|
+
// next one when one fails).
|
|
296
|
+
async describeImages(images, seers, task, signal) {
|
|
247
297
|
const out = []
|
|
298
|
+
let by = ''
|
|
248
299
|
for (const [i, url] of images.entries()) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
300
|
+
let said = ''
|
|
301
|
+
for (const seer of seers) {
|
|
302
|
+
this.ui.spinner(`${seer.model} is looking at image ${i + 1}`)
|
|
303
|
+
try {
|
|
304
|
+
const { client } = await this.engineFor(seer)
|
|
305
|
+
client.retryIf = (e, attempt) => attempt < 3
|
|
306
|
+
said = (await client.chat({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt('guided/describe-image', { task }) }, { type: 'image_url', image_url: { url } }] }] }, { signal })).content.trim()
|
|
307
|
+
} catch (e) {
|
|
308
|
+
if (e.name === 'AbortError' || signal?.aborted) throw e
|
|
309
|
+
this.ui.warn(`${seer.model} couldn't look at image ${i + 1}: ${clip(e.message, 120)}`)
|
|
310
|
+
}
|
|
311
|
+
if (said) {
|
|
312
|
+
by = seer.model
|
|
313
|
+
this.ui.step('image', `${seer.model} described image ${i + 1}`)
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
out.push(`Image ${i + 1}:\n${said || '(none of your models that see images could look at it)'}`)
|
|
253
318
|
}
|
|
254
|
-
log('images', `described by ${
|
|
255
|
-
return `\n\n${prompt('agent/images-described', { model:
|
|
319
|
+
log('images', `described by ${by}`, out)
|
|
320
|
+
return `\n\n${prompt('agent/images-described', { model: by || 'another model', descriptions: out.join('\n\n') })}`
|
|
256
321
|
}
|
|
257
322
|
|
|
258
323
|
// /btw: a side question, answered from the conversation so far. Nothing goes into the
|
|
@@ -272,7 +337,7 @@ export class Session {
|
|
|
272
337
|
messages = [{ role: 'user', content: past ? `${prompt('agent/btw-history', { conversation: past })}\n\n${ask}` : ask }]
|
|
273
338
|
}
|
|
274
339
|
const side = new Client({ baseUrl: client.baseUrl, apiKey: client.apiKey, model: client.model, provider: client.provider })
|
|
275
|
-
Object.assign(side, { effort: client.effort, quirks: client.quirks, tokenRatio: client.tokenRatio, onSpend: client.onSpend, purpose: 'btw' })
|
|
340
|
+
Object.assign(side, { effort: client.effort, quirks: client.quirks, can: client.can, tokenRatio: client.tokenRatio, onSpend: client.onSpend, purpose: 'btw' })
|
|
276
341
|
log('btw', question)
|
|
277
342
|
const res = await side.chat({ messages }, { signal })
|
|
278
343
|
log('btw', 'answer:', res.content)
|
|
@@ -291,23 +356,6 @@ export class Session {
|
|
|
291
356
|
return summary
|
|
292
357
|
}
|
|
293
358
|
|
|
294
|
-
// After a task: if the user said something worth keeping and the model didn't save it, ask it to
|
|
295
|
-
// (one small request, with only the related notes). Never fails the task.
|
|
296
|
-
async afterTask(task, answer, signal) {
|
|
297
|
-
const writes = this.memoryWrites ?? 0
|
|
298
|
-
try {
|
|
299
|
-
if (!writes && REMEMBER.test(task)) {
|
|
300
|
-
this.ui.spinner('remembering')
|
|
301
|
-
await this.memoryTask(this.#extractPrompt(`${task}\n${answer}`), prompt('agent/memory-exchange', { task: clip(task, 4000), answer: clip(answer, 2000) }), signal)
|
|
302
|
-
}
|
|
303
|
-
} catch (e) {
|
|
304
|
-
if (e.name === 'AbortError') throw e
|
|
305
|
-
this.ui.warn(`memory: ${e.message}`)
|
|
306
|
-
} finally {
|
|
307
|
-
this.memoryWrites = 0
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
359
|
#extractPrompt(about) {
|
|
312
360
|
const notes = this.memory.relevant(about, 10).map((n) => `[[${n.name}]] — ${n.description} (${[n.scope, ...n.tags].join(', ')})`).join('\n')
|
|
313
361
|
return prompt('agent/memory-extract', { tags: this.memory.tagSummary() || '(none yet)', notes })
|
|
@@ -397,10 +445,20 @@ const sizeOf = (m) =>
|
|
|
397
445
|
(m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0) +
|
|
398
446
|
(m.reasoning_content ? est(m.reasoning_content) : 0)
|
|
399
447
|
export const size = (messages) => messages.reduce((n, m) => n + sizeOf(m), 0)
|
|
400
|
-
// Words that suggest the user said something to remember (then HeartCode asks the model to save it).
|
|
401
|
-
const REMEMBER = /\b(always|never|prefer|remember|don'?t|do not|instead of|from now on|we use|convention|make sure|keep in mind)\b/i
|
|
402
448
|
const kTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
|
|
403
449
|
|
|
450
|
+
// How many steps a helper (the agent tool) takes at most before it answers with what it has.
|
|
451
|
+
const HELPER_STEPS = 25
|
|
452
|
+
|
|
453
|
+
// A chat template's tool-call markup in a reply's text (a tool call the model wrote out): outside
|
|
454
|
+
// code blocks, and the reply ends in it (an answer that only mentions such tags isn't one).
|
|
455
|
+
const MARKUP = /<\/?tool_call>|<\/?arg_(?:key|value)>|<function=[\w.-]+>|<\|[a-z_]+\|>/i
|
|
456
|
+
export function leakedMarkup(text) {
|
|
457
|
+
const prose = String(text ?? '').replace(/```[\s\S]*?(```|$)/g, (m) => ' '.repeat(m.length)) // code keeps its place
|
|
458
|
+
const m = MARKUP.exec(prose)
|
|
459
|
+
return m && /[>}]\s*$/.test(prose) ? m : null
|
|
460
|
+
}
|
|
461
|
+
|
|
404
462
|
// "assistant to=mcp({...})", "to=functions.read_file <|constrain|>json<|message|>{...}": a tool
|
|
405
463
|
// call written as text. Returns { name, arguments } (arguments as JSON text), or null.
|
|
406
464
|
export function leakedCall(text) {
|
|
@@ -477,9 +535,9 @@ export function fitMessages(messages, budget) {
|
|
|
477
535
|
// The model keeps replying with nothing (seen when it's done but its server fails to decode the
|
|
478
536
|
// last message): asked once more without tools, for the answer only. Never ends a task with
|
|
479
537
|
// nothing: failing that, HeartCode says itself what changed.
|
|
480
|
-
async function finalAnswer(s, signal) {
|
|
538
|
+
async function finalAnswer(s, signal, why = 'empty replies in a row') {
|
|
481
539
|
const { client, ui } = s
|
|
482
|
-
log('task',
|
|
540
|
+
log('task', `${why}; asking for the final answer without tools`)
|
|
483
541
|
ui.spinner('asking for the final answer')
|
|
484
542
|
try {
|
|
485
543
|
const res = await client.chat({ messages: [...s.messages, { role: 'user', content: prompt('agent/final-answer') }] }, { signal })
|
|
@@ -572,16 +630,29 @@ async function runNative(s, task, images, signal) {
|
|
|
572
630
|
const content = images.length ? [{ type: 'text', text: said }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] : said
|
|
573
631
|
s.messages.push({ role: 'user', content })
|
|
574
632
|
attachNamed(s, task) // files the message names: as if already read (saves a round trip each)
|
|
575
|
-
|
|
633
|
+
// the tools: built again each step (the model answering may change; client.js leaves them out for
|
|
634
|
+
// one that can't call tools)
|
|
635
|
+
const toolList = () => [...TOOL_DEFS, ...extraTools(s)].filter((f) => allowed(s, f.name) && !(planning && PLAN_HIDDEN.has(f.name)) && !(f.name === 'ask_user' && ui.noUser)).map((f) => ({ type: 'function', function: f }))
|
|
636
|
+
let lastCall = '' // the last call (tool and arguments), and how many times in a row
|
|
637
|
+
let inRow = 0
|
|
576
638
|
// A todo list left done from an earlier message is cleared; an unfinished one stays.
|
|
577
639
|
if (!s.isSub && s.chat.todos?.every((t) => t.status === 'completed')) s.chat.todos = []
|
|
578
640
|
s.todoTouched = false
|
|
579
641
|
let nudged = 0 // reminders sent this message about open todo items or a Stop hook
|
|
580
642
|
|
|
581
643
|
let empty = 0 // replies in a row with neither text nor tool calls
|
|
644
|
+
let stuck = null // a tool called the same way five times this message: stop
|
|
645
|
+
let garbled = 0 // replies with a tool call written as text (template markup), this message
|
|
582
646
|
let movedOn = 0 // models given up on for that, in this message
|
|
583
647
|
// No step limit: a long fix takes many steps, and Esc stops it. A caller can set one (--max-turns).
|
|
584
648
|
for (let step = 0; ; step++) {
|
|
649
|
+
const tools = toolList()
|
|
650
|
+
// a helper has a budget: then it answers with what it has (one once searched the web for 40
|
|
651
|
+
// steps to answer which of two numbers is bigger)
|
|
652
|
+
if (s.isSub && step >= HELPER_STEPS) {
|
|
653
|
+
ui.warn(`${client.model} took ${HELPER_STEPS} steps; asking it for its answer`)
|
|
654
|
+
return finalAnswer(s, signal, `helper: ${HELPER_STEPS} steps`)
|
|
655
|
+
}
|
|
585
656
|
if (s.maxTurns && step >= s.maxTurns) {
|
|
586
657
|
ui.warn(`stopped after ${s.maxTurns} steps (--max-turns); say "continue" to go on`)
|
|
587
658
|
throw Object.assign(new Error(`stopped after ${s.maxTurns} steps (--max-turns)`), { maxTurns: true })
|
|
@@ -620,6 +691,10 @@ async function runNative(s, task, images, signal) {
|
|
|
620
691
|
res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
|
|
621
692
|
} catch (e) {
|
|
622
693
|
if (!e.inputLimit || shrink < 0.3) throw e
|
|
694
|
+
// what every request carries (the system prompt and the tools) is over the limit already: no
|
|
695
|
+
// shorter conversation fits (a per-minute cap smaller than that), so not tried again
|
|
696
|
+
const fixed = Math.round((est(JSON.stringify(tools)) + sizeOf(s.messages[0])) * (strat.ratio ?? 1))
|
|
697
|
+
if (fixed >= e.inputLimit) throw Object.assign(e, { message: `${e.message} (the system prompt and tools alone are ~${fixed} tokens: this can't fit)` })
|
|
623
698
|
s.sizeCap = Math.min(s.sizeCap ?? Infinity, e.inputLimit)
|
|
624
699
|
out.end()
|
|
625
700
|
shrink *= 0.8 // strat.context already dropped to the limit; leave more headroom each time
|
|
@@ -633,6 +708,7 @@ async function runNative(s, task, images, signal) {
|
|
|
633
708
|
}
|
|
634
709
|
out.end()
|
|
635
710
|
s.messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }), ...(res.reasoning && res.toolCalls.length && { reasoning_content: res.reasoning }) }) // client.js sends it by the name the provider uses, or not at all
|
|
711
|
+
writtenBy.set(s.messages.at(-1), client.model) // its thinking goes back to this model only
|
|
636
712
|
// a tool call written out as text instead of made (some models do, after a long conversation): made
|
|
637
713
|
const leak = !res.toolCalls.length && leakedCall(res.content)
|
|
638
714
|
if (leak && tools.some((t) => t.function.name === leak.name)) {
|
|
@@ -641,6 +717,20 @@ async function runNative(s, task, images, signal) {
|
|
|
641
717
|
res.content = ''
|
|
642
718
|
s.messages[s.messages.length - 1] = { role: 'assistant', content: '', tool_calls: res.toolCalls }
|
|
643
719
|
}
|
|
720
|
+
// A tool call written as text in the model's own template (<tool_call>, <arg_key>, <function=…>,
|
|
721
|
+
// <|…|>): not an answer. The model is told and asked again (twice); then the text before the
|
|
722
|
+
// markup is the answer.
|
|
723
|
+
const markup = !res.toolCalls.length ? leakedMarkup(res.content) : null
|
|
724
|
+
if (markup && garbled++ < 2) {
|
|
725
|
+
log('task', `${client.model}: a tool call written as text; asking again`, res.content)
|
|
726
|
+
s.messages.pop()
|
|
727
|
+
s.messages.push({ role: 'user', content: prompt('agent/garbled-call') })
|
|
728
|
+
continue
|
|
729
|
+
}
|
|
730
|
+
if (markup) {
|
|
731
|
+
res.content = res.content.slice(0, markup.index).trim()
|
|
732
|
+
s.messages[s.messages.length - 1] = { ...s.messages.at(-1), content: res.content }
|
|
733
|
+
}
|
|
644
734
|
s.onEvent?.({ type: 'assistant', model: `${client.provider}/${client.model}`, content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
|
|
645
735
|
if (!res.toolCalls.length && !res.content?.trim()) {
|
|
646
736
|
// Nothing at all (seen after a failed tool call): not an answer. The same request again
|
|
@@ -703,19 +793,29 @@ async function runNative(s, task, images, signal) {
|
|
|
703
793
|
? ''
|
|
704
794
|
: (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
|
|
705
795
|
if (name !== 'run_command' && !together.has(call.id)) ui.step(name, clip(detail, 80))
|
|
706
|
-
|
|
796
|
+
let out = together.has(call.id) ? await together.get(call.id) : !allowed(s, name) ? `error: the tool ${name} is not allowed here` : args ? await runTool(name, args, s, signal, call.id) : 'error: the tool arguments were not valid JSON'
|
|
797
|
+
// the same call again and again in a row (a weak model going round): told at the third, stopped at the fifth
|
|
798
|
+
const sig = `${name} ${call.function.arguments}`
|
|
799
|
+
inRow = sig === lastCall ? inRow + 1 : 1
|
|
800
|
+
lastCall = sig
|
|
801
|
+
if (inRow >= 3) out += `\n\n${prompt('agent/repeating', { n: inRow })}`
|
|
802
|
+
if (inRow >= 5) stuck = name
|
|
707
803
|
if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
|
|
708
|
-
else if (name === 'agent' && !out
|
|
709
|
-
else if (out
|
|
804
|
+
else if (name === 'agent' && !failed(out)) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
|
|
805
|
+
else if (failed(out)) ui.result(clip(out, 120))
|
|
710
806
|
const msg = { role: 'tool', tool_call_id: call.id, content: fitResult(s, name, out) }
|
|
711
|
-
if (name === 'read_file' && args && !args.start_line && !args.end_line && !out
|
|
807
|
+
if (name === 'read_file' && args && !args.start_line && !args.end_line && !failed(out)) replaceCopy(s, args.path, msg)
|
|
712
808
|
s.messages.push(msg)
|
|
713
809
|
s.onEvent?.({ type: 'tool', id: call.id, name, output: out })
|
|
714
810
|
}
|
|
715
811
|
trimOldResults(s)
|
|
812
|
+
if (stuck) {
|
|
813
|
+
ui.warn(`stopped: ${client.model} made the same ${stuck} call five times in a row`)
|
|
814
|
+
return finalAnswer(s, signal, `the same ${stuck} call five times`)
|
|
815
|
+
}
|
|
716
816
|
// screenshots from MCP tools: tool results are text only, so images follow as a message
|
|
717
817
|
if (s.pendingImages?.length) {
|
|
718
|
-
s.messages.push({ role: 'user', content: [{ type: 'text', text: prompt('agent/tool-images') }, ...s.pendingImages.map((url) => ({ type: 'image_url', image_url: { url } }))] })
|
|
818
|
+
s.messages.push({ role: 'user', content: [{ type: 'text', text: prompt('agent/tool-images', { files: s.pendingImages.map((p) => p.file).join(', ') }) }, ...s.pendingImages.map(({ url }) => ({ type: 'image_url', image_url: { url } }))] })
|
|
719
819
|
s.pendingImages = []
|
|
720
820
|
}
|
|
721
821
|
}
|
|
@@ -825,7 +925,7 @@ function attachNamed(s, task) {
|
|
|
825
925
|
} catch {
|
|
826
926
|
continue
|
|
827
927
|
}
|
|
828
|
-
if (body
|
|
928
|
+
if (failed(body) || body.startsWith(`${f} is a binary file`)) continue // an image: read_file shows it, or says who can see it
|
|
829
929
|
const id = `named_${Date.now().toString(36)}_${calls.length}`
|
|
830
930
|
calls.push({ id, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: f }) } })
|
|
831
931
|
const msg = { role: 'tool', tool_call_id: id, content: body }
|