@element47/ag 4.5.5 → 4.5.6

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.
Files changed (59) hide show
  1. package/README.md +44 -14
  2. package/dist/cli/parser.d.ts.map +1 -1
  3. package/dist/cli/parser.js +8 -5
  4. package/dist/cli/parser.js.map +1 -1
  5. package/dist/cli/repl.d.ts.map +1 -1
  6. package/dist/cli/repl.js +136 -72
  7. package/dist/cli/repl.js.map +1 -1
  8. package/dist/core/__tests__/agent-units.test.d.ts +2 -0
  9. package/dist/core/__tests__/agent-units.test.d.ts.map +1 -0
  10. package/dist/core/__tests__/agent-units.test.js +144 -0
  11. package/dist/core/__tests__/agent-units.test.js.map +1 -0
  12. package/dist/core/__tests__/context.test.js +24 -0
  13. package/dist/core/__tests__/context.test.js.map +1 -1
  14. package/dist/core/__tests__/events.test.js +1 -1
  15. package/dist/core/__tests__/events.test.js.map +1 -1
  16. package/dist/core/__tests__/streaming.test.js +2 -1
  17. package/dist/core/__tests__/streaming.test.js.map +1 -1
  18. package/dist/core/agent.d.ts +7 -8
  19. package/dist/core/agent.d.ts.map +1 -1
  20. package/dist/core/agent.js +94 -428
  21. package/dist/core/agent.js.map +1 -1
  22. package/dist/core/compaction.d.ts +27 -0
  23. package/dist/core/compaction.d.ts.map +1 -0
  24. package/dist/core/compaction.js +102 -0
  25. package/dist/core/compaction.js.map +1 -0
  26. package/dist/core/context.d.ts.map +1 -1
  27. package/dist/core/context.js +6 -2
  28. package/dist/core/context.js.map +1 -1
  29. package/dist/core/events.d.ts.map +1 -1
  30. package/dist/core/events.js +6 -1
  31. package/dist/core/events.js.map +1 -1
  32. package/dist/core/prompt.d.ts +23 -0
  33. package/dist/core/prompt.d.ts.map +1 -0
  34. package/dist/core/prompt.js +122 -0
  35. package/dist/core/prompt.js.map +1 -0
  36. package/dist/core/types.d.ts +1 -1
  37. package/dist/core/types.d.ts.map +1 -1
  38. package/dist/core/utils.d.ts +11 -0
  39. package/dist/core/utils.d.ts.map +1 -0
  40. package/dist/core/utils.js +82 -0
  41. package/dist/core/utils.js.map +1 -0
  42. package/dist/memory/__tests__/memory.test.js +47 -2
  43. package/dist/memory/__tests__/memory.test.js.map +1 -1
  44. package/dist/memory/memory.d.ts +8 -0
  45. package/dist/memory/memory.d.ts.map +1 -1
  46. package/dist/memory/memory.js +93 -6
  47. package/dist/memory/memory.js.map +1 -1
  48. package/dist/tools/agent.js +17 -15
  49. package/dist/tools/agent.js.map +1 -1
  50. package/dist/tools/file.d.ts.map +1 -1
  51. package/dist/tools/file.js +9 -5
  52. package/dist/tools/file.js.map +1 -1
  53. package/dist/tools/grep.d.ts.map +1 -1
  54. package/dist/tools/grep.js +7 -5
  55. package/dist/tools/grep.js.map +1 -1
  56. package/dist/tools/task.d.ts.map +1 -1
  57. package/dist/tools/task.js +40 -33
  58. package/dist/tools/task.js.map +1 -1
  59. package/package.json +1 -1
