@element47/ag 4.5.1 → 4.5.3

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 (77) hide show
  1. package/README.md +184 -8
  2. package/dist/cli/__tests__/repl-rawmode.test.d.ts +2 -0
  3. package/dist/cli/__tests__/repl-rawmode.test.d.ts.map +1 -0
  4. package/dist/cli/__tests__/repl-rawmode.test.js +46 -0
  5. package/dist/cli/__tests__/repl-rawmode.test.js.map +1 -0
  6. package/dist/cli/repl.d.ts +7 -2
  7. package/dist/cli/repl.d.ts.map +1 -1
  8. package/dist/cli/repl.js +203 -25
  9. package/dist/cli/repl.js.map +1 -1
  10. package/dist/cli.js +10 -8
  11. package/dist/cli.js.map +1 -1
  12. package/dist/core/__tests__/agent.test.js +42 -1
  13. package/dist/core/__tests__/agent.test.js.map +1 -1
  14. package/dist/core/__tests__/context.test.js +81 -0
  15. package/dist/core/__tests__/context.test.js.map +1 -1
  16. package/dist/core/__tests__/events.test.d.ts +2 -0
  17. package/dist/core/__tests__/events.test.d.ts.map +1 -0
  18. package/dist/core/__tests__/events.test.js +131 -0
  19. package/dist/core/__tests__/events.test.js.map +1 -0
  20. package/dist/core/__tests__/extensions.test.d.ts +2 -0
  21. package/dist/core/__tests__/extensions.test.d.ts.map +1 -0
  22. package/dist/core/__tests__/extensions.test.js +59 -0
  23. package/dist/core/__tests__/extensions.test.js.map +1 -0
  24. package/dist/core/__tests__/guardrails.test.d.ts +2 -0
  25. package/dist/core/__tests__/guardrails.test.d.ts.map +1 -0
  26. package/dist/core/__tests__/guardrails.test.js +418 -0
  27. package/dist/core/__tests__/guardrails.test.js.map +1 -0
  28. package/dist/core/__tests__/permission-manager.test.d.ts +2 -0
  29. package/dist/core/__tests__/permission-manager.test.d.ts.map +1 -0
  30. package/dist/core/__tests__/permission-manager.test.js +246 -0
  31. package/dist/core/__tests__/permission-manager.test.js.map +1 -0
  32. package/dist/core/__tests__/streaming.test.js +8 -1
  33. package/dist/core/__tests__/streaming.test.js.map +1 -1
  34. package/dist/core/agent.d.ts +27 -0
  35. package/dist/core/agent.d.ts.map +1 -1
  36. package/dist/core/agent.js +263 -121
  37. package/dist/core/agent.js.map +1 -1
  38. package/dist/core/context.d.ts.map +1 -1
  39. package/dist/core/context.js +14 -6
  40. package/dist/core/context.js.map +1 -1
  41. package/dist/core/events.d.ts +62 -0
  42. package/dist/core/events.d.ts.map +1 -0
  43. package/dist/core/events.js +23 -0
  44. package/dist/core/events.js.map +1 -0
  45. package/dist/core/extensions.d.ts +10 -0
  46. package/dist/core/extensions.d.ts.map +1 -0
  47. package/dist/core/extensions.js +66 -0
  48. package/dist/core/extensions.js.map +1 -0
  49. package/dist/core/guardrails.d.ts +32 -0
  50. package/dist/core/guardrails.d.ts.map +1 -0
  51. package/dist/core/guardrails.js +149 -0
  52. package/dist/core/guardrails.js.map +1 -0
  53. package/dist/core/loader.d.ts +6 -2
  54. package/dist/core/loader.d.ts.map +1 -1
  55. package/dist/core/loader.js +23 -9
  56. package/dist/core/loader.js.map +1 -1
  57. package/dist/core/permissions.d.ts +60 -0
  58. package/dist/core/permissions.d.ts.map +1 -0
  59. package/dist/core/permissions.js +252 -0
  60. package/dist/core/permissions.js.map +1 -0
  61. package/dist/core/registry.d.ts.map +1 -1
  62. package/dist/core/registry.js +16 -0
  63. package/dist/core/registry.js.map +1 -1
  64. package/dist/core/skills.d.ts.map +1 -1
  65. package/dist/core/skills.js +26 -3
  66. package/dist/core/skills.js.map +1 -1
  67. package/dist/core/types.d.ts +15 -1
  68. package/dist/core/types.d.ts.map +1 -1
  69. package/dist/memory/__tests__/memory.test.js +28 -0
  70. package/dist/memory/__tests__/memory.test.js.map +1 -1
  71. package/dist/memory/memory.js +1 -1
  72. package/dist/memory/memory.js.map +1 -1
  73. package/dist/tools/__tests__/bash.test.js +4 -0
  74. package/dist/tools/__tests__/bash.test.js.map +1 -1
  75. package/dist/tools/__tests__/file.test.js +7 -1
  76. package/dist/tools/__tests__/file.test.js.map +1 -1
  77. package/package.json +1 -4
