heartcode 0.1.6 → 0.1.8

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,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
- out.push(`data:${type};base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`)
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(`data:${MIME[ext]};base64,${fs.readFileSync(abs).toString('base64')}`)
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 names is attached: "@shot.png", or a path anywhere (/Users/me/Downloads/a.png,
42
- // ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there. The path stays in the text.
43
- 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
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
- 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
+ }
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 seer = this.team?.().find((t) => t.images) // another of your models that can: it describes them
170
- 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)
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 order = [first, ...all.filter((t) => t !== first)].filter(able)
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)} did it${failed.length ? `; before it: ${failed.join('; ')}` : ''})\n\n${answer}`
218
- if (failed.length < i + 1) failed.push(`${t.model}: no reply`)
219
- this.failures = { ...this.failures, [name(t)]: (this.failures?.[name(t)] ?? 0) + 1 } // shown to the model (Your other models)
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 (!t.self) {
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)) // then the next model gets the job
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
- const answer = await (strat.actions === 'tools' ? runNative : runGuided)(helper, task, images, signal)
240
- log('agent', `helper "${label}" done`, answer)
241
- return answer
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: another of yours (seer) describes each, for the task.
245
- async describeImages(images, seer, task, signal) {
246
- const { client } = await this.engineFor(seer)
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
- this.ui.spinner(`${seer.model} is looking at image ${i + 1}`)
250
- const res = await client.chat({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt('guided/describe-image', { task }) }, { type: 'image_url', image_url: { url } }] }] }, { signal })
251
- out.push(`Image ${i + 1}:\n${res.content.trim()}`)
252
- 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)'}`)
253
318
  }
254
- log('images', `described by ${seer.provider} ${seer.model}`, out)
255
- 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') })}`
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', 'empty replies in a row; asking for the final answer without tools')
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
- 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
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
- 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, call.id) : '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
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.startsWith('error')) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
709
- 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))
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.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)
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.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
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 }