@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.
- package/README.md +44 -14
- package/dist/cli/parser.d.ts.map +1 -1
- package/dist/cli/parser.js +8 -5
- package/dist/cli/parser.js.map +1 -1
- package/dist/cli/repl.d.ts.map +1 -1
- package/dist/cli/repl.js +136 -72
- package/dist/cli/repl.js.map +1 -1
- package/dist/core/__tests__/agent-units.test.d.ts +2 -0
- package/dist/core/__tests__/agent-units.test.d.ts.map +1 -0
- package/dist/core/__tests__/agent-units.test.js +144 -0
- package/dist/core/__tests__/agent-units.test.js.map +1 -0
- package/dist/core/__tests__/context.test.js +24 -0
- package/dist/core/__tests__/context.test.js.map +1 -1
- package/dist/core/__tests__/events.test.js +1 -1
- package/dist/core/__tests__/events.test.js.map +1 -1
- package/dist/core/__tests__/streaming.test.js +2 -1
- package/dist/core/__tests__/streaming.test.js.map +1 -1
- package/dist/core/agent.d.ts +7 -8
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +94 -428
- package/dist/core/agent.js.map +1 -1
- package/dist/core/compaction.d.ts +27 -0
- package/dist/core/compaction.d.ts.map +1 -0
- package/dist/core/compaction.js +102 -0
- package/dist/core/compaction.js.map +1 -0
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +6 -2
- package/dist/core/context.js.map +1 -1
- package/dist/core/events.d.ts.map +1 -1
- package/dist/core/events.js +6 -1
- package/dist/core/events.js.map +1 -1
- package/dist/core/prompt.d.ts +23 -0
- package/dist/core/prompt.d.ts.map +1 -0
- package/dist/core/prompt.js +122 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/core/types.d.ts +1 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/utils.d.ts +11 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/core/utils.js +82 -0
- package/dist/core/utils.js.map +1 -0
- package/dist/memory/__tests__/memory.test.js +47 -2
- package/dist/memory/__tests__/memory.test.js.map +1 -1
- package/dist/memory/memory.d.ts +8 -0
- package/dist/memory/memory.d.ts.map +1 -1
- package/dist/memory/memory.js +93 -6
- package/dist/memory/memory.js.map +1 -1
- package/dist/tools/agent.js +17 -15
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/file.d.ts.map +1 -1
- package/dist/tools/file.js +9 -5
- package/dist/tools/file.js.map +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +7 -5
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/task.d.ts.map +1 -1
- package/dist/tools/task.js +40 -33
- package/dist/tools/task.js.map +1 -1
- package/package.json +1 -1
package/dist/core/agent.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
397
|
-
if (
|
|
245
|
+
const result = await compactMessages(this.messages, { baseURL: this.baseURL, apiKey: this.apiKey, model: this.model }, customSummary);
|
|
246
|
+
if (!result)
|
|
398
247
|
return;
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
|
680
|
+
const res = await fetchWithRetry(`${this.baseURL}/models`, {
|
|
1015
681
|
headers: { 'Authorization': `Bearer ${this.apiKey}` }
|
|
1016
682
|
});
|
|
1017
683
|
if (!res.ok)
|