agent-sh 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +14 -21
  2. package/dist/agent/agent-loop.d.ts +43 -3
  3. package/dist/agent/agent-loop.js +811 -128
  4. package/dist/agent/conversation-state.d.ts +72 -21
  5. package/dist/agent/conversation-state.js +357 -150
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +84 -3
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +34 -1
  15. package/dist/agent/system-prompt.js +96 -47
  16. package/dist/agent/token-budget.d.ts +5 -4
  17. package/dist/agent/token-budget.js +14 -19
  18. package/dist/agent/tool-protocol.d.ts +23 -1
  19. package/dist/agent/tool-protocol.js +169 -4
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +1 -1
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.js +27 -6
  29. package/dist/event-bus.d.ts +59 -2
  30. package/dist/executor.d.ts +4 -3
  31. package/dist/executor.js +18 -15
  32. package/dist/extension-loader.js +50 -13
  33. package/dist/extensions/agent-backend.d.ts +8 -7
  34. package/dist/extensions/agent-backend.js +69 -48
  35. package/dist/extensions/index.js +0 -1
  36. package/dist/extensions/slash-commands.js +14 -9
  37. package/dist/extensions/tui-renderer.js +62 -78
  38. package/dist/index.js +25 -6
  39. package/dist/settings.d.ts +36 -5
  40. package/dist/settings.js +53 -9
  41. package/dist/shell/input-handler.d.ts +2 -1
  42. package/dist/shell/input-handler.js +82 -73
  43. package/dist/shell/shell.js +19 -2
  44. package/dist/types.d.ts +12 -0
  45. package/dist/utils/ansi.d.ts +5 -0
  46. package/dist/utils/ansi.js +1 -1
  47. package/dist/utils/compositor.d.ts +5 -0
  48. package/dist/utils/compositor.js +31 -3
  49. package/dist/utils/diff-renderer.d.ts +9 -0
  50. package/dist/utils/diff-renderer.js +221 -143
  51. package/dist/utils/diff.d.ts +21 -2
  52. package/dist/utils/diff.js +165 -89
  53. package/dist/utils/handler-registry.d.ts +5 -0
  54. package/dist/utils/handler-registry.js +6 -0
  55. package/dist/utils/line-editor.d.ts +11 -1
  56. package/dist/utils/line-editor.js +44 -5
  57. package/dist/utils/tool-display.d.ts +1 -1
  58. package/dist/utils/tool-display.js +4 -4
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
  60. package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
  61. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  62. package/examples/extensions/claude-code-bridge/package.json +1 -0
  63. package/examples/extensions/interactive-prompts.ts +39 -25
  64. package/examples/extensions/overlay-agent.ts +3 -3
  65. package/examples/extensions/peer-mesh.ts +115 -0
  66. package/examples/extensions/pi-bridge/index.ts +2 -2
  67. package/examples/extensions/questionnaire.ts +16 -5
  68. package/examples/extensions/subagents.ts +19 -4
  69. package/examples/extensions/terminal-buffer.ts +163 -0
  70. package/examples/extensions/user-shell.ts +136 -0
  71. package/examples/extensions/web-access.ts +8 -0
  72. package/package.json +36 -2
  73. package/dist/agent/tools/display.d.ts +0 -13
  74. package/dist/agent/tools/display.js +0 -70
  75. package/dist/agent/tools/user-shell.d.ts +0 -13
  76. package/dist/agent/tools/user-shell.js +0 -87
  77. package/dist/extensions/terminal-buffer.d.ts +0 -14
  78. package/dist/extensions/terminal-buffer.js +0 -134
