heartcode 0.1.5 → 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/src/agent.js CHANGED
@@ -4,25 +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 } 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' }
14
+ // Images for a helper (the agent tool's "images"), as data: URLs: image URLs (fetched), data: URLs,
15
+ // or files: in the project, saved screenshots (OUTPUTS), or a path the user gave in a message.
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
+ }
25
+ const out = []
26
+ for (const x of [].concat(list ?? []).filter((v) => v != null).map((v) => String(v).trim()).filter(Boolean)) {
27
+ if (x.startsWith('data:image/')) {
28
+ out.push(x)
29
+ } else if (/^https?:\/\//i.test(x)) {
30
+ const res = await fetch(x, { signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]) })
31
+ const type = res.headers.get('content-type')?.split(';')[0].trim() ?? ''
32
+ if (!res.ok || !type.startsWith('image/')) throw new Error(`${x} is not an image (${res.status}${type ? `, ${type}` : ''})`)
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')}`)
36
+ } else {
37
+ const abs = path.resolve(s.root, x.replace(/^~(?=\/)/, os.homedir()))
38
+ const inside = (dir) => abs.startsWith(dir + path.sep)
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))
41
+ }
42
+ }
43
+ return out
44
+ }
15
45
 
16
- // An image the message names is attached: "@shot.png", or a path anywhere (/Users/me/Downloads/a.png,
17
- // ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there. The path stays in the text.
18
- const IMAGE_PATH = /(^|\s)@?(?:(["'])([^"'\n]+?\.(?:png|jpe?g|gif|webp))\2|((?:\\ |[^\s"'])+?\.(?:png|jpe?g|gif|webp)))(?=$|[\s,;:!?).])/gi
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
19
51
  export function extractImages(task, root) {
20
52
  const images = []
21
- const text = task.replace(IMAGE_PATH, (m, pre, q, quoted, bare) => {
53
+ const text = task.replace(IMAGE_PATH, (m, pre, at, q, quoted, bare) => {
22
54
  const file = (quoted ?? bare.replace(/\\ /g, ' ')).replace(/^~(?=\/)/, os.homedir())
55
+ if (!at && !path.isAbsolute(file)) return m
23
56
  const abs = path.resolve(root, file)
24
57
  if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return m
25
- images.push(`data:${MIME[path.extname(abs).slice(1).toLowerCase()]};base64,${fs.readFileSync(abs).toString('base64')}`)
58
+ try {
59
+ images.push(imageUrl(abs, file))
60
+ } catch (e) {
61
+ return `${pre}${file} (${e.message})`
62
+ }
26
63
  return `${pre}${file}`
27
64
  })
28
65
  return { text, images }
@@ -141,8 +178,9 @@ export class Session {
141
178
  let { text, images } = extractImages(task, this.root)
142
179
  images = [...images, ...extra]
143
180
  if (images.length && !this.strat.vision) {
144
- const seer = this.team?.().find((t) => t.images) // another of your models that can: it describes them
145
- if (seer) text += await this.describeImages(images, seer, text, signal)
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)
146
184
  else this.ui.warn(`${this.client.model} can't read images (per its probe); ignoring ${images.length} image(s)`)
147
185
  images = []
148
186
  }
@@ -150,48 +188,136 @@ export class Session {
150
188
  const answer = this.strat.actions === 'tools' ? await runNative(this, text, images, signal) : await runGuided(this, text, images, signal)
151
189
  log('task', 'answer:', answer)
152
190
  this.history.push({ task: text, answer })
153
- 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
154
191
  return answer
155
192
  }
156
193
 
157
194
  // The agent tool: a helper with a fresh conversation and the same tools (but no helpers of its
158
195
  // own, no memory, no director) does one job; its final reply is the tool's result. Its steps show
159
196
  // under the main conversation's, its file changes count for /rewind like the main agent's.
160
- // ponytail: helpers run one after another; run several at once if waiting on them gets long.
161
- // model: another of your models (team) does it, for what this one can't do (see images, call tools…).
162
- async subagent(args, signal) {
197
+ // model: one of your models (this one when left out); images: for it to look at (it must see).
198
+ // A helper that fails or replies with nothing: the job goes to the next of your models that can
199
+ // do it, until one does. id: the agent tool call's; the helper's steps go to onEvent (headless,
200
+ // ACP) as that call's.
201
+ async subagent(args, signal, id) {
163
202
  const task = String(args.prompt ?? args.task ?? '').trim()
164
203
  if (!task) return 'error: give the helper its job in "prompt"'
165
- let { client, profile, strat } = this
204
+ let images
205
+ try {
206
+ images = await loadImages(args.images, this, signal)
207
+ } catch (e) {
208
+ if (e.name === 'AbortError') throw e
209
+ return `error: ${e.message}`
210
+ }
211
+ const name = (t) => `${t.provider} ${t.model}`
212
+ const self = { provider: this.client.provider, model: this.client.model, images: this.strat.vision, tools: this.strat.actions === 'tools', self: true }
213
+ const all = [self, ...(this.team?.() ?? [])]
166
214
  const want = String(args.model ?? '').trim()
167
- if (want) {
168
- const team = this.team?.() ?? []
169
- const t = team.find((x) => `${x.provider} ${x.model}` === want) ?? team.find((x) => x.model === want || want.endsWith(` ${x.model}`))
170
- if (t) ({ client, profile, strat } = await this.engineFor(t))
171
- else if (want !== client.model && !want.endsWith(` ${client.model}`)) return `error: no model "${want}". Yours: ${[client.model, ...team.map((x) => `${x.provider} ${x.model}`)].join(', ')}`
215
+ const first = !want ? self : (all.find((t) => name(t) === want) ?? all.find((t) => t.model === want || want.endsWith(` ${t.model}`)))
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
219
+ const able = (t) => !images.length || t.images
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)
222
+ if (!order.length) return 'error: none of your models can see images'
223
+ const failed = []
224
+ for (const [i, t] of order.entries()) {
225
+ let answer = ''
226
+ try {
227
+ answer = (await this.#helper(t, task, images, args.description, id, signal))?.trim()
228
+ } catch (e) {
229
+ if (e.name === 'AbortError' || signal?.aborted) throw e
230
+ failed.push(`${t.model}: ${clip(e.message, 150)}`)
231
+ }
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
+ }
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}`)
172
238
  }
173
- const label = clip(`${client.model}: ${String(args.description ?? '').trim() || task.split('\n')[0]}`, 60)
239
+ return `error: none of your models that can do it managed: ${failed.join('; ')}`
240
+ }
241
+
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).
245
+ async #helper(t, task, images, description, id, signal) {
246
+ let { client, profile, strat } = this
247
+ if (this.engineFor) {
248
+ ;({ client, profile, strat } = await this.engineFor(t))
249
+ const was = client.retryIf
250
+ client.retryIf = (e, attempt) => attempt < 3 && (!was || was(e, attempt))
251
+ }
252
+ const label = clip(`${client.model}: ${String(description ?? '').trim() || task.split('\n')[0]}`, 60)
174
253
  const helper = new Session({ client, profile, strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
175
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 })
176
255
  helper.track = (rel, before) => this.track(rel, before)
177
- log('agent', `helper "${label}": ${task}`)
178
- const answer = await (strat.actions === 'tools' ? runNative : runGuided)(helper, task, [], signal)
179
- log('agent', `helper "${label}" done`, answer)
180
- return answer?.trim() || '(the helper finished without a reply)'
256
+ helper.mcpGate = async () => (await this.#gate(helper), () => {})
257
+ if (this.onEvent) helper.onEvent = (e) => this.onEvent({ ...e, parent: id, helper: label })
258
+ log('agent', `helper "${label}" (${images.length} image(s)): ${task}`)
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
+ }
181
266
  }
182
267
 
183
- // Images for a model that can't see them: another of yours (seer) describes each, for the task.
184
- async describeImages(images, seer, task, signal) {
185
- const { client } = await this.engineFor(seer)
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()
292
+ }
293
+
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) {
186
297
  const out = []
298
+ let by = ''
187
299
  for (const [i, url] of images.entries()) {
188
- this.ui.spinner(`${seer.model} is looking at image ${i + 1}`)
189
- const res = await client.chat({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt('guided/describe-image', { task }) }, { type: 'image_url', image_url: { url } }] }] }, { signal })
190
- out.push(`Image ${i + 1}:\n${res.content.trim()}`)
191
- this.ui.step('image', `${seer.model} described image ${i + 1}`)
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)'}`)
192
318
  }
193
- log('images', `described by ${seer.provider} ${seer.model}`, out)
194
- return `\n\n${prompt('agent/images-described', { model: seer.model, descriptions: out.join('\n\n') })}`
319
+ log('images', `described by ${by}`, out)
320
+ return `\n\n${prompt('agent/images-described', { model: by || 'another model', descriptions: out.join('\n\n') })}`
195
321
  }
196
322
 
197
323
  // /btw: a side question, answered from the conversation so far. Nothing goes into the
@@ -211,7 +337,7 @@ export class Session {
211
337
  messages = [{ role: 'user', content: past ? `${prompt('agent/btw-history', { conversation: past })}\n\n${ask}` : ask }]
212
338
  }
213
339
  const side = new Client({ baseUrl: client.baseUrl, apiKey: client.apiKey, model: client.model, provider: client.provider })
214
- 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' })
215
341
  log('btw', question)
216
342
  const res = await side.chat({ messages }, { signal })
217
343
  log('btw', 'answer:', res.content)
@@ -230,23 +356,6 @@ export class Session {
230
356
  return summary
231
357
  }
232
358
 
233
- // After a task: if the user said something worth keeping and the model didn't save it, ask it to
234
- // (one small request, with only the related notes). Never fails the task.
235
- async afterTask(task, answer, signal) {
236
- const writes = this.memoryWrites ?? 0
237
- try {
238
- if (!writes && REMEMBER.test(task)) {
239
- this.ui.spinner('remembering')
240
- await this.memoryTask(this.#extractPrompt(`${task}\n${answer}`), prompt('agent/memory-exchange', { task: clip(task, 4000), answer: clip(answer, 2000) }), signal)
241
- }
242
- } catch (e) {
243
- if (e.name === 'AbortError') throw e
244
- this.ui.warn(`memory: ${e.message}`)
245
- } finally {
246
- this.memoryWrites = 0
247
- }
248
- }
249
-
250
359
  #extractPrompt(about) {
251
360
  const notes = this.memory.relevant(about, 10).map((n) => `[[${n.name}]] — ${n.description} (${[n.scope, ...n.tags].join(', ')})`).join('\n')
252
361
  return prompt('agent/memory-extract', { tags: this.memory.tagSummary() || '(none yet)', notes })
@@ -309,6 +418,7 @@ const SYSTEM = (s) => {
309
418
  prompt('agent/system', { root: s.root, files: '', more: '', caller: Boolean(s.appendSystem) }),
310
419
  instructions && prompt('agent/instructions', { instructions }),
311
420
  s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
421
+ s.strat.vision && prompt('agent/you-see'), // models that can see often say they can't
312
422
  s.mcpInstructions && prompt('agent/mcp-instructions', { instructions: s.mcpInstructions }), // what the MCP servers say about their tools
313
423
  s.director && !s.appendSystem && s.director.app.cfg.director !== false && prompt('agent/director'),
314
424
  !s.isSub && teamPrompt(s),
@@ -324,7 +434,8 @@ const SYSTEM = (s) => {
324
434
  const teamPrompt = (s) => {
325
435
  const team = s.team?.() ?? []
326
436
  if (!team.length) return ''
327
- const can = (t) => [t.tools ? 'tool calls' : 'no tool calls', t.images && 'images', t.thinking && 'thinking', `${Math.round(t.context / 1000)}k context`].filter(Boolean).join(', ')
437
+ const fails = (t) => s.failures?.[`${t.provider} ${t.model}`] && `failed ${s.failures[`${t.provider} ${t.model}`]}× this session`
438
+ const can = (t) => [t.tools ? 'tool calls' : 'no tool calls', t.images && 'images', t.thinking && 'thinking', `${Math.round(t.context / 1000)}k context`, fails(t)].filter(Boolean).join(', ')
328
439
  const you = [s.strat.vision ? 'images' : 'no images', s.strat.thinking && 'thinking'].filter(Boolean).join(', ')
329
440
  return prompt('agent/team', { models: team.map((t) => `- ${t.provider} ${t.model}: ${can(t)}`).join('\n'), you })
330
441
  }
@@ -334,10 +445,20 @@ const sizeOf = (m) =>
334
445
  (m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0) +
335
446
  (m.reasoning_content ? est(m.reasoning_content) : 0)
336
447
  export const size = (messages) => messages.reduce((n, m) => n + sizeOf(m), 0)
337
- // Words that suggest the user said something to remember (then HeartCode asks the model to save it).
338
- 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
339
448
  const kTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
340
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
+
341
462
  // "assistant to=mcp({...})", "to=functions.read_file <|constrain|>json<|message|>{...}": a tool
342
463
  // call written as text. Returns { name, arguments } (arguments as JSON text), or null.
343
464
  export function leakedCall(text) {
@@ -414,9 +535,9 @@ export function fitMessages(messages, budget) {
414
535
  // The model keeps replying with nothing (seen when it's done but its server fails to decode the
415
536
  // last message): asked once more without tools, for the answer only. Never ends a task with
416
537
  // nothing: failing that, HeartCode says itself what changed.
417
- async function finalAnswer(s, signal) {
538
+ async function finalAnswer(s, signal, why = 'empty replies in a row') {
418
539
  const { client, ui } = s
419
- log('task', 'empty replies in a row; asking for the final answer without tools')
540
+ log('task', `${why}; asking for the final answer without tools`)
420
541
  ui.spinner('asking for the final answer')
421
542
  try {
422
543
  const res = await client.chat({ messages: [...s.messages, { role: 'user', content: prompt('agent/final-answer') }] }, { signal })
@@ -509,16 +630,29 @@ async function runNative(s, task, images, signal) {
509
630
  const content = images.length ? [{ type: 'text', text: said }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] : said
510
631
  s.messages.push({ role: 'user', content })
511
632
  attachNamed(s, task) // files the message names: as if already read (saves a round trip each)
512
- const tools = [...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 }))
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
513
638
  // A todo list left done from an earlier message is cleared; an unfinished one stays.
514
639
  if (!s.isSub && s.chat.todos?.every((t) => t.status === 'completed')) s.chat.todos = []
515
640
  s.todoTouched = false
516
641
  let nudged = 0 // reminders sent this message about open todo items or a Stop hook
517
642
 
518
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
519
646
  let movedOn = 0 // models given up on for that, in this message
520
647
  // No step limit: a long fix takes many steps, and Esc stops it. A caller can set one (--max-turns).
521
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
+ }
522
656
  if (s.maxTurns && step >= s.maxTurns) {
523
657
  ui.warn(`stopped after ${s.maxTurns} steps (--max-turns); say "continue" to go on`)
524
658
  throw Object.assign(new Error(`stopped after ${s.maxTurns} steps (--max-turns)`), { maxTurns: true })
@@ -542,6 +676,9 @@ async function runNative(s, task, images, signal) {
542
676
  log('task', 'the user added while working:', added)
543
677
  s.messages.push({ role: 'user', content: prompt('agent/user-added', { text: added.join('\n\n') }) })
544
678
  }
679
+ // the model answering can change mid-message (your model list): the system prompt follows it
680
+ const now = SYSTEM(s)
681
+ if (s.messages[0]?.content !== now && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: now }
545
682
  fitMessages(s.messages, budget())
546
683
  pairTools(s.messages)
547
684
  ui.spinner('thinking')
@@ -554,6 +691,10 @@ async function runNative(s, task, images, signal) {
554
691
  res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
555
692
  } catch (e) {
556
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)` })
557
698
  s.sizeCap = Math.min(s.sizeCap ?? Infinity, e.inputLimit)
558
699
  out.end()
559
700
  shrink *= 0.8 // strat.context already dropped to the limit; leave more headroom each time
@@ -567,6 +708,7 @@ async function runNative(s, task, images, signal) {
567
708
  }
568
709
  out.end()
569
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
570
712
  // a tool call written out as text instead of made (some models do, after a long conversation): made
571
713
  const leak = !res.toolCalls.length && leakedCall(res.content)
572
714
  if (leak && tools.some((t) => t.function.name === leak.name)) {
@@ -575,7 +717,21 @@ async function runNative(s, task, images, signal) {
575
717
  res.content = ''
576
718
  s.messages[s.messages.length - 1] = { role: 'assistant', content: '', tool_calls: res.toolCalls }
577
719
  }
578
- s.onEvent?.({ type: 'assistant', content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
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
+ }
734
+ s.onEvent?.({ type: 'assistant', model: `${client.provider}/${client.model}`, content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
579
735
  if (!res.toolCalls.length && !res.content?.trim()) {
580
736
  // Nothing at all (seen after a failed tool call): not an answer. The same request again
581
737
  // first (often fine the second time), then asked to go on; three in a row ends the task.
@@ -618,7 +774,7 @@ async function runNative(s, task, images, signal) {
618
774
  const args = argsOf(call)
619
775
  if (call.function.name !== 'agent' || !args || !allowed(s, 'agent') || res.toolCalls.filter((x) => x.function.name === 'agent').length < 2) continue
620
776
  ui.step('agent', clip([args.model && `${args.model}:`, args.description].filter(Boolean).join(' '), 80))
621
- together.set(call.id, runTool('agent', args, s, signal))
777
+ together.set(call.id, runTool('agent', args, s, signal, call.id))
622
778
  }
623
779
  for (const call of res.toolCalls) {
624
780
  const name = call.function.name
@@ -637,19 +793,29 @@ async function runNative(s, task, images, signal) {
637
793
  ? ''
638
794
  : (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
639
795
  if (name !== 'run_command' && !together.has(call.id)) ui.step(name, clip(detail, 80))
640
- const 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) : 'error: the tool arguments were not valid JSON'
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
641
803
  if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
642
- else if (name === 'agent' && !out.startsWith('error')) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
643
- else if (out.startsWith('error')) ui.result(clip(out, 120))
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))
644
806
  const msg = { role: 'tool', tool_call_id: call.id, content: fitResult(s, name, out) }
645
- if (name === 'read_file' && args && !args.start_line && !args.end_line && !out.startsWith('error')) replaceCopy(s, args.path, msg)
807
+ if (name === 'read_file' && args && !args.start_line && !args.end_line && !failed(out)) replaceCopy(s, args.path, msg)
646
808
  s.messages.push(msg)
647
809
  s.onEvent?.({ type: 'tool', id: call.id, name, output: out })
648
810
  }
649
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
+ }
650
816
  // screenshots from MCP tools: tool results are text only, so images follow as a message
651
817
  if (s.pendingImages?.length) {
652
- 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 } }))] })
653
819
  s.pendingImages = []
654
820
  }
655
821
  }
@@ -759,7 +925,7 @@ function attachNamed(s, task) {
759
925
  } catch {
760
926
  continue
761
927
  }
762
- if (body.startsWith('error') || body.startsWith(`${f} is a binary file`)) continue // an image: read_file shows it, or says who can see it
928
+ if (failed(body) || body.startsWith(`${f} is a binary file`)) continue // an image: read_file shows it, or says who can see it
763
929
  const id = `named_${Date.now().toString(36)}_${calls.length}`
764
930
  calls.push({ id, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: f }) } })
765
931
  const msg = { role: 'tool', tool_call_id: id, content: body }