agent-sh 0.7.0 → 0.9.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/README.md +28 -33
- package/dist/agent/agent-loop.d.ts +31 -8
- package/dist/agent/agent-loop.js +277 -66
- package/dist/agent/conversation-state.d.ts +41 -9
- package/dist/agent/conversation-state.js +340 -17
- package/dist/agent/history-file.d.ts +36 -0
- package/dist/agent/history-file.js +167 -0
- package/dist/agent/nuclear-form.d.ts +41 -0
- package/dist/agent/nuclear-form.js +176 -0
- package/dist/agent/system-prompt.d.ts +4 -5
- package/dist/agent/system-prompt.js +16 -11
- package/dist/agent/token-budget.d.ts +13 -0
- package/dist/agent/token-budget.js +50 -0
- package/dist/agent/tool-protocol.d.ts +83 -0
- package/dist/agent/tool-protocol.js +386 -0
- package/dist/agent/tools/user-shell.js +4 -1
- package/dist/agent/types.d.ts +21 -1
- package/dist/context-manager.d.ts +0 -1
- package/dist/context-manager.js +5 -110
- package/dist/core.d.ts +7 -7
- package/dist/core.js +76 -180
- package/dist/event-bus.d.ts +40 -0
- package/dist/event-bus.js +20 -1
- package/dist/extension-loader.d.ts +5 -0
- package/dist/extension-loader.js +104 -17
- package/dist/extensions/agent-backend.d.ts +13 -0
- package/dist/extensions/agent-backend.js +167 -0
- package/dist/extensions/command-suggest.d.ts +3 -3
- package/dist/extensions/command-suggest.js +4 -3
- package/dist/extensions/index.d.ts +19 -0
- package/dist/extensions/index.js +25 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +44 -1
- package/dist/extensions/terminal-buffer.d.ts +1 -1
- package/dist/extensions/terminal-buffer.js +22 -8
- package/dist/extensions/tui-renderer.js +177 -122
- package/dist/index.js +14 -20
- package/dist/settings.d.ts +25 -2
- package/dist/settings.js +25 -4
- package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
- package/dist/{input-handler.js → shell/input-handler.js} +60 -43
- package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
- package/dist/{output-parser.js → shell/output-parser.js} +1 -1
- package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
- package/dist/{shell.js → shell/shell.js} +24 -6
- package/dist/types.d.ts +49 -32
- package/dist/utils/ansi.d.ts +10 -0
- package/dist/utils/ansi.js +27 -0
- package/dist/utils/compositor.d.ts +62 -0
- package/dist/utils/compositor.js +88 -0
- package/dist/utils/diff-renderer.js +92 -4
- package/dist/utils/floating-panel.d.ts +34 -3
- package/dist/utils/floating-panel.js +315 -82
- package/dist/utils/handler-registry.d.ts +26 -10
- package/dist/utils/handler-registry.js +52 -16
- package/dist/utils/line-editor.d.ts +32 -3
- package/dist/utils/line-editor.js +218 -36
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +4 -4
- package/dist/utils/message-utils.d.ts +35 -0
- package/dist/utils/message-utils.js +75 -0
- package/dist/utils/terminal-buffer.d.ts +9 -1
- package/dist/utils/terminal-buffer.js +31 -2
- package/dist/utils/tool-display.d.ts +1 -0
- package/dist/utils/tool-display.js +1 -1
- package/dist/utils/tool-interactive.d.ts +12 -0
- package/dist/utils/tool-interactive.js +53 -0
- package/examples/extensions/ash-acp-bridge/README.md +39 -0
- package/examples/extensions/ash-acp-bridge/package.json +23 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
- package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
- package/examples/extensions/ash-mcp-bridge/README.md +72 -0
- package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
- package/examples/extensions/ash-mcp-bridge/package.json +9 -0
- package/examples/extensions/claude-code-bridge/index.ts +77 -1
- package/examples/extensions/interactive-prompts.ts +82 -110
- package/examples/extensions/overlay-agent.ts +84 -38
- package/examples/extensions/peer-mesh.ts +450 -0
- package/examples/extensions/pi-bridge/index.ts +87 -2
- package/examples/extensions/questionnaire.ts +249 -0
- package/examples/extensions/tmux-pane.ts +307 -0
- package/examples/extensions/web-access.ts +327 -0
- package/package.json +9 -1
- package/dist/extensions/overlay-agent.d.ts +0 -11
- package/dist/extensions/overlay-agent.js +0 -43
- package/examples/extensions/terminal-buffer.ts +0 -184
package/dist/agent/agent-loop.js
CHANGED
|
@@ -4,7 +4,12 @@ import * as path from "node:path";
|
|
|
4
4
|
import { computeDiff } from "../utils/diff.js";
|
|
5
5
|
import { ToolRegistry } from "./tool-registry.js";
|
|
6
6
|
import { ConversationState } from "./conversation-state.js";
|
|
7
|
+
import { HistoryFile } from "./history-file.js";
|
|
7
8
|
import { STATIC_SYSTEM_PROMPT, buildDynamicContext } from "./system-prompt.js";
|
|
9
|
+
import { createToolUI } from "../utils/tool-interactive.js";
|
|
10
|
+
import { TokenBudget } from "./token-budget.js";
|
|
11
|
+
import { getSettings } from "../settings.js";
|
|
12
|
+
import { createToolProtocol } from "./tool-protocol.js";
|
|
8
13
|
// Core tool factories
|
|
9
14
|
import { createBashTool } from "./tools/bash.js";
|
|
10
15
|
import { createReadFileTool } from "./tools/read-file.js";
|
|
@@ -18,34 +23,61 @@ import { createDisplayTool } from "./tools/display.js";
|
|
|
18
23
|
import { createListSkillsTool } from "./tools/list-skills.js";
|
|
19
24
|
import { discoverProjectSkills } from "./skills.js";
|
|
20
25
|
export class AgentLoop {
|
|
21
|
-
bus;
|
|
22
|
-
contextManager;
|
|
23
|
-
llmClient;
|
|
24
|
-
handlers;
|
|
25
26
|
abortController = null;
|
|
26
27
|
toolRegistry = new ToolRegistry();
|
|
27
|
-
|
|
28
|
+
historyFile = new HistoryFile();
|
|
29
|
+
conversation = new ConversationState(this.historyFile);
|
|
28
30
|
fileReadCache = new Map();
|
|
31
|
+
tokenBudget;
|
|
29
32
|
modes;
|
|
30
33
|
currentModeIndex = 0;
|
|
31
34
|
boundListeners = [];
|
|
35
|
+
ctorListeners = [];
|
|
36
|
+
ctorPipeListeners = [];
|
|
32
37
|
lastProjectSkillNames = new Set();
|
|
33
38
|
static THINKING_LEVELS = ["off", "low", "medium", "high"];
|
|
39
|
+
bus;
|
|
40
|
+
contextManager;
|
|
41
|
+
llmClient;
|
|
42
|
+
handlers;
|
|
34
43
|
thinkingLevel = "off";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
this.
|
|
39
|
-
this.
|
|
44
|
+
compositor = null;
|
|
45
|
+
toolProtocol;
|
|
46
|
+
constructor(config) {
|
|
47
|
+
this.bus = config.bus;
|
|
48
|
+
this.contextManager = config.contextManager;
|
|
49
|
+
this.llmClient = config.llmClient;
|
|
50
|
+
this.handlers = config.handlers;
|
|
51
|
+
this.compositor = config.compositor ?? null;
|
|
40
52
|
// Default modes: just the configured model
|
|
41
|
-
this.modes =
|
|
42
|
-
{ model: llmClient.model },
|
|
53
|
+
this.modes = config.modes ?? [
|
|
54
|
+
{ model: config.llmClient.model },
|
|
43
55
|
];
|
|
44
|
-
this.currentModeIndex = initialModeIndex ?? 0;
|
|
56
|
+
this.currentModeIndex = config.initialModeIndex ?? 0;
|
|
57
|
+
// Unified token budget — adapts to current model's context window
|
|
58
|
+
this.tokenBudget = new TokenBudget(this.currentMode.contextWindow);
|
|
59
|
+
// Tool protocol — controls how tools are presented to the LLM
|
|
60
|
+
this.toolProtocol = createToolProtocol(getSettings().toolMode ?? "api");
|
|
45
61
|
// Register core tools
|
|
46
62
|
this.registerCoreTools();
|
|
63
|
+
// Update token budget with tool count
|
|
64
|
+
this.tokenBudget.update(undefined, this.toolRegistry.all().length);
|
|
47
65
|
// Register handlers — extensions can advise these
|
|
48
66
|
this.registerHandlers();
|
|
67
|
+
// Subscribe to bus-based tool/instruction registration from extensions.
|
|
68
|
+
// These must be in the constructor (not wire()) because extensions call
|
|
69
|
+
// registerTool() during activate(), before activateBackend() calls wire().
|
|
70
|
+
const onCtor = (event, fn) => {
|
|
71
|
+
this.bus.on(event, fn);
|
|
72
|
+
this.ctorListeners.push({ event, fn });
|
|
73
|
+
};
|
|
74
|
+
onCtor("agent:register-tool", ({ tool }) => this.registerTool(tool));
|
|
75
|
+
onCtor("agent:unregister-tool", ({ name }) => this.unregisterTool(name));
|
|
76
|
+
onCtor("agent:register-instruction", ({ name, text }) => this.registerInstruction(name, text));
|
|
77
|
+
onCtor("agent:remove-instruction", ({ name }) => this.removeInstruction(name));
|
|
78
|
+
const getToolsPipe = () => ({ tools: this.getTools() });
|
|
79
|
+
this.bus.onPipe("agent:get-tools", getToolsPipe);
|
|
80
|
+
this.ctorPipeListeners.push({ event: "agent:get-tools", fn: getToolsPipe });
|
|
49
81
|
}
|
|
50
82
|
/** Subscribe to bus events — activates this backend. */
|
|
51
83
|
wire() {
|
|
@@ -74,8 +106,9 @@ export class AgentLoop {
|
|
|
74
106
|
else {
|
|
75
107
|
this.llmClient.model = m.model;
|
|
76
108
|
}
|
|
109
|
+
this.tokenBudget.update(m.contextWindow, this.toolRegistry.all().length);
|
|
77
110
|
const label = m.provider ? `${m.provider}: ${m.model}` : m.model;
|
|
78
|
-
this.bus.emit("agent:info", { name: "
|
|
111
|
+
this.bus.emit("agent:info", { name: "ash", version: "0.4", model: m.model, provider: m.provider, contextWindow: m.contextWindow });
|
|
79
112
|
this.bus.emit("ui:info", { message: `Model: ${label}` });
|
|
80
113
|
this.bus.emit("config:changed", {});
|
|
81
114
|
});
|
|
@@ -117,13 +150,50 @@ export class AgentLoop {
|
|
|
117
150
|
else {
|
|
118
151
|
this.llmClient.model = m.model;
|
|
119
152
|
}
|
|
153
|
+
this.tokenBudget.update(m.contextWindow, this.toolRegistry.all().length);
|
|
154
|
+
this.bus.emit("config:changed", {});
|
|
155
|
+
});
|
|
156
|
+
on("config:add-modes", ({ modes: extra }) => {
|
|
157
|
+
// Remove any existing modes for the same provider, then append
|
|
158
|
+
const providers = new Set(extra.map((m) => m.provider).filter(Boolean));
|
|
159
|
+
this.modes = [
|
|
160
|
+
...this.modes.filter((m) => !m.provider || !providers.has(m.provider)),
|
|
161
|
+
...extra,
|
|
162
|
+
];
|
|
120
163
|
this.bus.emit("config:changed", {});
|
|
121
164
|
});
|
|
122
165
|
on("agent:reset-session", () => {
|
|
123
166
|
this.cancel();
|
|
124
|
-
this.conversation = new ConversationState();
|
|
167
|
+
this.conversation = new ConversationState(this.historyFile);
|
|
125
168
|
this.lastProjectSkillNames.clear();
|
|
126
169
|
});
|
|
170
|
+
on("agent:compact-request", () => {
|
|
171
|
+
// Force compaction: use target of 0 so every non-pinned turn is evicted
|
|
172
|
+
const stats = this.conversation.compact(0, 10, true);
|
|
173
|
+
this.conversation.flush().catch(() => { });
|
|
174
|
+
if (stats) {
|
|
175
|
+
this.bus.emit("ui:info", {
|
|
176
|
+
message: `(compacted: ~${stats.before.toLocaleString()} → ~${stats.after.toLocaleString()} tokens)`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.bus.emit("ui:info", { message: "(nothing to compact)" });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
this.bus.onPipe("context:get-stats", () => {
|
|
184
|
+
return {
|
|
185
|
+
activeTokens: this.conversation.estimateTokens(),
|
|
186
|
+
nuclearEntries: this.conversation.getNuclearEntryCount(),
|
|
187
|
+
recallArchiveSize: this.conversation.getRecallArchiveSize(),
|
|
188
|
+
budgetTokens: this.tokenBudget.conversationBudgetTokens,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
// Load prior history from disk (non-blocking)
|
|
192
|
+
this.historyFile.readRecent().then((entries) => {
|
|
193
|
+
if (entries.length > 0) {
|
|
194
|
+
this.conversation.loadPriorHistory(entries);
|
|
195
|
+
}
|
|
196
|
+
}).catch(() => { });
|
|
127
197
|
on("shell:cwd-change", ({ cwd }) => {
|
|
128
198
|
const projectSkills = discoverProjectSkills(cwd);
|
|
129
199
|
const newNames = new Set(projectSkills.map(s => s.name));
|
|
@@ -150,12 +220,44 @@ export class AgentLoop {
|
|
|
150
220
|
registerTool(tool) {
|
|
151
221
|
this.toolRegistry.register(tool);
|
|
152
222
|
}
|
|
223
|
+
/** Unregister a tool by name. */
|
|
224
|
+
unregisterTool(name) {
|
|
225
|
+
this.toolRegistry.unregister(name);
|
|
226
|
+
}
|
|
153
227
|
/** Get all registered tools. */
|
|
154
228
|
getTools() {
|
|
155
229
|
return this.toolRegistry.all();
|
|
156
230
|
}
|
|
231
|
+
// ── Extension instructions & tool tracking ──────────────────────
|
|
232
|
+
instructions = new Map();
|
|
233
|
+
/** Register a named instruction block for the system prompt. */
|
|
234
|
+
registerInstruction(name, text) {
|
|
235
|
+
this.instructions.set(name, text);
|
|
236
|
+
}
|
|
237
|
+
/** Remove a named instruction block. */
|
|
238
|
+
removeInstruction(name) {
|
|
239
|
+
this.instructions.delete(name);
|
|
240
|
+
}
|
|
241
|
+
/** Get instruction blocks registered by extensions. */
|
|
242
|
+
getInstructionSections() {
|
|
243
|
+
const sections = [];
|
|
244
|
+
for (const [name, text] of this.instructions) {
|
|
245
|
+
sections.push(`## ${name}\n${text}`);
|
|
246
|
+
}
|
|
247
|
+
return sections;
|
|
248
|
+
}
|
|
157
249
|
kill() {
|
|
158
250
|
this.cancel();
|
|
251
|
+
this.unwire();
|
|
252
|
+
// Clean up constructor-level bus subscriptions
|
|
253
|
+
for (const { event, fn } of this.ctorListeners) {
|
|
254
|
+
this.bus.off(event, fn);
|
|
255
|
+
}
|
|
256
|
+
this.ctorListeners = [];
|
|
257
|
+
for (const { event, fn } of this.ctorPipeListeners) {
|
|
258
|
+
this.bus.offPipe(event, fn);
|
|
259
|
+
}
|
|
260
|
+
this.ctorPipeListeners = [];
|
|
159
261
|
}
|
|
160
262
|
cancel() {
|
|
161
263
|
this.abortController?.abort();
|
|
@@ -187,10 +289,11 @@ export class AgentLoop {
|
|
|
187
289
|
else {
|
|
188
290
|
this.llmClient.model = newMode.model;
|
|
189
291
|
}
|
|
292
|
+
this.tokenBudget.update(newMode.contextWindow, this.toolRegistry.all().length);
|
|
190
293
|
const label = newMode.provider
|
|
191
294
|
? `${newMode.provider}: ${newMode.model}`
|
|
192
295
|
: newMode.model;
|
|
193
|
-
this.bus.emit("agent:info", { name: "
|
|
296
|
+
this.bus.emit("agent:info", { name: "ash", version: "0.4", model: newMode.model, provider: newMode.provider, contextWindow: newMode.contextWindow });
|
|
194
297
|
this.bus.emit("ui:info", { message: `Model: ${label}` });
|
|
195
298
|
this.bus.emit("config:changed", {});
|
|
196
299
|
}
|
|
@@ -289,6 +392,46 @@ export class AgentLoop {
|
|
|
289
392
|
this.toolRegistry.register(createUserShellTool({ getCwd, bus: this.bus }));
|
|
290
393
|
this.toolRegistry.register(createDisplayTool({ getCwd, bus: this.bus }));
|
|
291
394
|
this.toolRegistry.register(createListSkillsTool(getCwd));
|
|
395
|
+
// conversation_recall — search/expand evicted conversation turns
|
|
396
|
+
this.toolRegistry.register({
|
|
397
|
+
name: "conversation_recall",
|
|
398
|
+
displayName: "recall",
|
|
399
|
+
description: "Browse, search, or expand evicted conversation turns. " +
|
|
400
|
+
"Use when you need context from earlier in the conversation that was compacted away.",
|
|
401
|
+
input_schema: {
|
|
402
|
+
type: "object",
|
|
403
|
+
properties: {
|
|
404
|
+
action: {
|
|
405
|
+
type: "string",
|
|
406
|
+
enum: ["browse", "search", "expand"],
|
|
407
|
+
description: "browse: list evicted turns, search: regex search, expand: show full turn",
|
|
408
|
+
},
|
|
409
|
+
query: {
|
|
410
|
+
type: "string",
|
|
411
|
+
description: "Search query (for action=search)",
|
|
412
|
+
},
|
|
413
|
+
turn_id: {
|
|
414
|
+
type: "number",
|
|
415
|
+
description: "Turn ID to expand (for action=expand)",
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
required: ["action"],
|
|
419
|
+
},
|
|
420
|
+
execute: async (args) => {
|
|
421
|
+
const action = args.action;
|
|
422
|
+
let content;
|
|
423
|
+
if (action === "search") {
|
|
424
|
+
content = await this.conversation.search(args.query ?? "");
|
|
425
|
+
}
|
|
426
|
+
else if (action === "expand") {
|
|
427
|
+
content = await this.conversation.expand(args.turn_id);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
content = await this.conversation.browse();
|
|
431
|
+
}
|
|
432
|
+
return { content, exitCode: 0, isError: false };
|
|
433
|
+
},
|
|
434
|
+
});
|
|
292
435
|
}
|
|
293
436
|
/**
|
|
294
437
|
* Register named handlers that extensions can advise.
|
|
@@ -296,8 +439,17 @@ export class AgentLoop {
|
|
|
296
439
|
*/
|
|
297
440
|
registerHandlers() {
|
|
298
441
|
const h = this.handlers;
|
|
442
|
+
// System prompt: static identity + behavioral instructions.
|
|
443
|
+
// Extensions can use registerInstruction() for a managed section,
|
|
444
|
+
// or advise this handler directly for full control.
|
|
445
|
+
h.define("system-prompt:build", () => {
|
|
446
|
+
const instructions = this.getInstructionSections();
|
|
447
|
+
if (instructions.length === 0)
|
|
448
|
+
return STATIC_SYSTEM_PROMPT;
|
|
449
|
+
return STATIC_SYSTEM_PROMPT + "\n\n# Extension Instructions\n\n" + instructions.join("\n\n");
|
|
450
|
+
});
|
|
299
451
|
// Extensions compose additional context (git info, project rules, etc.)
|
|
300
|
-
h.define("dynamic-context:build", () => buildDynamicContext(this.
|
|
452
|
+
h.define("dynamic-context:build", () => buildDynamicContext(this.contextManager, this.tokenBudget.shellBudgetTokens));
|
|
301
453
|
// Full control over what the LLM sees: takes messages[], returns messages[].
|
|
302
454
|
// Default: pass through. Extensions can advise to compact, summarize,
|
|
303
455
|
// filter, reorder, inject — whatever strategy fits.
|
|
@@ -331,7 +483,7 @@ export class AgentLoop {
|
|
|
331
483
|
// write_file
|
|
332
484
|
newContent = args.content;
|
|
333
485
|
}
|
|
334
|
-
else if (typeof args.old_text === "string" && typeof args.new_text === "string" && oldContent) {
|
|
486
|
+
else if (typeof args.old_text === "string" && typeof args.new_text === "string" && oldContent !== null) {
|
|
335
487
|
// edit_file
|
|
336
488
|
newContent = oldContent.replace(args.old_text.replace(/\r\n/g, "\n"), args.new_text.replace(/\r\n/g, "\n"));
|
|
337
489
|
}
|
|
@@ -355,10 +507,14 @@ export class AgentLoop {
|
|
|
355
507
|
}
|
|
356
508
|
catch { /* fall back to generic permission */ }
|
|
357
509
|
}
|
|
510
|
+
const ui = this.compositor
|
|
511
|
+
? createToolUI(this.bus, this.compositor.surface("agent"))
|
|
512
|
+
: undefined;
|
|
358
513
|
const perm = await this.bus.emitPipeAsync("permission:request", {
|
|
359
514
|
kind: permKind,
|
|
360
515
|
title: permTitle,
|
|
361
516
|
metadata,
|
|
517
|
+
ui,
|
|
362
518
|
decision: { outcome: "approved" },
|
|
363
519
|
});
|
|
364
520
|
if (perm.decision.outcome !== "approved") {
|
|
@@ -380,7 +536,10 @@ export class AgentLoop {
|
|
|
380
536
|
const onChunk = (tool.showOutput !== false && !diffShown)
|
|
381
537
|
? ctx.onChunk
|
|
382
538
|
: undefined;
|
|
383
|
-
const
|
|
539
|
+
const toolCtx = this.compositor
|
|
540
|
+
? { ui: createToolUI(this.bus, this.compositor.surface("agent")) }
|
|
541
|
+
: undefined;
|
|
542
|
+
const result = await tool.execute(args, onChunk, toolCtx);
|
|
384
543
|
// Invalidate read cache when a file is modified
|
|
385
544
|
if (tool.modifiesFiles && typeof args.path === "string" && !result.isError) {
|
|
386
545
|
const absPath = path.resolve(process.cwd(), args.path);
|
|
@@ -408,8 +567,8 @@ export class AgentLoop {
|
|
|
408
567
|
this.abortController = new AbortController();
|
|
409
568
|
const signal = this.abortController.signal;
|
|
410
569
|
// Each loop iteration adds an abort listener (via OpenAI SDK stream);
|
|
411
|
-
//
|
|
412
|
-
setMaxListeners(
|
|
570
|
+
// disable the limit — long-running tool loops can easily exceed any cap.
|
|
571
|
+
setMaxListeners(0, signal);
|
|
413
572
|
this.bus.emit("agent:query", { query });
|
|
414
573
|
this.bus.emit("agent:processing-start", {});
|
|
415
574
|
let responseText = "";
|
|
@@ -441,8 +600,6 @@ export class AgentLoop {
|
|
|
441
600
|
this.abortController = null;
|
|
442
601
|
}
|
|
443
602
|
}
|
|
444
|
-
/** Max tokens before auto-compaction (conservative default). */
|
|
445
|
-
maxContextTokens = 60_000;
|
|
446
603
|
/**
|
|
447
604
|
* Core agent loop: stream LLM response → execute tools → repeat.
|
|
448
605
|
* Returns the final accumulated response text.
|
|
@@ -450,22 +607,31 @@ export class AgentLoop {
|
|
|
450
607
|
async executeLoop(signal) {
|
|
451
608
|
let fullResponseText = "";
|
|
452
609
|
while (!signal.aborted) {
|
|
453
|
-
// Auto-compact
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
this.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
610
|
+
// Auto-compact when conversation exceeds threshold fraction of budget
|
|
611
|
+
const budgetTokens = this.tokenBudget.conversationBudgetTokens;
|
|
612
|
+
const autoCompactThreshold = Math.floor(budgetTokens * getSettings().autoCompactThreshold);
|
|
613
|
+
if (this.conversation.estimateTokens() > autoCompactThreshold) {
|
|
614
|
+
const stats = this.conversation.compact(autoCompactThreshold);
|
|
615
|
+
await this.conversation.flush();
|
|
616
|
+
if (stats) {
|
|
617
|
+
this.bus.emit("ui:info", {
|
|
618
|
+
message: `(compacted: ~${stats.before.toLocaleString()} → ~${stats.after.toLocaleString()} tokens)`,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// System prompt uses handler so extensions can append instructions (cacheable);
|
|
623
|
+
// dynamic context uses handler for per-query state via advise()
|
|
624
|
+
const systemPrompt = this.handlers.call("system-prompt:build");
|
|
462
625
|
const dynamicContext = this.handlers.call("dynamic-context:build");
|
|
463
626
|
// Stream LLM response with retry
|
|
464
627
|
const result = await this.streamWithRetry(systemPrompt, dynamicContext, signal);
|
|
465
|
-
const { text, toolCalls
|
|
628
|
+
const { text, toolCalls: streamedToolCalls } = result;
|
|
629
|
+
// Extract tool calls via protocol (API mode uses streamed calls,
|
|
630
|
+
// inline mode parses XML from text)
|
|
631
|
+
const toolCalls = this.toolProtocol.extractToolCalls(text, streamedToolCalls);
|
|
466
632
|
fullResponseText += text;
|
|
467
|
-
// Record the assistant message
|
|
468
|
-
this.
|
|
633
|
+
// Record the assistant message via protocol
|
|
634
|
+
this.toolProtocol.recordAssistant(this.conversation, text, toolCalls);
|
|
469
635
|
// No tool calls → agent is done
|
|
470
636
|
if (toolCalls.length === 0)
|
|
471
637
|
break;
|
|
@@ -496,10 +662,28 @@ export class AgentLoop {
|
|
|
496
662
|
// Execute tool calls — run read-only tools in parallel, permission-
|
|
497
663
|
// requiring tools sequentially (to avoid overlapping permission prompts).
|
|
498
664
|
const batchTotal = toolCalls.length;
|
|
665
|
+
const collectedResults = [];
|
|
499
666
|
const executeSingle = async (tc, batchIndex) => {
|
|
667
|
+
// Rewrite meta-tool calls (e.g., use_extension → actual tool)
|
|
668
|
+
tc = this.toolProtocol.rewriteToolCall(tc);
|
|
669
|
+
// Check for validation errors from rewrite (e.g., wrong extension params)
|
|
670
|
+
try {
|
|
671
|
+
const maybeError = JSON.parse(tc.argumentsJson);
|
|
672
|
+
if (maybeError._error) {
|
|
673
|
+
collectedResults.push({
|
|
674
|
+
callId: tc.id, toolName: tc.name,
|
|
675
|
+
content: maybeError._error, isError: true,
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
catch { /* not an error payload, continue */ }
|
|
500
681
|
const tool = this.toolRegistry.get(tc.name);
|
|
501
682
|
if (!tool) {
|
|
502
|
-
|
|
683
|
+
collectedResults.push({
|
|
684
|
+
callId: tc.id, toolName: tc.name,
|
|
685
|
+
content: `Unknown tool "${tc.name}"`, isError: true,
|
|
686
|
+
});
|
|
503
687
|
return;
|
|
504
688
|
}
|
|
505
689
|
let args;
|
|
@@ -507,7 +691,10 @@ export class AgentLoop {
|
|
|
507
691
|
args = JSON.parse(tc.argumentsJson);
|
|
508
692
|
}
|
|
509
693
|
catch {
|
|
510
|
-
|
|
694
|
+
collectedResults.push({
|
|
695
|
+
callId: tc.id, toolName: tc.name,
|
|
696
|
+
content: `Invalid JSON arguments for ${tc.name}`, isError: true,
|
|
697
|
+
});
|
|
511
698
|
return;
|
|
512
699
|
}
|
|
513
700
|
// Execute via handler — extensions can advise to add safe-mode,
|
|
@@ -517,11 +704,8 @@ export class AgentLoop {
|
|
|
517
704
|
};
|
|
518
705
|
const result = await this.handlers.call("tool:execute", { name: tc.name, id: tc.id, args, tool, onChunk: defaultOnChunk,
|
|
519
706
|
batchIndex, batchTotal: batchTotal > 1 ? batchTotal : undefined });
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
let content = result.isError
|
|
523
|
-
? `Error: ${result.content}`
|
|
524
|
-
: result.content;
|
|
707
|
+
// Truncate large outputs to avoid blowing context
|
|
708
|
+
let content = result.content;
|
|
525
709
|
const maxBytes = 16_384; // ~4k tokens
|
|
526
710
|
if (content.length > maxBytes) {
|
|
527
711
|
const headBytes = Math.floor(maxBytes * 0.6);
|
|
@@ -544,7 +728,10 @@ export class AgentLoop {
|
|
|
544
728
|
...lines.slice(tailStart),
|
|
545
729
|
].join("\n");
|
|
546
730
|
}
|
|
547
|
-
|
|
731
|
+
collectedResults.push({
|
|
732
|
+
callId: tc.id, toolName: tc.name,
|
|
733
|
+
content, isError: result.isError,
|
|
734
|
+
});
|
|
548
735
|
};
|
|
549
736
|
// Partition into parallel-safe (read-only) and sequential (needs permission)
|
|
550
737
|
const parallel = [];
|
|
@@ -572,6 +759,8 @@ export class AgentLoop {
|
|
|
572
759
|
break;
|
|
573
760
|
await executeSingle(tc, ++batchIdx);
|
|
574
761
|
}
|
|
762
|
+
// Record all tool results via protocol
|
|
763
|
+
this.toolProtocol.recordResults(this.conversation, collectedResults);
|
|
575
764
|
// Loop back — LLM sees tool results
|
|
576
765
|
}
|
|
577
766
|
return fullResponseText;
|
|
@@ -591,10 +780,14 @@ export class AgentLoop {
|
|
|
591
780
|
catch (e) {
|
|
592
781
|
if (signal.aborted)
|
|
593
782
|
throw e;
|
|
594
|
-
// Context overflow — compact and retry
|
|
783
|
+
// Context overflow — aggressively compact and retry
|
|
595
784
|
if (this.isContextOverflow(e)) {
|
|
596
|
-
|
|
597
|
-
|
|
785
|
+
// Use 60% of the budget to leave headroom
|
|
786
|
+
const aggressiveBudget = Math.floor(this.tokenBudget.conversationBudgetTokens * 0.6);
|
|
787
|
+
const stats = this.conversation.compact(aggressiveBudget, 6);
|
|
788
|
+
await this.conversation.flush();
|
|
789
|
+
const detail = stats ? ` ~${stats.before.toLocaleString()} → ~${stats.after.toLocaleString()} tokens` : "";
|
|
790
|
+
this.bus.emit("ui:info", { message: `(context overflow — compacted${detail}, retrying)` });
|
|
598
791
|
continue;
|
|
599
792
|
}
|
|
600
793
|
// Retryable transient error — backoff
|
|
@@ -633,9 +826,21 @@ export class AgentLoop {
|
|
|
633
826
|
];
|
|
634
827
|
// Let extensions transform the message array (compact, summarize, filter, etc.)
|
|
635
828
|
const messages = this.handlers.call("conversation:prepare", rawMessages);
|
|
829
|
+
// Tool protocol controls what goes in the API tools param vs dynamic context
|
|
830
|
+
const apiTools = this.toolProtocol.getApiTools(this.toolRegistry.all());
|
|
831
|
+
const toolPrompt = this.toolProtocol.getToolPrompt(this.toolRegistry.all());
|
|
832
|
+
// Append tool catalog to dynamic context (closer to user query = better followed)
|
|
833
|
+
if (toolPrompt) {
|
|
834
|
+
const ctxMsg = messages[1]; // dynamic context user message
|
|
835
|
+
if (ctxMsg && typeof ctxMsg.content === "string") {
|
|
836
|
+
ctxMsg.content += "\n" + toolPrompt;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Stream filter strips tool tags from display (inline mode only)
|
|
840
|
+
const streamFilter = this.toolProtocol.createStreamFilter(this.toolRegistry.all().map((t) => t.name));
|
|
636
841
|
const stream = await this.llmClient.stream({
|
|
637
842
|
messages,
|
|
638
|
-
tools:
|
|
843
|
+
tools: apiTools,
|
|
639
844
|
model: this.currentModel,
|
|
640
845
|
reasoning_effort: this.shouldSendReasoningEffort() ? this.thinkingLevel : undefined,
|
|
641
846
|
signal,
|
|
@@ -643,6 +848,15 @@ export class AgentLoop {
|
|
|
643
848
|
for await (const chunk of stream) {
|
|
644
849
|
if (signal.aborted)
|
|
645
850
|
break;
|
|
851
|
+
// Token usage (may arrive in a chunk with empty choices)
|
|
852
|
+
if (chunk.usage) {
|
|
853
|
+
const u = chunk.usage;
|
|
854
|
+
this.bus.emit("agent:usage", {
|
|
855
|
+
prompt_tokens: u.prompt_tokens ?? 0,
|
|
856
|
+
completion_tokens: u.completion_tokens ?? 0,
|
|
857
|
+
total_tokens: u.total_tokens ?? 0,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
646
860
|
const choice = chunk.choices[0];
|
|
647
861
|
if (!choice)
|
|
648
862
|
continue;
|
|
@@ -650,9 +864,15 @@ export class AgentLoop {
|
|
|
650
864
|
// Text content
|
|
651
865
|
if (delta?.content) {
|
|
652
866
|
text += delta.content;
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
867
|
+
// Filter tool tags from display output (inline mode)
|
|
868
|
+
const displayText = streamFilter
|
|
869
|
+
? streamFilter.feed(delta.content)
|
|
870
|
+
: delta.content;
|
|
871
|
+
if (displayText) {
|
|
872
|
+
this.bus.emitTransform("agent:response-chunk", {
|
|
873
|
+
blocks: [{ type: "text", text: displayText }],
|
|
874
|
+
});
|
|
875
|
+
}
|
|
656
876
|
}
|
|
657
877
|
// Reasoning/thinking tokens (non-standard, e.g. DeepSeek)
|
|
658
878
|
if (delta?.reasoning_content) {
|
|
@@ -677,28 +897,19 @@ export class AgentLoop {
|
|
|
677
897
|
}
|
|
678
898
|
}
|
|
679
899
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
900
|
+
}
|
|
901
|
+
// Flush any buffered content from the stream filter
|
|
902
|
+
if (streamFilter) {
|
|
903
|
+
const remaining = streamFilter.flush();
|
|
904
|
+
if (remaining) {
|
|
905
|
+
this.bus.emitTransform("agent:response-chunk", {
|
|
906
|
+
blocks: [{ type: "text", text: remaining }],
|
|
687
907
|
});
|
|
688
908
|
}
|
|
689
909
|
}
|
|
690
|
-
// Build assistant tool calls for conversation recording
|
|
691
|
-
const assistantToolCalls = pendingToolCalls.length
|
|
692
|
-
? pendingToolCalls.map((tc) => ({
|
|
693
|
-
id: tc.id,
|
|
694
|
-
function: { name: tc.name, arguments: tc.argumentsJson },
|
|
695
|
-
}))
|
|
696
|
-
: undefined;
|
|
697
910
|
return {
|
|
698
911
|
text,
|
|
699
912
|
toolCalls: pendingToolCalls,
|
|
700
|
-
assistantContent: text || null,
|
|
701
|
-
assistantToolCalls,
|
|
702
913
|
};
|
|
703
914
|
}
|
|
704
915
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { ChatCompletionMessageParam } from "../utils/llm-client.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Separate from ContextManager — this is the LLM conversation,
|
|
5
|
-
* not the shell history.
|
|
6
|
-
*/
|
|
2
|
+
import { type NuclearEntry } from "./nuclear-form.js";
|
|
3
|
+
import type { HistoryFile } from "./history-file.js";
|
|
7
4
|
export declare class ConversationState {
|
|
8
5
|
private messages;
|
|
6
|
+
private nuclearEntries;
|
|
7
|
+
private recallArchive;
|
|
8
|
+
private historyFile;
|
|
9
|
+
private nextSeq;
|
|
10
|
+
constructor(historyFile?: HistoryFile);
|
|
11
|
+
get instanceId(): string;
|
|
9
12
|
addUserMessage(text: string): void;
|
|
10
13
|
addAssistantMessage(content: string | null, toolCalls?: {
|
|
11
14
|
id: string;
|
|
@@ -15,13 +18,42 @@ export declare class ConversationState {
|
|
|
15
18
|
};
|
|
16
19
|
}[]): void;
|
|
17
20
|
addToolResult(toolCallId: string, content: string): void;
|
|
18
|
-
/**
|
|
21
|
+
/** Add tool results as a user message (for inline tool protocol). */
|
|
22
|
+
addToolResultInline(content: string): void;
|
|
19
23
|
addSystemNote(text: string): void;
|
|
20
24
|
getMessages(): ChatCompletionMessageParam[];
|
|
25
|
+
estimateTokens(): number;
|
|
21
26
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
27
|
+
* Priority-based compaction. Evicts lowest-priority turns, replacing
|
|
28
|
+
* them with nuclear one-liner summaries that stay in the conversation.
|
|
29
|
+
* Read-only tool results are dropped entirely.
|
|
24
30
|
*/
|
|
25
|
-
compact(
|
|
31
|
+
compact(targetTokens: number, recentTurnsToKeep?: number, force?: boolean): {
|
|
32
|
+
before: number;
|
|
33
|
+
after: number;
|
|
34
|
+
} | null;
|
|
35
|
+
/**
|
|
36
|
+
* Flush oldest nuclear entries to the history file when the
|
|
37
|
+
* in-context nuclear block grows too large.
|
|
38
|
+
*/
|
|
39
|
+
flush(): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Inject prior session history from the history file as a context note.
|
|
42
|
+
*/
|
|
43
|
+
loadPriorHistory(entries: NuclearEntry[]): void;
|
|
44
|
+
/** Search Tier 2 archive + Tier 3 history file. */
|
|
45
|
+
search(query: string): Promise<string>;
|
|
46
|
+
/** Expand full content of a nuclear entry by seq number. */
|
|
47
|
+
expand(seq: number): Promise<string>;
|
|
48
|
+
/** Browse nuclear entries (Tier 2) + recent history (Tier 3). */
|
|
49
|
+
browse(): Promise<string>;
|
|
50
|
+
getNuclearEntryCount(): number;
|
|
51
|
+
getRecallArchiveSize(): number;
|
|
26
52
|
clear(): void;
|
|
53
|
+
private buildNuclearBlock;
|
|
54
|
+
private updateNuclearBlockInMessages;
|
|
55
|
+
private parseTurns;
|
|
56
|
+
private inferPriority;
|
|
57
|
+
private searchArchive;
|
|
58
|
+
private turnToText;
|
|
27
59
|
}
|