agent-sh 0.9.0 → 0.10.1
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 +25 -30
- package/dist/agent/agent-loop.d.ts +43 -6
- package/dist/agent/agent-loop.js +817 -157
- package/dist/agent/conversation-state.d.ts +72 -21
- package/dist/agent/conversation-state.js +364 -151
- package/dist/agent/history-file.d.ts +13 -4
- package/dist/agent/history-file.js +110 -36
- package/dist/agent/nuclear-form.d.ts +28 -3
- package/dist/agent/nuclear-form.js +84 -3
- package/dist/agent/skills.d.ts +2 -4
- package/dist/agent/skills.js +10 -4
- package/dist/agent/subagent.d.ts +23 -0
- package/dist/agent/subagent.js +53 -11
- package/dist/agent/system-prompt.d.ts +34 -1
- package/dist/agent/system-prompt.js +96 -47
- package/dist/agent/token-budget.d.ts +10 -13
- package/dist/agent/token-budget.js +6 -46
- package/dist/agent/tool-protocol.d.ts +23 -1
- package/dist/agent/tool-protocol.js +169 -4
- package/dist/agent/tools/bash.js +3 -3
- package/dist/agent/tools/edit-file.js +9 -6
- package/dist/agent/tools/glob.js +4 -2
- package/dist/agent/tools/grep.js +27 -3
- package/dist/agent/tools/ls.js +5 -6
- package/dist/agent/types.d.ts +1 -2
- package/dist/context-manager.d.ts +16 -19
- package/dist/context-manager.js +48 -152
- package/dist/core.js +27 -6
- package/dist/event-bus.d.ts +59 -3
- package/dist/executor.d.ts +4 -3
- package/dist/executor.js +18 -15
- package/dist/extension-loader.js +75 -17
- package/dist/extensions/agent-backend.d.ts +8 -7
- package/dist/extensions/agent-backend.js +72 -50
- package/dist/extensions/index.js +0 -2
- package/dist/extensions/slash-commands.js +14 -9
- package/dist/extensions/tui-renderer.js +67 -80
- package/dist/index.js +25 -6
- package/dist/settings.d.ts +39 -16
- package/dist/settings.js +51 -11
- package/dist/shell/input-handler.d.ts +2 -1
- package/dist/shell/input-handler.js +84 -76
- package/dist/shell/shell.js +19 -2
- package/dist/types.d.ts +15 -0
- package/dist/utils/ansi.d.ts +7 -0
- package/dist/utils/ansi.js +69 -8
- package/dist/utils/box-frame.js +8 -2
- package/dist/utils/compositor.d.ts +5 -0
- package/dist/utils/compositor.js +31 -3
- package/dist/utils/diff-renderer.d.ts +9 -0
- package/dist/utils/diff-renderer.js +221 -143
- package/dist/utils/diff.d.ts +21 -2
- package/dist/utils/diff.js +165 -89
- package/dist/utils/handler-registry.d.ts +5 -0
- package/dist/utils/handler-registry.js +6 -0
- package/dist/utils/line-editor.d.ts +11 -1
- package/dist/utils/line-editor.js +44 -5
- package/dist/utils/markdown.js +23 -8
- package/dist/utils/package-version.d.ts +1 -0
- package/dist/utils/package-version.js +10 -0
- package/dist/utils/shell-output-spill.d.ts +2 -0
- package/dist/utils/shell-output-spill.js +81 -0
- package/dist/utils/tool-display.d.ts +1 -1
- package/dist/utils/tool-display.js +4 -4
- package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
- package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
- package/examples/extensions/claude-code-bridge/README.md +14 -0
- package/examples/extensions/claude-code-bridge/index.ts +204 -145
- package/examples/extensions/claude-code-bridge/package.json +1 -0
- package/examples/extensions/interactive-prompts.ts +39 -25
- package/examples/extensions/overlay-agent.ts +3 -3
- package/examples/extensions/peer-mesh.ts +115 -0
- package/examples/extensions/pi-bridge/README.md +16 -0
- package/examples/extensions/pi-bridge/index.ts +9 -155
- package/examples/extensions/questionnaire.ts +16 -5
- package/examples/extensions/subagents.ts +19 -4
- package/examples/extensions/terminal-buffer.ts +163 -0
- package/examples/extensions/user-shell.ts +136 -0
- package/examples/extensions/web-access.ts +8 -0
- package/package.json +36 -2
- package/dist/agent/tools/display.d.ts +0 -13
- package/dist/agent/tools/display.js +0 -70
- package/dist/agent/tools/user-shell.d.ts +0 -13
- package/dist/agent/tools/user-shell.js +0 -87
- package/dist/extensions/shell-recall.d.ts +0 -9
- package/dist/extensions/shell-recall.js +0 -8
- package/dist/extensions/terminal-buffer.d.ts +0 -14
- 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
|
-
*
|
|
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
|
-
|
|
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 —
|
|
2
|
+
* Persistent history file — append-only JSONL at ~/.agent-sh/history.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
+
if (entry && !isReadOnly(entry))
|
|
47
|
+
recent.push(entry);
|
|
48
|
+
if (recent.length >= want)
|
|
49
|
+
break;
|
|
55
50
|
}
|
|
56
|
-
|
|
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
|
|
71
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
19
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/agent/skills.d.ts
CHANGED
|
@@ -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.
|
package/dist/agent/skills.js
CHANGED
|
@@ -124,11 +124,13 @@ function addUnique(target, source, seen) {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
package/dist/agent/subagent.d.ts
CHANGED
|
@@ -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.
|
package/dist/agent/subagent.js
CHANGED
|
@@ -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
|
|
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;
|