agent-sh 0.14.11 → 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.
Files changed (64) hide show
  1. package/README.md +38 -42
  2. package/dist/agent/agent-loop.d.ts +9 -17
  3. package/dist/agent/agent-loop.js +104 -136
  4. package/dist/agent/events.d.ts +8 -11
  5. package/dist/agent/host-types.d.ts +17 -11
  6. package/dist/agent/index.d.ts +1 -1
  7. package/dist/agent/index.js +38 -22
  8. package/dist/agent/providers/deepseek.js +9 -1
  9. package/dist/agent/session-store.js +1 -1
  10. package/dist/agent/system-prompt.d.ts +7 -3
  11. package/dist/agent/system-prompt.js +11 -14
  12. package/dist/agent/tool-protocol.js +0 -7
  13. package/dist/cli/args.js +2 -1
  14. package/dist/cli/install.d.ts +1 -0
  15. package/dist/cli/install.js +29 -1
  16. package/dist/cli/subcommands.js +1 -0
  17. package/dist/core/event-bus.js +0 -2
  18. package/dist/core/extension-loader.js +3 -1
  19. package/dist/core/index.d.ts +1 -1
  20. package/dist/core/index.js +3 -2
  21. package/dist/extensions/slash-commands/index.js +16 -11
  22. package/dist/shell/index.js +9 -0
  23. package/dist/shell/shell-context.d.ts +2 -2
  24. package/dist/shell/shell-context.js +26 -11
  25. package/dist/shell/tui-renderer.js +0 -1
  26. package/dist/utils/diff-renderer.js +2 -9
  27. package/dist/utils/handler-registry.d.ts +1 -6
  28. package/dist/utils/handler-registry.js +1 -6
  29. package/dist/utils/line-editor.js +0 -2
  30. package/dist/utils/palette.js +4 -4
  31. package/dist/utils/terminal-buffer.d.ts +2 -0
  32. package/dist/utils/terminal-buffer.js +4 -0
  33. package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
  34. package/examples/extensions/ash-scheme/index.ts +104 -74
  35. package/examples/extensions/ashi/EXTENDING.md +2 -0
  36. package/examples/extensions/ashi/README.md +17 -1
  37. package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
  38. package/examples/extensions/ashi/package.json +9 -1
  39. package/examples/extensions/ashi/src/capture.ts +45 -7
  40. package/examples/extensions/ashi/src/chat/assistant.ts +23 -43
  41. package/examples/extensions/ashi/src/chat/lines.ts +20 -1
  42. package/examples/extensions/ashi/src/cli.ts +25 -3
  43. package/examples/extensions/ashi/src/clipboard-image.ts +1 -1
  44. package/examples/extensions/ashi/src/dialogs.ts +67 -0
  45. package/examples/extensions/ashi/src/display-config.ts +7 -0
  46. package/examples/extensions/ashi/src/docks.ts +31 -0
  47. package/examples/extensions/ashi/src/events.ts +16 -0
  48. package/examples/extensions/ashi/src/frontend.ts +134 -27
  49. package/examples/extensions/ashi/src/hooks.ts +6 -12
  50. package/examples/extensions/ashi/src/input-prompt.ts +64 -0
  51. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +7 -3
  52. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +67 -10
  53. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +11 -1
  54. package/examples/extensions/ashi/src/schema.ts +3 -0
  55. package/examples/extensions/ashi/src/session-commands.ts +2 -1
  56. package/examples/extensions/ashi/src/status-footer.ts +21 -3
  57. package/examples/extensions/ashi/src/ui.ts +88 -0
  58. package/examples/extensions/ashi-ink/README.md +2 -0
  59. package/examples/extensions/ashi-scheme-render.ts +8 -2
  60. package/examples/extensions/ashi-ui-demo.ts +63 -0
  61. package/examples/extensions/latex-images.ts +57 -9
  62. package/examples/extensions/overlay-agent.ts +5 -5
  63. package/examples/extensions/pi-bridge/index.ts +7 -12
  64. package/package.json +1 -1
