agent-sh 0.4.0 → 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.
- package/README.md +66 -113
- package/dist/agent/agent-loop.d.ts +85 -0
- package/dist/agent/agent-loop.js +611 -0
- package/dist/agent/conversation-state.d.ts +27 -0
- package/dist/agent/conversation-state.js +59 -0
- package/dist/agent/index.d.ts +11 -0
- package/dist/agent/index.js +9 -0
- package/dist/agent/skills.d.ts +25 -0
- package/dist/agent/skills.js +186 -0
- package/dist/agent/subagent.d.ts +37 -0
- package/dist/agent/subagent.js +117 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +98 -0
- package/dist/agent/tool-registry.d.ts +15 -0
- package/dist/agent/tool-registry.js +30 -0
- package/dist/agent/tools/bash.d.ts +7 -0
- package/dist/agent/tools/bash.js +62 -0
- package/dist/agent/tools/edit-file.d.ts +2 -0
- package/dist/agent/tools/edit-file.js +95 -0
- package/dist/agent/tools/glob.d.ts +2 -0
- package/dist/agent/tools/glob.js +55 -0
- package/dist/agent/tools/grep.d.ts +2 -0
- package/dist/agent/tools/grep.js +77 -0
- package/dist/agent/tools/list-skills.d.ts +2 -0
- package/dist/agent/tools/list-skills.js +28 -0
- package/dist/agent/tools/ls.d.ts +2 -0
- package/dist/agent/tools/ls.js +43 -0
- package/dist/agent/tools/read-file.d.ts +2 -0
- package/dist/agent/tools/read-file.js +55 -0
- package/dist/agent/tools/user-shell.d.ts +13 -0
- package/dist/agent/tools/user-shell.js +57 -0
- package/dist/agent/tools/write-file.d.ts +2 -0
- package/dist/agent/tools/write-file.js +74 -0
- package/dist/agent/types.d.ts +44 -0
- package/dist/agent/types.js +1 -0
- package/dist/core.d.ts +24 -14
- package/dist/core.js +260 -36
- package/dist/event-bus.d.ts +80 -14
- package/dist/event-bus.js +10 -1
- package/dist/extension-loader.js +12 -1
- package/dist/extensions/command-suggest.d.ts +10 -0
- package/dist/extensions/command-suggest.js +41 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +161 -64
- package/dist/extensions/tui-renderer.js +90 -48
- package/dist/index.js +98 -122
- package/dist/input-handler.js +74 -7
- package/dist/output-parser.d.ts +7 -0
- package/dist/output-parser.js +27 -0
- package/dist/settings.d.ts +53 -2
- package/dist/settings.js +45 -2
- package/dist/shell.js +33 -26
- package/dist/types.d.ts +33 -6
- package/dist/utils/box-frame.d.ts +3 -1
- package/dist/utils/box-frame.js +12 -5
- package/dist/utils/llm-client.d.ts +45 -0
- package/dist/utils/llm-client.js +60 -0
- package/dist/utils/markdown.js +2 -2
- package/dist/utils/stream-transform.js +20 -47
- package/dist/utils/tool-display.js +15 -5
- package/examples/extensions/claude-code-bridge/README.md +35 -0
- package/examples/extensions/claude-code-bridge/index.ts +198 -0
- package/examples/extensions/claude-code-bridge/package.json +11 -0
- package/examples/extensions/openrouter.ts +87 -0
- package/examples/extensions/pi-bridge/README.md +35 -0
- package/examples/extensions/pi-bridge/index.ts +265 -0
- package/examples/extensions/pi-bridge/package.json +13 -0
- package/examples/extensions/subagents.ts +87 -0
- package/package.json +3 -5
- package/dist/acp-client.d.ts +0 -105
- package/dist/acp-client.js +0 -684
- package/dist/extensions/shell-exec.d.ts +0 -24
- package/dist/extensions/shell-exec.js +0 -188
- package/dist/mcp-server.d.ts +0 -13
- package/dist/mcp-server.js +0 -234
- 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
|
+
}
|