toolnexus 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -410
- package/dist/acp.d.ts +42 -0
- package/dist/acp.js +246 -0
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +3 -2
- package/dist/agents/loop.d.ts +21 -0
- package/dist/agents/loop.js +38 -2
- package/dist/agents/runtime.d.ts +64 -3
- package/dist/agents/runtime.js +88 -19
- package/dist/builtin.js +18 -12
- package/dist/classifier.d.ts +23 -0
- package/dist/classifier.js +30 -24
- package/dist/client.d.ts +98 -12
- package/dist/client.js +129 -31
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/order.d.ts +46 -0
- package/dist/order.js +71 -0
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +110 -27
- package/dist/types.d.ts +11 -0
- package/dist/types.js +22 -0
- package/package.json +2 -2
- package/src/acp.ts +308 -0
- package/src/agents/agent.ts +15 -3
- package/src/agents/loop.ts +40 -2
- package/src/agents/runtime.ts +142 -24
- package/src/builtin.ts +18 -9
- package/src/classifier.ts +42 -26
- package/src/client.ts +156 -43
- package/src/index.ts +1 -0
- package/src/order.ts +70 -0
- package/src/skill.ts +121 -27
- package/src/types.ts +22 -0
package/dist/skill.js
CHANGED
|
@@ -13,6 +13,7 @@ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { pathToFileURL } from "node:url";
|
|
15
15
|
import { parse as parseYaml } from "yaml";
|
|
16
|
+
import { compareCodePoints, compareDiscovery, sortEntriesByName } from "./order.js";
|
|
16
17
|
export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
|
|
17
18
|
|
|
18
19
|
Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
|
|
@@ -25,39 +26,84 @@ The skill name must match one of the skills listed in your system prompt.`;
|
|
|
25
26
|
export const SKILLS_PROMPT_PREAMBLE = "Skills provide specialized instructions and workflows for specific tasks.\n" +
|
|
26
27
|
"Use the skill tool to load a skill when a task matches its description.";
|
|
27
28
|
/**
|
|
28
|
-
* Parse YAML frontmatter (between the leading `---` fences) with a real YAML
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* Parse YAML frontmatter (between the leading `---` fences) with a real YAML parser, so folded
|
|
30
|
+
* (`>`)/literal (`|`) block scalars, quoting and multi-line values all resolve correctly.
|
|
31
|
+
* Scalar values are coerced to strings.
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* LENIENT FALLBACK (ADR 0028, issue #93) — **YAML FIRST**. A line-wise `key: rest-of-line` read
|
|
34
|
+
* of `name`/`description` runs ONLY on frontmatter a real YAML parser has ALREADY refused.
|
|
35
|
+
* The order is the whole point: line-wise FIRST misparses legitimate YAML (`description: |`
|
|
36
|
+
* becomes `"|"`, a continued plain scalar truncates to its first line), and running it only
|
|
37
|
+
* after a refusal means it can never see a file whose YAML semantics matter — by construction
|
|
38
|
+
* those files have none left to preserve. No file that parses today changes value.
|
|
39
|
+
*
|
|
40
|
+
* `malformed` is true only when fences are present, YAML refused, AND the rescue found no name.
|
|
36
41
|
*/
|
|
42
|
+
const LENIENT_KEYS = ["name", "description"];
|
|
43
|
+
/** A value opening one of these is a construct the rescue does not implement — refused, never
|
|
44
|
+
* guessed, so a half-broken block scalar degrades to "no description", not to `"|"`. */
|
|
45
|
+
const LENIENT_REFUSED_OPENERS = new Set(["|", ">", "&", "*", "[", "{", "!"]);
|
|
46
|
+
/** Column 0, ordinary key characters, one `:`; the value is the rest of the line. */
|
|
47
|
+
const LENIENT_LINE = /^([A-Za-z0-9_][A-Za-z0-9_.-]*):[ \t]*(.*)$/;
|
|
48
|
+
function unquoteScalar(v) {
|
|
49
|
+
const t = v.trim();
|
|
50
|
+
if (t.length > 1 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0])
|
|
51
|
+
return t.slice(1, -1);
|
|
52
|
+
return t;
|
|
53
|
+
}
|
|
54
|
+
/** Rescue `name`/`description` from a frontmatter block YAML refused. First-wins, column 0. */
|
|
55
|
+
function lineWiseFrontmatter(block) {
|
|
56
|
+
const data = {};
|
|
57
|
+
for (const raw of block.split(/\r?\n/)) {
|
|
58
|
+
const head = raw.slice(0, 1);
|
|
59
|
+
// indented continuation, comment, or blank — not a top-level key
|
|
60
|
+
if (head === " " || head === "\t" || head === "#" || head === "")
|
|
61
|
+
continue;
|
|
62
|
+
const m = LENIENT_LINE.exec(raw);
|
|
63
|
+
if (!m)
|
|
64
|
+
continue;
|
|
65
|
+
const key = m[1];
|
|
66
|
+
const value = m[2].trim();
|
|
67
|
+
if (!LENIENT_KEYS.includes(key) || key in data)
|
|
68
|
+
continue;
|
|
69
|
+
if (!value || LENIENT_REFUSED_OPENERS.has(value[0]))
|
|
70
|
+
continue;
|
|
71
|
+
data[key] = unquoteScalar(value);
|
|
72
|
+
}
|
|
73
|
+
return data;
|
|
74
|
+
}
|
|
37
75
|
function parseFrontmatter(text) {
|
|
38
76
|
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
39
77
|
if (!match)
|
|
40
78
|
return { data: {}, content: text, malformed: false };
|
|
41
79
|
const data = {};
|
|
42
80
|
let parsed;
|
|
43
|
-
let
|
|
81
|
+
let detail;
|
|
44
82
|
try {
|
|
45
83
|
parsed = parseYaml(match[1]);
|
|
46
84
|
}
|
|
47
|
-
catch {
|
|
85
|
+
catch (e) {
|
|
48
86
|
parsed = null;
|
|
49
|
-
|
|
87
|
+
detail = e instanceof Error ? e.message : String(e);
|
|
50
88
|
}
|
|
51
|
-
if (parsed && typeof parsed === "object") {
|
|
89
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
52
90
|
for (const [key, value] of Object.entries(parsed)) {
|
|
53
91
|
// Trim so block-scalar trailing newlines (chomping differs subtly between
|
|
54
|
-
// YAML libs) don't leak — keeps the
|
|
92
|
+
// YAML libs) don't leak — keeps the ports byte-identical.
|
|
55
93
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
56
94
|
data[key] = String(value).trim();
|
|
57
95
|
}
|
|
58
96
|
}
|
|
97
|
+
return { data, content: match[2], malformed: false };
|
|
59
98
|
}
|
|
60
|
-
|
|
99
|
+
// YAML refused (threw, or produced something that is not a mapping): rescue the two scalars.
|
|
100
|
+
const rescued = lineWiseFrontmatter(match[1]);
|
|
101
|
+
return {
|
|
102
|
+
data: rescued,
|
|
103
|
+
content: match[2],
|
|
104
|
+
malformed: !rescued.name,
|
|
105
|
+
detail: detail ?? (rescued.name ? undefined : "frontmatter is not a YAML mapping"),
|
|
106
|
+
};
|
|
61
107
|
}
|
|
62
108
|
function walkSkillFiles(root) {
|
|
63
109
|
const out = [];
|
|
@@ -69,11 +115,17 @@ function walkSkillFiles(root) {
|
|
|
69
115
|
const dir = stack.pop();
|
|
70
116
|
let entries;
|
|
71
117
|
try {
|
|
72
|
-
|
|
118
|
+
// EXPLICIT (A24): `readdirSync` is sorted on most platforms, but Node guarantees
|
|
119
|
+
// nothing. An absent sort over a directory read is the same defect class as a wrong one,
|
|
120
|
+
// and it is invisible to a grep for comparators.
|
|
121
|
+
entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }));
|
|
73
122
|
}
|
|
74
123
|
catch {
|
|
75
124
|
continue;
|
|
76
125
|
}
|
|
126
|
+
// Subdirectories are pushed REVERSED so the LIFO stack pops them in name order.
|
|
127
|
+
for (const entry of [...entries].reverse().filter((e) => e.isDirectory() || e.isSymbolicLink()).reverse().concat([]))
|
|
128
|
+
void entry;
|
|
77
129
|
for (const entry of entries) {
|
|
78
130
|
const full = path.join(dir, entry.name);
|
|
79
131
|
let isDir = entry.isDirectory();
|
|
@@ -111,11 +163,23 @@ function walkSkillFiles(root) {
|
|
|
111
163
|
}
|
|
112
164
|
return out;
|
|
113
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* The `<skill_files>` SAMPLE LIST: sibling resources shown to the model when a skill is loaded.
|
|
168
|
+
*
|
|
169
|
+
* This is SHIPPED OUTPUT, so its order is part of the cross-port contract (addendum A22). It
|
|
170
|
+
* used to be raw `readdirSync` order over a LIFO `stack.pop()` walk with the cap applied MID
|
|
171
|
+
* TRAVERSAL — so the filesystem decided not merely how the sample was ordered but WHICH files
|
|
172
|
+
* appeared in it at all. Now: collect everything, then order by DEPTH ascending then Unicode
|
|
173
|
+
* CODE POINT over the path relative to the skill dir (the same `compareDiscovery` rule as
|
|
174
|
+
* discovery), and only then apply the cap. The sample is therefore a function of the tree and
|
|
175
|
+
* the cap alone — never of readdir order, and stable across machines and platforms. The
|
|
176
|
+
* ordering is PLAIN Unicode code point over the relative path (A25).
|
|
177
|
+
*/
|
|
114
178
|
function sampleSiblingFiles(dir, limit = 10) {
|
|
115
|
-
const
|
|
179
|
+
const found = [];
|
|
116
180
|
const stack = [dir];
|
|
117
181
|
const seen = new Set();
|
|
118
|
-
while (stack.length
|
|
182
|
+
while (stack.length) {
|
|
119
183
|
const cur = stack.pop();
|
|
120
184
|
let entries;
|
|
121
185
|
try {
|
|
@@ -125,8 +189,6 @@ function sampleSiblingFiles(dir, limit = 10) {
|
|
|
125
189
|
continue;
|
|
126
190
|
}
|
|
127
191
|
for (const entry of entries) {
|
|
128
|
-
if (out.length >= limit)
|
|
129
|
-
break;
|
|
130
192
|
const full = path.join(cur, entry.name);
|
|
131
193
|
let isDir = entry.isDirectory();
|
|
132
194
|
let isFile = entry.isFile();
|
|
@@ -156,11 +218,17 @@ function sampleSiblingFiles(dir, limit = 10) {
|
|
|
156
218
|
stack.push(full);
|
|
157
219
|
}
|
|
158
220
|
else if (isFile && entry.name !== "SKILL.md") {
|
|
159
|
-
|
|
221
|
+
found.push(full);
|
|
160
222
|
}
|
|
161
223
|
}
|
|
162
224
|
}
|
|
163
|
-
|
|
225
|
+
// Order FIRST, cap SECOND: capping during the walk let readdir order pick the sample.
|
|
226
|
+
// PLAIN code point over the path relative to the skill dir (A25) — NOT the discovery rule.
|
|
227
|
+
// A15's depth rule exists to decide WHICH FILE a duplicated skill NAME resolves to; it has no
|
|
228
|
+
// business ordering a flat listing, where it would interleave `a-root.txt, z-root.txt,
|
|
229
|
+
// alpha/f.txt` instead of `a-root.txt, alpha/f.txt, z-root.txt`.
|
|
230
|
+
found.sort((a, b) => compareCodePoints(path.relative(dir, a), path.relative(dir, b)));
|
|
231
|
+
return found.slice(0, limit);
|
|
164
232
|
}
|
|
165
233
|
/** Discover candidates under one on-disk root (order = discovery order). */
|
|
166
234
|
function candidatesFromDir(root) {
|
|
@@ -176,18 +244,27 @@ function candidatesFromDir(root) {
|
|
|
176
244
|
console.warn(`[toolnexus] skills dir not found: ${root}`);
|
|
177
245
|
return out;
|
|
178
246
|
}
|
|
179
|
-
|
|
247
|
+
// DISCOVERY ORDER (addendum A1/A1a/A15): dirs in the order the CALLER passed them; within a
|
|
248
|
+
// dir, by DEPTH ASCENDING, then Unicode CODE-POINT comparison of the path relative to that dir.
|
|
249
|
+
// A symlink sorts at its DISCOVERED path, never its target.
|
|
250
|
+
//
|
|
251
|
+
// Depth comes FIRST, and that is the whole rule: code point alone makes the winner depend on
|
|
252
|
+
// the skill's own first letter. `docx`/`pdf`/`pptx` beat `synced/<uuid>/…` because d/p < s —
|
|
253
|
+
// but `xlsx` LOSES to it, because x > s. Depth-first makes "a shallower path beats a nested
|
|
254
|
+
// copy of the same name" true for EVERY name, which is what SPEC §3 already claims.
|
|
255
|
+
const files = walkSkillFiles(root).sort((a, b) => compareDiscovery(path.relative(root, a), path.relative(root, b), path.sep));
|
|
256
|
+
for (const file of files) {
|
|
180
257
|
let text;
|
|
181
258
|
try {
|
|
182
259
|
text = readFileSync(file, "utf8");
|
|
183
260
|
}
|
|
184
|
-
catch {
|
|
185
|
-
out.push({ skip: { location: file, reason: "unreadable" } });
|
|
261
|
+
catch (e) {
|
|
262
|
+
out.push({ skip: { location: file, reason: "unreadable", detail: e instanceof Error ? e.message : String(e) } });
|
|
186
263
|
continue;
|
|
187
264
|
}
|
|
188
|
-
const { data, content, malformed } = parseFrontmatter(text);
|
|
265
|
+
const { data, content, malformed, detail } = parseFrontmatter(text);
|
|
189
266
|
if (malformed) {
|
|
190
|
-
out.push({ skip: { location: file, reason: "malformed-frontmatter" } });
|
|
267
|
+
out.push({ skip: { location: file, reason: "malformed-frontmatter", ...(detail ? { detail } : {}) } });
|
|
191
268
|
continue;
|
|
192
269
|
}
|
|
193
270
|
if (!data.name) {
|
|
@@ -286,6 +363,7 @@ export function loadSkills(input) {
|
|
|
286
363
|
const sampleLimit = opts.sampleLimit ?? 0;
|
|
287
364
|
const merged = mergeCandidates(collectCandidates(opts));
|
|
288
365
|
const skills = applySkillsFilter(merged.skills, opts.filter);
|
|
366
|
+
const skipped = merged.skipped;
|
|
289
367
|
const tool = {
|
|
290
368
|
name: "skill",
|
|
291
369
|
description: SKILL_TOOL_DESCRIPTION,
|
|
@@ -301,7 +379,7 @@ export function loadSkills(input) {
|
|
|
301
379
|
const info = skills[name];
|
|
302
380
|
if (!info) {
|
|
303
381
|
return {
|
|
304
|
-
output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort().join(", ") || "none"}`,
|
|
382
|
+
output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort(compareCodePoints).join(", ") || "none"}`,
|
|
305
383
|
isError: true,
|
|
306
384
|
};
|
|
307
385
|
}
|
|
@@ -347,10 +425,15 @@ export function loadSkills(input) {
|
|
|
347
425
|
return {
|
|
348
426
|
skills,
|
|
349
427
|
tool,
|
|
428
|
+
skipped,
|
|
350
429
|
prompt() {
|
|
351
430
|
const described = Object.values(skills)
|
|
352
431
|
.filter((s) => s.description !== undefined)
|
|
353
|
-
|
|
432
|
+
// Unicode CODE POINT over the skill name, never `localeCompare` (addendum A22): a
|
|
433
|
+
// locale collation orders by the machine's ICU data, so the same build emitted a
|
|
434
|
+
// DIFFERENTLY ORDERED prompt on two machines. SPEC §0.10 pins this prompt as
|
|
435
|
+
// byte-identical across ports; it was not even stable within js.
|
|
436
|
+
.sort((a, b) => compareCodePoints(a.name, b.name));
|
|
354
437
|
if (described.length === 0)
|
|
355
438
|
return "No skills are currently available.";
|
|
356
439
|
return [
|
package/dist/types.d.ts
CHANGED
|
@@ -52,6 +52,17 @@ export interface Answer {
|
|
|
52
52
|
*/
|
|
53
53
|
reason?: "declined" | "cancelled" | "expired";
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Typed constructor for the common resume payload: "here is the tool's output" (ADR 0026 D2).
|
|
57
|
+
* Hand-building `{ id, ok: true, data: { output } }` is where hosts get the key wrong; this
|
|
58
|
+
* removes the key from the host's hands entirely.
|
|
59
|
+
*
|
|
60
|
+
* `output` MUST be a string. A non-string is an ERROR, never a silent degrade to `""` — a
|
|
61
|
+
* fabricated empty result is indistinguishable from an answer the human never gave.
|
|
62
|
+
*/
|
|
63
|
+
export declare function answerOutput(id: string, output: string): Answer;
|
|
64
|
+
/** The matching refusal: `ok:false` with an advisory reason the loop does not branch on. */
|
|
65
|
+
export declare function answerDeclined(id: string, reason?: Answer["reason"]): Answer;
|
|
55
66
|
/** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
|
|
56
67
|
export declare function pending(request: Omit<Request, "id"> & {
|
|
57
68
|
id?: string;
|
package/dist/types.js
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared contract types. See ../../SPEC.md.
|
|
3
3
|
*/
|
|
4
|
+
/**
|
|
5
|
+
* Typed constructor for the common resume payload: "here is the tool's output" (ADR 0026 D2).
|
|
6
|
+
* Hand-building `{ id, ok: true, data: { output } }` is where hosts get the key wrong; this
|
|
7
|
+
* removes the key from the host's hands entirely.
|
|
8
|
+
*
|
|
9
|
+
* `output` MUST be a string. A non-string is an ERROR, never a silent degrade to `""` — a
|
|
10
|
+
* fabricated empty result is indistinguishable from an answer the human never gave.
|
|
11
|
+
*/
|
|
12
|
+
export function answerOutput(id, output) {
|
|
13
|
+
if (typeof output !== "string") {
|
|
14
|
+
throw new TypeError(`answerOutput: output must be a string, got ${output === null ? "null" : typeof output}`);
|
|
15
|
+
}
|
|
16
|
+
if (!id)
|
|
17
|
+
throw new TypeError("answerOutput: id is required (it must match the Request's id)");
|
|
18
|
+
return { id, ok: true, data: { output } };
|
|
19
|
+
}
|
|
20
|
+
/** The matching refusal: `ok:false` with an advisory reason the loop does not branch on. */
|
|
21
|
+
export function answerDeclined(id, reason = "declined") {
|
|
22
|
+
if (!id)
|
|
23
|
+
throw new TypeError("answerDeclined: id is required (it must match the Request's id)");
|
|
24
|
+
return { id, ok: false, reason };
|
|
25
|
+
}
|
|
4
26
|
let _pendingSeq = 0;
|
|
5
27
|
/** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
|
|
6
28
|
export function pending(request) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
|
-
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts",
|
|
20
|
+
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts",
|
|
21
21
|
"example": "node --experimental-strip-types examples/basic.ts",
|
|
22
22
|
"example:judge": "node --experimental-strip-types examples/judge.ts",
|
|
23
23
|
"example:multimodal": "node --experimental-strip-types examples/multimodal.ts"
|
package/src/acp.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP (Agent Client Protocol) model source — issue #96, ADR 0031
|
|
3
|
+
* ("ACP: the warm session is the feature, and it breaks the stateless request"),
|
|
4
|
+
* openspec/changes/add-acp-model-source.
|
|
5
|
+
*
|
|
6
|
+
* ACP is to *agents* what MCP is to *tools*: JSON-RPC 2.0, one object per line,
|
|
7
|
+
* over a child process's stdin/stdout (`devin acp`, Gemini CLI, Zed's agents all
|
|
8
|
+
* speak it). This file ships ACP as nothing more than a `generate` function —
|
|
9
|
+
* the exact shape {@link InProcessOptions.generate} already defines — so the
|
|
10
|
+
* tool-calling loop, skills, MCP tools, adapters and sub-agents are entirely
|
|
11
|
+
* untouched. `loadACP` opens the connection once (`initialize` → `session/new` →
|
|
12
|
+
* optional `session/set_mode`) and hands back an {@link ACPClient} whose
|
|
13
|
+
* `.generate` can be passed straight to {@link createInProcessClient}.
|
|
14
|
+
*
|
|
15
|
+
* The hard part is not the JSON-RPC plumbing — it is that an ACP session is
|
|
16
|
+
* *stateful* while every other toolnexus model source is stateless-by-default.
|
|
17
|
+
* toolnexus assembles a complete request every turn; sending the whole thing
|
|
18
|
+
* into a session that already remembers the transcript makes some agents answer
|
|
19
|
+
* a near-duplicate prompt from stale history (reproduced in `spikes/acp/`).
|
|
20
|
+
* The mitigation (kept as the default, not left to the caller) is an explicit
|
|
21
|
+
* "this supersedes everything earlier" marker appended to every rendered
|
|
22
|
+
* prompt — see {@link renderPrompt}.
|
|
23
|
+
*
|
|
24
|
+
* Three more traps this client exists to close, all proven in the spike:
|
|
25
|
+
* - `session/update` notifications interleave with JSON-RPC replies on the
|
|
26
|
+
* same stdout stream, so everything arriving is demultiplexed by id.
|
|
27
|
+
* - Only `agent_message_chunk` text may be accumulated into the reply —
|
|
28
|
+
* `agent_thought_chunk` and tool-call narration wrap prose around
|
|
29
|
+
* structured output and corrupt it if mixed in.
|
|
30
|
+
* - An unanswered `session/request_permission` hangs the turn FOREVER, even
|
|
31
|
+
* in an agent's bypass mode. The client answers with the first `allow`-kind
|
|
32
|
+
* option the instant the request arrives; `permissionTimeoutMs` is only a
|
|
33
|
+
* safety net for a broken agent that never asks and never replies at all.
|
|
34
|
+
*/
|
|
35
|
+
import { spawn } from "node:child_process"
|
|
36
|
+
import { createInterface } from "node:readline"
|
|
37
|
+
import path from "node:path"
|
|
38
|
+
import type { InProcessRequest, InProcessResponse } from "./client.js"
|
|
39
|
+
|
|
40
|
+
/** Options for {@link loadACP}. `command`/`args` spawn the ACP agent as a child
|
|
41
|
+
* process; everything else tunes the session handshake. */
|
|
42
|
+
export interface ACPOptions {
|
|
43
|
+
/** The ACP agent binary, e.g. `"devin"`. */
|
|
44
|
+
command: string
|
|
45
|
+
/** Arguments to the agent, e.g. `["acp"]`. */
|
|
46
|
+
args?: string[]
|
|
47
|
+
/** Working directory for `session/new`. Real ACP agents (e.g. `devin acp`)
|
|
48
|
+
* reject a relative `cwd` with `-32602 Invalid params` — this is always
|
|
49
|
+
* resolved to an absolute path before it is sent. Default: `process.cwd()`. */
|
|
50
|
+
cwd?: string
|
|
51
|
+
/** Extra environment variables for the child, merged over the current
|
|
52
|
+
* process environment. */
|
|
53
|
+
env?: Record<string, string>
|
|
54
|
+
/** ACP `initialize` protocolVersion. Default `1`. */
|
|
55
|
+
protocolVersion?: number
|
|
56
|
+
/** Safety-net timeout (ms) for one `session/prompt` turn — NOT specifically
|
|
57
|
+
* for the permission handshake, which this client always answers instantly.
|
|
58
|
+
* It exists only so a broken/unresponsive agent cannot hang a caller
|
|
59
|
+
* forever. Default 30000. */
|
|
60
|
+
permissionTimeoutMs?: number
|
|
61
|
+
/** Optional `session/set_mode` sent once, right after `session/new`. */
|
|
62
|
+
mode?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One warm ACP connection: one child process, one session, alive across many
|
|
66
|
+
* turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
|
|
67
|
+
* wants, so it plugs straight into {@link createInProcessClient}. */
|
|
68
|
+
export interface ACPClient {
|
|
69
|
+
generate: (request: InProcessRequest) => Promise<InProcessResponse>
|
|
70
|
+
/** Terminates the agent process. Idempotent — a second call is a no-op. */
|
|
71
|
+
close: () => Promise<void>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The literal marker every rendered prompt ends with, naming the current
|
|
75
|
+
* turn as the one to answer. Kept as a named constant (not just inlined)
|
|
76
|
+
* because the exact wording is shared, by convention, with the other
|
|
77
|
+
* language ports and the spike that proved it fixes the stale-answer bug. */
|
|
78
|
+
const SUPERSEDES_MARKER = "SUPERSEDES-ALL-PRIOR:"
|
|
79
|
+
|
|
80
|
+
interface RpcMessage {
|
|
81
|
+
jsonrpc?: string
|
|
82
|
+
id?: string | number
|
|
83
|
+
method?: string
|
|
84
|
+
params?: any
|
|
85
|
+
result?: any
|
|
86
|
+
error?: { code: number; message: string }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderContentPart(part: any): string {
|
|
90
|
+
if (typeof part === "string") return part
|
|
91
|
+
if (part == null) return ""
|
|
92
|
+
if (typeof part === "object" && "text" in part && typeof part.text === "string") return part.text
|
|
93
|
+
return JSON.stringify(part)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Flattens `content` (a string, or an array of content parts) to plain text. */
|
|
97
|
+
function renderContent(content: any): string {
|
|
98
|
+
if (typeof content === "string") return content
|
|
99
|
+
if (Array.isArray(content)) return content.map(renderContentPart).join(" ")
|
|
100
|
+
return renderContentPart(content)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Renders the FULL assembled request as one prompt string — role + content
|
|
104
|
+
* per message — then appends the supersedes marker naming the latest user
|
|
105
|
+
* turn, per ADR 0031's proposed (and spike-confirmed) default: full request
|
|
106
|
+
* every turn, not a delta, so this stays stateless like every other
|
|
107
|
+
* toolnexus model source and never risks a shadow transcript drifting from
|
|
108
|
+
* the caller's own conversation state. */
|
|
109
|
+
function renderPrompt(request: InProcessRequest): string {
|
|
110
|
+
const messages = request.messages ?? []
|
|
111
|
+
const lines = messages.map((m) => `${m?.role ?? "user"}: ${renderContent(m?.content)}`)
|
|
112
|
+
|
|
113
|
+
let latest = ""
|
|
114
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
115
|
+
if (messages[i]?.role === "user") {
|
|
116
|
+
latest = renderContent(messages[i].content)
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!latest && messages.length > 0) latest = renderContent(messages[messages.length - 1]?.content)
|
|
121
|
+
|
|
122
|
+
lines.push(`${SUPERSEDES_MARKER} ${latest}`)
|
|
123
|
+
return lines.join("\n")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Opens one ACP connection: spawns `command`, performs `initialize` →
|
|
128
|
+
* `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
|
|
129
|
+
* trap real `devin acp` enforces with `-32602` otherwise) → an optional
|
|
130
|
+
* `session/set_mode`, then returns a client whose `.generate` sends exactly
|
|
131
|
+
* one `session/prompt` per call, accumulating only `agent_message_chunk`
|
|
132
|
+
* text.
|
|
133
|
+
*/
|
|
134
|
+
export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
135
|
+
const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd()
|
|
136
|
+
const permissionTimeoutMs = opts.permissionTimeoutMs ?? 30000
|
|
137
|
+
|
|
138
|
+
const child = spawn(opts.command, opts.args ?? [], {
|
|
139
|
+
cwd,
|
|
140
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
141
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
let closed = false
|
|
145
|
+
let nextId = 0
|
|
146
|
+
const pending = new Map<string, (msg: RpcMessage) => void>()
|
|
147
|
+
let updateSink: ((params: any) => void) | null = null
|
|
148
|
+
|
|
149
|
+
const rl = createInterface({ input: child.stdout! })
|
|
150
|
+
rl.on("line", (raw) => {
|
|
151
|
+
const line = raw.trim()
|
|
152
|
+
if (!line) return
|
|
153
|
+
let msg: RpcMessage
|
|
154
|
+
try {
|
|
155
|
+
msg = JSON.parse(line)
|
|
156
|
+
} catch {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
handleLine(msg)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
function write(msg: Record<string, unknown>): void {
|
|
163
|
+
child.stdin!.write(JSON.stringify(msg) + "\n")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function reply(id: unknown, result: unknown): void {
|
|
167
|
+
write({ jsonrpc: "2.0", id, result })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function call(method: string, params: unknown): Promise<any> {
|
|
171
|
+
const id = `c-${++nextId}`
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
pending.set(id, (msg) => {
|
|
174
|
+
if (msg.error) reject(new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`))
|
|
175
|
+
else resolve(msg.result)
|
|
176
|
+
})
|
|
177
|
+
write({ jsonrpc: "2.0", id, method, params })
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Demultiplexes EVERYTHING arriving on stdout: replies to our requests
|
|
182
|
+
* (matched by JSON-RPC id), `session/update` notifications (interleaved
|
|
183
|
+
* with replies, routed to whichever turn is in flight), and
|
|
184
|
+
* server-initiated requests like `session/request_permission`. */
|
|
185
|
+
function handleLine(msg: RpcMessage): void {
|
|
186
|
+
if (msg.method === undefined && msg.id !== undefined) {
|
|
187
|
+
const cb = pending.get(String(msg.id))
|
|
188
|
+
if (cb) {
|
|
189
|
+
pending.delete(String(msg.id))
|
|
190
|
+
cb(msg)
|
|
191
|
+
}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (msg.method === "session/update") {
|
|
195
|
+
updateSink?.(msg.params)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (msg.method === "session/request_permission" && msg.id !== undefined) {
|
|
199
|
+
answerPermission(msg)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
// An unhandled server->client request still needs a reply or a
|
|
203
|
+
// strict agent may itself hang waiting on one.
|
|
204
|
+
if (msg.id !== undefined) reply(msg.id, {})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Answers with the FIRST option whose `kind` starts with `allow` — an
|
|
208
|
+
* unanswered permission request hangs the turn forever (proven in
|
|
209
|
+
* spikes/acp). This runs the instant the request arrives, never waiting
|
|
210
|
+
* for anything else. */
|
|
211
|
+
function answerPermission(req: RpcMessage): void {
|
|
212
|
+
const options: Array<{ optionId: string; kind?: string }> = req.params?.options ?? []
|
|
213
|
+
const chosen = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"))
|
|
214
|
+
const result = chosen
|
|
215
|
+
? { outcome: { outcome: "selected", optionId: chosen.optionId } }
|
|
216
|
+
: { outcome: { outcome: "cancelled" } }
|
|
217
|
+
reply(req.id, result)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await call("initialize", {
|
|
221
|
+
protocolVersion: opts.protocolVersion ?? 1,
|
|
222
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const sessionResult = await call("session/new", { cwd, mcpServers: [] })
|
|
226
|
+
const sessionId = sessionResult?.sessionId
|
|
227
|
+
|
|
228
|
+
if (opts.mode) {
|
|
229
|
+
await call("session/set_mode", { sessionId, modeId: opts.mode })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Serialises turns: one ACP session is one conversation, so concurrent
|
|
233
|
+
// prompts must not interleave into it. Idiomatic promise-chaining queue —
|
|
234
|
+
// each generate() call attaches after whatever is already queued, and the
|
|
235
|
+
// queue itself never rejects (a failed turn does not poison later ones).
|
|
236
|
+
let turnQueue: Promise<unknown> = Promise.resolve()
|
|
237
|
+
|
|
238
|
+
async function promptOnce(request: InProcessRequest): Promise<InProcessResponse> {
|
|
239
|
+
const text = renderPrompt(request)
|
|
240
|
+
const id = `c-${++nextId}`
|
|
241
|
+
|
|
242
|
+
let content = ""
|
|
243
|
+
updateSink = (params: any) => {
|
|
244
|
+
const update = params?.update
|
|
245
|
+
if (update?.sessionUpdate === "agent_message_chunk") {
|
|
246
|
+
content += update?.content?.text ?? ""
|
|
247
|
+
}
|
|
248
|
+
// agent_thought_chunk and tool_call/tool_call_update narration are
|
|
249
|
+
// deliberately dropped — mixing them into the reply corrupts
|
|
250
|
+
// structured output a caller expects to parse.
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const replyPromise = new Promise<RpcMessage>((resolve) => pending.set(id, resolve))
|
|
254
|
+
write({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: [{ type: "text", text }] } })
|
|
255
|
+
|
|
256
|
+
let timer: NodeJS.Timeout | undefined
|
|
257
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
258
|
+
timer = setTimeout(
|
|
259
|
+
() => reject(new Error(`toolnexus: ACP turn timed out after ${permissionTimeoutMs}ms`)),
|
|
260
|
+
permissionTimeoutMs,
|
|
261
|
+
)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const msg = await Promise.race([replyPromise, timeout])
|
|
266
|
+
if (msg.error) throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`)
|
|
267
|
+
return { content }
|
|
268
|
+
} finally {
|
|
269
|
+
clearTimeout(timer)
|
|
270
|
+
pending.delete(id)
|
|
271
|
+
updateSink = null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const generate = (request: InProcessRequest): Promise<InProcessResponse> => {
|
|
276
|
+
if (closed) return Promise.reject(new Error("toolnexus: ACP client is closed"))
|
|
277
|
+
const turn = turnQueue.then(() => promptOnce(request))
|
|
278
|
+
turnQueue = turn.then(
|
|
279
|
+
() => undefined,
|
|
280
|
+
() => undefined,
|
|
281
|
+
)
|
|
282
|
+
return turn
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const close = (): Promise<void> => {
|
|
286
|
+
if (closed) return Promise.resolve()
|
|
287
|
+
closed = true
|
|
288
|
+
rl.close()
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
291
|
+
resolve()
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
const done = () => resolve()
|
|
295
|
+
child.once("exit", done)
|
|
296
|
+
child.kill()
|
|
297
|
+
// Belt-and-braces: resolve even if the child never emits `exit` (e.g.
|
|
298
|
+
// already reaped), so close() itself never hangs.
|
|
299
|
+
setTimeout(done, 1000)
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const client: ACPClient = { generate, close }
|
|
304
|
+
// Non-enumerable, undocumented — internal test hook only, so the public
|
|
305
|
+
// ACPClient surface stays exactly {generate, close}.
|
|
306
|
+
Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false })
|
|
307
|
+
return client
|
|
308
|
+
}
|
package/src/agents/agent.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { PromptInput } from "../content.js"
|
|
|
11
11
|
import fs from "node:fs"
|
|
12
12
|
import { defineTool } from "../native.js"
|
|
13
13
|
import type { Answer, Request, Tool } from "../types.js"
|
|
14
|
-
import { AgentRuntime, isVerbError, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
|
|
14
|
+
import { AgentRuntime, isVerbError, S, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
|
|
15
15
|
import { Loop, guardedHooks, type Completion, type Guardrail } from "./loop.js"
|
|
16
16
|
import type { ClientOptions } from "../client.js"
|
|
17
17
|
import type { Toolkit } from "../toolkit.js"
|
|
@@ -32,7 +32,18 @@ export interface AgentSpec {
|
|
|
32
32
|
budget?: Budget
|
|
33
33
|
/** Model id; default "inherit" (the runtime's `llm.model`). */
|
|
34
34
|
model?: string
|
|
35
|
-
/** §10 interpreter authority for this agent's subtree.
|
|
35
|
+
/** §10 interpreter authority for this agent's subtree.
|
|
36
|
+
*
|
|
37
|
+
* IDEMPOTENCY CONTRACT (SPEC §7D rewind-to-checkpoint). A durable resume REPLAYS the suspended
|
|
38
|
+
* turn from its PRE-TURN checkpoint: "continues from its checkpoint" means the transcript is
|
|
39
|
+
* REWOUND, not carried. EVERY tool that ran in that turn RUNS AGAIN, and the suspended tool
|
|
40
|
+
* itself is always re-executed (with `ctx.answer`) — that is §10's resolution mechanism, not a
|
|
41
|
+
* bug. Reattachment by task key makes a re-run `task` call idempotent; a leaf agent's own
|
|
42
|
+
* side-effecting tools (`git push`, a charge, a delete) have NO equivalent and are the host's
|
|
43
|
+
* responsibility. Any tool reachable in a suspendable turn must be idempotent.
|
|
44
|
+
* Replaying the leaf's stored transcript instead is DEFERRED to its own change; it would not
|
|
45
|
+
* remove this requirement anyway.
|
|
46
|
+
*/
|
|
36
47
|
waitFor?: (request: Request) => Promise<Answer>
|
|
37
48
|
onSpawn?: AgentDef["onSpawn"]
|
|
38
49
|
onClose?: AgentDef["onClose"]
|
|
@@ -118,7 +129,8 @@ export class Agent {
|
|
|
118
129
|
const runtime = this.createRuntime(opts)
|
|
119
130
|
const h = runtime.spawn(runtime.root, this.name)
|
|
120
131
|
if (isVerbError(h)) {
|
|
121
|
-
|
|
132
|
+
// A spawn refusal is an ERROR, not a limit stop: it names no `limit` (A18).
|
|
133
|
+
return { text: h.error, isError: true, status: S.error, turns: 0, totalTokens: 0, ownTokens: 0, runtime }
|
|
122
134
|
}
|
|
123
135
|
const woke = runtime.wake(h, prompt)
|
|
124
136
|
if (!woke.ok) {
|