@@ -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 { STATIC_SYSTEM_PROMPT, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
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
- activeMode;
41
+ activeModel;
42
+ activeEndpoint;
44
43
  boundListeners = [];
45
44
  boundPipeListeners = [];
46
45
  lastProjectSkillNames = new Set();
47
- // ── Session telemetry behavioral self-awareness ──────────────
48
- // Every ash deserves to know what it's been doing. This tracks the
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 — captures "error X resolved by action Y"
67
- // When a tool errors, we remember what went wrong. When the same tool or
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.activeMode = config.initialMode ?? { model: config.llmClient.model };
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]));
@@ -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", ({ model: target }) => {
149
- const atIdx = target.lastIndexOf("@");
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: ${target}` });
140
+ this.bus.emit("ui:error", { message: `Unknown model: ${provider}:${id}` });
156
141
  return;
157
142
  }
158
- this.activeMode = found;
159
- if (found.providerConfig) {
160
- this.llmClient.reconfigure({ ...found.providerConfig, model: found.model });
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.model;
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
- // Safe even for dynamic providers: agent-backend defers mode
169
- // resolution to `core:extensions-loaded`, so the extension gets
170
- // to re-register before the persisted default is looked up.
171
- if (found.provider) {
172
- updateSettings({
173
- defaultProvider: found.provider,
174
- providers: { [found.provider]: { defaultModel: found.model } },
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:modes-changed", () => {
184
- const modes = this.pullModes();
185
- const prev = this.activeMode;
186
- const fresh = modes.find((m) => m.model === prev.model && m.provider === prev.provider);
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.activeMode = fresh;
190
- if (fresh.providerConfig && fresh.providerConfig !== prev.providerConfig) {
191
- this.llmClient.reconfigure({ ...fresh.providerConfig, model: fresh.model });
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
- identityChanged = fresh.model !== prev.model
194
- || fresh.provider !== prev.provider
195
- || fresh.contextWindow !== prev.contextWindow;
174
+ this.activeEndpoint = ep;
175
+ identityChanged = fresh.contextWindow !== prev.contextWindow;
196
176
  }
197
- else if (prev.provider) {
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.model} is not in the refreshed catalog — keeping it active until you /model to another.`,
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 modes = this.pullModes();
209
- const models = modes.map((m) => ({ model: m.model, provider: m.provider ?? "" }));
210
- // Surface a ghost active mode so /model still shows it.
211
- if (!modes.some((m) => m.model === this.activeMode.model && m.provider === this.activeMode.provider)) {
212
- models.push({ model: this.activeMode.model, provider: this.activeMode.provider ?? "" });
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
- const active = { model: this.activeMode.model, provider: this.activeMode.provider ?? "" };
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.currentMode;
201
+ const mode = this.activeModel;
223
202
  if (level !== "off" && mode.reasoning === false) {
224
- this.bus.emit("ui:error", { message: `Model ${mode.model} does not support thinking.` });
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 ?? "unknown"} does not support reasoning_effort.` });
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.currentMode;
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.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
239
+ budgetTokens: this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
261
240
  }));
262
241
  onPipe("context:snapshot", (payload) => {
263
242
  payload.messages = this.conversation.get();
264
- payload.contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
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
- // Track generic compaction metrics from the `conversation:after-compact`
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 mode = this.currentMode;
405
- if (mode.reasoning === false)
380
+ const model = this.activeModel;
381
+ if (model.reasoning === false)
406
382
  return {};
407
- if (mode.supportsReasoningEffort === false)
383
+ if (model.supportsReasoningEffort === false)
408
384
  return {};
409
- if (mode.buildReasoningParams)
410
- return mode.buildReasoningParams(this.thinkingLevel);
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
- get currentMode() {
417
- return this.activeMode;
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
- pullModes() {
401
+ pullModels() {
420
402
  try {
421
- return this.handlers.call("agent:get-modes") ?? [];
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.activeMode;
410
+ const m = this.activeModel;
429
411
  this.bus.emit("agent:info", {
430
412
  name: "ash",
431
413
  version: PACKAGE_VERSION,
432
- model: m.model,
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.currentModel;
495
+ const model = this.activeModel.id;
517
496
  const baseURL = this.llmClient.config?.baseURL;
518
- const provider = this.currentMode.provider;
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
- // or advise this handler directly for full control.
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
- const parts = [STATIC_SYSTEM_PROMPT];
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.currentMode.modalities?.includes("image")) {
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-mode", () => ({
594
- model: this.currentMode.model,
595
- provider: this.currentMode.provider ?? "",
577
+ h.define("agent:get-model", () => ({
578
+ model: this.activeModel.id,
579
+ provider: this.activeModel.provider,
596
580
  thinkingLevel: this.thinkingLevel,
597
- contextWindow: this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
581
+ contextWindow: this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
598
582
  }));
599
583
  h.define("agent:get-tokens", () => {
600
- const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
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.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
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,9 +633,9 @@ 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.ts.
653
- // ash consumes them via the envelope wrapping in streamResponse +
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.
@@ -703,7 +687,6 @@ export class AgentLoop {
703
687
  return { content: msg, exitCode: 1, isError: true };
704
688
  }
705
689
  const display = tool.getDisplayInfo?.(args) ?? { kind: "execute" };
706
- // Emit tool-started for TUI
707
690
  const label = tool.displayName ?? name;
708
691
  this.bus.emit("agent:tool-started", {
709
692
  title: typeof args.description === "string" ? `${label}: ${args.description}` : label,
@@ -735,7 +718,6 @@ export class AgentLoop {
735
718
  const message = err instanceof Error ? err.message : String(err);
736
719
  result = { content: message, exitCode: 1, isError: true };
737
720
  }
738
- // Invalidate read cache when a file is modified
739
721
  if (tool.modifiesFiles && typeof args.path === "string" && !result.isError) {
740
722
  const absPath = path.resolve(process.cwd(), args.path);
741
723
  this.fileReadCache.delete(absPath);
@@ -754,7 +736,6 @@ export class AgentLoop {
754
736
  });
755
737
  }
756
738
  async handleQuery(query, images) {
757
- // Cancel any in-flight loop (concurrent prompt handling)
758
739
  if (this.abortController) {
759
740
  this.abortController.abort();
760
741
  }
@@ -779,7 +760,7 @@ export class AgentLoop {
779
760
  // Fail closed: an image sent to a non-vision model errors and leaves an
780
761
  // unsendable message poisoning history, so require declared image support.
781
762
  let userImages = images?.length ? images : undefined;
782
- if (userImages && !this.currentMode.modalities?.includes("image")) {
763
+ if (userImages && !this.activeModel.modalities?.includes("image")) {
783
764
  this.bus.emit("ui:info", { message: `Current model has no declared image support — ${userImages.length} image(s) dropped.` });
784
765
  userImages = undefined;
785
766
  }
@@ -830,7 +811,7 @@ export class AgentLoop {
830
811
  while (!signal.aborted) {
831
812
  // Auto-compact when total context approaches the window limit.
832
813
  const totalEstimate = this.conversation.estimatePromptTokens();
833
- const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
814
+ const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
834
815
  const s = getSettings();
835
816
  const threshold = Math.floor((contextWindow - RESPONSE_RESERVE) * s.autoCompactThreshold);
836
817
  if (s.autoCompact && totalEstimate > threshold) {
@@ -855,10 +836,9 @@ export class AgentLoop {
855
836
  }
856
837
  const systemPrompt = cachedSystemPrompt ?? (cachedSystemPrompt = this.handlers.call("system-prompt:build"));
857
838
  const dynamicContext = this.handlers.call("dynamic-context:build");
858
- // Shell events are injected once per user query (see query() above),
839
+ // Shell events are injected once per user query (see handleQuery),
859
840
  // not per loop iteration. Mid-loop injection would break the
860
841
  // tool_call → tool_result chain some providers require.
861
- // Stream LLM response with retry
862
842
  const result = await this.streamWithRetry(systemPrompt, dynamicContext, signal);
863
843
  const { text, toolCalls: streamedToolCalls, extras } = result;
864
844
  const toolCalls = this.handlers.call("tool-protocol:extract-calls", {
@@ -875,7 +855,6 @@ export class AgentLoop {
875
855
  }
876
856
  if (signal.aborted)
877
857
  break;
878
- // No tool calls → agent is done
879
858
  if (toolCalls.length === 0) {
880
859
  break;
881
860
  }
@@ -1051,10 +1030,8 @@ export class AgentLoop {
1051
1030
  break;
1052
1031
  await executeSingle(tc, ++batchIdx);
1053
1032
  }
1054
- // ── Consecutive error detection (metacognitive nudge) ──
1055
- // Track errors per tool and total. When the same tool errors N times
1056
- // in a row, nudge to read source. When errors cascade across tools,
1057
- // 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.
1058
1035
  const errorTools = new Set();
1059
1036
  const successTools = new Set();
1060
1037
  const errorSummaries = new Map(); // tool → brief error description
@@ -1074,10 +1051,6 @@ export class AgentLoop {
1074
1051
  const hadAnyError = errorTools.size > 0;
1075
1052
  const hadAnySuccess = successTools.size > 0;
1076
1053
  // ── Session telemetry accumulation ──
1077
- // Track every tool call's outcome. Exposed via orthogonal handlers
1078
- // (agent:get-counters, agent:get-tool-stats) for extensions that
1079
- // want behavioral signals. The data layer for metacognition — you
1080
- // can't improve what you don't measure.
1081
1054
  for (const r of collectedResults) {
1082
1055
  const counts = this.toolCallCounts.get(r.toolName) ?? { success: 0, error: 0 };
1083
1056
  if (r.isError) {
@@ -1144,7 +1117,6 @@ export class AgentLoop {
1144
1117
  catch { }
1145
1118
  }
1146
1119
  }
1147
- // Clear resolved error-by-tool entries for successful tools
1148
1120
  for (const tool of successTools) {
1149
1121
  this.lastErrorByTool.delete(tool);
1150
1122
  }
@@ -1159,7 +1131,6 @@ export class AgentLoop {
1159
1131
  errorSummary: r.isError ? errorSummaries.get(r.toolName) : undefined,
1160
1132
  })),
1161
1133
  });
1162
- // Record all tool results via protocol
1163
1134
  this.toolProtocol.recordResults(this.conversation, collectedResults);
1164
1135
  // Emit enriched message-appended events so derived-log extensions
1165
1136
  // can summarize each tool result without re-parsing the message
@@ -1180,7 +1151,6 @@ export class AgentLoop {
1180
1151
  isError: !!r.isError,
1181
1152
  });
1182
1153
  }
1183
- // Loop back — LLM sees tool results
1184
1154
  }
1185
1155
  return fullResponseText;
1186
1156
  }
@@ -1210,7 +1180,7 @@ export class AgentLoop {
1210
1180
  throw e;
1211
1181
  // Context overflow — aggressively compact and retry
1212
1182
  if (this.isContextOverflow(e)) {
1213
- const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
1183
+ const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
1214
1184
  const target = Math.floor((contextWindow - RESPONSE_RESERVE) * 0.6);
1215
1185
  const stats = await this.compactWithHooks(target, 1);
1216
1186
  // If compaction freed nothing, retrying will hit the same error.
@@ -1276,8 +1246,8 @@ export class AgentLoop {
1276
1246
  const requestParams = {
1277
1247
  messages,
1278
1248
  tools: apiTools,
1279
- model: this.currentModel,
1280
- max_tokens: this.currentMode.maxTokens ?? 65536,
1249
+ model: this.activeModel.id,
1250
+ max_tokens: this.activeModel.maxTokens ?? 65536,
1281
1251
  ...this.reasoningParams(),
1282
1252
  };
1283
1253
  this.bus.emit("llm:request", requestParams);
@@ -1291,12 +1261,13 @@ export class AgentLoop {
1291
1261
  if (chunk.usage) {
1292
1262
  const u = chunk.usage;
1293
1263
  const promptTokens = u.prompt_tokens ?? 0;
1264
+ const cachedPromptTokens = this.activeEndpoint?.extractCachedTokens?.(u);
1294
1265
  this.bus.emit("agent:usage", {
1295
1266
  prompt_tokens: promptTokens,
1296
1267
  completion_tokens: u.completion_tokens ?? 0,
1297
1268
  total_tokens: u.total_tokens ?? 0,
1269
+ ...(typeof cachedPromptTokens === "number" ? { cached_prompt_tokens: cachedPromptTokens } : {}),
1298
1270
  });
1299
- // Feed accurate token count back to conversation state
1300
1271
  if (promptTokens > 0) {
1301
1272
  this.conversation.updateApiTokenCount(promptTokens);
1302
1273
  }
@@ -1305,10 +1276,8 @@ export class AgentLoop {
1305
1276
  if (!choice)
1306
1277
  continue;
1307
1278
  const delta = choice.delta;
1308
- // Text content
1309
1279
  if (delta?.content) {
1310
1280
  text += delta.content;
1311
- // Filter tool tags from display output (inline mode)
1312
1281
  const displayText = streamFilter
1313
1282
  ? streamFilter.feed(delta.content)
1314
1283
  : delta.content;
@@ -1366,7 +1335,6 @@ export class AgentLoop {
1366
1335
  if (!signal.aborted)
1367
1336
  throw e;
1368
1337
  }
1369
- // Flush any buffered content from the stream filter
1370
1338
  if (streamFilter) {
1371
1339
  const remaining = streamFilter.flush();
1372
1340
  if (remaining) {
@@ -1396,7 +1364,7 @@ export class AgentLoop {
1396
1364
  }
1397
1365
  // Echo reasoning only for modes that opt in (e.g. DeepSeek-R1).
1398
1366
  const extras = {};
1399
- if (this.currentMode.echoReasoning) {
1367
+ if (this.activeModel.echoReasoning) {
1400
1368
  if (reasoning && reasoningField)
1401
1369
  extras[reasoningField] = reasoning;
1402
1370
  if (reasoningDetailsByIndex.size > 0) {
@@ -1,4 +1,4 @@
1
- import type { ProviderRegistration } from "./host-types.js";
1
+ import type { Model, ProviderRegistration } from "./host-types.js";
2
2
  import type { ImageContent, ToolDefinition, ToolResultDisplay } from "./types.js";
3
3
  export interface AgentIdentity {
4
4
  name: string;
@@ -20,8 +20,9 @@ declare module "../core/event-bus.js" {
20
20
  "provider:configure": {
21
21
  id: string;
22
22
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
23
+ cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
23
24
  };
24
- "agent:modes-changed": Record<string, never>;
25
+ "agent:models-changed": Record<string, never>;
25
26
  "config:switch-provider": {
26
27
  provider: string;
27
28
  };
@@ -70,6 +71,7 @@ declare module "../core/event-bus.js" {
70
71
  prompt_tokens: number;
71
72
  completion_tokens: number;
72
73
  total_tokens: number;
74
+ cached_prompt_tokens?: number;
73
75
  };
74
76
  "agent:processing-start": Record<string, never>;
75
77
  "agent:processing-done": Record<string, never>;
@@ -190,17 +192,12 @@ declare module "../core/event-bus.js" {
190
192
  };
191
193
  };
192
194
  "config:switch-model": {
193
- model: string;
195
+ id: string;
196
+ provider: string;
194
197
  };
195
198
  "config:get-models": {
196
- models: {
197
- model: string;
198
- provider: string;
199
- }[];
200
- active: {
201
- model: string;
202
- provider: string;
203
- } | null;
199
+ models: Model[];
200
+ active: Model | null;
204
201
  };
205
202
  "config:set-thinking": {
206
203
  level: string;