heartcode 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,697 @@
1
+ import fs from 'node:fs'
2
+ import { log } from './log.js'
3
+ import { prompt, userInstructions } from './prompts.js'
4
+ import { newChat } from './sessions.js'
5
+ import path from 'node:path'
6
+ import { est, Client } from './client.js'
7
+ import { runGuided } from './guided.js'
8
+ import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput } from './tools.js'
9
+ import { loadHooks, runHooks } from './hooks.js'
10
+ import { takeConfigErrors } from './config.js'
11
+ import { c } from './ui.js'
12
+
13
+ const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
14
+
15
+ // "@shot.png" in a task attaches that image.
16
+ export function extractImages(task, root) {
17
+ const images = []
18
+ const text = task.replace(/(^|\s)@(\S+\.(png|jpe?g|gif|webp))\b/gi, (m, pre, file, ext) => {
19
+ const abs = path.resolve(root, file)
20
+ if (!fs.existsSync(abs)) return m
21
+ images.push(`data:${MIME[ext.toLowerCase()]};base64,${fs.readFileSync(abs).toString('base64')}`)
22
+ return `${pre}${file}`
23
+ })
24
+ return { text, images }
25
+ }
26
+
27
+ // One model working on one chat. The chat (sessions.js) outlives it: switching models keeps it.
28
+ export class Session {
29
+ constructor({ client, profile, strat, root, ui, chat }) {
30
+ Object.assign(this, { client, profile, strat, root, ui, chat: chat ?? newChat(root) })
31
+ }
32
+
33
+ get messages() {
34
+ return this.chat.messages
35
+ }
36
+ set messages(v) {
37
+ this.chat.messages = v
38
+ }
39
+ get history() {
40
+ return this.chat.history
41
+ }
42
+
43
+ // Forget what the model has seen (the transcript and rewind points stay).
44
+ reset() {
45
+ this.chat.messages = null
46
+ this.chat.history.length = 0
47
+ }
48
+
49
+ // Called before HeartCode changes a file: keeps its first version in this turn, for rewinds.
50
+ track(rel, before) {
51
+ const turn = this.chat.turns.at(-1)
52
+ if (turn && !(rel in turn.files)) turn.files[rel] = before
53
+ }
54
+
55
+ // Replaces the conversation so far with a summary the model writes (prompts/compact/*.md).
56
+ // midTask: compacting in the middle of a task, so the model is told to carry on with `task`
57
+ // (the user's request, kept word for word).
58
+ // The conversation before is kept per compaction ("epoch"), so /rewind can still go back.
59
+ async compact({ signal, focus = '', midTask = false, task = '' } = {}) {
60
+ const { chat, client, strat, ui } = this
61
+ const native = Boolean(chat.messages?.length)
62
+ const head = native ? chat.messages.slice(0, chat.messages[0].role === 'system' ? 1 : 2) : [] // the system prompt stays
63
+ const body = native ? chat.messages.slice(head.length) : []
64
+ const conversation = native ? transcript(body) : chat.history.map((h) => `User: ${h.task}\nAssistant: ${h.answer}`).join('\n\n')
65
+ if (!conversation.trim()) return null
66
+ let room = Math.floor((strat.context * 0.6 * 3.5) / (strat.ratio ?? 1)) // characters of conversation the summary request can carry
67
+ const cut = () => (conversation.length > room ? `[… the earliest part is cut]\n${conversation.slice(-room)}` : conversation)
68
+ const before = native ? size(body) : est(conversation)
69
+ ui.spinner(midTask ? 'context almost full: compacting the conversation' : 'compacting the conversation')
70
+ // A model shown its own work tends to carry on with it (calling tools, or writing tool calls
71
+ // as text) instead of summarizing: so it's told what it is, and a reply that isn't a summary
72
+ // is asked for again once; then the compaction fails (the conversation is trimmed instead).
73
+ const request = (extra) => prompt('compact/summarize', { conversation: cut(), focus }) + extra
74
+ const send = (extra) =>
75
+ client.chat({ messages: strat.mergeSystem ? [{ role: 'user', content: `${prompt('compact/summarizer')}\n\n${request(extra)}` }] : [{ role: 'system', content: prompt('compact/summarizer') }, { role: 'user', content: request(extra) }] }, { signal })
76
+ // too large even for the summary request: send less of it (the newest part) until it fits
77
+ const ask = async (extra = '') => {
78
+ for (;;) {
79
+ try {
80
+ return await send(extra)
81
+ } catch (e) {
82
+ if (!e.inputLimit || room < 2000) throw e
83
+ room = Math.floor(Math.min(room, (e.inputLimit * 3.5) / (strat.ratio ?? 1)) * 0.7)
84
+ }
85
+ }
86
+ }
87
+ let summary = notSummary((await ask()).content)
88
+ if (!summary) {
89
+ log('compact', 'the reply was not a summary; asking again')
90
+ summary = notSummary((await ask(`\n\n${prompt('compact/summarize-again')}`)).content)
91
+ }
92
+ if (!summary) throw new Error('the model did not write a summary')
93
+ const epoch = chat.epoch ?? 0
94
+ ;(chat.snapshots ??= [])[epoch] = { messages: chat.messages, history: [...chat.history] }
95
+ chat.sentNotes = [] // the summary replaces the messages the notes came with
96
+ chat.epoch = epoch + 1
97
+ const recap = prompt('compact/summary', { summary, requests: requestsOf(chat) })
98
+ if (native) {
99
+ chat.messages = midTask
100
+ ? [...head, { role: 'user', content: `${recap}\n\n${prompt('compact/continue', { task })}` }]
101
+ : [...head, { role: 'user', content: recap }, { role: 'assistant', content: prompt('compact/acknowledged') }]
102
+ }
103
+ chat.history.splice(0, chat.history.length, { task: '(the conversation before this point, summarized)', answer: summary })
104
+ const after = native ? size(chat.messages.slice(head.length)) : est(summary)
105
+ log('compact', `${midTask ? 'auto' : 'manual'}: ~${before} → ~${after} tokens`, summary)
106
+ ui.stopSpin()
107
+ ui.print(`${c.faint('✻')} ${c.muted(`Compacted the conversation: ~${kTokens(before)} → ~${kTokens(after)} tokens`)}`)
108
+ return summary
109
+ }
110
+
111
+ // images: data: URLs from the caller (headless input), besides @shot.png in the task.
112
+ async run(task, signal, extra = []) {
113
+ if (!this.isSub) this.client.newMessage?.() // the model list: from #1 again (unless set to stay)
114
+ const { chat, ui } = this
115
+ repairSummary(chat)
116
+ if (this.hooks !== false) this.hooks = loadHooks(this.root) // edits to hooks.json apply right away
117
+ if (!this.isSub) for (const e of takeConfigErrors()) ui.warn(e) // a hooks.json or mcp.json that doesn't parse
118
+ const hook = await runHooks(this, 'UserPromptSubmit', { prompt: task }, signal)
119
+ if (hook.block) {
120
+ const answer = prompt('agent/hook-blocked-prompt', { reason: hook.block })
121
+ ui.warn(answer)
122
+ return answer
123
+ }
124
+ if (hook.context) task = `${task}\n\n${hook.context}`
125
+ // guided mode only shows the last few exchanges: fold older ones into a summary first
126
+ if (this.strat.actions !== 'tools' && this.autoCompact && chat.history.length > 5) {
127
+ await this.compact({ signal }).catch((e) => e.name === 'AbortError' ? Promise.reject(e) : ui.warn(`could not compact: ${e.message}`))
128
+ }
129
+ const epoch = chat.epoch ?? 0
130
+ chat.turns.push({ id: Math.random().toString(36).slice(2, 10), task, at: new Date().toISOString(), epoch, msgIndex: chat.messages?.length ?? 0, histIndex: chat.history.length, logIndex: ui.inputMark ?? Math.max(0, ui.log.length - 1), files: {} })
131
+ this.onTurn?.() // saved now: a message sent and not answered yet (quit, crash) can still be continued
132
+ // a summary of the branch the user left (/tree) goes in before this message
133
+ if (chat.branchNote && this.strat.actions !== 'tools') {
134
+ chat.history.push({ task: prompt('compact/branch-note-task'), answer: chat.branchNote })
135
+ delete chat.branchNote
136
+ }
137
+ let { text, images } = extractImages(task, this.root)
138
+ images = [...images, ...extra]
139
+ if (images.length && !this.strat.vision) {
140
+ this.ui.warn(`${this.client.model} can't read images (per its probe); ignoring ${images.length} image(s)`)
141
+ images = []
142
+ }
143
+ log('task', `${text} (${this.strat.actions} mode, ${images.length} image(s))`)
144
+ const answer = this.strat.actions === 'tools' ? await runNative(this, text, images, signal) : await runGuided(this, text, images, signal)
145
+ log('task', 'answer:', answer)
146
+ this.history.push({ task: text, answer })
147
+ 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
148
+ return answer
149
+ }
150
+
151
+ // The agent tool: a helper with a fresh conversation and the same tools (but no helpers of its
152
+ // own, no memory, no director) does one job; its final reply is the tool's result. Its steps show
153
+ // under the main conversation's, its file changes count for /rewind like the main agent's.
154
+ // ponytail: helpers run one after another; run several at once if waiting on them gets long.
155
+ async subagent(args, signal) {
156
+ const task = String(args.prompt ?? args.task ?? '').trim()
157
+ if (!task) return 'error: give the helper its job in "prompt"'
158
+ const label = clip(String(args.description ?? '').trim() || task.split('\n')[0], 40)
159
+ const helper = new Session({ client: this.client, profile: this.profile, strat: this.strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
160
+ 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 })
161
+ helper.track = (rel, before) => this.track(rel, before)
162
+ log('agent', `helper "${label}": ${task}`)
163
+ const answer = await runNative(helper, task, [], signal)
164
+ log('agent', `helper "${label}" done`, answer)
165
+ return answer?.trim() || '(the helper finished without a reply)'
166
+ }
167
+
168
+ // /btw: a side question, answered from the conversation so far. Nothing goes into the
169
+ // conversation and no tools are offered, so it can run while a task works: on its own client,
170
+ // so the task's request timer and retries are left alone (its cost still counts).
171
+ async side(question, signal) {
172
+ const { client, strat, chat } = this
173
+ const ask = prompt('agent/btw', { question })
174
+ let messages
175
+ if (chat.messages?.length) {
176
+ messages = chat.messages.map((m) => ({ ...m })) // copies: trimming them leaves the real ones alone
177
+ pairTools(messages) // a tool still running has no result yet
178
+ messages.push({ role: 'user', content: ask })
179
+ fitMessages(messages, Math.floor((strat.context * 0.75) / (strat.ratio ?? 1)) - 800)
180
+ } else {
181
+ const past = chat.history.map((h) => `User: ${h.task}\nAssistant: ${h.answer}`).join('\n\n').slice(-Math.floor(strat.context * 2))
182
+ messages = [{ role: 'user', content: past ? `${prompt('agent/btw-history', { conversation: past })}\n\n${ask}` : ask }]
183
+ }
184
+ const side = new Client({ baseUrl: client.baseUrl, apiKey: client.apiKey, model: client.model, provider: client.provider })
185
+ Object.assign(side, { effort: client.effort, quirks: client.quirks, tokenRatio: client.tokenRatio, onSpend: client.onSpend, purpose: 'btw' })
186
+ log('btw', question)
187
+ const res = await side.chat({ messages }, { signal })
188
+ log('btw', 'answer:', res.content)
189
+ return res.content?.trim() ?? ''
190
+ }
191
+
192
+ // A summary of the part of a branch the user is leaving (/tree), for the branch they go to.
193
+ async summarizeBranch(conversation, files, signal) {
194
+ const { client, strat } = this
195
+ const room = Math.floor((strat.context * 0.6 * 3.5) / (strat.ratio ?? 1))
196
+ const text = conversation.length > room ? `[… the earliest part is cut]\n${conversation.slice(-room)}` : conversation
197
+ const request = prompt('compact/branch-summarize', { conversation: text, files: files.join(', ') })
198
+ const messages = strat.mergeSystem ? [{ role: 'user', content: `${prompt('compact/summarizer')}\n\n${request}` }] : [{ role: 'system', content: prompt('compact/summarizer') }, { role: 'user', content: request }]
199
+ const summary = notSummary((await client.chat({ messages }, { signal })).content)
200
+ if (!summary) throw new Error('the model did not write a summary')
201
+ return summary
202
+ }
203
+
204
+ // After a task: if the user said something worth keeping and the model didn't save it, ask it to
205
+ // (one small request, with only the related notes). Never fails the task.
206
+ async afterTask(task, answer, signal) {
207
+ const writes = this.memoryWrites ?? 0
208
+ try {
209
+ if (!writes && REMEMBER.test(task)) {
210
+ this.ui.spinner('remembering')
211
+ await this.memoryTask(this.#extractPrompt(`${task}\n${answer}`), prompt('agent/memory-exchange', { task: clip(task, 4000), answer: clip(answer, 2000) }), signal)
212
+ }
213
+ } catch (e) {
214
+ if (e.name === 'AbortError') throw e
215
+ this.ui.warn(`memory: ${e.message}`)
216
+ } finally {
217
+ this.memoryWrites = 0
218
+ }
219
+ }
220
+
221
+ #extractPrompt(about) {
222
+ const notes = this.memory.relevant(about, 10).map((n) => `[[${n.name}]] — ${n.description} (${[n.scope, ...n.tags].join(', ')})`).join('\n')
223
+ return prompt('agent/memory-extract', { tags: this.memory.tagSummary() || '(none yet)', notes })
224
+ }
225
+
226
+ // A small tool loop with only the memory tool (saving and tidying notes).
227
+ async memoryTask(system, user, signal, steps = 12) {
228
+ const tools = extraTools({ memory: this.memory }).map((f) => ({ type: 'function', function: f }))
229
+ const messages = [{ role: 'system', content: system }, { role: 'user', content: user }]
230
+ for (let step = 0; step < steps; step++) {
231
+ const was = this.client.purpose
232
+ this.client.purpose = 'memory' // in the requests log
233
+ let res
234
+ try {
235
+ res = await this.client.chat({ messages, tools }, { signal })
236
+ } finally {
237
+ this.client.purpose = was
238
+ }
239
+ messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }) })
240
+ if (!res.toolCalls.length) return res.content.trim()
241
+ for (const call of res.toolCalls) {
242
+ let args = {}
243
+ try {
244
+ args = JSON.parse(call.function.arguments || '{}')
245
+ } catch {}
246
+ const out = await runTool(call.function.name, args, this, signal)
247
+ if (args.action === 'write' || args.action === 'delete') this.ui.step('memory', `${args.action === 'write' ? 'saved' : 'deleted'} ${out.match(/\[\[[^\]]+\]\]/)?.[0] ?? ''}`)
248
+ messages.push({ role: 'tool', tool_call_id: call.id, content: out })
249
+ }
250
+ }
251
+ return ''
252
+ }
253
+
254
+ // /tidy_memory: merge duplicates, drop what's outdated, link related notes.
255
+ async tidyMemory(signal) {
256
+ const notes = this.memory.list()
257
+ if (!notes.length) return 'No notes to tidy.'
258
+ this.ui.spinner('tidying the memory')
259
+ const room = Math.floor(this.strat.context * 0.5 * 3.5)
260
+ const text = notes.map((n) => `## [[${n.name}]] (${n.scope}, ${n.type}; tags: ${n.tags.join(', ') || 'none'}) — ${n.description}\n${n.body}`).join('\n\n').slice(0, room)
261
+ const broken = this.memory.brokenLinks().map((b) => `[[${b.to}]] in [[${b.from}]]`).join(', ')
262
+ const summary = await this.memoryTask(this.#extractPrompt(''), prompt('agent/memory-tidy', { notes: text, broken }), signal, 30 + notes.length * 3)
263
+ this.ui.stopSpin()
264
+ this.ui.print(`${c.faint('✻')} ${c.muted(`Tidied the memory${summary && summary !== 'NONE' ? `: ${clip(summary.split('\n')[0], 140)}` : ''}`)}`)
265
+ return summary
266
+ }
267
+ }
268
+
269
+ // The system prompt: prompts/tools/system.md. No absolute path in it: small models copy it into
270
+ // tool calls, garbled.
271
+ // The user's HEARTCODE.md / AGENTS.md instructions are added when there are any.
272
+ // In director mode the director part is added.
273
+ // No list of the project's files: the model looks up what the task needs (list_files, search).
274
+ // A program's own instructions (--append-system-prompt: Nibedita, a script) come last, and HeartCode's
275
+ // own say less then (caller): no role of its own, no "stay in the folder", no way to finish of its
276
+ // own, no director; they're told to follow the program's where they differ.
277
+ const SYSTEM = (s) => {
278
+ const instructions = userInstructions(s.root)
279
+ return [
280
+ prompt('agent/system', { root: s.root, files: '', more: '', caller: Boolean(s.appendSystem) }),
281
+ instructions && prompt('agent/instructions', { instructions }),
282
+ s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
283
+ s.mcpInstructions && prompt('agent/mcp-instructions', { instructions: s.mcpInstructions }), // what the MCP servers say about their tools
284
+ s.director && !s.appendSystem && s.director.app.cfg.director !== false && prompt('agent/director'),
285
+ s.isSub && prompt('agent/subagent'),
286
+ s.appendSystem && prompt('agent/caller'),
287
+ s.appendSystem,
288
+ ]
289
+ .filter(Boolean)
290
+ .join('\n\n')
291
+ }
292
+
293
+ const sizeOf = (m) =>
294
+ (Array.isArray(m.content) ? m.content.reduce((n, p) => n + (p.type === 'text' ? est(p.text) : 800), 0) : est(m.content)) +
295
+ (m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0) +
296
+ (m.reasoning_content ? est(m.reasoning_content) : 0)
297
+ export const size = (messages) => messages.reduce((n, m) => n + sizeOf(m), 0)
298
+ // Words that suggest the user said something to remember (then HeartCode asks the model to save it).
299
+ 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
300
+ const kTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
301
+
302
+ // "assistant to=mcp({...})", "to=functions.read_file <|constrain|>json<|message|>{...}": a tool
303
+ // call written as text. Returns { name, arguments } (arguments as JSON text), or null.
304
+ export function leakedCall(text) {
305
+ const m = /^\s*(?:assistant\s*)?(?:<\|[^|]*\|>\s*)*to=(?:functions\.)?([\w-]+)[^{]*?(\{[\s\S]*\})\s*\)?\s*$/.exec(String(text ?? ''))
306
+ if (!m) return null
307
+ try {
308
+ JSON.parse(m[2])
309
+ } catch {
310
+ return null
311
+ }
312
+ return { name: m[1], arguments: m[2] }
313
+ }
314
+
315
+ // The user's messages in this chat, word for word, newest last (as many as fit in ~1500 tokens):
316
+ // they go with every summary, so what was asked is never lost, even when the last message is just
317
+ // "continue".
318
+ export function requestsOf(chat, room = 1500) {
319
+ const out = []
320
+ for (const t of [...chat.turns].reverse()) {
321
+ const line = `- ${t.task.trim()}`
322
+ if (est(out.join('\n') + line) > room && out.length) break
323
+ out.unshift(est(line) > room ? `${line.slice(0, room * 3)}…` : line)
324
+ }
325
+ return out.join('\n')
326
+ }
327
+
328
+ // A chat compacted by an older HeartCode can hold a "summary" that isn't one (a tool call the
329
+ // model wrote as text). It's replaced with what's known for sure: the user's messages.
330
+ export function repairSummary(chat) {
331
+ const head = prompt('compact/summary', { summary: '\u0000', requests: '' }).split('\u0000')[0]
332
+ const i = chat.messages?.findIndex((m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith(head)) ?? -1
333
+ if (i === -1) return false
334
+ const rest = chat.messages[i].content.slice(head.length)
335
+ if (rest.trim() && !leakedCall(rest.split('\n\n')[0])) return false // a real summary (only a tool call written as text is broken)
336
+ log('compact', 'a broken summary in this chat was replaced with the user\'s messages')
337
+ chat.messages[i] = { ...chat.messages[i], content: prompt('compact/summary', { summary: prompt('compact/summary-lost'), requests: requestsOf(chat) }) }
338
+ if (chat.messages[i + 1]?.role === 'assistant' && leakedCall(chat.messages[i + 1].content)) chat.messages[i + 1] = { role: 'assistant', content: prompt('compact/acknowledged') }
339
+ return true
340
+ }
341
+
342
+ // The summary, or '' when the reply isn't one: empty, too short, or a tool call written as text.
343
+ export function notSummary(text) {
344
+ const t = String(text ?? '').trim()
345
+ return t.length < 10 || leakedCall(t) || /^\s*\{\s*"/.test(t) ? '' : t
346
+ }
347
+
348
+ // A conversation as plain text, for the model to summarize. Tool calls are described, not written
349
+ // as calls, so the summarizer doesn't imitate them.
350
+ export function transcript(messages) {
351
+ const text = (content) => (Array.isArray(content) ? content.map((p) => (p.type === 'text' ? p.text : '[image]')).join(' ') : String(content ?? ''))
352
+ return messages
353
+ .map((m) => {
354
+ if (m.role === 'tool') return `[tool result] ${clip(text(m.content), 1500)}`
355
+ const calls = (m.tool_calls ?? []).map((t) => `\n[used the ${t.function.name} tool with: ${clip(t.function.arguments, 400)}]`).join('')
356
+ return `${m.role === 'user' ? 'User' : 'Assistant'}: ${text(m.content)}${calls}`
357
+ })
358
+ .join('\n\n')
359
+ }
360
+
361
+ // Keep the conversation inside the context window: shrink old tool output first, then drop old turns.
362
+ export function fitMessages(messages, budget) {
363
+ const total = () => messages.reduce((n, m) => n + sizeOf(m), 0)
364
+ for (let i = 1; i < messages.length - 2 && total() > budget; i++) {
365
+ const m = messages[i]
366
+ if (m.role === 'tool' && m.content.length > 300) m.content = m.content.slice(0, 300) + '\n[older output trimmed]'
367
+ }
368
+ while (total() > budget) {
369
+ const next = messages.findIndex((m, i) => i > 1 && m.role === 'user')
370
+ if (next === -1) break
371
+ messages.splice(1, next - 1)
372
+ }
373
+ }
374
+
375
+ // The model keeps replying with nothing (seen when it's done but its server fails to decode the
376
+ // last message): asked once more without tools, for the answer only. Never ends a task with
377
+ // nothing: failing that, HeartCode says itself what changed.
378
+ async function finalAnswer(s, signal) {
379
+ const { client, ui } = s
380
+ log('task', 'empty replies in a row; asking for the final answer without tools')
381
+ ui.spinner('asking for the final answer')
382
+ try {
383
+ const res = await client.chat({ messages: [...s.messages, { role: 'user', content: prompt('agent/final-answer') }] }, { signal })
384
+ const text = res.content?.trim()
385
+ if (text && !leakedCall(text)) {
386
+ s.messages.push({ role: 'user', content: prompt('agent/final-answer') }, { role: 'assistant', content: text })
387
+ ui.stopSpin()
388
+ const out = ui.response()
389
+ out.text(text)
390
+ out.end()
391
+ return text
392
+ }
393
+ } catch (e) {
394
+ if (e.name === 'AbortError') throw e
395
+ log('task', `final answer request failed: ${e.message}`)
396
+ }
397
+ ui.stopSpin()
398
+ const changed = Object.keys(s.chat.turns.at(-1)?.files ?? {})
399
+ const text = prompt('agent/no-final-answer', { model: client.model, changed: changed.join(', ') })
400
+ ui.warn(text)
401
+ return text
402
+ }
403
+
404
+ // Every tool call needs its result right after it, and every result its call, or the API rejects
405
+ // the request (and it would be retried forever). Esc during a tool leaves a call without a result;
406
+ // trimming can leave a result without its call. Mends both, in place.
407
+ export function pairTools(messages) {
408
+ for (let i = 0; i < messages.length; i++) {
409
+ const m = messages[i]
410
+ if (m.role === 'tool') {
411
+ const owner = messages.slice(0, i).findLast((x) => x.role !== 'tool')
412
+ if (!owner?.tool_calls?.some((t) => t.id === m.tool_call_id)) messages.splice(i--, 1)
413
+ continue
414
+ }
415
+ if (!m.tool_calls?.length) continue
416
+ let j = i + 1
417
+ const have = new Set()
418
+ while (messages[j]?.role === 'tool') have.add(messages[j++].tool_call_id)
419
+ const missing = m.tool_calls.filter((t) => !have.has(t.id)).map((t) => ({ role: 'tool', tool_call_id: t.id, content: '(not run: the user stopped it)' }))
420
+ messages.splice(j, 0, ...missing)
421
+ }
422
+ }
423
+
424
+ async function runNative(s, task, images, signal) {
425
+ const { client, strat, ui } = s
426
+ if (s.mcp?.names.length) {
427
+ ui.spinner('getting the MCP servers\' tools')
428
+ await loadMcpTools(s, signal) // before the system prompt: it has their instructions
429
+ }
430
+ // The model's thinking goes back with its steps while it works on a message (it keeps its plan
431
+ // from one tool call to the next), not into later messages.
432
+ for (const m of s.messages ?? []) delete m.reasoning_content
433
+ // The system prompt is rebuilt for every message: files come and go, and a resumed chat's is
434
+ // from back then (it once told the model the project was empty after it had made files).
435
+ // Unchanged, it stays the same text, so a provider's prompt cache still holds.
436
+ const sys = SYSTEM(s)
437
+ if (!s.messages) {
438
+ s.messages = strat.mergeSystem ? [{ role: 'user', content: sys }, { role: 'assistant', content: prompt('agent/system-acknowledged') }] : [{ role: 'system', content: sys }]
439
+ } else if (s.messages[0]?.content !== sys && (s.messages[0]?.role === 'system' || strat.mergeSystem)) {
440
+ s.messages[0] = { ...s.messages[0], content: sys }
441
+ }
442
+ if (s.chat.branchNote && !s.isSub) {
443
+ s.messages.push({ role: 'user', content: s.chat.branchNote }, { role: 'assistant', content: prompt('compact/acknowledged') })
444
+ delete s.chat.branchNote
445
+ }
446
+ let shrink = 1 // drops when the server says a request is too large
447
+ s.compactedAt = -1
448
+ // What may be sent: the window less a tenth (estimates are estimates) and less room for the
449
+ // reply (the longest the model gives, from models.dev, at most a quarter of the window; 8k when
450
+ // unknown) and the tools; trimming keeps to it. Auto-compact starts at 90% of it, so it comes
451
+ // before any trimming.
452
+ // s.sizeCap: "too large" without the server saying its limit: a guess for this conversation only
453
+ const windowSize = () => Math.min(strat.context, s.sizeCap ?? Infinity)
454
+ const reply = () => Math.min(strat.reply || 8192, Math.floor(windowSize() * 0.25))
455
+ const budget = () => Math.floor(((windowSize() * 0.9 - reply()) * shrink) / (strat.ratio ?? 1)) - 800 // in estimated tokens
456
+ const nearlyFull = () => s.autoCompact && s.messages.length > 3 && size(s.messages) > budget() * 0.9
457
+ // Already near full from earlier messages: compact first, so the new message stays word for word.
458
+ if (nearlyFull()) {
459
+ await s.compact({ signal }).catch((e) => (e.name === 'AbortError' ? Promise.reject(e) : ui.warn(`could not compact: ${e.message}`)))
460
+ }
461
+ // only the memory notes this message matches go with it (the system prompt has just the tags)
462
+ // each note once per conversation (again if it changed, or after compacting dropped it)
463
+ const sent = new Set(s.chat.sentNotes ?? [])
464
+ const key = (n) => `${n.scope}:${n.name}:${n.description}`
465
+ const notes = (s.memory?.relevant(task) ?? []).filter((n) => !sent.has(key(n)))
466
+ s.chat.sentNotes = [...sent, ...notes.map(key)]
467
+ const text = notes.length ? `${task}\n\n${prompt('agent/memory-relevant', { notes: notes.map((n) => `[[${n.name}]] — ${n.description}`).join('\n') })}` : task
468
+ const planning = ui.permission === 'plan'
469
+ const said = planning && !s.isSub ? `${text}\n\n${prompt('agent/plan-mode')}` : text
470
+ const content = images.length ? [{ type: 'text', text: said }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] : said
471
+ s.messages.push({ role: 'user', content })
472
+ attachNamed(s, task) // files the message names: as if already read (saves a round trip each)
473
+ 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 }))
474
+ // A todo list left done from an earlier message is cleared; an unfinished one stays.
475
+ if (!s.isSub && s.chat.todos?.every((t) => t.status === 'completed')) s.chat.todos = []
476
+ s.todoTouched = false
477
+ let nudged = 0 // reminders sent this message about open todo items or a Stop hook
478
+
479
+ let empty = 0 // replies in a row with neither text nor tool calls
480
+ let movedOn = 0 // models given up on for that, in this message
481
+ // No step limit: a long fix takes many steps, and Esc stops it. A caller can set one (--max-turns).
482
+ for (let step = 0; ; step++) {
483
+ if (s.maxTurns && step >= s.maxTurns) {
484
+ ui.warn(`stopped after ${s.maxTurns} steps (--max-turns); say "continue" to go on`)
485
+ throw Object.assign(new Error(`stopped after ${s.maxTurns} steps (--max-turns)`), { maxTurns: true })
486
+ }
487
+ // after a failed compaction, the next try waits until the conversation grew by a fifth
488
+ if (nearlyFull() && s.messages.length > 4 && step !== s.compactedAt && size(s.messages) > (s.compactFailedAt ?? 0) * 1.2) {
489
+ s.compactedAt = step
490
+ await s.compact({ signal, midTask: true, task }).catch((e) => {
491
+ if (e.name === 'AbortError') throw e
492
+ // no summary: free the space anyway, by shortening older tool output and dropping the oldest turns
493
+ fitMessages(s.messages, Math.floor(budget() * 0.5))
494
+ s.compactFailedAt = size(s.messages)
495
+ ui.warn(`could not compact (${e.message}); shortened older tool output and messages instead`)
496
+ log('compact', `failed (${e.message}); trimmed to ~${s.compactFailedAt} tokens`)
497
+ })
498
+ }
499
+ // what the user typed while it worked goes in before the next step (steering)
500
+ const added = step && !s.isSub ? (ui.takeAhead?.() ?? []) : []
501
+ if (added.length) {
502
+ for (const l of added) ui.print(`${c.magenta('♥')} ${c.bright(l)}`)
503
+ log('task', 'the user added while working:', added)
504
+ s.messages.push({ role: 'user', content: prompt('agent/user-added', { text: added.join('\n\n') }) })
505
+ }
506
+ fitMessages(s.messages, budget())
507
+ pairTools(s.messages)
508
+ ui.spinner('thinking')
509
+ const out = ui.response()
510
+ const stream = strat.toolsStream
511
+ let res
512
+ try {
513
+ // A long tool call (a whole file) streams in silently: say what's being written.
514
+ const onToolDelta = (name, args) => ui.spinner(`${name === 'run_command' ? 'preparing a command' : `writing ${/"path"\s*:\s*"([^"]+)"/.exec(args)?.[1] ?? name}`} · ${args.length >= 1000 ? `${(args.length / 1000).toFixed(1)}k` : args.length} chars`)
515
+ res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
516
+ } catch (e) {
517
+ if (!e.inputLimit || shrink < 0.3) throw e
518
+ s.sizeCap = Math.min(s.sizeCap ?? Infinity, e.inputLimit)
519
+ out.end()
520
+ shrink *= 0.8 // strat.context already dropped to the limit; leave more headroom each time
521
+ step--
522
+ continue
523
+ }
524
+ ui.stopSpin()
525
+ if (!stream) {
526
+ out.think(res.reasoning)
527
+ out.text(res.content)
528
+ }
529
+ out.end()
530
+ 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
531
+ // a tool call written out as text instead of made (some models do, after a long conversation): made
532
+ const leak = !res.toolCalls.length && leakedCall(res.content)
533
+ if (leak && tools.some((t) => t.function.name === leak.name)) {
534
+ log('tool', `tool call written as text, made for real: ${leak.name}`)
535
+ res.toolCalls = [{ id: `text_${Date.now().toString(36)}`, type: 'function', function: { name: leak.name, arguments: leak.arguments } }]
536
+ res.content = ''
537
+ s.messages[s.messages.length - 1] = { role: 'assistant', content: '', tool_calls: res.toolCalls }
538
+ }
539
+ s.onEvent?.({ type: 'assistant', content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
540
+ if (!res.toolCalls.length && !res.content?.trim()) {
541
+ // Nothing at all (seen after a failed tool call): not an answer. The same request again
542
+ // first (often fine the second time), then asked to go on; three in a row ends the task.
543
+ s.messages.pop() // some APIs reject an empty assistant message
544
+ if (empty++ < 3) {
545
+ log('task', `empty reply ${empty}; ${empty === 1 ? 'asking again' : 'asking the model to continue'}`)
546
+ if (empty > 1 && s.messages.at(-1)?.content !== prompt('agent/empty-reply')) s.messages.push({ role: 'user', content: prompt('agent/empty-reply') })
547
+ continue
548
+ }
549
+ // still nothing: the next model in your list carries on (each one once), before giving up
550
+ const was = client.model
551
+ if (movedOn < (client.rotation?.list.length ?? 1) - 1 && client.moveOn(`${was}: ${empty} empty replies in a row`)) {
552
+ movedOn++
553
+ empty = 0
554
+ ui.warn(`${was} keeps replying with nothing; going on with ${client.model}`)
555
+ continue
556
+ }
557
+ return finalAnswer(s, signal)
558
+ }
559
+ empty = 0
560
+ if (!res.toolCalls.length) {
561
+ const more = nudged < 3 && !s.isSub && (await notDone(s, signal, nudged > 0))
562
+ if (!more) return res.content
563
+ nudged++
564
+ s.messages.push({ role: 'user', content: more })
565
+ continue
566
+ }
567
+
568
+ for (const call of res.toolCalls) {
569
+ const name = call.function.name
570
+ let args = null
571
+ try {
572
+ args = JSON.parse(call.function.arguments || '{}')
573
+ } catch {}
574
+ const detail = !args
575
+ ? ''
576
+ : args.server
577
+ ? `${args.server} · ${args.tool ?? ''}`
578
+ : name === 'memory'
579
+ ? `${args.action} ${args.name ? `[[${args.name}]]` : (args.query ?? '')}`
580
+ : name === 'manage'
581
+ ? [args.action, args.kind, args.name].filter(Boolean).join(' ')
582
+ : name === 'agent'
583
+ ? (args.description ?? '')
584
+ : name === 'todo' || name === 'ask_user'
585
+ ? ''
586
+ : (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
587
+ if (name !== 'run_command') ui.step(name, clip(detail, 80))
588
+ const out = !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'
589
+ if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
590
+ else if (name === 'agent' && !out.startsWith('error')) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
591
+ else if (out.startsWith('error')) ui.result(clip(out, 120))
592
+ const msg = { role: 'tool', tool_call_id: call.id, content: fitResult(s, name, out) }
593
+ if (name === 'read_file' && args && !args.start_line && !args.end_line && !out.startsWith('error')) replaceCopy(s, args.path, msg)
594
+ s.messages.push(msg)
595
+ s.onEvent?.({ type: 'tool', id: call.id, name, output: out })
596
+ }
597
+ trimOldResults(s)
598
+ // screenshots from MCP tools: tool results are text only, so images follow as a message
599
+ if (s.pendingImages?.length) {
600
+ s.messages.push({ role: 'user', content: [{ type: 'text', text: prompt('agent/tool-images') }, ...s.pendingImages.map((url) => ({ type: 'image_url', image_url: { url } }))] })
601
+ s.pendingImages = []
602
+ }
603
+ }
604
+ }
605
+
606
+ // The model says it's done: is it? Not while its todo list (from this message) has open items, or
607
+ // while a Stop hook says otherwise. Returns what to tell it, or null. Each is asked a few times at
608
+ // most (the caller counts), so a model that can't finish still ends.
609
+ async function notDone(s, signal, again) {
610
+ const open = s.todoTouched && s.ui.permission !== 'plan' ? (s.chat.todos ?? []).filter((t) => t.status !== 'completed') : [] // plan mode: they wait for the approval
611
+ if (open.length) {
612
+ log('task', `stopped with ${open.length} open todo item(s); reminding`)
613
+ return prompt('agent/todo-unfinished', { items: open.map((t) => `- ${t.content}`).join('\n') })
614
+ }
615
+ const hook = await runHooks(s, 'Stop', { stop_hook_active: again }, signal)
616
+ return hook.block ? prompt('agent/hook-stop', { reason: hook.block }) : null
617
+ }
618
+
619
+ // A helper agent's view of the screen: its steps are labelled, and its replies (which go to the
620
+ // main agent) aren't shown as answers. Everything else is the main UI's.
621
+ function helperUI(ui, label) {
622
+ const quiet = { think() {}, text() {}, end() {} }
623
+ const own = {
624
+ step: (name, detail) => ui.step(` ${label} › ${name}`, detail),
625
+ result: (text) => ui.result(` ${text}`),
626
+ response: () => quiet,
627
+ spinner: (text) => ui.spinner(`${label} · ${text}`),
628
+ }
629
+ return new Proxy(ui, { get: (t, k) => (k in own ? own[k] : typeof t[k] === 'function' ? t[k].bind(t) : t[k]), set: (t, k, v) => ((t[k] = v), true) })
630
+ }
631
+
632
+ export { allowed }
633
+
634
+ const shortened = new WeakSet() // kept outside the messages: providers reject unknown message fields
635
+
636
+ // How long a tool result may be in the conversation: a tenth of the window, 4k to 100k characters
637
+ // (Claude Code allows about 25k tokens). A longer one: its start, and the whole of it saved to a
638
+ // file the model can read in parts (read_file, search), as Claude Code does.
639
+ export function fitResult(s, name, out) {
640
+ const max = Math.max(4000, Math.min(100_000, Math.floor((s.strat.context * 3.5 * 0.1) / (s.strat.ratio ?? 1))))
641
+ if (out.length <= max) return out
642
+ const file = saveOutput(s.chat?.id, name, out)
643
+ log('tool', `${name}: ${out.length} characters, ${max} shown${file ? `; all of it in ${file}` : ''}`)
644
+ return `${out.slice(0, max)}\n…\n${prompt('agent/output-cut', { shown: max, total: out.length, file })}`
645
+ }
646
+
647
+ // A long task's conversation fills up with old tool output (a browser's page snapshots, each out of
648
+ // date by the next). Past 12 long results, all but the newest 4 are cut to their start, each saved
649
+ // to a file first. All at once, so a provider's prompt cache (the conversation's unchanged start)
650
+ // is broken rarely, not at every step.
651
+ export function trimOldResults(s, keep = 4, most = 12) {
652
+ const long = s.messages.filter((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.length > 2000 && !shortened.has(m) && !/^\[older output/.test(m.content))
653
+ if (long.length <= most) return
654
+ for (const m of long.slice(0, -keep)) {
655
+ const file = saveOutput(s.chat?.id, 'older', m.content)
656
+ m.content = `${prompt('agent/output-old', { total: m.content.length, file })}\n${m.content.slice(0, 600)}\n…`
657
+ shortened.add(m)
658
+ }
659
+ log('tool', `older tool output cut: ${long.length - keep} results`)
660
+ }
661
+
662
+ // A file read again: the older copy in the conversation becomes a one-line note.
663
+ function replaceCopy(s, file, msg) {
664
+ const key = path.normalize(String(file))
665
+ const older = (s.copies ??= new Map()).get(key)
666
+ if (older && !shortened.has(older)) {
667
+ older.content = prompt('agent/older-copy', { file })
668
+ shortened.add(older)
669
+ }
670
+ s.copies.set(key, msg)
671
+ }
672
+
673
+ // Files the user's message names go in as if the model had already read them: one round trip less
674
+ // per file, and the model starts from the real content. Small text files only, at most 3.
675
+ function attachNamed(s, task) {
676
+ const files = namedFiles(task, listFiles(s.root)).slice(0, 3)
677
+ const calls = []
678
+ const results = []
679
+ for (const f of files) {
680
+ let body
681
+ try {
682
+ if (fs.statSync(path.join(s.root, f)).size > 40_000) continue
683
+ body = readFileTool(s.root, f)
684
+ } catch {
685
+ continue
686
+ }
687
+ if (body.startsWith('error')) continue
688
+ const id = `named_${Date.now().toString(36)}_${calls.length}`
689
+ calls.push({ id, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: f }) } })
690
+ const msg = { role: 'tool', tool_call_id: id, content: body }
691
+ replaceCopy(s, f, msg)
692
+ results.push(msg)
693
+ s.ui.step('read_file', f)
694
+ s.ui.result(`${body.split('\n').length} lines`)
695
+ }
696
+ if (calls.length) s.messages.push({ role: 'assistant', content: '', tool_calls: calls }, ...results)
697
+ }