agent-sh 0.15.0 → 0.15.2
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/dist/agent/agent-loop.js +11 -8
- package/dist/agent/events.d.ts +4 -0
- package/docs/README.md +14 -0
- package/docs/agent.md +398 -0
- package/docs/architecture.md +196 -0
- package/docs/context-management.md +200 -0
- package/docs/extensions.md +951 -0
- package/docs/library.md +84 -0
- package/docs/troubleshooting.md +65 -0
- package/docs/tui-composition.md +294 -0
- package/docs/usage.md +306 -0
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +2 -2
- package/examples/extensions/ashi/README.md +2 -2
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
- package/examples/extensions/ashi/package.json +5 -3
- package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
- package/examples/extensions/ashi/src/cli.ts +9 -8
- package/examples/extensions/ashi/src/dialogs.ts +16 -1
- package/examples/extensions/ashi/src/events.ts +1 -0
- package/examples/extensions/ashi/src/frontend.ts +26 -6
- package/examples/extensions/ashi/src/renderer.ts +24 -4
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
- package/examples/extensions/ashi/src/ui.ts +11 -0
- package/examples/extensions/ashi-ink/package.json +2 -2
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/package.json +3 -1
- package/src/agent/agent-loop.ts +1566 -0
- package/src/agent/entry-format.ts +19 -0
- package/src/agent/events.ts +153 -0
- package/src/agent/extensions/rolling-history/constants.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +202 -0
- package/src/agent/extensions/rolling-history/recall.ts +131 -0
- package/src/agent/extensions/rolling-history/strategy.ts +404 -0
- package/src/agent/host-types.ts +192 -0
- package/src/agent/index.ts +591 -0
- package/src/agent/live-view.ts +279 -0
- package/src/agent/llm-client.ts +111 -0
- package/src/agent/llm-facade.ts +43 -0
- package/src/agent/normalize-args.ts +61 -0
- package/src/agent/nuclear-form.ts +382 -0
- package/src/agent/providers/deepseek.ts +39 -0
- package/src/agent/providers/ollama.ts +92 -0
- package/src/agent/providers/openai-compatible.ts +36 -0
- package/src/agent/providers/openai.ts +52 -0
- package/src/agent/providers/opencode.ts +142 -0
- package/src/agent/providers/openrouter.ts +105 -0
- package/src/agent/providers/zai-coding-plan.ts +33 -0
- package/src/agent/session-store.ts +336 -0
- package/src/agent/skills.ts +228 -0
- package/src/agent/store.ts +310 -0
- package/src/agent/subagent.ts +305 -0
- package/src/agent/system-prompt.ts +151 -0
- package/src/agent/token-budget.ts +12 -0
- package/src/agent/tool-protocol.ts +722 -0
- package/src/agent/tool-registry.ts +66 -0
- package/src/agent/tools/bash.ts +95 -0
- package/src/agent/tools/edit-file.ts +154 -0
- package/src/agent/tools/expand-home.ts +7 -0
- package/src/agent/tools/glob.ts +108 -0
- package/src/agent/tools/grep.ts +228 -0
- package/src/agent/tools/list-skills.ts +37 -0
- package/src/agent/tools/ls.ts +81 -0
- package/src/agent/tools/pwsh.ts +140 -0
- package/src/agent/tools/read-file.ts +164 -0
- package/src/agent/tools/write-file.ts +72 -0
- package/src/agent/types.ts +149 -0
- package/src/cli/args.ts +91 -0
- package/src/cli/auth/cli.ts +244 -0
- package/src/cli/auth/discover.ts +52 -0
- package/src/cli/auth/keys.ts +143 -0
- package/src/cli/index.ts +295 -0
- package/src/cli/init.ts +74 -0
- package/src/cli/install.ts +439 -0
- package/src/cli/shell-env.ts +68 -0
- package/src/cli/subcommands.ts +24 -0
- package/src/core/event-bus.ts +252 -0
- package/src/core/extension-loader.ts +347 -0
- package/src/core/index.ts +152 -0
- package/src/core/settings.ts +398 -0
- package/src/core/types.ts +61 -0
- package/src/extensions/file-autocomplete.ts +71 -0
- package/src/extensions/index.ts +38 -0
- package/src/extensions/slash-commands/events.ts +14 -0
- package/src/extensions/slash-commands/index.ts +269 -0
- package/src/shell/events.ts +73 -0
- package/src/shell/host-types.ts +150 -0
- package/src/shell/index.ts +159 -0
- package/src/shell/input-handler.ts +505 -0
- package/src/shell/output-parser.ts +156 -0
- package/src/shell/shell-context.ts +193 -0
- package/src/shell/shell.ts +414 -0
- package/src/shell/strategies/bash.ts +83 -0
- package/src/shell/strategies/fish.ts +77 -0
- package/src/shell/strategies/index.ts +24 -0
- package/src/shell/strategies/types.ts +64 -0
- package/src/shell/strategies/zsh.ts +92 -0
- package/src/shell/terminal.ts +124 -0
- package/src/shell/tui-input-view.ts +222 -0
- package/src/shell/tui-renderer.ts +1126 -0
- package/src/utils/ansi.ts +140 -0
- package/src/utils/box-frame.ts +138 -0
- package/src/utils/compositor.ts +157 -0
- package/src/utils/diff-renderer.ts +829 -0
- package/src/utils/diff.ts +244 -0
- package/src/utils/executor.ts +305 -0
- package/src/utils/file-watcher.ts +110 -0
- package/src/utils/floating-panel.ts +1160 -0
- package/src/utils/handler-registry.ts +110 -0
- package/src/utils/line-editor.ts +636 -0
- package/src/utils/markdown.ts +437 -0
- package/src/utils/message-utils.ts +113 -0
- package/src/utils/package-version.ts +12 -0
- package/src/utils/palette.ts +64 -0
- package/src/utils/ref-counter.ts +9 -0
- package/src/utils/ripgrep-path.ts +17 -0
- package/src/utils/shell-output-spill.ts +76 -0
- package/src/utils/stream-transform.ts +292 -0
- package/src/utils/terminal-buffer.ts +213 -0
- package/src/utils/tool-display.ts +315 -0
- package/src/utils/tool-interactive.ts +71 -0
- package/src/utils/tty.ts +14 -0
|
@@ -0,0 +1,1566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal agent backend — bus-driven and self-wiring. wire() subscribes to
|
|
3
|
+
* agent:submit (run the LLM tool loop) and agent:cancel-request (abort it),
|
|
4
|
+
* and the loop emits the agent:* progress/response/tool event stream.
|
|
5
|
+
*/
|
|
6
|
+
import type { EventBus, BusEvents } from "../core/event-bus.js";
|
|
7
|
+
import type { Model, ModelEndpoint } from "./host-types.js";
|
|
8
|
+
import type { LlmClient } from "./llm-client.js";
|
|
9
|
+
import type { HandlerFunctions } from "../utils/handler-registry.js";
|
|
10
|
+
import { setMaxListeners } from "node:events";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { contentText, type AgentBackend, type ImageContent, type SkillView, type ToolDefinition, type ToolExecutionContext } from "./types.js";
|
|
13
|
+
import { ToolRegistry } from "./tool-registry.js";
|
|
14
|
+
import { normalizeToolArgs } from "./normalize-args.js";
|
|
15
|
+
import { LiveView, type CompactResult } from "./live-view.js";
|
|
16
|
+
import { STATIC_IDENTITY, STATIC_GUIDE, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
|
|
17
|
+
import type { Compositor } from "../utils/compositor.js";
|
|
18
|
+
import { createToolUI } from "../utils/tool-interactive.js";
|
|
19
|
+
import { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW } from "./token-budget.js";
|
|
20
|
+
import { PACKAGE_VERSION } from "../utils/package-version.js";
|
|
21
|
+
import { wrapTrailingWithDynamicContext } from "../utils/message-utils.js";
|
|
22
|
+
import { getSettings, updateSettings } from "../core/settings.js";
|
|
23
|
+
import { createToolProtocol, type ToolProtocol, type PendingToolCall as ProtocolPendingToolCall, type ToolResult as ProtocolToolResult } from "./tool-protocol.js";
|
|
24
|
+
|
|
25
|
+
import { discoverGlobalSkills, discoverProjectSkills } from "./skills.js";
|
|
26
|
+
import type { FileReadCache } from "./tools/read-file.js";
|
|
27
|
+
|
|
28
|
+
type PendingToolCall = ProtocolPendingToolCall;
|
|
29
|
+
|
|
30
|
+
/** Reject on abort; orphaned `p` keeps running but its result is dropped. */
|
|
31
|
+
function raceAbort<T>(p: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
32
|
+
if (signal.aborted) return Promise.reject(new Error("cancelled"));
|
|
33
|
+
return new Promise<T>((resolve, reject) => {
|
|
34
|
+
const onAbort = () => reject(new Error("cancelled"));
|
|
35
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
36
|
+
p.then(
|
|
37
|
+
(v) => { signal.removeEventListener("abort", onAbort); resolve(v); },
|
|
38
|
+
(e) => { signal.removeEventListener("abort", onAbort); reject(e); },
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One-line summary of a tool description for the always-visible extension
|
|
45
|
+
* catalog in the system prompt. The full description still reaches the LLM
|
|
46
|
+
* via the API `tools` param (or load_tool in deferred-lookup mode).
|
|
47
|
+
*/
|
|
48
|
+
function summarizeDescription(desc: string): string {
|
|
49
|
+
const firstLine = desc.split("\n", 1)[0]!;
|
|
50
|
+
const sentenceEnd = firstLine.search(/[.!?](\s|$)/);
|
|
51
|
+
const candidate = sentenceEnd > 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine;
|
|
52
|
+
return candidate.length > 140 ? candidate.slice(0, 137) + "..." : candidate;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AgentLoopConfig {
|
|
56
|
+
bus: EventBus;
|
|
57
|
+
llmClient: LlmClient;
|
|
58
|
+
handlers: HandlerFunctions;
|
|
59
|
+
initialModel?: Model;
|
|
60
|
+
compositor?: Compositor;
|
|
61
|
+
/** Instance ID from core — ensures history entries match the ID in prompts. */
|
|
62
|
+
instanceId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class AgentLoop implements AgentBackend {
|
|
66
|
+
private abortController: AbortController | null = null;
|
|
67
|
+
private toolRegistry: ToolRegistry;
|
|
68
|
+
private conversation: LiveView;
|
|
69
|
+
private fileReadCache: FileReadCache;
|
|
70
|
+
private activeModel: Model;
|
|
71
|
+
private activeEndpoint: ModelEndpoint | undefined;
|
|
72
|
+
private boundListeners: Array<{ event: string; fn: (...args: any[]) => void }> = [];
|
|
73
|
+
private boundPipeListeners: Array<{ event: string; fn: (...args: any[]) => any; async: boolean }> = [];
|
|
74
|
+
private lastProjectSkillNames = new Set<string>();
|
|
75
|
+
|
|
76
|
+
// ── Session telemetry: per-session behavioral counters ──
|
|
77
|
+
// Exposed to extensions via the agent:get-* handlers below.
|
|
78
|
+
private sessionStartTime = Date.now();
|
|
79
|
+
private toolCallCounts = new Map<string, { success: number; error: number }>();
|
|
80
|
+
private totalToolCalls = 0;
|
|
81
|
+
private totalToolErrors = 0;
|
|
82
|
+
private totalResolutions = 0;
|
|
83
|
+
private compactionCount = 0;
|
|
84
|
+
private cumulativeCompactedTokens = 0;
|
|
85
|
+
private peakConversationTokens = 0;
|
|
86
|
+
private queryCount = 0;
|
|
87
|
+
private totalLoopIterations = 0;
|
|
88
|
+
|
|
89
|
+
// Resolution pattern tracking: "error X later resolved by action Y".
|
|
90
|
+
// Populated/consumed in executeLoop; surfaced via agent:get-counters.
|
|
91
|
+
private lastErrorByTool = new Map<string, string>(); // tool → error summary
|
|
92
|
+
private lastErrorByFile = new Map<string, string>(); // file path → error summary
|
|
93
|
+
|
|
94
|
+
private static readonly THINKING_LEVELS = ["off", "low", "medium", "high", "xhigh"];
|
|
95
|
+
|
|
96
|
+
private bus: EventBus;
|
|
97
|
+
private llmClient: LlmClient;
|
|
98
|
+
private handlers: HandlerFunctions;
|
|
99
|
+
private thinkingLevel: string = getSettings().thinkingLevel ?? "off";
|
|
100
|
+
private compositor: Compositor | null = null;
|
|
101
|
+
private toolProtocol: ToolProtocol;
|
|
102
|
+
private instanceId: string;
|
|
103
|
+
|
|
104
|
+
constructor(config: AgentLoopConfig) {
|
|
105
|
+
this.bus = config.bus;
|
|
106
|
+
this.llmClient = config.llmClient;
|
|
107
|
+
this.handlers = config.handlers;
|
|
108
|
+
this.compositor = config.compositor ?? null;
|
|
109
|
+
this.instanceId = config.instanceId ?? "unknown";
|
|
110
|
+
this.toolRegistry = new ToolRegistry(this.handlers);
|
|
111
|
+
this.fileReadCache = this.handlers.call("agent:file-read-cache") as FileReadCache;
|
|
112
|
+
|
|
113
|
+
this.conversation = new LiveView(this.handlers, this.instanceId);
|
|
114
|
+
|
|
115
|
+
this.activeModel = config.initialModel ?? { id: config.llmClient.model, provider: "custom" };
|
|
116
|
+
this.activeEndpoint = this.resolveEndpoint(this.activeModel);
|
|
117
|
+
|
|
118
|
+
// Tool protocol — controls how tools are presented to the LLM
|
|
119
|
+
const { names: fromExtensions } = this.bus.emitPipe("agent:core-tools:collect", { names: [] });
|
|
120
|
+
const coreTools = Array.from(new Set([...(getSettings().coreTools ?? []), ...fromExtensions]));
|
|
121
|
+
this.toolProtocol = createToolProtocol(
|
|
122
|
+
getSettings().toolMode ?? "api",
|
|
123
|
+
coreTools,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Register any protocol-provided tools (e.g. load_tool for deferred-lookup).
|
|
127
|
+
const protocolTools = this.toolProtocol.getProtocolTools?.() ?? [];
|
|
128
|
+
for (const t of protocolTools) this.registerTool(t);
|
|
129
|
+
|
|
130
|
+
// Register handlers — extensions can advise these
|
|
131
|
+
this.registerHandlers();
|
|
132
|
+
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Subscribe to bus events — activates this backend. */
|
|
136
|
+
wire(): void {
|
|
137
|
+
const on = <K extends keyof BusEvents>(
|
|
138
|
+
event: K,
|
|
139
|
+
fn: (payload: BusEvents[K]) => void,
|
|
140
|
+
) => {
|
|
141
|
+
this.bus.on(event, fn);
|
|
142
|
+
this.boundListeners.push({ event, fn });
|
|
143
|
+
};
|
|
144
|
+
const onPipe = <K extends keyof BusEvents>(
|
|
145
|
+
event: K,
|
|
146
|
+
fn: (payload: BusEvents[K]) => BusEvents[K] | void,
|
|
147
|
+
) => {
|
|
148
|
+
this.bus.onPipe(event, fn as any);
|
|
149
|
+
this.boundPipeListeners.push({ event, fn, async: false });
|
|
150
|
+
};
|
|
151
|
+
const onPipeAsync = <K extends keyof BusEvents>(
|
|
152
|
+
event: K,
|
|
153
|
+
fn: (payload: BusEvents[K]) => Promise<BusEvents[K] | void>,
|
|
154
|
+
) => {
|
|
155
|
+
this.bus.onPipeAsync(event, fn as any);
|
|
156
|
+
this.boundPipeListeners.push({ event, fn, async: true });
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
onPipe("agent:tools", (acc) => {
|
|
160
|
+
// Read internal storage, NOT this.getTools() — that queries the
|
|
161
|
+
// pipe and would recurse.
|
|
162
|
+
for (const tool of this.toolRegistry.allView()) acc.tools.push(tool);
|
|
163
|
+
return acc;
|
|
164
|
+
});
|
|
165
|
+
onPipe("agent:instructions", (acc) => {
|
|
166
|
+
for (const [name] of this.instructions) {
|
|
167
|
+
const text = this.handlers.call(`instruction:${name}`) as string;
|
|
168
|
+
acc.instructions.push({ name, text });
|
|
169
|
+
}
|
|
170
|
+
return acc;
|
|
171
|
+
});
|
|
172
|
+
onPipe("agent:skills", (acc) => {
|
|
173
|
+
for (const [name] of this.skills) {
|
|
174
|
+
const view = this.handlers.call(`skill:${name}:view`) as SkillView;
|
|
175
|
+
acc.skills.push({ name, description: view.description, filePath: view.filePath });
|
|
176
|
+
}
|
|
177
|
+
return acc;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
on("agent:submit", ({ query, images }) => {
|
|
182
|
+
this.handleQuery(query, images).catch(() => {});
|
|
183
|
+
});
|
|
184
|
+
on("agent:cancel-request", (e) => {
|
|
185
|
+
this.abortController?.abort(e.silent ? "silent" : undefined);
|
|
186
|
+
});
|
|
187
|
+
on("agent:append-user-message", ({ text }) => {
|
|
188
|
+
this.conversation.appendUserMessage(text);
|
|
189
|
+
this.bus.emit("conversation:message-appended", { role: "user", content: text });
|
|
190
|
+
});
|
|
191
|
+
on("config:switch-model", ({ id, provider }) => {
|
|
192
|
+
const found = this.pullModels().find((m) => m.id === id && m.provider === provider);
|
|
193
|
+
if (!found) {
|
|
194
|
+
this.bus.emit("ui:error", { message: `Unknown model: ${provider}:${id}` });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.activeModel = found;
|
|
198
|
+
this.activeEndpoint = this.resolveEndpoint(found);
|
|
199
|
+
if (this.activeEndpoint) {
|
|
200
|
+
this.llmClient.reconfigure({ apiKey: this.activeEndpoint.apiKey, baseURL: this.activeEndpoint.baseURL, model: found.id });
|
|
201
|
+
} else {
|
|
202
|
+
this.llmClient.model = found.id;
|
|
203
|
+
}
|
|
204
|
+
this.emitIdentity();
|
|
205
|
+
|
|
206
|
+
// Persist as the new default — selection survives restart. Safe even for
|
|
207
|
+
// dynamic providers: agent-backend defers model resolution to
|
|
208
|
+
// core:extensions-loaded, so the extension re-registers before the
|
|
209
|
+
// persisted default is looked up.
|
|
210
|
+
updateSettings({
|
|
211
|
+
defaultProvider: found.provider,
|
|
212
|
+
providers: { [found.provider]: { defaultModel: found.id } },
|
|
213
|
+
});
|
|
214
|
+
this.bus.emit("ui:info", { message: `Model: ${found.provider}: ${found.id} (saved as default)` });
|
|
215
|
+
this.bus.emit("config:changed", {});
|
|
216
|
+
});
|
|
217
|
+
on("agent:models-changed", () => {
|
|
218
|
+
const models = this.pullModels();
|
|
219
|
+
const prev = this.activeModel;
|
|
220
|
+
const fresh = models.find((m) => m.id === prev.id && m.provider === prev.provider);
|
|
221
|
+
let identityChanged = false;
|
|
222
|
+
if (fresh) {
|
|
223
|
+
this.activeModel = fresh;
|
|
224
|
+
const ep = this.resolveEndpoint(fresh);
|
|
225
|
+
if (ep && (ep.apiKey !== this.activeEndpoint?.apiKey || ep.baseURL !== this.activeEndpoint?.baseURL)) {
|
|
226
|
+
this.llmClient.reconfigure({ apiKey: ep.apiKey, baseURL: ep.baseURL, model: fresh.id });
|
|
227
|
+
}
|
|
228
|
+
this.activeEndpoint = ep;
|
|
229
|
+
identityChanged = fresh.contextWindow !== prev.contextWindow;
|
|
230
|
+
} else {
|
|
231
|
+
// Ghost: keep prev active so mid-turn stream() doesn't switch models.
|
|
232
|
+
this.bus.emit("ui:info", {
|
|
233
|
+
message: `${prev.provider}:${prev.id} is not in the refreshed catalog — keeping it active until you /model to another.`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (identityChanged) this.emitIdentity();
|
|
237
|
+
this.bus.emit("config:changed", {});
|
|
238
|
+
});
|
|
239
|
+
onPipe("config:get-models", () => {
|
|
240
|
+
const models = this.pullModels();
|
|
241
|
+
const list = [...models];
|
|
242
|
+
// Surface a ghost active model so /model still shows it.
|
|
243
|
+
if (!models.some((m) => m.id === this.activeModel.id && m.provider === this.activeModel.provider)) {
|
|
244
|
+
list.push(this.activeModel);
|
|
245
|
+
}
|
|
246
|
+
return { models: list, active: this.activeModel };
|
|
247
|
+
});
|
|
248
|
+
on("config:set-thinking", ({ level }) => {
|
|
249
|
+
if (!AgentLoop.THINKING_LEVELS.includes(level)) {
|
|
250
|
+
this.bus.emit("ui:error", { message: `Unknown thinking level: ${level}. Use: ${AgentLoop.THINKING_LEVELS.join(", ")}` });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const mode = this.activeModel;
|
|
254
|
+
if (level !== "off" && mode.reasoning === false) {
|
|
255
|
+
this.bus.emit("ui:error", { message: `Model ${mode.id} does not support thinking.` });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (level !== "off" && mode.supportsReasoningEffort === false) {
|
|
259
|
+
this.bus.emit("ui:error", { message: `Provider ${mode.provider} does not support reasoning_effort.` });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this.thinkingLevel = level;
|
|
263
|
+
updateSettings({ thinkingLevel: level });
|
|
264
|
+
this.bus.emit("config:changed", {});
|
|
265
|
+
});
|
|
266
|
+
onPipe("config:get-thinking", () => {
|
|
267
|
+
const mode = this.activeModel;
|
|
268
|
+
const supported = mode.reasoning !== false && mode.supportsReasoningEffort !== false;
|
|
269
|
+
return { level: this.thinkingLevel, levels: AgentLoop.THINKING_LEVELS, supported };
|
|
270
|
+
});
|
|
271
|
+
on("agent:reset-session", () => {
|
|
272
|
+
this.cancel();
|
|
273
|
+
this.conversation = new LiveView(this.handlers, this.instanceId);
|
|
274
|
+
this.lastProjectSkillNames.clear();
|
|
275
|
+
});
|
|
276
|
+
on("agent:compact-request", async () => {
|
|
277
|
+
// Force compaction. Strategy lives behind `conversation:compact`.
|
|
278
|
+
const stats = await this.compactWithHooks(0, 0, true);
|
|
279
|
+
if (stats) {
|
|
280
|
+
this.bus.emit("ui:info", {
|
|
281
|
+
message: `(compacted: ~${stats.before.toLocaleString()} → ~${stats.after.toLocaleString()} tokens)`,
|
|
282
|
+
});
|
|
283
|
+
} else {
|
|
284
|
+
this.bus.emit("ui:info", { message: "(nothing to compact)" });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
onPipe("context:get-stats", () => ({
|
|
288
|
+
activeTokens: this.conversation.estimateTokens(),
|
|
289
|
+
totalTokens: this.conversation.estimatePromptTokens(),
|
|
290
|
+
budgetTokens: this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
onPipe("context:snapshot", (payload) => {
|
|
294
|
+
payload.messages = this.conversation.get();
|
|
295
|
+
payload.contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
296
|
+
payload.activeTokens = this.conversation.estimateTokens();
|
|
297
|
+
return payload;
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
onPipeAsync("context:compact", async (payload) => {
|
|
301
|
+
const stats = await this.compactWithHooks(0, undefined, false, payload.strategy);
|
|
302
|
+
if (stats) payload.stats = { before: stats.before, after: stats.after, evictedCount: stats.evictedCount };
|
|
303
|
+
return payload;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Accumulate counters regardless of which compaction strategy ran.
|
|
307
|
+
on("conversation:after-compact", ({ beforeTokens, afterTokens }) => {
|
|
308
|
+
this.compactionCount++;
|
|
309
|
+
this.cumulativeCompactedTokens += Math.max(0, beforeTokens - afterTokens);
|
|
310
|
+
if (beforeTokens > this.peakConversationTokens) {
|
|
311
|
+
this.peakConversationTokens = beforeTokens;
|
|
312
|
+
}
|
|
313
|
+
// The "File unchanged" stub assumes the prior read output is still
|
|
314
|
+
// in context; compaction can evict it. Clear so the next read re-emits.
|
|
315
|
+
this.fileReadCache.clear();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
on("shell:cwd-change", ({ cwd }) => {
|
|
319
|
+
const projectSkills = discoverProjectSkills(cwd);
|
|
320
|
+
const newNames = new Set(projectSkills.map(s => s.name));
|
|
321
|
+
|
|
322
|
+
if (newNames.size === this.lastProjectSkillNames.size &&
|
|
323
|
+
[...newNames].every(n => this.lastProjectSkillNames.has(n))) {
|
|
324
|
+
return; // no change
|
|
325
|
+
}
|
|
326
|
+
this.lastProjectSkillNames = newNames;
|
|
327
|
+
|
|
328
|
+
if (projectSkills.length > 0) {
|
|
329
|
+
const names = projectSkills.map(s => s.name).join(", ");
|
|
330
|
+
const note = `[Project skills available: ${names}. Use list_skills for details, read_file to load.]`;
|
|
331
|
+
this.conversation.addSystemNote(note);
|
|
332
|
+
this.bus.emit("conversation:message-appended", { role: "system", content: note });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
this.emitIdentity();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Unsubscribe from bus events — deactivates this backend. */
|
|
339
|
+
unwire(): void {
|
|
340
|
+
for (const { event, fn } of this.boundListeners) {
|
|
341
|
+
this.bus.off(event as any, fn);
|
|
342
|
+
}
|
|
343
|
+
this.boundListeners = [];
|
|
344
|
+
for (const { event, fn, async } of this.boundPipeListeners) {
|
|
345
|
+
if (async) this.bus.offPipeAsync(event as any, fn);
|
|
346
|
+
else this.bus.offPipe(event as any, fn);
|
|
347
|
+
}
|
|
348
|
+
this.boundPipeListeners = [];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Register a tool (used by extensions via ctx.agent.registerTool). */
|
|
352
|
+
registerTool(tool: ToolDefinition): void {
|
|
353
|
+
this.toolRegistry.register(tool);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Unregister a tool by name. */
|
|
357
|
+
unregisterTool(name: string): void {
|
|
358
|
+
this.toolRegistry.unregister(name);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Get all registered tools (union of builtins + extension contributions). */
|
|
362
|
+
getTools(): ToolDefinition[] {
|
|
363
|
+
return this.bus.emitPipe("agent:tools", { tools: [] }).tools;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Find a tool by name across the full pipe union. */
|
|
367
|
+
private findTool(name: string): ToolDefinition | undefined {
|
|
368
|
+
return this.getTools().find((t) => t.name === name);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Extension instructions, skills & tool tracking ──────────────────
|
|
372
|
+
|
|
373
|
+
/** Instructions keyed by name, with extension attribution. */
|
|
374
|
+
private instructions = new Map<string, { text: string; extensionName: string }>();
|
|
375
|
+
|
|
376
|
+
/** Skills keyed by name, with extension attribution. */
|
|
377
|
+
private skills = new Map<string, { description: string; filePath: string; extensionName: string }>();
|
|
378
|
+
|
|
379
|
+
/** Tool → extension name attribution. */
|
|
380
|
+
private toolExtensions = new Map<string, string>();
|
|
381
|
+
|
|
382
|
+
/** Register a named instruction block for the system prompt. */
|
|
383
|
+
registerInstruction(name: string, text: string, extensionName: string): void {
|
|
384
|
+
this.instructions.set(name, { text, extensionName });
|
|
385
|
+
this.handlers.define(`instruction:${name}`, () => this.instructions.get(name)?.text ?? "");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
removeInstruction(name: string): void {
|
|
389
|
+
this.instructions.delete(name);
|
|
390
|
+
// Handler entry retained so external advisors survive a reload of the owner.
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Register a named skill (on-demand reference material). */
|
|
394
|
+
registerSkill(name: string, description: string, filePath: string, extensionName: string): void {
|
|
395
|
+
this.skills.set(name, { description, filePath, extensionName });
|
|
396
|
+
this.handlers.define(`skill:${name}:view`, (): SkillView => {
|
|
397
|
+
const s = this.skills.get(name);
|
|
398
|
+
return { description: s?.description ?? "", filePath: s?.filePath ?? "" };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
removeSkill(name: string): void {
|
|
403
|
+
this.skills.delete(name);
|
|
404
|
+
// Handler entry retained so external advisors survive a reload of the owner.
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Build the "Extensions" section of the system prompt. Includes tools,
|
|
409
|
+
* skills, and instructions contributed by extensions (i.e. anything
|
|
410
|
+
* registered via ctx.agent.registerTool/Skill/Instruction). AgentLoop's
|
|
411
|
+
* own builtins are excluded by name — they're documented elsewhere in
|
|
412
|
+
* the prompt or in the tool API params.
|
|
413
|
+
*/
|
|
414
|
+
buildExtensionSections(): string[] {
|
|
415
|
+
const BUILTIN_TOOLS = new Set([
|
|
416
|
+
"bash", "read_file", "write_file", "edit_file", "grep", "glob", "ls",
|
|
417
|
+
"list_skills",
|
|
418
|
+
]);
|
|
419
|
+
const BUILTIN_INSTRUCTIONS = new Set(["recall-guidance"]);
|
|
420
|
+
const BUILTIN_SKILLS = new Set<string>();
|
|
421
|
+
|
|
422
|
+
const allTools = this.bus.emitPipe("agent:tools", { tools: [] }).tools;
|
|
423
|
+
const allInstructions = this.bus.emitPipe("agent:instructions", { instructions: [] }).instructions;
|
|
424
|
+
const allSkills = this.bus.emitPipe("agent:skills", { skills: [] }).skills;
|
|
425
|
+
|
|
426
|
+
const extTools = this.toolProtocol.mode === "api"
|
|
427
|
+
? []
|
|
428
|
+
: allTools.filter((t) => !BUILTIN_TOOLS.has(t.name));
|
|
429
|
+
const extInstructions = allInstructions.filter((i) => !BUILTIN_INSTRUCTIONS.has(i.name));
|
|
430
|
+
const extSkills = allSkills.filter((s) => !BUILTIN_SKILLS.has(s.name));
|
|
431
|
+
|
|
432
|
+
if (extTools.length + extInstructions.length + extSkills.length === 0) return [];
|
|
433
|
+
|
|
434
|
+
const parts: string[] = [];
|
|
435
|
+
if (extTools.length > 0)
|
|
436
|
+
parts.push("### Tools\n" + extTools.map(t => `${t.name} — ${summarizeDescription(t.description)}`).join("\n"));
|
|
437
|
+
if (extSkills.length > 0)
|
|
438
|
+
parts.push("### Skills\n" + extSkills.map(s => `${s.name}: ${s.description}\n → ${s.filePath}`).join("\n\n"));
|
|
439
|
+
if (extInstructions.length > 0)
|
|
440
|
+
parts.push("### Instructions\n" + extInstructions.map(i => i.text).join("\n\n"));
|
|
441
|
+
return [`## Extensions\n${parts.join("\n\n")}`];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
kill(): void {
|
|
445
|
+
this.cancel();
|
|
446
|
+
this.unwire();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private cancel(): void {
|
|
450
|
+
this.abortController?.abort();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private reasoningParams(): Record<string, unknown> {
|
|
454
|
+
const model = this.activeModel;
|
|
455
|
+
if (model.reasoning === false) return {};
|
|
456
|
+
if (model.supportsReasoningEffort === false) return {};
|
|
457
|
+
const build = this.activeEndpoint?.buildReasoningParams;
|
|
458
|
+
if (build) return build(this.thinkingLevel);
|
|
459
|
+
if (this.thinkingLevel === "off") return {};
|
|
460
|
+
const effort = this.thinkingLevel === "xhigh" ? "high" : this.thinkingLevel;
|
|
461
|
+
return { reasoning_effort: effort };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
private resolveEndpoint(m: Model): ModelEndpoint | undefined {
|
|
466
|
+
try {
|
|
467
|
+
return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id }) as ModelEndpoint | undefined;
|
|
468
|
+
} catch {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private pullModels(): Model[] {
|
|
474
|
+
try {
|
|
475
|
+
return (this.handlers.call("agent:get-models") as Model[]) ?? [];
|
|
476
|
+
} catch {
|
|
477
|
+
return [];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private emitIdentity(): void {
|
|
482
|
+
const m = this.activeModel;
|
|
483
|
+
this.bus.emit("agent:info", {
|
|
484
|
+
name: "ash",
|
|
485
|
+
version: PACKAGE_VERSION,
|
|
486
|
+
model: m.id,
|
|
487
|
+
provider: m.provider,
|
|
488
|
+
contextWindow: m.contextWindow,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Run compaction via the `conversation:compact` handler. After any
|
|
494
|
+
* compaction, emit `conversation:after-compact` so listeners
|
|
495
|
+
* (metrics, UI, agent-awareness notes) can react.
|
|
496
|
+
*/
|
|
497
|
+
private async compactWithHooks(
|
|
498
|
+
target: number,
|
|
499
|
+
keepRecent?: number,
|
|
500
|
+
force?: boolean,
|
|
501
|
+
strategy?: BusEvents["context:compact"]["strategy"],
|
|
502
|
+
): Promise<CompactResult | null> {
|
|
503
|
+
const stats = (await this.handlers.call("conversation:compact", {
|
|
504
|
+
target,
|
|
505
|
+
keepRecent,
|
|
506
|
+
force: !!force,
|
|
507
|
+
strategy,
|
|
508
|
+
})) as CompactResult | null;
|
|
509
|
+
if (stats) {
|
|
510
|
+
this.bus.emit("conversation:after-compact", {
|
|
511
|
+
beforeTokens: stats.before,
|
|
512
|
+
afterTokens: stats.after,
|
|
513
|
+
evictedCount: stats.evictedCount,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
return stats;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private isContextOverflow(e: unknown): boolean {
|
|
520
|
+
if (!(e instanceof Error)) return false;
|
|
521
|
+
// Match the specific error codes providers use, or unambiguous phrases.
|
|
522
|
+
// Bare "token"/"context" match too broadly (auth errors, model-name
|
|
523
|
+
// mismatches, etc.) and caused infinite-no-op retry loops.
|
|
524
|
+
const code = (e as any).code;
|
|
525
|
+
if (code === "context_length_exceeded" || code === "string_above_max_length") return true;
|
|
526
|
+
const msg = e.message.toLowerCase();
|
|
527
|
+
return (
|
|
528
|
+
msg.includes("context length") ||
|
|
529
|
+
msg.includes("context window") ||
|
|
530
|
+
msg.includes("maximum context") ||
|
|
531
|
+
msg.includes("prompt is too long") ||
|
|
532
|
+
msg.includes("input is too long") ||
|
|
533
|
+
msg.includes("too many tokens") ||
|
|
534
|
+
msg.includes("reduce the length")
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Check if an error is retryable (transient). */
|
|
539
|
+
private isRetryable(e: unknown): boolean {
|
|
540
|
+
if (!(e instanceof Error)) return false;
|
|
541
|
+
const msg = e.message.toLowerCase();
|
|
542
|
+
|
|
543
|
+
// Network errors
|
|
544
|
+
if (msg.includes("econnreset") || msg.includes("econnrefused") ||
|
|
545
|
+
msg.includes("etimedout") || msg.includes("fetch failed") ||
|
|
546
|
+
msg.includes("network") || msg.includes("socket hang up")) {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// HTTP status-based (OpenAI SDK includes status in error)
|
|
551
|
+
const status = (e as any).status;
|
|
552
|
+
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 529) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Extract retry delay from error headers or use exponential backoff. */
|
|
560
|
+
private getRetryDelay(e: unknown, attempt: number): number {
|
|
561
|
+
// Check for Retry-After header (OpenAI SDK exposes headers)
|
|
562
|
+
const headers = (e as any).headers;
|
|
563
|
+
if (headers) {
|
|
564
|
+
const retryAfter = headers["retry-after"] ?? headers.get?.("retry-after");
|
|
565
|
+
if (retryAfter) {
|
|
566
|
+
const seconds = parseInt(retryAfter, 10);
|
|
567
|
+
if (!isNaN(seconds)) return seconds * 1000;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, capped at 30s
|
|
572
|
+
return Math.min(1000 * Math.pow(2, attempt), 30_000);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Format an error with provider context for user-facing display. */
|
|
576
|
+
private formatError(e: unknown): string {
|
|
577
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
578
|
+
const status = (e as any).status;
|
|
579
|
+
const model = this.activeModel.id;
|
|
580
|
+
const baseURL = (this.llmClient as any).config?.baseURL;
|
|
581
|
+
const provider = this.activeModel.provider;
|
|
582
|
+
|
|
583
|
+
// Connection errors — most likely misconfigured provider
|
|
584
|
+
if (raw.includes("ECONNREFUSED") || raw.includes("ECONNRESET") ||
|
|
585
|
+
raw.includes("ETIMEDOUT") || raw.includes("fetch failed") ||
|
|
586
|
+
raw.includes("socket hang up")) {
|
|
587
|
+
const target = baseURL ?? provider ?? "provider";
|
|
588
|
+
return `Could not connect to ${target} (${raw}). Check that the API endpoint is reachable.`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Explicit signals only — bare "auth" hit "author" in echoed API params.
|
|
592
|
+
if (status === 401 || status === 403 ||
|
|
593
|
+
/\b(unauthorized|authentication|api[-_ ]?key|invalid[-_ ]?token)\b/i.test(raw)) {
|
|
594
|
+
return `Authentication failed for ${provider ?? "provider"} (model: ${model}). Check your API key.`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Model not found
|
|
598
|
+
if (status === 404) {
|
|
599
|
+
return `Model "${model}" not found at ${provider ?? baseURL ?? "provider"}. Check the model name.`;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Rate limit (after retries exhausted)
|
|
603
|
+
if (status === 429) {
|
|
604
|
+
return `Rate limited by ${provider ?? "provider"} (model: ${model}). Try again in a moment.`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Generic with context
|
|
608
|
+
const context = provider ? ` (${provider}, model: ${model})` : ` (model: ${model})`;
|
|
609
|
+
return `${raw}${context}`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Register named handlers that extensions can advise.
|
|
614
|
+
* Only high-power use cases where multiple extensions compose.
|
|
615
|
+
*/
|
|
616
|
+
private registerHandlers(): void {
|
|
617
|
+
const h = this.handlers;
|
|
618
|
+
|
|
619
|
+
// Advisable so extensions can inject fallback parsers without
|
|
620
|
+
// subclassing the protocol.
|
|
621
|
+
h.define("tool-protocol:extract-calls", (args: {
|
|
622
|
+
text: string;
|
|
623
|
+
streamedCalls: ProtocolPendingToolCall[];
|
|
624
|
+
}) => this.toolProtocol.extractToolCalls(args.text, args.streamedCalls));
|
|
625
|
+
|
|
626
|
+
// System prompt: static identity + behavioral instructions. Extensions can
|
|
627
|
+
// use registerInstruction() for a managed section, advise system-prompt:identity
|
|
628
|
+
// to replace the kernel identity, advise system-prompt:frontend to describe their
|
|
629
|
+
// surface high in the prompt, or advise system-prompt:build directly for full control.
|
|
630
|
+
h.define("system-prompt:identity", () => STATIC_IDENTITY);
|
|
631
|
+
h.define("system-prompt:build", () => {
|
|
632
|
+
// The active frontend's surface goes right after the identity; omitted if none.
|
|
633
|
+
const frontend = ((this.handlers.call("system-prompt:frontend") as string) ?? "").trim();
|
|
634
|
+
const parts: string[] = [this.handlers.call("system-prompt:identity") as string];
|
|
635
|
+
if (frontend) parts.push(frontend);
|
|
636
|
+
parts.push(STATIC_GUIDE);
|
|
637
|
+
|
|
638
|
+
// Global behavioral rules (~/.agent-sh/AGENTS.md) — persistent agent memory
|
|
639
|
+
const agentsMd = loadGlobalAgentsMd();
|
|
640
|
+
if (agentsMd) parts.push(agentsMd);
|
|
641
|
+
|
|
642
|
+
// Global skills — stable across cwd changes, cacheable with the system prompt
|
|
643
|
+
const globalSkills = discoverGlobalSkills();
|
|
644
|
+
const skillsBlock = formatSkillsBlock(globalSkills);
|
|
645
|
+
if (skillsBlock) parts.push(skillsBlock);
|
|
646
|
+
|
|
647
|
+
// Project conventions + project skills — stable within a cwd.
|
|
648
|
+
// Placed here so they enter the provider's prompt cache with the
|
|
649
|
+
// system prompt, and only re-materialize when cwd changes invalidate
|
|
650
|
+
// cachedSystemPrompt in executeLoop.
|
|
651
|
+
const projectStatic = buildStaticByCwd(this.handlers.call("cwd") as string);
|
|
652
|
+
if (projectStatic) parts.push(projectStatic);
|
|
653
|
+
|
|
654
|
+
const extensionSections = this.buildExtensionSections();
|
|
655
|
+
if (extensionSections.length > 0) {
|
|
656
|
+
parts.push("# Extension Instructions\n\n" + extensionSections.join("\n\n"));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (this.activeModel.modalities?.includes("image")) {
|
|
660
|
+
parts.push(
|
|
661
|
+
"# Image Support\n\n"
|
|
662
|
+
+ "This model supports image input. When you need visual information, "
|
|
663
|
+
+ "you can read image files (PNG, JPEG, GIF, WebP) with read_file — "
|
|
664
|
+
+ "they will be shown to you directly. Use this to inspect screenshots, "
|
|
665
|
+
+ "diagrams, UI mockups, charts, or any visual content relevant to the task.",
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return parts.join("\n\n");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// ── Orthogonal core-state accessors ──────────────────────────
|
|
673
|
+
// Each handler exposes one cohesive piece of core-owned runtime
|
|
674
|
+
// state. Extensions compose whichever they need — core doesn't
|
|
675
|
+
// decide the aggregation shape. Adding a new handler here should
|
|
676
|
+
// only happen for state the core genuinely owns (not state that
|
|
677
|
+
// an extension could track by listening to events).
|
|
678
|
+
|
|
679
|
+
h.define("agent:get-model", () => ({
|
|
680
|
+
model: this.activeModel.id,
|
|
681
|
+
provider: this.activeModel.provider,
|
|
682
|
+
thinkingLevel: this.thinkingLevel,
|
|
683
|
+
contextWindow: this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
684
|
+
}));
|
|
685
|
+
|
|
686
|
+
h.define("agent:get-tokens", () => {
|
|
687
|
+
const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
688
|
+
const promptTokens = this.conversation.estimatePromptTokens();
|
|
689
|
+
return {
|
|
690
|
+
active: this.conversation.estimateTokens(),
|
|
691
|
+
peak: this.peakConversationTokens,
|
|
692
|
+
cumulativeCompacted: this.cumulativeCompactedTokens,
|
|
693
|
+
promptTokens,
|
|
694
|
+
contextPercent: Math.round((promptTokens / contextWindow) * 100),
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
h.define("agent:get-counters", () => ({
|
|
699
|
+
queryCount: this.queryCount,
|
|
700
|
+
totalToolCalls: this.totalToolCalls,
|
|
701
|
+
totalToolErrors: this.totalToolErrors,
|
|
702
|
+
totalResolutions: this.totalResolutions,
|
|
703
|
+
totalLoopIterations: this.totalLoopIterations,
|
|
704
|
+
errorRate: this.totalToolCalls > 0
|
|
705
|
+
? Math.round((this.totalToolErrors / this.totalToolCalls) * 100)
|
|
706
|
+
: 0,
|
|
707
|
+
}));
|
|
708
|
+
|
|
709
|
+
h.define("agent:get-timing", () => ({
|
|
710
|
+
startedAt: this.sessionStartTime,
|
|
711
|
+
elapsedSeconds: Math.round((Date.now() - this.sessionStartTime) / 1000),
|
|
712
|
+
}));
|
|
713
|
+
|
|
714
|
+
h.define("agent:get-tool-stats", () =>
|
|
715
|
+
[...this.toolCallCounts.entries()]
|
|
716
|
+
.map(([name, counts]) => ({
|
|
717
|
+
name,
|
|
718
|
+
total: counts.success + counts.error,
|
|
719
|
+
success: counts.success,
|
|
720
|
+
error: counts.error,
|
|
721
|
+
}))
|
|
722
|
+
.sort((a, b) => b.total - a.total));
|
|
723
|
+
|
|
724
|
+
h.define("agent:get-file-read-cache", () =>
|
|
725
|
+
[...this.fileReadCache.entries()].map(([p, s]) => ({
|
|
726
|
+
path: p,
|
|
727
|
+
offset: s.offset,
|
|
728
|
+
limit: s.limit ?? null,
|
|
729
|
+
mtimeMs: s.mtimeMs,
|
|
730
|
+
})));
|
|
731
|
+
|
|
732
|
+
h.define("agent:get-recent-errors", () => ({
|
|
733
|
+
byTool: [...this.lastErrorByTool.entries()].map(([tool, error]) => ({ tool, error })),
|
|
734
|
+
byFile: [...this.lastErrorByFile.entries()].map(([file, error]) => ({ file, error })),
|
|
735
|
+
}));
|
|
736
|
+
|
|
737
|
+
h.define("agent:get-compaction-state", () => {
|
|
738
|
+
const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
739
|
+
const ratio = getSettings().autoCompactThreshold ?? 0.5;
|
|
740
|
+
return {
|
|
741
|
+
count: this.compactionCount,
|
|
742
|
+
autoCompactThreshold: ratio,
|
|
743
|
+
autoCompactThresholdTokens: Math.floor((contextWindow - RESPONSE_RESERVE) * ratio),
|
|
744
|
+
};
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
h.define("agent:get-self", () => this);
|
|
748
|
+
|
|
749
|
+
// dynamic-context:build / query-context:build are defined in the core
|
|
750
|
+
// kernel (src/core/index.ts). ash consumes them via the envelope wrapping
|
|
751
|
+
// in streamResponse + handleQuery; other backends may ignore.
|
|
752
|
+
|
|
753
|
+
// Full control over what the LLM sees: takes messages[], returns messages[].
|
|
754
|
+
// Default: pass through. Extensions can advise to compact, summarize,
|
|
755
|
+
// filter, reorder, inject — whatever strategy fits.
|
|
756
|
+
h.define("conversation:prepare", (messages: unknown[]) => messages);
|
|
757
|
+
|
|
758
|
+
// ── Conversation primitives for compaction strategies ─────────
|
|
759
|
+
// Canonical array (link/replace index space), not forLLM().
|
|
760
|
+
h.define("conversation:get-messages", () => this.conversation.get());
|
|
761
|
+
h.define("conversation:replace-messages", (msgs: unknown[]) => {
|
|
762
|
+
this.conversation.replace(msgs as ReturnType<typeof this.conversation.get>);
|
|
763
|
+
});
|
|
764
|
+
h.define("conversation:estimate-tokens", () => this.conversation.estimateTokens());
|
|
765
|
+
h.define("conversation:estimate-prompt-tokens", () => this.conversation.estimatePromptTokens());
|
|
766
|
+
h.define("conversation:link", (index: number, entryId: string) => this.conversation.link(index, entryId));
|
|
767
|
+
|
|
768
|
+
h.define("conversation:compact", (opts: {
|
|
769
|
+
target?: number;
|
|
770
|
+
keepRecent?: number;
|
|
771
|
+
force?: boolean;
|
|
772
|
+
strategy?:
|
|
773
|
+
| { kind: "two-tier-pin"; target: number; keepRecent?: number; force?: boolean }
|
|
774
|
+
| { kind: "rewind"; toIndex: number }
|
|
775
|
+
| { kind: "replace"; messages: unknown[] };
|
|
776
|
+
}) => {
|
|
777
|
+
const strategy = opts.strategy;
|
|
778
|
+
if (strategy?.kind === "rewind" || strategy?.kind === "replace") {
|
|
779
|
+
const before = this.conversation.estimatePromptTokens();
|
|
780
|
+
const beforeLen = this.conversation.get().length;
|
|
781
|
+
const next = strategy.kind === "rewind"
|
|
782
|
+
? this.conversation.get().slice(0, strategy.toIndex)
|
|
783
|
+
: (strategy.messages as ReturnType<LiveView["get"]>);
|
|
784
|
+
this.conversation.replace(next);
|
|
785
|
+
const after = this.conversation.estimatePromptTokens();
|
|
786
|
+
const afterLen = this.conversation.get().length;
|
|
787
|
+
return { before, after, evictedCount: Math.max(0, beforeLen - afterLen) } as CompactResult;
|
|
788
|
+
}
|
|
789
|
+
return null;
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Inject a system note mid-loop — used by extensions (subagents,
|
|
793
|
+
// peer messages) to deliver async results into the next iteration.
|
|
794
|
+
h.define("conversation:inject-note", (text: string) => {
|
|
795
|
+
this.conversation.addSystemNote(text);
|
|
796
|
+
this.bus.emit("conversation:message-appended", { role: "system", content: text });
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// Fires on user-abort; extensions advise per tool name for cleanup.
|
|
800
|
+
h.define("tool:cancel", (_ctx: {
|
|
801
|
+
name: string;
|
|
802
|
+
args: Record<string, unknown>;
|
|
803
|
+
reason: "user-aborted";
|
|
804
|
+
}) => {});
|
|
805
|
+
|
|
806
|
+
// Wraps each tool call: permission → execute → emit events.
|
|
807
|
+
h.define("tool:execute", async (ctx: {
|
|
808
|
+
name: string; id: string;
|
|
809
|
+
args: Record<string, unknown>;
|
|
810
|
+
tool: ToolDefinition;
|
|
811
|
+
onChunk?: (chunk: string) => void;
|
|
812
|
+
batchIndex?: number;
|
|
813
|
+
batchTotal?: number;
|
|
814
|
+
signal: AbortSignal;
|
|
815
|
+
}) => {
|
|
816
|
+
const { name, id, args, tool, signal } = ctx;
|
|
817
|
+
|
|
818
|
+
// Validate required input fields before display/permission/execute.
|
|
819
|
+
// Some models emit wrong arg names (e.g. `file_path` instead of `path`),
|
|
820
|
+
// and downstream helpers assume required strings are present.
|
|
821
|
+
const schema = tool.input_schema as { required?: unknown; properties?: Record<string, { type?: string }> } | undefined;
|
|
822
|
+
const required = Array.isArray(schema?.required) ? schema!.required as string[] : [];
|
|
823
|
+
const missing = required.filter((k) => args[k] === undefined || args[k] === null);
|
|
824
|
+
if (missing.length > 0) {
|
|
825
|
+
const msg = `Missing required argument(s): ${missing.join(", ")}. Expected: ${required.join(", ")}. Received: ${Object.keys(args).join(", ") || "(none)"}`;
|
|
826
|
+
this.bus.emit("agent:tool-call", { tool: name, args });
|
|
827
|
+
return { content: msg, exitCode: 1, isError: true };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const display = tool.getDisplayInfo?.(args) ?? { kind: "execute" as const };
|
|
831
|
+
|
|
832
|
+
const label = tool.displayName ?? name;
|
|
833
|
+
this.bus.emit("agent:tool-started", {
|
|
834
|
+
title: typeof args.description === "string" ? `${label}: ${args.description}` : label,
|
|
835
|
+
name,
|
|
836
|
+
toolCallId: id,
|
|
837
|
+
kind: display.kind, icon: display.icon, locations: display.locations, rawInput: args,
|
|
838
|
+
displayDetail: tool.formatCall?.(args),
|
|
839
|
+
sourceLanguage: display.sourceLanguage,
|
|
840
|
+
batchIndex: ctx.batchIndex, batchTotal: ctx.batchTotal,
|
|
841
|
+
});
|
|
842
|
+
this.bus.emit("agent:tool-call", { tool: name, args });
|
|
843
|
+
|
|
844
|
+
// Execute — use ctx.onChunk so advisors can wrap the streaming callback.
|
|
845
|
+
const onChunk = tool.showOutput !== false ? ctx.onChunk : undefined;
|
|
846
|
+
const toolCtx: ToolExecutionContext = { signal };
|
|
847
|
+
if (this.compositor) {
|
|
848
|
+
toolCtx.ui = createToolUI(this.bus, this.compositor.surface("agent"));
|
|
849
|
+
}
|
|
850
|
+
let result: Awaited<ReturnType<typeof tool.execute>>;
|
|
851
|
+
try {
|
|
852
|
+
result = await raceAbort(this.toolRegistry.call(name, args, onChunk, toolCtx), signal);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
if (signal.aborted) {
|
|
855
|
+
try { this.handlers.call("tool:cancel", { name, args, reason: "user-aborted" }); } catch {}
|
|
856
|
+
}
|
|
857
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
858
|
+
result = { content: message, exitCode: 1, isError: true };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (tool.modifiesFiles && typeof args.path === "string" && !result.isError) {
|
|
862
|
+
const absPath = path.resolve(process.cwd(), args.path);
|
|
863
|
+
this.fileReadCache.delete(absPath);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const resultDisplay = result.display ?? tool.formatResult?.(args, result);
|
|
867
|
+
|
|
868
|
+
// Emit completion events (via transform pipe so extensions can override)
|
|
869
|
+
this.bus.emitTransform("agent:tool-completed", {
|
|
870
|
+
toolCallId: id, exitCode: result.exitCode,
|
|
871
|
+
rawOutput: result.content, kind: display.kind,
|
|
872
|
+
resultDisplay,
|
|
873
|
+
});
|
|
874
|
+
this.bus.emit("agent:tool-output", {
|
|
875
|
+
tool: name, output: contentText(result.content), exitCode: result.exitCode,
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
return result;
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private async handleQuery(query: string, images?: ImageContent[]): Promise<void> {
|
|
883
|
+
if (this.abortController) {
|
|
884
|
+
this.abortController.abort();
|
|
885
|
+
}
|
|
886
|
+
this.abortController = new AbortController();
|
|
887
|
+
const signal = this.abortController.signal;
|
|
888
|
+
// Each loop iteration adds an abort listener (via OpenAI SDK stream);
|
|
889
|
+
// disable the limit — long-running tool loops can easily exceed any cap.
|
|
890
|
+
setMaxListeners(0, signal);
|
|
891
|
+
|
|
892
|
+
this.queryCount++;
|
|
893
|
+
this.bus.emit("agent:query", { query });
|
|
894
|
+
this.bus.emit("agent:processing-start", {});
|
|
895
|
+
let responseText = "";
|
|
896
|
+
|
|
897
|
+
try {
|
|
898
|
+
// Per-query producers (shell events + any extension-registered
|
|
899
|
+
// per-query signals) produce content that gets frozen into this
|
|
900
|
+
// user message inside <query_context>, distinguishing it from the
|
|
901
|
+
// per-request <dynamic_context> wrapped on the trailing message.
|
|
902
|
+
const queryContext = ((this.handlers.call("query-context:build") as string) ?? "").trim();
|
|
903
|
+
const userContent = queryContext
|
|
904
|
+
? `<query_context>\n${queryContext}\n</query_context>\n\n${query}`
|
|
905
|
+
: query;
|
|
906
|
+
|
|
907
|
+
// Fail closed: an image sent to a non-vision model errors and leaves an
|
|
908
|
+
// unsendable message poisoning history, so require declared image support.
|
|
909
|
+
let userImages = images?.length ? images : undefined;
|
|
910
|
+
if (userImages && !this.activeModel.modalities?.includes("image")) {
|
|
911
|
+
this.bus.emit("ui:info", { message: `Current model has no declared image support — ${userImages.length} image(s) dropped.` });
|
|
912
|
+
userImages = undefined;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
this.conversation.addUserMessage(userContent, userImages);
|
|
916
|
+
this.bus.emit("conversation:message-appended", { role: "user", content: query });
|
|
917
|
+
|
|
918
|
+
responseText = await this.executeLoop(signal);
|
|
919
|
+
} catch (e) {
|
|
920
|
+
if (!signal.aborted) {
|
|
921
|
+
if (e instanceof Error) console.error("[agent-sh] query failed:\n" + e.stack);
|
|
922
|
+
const msg = this.formatError(e);
|
|
923
|
+
this.bus.emit("agent:error", { message: msg });
|
|
924
|
+
}
|
|
925
|
+
} finally {
|
|
926
|
+
if (signal.aborted && signal.reason !== "silent") {
|
|
927
|
+
this.bus.emit("agent:cancelled", {});
|
|
928
|
+
}
|
|
929
|
+
// Ensure any buffered text in the stream transform pipeline gets
|
|
930
|
+
// flushed as a complete line before response-done closes the box.
|
|
931
|
+
if (responseText && !responseText.endsWith("\n")) {
|
|
932
|
+
this.bus.emitTransform("agent:response-chunk", {
|
|
933
|
+
blocks: [{ type: "text", text: "\n" }],
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
this.bus.emitTransform("agent:response-done", {
|
|
937
|
+
response: responseText,
|
|
938
|
+
});
|
|
939
|
+
this.bus.emit("agent:processing-done", {});
|
|
940
|
+
this.abortController = null;
|
|
941
|
+
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Core agent loop: stream LLM response → execute tools → repeat.
|
|
947
|
+
* Returns the final accumulated response text.
|
|
948
|
+
*/
|
|
949
|
+
private async executeLoop(signal: AbortSignal): Promise<string> {
|
|
950
|
+
let fullResponseText = "";
|
|
951
|
+
|
|
952
|
+
// System prompt carries things stable within a turn: static identity,
|
|
953
|
+
// global agent rules, project conventions, project skills. Invalidated
|
|
954
|
+
// only by compaction (context shape changed) or cwd change (project
|
|
955
|
+
// conventions/skills changed). Dynamic context rebuilds every iteration
|
|
956
|
+
// so live signals (budget, in-flight subagents, metacognitive warnings)
|
|
957
|
+
// are fresh.
|
|
958
|
+
let cachedSystemPrompt: string | undefined;
|
|
959
|
+
let lastCwd = this.handlers.call("cwd") as string;
|
|
960
|
+
|
|
961
|
+
while (!signal.aborted) {
|
|
962
|
+
// Auto-compact when total context approaches the window limit.
|
|
963
|
+
const totalEstimate = this.conversation.estimatePromptTokens();
|
|
964
|
+
const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
965
|
+
const s = getSettings();
|
|
966
|
+
const threshold = Math.floor(
|
|
967
|
+
(contextWindow - RESPONSE_RESERVE) * s.autoCompactThreshold,
|
|
968
|
+
);
|
|
969
|
+
if (s.autoCompact && totalEstimate > threshold) {
|
|
970
|
+
// Compact deeply — shallow targets buy only 1–2 turns of runway on
|
|
971
|
+
// tool-heavy workloads.
|
|
972
|
+
const target = Math.floor(threshold * 0.25);
|
|
973
|
+
const result = await this.compactWithHooks(target, 1);
|
|
974
|
+
if (!result) {
|
|
975
|
+
// Auto-compact fired but nothing was evictable. This can happen
|
|
976
|
+
// in short conversations with heavy tool output where the pin
|
|
977
|
+
// fraction consumes all turns. Log it so it's not silent.
|
|
978
|
+
this.bus.emit("ui:info", {
|
|
979
|
+
message: `[auto-compact] above threshold (${totalEstimate.toLocaleString()} > ${threshold.toLocaleString()}) but nothing to evict — conversation may be too short`,
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
cachedSystemPrompt = undefined;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const currentCwd = this.handlers.call("cwd") as string;
|
|
986
|
+
if (currentCwd !== lastCwd) {
|
|
987
|
+
cachedSystemPrompt = undefined;
|
|
988
|
+
lastCwd = currentCwd;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const systemPrompt = cachedSystemPrompt ?? (cachedSystemPrompt = this.handlers.call("system-prompt:build") as string);
|
|
992
|
+
const dynamicContext = this.handlers.call("dynamic-context:build") as string;
|
|
993
|
+
|
|
994
|
+
// Shell events are injected once per user query (see handleQuery),
|
|
995
|
+
// not per loop iteration. Mid-loop injection would break the
|
|
996
|
+
// tool_call → tool_result chain some providers require.
|
|
997
|
+
const result = await this.streamWithRetry(systemPrompt, dynamicContext, signal);
|
|
998
|
+
|
|
999
|
+
const { text, toolCalls: streamedToolCalls, extras } = result;
|
|
1000
|
+
|
|
1001
|
+
const toolCalls = this.handlers.call("tool-protocol:extract-calls", {
|
|
1002
|
+
text,
|
|
1003
|
+
streamedCalls: streamedToolCalls,
|
|
1004
|
+
}) as ProtocolPendingToolCall[];
|
|
1005
|
+
|
|
1006
|
+
fullResponseText += text;
|
|
1007
|
+
|
|
1008
|
+
if (text || toolCalls.length > 0) {
|
|
1009
|
+
this.toolProtocol.recordAssistant(this.conversation, text, toolCalls, extras);
|
|
1010
|
+
this.bus.emit("conversation:message-appended", {
|
|
1011
|
+
role: "assistant",
|
|
1012
|
+
content: text,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (signal.aborted) break;
|
|
1017
|
+
|
|
1018
|
+
if (toolCalls.length === 0) {
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Emit batch info so the TUI can render group headers upfront
|
|
1023
|
+
{
|
|
1024
|
+
const groupMap = new Map<string, Array<{ name: string; displayDetail?: string }>>();
|
|
1025
|
+
for (const tc of toolCalls) {
|
|
1026
|
+
const tool = this.findTool(tc.name);
|
|
1027
|
+
const kind = tool?.getDisplayInfo?.((() => { try { return JSON.parse(tc.argumentsJson); } catch { return {}; } })())?.kind ?? "execute";
|
|
1028
|
+
let args: Record<string, unknown> = {};
|
|
1029
|
+
try { args = JSON.parse(tc.argumentsJson); } catch {}
|
|
1030
|
+
const detail = tool?.formatCall?.(args);
|
|
1031
|
+
if (!groupMap.has(kind)) groupMap.set(kind, []);
|
|
1032
|
+
groupMap.get(kind)!.push({ name: tc.name, displayDetail: detail });
|
|
1033
|
+
}
|
|
1034
|
+
const groups = Array.from(groupMap.entries()).map(([kind, tools]) => ({ kind, tools }));
|
|
1035
|
+
this.bus.emit("agent:tool-batch", { groups });
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Execute tool calls — run read-only tools in parallel, permission-
|
|
1039
|
+
// requiring tools sequentially (to avoid overlapping permission prompts).
|
|
1040
|
+
const batchTotal = toolCalls.length;
|
|
1041
|
+
const collectedResults: ProtocolToolResult[] = [];
|
|
1042
|
+
|
|
1043
|
+
// Round-scoped cache for pure, read-only tool calls
|
|
1044
|
+
const roundCache = new Map<string, ProtocolToolResult>();
|
|
1045
|
+
|
|
1046
|
+
const executeSingle = async (tc: PendingToolCall, batchIndex?: number) => {
|
|
1047
|
+
// Rewrite meta-tool calls (e.g., use_extension → actual tool)
|
|
1048
|
+
tc = this.toolProtocol.rewriteToolCall(tc);
|
|
1049
|
+
|
|
1050
|
+
// Check for validation errors from rewrite (e.g., wrong extension params)
|
|
1051
|
+
try {
|
|
1052
|
+
const maybeError = JSON.parse(tc.argumentsJson);
|
|
1053
|
+
if (maybeError._error) {
|
|
1054
|
+
collectedResults.push({
|
|
1055
|
+
callId: tc.id, toolName: tc.name,
|
|
1056
|
+
content: maybeError._error, isError: true,
|
|
1057
|
+
});
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
} catch { /* not an error payload, continue */ }
|
|
1061
|
+
|
|
1062
|
+
const tool = this.findTool(tc.name);
|
|
1063
|
+
if (!tool) {
|
|
1064
|
+
const available = this.getTools().map((t) => t.name).join(", ");
|
|
1065
|
+
collectedResults.push({
|
|
1066
|
+
callId: tc.id, toolName: tc.name,
|
|
1067
|
+
content: `Unknown tool "${tc.name}". Available tools: ${available}`,
|
|
1068
|
+
isError: true,
|
|
1069
|
+
});
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
let args: Record<string, unknown>;
|
|
1074
|
+
try {
|
|
1075
|
+
args = JSON.parse(tc.argumentsJson);
|
|
1076
|
+
} catch {
|
|
1077
|
+
collectedResults.push({
|
|
1078
|
+
callId: tc.id, toolName: tc.name,
|
|
1079
|
+
content: `Invalid JSON arguments for ${tc.name}`, isError: true,
|
|
1080
|
+
});
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
// Normalize against the tool's input_schema: some LLMs stringify
|
|
1084
|
+
// nested object/array args despite the schema. See
|
|
1085
|
+
// normalize-args.ts for the diagnostic that uncovered this.
|
|
1086
|
+
args = normalizeToolArgs(args, tool.input_schema);
|
|
1087
|
+
|
|
1088
|
+
// ── Round-scoped cache for cacheable read-only tools ──
|
|
1089
|
+
const cacheable = !tool.modifiesFiles && tool.showOutput !== true;
|
|
1090
|
+
const cacheKey = cacheable ? `${tc.name}:${JSON.stringify(args)}` : null;
|
|
1091
|
+
if (cacheKey) {
|
|
1092
|
+
const cached = roundCache.get(cacheKey);
|
|
1093
|
+
if (cached) {
|
|
1094
|
+
const display = tool.getDisplayInfo?.(args) ?? { kind: "execute" as const };
|
|
1095
|
+
this.bus.emit("agent:tool-started", {
|
|
1096
|
+
title: tool.displayName ?? tc.name,
|
|
1097
|
+
name: tc.name,
|
|
1098
|
+
toolCallId: tc.id,
|
|
1099
|
+
kind: display.kind, icon: display.icon, locations: display.locations, rawInput: args,
|
|
1100
|
+
displayDetail: tool.formatCall?.(args),
|
|
1101
|
+
sourceLanguage: display.sourceLanguage,
|
|
1102
|
+
batchIndex, batchTotal: batchTotal > 1 ? batchTotal : undefined,
|
|
1103
|
+
});
|
|
1104
|
+
this.bus.emit("agent:tool-call", { tool: tc.name, args });
|
|
1105
|
+
// Reconstruct a ToolResult for formatResult; ProtocolToolResult has no exitCode
|
|
1106
|
+
const cachedToolResult = { content: cached.content, exitCode: 0, isError: cached.isError };
|
|
1107
|
+
const resultDisplay = tool.formatResult?.(args, cachedToolResult);
|
|
1108
|
+
this.bus.emitTransform("agent:tool-completed", {
|
|
1109
|
+
toolCallId: tc.id, exitCode: 0,
|
|
1110
|
+
rawOutput: cached.content, kind: display.kind,
|
|
1111
|
+
resultDisplay,
|
|
1112
|
+
});
|
|
1113
|
+
this.bus.emit("agent:tool-output", {
|
|
1114
|
+
tool: tc.name, output: contentText(cached.content), exitCode: 0,
|
|
1115
|
+
});
|
|
1116
|
+
collectedResults.push({
|
|
1117
|
+
callId: tc.id, toolName: tc.name,
|
|
1118
|
+
content: cached.content, isError: cached.isError,
|
|
1119
|
+
});
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Execute via handler — extensions can advise to add safe-mode,
|
|
1125
|
+
// logging, metrics, custom permission policies, etc.
|
|
1126
|
+
const defaultOnChunk = (chunk: string) => {
|
|
1127
|
+
this.bus.emit("agent:tool-output-chunk", { chunk });
|
|
1128
|
+
};
|
|
1129
|
+
const result = await this.handlers.call(
|
|
1130
|
+
"tool:execute",
|
|
1131
|
+
{ name: tc.name, id: tc.id, args, tool, onChunk: defaultOnChunk,
|
|
1132
|
+
batchIndex, batchTotal: batchTotal > 1 ? batchTotal : undefined,
|
|
1133
|
+
signal },
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
let content = result.content;
|
|
1137
|
+
if (typeof content === "string") {
|
|
1138
|
+
const maxBytes = tool.maxResultBytes ?? 100_000; // ~25k tokens
|
|
1139
|
+
if (content.length > maxBytes) {
|
|
1140
|
+
const headBytes = Math.floor(maxBytes * 0.6);
|
|
1141
|
+
const tailBytes = maxBytes - headBytes;
|
|
1142
|
+
const lines = content.split("\n");
|
|
1143
|
+
let headEnd = 0, headLen = 0;
|
|
1144
|
+
for (let i = 0; i < lines.length && headLen + lines[i].length + 1 <= headBytes; i++) {
|
|
1145
|
+
headLen += lines[i].length + 1;
|
|
1146
|
+
headEnd = i + 1;
|
|
1147
|
+
}
|
|
1148
|
+
let tailStart = lines.length, tailLen = 0;
|
|
1149
|
+
for (let i = lines.length - 1; i >= headEnd && tailLen + lines[i].length + 1 <= tailBytes; i--) {
|
|
1150
|
+
tailLen += lines[i].length + 1;
|
|
1151
|
+
tailStart = i;
|
|
1152
|
+
}
|
|
1153
|
+
const omitted = tailStart - headEnd;
|
|
1154
|
+
content = [
|
|
1155
|
+
...lines.slice(0, headEnd),
|
|
1156
|
+
`\n[… ${omitted} lines omitted (output truncated to ${Math.round(maxBytes / 1024)}KB) …]\n`,
|
|
1157
|
+
...lines.slice(tailStart),
|
|
1158
|
+
].join("\n");
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const finalResult: ProtocolToolResult = {
|
|
1163
|
+
callId: tc.id, toolName: tc.name,
|
|
1164
|
+
content, isError: result.isError,
|
|
1165
|
+
};
|
|
1166
|
+
if (cacheKey) {
|
|
1167
|
+
roundCache.set(cacheKey, finalResult);
|
|
1168
|
+
}
|
|
1169
|
+
collectedResults.push(finalResult);
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const parallel: PendingToolCall[] = [];
|
|
1173
|
+
const sequential: PendingToolCall[] = [];
|
|
1174
|
+
for (const tc of toolCalls) {
|
|
1175
|
+
const tool = this.findTool(tc.name);
|
|
1176
|
+
if (tool && !tool.modifiesFiles) {
|
|
1177
|
+
parallel.push(tc);
|
|
1178
|
+
} else {
|
|
1179
|
+
sequential.push(tc);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Run read-only tools in parallel
|
|
1184
|
+
let batchIdx = 0;
|
|
1185
|
+
if (parallel.length > 0 && !signal.aborted) {
|
|
1186
|
+
await Promise.all(parallel.map(tc => {
|
|
1187
|
+
const idx = ++batchIdx;
|
|
1188
|
+
return signal.aborted ? Promise.resolve() : executeSingle(tc, idx);
|
|
1189
|
+
}));
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Run permission-requiring tools sequentially
|
|
1193
|
+
for (const tc of sequential) {
|
|
1194
|
+
if (signal.aborted) break;
|
|
1195
|
+
await executeSingle(tc, ++batchIdx);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Categorize this round's results; the summaries feed
|
|
1199
|
+
// agent:tool-batch-complete below, where extensions decide on nudges.
|
|
1200
|
+
const errorTools = new Set<string>();
|
|
1201
|
+
const successTools = new Set<string>();
|
|
1202
|
+
const errorSummaries = new Map<string, string>(); // tool → brief error description
|
|
1203
|
+
const successSummaries = new Map<string, string>(); // tool → brief success description
|
|
1204
|
+
|
|
1205
|
+
for (const r of collectedResults) {
|
|
1206
|
+
const content = typeof r.content === "string" ? r.content : String(r.content);
|
|
1207
|
+
const brief = content.slice(0, 80).replace(/\n/g, " ").trim();
|
|
1208
|
+
if (r.isError) {
|
|
1209
|
+
errorTools.add(r.toolName);
|
|
1210
|
+
errorSummaries.set(r.toolName, brief);
|
|
1211
|
+
} else {
|
|
1212
|
+
successTools.add(r.toolName);
|
|
1213
|
+
successSummaries.set(r.toolName, brief);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const hadAnyError = errorTools.size > 0;
|
|
1218
|
+
const hadAnySuccess = successTools.size > 0;
|
|
1219
|
+
|
|
1220
|
+
// ── Session telemetry accumulation ──
|
|
1221
|
+
for (const r of collectedResults) {
|
|
1222
|
+
const counts = this.toolCallCounts.get(r.toolName) ?? { success: 0, error: 0 };
|
|
1223
|
+
if (r.isError) {
|
|
1224
|
+
counts.error++;
|
|
1225
|
+
this.totalToolErrors++;
|
|
1226
|
+
} else {
|
|
1227
|
+
counts.success++;
|
|
1228
|
+
}
|
|
1229
|
+
this.toolCallCounts.set(r.toolName, counts);
|
|
1230
|
+
this.totalToolCalls++;
|
|
1231
|
+
}
|
|
1232
|
+
this.totalLoopIterations++;
|
|
1233
|
+
|
|
1234
|
+
// ── Resolution pattern tracking ──
|
|
1235
|
+
// When a tool errors, record the error context. When the same tool
|
|
1236
|
+
// (or a write tool touching the same file) succeeds afterward,
|
|
1237
|
+
// increment totalResolutions — the positive feedback signal exposed
|
|
1238
|
+
// to extensions via agent:get-counters.
|
|
1239
|
+
if (hadAnyError) {
|
|
1240
|
+
for (const [tool, summary] of errorSummaries) {
|
|
1241
|
+
this.lastErrorByTool.set(tool, summary);
|
|
1242
|
+
}
|
|
1243
|
+
for (const r of collectedResults) {
|
|
1244
|
+
if (!r.isError) continue;
|
|
1245
|
+
const tc = toolCalls.find(t => t.id === r.callId || t.name === r.toolName);
|
|
1246
|
+
if (!tc) continue;
|
|
1247
|
+
try {
|
|
1248
|
+
const args = JSON.parse(tc.argumentsJson);
|
|
1249
|
+
const fp = this.filePathFromArgs(r.toolName, args);
|
|
1250
|
+
if (fp) this.lastErrorByFile.set(fp, errorSummaries.get(r.toolName) ?? "");
|
|
1251
|
+
} catch {}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (hadAnySuccess) {
|
|
1256
|
+
let resolved = false;
|
|
1257
|
+
for (const [tool] of successSummaries) {
|
|
1258
|
+
if (this.lastErrorByTool.get(tool)) {
|
|
1259
|
+
this.lastErrorByTool.delete(tool);
|
|
1260
|
+
this.totalResolutions++;
|
|
1261
|
+
resolved = true;
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (!resolved) {
|
|
1266
|
+
for (const r of collectedResults) {
|
|
1267
|
+
if (r.isError) continue;
|
|
1268
|
+
const tc = toolCalls.find(t => t.id === r.callId || t.name === r.toolName);
|
|
1269
|
+
if (!tc) continue;
|
|
1270
|
+
try {
|
|
1271
|
+
const args = JSON.parse(tc.argumentsJson);
|
|
1272
|
+
const fp = this.filePathFromArgs(r.toolName, args);
|
|
1273
|
+
if (fp && this.lastErrorByFile.get(fp)) {
|
|
1274
|
+
this.lastErrorByFile.delete(fp);
|
|
1275
|
+
this.totalResolutions++;
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
} catch {}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
for (const tool of successTools) {
|
|
1282
|
+
this.lastErrorByTool.delete(tool);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Announce the batch — extensions that care about batch-level
|
|
1287
|
+
// outcomes (consecutive-error tracking, resolution pattern logging,
|
|
1288
|
+
// metacognitive nudges) listen here.
|
|
1289
|
+
this.bus.emit("agent:tool-batch-complete", {
|
|
1290
|
+
results: collectedResults.map((r) => ({
|
|
1291
|
+
name: r.toolName,
|
|
1292
|
+
isError: !!r.isError,
|
|
1293
|
+
errorSummary: r.isError ? errorSummaries.get(r.toolName) : undefined,
|
|
1294
|
+
})),
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
this.toolProtocol.recordResults(this.conversation, collectedResults);
|
|
1298
|
+
|
|
1299
|
+
// Emit enriched message-appended events so derived-log extensions
|
|
1300
|
+
// can summarize each tool result without re-parsing the message
|
|
1301
|
+
// structure.
|
|
1302
|
+
for (const r of collectedResults) {
|
|
1303
|
+
const content = typeof r.content === "string" ? r.content : String(r.content);
|
|
1304
|
+
const tc = toolCalls.find(t => t.id === r.callId || t.name === r.toolName);
|
|
1305
|
+
let args: Record<string, unknown> = {};
|
|
1306
|
+
try { args = tc ? JSON.parse(tc.argumentsJson) : {}; } catch {}
|
|
1307
|
+
this.bus.emit("conversation:message-appended", {
|
|
1308
|
+
role: "tool",
|
|
1309
|
+
content,
|
|
1310
|
+
toolName: r.toolName,
|
|
1311
|
+
toolArgs: args,
|
|
1312
|
+
isError: !!r.isError,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
return fullResponseText;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
private readonly maxRetries = 3;
|
|
1321
|
+
|
|
1322
|
+
// ── Resolution pattern helpers ──
|
|
1323
|
+
// Extract a file path from a tool call's arguments. Used to correlate
|
|
1324
|
+
// errors with subsequent successful writes on the same file.
|
|
1325
|
+
private filePathFromArgs(toolName: string, args: Record<string, unknown>): string | undefined {
|
|
1326
|
+
if (toolName === "edit_file" || toolName === "write_file" || toolName === "read_file") {
|
|
1327
|
+
return (args.path ?? args.file_path) as string | undefined;
|
|
1328
|
+
}
|
|
1329
|
+
return undefined;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Stream with retry logic. Handles:
|
|
1334
|
+
* - Context overflow → compact and retry
|
|
1335
|
+
* - Rate limits (429) → backoff with Retry-After
|
|
1336
|
+
* - Transient errors (500/502/503, network) → exponential backoff
|
|
1337
|
+
*/
|
|
1338
|
+
private async streamWithRetry(
|
|
1339
|
+
systemPrompt: string,
|
|
1340
|
+
dynamicContext: string,
|
|
1341
|
+
signal: AbortSignal,
|
|
1342
|
+
): ReturnType<typeof this.streamResponse> {
|
|
1343
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
1344
|
+
try {
|
|
1345
|
+
return await this.streamResponse(systemPrompt, dynamicContext, signal);
|
|
1346
|
+
} catch (e) {
|
|
1347
|
+
if (signal.aborted) throw e;
|
|
1348
|
+
|
|
1349
|
+
// Context overflow — aggressively compact and retry
|
|
1350
|
+
if (this.isContextOverflow(e)) {
|
|
1351
|
+
const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
1352
|
+
const target = Math.floor((contextWindow - RESPONSE_RESERVE) * 0.6);
|
|
1353
|
+
const stats = await this.compactWithHooks(target, 1);
|
|
1354
|
+
// If compaction freed nothing, retrying will hit the same error.
|
|
1355
|
+
// Surface the real failure instead of looping until exhaustion.
|
|
1356
|
+
if (!stats || stats.after >= stats.before) {
|
|
1357
|
+
this.bus.emit("ui:info", {
|
|
1358
|
+
message: "(context overflow — nothing to compact; aborting retries)",
|
|
1359
|
+
});
|
|
1360
|
+
throw e;
|
|
1361
|
+
}
|
|
1362
|
+
this.bus.emit("ui:info", {
|
|
1363
|
+
message: `(context overflow — compacted ~${stats.before.toLocaleString()} → ~${stats.after.toLocaleString()} tokens, retrying)`,
|
|
1364
|
+
});
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Retryable transient error — backoff
|
|
1369
|
+
if (this.isRetryable(e) && attempt < this.maxRetries) {
|
|
1370
|
+
const delay = this.getRetryDelay(e, attempt);
|
|
1371
|
+
const status = (e as any).status;
|
|
1372
|
+
const reason = status === 429 ? "rate limited" : `error ${status ?? "network"}`;
|
|
1373
|
+
this.bus.emit("ui:info", {
|
|
1374
|
+
message: `(${reason}, retrying in ${Math.ceil(delay / 1000)}s — attempt ${attempt + 2}/${this.maxRetries + 1})`,
|
|
1375
|
+
});
|
|
1376
|
+
await new Promise<void>((resolve, reject) => {
|
|
1377
|
+
const timer = setTimeout(resolve, delay);
|
|
1378
|
+
signal.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("aborted")); }, { once: true });
|
|
1379
|
+
});
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Non-retryable or exhausted retries
|
|
1384
|
+
throw e;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
// Should not reach here, but TypeScript needs it
|
|
1388
|
+
throw new Error("Retry loop exhausted");
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Stream a single LLM response. Returns accumulated text, parsed tool calls,
|
|
1393
|
+
* and the raw assistant message data for conversation recording.
|
|
1394
|
+
*/
|
|
1395
|
+
private async streamResponse(
|
|
1396
|
+
systemPrompt: string,
|
|
1397
|
+
dynamicContext: string,
|
|
1398
|
+
signal: AbortSignal,
|
|
1399
|
+
): Promise<{
|
|
1400
|
+
text: string;
|
|
1401
|
+
toolCalls: PendingToolCall[];
|
|
1402
|
+
/** Provider-specific fields (reasoning, reasoning_details, …) to
|
|
1403
|
+
* echo back verbatim on the next turn. */
|
|
1404
|
+
extras?: Record<string, unknown>;
|
|
1405
|
+
}> {
|
|
1406
|
+
let text = "";
|
|
1407
|
+
// reasoning_details streams as per-chunk fragments keyed by index;
|
|
1408
|
+
// merge .text per index or the provider rejects the fragmented shape.
|
|
1409
|
+
let reasoningField: string | null = null;
|
|
1410
|
+
let reasoning = "";
|
|
1411
|
+
const reasoningDetailsByIndex = new Map<number, Record<string, unknown>>();
|
|
1412
|
+
const pendingToolCalls: PendingToolCall[] = [];
|
|
1413
|
+
|
|
1414
|
+
// Tool protocol controls what goes in the API tools param vs dynamic context.
|
|
1415
|
+
// agent:tools:visible is a filter point on the assembled list — distinct from
|
|
1416
|
+
// getTools(), which other code (e.g. tool bridges) needs unfiltered.
|
|
1417
|
+
const toolView = this.bus.emitPipe("agent:tools:visible", { tools: this.getTools() }).tools;
|
|
1418
|
+
const apiTools = this.toolProtocol.getApiTools(toolView);
|
|
1419
|
+
const toolPrompt = this.toolProtocol.getToolPrompt(toolView);
|
|
1420
|
+
|
|
1421
|
+
// Dynamic context rides on the trailing message — see
|
|
1422
|
+
// wrapTrailingWithDynamicContext for the cache-stability rationale.
|
|
1423
|
+
const rawMessages = [
|
|
1424
|
+
{ role: "system" as const, content: systemPrompt },
|
|
1425
|
+
...wrapTrailingWithDynamicContext(this.conversation.forLLM(), dynamicContext, toolPrompt),
|
|
1426
|
+
];
|
|
1427
|
+
|
|
1428
|
+
// Let extensions transform the message array (compact, summarize, filter, etc.)
|
|
1429
|
+
const messages = this.handlers.call("conversation:prepare", rawMessages);
|
|
1430
|
+
|
|
1431
|
+
// Stream filter strips tool tags from display (inline mode only)
|
|
1432
|
+
const streamFilter = this.toolProtocol.createStreamFilter(
|
|
1433
|
+
toolView.map((t) => t.name),
|
|
1434
|
+
);
|
|
1435
|
+
|
|
1436
|
+
const requestParams = {
|
|
1437
|
+
messages,
|
|
1438
|
+
tools: apiTools,
|
|
1439
|
+
model: this.activeModel.id,
|
|
1440
|
+
max_tokens: this.activeModel.maxTokens ?? 65536,
|
|
1441
|
+
...this.reasoningParams(),
|
|
1442
|
+
};
|
|
1443
|
+
this.bus.emit("llm:request", requestParams);
|
|
1444
|
+
|
|
1445
|
+
const stream = await this.llmClient.stream({ ...requestParams, signal });
|
|
1446
|
+
|
|
1447
|
+
try {
|
|
1448
|
+
for await (const chunk of stream) {
|
|
1449
|
+
if (signal.aborted) break;
|
|
1450
|
+
this.bus.emit("llm:chunk", { chunk });
|
|
1451
|
+
|
|
1452
|
+
// Token usage (may arrive in a chunk with empty choices)
|
|
1453
|
+
if ((chunk as any).usage) {
|
|
1454
|
+
const u = (chunk as any).usage;
|
|
1455
|
+
const promptTokens = u.prompt_tokens ?? 0;
|
|
1456
|
+
const cachedPromptTokens = this.activeEndpoint?.extractCachedTokens?.(u);
|
|
1457
|
+
this.bus.emit("agent:usage", {
|
|
1458
|
+
prompt_tokens: promptTokens,
|
|
1459
|
+
completion_tokens: u.completion_tokens ?? 0,
|
|
1460
|
+
total_tokens: u.total_tokens ?? 0,
|
|
1461
|
+
...(typeof cachedPromptTokens === "number" ? { cached_prompt_tokens: cachedPromptTokens } : {}),
|
|
1462
|
+
});
|
|
1463
|
+
if (promptTokens > 0) {
|
|
1464
|
+
this.conversation.updateApiTokenCount(promptTokens);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const choice = chunk.choices[0];
|
|
1469
|
+
if (!choice) continue;
|
|
1470
|
+
|
|
1471
|
+
const delta = choice.delta;
|
|
1472
|
+
|
|
1473
|
+
if (delta?.content) {
|
|
1474
|
+
text += delta.content;
|
|
1475
|
+
const displayText = streamFilter
|
|
1476
|
+
? streamFilter.feed(delta.content)
|
|
1477
|
+
: delta.content;
|
|
1478
|
+
if (displayText) {
|
|
1479
|
+
this.bus.emitTransform("agent:response-chunk", {
|
|
1480
|
+
blocks: [{ type: "text", text: displayText }],
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const d = delta as any;
|
|
1486
|
+
for (const name of ["reasoning", "reasoning_content"] as const) {
|
|
1487
|
+
if (typeof d?.[name] === "string" && d[name].length > 0) {
|
|
1488
|
+
reasoning += d[name];
|
|
1489
|
+
reasoningField ??= name;
|
|
1490
|
+
this.bus.emit("agent:thinking-chunk", { text: d[name] });
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
if (Array.isArray(d?.reasoning_details)) {
|
|
1494
|
+
for (const x of d.reasoning_details) {
|
|
1495
|
+
const idx = typeof x?.index === "number" ? x.index : reasoningDetailsByIndex.size;
|
|
1496
|
+
const prev = reasoningDetailsByIndex.get(idx);
|
|
1497
|
+
if (!prev) {
|
|
1498
|
+
reasoningDetailsByIndex.set(idx, { ...x });
|
|
1499
|
+
} else {
|
|
1500
|
+
if (typeof x.text === "string") prev.text = (prev.text ?? "") + x.text;
|
|
1501
|
+
for (const [k, v] of Object.entries(x)) if (k !== "text" && prev[k] === undefined) prev[k] = v;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Tool calls (streamed incrementally)
|
|
1507
|
+
if (delta?.tool_calls) {
|
|
1508
|
+
for (const tc of delta.tool_calls) {
|
|
1509
|
+
const idx = tc.index;
|
|
1510
|
+
|
|
1511
|
+
if (!pendingToolCalls[idx]) {
|
|
1512
|
+
pendingToolCalls[idx] = {
|
|
1513
|
+
id: tc.id!,
|
|
1514
|
+
name: tc.function!.name!,
|
|
1515
|
+
argumentsJson: "",
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (tc.function?.arguments) {
|
|
1520
|
+
pendingToolCalls[idx].argumentsJson +=
|
|
1521
|
+
tc.function.arguments;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
} catch (e) {
|
|
1527
|
+
// On abort, fall through with whatever was accumulated so far.
|
|
1528
|
+
if (!signal.aborted) throw e;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (streamFilter) {
|
|
1532
|
+
const remaining = streamFilter.flush();
|
|
1533
|
+
if (remaining) {
|
|
1534
|
+
this.bus.emitTransform("agent:response-chunk", {
|
|
1535
|
+
blocks: [{ type: "text", text: remaining }],
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// Normalize arguments JSON — some providers (Alibaba/qwen) strictly
|
|
1541
|
+
// validate `function.arguments` as parseable JSON on the NEXT turn,
|
|
1542
|
+
// and reject empty strings or partial chunks. OpenAI itself is lenient,
|
|
1543
|
+
// so empty "" slips through locally but the replay breaks upstream.
|
|
1544
|
+
for (const tc of pendingToolCalls) {
|
|
1545
|
+
if (!tc) continue;
|
|
1546
|
+
const s = tc.argumentsJson.trim();
|
|
1547
|
+
if (s === "") { tc.argumentsJson = "{}"; continue; }
|
|
1548
|
+
try { JSON.parse(s); } catch { tc.argumentsJson = "{}"; }
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Echo reasoning only for modes that opt in (e.g. DeepSeek-R1).
|
|
1552
|
+
const extras: Record<string, unknown> = {};
|
|
1553
|
+
if (this.activeModel.echoReasoning) {
|
|
1554
|
+
if (reasoning && reasoningField) extras[reasoningField] = reasoning;
|
|
1555
|
+
if (reasoningDetailsByIndex.size > 0) {
|
|
1556
|
+
extras.reasoning_details = [...reasoningDetailsByIndex.entries()]
|
|
1557
|
+
.sort((a, b) => a[0] - b[0]).map(([, v]) => v);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
return {
|
|
1561
|
+
text,
|
|
1562
|
+
toolCalls: pendingToolCalls,
|
|
1563
|
+
extras: Object.keys(extras).length > 0 ? extras : undefined,
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
}
|