agent-sh 0.4.0 → 0.6.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 (83) hide show
  1. package/README.md +37 -115
  2. package/dist/agent/agent-loop.d.ts +86 -0
  3. package/dist/agent/agent-loop.js +704 -0
  4. package/dist/agent/conversation-state.d.ts +27 -0
  5. package/dist/agent/conversation-state.js +59 -0
  6. package/dist/agent/index.d.ts +11 -0
  7. package/dist/agent/index.js +9 -0
  8. package/dist/agent/skills.d.ts +25 -0
  9. package/dist/agent/skills.js +186 -0
  10. package/dist/agent/subagent.d.ts +37 -0
  11. package/dist/agent/subagent.js +119 -0
  12. package/dist/agent/system-prompt.d.ts +14 -0
  13. package/dist/agent/system-prompt.js +103 -0
  14. package/dist/agent/tool-registry.d.ts +15 -0
  15. package/dist/agent/tool-registry.js +30 -0
  16. package/dist/agent/tools/bash.d.ts +7 -0
  17. package/dist/agent/tools/bash.js +71 -0
  18. package/dist/agent/tools/display.d.ts +13 -0
  19. package/dist/agent/tools/display.js +70 -0
  20. package/dist/agent/tools/edit-file.d.ts +2 -0
  21. package/dist/agent/tools/edit-file.js +148 -0
  22. package/dist/agent/tools/glob.d.ts +2 -0
  23. package/dist/agent/tools/glob.js +87 -0
  24. package/dist/agent/tools/grep.d.ts +2 -0
  25. package/dist/agent/tools/grep.js +168 -0
  26. package/dist/agent/tools/list-skills.d.ts +2 -0
  27. package/dist/agent/tools/list-skills.js +28 -0
  28. package/dist/agent/tools/ls.d.ts +2 -0
  29. package/dist/agent/tools/ls.js +72 -0
  30. package/dist/agent/tools/read-file.d.ts +10 -0
  31. package/dist/agent/tools/read-file.js +101 -0
  32. package/dist/agent/tools/user-shell.d.ts +13 -0
  33. package/dist/agent/tools/user-shell.js +84 -0
  34. package/dist/agent/tools/write-file.d.ts +2 -0
  35. package/dist/agent/tools/write-file.js +82 -0
  36. package/dist/agent/types.d.ts +78 -0
  37. package/dist/agent/types.js +1 -0
  38. package/dist/core.d.ts +22 -14
  39. package/dist/core.js +256 -36
  40. package/dist/event-bus.d.ts +98 -17
  41. package/dist/event-bus.js +10 -1
  42. package/dist/extension-loader.d.ts +1 -1
  43. package/dist/extension-loader.js +10 -1
  44. package/dist/extensions/command-suggest.d.ts +10 -0
  45. package/dist/extensions/command-suggest.js +41 -0
  46. package/dist/extensions/slash-commands.d.ts +1 -1
  47. package/dist/extensions/slash-commands.js +161 -64
  48. package/dist/extensions/tui-renderer.js +426 -126
  49. package/dist/index.js +110 -129
  50. package/dist/input-handler.js +78 -9
  51. package/dist/output-parser.d.ts +7 -0
  52. package/dist/output-parser.js +27 -0
  53. package/dist/settings.d.ts +53 -2
  54. package/dist/settings.js +46 -3
  55. package/dist/shell.js +35 -28
  56. package/dist/types.d.ts +33 -6
  57. package/dist/utils/box-frame.d.ts +3 -1
  58. package/dist/utils/box-frame.js +12 -5
  59. package/dist/utils/diff.js +10 -0
  60. package/dist/utils/llm-client.d.ts +45 -0
  61. package/dist/utils/llm-client.js +60 -0
  62. package/dist/utils/markdown.d.ts +1 -0
  63. package/dist/utils/markdown.js +25 -3
  64. package/dist/utils/stream-transform.js +20 -47
  65. package/dist/utils/tool-display.d.ts +4 -0
  66. package/dist/utils/tool-display.js +35 -8
  67. package/examples/extensions/claude-code-bridge/README.md +35 -0
  68. package/examples/extensions/claude-code-bridge/index.ts +194 -0
  69. package/examples/extensions/claude-code-bridge/package.json +11 -0
  70. package/examples/extensions/openrouter.ts +87 -0
  71. package/examples/extensions/pi-bridge/README.md +35 -0
  72. package/examples/extensions/pi-bridge/index.ts +263 -0
  73. package/examples/extensions/pi-bridge/package.json +13 -0
  74. package/examples/extensions/secret-guard.ts +100 -0
  75. package/examples/extensions/subagents.ts +87 -0
  76. package/package.json +3 -5
  77. package/dist/acp-client.d.ts +0 -105
  78. package/dist/acp-client.js +0 -684
  79. package/dist/extensions/shell-exec.d.ts +0 -24
  80. package/dist/extensions/shell-exec.js +0 -188
  81. package/dist/mcp-server.d.ts +0 -13
  82. package/dist/mcp-server.js +0 -234
  83. package/examples/pi-agent-sh.ts +0 -166
