agent-sh 0.14.10 → 0.15.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 +36 -13
- package/dist/agent/agent-loop.d.ts +9 -17
- package/dist/agent/agent-loop.js +123 -150
- package/dist/agent/events.d.ts +10 -12
- package/dist/agent/host-types.d.ts +17 -11
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +76 -29
- package/dist/agent/live-view.d.ts +3 -3
- package/dist/agent/live-view.js +15 -7
- package/dist/agent/providers/deepseek.js +9 -1
- package/dist/agent/providers/openrouter.js +9 -0
- package/dist/agent/session-store.js +1 -1
- package/dist/agent/subagent.js +1 -1
- package/dist/agent/system-prompt.d.ts +7 -3
- package/dist/agent/system-prompt.js +11 -14
- package/dist/agent/tool-protocol.js +0 -7
- package/dist/cli/args.js +2 -1
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.js +39 -2
- package/dist/cli/subcommands.js +1 -0
- package/dist/core/event-bus.js +0 -2
- package/dist/core/extension-loader.js +3 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +3 -2
- package/dist/extensions/slash-commands/index.js +16 -11
- package/dist/shell/events.d.ts +3 -0
- package/dist/shell/index.js +9 -0
- package/dist/shell/shell-context.d.ts +2 -2
- package/dist/shell/shell-context.js +26 -11
- package/dist/shell/shell.js +3 -0
- package/dist/shell/tui-renderer.js +0 -1
- package/dist/utils/diff-renderer.d.ts +4 -0
- package/dist/utils/diff-renderer.js +15 -27
- package/dist/utils/handler-registry.d.ts +1 -6
- package/dist/utils/handler-registry.js +1 -6
- package/dist/utils/line-editor.js +0 -2
- package/dist/utils/palette.js +4 -4
- package/dist/utils/terminal-buffer.d.ts +2 -0
- package/dist/utils/terminal-buffer.js +4 -0
- package/examples/extensions/ads/SKILL.md +170 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
- package/examples/extensions/ash-scheme/index.ts +377 -687
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +118 -0
- package/examples/extensions/ashi/README.md +26 -54
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
- package/examples/extensions/ashi/package.json +14 -2
- package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
- package/examples/extensions/ashi/src/autocomplete.ts +1 -23
- package/examples/extensions/ashi/src/capture.ts +54 -10
- package/examples/extensions/ashi/src/chat/assistant.ts +67 -0
- package/examples/extensions/ashi/src/chat/lines.ts +39 -0
- package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
- package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
- package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
- package/examples/extensions/ashi/src/cli.ts +80 -12
- package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
- package/examples/extensions/ashi/src/commands.ts +11 -1
- package/examples/extensions/ashi/src/dialogs.ts +67 -0
- package/examples/extensions/ashi/src/display-config.ts +16 -1
- package/examples/extensions/ashi/src/docks.ts +31 -0
- package/examples/extensions/ashi/src/events.ts +16 -0
- package/examples/extensions/ashi/src/frontend.ts +456 -268
- package/examples/extensions/ashi/src/hooks.ts +27 -40
- package/examples/extensions/ashi/src/input-prompt.ts +64 -0
- package/examples/extensions/ashi/src/renderer.ts +222 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +27 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +190 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +203 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
- package/examples/extensions/ashi/src/schema.ts +46 -205
- package/examples/extensions/ashi/src/session-commands.ts +2 -1
- package/examples/extensions/ashi/src/status-footer.ts +35 -25
- package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
- package/examples/extensions/ashi/src/theme.ts +1 -47
- package/examples/extensions/ashi/src/ui.ts +88 -0
- package/examples/extensions/ashi-ink/README.md +61 -0
- package/examples/extensions/ashi-ink/package.json +30 -0
- package/examples/extensions/ashi-ink/src/index.ts +6 -0
- package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
- package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
- package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
- package/examples/extensions/ashi-ink/tsconfig.json +14 -0
- package/examples/extensions/ashi-scheme-render.ts +10 -10
- package/examples/extensions/ashi-shell-passthrough.ts +95 -0
- package/examples/extensions/ashi-ui-demo.ts +63 -0
- package/examples/extensions/latex-images.ts +70 -19
- package/examples/extensions/overlay-agent.ts +5 -5
- package/examples/extensions/pi-bridge/index.ts +7 -12
- package/examples/extensions/terminal-buffer.ts +4 -2
- package/package.json +3 -9
- package/examples/extensions/ashi/src/components.ts +0 -238
- package/examples/extensions/ollama.ts +0 -108
- package/examples/extensions/opencode-provider.ts +0 -251
- package/examples/extensions/zai-coding-plan.ts +0 -35
package/dist/agent/agent-loop.js
CHANGED
|
@@ -4,7 +4,7 @@ import { contentText } from "./types.js";
|
|
|
4
4
|
import { ToolRegistry } from "./tool-registry.js";
|
|
5
5
|
import { normalizeToolArgs } from "./normalize-args.js";
|
|
6
6
|
import { LiveView } from "./live-view.js";
|
|
7
|
-
import {
|
|
7
|
+
import { STATIC_IDENTITY, STATIC_GUIDE, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
|
|
8
8
|
import { createToolUI } from "../utils/tool-interactive.js";
|
|
9
9
|
import { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW } from "./token-budget.js";
|
|
10
10
|
import { PACKAGE_VERSION } from "../utils/package-version.js";
|
|
@@ -12,13 +12,6 @@ import { wrapTrailingWithDynamicContext } from "../utils/message-utils.js";
|
|
|
12
12
|
import { getSettings, updateSettings } from "../core/settings.js";
|
|
13
13
|
import { createToolProtocol } from "./tool-protocol.js";
|
|
14
14
|
import { discoverGlobalSkills, discoverProjectSkills } from "./skills.js";
|
|
15
|
-
/**
|
|
16
|
-
* Compact one-line summary of a tool description for the extension
|
|
17
|
-
* catalog in the system prompt. Takes the first line, then the first
|
|
18
|
-
* sentence, capped at 140 chars. The full description still reaches
|
|
19
|
-
* the LLM via the API `tools` param (or via load_tool in deferred-
|
|
20
|
-
* lookup mode) — this only trims the always-visible catalog.
|
|
21
|
-
*/
|
|
22
15
|
/** Reject on abort; orphaned `p` keeps running but its result is dropped. */
|
|
23
16
|
function raceAbort(p, signal) {
|
|
24
17
|
if (signal.aborted)
|
|
@@ -29,6 +22,11 @@ function raceAbort(p, signal) {
|
|
|
29
22
|
p.then((v) => { signal.removeEventListener("abort", onAbort); resolve(v); }, (e) => { signal.removeEventListener("abort", onAbort); reject(e); });
|
|
30
23
|
});
|
|
31
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* One-line summary of a tool description for the always-visible extension
|
|
27
|
+
* catalog in the system prompt. The full description still reaches the LLM
|
|
28
|
+
* via the API `tools` param (or load_tool in deferred-lookup mode).
|
|
29
|
+
*/
|
|
32
30
|
function summarizeDescription(desc) {
|
|
33
31
|
const firstLine = desc.split("\n", 1)[0];
|
|
34
32
|
const sentenceEnd = firstLine.search(/[.!?](\s|$)/);
|
|
@@ -40,19 +38,13 @@ export class AgentLoop {
|
|
|
40
38
|
toolRegistry;
|
|
41
39
|
conversation;
|
|
42
40
|
fileReadCache;
|
|
43
|
-
|
|
41
|
+
activeModel;
|
|
42
|
+
activeEndpoint;
|
|
44
43
|
boundListeners = [];
|
|
45
44
|
boundPipeListeners = [];
|
|
46
45
|
lastProjectSkillNames = new Set();
|
|
47
|
-
// ── Session telemetry
|
|
48
|
-
//
|
|
49
|
-
// agent's own behavioral patterns across the session: which tools
|
|
50
|
-
// it favors, how often it errs, how many times it's been compacted,
|
|
51
|
-
// and how long it's been alive. Surface via introspect(telemetry)
|
|
52
|
-
// or automatically in dynamic context when patterns are notable.
|
|
53
|
-
//
|
|
54
|
-
// Built by the 25th ash. The lineage's metacognitive frontier isn't
|
|
55
|
-
// about thinking harder — it's about seeing yourself clearly.
|
|
46
|
+
// ── Session telemetry: per-session behavioral counters ──
|
|
47
|
+
// Exposed to extensions via the agent:get-* handlers below.
|
|
56
48
|
sessionStartTime = Date.now();
|
|
57
49
|
toolCallCounts = new Map();
|
|
58
50
|
totalToolCalls = 0;
|
|
@@ -63,12 +55,8 @@ export class AgentLoop {
|
|
|
63
55
|
peakConversationTokens = 0;
|
|
64
56
|
queryCount = 0;
|
|
65
57
|
totalLoopIterations = 0;
|
|
66
|
-
// Resolution pattern tracking
|
|
67
|
-
//
|
|
68
|
-
// a write tool on the same file succeeds afterward, we annotate the success
|
|
69
|
-
// entry with a brief resolution note. This gives future ashes a positive
|
|
70
|
-
// feedback signal: not just "there were errors" but "the error was fixed by
|
|
71
|
-
// doing X." Addresses Q3 in QUESTIONS.md.
|
|
58
|
+
// Resolution pattern tracking: "error X later resolved by action Y".
|
|
59
|
+
// Populated/consumed in executeLoop; surfaced via agent:get-counters.
|
|
72
60
|
lastErrorByTool = new Map(); // tool → error summary
|
|
73
61
|
lastErrorByFile = new Map(); // file path → error summary
|
|
74
62
|
static THINKING_LEVELS = ["off", "low", "medium", "high", "xhigh"];
|
|
@@ -88,7 +76,8 @@ export class AgentLoop {
|
|
|
88
76
|
this.toolRegistry = new ToolRegistry(this.handlers);
|
|
89
77
|
this.fileReadCache = this.handlers.call("agent:file-read-cache");
|
|
90
78
|
this.conversation = new LiveView(this.handlers, this.instanceId);
|
|
91
|
-
this.
|
|
79
|
+
this.activeModel = config.initialModel ?? { id: config.llmClient.model, provider: "custom" };
|
|
80
|
+
this.activeEndpoint = this.resolveEndpoint(this.activeModel);
|
|
92
81
|
// Tool protocol — controls how tools are presented to the LLM
|
|
93
82
|
const { names: fromExtensions } = this.bus.emitPipe("agent:core-tools:collect", { names: [] });
|
|
94
83
|
const coreTools = Array.from(new Set([...(getSettings().coreTools ?? []), ...fromExtensions]));
|
|
@@ -135,8 +124,8 @@ export class AgentLoop {
|
|
|
135
124
|
}
|
|
136
125
|
return acc;
|
|
137
126
|
});
|
|
138
|
-
on("agent:submit", ({ query }) => {
|
|
139
|
-
this.handleQuery(query).catch(() => { });
|
|
127
|
+
on("agent:submit", ({ query, images }) => {
|
|
128
|
+
this.handleQuery(query, images).catch(() => { });
|
|
140
129
|
});
|
|
141
130
|
on("agent:cancel-request", (e) => {
|
|
142
131
|
this.abortController?.abort(e.silent ? "silent" : undefined);
|
|
@@ -145,59 +134,50 @@ export class AgentLoop {
|
|
|
145
134
|
this.conversation.appendUserMessage(text);
|
|
146
135
|
this.bus.emit("conversation:message-appended", { role: "user", content: text });
|
|
147
136
|
});
|
|
148
|
-
on("config:switch-model", ({
|
|
149
|
-
const
|
|
150
|
-
const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
|
|
151
|
-
const providerHint = atIdx > 0 ? target.slice(atIdx + 1) : undefined;
|
|
152
|
-
const modes = this.pullModes();
|
|
153
|
-
const found = modes.find((m) => m.model === modelId && (!providerHint || m.provider === providerHint));
|
|
137
|
+
on("config:switch-model", ({ id, provider }) => {
|
|
138
|
+
const found = this.pullModels().find((m) => m.id === id && m.provider === provider);
|
|
154
139
|
if (!found) {
|
|
155
|
-
this.bus.emit("ui:error", { message: `Unknown model: ${
|
|
140
|
+
this.bus.emit("ui:error", { message: `Unknown model: ${provider}:${id}` });
|
|
156
141
|
return;
|
|
157
142
|
}
|
|
158
|
-
this.
|
|
159
|
-
|
|
160
|
-
|
|
143
|
+
this.activeModel = found;
|
|
144
|
+
this.activeEndpoint = this.resolveEndpoint(found);
|
|
145
|
+
if (this.activeEndpoint) {
|
|
146
|
+
this.llmClient.reconfigure({ apiKey: this.activeEndpoint.apiKey, baseURL: this.activeEndpoint.baseURL, model: found.id });
|
|
161
147
|
}
|
|
162
148
|
else {
|
|
163
|
-
this.llmClient.model = found.
|
|
149
|
+
this.llmClient.model = found.id;
|
|
164
150
|
}
|
|
165
|
-
const label = found.provider ? `${found.provider}: ${found.model}` : found.model;
|
|
166
151
|
this.emitIdentity();
|
|
167
|
-
// Persist as the new default — selection survives restart.
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.bus.emit("ui:info", { message: `Model: ${label} (saved as default)` });
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
this.bus.emit("ui:info", { message: `Model: ${label}` });
|
|
180
|
-
}
|
|
152
|
+
// Persist as the new default — selection survives restart. Safe even for
|
|
153
|
+
// dynamic providers: agent-backend defers model resolution to
|
|
154
|
+
// core:extensions-loaded, so the extension re-registers before the
|
|
155
|
+
// persisted default is looked up.
|
|
156
|
+
updateSettings({
|
|
157
|
+
defaultProvider: found.provider,
|
|
158
|
+
providers: { [found.provider]: { defaultModel: found.id } },
|
|
159
|
+
});
|
|
160
|
+
this.bus.emit("ui:info", { message: `Model: ${found.provider}: ${found.id} (saved as default)` });
|
|
181
161
|
this.bus.emit("config:changed", {});
|
|
182
162
|
});
|
|
183
|
-
on("agent:
|
|
184
|
-
const
|
|
185
|
-
const prev = this.
|
|
186
|
-
const fresh =
|
|
163
|
+
on("agent:models-changed", () => {
|
|
164
|
+
const models = this.pullModels();
|
|
165
|
+
const prev = this.activeModel;
|
|
166
|
+
const fresh = models.find((m) => m.id === prev.id && m.provider === prev.provider);
|
|
187
167
|
let identityChanged = false;
|
|
188
168
|
if (fresh) {
|
|
189
|
-
this.
|
|
190
|
-
|
|
191
|
-
|
|
169
|
+
this.activeModel = fresh;
|
|
170
|
+
const ep = this.resolveEndpoint(fresh);
|
|
171
|
+
if (ep && (ep.apiKey !== this.activeEndpoint?.apiKey || ep.baseURL !== this.activeEndpoint?.baseURL)) {
|
|
172
|
+
this.llmClient.reconfigure({ apiKey: ep.apiKey, baseURL: ep.baseURL, model: fresh.id });
|
|
192
173
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|| fresh.contextWindow !== prev.contextWindow;
|
|
174
|
+
this.activeEndpoint = ep;
|
|
175
|
+
identityChanged = fresh.contextWindow !== prev.contextWindow;
|
|
196
176
|
}
|
|
197
|
-
else
|
|
177
|
+
else {
|
|
198
178
|
// Ghost: keep prev active so mid-turn stream() doesn't switch models.
|
|
199
179
|
this.bus.emit("ui:info", {
|
|
200
|
-
message: `${prev.provider}:${prev.
|
|
180
|
+
message: `${prev.provider}:${prev.id} is not in the refreshed catalog — keeping it active until you /model to another.`,
|
|
201
181
|
});
|
|
202
182
|
}
|
|
203
183
|
if (identityChanged)
|
|
@@ -205,27 +185,26 @@ export class AgentLoop {
|
|
|
205
185
|
this.bus.emit("config:changed", {});
|
|
206
186
|
});
|
|
207
187
|
onPipe("config:get-models", () => {
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
// Surface a ghost active
|
|
211
|
-
if (!
|
|
212
|
-
|
|
188
|
+
const models = this.pullModels();
|
|
189
|
+
const list = [...models];
|
|
190
|
+
// Surface a ghost active model so /model still shows it.
|
|
191
|
+
if (!models.some((m) => m.id === this.activeModel.id && m.provider === this.activeModel.provider)) {
|
|
192
|
+
list.push(this.activeModel);
|
|
213
193
|
}
|
|
214
|
-
|
|
215
|
-
return { models, active };
|
|
194
|
+
return { models: list, active: this.activeModel };
|
|
216
195
|
});
|
|
217
196
|
on("config:set-thinking", ({ level }) => {
|
|
218
197
|
if (!AgentLoop.THINKING_LEVELS.includes(level)) {
|
|
219
198
|
this.bus.emit("ui:error", { message: `Unknown thinking level: ${level}. Use: ${AgentLoop.THINKING_LEVELS.join(", ")}` });
|
|
220
199
|
return;
|
|
221
200
|
}
|
|
222
|
-
const mode = this.
|
|
201
|
+
const mode = this.activeModel;
|
|
223
202
|
if (level !== "off" && mode.reasoning === false) {
|
|
224
|
-
this.bus.emit("ui:error", { message: `Model ${mode.
|
|
203
|
+
this.bus.emit("ui:error", { message: `Model ${mode.id} does not support thinking.` });
|
|
225
204
|
return;
|
|
226
205
|
}
|
|
227
206
|
if (level !== "off" && mode.supportsReasoningEffort === false) {
|
|
228
|
-
this.bus.emit("ui:error", { message: `Provider ${mode.provider
|
|
207
|
+
this.bus.emit("ui:error", { message: `Provider ${mode.provider} does not support reasoning_effort.` });
|
|
229
208
|
return;
|
|
230
209
|
}
|
|
231
210
|
this.thinkingLevel = level;
|
|
@@ -233,7 +212,7 @@ export class AgentLoop {
|
|
|
233
212
|
this.bus.emit("config:changed", {});
|
|
234
213
|
});
|
|
235
214
|
onPipe("config:get-thinking", () => {
|
|
236
|
-
const mode = this.
|
|
215
|
+
const mode = this.activeModel;
|
|
237
216
|
const supported = mode.reasoning !== false && mode.supportsReasoningEffort !== false;
|
|
238
217
|
return { level: this.thinkingLevel, levels: AgentLoop.THINKING_LEVELS, supported };
|
|
239
218
|
});
|
|
@@ -257,11 +236,11 @@ export class AgentLoop {
|
|
|
257
236
|
onPipe("context:get-stats", () => ({
|
|
258
237
|
activeTokens: this.conversation.estimateTokens(),
|
|
259
238
|
totalTokens: this.conversation.estimatePromptTokens(),
|
|
260
|
-
budgetTokens: this.
|
|
239
|
+
budgetTokens: this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
261
240
|
}));
|
|
262
241
|
onPipe("context:snapshot", (payload) => {
|
|
263
|
-
payload.messages = this.conversation.
|
|
264
|
-
payload.contextWindow = this.
|
|
242
|
+
payload.messages = this.conversation.get();
|
|
243
|
+
payload.contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
265
244
|
payload.activeTokens = this.conversation.estimateTokens();
|
|
266
245
|
return payload;
|
|
267
246
|
});
|
|
@@ -271,9 +250,7 @@ export class AgentLoop {
|
|
|
271
250
|
payload.stats = { before: stats.before, after: stats.after, evictedCount: stats.evictedCount };
|
|
272
251
|
return payload;
|
|
273
252
|
});
|
|
274
|
-
//
|
|
275
|
-
// event. Whatever strategy ran, core accumulates these counters for
|
|
276
|
-
// status/introspect consumers.
|
|
253
|
+
// Accumulate counters regardless of which compaction strategy ran.
|
|
277
254
|
on("conversation:after-compact", ({ beforeTokens, afterTokens }) => {
|
|
278
255
|
this.compactionCount++;
|
|
279
256
|
this.cumulativeCompactedTokens += Math.max(0, beforeTokens - afterTokens);
|
|
@@ -287,7 +264,6 @@ export class AgentLoop {
|
|
|
287
264
|
on("shell:cwd-change", ({ cwd }) => {
|
|
288
265
|
const projectSkills = discoverProjectSkills(cwd);
|
|
289
266
|
const newNames = new Set(projectSkills.map(s => s.name));
|
|
290
|
-
// Check if the set of project skills changed
|
|
291
267
|
if (newNames.size === this.lastProjectSkillNames.size &&
|
|
292
268
|
[...newNames].every(n => this.lastProjectSkillNames.has(n))) {
|
|
293
269
|
return; // no change
|
|
@@ -401,42 +377,45 @@ export class AgentLoop {
|
|
|
401
377
|
this.abortController?.abort();
|
|
402
378
|
}
|
|
403
379
|
reasoningParams() {
|
|
404
|
-
const
|
|
405
|
-
if (
|
|
380
|
+
const model = this.activeModel;
|
|
381
|
+
if (model.reasoning === false)
|
|
406
382
|
return {};
|
|
407
|
-
if (
|
|
383
|
+
if (model.supportsReasoningEffort === false)
|
|
408
384
|
return {};
|
|
409
|
-
|
|
410
|
-
|
|
385
|
+
const build = this.activeEndpoint?.buildReasoningParams;
|
|
386
|
+
if (build)
|
|
387
|
+
return build(this.thinkingLevel);
|
|
411
388
|
if (this.thinkingLevel === "off")
|
|
412
389
|
return {};
|
|
413
390
|
const effort = this.thinkingLevel === "xhigh" ? "high" : this.thinkingLevel;
|
|
414
391
|
return { reasoning_effort: effort };
|
|
415
392
|
}
|
|
416
|
-
|
|
417
|
-
|
|
393
|
+
resolveEndpoint(m) {
|
|
394
|
+
try {
|
|
395
|
+
return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id });
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
418
400
|
}
|
|
419
|
-
|
|
401
|
+
pullModels() {
|
|
420
402
|
try {
|
|
421
|
-
return this.handlers.call("agent:get-
|
|
403
|
+
return this.handlers.call("agent:get-models") ?? [];
|
|
422
404
|
}
|
|
423
405
|
catch {
|
|
424
406
|
return [];
|
|
425
407
|
}
|
|
426
408
|
}
|
|
427
409
|
emitIdentity() {
|
|
428
|
-
const m = this.
|
|
410
|
+
const m = this.activeModel;
|
|
429
411
|
this.bus.emit("agent:info", {
|
|
430
412
|
name: "ash",
|
|
431
413
|
version: PACKAGE_VERSION,
|
|
432
|
-
model: m.
|
|
414
|
+
model: m.id,
|
|
433
415
|
provider: m.provider,
|
|
434
416
|
contextWindow: m.contextWindow,
|
|
435
417
|
});
|
|
436
418
|
}
|
|
437
|
-
get currentModel() {
|
|
438
|
-
return this.activeMode.model;
|
|
439
|
-
}
|
|
440
419
|
/**
|
|
441
420
|
* Run compaction via the `conversation:compact` handler. After any
|
|
442
421
|
* compaction, emit `conversation:after-compact` so listeners
|
|
@@ -513,9 +492,9 @@ export class AgentLoop {
|
|
|
513
492
|
formatError(e) {
|
|
514
493
|
const raw = e instanceof Error ? e.message : String(e);
|
|
515
494
|
const status = e.status;
|
|
516
|
-
const model = this.
|
|
495
|
+
const model = this.activeModel.id;
|
|
517
496
|
const baseURL = this.llmClient.config?.baseURL;
|
|
518
|
-
const provider = this.
|
|
497
|
+
const provider = this.activeModel.provider;
|
|
519
498
|
// Connection errors — most likely misconfigured provider
|
|
520
499
|
if (raw.includes("ECONNREFUSED") || raw.includes("ECONNRESET") ||
|
|
521
500
|
raw.includes("ETIMEDOUT") || raw.includes("fetch failed") ||
|
|
@@ -551,9 +530,15 @@ export class AgentLoop {
|
|
|
551
530
|
h.define("tool-protocol:extract-calls", (args) => this.toolProtocol.extractToolCalls(args.text, args.streamedCalls));
|
|
552
531
|
// System prompt: static identity + behavioral instructions.
|
|
553
532
|
// Extensions can use registerInstruction() for a managed section,
|
|
554
|
-
//
|
|
533
|
+
// advise system-prompt:frontend to describe their surface high in the
|
|
534
|
+
// prompt, or advise this handler directly for full control.
|
|
555
535
|
h.define("system-prompt:build", () => {
|
|
556
|
-
|
|
536
|
+
// The active frontend's surface goes right after the identity; omitted if none.
|
|
537
|
+
const frontend = (this.handlers.call("system-prompt:frontend") ?? "").trim();
|
|
538
|
+
const parts = [STATIC_IDENTITY];
|
|
539
|
+
if (frontend)
|
|
540
|
+
parts.push(frontend);
|
|
541
|
+
parts.push(STATIC_GUIDE);
|
|
557
542
|
// Global behavioral rules (~/.agent-sh/AGENTS.md) — persistent agent memory
|
|
558
543
|
const agentsMd = loadGlobalAgentsMd();
|
|
559
544
|
if (agentsMd)
|
|
@@ -570,12 +555,11 @@ export class AgentLoop {
|
|
|
570
555
|
const projectStatic = buildStaticByCwd(this.handlers.call("cwd"));
|
|
571
556
|
if (projectStatic)
|
|
572
557
|
parts.push(projectStatic);
|
|
573
|
-
// Extension sections (tools, skills, instructions grouped by extension)
|
|
574
558
|
const extensionSections = this.buildExtensionSections();
|
|
575
559
|
if (extensionSections.length > 0) {
|
|
576
560
|
parts.push("# Extension Instructions\n\n" + extensionSections.join("\n\n"));
|
|
577
561
|
}
|
|
578
|
-
if (this.
|
|
562
|
+
if (this.activeModel.modalities?.includes("image")) {
|
|
579
563
|
parts.push("# Image Support\n\n"
|
|
580
564
|
+ "This model supports image input. When you need visual information, "
|
|
581
565
|
+ "you can read image files (PNG, JPEG, GIF, WebP) with read_file — "
|
|
@@ -590,14 +574,14 @@ export class AgentLoop {
|
|
|
590
574
|
// decide the aggregation shape. Adding a new handler here should
|
|
591
575
|
// only happen for state the core genuinely owns (not state that
|
|
592
576
|
// an extension could track by listening to events).
|
|
593
|
-
h.define("agent:get-
|
|
594
|
-
model: this.
|
|
595
|
-
provider: this.
|
|
577
|
+
h.define("agent:get-model", () => ({
|
|
578
|
+
model: this.activeModel.id,
|
|
579
|
+
provider: this.activeModel.provider,
|
|
596
580
|
thinkingLevel: this.thinkingLevel,
|
|
597
|
-
contextWindow: this.
|
|
581
|
+
contextWindow: this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
598
582
|
}));
|
|
599
583
|
h.define("agent:get-tokens", () => {
|
|
600
|
-
const contextWindow = this.
|
|
584
|
+
const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
601
585
|
const promptTokens = this.conversation.estimatePromptTokens();
|
|
602
586
|
return {
|
|
603
587
|
active: this.conversation.estimateTokens(),
|
|
@@ -640,7 +624,7 @@ export class AgentLoop {
|
|
|
640
624
|
byFile: [...this.lastErrorByFile.entries()].map(([file, error]) => ({ file, error })),
|
|
641
625
|
}));
|
|
642
626
|
h.define("agent:get-compaction-state", () => {
|
|
643
|
-
const contextWindow = this.
|
|
627
|
+
const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
644
628
|
const ratio = getSettings().autoCompactThreshold ?? 0.5;
|
|
645
629
|
return {
|
|
646
630
|
count: this.compactionCount,
|
|
@@ -649,20 +633,18 @@ export class AgentLoop {
|
|
|
649
633
|
};
|
|
650
634
|
});
|
|
651
635
|
h.define("agent:get-self", () => this);
|
|
652
|
-
// dynamic-context:build / query-context:build are defined in core
|
|
653
|
-
// ash consumes them via the envelope wrapping
|
|
654
|
-
// handleQuery; other backends may ignore.
|
|
636
|
+
// dynamic-context:build / query-context:build are defined in the core
|
|
637
|
+
// kernel (src/core/index.ts). ash consumes them via the envelope wrapping
|
|
638
|
+
// in streamResponse + handleQuery; other backends may ignore.
|
|
655
639
|
// Full control over what the LLM sees: takes messages[], returns messages[].
|
|
656
640
|
// Default: pass through. Extensions can advise to compact, summarize,
|
|
657
641
|
// filter, reorder, inject — whatever strategy fits.
|
|
658
642
|
h.define("conversation:prepare", (messages) => messages);
|
|
659
643
|
// ── Conversation primitives for compaction strategies ─────────
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
// `conversation:compact` use these to observe and mutate.
|
|
663
|
-
h.define("conversation:get-messages", () => this.conversation.getMessages());
|
|
644
|
+
// Canonical array (link/replace index space), not forLLM().
|
|
645
|
+
h.define("conversation:get-messages", () => this.conversation.get());
|
|
664
646
|
h.define("conversation:replace-messages", (msgs) => {
|
|
665
|
-
this.conversation.
|
|
647
|
+
this.conversation.replace(msgs);
|
|
666
648
|
});
|
|
667
649
|
h.define("conversation:estimate-tokens", () => this.conversation.estimateTokens());
|
|
668
650
|
h.define("conversation:estimate-prompt-tokens", () => this.conversation.estimatePromptTokens());
|
|
@@ -671,13 +653,13 @@ export class AgentLoop {
|
|
|
671
653
|
const strategy = opts.strategy;
|
|
672
654
|
if (strategy?.kind === "rewind" || strategy?.kind === "replace") {
|
|
673
655
|
const before = this.conversation.estimatePromptTokens();
|
|
674
|
-
const beforeLen = this.conversation.
|
|
656
|
+
const beforeLen = this.conversation.get().length;
|
|
675
657
|
const next = strategy.kind === "rewind"
|
|
676
|
-
? this.conversation.
|
|
658
|
+
? this.conversation.get().slice(0, strategy.toIndex)
|
|
677
659
|
: strategy.messages;
|
|
678
|
-
this.conversation.
|
|
660
|
+
this.conversation.replace(next);
|
|
679
661
|
const after = this.conversation.estimatePromptTokens();
|
|
680
|
-
const afterLen = this.conversation.
|
|
662
|
+
const afterLen = this.conversation.get().length;
|
|
681
663
|
return { before, after, evictedCount: Math.max(0, beforeLen - afterLen) };
|
|
682
664
|
}
|
|
683
665
|
return null;
|
|
@@ -705,7 +687,6 @@ export class AgentLoop {
|
|
|
705
687
|
return { content: msg, exitCode: 1, isError: true };
|
|
706
688
|
}
|
|
707
689
|
const display = tool.getDisplayInfo?.(args) ?? { kind: "execute" };
|
|
708
|
-
// Emit tool-started for TUI
|
|
709
690
|
const label = tool.displayName ?? name;
|
|
710
691
|
this.bus.emit("agent:tool-started", {
|
|
711
692
|
title: typeof args.description === "string" ? `${label}: ${args.description}` : label,
|
|
@@ -737,7 +718,6 @@ export class AgentLoop {
|
|
|
737
718
|
const message = err instanceof Error ? err.message : String(err);
|
|
738
719
|
result = { content: message, exitCode: 1, isError: true };
|
|
739
720
|
}
|
|
740
|
-
// Invalidate read cache when a file is modified
|
|
741
721
|
if (tool.modifiesFiles && typeof args.path === "string" && !result.isError) {
|
|
742
722
|
const absPath = path.resolve(process.cwd(), args.path);
|
|
743
723
|
this.fileReadCache.delete(absPath);
|
|
@@ -755,8 +735,7 @@ export class AgentLoop {
|
|
|
755
735
|
return result;
|
|
756
736
|
});
|
|
757
737
|
}
|
|
758
|
-
async handleQuery(query) {
|
|
759
|
-
// Cancel any in-flight loop (concurrent prompt handling)
|
|
738
|
+
async handleQuery(query, images) {
|
|
760
739
|
if (this.abortController) {
|
|
761
740
|
this.abortController.abort();
|
|
762
741
|
}
|
|
@@ -778,7 +757,14 @@ export class AgentLoop {
|
|
|
778
757
|
const userContent = queryContext
|
|
779
758
|
? `<query_context>\n${queryContext}\n</query_context>\n\n${query}`
|
|
780
759
|
: query;
|
|
781
|
-
|
|
760
|
+
// Fail closed: an image sent to a non-vision model errors and leaves an
|
|
761
|
+
// unsendable message poisoning history, so require declared image support.
|
|
762
|
+
let userImages = images?.length ? images : undefined;
|
|
763
|
+
if (userImages && !this.activeModel.modalities?.includes("image")) {
|
|
764
|
+
this.bus.emit("ui:info", { message: `Current model has no declared image support — ${userImages.length} image(s) dropped.` });
|
|
765
|
+
userImages = undefined;
|
|
766
|
+
}
|
|
767
|
+
this.conversation.addUserMessage(userContent, userImages);
|
|
782
768
|
this.bus.emit("conversation:message-appended", { role: "user", content: query });
|
|
783
769
|
responseText = await this.executeLoop(signal);
|
|
784
770
|
}
|
|
@@ -825,7 +811,7 @@ export class AgentLoop {
|
|
|
825
811
|
while (!signal.aborted) {
|
|
826
812
|
// Auto-compact when total context approaches the window limit.
|
|
827
813
|
const totalEstimate = this.conversation.estimatePromptTokens();
|
|
828
|
-
const contextWindow = this.
|
|
814
|
+
const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
829
815
|
const s = getSettings();
|
|
830
816
|
const threshold = Math.floor((contextWindow - RESPONSE_RESERVE) * s.autoCompactThreshold);
|
|
831
817
|
if (s.autoCompact && totalEstimate > threshold) {
|
|
@@ -850,10 +836,9 @@ export class AgentLoop {
|
|
|
850
836
|
}
|
|
851
837
|
const systemPrompt = cachedSystemPrompt ?? (cachedSystemPrompt = this.handlers.call("system-prompt:build"));
|
|
852
838
|
const dynamicContext = this.handlers.call("dynamic-context:build");
|
|
853
|
-
// Shell events are injected once per user query (see
|
|
839
|
+
// Shell events are injected once per user query (see handleQuery),
|
|
854
840
|
// not per loop iteration. Mid-loop injection would break the
|
|
855
841
|
// tool_call → tool_result chain some providers require.
|
|
856
|
-
// Stream LLM response with retry
|
|
857
842
|
const result = await this.streamWithRetry(systemPrompt, dynamicContext, signal);
|
|
858
843
|
const { text, toolCalls: streamedToolCalls, extras } = result;
|
|
859
844
|
const toolCalls = this.handlers.call("tool-protocol:extract-calls", {
|
|
@@ -870,7 +855,6 @@ export class AgentLoop {
|
|
|
870
855
|
}
|
|
871
856
|
if (signal.aborted)
|
|
872
857
|
break;
|
|
873
|
-
// No tool calls → agent is done
|
|
874
858
|
if (toolCalls.length === 0) {
|
|
875
859
|
break;
|
|
876
860
|
}
|
|
@@ -1046,10 +1030,8 @@ export class AgentLoop {
|
|
|
1046
1030
|
break;
|
|
1047
1031
|
await executeSingle(tc, ++batchIdx);
|
|
1048
1032
|
}
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
// in a row, nudge to read source. When errors cascade across tools,
|
|
1052
|
-
// nudge to step back and reassess approach.
|
|
1033
|
+
// Categorize this round's results; the summaries feed
|
|
1034
|
+
// agent:tool-batch-complete below, where extensions decide on nudges.
|
|
1053
1035
|
const errorTools = new Set();
|
|
1054
1036
|
const successTools = new Set();
|
|
1055
1037
|
const errorSummaries = new Map(); // tool → brief error description
|
|
@@ -1069,10 +1051,6 @@ export class AgentLoop {
|
|
|
1069
1051
|
const hadAnyError = errorTools.size > 0;
|
|
1070
1052
|
const hadAnySuccess = successTools.size > 0;
|
|
1071
1053
|
// ── Session telemetry accumulation ──
|
|
1072
|
-
// Track every tool call's outcome. Exposed via orthogonal handlers
|
|
1073
|
-
// (agent:get-counters, agent:get-tool-stats) for extensions that
|
|
1074
|
-
// want behavioral signals. The data layer for metacognition — you
|
|
1075
|
-
// can't improve what you don't measure.
|
|
1076
1054
|
for (const r of collectedResults) {
|
|
1077
1055
|
const counts = this.toolCallCounts.get(r.toolName) ?? { success: 0, error: 0 };
|
|
1078
1056
|
if (r.isError) {
|
|
@@ -1139,7 +1117,6 @@ export class AgentLoop {
|
|
|
1139
1117
|
catch { }
|
|
1140
1118
|
}
|
|
1141
1119
|
}
|
|
1142
|
-
// Clear resolved error-by-tool entries for successful tools
|
|
1143
1120
|
for (const tool of successTools) {
|
|
1144
1121
|
this.lastErrorByTool.delete(tool);
|
|
1145
1122
|
}
|
|
@@ -1154,7 +1131,6 @@ export class AgentLoop {
|
|
|
1154
1131
|
errorSummary: r.isError ? errorSummaries.get(r.toolName) : undefined,
|
|
1155
1132
|
})),
|
|
1156
1133
|
});
|
|
1157
|
-
// Record all tool results via protocol
|
|
1158
1134
|
this.toolProtocol.recordResults(this.conversation, collectedResults);
|
|
1159
1135
|
// Emit enriched message-appended events so derived-log extensions
|
|
1160
1136
|
// can summarize each tool result without re-parsing the message
|
|
@@ -1175,7 +1151,6 @@ export class AgentLoop {
|
|
|
1175
1151
|
isError: !!r.isError,
|
|
1176
1152
|
});
|
|
1177
1153
|
}
|
|
1178
|
-
// Loop back — LLM sees tool results
|
|
1179
1154
|
}
|
|
1180
1155
|
return fullResponseText;
|
|
1181
1156
|
}
|
|
@@ -1205,7 +1180,7 @@ export class AgentLoop {
|
|
|
1205
1180
|
throw e;
|
|
1206
1181
|
// Context overflow — aggressively compact and retry
|
|
1207
1182
|
if (this.isContextOverflow(e)) {
|
|
1208
|
-
const contextWindow = this.
|
|
1183
|
+
const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
1209
1184
|
const target = Math.floor((contextWindow - RESPONSE_RESERVE) * 0.6);
|
|
1210
1185
|
const stats = await this.compactWithHooks(target, 1);
|
|
1211
1186
|
// If compaction freed nothing, retrying will hit the same error.
|
|
@@ -1262,7 +1237,7 @@ export class AgentLoop {
|
|
|
1262
1237
|
// wrapTrailingWithDynamicContext for the cache-stability rationale.
|
|
1263
1238
|
const rawMessages = [
|
|
1264
1239
|
{ role: "system", content: systemPrompt },
|
|
1265
|
-
...wrapTrailingWithDynamicContext(this.conversation.
|
|
1240
|
+
...wrapTrailingWithDynamicContext(this.conversation.forLLM(), dynamicContext, toolPrompt),
|
|
1266
1241
|
];
|
|
1267
1242
|
// Let extensions transform the message array (compact, summarize, filter, etc.)
|
|
1268
1243
|
const messages = this.handlers.call("conversation:prepare", rawMessages);
|
|
@@ -1271,8 +1246,8 @@ export class AgentLoop {
|
|
|
1271
1246
|
const requestParams = {
|
|
1272
1247
|
messages,
|
|
1273
1248
|
tools: apiTools,
|
|
1274
|
-
model: this.
|
|
1275
|
-
max_tokens: this.
|
|
1249
|
+
model: this.activeModel.id,
|
|
1250
|
+
max_tokens: this.activeModel.maxTokens ?? 65536,
|
|
1276
1251
|
...this.reasoningParams(),
|
|
1277
1252
|
};
|
|
1278
1253
|
this.bus.emit("llm:request", requestParams);
|
|
@@ -1286,12 +1261,13 @@ export class AgentLoop {
|
|
|
1286
1261
|
if (chunk.usage) {
|
|
1287
1262
|
const u = chunk.usage;
|
|
1288
1263
|
const promptTokens = u.prompt_tokens ?? 0;
|
|
1264
|
+
const cachedPromptTokens = this.activeEndpoint?.extractCachedTokens?.(u);
|
|
1289
1265
|
this.bus.emit("agent:usage", {
|
|
1290
1266
|
prompt_tokens: promptTokens,
|
|
1291
1267
|
completion_tokens: u.completion_tokens ?? 0,
|
|
1292
1268
|
total_tokens: u.total_tokens ?? 0,
|
|
1269
|
+
...(typeof cachedPromptTokens === "number" ? { cached_prompt_tokens: cachedPromptTokens } : {}),
|
|
1293
1270
|
});
|
|
1294
|
-
// Feed accurate token count back to conversation state
|
|
1295
1271
|
if (promptTokens > 0) {
|
|
1296
1272
|
this.conversation.updateApiTokenCount(promptTokens);
|
|
1297
1273
|
}
|
|
@@ -1300,10 +1276,8 @@ export class AgentLoop {
|
|
|
1300
1276
|
if (!choice)
|
|
1301
1277
|
continue;
|
|
1302
1278
|
const delta = choice.delta;
|
|
1303
|
-
// Text content
|
|
1304
1279
|
if (delta?.content) {
|
|
1305
1280
|
text += delta.content;
|
|
1306
|
-
// Filter tool tags from display output (inline mode)
|
|
1307
1281
|
const displayText = streamFilter
|
|
1308
1282
|
? streamFilter.feed(delta.content)
|
|
1309
1283
|
: delta.content;
|
|
@@ -1361,7 +1335,6 @@ export class AgentLoop {
|
|
|
1361
1335
|
if (!signal.aborted)
|
|
1362
1336
|
throw e;
|
|
1363
1337
|
}
|
|
1364
|
-
// Flush any buffered content from the stream filter
|
|
1365
1338
|
if (streamFilter) {
|
|
1366
1339
|
const remaining = streamFilter.flush();
|
|
1367
1340
|
if (remaining) {
|
|
@@ -1391,7 +1364,7 @@ export class AgentLoop {
|
|
|
1391
1364
|
}
|
|
1392
1365
|
// Echo reasoning only for modes that opt in (e.g. DeepSeek-R1).
|
|
1393
1366
|
const extras = {};
|
|
1394
|
-
if (this.
|
|
1367
|
+
if (this.activeModel.echoReasoning) {
|
|
1395
1368
|
if (reasoning && reasoningField)
|
|
1396
1369
|
extras[reasoningField] = reasoning;
|
|
1397
1370
|
if (reasoningDetailsByIndex.size > 0) {
|