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/SKILL.md +33 -11
- package/bin/heartcode.js +83 -30
- package/package.json +27 -1
- package/prompts/agent.md +42 -32
- package/prompts/guided.md +6 -6
- package/src/acp.js +4 -4
- package/src/agent.js +232 -66
- package/src/client.js +165 -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 +20 -12
- package/src/hooks.js +1 -1
- package/src/mcp.js +24 -8
- package/src/memory.js +6 -4
- package/src/probe.js +8 -4
- package/src/settings.js +19 -2
- package/src/tools.js +149 -38
- package/src/usage.js +9 -4
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
|
-
|
|
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
|
|
17
|
-
// ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there.
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
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
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
log('agent', `helper "${label}"
|
|
180
|
-
|
|
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
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 ${
|
|
194
|
-
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') })}`
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
643
|
-
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))
|
|
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
|
|
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
|
|
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 }
|