heartcode 0.0.0-stage → 0.1.0

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,642 @@
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 } 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') await this.afterTask(text, answer, signal)
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, 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 res = await this.client.chat({ messages, tools }, { signal })
232
+ messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }) })
233
+ if (!res.toolCalls.length) return res.content.trim()
234
+ for (const call of res.toolCalls) {
235
+ let args = {}
236
+ try {
237
+ args = JSON.parse(call.function.arguments || '{}')
238
+ } catch {}
239
+ const out = await runTool(call.function.name, args, this, signal)
240
+ if (args.action === 'write' || args.action === 'delete') this.ui.step('memory', `${args.action === 'write' ? 'saved' : 'deleted'} ${out.match(/\[\[[^\]]+\]\]/)?.[0] ?? ''}`)
241
+ messages.push({ role: 'tool', tool_call_id: call.id, content: out })
242
+ }
243
+ }
244
+ return ''
245
+ }
246
+
247
+ // /tidy_memory: merge duplicates, drop what's outdated, link related notes.
248
+ async tidyMemory(signal) {
249
+ const notes = this.memory.list()
250
+ if (!notes.length) return 'No notes to tidy.'
251
+ this.ui.spinner('tidying the memory')
252
+ const room = Math.floor(this.strat.context * 0.5 * 3.5)
253
+ 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)
254
+ const broken = this.memory.brokenLinks().map((b) => `[[${b.to}]] in [[${b.from}]]`).join(', ')
255
+ const summary = await this.memoryTask(this.#extractPrompt(''), prompt('agent/memory-tidy', { notes: text, broken }), signal, 30 + notes.length * 3)
256
+ this.ui.stopSpin()
257
+ this.ui.print(`${c.faint('✻')} ${c.muted(`Tidied the memory${summary && summary !== 'NONE' ? `: ${clip(summary.split('\n')[0], 140)}` : ''}`)}`)
258
+ return summary
259
+ }
260
+ }
261
+
262
+ // The system prompt: prompts/tools/system.md. No absolute path in it: small models copy it into
263
+ // tool calls, garbled.
264
+ // The user's HEARTCODE.md / AGENTS.md instructions are added when there are any.
265
+ // In director mode the director part is added.
266
+ // No list of the project's files: the model looks up what the task needs (list_files, search).
267
+ const SYSTEM = (s) => {
268
+ const instructions = userInstructions(s.root)
269
+ return [
270
+ prompt('agent/system', { root: s.root, files: '', more: '' }),
271
+ instructions && prompt('agent/instructions', { instructions }),
272
+ s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
273
+ s.director && s.director.app.cfg.director !== false && prompt('agent/director'),
274
+ s.isSub && prompt('agent/subagent'),
275
+ s.appendSystem, // from the caller (--append-system-prompt)
276
+ ]
277
+ .filter(Boolean)
278
+ .join('\n\n')
279
+ }
280
+
281
+ const sizeOf = (m) =>
282
+ (Array.isArray(m.content) ? m.content.reduce((n, p) => n + (p.type === 'text' ? est(p.text) : 800), 0) : est(m.content)) +
283
+ (m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0)
284
+ export const size = (messages) => messages.reduce((n, m) => n + sizeOf(m), 0)
285
+ // Words that suggest the user said something to remember (then HeartCode asks the model to save it).
286
+ 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
287
+ const kTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
288
+
289
+ // "assistant to=mcp({...})", "to=functions.read_file <|constrain|>json<|message|>{...}": a tool
290
+ // call written as text. Returns { name, arguments } (arguments as JSON text), or null.
291
+ export function leakedCall(text) {
292
+ const m = /^\s*(?:assistant\s*)?(?:<\|[^|]*\|>\s*)*to=(?:functions\.)?([\w-]+)[^{]*?(\{[\s\S]*\})\s*\)?\s*$/.exec(String(text ?? ''))
293
+ if (!m) return null
294
+ try {
295
+ JSON.parse(m[2])
296
+ } catch {
297
+ return null
298
+ }
299
+ return { name: m[1], arguments: m[2] }
300
+ }
301
+
302
+ // The user's messages in this chat, word for word, newest last (as many as fit in ~1500 tokens):
303
+ // they go with every summary, so what was asked is never lost, even when the last message is just
304
+ // "continue".
305
+ export function requestsOf(chat, room = 1500) {
306
+ const out = []
307
+ for (const t of [...chat.turns].reverse()) {
308
+ const line = `- ${t.task.trim()}`
309
+ if (est(out.join('\n') + line) > room && out.length) break
310
+ out.unshift(est(line) > room ? `${line.slice(0, room * 3)}…` : line)
311
+ }
312
+ return out.join('\n')
313
+ }
314
+
315
+ // A chat compacted by an older HeartCode can hold a "summary" that isn't one (a tool call the
316
+ // model wrote as text). It's replaced with what's known for sure: the user's messages.
317
+ export function repairSummary(chat) {
318
+ const head = prompt('compact/summary', { summary: '\u0000', requests: '' }).split('\u0000')[0]
319
+ const i = chat.messages?.findIndex((m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith(head)) ?? -1
320
+ if (i === -1) return false
321
+ const rest = chat.messages[i].content.slice(head.length)
322
+ if (rest.trim() && !leakedCall(rest.split('\n\n')[0])) return false // a real summary (only a tool call written as text is broken)
323
+ log('compact', 'a broken summary in this chat was replaced with the user\'s messages')
324
+ chat.messages[i] = { ...chat.messages[i], content: prompt('compact/summary', { summary: prompt('compact/summary-lost'), requests: requestsOf(chat) }) }
325
+ if (chat.messages[i + 1]?.role === 'assistant' && leakedCall(chat.messages[i + 1].content)) chat.messages[i + 1] = { role: 'assistant', content: prompt('compact/acknowledged') }
326
+ return true
327
+ }
328
+
329
+ // The summary, or '' when the reply isn't one: empty, too short, or a tool call written as text.
330
+ export function notSummary(text) {
331
+ const t = String(text ?? '').trim()
332
+ return t.length < 10 || leakedCall(t) || /^\s*\{\s*"/.test(t) ? '' : t
333
+ }
334
+
335
+ // A conversation as plain text, for the model to summarize. Tool calls are described, not written
336
+ // as calls, so the summarizer doesn't imitate them.
337
+ export function transcript(messages) {
338
+ const text = (content) => (Array.isArray(content) ? content.map((p) => (p.type === 'text' ? p.text : '[image]')).join(' ') : String(content ?? ''))
339
+ return messages
340
+ .map((m) => {
341
+ if (m.role === 'tool') return `[tool result] ${clip(text(m.content), 1500)}`
342
+ const calls = (m.tool_calls ?? []).map((t) => `\n[used the ${t.function.name} tool with: ${clip(t.function.arguments, 400)}]`).join('')
343
+ return `${m.role === 'user' ? 'User' : 'Assistant'}: ${text(m.content)}${calls}`
344
+ })
345
+ .join('\n\n')
346
+ }
347
+
348
+ // Keep the conversation inside the context window: shrink old tool output first, then drop old turns.
349
+ export function fitMessages(messages, budget) {
350
+ const total = () => messages.reduce((n, m) => n + sizeOf(m), 0)
351
+ for (let i = 1; i < messages.length - 2 && total() > budget; i++) {
352
+ const m = messages[i]
353
+ if (m.role === 'tool' && m.content.length > 300) m.content = m.content.slice(0, 300) + '\n[older output trimmed]'
354
+ }
355
+ while (total() > budget) {
356
+ const next = messages.findIndex((m, i) => i > 1 && m.role === 'user')
357
+ if (next === -1) break
358
+ messages.splice(1, next - 1)
359
+ }
360
+ }
361
+
362
+ // The model keeps replying with nothing (seen when it's done but its server fails to decode the
363
+ // last message): asked once more without tools, for the answer only. Never ends a task with
364
+ // nothing: failing that, HeartCode says itself what changed.
365
+ async function finalAnswer(s, signal) {
366
+ const { client, ui } = s
367
+ log('task', 'empty replies in a row; asking for the final answer without tools')
368
+ ui.spinner('asking for the final answer')
369
+ try {
370
+ const res = await client.chat({ messages: [...s.messages, { role: 'user', content: prompt('agent/final-answer') }] }, { signal })
371
+ const text = res.content?.trim()
372
+ if (text && !leakedCall(text)) {
373
+ s.messages.push({ role: 'user', content: prompt('agent/final-answer') }, { role: 'assistant', content: text })
374
+ ui.stopSpin()
375
+ const out = ui.response()
376
+ out.text(text)
377
+ out.end()
378
+ return text
379
+ }
380
+ } catch (e) {
381
+ if (e.name === 'AbortError') throw e
382
+ log('task', `final answer request failed: ${e.message}`)
383
+ }
384
+ ui.stopSpin()
385
+ const changed = Object.keys(s.chat.turns.at(-1)?.files ?? {})
386
+ const text = prompt('agent/no-final-answer', { model: client.model, changed: changed.join(', ') })
387
+ ui.warn(text)
388
+ return text
389
+ }
390
+
391
+ // Every tool call needs its result right after it, and every result its call, or the API rejects
392
+ // the request (and it would be retried forever). Esc during a tool leaves a call without a result;
393
+ // trimming can leave a result without its call. Mends both, in place.
394
+ export function pairTools(messages) {
395
+ for (let i = 0; i < messages.length; i++) {
396
+ const m = messages[i]
397
+ if (m.role === 'tool') {
398
+ const owner = messages.slice(0, i).findLast((x) => x.role !== 'tool')
399
+ if (!owner?.tool_calls?.some((t) => t.id === m.tool_call_id)) messages.splice(i--, 1)
400
+ continue
401
+ }
402
+ if (!m.tool_calls?.length) continue
403
+ let j = i + 1
404
+ const have = new Set()
405
+ while (messages[j]?.role === 'tool') have.add(messages[j++].tool_call_id)
406
+ 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)' }))
407
+ messages.splice(j, 0, ...missing)
408
+ }
409
+ }
410
+
411
+ async function runNative(s, task, images, signal) {
412
+ const { client, strat, ui } = s
413
+ // The system prompt is rebuilt for every message: files come and go, and a resumed chat's is
414
+ // from back then (it once told the model the project was empty after it had made files).
415
+ // Unchanged, it stays the same text, so a provider's prompt cache still holds.
416
+ const sys = SYSTEM(s)
417
+ if (!s.messages) {
418
+ s.messages = strat.mergeSystem ? [{ role: 'user', content: sys }, { role: 'assistant', content: prompt('agent/system-acknowledged') }] : [{ role: 'system', content: sys }]
419
+ } else if (s.messages[0]?.content !== sys && (s.messages[0]?.role === 'system' || strat.mergeSystem)) {
420
+ s.messages[0] = { ...s.messages[0], content: sys }
421
+ }
422
+ if (s.chat.branchNote && !s.isSub) {
423
+ s.messages.push({ role: 'user', content: s.chat.branchNote }, { role: 'assistant', content: prompt('compact/acknowledged') })
424
+ delete s.chat.branchNote
425
+ }
426
+ let shrink = 1 // drops when the server says a request is too large
427
+ s.compactedAt = -1
428
+ // What may be sent: the window less a tenth (estimates are estimates) and less room for the
429
+ // reply (the longest the model gives, from models.dev, at most a quarter of the window; 8k when
430
+ // unknown) and the tools; trimming keeps to it. Auto-compact starts at 90% of it, so it comes
431
+ // before any trimming.
432
+ // s.sizeCap: "too large" without the server saying its limit: a guess for this conversation only
433
+ const windowSize = () => Math.min(strat.context, s.sizeCap ?? Infinity)
434
+ const reply = () => Math.min(strat.reply || 8192, Math.floor(windowSize() * 0.25))
435
+ const budget = () => Math.floor(((windowSize() * 0.9 - reply()) * shrink) / (strat.ratio ?? 1)) - 800 // in estimated tokens
436
+ const nearlyFull = () => s.autoCompact && s.messages.length > 3 && size(s.messages) > budget() * 0.9
437
+ // Already near full from earlier messages: compact first, so the new message stays word for word.
438
+ if (nearlyFull()) {
439
+ await s.compact({ signal }).catch((e) => (e.name === 'AbortError' ? Promise.reject(e) : ui.warn(`could not compact: ${e.message}`)))
440
+ }
441
+ // only the memory notes this message matches go with it (the system prompt has just the tags)
442
+ // each note once per conversation (again if it changed, or after compacting dropped it)
443
+ const sent = new Set(s.chat.sentNotes ?? [])
444
+ const key = (n) => `${n.scope}:${n.name}:${n.description}`
445
+ const notes = (s.memory?.relevant(task) ?? []).filter((n) => !sent.has(key(n)))
446
+ s.chat.sentNotes = [...sent, ...notes.map(key)]
447
+ const text = notes.length ? `${task}\n\n${prompt('agent/memory-relevant', { notes: notes.map((n) => `[[${n.name}]] — ${n.description}`).join('\n') })}` : task
448
+ const planning = ui.permission === 'plan'
449
+ const said = planning && !s.isSub ? `${text}\n\n${prompt('agent/plan-mode')}` : text
450
+ const content = images.length ? [{ type: 'text', text: said }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] : said
451
+ s.messages.push({ role: 'user', content })
452
+ attachNamed(s, task) // files the message names: as if already read (saves a round trip each)
453
+ 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 }))
454
+ // A todo list left done from an earlier message is cleared; an unfinished one stays.
455
+ if (!s.isSub && s.chat.todos?.every((t) => t.status === 'completed')) s.chat.todos = []
456
+ s.todoTouched = false
457
+ let nudged = 0 // reminders sent this message about open todo items or a Stop hook
458
+
459
+ let empty = 0 // replies in a row with neither text nor tool calls
460
+ // No step limit: a long fix takes many steps, and Esc stops it. A caller can set one (--max-turns).
461
+ for (let step = 0; ; step++) {
462
+ if (s.maxTurns && step >= s.maxTurns) {
463
+ ui.warn(`stopped after ${s.maxTurns} steps (--max-turns); say "continue" to go on`)
464
+ throw Object.assign(new Error(`stopped after ${s.maxTurns} steps (--max-turns)`), { maxTurns: true })
465
+ }
466
+ // after a failed compaction, the next try waits until the conversation grew by a fifth
467
+ if (nearlyFull() && s.messages.length > 4 && step !== s.compactedAt && size(s.messages) > (s.compactFailedAt ?? 0) * 1.2) {
468
+ s.compactedAt = step
469
+ await s.compact({ signal, midTask: true, task }).catch((e) => {
470
+ if (e.name === 'AbortError') throw e
471
+ // no summary: free the space anyway, by shortening older tool output and dropping the oldest turns
472
+ fitMessages(s.messages, Math.floor(budget() * 0.5))
473
+ s.compactFailedAt = size(s.messages)
474
+ ui.warn(`could not compact (${e.message}); shortened older tool output and messages instead`)
475
+ log('compact', `failed (${e.message}); trimmed to ~${s.compactFailedAt} tokens`)
476
+ })
477
+ }
478
+ // what the user typed while it worked goes in before the next step (steering)
479
+ const added = step && !s.isSub ? (ui.takeAhead?.() ?? []) : []
480
+ if (added.length) {
481
+ for (const l of added) ui.print(`${c.magenta('♥')} ${c.bright(l)}`)
482
+ log('task', 'the user added while working:', added)
483
+ s.messages.push({ role: 'user', content: prompt('agent/user-added', { text: added.join('\n\n') }) })
484
+ }
485
+ fitMessages(s.messages, budget())
486
+ pairTools(s.messages)
487
+ ui.spinner('thinking')
488
+ const out = ui.response()
489
+ const stream = strat.toolsStream
490
+ let res
491
+ try {
492
+ // A long tool call (a whole file) streams in silently: say what's being written.
493
+ 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`)
494
+ res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
495
+ } catch (e) {
496
+ if (!e.inputLimit || shrink < 0.3) throw e
497
+ s.sizeCap = Math.min(s.sizeCap ?? Infinity, e.inputLimit)
498
+ out.end()
499
+ shrink *= 0.8 // strat.context already dropped to the limit; leave more headroom each time
500
+ step--
501
+ continue
502
+ }
503
+ ui.stopSpin()
504
+ if (!stream) {
505
+ out.think(res.reasoning)
506
+ out.text(res.content)
507
+ }
508
+ out.end()
509
+ s.messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }) })
510
+ // a tool call written out as text instead of made (some models do, after a long conversation): made
511
+ const leak = !res.toolCalls.length && leakedCall(res.content)
512
+ if (leak && tools.some((t) => t.function.name === leak.name)) {
513
+ log('tool', `tool call written as text, made for real: ${leak.name}`)
514
+ res.toolCalls = [{ id: `text_${Date.now().toString(36)}`, type: 'function', function: { name: leak.name, arguments: leak.arguments } }]
515
+ res.content = ''
516
+ s.messages[s.messages.length - 1] = { role: 'assistant', content: '', tool_calls: res.toolCalls }
517
+ }
518
+ s.onEvent?.({ type: 'assistant', content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
519
+ if (!res.toolCalls.length && !res.content?.trim()) {
520
+ // Nothing at all (seen after a failed tool call): not an answer. The same request again
521
+ // first (often fine the second time), then asked to go on; three in a row ends the task.
522
+ s.messages.pop() // some APIs reject an empty assistant message
523
+ if (empty++ < 3) {
524
+ log('task', `empty reply ${empty}; ${empty === 1 ? 'asking again' : 'asking the model to continue'}`)
525
+ if (empty > 1 && s.messages.at(-1)?.content !== prompt('agent/empty-reply')) s.messages.push({ role: 'user', content: prompt('agent/empty-reply') })
526
+ continue
527
+ }
528
+ return finalAnswer(s, signal)
529
+ }
530
+ empty = 0
531
+ if (!res.toolCalls.length) {
532
+ const more = nudged < 3 && !s.isSub && (await notDone(s, signal, nudged > 0))
533
+ if (!more) return res.content
534
+ nudged++
535
+ s.messages.push({ role: 'user', content: more })
536
+ continue
537
+ }
538
+
539
+ for (const call of res.toolCalls) {
540
+ const name = call.function.name
541
+ let args = null
542
+ try {
543
+ args = JSON.parse(call.function.arguments || '{}')
544
+ } catch {}
545
+ const detail = !args
546
+ ? ''
547
+ : args.server
548
+ ? `${args.server} · ${args.tool ?? ''}`
549
+ : name === 'memory'
550
+ ? `${args.action} ${args.name ? `[[${args.name}]]` : (args.query ?? '')}`
551
+ : name === 'manage'
552
+ ? [args.action, args.kind, args.name].filter(Boolean).join(' ')
553
+ : name === 'agent'
554
+ ? (args.description ?? '')
555
+ : name === 'todo' || name === 'ask_user'
556
+ ? ''
557
+ : (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
558
+ if (name !== 'run_command') ui.step(name, clip(detail, 80))
559
+ 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'
560
+ if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
561
+ else if (name === 'agent' && !out.startsWith('error')) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
562
+ else if (out.startsWith('error')) ui.result(clip(out, 120))
563
+ const msg = { role: 'tool', tool_call_id: call.id, content: clip(out, Math.min(12_000, strat.context)) }
564
+ if (name === 'read_file' && args && !args.start_line && !args.end_line && !out.startsWith('error')) replaceCopy(s, args.path, msg)
565
+ s.messages.push(msg)
566
+ s.onEvent?.({ type: 'tool', id: call.id, name, output: out })
567
+ }
568
+ // screenshots from MCP tools: tool results are text only, so images follow as a message
569
+ if (s.pendingImages?.length) {
570
+ s.messages.push({ role: 'user', content: [{ type: 'text', text: prompt('agent/tool-images') }, ...s.pendingImages.map((url) => ({ type: 'image_url', image_url: { url } }))] })
571
+ s.pendingImages = []
572
+ }
573
+ }
574
+ }
575
+
576
+ // The model says it's done: is it? Not while its todo list (from this message) has open items, or
577
+ // while a Stop hook says otherwise. Returns what to tell it, or null. Each is asked a few times at
578
+ // most (the caller counts), so a model that can't finish still ends.
579
+ async function notDone(s, signal, again) {
580
+ const open = s.todoTouched && s.ui.permission !== 'plan' ? (s.chat.todos ?? []).filter((t) => t.status !== 'completed') : [] // plan mode: they wait for the approval
581
+ if (open.length) {
582
+ log('task', `stopped with ${open.length} open todo item(s); reminding`)
583
+ return prompt('agent/todo-unfinished', { items: open.map((t) => `- ${t.content}`).join('\n') })
584
+ }
585
+ const hook = await runHooks(s, 'Stop', { stop_hook_active: again }, signal)
586
+ return hook.block ? prompt('agent/hook-stop', { reason: hook.block }) : null
587
+ }
588
+
589
+ // A helper agent's view of the screen: its steps are labelled, and its replies (which go to the
590
+ // main agent) aren't shown as answers. Everything else is the main UI's.
591
+ function helperUI(ui, label) {
592
+ const quiet = { think() {}, text() {}, end() {} }
593
+ const own = {
594
+ step: (name, detail) => ui.step(` ${label} › ${name}`, detail),
595
+ result: (text) => ui.result(` ${text}`),
596
+ response: () => quiet,
597
+ spinner: (text) => ui.spinner(`${label} · ${text}`),
598
+ }
599
+ 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) })
600
+ }
601
+
602
+ // Tools the caller allowed (--allowedTools) or took away (--disallowedTools); all by default.
603
+ export const allowed = (s, name) => (!s.allowTools || s.allowTools.has(name)) && !s.denyTools?.has(name)
604
+
605
+ const shortened = new WeakSet() // kept outside the messages: providers reject unknown message fields
606
+
607
+ // A file read again: the older copy in the conversation becomes a one-line note.
608
+ function replaceCopy(s, file, msg) {
609
+ const key = path.normalize(String(file))
610
+ const older = (s.copies ??= new Map()).get(key)
611
+ if (older && !shortened.has(older)) {
612
+ older.content = prompt('agent/older-copy', { file })
613
+ shortened.add(older)
614
+ }
615
+ s.copies.set(key, msg)
616
+ }
617
+
618
+ // Files the user's message names go in as if the model had already read them: one round trip less
619
+ // per file, and the model starts from the real content. Small text files only, at most 3.
620
+ function attachNamed(s, task) {
621
+ const files = namedFiles(task, listFiles(s.root)).slice(0, 3)
622
+ const calls = []
623
+ const results = []
624
+ for (const f of files) {
625
+ let body
626
+ try {
627
+ if (fs.statSync(path.join(s.root, f)).size > 40_000) continue
628
+ body = readFileTool(s.root, f)
629
+ } catch {
630
+ continue
631
+ }
632
+ if (body.startsWith('error')) continue
633
+ const id = `named_${Date.now().toString(36)}_${calls.length}`
634
+ calls.push({ id, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: f }) } })
635
+ const msg = { role: 'tool', tool_call_id: id, content: body }
636
+ replaceCopy(s, f, msg)
637
+ results.push(msg)
638
+ s.ui.step('read_file', f)
639
+ s.ui.result(`${body.split('\n').length} lines`)
640
+ }
641
+ if (calls.length) s.messages.push({ role: 'assistant', content: '', tool_calls: calls }, ...results)
642
+ }