loom-agent 1.3.2 → 1.5.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/CHANGELOG.md +216 -10
- package/README.md +10 -8
- package/package.json +2 -2
- package/src/core/agents.js +14 -1
- package/src/core/cli.js +30 -2
- package/src/core/hooks.js +28 -14
- package/src/core/memory.js +54 -6
- package/src/core/permissions.js +17 -4
- package/src/core/plugin-cmd.js +88 -1
- package/src/core/sandbox.js +311 -75
- package/src/core/session.js +262 -35
- package/src/core/transcript-undo.js +74 -0
- package/src/core/update.js +116 -6
- package/src/mcp/connector-presets.js +105 -0
- package/src/providers/anthropic.js +122 -25
- package/src/providers/google.js +19 -11
- package/src/providers/nvidia.js +45 -33
- package/src/providers/openai-compat.js +59 -27
- package/src/providers/openai.js +42 -18
- package/src/providers/openrouter.js +56 -26
- package/src/tools/index.js +206 -35
- package/src/tui/App.tsx +250 -36
- package/src/tui/components/Modals.tsx +130 -26
- package/src/tui/mcp-presets.ts +4 -65
- package/src/tui/store.ts +6 -1
package/src/core/session.js
CHANGED
|
@@ -71,15 +71,34 @@ function isAbortError(err) {
|
|
|
71
71
|
return /aborted|cancel(led|ed)/i.test(String(err.message || ''));
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Rate-limit errors — transient (429 / overloaded): wait and retry rather
|
|
75
|
+
// than switching models. Checked BEFORE isQuotaError so a 429 never triggers
|
|
76
|
+
// a spurious model switch.
|
|
77
|
+
function isRateLimitError(err) {
|
|
78
|
+
if (!err) return false;
|
|
79
|
+
const status = Number(err.status || err.code || err.error?.status || 0);
|
|
80
|
+
if (status === 429 || status === 529) return true;
|
|
81
|
+
const msg = String(err.message || err.error?.message || '');
|
|
82
|
+
return /rate.?limit|too many requests|overloaded/i.test(msg);
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
// Quota/credits exhausted errors — the model is out of tokens (free tiers,
|
|
75
86
|
// daily limits, billing caps). These are switch-to-another-model triggers.
|
|
87
|
+
// Rate-limit wording is deliberately NOT matched here (see isRateLimitError).
|
|
76
88
|
function isQuotaError(err) {
|
|
77
89
|
if (!err) return false;
|
|
78
90
|
const status = Number(err.status || err.code || err.error?.status || 0);
|
|
79
91
|
if (status === 402) return true;
|
|
80
|
-
if (status === 429) return true;
|
|
81
92
|
const msg = String(err.message || err.error?.message || err.body?.error?.message || '');
|
|
82
|
-
return /quota|exhausted|insufficient.{0,20}(balance|credits|quota)|out of (tokens|credits|balance)|no (more )?(tokens|credits|balance)|
|
|
93
|
+
return /quota|exhausted|insufficient.{0,20}(balance|credits|quota)|out of (tokens|credits|balance)|no (more )?(tokens|credits|balance)|billing limit|payment required|max.?quota|limit reached/i.test(msg);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Context-overflow errors — the prompt exceeded the model window. Compacting
|
|
97
|
+
// the conversation and retrying is the correct recovery (not a model switch).
|
|
98
|
+
function isContextOverflowError(err) {
|
|
99
|
+
if (!err) return false;
|
|
100
|
+
const msg = String(err.message || err.error?.message || err.body?.error?.message || '');
|
|
101
|
+
return /prompt is too long|context_length_exceeded|input length.{0,40}maximum length|request_too_large|too many tokens|maximum context length|reduce the length/i.test(msg);
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
class Session {
|
|
@@ -93,6 +112,8 @@ class Session {
|
|
|
93
112
|
this.tokensUsed = 0;
|
|
94
113
|
this.tokensIn = 0;
|
|
95
114
|
this.tokensOut = 0;
|
|
115
|
+
this.cacheReadTokens = 0;
|
|
116
|
+
this.cacheWriteTokens = 0;
|
|
96
117
|
this.sessionCost = 0;
|
|
97
118
|
this.permissions = new PermissionManager();
|
|
98
119
|
// OpenCode-style permission tree (config.permission + config.agent.<id>.permission).
|
|
@@ -218,6 +239,7 @@ class Session {
|
|
|
218
239
|
const plat = require('./platform').detect();
|
|
219
240
|
const shell = plat.platform === 'win32' ? 'PowerShell 5.1' : 'bash/zsh';
|
|
220
241
|
const hasWSL = plat.isWSL ? ' (WSL)' : '';
|
|
242
|
+
const hasTools = this.mode === 'build' || this.mode === 'plan';
|
|
221
243
|
return `You are Loom, a terminal coding agent: fast, terse, action-first.
|
|
222
244
|
|
|
223
245
|
## Environment
|
|
@@ -228,41 +250,43 @@ class Session {
|
|
|
228
250
|
|
|
229
251
|
## Memory (from LOOM.md)
|
|
230
252
|
${memory}
|
|
231
|
-
|
|
253
|
+
${hasTools ? `
|
|
232
254
|
When the user states a durable preference, correction, or project fact ("we use bun", "never touch X", "deploy via Y"), persist it yourself: append ONE dated bullet under the "## Remembered" heading of ./LOOM.md using the edit tool (create the heading if missing). Keep it terse; don't ask permission for obvious preferences. Don't store secrets or one-off task details.
|
|
233
|
-
|
|
255
|
+
` : ''}${this.mode === 'build' ? `
|
|
234
256
|
## Skills
|
|
235
257
|
${this.loadSkills()}
|
|
236
|
-
|
|
258
|
+
` : ''}
|
|
237
259
|
## Behavior
|
|
238
260
|
- Decide before acting: if the answer needs no external data, reply directly with ZERO tool calls. Simple or conversational questions are answered in one message, nothing runs.
|
|
239
|
-
|
|
261
|
+
${hasTools ? `- When tools ARE needed, act first and batch independent calls (e.g., read a file, grep a symbol, glob files at once). Never call tools speculatively or one at a time when they can be batched.
|
|
240
262
|
- Never call a time/date tool or a sequential-thinking tool — today's date is already in Environment, and you never need to "think" via a tool.
|
|
241
263
|
- Extended thinking is ON when your model supports it: reason BEFORE you act and re-evaluate AFTER every tool result. For complex or multi-step tasks, think hard at each stage (plan → act → check) instead of rushing to the answer — this is what separates a good result from a guessed one.
|
|
242
|
-
- Be terse in the final reply: say what changed and nothing else.
|
|
264
|
+
` : ''}- Be terse in the final reply: say what changed and nothing else.
|
|
243
265
|
- Never narrate ("I will now read the file…"). Just call the tool.
|
|
244
|
-
|
|
266
|
+
${this.mode === 'build' ? `- Prefer edits over full-file writes when the change is small.
|
|
245
267
|
- After writing code, run any test/bench command the user has relied on if one exists.
|
|
246
|
-
|
|
268
|
+
` : ''}
|
|
247
269
|
## Formatting
|
|
248
270
|
- ALWAYS reply in Markdown. The terminal renders it with colors, so make every reply structured and readable:
|
|
249
271
|
- Use **bold** for key terms and important results, \`inline code\` for file paths, symbols and commands, and fenced \`\`\` blocks for any code or config.
|
|
250
272
|
- Use ## headings and - bullet lists to organize longer replies; keep code inside fenced blocks, never inline dumps.
|
|
251
273
|
- Even one-line answers should still use markdown inline styling where it helps (e.g. "Done — **rewrote** \`src/app.ts\`: \`+12 -4\` lines, tests **pass**.").
|
|
252
274
|
- Never reply with a wall of plain unstyled text — the user's UI highlights markdown, and raw text renders as a single flat color.
|
|
253
|
-
|
|
254
|
-
|
|
275
|
+
${this.mode === 'build' ? `- Multi-step work MUST be tracked with the todowrite tool (the UI mirrors it live in a sidebar and in the chat): call it up front with the task list, update item statuses (pending/in_progress/completed/cancelled) as you work, and call it one last time when done. Keep the list items terse ("Fix signup bug", not prose).
|
|
276
|
+
` : ''}
|
|
255
277
|
## Hard rules
|
|
256
|
-
|
|
278
|
+
${hasTools ? `- Batch independent tool calls into a single response; never send one tool call at a time.
|
|
257
279
|
- You may only issue multiple tool calls if none of them depends on another's result.
|
|
258
280
|
- Never guess a tool result — read it before your next call.
|
|
259
|
-
- Never commit/push without explicit instruction. Never print API keys.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
281
|
+
` : ''}- Never commit/push without explicit instruction. Never print API keys.
|
|
282
|
+
${hasTools ? `- When a tool FAILS, do not retry the same call with the same arguments. After the first failure, change the approach: e.g. use read/grep to understand the state before trying an edit again, run a simpler variant of the command, or ask the user for the missing input (API key, path, permission). Three same-goal failures == stop and escalate: say what failed and suggest the fix instead of looping.
|
|
283
|
+
` : ''}
|
|
284
|
+
${this.mode === 'plan' ? `- For long outputs (links, large files), stream whatever the tool gave you in one message — do not emit partial/truncated responses piecemeal.
|
|
285
|
+
` : ''}
|
|
263
286
|
## Mode
|
|
264
287
|
You are in ${this.mode === 'build' ? 'BUILD MODE (full agent)' : this.mode === 'plan' ? 'PLAN MODE (read-only analysis)' : 'CHAT MODE (conversation only)'}.
|
|
265
288
|
${this.mode === 'build' ? `- You have all tools available: read, write, edit, bash, glob, grep, webfetch, and MCP tools.
|
|
289
|
+
- bash runs on the user's machine. When isolation is genuinely needed (the user asked for the sandbox, or the command is risky/Linux-only), run it INSIDE Docker instead: the \`sandbox\` tool, or bash with sandbox:true — both execute via \`docker exec loom-sandbox sh -lc "<command>"\`. Never run a sandbox-intended command on the host, and never for ordinary commands.
|
|
266
290
|
- Inspect, edit, and verify the result yourself end-to-end.
|
|
267
291
|
- When done, summarize in 1-2 sentences.` : ''}${this.mode === 'plan' ? `- Read-only tools only: read, glob, grep, webfetch, todowrite. No MCP tools.
|
|
268
292
|
- Investigate thoroughly, then output a "## Plan" with ordered steps (exact file path + what changes each step makes). No narration.` : ''}${this.mode === 'chat' ? `- No tools available; answer conversationally.
|
|
@@ -282,6 +306,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
282
306
|
return '\n\n## Acting as the "' + agent.name + '" agent\n' + agent.prompt + '\n';
|
|
283
307
|
}
|
|
284
308
|
|
|
309
|
+
// Dynamic Docker-sandbox line, resolved lazily per turn (like _primaryBlock)
|
|
310
|
+
// so /sandbox setup / on / off apply to the NEXT turn without rebuilding the
|
|
311
|
+
// base prompt. Config-only read — never spawns docker (no TUI freezes).
|
|
312
|
+
_sandboxBlock() {
|
|
313
|
+
if (this.mode !== 'build') return '';
|
|
314
|
+
let ready = false;
|
|
315
|
+
try { ready = require('./sandbox').shouldUseSandbox(); } catch { return ''; }
|
|
316
|
+
if (!ready) return '';
|
|
317
|
+
return '\n\n## Sandbox (Docker, ready)\n'
|
|
318
|
+
+ '- The loom-sandbox container is set up. When the user asks to run something "in the sandbox", or isolation is genuinely needed, run the command through `docker exec loom-sandbox sh -lc "<command>"` (workdir /workspace) — i.e. the `sandbox` tool, or bash with sandbox:true. NEVER run a sandbox-intended command directly on the host instead.\n'
|
|
319
|
+
+ '- Ordinary commands stay on the host via plain bash (or bash with sandbox omitted).\n';
|
|
320
|
+
}
|
|
321
|
+
|
|
285
322
|
// Create ./LOOM.md with a template when missing, so memory exists from the
|
|
286
323
|
// first turn (Claude Code behavior). Disable with LOOM_MEM_AUTO=0 (tests).
|
|
287
324
|
ensureMemoryFile() {
|
|
@@ -324,14 +361,53 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
324
361
|
if (!skills.length) return '(No skills installed. Run /skills to see options.)';
|
|
325
362
|
// Compact index only — full instructions are injected per-turn by the
|
|
326
363
|
// skill matcher when the user's message matches a skill's keywords.
|
|
364
|
+
// Descriptions are capped so one verbose skill can't bloat the system
|
|
365
|
+
// prompt (and its cache prefix) on every turn.
|
|
327
366
|
return skills
|
|
328
|
-
.map((s) =>
|
|
367
|
+
.map((s) => {
|
|
368
|
+
let d = s.description || 'no description';
|
|
369
|
+
if (d.length > 80) d = d.slice(0, 77).trimEnd() + '…';
|
|
370
|
+
return `- ${s.name}: ${d}`;
|
|
371
|
+
})
|
|
329
372
|
.join('\n');
|
|
330
373
|
} catch (e) {
|
|
331
374
|
return '(Skills unavailable: ' + e.message + ')';
|
|
332
375
|
}
|
|
333
376
|
}
|
|
334
377
|
|
|
378
|
+
// Cache-stable system prompt: the per-turn skill block is the ONLY part of
|
|
379
|
+
// the system prefix that changes between turns. Keeping it out of the system
|
|
380
|
+
// string (injected into the last user message instead) means Anthropic's
|
|
381
|
+
// system-prompt cache_control breakpoint stays byte-identical across turns
|
|
382
|
+
// even when a different skill matches, so cache reads keep hitting.
|
|
383
|
+
// Resolves the lazy primary/sandbox blocks once and caches them on
|
|
384
|
+
// _resolvedPrimary/_resolvedSandbox so estimateTokens sees the same strings
|
|
385
|
+
// that go out on the request.
|
|
386
|
+
_systemPrefix() {
|
|
387
|
+
this._resolvedPrimary = this._primaryBlock();
|
|
388
|
+
this._resolvedSandbox = this._sandboxBlock();
|
|
389
|
+
return this.systemPrompt + (this._agentBlock || '') + this._resolvedPrimary + this._resolvedSandbox;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Older tool results are elided to a head+tail slice before they reach the
|
|
393
|
+
// provider. Kept in-memory verbatim for the TUI; only the payload shrinks.
|
|
394
|
+
// Keep the most recent TOOL_RESULT_RECENT messages untouched so the cache
|
|
395
|
+
// prefix and the model's working set stay stable.
|
|
396
|
+
ageHistory(messages) {
|
|
397
|
+
const AGE_HEAD = 2000, AGE_TAIL = 400, RECENT = 12;
|
|
398
|
+
if (!messages || messages.length <= RECENT) return messages;
|
|
399
|
+
const cutoff = messages.length - RECENT;
|
|
400
|
+
return messages.map((m, i) => {
|
|
401
|
+
if (i >= cutoff || m.role !== 'tool') return m;
|
|
402
|
+
const text = typeof m.content === 'string' ? m.content : (m.content == null ? '' : JSON.stringify(m.content));
|
|
403
|
+
if (text.length <= AGE_HEAD + AGE_TAIL + 60) return m;
|
|
404
|
+
return Object.assign({}, m, {
|
|
405
|
+
content: text.slice(0, AGE_HEAD) + '\n[…older tool result elided — re-read the file if still needed…]\n' + text.slice(-AGE_TAIL),
|
|
406
|
+
_aged: true,
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
335
411
|
interrupt() {
|
|
336
412
|
this.interrupted = true;
|
|
337
413
|
if (this.abortController) this.abortController.abort();
|
|
@@ -367,6 +443,9 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
367
443
|
// trigger a refresh. Re-reading disk and re-init'ing the provider on every
|
|
368
444
|
// message was noticeable overhead per turn.
|
|
369
445
|
this.interrupted = false;
|
|
446
|
+
// Restored/compacted sessions may carry a history whose first row isn't
|
|
447
|
+
// a user message — Anthropic rejects that shape. Normalize once here.
|
|
448
|
+
this._normalizeHistory();
|
|
370
449
|
|
|
371
450
|
// Skill auto-trigger: if the user's message mentions a skill keyword
|
|
372
451
|
// (e.g. "slice", "gcode", "cad"), load the matched skill's instructions
|
|
@@ -391,6 +470,18 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
391
470
|
this._skillBlock = '';
|
|
392
471
|
}
|
|
393
472
|
|
|
473
|
+
// userPromptSubmit hook: may veto the prompt outright, or inject extra
|
|
474
|
+
// context (lint output, ticket data) appended to the user's message.
|
|
475
|
+
try {
|
|
476
|
+
const gate = await hooks.runHook('userPromptSubmit', { prompt: text });
|
|
477
|
+
if (gate.blocked) {
|
|
478
|
+
return { type: 'error', content: 'Prompt blocked by userPromptSubmit hook.' + (gate.reason ? ' ' + gate.reason : '') };
|
|
479
|
+
}
|
|
480
|
+
if (gate.additionalContext) {
|
|
481
|
+
text = text + '\n\n<user-prompt-submit-hook>\n' + gate.additionalContext + '\n</user-prompt-submit-hook>';
|
|
482
|
+
}
|
|
483
|
+
} catch {}
|
|
484
|
+
|
|
394
485
|
this.addMessage({ role: 'user', content: text });
|
|
395
486
|
this.turnCount++;
|
|
396
487
|
const turnAgent = this.agent?.id || null;
|
|
@@ -467,13 +558,24 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
467
558
|
|
|
468
559
|
// ─── Compaction ───
|
|
469
560
|
// Rough token estimate from message text (chars/4) — used to decide when
|
|
470
|
-
// the context window is getting full.
|
|
561
|
+
// the context window is getting full. Includes the system prompt and the
|
|
562
|
+
// serialized tool schemas: with many MCP tools those can be tens of
|
|
563
|
+
// thousands of tokens that a messages-only estimate ignores.
|
|
471
564
|
estimateTokens() {
|
|
472
565
|
let chars = 0;
|
|
473
566
|
for (const m of this.messages) {
|
|
474
567
|
chars += String(m.content || '').length;
|
|
475
568
|
for (const tc of m.toolCalls || []) chars += JSON.stringify(tc.input || {}).length;
|
|
476
569
|
}
|
|
570
|
+
chars += String(this.systemPrompt || '').length;
|
|
571
|
+
chars += this._toolSchemaChars || 0;
|
|
572
|
+
// Per-turn system-prompt suffixes (agent/primary/sandbox) + the skill
|
|
573
|
+
// block ride on top of this.systemPrompt — include them or the estimate
|
|
574
|
+
// under-counts exactly the parts that grow the request.
|
|
575
|
+
chars += (this._agentBlock || '').length;
|
|
576
|
+
chars += (this._resolvedPrimary || '').length;
|
|
577
|
+
chars += (this._resolvedSandbox || '').length;
|
|
578
|
+
chars += (this._skillBlock || '').length;
|
|
477
579
|
return Math.ceil(chars / 4);
|
|
478
580
|
}
|
|
479
581
|
|
|
@@ -482,15 +584,39 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
482
584
|
return meta?.context || 200000;
|
|
483
585
|
}
|
|
484
586
|
|
|
587
|
+
// Live context estimate for the /context TUI command: tokens (messages +
|
|
588
|
+
// system prompt + tool schemas), window size, and percent used. Recomputed
|
|
589
|
+
// on every call so it reflects the CURRENT conversation, not a startup
|
|
590
|
+
// snapshot.
|
|
591
|
+
contextEstimate() {
|
|
592
|
+
const tokens = this.estimateTokens();
|
|
593
|
+
const window = this.getContextWindow();
|
|
594
|
+
return { tokens, window, pct: Math.min(100, Math.round((tokens / window) * 100)) };
|
|
595
|
+
}
|
|
596
|
+
|
|
485
597
|
shouldCompact() {
|
|
486
598
|
if (this.messages.length < COMPACT_MIN_MESSAGES) return false;
|
|
487
599
|
const threshold = this.config.compactThreshold ?? COMPACT_DEFAULT_THRESHOLD;
|
|
488
600
|
return this.estimateTokens() > this.getContextWindow() * threshold;
|
|
489
601
|
}
|
|
490
602
|
|
|
603
|
+
// Ensure a compacted/resumed history starts with a user message — Anthropic
|
|
604
|
+
// rejects conversations whose first row is assistant/tool. Called after
|
|
605
|
+
// compaction and at the start of each user turn (covers restored sessions).
|
|
606
|
+
_normalizeHistory() {
|
|
607
|
+
if (!this.messages.length || this.messages[0].role === 'user') return;
|
|
608
|
+
let start = 0;
|
|
609
|
+
while (start < this.messages.length && this.messages[start].role !== 'user') start++;
|
|
610
|
+
if (start >= this.messages.length) return; // no user row — leave alone
|
|
611
|
+
this.messages = this.messages.slice(start);
|
|
612
|
+
}
|
|
613
|
+
|
|
491
614
|
// Real compaction: summarize the older messages with the model and keep the
|
|
492
615
|
// most recent COMPACT_KEEP_MESSAGES verbatim. Falls back to truncation when
|
|
493
|
-
// no provider/key is available or the summary call fails.
|
|
616
|
+
// no provider/key is available or the summary call fails. The summary is
|
|
617
|
+
// stored as a USER-role row (not system): openai-compat drops history rows
|
|
618
|
+
// with role 'system', which silently discarded the summary on every
|
|
619
|
+
// non-Anthropic provider.
|
|
494
620
|
async compact(callbacks = {}) {
|
|
495
621
|
if (this.messages.length <= COMPACT_KEEP_MESSAGES + 2) {
|
|
496
622
|
return { compacted: false, reason: 'conversation too short', removed: 0, method: 'none' };
|
|
@@ -522,9 +648,10 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
522
648
|
|
|
523
649
|
if (summary) {
|
|
524
650
|
this.messages = [
|
|
525
|
-
{ role: '
|
|
651
|
+
{ role: 'user', content: '[Compacted — earlier conversation summarized. Ask for details if you need specifics.]\n\n' + summary.slice(0, 8000) },
|
|
526
652
|
...keep,
|
|
527
653
|
];
|
|
654
|
+
this._normalizeHistory();
|
|
528
655
|
this.compactCount = (this.compactCount || 0) + 1;
|
|
529
656
|
this.lastCompact = { at: Date.now(), method: 'summary', removed: head.length };
|
|
530
657
|
if (callbacks.onCompact) callbacks.onCompact({ method: 'summary', removed: head.length, summary });
|
|
@@ -533,9 +660,10 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
533
660
|
|
|
534
661
|
// Fallback: drop the oldest messages, keep the recent tail verbatim.
|
|
535
662
|
this.messages = [
|
|
536
|
-
{ role: '
|
|
663
|
+
{ role: 'user', content: `[Compacted — ${head.length} earlier messages truncated. Ask for details if you need specifics.]` },
|
|
537
664
|
...keep,
|
|
538
665
|
];
|
|
666
|
+
this._normalizeHistory();
|
|
539
667
|
this.compactCount = (this.compactCount || 0) + 1;
|
|
540
668
|
this.lastCompact = { at: Date.now(), method: 'truncate', removed: head.length };
|
|
541
669
|
if (callbacks.onCompact) callbacks.onCompact({ method: 'truncate', removed: head.length, summary: null });
|
|
@@ -607,21 +735,55 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
607
735
|
let todowroteThisTurn = false;
|
|
608
736
|
// Transient provider-error retry state.
|
|
609
737
|
let providerRetries = 0;
|
|
738
|
+
// Reactive compaction already fired this turn (context-overflow 400).
|
|
739
|
+
let compactedThisTurn = false;
|
|
740
|
+
// Mid-turn compaction already fired this turn.
|
|
741
|
+
let compactedMidTurn = false;
|
|
610
742
|
while (true) {
|
|
611
743
|
if (this.interrupted) {
|
|
612
744
|
return finishInterrupted();
|
|
613
745
|
}
|
|
614
746
|
|
|
747
|
+
// Mid-turn compaction: a long tool loop can fill the window well past
|
|
748
|
+
// the threshold — compact before the next model call instead of dying
|
|
749
|
+
// with a context-overflow error.
|
|
750
|
+
if (ranTools && !compactedMidTurn && !this.interrupted && this.shouldCompact()) {
|
|
751
|
+
compactedMidTurn = true;
|
|
752
|
+
try {
|
|
753
|
+
const res = await this.compact(callbacks);
|
|
754
|
+
if (res.compacted && callbacks.onAutoCompact) callbacks.onAutoCompact(res);
|
|
755
|
+
} catch {}
|
|
756
|
+
}
|
|
757
|
+
|
|
615
758
|
let resp;
|
|
616
759
|
speed._turnStart = Date.now();
|
|
617
760
|
speed._firstTokenAt = 0;
|
|
618
761
|
speed._liveTokens = 0;
|
|
619
762
|
try {
|
|
620
763
|
resp = await this.getResponse(cb);
|
|
764
|
+
// Success: reset the transient-retry budget so one slow tool step
|
|
765
|
+
// doesn't consume a retry reserved for real failures.
|
|
766
|
+
providerRetries = 0;
|
|
621
767
|
} catch (err) {
|
|
622
768
|
if (isAbortError(err)) {
|
|
623
769
|
return finishInterrupted();
|
|
624
770
|
}
|
|
771
|
+
// Rate limit / overload: WAIT and retry (honor Retry-After when the
|
|
772
|
+
// provider sent one). Model switching on a 429 wastes the wait — the
|
|
773
|
+
// same limit usually applies across the account.
|
|
774
|
+
if (isRateLimitError(err)) {
|
|
775
|
+
if (providerRetries < PROVIDER_RETRIES) {
|
|
776
|
+
providerRetries++;
|
|
777
|
+
const waitMs = Math.min(
|
|
778
|
+
(err && err.retryAfterMs) || 1000 * Math.pow(2, providerRetries - 1),
|
|
779
|
+
30000
|
|
780
|
+
);
|
|
781
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
782
|
+
if (this.interrupted) return finishInterrupted();
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
// Waited enough — fall through to a model switch as a last resort.
|
|
786
|
+
}
|
|
625
787
|
// Model ran out of tokens → auto-switch to another model and retry once.
|
|
626
788
|
if (isQuotaError(err)) {
|
|
627
789
|
const fromKey = (this.provider.active?.name || '?') + '/' + (this.config.model?.[this.provider.active?.name] || '?');
|
|
@@ -633,6 +795,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
633
795
|
continue;
|
|
634
796
|
}
|
|
635
797
|
}
|
|
798
|
+
// Context overflow (prompt too long): compact and retry once per
|
|
799
|
+
// turn. This is the recovery path when the auto-compact threshold
|
|
800
|
+
// was missed (huge tool results, MCP schema growth mid-session).
|
|
801
|
+
if (isContextOverflowError(err) && !compactedThisTurn && this.messages.length > 10) {
|
|
802
|
+
compactedThisTurn = true;
|
|
803
|
+
try {
|
|
804
|
+
const res = await this.compact(callbacks);
|
|
805
|
+
if (res.compacted) {
|
|
806
|
+
if (callbacks.onAutoCompact) callbacks.onAutoCompact(res);
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
} catch {}
|
|
810
|
+
}
|
|
636
811
|
// Transient provider failure (network blip, 5xx, stream reset): retry
|
|
637
812
|
// the model call a couple of times instead of killing the turn
|
|
638
813
|
// mid-task. No tools ran for this iteration, so a retry is side-effect
|
|
@@ -753,7 +928,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
753
928
|
: [];
|
|
754
929
|
// Safety net: after all continuation budgets are spent, a
|
|
755
930
|
// finish_reason=length reply still gets the explanatory placeholder
|
|
756
|
-
// instead of mystery silence.
|
|
931
|
+
// instead of mystery silence.
|
|
757
932
|
const truncHint = resp.finishReason === 'length'
|
|
758
933
|
? '(the reply hit the max-tokens limit mid-stream — the model ran out of output budget before writing any text. Raise maxTokens in config, or /think off to free the thinking budget)'
|
|
759
934
|
: '(the model ended the turn without any text)';
|
|
@@ -803,11 +978,15 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
803
978
|
// results flow through the TUI popup; doom_loop/external_directory
|
|
804
979
|
// are checked separately where they apply.
|
|
805
980
|
const permKey = { read:'read', edit:'edit', write:'edit', glob:'glob', grep:'grep', bash:'bash',
|
|
806
|
-
task:'task', skill:'skill', lsp:'lsp', question:'question', ask:'question', webfetch:'webfetch',
|
|
807
|
-
websearch:'websearch', browser:'browser' }[tc.name]
|
|
981
|
+
sandbox:'bash', task:'task', skill:'skill', lsp:'lsp', question:'question', ask:'question', webfetch:'webfetch',
|
|
982
|
+
websearch:'websearch', browser:'browser', todoread:'read' }[tc.name]
|
|
983
|
+
|| (tc.name.indexOf('mcp__') === 0 ? 'mcp' : undefined);
|
|
808
984
|
const isMcpAdd = tc.name === 'mcp' && tc.input && tc.input.action === 'add';
|
|
809
985
|
if (permKey || isMcpAdd) {
|
|
810
|
-
|
|
986
|
+
// MCP tool calls are gated by the 'mcp' permission key with the
|
|
987
|
+
// FULL tool name as the pattern arg (mcp__github__search), so the
|
|
988
|
+
// user can allow exactly one server/tool.
|
|
989
|
+
const arg = permKey === 'mcp' ? tc.name : this.permissions.permissionArg(tc.name, tc.input || {});
|
|
811
990
|
let action = this.permissions.resolve(tc.name, arg);
|
|
812
991
|
// mcp add spawns arbitrary stdio servers with optional env secrets —
|
|
813
992
|
// always ask unless the user saved a rule for the exact command.
|
|
@@ -849,7 +1028,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
849
1028
|
// The permission gate above is the interactive check; mark the command
|
|
850
1029
|
// as approved so the tool-layer safety filter doesn't double-block
|
|
851
1030
|
// commands the user explicitly allowed.
|
|
852
|
-
const input = (tc.name === 'bash' || isMcpAdd) ? { ...tc.input, _approved: true } : tc.input;
|
|
1031
|
+
const input = (tc.name === 'bash' || tc.name === 'sandbox' || isMcpAdd) ? { ...tc.input, _approved: true } : tc.input;
|
|
853
1032
|
// preToolUse hook: a user-configured command may veto this call.
|
|
854
1033
|
try {
|
|
855
1034
|
const gate = await hooks.runHook('preToolUse', { tool: tc.name, input: tc.input });
|
|
@@ -869,14 +1048,21 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
869
1048
|
try { if (callbacks.onToolOutput) callbacks.onToolOutput(tc, chunk, kind); } catch {}
|
|
870
1049
|
},
|
|
871
1050
|
});
|
|
872
|
-
// postToolUse hook (informational — never blocks).
|
|
873
|
-
|
|
1051
|
+
// postToolUse hook (informational — never blocks). tool_response lets
|
|
1052
|
+
// audit hooks see what the tool actually returned (truncated to keep
|
|
1053
|
+
// the hook's stdin small).
|
|
1054
|
+
try {
|
|
1055
|
+
const respText = outcome.error ? `Error: ${outcome.error}` : String(outcome.result ?? '');
|
|
1056
|
+
await hooks.runHook('postToolUse', { tool: tc.name, input: tc.input, tool_response: respText.slice(0, 4000) });
|
|
1057
|
+
} catch {}
|
|
874
1058
|
return { tc, outcome };
|
|
875
1059
|
}));
|
|
876
1060
|
|
|
877
1061
|
for (const { tc, outcome } of outcomes) {
|
|
878
1062
|
const text = outcome.error ? `Error: ${outcome.error}` : String(outcome.result ?? '');
|
|
879
|
-
|
|
1063
|
+
// isError marks failed tool calls so Anthropic tool_results carry
|
|
1064
|
+
// is_error:true (the model treats the text as an error report).
|
|
1065
|
+
this.addMessage({ role: 'tool', toolCallId: tc.id, content: text, isError: !!outcome.error });
|
|
880
1066
|
// Persist real todo state when the model uses the todowrite tool.
|
|
881
1067
|
if (tc.name === 'todowrite' && !outcome.error) {
|
|
882
1068
|
todowroteThisTurn = true;
|
|
@@ -894,6 +1080,9 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
894
1080
|
const tools = await getAllToolDefinitions(this.mode);
|
|
895
1081
|
// Agent-scoped turns (delegation) see only their agent's tool list.
|
|
896
1082
|
const toolDefs = this.agent ? filterToolDefs(this.agent, tools) : tools;
|
|
1083
|
+
// Track schema size for estimateTokens — many MCP tools can add tens of
|
|
1084
|
+
// thousands of tokens that a messages-only estimate ignores.
|
|
1085
|
+
this._toolSchemaChars = toolDefs && toolDefs.length ? JSON.stringify(toolDefs).length : 0;
|
|
897
1086
|
|
|
898
1087
|
// Budget router: when a level is active (free/cheap/best), pick the model
|
|
899
1088
|
// for THIS call without rewriting the user's saved provider/model. "Free"
|
|
@@ -951,7 +1140,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
951
1140
|
maxTokens: this.config.maxTokens || 8192,
|
|
952
1141
|
temperature: this.config.temperature ?? 0.7,
|
|
953
1142
|
tools: toolDefs,
|
|
954
|
-
system: this.
|
|
1143
|
+
system: this._systemPrefix(),
|
|
955
1144
|
signal: this.abortController?.signal,
|
|
956
1145
|
// Extended thinking: models tagged 'reasoning' reason BEFORE every
|
|
957
1146
|
// reply — including after each tool result — so complex tasks get
|
|
@@ -970,11 +1159,28 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
970
1159
|
this.abortController = new AbortController();
|
|
971
1160
|
opts.signal = this.abortController.signal;
|
|
972
1161
|
|
|
1162
|
+
// Inject the per-turn skill block into the LAST user message of a wire
|
|
1163
|
+
// copy (never this.messages) — keeps the system prompt byte-stable for
|
|
1164
|
+
// prompt caching while still steering this one turn. Then age older tool
|
|
1165
|
+
// results so a huge read/grep output from 20 turns ago stops costing
|
|
1166
|
+
// full input price on every later call.
|
|
1167
|
+
let wire = this.messages;
|
|
1168
|
+
if (this._skillBlock) {
|
|
1169
|
+
wire = wire.slice();
|
|
1170
|
+
for (let i = wire.length - 1; i >= 0; i--) {
|
|
1171
|
+
if (wire[i].role === 'user') {
|
|
1172
|
+
wire[i] = Object.assign({}, wire[i], { content: String(wire[i].content || '') + this._skillBlock });
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
wire = this.ageHistory(wire);
|
|
1178
|
+
|
|
973
1179
|
let resp;
|
|
974
1180
|
if (callbacks.onDelta || callbacks.onReasoning) {
|
|
975
|
-
resp = await provider.stream(
|
|
1181
|
+
resp = await provider.stream(wire, opts, callbacks.onDelta, callbacks.onReasoning);
|
|
976
1182
|
} else {
|
|
977
|
-
resp = await provider.chat(
|
|
1183
|
+
resp = await provider.chat(wire, opts);
|
|
978
1184
|
}
|
|
979
1185
|
|
|
980
1186
|
if (this.interrupted || (opts.signal && opts.signal.aborted)) {
|
|
@@ -992,22 +1198,41 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
992
1198
|
|
|
993
1199
|
// Normalize provider usage (OpenAI: prompt_tokens/completion_tokens, Anthropic: input_tokens/output_tokens)
|
|
994
1200
|
// into input/output counts, then add them to the session counters and the persistent lifetime tracker.
|
|
1201
|
+
// Prompt-cache hits/writes are priced at ~0.1x/~1.25x the input rate (Anthropic's
|
|
1202
|
+
// published cache rates; OpenAI/OpenRouter use the same 0.1x read discount).
|
|
1203
|
+
// Anthropic's input_tokens EXCLUDES cached tokens (they arrive as separate
|
|
1204
|
+
// cache_read_input_tokens/cache_creation_input_tokens); OpenAI/OpenRouter's
|
|
1205
|
+
// prompt_tokens_details.cached_tokens is a SUBSET of prompt_tokens.
|
|
995
1206
|
recordUsage(usage, modelId, providerName) {
|
|
996
1207
|
if (!usage) return;
|
|
997
|
-
const
|
|
1208
|
+
const rawIn = usage.prompt_tokens || usage.input_tokens || usage.promptTokenCount || 0;
|
|
998
1209
|
const output = usage.completion_tokens || usage.output_tokens || usage.candidatesTokenCount || 0;
|
|
1210
|
+
const cacheRead = usage.cache_read_input_tokens
|
|
1211
|
+
|| usage.prompt_tokens_details?.cached_tokens
|
|
1212
|
+
|| usage.cachedContentTokenCount
|
|
1213
|
+
|| 0;
|
|
1214
|
+
const cacheWrite = usage.cache_creation_input_tokens || 0;
|
|
1215
|
+
// Anthropic input excludes cache tokens; OpenAI input includes the cached subset.
|
|
1216
|
+
const input = providerName === 'anthropic' ? rawIn + cacheRead + cacheWrite : rawIn;
|
|
1217
|
+
const uncachedIn = providerName === 'anthropic' ? rawIn : Math.max(0, rawIn - cacheRead - cacheWrite);
|
|
999
1218
|
const total = usage.total_tokens || usage.totalTokens || (input + output);
|
|
1000
1219
|
if (!input && !output && !total) return;
|
|
1001
1220
|
|
|
1002
1221
|
this.tokensIn += input;
|
|
1003
1222
|
this.tokensOut += output;
|
|
1004
1223
|
this.tokensUsed += total;
|
|
1224
|
+
this.cacheReadTokens = (this.cacheReadTokens || 0) + cacheRead;
|
|
1225
|
+
this.cacheWriteTokens = (this.cacheWriteTokens || 0) + cacheWrite;
|
|
1005
1226
|
|
|
1006
1227
|
const { getModelMeta } = require('../providers');
|
|
1007
1228
|
const meta = getModelMeta(providerName, modelId);
|
|
1008
1229
|
let cost = 0;
|
|
1009
1230
|
if (meta && (meta.priceIn || meta.priceOut)) {
|
|
1010
|
-
|
|
1231
|
+
const priceIn = meta.priceIn || 0;
|
|
1232
|
+
cost = (uncachedIn / 1e6) * priceIn
|
|
1233
|
+
+ (cacheRead / 1e6) * priceIn * 0.1
|
|
1234
|
+
+ (cacheWrite / 1e6) * priceIn * 1.25
|
|
1235
|
+
+ (output / 1e6) * (meta.priceOut || 0);
|
|
1011
1236
|
this.sessionCost += cost;
|
|
1012
1237
|
}
|
|
1013
1238
|
const { recordUsage: persist } = require('./usage');
|
|
@@ -1047,6 +1272,8 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
1047
1272
|
this.tokensUsed = 0;
|
|
1048
1273
|
this.tokensIn = 0;
|
|
1049
1274
|
this.tokensOut = 0;
|
|
1275
|
+
this.cacheReadTokens = 0;
|
|
1276
|
+
this.cacheWriteTokens = 0;
|
|
1050
1277
|
this.sessionCost = 0;
|
|
1051
1278
|
this.todos = [];
|
|
1052
1279
|
this.compactCount = 0;
|
|
@@ -1056,4 +1283,4 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
1056
1283
|
}
|
|
1057
1284
|
}
|
|
1058
1285
|
|
|
1059
|
-
module.exports = { Session, MEMORY_TEMPLATE, isQuotaError, isAbortError };
|
|
1286
|
+
module.exports = { Session, MEMORY_TEMPLATE, isQuotaError, isAbortError, isRateLimitError, isContextOverflowError };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Transcript undo/redo — the conversation-level counterpart to /restore
|
|
2
|
+
// (which rewinds the FILE TREE). Each undoable step is one user turn: the
|
|
3
|
+
// full transcript (UI messages + session messages + todos) captured right
|
|
4
|
+
// before the turn ran, plus the restore-point id created for that same turn
|
|
5
|
+
// so an undo can also offer to roll the working tree back.
|
|
6
|
+
//
|
|
7
|
+
// Pure engine, no TUI/reactivity — the App owns the stacks and passes plain
|
|
8
|
+
// snapshots in, which keeps this unit-testable.
|
|
9
|
+
|
|
10
|
+
const MAX_UNDO = 50;
|
|
11
|
+
|
|
12
|
+
// A snapshot is an opaque object the caller builds (it knows how to clone its
|
|
13
|
+
// own UI + session state). We only ever store and hand back references.
|
|
14
|
+
// shape: { ui: any[], session: any[], todos: any[], restoreId?: string, label?: string }
|
|
15
|
+
|
|
16
|
+
function cloneSnap(snap) {
|
|
17
|
+
// structuredClone is available on Node 18+/Bun; fall back to JSON for the
|
|
18
|
+
// plain-JSON session data (never holds functions or DOM handles).
|
|
19
|
+
if (typeof structuredClone === 'function') {
|
|
20
|
+
try { return structuredClone(snap); } catch {}
|
|
21
|
+
}
|
|
22
|
+
return JSON.parse(JSON.stringify(snap));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class TranscriptUndo {
|
|
26
|
+
constructor(max = MAX_UNDO) {
|
|
27
|
+
this.max = max;
|
|
28
|
+
this.undoStack = [];
|
|
29
|
+
this.redoStack = [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Record the state as it was BEFORE a turn ran. Clears the redo stack (a
|
|
33
|
+
// fresh action diverges history, like any editor). Returns the step object
|
|
34
|
+
// so callers can attach late-bound data (e.g. a restore-point id created in
|
|
35
|
+
// a background setTimeout) without relying on a fragile stack index.
|
|
36
|
+
push(beforeSnap, label) {
|
|
37
|
+
const step = cloneSnap(beforeSnap);
|
|
38
|
+
step.label = label || '';
|
|
39
|
+
this.undoStack.push(step);
|
|
40
|
+
if (this.undoStack.length > this.max) this.undoStack.shift();
|
|
41
|
+
// A new action invalidates the future.
|
|
42
|
+
this.redoStack.length = 0;
|
|
43
|
+
return step;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Move one turn backward. `currentSnap` is the state we are leaving — it
|
|
47
|
+
// goes onto the redo stack so the undo can be re-done. Returns the snapshot
|
|
48
|
+
// to restore, or null when there is nothing to undo.
|
|
49
|
+
undo(currentSnap) {
|
|
50
|
+
if (!this.undoStack.length) return null;
|
|
51
|
+
const target = this.undoStack.pop();
|
|
52
|
+
if (currentSnap) this.redoStack.push(cloneSnap(currentSnap));
|
|
53
|
+
if (this.redoStack.length > this.max) this.redoStack.shift();
|
|
54
|
+
return target;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Move one turn forward again. Returns the snapshot to restore, or null.
|
|
58
|
+
redo(currentSnap) {
|
|
59
|
+
if (!this.redoStack.length) return null;
|
|
60
|
+
const target = this.redoStack.pop();
|
|
61
|
+
if (currentSnap) this.undoStack.push(cloneSnap(currentSnap));
|
|
62
|
+
if (this.undoStack.length > this.max) this.undoStack.shift();
|
|
63
|
+
return target;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
canUndo() { return this.undoStack.length > 0; }
|
|
67
|
+
canRedo() { return this.redoStack.length > 0; }
|
|
68
|
+
peekUndo() { return this.undoStack[this.undoStack.length - 1] || null; }
|
|
69
|
+
peekRedo() { return this.redoStack[this.redoStack.length - 1] || null; }
|
|
70
|
+
depth() { return { undo: this.undoStack.length, redo: this.redoStack.length }; }
|
|
71
|
+
clear() { this.undoStack.length = 0; this.redoStack.length = 0; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { TranscriptUndo, MAX_UNDO };
|