@@ -1,10 +1,7 @@
1
- import { readdirSync, statSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { execFileSync } from 'node:child_process';
4
1
  import { AgentEventEmitter } from './events.js';
5
2
  import { discoverExtensions, loadExtensions } from './extensions.js';
6
3
  import { C } from './colors.js';
7
- import { loadContext, loadHistory, appendHistory, getStats, clearProject, clearAll, paths, saveGlobalMemory, saveProjectMemory, savePlan, appendPlan, setActivePlan, getActivePlanName, loadGlobalMemory, loadProjectMemory, loadPlan, loadPlanByName, listPlans } from '../memory/memory.js';
4
+ import { loadContext, loadHistory, appendHistory, getStats, clearProject, clearAll, paths, saveGlobalMemory, saveProjectMemory, savePlan, appendPlan, setActivePlan, getActivePlanName, loadGlobalMemory, loadProjectMemory, loadPlan, loadPlanByName, listPlans, cleanupTasks } from '../memory/memory.js';
8
5
  import { bashToolFactory } from '../tools/bash.js';
9
6
  import { memoryTool } from '../tools/memory.js';
10
7
  import { planTool } from '../tools/plan.js';
@@ -17,123 +14,14 @@ import { grepTool } from '../tools/grep.js';
17
14
  import { fileTool } from '../tools/file.js';
18
15
  import { discoverSkills, buildSkillCatalog, getAlwaysOnContent, loadSkillTools } from './skills.js';
19
16
  import { ContextTracker } from './context.js';
17
+ import { startSpinner, fetchWithRetry, truncateToolResult, raceAll } from './utils.js';
18
+ import { getEnvironmentContext, isReadOnlyToolCall, getProjectListing, buildRequestBody } from './prompt.js';
19
+ import { compactMessages, COMPACT_THRESHOLD, COMPACT_HEAD_KEEP, COMPACT_TAIL_KEEP } from './compaction.js';
20
20
  export const MAX_ITERATIONS_REACHED = '[Max iterations reached]';
21
- const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
22
- function startSpinner(label) {
23
- if (!process.stderr.isTTY) {
24
- // Non-TTY fallback: static status line
25
- process.stderr.write(` ... ${label}\n`);
26
- return () => { };
27
- }
28
- let i = 0;
29
- process.stderr.write(` ${C.dim}${SPINNER_FRAMES[0]} ${label}${C.reset}\n`);
30
- const id = setInterval(() => {
31
- process.stderr.write(`\x1b[A\x1b[K ${C.dim}${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]} ${label}${C.reset}\n`);
32
- }, 80);
33
- return () => {
34
- clearInterval(id);
35
- process.stderr.write('\x1b[A\x1b[K');
36
- };
37
- }
21
+ // Re-export extracted functions for backwards compatibility
22
+ export { fetchWithRetry, truncateToolResult, raceAll } from './utils.js';
23
+ export { getEnvironmentContext, isReadOnlyToolCall } from './prompt.js';
38
24
  const MAX_MESSAGES = 200;
39
- const COMPACT_THRESHOLD = 0.9;
40
- const MAX_TOOL_RESULT_CHARS = 32768;
41
- const TRUNCATION_HEAD_LINES = 100;
42
- const TRUNCATION_TAIL_LINES = 100;
43
- const COMPACT_HEAD_KEEP = 2;
44
- const COMPACT_TAIL_KEEP = 10;
45
- const COMPACT_MSG_CHARS = 500;
46
- const COMPACT_TOTAL_CHARS = 50000;
47
- const COMPACTION_PROMPT = `Summarize this conversation between a user and a coding assistant. Extract essential context needed to continue working.
48
-
49
- You MUST preserve exactly:
50
- - All file paths that were read, edited, or created (full paths, not abbreviated)
51
- - All error messages and their causes
52
- - Decisions made and their rationale
53
- - Current task: what was asked, what's done, what remains
54
- - Any user preferences or constraints mentioned
55
-
56
- Format as structured bullet points. Be concise but never drop paths, error details, or decision rationale — these are critical for the assistant to continue without re-reading files or re-discovering errors.`;
57
- export function getEnvironmentContext(cwd) {
58
- const lines = ['# Environment'];
59
- lines.push(`Date: ${new Date().toISOString().slice(0, 10)}`);
60
- lines.push(`OS: ${process.platform}`);
61
- lines.push(`CWD: ${cwd}`);
62
- // Git info
63
- if (existsSync(join(cwd, '.git'))) {
64
- try {
65
- const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf-8', timeout: 3000 }).trim();
66
- lines.push(`Git branch: ${branch}`);
67
- const dirty = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8', timeout: 3000 }).trim();
68
- if (dirty) {
69
- const count = dirty.split('\n').length;
70
- lines.push(`Git status: ${count} changed file(s)`);
71
- }
72
- }
73
- catch { /* not a git repo or git not installed */ }
74
- }
75
- // Detect stack from config files
76
- const detectedStack = [];
77
- const stackHints = [
78
- ['package.json', 'Node.js'],
79
- ['tsconfig.json', 'TypeScript'],
80
- ['Cargo.toml', 'Rust'],
81
- ['go.mod', 'Go'],
82
- ['pyproject.toml', 'Python'],
83
- ['requirements.txt', 'Python'],
84
- ['Gemfile', 'Ruby'],
85
- ['pom.xml', 'Java/Maven'],
86
- ['build.gradle', 'Java/Gradle'],
87
- ];
88
- for (const [file, stack] of stackHints) {
89
- if (existsSync(join(cwd, file)))
90
- detectedStack.push(stack);
91
- }
92
- if (detectedStack.length > 0)
93
- lines.push(`Stack: ${detectedStack.join(', ')}`);
94
- return lines.join('\n');
95
- }
96
- /** Tool actions that are read-only and never need confirmation */
97
- const READ_ONLY_CALLS = {
98
- grep: true, // all grep actions are read-only
99
- memory: true, // saving memory is safe
100
- plan: true, // managing plans is safe
101
- skill: true, // activating skills is safe
102
- file: new Set(['read', 'list']), // only read/list are safe
103
- git: new Set(['status']), // only status is safe
104
- web: new Set(['search']), // search is safe, fetch needs confirm
105
- task: true, // all task actions are safe (internal state)
106
- agent: true, // sub-agent spawning is safe (internal orchestration)
107
- };
108
- export function isReadOnlyToolCall(toolName, args) {
109
- const rule = READ_ONLY_CALLS[toolName];
110
- if (rule === true)
111
- return true;
112
- if (rule instanceof Set)
113
- return rule.has(args.action);
114
- return false;
115
- }
116
- export function truncateToolResult(result) {
117
- if (result.length <= MAX_TOOL_RESULT_CHARS)
118
- return result;
119
- const lines = result.split('\n');
120
- if (lines.length <= TRUNCATION_HEAD_LINES + TRUNCATION_TAIL_LINES)
121
- return result;
122
- const head = lines.slice(0, TRUNCATION_HEAD_LINES);
123
- const tail = lines.slice(-TRUNCATION_TAIL_LINES);
124
- const omitted = lines.length - TRUNCATION_HEAD_LINES - TRUNCATION_TAIL_LINES;
125
- return [...head, `\n... [${omitted} lines truncated] ...\n`, ...tail].join('\n');
126
- }
127
- /** Yield promise results as they resolve (like Promise.all but streaming) */
128
- export async function* raceAll(promises) {
129
- const wrapped = promises.map((p, i) => p.then(v => ({ i, v })));
130
- const settled = new Set();
131
- while (settled.size < promises.length) {
132
- const result = await Promise.race(wrapped.filter((_, idx) => !settled.has(idx)));
133
- settled.add(result.i);
134
- yield result.v;
135
- }
136
- }
137
25
  export class Agent {
138
26
  apiKey;
139
27
  model;
@@ -159,6 +47,7 @@ export class Agent {
159
47
  spinnerControl = null;
160
48
  silent;
161
49
  noHistory;
50
+ steerQueue = [];
162
51
  constructor(config = {}) {
163
52
  this.apiKey = config.apiKey || process.env.OPENROUTER_API_KEY || '';
164
53
  if (!this.apiKey)
@@ -197,6 +86,11 @@ export class Agent {
197
86
  - Never amend commits or force-push without the user asking.
198
87
  - Never commit files that contain secrets (.env, credentials, keys).
199
88
 
89
+ # History
90
+ - Your conversation history is stored at the path shown in <history-file>. Each line is JSON with a "ts" timestamp field.
91
+ - When the user asks about past conversations, ALWAYS search it with grep(action=search, path="<the history-file path>", pattern="<search term>"). Pass the exact file path — do not search broadly or omit the path.
92
+ - Use the "ts" field to answer time-based questions (e.g., "what did we discuss last Tuesday?").
93
+
200
94
  # Output
201
95
  - Be concise. Short responses, no filler, no trailing summaries of what you just did.
202
96
  - When referencing code, include the file path and relevant context.
@@ -216,7 +110,7 @@ export class Agent {
216
110
  - plan — create and manage multi-step task plans
217
111
  - web(fetch/search) — fetch pages or search the web
218
112
  - task(create/list/update/read/remove/clear) — manage tasks for multi-step work
219
- - agent(prompt, taskId?, model?) — spawn sub-agents for parallel work
113
+ - agent(prompt, taskId?, model?) — spawn sub-agents for parallel work. Always include key findings from sub-agents in your response — the user cannot see tool output in full.
220
114
  - skill — activate a skill by name`;
221
115
  this.systemPromptSuffix = config.systemPromptSuffix || '';
222
116
  this.silent = config.silent ?? false;
@@ -251,6 +145,7 @@ export class Agent {
251
145
  // Load recent conversation history for continuity (sub-agents start clean)
252
146
  if (!config.noHistory) {
253
147
  this.messages = loadHistory(this.cwd);
148
+ cleanupTasks(this.cwd);
254
149
  }
255
150
  }
256
151
  addTool(tool) {
@@ -298,45 +193,10 @@ export class Agent {
298
193
  this.cachedCatalog = buildSkillCatalog(this.allSkills);
299
194
  this.cachedAlwaysOn = getAlwaysOnContent(this.allSkills);
300
195
  }
301
- getProjectListing() {
302
- const MAX_ENTRIES = 30;
303
- const IGNORE = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.cache', '__pycache__']);
304
- try {
305
- const entries = readdirSync(this.cwd, { withFileTypes: true });
306
- const lines = [];
307
- for (const e of entries) {
308
- if (lines.length >= MAX_ENTRIES) {
309
- lines.push(` ... (${entries.length - MAX_ENTRIES} more)`);
310
- break;
311
- }
312
- if (IGNORE.has(e.name))
313
- continue;
314
- if (e.name.startsWith('.') && e.name !== '.')
315
- continue;
316
- if (e.isDirectory()) {
317
- lines.push(` [dir] ${e.name}/`);
318
- }
319
- else {
320
- try {
321
- const s = statSync(join(this.cwd, e.name));
322
- const kb = (s.size / 1024).toFixed(1);
323
- lines.push(` ${e.name} (${kb}KB)`);
324
- }
325
- catch {
326
- lines.push(` ${e.name}`);
327
- }
328
- }
329
- }
330
- return lines.length > 0 ? `Project files (${this.cwd}):\n${lines.join('\n')}` : '';
331
- }
332
- catch {
333
- return '';
334
- }
335
- }
336
196
  get systemPrompt() {
337
197
  const parts = [this.baseSystemPrompt];
338
198
  parts.push(getEnvironmentContext(this.cwd));
339
- const listing = this.getProjectListing();
199
+ const listing = getProjectListing(this.cwd);
340
200
  if (listing)
341
201
  parts.push(listing);
342
202
  if (this.cachedContext)
@@ -352,25 +212,14 @@ export class Agent {
352
212
  parts.push(this.systemPromptSuffix);
353
213
  return parts.join('\n\n');
354
214
  }
355
- /** Build the JSON request body for chat completions, with prompt caching for supported models */
356
- buildRequestBody(stream, overrides) {
357
- const sysPrompt = overrides?.systemPrompt ?? this.systemPrompt;
358
- const msgs = overrides?.messages ?? this.messages;
359
- const body = {
215
+ getRequestBody(stream, overrides) {
216
+ return buildRequestBody({
360
217
  model: this.model,
361
- messages: [{ role: 'system', content: sysPrompt }, ...msgs],
218
+ systemPrompt: overrides?.systemPrompt ?? this.systemPrompt,
219
+ messages: overrides?.messages ?? this.messages,
362
220
  tools: Array.from(this.tools.values()).map(t => ({ type: t.type, function: t.function })),
363
- tool_choice: 'auto',
364
- };
365
- if (stream) {
366
- body.stream = true;
367
- body.stream_options = { include_usage: true };
368
- }
369
- // Enable prompt caching for Anthropic models (top-level cache_control)
370
- if (this.model.startsWith('anthropic/') || this.model.includes('claude')) {
371
- body.cache_control = { type: 'ephemeral' };
372
- }
373
- return body;
221
+ stream,
222
+ });
374
223
  }
375
224
  async activateSkill(name) {
376
225
  const skill = this.allSkills.find(s => s.name === name);
@@ -393,264 +242,59 @@ export class Agent {
393
242
  return `Skill "${name}" activated. Instructions loaded.`;
394
243
  }
395
244
  async compactConversation(customSummary) {
396
- const minMessages = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP + 4;
397
- if (this.messages.length <= minMessages)
245
+ const result = await compactMessages(this.messages, { baseURL: this.baseURL, apiKey: this.apiKey, model: this.model }, customSummary);
246
+ if (!result)
398
247
  return;
399
- const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
400
- const middle = this.messages.slice(COMPACT_HEAD_KEEP, -COMPACT_TAIL_KEEP);
401
- const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
402
- let summary;
403
- if (customSummary) {
404
- // Use extension-provided summary instead of LLM call
405
- summary = customSummary;
406
- }
407
- else {
408
- // Format middle messages for summarization, capping total size
409
- let totalChars = 0;
410
- const formatted = [];
411
- for (const m of middle) {
412
- let line;
413
- if (m.tool_calls?.length) {
414
- const names = m.tool_calls.map(tc => tc.function.name).join(', ');
415
- line = `[assistant]: (tool call: ${names})`;
416
- }
417
- else if (m.role === 'tool') {
418
- line = `[tool result]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
419
- }
420
- else {
421
- line = `[${m.role}]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
422
- }
423
- if (totalChars + line.length > COMPACT_TOTAL_CHARS)
424
- break;
425
- totalChars += line.length;
426
- formatted.push(line);
427
- }
428
- const stopSpinner = startSpinner('compacting context');
429
- let stopped = false;
430
- try {
431
- const res = await fetch(`${this.baseURL}/chat/completions`, {
432
- method: 'POST',
433
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
434
- body: JSON.stringify({
435
- model: this.model,
436
- messages: [
437
- { role: 'system', content: COMPACTION_PROMPT },
438
- { role: 'user', content: formatted.join('\n\n') }
439
- ]
440
- })
441
- });
442
- if (!res.ok)
443
- throw new Error(`API ${res.status}: ${await res.text()}`);
444
- const body = await res.json();
445
- summary = body.choices?.[0]?.message?.content;
446
- if (!summary)
447
- throw new Error('No summary returned');
448
- stopSpinner();
449
- stopped = true;
450
- }
451
- finally {
452
- if (!stopped)
453
- stopSpinner();
454
- }
455
- }
456
- const summaryMsg = {
457
- role: 'user',
458
- content: `[Conversation compacted — summary of ${middle.length} earlier messages]\n\n${summary}`
459
- };
460
- this.messages = [...head, summaryMsg, ...tail];
461
- this.appendToHistory(summaryMsg);
248
+ this.messages = result.messages;
249
+ this.appendToHistory(result.summaryMsg);
462
250
  // Re-estimate context usage from the compacted messages
463
251
  const compactedChars = this.messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0)
464
252
  + this.systemPrompt.length;
465
253
  this.contextTracker.estimateFromChars(compactedChars);
466
- process.stderr.write(` ${C.yellow}Context compacted: ${middle.length} messages → summary${C.reset}\n`);
467
254
  }
468
255
  async chat(content, signal) {
469
- // ── input event ──
470
- const inputEvent = { content, skip: false };
471
- await this.events.emit('input', inputEvent);
472
- if (inputEvent.skip)
473
- return '';
474
- content = inputEvent.content;
475
- const userMessage = { role: 'user', content };
476
- this.messages.push(userMessage);
477
- this.appendToHistory(userMessage);
478
- // Cap in-memory messages to prevent unbounded growth
479
- if (this.messages.length > MAX_MESSAGES) {
480
- this.messages = this.messages.slice(-MAX_MESSAGES);
481
- }
482
- for (let i = 0; i < this.maxIterations; i++) {
483
- if (signal?.aborted) {
484
- this.messages.push({ role: 'assistant', content: '[interrupted by user]' });
485
- return '[interrupted by user]';
486
- }
487
- // ── turn_start event ──
488
- await this.events.emit('turn_start', { iteration: i, maxIterations: this.maxIterations, messageCount: this.messages.length });
489
- const iterLabel = this.maxIterations > 1 ? ` [${i + 1}/${this.maxIterations}]` : '';
490
- const stopSpinner = this.silent ? () => { } : startSpinner(`thinking${iterLabel}`);
491
- // ── before_compact event ──
492
- if (!this.compactionInProgress && this.contextTracker.shouldCompact(COMPACT_THRESHOLD)) {
493
- const compactEvent = { messageCount: this.messages.length, cancel: false, customSummary: undefined };
494
- await this.events.emit('before_compact', compactEvent);
495
- if (!compactEvent.cancel) {
496
- this.compactionInProgress = true;
497
- try {
498
- await this.compactConversation(compactEvent.customSummary);
499
- }
500
- catch (e) {
501
- if (!this.silent)
502
- process.stderr.write(` ${C.dim}Compaction failed: ${e} — falling back to truncation${C.reset}\n`);
503
- const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
504
- if (this.messages.length > keep) {
505
- const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
506
- const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
507
- const truncMsg = {
508
- role: 'user',
509
- content: `[Conversation truncated — ${this.messages.length - keep} older messages removed to stay within context limit]`
510
- };
511
- this.messages = [...head, truncMsg, ...tail];
256
+ let finalContent = '';
257
+ let stopSpinner = () => { };
258
+ try {
259
+ for await (const chunk of this.chatStream(content, signal)) {
260
+ switch (chunk.type) {
261
+ case 'thinking':
262
+ stopSpinner();
263
+ stopSpinner = this.silent ? () => { } : startSpinner(chunk.content || 'thinking');
264
+ break;
265
+ case 'text':
266
+ stopSpinner();
267
+ stopSpinner = () => { };
268
+ break;
269
+ case 'tool_start':
270
+ stopSpinner();
271
+ stopSpinner = this.silent ? () => { } : startSpinner(`[${chunk.toolName}] ${(chunk.content || '').slice(0, 80)}`);
272
+ break;
273
+ case 'tool_end':
274
+ stopSpinner();
275
+ stopSpinner = () => { };
276
+ if (!this.silent) {
277
+ const icon = chunk.success ? `${C.green}✓` : `${C.red}✗`;
278
+ const preview = (chunk.content || '').slice(0, 150).split('\n')[0];
279
+ process.stderr.write(` ${icon} ${C.dim}[${chunk.toolName}]${C.reset} ${C.dim}${preview}${(chunk.content || '').length > 150 ? '...' : ''}${C.reset}\n`);
512
280
  }
513
- }
514
- finally {
515
- this.compactionInProgress = false;
516
- }
517
- }
518
- }
519
- // ── before_request event ──
520
- const reqEvent = { messages: this.messages, systemPrompt: this.systemPrompt, model: this.model, stream: false };
521
- await this.events.emit('before_request', reqEvent);
522
- let msg;
523
- try {
524
- const res = await fetch(`${this.baseURL}/chat/completions`, {
525
- method: 'POST',
526
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
527
- body: JSON.stringify(this.buildRequestBody(false, { messages: reqEvent.messages, systemPrompt: reqEvent.systemPrompt })),
528
- signal
529
- });
530
- if (!res.ok)
531
- throw new Error(`API ${res.status}: ${await res.text()}`);
532
- const body = await res.json();
533
- if (!body || typeof body !== 'object' || !Array.isArray(body.choices) || !body.choices[0]?.message) {
534
- throw new Error(`Unexpected API response shape: ${JSON.stringify(body).slice(0, 200)}`);
535
- }
536
- msg = body.choices[0].message;
537
- if (!msg)
538
- throw new Error('No response from model');
539
- if (body.usage)
540
- this.contextTracker.update(body.usage);
541
- }
542
- finally {
543
- stopSpinner();
544
- }
545
- // ── after_response event ──
546
- await this.events.emit('after_response', { message: msg, usage: undefined });
547
- this.messages.push(msg);
548
- this.appendToHistory(msg);
549
- if (!msg.tool_calls?.length) {
550
- // ── turn_end event (no tools) ──
551
- await this.events.emit('turn_end', { iteration: i, hadToolCalls: false, toolCallCount: 0 });
552
- return msg.content || '';
553
- }
554
- // Permission checks — run sequentially so prompts don't overlap
555
- const permissionDecisions = new Map();
556
- for (const call of msg.tool_calls) {
557
- const tool = this.tools.get(call.function.name);
558
- if (!tool)
559
- continue;
560
- let args;
561
- try {
562
- args = JSON.parse(call.function.arguments || '{}');
563
- }
564
- catch {
565
- continue;
566
- }
567
- if (this.confirmToolCall && !isReadOnlyToolCall(call.function.name, args)) {
568
- permissionDecisions.set(call.id, await this.confirmToolCall(call.function.name, args, tool.permissionKey));
569
- }
570
- }
571
- // Combined spinner for parallel tools (shows elapsed time)
572
- const usesCombinedSpinner = !this.silent && msg.tool_calls.length > 1;
573
- let stopCombinedSpinner = () => { };
574
- if (usesCombinedSpinner) {
575
- const toolNames = msg.tool_calls.map((c) => c.function.name);
576
- const unique = [...new Set(toolNames)];
577
- const label = unique.length === 1 && unique[0] === 'agent'
578
- ? `${toolNames.length} sub-agents running`
579
- : `${toolNames.length} tools running: ${unique.join(', ')}`;
580
- const start = Date.now();
581
- stopCombinedSpinner = startSpinner(`${label} (0s)`);
582
- const timerId = setInterval(() => {
583
- const elapsed = Math.round((Date.now() - start) / 1000);
584
- // Update spinner label with elapsed time — clear line then rewrite
585
- if (process.stderr.isTTY) {
586
- process.stderr.write(`\x1b[A\x1b[K ${C.dim}${SPINNER_FRAMES[elapsed % SPINNER_FRAMES.length]} ${label} (${elapsed}s)${C.reset}\n`);
587
- }
588
- }, 1000);
589
- const origStop = stopCombinedSpinner;
590
- stopCombinedSpinner = () => { clearInterval(timerId); origStop(); };
591
- }
592
- // Execute tool calls in parallel (permissions already resolved)
593
- const toolPromises = msg.tool_calls.map(async (call) => {
594
- const tool = this.tools.get(call.function.name);
595
- if (!tool) {
596
- return { call, content: `Error: unknown tool "${call.function.name}"` };
597
- }
598
- let args;
599
- try {
600
- args = JSON.parse(call.function.arguments || '{}');
601
- }
602
- catch {
603
- return { call, content: 'Error: malformed tool arguments' };
604
- }
605
- if (permissionDecisions.get(call.id) === 'deny') {
606
- return { call, content: 'Tool call denied by user.' };
607
- }
608
- // ── tool_call event ──
609
- const tcEvent = { toolName: call.function.name, toolCallId: call.id, args, block: false, blockReason: undefined };
610
- await this.events.emit('tool_call', tcEvent);
611
- if (tcEvent.block) {
612
- return { call, content: tcEvent.blockReason || 'Blocked by extension' };
613
- }
614
- args = tcEvent.args;
615
- const summary = String(args.command ?? args.action ?? args.prompt ?? JSON.stringify(args)).slice(0, 80);
616
- const stopToolSpinner = this.silent || usesCombinedSpinner ? () => { } : startSpinner(`[${call.function.name}] ${summary}`);
617
- try {
618
- const rawResult = await tool.execute(args);
619
- let result = truncateToolResult(rawResult);
620
- let isError = result.startsWith('Error:') || result.startsWith('EXIT ');
621
- // ── tool_result event ──
622
- const trEvent = { toolName: call.function.name, toolCallId: call.id, args, content: result, isError };
623
- await this.events.emit('tool_result', trEvent);
624
- result = trEvent.content;
625
- isError = trEvent.isError;
626
- stopToolSpinner();
627
- const isErrorFinal = isError;
628
- return { call, content: result, isError: isErrorFinal };
629
- }
630
- catch (error) {
631
- stopToolSpinner();
632
- const errMsg = `Tool error: ${error}`;
633
- return { call, content: errMsg, isError: true };
634
- }
635
- });
636
- const results = await Promise.all(toolPromises);
637
- stopCombinedSpinner();
638
- // Print tool results (deferred until after combined spinner clears)
639
- if (!this.silent) {
640
- for (const r of results) {
641
- const icon = r.isError ? `${C.red}✗` : `${C.green}✓`;
642
- const preview = r.content.slice(0, 150).split('\n')[0];
643
- process.stderr.write(` ${icon} ${C.dim}[${r.call.function.name}]${C.reset} ${C.dim}${preview}${r.content.length > 150 ? '...' : ''}${C.reset}\n`);
281
+ break;
282
+ case 'done':
283
+ finalContent = chunk.content || '';
284
+ break;
285
+ case 'interrupted':
286
+ return '[interrupted by user]';
287
+ case 'max_iterations':
288
+ return MAX_ITERATIONS_REACHED;
289
+ case 'steer':
290
+ break;
644
291
  }
645
292
  }
646
- for (const { call, content } of results) {
647
- this.messages.push({ role: 'tool', tool_call_id: call.id, content });
648
- this.appendToHistory({ role: 'tool', tool_call_id: call.id, content });
649
- }
650
- // ── turn_end event ──
651
- await this.events.emit('turn_end', { iteration: i, hadToolCalls: true, toolCallCount: msg.tool_calls.length });
652
293
  }
653
- return MAX_ITERATIONS_REACHED;
294
+ finally {
295
+ stopSpinner();
296
+ }
297
+ return finalContent;
654
298
  }
655
299
  async *chatStream(content, signal) {
656
300
  // ── input event ──
@@ -672,6 +316,14 @@ export class Agent {
672
316
  yield { type: 'interrupted' };
673
317
  return;
674
318
  }
319
+ // ── Inject steer messages before next LLM turn ──
320
+ while (this.steerQueue.length > 0) {
321
+ const steer = this.steerQueue.shift();
322
+ const steerMsg = { role: 'user', content: steer };
323
+ this.messages.push(steerMsg);
324
+ this.appendToHistory(steerMsg);
325
+ yield { type: 'steer', content: steer };
326
+ }
675
327
  // ── turn_start event ──
676
328
  await this.events.emit('turn_start', { iteration: i, maxIterations: this.maxIterations, messageCount: this.messages.length });
677
329
  const iterLabel = this.maxIterations > 1 ? ` [${i + 1}/${this.maxIterations}]` : '';
@@ -685,7 +337,9 @@ export class Agent {
685
337
  try {
686
338
  await this.compactConversation(compactEvent.customSummary);
687
339
  }
688
- catch {
340
+ catch (e) {
341
+ if (!this.silent)
342
+ process.stderr.write(` ${C.dim}Compaction failed: ${e} — falling back to truncation${C.reset}\n`);
689
343
  const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
690
344
  if (this.messages.length > keep) {
691
345
  const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
@@ -705,13 +359,13 @@ export class Agent {
705
359
  // ── before_request event ──
706
360
  const reqEvent = { messages: this.messages, systemPrompt: this.systemPrompt, model: this.model, stream: true };
707
361
  await this.events.emit('before_request', reqEvent);
708
- // ── API call with abort signal ──
362
+ // ── API call with abort signal and retry ──
709
363
  let res;
710
364
  try {
711
- res = await fetch(`${this.baseURL}/chat/completions`, {
365
+ res = await fetchWithRetry(`${this.baseURL}/chat/completions`, {
712
366
  method: 'POST',
713
367
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
714
- body: JSON.stringify(this.buildRequestBody(true, { messages: reqEvent.messages, systemPrompt: reqEvent.systemPrompt })),
368
+ body: JSON.stringify(this.getRequestBody(true, { messages: reqEvent.messages, systemPrompt: reqEvent.systemPrompt })),
715
369
  signal
716
370
  });
717
371
  }
@@ -853,9 +507,11 @@ export class Agent {
853
507
  return JSON.parse(call.function.arguments || '{}');
854
508
  }
855
509
  catch {
856
- return {};
510
+ return null;
857
511
  } })();
858
- const summary = String(args.command ?? args.action ?? args.prompt ?? call.function.name).slice(0, 80);
512
+ const summary = args
513
+ ? String(args.command ?? args.action ?? args.prompt ?? call.function.name).slice(0, 80)
514
+ : `${call.function.name} (malformed arguments)`;
859
515
  yield { type: 'tool_start', toolName: call.function.name, toolCallId: call.id, content: summary };
860
516
  }
861
517
  // Permission checks — run sequentially so prompts don't overlap
@@ -871,6 +527,7 @@ export class Agent {
871
527
  args = JSON.parse(call.function.arguments || '{}');
872
528
  }
873
529
  catch {
530
+ permissionDecisions.set(call.id, 'deny');
874
531
  continue;
875
532
  }
876
533
  if (this.confirmToolCall && !isReadOnlyToolCall(call.function.name, args)) {
@@ -927,6 +584,10 @@ export class Agent {
927
584
  if (signal?.aborted)
928
585
  break;
929
586
  }
587
+ // Re-estimate context so shouldCompact() reflects tool result sizes
588
+ if (!signal?.aborted) {
589
+ this.contextTracker.estimateFromChars(this.getTotalContextChars());
590
+ }
930
591
  // Fill placeholders for any tool calls that didn't complete (API requires all tool_call_ids)
931
592
  if (signal?.aborted && msg.tool_calls) {
932
593
  for (const call of msg.tool_calls) {
@@ -951,7 +612,7 @@ export class Agent {
951
612
  const parts = [];
952
613
  parts.push({ label: 'System prompt', chars: this.baseSystemPrompt.length });
953
614
  parts.push({ label: 'Environment', chars: getEnvironmentContext(this.cwd).length });
954
- const listing = this.getProjectListing();
615
+ const listing = getProjectListing(this.cwd);
955
616
  if (listing)
956
617
  parts.push({ label: 'Project files', chars: listing.length });
957
618
  // cachedContext contains global memory + project memory + plan, but we want them separate
@@ -1006,12 +667,17 @@ export class Agent {
1006
667
  getApiKey() { return this.apiKey; }
1007
668
  getCwd() { return this.cwd; }
1008
669
  isSilent() { return this.silent; }
670
+ /** Queue a message to inject before the next LLM turn (non-destructive steering) */
671
+ queueSteer(message) {
672
+ this.steerQueue.push(message);
673
+ }
1009
674
  setModel(model) { this.model = model; this.contextTracker = new ContextTracker(model); }
1010
675
  setBaseURL(url) { this.baseURL = url; }
676
+ getConfirmToolCall() { return this.confirmToolCall; }
1011
677
  setConfirmToolCall(cb) { this.confirmToolCall = cb; }
1012
678
  async compactNow() { await this.compactConversation(); }
1013
679
  async fetchModels(query) {
1014
- const res = await fetch(`${this.baseURL}/models`, {
680
+ const res = await fetchWithRetry(`${this.baseURL}/models`, {
1015
681
  headers: { 'Authorization': `Bearer ${this.apiKey}` }
1016
682
  });
1017
683
  if (!res.ok)