@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.
- package/README.md +184 -8
- package/dist/cli/__tests__/repl-rawmode.test.d.ts +2 -0
- package/dist/cli/__tests__/repl-rawmode.test.d.ts.map +1 -0
- package/dist/cli/__tests__/repl-rawmode.test.js +46 -0
- package/dist/cli/__tests__/repl-rawmode.test.js.map +1 -0
- package/dist/cli/repl.d.ts +7 -2
- package/dist/cli/repl.d.ts.map +1 -1
- package/dist/cli/repl.js +203 -25
- package/dist/cli/repl.js.map +1 -1
- package/dist/cli.js +10 -8
- package/dist/cli.js.map +1 -1
- package/dist/core/__tests__/agent.test.js +42 -1
- package/dist/core/__tests__/agent.test.js.map +1 -1
- package/dist/core/__tests__/context.test.js +81 -0
- package/dist/core/__tests__/context.test.js.map +1 -1
- package/dist/core/__tests__/events.test.d.ts +2 -0
- package/dist/core/__tests__/events.test.d.ts.map +1 -0
- package/dist/core/__tests__/events.test.js +131 -0
- package/dist/core/__tests__/events.test.js.map +1 -0
- package/dist/core/__tests__/extensions.test.d.ts +2 -0
- package/dist/core/__tests__/extensions.test.d.ts.map +1 -0
- package/dist/core/__tests__/extensions.test.js +59 -0
- package/dist/core/__tests__/extensions.test.js.map +1 -0
- package/dist/core/__tests__/guardrails.test.d.ts +2 -0
- package/dist/core/__tests__/guardrails.test.d.ts.map +1 -0
- package/dist/core/__tests__/guardrails.test.js +418 -0
- package/dist/core/__tests__/guardrails.test.js.map +1 -0
- package/dist/core/__tests__/permission-manager.test.d.ts +2 -0
- package/dist/core/__tests__/permission-manager.test.d.ts.map +1 -0
- package/dist/core/__tests__/permission-manager.test.js +246 -0
- package/dist/core/__tests__/permission-manager.test.js.map +1 -0
- package/dist/core/__tests__/streaming.test.js +8 -1
- package/dist/core/__tests__/streaming.test.js.map +1 -1
- package/dist/core/agent.d.ts +27 -0
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +263 -121
- package/dist/core/agent.js.map +1 -1
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +14 -6
- package/dist/core/context.js.map +1 -1
- package/dist/core/events.d.ts +62 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +23 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/extensions.d.ts +10 -0
- package/dist/core/extensions.d.ts.map +1 -0
- package/dist/core/extensions.js +66 -0
- package/dist/core/extensions.js.map +1 -0
- package/dist/core/guardrails.d.ts +32 -0
- package/dist/core/guardrails.d.ts.map +1 -0
- package/dist/core/guardrails.js +149 -0
- package/dist/core/guardrails.js.map +1 -0
- package/dist/core/loader.d.ts +6 -2
- package/dist/core/loader.d.ts.map +1 -1
- package/dist/core/loader.js +23 -9
- package/dist/core/loader.js.map +1 -1
- package/dist/core/permissions.d.ts +60 -0
- package/dist/core/permissions.d.ts.map +1 -0
- package/dist/core/permissions.js +252 -0
- package/dist/core/permissions.js.map +1 -0
- package/dist/core/registry.d.ts.map +1 -1
- package/dist/core/registry.js +16 -0
- package/dist/core/registry.js.map +1 -1
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +26 -3
- package/dist/core/skills.js.map +1 -1
- package/dist/core/types.d.ts +15 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/memory/__tests__/memory.test.js +28 -0
- package/dist/memory/__tests__/memory.test.js.map +1 -1
- package/dist/memory/memory.js +1 -1
- package/dist/memory/memory.js.map +1 -1
- package/dist/tools/__tests__/bash.test.js +4 -0
- package/dist/tools/__tests__/bash.test.js.map +1 -1
- package/dist/tools/__tests__/file.test.js +7 -1
- package/dist/tools/__tests__/file.test.js.map +1 -1
- package/package.json +1 -4
package/dist/core/agent.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
//
|
|
439
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
627
|
+
// ── before_compact event ──
|
|
531
628
|
if (!this.compactionInProgress && this.contextTracker.shouldCompact(COMPACT_THRESHOLD)) {
|
|
532
|
-
this.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
713
|
-
|
|
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 => ({
|
|
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); }
|