toolnexus 0.26.0 → 0.28.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 +6 -2
- package/dist/acp.d.ts +16 -2
- package/dist/acp.js +74 -21
- package/dist/agents/home.d.ts +85 -4
- package/dist/agents/home.js +347 -52
- package/dist/classifier.d.ts +21 -3
- package/dist/classifier.js +175 -9
- package/dist/client.d.ts +109 -0
- package/dist/client.js +380 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +29 -7
- package/dist/mcp2026/source.d.ts +5 -0
- package/dist/mcp2026/source.js +11 -2
- package/dist/mcp2026/stdio.d.ts +43 -0
- package/dist/mcp2026/stdio.js +144 -20
- package/dist/mcphandler.js +2 -4
- package/dist/mcpserve.js +2 -1
- package/dist/nodefetch.d.ts +1 -0
- package/dist/nodefetch.js +156 -0
- package/dist/serve.js +5 -1
- package/dist/skill.d.ts +14 -0
- package/dist/skill.js +33 -2
- package/dist/transcript_search.d.ts +27 -0
- package/dist/transcript_search.js +165 -0
- package/package.json +2 -2
- package/src/acp.ts +101 -22
- package/src/agents/home.ts +381 -53
- package/src/classifier.ts +168 -12
- package/src/client.ts +368 -57
- package/src/index.ts +1 -0
- package/src/mcp.ts +36 -7
- package/src/mcp2026/source.ts +14 -4
- package/src/mcp2026/stdio.ts +148 -23
- package/src/mcphandler.ts +2 -3
- package/src/mcpserve.ts +2 -1
- package/src/nodefetch.ts +143 -0
- package/src/serve.ts +6 -2
- package/src/skill.ts +51 -2
- package/src/transcript_search.ts +172 -0
package/dist/skill.js
CHANGED
|
@@ -12,8 +12,16 @@
|
|
|
12
12
|
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { pathToFileURL } from "node:url";
|
|
15
|
-
import {
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
16
|
import { compareCodePoints, compareDiscovery, sortEntriesByName } from "./order.js";
|
|
17
|
+
// yaml is required on the first frontmatter parse, not at import, so a host with no skills never
|
|
18
|
+
// loads it. `require` keeps the parse synchronous (listSkills/loadSkills are sync); yaml's Node
|
|
19
|
+
// entry is CommonJS, so this is the same module instance an `import` would get.
|
|
20
|
+
let yamlParse;
|
|
21
|
+
function parseYaml(src) {
|
|
22
|
+
yamlParse ??= createRequire(import.meta.url)("yaml").parse;
|
|
23
|
+
return yamlParse(src);
|
|
24
|
+
}
|
|
17
25
|
export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
|
|
18
26
|
|
|
19
27
|
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,6 +33,8 @@ The skill name must match one of the skills listed in your system prompt.`;
|
|
|
25
33
|
*/
|
|
26
34
|
export const SKILLS_PROMPT_PREAMBLE = "Skills provide specialized instructions and workflows for specific tasks.\n" +
|
|
27
35
|
"Use the skill tool to load a skill when a task matches its description.";
|
|
36
|
+
/** Maximum skill `description` length, in Unicode code points, before a warning (SPEC.md §3). */
|
|
37
|
+
export const SKILL_DESCRIPTION_MAX_CHARS = 1024;
|
|
28
38
|
/**
|
|
29
39
|
* Parse YAML frontmatter (between the leading `---` fences) with a real YAML parser, so folded
|
|
30
40
|
* (`>`)/literal (`|`) block scalars, quoting and multi-line values all resolve correctly.
|
|
@@ -304,6 +314,24 @@ function collectCandidates(opts) {
|
|
|
304
314
|
cands.push(...candidatesFromDefs(opts.skills));
|
|
305
315
|
return cands;
|
|
306
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Warn — never modify — skills whose description exceeds SKILL_DESCRIPTION_MAX_CHARS code
|
|
319
|
+
* points (SPEC.md §3, #84). Each warning is also sent to the same channel as the
|
|
320
|
+
* duplicate-name warning. Order follows the given skills (discovery order).
|
|
321
|
+
*/
|
|
322
|
+
function describeWarnings(skills) {
|
|
323
|
+
const out = [];
|
|
324
|
+
for (const s of skills) {
|
|
325
|
+
if (s.description === undefined)
|
|
326
|
+
continue;
|
|
327
|
+
const chars = [...s.description].length;
|
|
328
|
+
if (chars > SKILL_DESCRIPTION_MAX_CHARS) {
|
|
329
|
+
console.warn(`[toolnexus] skill "${s.name}" (${s.location}) description is ${chars} chars, over ${SKILL_DESCRIPTION_MAX_CHARS} — kept unmodified`);
|
|
330
|
+
out.push({ location: s.location, reason: "oversized-description", chars });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
307
335
|
/** Dedupe candidates by name (first-wins); later duplicates become skips. */
|
|
308
336
|
function mergeCandidates(cands) {
|
|
309
337
|
const skills = {};
|
|
@@ -355,7 +383,8 @@ function applySkillsFilter(skills, filter) {
|
|
|
355
383
|
export function listSkills(input) {
|
|
356
384
|
const opts = typeof input === "string" || Array.isArray(input) ? { dirs: input } : input;
|
|
357
385
|
const merged = mergeCandidates(collectCandidates(opts));
|
|
358
|
-
|
|
386
|
+
const list = Object.values(merged.skills);
|
|
387
|
+
return { skills: list, skipped: merged.skipped, warnings: describeWarnings(list) };
|
|
359
388
|
}
|
|
360
389
|
/** Discover skills (dirs and/or data) and build the `skill` loader tool. */
|
|
361
390
|
export function loadSkills(input) {
|
|
@@ -364,6 +393,7 @@ export function loadSkills(input) {
|
|
|
364
393
|
const merged = mergeCandidates(collectCandidates(opts));
|
|
365
394
|
const skills = applySkillsFilter(merged.skills, opts.filter);
|
|
366
395
|
const skipped = merged.skipped;
|
|
396
|
+
const warnings = describeWarnings(Object.values(skills));
|
|
367
397
|
const tool = {
|
|
368
398
|
name: "skill",
|
|
369
399
|
description: SKILL_TOOL_DESCRIPTION,
|
|
@@ -426,6 +456,7 @@ export function loadSkills(input) {
|
|
|
426
456
|
skills,
|
|
427
457
|
tool,
|
|
428
458
|
skipped,
|
|
459
|
+
warnings,
|
|
429
460
|
prompt() {
|
|
430
461
|
const described = Object.values(skills)
|
|
431
462
|
.filter((s) => s.description !== undefined)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Tool } from "./types.js";
|
|
2
|
+
import type { ConversationEntry, ConversationStore } from "./client.js";
|
|
3
|
+
/** Options for {@link transcriptSearchTool}. */
|
|
4
|
+
export interface TranscriptSearchOptions {
|
|
5
|
+
/** Most hits returned per call (and the ceiling for the `limit` input). Default 20. */
|
|
6
|
+
maxResults?: number;
|
|
7
|
+
/** Snippet length in characters (Unicode code points, not bytes). Default 200. */
|
|
8
|
+
snippetChars?: number;
|
|
9
|
+
/** Tool name. Default `transcript_search`. */
|
|
10
|
+
name?: string;
|
|
11
|
+
}
|
|
12
|
+
/** A store that can enumerate its conversations — what transcript search requires. */
|
|
13
|
+
export type ListableConversationStore = ConversationStore & {
|
|
14
|
+
list(): Promise<ConversationEntry[]>;
|
|
15
|
+
};
|
|
16
|
+
export declare const TRANSCRIPT_SEARCH_DESCRIPTION: string;
|
|
17
|
+
/** The searchable text of one stored message (§7E): string content, text parts, tool_use
|
|
18
|
+
* input as compact JSON, tool_result content, then each OpenAI tool call's `arguments`
|
|
19
|
+
* string — pieces joined by "\n", then whitespace-normalized. Images are not text. */
|
|
20
|
+
export declare function messageText(msg: unknown): string;
|
|
21
|
+
/**
|
|
22
|
+
* A read-only {@link Tool} over `store` (§7E). Construction throws unless the store has
|
|
23
|
+
* `list()`. Input `{query, conversationId?, limit?}`; output one line per hit,
|
|
24
|
+
* `<conversationId>#<index> <role>: <snippet>`, `no matches` when none, and a trailing
|
|
25
|
+
* `(+<k> more)` when hits exceed the limit. Never writes.
|
|
26
|
+
*/
|
|
27
|
+
export declare function transcriptSearchTool(store: ConversationStore, opts?: TranscriptSearchOptions): Tool;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transcriptSearchTool` — read-only search over a {@link ConversationStore} (SPEC.md §7E
|
|
3
|
+
* "Memory hierarchy" → transcript search). The ground-truth tier of agent memory: a dreaming
|
|
4
|
+
* persona checks its notes against what actually happened, not against its own notes.
|
|
5
|
+
*/
|
|
6
|
+
import { defineTool } from "./native.js";
|
|
7
|
+
export const TRANSCRIPT_SEARCH_DESCRIPTION = "Search past conversation transcripts — the ground truth of what actually happened. " +
|
|
8
|
+
"Case-insensitive substring match over message text. Returns one line per matching " +
|
|
9
|
+
"message: <conversationId>#<index> <role>: <snippet>. Read-only.";
|
|
10
|
+
const WS = /[ \t\n\r\f\v]+/g;
|
|
11
|
+
/** Collapse every run of ASCII whitespace to one space and trim (§7E). */
|
|
12
|
+
function normalize(s) {
|
|
13
|
+
return s.replace(WS, " ").replace(/^ | $/g, "");
|
|
14
|
+
}
|
|
15
|
+
/** ASCII-only case fold — never Unicode lowercasing (it changes lengths, differs per port). */
|
|
16
|
+
function fold(s) {
|
|
17
|
+
return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
|
|
18
|
+
}
|
|
19
|
+
/** Code-point order (not UTF-16 code-unit order, which differs above U+FFFF). */
|
|
20
|
+
function byCodePoint(a, b) {
|
|
21
|
+
const x = Array.from(a);
|
|
22
|
+
const y = Array.from(b);
|
|
23
|
+
for (let i = 0; i < x.length && i < y.length; i++) {
|
|
24
|
+
const d = x[i].codePointAt(0) - y[i].codePointAt(0);
|
|
25
|
+
if (d !== 0)
|
|
26
|
+
return d;
|
|
27
|
+
}
|
|
28
|
+
return x.length - y.length;
|
|
29
|
+
}
|
|
30
|
+
/** A copy with every object's keys sorted by code point, recursively (§7E); arrays keep order. */
|
|
31
|
+
function sortKeys(v) {
|
|
32
|
+
if (Array.isArray(v))
|
|
33
|
+
return v.map(sortKeys);
|
|
34
|
+
if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype) {
|
|
35
|
+
const o = v;
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const k of Object.keys(o).sort(byCodePoint))
|
|
38
|
+
out[k] = sortKeys(o[k]);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
return v;
|
|
42
|
+
}
|
|
43
|
+
function partsText(content) {
|
|
44
|
+
if (typeof content === "string")
|
|
45
|
+
return [content];
|
|
46
|
+
if (!Array.isArray(content))
|
|
47
|
+
return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const p of content) {
|
|
50
|
+
if (!p || typeof p !== "object")
|
|
51
|
+
continue;
|
|
52
|
+
const part = p;
|
|
53
|
+
if (part.type === "text" && typeof part.text === "string")
|
|
54
|
+
out.push(part.text);
|
|
55
|
+
else if (part.type === "tool_use")
|
|
56
|
+
out.push(JSON.stringify(sortKeys(part.input ?? {})));
|
|
57
|
+
else if (part.type === "tool_result")
|
|
58
|
+
out.push(...partsText(part.content));
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** The searchable text of one stored message (§7E): string content, text parts, tool_use
|
|
63
|
+
* input as compact JSON, tool_result content, then each OpenAI tool call's `arguments`
|
|
64
|
+
* string — pieces joined by "\n", then whitespace-normalized. Images are not text. */
|
|
65
|
+
export function messageText(msg) {
|
|
66
|
+
if (!msg || typeof msg !== "object")
|
|
67
|
+
return "";
|
|
68
|
+
const m = msg;
|
|
69
|
+
const pieces = partsText(m.content);
|
|
70
|
+
if (Array.isArray(m.tool_calls)) {
|
|
71
|
+
for (const tc of m.tool_calls) {
|
|
72
|
+
const a = tc?.function?.arguments;
|
|
73
|
+
if (typeof a === "string")
|
|
74
|
+
pieces.push(a);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return normalize(pieces.join("\n"));
|
|
78
|
+
}
|
|
79
|
+
/** Snippet of `snippetChars` code points centred on the match; `…` marks elision. */
|
|
80
|
+
function snippet(cps, at, qlen, size) {
|
|
81
|
+
if (cps.length <= size)
|
|
82
|
+
return cps.join("");
|
|
83
|
+
let start = at + Math.floor(qlen / 2) - Math.floor(size / 2);
|
|
84
|
+
start = Math.max(0, Math.min(start, cps.length - size));
|
|
85
|
+
const end = start + size;
|
|
86
|
+
return (start > 0 ? "…" : "") + cps.slice(start, end).join("") + (end < cps.length ? "…" : "");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A read-only {@link Tool} over `store` (§7E). Construction throws unless the store has
|
|
90
|
+
* `list()`. Input `{query, conversationId?, limit?}`; output one line per hit,
|
|
91
|
+
* `<conversationId>#<index> <role>: <snippet>`, `no matches` when none, and a trailing
|
|
92
|
+
* `(+<k> more)` when hits exceed the limit. Never writes.
|
|
93
|
+
*/
|
|
94
|
+
export function transcriptSearchTool(store, opts = {}) {
|
|
95
|
+
if (!store || typeof store.list !== "function") {
|
|
96
|
+
throw new Error("transcriptSearchTool: the store must implement list()");
|
|
97
|
+
}
|
|
98
|
+
const listable = store;
|
|
99
|
+
const maxResults = opts.maxResults ?? 20;
|
|
100
|
+
const snippetChars = opts.snippetChars ?? 200;
|
|
101
|
+
if (!Number.isInteger(maxResults) || maxResults < 1)
|
|
102
|
+
throw new Error("transcriptSearchTool: maxResults must be an integer >= 1");
|
|
103
|
+
if (!Number.isInteger(snippetChars) || snippetChars < 1)
|
|
104
|
+
throw new Error("transcriptSearchTool: snippetChars must be an integer >= 1");
|
|
105
|
+
return defineTool({
|
|
106
|
+
name: opts.name ?? "transcript_search",
|
|
107
|
+
description: TRANSCRIPT_SEARCH_DESCRIPTION,
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
query: { type: "string", description: "Text to find (case-insensitive substring)." },
|
|
112
|
+
conversationId: { type: "string", description: "Search only this conversation." },
|
|
113
|
+
limit: { type: "integer", description: `Most hits to return (default and max ${maxResults}).` },
|
|
114
|
+
},
|
|
115
|
+
required: ["query"],
|
|
116
|
+
},
|
|
117
|
+
run: async (args) => {
|
|
118
|
+
const query = typeof args.query === "string" ? normalize(args.query) : "";
|
|
119
|
+
if (query === "")
|
|
120
|
+
return { output: "query is required", isError: true };
|
|
121
|
+
let limit = maxResults;
|
|
122
|
+
if (args.limit !== undefined) {
|
|
123
|
+
if (!Number.isInteger(args.limit) || args.limit < 1) {
|
|
124
|
+
return { output: "limit must be an integer >= 1", isError: true };
|
|
125
|
+
}
|
|
126
|
+
limit = Math.min(args.limit, maxResults);
|
|
127
|
+
}
|
|
128
|
+
const ids = typeof args.conversationId === "string" ? [args.conversationId] : (await listable.list()).map((e) => e.id);
|
|
129
|
+
const q = Array.from(fold(query));
|
|
130
|
+
const hits = [];
|
|
131
|
+
let total = 0;
|
|
132
|
+
for (const id of ids) {
|
|
133
|
+
const msgs = await listable.get(id);
|
|
134
|
+
if (!msgs)
|
|
135
|
+
continue;
|
|
136
|
+
msgs.forEach((msg, index) => {
|
|
137
|
+
const cps = Array.from(messageText(msg));
|
|
138
|
+
const hay = Array.from(fold(cps.join("")));
|
|
139
|
+
const at = indexOfCps(hay, q);
|
|
140
|
+
if (at < 0)
|
|
141
|
+
return;
|
|
142
|
+
total++;
|
|
143
|
+
if (hits.length < limit) {
|
|
144
|
+
const role = typeof msg?.role === "string" ? msg.role : "unknown";
|
|
145
|
+
hits.push(`${id}#${index} ${role}: ${snippet(cps, at, q.length, snippetChars)}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (total === 0)
|
|
150
|
+
return "no matches";
|
|
151
|
+
if (total > hits.length)
|
|
152
|
+
hits.push(`(+${total - hits.length} more)`);
|
|
153
|
+
return hits.join("\n");
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function indexOfCps(hay, needle) {
|
|
158
|
+
outer: for (let i = 0; i + needle.length <= hay.length; i++) {
|
|
159
|
+
for (let j = 0; j < needle.length; j++)
|
|
160
|
+
if (hay[i + j] !== needle[j])
|
|
161
|
+
continue outer;
|
|
162
|
+
return i;
|
|
163
|
+
}
|
|
164
|
+
return -1;
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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/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/classifier-backends.test.ts test/acp.test.ts test/acp-client.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/tool-concurrency.test.ts test/media.test.ts test/examples.test.ts test/mcphandler.test.ts test/mcphandler-conformance.test.ts test/mcp2026.test.ts test/serve-mcp-conformance.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/memory-hierarchy.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/classifier-backends.test.ts test/acp.test.ts test/acp-client.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/tool-concurrency.test.ts test/media.test.ts test/examples.test.ts test/mcphandler.test.ts test/mcphandler-conformance.test.ts test/mcp2026.test.ts test/serve-mcp-conformance.test.ts test/mock-llm.test.ts test/nodefetch.test.ts test/retry-stream.test.ts test/stop-recovery.test.ts test/round-boundary.test.ts test/reactive-compaction.test.ts test/fallback-models.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
CHANGED
|
@@ -86,15 +86,34 @@ export interface ACPOptions {
|
|
|
86
86
|
/** One warm ACP connection: one child process, one session, alive across many
|
|
87
87
|
* turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
|
|
88
88
|
* wants, so it plugs straight into {@link createInProcessClient}. */
|
|
89
|
+
/** Per-call options for {@link ACPClient.generate} / {@link ACPClient.generateStream}. */
|
|
90
|
+
export interface ACPGenerateOptions {
|
|
91
|
+
/** Cancels the turn: when it aborts mid-turn the `session/cancel` notification is sent,
|
|
92
|
+
* the turn's reply is awaited (bounded by `requestTimeoutMs`, default 30s) so the next
|
|
93
|
+
* turn never lands on a busy session, and the call rejects with an `AbortError`. The
|
|
94
|
+
* process and session stay warm. */
|
|
95
|
+
signal?: AbortSignal
|
|
96
|
+
}
|
|
97
|
+
|
|
89
98
|
export interface ACPClient {
|
|
90
|
-
generate: (request: InProcessRequest) => Promise<InProcessResponse>
|
|
99
|
+
generate: (request: InProcessRequest, opts?: ACPGenerateOptions) => Promise<InProcessResponse>
|
|
91
100
|
/** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
|
|
92
101
|
* agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
|
|
93
102
|
* tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
|
|
94
103
|
* (both via "acp": the agent's own tools, reported, never executed by the loop).
|
|
95
104
|
* Shaped for {@link InProcessOptions.generateStream}. The returned content is the
|
|
96
105
|
* message text only. */
|
|
97
|
-
generateStream: (
|
|
106
|
+
generateStream: (
|
|
107
|
+
request: InProcessRequest,
|
|
108
|
+
emit: (event: StreamEvent) => void,
|
|
109
|
+
opts?: ACPGenerateOptions,
|
|
110
|
+
) => Promise<InProcessResponse>
|
|
111
|
+
/** Sends `session/set_mode {sessionId, modeId}` on the running session (no restart). The
|
|
112
|
+
* agent's error rejects this call only; the client stays usable. */
|
|
113
|
+
setMode: (mode: string) => Promise<void>
|
|
114
|
+
/** Changes the model on the running session exactly as the `model` option does:
|
|
115
|
+
* `session/set_config_option {configId:"model"}`, then `session/set_model`; both failing rejects. */
|
|
116
|
+
setModel: (model: string) => Promise<void>
|
|
98
117
|
/** Terminates the agent's process group. Idempotent — a second call is a no-op. */
|
|
99
118
|
close: () => Promise<void>
|
|
100
119
|
/** The agent's raw `initialize` result. */
|
|
@@ -353,20 +372,21 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
353
372
|
if (opts.mode) {
|
|
354
373
|
await step("session/set_mode", () => call("session/set_mode", { sessionId, modeId: opts.mode }))
|
|
355
374
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
375
|
+
// Passed through unchanged. Stable ACP spelling first, then the older session/set_model.
|
|
376
|
+
const sendModel = async (model: string): Promise<void> => {
|
|
377
|
+
try {
|
|
378
|
+
await call("session/set_config_option", { sessionId, configId: "model", value: model })
|
|
379
|
+
} catch (e1) {
|
|
360
380
|
try {
|
|
361
|
-
await call("session/
|
|
362
|
-
} catch (
|
|
363
|
-
|
|
364
|
-
await call("session/set_model", { sessionId, modelId: model })
|
|
365
|
-
} catch (e2) {
|
|
366
|
-
throw new Error(`${(e1 as Error).message}; ${(e2 as Error).message}`)
|
|
367
|
-
}
|
|
381
|
+
await call("session/set_model", { sessionId, modelId: model })
|
|
382
|
+
} catch (e2) {
|
|
383
|
+
throw new Error(`${(e1 as Error).message}; ${(e2 as Error).message}`)
|
|
368
384
|
}
|
|
369
|
-
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (opts.model) {
|
|
388
|
+
const model = opts.model
|
|
389
|
+
await step(`set model ${JSON.stringify(model)}`, () => sendModel(model))
|
|
370
390
|
}
|
|
371
391
|
|
|
372
392
|
// Serialises turns: one ACP session is one conversation, so concurrent
|
|
@@ -375,7 +395,12 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
375
395
|
// queue itself never rejects (a failed turn does not poison later ones).
|
|
376
396
|
let turnQueue: Promise<unknown> = Promise.resolve()
|
|
377
397
|
|
|
378
|
-
async function promptOnce(
|
|
398
|
+
async function promptOnce(
|
|
399
|
+
request: InProcessRequest,
|
|
400
|
+
emit?: (event: StreamEvent) => void,
|
|
401
|
+
signal?: AbortSignal,
|
|
402
|
+
): Promise<InProcessResponse> {
|
|
403
|
+
if (signal?.aborted) throw acpAbortError()
|
|
379
404
|
const text = renderPrompt(request)
|
|
380
405
|
const id = `c-${++nextId}`
|
|
381
406
|
|
|
@@ -413,11 +438,18 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
413
438
|
break
|
|
414
439
|
}
|
|
415
440
|
case "tool_call_update": {
|
|
441
|
+
const callId: string = u.toolCallId ?? ""
|
|
442
|
+
// A non-empty title is remembered as the call's name for every later event (#188).
|
|
443
|
+
if (u.title) toolNames.set(callId, u.title)
|
|
444
|
+
const name: string = toolNames.get(callId) || ""
|
|
445
|
+
const raw = u.rawInput
|
|
446
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && Object.keys(raw).length > 0) {
|
|
447
|
+
send({ type: "tool_call_args", id: callId, name, args: raw, via: "acp" })
|
|
448
|
+
}
|
|
416
449
|
if (u.status !== "completed" && u.status !== "failed") break
|
|
417
|
-
const name: string = u.title || toolNames.get(u.toolCallId ?? "") || ""
|
|
418
450
|
send({
|
|
419
451
|
type: "tool_result",
|
|
420
|
-
id:
|
|
452
|
+
id: callId,
|
|
421
453
|
name,
|
|
422
454
|
output: acpToolContentText(u.content),
|
|
423
455
|
isError: u.status === "failed",
|
|
@@ -440,21 +472,48 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
440
472
|
)
|
|
441
473
|
})
|
|
442
474
|
|
|
475
|
+
let onAbort: (() => void) | undefined
|
|
476
|
+
const cancelled = new Promise<"cancelled">((resolve) => {
|
|
477
|
+
if (!signal) return
|
|
478
|
+
onAbort = () => resolve("cancelled")
|
|
479
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
480
|
+
})
|
|
481
|
+
|
|
443
482
|
try {
|
|
444
|
-
const msg = await Promise.race([replyPromise, timeout])
|
|
483
|
+
const msg = await Promise.race([replyPromise, timeout, cancelled])
|
|
484
|
+
if (msg === "cancelled") {
|
|
485
|
+
// ACP: session/cancel is a notification (no id); the agent then ends the turn with
|
|
486
|
+
// stopReason "cancelled". Wait (bounded) for that reply so the session is idle again.
|
|
487
|
+
write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } })
|
|
488
|
+
const waitMs = opts.requestTimeoutMs && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : 30000
|
|
489
|
+
let wait: NodeJS.Timeout | undefined
|
|
490
|
+
await Promise.race([
|
|
491
|
+
replyPromise,
|
|
492
|
+
new Promise((r) => {
|
|
493
|
+
wait = setTimeout(r, waitMs)
|
|
494
|
+
}),
|
|
495
|
+
])
|
|
496
|
+
clearTimeout(wait)
|
|
497
|
+
throw acpAbortError()
|
|
498
|
+
}
|
|
445
499
|
if (msg.error && msg.error.code === 0) throw new Error(msg.error.message)
|
|
446
500
|
if (msg.error) throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`)
|
|
447
501
|
return { content }
|
|
448
502
|
} finally {
|
|
449
503
|
clearTimeout(timer)
|
|
504
|
+
if (onAbort) signal!.removeEventListener("abort", onAbort)
|
|
450
505
|
pending.delete(id)
|
|
451
506
|
updateSink = null
|
|
452
507
|
}
|
|
453
508
|
}
|
|
454
509
|
|
|
455
|
-
const generateStream = (
|
|
510
|
+
const generateStream = (
|
|
511
|
+
request: InProcessRequest,
|
|
512
|
+
emit?: (event: StreamEvent) => void,
|
|
513
|
+
callOpts?: ACPGenerateOptions,
|
|
514
|
+
): Promise<InProcessResponse> => {
|
|
456
515
|
if (closed) return Promise.reject(new Error("toolnexus: ACP client is closed"))
|
|
457
|
-
const turn = turnQueue.then(() => promptOnce(request, emit))
|
|
516
|
+
const turn = turnQueue.then(() => promptOnce(request, emit, callOpts?.signal))
|
|
458
517
|
turnQueue = turn.then(
|
|
459
518
|
() => undefined,
|
|
460
519
|
() => undefined,
|
|
@@ -478,15 +537,35 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
478
537
|
if (t === "late") await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())])
|
|
479
538
|
}
|
|
480
539
|
|
|
481
|
-
const generate = (request: InProcessRequest): Promise<InProcessResponse> =>
|
|
540
|
+
const generate = (request: InProcessRequest, callOpts?: ACPGenerateOptions): Promise<InProcessResponse> =>
|
|
541
|
+
generateStream(request, undefined, callOpts)
|
|
482
542
|
|
|
483
|
-
|
|
543
|
+
// SPEC §8C (#197): sent at once on the running session — no restart, not queued behind a turn.
|
|
544
|
+
const liveCall = async (label: string, fn: () => Promise<unknown>): Promise<void> => {
|
|
545
|
+
if (closed) throw new Error("toolnexus: ACP client is closed")
|
|
546
|
+
try {
|
|
547
|
+
await fn()
|
|
548
|
+
} catch (e) {
|
|
549
|
+
throw new Error(`toolnexus: acp: ${label}: ${e instanceof Error ? e.message : String(e)}`)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const setMode = (mode: string) =>
|
|
553
|
+
liveCall("session/set_mode", () => call("session/set_mode", { sessionId, modeId: mode }))
|
|
554
|
+
const setModel = (model: string) => liveCall(`set model ${JSON.stringify(model)}`, () => sendModel(model))
|
|
555
|
+
|
|
556
|
+
const client: ACPClient = { generate, generateStream, setMode, setModel, close, initializeResult }
|
|
484
557
|
// Non-enumerable, undocumented — internal test hook only, so the public
|
|
485
558
|
// ACPClient surface stays exactly {generate, generateStream, close}.
|
|
486
559
|
Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false })
|
|
487
560
|
return client
|
|
488
561
|
}
|
|
489
562
|
|
|
563
|
+
function acpAbortError(): Error {
|
|
564
|
+
const e = new Error("toolnexus: ACP turn cancelled")
|
|
565
|
+
e.name = "AbortError"
|
|
566
|
+
return e
|
|
567
|
+
}
|
|
568
|
+
|
|
490
569
|
/** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
|
|
491
570
|
function killGroup(pid: number, signal: NodeJS.Signals): void {
|
|
492
571
|
if (process.platform !== "win32") {
|