@@ -1,6 +1,8 @@
1
1
  import { readdirSync, statSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
+ import { AgentEventEmitter } from './events.js';
5
+ import { discoverExtensions, loadExtensions } from './extensions.js';
4
6
  import { C } from './colors.js';
5
7
  import { loadContext, loadHistory, appendHistory, getStats, clearProject, clearAll, paths, saveGlobalMemory, saveProjectMemory, savePlan, appendPlan, setActivePlan, getActivePlanName, loadGlobalMemory, loadProjectMemory, loadPlan, loadPlanByName, listPlans } from '../memory/memory.js';
6
8
  import { bashToolFactory } from '../tools/bash.js';
@@ -119,7 +121,7 @@ export function truncateToolResult(result) {
119
121
  return [...head, `\n... [${omitted} lines truncated] ...\n`, ...tail].join('\n');
120
122
  }
121
123
  /** Yield promise results as they resolve (like Promise.all but streaming) */
122
- async function* raceAll(promises) {
124
+ export async function* raceAll(promises) {
123
125
  const wrapped = promises.map((p, i) => p.then(v => ({ i, v })));
124
126
  const settled = new Set();
125
127
  while (settled.size < promises.length) {
@@ -143,9 +145,13 @@ export class Agent {
143
145
  cachedCatalog = '';
144
146
  cachedAlwaysOn = '';
145
147
  builtinToolNames = new Set();
148
+ toolFailures;
146
149
  contextTracker;
147
150
  compactionInProgress = false;
148
151
  confirmToolCall;
152
+ events = new AgentEventEmitter();
153
+ loadedExtensions = [];
154
+ spinnerControl = null;
149
155
  constructor(config = {}) {
150
156
  this.apiKey = config.apiKey || process.env.OPENROUTER_API_KEY || '';
151
157
  if (!this.apiKey)
@@ -222,6 +228,7 @@ export class Agent {
222
228
  this.builtinToolNames = new Set(this.tools.keys());
223
229
  for (const t of config.extraTools ?? [])
224
230
  this.addTool(t);
231
+ this.toolFailures = config.toolFailures ?? [];
225
232
  // Context window tracking
226
233
  this.contextTracker = new ContextTracker(this.model);
227
234
  // Cache context and skill catalog (invalidated on skill activation/refresh)
@@ -236,6 +243,34 @@ export class Agent {
236
243
  }
237
244
  this.tools.set(name, tool);
238
245
  }
246
+ /** Subscribe to an agent lifecycle event. Returns an unsubscribe function. */
247
+ on(event, handler) {
248
+ return this.events.on(event, handler);
249
+ }
250
+ /** Discover and load extensions from .ag/extensions/ and ~/.ag/extensions/ */
251
+ async initExtensions(extraPaths) {
252
+ const discovered = discoverExtensions(this.cwd);
253
+ const all = [...discovered, ...(extraPaths ?? [])];
254
+ if (all.length > 0) {
255
+ this.loadedExtensions = await loadExtensions(this, all);
256
+ }
257
+ }
258
+ /** Metadata for successfully loaded extensions */
259
+ getLoadedExtensions() {
260
+ return this.loadedExtensions;
261
+ }
262
+ /** Spinner-safe log for extensions. Pauses spinner, writes, resumes. */
263
+ log(message) {
264
+ if (this.spinnerControl)
265
+ this.spinnerControl.pause();
266
+ process.stderr.write(message + '\n');
267
+ if (this.spinnerControl)
268
+ this.spinnerControl.resume();
269
+ }
270
+ /** Called by the REPL to register spinner pause/resume for safe output */
271
+ setSpinnerControl(control) {
272
+ this.spinnerControl = control;
273
+ }
239
274
  refreshCache() {
240
275
  this.cachedContext = loadContext(this.cwd);
241
276
  this.cachedCatalog = buildSkillCatalog(this.allSkills);
@@ -294,10 +329,12 @@ export class Agent {
294
329
  return parts.join('\n\n');
295
330
  }
296
331
  /** Build the JSON request body for chat completions, with prompt caching for supported models */
297
- buildRequestBody(stream) {
332
+ buildRequestBody(stream, overrides) {
333
+ const sysPrompt = overrides?.systemPrompt ?? this.systemPrompt;
334
+ const msgs = overrides?.messages ?? this.messages;
298
335
  const body = {
299
336
  model: this.model,
300
- messages: [{ role: 'system', content: this.systemPrompt }, ...this.messages],
337
+ messages: [{ role: 'system', content: sysPrompt }, ...msgs],
301
338
  tools: Array.from(this.tools.values()).map(t => ({ type: t.type, function: t.function })),
302
339
  tool_choice: 'auto',
303
340
  };
@@ -331,73 +368,86 @@ export class Agent {
331
368
  }
332
369
  return `Skill "${name}" activated. Instructions loaded.`;
333
370
  }
334
- async compactConversation() {
371
+ async compactConversation(customSummary) {
335
372
  const minMessages = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP + 4;
336
373
  if (this.messages.length <= minMessages)
337
374
  return;
338
375
  const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
339
376
  const middle = this.messages.slice(COMPACT_HEAD_KEEP, -COMPACT_TAIL_KEEP);
340
377
  const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
341
- // Format middle messages for summarization, capping total size
342
- let totalChars = 0;
343
- const formatted = [];
344
- for (const m of middle) {
345
- let line;
346
- if (m.tool_calls?.length) {
347
- const names = m.tool_calls.map(tc => tc.function.name).join(', ');
348
- line = `[assistant]: (tool call: ${names})`;
378
+ let summary;
379
+ if (customSummary) {
380
+ // Use extension-provided summary instead of LLM call
381
+ summary = customSummary;
382
+ }
383
+ else {
384
+ // Format middle messages for summarization, capping total size
385
+ let totalChars = 0;
386
+ const formatted = [];
387
+ for (const m of middle) {
388
+ let line;
389
+ if (m.tool_calls?.length) {
390
+ const names = m.tool_calls.map(tc => tc.function.name).join(', ');
391
+ line = `[assistant]: (tool call: ${names})`;
392
+ }
393
+ else if (m.role === 'tool') {
394
+ line = `[tool result]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
395
+ }
396
+ else {
397
+ line = `[${m.role}]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
398
+ }
399
+ if (totalChars + line.length > COMPACT_TOTAL_CHARS)
400
+ break;
401
+ totalChars += line.length;
402
+ formatted.push(line);
349
403
  }
350
- else if (m.role === 'tool') {
351
- line = `[tool result]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
404
+ const stopSpinner = startSpinner('compacting context');
405
+ let stopped = false;
406
+ try {
407
+ const res = await fetch(`${this.baseURL}/chat/completions`, {
408
+ method: 'POST',
409
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
410
+ body: JSON.stringify({
411
+ model: this.model,
412
+ messages: [
413
+ { role: 'system', content: COMPACTION_PROMPT },
414
+ { role: 'user', content: formatted.join('\n\n') }
415
+ ]
416
+ })
417
+ });
418
+ if (!res.ok)
419
+ throw new Error(`API ${res.status}: ${await res.text()}`);
420
+ const body = await res.json();
421
+ summary = body.choices?.[0]?.message?.content;
422
+ if (!summary)
423
+ throw new Error('No summary returned');
424
+ stopSpinner();
425
+ stopped = true;
352
426
  }
353
- else {
354
- line = `[${m.role}]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
427
+ finally {
428
+ if (!stopped)
429
+ stopSpinner();
355
430
  }
356
- if (totalChars + line.length > COMPACT_TOTAL_CHARS)
357
- break;
358
- totalChars += line.length;
359
- formatted.push(line);
360
- }
361
- const stopSpinner = startSpinner('compacting context');
362
- let stopped = false;
363
- try {
364
- const res = await fetch(`${this.baseURL}/chat/completions`, {
365
- method: 'POST',
366
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
367
- body: JSON.stringify({
368
- model: this.model,
369
- messages: [
370
- { role: 'system', content: COMPACTION_PROMPT },
371
- { role: 'user', content: formatted.join('\n\n') }
372
- ]
373
- })
374
- });
375
- if (!res.ok)
376
- throw new Error(`API ${res.status}: ${await res.text()}`);
377
- const body = await res.json();
378
- const summary = body.choices?.[0]?.message?.content;
379
- if (!summary)
380
- throw new Error('No summary returned');
381
- const summaryMsg = {
382
- role: 'user',
383
- content: `[Conversation compacted — summary of ${middle.length} earlier messages]\n\n${summary}`
384
- };
385
- this.messages = [...head, summaryMsg, ...tail];
386
- appendHistory(summaryMsg, this.cwd);
387
- // Re-estimate context usage from the compacted messages
388
- const compactedChars = this.messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0)
389
- + this.systemPrompt.length;
390
- this.contextTracker.estimateFromChars(compactedChars);
391
- stopSpinner();
392
- stopped = true;
393
- process.stderr.write(` ${C.yellow}Context compacted: ${middle.length} messages → summary${C.reset}\n`);
394
- }
395
- finally {
396
- if (!stopped)
397
- stopSpinner();
398
431
  }
432
+ const summaryMsg = {
433
+ role: 'user',
434
+ content: `[Conversation compacted — summary of ${middle.length} earlier messages]\n\n${summary}`
435
+ };
436
+ this.messages = [...head, summaryMsg, ...tail];
437
+ appendHistory(summaryMsg, this.cwd);
438
+ // Re-estimate context usage from the compacted messages
439
+ const compactedChars = this.messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0)
440
+ + this.systemPrompt.length;
441
+ this.contextTracker.estimateFromChars(compactedChars);
442
+ process.stderr.write(` ${C.yellow}Context compacted: ${middle.length} messages → summary${C.reset}\n`);
399
443
  }
400
444
  async chat(content, signal) {
445
+ // ── input event ──
446
+ const inputEvent = { content, skip: false };
447
+ await this.events.emit('input', inputEvent);
448
+ if (inputEvent.skip)
449
+ return '';
450
+ content = inputEvent.content;
401
451
  const userMessage = { role: 'user', content };
402
452
  this.messages.push(userMessage);
403
453
  appendHistory(userMessage, this.cwd);
@@ -410,14 +460,46 @@ export class Agent {
410
460
  this.messages.push({ role: 'assistant', content: '[interrupted by user]' });
411
461
  return '[interrupted by user]';
412
462
  }
463
+ // ── turn_start event ──
464
+ await this.events.emit('turn_start', { iteration: i, maxIterations: this.maxIterations, messageCount: this.messages.length });
413
465
  const iterLabel = this.maxIterations > 1 ? ` [${i + 1}/${this.maxIterations}]` : '';
414
466
  const stopSpinner = startSpinner(`thinking${iterLabel}`);
467
+ // ── before_compact event ──
468
+ if (!this.compactionInProgress && this.contextTracker.shouldCompact(COMPACT_THRESHOLD)) {
469
+ const compactEvent = { messageCount: this.messages.length, cancel: false, customSummary: undefined };
470
+ await this.events.emit('before_compact', compactEvent);
471
+ if (!compactEvent.cancel) {
472
+ this.compactionInProgress = true;
473
+ try {
474
+ await this.compactConversation(compactEvent.customSummary);
475
+ }
476
+ catch (e) {
477
+ process.stderr.write(` ${C.dim}Compaction failed: ${e} — falling back to truncation${C.reset}\n`);
478
+ const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
479
+ if (this.messages.length > keep) {
480
+ const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
481
+ const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
482
+ const truncMsg = {
483
+ role: 'user',
484
+ content: `[Conversation truncated — ${this.messages.length - keep} older messages removed to stay within context limit]`
485
+ };
486
+ this.messages = [...head, truncMsg, ...tail];
487
+ }
488
+ }
489
+ finally {
490
+ this.compactionInProgress = false;
491
+ }
492
+ }
493
+ }
494
+ // ── before_request event ──
495
+ const reqEvent = { messages: this.messages, systemPrompt: this.systemPrompt, model: this.model, stream: false };
496
+ await this.events.emit('before_request', reqEvent);
415
497
  let msg;
416
498
  try {
417
499
  const res = await fetch(`${this.baseURL}/chat/completions`, {
418
500
  method: 'POST',
419
501
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
420
- body: JSON.stringify(this.buildRequestBody(false)),
502
+ body: JSON.stringify(this.buildRequestBody(false, { messages: reqEvent.messages, systemPrompt: reqEvent.systemPrompt })),
421
503
  signal
422
504
  });
423
505
  if (!res.ok)
@@ -435,36 +517,33 @@ export class Agent {
435
517
  finally {
436
518
  stopSpinner();
437
519
  }
438
- // Compact conversation if approaching context limit
439
- if (!this.compactionInProgress && this.contextTracker.shouldCompact(COMPACT_THRESHOLD)) {
440
- this.compactionInProgress = true;
441
- try {
442
- await this.compactConversation();
443
- }
444
- catch (e) {
445
- process.stderr.write(` ${C.dim}Compaction failed: ${e} — falling back to truncation${C.reset}\n`);
446
- // Fallback: simple truncation to prevent context overflow
447
- const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
448
- if (this.messages.length > keep) {
449
- const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
450
- const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
451
- const truncMsg = {
452
- role: 'user',
453
- content: `[Conversation truncated — ${this.messages.length - keep} older messages removed to stay within context limit]`
454
- };
455
- this.messages = [...head, truncMsg, ...tail];
456
- }
457
- }
458
- finally {
459
- this.compactionInProgress = false;
460
- }
461
- }
520
+ // ── after_response event ──
521
+ await this.events.emit('after_response', { message: msg, usage: undefined });
462
522
  this.messages.push(msg);
463
523
  appendHistory(msg, this.cwd);
464
524
  if (!msg.tool_calls?.length) {
525
+ // ── turn_end event (no tools) ──
526
+ await this.events.emit('turn_end', { iteration: i, hadToolCalls: false, toolCallCount: 0 });
465
527
  return msg.content || '';
466
528
  }
467
- // Execute tool calls in parallel
529
+ // Permission checks run sequentially so prompts don't overlap
530
+ const permissionDecisions = new Map();
531
+ for (const call of msg.tool_calls) {
532
+ const tool = this.tools.get(call.function.name);
533
+ if (!tool)
534
+ continue;
535
+ let args;
536
+ try {
537
+ args = JSON.parse(call.function.arguments || '{}');
538
+ }
539
+ catch {
540
+ continue;
541
+ }
542
+ if (this.confirmToolCall && !isReadOnlyToolCall(call.function.name, args)) {
543
+ permissionDecisions.set(call.id, await this.confirmToolCall(call.function.name, args, tool.permissionKey));
544
+ }
545
+ }
546
+ // Execute tool calls in parallel (permissions already resolved)
468
547
  const toolPromises = msg.tool_calls.map(async (call) => {
469
548
  const tool = this.tools.get(call.function.name);
470
549
  if (!tool) {
@@ -477,20 +556,28 @@ export class Agent {
477
556
  catch {
478
557
  return { call, content: 'Error: malformed tool arguments' };
479
558
  }
480
- // Permission check
481
- if (this.confirmToolCall && !isReadOnlyToolCall(call.function.name, args)) {
482
- const decision = await this.confirmToolCall(call.function.name, args);
483
- if (decision === 'deny') {
484
- return { call, content: 'Tool call denied by user.' };
485
- }
559
+ if (permissionDecisions.get(call.id) === 'deny') {
560
+ return { call, content: 'Tool call denied by user.' };
561
+ }
562
+ // ── tool_call event ──
563
+ const tcEvent = { toolName: call.function.name, toolCallId: call.id, args, block: false, blockReason: undefined };
564
+ await this.events.emit('tool_call', tcEvent);
565
+ if (tcEvent.block) {
566
+ return { call, content: tcEvent.blockReason || 'Blocked by extension' };
486
567
  }
568
+ args = tcEvent.args;
487
569
  const summary = args.command ?? args.action ?? JSON.stringify(args).slice(0, 80);
488
570
  const stopToolSpinner = startSpinner(`[${call.function.name}] ${summary}`);
489
571
  try {
490
572
  const rawResult = await tool.execute(args);
491
- const result = truncateToolResult(rawResult);
573
+ let result = truncateToolResult(rawResult);
574
+ let isError = result.startsWith('Error:') || result.startsWith('EXIT ');
575
+ // ── tool_result event ──
576
+ const trEvent = { toolName: call.function.name, toolCallId: call.id, args, content: result, isError };
577
+ await this.events.emit('tool_result', trEvent);
578
+ result = trEvent.content;
579
+ isError = trEvent.isError;
492
580
  stopToolSpinner();
493
- const isError = result.startsWith('Error:') || result.startsWith('EXIT ');
494
581
  const icon = isError ? `${C.red}✗` : `${C.green}✓`;
495
582
  const preview = result.slice(0, 150).split('\n')[0];
496
583
  process.stderr.write(` ${icon} ${C.dim}[${call.function.name}]${C.reset} ${C.dim}${preview}${result.length > 150 ? '...' : ''}${C.reset}\n`);
@@ -508,10 +595,18 @@ export class Agent {
508
595
  this.messages.push({ role: 'tool', tool_call_id: call.id, content });
509
596
  appendHistory({ role: 'tool', tool_call_id: call.id, content }, this.cwd);
510
597
  }
598
+ // ── turn_end event ──
599
+ await this.events.emit('turn_end', { iteration: i, hadToolCalls: true, toolCallCount: msg.tool_calls.length });
511
600
  }
512
601
  return MAX_ITERATIONS_REACHED;
513
602
  }
514
603
  async *chatStream(content, signal) {
604
+ // ── input event ──
605
+ const inputEvent = { content, skip: false };
606
+ await this.events.emit('input', inputEvent);
607
+ if (inputEvent.skip)
608
+ return;
609
+ content = inputEvent.content;
515
610
  const userMessage = { role: 'user', content };
516
611
  this.messages.push(userMessage);
517
612
  appendHistory(userMessage, this.cwd);
@@ -525,37 +620,46 @@ export class Agent {
525
620
  yield { type: 'interrupted' };
526
621
  return;
527
622
  }
623
+ // ── turn_start event ──
624
+ await this.events.emit('turn_start', { iteration: i, maxIterations: this.maxIterations, messageCount: this.messages.length });
528
625
  const iterLabel = this.maxIterations > 1 ? ` [${i + 1}/${this.maxIterations}]` : '';
529
626
  yield { type: 'thinking', content: `thinking${iterLabel}` };
530
- // Compact if needed
627
+ // ── before_compact event ──
531
628
  if (!this.compactionInProgress && this.contextTracker.shouldCompact(COMPACT_THRESHOLD)) {
532
- this.compactionInProgress = true;
533
- try {
534
- await this.compactConversation();
535
- }
536
- catch (e) {
537
- const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
538
- if (this.messages.length > keep) {
539
- const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
540
- const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
541
- const truncMsg = {
542
- role: 'user',
543
- content: `[Conversation truncated — ${this.messages.length - keep} older messages removed to stay within context limit]`
544
- };
545
- this.messages = [...head, truncMsg, ...tail];
629
+ const compactEvent = { messageCount: this.messages.length, cancel: false, customSummary: undefined };
630
+ await this.events.emit('before_compact', compactEvent);
631
+ if (!compactEvent.cancel) {
632
+ this.compactionInProgress = true;
633
+ try {
634
+ await this.compactConversation(compactEvent.customSummary);
635
+ }
636
+ catch {
637
+ const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
638
+ if (this.messages.length > keep) {
639
+ const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
640
+ const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
641
+ const truncMsg = {
642
+ role: 'user',
643
+ content: `[Conversation truncated — ${this.messages.length - keep} older messages removed to stay within context limit]`
644
+ };
645
+ this.messages = [...head, truncMsg, ...tail];
646
+ }
647
+ }
648
+ finally {
649
+ this.compactionInProgress = false;
546
650
  }
547
- }
548
- finally {
549
- this.compactionInProgress = false;
550
651
  }
551
652
  }
653
+ // ── before_request event ──
654
+ const reqEvent = { messages: this.messages, systemPrompt: this.systemPrompt, model: this.model, stream: true };
655
+ await this.events.emit('before_request', reqEvent);
552
656
  // ── API call with abort signal ──
553
657
  let res;
554
658
  try {
555
659
  res = await fetch(`${this.baseURL}/chat/completions`, {
556
660
  method: 'POST',
557
661
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
558
- body: JSON.stringify(this.buildRequestBody(true)),
662
+ body: JSON.stringify(this.buildRequestBody(true, { messages: reqEvent.messages, systemPrompt: reqEvent.systemPrompt })),
559
663
  signal
560
664
  });
561
665
  }
@@ -669,10 +773,14 @@ export class Agent {
669
773
  function: { name: tc.name, arguments: tc.arguments }
670
774
  }));
671
775
  }
776
+ // ── after_response event ──
777
+ await this.events.emit('after_response', { message: msg, usage: usage ?? undefined });
672
778
  this.messages.push(msg);
673
779
  appendHistory(msg, this.cwd);
674
780
  if (!msg.tool_calls?.length) {
675
- yield { type: 'done', content: assistantContent };
781
+ // ── turn_end event (no tools) ──
782
+ await this.events.emit('turn_end', { iteration: i, hadToolCalls: false, toolCallCount: 0 });
783
+ yield { type: 'done', content: msg.content || '' };
676
784
  return;
677
785
  }
678
786
  // Notify consumers that tools are about to run (shows spinner immediately)
@@ -686,6 +794,25 @@ export class Agent {
686
794
  const summary = args.command ?? args.action ?? call.function.name;
687
795
  yield { type: 'tool_start', toolName: call.function.name, toolCallId: call.id, content: summary };
688
796
  }
797
+ // Permission checks — run sequentially so prompts don't overlap
798
+ const permissionDecisions = new Map();
799
+ for (const call of msg.tool_calls) {
800
+ if (signal?.aborted)
801
+ break;
802
+ const tool = this.tools.get(call.function.name);
803
+ if (!tool)
804
+ continue;
805
+ let args;
806
+ try {
807
+ args = JSON.parse(call.function.arguments || '{}');
808
+ }
809
+ catch {
810
+ continue;
811
+ }
812
+ if (this.confirmToolCall && !isReadOnlyToolCall(call.function.name, args)) {
813
+ permissionDecisions.set(call.id, await this.confirmToolCall(call.function.name, args, tool.permissionKey));
814
+ }
815
+ }
689
816
  // Execute tool calls — yield results as they complete (not Promise.all)
690
817
  const execPromises = msg.tool_calls.map(async (call) => {
691
818
  if (signal?.aborted)
@@ -700,17 +827,25 @@ export class Agent {
700
827
  catch {
701
828
  return { call, content: 'Error: malformed tool arguments', isError: true };
702
829
  }
703
- // Permission check
704
- if (this.confirmToolCall && !isReadOnlyToolCall(call.function.name, args)) {
705
- const decision = await this.confirmToolCall(call.function.name, args);
706
- if (decision === 'deny') {
707
- return { call, content: 'Tool call denied by user.', isError: true };
708
- }
830
+ if (permissionDecisions.get(call.id) === 'deny') {
831
+ return { call, content: 'Tool call denied by user.', isError: true };
832
+ }
833
+ // ── tool_call event ──
834
+ const tcEvent = { toolName: call.function.name, toolCallId: call.id, args, block: false, blockReason: undefined };
835
+ await this.events.emit('tool_call', tcEvent);
836
+ if (tcEvent.block) {
837
+ return { call, content: tcEvent.blockReason || 'Blocked by extension', isError: true };
709
838
  }
839
+ args = tcEvent.args;
710
840
  try {
711
841
  const rawResult = await tool.execute(args);
712
- const result = truncateToolResult(rawResult);
713
- const isError = result.startsWith('Error:') || result.startsWith('EXIT ');
842
+ let result = truncateToolResult(rawResult);
843
+ let isError = result.startsWith('Error:') || result.startsWith('EXIT ');
844
+ // ── tool_result event ──
845
+ const trEvent = { toolName: call.function.name, toolCallId: call.id, args, content: result, isError };
846
+ await this.events.emit('tool_result', trEvent);
847
+ result = trEvent.content;
848
+ isError = trEvent.isError;
714
849
  return { call, content: result, isError };
715
850
  }
716
851
  catch (error) {
@@ -741,6 +876,8 @@ export class Agent {
741
876
  yield { type: 'interrupted', content: `${completedCallIds.size} completed, ${msg.tool_calls.length - completedCallIds.size} cancelled` };
742
877
  return;
743
878
  }
879
+ // ── turn_end event ──
880
+ await this.events.emit('turn_end', { iteration: i, hadToolCalls: true, toolCallCount: msg.tool_calls.length });
744
881
  }
745
882
  yield { type: 'max_iterations' };
746
883
  }
@@ -793,8 +930,13 @@ export class Agent {
793
930
  return parts;
794
931
  }
795
932
  getTools() {
796
- return Array.from(this.tools.values()).map(t => ({ name: t.function.name, description: t.function.description }));
933
+ return Array.from(this.tools.values()).map(t => ({
934
+ name: t.function.name,
935
+ description: t.function.description,
936
+ isBuiltin: this.builtinToolNames.has(t.function.name),
937
+ }));
797
938
  }
939
+ getToolFailures() { return this.toolFailures; }
798
940
  getModel() { return this.model; }
799
941
  getBaseURL() { return this.baseURL; }
800
942
  setModel(model) { this.model = model; this.contextTracker = new ContextTracker(model); }