llm-agent-cli 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +112 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +730 -0
- package/dist/agent.js.map +1 -0
- package/dist/audit.d.ts +24 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +94 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth.d.ts +36 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +236 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +92 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +94 -0
- package/dist/context.js.map +1 -0
- package/dist/diff.d.ts +27 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +174 -0
- package/dist/diff.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +905 -0
- package/dist/index.js.map +1 -0
- package/dist/input.d.ts +13 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +88 -0
- package/dist/input.js.map +1 -0
- package/dist/memory.d.ts +32 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +103 -0
- package/dist/memory.js.map +1 -0
- package/dist/preprocessor.d.ts +12 -0
- package/dist/preprocessor.d.ts.map +1 -0
- package/dist/preprocessor.js +138 -0
- package/dist/preprocessor.js.map +1 -0
- package/dist/project.d.ts +10 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +145 -0
- package/dist/project.js.map +1 -0
- package/dist/renderer.d.ts +2 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +31 -0
- package/dist/renderer.js.map +1 -0
- package/dist/session.d.ts +36 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +78 -0
- package/dist/session.js.map +1 -0
- package/dist/tools/filesystem.d.ts +138 -0
- package/dist/tools/filesystem.d.ts.map +1 -0
- package/dist/tools/filesystem.js +539 -0
- package/dist/tools/filesystem.js.map +1 -0
- package/dist/tools/git.d.ts +60 -0
- package/dist/tools/git.d.ts.map +1 -0
- package/dist/tools/git.js +188 -0
- package/dist/tools/git.js.map +1 -0
- package/dist/tools/index.d.ts +386 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +142 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/linter.d.ts +44 -0
- package/dist/tools/linter.d.ts.map +1 -0
- package/dist/tools/linter.js +426 -0
- package/dist/tools/linter.js.map +1 -0
- package/dist/tools/network.d.ts +17 -0
- package/dist/tools/network.d.ts.map +1 -0
- package/dist/tools/network.js +121 -0
- package/dist/tools/network.js.map +1 -0
- package/dist/tools/search.d.ts +33 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +263 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/security.d.ts +18 -0
- package/dist/tools/security.d.ts.map +1 -0
- package/dist/tools/security.js +242 -0
- package/dist/tools/security.js.map +1 -0
- package/dist/tools/shell.d.ts +14 -0
- package/dist/tools/shell.d.ts.map +1 -0
- package/dist/tools/shell.js +68 -0
- package/dist/tools/shell.js.map +1 -0
- package/dist/tools/types.d.ts +5 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/package.json +59 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { TOOL_DEFINITIONS, TOOL_MAP, configureToolRuntime, getToolRuntime, } from "./tools/index.js";
|
|
2
|
+
import { estimateTokens } from "./context.js";
|
|
3
|
+
class RouterHttpError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
constructor(status, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "RouterHttpError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class TurnCancelledError extends Error {
|
|
12
|
+
constructor(message = "Turn cancelled") {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "TurnCancelledError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const RETRYABLE_STATUS = new Set([429, 500, 503]);
|
|
18
|
+
const MAX_ATTEMPTS = 3;
|
|
19
|
+
function getRouterConfig() {
|
|
20
|
+
const baseUrl = process.env.LLM_ROUTER_BASE_URL;
|
|
21
|
+
const apiKey = process.env.LLM_ROUTER_API_KEY;
|
|
22
|
+
const model = process.env.LLM_ROUTER_MODEL ?? "auto";
|
|
23
|
+
if (!baseUrl)
|
|
24
|
+
throw new Error("LLM_ROUTER_BASE_URL is not set in .env");
|
|
25
|
+
if (!apiKey)
|
|
26
|
+
throw new Error("LLM_ROUTER_API_KEY is not set in .env");
|
|
27
|
+
return { baseUrl, apiKey, model };
|
|
28
|
+
}
|
|
29
|
+
async function sleep(ms, signal) {
|
|
30
|
+
if (signal?.aborted) {
|
|
31
|
+
throw new TurnCancelledError();
|
|
32
|
+
}
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
const timeout = setTimeout(() => {
|
|
35
|
+
cleanup();
|
|
36
|
+
resolve();
|
|
37
|
+
}, ms);
|
|
38
|
+
const onAbort = () => {
|
|
39
|
+
cleanup();
|
|
40
|
+
reject(new TurnCancelledError());
|
|
41
|
+
};
|
|
42
|
+
const cleanup = () => {
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
signal?.removeEventListener("abort", onAbort);
|
|
45
|
+
};
|
|
46
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function isRetryableError(err) {
|
|
50
|
+
if (err instanceof TurnCancelledError)
|
|
51
|
+
return false;
|
|
52
|
+
if (err instanceof RouterHttpError) {
|
|
53
|
+
return RETRYABLE_STATUS.has(err.status);
|
|
54
|
+
}
|
|
55
|
+
if (err instanceof TypeError) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
function buildSystemPrompt(workspaceRoot, runtime, systemContext) {
|
|
61
|
+
const contextBlock = systemContext
|
|
62
|
+
? `\nProject context:\n${systemContext}\n`
|
|
63
|
+
: "";
|
|
64
|
+
return `You are a capable terminal-based AI agent.
|
|
65
|
+
|
|
66
|
+
Available tools:
|
|
67
|
+
- read_file
|
|
68
|
+
- write_to_file
|
|
69
|
+
- apply_diff
|
|
70
|
+
- list_directory
|
|
71
|
+
- create_directory
|
|
72
|
+
- delete_file
|
|
73
|
+
- move_file
|
|
74
|
+
- get_file_info
|
|
75
|
+
- search_files
|
|
76
|
+
- find_files
|
|
77
|
+
- fetch_url
|
|
78
|
+
- run_bash_command
|
|
79
|
+
|
|
80
|
+
Execution constraints:
|
|
81
|
+
- Workspace root: ${workspaceRoot}
|
|
82
|
+
- Permission mode: ${runtime.permissionMode}
|
|
83
|
+
- Max read size: ${runtime.maxReadBytes} bytes
|
|
84
|
+
- Max tool result passed back to model: ${runtime.maxToolResultChars} chars
|
|
85
|
+
- Use targeted edits (apply_diff) when possible instead of full rewrites.
|
|
86
|
+
- Inspect files before editing.
|
|
87
|
+
- Summarize what changed when complete.${contextBlock}`;
|
|
88
|
+
}
|
|
89
|
+
function buildFullSystemPrompt(base, memory) {
|
|
90
|
+
if (!memory.trim())
|
|
91
|
+
return base;
|
|
92
|
+
return `${base}\n\n# Persistent Memory\n${memory}`;
|
|
93
|
+
}
|
|
94
|
+
function normalizeToolCalls(map) {
|
|
95
|
+
return Array.from(map.entries())
|
|
96
|
+
.sort(([a], [b]) => a - b)
|
|
97
|
+
.map(([, value]) => value)
|
|
98
|
+
.filter((call) => call.function.name.trim().length > 0);
|
|
99
|
+
}
|
|
100
|
+
async function parseSseResponse(response, signal, onToken) {
|
|
101
|
+
const reader = response.body?.getReader();
|
|
102
|
+
if (!reader) {
|
|
103
|
+
throw new Error("Router response body is empty");
|
|
104
|
+
}
|
|
105
|
+
const decoder = new TextDecoder();
|
|
106
|
+
let buffer = "";
|
|
107
|
+
let role = "assistant";
|
|
108
|
+
let content = "";
|
|
109
|
+
let finishReason = "stop";
|
|
110
|
+
let usage;
|
|
111
|
+
let model;
|
|
112
|
+
const toolCalls = new Map();
|
|
113
|
+
while (true) {
|
|
114
|
+
if (signal?.aborted) {
|
|
115
|
+
throw new TurnCancelledError();
|
|
116
|
+
}
|
|
117
|
+
const { done, value } = await reader.read();
|
|
118
|
+
if (done)
|
|
119
|
+
break;
|
|
120
|
+
if (!value)
|
|
121
|
+
continue;
|
|
122
|
+
buffer += decoder.decode(value, { stream: true });
|
|
123
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
124
|
+
while (newlineIndex !== -1) {
|
|
125
|
+
const rawLine = buffer.slice(0, newlineIndex);
|
|
126
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
127
|
+
const line = rawLine.trim();
|
|
128
|
+
if (!line || line.startsWith(":")) {
|
|
129
|
+
newlineIndex = buffer.indexOf("\n");
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!line.startsWith("data:")) {
|
|
133
|
+
newlineIndex = buffer.indexOf("\n");
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const payload = line.slice(5).trim();
|
|
137
|
+
if (!payload || payload === "[DONE]") {
|
|
138
|
+
newlineIndex = buffer.indexOf("\n");
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(payload);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
newlineIndex = buffer.indexOf("\n");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (parsed.model) {
|
|
150
|
+
model = parsed.model;
|
|
151
|
+
}
|
|
152
|
+
if (parsed.usage) {
|
|
153
|
+
usage = parsed.usage;
|
|
154
|
+
}
|
|
155
|
+
const choice = parsed.choices?.[0];
|
|
156
|
+
if (choice?.finish_reason) {
|
|
157
|
+
finishReason = choice.finish_reason;
|
|
158
|
+
}
|
|
159
|
+
if (choice?.delta?.role) {
|
|
160
|
+
role = choice.delta.role;
|
|
161
|
+
}
|
|
162
|
+
const deltaContent = choice?.delta?.content;
|
|
163
|
+
if (deltaContent) {
|
|
164
|
+
content += deltaContent;
|
|
165
|
+
if (onToken) {
|
|
166
|
+
await onToken(deltaContent);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const deltaToolCalls = choice?.delta?.tool_calls ?? [];
|
|
170
|
+
for (const deltaToolCall of deltaToolCalls) {
|
|
171
|
+
const index = deltaToolCall.index ?? 0;
|
|
172
|
+
const existing = toolCalls.get(index) ?? {
|
|
173
|
+
id: deltaToolCall.id ?? `tool_call_${index}`,
|
|
174
|
+
type: "function",
|
|
175
|
+
function: {
|
|
176
|
+
name: "",
|
|
177
|
+
arguments: "",
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
if (deltaToolCall.id) {
|
|
181
|
+
existing.id = deltaToolCall.id;
|
|
182
|
+
}
|
|
183
|
+
const functionName = deltaToolCall.function?.name;
|
|
184
|
+
if (functionName) {
|
|
185
|
+
existing.function.name += functionName;
|
|
186
|
+
}
|
|
187
|
+
const functionArgs = deltaToolCall.function?.arguments;
|
|
188
|
+
if (functionArgs) {
|
|
189
|
+
existing.function.arguments += functionArgs;
|
|
190
|
+
}
|
|
191
|
+
toolCalls.set(index, existing);
|
|
192
|
+
}
|
|
193
|
+
newlineIndex = buffer.indexOf("\n");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
message: {
|
|
198
|
+
role,
|
|
199
|
+
content: content || null,
|
|
200
|
+
tool_calls: normalizeToolCalls(toolCalls),
|
|
201
|
+
},
|
|
202
|
+
finishReason,
|
|
203
|
+
usage,
|
|
204
|
+
model,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
async function callRouterOnce(messages, options) {
|
|
208
|
+
const { baseUrl, apiKey, model } = getRouterConfig();
|
|
209
|
+
const body = {
|
|
210
|
+
model,
|
|
211
|
+
messages,
|
|
212
|
+
};
|
|
213
|
+
if (options.toolsEnabled) {
|
|
214
|
+
body.tools = TOOL_DEFINITIONS;
|
|
215
|
+
body.tool_choice = "auto";
|
|
216
|
+
}
|
|
217
|
+
if (options.stream) {
|
|
218
|
+
body.stream = true;
|
|
219
|
+
body.stream_options = { include_usage: true };
|
|
220
|
+
}
|
|
221
|
+
const response = await fetch(`${baseUrl}/v1/chat/completions`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: {
|
|
224
|
+
"Content-Type": "application/json",
|
|
225
|
+
Authorization: `Bearer ${apiKey}`,
|
|
226
|
+
"X-Client-Type": "cli-agent",
|
|
227
|
+
Accept: options.stream ? "text/event-stream, application/json" : "application/json",
|
|
228
|
+
},
|
|
229
|
+
signal: options.signal,
|
|
230
|
+
body: JSON.stringify(body),
|
|
231
|
+
});
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
const errorBody = await response.text();
|
|
234
|
+
throw new RouterHttpError(response.status, `LLM Router error ${response.status}: ${errorBody}`);
|
|
235
|
+
}
|
|
236
|
+
if (options.stream) {
|
|
237
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
238
|
+
if (!contentType.includes("application/json")) {
|
|
239
|
+
return parseSseResponse(response, options.signal, options.onToken);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const data = (await response.json());
|
|
243
|
+
const choice = data.choices[0];
|
|
244
|
+
if (!choice) {
|
|
245
|
+
throw new Error("No choice returned from router");
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
message: choice.message,
|
|
249
|
+
finishReason: choice.finish_reason,
|
|
250
|
+
usage: data.usage,
|
|
251
|
+
model: data.model,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function callRouterWithRetry(messages, signal, callbacks) {
|
|
255
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
256
|
+
try {
|
|
257
|
+
return await callRouterOnce(messages, {
|
|
258
|
+
signal,
|
|
259
|
+
toolsEnabled: true,
|
|
260
|
+
stream: true,
|
|
261
|
+
onToken: callbacks.onToken,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
if (signal?.aborted) {
|
|
266
|
+
throw new TurnCancelledError();
|
|
267
|
+
}
|
|
268
|
+
if (!isRetryableError(err) || attempt >= MAX_ATTEMPTS) {
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
const delayMs = 1000 * 2 ** (attempt - 1);
|
|
272
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
273
|
+
if (callbacks.onRetry) {
|
|
274
|
+
await callbacks.onRetry(attempt, delayMs, reason);
|
|
275
|
+
}
|
|
276
|
+
await sleep(delayMs, signal);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
throw new Error("Router call failed after maximum retries");
|
|
280
|
+
}
|
|
281
|
+
function previewArgs(rawArgs) {
|
|
282
|
+
const collapsed = rawArgs.replace(/\s+/g, " ").trim();
|
|
283
|
+
if (collapsed.length <= 120)
|
|
284
|
+
return collapsed;
|
|
285
|
+
return `${collapsed.slice(0, 117)}...`;
|
|
286
|
+
}
|
|
287
|
+
function truncateToolResult(rawResult) {
|
|
288
|
+
const runtime = getToolRuntime();
|
|
289
|
+
if (rawResult.length <= runtime.maxToolResultChars) {
|
|
290
|
+
return { output: rawResult, truncated: false };
|
|
291
|
+
}
|
|
292
|
+
const trimmed = rawResult.slice(0, runtime.maxToolResultChars);
|
|
293
|
+
const dropped = rawResult.length - runtime.maxToolResultChars;
|
|
294
|
+
return {
|
|
295
|
+
output: `${trimmed}\n\n[Tool output truncated: removed ${dropped} chars before returning to model]`,
|
|
296
|
+
truncated: true,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function isApprovedFromToolResult(toolName, toolResult) {
|
|
300
|
+
const lower = toolResult.toLowerCase();
|
|
301
|
+
if (!lower.startsWith("error")) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
if (lower.includes("user denied") ||
|
|
305
|
+
lower.includes("disabled in safe mode") ||
|
|
306
|
+
lower.includes("requires confirmation") ||
|
|
307
|
+
lower.includes("blocked dangerous command pattern")) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
if (toolName === "run_bash_command" && lower.includes("shell commands are disabled")) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
function normalizeMessageContent(content) {
|
|
316
|
+
if (content === undefined)
|
|
317
|
+
return null;
|
|
318
|
+
return content;
|
|
319
|
+
}
|
|
320
|
+
export async function checkRouterHealth() {
|
|
321
|
+
const baseUrl = process.env.LLM_ROUTER_BASE_URL;
|
|
322
|
+
if (!baseUrl) {
|
|
323
|
+
return { ok: false, message: "LLM_ROUTER_BASE_URL is not set" };
|
|
324
|
+
}
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
const timeout = setTimeout(() => controller.abort(), 2_000);
|
|
327
|
+
try {
|
|
328
|
+
const response = await fetch(`${baseUrl}/health`, {
|
|
329
|
+
method: "GET",
|
|
330
|
+
signal: controller.signal,
|
|
331
|
+
});
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
message: `Router health check failed with HTTP ${response.status}`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return { ok: true, message: "Router reachable" };
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
message: `Router unreachable at ${baseUrl}`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
clearTimeout(timeout);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
export class Agent {
|
|
351
|
+
history = [];
|
|
352
|
+
lastUserInput = null;
|
|
353
|
+
verboseTools = false;
|
|
354
|
+
auditLogger;
|
|
355
|
+
baseSystemPrompt;
|
|
356
|
+
memory;
|
|
357
|
+
contextWindowTokens;
|
|
358
|
+
constructor(options = {}) {
|
|
359
|
+
configureToolRuntime({
|
|
360
|
+
workspaceRoot: options.workspaceRoot,
|
|
361
|
+
permissionMode: options.permissionMode,
|
|
362
|
+
maxReadBytes: options.maxReadBytes,
|
|
363
|
+
maxToolResultChars: options.maxToolResultChars,
|
|
364
|
+
maxCommandOutputChars: options.maxCommandOutputChars,
|
|
365
|
+
shellTimeoutMs: options.shellTimeoutMs,
|
|
366
|
+
});
|
|
367
|
+
const runtime = getToolRuntime();
|
|
368
|
+
this.baseSystemPrompt = buildSystemPrompt(runtime.workspaceRoot, runtime, options.systemContext);
|
|
369
|
+
this.memory = options.memory ?? "";
|
|
370
|
+
this.auditLogger = options.auditLogger;
|
|
371
|
+
this.contextWindowTokens = options.contextWindowTokens ?? 200_000;
|
|
372
|
+
this.history.push({
|
|
373
|
+
role: "system",
|
|
374
|
+
content: buildFullSystemPrompt(this.baseSystemPrompt, this.memory),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Hot-update the persistent memory injected into the system prompt.
|
|
379
|
+
* Safe to call mid-session — patches history[0] in place so the model sees
|
|
380
|
+
* the new memory on the very next turn.
|
|
381
|
+
*/
|
|
382
|
+
updateMemory(memory) {
|
|
383
|
+
this.memory = memory;
|
|
384
|
+
if (this.history[0]) {
|
|
385
|
+
this.history[0].content = buildFullSystemPrompt(this.baseSystemPrompt, this.memory);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
getLastUserInput() {
|
|
389
|
+
return this.lastUserInput;
|
|
390
|
+
}
|
|
391
|
+
getPermissionMode() {
|
|
392
|
+
return getToolRuntime().permissionMode;
|
|
393
|
+
}
|
|
394
|
+
getWorkspaceRoot() {
|
|
395
|
+
return getToolRuntime().workspaceRoot;
|
|
396
|
+
}
|
|
397
|
+
isVerboseTools() {
|
|
398
|
+
return this.verboseTools;
|
|
399
|
+
}
|
|
400
|
+
setVerboseTools(enabled) {
|
|
401
|
+
this.verboseTools = enabled;
|
|
402
|
+
}
|
|
403
|
+
reset() {
|
|
404
|
+
this.history = [
|
|
405
|
+
{
|
|
406
|
+
role: "system",
|
|
407
|
+
content: buildFullSystemPrompt(this.baseSystemPrompt, this.memory),
|
|
408
|
+
},
|
|
409
|
+
];
|
|
410
|
+
this.lastUserInput = null;
|
|
411
|
+
}
|
|
412
|
+
undoLastTurn() {
|
|
413
|
+
for (let i = this.history.length - 1; i >= 1; i -= 1) {
|
|
414
|
+
if (this.history[i]?.role === "user") {
|
|
415
|
+
const removedUser = this.history[i]?.content ?? null;
|
|
416
|
+
this.history = this.history.slice(0, i);
|
|
417
|
+
this.lastUserInput = removedUser;
|
|
418
|
+
return removedUser;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
exportState(contextState) {
|
|
424
|
+
return {
|
|
425
|
+
history: this.history.map((msg) => ({
|
|
426
|
+
role: msg.role,
|
|
427
|
+
content: normalizeMessageContent(msg.content),
|
|
428
|
+
tool_calls: msg.tool_calls,
|
|
429
|
+
tool_call_id: msg.tool_call_id,
|
|
430
|
+
name: msg.name,
|
|
431
|
+
})),
|
|
432
|
+
lastUserInput: this.lastUserInput,
|
|
433
|
+
verboseTools: this.verboseTools,
|
|
434
|
+
cwd: process.cwd(),
|
|
435
|
+
contextState,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
importState(state) {
|
|
439
|
+
if (!state.history || state.history.length === 0) {
|
|
440
|
+
this.reset();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
this.history = state.history.map((msg) => ({
|
|
444
|
+
role: msg.role,
|
|
445
|
+
content: normalizeMessageContent(msg.content),
|
|
446
|
+
tool_calls: msg.tool_calls,
|
|
447
|
+
tool_call_id: msg.tool_call_id,
|
|
448
|
+
name: msg.name,
|
|
449
|
+
}));
|
|
450
|
+
this.lastUserInput = state.lastUserInput;
|
|
451
|
+
this.verboseTools = state.verboseTools;
|
|
452
|
+
}
|
|
453
|
+
async run(userInput, options = {}) {
|
|
454
|
+
if (options.signal?.aborted) {
|
|
455
|
+
throw new TurnCancelledError();
|
|
456
|
+
}
|
|
457
|
+
this.lastUserInput = userInput;
|
|
458
|
+
const historyStart = this.history.length;
|
|
459
|
+
this.history.push({ role: "user", content: userInput });
|
|
460
|
+
// ── Pre-flight context estimation ─────────────────────────────────────────
|
|
461
|
+
// Estimate token usage before sending so the user sees a warning (or an
|
|
462
|
+
// auto-compact) instead of an opaque 400 error from the router.
|
|
463
|
+
const estimatedTokens = estimateTokens(this.history);
|
|
464
|
+
const compactThreshold = Math.floor(this.contextWindowTokens * 0.9);
|
|
465
|
+
const warnThreshold = Math.floor(this.contextWindowTokens * 0.7);
|
|
466
|
+
if (estimatedTokens >= compactThreshold) {
|
|
467
|
+
if (options.callbacks?.onToken) {
|
|
468
|
+
await options.callbacks.onToken(`\n[Context ~${estimatedTokens.toLocaleString()} tokens ` +
|
|
469
|
+
`(${Math.round((estimatedTokens / this.contextWindowTokens) * 100)}% of window). ` +
|
|
470
|
+
`Auto-compacting before sending...]\n`);
|
|
471
|
+
}
|
|
472
|
+
await this.compactHistory(options.signal);
|
|
473
|
+
}
|
|
474
|
+
else if (estimatedTokens >= warnThreshold) {
|
|
475
|
+
if (options.callbacks?.onToken) {
|
|
476
|
+
await options.callbacks.onToken(`\n[Context ~${estimatedTokens.toLocaleString()} tokens ` +
|
|
477
|
+
`(${Math.round((estimatedTokens / this.contextWindowTokens) * 100)}% of window). ` +
|
|
478
|
+
`Consider /compact to free space.]\n`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
let finalResponse = "";
|
|
482
|
+
let finalUsage;
|
|
483
|
+
let finalModel = getRouterConfig().model;
|
|
484
|
+
let finishReason = "stop";
|
|
485
|
+
let toolCallCount = 0;
|
|
486
|
+
try {
|
|
487
|
+
let iterations = 0;
|
|
488
|
+
const maxIterations = 20;
|
|
489
|
+
while (iterations < maxIterations) {
|
|
490
|
+
iterations += 1;
|
|
491
|
+
const streamed = await callRouterWithRetry(this.history, options.signal, {
|
|
492
|
+
onToken: options.callbacks?.onToken,
|
|
493
|
+
onRetry: options.callbacks?.onRetry,
|
|
494
|
+
});
|
|
495
|
+
const assistantMessage = streamed.message;
|
|
496
|
+
finalModel = streamed.model ?? finalModel;
|
|
497
|
+
finishReason = streamed.finishReason;
|
|
498
|
+
this.history.push({
|
|
499
|
+
role: "assistant",
|
|
500
|
+
content: assistantMessage.content ?? null,
|
|
501
|
+
tool_calls: assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0
|
|
502
|
+
? assistantMessage.tool_calls
|
|
503
|
+
: undefined,
|
|
504
|
+
});
|
|
505
|
+
if (streamed.finishReason === "tool_calls" &&
|
|
506
|
+
assistantMessage.tool_calls?.length) {
|
|
507
|
+
const total = assistantMessage.tool_calls.length;
|
|
508
|
+
for (let index = 0; index < total; index += 1) {
|
|
509
|
+
if (options.signal?.aborted) {
|
|
510
|
+
throw new TurnCancelledError();
|
|
511
|
+
}
|
|
512
|
+
const toolCall = assistantMessage.tool_calls[index];
|
|
513
|
+
toolCallCount += 1;
|
|
514
|
+
const toolName = toolCall.function.name;
|
|
515
|
+
const toolFn = TOOL_MAP[toolName];
|
|
516
|
+
const argsPreview = previewArgs(toolCall.function.arguments);
|
|
517
|
+
if (options.callbacks?.onToolStart) {
|
|
518
|
+
await options.callbacks.onToolStart({
|
|
519
|
+
toolName,
|
|
520
|
+
argsPreview,
|
|
521
|
+
index: index + 1,
|
|
522
|
+
total,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
let toolResult = "";
|
|
526
|
+
let parsedArgs = {};
|
|
527
|
+
if (!toolFn) {
|
|
528
|
+
toolResult = `Error: unknown tool \"${toolName}\"`;
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
try {
|
|
532
|
+
parsedArgs = JSON.parse(toolCall.function.arguments);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
parsedArgs = {};
|
|
536
|
+
toolResult =
|
|
537
|
+
"Error: could not parse tool arguments as JSON; received raw argument string.";
|
|
538
|
+
}
|
|
539
|
+
if (!toolResult) {
|
|
540
|
+
const toolContext = {
|
|
541
|
+
abortSignal: options.signal,
|
|
542
|
+
};
|
|
543
|
+
toolResult = await toolFn(parsedArgs, toolContext);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const truncated = truncateToolResult(toolResult);
|
|
547
|
+
if (this.auditLogger) {
|
|
548
|
+
await this.auditLogger.logTool(toolName, parsedArgs, truncated.output, isApprovedFromToolResult(toolName, toolResult));
|
|
549
|
+
}
|
|
550
|
+
if (options.callbacks?.onToolEnd) {
|
|
551
|
+
const preview = this.verboseTools
|
|
552
|
+
? truncated.output
|
|
553
|
+
: truncated.output.slice(0, 300);
|
|
554
|
+
await options.callbacks.onToolEnd({
|
|
555
|
+
toolName,
|
|
556
|
+
index: index + 1,
|
|
557
|
+
total,
|
|
558
|
+
truncated: truncated.truncated,
|
|
559
|
+
resultPreview: preview,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
this.history.push({
|
|
563
|
+
role: "tool",
|
|
564
|
+
tool_call_id: toolCall.id,
|
|
565
|
+
name: toolName,
|
|
566
|
+
content: truncated.output,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
finalResponse = assistantMessage.content ?? "";
|
|
572
|
+
finalUsage = streamed.usage;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
if (!finalResponse && finishReason !== "tool_calls") {
|
|
576
|
+
finalResponse = "[Agent produced an empty response]";
|
|
577
|
+
}
|
|
578
|
+
if (!finalResponse && finishReason === "tool_calls") {
|
|
579
|
+
finalResponse =
|
|
580
|
+
"[Agent stopped: reached maximum iteration limit of 20 iterations]";
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
response: finalResponse,
|
|
584
|
+
usage: finalUsage,
|
|
585
|
+
model: finalModel,
|
|
586
|
+
finishReason,
|
|
587
|
+
toolCalls: toolCallCount,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
this.history = this.history.slice(0, historyStart);
|
|
592
|
+
throw err;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async compactHistory(signal) {
|
|
596
|
+
if (this.history.length <= 2) {
|
|
597
|
+
return "Conversation is already compact.";
|
|
598
|
+
}
|
|
599
|
+
// ── Tiered compaction ────────────────────────────────────────────────────
|
|
600
|
+
// Recent exchanges are preserved verbatim so the model never loses sight
|
|
601
|
+
// of what was just asked / just returned. Everything older is compressed
|
|
602
|
+
// into a structured summary block.
|
|
603
|
+
//
|
|
604
|
+
// Structure after compaction:
|
|
605
|
+
// [system, assistant(summary), ...last-N-turns-verbatim]
|
|
606
|
+
//
|
|
607
|
+
const KEEP_TURNS = 3; // number of complete user→assistant exchanges to keep
|
|
608
|
+
const keepFromIndex = this._findKeepBoundary(KEEP_TURNS);
|
|
609
|
+
// Nothing old enough to summarise — all turns fall within the keep window.
|
|
610
|
+
if (keepFromIndex <= 1) {
|
|
611
|
+
return "Recent context fits within the keep window — nothing to compact.";
|
|
612
|
+
}
|
|
613
|
+
const toSummarize = this.history.slice(1, keepFromIndex);
|
|
614
|
+
const toKeep = this.history.slice(keepFromIndex);
|
|
615
|
+
// ── Build transcript of the portion being compressed ────────────────────
|
|
616
|
+
const rawTranscript = toSummarize
|
|
617
|
+
.map((msg, i) => {
|
|
618
|
+
let header = `${i + 1}. [${msg.role}]`;
|
|
619
|
+
if (msg.role === "assistant" && msg.tool_calls?.length) {
|
|
620
|
+
const calls = msg.tool_calls.map((tc) => tc.function.name).join(", ");
|
|
621
|
+
header += ` → calls: ${calls}`;
|
|
622
|
+
}
|
|
623
|
+
if (msg.role === "tool" && msg.name) {
|
|
624
|
+
header += ` result for: ${msg.name}`;
|
|
625
|
+
}
|
|
626
|
+
// 5 000 chars per message — enough signal without overwhelming the
|
|
627
|
+
// summariser for large file reads.
|
|
628
|
+
const body = (msg.content ?? "").slice(0, 5_000);
|
|
629
|
+
return `${header}\n${body}`;
|
|
630
|
+
})
|
|
631
|
+
.join("\n\n");
|
|
632
|
+
// Hard cap: 120 k chars ≈ 30 k tokens keeps the summarisation call safely
|
|
633
|
+
// inside even a 32 k-token context window.
|
|
634
|
+
const MAX_TRANSCRIPT = 120_000;
|
|
635
|
+
const transcript = rawTranscript.length > MAX_TRANSCRIPT
|
|
636
|
+
? `[... ${(rawTranscript.length - MAX_TRANSCRIPT).toLocaleString()} earlier chars omitted ...]\n\n` +
|
|
637
|
+
rawTranscript.slice(-MAX_TRANSCRIPT)
|
|
638
|
+
: rawTranscript;
|
|
639
|
+
// ── Structured summarisation prompt ──────────────────────────────────────
|
|
640
|
+
// Sections match what the agent needs to resume work cleanly. Be explicit
|
|
641
|
+
// about requiring file paths, line numbers, and exact values so the model
|
|
642
|
+
// doesn't produce vague prose.
|
|
643
|
+
const prompt = `Compress the following conversation transcript into a structured summary.
|
|
644
|
+
Use these exact section headers. Be specific: include file paths, line numbers, function names, and exact values where they matter. Omit sections that have no content.
|
|
645
|
+
|
|
646
|
+
## Objective
|
|
647
|
+
What the user is trying to achieve overall.
|
|
648
|
+
|
|
649
|
+
## Work Completed
|
|
650
|
+
Bullet list of finished tasks. Include file paths and what changed.
|
|
651
|
+
|
|
652
|
+
## Files Touched
|
|
653
|
+
For each file: path, what was read/modified, key contents if critical to remember.
|
|
654
|
+
|
|
655
|
+
## Commands Run
|
|
656
|
+
Shell commands executed and their notable output or outcome.
|
|
657
|
+
|
|
658
|
+
## Decisions Made
|
|
659
|
+
Architecture, design, or implementation decisions with rationale.
|
|
660
|
+
|
|
661
|
+
## In Progress
|
|
662
|
+
What was actively being worked on at the point of compaction.
|
|
663
|
+
|
|
664
|
+
## Remaining Work
|
|
665
|
+
Tasks not yet started, bugs not yet fixed, open questions.
|
|
666
|
+
|
|
667
|
+
## Key Context
|
|
668
|
+
Constants, API shapes, naming conventions, configuration values, or constraints the agent must remember.
|
|
669
|
+
|
|
670
|
+
---
|
|
671
|
+
TRANSCRIPT TO COMPRESS:
|
|
672
|
+
|
|
673
|
+
${transcript}`;
|
|
674
|
+
const summaryResult = await callRouterOnce([
|
|
675
|
+
this.history[0],
|
|
676
|
+
{ role: "user", content: prompt },
|
|
677
|
+
], { signal, toolsEnabled: false, stream: false });
|
|
678
|
+
const summary = summaryResult.message.content?.trim() ?? "Conversation summary unavailable.";
|
|
679
|
+
// ── Rebuild history ───────────────────────────────────────────────────────
|
|
680
|
+
// [system, assistant(structured summary), ...last-N-turns-verbatim]
|
|
681
|
+
//
|
|
682
|
+
// Storing the summary as "assistant" keeps the sequence valid for all
|
|
683
|
+
// providers (no double system messages). The verbatim recent turns follow
|
|
684
|
+
// immediately so the model has full fidelity on the current task.
|
|
685
|
+
this.history = [
|
|
686
|
+
this.history[0],
|
|
687
|
+
{
|
|
688
|
+
role: "assistant",
|
|
689
|
+
content: `[Prior context — compacted]\n\n${summary}`,
|
|
690
|
+
},
|
|
691
|
+
...toKeep,
|
|
692
|
+
];
|
|
693
|
+
return summary;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Find the history index of the start of the Nth-from-last complete exchange
|
|
697
|
+
* (where an exchange begins with a user message). Returns 1 if there are
|
|
698
|
+
* fewer than keepTurns user messages — meaning nothing should be compacted.
|
|
699
|
+
*/
|
|
700
|
+
_findKeepBoundary(keepTurns) {
|
|
701
|
+
let userCount = 0;
|
|
702
|
+
for (let i = this.history.length - 1; i >= 1; i -= 1) {
|
|
703
|
+
if (this.history[i]?.role === "user") {
|
|
704
|
+
userCount += 1;
|
|
705
|
+
if (userCount === keepTurns) {
|
|
706
|
+
return i;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return 1;
|
|
711
|
+
}
|
|
712
|
+
async listModels(signal) {
|
|
713
|
+
const { baseUrl, apiKey } = getRouterConfig();
|
|
714
|
+
const response = await fetch(`${baseUrl}/v1/models`, {
|
|
715
|
+
method: "GET",
|
|
716
|
+
signal,
|
|
717
|
+
headers: {
|
|
718
|
+
Authorization: `Bearer ${apiKey}`,
|
|
719
|
+
"X-Client-Type": "cli-agent",
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
if (!response.ok) {
|
|
723
|
+
const body = await response.text();
|
|
724
|
+
throw new Error(`Failed to fetch models (${response.status}): ${body}`);
|
|
725
|
+
}
|
|
726
|
+
const payload = (await response.json());
|
|
727
|
+
return payload.data ?? [];
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
//# sourceMappingURL=agent.js.map
|