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