@@ -12,20 +12,29 @@ export declare class HistoryFile {
12
12
  */
13
13
  append(entries: NuclearEntry[]): Promise<void>;
14
14
  /**
15
- * Read the most recent N entries from the history file.
15
+ * Read the most recent N entries from the history file, filtered.
16
+ * Read-only tool calls (read_file, grep, glob, ls) are excluded so
17
+ * the returned entries are all meaningful conversation turns.
16
18
  */
17
19
  readRecent(maxEntries?: number): Promise<NuclearEntry[]>;
18
20
  /**
19
- * Search history entries by regex/keyword.
21
+ * Search history entries by regex/keyword, scanning the file from the
22
+ * end. Caps at ~20 MB of content to bound cost on 100 MB history files.
20
23
  */
21
24
  search(query: string): Promise<{
22
25
  entry: NuclearEntry;
23
26
  line: string;
24
27
  }[]>;
28
+ /** Find a single entry by sequence number, streaming from the file end. */
29
+ findBySeq(seq: number): Promise<NuclearEntry | null>;
30
+ getSize(): Promise<number>;
25
31
  /**
26
- * Get file size in bytes. Returns 0 if file doesn't exist.
32
+ * Yield lines from the file in reverse order (newest-first). Buffers
33
+ * pre-first-newline bytes across chunks to stitch lines that straddle
34
+ * a boundary; carries raw bytes (not strings) so UTF-8 characters split
35
+ * by a chunk boundary are never decoded mid-codepoint.
27
36
  */
28
- getSize(): Promise<number>;
37
+ private streamReverseLines;
29
38
  /**
30
39
  * Truncate from the front if file exceeds historyMaxBytes.
31
40
  * Uses a lock file for the rewrite operation.
@@ -1,17 +1,16 @@
1
1
  /**
2
- * Persistent history file — Tier 3 of the three-tier history system.
2
+ * Persistent history file — append-only JSONL at ~/.agent-sh/history.
3
3
  *
4
- * Append-only JSONL file at ~/.agent-sh/history. Multiple agent-sh
5
- * instances can write concurrently each line is under PIPE_BUF so
6
- * O_APPEND writes are atomic. Only truncation (which rewrites the file)
7
- * uses a lock file for safety.
4
+ * Multiple agent-sh instances can write concurrently each line is under
5
+ * PIPE_BUF so O_APPEND writes are atomic. Only truncation (which rewrites
6
+ * the file) uses a lock file for safety.
8
7
  */
9
8
  import * as fs from "node:fs/promises";
10
9
  import * as fss from "node:fs";
11
10
  import * as path from "node:path";
12
11
  import * as crypto from "node:crypto";
13
12
  import { CONFIG_DIR, getSettings } from "../settings.js";
14
- import { serializeEntry, deserializeEntry, formatNuclearLine, } from "./nuclear-form.js";
13
+ import { serializeEntry, deserializeEntry, formatNuclearLine, isReadOnly, } from "./nuclear-form.js";
15
14
  const HISTORY_PATH = path.join(CONFIG_DIR, "history");
16
15
  const LOCK_PATH = HISTORY_PATH + ".lock";
17
16
  const LOCK_STALE_MS = 10_000; // consider lock stale after 10s
@@ -34,29 +33,27 @@ export class HistoryFile {
34
33
  await this.maybeTruncate();
35
34
  }
36
35
  /**
37
- * Read the most recent N entries from the history file.
36
+ * Read the most recent N entries from the history file, filtered.
37
+ * Read-only tool calls (read_file, grep, glob, ls) are excluded so
38
+ * the returned entries are all meaningful conversation turns.
38
39
  */
39
40
  async readRecent(maxEntries) {
40
41
  maxEntries ??= getSettings().historyStartupEntries;
41
- let content;
42
- try {
43
- content = await fs.readFile(this.filePath, "utf-8");
44
- }
45
- catch {
46
- return [];
47
- }
48
- const lines = content.trim().split("\n").filter(Boolean);
49
- const recent = lines.slice(-maxEntries);
50
- const entries = [];
51
- for (const line of recent) {
42
+ const want = maxEntries * 3 + 10;
43
+ const recent = []; // newest-first
44
+ for await (const line of this.streamReverseLines()) {
52
45
  const entry = deserializeEntry(line);
53
- if (entry)
54
- entries.push(entry);
46
+ if (entry && !isReadOnly(entry))
47
+ recent.push(entry);
48
+ if (recent.length >= want)
49
+ break;
55
50
  }
56
- return entries;
51
+ // Caller expects oldest-to-newest order.
52
+ return recent.reverse().slice(-maxEntries);
57
53
  }
58
54
  /**
59
- * Search history entries by regex/keyword.
55
+ * Search history entries by regex/keyword, scanning the file from the
56
+ * end. Caps at ~20 MB of content to bound cost on 100 MB history files.
60
57
  */
61
58
  async search(query) {
62
59
  if (!query.trim())
@@ -67,28 +64,37 @@ export class HistoryFile {
67
64
  }
68
65
  catch {
69
66
  const words = query.split(/\s+/).filter((w) => w.length > 0);
70
- const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
71
- regex = new RegExp(pattern, "i");
72
- }
73
- let content;
74
- try {
75
- content = await fs.readFile(this.filePath, "utf-8");
76
- }
77
- catch {
78
- return [];
67
+ const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
68
+ const lookaheads = escaped.map((w) => `(?=.*${w})`).join("");
69
+ regex = new RegExp(lookaheads, "i");
79
70
  }
71
+ const budgetBytes = 20 * 1024 * 1024;
72
+ let scanned = 0;
80
73
  const results = [];
81
- for (const line of content.trim().split("\n")) {
74
+ for await (const line of this.streamReverseLines()) {
75
+ scanned += line.length + 1;
76
+ if (scanned > budgetBytes)
77
+ break;
82
78
  const entry = deserializeEntry(line);
83
- if (entry && regex.test(entry.sum)) {
79
+ if (!entry || isReadOnly(entry))
80
+ continue;
81
+ // Body can hold ~4000 chars the summary truncates — search both.
82
+ const searchText = [entry.sum, entry.body].filter(Boolean).join("\n");
83
+ if (regex.test(searchText)) {
84
84
  results.push({ entry, line: formatNuclearLine(entry) });
85
85
  }
86
86
  }
87
87
  return results;
88
88
  }
89
- /**
90
- * Get file size in bytes. Returns 0 if file doesn't exist.
91
- */
89
+ /** Find a single entry by sequence number, streaming from the file end. */
90
+ async findBySeq(seq) {
91
+ for await (const line of this.streamReverseLines()) {
92
+ const entry = deserializeEntry(line);
93
+ if (entry && entry.seq === seq)
94
+ return entry;
95
+ }
96
+ return null;
97
+ }
92
98
  async getSize() {
93
99
  try {
94
100
  const stat = await fs.stat(this.filePath);
@@ -98,6 +104,74 @@ export class HistoryFile {
98
104
  return 0;
99
105
  }
100
106
  }
107
+ /**
108
+ * Yield lines from the file in reverse order (newest-first). Buffers
109
+ * pre-first-newline bytes across chunks to stitch lines that straddle
110
+ * a boundary; carries raw bytes (not strings) so UTF-8 characters split
111
+ * by a chunk boundary are never decoded mid-codepoint.
112
+ */
113
+ async *streamReverseLines(chunkBytes = 1 << 20) {
114
+ let handle;
115
+ let fileSize;
116
+ try {
117
+ const stat = await fs.stat(this.filePath);
118
+ fileSize = stat.size;
119
+ if (fileSize === 0)
120
+ return;
121
+ handle = await fs.open(this.filePath, "r");
122
+ }
123
+ catch {
124
+ return;
125
+ }
126
+ try {
127
+ let position = fileSize;
128
+ let pending = Buffer.alloc(0);
129
+ while (position > 0) {
130
+ const readSize = Math.min(chunkBytes, position);
131
+ position -= readSize;
132
+ const buf = Buffer.alloc(readSize);
133
+ await handle.read(buf, 0, readSize, position);
134
+ // pending: start-bytes of a line whose first \n lives in this chunk.
135
+ const combined = Buffer.concat([buf, pending]);
136
+ const newlineIdxs = [];
137
+ for (let i = 0; i < combined.length; i++) {
138
+ if (combined[i] === 0x0A)
139
+ newlineIdxs.push(i);
140
+ }
141
+ if (newlineIdxs.length === 0) {
142
+ pending = combined;
143
+ continue;
144
+ }
145
+ const firstNl = newlineIdxs[0];
146
+ const lastNl = newlineIdxs[newlineIdxs.length - 1];
147
+ // Post-last-\n: a line straddling into the later chunk (completed
148
+ // here because `pending` was appended at the end of `combined`).
149
+ const trailing = combined.subarray(lastNl + 1);
150
+ if (trailing.length > 0)
151
+ yield trailing.toString("utf-8");
152
+ for (let i = newlineIdxs.length - 1; i >= 1; i--) {
153
+ const seg = combined.subarray(newlineIdxs[i - 1] + 1, newlineIdxs[i]);
154
+ if (seg.length > 0)
155
+ yield seg.toString("utf-8");
156
+ }
157
+ // Pre-first-\n: partial if there's more file to the left, else complete.
158
+ const leading = combined.subarray(0, firstNl);
159
+ if (position === 0) {
160
+ if (leading.length > 0)
161
+ yield leading.toString("utf-8");
162
+ pending = Buffer.alloc(0);
163
+ }
164
+ else {
165
+ pending = leading;
166
+ }
167
+ }
168
+ if (pending.length > 0)
169
+ yield pending.toString("utf-8");
170
+ }
171
+ finally {
172
+ await handle.close();
173
+ }
174
+ }
101
175
  // ── Truncation ──────────────────────────────────────────────────
102
176
  /**
103
177
  * Truncate from the front if file exceeds historyMaxBytes.
@@ -15,17 +15,42 @@ export interface NuclearEntry {
15
15
  ts: number;
16
16
  /** Instance ID — 4-char hex identifying the agent-sh process. */
17
17
  iid: string;
18
- /** Entry kind. */
19
- kind: "user" | "agent" | "tool" | "error";
18
+ /**
19
+ * Entry kind. Core kinds are "user" | "agent" | "tool" | "error" | "session";
20
+ * advisors may emit additional labels.
21
+ */
22
+ kind: "user" | "agent" | "tool" | "error" | "session" | (string & {});
20
23
  /** Tool name (for kind=tool or kind=error). */
21
24
  tool?: string;
22
- /** The one-liner summary. */
25
+ /** The one-liner summary — injected in startup context. */
23
26
  sum: string;
27
+ /** Expanded content — on disk only, fetched by conversation_recall expand. */
28
+ body?: string;
29
+ /**
30
+ * Optional reasoning annotation. Nucleation advisors may populate this
31
+ * (e.g. by extracting `[why: ...]` from agent text) so the rationale
32
+ * survives into summaries. Displayed as `{why}` in formatNuclearLine.
33
+ */
34
+ why?: string;
24
35
  }
36
+ /**
37
+ * Create a session-start marker entry. Markers use seq=0 by default —
38
+ * they are not part of the nuclear sequence and should not advance the
39
+ * sequence counter when read back from disk.
40
+ */
41
+ export declare function createSessionMarker(iid: string, seq?: number): NuclearEntry;
42
+ /** Check if an entry is a session-start marker. */
43
+ export declare function isSessionMarker(entry: NuclearEntry): boolean;
25
44
  /** Read-only tools whose results are dropped at Tier 1→2 (agent can re-read). */
26
45
  export declare const READ_ONLY_TOOLS: Set<string>;
27
46
  /** State-changing tools whose summaries are kept in nuclear memory. */
28
47
  export declare const WRITE_TOOLS: Set<string>;
48
+ /**
49
+ * Produce a nuclear entry eagerly — called at each hook point as messages
50
+ * arrive, not during compaction. Returns { sum, body }.
51
+ */
52
+ export declare function nucleate(kind: "user" | "agent", text: string, iid: string, seq: number): NuclearEntry;
53
+ export declare function nucleate(kind: "tool" | "error", toolName: string, args: Record<string, unknown>, resultContent: string, isError: boolean, iid: string, seq: number): NuclearEntry;
29
54
  /**
30
55
  * Generate nuclear entries from a logical turn (a sequence of messages
31
56
  * starting with a user message, followed by assistant + tool messages).
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Create a session-start marker entry. Markers use seq=0 by default —
3
+ * they are not part of the nuclear sequence and should not advance the
4
+ * sequence counter when read back from disk.
5
+ */
6
+ export function createSessionMarker(iid, seq = 0) {
7
+ return { seq, ts: Date.now(), iid, kind: "session", sum: "session start" };
8
+ }
9
+ /** Check if an entry is a session-start marker. */
10
+ export function isSessionMarker(entry) {
11
+ return entry.kind === "session";
12
+ }
1
13
  // ── Tool classification ───────────────────────────────────────────
2
14
  /** Read-only tools whose results are dropped at Tier 1→2 (agent can re-read). */
3
15
  export const READ_ONLY_TOOLS = new Set([
@@ -7,6 +19,76 @@ export const READ_ONLY_TOOLS = new Set([
7
19
  export const WRITE_TOOLS = new Set([
8
20
  "write_file", "edit_file", "write", "edit", "patch",
9
21
  ]);
22
+ // ── Eager nucleation ──────────────────────────────────────────────
23
+ /** Body caps by entry kind (in characters). 0 = no body stored.
24
+ * These are only recovered via conversation_recall expand — they
25
+ * never enter the context window automatically, so be generous. */
26
+ const BODY_CAPS = {
27
+ user: 8000,
28
+ agent: 8000,
29
+ tool: 16000,
30
+ error: 8000,
31
+ };
32
+ export function nucleate(kindOrName, textOrTool, arg2, arg3, arg4, arg5, arg6) {
33
+ if (kindOrName === "user" || kindOrName === "agent") {
34
+ // Simple overload: nucleate("user", text, iid, seq)
35
+ const text = textOrTool;
36
+ const iid = arg2;
37
+ const seq = arg3;
38
+ const maxSum = kindOrName === "user" ? 200 : 150;
39
+ const cap = BODY_CAPS[kindOrName];
40
+ return {
41
+ seq, ts: Date.now(), iid,
42
+ kind: kindOrName,
43
+ sum: `${kindOrName}: "${truncate(text, maxSum)}"`,
44
+ body: text.length > cap ? truncate(text, cap) : text,
45
+ };
46
+ }
47
+ else {
48
+ // Tool/error overload: nucleate("tool", toolName, args, resultContent, isError, iid, seq)
49
+ const toolName = textOrTool;
50
+ const args = arg2;
51
+ const resultContent = arg3;
52
+ const isError = arg4;
53
+ const iid = arg5;
54
+ const seq = arg6;
55
+ const kind = isError ? "error" : "tool";
56
+ const summary = summarizeToolCall(toolName, args);
57
+ const enriched = isError
58
+ ? `error: ${toolName} ${truncate(resultContent, 80)}`
59
+ : enrichWithResult(toolName, summary, resultContent);
60
+ let body;
61
+ if (READ_ONLY_TOOLS.has(toolName)) {
62
+ // Read-only tools: no body (agent can re-read the file)
63
+ body = undefined;
64
+ }
65
+ else {
66
+ const cap = BODY_CAPS[kind];
67
+ const fullBody = buildToolBody(toolName, args, resultContent);
68
+ body = fullBody.length > cap ? truncate(fullBody, cap) : fullBody;
69
+ }
70
+ return {
71
+ seq, ts: Date.now(), iid,
72
+ kind,
73
+ tool: toolName,
74
+ sum: enriched,
75
+ body,
76
+ };
77
+ }
78
+ }
79
+ /** Build body text for a tool result — command + truncated output. */
80
+ function buildToolBody(toolName, args, result) {
81
+ const argStr = toolName === "bash" || toolName === "user_shell"
82
+ ? String(args.command ?? "")
83
+ : JSON.stringify(args);
84
+ const maxResult = 12000;
85
+ const truncated = result.length > maxResult
86
+ ? result.slice(0, Math.floor(maxResult * 0.6))
87
+ + `\n[… truncated …]\n`
88
+ + result.slice(result.length - Math.floor(maxResult * 0.4))
89
+ : result;
90
+ return `$ ${argStr}\n${truncated}`;
91
+ }
10
92
  // ── Nuclear entry generation ──────────────────────────────────────
11
93
  /**
12
94
  * Generate nuclear entries from a logical turn (a sequence of messages
@@ -82,7 +164,8 @@ export function formatNuclearLine(entry) {
82
164
  const pad = (n) => String(n).padStart(2, "0");
83
165
  // ISO-ish compact: 2026-04-13 14:05
84
166
  const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
85
- return `#${entry.seq} [${stamp}] ${entry.sum}`;
167
+ const whyTag = entry.why ? ` {${entry.why.length > 80 ? entry.why.slice(0, 77) + "..." : entry.why}}` : "";
168
+ return `#${entry.seq} [${stamp}] ${entry.sum}${whyTag}`;
86
169
  }
87
170
  // ── Serialization (JSONL for history file) ────────────────────────
88
171
  /** Serialize a nuclear entry to a JSONL line. */
@@ -138,8 +221,6 @@ function summarizeToolCall(name, args) {
138
221
  return `glob ${args.pattern ?? ""}`;
139
222
  case "ls":
140
223
  return `ls ${args.path ?? "."}`;
141
- case "display":
142
- return `display: ${truncate(String(args.command ?? ""), 60)}`;
143
224
  default:
144
225
  return `${name}`;
145
226
  }
@@ -4,11 +4,9 @@ export interface Skill {
4
4
  filePath: string;
5
5
  baseDir: string;
6
6
  }
7
- /**
8
- * Discover global skills (stable across cwd changes).
9
- * Default: ~/.agents/skills/, plus any skillPaths from settings.
10
- */
7
+ /** Discover global skills (stable across cwd changes). Cached per-process. */
11
8
  export declare function discoverGlobalSkills(): Skill[];
9
+ export declare function invalidateGlobalSkillsCache(): void;
12
10
  /**
13
11
  * Discover project-level skills from .agents/skills/ in cwd hierarchy.
14
12
  * Scans from cwd up to git root.
@@ -124,11 +124,13 @@ function addUnique(target, source, seen) {
124
124
  }
125
125
  }
126
126
  }
127
- /**
128
- * Discover global skills (stable across cwd changes).
129
- * Default: ~/.agents/skills/, plus any skillPaths from settings.
130
- */
127
+ // Global skill sources are stable within a session, so cache the result
128
+ // to skip filesystem scans on every system-prompt:build.
129
+ let _cachedGlobalSkills = null;
130
+ /** Discover global skills (stable across cwd changes). Cached per-process. */
131
131
  export function discoverGlobalSkills() {
132
+ if (_cachedGlobalSkills)
133
+ return _cachedGlobalSkills;
132
134
  const seen = new Set();
133
135
  const skills = [];
134
136
  addUnique(skills, scanDir(path.join(os.homedir(), ".agent-sh", "skills")), seen);
@@ -136,8 +138,12 @@ export function discoverGlobalSkills() {
136
138
  for (const p of settings.skillPaths ?? []) {
137
139
  addUnique(skills, scanDir(path.resolve(expandHome(p))), seen);
138
140
  }
141
+ _cachedGlobalSkills = skills;
139
142
  return skills;
140
143
  }
144
+ export function invalidateGlobalSkillsCache() {
145
+ _cachedGlobalSkills = null;
146
+ }
141
147
  /**
142
148
  * Discover project-level skills from .agents/skills/ in cwd hierarchy.
143
149
  * Scans from cwd up to git root.
@@ -29,6 +29,29 @@ export interface SubagentOptions {
29
29
  signal?: AbortSignal;
30
30
  /** Max tool loop iterations (default 20). */
31
31
  maxIterations?: number;
32
+ /**
33
+ * Ambient context rebuilt per iteration, same shape the parent's
34
+ * streamResponse uses. If provided, the subagent sees budget,
35
+ * metacognitive signals, in-flight siblings, etc.
36
+ */
37
+ dynamicContext?: string;
38
+ /**
39
+ * Per-subagent token budget. When total (prompt+completion) tokens
40
+ * exceed this, the subagent terminates gracefully on the next
41
+ * iteration. The parent's daily budget still counts these tokens
42
+ * via onUsage; this is an additional per-call cap.
43
+ */
44
+ budgetTokens?: number;
45
+ /**
46
+ * Invoked after every streamed LLM response with its usage totals.
47
+ * The parent uses this to forward to its event bus so global budget
48
+ * tracking stays accurate.
49
+ */
50
+ onUsage?: (usage: {
51
+ prompt_tokens: number;
52
+ completion_tokens: number;
53
+ total_tokens: number;
54
+ }) => void;
32
55
  }
33
56
  /**
34
57
  * Run a subagent to completion.
@@ -4,7 +4,7 @@ import { ConversationState } from "./conversation-state.js";
4
4
  * Returns the final response text.
5
5
  */
6
6
  export async function runSubagent(opts) {
7
- const { llmClient, tools, systemPrompt, task, model, bus, signal, maxIterations = 20, } = opts;
7
+ const { llmClient, tools, systemPrompt, task, model, bus, signal, maxIterations = 20, dynamicContext, budgetTokens, onUsage, } = opts;
8
8
  const toolMap = new Map(tools.map(t => [t.name, t]));
9
9
  const apiTools = tools.map(t => ({
10
10
  type: "function",
@@ -18,11 +18,21 @@ export async function runSubagent(opts) {
18
18
  conversation.addUserMessage(task);
19
19
  let fullResponseText = "";
20
20
  let iterations = 0;
21
+ let tokensConsumed = 0;
22
+ let budgetExhausted = false;
21
23
  while (iterations++ < maxIterations) {
22
24
  if (signal?.aborted)
23
25
  break;
26
+ if (budgetTokens != null && tokensConsumed >= budgetTokens) {
27
+ budgetExhausted = true;
28
+ break;
29
+ }
24
30
  // Stream LLM response
25
- const { text, toolCalls, assistantContent, assistantToolCalls } = await streamOnce(llmClient, systemPrompt, conversation, apiTools, model, signal);
31
+ const { text, toolCalls, assistantContent, assistantToolCalls, usage } = await streamOnce(llmClient, systemPrompt, conversation, apiTools, model, signal, dynamicContext);
32
+ if (usage) {
33
+ tokensConsumed += usage.total_tokens || 0;
34
+ onUsage?.(usage);
35
+ }
26
36
  fullResponseText += text;
27
37
  conversation.addAssistantMessage(assistantContent, assistantToolCalls);
28
38
  // No tool calls → done
@@ -34,7 +44,7 @@ export async function runSubagent(opts) {
34
44
  break;
35
45
  const tool = toolMap.get(tc.name);
36
46
  if (!tool) {
37
- conversation.addToolResult(tc.id, `Error: Unknown tool "${tc.name}"`);
47
+ conversation.addToolResult(tc.id, `Error: Unknown tool "${tc.name}"`, true);
38
48
  continue;
39
49
  }
40
50
  let args;
@@ -42,7 +52,7 @@ export async function runSubagent(opts) {
42
52
  args = JSON.parse(tc.argumentsJson);
43
53
  }
44
54
  catch {
45
- conversation.addToolResult(tc.id, `Error: Invalid JSON arguments for ${tc.name}`);
55
+ conversation.addToolResult(tc.id, `Error: Invalid JSON arguments for ${tc.name}`, true);
46
56
  continue;
47
57
  }
48
58
  // Emit tool events for TUI (if bus provided)
@@ -72,20 +82,29 @@ export async function runSubagent(opts) {
72
82
  });
73
83
  }
74
84
  const content = result.isError ? `Error: ${result.content}` : result.content;
75
- conversation.addToolResult(tc.id, content);
85
+ conversation.addToolResult(tc.id, content, !!result.isError);
76
86
  }
77
87
  }
88
+ if (budgetExhausted) {
89
+ const note = `\n\n[Subagent terminated: token budget (${budgetTokens}) exhausted after ${tokensConsumed} tokens. Returning partial progress.]`;
90
+ return fullResponseText + note;
91
+ }
78
92
  return fullResponseText;
79
93
  }
80
94
  /** Stream a single LLM response. */
81
- async function streamOnce(llmClient, systemPrompt, conversation, apiTools, model, signal) {
95
+ async function streamOnce(llmClient, systemPrompt, conversation, apiTools, model, signal, dynamicContext) {
82
96
  let text = "";
83
97
  const pendingToolCalls = [];
98
+ let usage = null;
99
+ const messages = [
100
+ { role: "system", content: systemPrompt },
101
+ ];
102
+ if (dynamicContext) {
103
+ messages.push({ role: "user", content: `<context>\n${dynamicContext}\n</context>` });
104
+ messages.push({ role: "assistant", content: "Understood." });
105
+ }
84
106
  const stream = await llmClient.stream({
85
- messages: [
86
- { role: "system", content: systemPrompt },
87
- ...conversation.getMessages(),
88
- ],
107
+ messages: [...messages, ...conversation.getMessages()],
89
108
  tools: apiTools.length > 0 ? apiTools : undefined,
90
109
  model,
91
110
  signal,
@@ -93,6 +112,14 @@ async function streamOnce(llmClient, systemPrompt, conversation, apiTools, model
93
112
  for await (const chunk of stream) {
94
113
  if (signal?.aborted)
95
114
  break;
115
+ if (chunk.usage) {
116
+ const u = chunk.usage;
117
+ usage = {
118
+ prompt_tokens: u.prompt_tokens ?? 0,
119
+ completion_tokens: u.completion_tokens ?? 0,
120
+ total_tokens: u.total_tokens ?? 0,
121
+ };
122
+ }
96
123
  const choice = chunk.choices[0];
97
124
  if (!choice)
98
125
  continue;
@@ -112,8 +139,23 @@ async function streamOnce(llmClient, systemPrompt, conversation, apiTools, model
112
139
  }
113
140
  }
114
141
  }
142
+ // Normalize arguments JSON (same fix as agent-loop): strict providers
143
+ // reject empty "" on replay next turn even though OpenAI is lenient.
144
+ for (const tc of pendingToolCalls) {
145
+ const s = tc.argumentsJson.trim();
146
+ if (s === "") {
147
+ tc.argumentsJson = "{}";
148
+ continue;
149
+ }
150
+ try {
151
+ JSON.parse(s);
152
+ }
153
+ catch {
154
+ tc.argumentsJson = "{}";
155
+ }
156
+ }
115
157
  const assistantToolCalls = pendingToolCalls.length
116
158
  ? pendingToolCalls.map(tc => ({ id: tc.id, function: { name: tc.name, arguments: tc.argumentsJson } }))
117
159
  : undefined;
118
- return { text, toolCalls: pendingToolCalls, assistantContent: text || null, assistantToolCalls };
160
+ return { text, toolCalls: pendingToolCalls, assistantContent: text || null, assistantToolCalls, usage };
119
161
  }
@@ -1,4 +1,12 @@
1
1
  import type { ContextManager } from "../context-manager.js";
2
+ import { type Skill } from "./skills.js";
3
+ /**
4
+ * Format skills for inline display in prompt.
5
+ * Shows name, description, and file path so the model can decide immediately
6
+ * whether to load a skill — no extra round-trip needed.
7
+ */
8
+ export declare function formatSkillsBlock(skills: Skill[]): string;
9
+ export declare function loadGlobalAgentsMd(): string | null;
2
10
  /**
3
11
  * Static system prompt — identical across all queries, cacheable.
4
12
  * Contains only identity and behavioral instructions.
@@ -10,4 +18,29 @@ export declare const STATIC_SYSTEM_PROMPT: string;
10
18
  *
11
19
  * Runs through the "dynamic-context:build" handler so extensions can advise.
12
20
  */
13
- export declare function buildDynamicContext(contextManager: ContextManager, shellBudgetTokens?: number): string;
21
+ export interface TokenStatus {
22
+ /** Estimated prompt tokens (API-grounded when available, else chars/4). */
23
+ promptTokens: number;
24
+ /** Model's context window in tokens. */
25
+ contextWindow: number;
26
+ }
27
+ /**
28
+ * CWD-scoped static context: project conventions (CLAUDE.md / AGENT.md)
29
+ * and discovered skills. Stable for a given cwd — callers should cache
30
+ * on cwd identity rather than rebuilding per LLM iteration.
31
+ */
32
+ export declare function buildStaticByCwd(cwd: string): string;
33
+ /**
34
+ * Per-iteration dynamic context: date, working directory, token usage.
35
+ * Rebuilt every LLM call. Extension advisors add more sections (budget,
36
+ * subagents, metacognitive signals, etc.) on top.
37
+ *
38
+ * Skills, AGENTS.md, and project conventions live in the system prompt
39
+ * (see `system-prompt:build` in agent-loop) so they enter the provider's
40
+ * prefix cache instead of being rebuilt and re-sent every turn.
41
+ *
42
+ * Shell context is likewise not injected here — it flows into the
43
+ * conversation as incremental <shell-events> messages (see
44
+ * AgentLoop.injectShellDelta) for the same reason.
45
+ */
46
+ export declare function buildDynamicContext(contextManager: ContextManager, tokenStatus: TokenStatus): string;