toolnexus 0.23.0 → 0.25.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 +139 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/agents/loop.js +4 -1
- package/dist/agents/runtime.js +4 -1
- package/dist/client.d.ts +55 -9
- package/dist/client.js +154 -41
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp.d.ts +32 -7
- package/dist/mcp.js +125 -36
- package/dist/mcp2026/auth.d.ts +137 -0
- package/dist/mcp2026/auth.js +446 -0
- package/dist/mcp2026/client.d.ts +48 -0
- package/dist/mcp2026/client.js +160 -0
- package/dist/mcp2026/protocol.d.ts +54 -0
- package/dist/mcp2026/protocol.js +113 -0
- package/dist/mcp2026/skills.d.ts +5 -0
- package/dist/mcp2026/skills.js +25 -0
- package/dist/mcp2026/source.d.ts +86 -0
- package/dist/mcp2026/source.js +372 -0
- package/dist/mcp2026/stdio.d.ts +24 -0
- package/dist/mcp2026/stdio.js +99 -0
- package/dist/mcp2026/toolsearch.d.ts +5 -0
- package/dist/mcp2026/toolsearch.js +58 -0
- package/dist/mcphandler.d.ts +169 -0
- package/dist/mcphandler.js +1145 -0
- package/dist/mcpheader.d.ts +5 -0
- package/dist/mcpheader.js +18 -0
- package/dist/mcpserve.d.ts +24 -15
- package/dist/mcpserve.js +105 -2
- package/dist/mcpserverauth.d.ts +48 -0
- package/dist/mcpserverauth.js +203 -0
- package/dist/media.d.ts +126 -0
- package/dist/media.js +540 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolconcurrency.d.ts +16 -0
- package/dist/toolconcurrency.js +59 -0
- package/dist/toolkit.d.ts +23 -1
- package/dist/toolkit.js +26 -4
- package/dist/types.d.ts +26 -1
- package/package.json +5 -3
- package/src/acp.ts +258 -55
- package/src/agents/loop.ts +4 -1
- package/src/agents/runtime.ts +4 -1
- package/src/client.ts +196 -48
- package/src/index.ts +4 -0
- package/src/mcp.ts +162 -43
- package/src/mcp2026/auth.ts +473 -0
- package/src/mcp2026/client.ts +161 -0
- package/src/mcp2026/protocol.ts +135 -0
- package/src/mcp2026/skills.ts +22 -0
- package/src/mcp2026/source.ts +407 -0
- package/src/mcp2026/stdio.ts +102 -0
- package/src/mcp2026/toolsearch.ts +56 -0
- package/src/mcphandler.ts +1252 -0
- package/src/mcpheader.ts +19 -0
- package/src/mcpserve.ts +115 -2
- package/src/mcpserverauth.ts +216 -0
- package/src/media.ts +604 -0
- package/src/skill.ts +50 -0
- package/src/toolconcurrency.ts +67 -0
- package/src/toolkit.ts +35 -5
- package/src/types.ts +27 -1
package/dist/toolkit.d.ts
CHANGED
|
@@ -7,8 +7,10 @@ import { type SkillDef } from "./skill.js";
|
|
|
7
7
|
import { type BuiltinsConfig } from "./builtin.js";
|
|
8
8
|
import { type Agent } from "./a2a.js";
|
|
9
9
|
import { type A2AConfig, type OnTask, type ServeHandle } from "./serve.js";
|
|
10
|
-
import { type MCPServeConfig, type OnCall } from "./mcpserve.js";
|
|
10
|
+
import { type LocalMcpServer, type MCPServeConfig, type OnCall } from "./mcpserve.js";
|
|
11
|
+
import { type McpHandler, type McpHandlerConfig } from "./mcphandler.js";
|
|
11
12
|
import type { Client } from "./client.js";
|
|
13
|
+
import type { Media } from "./media.js";
|
|
12
14
|
export interface ToolkitOptions {
|
|
13
15
|
mcpConfig?: string | object;
|
|
14
16
|
skillsDir?: string | string[];
|
|
@@ -28,6 +30,8 @@ export interface ToolkitOptions {
|
|
|
28
30
|
/** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files> (§3, S5). */
|
|
29
31
|
skillSampleLimit?: number;
|
|
30
32
|
extraTools?: Tool[];
|
|
33
|
+
/** Media generation (§7G): registers `media.tools()` (source "media"). */
|
|
34
|
+
media?: Media;
|
|
31
35
|
/** Built-in tools (§4A). On by default. false | { disabled:true } | { enabled:false } ⇒ off. */
|
|
32
36
|
builtins?: BuiltinsConfig;
|
|
33
37
|
/** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
|
|
@@ -42,6 +46,10 @@ export interface ToolkitOptions {
|
|
|
42
46
|
/** §2 Gap 3. Bounds the MCP load: each connect/list is capped by the server timeout and the
|
|
43
47
|
* whole load aborts promptly if this signal fires. Omit ⇒ unbounded as before. */
|
|
44
48
|
signal?: AbortSignal;
|
|
49
|
+
/** §2A.1a where MCP OAuth tokens live (default: in memory, per load). */
|
|
50
|
+
tokenStore?: import("./mcp2026/auth.js").TokenStore;
|
|
51
|
+
/** §2A.1a how `auto` MCP OAuth opens the authorization URL (default: the OS opener). */
|
|
52
|
+
openUrl?: (url: string) => unknown | Promise<unknown>;
|
|
45
53
|
}
|
|
46
54
|
export declare class Toolkit {
|
|
47
55
|
private readonly mcp;
|
|
@@ -78,6 +86,20 @@ export declare class Toolkit {
|
|
|
78
86
|
mcp?: MCPServeConfig;
|
|
79
87
|
onCall?: OnCall;
|
|
80
88
|
}): Promise<ServeHandle>;
|
|
89
|
+
/**
|
|
90
|
+
* §7C.1 (ADR 0039). An embeddable streamable-HTTP MCP server as a Node request listener
|
|
91
|
+
* `(req, res) => void` (+ `notifyToolsChanged()`), to mount on the host's own server/router
|
|
92
|
+
* behind its own TLS, CORS and OAuth. Every policy is a hook in `cfg`; `serve()` is unchanged.
|
|
93
|
+
* Without `cfg.listTools` it exposes this toolkit's tools.
|
|
94
|
+
*/
|
|
95
|
+
mcpHandler(cfg?: McpHandlerConfig): McpHandler;
|
|
96
|
+
/**
|
|
97
|
+
* SPEC §8C. Serves this toolkit for a local agent to call: the §7C MCP mapping (tool names
|
|
98
|
+
* verbatim, `skill` stays `skill`, optional `config` filter) on `127.0.0.1:<ephemeral>/mcp`
|
|
99
|
+
* behind a fresh 64-hex bearer token. The caller builds whatever `mcpServers` entry its agent
|
|
100
|
+
* accepts from `{url, token}`; `close()` stops it (idempotent).
|
|
101
|
+
*/
|
|
102
|
+
asMcpServer(config?: MCPServeConfig): Promise<LocalMcpServer>;
|
|
81
103
|
execute(name: string, args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>;
|
|
82
104
|
/** Markdown skill catalog for the system prompt. */
|
|
83
105
|
skillsPrompt(): string;
|
package/dist/toolkit.js
CHANGED
|
@@ -3,6 +3,8 @@ import { loadSkills } from "./skill.js";
|
|
|
3
3
|
import { selectBuiltins } from "./builtin.js";
|
|
4
4
|
import { agentTools, parseAgentsConfig } from "./a2a.js";
|
|
5
5
|
import { startA2AServer } from "./serve.js";
|
|
6
|
+
import { serveLocalMcp } from "./mcpserve.js";
|
|
7
|
+
import { createMcpHandler } from "./mcphandler.js";
|
|
6
8
|
import { toAnthropic, toGemini, toOpenAI } from "./adapters.js";
|
|
7
9
|
export class Toolkit {
|
|
8
10
|
mcp;
|
|
@@ -39,12 +41,13 @@ export class Toolkit {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
static async create(opts = {}) {
|
|
42
|
-
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal }) : undefined;
|
|
44
|
+
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal, tokenStore: opts.tokenStore, openUrl: opts.openUrl, disableTools: opts.disableTools }) : undefined;
|
|
43
45
|
// Skills come from directories, in-memory data, and/or a lazy provider (§3,
|
|
44
46
|
// S1). The provider is resolved here (create() is async) and merged with the
|
|
45
47
|
// data list; a provider failure is isolated so other sources still load.
|
|
46
48
|
let skill;
|
|
47
|
-
|
|
49
|
+
const mcpSkills = mcp?.skills ?? [];
|
|
50
|
+
if (opts.skillsDir !== undefined || opts.skills !== undefined || opts.skillProvider !== undefined || mcpSkills.length > 0) {
|
|
48
51
|
let providerDefs = [];
|
|
49
52
|
if (opts.skillProvider) {
|
|
50
53
|
try {
|
|
@@ -55,7 +58,8 @@ export class Toolkit {
|
|
|
55
58
|
providerDefs = [];
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
|
-
|
|
61
|
+
// §2.7 remote skills (MCP skills extension) join the same inventory, after local ones.
|
|
62
|
+
const dataDefs = [...(opts.skills ?? []), ...providerDefs, ...mcpSkills];
|
|
59
63
|
// disableSkills is sugar over a skillsFilter drop-list: fold each name in as false
|
|
60
64
|
// WITHOUT overriding an explicit skillsFilter entry (explicit entries win).
|
|
61
65
|
const filter = opts.disableSkills && opts.disableSkills.length > 0
|
|
@@ -89,7 +93,7 @@ export class Toolkit {
|
|
|
89
93
|
console.warn(`[toolnexus] A2A agent "${a.card}" failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
90
94
|
return [];
|
|
91
95
|
})));
|
|
92
|
-
const agentToolsFlat = agentToolLists.flat();
|
|
96
|
+
const agentToolsFlat = [...agentToolLists.flat(), ...(opts.media ? opts.media.tools() : [])];
|
|
93
97
|
// A2A inbound profile: a top-level `a2a` block on a parsed config object
|
|
94
98
|
// (mirrors builtins/agents). serve() prefers its inline `a2a` over this.
|
|
95
99
|
let a2aConfig;
|
|
@@ -157,6 +161,24 @@ export class Toolkit {
|
|
|
157
161
|
onCall: opts.onCall,
|
|
158
162
|
});
|
|
159
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* §7C.1 (ADR 0039). An embeddable streamable-HTTP MCP server as a Node request listener
|
|
166
|
+
* `(req, res) => void` (+ `notifyToolsChanged()`), to mount on the host's own server/router
|
|
167
|
+
* behind its own TLS, CORS and OAuth. Every policy is a hook in `cfg`; `serve()` is unchanged.
|
|
168
|
+
* Without `cfg.listTools` it exposes this toolkit's tools.
|
|
169
|
+
*/
|
|
170
|
+
mcpHandler(cfg = {}) {
|
|
171
|
+
return createMcpHandler(() => this.tools(), cfg);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* SPEC §8C. Serves this toolkit for a local agent to call: the §7C MCP mapping (tool names
|
|
175
|
+
* verbatim, `skill` stays `skill`, optional `config` filter) on `127.0.0.1:<ephemeral>/mcp`
|
|
176
|
+
* behind a fresh 64-hex bearer token. The caller builds whatever `mcpServers` entry its agent
|
|
177
|
+
* accepts from `{url, token}`; `close()` stops it (idempotent).
|
|
178
|
+
*/
|
|
179
|
+
asMcpServer(config) {
|
|
180
|
+
return serveLocalMcp(this.tools(), config);
|
|
181
|
+
}
|
|
160
182
|
async execute(name, args, ctx) {
|
|
161
183
|
const tool = this.byName.get(name);
|
|
162
184
|
if (!tool) {
|
package/dist/types.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type JSONSchema = {
|
|
|
9
9
|
[k: string]: unknown;
|
|
10
10
|
};
|
|
11
11
|
import type { ContentPart } from "./content.js";
|
|
12
|
-
export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom";
|
|
12
|
+
export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "media" | "custom";
|
|
13
13
|
export interface ToolResult {
|
|
14
14
|
output: string;
|
|
15
15
|
isError: boolean;
|
|
@@ -21,6 +21,21 @@ export interface ToolResult {
|
|
|
21
21
|
*/
|
|
22
22
|
parts?: ContentPart[];
|
|
23
23
|
metadata?: Record<string, unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* Machine-readable result (MCP `structuredContent`, SPEC §1). Absent ⇒ byte-identical wire.
|
|
26
|
+
* When set and `output` is empty, MCP serve emits the JSON text of `structured` as the text block.
|
|
27
|
+
*/
|
|
28
|
+
structured?: unknown;
|
|
29
|
+
/** Protocol metadata for the wire result (MCP `_meta`, SPEC §1). Absent ⇒ omitted. */
|
|
30
|
+
meta?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
/** MCP tool annotations (SPEC §1, §7C.1), sent verbatim on an MCP `tools/list` entry. */
|
|
33
|
+
export interface ToolAnnotations {
|
|
34
|
+
title?: string;
|
|
35
|
+
readOnlyHint?: boolean;
|
|
36
|
+
destructiveHint?: boolean;
|
|
37
|
+
idempotentHint?: boolean;
|
|
38
|
+
openWorldHint?: boolean;
|
|
24
39
|
}
|
|
25
40
|
export interface ToolContext {
|
|
26
41
|
signal?: AbortSignal;
|
|
@@ -30,6 +45,9 @@ export interface ToolContext {
|
|
|
30
45
|
/** The model's id for this tool call, set by the client loop (§4C). A message's calls run
|
|
31
46
|
* concurrently, so this is how a tool tells its sibling calls apart. Absent outside the loop. */
|
|
32
47
|
callId?: string;
|
|
48
|
+
/** Set by `toolkit.mcpHandler` (§7C.1): the inbound MCP request this call serves. Read it with
|
|
49
|
+
* `identityFrom(ctx)` / `requestFrom(ctx)` / `mcpSessionIdFrom(ctx)` / `mcpRequestMetaFrom(ctx)`. */
|
|
50
|
+
mcp?: import("./mcphandler.js").McpContext;
|
|
33
51
|
}
|
|
34
52
|
/**
|
|
35
53
|
* Advisory behavior hints (named after MCP tool annotations, §4C), for host permission gates
|
|
@@ -96,6 +114,13 @@ export interface Tool {
|
|
|
96
114
|
/** What the tool may do (§4C). Builtins always carry hints; MCP tools carry them when the
|
|
97
115
|
* server sends annotations. Advisory: a server can mislabel its tools. */
|
|
98
116
|
hints?: ToolHints;
|
|
117
|
+
/** Decides per call whether this tool may run alongside other safe calls of the same model
|
|
118
|
+
* response (SPEC §8 "Tool scheduling"). Absent ⇒ safe only when `hints.readOnly`. */
|
|
119
|
+
concurrencySafe?: (args: Record<string, unknown>) => boolean;
|
|
120
|
+
/** JSON Schema of `ToolResult.structured` (SPEC §1); absent ⇒ omitted everywhere. */
|
|
121
|
+
outputSchema?: Record<string, unknown>;
|
|
122
|
+
/** MCP annotations (§7C.1), verbatim on the MCP wire; absent ⇒ derived from `hints`, else omitted. */
|
|
123
|
+
annotations?: ToolAnnotations;
|
|
99
124
|
}
|
|
100
125
|
export type McpStatus = "connected" | "disabled" | "failed";
|
|
101
126
|
/** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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,11 +17,13 @@
|
|
|
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/acp.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/examples.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/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",
|
|
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",
|
|
24
|
-
"example:
|
|
24
|
+
"example:media": "node --experimental-strip-types examples/media.ts",
|
|
25
|
+
"example:prompt-cache": "node --experimental-strip-types examples/prompt_cache.ts",
|
|
26
|
+
"example:mcp-handler": "node --experimental-strip-types examples/mcp_handler.ts"
|
|
25
27
|
},
|
|
26
28
|
"keywords": [
|
|
27
29
|
"mcp",
|
package/src/acp.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* openspec/changes/add-acp-model-source.
|
|
5
5
|
*
|
|
6
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
|
|
8
|
-
* speak it). This file ships ACP as nothing more than a `generate` function —
|
|
7
|
+
* over a child process's stdin/stdout. This file ships ACP as nothing more than a `generate` function —
|
|
9
8
|
* the exact shape {@link InProcessOptions.generate} already defines — so the
|
|
10
9
|
* tool-calling loop, skills, MCP tools, adapters and sub-agents are entirely
|
|
11
10
|
* untouched. `loadACP` opens the connection once (`initialize` → `session/new` →
|
|
@@ -35,16 +34,16 @@
|
|
|
35
34
|
import { spawn } from "node:child_process"
|
|
36
35
|
import { createInterface } from "node:readline"
|
|
37
36
|
import path from "node:path"
|
|
38
|
-
import type { InProcessRequest, InProcessResponse } from "./client.js"
|
|
37
|
+
import type { InProcessRequest, InProcessResponse, StreamEvent } from "./client.js"
|
|
39
38
|
|
|
40
39
|
/** Options for {@link loadACP}. `command`/`args` spawn the ACP agent as a child
|
|
41
40
|
* process; everything else tunes the session handshake. */
|
|
42
41
|
export interface ACPOptions {
|
|
43
|
-
/** The ACP agent binary
|
|
42
|
+
/** The ACP agent binary. */
|
|
44
43
|
command: string
|
|
45
|
-
/** Arguments to the agent,
|
|
44
|
+
/** Arguments to the agent, passed untouched. */
|
|
46
45
|
args?: string[]
|
|
47
|
-
/** Working directory for `session/new`. Real ACP agents
|
|
46
|
+
/** Working directory for `session/new`. Real ACP agents
|
|
48
47
|
* reject a relative `cwd` with `-32602 Invalid params` — this is always
|
|
49
48
|
* resolved to an absolute path before it is sent. Default: `process.cwd()`. */
|
|
50
49
|
cwd?: string
|
|
@@ -60,6 +59,28 @@ export interface ACPOptions {
|
|
|
60
59
|
permissionTimeoutMs?: number
|
|
61
60
|
/** Optional `session/set_mode` sent once, right after `session/new`. */
|
|
62
61
|
mode?: string
|
|
62
|
+
/** Sent as `session/new` `mcpServers` VERBATIM and in order (absent ⇒ `[]`): toolnexus does
|
|
63
|
+
* not validate, rename, reshape or pick between the http/stdio forms (SPEC §8C). */
|
|
64
|
+
mcpServers?: Record<string, unknown>[]
|
|
65
|
+
/** Sent as `session/new` `_meta`; the key is absent when unset. Opaque to toolnexus. */
|
|
66
|
+
sessionMeta?: Record<string, unknown>
|
|
67
|
+
/** Sent as `initialize` `clientCapabilities._meta` (vendor flags). Opaque to toolnexus. */
|
|
68
|
+
clientCapsMeta?: Record<string, unknown>
|
|
69
|
+
/** Passed through unchanged after `session/new`: `session/set_config_option`
|
|
70
|
+
* `{configId:"model"}`, then `session/set_model` if the agent rejects that. Both failing
|
|
71
|
+
* fails `loadACP`. */
|
|
72
|
+
model?: string
|
|
73
|
+
/** Receives the agent's stderr (default: discarded). */
|
|
74
|
+
stderr?: (chunk: string) => void
|
|
75
|
+
/** Decides each `session/request_permission`: return an `optionId` to select it, or
|
|
76
|
+
* `""`/undefined to answer with the first `reject*` option. Absent ⇒ the first `allow*`
|
|
77
|
+
* option, as before. */
|
|
78
|
+
onPermission?: (params: any) => string | undefined | Promise<string | undefined>
|
|
79
|
+
/** Observes every raw `session/update` params object (must not throw). */
|
|
80
|
+
onUpdate?: (params: any) => void
|
|
81
|
+
/** Bounds each handshake request (`initialize`, `session/new`, …). Absent ⇒ unbounded;
|
|
82
|
+
* a request still fails at once when the agent exits. */
|
|
83
|
+
requestTimeoutMs?: number
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
/** One warm ACP connection: one child process, one session, alive across many
|
|
@@ -67,8 +88,17 @@ export interface ACPOptions {
|
|
|
67
88
|
* wants, so it plugs straight into {@link createInProcessClient}. */
|
|
68
89
|
export interface ACPClient {
|
|
69
90
|
generate: (request: InProcessRequest) => Promise<InProcessResponse>
|
|
70
|
-
/**
|
|
91
|
+
/** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
|
|
92
|
+
* agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
|
|
93
|
+
* tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
|
|
94
|
+
* (both via "acp": the agent's own tools, reported, never executed by the loop).
|
|
95
|
+
* Shaped for {@link InProcessOptions.generateStream}. The returned content is the
|
|
96
|
+
* message text only. */
|
|
97
|
+
generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void) => Promise<InProcessResponse>
|
|
98
|
+
/** Terminates the agent's process group. Idempotent — a second call is a no-op. */
|
|
71
99
|
close: () => Promise<void>
|
|
100
|
+
/** The agent's raw `initialize` result. */
|
|
101
|
+
readonly initializeResult: any
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
/** The literal marker every rendered prompt ends with, naming the current
|
|
@@ -126,7 +156,7 @@ function renderPrompt(request: InProcessRequest): string {
|
|
|
126
156
|
/**
|
|
127
157
|
* Opens one ACP connection: spawns `command`, performs `initialize` →
|
|
128
158
|
* `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
|
|
129
|
-
* trap real
|
|
159
|
+
* trap real agents enforce with `-32602` otherwise) → an optional
|
|
130
160
|
* `session/set_mode`, then returns a client whose `.generate` sends exactly
|
|
131
161
|
* one `session/prompt` per call, accumulating only `agent_message_chunk`
|
|
132
162
|
* text.
|
|
@@ -135,18 +165,59 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
135
165
|
const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd()
|
|
136
166
|
const permissionTimeoutMs = opts.permissionTimeoutMs ?? 30000
|
|
137
167
|
|
|
168
|
+
// Its own process group (detached), so close() reaches an adapter's children too — an
|
|
169
|
+
// `npx …` launch is a tree, not one process.
|
|
138
170
|
const child = spawn(opts.command, opts.args ?? [], {
|
|
139
171
|
cwd,
|
|
140
172
|
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
141
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
173
|
+
stdio: ["pipe", "pipe", opts.stderr ? "pipe" : "ignore"],
|
|
174
|
+
detached: process.platform !== "win32",
|
|
142
175
|
})
|
|
176
|
+
if (opts.stderr) {
|
|
177
|
+
child.stderr!.setEncoding("utf8")
|
|
178
|
+
child.stderr!.on("data", (d: string) => {
|
|
179
|
+
try {
|
|
180
|
+
opts.stderr!(d)
|
|
181
|
+
} catch {
|
|
182
|
+
// a host sink never breaks the session
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
}
|
|
143
186
|
|
|
144
187
|
let closed = false
|
|
145
188
|
let nextId = 0
|
|
146
|
-
const pending = new Map<string, (msg: RpcMessage) => void>()
|
|
189
|
+
const pending = new Map<string, { method: string; cb: (msg: RpcMessage) => void }>()
|
|
147
190
|
let updateSink: ((params: any) => void) | null = null
|
|
191
|
+
let exited = false
|
|
192
|
+
let startError: Error | undefined
|
|
193
|
+
const childGone = new Promise<void>((resolve) => {
|
|
194
|
+
child.once("exit", () => resolve())
|
|
195
|
+
child.once("error", () => resolve())
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// Fail fast: a request still waiting when the agent's stdout closes fails at once
|
|
199
|
+
// instead of waiting out a timeout. setImmediate lets a spawn `error` land first.
|
|
200
|
+
const failPending = () => {
|
|
201
|
+
exited = true
|
|
202
|
+
for (const [id, p] of pending) {
|
|
203
|
+
pending.delete(id)
|
|
204
|
+
p.cb({ error: { code: 0, message: exitMessage(p.method) } })
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const exitMessage = (method: string) =>
|
|
208
|
+
startError
|
|
209
|
+
? `toolnexus: acp: start ${JSON.stringify(opts.command)}: ${startError.message}`
|
|
210
|
+
: `toolnexus: acp: agent exited before replying to ${method}`
|
|
211
|
+
child.on("error", (e) => {
|
|
212
|
+
startError = e
|
|
213
|
+
setImmediate(failPending)
|
|
214
|
+
})
|
|
215
|
+
child.stdin!.on("error", () => {
|
|
216
|
+
// EPIPE after the agent died: surfaced through failPending, never as a crash.
|
|
217
|
+
})
|
|
148
218
|
|
|
149
219
|
const rl = createInterface({ input: child.stdout! })
|
|
220
|
+
rl.on("close", () => setImmediate(failPending))
|
|
150
221
|
rl.on("line", (raw) => {
|
|
151
222
|
const line = raw.trim()
|
|
152
223
|
if (!line) return
|
|
@@ -160,6 +231,7 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
160
231
|
})
|
|
161
232
|
|
|
162
233
|
function write(msg: Record<string, unknown>): void {
|
|
234
|
+
if (exited || child.stdin!.destroyed) return
|
|
163
235
|
child.stdin!.write(JSON.stringify(msg) + "\n")
|
|
164
236
|
}
|
|
165
237
|
|
|
@@ -170,10 +242,27 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
170
242
|
function call(method: string, params: unknown): Promise<any> {
|
|
171
243
|
const id = `c-${++nextId}`
|
|
172
244
|
return new Promise((resolve, reject) => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
245
|
+
if (exited) {
|
|
246
|
+
reject(new Error(exitMessage(method)))
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
let timer: NodeJS.Timeout | undefined
|
|
250
|
+
pending.set(id, {
|
|
251
|
+
method,
|
|
252
|
+
cb: (msg) => {
|
|
253
|
+
clearTimeout(timer)
|
|
254
|
+
if (msg.error && msg.error.code === 0) reject(new Error(msg.error.message))
|
|
255
|
+
else if (msg.error) reject(new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`))
|
|
256
|
+
else resolve(msg.result)
|
|
257
|
+
},
|
|
176
258
|
})
|
|
259
|
+
if (opts.requestTimeoutMs && opts.requestTimeoutMs > 0) {
|
|
260
|
+
const ms = opts.requestTimeoutMs
|
|
261
|
+
timer = setTimeout(() => {
|
|
262
|
+
pending.delete(id)
|
|
263
|
+
reject(new Error(`toolnexus: acp: ${method} timed out after ${ms}ms`))
|
|
264
|
+
}, ms)
|
|
265
|
+
}
|
|
177
266
|
write({ jsonrpc: "2.0", id, method, params })
|
|
178
267
|
})
|
|
179
268
|
}
|
|
@@ -184,49 +273,100 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
184
273
|
* server-initiated requests like `session/request_permission`. */
|
|
185
274
|
function handleLine(msg: RpcMessage): void {
|
|
186
275
|
if (msg.method === undefined && msg.id !== undefined) {
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
276
|
+
const p = pending.get(String(msg.id))
|
|
277
|
+
if (p) {
|
|
189
278
|
pending.delete(String(msg.id))
|
|
190
|
-
cb(msg)
|
|
279
|
+
p.cb(msg)
|
|
191
280
|
}
|
|
192
281
|
return
|
|
193
282
|
}
|
|
194
283
|
if (msg.method === "session/update") {
|
|
284
|
+
if (opts.onUpdate) {
|
|
285
|
+
try {
|
|
286
|
+
opts.onUpdate(msg.params)
|
|
287
|
+
} catch {
|
|
288
|
+
// an observer never breaks the session
|
|
289
|
+
}
|
|
290
|
+
}
|
|
195
291
|
updateSink?.(msg.params)
|
|
196
292
|
return
|
|
197
293
|
}
|
|
198
294
|
if (msg.method === "session/request_permission" && msg.id !== undefined) {
|
|
199
|
-
answerPermission(msg)
|
|
295
|
+
void answerPermission(msg)
|
|
200
296
|
return
|
|
201
297
|
}
|
|
202
|
-
// An
|
|
203
|
-
//
|
|
204
|
-
if (msg.id !== undefined)
|
|
298
|
+
// An agent->client REQUEST we do not implement (fs/*, terminal/* — declared
|
|
299
|
+
// unsupported in clientCapabilities). Answer it, or the agent waits forever.
|
|
300
|
+
if (msg.id !== undefined) {
|
|
301
|
+
write({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `method not found: ${msg.method}` } })
|
|
302
|
+
}
|
|
205
303
|
}
|
|
206
304
|
|
|
207
305
|
/** Answers with the FIRST option whose `kind` starts with `allow` — an
|
|
208
306
|
* unanswered permission request hangs the turn forever (proven in
|
|
209
307
|
* spikes/acp). This runs the instant the request arrives, never waiting
|
|
210
308
|
* for anything else. */
|
|
211
|
-
function answerPermission(req: RpcMessage): void {
|
|
309
|
+
async function answerPermission(req: RpcMessage): Promise<void> {
|
|
212
310
|
const options: Array<{ optionId: string; kind?: string }> = req.params?.options ?? []
|
|
213
|
-
|
|
311
|
+
let chosen: { optionId: string } | undefined
|
|
312
|
+
if (opts.onPermission) {
|
|
313
|
+
let picked: string | undefined
|
|
314
|
+
try {
|
|
315
|
+
picked = await opts.onPermission(req.params)
|
|
316
|
+
} catch {
|
|
317
|
+
picked = undefined
|
|
318
|
+
}
|
|
319
|
+
chosen = picked
|
|
320
|
+
? { optionId: picked }
|
|
321
|
+
: options.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"))
|
|
322
|
+
} else {
|
|
323
|
+
chosen = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"))
|
|
324
|
+
}
|
|
214
325
|
const result = chosen
|
|
215
326
|
? { outcome: { outcome: "selected", optionId: chosen.optionId } }
|
|
216
327
|
: { outcome: { outcome: "cancelled" } }
|
|
217
328
|
reply(req.id, result)
|
|
218
329
|
}
|
|
219
330
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const
|
|
331
|
+
const clientCapabilities: Record<string, unknown> = {
|
|
332
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
333
|
+
terminal: false,
|
|
334
|
+
}
|
|
335
|
+
if (opts.clientCapsMeta) clientCapabilities._meta = opts.clientCapsMeta
|
|
336
|
+
const step = async <T>(label: string, fn: () => Promise<T>): Promise<T> => {
|
|
337
|
+
try {
|
|
338
|
+
return await fn()
|
|
339
|
+
} catch (e) {
|
|
340
|
+
await close()
|
|
341
|
+
const m = e instanceof Error ? e.message : String(e)
|
|
342
|
+
throw new Error(startError ? m : `toolnexus: acp: ${label}: ${m}`)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const initializeResult = await step("initialize", () =>
|
|
346
|
+
call("initialize", { protocolVersion: opts.protocolVersion ?? 1, clientCapabilities }),
|
|
347
|
+
)
|
|
348
|
+
const sessionParams: Record<string, unknown> = { cwd, mcpServers: opts.mcpServers ?? [] }
|
|
349
|
+
if (opts.sessionMeta) sessionParams._meta = opts.sessionMeta
|
|
350
|
+
const sessionResult = await step("session/new", () => call("session/new", sessionParams))
|
|
226
351
|
const sessionId = sessionResult?.sessionId
|
|
227
352
|
|
|
228
353
|
if (opts.mode) {
|
|
229
|
-
await call("session/set_mode", { sessionId, modeId: opts.mode })
|
|
354
|
+
await step("session/set_mode", () => call("session/set_mode", { sessionId, modeId: opts.mode }))
|
|
355
|
+
}
|
|
356
|
+
if (opts.model) {
|
|
357
|
+
// Passed through unchanged. Stable ACP spelling first, then the older session/set_model.
|
|
358
|
+
const model = opts.model
|
|
359
|
+
await step(`set model ${JSON.stringify(model)}`, async () => {
|
|
360
|
+
try {
|
|
361
|
+
await call("session/set_config_option", { sessionId, configId: "model", value: model })
|
|
362
|
+
} catch (e1) {
|
|
363
|
+
try {
|
|
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
|
+
}
|
|
368
|
+
}
|
|
369
|
+
})
|
|
230
370
|
}
|
|
231
371
|
|
|
232
372
|
// Serialises turns: one ACP session is one conversation, so concurrent
|
|
@@ -235,22 +375,61 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
235
375
|
// queue itself never rejects (a failed turn does not poison later ones).
|
|
236
376
|
let turnQueue: Promise<unknown> = Promise.resolve()
|
|
237
377
|
|
|
238
|
-
async function promptOnce(request: InProcessRequest): Promise<InProcessResponse> {
|
|
378
|
+
async function promptOnce(request: InProcessRequest, emit?: (event: StreamEvent) => void): Promise<InProcessResponse> {
|
|
239
379
|
const text = renderPrompt(request)
|
|
240
380
|
const id = `c-${++nextId}`
|
|
241
381
|
|
|
242
382
|
let content = ""
|
|
383
|
+
const toolNames = new Map<string, string>()
|
|
384
|
+
const send = (ev: StreamEvent) => {
|
|
385
|
+
try {
|
|
386
|
+
emit?.(ev)
|
|
387
|
+
} catch {
|
|
388
|
+
// a consumer never breaks the session
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Runs from the stdout read loop, so every event is emitted the moment its
|
|
392
|
+
// notification is processed — never buffered to the end of the turn.
|
|
243
393
|
updateSink = (params: any) => {
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
394
|
+
const u = params?.update
|
|
395
|
+
switch (u?.sessionUpdate) {
|
|
396
|
+
case "agent_message_chunk": {
|
|
397
|
+
const t: string = u?.content?.text ?? ""
|
|
398
|
+
content += t
|
|
399
|
+
if (t) send({ type: "text", delta: t })
|
|
400
|
+
break
|
|
401
|
+
}
|
|
402
|
+
// Thought and tool narration never reach the content — mixing them into
|
|
403
|
+
// the reply corrupts structured output a caller expects to parse.
|
|
404
|
+
case "agent_thought_chunk": {
|
|
405
|
+
const t: string = u?.content?.text ?? ""
|
|
406
|
+
if (t) send({ type: "thought", delta: t, via: "acp" })
|
|
407
|
+
break
|
|
408
|
+
}
|
|
409
|
+
case "tool_call": {
|
|
410
|
+
const name: string = u.title || u.kind || ""
|
|
411
|
+
toolNames.set(u.toolCallId ?? "", name)
|
|
412
|
+
send({ type: "tool_call", id: u.toolCallId ?? "", name, args: u.rawInput ?? {}, via: "acp" })
|
|
413
|
+
break
|
|
414
|
+
}
|
|
415
|
+
case "tool_call_update": {
|
|
416
|
+
if (u.status !== "completed" && u.status !== "failed") break
|
|
417
|
+
const name: string = u.title || toolNames.get(u.toolCallId ?? "") || ""
|
|
418
|
+
send({
|
|
419
|
+
type: "tool_result",
|
|
420
|
+
id: u.toolCallId ?? "",
|
|
421
|
+
name,
|
|
422
|
+
output: acpToolContentText(u.content),
|
|
423
|
+
isError: u.status === "failed",
|
|
424
|
+
via: "acp",
|
|
425
|
+
})
|
|
426
|
+
break
|
|
427
|
+
}
|
|
247
428
|
}
|
|
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
429
|
}
|
|
252
430
|
|
|
253
|
-
|
|
431
|
+
if (exited) throw new Error(exitMessage("session/prompt"))
|
|
432
|
+
const replyPromise = new Promise<RpcMessage>((resolve) => pending.set(id, { method: "session/prompt", cb: resolve }))
|
|
254
433
|
write({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: [{ type: "text", text }] } })
|
|
255
434
|
|
|
256
435
|
let timer: NodeJS.Timeout | undefined
|
|
@@ -263,6 +442,7 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
263
442
|
|
|
264
443
|
try {
|
|
265
444
|
const msg = await Promise.race([replyPromise, timeout])
|
|
445
|
+
if (msg.error && msg.error.code === 0) throw new Error(msg.error.message)
|
|
266
446
|
if (msg.error) throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`)
|
|
267
447
|
return { content }
|
|
268
448
|
} finally {
|
|
@@ -272,9 +452,9 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
272
452
|
}
|
|
273
453
|
}
|
|
274
454
|
|
|
275
|
-
const
|
|
455
|
+
const generateStream = (request: InProcessRequest, emit?: (event: StreamEvent) => void): Promise<InProcessResponse> => {
|
|
276
456
|
if (closed) return Promise.reject(new Error("toolnexus: ACP client is closed"))
|
|
277
|
-
const turn = turnQueue.then(() => promptOnce(request))
|
|
457
|
+
const turn = turnQueue.then(() => promptOnce(request, emit))
|
|
278
458
|
turnQueue = turn.then(
|
|
279
459
|
() => undefined,
|
|
280
460
|
() => undefined,
|
|
@@ -282,27 +462,50 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
282
462
|
return turn
|
|
283
463
|
}
|
|
284
464
|
|
|
285
|
-
|
|
286
|
-
if (closed) return
|
|
465
|
+
async function close(): Promise<void> {
|
|
466
|
+
if (closed) return
|
|
287
467
|
closed = true
|
|
288
468
|
rl.close()
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
setTimeout(done, 1000)
|
|
300
|
-
})
|
|
469
|
+
child.stdin!.end()
|
|
470
|
+
if (startError || child.pid === undefined) return
|
|
471
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
472
|
+
killGroup(child.pid, "SIGKILL") // reap any orphaned children of the group
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
killGroup(child.pid, "SIGTERM")
|
|
476
|
+
const t = await Promise.race([childGone.then(() => "gone"), new Promise((r) => setTimeout(() => r("late"), 3000).unref())])
|
|
477
|
+
killGroup(child.pid, "SIGKILL")
|
|
478
|
+
if (t === "late") await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())])
|
|
301
479
|
}
|
|
302
480
|
|
|
303
|
-
const
|
|
481
|
+
const generate = (request: InProcessRequest): Promise<InProcessResponse> => generateStream(request)
|
|
482
|
+
|
|
483
|
+
const client: ACPClient = { generate, generateStream, close, initializeResult }
|
|
304
484
|
// Non-enumerable, undocumented — internal test hook only, so the public
|
|
305
|
-
// ACPClient surface stays exactly {generate, close}.
|
|
485
|
+
// ACPClient surface stays exactly {generate, generateStream, close}.
|
|
306
486
|
Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false })
|
|
307
487
|
return client
|
|
308
488
|
}
|
|
489
|
+
|
|
490
|
+
/** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
|
|
491
|
+
function killGroup(pid: number, signal: NodeJS.Signals): void {
|
|
492
|
+
if (process.platform !== "win32") {
|
|
493
|
+
try {
|
|
494
|
+
process.kill(-pid, signal)
|
|
495
|
+
return
|
|
496
|
+
} catch {
|
|
497
|
+
// group already gone; fall through to the pid itself
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
process.kill(pid, signal)
|
|
502
|
+
} catch {
|
|
503
|
+
// already gone
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Joins the text of an ACP tool_call_update's `content` array ({content:{text}} parts). */
|
|
508
|
+
function acpToolContentText(content: any): string {
|
|
509
|
+
if (!Array.isArray(content)) return ""
|
|
510
|
+
return content.map((p: any) => (typeof p?.content?.text === "string" ? p.content.text : "")).join("")
|
|
511
|
+
}
|