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.
Files changed (86) hide show
  1. package/README.md +28 -33
  2. package/dist/agent/agent-loop.d.ts +31 -8
  3. package/dist/agent/agent-loop.js +277 -66
  4. package/dist/agent/conversation-state.d.ts +41 -9
  5. package/dist/agent/conversation-state.js +340 -17
  6. package/dist/agent/history-file.d.ts +36 -0
  7. package/dist/agent/history-file.js +167 -0
  8. package/dist/agent/nuclear-form.d.ts +41 -0
  9. package/dist/agent/nuclear-form.js +176 -0
  10. package/dist/agent/system-prompt.d.ts +4 -5
  11. package/dist/agent/system-prompt.js +16 -11
  12. package/dist/agent/token-budget.d.ts +13 -0
  13. package/dist/agent/token-budget.js +50 -0
  14. package/dist/agent/tool-protocol.d.ts +83 -0
  15. package/dist/agent/tool-protocol.js +386 -0
  16. package/dist/agent/tools/user-shell.js +4 -1
  17. package/dist/agent/types.d.ts +21 -1
  18. package/dist/context-manager.d.ts +0 -1
  19. package/dist/context-manager.js +5 -110
  20. package/dist/core.d.ts +7 -7
  21. package/dist/core.js +76 -180
  22. package/dist/event-bus.d.ts +40 -0
  23. package/dist/event-bus.js +20 -1
  24. package/dist/extension-loader.d.ts +5 -0
  25. package/dist/extension-loader.js +104 -17
  26. package/dist/extensions/agent-backend.d.ts +13 -0
  27. package/dist/extensions/agent-backend.js +167 -0
  28. package/dist/extensions/command-suggest.d.ts +3 -3
  29. package/dist/extensions/command-suggest.js +4 -3
  30. package/dist/extensions/index.d.ts +19 -0
  31. package/dist/extensions/index.js +25 -0
  32. package/dist/extensions/slash-commands.d.ts +1 -1
  33. package/dist/extensions/slash-commands.js +44 -1
  34. package/dist/extensions/terminal-buffer.d.ts +1 -1
  35. package/dist/extensions/terminal-buffer.js +22 -8
  36. package/dist/extensions/tui-renderer.js +177 -122
  37. package/dist/index.js +14 -20
  38. package/dist/settings.d.ts +25 -2
  39. package/dist/settings.js +25 -4
  40. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  41. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  42. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  43. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  44. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  45. package/dist/{shell.js → shell/shell.js} +24 -6
  46. package/dist/types.d.ts +49 -32
  47. package/dist/utils/ansi.d.ts +10 -0
  48. package/dist/utils/ansi.js +27 -0
  49. package/dist/utils/compositor.d.ts +62 -0
  50. package/dist/utils/compositor.js +88 -0
  51. package/dist/utils/diff-renderer.js +92 -4
  52. package/dist/utils/floating-panel.d.ts +34 -3
  53. package/dist/utils/floating-panel.js +315 -82
  54. package/dist/utils/handler-registry.d.ts +26 -10
  55. package/dist/utils/handler-registry.js +52 -16
  56. package/dist/utils/line-editor.d.ts +32 -3
  57. package/dist/utils/line-editor.js +218 -36
  58. package/dist/utils/markdown.d.ts +1 -0
  59. package/dist/utils/markdown.js +4 -4
  60. package/dist/utils/message-utils.d.ts +35 -0
  61. package/dist/utils/message-utils.js +75 -0
  62. package/dist/utils/terminal-buffer.d.ts +9 -1
  63. package/dist/utils/terminal-buffer.js +31 -2
  64. package/dist/utils/tool-display.d.ts +1 -0
  65. package/dist/utils/tool-display.js +1 -1
  66. package/dist/utils/tool-interactive.d.ts +12 -0
  67. package/dist/utils/tool-interactive.js +53 -0
  68. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  69. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  70. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  71. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  72. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  73. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  74. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  75. package/examples/extensions/claude-code-bridge/index.ts +77 -1
  76. package/examples/extensions/interactive-prompts.ts +82 -110
  77. package/examples/extensions/overlay-agent.ts +84 -38
  78. package/examples/extensions/peer-mesh.ts +450 -0
  79. package/examples/extensions/pi-bridge/index.ts +87 -2
  80. package/examples/extensions/questionnaire.ts +249 -0
  81. package/examples/extensions/tmux-pane.ts +307 -0
  82. package/examples/extensions/web-access.ts +327 -0
  83. package/package.json +9 -1
  84. package/dist/extensions/overlay-agent.d.ts +0 -11
  85. package/dist/extensions/overlay-agent.js +0 -43
  86. package/examples/extensions/terminal-buffer.ts +0 -184
@@ -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
- conversation = new ConversationState();
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
- constructor(bus, contextManager, llmClient, handlers, modeConfig, initialModeIndex) {
36
- this.bus = bus;
37
- this.contextManager = contextManager;
38
- this.llmClient = llmClient;
39
- this.handlers = handlers;
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 = modeConfig ?? [
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: "agent-sh", version: "0.4", model: m.model, provider: m.provider, contextWindow: m.contextWindow });
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: "agent-sh", version: "0.4", model: newMode.model, provider: newMode.provider, contextWindow: newMode.contextWindow });
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.toolRegistry.all(), this.contextManager));
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 result = await tool.execute(args, onChunk);
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
- // raise the limit to avoid spurious warnings on multi-tool queries.
412
- setMaxListeners(50, signal);
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 if conversation is getting large
454
- const estimatedTokens = Math.ceil(JSON.stringify(this.conversation.getMessages()).length / 4);
455
- if (estimatedTokens > this.maxContextTokens) {
456
- this.conversation.compact(10);
457
- this.bus.emit("ui:info", { message: "(conversation compacted)" });
458
- }
459
- // System prompt is static (cacheable); dynamic context uses handler
460
- // so extensions can compose additional context via advise()
461
- const systemPrompt = STATIC_SYSTEM_PROMPT;
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, assistantContent, assistantToolCalls } = result;
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 in conversation
468
- this.conversation.addAssistantMessage(assistantContent, assistantToolCalls);
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
- this.conversation.addToolResult(tc.id, `Error: Unknown tool "${tc.name}"`);
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
- this.conversation.addToolResult(tc.id, `Error: Invalid JSON arguments for ${tc.name}`);
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
- // Add tool result to conversation (truncate large outputs to avoid
521
- // blowing through the context window on a single tool call)
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
- this.conversation.addToolResult(tc.id, content);
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 (no backoff needed)
783
+ // Context overflow — aggressively compact and retry
595
784
  if (this.isContextOverflow(e)) {
596
- this.conversation.compact(6);
597
- this.bus.emit("ui:info", { message: "(context overflow — compacted, retrying)" });
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: this.toolRegistry.toAPITools(),
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
- this.bus.emitTransform("agent:response-chunk", {
654
- blocks: [{ type: "text", text: delta.content }],
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
- // Token usage (final chunk from providers that support it)
681
- if (chunk.usage) {
682
- const u = chunk.usage;
683
- this.bus.emit("agent:usage", {
684
- prompt_tokens: u.prompt_tokens ?? 0,
685
- completion_tokens: u.completion_tokens ?? 0,
686
- total_tokens: u.total_tokens ?? 0,
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
- * Manages the OpenAI chat messages array for the agent loop.
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
- /** Inject a system-level note into the conversation (e.g. context change). */
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
- * Simple compaction drop oldest turns, keeping the first user message
23
- * (original task context) and the most recent turns.
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(maxTurns: number): void;
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
  }