@@ -0,0 +1,704 @@
1
+ import { setMaxListeners } from "node:events";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { computeDiff } from "../utils/diff.js";
5
+ import { ToolRegistry } from "./tool-registry.js";
6
+ import { ConversationState } from "./conversation-state.js";
7
+ import { STATIC_SYSTEM_PROMPT, buildDynamicContext } from "./system-prompt.js";
8
+ // Core tool factories
9
+ import { createBashTool } from "./tools/bash.js";
10
+ import { createReadFileTool } from "./tools/read-file.js";
11
+ import { createWriteFileTool } from "./tools/write-file.js";
12
+ import { createEditFileTool } from "./tools/edit-file.js";
13
+ import { createGrepTool } from "./tools/grep.js";
14
+ import { createGlobTool } from "./tools/glob.js";
15
+ import { createLsTool } from "./tools/ls.js";
16
+ import { createUserShellTool } from "./tools/user-shell.js";
17
+ import { createDisplayTool } from "./tools/display.js";
18
+ import { createListSkillsTool } from "./tools/list-skills.js";
19
+ import { discoverProjectSkills } from "./skills.js";
20
+ export class AgentLoop {
21
+ bus;
22
+ contextManager;
23
+ llmClient;
24
+ handlers;
25
+ abortController = null;
26
+ toolRegistry = new ToolRegistry();
27
+ conversation = new ConversationState();
28
+ fileReadCache = new Map();
29
+ modes;
30
+ currentModeIndex = 0;
31
+ boundListeners = [];
32
+ lastProjectSkillNames = new Set();
33
+ static THINKING_LEVELS = ["off", "low", "medium", "high"];
34
+ 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;
40
+ // Default modes: just the configured model
41
+ this.modes = modeConfig ?? [
42
+ { model: llmClient.model },
43
+ ];
44
+ this.currentModeIndex = initialModeIndex ?? 0;
45
+ // Register core tools
46
+ this.registerCoreTools();
47
+ // Register handlers — extensions can advise these
48
+ this.registerHandlers();
49
+ }
50
+ /** Subscribe to bus events — activates this backend. */
51
+ wire() {
52
+ const on = (event, fn) => {
53
+ this.bus.on(event, fn);
54
+ this.boundListeners.push({ event, fn });
55
+ };
56
+ on("agent:submit", ({ query }) => {
57
+ this.handleQuery(query).catch(() => { });
58
+ });
59
+ on("agent:cancel-request", (e) => {
60
+ this.abortController?.abort(e.silent ? "silent" : undefined);
61
+ });
62
+ on("config:cycle", () => this.cycleMode());
63
+ on("config:switch-model", ({ model: target }) => {
64
+ const idx = this.modes.findIndex((m) => m.model === target);
65
+ if (idx === -1) {
66
+ this.bus.emit("ui:error", { message: `Unknown model: ${target}` });
67
+ return;
68
+ }
69
+ this.currentModeIndex = idx;
70
+ const m = this.modes[idx];
71
+ if (m.providerConfig) {
72
+ this.llmClient.reconfigure({ ...m.providerConfig, model: m.model });
73
+ }
74
+ else {
75
+ this.llmClient.model = m.model;
76
+ }
77
+ 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 });
79
+ this.bus.emit("ui:info", { message: `Model: ${label}` });
80
+ this.bus.emit("config:changed", {});
81
+ });
82
+ this.bus.onPipe("config:get-models", (payload) => {
83
+ const models = this.modes.map((m) => ({ model: m.model, provider: m.provider ?? "" }));
84
+ const active = this.modes[this.currentModeIndex]?.model ?? null;
85
+ return { models, active };
86
+ });
87
+ on("config:set-thinking", ({ level }) => {
88
+ if (!AgentLoop.THINKING_LEVELS.includes(level)) {
89
+ this.bus.emit("ui:error", { message: `Unknown thinking level: ${level}. Use: ${AgentLoop.THINKING_LEVELS.join(", ")}` });
90
+ return;
91
+ }
92
+ const mode = this.currentMode;
93
+ if (level !== "off" && mode.reasoning === false) {
94
+ this.bus.emit("ui:error", { message: `Model ${mode.model} does not support thinking.` });
95
+ return;
96
+ }
97
+ if (level !== "off" && mode.supportsReasoningEffort === false) {
98
+ this.bus.emit("ui:error", { message: `Provider ${mode.provider ?? "unknown"} does not support reasoning_effort.` });
99
+ return;
100
+ }
101
+ this.thinkingLevel = level;
102
+ this.bus.emit("ui:info", { message: `Thinking: ${level}` });
103
+ this.bus.emit("config:changed", {});
104
+ });
105
+ this.bus.onPipe("config:get-thinking", () => {
106
+ const mode = this.currentMode;
107
+ const supported = mode.reasoning !== false && mode.supportsReasoningEffort !== false;
108
+ return { level: this.thinkingLevel, levels: AgentLoop.THINKING_LEVELS, supported };
109
+ });
110
+ on("config:set-modes", ({ modes: newModes }) => {
111
+ this.modes = newModes;
112
+ this.currentModeIndex = 0;
113
+ const m = this.modes[0];
114
+ if (m.providerConfig) {
115
+ this.llmClient.reconfigure({ ...m.providerConfig, model: m.model });
116
+ }
117
+ else {
118
+ this.llmClient.model = m.model;
119
+ }
120
+ this.bus.emit("config:changed", {});
121
+ });
122
+ on("agent:reset-session", () => {
123
+ this.cancel();
124
+ this.conversation = new ConversationState();
125
+ this.lastProjectSkillNames.clear();
126
+ });
127
+ on("shell:cwd-change", ({ cwd }) => {
128
+ const projectSkills = discoverProjectSkills(cwd);
129
+ const newNames = new Set(projectSkills.map(s => s.name));
130
+ // Check if the set of project skills changed
131
+ if (newNames.size === this.lastProjectSkillNames.size &&
132
+ [...newNames].every(n => this.lastProjectSkillNames.has(n))) {
133
+ return; // no change
134
+ }
135
+ this.lastProjectSkillNames = newNames;
136
+ if (projectSkills.length > 0) {
137
+ const names = projectSkills.map(s => s.name).join(", ");
138
+ this.conversation.addSystemNote(`[Project skills available: ${names}. Use list_skills for details, read_file to load.]`);
139
+ }
140
+ });
141
+ }
142
+ /** Unsubscribe from bus events — deactivates this backend. */
143
+ unwire() {
144
+ for (const { event, fn } of this.boundListeners) {
145
+ this.bus.off(event, fn);
146
+ }
147
+ this.boundListeners = [];
148
+ }
149
+ /** Register a tool (used by extensions via ctx.registerTool). */
150
+ registerTool(tool) {
151
+ this.toolRegistry.register(tool);
152
+ }
153
+ /** Get all registered tools. */
154
+ getTools() {
155
+ return this.toolRegistry.all();
156
+ }
157
+ kill() {
158
+ this.cancel();
159
+ }
160
+ cancel() {
161
+ this.abortController?.abort();
162
+ }
163
+ /** Check if reasoning_effort should be sent for the current model/provider. */
164
+ shouldSendReasoningEffort() {
165
+ if (this.thinkingLevel === "off")
166
+ return false;
167
+ const mode = this.currentMode;
168
+ if (mode.reasoning === false)
169
+ return false;
170
+ if (mode.supportsReasoningEffort === false)
171
+ return false;
172
+ return true;
173
+ }
174
+ cycleMode() {
175
+ const prevMode = this.modes[this.currentModeIndex];
176
+ this.currentModeIndex =
177
+ (this.currentModeIndex + 1) % this.modes.length;
178
+ const newMode = this.modes[this.currentModeIndex];
179
+ // Reconfigure LlmClient if provider changed
180
+ if (newMode.provider !== prevMode.provider && newMode.providerConfig) {
181
+ this.llmClient.reconfigure({
182
+ apiKey: newMode.providerConfig.apiKey,
183
+ baseURL: newMode.providerConfig.baseURL,
184
+ model: newMode.model,
185
+ });
186
+ }
187
+ else {
188
+ this.llmClient.model = newMode.model;
189
+ }
190
+ const label = newMode.provider
191
+ ? `${newMode.provider}: ${newMode.model}`
192
+ : newMode.model;
193
+ this.bus.emit("agent:info", { name: "agent-sh", version: "0.4", model: newMode.model, provider: newMode.provider, contextWindow: newMode.contextWindow });
194
+ this.bus.emit("ui:info", { message: `Model: ${label}` });
195
+ this.bus.emit("config:changed", {});
196
+ }
197
+ get currentMode() {
198
+ return this.modes[this.currentModeIndex];
199
+ }
200
+ get currentModel() {
201
+ return this.modes[this.currentModeIndex].model;
202
+ }
203
+ isContextOverflow(e) {
204
+ if (!(e instanceof Error))
205
+ return false;
206
+ const msg = e.message.toLowerCase();
207
+ return msg.includes("context") || msg.includes("token") || msg.includes("too long");
208
+ }
209
+ /** Check if an error is retryable (transient). */
210
+ isRetryable(e) {
211
+ if (!(e instanceof Error))
212
+ return false;
213
+ const msg = e.message.toLowerCase();
214
+ // Network errors
215
+ if (msg.includes("econnreset") || msg.includes("econnrefused") ||
216
+ msg.includes("etimedout") || msg.includes("fetch failed") ||
217
+ msg.includes("network") || msg.includes("socket hang up")) {
218
+ return true;
219
+ }
220
+ // HTTP status-based (OpenAI SDK includes status in error)
221
+ const status = e.status;
222
+ if (status === 429 || status === 500 || status === 502 || status === 503 || status === 529) {
223
+ return true;
224
+ }
225
+ return false;
226
+ }
227
+ /** Extract retry delay from error headers or use exponential backoff. */
228
+ getRetryDelay(e, attempt) {
229
+ // Check for Retry-After header (OpenAI SDK exposes headers)
230
+ const headers = e.headers;
231
+ if (headers) {
232
+ const retryAfter = headers["retry-after"] ?? headers.get?.("retry-after");
233
+ if (retryAfter) {
234
+ const seconds = parseInt(retryAfter, 10);
235
+ if (!isNaN(seconds))
236
+ return seconds * 1000;
237
+ }
238
+ }
239
+ // Exponential backoff: 1s, 2s, 4s, 8s, capped at 30s
240
+ return Math.min(1000 * Math.pow(2, attempt), 30_000);
241
+ }
242
+ /** Format an error with provider context for user-facing display. */
243
+ formatError(e) {
244
+ const raw = e instanceof Error ? e.message : String(e);
245
+ const status = e.status;
246
+ const model = this.currentModel;
247
+ const baseURL = this.llmClient.config?.baseURL;
248
+ const provider = this.currentMode.provider;
249
+ // Connection errors — most likely misconfigured provider
250
+ if (raw.includes("ECONNREFUSED") || raw.includes("ECONNRESET") ||
251
+ raw.includes("ETIMEDOUT") || raw.includes("fetch failed") ||
252
+ raw.includes("socket hang up")) {
253
+ const target = baseURL ?? provider ?? "provider";
254
+ return `Could not connect to ${target} (${raw}). Check that the API endpoint is reachable.`;
255
+ }
256
+ // Auth errors
257
+ if (status === 401 || raw.toLowerCase().includes("auth")) {
258
+ return `Authentication failed for ${provider ?? "provider"} (model: ${model}). Check your API key.`;
259
+ }
260
+ // Model not found
261
+ if (status === 404) {
262
+ return `Model "${model}" not found at ${provider ?? baseURL ?? "provider"}. Check the model name.`;
263
+ }
264
+ // Rate limit (after retries exhausted)
265
+ if (status === 429) {
266
+ return `Rate limited by ${provider ?? "provider"} (model: ${model}). Try again in a moment.`;
267
+ }
268
+ // Generic with context
269
+ const context = provider ? ` (${provider}, model: ${model})` : ` (model: ${model})`;
270
+ return `${raw}${context}`;
271
+ }
272
+ registerCoreTools() {
273
+ const getCwd = () => this.contextManager.getCwd();
274
+ const getEnv = () => {
275
+ const env = {};
276
+ for (const [k, v] of Object.entries(process.env)) {
277
+ if (v !== undefined)
278
+ env[k] = v;
279
+ }
280
+ return env;
281
+ };
282
+ this.toolRegistry.register(createBashTool({ getCwd, getEnv, bus: this.bus }));
283
+ this.toolRegistry.register(createReadFileTool(getCwd, this.fileReadCache));
284
+ this.toolRegistry.register(createWriteFileTool(getCwd));
285
+ this.toolRegistry.register(createEditFileTool(getCwd));
286
+ this.toolRegistry.register(createGrepTool(getCwd));
287
+ this.toolRegistry.register(createGlobTool(getCwd));
288
+ this.toolRegistry.register(createLsTool(getCwd));
289
+ this.toolRegistry.register(createUserShellTool({ getCwd, bus: this.bus }));
290
+ this.toolRegistry.register(createDisplayTool({ getCwd, bus: this.bus }));
291
+ this.toolRegistry.register(createListSkillsTool(getCwd));
292
+ }
293
+ /**
294
+ * Register named handlers that extensions can advise.
295
+ * Only high-power use cases where multiple extensions compose.
296
+ */
297
+ registerHandlers() {
298
+ const h = this.handlers;
299
+ // Extensions compose additional context (git info, project rules, etc.)
300
+ h.define("dynamic-context:build", () => buildDynamicContext(this.toolRegistry.all(), this.contextManager));
301
+ // Full control over what the LLM sees: takes messages[], returns messages[].
302
+ // Default: pass through. Extensions can advise to compact, summarize,
303
+ // filter, reorder, inject — whatever strategy fits.
304
+ h.define("conversation:prepare", (messages) => messages);
305
+ // Wraps each tool call: permission → execute → emit events.
306
+ // Extensions advise to add safe-mode, logging, metrics, custom policies.
307
+ // The ctx.onChunk callback is exposed so advisors can wrap it to
308
+ // intercept/transform streamed tool output (e.g. secret redaction).
309
+ h.define("tool:execute", async (ctx) => {
310
+ const { name, id, args, tool } = ctx;
311
+ const display = tool.getDisplayInfo?.(args) ?? { kind: "execute" };
312
+ let diffShown = false;
313
+ // Permission gating
314
+ if (tool.requiresPermission) {
315
+ let permKind = "tool-call";
316
+ let permTitle = typeof args.description === "string"
317
+ ? `${name}: ${args.description}`
318
+ : name;
319
+ let metadata = { args };
320
+ // For file-modifying tools, pre-compute diff for display
321
+ if (tool.modifiesFiles && typeof args.path === "string") {
322
+ try {
323
+ const absPath = path.resolve(process.cwd(), args.path);
324
+ let oldContent = null;
325
+ try {
326
+ oldContent = await fs.readFile(absPath, "utf-8");
327
+ }
328
+ catch { /* new file */ }
329
+ let newContent;
330
+ if (typeof args.content === "string") {
331
+ // write_file
332
+ newContent = args.content;
333
+ }
334
+ else if (typeof args.old_text === "string" && typeof args.new_text === "string" && oldContent) {
335
+ // edit_file
336
+ newContent = oldContent.replace(args.old_text.replace(/\r\n/g, "\n"), args.new_text.replace(/\r\n/g, "\n"));
337
+ }
338
+ if (newContent !== undefined) {
339
+ const diff = computeDiff(oldContent, newContent);
340
+ if (!diff.isIdentical) {
341
+ permKind = "file-write";
342
+ // Shorten path for display
343
+ const cwd = process.cwd();
344
+ const home = process.env.HOME;
345
+ let displayPath = absPath;
346
+ if (absPath.startsWith(cwd + "/"))
347
+ displayPath = absPath.slice(cwd.length + 1);
348
+ else if (home && absPath.startsWith(home + "/"))
349
+ displayPath = "~/" + absPath.slice(home.length + 1);
350
+ permTitle = displayPath;
351
+ metadata = { args, diff };
352
+ diffShown = true;
353
+ }
354
+ }
355
+ }
356
+ catch { /* fall back to generic permission */ }
357
+ }
358
+ const perm = await this.bus.emitPipeAsync("permission:request", {
359
+ kind: permKind,
360
+ title: permTitle,
361
+ metadata,
362
+ decision: { outcome: "approved" },
363
+ });
364
+ if (perm.decision.outcome !== "approved") {
365
+ return { content: "Permission denied by user.", exitCode: 1, isError: true };
366
+ }
367
+ }
368
+ // Emit tool-started for TUI
369
+ const label = tool.displayName ?? name;
370
+ this.bus.emit("agent:tool-started", {
371
+ title: typeof args.description === "string" ? `${label}: ${args.description}` : label,
372
+ toolCallId: id,
373
+ kind: display.kind, icon: display.icon, locations: display.locations, rawInput: args,
374
+ displayDetail: tool.formatCall?.(args),
375
+ batchIndex: ctx.batchIndex, batchTotal: ctx.batchTotal,
376
+ });
377
+ this.bus.emit("agent:tool-call", { tool: name, args });
378
+ // Execute — use ctx.onChunk so advisors can wrap the streaming callback.
379
+ // Suppress streaming output if diff was already shown.
380
+ const onChunk = (tool.showOutput !== false && !diffShown)
381
+ ? ctx.onChunk
382
+ : undefined;
383
+ const result = await tool.execute(args, onChunk);
384
+ // Invalidate read cache when a file is modified
385
+ if (tool.modifiesFiles && typeof args.path === "string" && !result.isError) {
386
+ const absPath = path.resolve(process.cwd(), args.path);
387
+ this.fileReadCache.delete(absPath);
388
+ }
389
+ // Compute result display: tool-provided → default (none)
390
+ const resultDisplay = tool.formatResult?.(args, result);
391
+ // Emit completion events (via transform pipe so extensions can override)
392
+ this.bus.emitTransform("agent:tool-completed", {
393
+ toolCallId: id, exitCode: result.exitCode,
394
+ rawOutput: result.content, kind: display.kind,
395
+ resultDisplay,
396
+ });
397
+ this.bus.emit("agent:tool-output", {
398
+ tool: name, output: result.content, exitCode: result.exitCode,
399
+ });
400
+ return result;
401
+ });
402
+ }
403
+ async handleQuery(query) {
404
+ // Cancel any in-flight loop (concurrent prompt handling)
405
+ if (this.abortController) {
406
+ this.abortController.abort();
407
+ }
408
+ this.abortController = new AbortController();
409
+ const signal = this.abortController.signal;
410
+ // 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);
413
+ this.bus.emit("agent:query", { query });
414
+ this.bus.emit("agent:processing-start", {});
415
+ let responseText = "";
416
+ try {
417
+ this.conversation.addUserMessage(query);
418
+ responseText = await this.executeLoop(signal);
419
+ }
420
+ catch (e) {
421
+ if (signal.aborted && signal.reason !== "silent") {
422
+ this.bus.emit("agent:cancelled", {});
423
+ }
424
+ else if (!signal.aborted) {
425
+ const msg = this.formatError(e);
426
+ this.bus.emit("agent:error", { message: msg });
427
+ }
428
+ }
429
+ finally {
430
+ // Ensure any buffered text in the stream transform pipeline gets
431
+ // flushed as a complete line before response-done closes the box.
432
+ if (responseText && !responseText.endsWith("\n")) {
433
+ this.bus.emitTransform("agent:response-chunk", {
434
+ blocks: [{ type: "text", text: "\n" }],
435
+ });
436
+ }
437
+ this.bus.emitTransform("agent:response-done", {
438
+ response: responseText,
439
+ });
440
+ this.bus.emit("agent:processing-done", {});
441
+ this.abortController = null;
442
+ }
443
+ }
444
+ /** Max tokens before auto-compaction (conservative default). */
445
+ maxContextTokens = 60_000;
446
+ /**
447
+ * Core agent loop: stream LLM response → execute tools → repeat.
448
+ * Returns the final accumulated response text.
449
+ */
450
+ async executeLoop(signal) {
451
+ let fullResponseText = "";
452
+ 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;
462
+ const dynamicContext = this.handlers.call("dynamic-context:build");
463
+ // Stream LLM response with retry
464
+ const result = await this.streamWithRetry(systemPrompt, dynamicContext, signal);
465
+ const { text, toolCalls, assistantContent, assistantToolCalls } = result;
466
+ fullResponseText += text;
467
+ // Record the assistant message in conversation
468
+ this.conversation.addAssistantMessage(assistantContent, assistantToolCalls);
469
+ // No tool calls → agent is done
470
+ if (toolCalls.length === 0)
471
+ break;
472
+ // Emit batch info so the TUI can render group headers upfront
473
+ {
474
+ const groupMap = new Map();
475
+ for (const tc of toolCalls) {
476
+ const tool = this.toolRegistry.get(tc.name);
477
+ const kind = tool?.getDisplayInfo?.((() => { try {
478
+ return JSON.parse(tc.argumentsJson);
479
+ }
480
+ catch {
481
+ return {};
482
+ } })())?.kind ?? "execute";
483
+ let args = {};
484
+ try {
485
+ args = JSON.parse(tc.argumentsJson);
486
+ }
487
+ catch { }
488
+ const detail = tool?.formatCall?.(args);
489
+ if (!groupMap.has(kind))
490
+ groupMap.set(kind, []);
491
+ groupMap.get(kind).push({ name: tc.name, displayDetail: detail });
492
+ }
493
+ const groups = Array.from(groupMap.entries()).map(([kind, tools]) => ({ kind, tools }));
494
+ this.bus.emit("agent:tool-batch", { groups });
495
+ }
496
+ // Execute tool calls — run read-only tools in parallel, permission-
497
+ // requiring tools sequentially (to avoid overlapping permission prompts).
498
+ const batchTotal = toolCalls.length;
499
+ const executeSingle = async (tc, batchIndex) => {
500
+ const tool = this.toolRegistry.get(tc.name);
501
+ if (!tool) {
502
+ this.conversation.addToolResult(tc.id, `Error: Unknown tool "${tc.name}"`);
503
+ return;
504
+ }
505
+ let args;
506
+ try {
507
+ args = JSON.parse(tc.argumentsJson);
508
+ }
509
+ catch {
510
+ this.conversation.addToolResult(tc.id, `Error: Invalid JSON arguments for ${tc.name}`);
511
+ return;
512
+ }
513
+ // Execute via handler — extensions can advise to add safe-mode,
514
+ // logging, metrics, custom permission policies, etc.
515
+ const defaultOnChunk = (chunk) => {
516
+ this.bus.emit("agent:tool-output-chunk", { chunk });
517
+ };
518
+ const result = await this.handlers.call("tool:execute", { name: tc.name, id: tc.id, args, tool, onChunk: defaultOnChunk,
519
+ 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;
525
+ const maxBytes = 16_384; // ~4k tokens
526
+ if (content.length > maxBytes) {
527
+ const headBytes = Math.floor(maxBytes * 0.6);
528
+ const tailBytes = maxBytes - headBytes;
529
+ const lines = content.split("\n");
530
+ let headEnd = 0, headLen = 0;
531
+ for (let i = 0; i < lines.length && headLen + lines[i].length + 1 <= headBytes; i++) {
532
+ headLen += lines[i].length + 1;
533
+ headEnd = i + 1;
534
+ }
535
+ let tailStart = lines.length, tailLen = 0;
536
+ for (let i = lines.length - 1; i >= headEnd && tailLen + lines[i].length + 1 <= tailBytes; i--) {
537
+ tailLen += lines[i].length + 1;
538
+ tailStart = i;
539
+ }
540
+ const omitted = tailStart - headEnd;
541
+ content = [
542
+ ...lines.slice(0, headEnd),
543
+ `\n[… ${omitted} lines omitted (output truncated to ${Math.round(maxBytes / 1024)}KB) …]\n`,
544
+ ...lines.slice(tailStart),
545
+ ].join("\n");
546
+ }
547
+ this.conversation.addToolResult(tc.id, content);
548
+ };
549
+ // Partition into parallel-safe (read-only) and sequential (needs permission)
550
+ const parallel = [];
551
+ const sequential = [];
552
+ for (const tc of toolCalls) {
553
+ const tool = this.toolRegistry.get(tc.name);
554
+ if (tool && !tool.requiresPermission && !tool.modifiesFiles) {
555
+ parallel.push(tc);
556
+ }
557
+ else {
558
+ sequential.push(tc);
559
+ }
560
+ }
561
+ // Run read-only tools in parallel
562
+ let batchIdx = 0;
563
+ if (parallel.length > 0 && !signal.aborted) {
564
+ await Promise.all(parallel.map(tc => {
565
+ const idx = ++batchIdx;
566
+ return signal.aborted ? Promise.resolve() : executeSingle(tc, idx);
567
+ }));
568
+ }
569
+ // Run permission-requiring tools sequentially
570
+ for (const tc of sequential) {
571
+ if (signal.aborted)
572
+ break;
573
+ await executeSingle(tc, ++batchIdx);
574
+ }
575
+ // Loop back — LLM sees tool results
576
+ }
577
+ return fullResponseText;
578
+ }
579
+ maxRetries = 3;
580
+ /**
581
+ * Stream with retry logic. Handles:
582
+ * - Context overflow → compact and retry
583
+ * - Rate limits (429) → backoff with Retry-After
584
+ * - Transient errors (500/502/503, network) → exponential backoff
585
+ */
586
+ async streamWithRetry(systemPrompt, dynamicContext, signal) {
587
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
588
+ try {
589
+ return await this.streamResponse(systemPrompt, dynamicContext, signal);
590
+ }
591
+ catch (e) {
592
+ if (signal.aborted)
593
+ throw e;
594
+ // Context overflow — compact and retry (no backoff needed)
595
+ if (this.isContextOverflow(e)) {
596
+ this.conversation.compact(6);
597
+ this.bus.emit("ui:info", { message: "(context overflow — compacted, retrying)" });
598
+ continue;
599
+ }
600
+ // Retryable transient error — backoff
601
+ if (this.isRetryable(e) && attempt < this.maxRetries) {
602
+ const delay = this.getRetryDelay(e, attempt);
603
+ const status = e.status;
604
+ const reason = status === 429 ? "rate limited" : `error ${status ?? "network"}`;
605
+ this.bus.emit("ui:info", {
606
+ message: `(${reason}, retrying in ${Math.ceil(delay / 1000)}s — attempt ${attempt + 2}/${this.maxRetries + 1})`,
607
+ });
608
+ await new Promise((resolve, reject) => {
609
+ const timer = setTimeout(resolve, delay);
610
+ signal.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("aborted")); }, { once: true });
611
+ });
612
+ continue;
613
+ }
614
+ // Non-retryable or exhausted retries
615
+ throw e;
616
+ }
617
+ }
618
+ // Should not reach here, but TypeScript needs it
619
+ throw new Error("Retry loop exhausted");
620
+ }
621
+ /**
622
+ * Stream a single LLM response. Returns accumulated text, parsed tool calls,
623
+ * and the raw assistant message data for conversation recording.
624
+ */
625
+ async streamResponse(systemPrompt, dynamicContext, signal) {
626
+ let text = "";
627
+ const pendingToolCalls = [];
628
+ const rawMessages = [
629
+ { role: "system", content: systemPrompt },
630
+ { role: "user", content: `<context>\n${dynamicContext}\n</context>` },
631
+ { role: "assistant", content: "Understood." },
632
+ ...this.conversation.getMessages(),
633
+ ];
634
+ // Let extensions transform the message array (compact, summarize, filter, etc.)
635
+ const messages = this.handlers.call("conversation:prepare", rawMessages);
636
+ const stream = await this.llmClient.stream({
637
+ messages,
638
+ tools: this.toolRegistry.toAPITools(),
639
+ model: this.currentModel,
640
+ reasoning_effort: this.shouldSendReasoningEffort() ? this.thinkingLevel : undefined,
641
+ signal,
642
+ });
643
+ for await (const chunk of stream) {
644
+ if (signal.aborted)
645
+ break;
646
+ const choice = chunk.choices[0];
647
+ if (!choice)
648
+ continue;
649
+ const delta = choice.delta;
650
+ // Text content
651
+ if (delta?.content) {
652
+ text += delta.content;
653
+ this.bus.emitTransform("agent:response-chunk", {
654
+ blocks: [{ type: "text", text: delta.content }],
655
+ });
656
+ }
657
+ // Reasoning/thinking tokens (non-standard, e.g. DeepSeek)
658
+ if (delta?.reasoning_content) {
659
+ this.bus.emit("agent:thinking-chunk", {
660
+ text: delta.reasoning_content,
661
+ });
662
+ }
663
+ // Tool calls (streamed incrementally)
664
+ if (delta?.tool_calls) {
665
+ for (const tc of delta.tool_calls) {
666
+ const idx = tc.index;
667
+ if (!pendingToolCalls[idx]) {
668
+ pendingToolCalls[idx] = {
669
+ id: tc.id,
670
+ name: tc.function.name,
671
+ argumentsJson: "",
672
+ };
673
+ }
674
+ if (tc.function?.arguments) {
675
+ pendingToolCalls[idx].argumentsJson +=
676
+ tc.function.arguments;
677
+ }
678
+ }
679
+ }
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,
687
+ });
688
+ }
689
+ }
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
+ return {
698
+ text,
699
+ toolCalls: pendingToolCalls,
700
+ assistantContent: text || null,
701
+ assistantToolCalls,
702
+ };
703
+ }
704
+ }