toolnexus 0.24.0 → 0.26.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 +151 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/classifier.d.ts +189 -31
- package/dist/classifier.js +589 -30
- package/dist/client.d.ts +38 -8
- package/dist/client.js +102 -23
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/judge.d.ts +6 -3
- package/dist/judge.js +13 -1
- 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/serve.js +7 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolkit.d.ts +20 -1
- package/dist/toolkit.js +25 -3
- package/dist/types.d.ts +22 -0
- package/package.json +4 -3
- package/src/acp.ts +258 -55
- package/src/classifier.ts +677 -46
- package/src/client.ts +130 -27
- package/src/index.ts +2 -0
- package/src/judge.ts +19 -4
- 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/serve.ts +7 -0
- package/src/skill.ts +50 -0
- package/src/toolkit.ts +31 -4
- package/src/types.ts +23 -0
package/README.md
CHANGED
|
@@ -93,7 +93,8 @@ tool. Runnable: `npm run example:prompt-cache` ([`examples/prompt_cache.ts`](exa
|
|
|
93
93
|
- `agents.compactor({ onCompact })`, `agent.asToolWith(ctx => ({ hooks, waitFor }))`, runtime
|
|
94
94
|
`waitFor`, `ctx.callId` on tools, `listModels(opts)`.
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
An in-process model streams for real when it is given as `generateStream(request, emit)`; a plain
|
|
97
|
+
`generate` still refuses `stream()`.
|
|
97
98
|
|
|
98
99
|
## Simple judgments
|
|
99
100
|
|
|
@@ -135,6 +136,41 @@ const out = await gate(c, state, questions, [
|
|
|
135
136
|
filter, content and model batteries into `hooks`. A `beforeLLM` hook may return `{ model }` to
|
|
136
137
|
send another model for that turn only.
|
|
137
138
|
|
|
139
|
+
### Backends, images, cache and helpers
|
|
140
|
+
|
|
141
|
+
Every default below is a **proposed default, owner to confirm**
|
|
142
|
+
(`openspec/changes/add-classifier-backends`).
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { createClassifier, judge, MemoryCache, validateDecision, thresholdBand, chunkQuestions } from "toolnexus"
|
|
146
|
+
|
|
147
|
+
const c = createClassifier({ backend: "openrouter", cache: new MemoryCache(3_600_000, 1000) })
|
|
148
|
+
const d = await c.state({ note: "listing", photo: { type: "image", mimeType: "image/png", data: b64 } })
|
|
149
|
+
.evaluate(judge.noul("red", "Is the object in `photo` red?")) // fails closed on an invalid reply
|
|
150
|
+
const rs = await c.states("a", "b").evaluate(judge.noul("q", "…")) // [{ decision } | { error }], in order
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- `backend`: `typesafe` (default) · `openrouter` (`typesafe/jev-1.14`) · `cloudflare` (`/ai/run`
|
|
154
|
+
envelope; `${CLOUDFLARE_ACCOUNT_ID}` in the URL expands at call time, errors name it unexpanded)
|
|
155
|
+
· `vercel` · `custom` (requires `baseUrl` and `supports`). The table is data:
|
|
156
|
+
`CLASSIFIER_BACKENDS`; `supports: { images }` overrides it per classifier.
|
|
157
|
+
- Images (style `systemone` only): image parts in the state are lifted into `assets` (cloudflare:
|
|
158
|
+
data URLs). PNG/JPEG, 280–1568 px a side, ≤ 16 MiB each and 32 MiB per request — never resized.
|
|
159
|
+
A backend without image support throws `ClassifierUnsupported` before any request. A state with
|
|
160
|
+
no content part is sent byte-identically to before. JS has no distinct part type, so **any**
|
|
161
|
+
object shaped like a `ContentPart` (`{type:"text",text}`, `{type:"image",mimeType,…}`) inside a
|
|
162
|
+
systemone state is treated as a part.
|
|
163
|
+
- `cache: { get(key), set(key, value) }` (sync or async, string values) — off when absent; only
|
|
164
|
+
valid replies are stored; keys follow the served model. A hit sets `decision.cached` and the
|
|
165
|
+
`classifier.evaluate` event's `cached`. Every such event also carries `questions` and
|
|
166
|
+
`questionsHash`.
|
|
167
|
+
- Helpers: `validateDecision(questions, decision)` → `Error | undefined`; `thresholdBand(answer,
|
|
168
|
+
thresholds?)` with `DEFAULT_THRESHOLDS` 0.8 / 0.2 / 0.6 (inclusive; `Bands` stays the `ask`/`gate`
|
|
169
|
+
default); `chunkQuestions(questions, size)`.
|
|
170
|
+
- Open union: `judge.raw(name, type, instructions, criteria?)` / `raw(...)`; an answer of an
|
|
171
|
+
unmodelled type decodes to `{ type, value }` (`decision.raw(key)`).
|
|
172
|
+
- `style: "llm"` is **deprecated** — it still works unchanged, with no runtime warning.
|
|
173
|
+
|
|
138
174
|
## Media generation
|
|
139
175
|
|
|
140
176
|
Generate images, video and speech (plus transcription, voice cloning and talking-head video)
|
|
@@ -158,6 +194,120 @@ const res = await agent.run("Draw a fox, then animate it.", { toolkit: tk })
|
|
|
158
194
|
for (const f of res.files) console.log(f.from, f.mimeType, f.url)
|
|
159
195
|
```
|
|
160
196
|
|
|
197
|
+
## MCP 2026-07-28 + OAuth
|
|
198
|
+
|
|
199
|
+
`loadMcp` / `Toolkit.create({ mcpConfig })` now speak MCP **2026-07-28** first, with no MCP SDK on
|
|
200
|
+
that path: every server (stdio and HTTP) gets `server/discover` before anything else. A pre-2026
|
|
201
|
+
server answers it with an error, a 400/404/405, or (stdio) silence for `min(timeout, 5 s)`, and is
|
|
202
|
+
then served by the unchanged SDK path. Tool names, result mapping, status, `tools` filters and
|
|
203
|
+
`disableTools` are the same on both paths.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const toolkit = await Toolkit.create({
|
|
207
|
+
mcpConfig: {
|
|
208
|
+
mcpServers: {
|
|
209
|
+
docs: { url: "https://mcp.example.com/mcp", auth: { clientId: "my-registered-app" } },
|
|
210
|
+
big: { command: ["my-server"], toolSearch: true, description: "the billing API" },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
waitFor, // answers MRTR input requests and `pause`-mode authorization
|
|
214
|
+
tokenStore, // { load(resource), save(resource, token), delete(resource) }; default in memory
|
|
215
|
+
openUrl: (url) => open(url), // how `auto` OAuth opens the browser; default: the OS opener
|
|
216
|
+
})
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- **MRTR** (`input_required`) goes through `waitFor` (form ⇒ `kind:"input"` with `data.schema`,
|
|
220
|
+
URL ⇒ `kind:"authorization"`). Without `waitFor` the tool returns a §10 pending result; the
|
|
221
|
+
resumed call (with `ctx.answer`) sends that round once and is never re-asked.
|
|
222
|
+
- **OAuth 2.1** on a 401: protected-resource metadata → AS metadata → a pre-registered `clientId`
|
|
223
|
+
(`clientIds` per issuer, optional `clientSecret`) or a Client ID Metadata Document
|
|
224
|
+
(`clientMetadataUrl`). **No dynamic client registration.** PKCE S256 + `resource`, `iss`/`state`
|
|
225
|
+
checks, refresh (single-flight; `omitRefreshResource` for Entra) and step-up on
|
|
226
|
+
`insufficient_scope`. `mode: "auto"` (loopback callback + `openUrl`) or `mode: "pause"` (needs
|
|
227
|
+
`redirectUri`; answer the `authorization` request with `data: { callbackUrl }`). Tokens are never
|
|
228
|
+
logged or put in error text.
|
|
229
|
+
- **Old servers + OAuth:** the bearer is attached to the SDK transport through its `fetch` hook,
|
|
230
|
+
and a 401 there runs the same flow once.
|
|
231
|
+
- **Skills over MCP:** a server advertising the skills extension contributes its skills to the
|
|
232
|
+
`skill` tool (base `skill://<name>/`).
|
|
233
|
+
- **Tool search:** `toolSearch: true` replaces a server's tools with `<server>_search_tools` and
|
|
234
|
+
`<server>_call_tool`.
|
|
235
|
+
- A failed `tools/call` (timeout, 5xx, dropped connection) is never re-sent.
|
|
236
|
+
|
|
237
|
+
## Embeddable MCP server (`mcpHandler`)
|
|
238
|
+
|
|
239
|
+
`serve(addr, { mcp })` owns a port and exposes one static tool list. `toolkit.mcpHandler(cfg)`
|
|
240
|
+
returns a plain Node request listener `(req, res) => void` (plus `notifyToolsChanged()`) that you
|
|
241
|
+
mount on **your** server or router, behind your own TLS, CORS and OAuth. Every policy is a hook:
|
|
242
|
+
`authenticate` (+ `wwwAuthenticate` for the 401), `listTools` per request (a name outside it is never
|
|
243
|
+
executed), `beforeCall` allow / deny / confirm (confirm uses MCP elicitation when the client supports
|
|
244
|
+
it, otherwise returns your fallback `result`), `onCall` telemetry, `serverInfo`, resources/prompts,
|
|
245
|
+
pluggable `sessionIds`, `stateless`, `protocolVersions`. Hooks and tools read the caller with
|
|
246
|
+
`identityFrom(ctx)`, `requestFrom(ctx)`, `mcpSessionIdFrom(ctx)` and `mcpRequestMetaFrom(ctx)`.
|
|
247
|
+
`ToolResult.structured` / `meta` and `Tool.outputSchema` / `annotations` go on the wire as
|
|
248
|
+
`structuredContent` / `_meta` / `outputSchema` / `annotations`. SPEC §7C.1; runnable:
|
|
249
|
+
`npm run example:mcp-handler`.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import http from "node:http"
|
|
253
|
+
import { createToolkit, identityFrom } from "toolnexus"
|
|
254
|
+
|
|
255
|
+
const tk = await createToolkit({ builtins: false })
|
|
256
|
+
const mcp = tk.mcpHandler({
|
|
257
|
+
authenticate: (req) => verifyBearer(req.headers.authorization), // throw ⇒ 401
|
|
258
|
+
listTools: (ctx) => toolsFor(identityFrom(ctx)),
|
|
259
|
+
beforeCall: (_ctx, tool) =>
|
|
260
|
+
tool.annotations?.destructiveHint ? { kind: "confirm", message: `Run ${tool.name}?` } : { kind: "allow" },
|
|
261
|
+
})
|
|
262
|
+
http.createServer((req, res) => (req.url?.startsWith("/mcp") ? mcp(req, res) : res.writeHead(404).end())).listen(8080)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### MCP 2026-07-28 (opt-in)
|
|
266
|
+
|
|
267
|
+
List `"2026-07-28"` in `protocolVersions` to serve the stateless 2026 wire (`server/discover`,
|
|
268
|
+
header validation, `resultType`, MRTR): `["2026-07-28"]` alone, or `["2026-07-28", "2025-06-18"]` for
|
|
269
|
+
both wires on one mount. It is never a default and `initialize` never negotiates it. A `confirm`
|
|
270
|
+
decision and a tool suspension (§10 `pending(...)`) leave as `input_required`; the client's retry
|
|
271
|
+
reaches the tool as `ctx.answer`. `listSkills` publishes skills through the skills extension
|
|
272
|
+
(`skills/list`, `skills/get`, `resources/read` of `skill://` URIs); `loadMcpSkills(dir)` builds that
|
|
273
|
+
list from a skills folder. `McpServerAuth` makes the handler an OAuth protected resource: its
|
|
274
|
+
`authenticate` verifies RS256 JWTs against the issuer's JWKS (or your `verify`), applies per-tool
|
|
275
|
+
`scopes`, answers with exact `WWW-Authenticate` challenges, and returns an `McpPrincipal`;
|
|
276
|
+
`metadata()` serves the RFC 9728 document. Bring your own authorization server; the
|
|
277
|
+
auto-approving dev authorization server the suite uses is test tooling (`test/support`), not part of
|
|
278
|
+
the package. SPEC §7C.2.
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
import { McpServerAuth, loadMcpSkills } from "toolnexus"
|
|
282
|
+
|
|
283
|
+
const auth = new McpServerAuth({ issuer: "https://login.example", scopes: { "*": ["mcp"], wipe: ["mcp:admin"] } })
|
|
284
|
+
const mcp = tk.mcpHandler({
|
|
285
|
+
protocolVersions: ["2026-07-28", "2025-06-18"],
|
|
286
|
+
authenticate: auth.authenticate,
|
|
287
|
+
listSkills: () => loadMcpSkills("./skills"),
|
|
288
|
+
})
|
|
289
|
+
const prm = auth.metadata() // mount at /.well-known/oauth-protected-resource[/mcp]
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Agents via ACP
|
|
293
|
+
|
|
294
|
+
toolnexus ships no per-agent client. Three vendor-neutral pieces compose (SPEC §8C):
|
|
295
|
+
`toolkit.asMcpServer()` serves the toolkit on `127.0.0.1` behind a bearer token (`{url, token,
|
|
296
|
+
close}`); `loadACP({command, args, mcpServers, sessionMeta, clientCapsMeta, model})` drives any
|
|
297
|
+
ACP agent, passing `mcpServers` verbatim; and its `generateStream` plugs into
|
|
298
|
+
`createInProcessClient({ generateStream })` for incremental `stream()` (text, `thought`, and the
|
|
299
|
+
agent's own `tool_call`/`tool_result` with `via: "acp"`).
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
const srv = await tk.asMcpServer()
|
|
303
|
+
const acp = await loadACP({
|
|
304
|
+
command: "my-acp-agent",
|
|
305
|
+
mcpServers: [{ type: "http", name: "toolnexus", url: srv.url,
|
|
306
|
+
headers: [{ name: "Authorization", value: `Bearer ${srv.token}` }] }],
|
|
307
|
+
})
|
|
308
|
+
const client = createInProcessClient({ generate: acp.generate, generateStream: acp.generateStream })
|
|
309
|
+
```
|
|
310
|
+
|
|
161
311
|
## Documentation
|
|
162
312
|
|
|
163
313
|
Everything else — the full surface, with runnable examples — lives on the docs site:
|
package/dist/acp.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { InProcessRequest, InProcessResponse } from "./client.js";
|
|
1
|
+
import type { InProcessRequest, InProcessResponse, StreamEvent } from "./client.js";
|
|
2
2
|
/** Options for {@link loadACP}. `command`/`args` spawn the ACP agent as a child
|
|
3
3
|
* process; everything else tunes the session handshake. */
|
|
4
4
|
export interface ACPOptions {
|
|
5
|
-
/** The ACP agent binary
|
|
5
|
+
/** The ACP agent binary. */
|
|
6
6
|
command: string;
|
|
7
|
-
/** Arguments to the agent,
|
|
7
|
+
/** Arguments to the agent, passed untouched. */
|
|
8
8
|
args?: string[];
|
|
9
|
-
/** Working directory for `session/new`. Real ACP agents
|
|
9
|
+
/** Working directory for `session/new`. Real ACP agents
|
|
10
10
|
* reject a relative `cwd` with `-32602 Invalid params` — this is always
|
|
11
11
|
* resolved to an absolute path before it is sent. Default: `process.cwd()`. */
|
|
12
12
|
cwd?: string;
|
|
@@ -22,19 +22,50 @@ export interface ACPOptions {
|
|
|
22
22
|
permissionTimeoutMs?: number;
|
|
23
23
|
/** Optional `session/set_mode` sent once, right after `session/new`. */
|
|
24
24
|
mode?: string;
|
|
25
|
+
/** Sent as `session/new` `mcpServers` VERBATIM and in order (absent ⇒ `[]`): toolnexus does
|
|
26
|
+
* not validate, rename, reshape or pick between the http/stdio forms (SPEC §8C). */
|
|
27
|
+
mcpServers?: Record<string, unknown>[];
|
|
28
|
+
/** Sent as `session/new` `_meta`; the key is absent when unset. Opaque to toolnexus. */
|
|
29
|
+
sessionMeta?: Record<string, unknown>;
|
|
30
|
+
/** Sent as `initialize` `clientCapabilities._meta` (vendor flags). Opaque to toolnexus. */
|
|
31
|
+
clientCapsMeta?: Record<string, unknown>;
|
|
32
|
+
/** Passed through unchanged after `session/new`: `session/set_config_option`
|
|
33
|
+
* `{configId:"model"}`, then `session/set_model` if the agent rejects that. Both failing
|
|
34
|
+
* fails `loadACP`. */
|
|
35
|
+
model?: string;
|
|
36
|
+
/** Receives the agent's stderr (default: discarded). */
|
|
37
|
+
stderr?: (chunk: string) => void;
|
|
38
|
+
/** Decides each `session/request_permission`: return an `optionId` to select it, or
|
|
39
|
+
* `""`/undefined to answer with the first `reject*` option. Absent ⇒ the first `allow*`
|
|
40
|
+
* option, as before. */
|
|
41
|
+
onPermission?: (params: any) => string | undefined | Promise<string | undefined>;
|
|
42
|
+
/** Observes every raw `session/update` params object (must not throw). */
|
|
43
|
+
onUpdate?: (params: any) => void;
|
|
44
|
+
/** Bounds each handshake request (`initialize`, `session/new`, …). Absent ⇒ unbounded;
|
|
45
|
+
* a request still fails at once when the agent exits. */
|
|
46
|
+
requestTimeoutMs?: number;
|
|
25
47
|
}
|
|
26
48
|
/** One warm ACP connection: one child process, one session, alive across many
|
|
27
49
|
* turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
|
|
28
50
|
* wants, so it plugs straight into {@link createInProcessClient}. */
|
|
29
51
|
export interface ACPClient {
|
|
30
52
|
generate: (request: InProcessRequest) => Promise<InProcessResponse>;
|
|
31
|
-
/**
|
|
53
|
+
/** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
|
|
54
|
+
* agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
|
|
55
|
+
* tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
|
|
56
|
+
* (both via "acp": the agent's own tools, reported, never executed by the loop).
|
|
57
|
+
* Shaped for {@link InProcessOptions.generateStream}. The returned content is the
|
|
58
|
+
* message text only. */
|
|
59
|
+
generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void) => Promise<InProcessResponse>;
|
|
60
|
+
/** Terminates the agent's process group. Idempotent — a second call is a no-op. */
|
|
32
61
|
close: () => Promise<void>;
|
|
62
|
+
/** The agent's raw `initialize` result. */
|
|
63
|
+
readonly initializeResult: any;
|
|
33
64
|
}
|
|
34
65
|
/**
|
|
35
66
|
* Opens one ACP connection: spawns `command`, performs `initialize` →
|
|
36
67
|
* `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
|
|
37
|
-
* trap real
|
|
68
|
+
* trap real agents enforce with `-32602` otherwise) → an optional
|
|
38
69
|
* `session/set_mode`, then returns a client whose `.generate` sends exactly
|
|
39
70
|
* one `session/prompt` per call, accumulating only `agent_message_chunk`
|
|
40
71
|
* text.
|
package/dist/acp.js
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` →
|
|
@@ -81,7 +80,7 @@ function renderPrompt(request) {
|
|
|
81
80
|
/**
|
|
82
81
|
* Opens one ACP connection: spawns `command`, performs `initialize` →
|
|
83
82
|
* `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
|
|
84
|
-
* trap real
|
|
83
|
+
* trap real agents enforce with `-32602` otherwise) → an optional
|
|
85
84
|
* `session/set_mode`, then returns a client whose `.generate` sends exactly
|
|
86
85
|
* one `session/prompt` per call, accumulating only `agent_message_chunk`
|
|
87
86
|
* text.
|
|
@@ -89,16 +88,56 @@ function renderPrompt(request) {
|
|
|
89
88
|
export async function loadACP(opts) {
|
|
90
89
|
const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
|
|
91
90
|
const permissionTimeoutMs = opts.permissionTimeoutMs ?? 30000;
|
|
91
|
+
// Its own process group (detached), so close() reaches an adapter's children too — an
|
|
92
|
+
// `npx …` launch is a tree, not one process.
|
|
92
93
|
const child = spawn(opts.command, opts.args ?? [], {
|
|
93
94
|
cwd,
|
|
94
95
|
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
95
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
96
|
+
stdio: ["pipe", "pipe", opts.stderr ? "pipe" : "ignore"],
|
|
97
|
+
detached: process.platform !== "win32",
|
|
96
98
|
});
|
|
99
|
+
if (opts.stderr) {
|
|
100
|
+
child.stderr.setEncoding("utf8");
|
|
101
|
+
child.stderr.on("data", (d) => {
|
|
102
|
+
try {
|
|
103
|
+
opts.stderr(d);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// a host sink never breaks the session
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
97
110
|
let closed = false;
|
|
98
111
|
let nextId = 0;
|
|
99
112
|
const pending = new Map();
|
|
100
113
|
let updateSink = null;
|
|
114
|
+
let exited = false;
|
|
115
|
+
let startError;
|
|
116
|
+
const childGone = new Promise((resolve) => {
|
|
117
|
+
child.once("exit", () => resolve());
|
|
118
|
+
child.once("error", () => resolve());
|
|
119
|
+
});
|
|
120
|
+
// Fail fast: a request still waiting when the agent's stdout closes fails at once
|
|
121
|
+
// instead of waiting out a timeout. setImmediate lets a spawn `error` land first.
|
|
122
|
+
const failPending = () => {
|
|
123
|
+
exited = true;
|
|
124
|
+
for (const [id, p] of pending) {
|
|
125
|
+
pending.delete(id);
|
|
126
|
+
p.cb({ error: { code: 0, message: exitMessage(p.method) } });
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const exitMessage = (method) => startError
|
|
130
|
+
? `toolnexus: acp: start ${JSON.stringify(opts.command)}: ${startError.message}`
|
|
131
|
+
: `toolnexus: acp: agent exited before replying to ${method}`;
|
|
132
|
+
child.on("error", (e) => {
|
|
133
|
+
startError = e;
|
|
134
|
+
setImmediate(failPending);
|
|
135
|
+
});
|
|
136
|
+
child.stdin.on("error", () => {
|
|
137
|
+
// EPIPE after the agent died: surfaced through failPending, never as a crash.
|
|
138
|
+
});
|
|
101
139
|
const rl = createInterface({ input: child.stdout });
|
|
140
|
+
rl.on("close", () => setImmediate(failPending));
|
|
102
141
|
rl.on("line", (raw) => {
|
|
103
142
|
const line = raw.trim();
|
|
104
143
|
if (!line)
|
|
@@ -113,6 +152,8 @@ export async function loadACP(opts) {
|
|
|
113
152
|
handleLine(msg);
|
|
114
153
|
});
|
|
115
154
|
function write(msg) {
|
|
155
|
+
if (exited || child.stdin.destroyed)
|
|
156
|
+
return;
|
|
116
157
|
child.stdin.write(JSON.stringify(msg) + "\n");
|
|
117
158
|
}
|
|
118
159
|
function reply(id, result) {
|
|
@@ -121,12 +162,30 @@ export async function loadACP(opts) {
|
|
|
121
162
|
function call(method, params) {
|
|
122
163
|
const id = `c-${++nextId}`;
|
|
123
164
|
return new Promise((resolve, reject) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
165
|
+
if (exited) {
|
|
166
|
+
reject(new Error(exitMessage(method)));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
let timer;
|
|
170
|
+
pending.set(id, {
|
|
171
|
+
method,
|
|
172
|
+
cb: (msg) => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
if (msg.error && msg.error.code === 0)
|
|
175
|
+
reject(new Error(msg.error.message));
|
|
176
|
+
else if (msg.error)
|
|
177
|
+
reject(new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`));
|
|
178
|
+
else
|
|
179
|
+
resolve(msg.result);
|
|
180
|
+
},
|
|
129
181
|
});
|
|
182
|
+
if (opts.requestTimeoutMs && opts.requestTimeoutMs > 0) {
|
|
183
|
+
const ms = opts.requestTimeoutMs;
|
|
184
|
+
timer = setTimeout(() => {
|
|
185
|
+
pending.delete(id);
|
|
186
|
+
reject(new Error(`toolnexus: acp: ${method} timed out after ${ms}ms`));
|
|
187
|
+
}, ms);
|
|
188
|
+
}
|
|
130
189
|
write({ jsonrpc: "2.0", id, method, params });
|
|
131
190
|
});
|
|
132
191
|
}
|
|
@@ -136,66 +195,167 @@ export async function loadACP(opts) {
|
|
|
136
195
|
* server-initiated requests like `session/request_permission`. */
|
|
137
196
|
function handleLine(msg) {
|
|
138
197
|
if (msg.method === undefined && msg.id !== undefined) {
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
198
|
+
const p = pending.get(String(msg.id));
|
|
199
|
+
if (p) {
|
|
141
200
|
pending.delete(String(msg.id));
|
|
142
|
-
cb(msg);
|
|
201
|
+
p.cb(msg);
|
|
143
202
|
}
|
|
144
203
|
return;
|
|
145
204
|
}
|
|
146
205
|
if (msg.method === "session/update") {
|
|
206
|
+
if (opts.onUpdate) {
|
|
207
|
+
try {
|
|
208
|
+
opts.onUpdate(msg.params);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// an observer never breaks the session
|
|
212
|
+
}
|
|
213
|
+
}
|
|
147
214
|
updateSink?.(msg.params);
|
|
148
215
|
return;
|
|
149
216
|
}
|
|
150
217
|
if (msg.method === "session/request_permission" && msg.id !== undefined) {
|
|
151
|
-
answerPermission(msg);
|
|
218
|
+
void answerPermission(msg);
|
|
152
219
|
return;
|
|
153
220
|
}
|
|
154
|
-
// An
|
|
155
|
-
//
|
|
156
|
-
if (msg.id !== undefined)
|
|
157
|
-
|
|
221
|
+
// An agent->client REQUEST we do not implement (fs/*, terminal/* — declared
|
|
222
|
+
// unsupported in clientCapabilities). Answer it, or the agent waits forever.
|
|
223
|
+
if (msg.id !== undefined) {
|
|
224
|
+
write({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `method not found: ${msg.method}` } });
|
|
225
|
+
}
|
|
158
226
|
}
|
|
159
227
|
/** Answers with the FIRST option whose `kind` starts with `allow` — an
|
|
160
228
|
* unanswered permission request hangs the turn forever (proven in
|
|
161
229
|
* spikes/acp). This runs the instant the request arrives, never waiting
|
|
162
230
|
* for anything else. */
|
|
163
|
-
function answerPermission(req) {
|
|
231
|
+
async function answerPermission(req) {
|
|
164
232
|
const options = req.params?.options ?? [];
|
|
165
|
-
|
|
233
|
+
let chosen;
|
|
234
|
+
if (opts.onPermission) {
|
|
235
|
+
let picked;
|
|
236
|
+
try {
|
|
237
|
+
picked = await opts.onPermission(req.params);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
picked = undefined;
|
|
241
|
+
}
|
|
242
|
+
chosen = picked
|
|
243
|
+
? { optionId: picked }
|
|
244
|
+
: options.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"));
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
chosen = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
|
|
248
|
+
}
|
|
166
249
|
const result = chosen
|
|
167
250
|
? { outcome: { outcome: "selected", optionId: chosen.optionId } }
|
|
168
251
|
: { outcome: { outcome: "cancelled" } };
|
|
169
252
|
reply(req.id, result);
|
|
170
253
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
254
|
+
const clientCapabilities = {
|
|
255
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
256
|
+
terminal: false,
|
|
257
|
+
};
|
|
258
|
+
if (opts.clientCapsMeta)
|
|
259
|
+
clientCapabilities._meta = opts.clientCapsMeta;
|
|
260
|
+
const step = async (label, fn) => {
|
|
261
|
+
try {
|
|
262
|
+
return await fn();
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
await close();
|
|
266
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
267
|
+
throw new Error(startError ? m : `toolnexus: acp: ${label}: ${m}`);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const initializeResult = await step("initialize", () => call("initialize", { protocolVersion: opts.protocolVersion ?? 1, clientCapabilities }));
|
|
271
|
+
const sessionParams = { cwd, mcpServers: opts.mcpServers ?? [] };
|
|
272
|
+
if (opts.sessionMeta)
|
|
273
|
+
sessionParams._meta = opts.sessionMeta;
|
|
274
|
+
const sessionResult = await step("session/new", () => call("session/new", sessionParams));
|
|
176
275
|
const sessionId = sessionResult?.sessionId;
|
|
177
276
|
if (opts.mode) {
|
|
178
|
-
await call("session/set_mode", { sessionId, modeId: opts.mode });
|
|
277
|
+
await step("session/set_mode", () => call("session/set_mode", { sessionId, modeId: opts.mode }));
|
|
278
|
+
}
|
|
279
|
+
if (opts.model) {
|
|
280
|
+
// Passed through unchanged. Stable ACP spelling first, then the older session/set_model.
|
|
281
|
+
const model = opts.model;
|
|
282
|
+
await step(`set model ${JSON.stringify(model)}`, async () => {
|
|
283
|
+
try {
|
|
284
|
+
await call("session/set_config_option", { sessionId, configId: "model", value: model });
|
|
285
|
+
}
|
|
286
|
+
catch (e1) {
|
|
287
|
+
try {
|
|
288
|
+
await call("session/set_model", { sessionId, modelId: model });
|
|
289
|
+
}
|
|
290
|
+
catch (e2) {
|
|
291
|
+
throw new Error(`${e1.message}; ${e2.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
179
295
|
}
|
|
180
296
|
// Serialises turns: one ACP session is one conversation, so concurrent
|
|
181
297
|
// prompts must not interleave into it. Idiomatic promise-chaining queue —
|
|
182
298
|
// each generate() call attaches after whatever is already queued, and the
|
|
183
299
|
// queue itself never rejects (a failed turn does not poison later ones).
|
|
184
300
|
let turnQueue = Promise.resolve();
|
|
185
|
-
async function promptOnce(request) {
|
|
301
|
+
async function promptOnce(request, emit) {
|
|
186
302
|
const text = renderPrompt(request);
|
|
187
303
|
const id = `c-${++nextId}`;
|
|
188
304
|
let content = "";
|
|
305
|
+
const toolNames = new Map();
|
|
306
|
+
const send = (ev) => {
|
|
307
|
+
try {
|
|
308
|
+
emit?.(ev);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// a consumer never breaks the session
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
// Runs from the stdout read loop, so every event is emitted the moment its
|
|
315
|
+
// notification is processed — never buffered to the end of the turn.
|
|
189
316
|
updateSink = (params) => {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
317
|
+
const u = params?.update;
|
|
318
|
+
switch (u?.sessionUpdate) {
|
|
319
|
+
case "agent_message_chunk": {
|
|
320
|
+
const t = u?.content?.text ?? "";
|
|
321
|
+
content += t;
|
|
322
|
+
if (t)
|
|
323
|
+
send({ type: "text", delta: t });
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
// Thought and tool narration never reach the content — mixing them into
|
|
327
|
+
// the reply corrupts structured output a caller expects to parse.
|
|
328
|
+
case "agent_thought_chunk": {
|
|
329
|
+
const t = u?.content?.text ?? "";
|
|
330
|
+
if (t)
|
|
331
|
+
send({ type: "thought", delta: t, via: "acp" });
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
case "tool_call": {
|
|
335
|
+
const name = u.title || u.kind || "";
|
|
336
|
+
toolNames.set(u.toolCallId ?? "", name);
|
|
337
|
+
send({ type: "tool_call", id: u.toolCallId ?? "", name, args: u.rawInput ?? {}, via: "acp" });
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "tool_call_update": {
|
|
341
|
+
if (u.status !== "completed" && u.status !== "failed")
|
|
342
|
+
break;
|
|
343
|
+
const name = u.title || toolNames.get(u.toolCallId ?? "") || "";
|
|
344
|
+
send({
|
|
345
|
+
type: "tool_result",
|
|
346
|
+
id: u.toolCallId ?? "",
|
|
347
|
+
name,
|
|
348
|
+
output: acpToolContentText(u.content),
|
|
349
|
+
isError: u.status === "failed",
|
|
350
|
+
via: "acp",
|
|
351
|
+
});
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
193
354
|
}
|
|
194
|
-
// agent_thought_chunk and tool_call/tool_call_update narration are
|
|
195
|
-
// deliberately dropped — mixing them into the reply corrupts
|
|
196
|
-
// structured output a caller expects to parse.
|
|
197
355
|
};
|
|
198
|
-
|
|
356
|
+
if (exited)
|
|
357
|
+
throw new Error(exitMessage("session/prompt"));
|
|
358
|
+
const replyPromise = new Promise((resolve) => pending.set(id, { method: "session/prompt", cb: resolve }));
|
|
199
359
|
write({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: [{ type: "text", text }] } });
|
|
200
360
|
let timer;
|
|
201
361
|
const timeout = new Promise((_, reject) => {
|
|
@@ -203,6 +363,8 @@ export async function loadACP(opts) {
|
|
|
203
363
|
});
|
|
204
364
|
try {
|
|
205
365
|
const msg = await Promise.race([replyPromise, timeout]);
|
|
366
|
+
if (msg.error && msg.error.code === 0)
|
|
367
|
+
throw new Error(msg.error.message);
|
|
206
368
|
if (msg.error)
|
|
207
369
|
throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`);
|
|
208
370
|
return { content };
|
|
@@ -213,34 +375,59 @@ export async function loadACP(opts) {
|
|
|
213
375
|
updateSink = null;
|
|
214
376
|
}
|
|
215
377
|
}
|
|
216
|
-
const
|
|
378
|
+
const generateStream = (request, emit) => {
|
|
217
379
|
if (closed)
|
|
218
380
|
return Promise.reject(new Error("toolnexus: ACP client is closed"));
|
|
219
|
-
const turn = turnQueue.then(() => promptOnce(request));
|
|
381
|
+
const turn = turnQueue.then(() => promptOnce(request, emit));
|
|
220
382
|
turnQueue = turn.then(() => undefined, () => undefined);
|
|
221
383
|
return turn;
|
|
222
384
|
};
|
|
223
|
-
|
|
385
|
+
async function close() {
|
|
224
386
|
if (closed)
|
|
225
|
-
return
|
|
387
|
+
return;
|
|
226
388
|
closed = true;
|
|
227
389
|
rl.close();
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
const
|
|
390
|
+
child.stdin.end();
|
|
391
|
+
if (startError || child.pid === undefined)
|
|
392
|
+
return;
|
|
393
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
394
|
+
killGroup(child.pid, "SIGKILL"); // reap any orphaned children of the group
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
killGroup(child.pid, "SIGTERM");
|
|
398
|
+
const t = await Promise.race([childGone.then(() => "gone"), new Promise((r) => setTimeout(() => r("late"), 3000).unref())]);
|
|
399
|
+
killGroup(child.pid, "SIGKILL");
|
|
400
|
+
if (t === "late")
|
|
401
|
+
await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())]);
|
|
402
|
+
}
|
|
403
|
+
const generate = (request) => generateStream(request);
|
|
404
|
+
const client = { generate, generateStream, close, initializeResult };
|
|
242
405
|
// Non-enumerable, undocumented — internal test hook only, so the public
|
|
243
|
-
// ACPClient surface stays exactly {generate, close}.
|
|
406
|
+
// ACPClient surface stays exactly {generate, generateStream, close}.
|
|
244
407
|
Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false });
|
|
245
408
|
return client;
|
|
246
409
|
}
|
|
410
|
+
/** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
|
|
411
|
+
function killGroup(pid, signal) {
|
|
412
|
+
if (process.platform !== "win32") {
|
|
413
|
+
try {
|
|
414
|
+
process.kill(-pid, signal);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// group already gone; fall through to the pid itself
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
process.kill(pid, signal);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// already gone
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** Joins the text of an ACP tool_call_update's `content` array ({content:{text}} parts). */
|
|
429
|
+
function acpToolContentText(content) {
|
|
430
|
+
if (!Array.isArray(content))
|
|
431
|
+
return "";
|
|
432
|
+
return content.map((p) => (typeof p?.content?.text === "string" ? p.content.text : "")).join("");
|
|
433
|
+
}
|