toolnexus 0.5.0 → 0.7.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 +35 -0
- package/dist/builtin.js +26 -3
- package/dist/client.d.ts +24 -1
- package/dist/client.js +118 -18
- package/dist/mcp.d.ts +9 -2
- package/dist/mcp.js +36 -2
- package/dist/serve.js +17 -6
- package/dist/toolkit.d.ts +7 -0
- package/dist/toolkit.js +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.js +19 -0
- package/package.json +1 -1
- package/src/builtin.ts +25 -3
- package/src/client.ts +126 -20
- package/src/mcp.ts +40 -4
- package/src/serve.ts +15 -5
- package/src/toolkit.ts +8 -1
- package/src/types.ts +50 -0
package/README.md
CHANGED
|
@@ -61,6 +61,41 @@ console.log(text)
|
|
|
61
61
|
await tk.close() // disconnect MCP servers
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
## Suspension — login / approval / input (`Pending` / `waitFor`)
|
|
65
|
+
|
|
66
|
+
Some tools can't finish in one shot: they need an **out-of-band, async resolution** — a human
|
|
67
|
+
logs in, approves a trade, types a value. That's one idea, not one feature: *login* is just the
|
|
68
|
+
`kind:"authorization"` case. A tool returns **`Pending(request)`**; the client calls your
|
|
69
|
+
**`waitFor(request) → answer`**, then **retries the tool** with `ctx.answer`, and the model gets
|
|
70
|
+
the real answer. Everything crossing a boundary is plain data, so it works the same across
|
|
71
|
+
processes, agents, and restarts — and identically in all five language ports.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { authRequired } from "toolnexus"
|
|
75
|
+
|
|
76
|
+
// a tool that needs login the first time:
|
|
77
|
+
defineTool({
|
|
78
|
+
name: "get_balance", description: "…", inputSchema: { type: "object", properties: {} },
|
|
79
|
+
run: (_args, ctx) =>
|
|
80
|
+
authed ? `balance: ₹67,417` : authRequired("https://example.com/login", "Log in to continue"),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// the host supplies ONE function — how the link reaches the human is your call:
|
|
84
|
+
const agent = createClient({
|
|
85
|
+
baseUrl, style: "openai", model,
|
|
86
|
+
waitFor: async (request) => { // interactive: open a browser + poll
|
|
87
|
+
open(request.url); await pollUntilLoggedIn(); return { id: request.id, ok: true }
|
|
88
|
+
},
|
|
89
|
+
// headless desk: waitFor = send request.url to your channel + poll
|
|
90
|
+
// another agent: waitFor = forward the request over A2A, await its answer
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Omit `waitFor` for a **durable** host: `run()` doesn't hang — it returns
|
|
95
|
+
`{ status: "pending", pending: request }`, so you can deliver the link out-of-band (file, HTTP,
|
|
96
|
+
channel) and resume later, even in another process. `stream()` emits a `{ type: "pending", request }`
|
|
97
|
+
event so a channel can push the link in real time. Runnable demo: `examples/pending.ts`.
|
|
98
|
+
|
|
64
99
|
## Conversations / memory
|
|
65
100
|
|
|
66
101
|
`run()` is stateless — each call starts fresh. To keep a thread's history, use `ask()` with an
|
package/dist/builtin.js
CHANGED
|
@@ -11,6 +11,7 @@ import { spawn } from "node:child_process";
|
|
|
11
11
|
import fs from "node:fs/promises";
|
|
12
12
|
import { existsSync, readdirSync } from "node:fs";
|
|
13
13
|
import path from "node:path";
|
|
14
|
+
import { pending } from "./types.js";
|
|
14
15
|
/**
|
|
15
16
|
* Whether the builtin source is on. Default ON. Same precedence as MCP:
|
|
16
17
|
* `disabled:true` wins, else `enabled:false` disables, otherwise enabled.
|
|
@@ -388,8 +389,25 @@ function webfetchTool() {
|
|
|
388
389
|
}
|
|
389
390
|
});
|
|
390
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* Render the questions into a human-readable `Request.prompt` (§10). Byte-identical across ports:
|
|
394
|
+
* each question's text in order, `" (options: a, b, c)"` appended when it has non-empty options,
|
|
395
|
+
* joined by "\n" (no trailing newline). `header` is not rendered — it survives in `data.questions`.
|
|
396
|
+
*/
|
|
397
|
+
function renderQuestionPrompt(questions) {
|
|
398
|
+
return questions
|
|
399
|
+
.map((q) => {
|
|
400
|
+
const item = q;
|
|
401
|
+
let line = typeof item?.question === "string" ? item.question : "";
|
|
402
|
+
const opts = Array.isArray(item?.options) ? item.options : [];
|
|
403
|
+
if (opts.length > 0)
|
|
404
|
+
line += ` (options: ${opts.join(", ")})`;
|
|
405
|
+
return line;
|
|
406
|
+
})
|
|
407
|
+
.join("\n");
|
|
408
|
+
}
|
|
391
409
|
function questionTool() {
|
|
392
|
-
return builtin("question", "Ask the host one or more questions.
|
|
410
|
+
return builtin("question", "Ask the host one or more questions. Suspends via a kind:\"question\" Request (§10); the host's waitFor resolves it and the answer is returned to the model.", {
|
|
393
411
|
type: "object",
|
|
394
412
|
properties: {
|
|
395
413
|
questions: {
|
|
@@ -409,9 +427,14 @@ function questionTool() {
|
|
|
409
427
|
},
|
|
410
428
|
required: ["questions"],
|
|
411
429
|
additionalProperties: false,
|
|
412
|
-
}, async (args) => {
|
|
430
|
+
}, async (args, ctx) => {
|
|
413
431
|
const questions = Array.isArray(args.questions) ? args.questions : [];
|
|
414
|
-
|
|
432
|
+
// Re-executed after the host's waitFor resolved (§10 loop rule): the resolution IS the
|
|
433
|
+
// answer, as with kind:"input" — forward it verbatim to the model.
|
|
434
|
+
if (ctx?.answer)
|
|
435
|
+
return ok(JSON.stringify(ctx.answer.data ?? {}));
|
|
436
|
+
// First call: suspend. A question is just a §10 Request with kind:"question".
|
|
437
|
+
return pending({ kind: "question", prompt: renderQuestionPrompt(questions), data: { questions } });
|
|
415
438
|
});
|
|
416
439
|
}
|
|
417
440
|
function todowriteTool() {
|
package/dist/client.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Toolkit. See ../../SPEC.md §8.
|
|
5
5
|
*/
|
|
6
6
|
import type { Toolkit } from "./toolkit.js";
|
|
7
|
-
import type { ToolResult } from "./types.js";
|
|
7
|
+
import type { ToolResult, Request, Answer } from "./types.js";
|
|
8
8
|
export type ClientStyle = "openai" | "anthropic";
|
|
9
9
|
export interface ClientOptions {
|
|
10
10
|
baseUrl: string;
|
|
@@ -27,6 +27,11 @@ export interface ClientOptions {
|
|
|
27
27
|
/** Observability sink — receives semantic metric events ({event:"llm"|"tool"|"run"…}) as the
|
|
28
28
|
* loop runs (§8 Observability). Forward to statsd/logs/OTel/anything. No cost when unset. */
|
|
29
29
|
onMetric?: (ev: MetricEvent) => void;
|
|
30
|
+
/** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
|
|
31
|
+
* this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
|
|
32
|
+
* (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
|
|
33
|
+
* host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
|
|
34
|
+
waitFor?: (request: Request) => Promise<Answer>;
|
|
30
35
|
}
|
|
31
36
|
/**
|
|
32
37
|
* Semantic observability events (§8). NOT counter/histogram primitives — readable records the
|
|
@@ -46,6 +51,7 @@ export type MetricEvent = {
|
|
|
46
51
|
source: string;
|
|
47
52
|
isError: boolean;
|
|
48
53
|
ms: number;
|
|
54
|
+
pending?: boolean;
|
|
49
55
|
} | {
|
|
50
56
|
event: "run";
|
|
51
57
|
model: string;
|
|
@@ -148,6 +154,10 @@ export interface RunResult {
|
|
|
148
154
|
usage: Usage;
|
|
149
155
|
/** The model used. */
|
|
150
156
|
model: string;
|
|
157
|
+
/** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10). */
|
|
158
|
+
status: "done" | "pending";
|
|
159
|
+
/** The unresolved suspension — present iff status === "pending" (§10). */
|
|
160
|
+
pending?: Request;
|
|
151
161
|
}
|
|
152
162
|
/** Events yielded by client.stream(). */
|
|
153
163
|
export type StreamEvent = {
|
|
@@ -167,6 +177,9 @@ export type StreamEvent = {
|
|
|
167
177
|
} | {
|
|
168
178
|
type: "usage";
|
|
169
179
|
usage: Usage;
|
|
180
|
+
} | {
|
|
181
|
+
type: "pending";
|
|
182
|
+
request: Request;
|
|
170
183
|
} | {
|
|
171
184
|
type: "done";
|
|
172
185
|
result: RunResult;
|
|
@@ -225,10 +238,20 @@ export declare class Client {
|
|
|
225
238
|
private llmFetch;
|
|
226
239
|
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
227
240
|
private runTool;
|
|
241
|
+
/** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
|
|
242
|
+
private emitTool;
|
|
228
243
|
/** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
|
|
229
244
|
private llmCallJson;
|
|
230
245
|
private runOpenAI;
|
|
231
246
|
private result;
|
|
247
|
+
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
248
|
+
private pendingRun;
|
|
249
|
+
/**
|
|
250
|
+
* §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
|
|
251
|
+
* tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
|
|
252
|
+
* no `waitFor` is configured (the run should stop and surface the request).
|
|
253
|
+
*/
|
|
254
|
+
private resolvePending;
|
|
232
255
|
private runAnthropic;
|
|
233
256
|
private streamOpenAI;
|
|
234
257
|
private streamAnthropic;
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pendingOf } from "./types.js";
|
|
1
2
|
/** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
|
|
2
3
|
export class InMemoryConversationStore {
|
|
3
4
|
map = new Map();
|
|
@@ -97,7 +98,7 @@ class MetricsRegistry {
|
|
|
97
98
|
observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000);
|
|
98
99
|
}
|
|
99
100
|
else if (ev.event === "tool") {
|
|
100
|
-
inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)]]));
|
|
101
|
+
inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)], ["pending", String(ev.pending ?? false)]]));
|
|
101
102
|
observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000);
|
|
102
103
|
}
|
|
103
104
|
else if (ev.event === "run") {
|
|
@@ -269,22 +270,31 @@ export class Client {
|
|
|
269
270
|
if (h?.beforeTool) {
|
|
270
271
|
const ov = await h.beforeTool({ name, args: a, id, turn });
|
|
271
272
|
if (ov?.result) {
|
|
272
|
-
// short-circuit (deny/cache/dry-run
|
|
273
|
-
|
|
273
|
+
// short-circuit (deny/cache/dry-run, or a guard-raised suspension — path B); still a tool
|
|
274
|
+
// call for metrics, but a suspension is classified pending, never an error.
|
|
275
|
+
this.emitTool(name, source, ov.result, t0);
|
|
274
276
|
return { args: a, result: ov.result };
|
|
275
277
|
}
|
|
276
278
|
if (ov?.args)
|
|
277
279
|
a = ov.args;
|
|
278
280
|
}
|
|
279
281
|
let result = await toolkit.execute(name, a);
|
|
280
|
-
|
|
282
|
+
// A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
|
|
283
|
+
// result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
|
|
284
|
+
// a tool error.
|
|
285
|
+
if (h?.afterTool && !pendingOf(result)) {
|
|
281
286
|
const ov = await h.afterTool({ name, args: a, result, id, turn });
|
|
282
287
|
if (ov?.result)
|
|
283
288
|
result = ov.result;
|
|
284
289
|
}
|
|
285
|
-
this.
|
|
290
|
+
this.emitTool(name, source, result, t0);
|
|
286
291
|
return { args: a, result };
|
|
287
292
|
}
|
|
293
|
+
/** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
|
|
294
|
+
emitTool(name, source, result, t0) {
|
|
295
|
+
const req = pendingOf(result);
|
|
296
|
+
this.emit({ event: "tool", tool: name, source, isError: req ? false : result.isError, ms: Date.now() - t0, ...(req ? { pending: true } : {}) });
|
|
297
|
+
}
|
|
288
298
|
/** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
|
|
289
299
|
async llmCallJson(url, init, signal, style) {
|
|
290
300
|
const t0 = Date.now();
|
|
@@ -342,12 +352,26 @@ export class Client {
|
|
|
342
352
|
if (calls.length === 0)
|
|
343
353
|
return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage);
|
|
344
354
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
355
|
+
const settled = await Promise.all(calls.map(async (call) => {
|
|
356
|
+
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
|
|
357
|
+
let halted;
|
|
358
|
+
const req = pendingOf(result);
|
|
359
|
+
if (req) {
|
|
360
|
+
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn);
|
|
361
|
+
result = r.result;
|
|
362
|
+
halted = r.halted;
|
|
363
|
+
}
|
|
364
|
+
return { call, args, result, halted };
|
|
349
365
|
}));
|
|
350
|
-
|
|
366
|
+
// Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
|
|
367
|
+
// and the later suspensions' placeholder results never enter the transcript (they re-suspend
|
|
368
|
+
// on resume). Mirrors the streaming path.
|
|
369
|
+
for (const s of settled) {
|
|
370
|
+
toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
|
|
371
|
+
messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output });
|
|
372
|
+
if (s.halted)
|
|
373
|
+
return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage);
|
|
374
|
+
}
|
|
351
375
|
}
|
|
352
376
|
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
|
|
353
377
|
}
|
|
@@ -357,7 +381,35 @@ export class Client {
|
|
|
357
381
|
}
|
|
358
382
|
}
|
|
359
383
|
result(text, messages, toolCalls, turns, usage) {
|
|
360
|
-
return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model };
|
|
384
|
+
return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" };
|
|
385
|
+
}
|
|
386
|
+
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
387
|
+
pendingRun(runStart, request, messages, toolCalls, turns, usage) {
|
|
388
|
+
this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
|
|
389
|
+
return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request };
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
|
|
393
|
+
* tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
|
|
394
|
+
* no `waitFor` is configured (the run should stop and surface the request).
|
|
395
|
+
*/
|
|
396
|
+
async resolvePending(toolkit, name, args, request, id, turn) {
|
|
397
|
+
if (!this.opts.waitFor)
|
|
398
|
+
return { result: { output: request.prompt, isError: true }, halted: request };
|
|
399
|
+
const answer = await this.opts.waitFor(request);
|
|
400
|
+
if (!answer?.ok)
|
|
401
|
+
return { result: { output: `declined/expired: ${request.prompt}`, isError: true } };
|
|
402
|
+
const t0 = Date.now();
|
|
403
|
+
let result = await toolkit.execute(name, args, { answer });
|
|
404
|
+
if (this.opts.hooks?.afterTool) {
|
|
405
|
+
const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn });
|
|
406
|
+
if (ov?.result)
|
|
407
|
+
result = ov.result;
|
|
408
|
+
}
|
|
409
|
+
this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0);
|
|
410
|
+
if (pendingOf(result))
|
|
411
|
+
result = { output: `unresolved: ${request.prompt}`, isError: true }; // never loop forever
|
|
412
|
+
return { result };
|
|
361
413
|
}
|
|
362
414
|
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
363
415
|
async runAnthropic(prompt, toolkit, external, history) {
|
|
@@ -402,12 +454,33 @@ export class Client {
|
|
|
402
454
|
return this.endRun(runStart, text, messages, toolCalls, turns, usage);
|
|
403
455
|
}
|
|
404
456
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
457
|
+
const settled = await Promise.all(uses.map(async (use) => {
|
|
458
|
+
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
|
|
459
|
+
let halted;
|
|
460
|
+
const req = pendingOf(result);
|
|
461
|
+
if (req) {
|
|
462
|
+
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn);
|
|
463
|
+
result = r.result;
|
|
464
|
+
halted = r.halted;
|
|
465
|
+
}
|
|
466
|
+
return { use, args, result, halted };
|
|
409
467
|
}));
|
|
410
|
-
|
|
468
|
+
// Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
|
|
469
|
+
// building the content, and surface it — deterministic (G3), later suspensions re-suspend on
|
|
470
|
+
// resume. Mirrors the OpenAI path and the streaming loops.
|
|
471
|
+
const content = [];
|
|
472
|
+
let halted;
|
|
473
|
+
for (const s of settled) {
|
|
474
|
+
toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
|
|
475
|
+
content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError });
|
|
476
|
+
if (s.halted) {
|
|
477
|
+
halted = s.halted;
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
messages.push({ role: "user", content });
|
|
482
|
+
if (halted)
|
|
483
|
+
return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
|
|
411
484
|
}
|
|
412
485
|
return this.endRun(runStart, "", messages, toolCalls, turns, usage);
|
|
413
486
|
}
|
|
@@ -500,7 +573,20 @@ export class Client {
|
|
|
500
573
|
yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) };
|
|
501
574
|
const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)));
|
|
502
575
|
for (let i = 0; i < calls.length; i++) {
|
|
503
|
-
const c = calls[i]
|
|
576
|
+
const c = calls[i];
|
|
577
|
+
let { args, result } = settled[i];
|
|
578
|
+
const req = pendingOf(result);
|
|
579
|
+
if (req) {
|
|
580
|
+
yield { type: "pending", request: req }; // surface BEFORE waitFor so a channel can push the link
|
|
581
|
+
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn);
|
|
582
|
+
result = r.result;
|
|
583
|
+
if (r.halted) {
|
|
584
|
+
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
585
|
+
messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
|
|
586
|
+
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
504
590
|
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
505
591
|
messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
|
|
506
592
|
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
|
|
@@ -594,7 +680,21 @@ export class Client {
|
|
|
594
680
|
const settled = await Promise.all(uses.map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)));
|
|
595
681
|
const results = [];
|
|
596
682
|
for (let i = 0; i < uses.length; i++) {
|
|
597
|
-
const u = uses[i]
|
|
683
|
+
const u = uses[i];
|
|
684
|
+
let { args, result } = settled[i];
|
|
685
|
+
const req = pendingOf(result);
|
|
686
|
+
if (req) {
|
|
687
|
+
yield { type: "pending", request: req };
|
|
688
|
+
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn);
|
|
689
|
+
result = r.result;
|
|
690
|
+
if (r.halted) {
|
|
691
|
+
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
692
|
+
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
|
|
693
|
+
messages.push({ role: "user", content: results });
|
|
694
|
+
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
598
698
|
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
599
699
|
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
|
|
600
700
|
yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError };
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { type Tool, type McpStatus, type Request, type Answer } from "./types.js";
|
|
3
|
+
/** Map an MCP `elicitation/create` request onto a §10 `Request`. */
|
|
4
|
+
export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
|
|
5
|
+
/** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
|
|
6
|
+
export declare function answerToElicitResult(answer: Answer): ElicitResult;
|
|
2
7
|
export interface LocalServer {
|
|
3
8
|
type?: "local";
|
|
4
9
|
command: string[];
|
|
@@ -32,4 +37,6 @@ export interface McpSource {
|
|
|
32
37
|
close(): Promise<void>;
|
|
33
38
|
}
|
|
34
39
|
/** Connect to every enabled server, list + convert tools. Failures are isolated. */
|
|
35
|
-
export declare function loadMcp(input: string | object
|
|
40
|
+
export declare function loadMcp(input: string | object, opts?: {
|
|
41
|
+
waitFor?: (request: Request) => Promise<Answer>;
|
|
42
|
+
}): Promise<McpSource>;
|
package/dist/mcp.js
CHANGED
|
@@ -8,7 +8,35 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
9
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
10
10
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
11
|
+
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
11
12
|
import { sanitize } from "./types.js";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
15
|
+
// (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
|
|
16
|
+
// URL mode → kind:"authorization". The two mappings are pure + byte-parity-tested.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
let _elcSeq = 0;
|
|
19
|
+
/** Map an MCP `elicitation/create` request onto a §10 `Request`. */
|
|
20
|
+
export function elicitationToRequest(params) {
|
|
21
|
+
const p = params;
|
|
22
|
+
const isUrl = p.mode === "url";
|
|
23
|
+
const req = {
|
|
24
|
+
id: `elc-${Date.now().toString(36)}-${++_elcSeq}`,
|
|
25
|
+
kind: isUrl ? "authorization" : "input",
|
|
26
|
+
prompt: String(p.message ?? ""),
|
|
27
|
+
};
|
|
28
|
+
if (isUrl && p.url)
|
|
29
|
+
req.url = p.url;
|
|
30
|
+
if (!isUrl && p.requestedSchema)
|
|
31
|
+
req.data = { schema: p.requestedSchema };
|
|
32
|
+
return req;
|
|
33
|
+
}
|
|
34
|
+
/** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
|
|
35
|
+
export function answerToElicitResult(answer) {
|
|
36
|
+
if (answer.ok)
|
|
37
|
+
return { action: "accept", content: (answer.data ?? {}) };
|
|
38
|
+
return { action: answer.reason === "declined" ? "decline" : "cancel" };
|
|
39
|
+
}
|
|
12
40
|
const DEFAULT_TIMEOUT = 30_000;
|
|
13
41
|
const MAX_LIST_PAGES = 1_000;
|
|
14
42
|
function isRemote(cfg) {
|
|
@@ -117,18 +145,24 @@ function convertTool(server, def, client, timeout) {
|
|
|
117
145
|
};
|
|
118
146
|
}
|
|
119
147
|
/** Connect to every enabled server, list + convert tools. Failures are isolated. */
|
|
120
|
-
export async function loadMcp(input) {
|
|
148
|
+
export async function loadMcp(input, opts) {
|
|
121
149
|
const config = parseMcpConfig(input);
|
|
122
150
|
const tools = [];
|
|
123
151
|
const status = {};
|
|
124
152
|
const clients = [];
|
|
153
|
+
const waitFor = opts?.waitFor;
|
|
125
154
|
await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
|
|
126
155
|
if (!isEnabled(cfg)) {
|
|
127
156
|
status[name] = "disabled";
|
|
128
157
|
return;
|
|
129
158
|
}
|
|
130
159
|
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
|
|
131
|
-
|
|
160
|
+
// Advertise elicitation + register the bridge ONLY when a waitFor exists to satisfy it —
|
|
161
|
+
// a waitFor-less host degrades cleanly (the server won't elicit). (§10 elicitation bridge)
|
|
162
|
+
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
|
|
163
|
+
if (waitFor) {
|
|
164
|
+
client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
|
|
165
|
+
}
|
|
132
166
|
try {
|
|
133
167
|
if (isRemote(cfg)) {
|
|
134
168
|
const url = new URL(cfg.url);
|
package/dist/serve.js
CHANGED
|
@@ -239,12 +239,23 @@ async function fulfil(id, text, store, runTask, onTask, contextId) {
|
|
|
239
239
|
// contextId groups a peer's turns into one conversation — thread it so the
|
|
240
240
|
// client's ConversationStore remembers across tasks in the same context.
|
|
241
241
|
result = await runTask(text, contextId);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
242
|
+
if (result.status === "pending" && result.pending) {
|
|
243
|
+
// §10 suspension over A2A: the run halted waiting on out-of-band input. Surface the
|
|
244
|
+
// protocol's `input-required` state carrying the request prompt — never a false `completed`.
|
|
245
|
+
task = {
|
|
246
|
+
id,
|
|
247
|
+
status: { state: "input-required", message: { role: "agent", parts: [{ kind: "text", text: result.pending.prompt }] } },
|
|
248
|
+
};
|
|
249
|
+
state = "input-required";
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const artifact = {
|
|
253
|
+
artifactId: randomUUID(),
|
|
254
|
+
parts: [{ kind: "text", text: result.text }],
|
|
255
|
+
};
|
|
256
|
+
task = { id, status: { state: "completed" }, artifacts: [artifact] };
|
|
257
|
+
state = "completed";
|
|
258
|
+
}
|
|
248
259
|
}
|
|
249
260
|
catch (e) {
|
|
250
261
|
const detail = e instanceof Error ? e.message : String(e);
|
package/dist/toolkit.d.ts
CHANGED
|
@@ -16,6 +16,13 @@ export interface ToolkitOptions {
|
|
|
16
16
|
builtins?: BuiltinsConfig;
|
|
17
17
|
/** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
|
|
18
18
|
agents?: Agent[];
|
|
19
|
+
/**
|
|
20
|
+
* Host resolver for out-of-band input (§10). When set, connected MCP servers may elicit input
|
|
21
|
+
* from the human mid-`tools/call` and it is bridged onto this `waitFor` (form→kind:"input",
|
|
22
|
+
* URL→kind:"authorization"). Typically the same function passed to the client. Omit ⇒ MCP
|
|
23
|
+
* elicitation is not advertised.
|
|
24
|
+
*/
|
|
25
|
+
waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>;
|
|
19
26
|
}
|
|
20
27
|
export declare class Toolkit {
|
|
21
28
|
private readonly mcp;
|
package/dist/toolkit.js
CHANGED
|
@@ -34,7 +34,7 @@ export class Toolkit {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
static async create(opts) {
|
|
37
|
-
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined;
|
|
37
|
+
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined;
|
|
38
38
|
const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined;
|
|
39
39
|
// The toggle comes from the `builtins` option, or a top-level `builtins`
|
|
40
40
|
// key on a parsed config object — same precedence as MCP's isEnabled.
|
package/dist/types.d.ts
CHANGED
|
@@ -17,7 +17,41 @@ export interface ToolResult {
|
|
|
17
17
|
export interface ToolContext {
|
|
18
18
|
signal?: AbortSignal;
|
|
19
19
|
timeout?: number;
|
|
20
|
+
/** Present ONLY on a post-waitFor retry (§10): the resolution of a prior suspension. */
|
|
21
|
+
answer?: Answer;
|
|
20
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* §10 Suspension. A tool that needs an out-of-band, async resolution (login, approval,
|
|
25
|
+
* input) returns a normal ToolResult carrying `metadata.pending = Request`. Byte-identical
|
|
26
|
+
* wire data — it crosses languages, processes, and agents unchanged.
|
|
27
|
+
*/
|
|
28
|
+
export interface Request {
|
|
29
|
+
id: string;
|
|
30
|
+
kind: string;
|
|
31
|
+
prompt: string;
|
|
32
|
+
url?: string;
|
|
33
|
+
data?: Record<string, unknown>;
|
|
34
|
+
expiresAt?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface Answer {
|
|
37
|
+
id: string;
|
|
38
|
+
ok: boolean;
|
|
39
|
+
data?: Record<string, unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* When `ok` is false, why (advisory — the loop rule branches only on `ok`). Distinguishes an
|
|
42
|
+
* explicit refusal from a dismissal/timeout; the MCP elicitation bridge maps `declined`→`decline`
|
|
43
|
+
* and everything else→`cancel`. (R1)
|
|
44
|
+
*/
|
|
45
|
+
reason?: "declined" | "cancelled" | "expired";
|
|
46
|
+
}
|
|
47
|
+
/** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
|
|
48
|
+
export declare function pending(request: Omit<Request, "id"> & {
|
|
49
|
+
id?: string;
|
|
50
|
+
}): ToolResult;
|
|
51
|
+
/** Sugar for the common case: `kind:"authorization"` at a login URL. */
|
|
52
|
+
export declare function authRequired(url: string, prompt?: string): ToolResult;
|
|
53
|
+
/** Read the suspension off a result, if any. */
|
|
54
|
+
export declare function pendingOf(result: ToolResult): Request | undefined;
|
|
21
55
|
export interface Tool {
|
|
22
56
|
name: string;
|
|
23
57
|
description: string;
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared contract types. See ../../SPEC.md.
|
|
3
3
|
*/
|
|
4
|
+
let _pendingSeq = 0;
|
|
5
|
+
/** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
|
|
6
|
+
export function pending(request) {
|
|
7
|
+
const id = request.id ?? `pnd-${Date.now().toString(36)}-${++_pendingSeq}`;
|
|
8
|
+
const req = { ...request, id };
|
|
9
|
+
return {
|
|
10
|
+
output: req.prompt + (req.url ? `\n${req.url}` : ""),
|
|
11
|
+
isError: true,
|
|
12
|
+
metadata: { pending: req },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Sugar for the common case: `kind:"authorization"` at a login URL. */
|
|
16
|
+
export function authRequired(url, prompt = "Authorization required to continue") {
|
|
17
|
+
return pending({ kind: "authorization", prompt, url });
|
|
18
|
+
}
|
|
19
|
+
/** Read the suspension off a result, if any. */
|
|
20
|
+
export function pendingOf(result) {
|
|
21
|
+
return result.metadata?.pending;
|
|
22
|
+
}
|
|
4
23
|
/** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
|
|
5
24
|
export const sanitize = (value) => value.replace(/[^a-zA-Z0-9_-]/g, "_");
|
package/package.json
CHANGED
package/src/builtin.ts
CHANGED
|
@@ -12,6 +12,7 @@ import fs from "node:fs/promises"
|
|
|
12
12
|
import { existsSync, readdirSync } from "node:fs"
|
|
13
13
|
import path from "node:path"
|
|
14
14
|
import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
|
|
15
|
+
import { pending } from "./types.js"
|
|
15
16
|
|
|
16
17
|
/** Config for the single global builtin toggle (mirrors MCP isEnabled precedence). */
|
|
17
18
|
export type BuiltinsConfig =
|
|
@@ -412,10 +413,27 @@ function webfetchTool(): Tool {
|
|
|
412
413
|
)
|
|
413
414
|
}
|
|
414
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Render the questions into a human-readable `Request.prompt` (§10). Byte-identical across ports:
|
|
418
|
+
* each question's text in order, `" (options: a, b, c)"` appended when it has non-empty options,
|
|
419
|
+
* joined by "\n" (no trailing newline). `header` is not rendered — it survives in `data.questions`.
|
|
420
|
+
*/
|
|
421
|
+
function renderQuestionPrompt(questions: unknown[]): string {
|
|
422
|
+
return questions
|
|
423
|
+
.map((q) => {
|
|
424
|
+
const item = q as { question?: unknown; options?: unknown }
|
|
425
|
+
let line = typeof item?.question === "string" ? item.question : ""
|
|
426
|
+
const opts = Array.isArray(item?.options) ? item.options : []
|
|
427
|
+
if (opts.length > 0) line += ` (options: ${opts.join(", ")})`
|
|
428
|
+
return line
|
|
429
|
+
})
|
|
430
|
+
.join("\n")
|
|
431
|
+
}
|
|
432
|
+
|
|
415
433
|
function questionTool(): Tool {
|
|
416
434
|
return builtin(
|
|
417
435
|
"question",
|
|
418
|
-
"Ask the host one or more questions.
|
|
436
|
+
"Ask the host one or more questions. Suspends via a kind:\"question\" Request (§10); the host's waitFor resolves it and the answer is returned to the model.",
|
|
419
437
|
{
|
|
420
438
|
type: "object",
|
|
421
439
|
properties: {
|
|
@@ -437,9 +455,13 @@ function questionTool(): Tool {
|
|
|
437
455
|
required: ["questions"],
|
|
438
456
|
additionalProperties: false,
|
|
439
457
|
},
|
|
440
|
-
async (args) => {
|
|
458
|
+
async (args, ctx) => {
|
|
441
459
|
const questions = Array.isArray(args.questions) ? args.questions : []
|
|
442
|
-
|
|
460
|
+
// Re-executed after the host's waitFor resolved (§10 loop rule): the resolution IS the
|
|
461
|
+
// answer, as with kind:"input" — forward it verbatim to the model.
|
|
462
|
+
if (ctx?.answer) return ok(JSON.stringify(ctx.answer.data ?? {}))
|
|
463
|
+
// First call: suspend. A question is just a §10 Request with kind:"question".
|
|
464
|
+
return pending({ kind: "question", prompt: renderQuestionPrompt(questions), data: { questions } })
|
|
443
465
|
},
|
|
444
466
|
)
|
|
445
467
|
}
|
package/src/client.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Toolkit. See ../../SPEC.md §8.
|
|
5
5
|
*/
|
|
6
6
|
import type { Toolkit } from "./toolkit.js"
|
|
7
|
-
import type { ToolResult } from "./types.js"
|
|
7
|
+
import type { ToolResult, Request, Answer } from "./types.js"
|
|
8
|
+
import { pendingOf } from "./types.js"
|
|
8
9
|
|
|
9
10
|
export type ClientStyle = "openai" | "anthropic"
|
|
10
11
|
|
|
@@ -29,6 +30,11 @@ export interface ClientOptions {
|
|
|
29
30
|
/** Observability sink — receives semantic metric events ({event:"llm"|"tool"|"run"…}) as the
|
|
30
31
|
* loop runs (§8 Observability). Forward to statsd/logs/OTel/anything. No cost when unset. */
|
|
31
32
|
onMetric?: (ev: MetricEvent) => void
|
|
33
|
+
/** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
|
|
34
|
+
* this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
|
|
35
|
+
* (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
|
|
36
|
+
* host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
|
|
37
|
+
waitFor?: (request: Request) => Promise<Answer>
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/**
|
|
@@ -38,7 +44,7 @@ export interface ClientOptions {
|
|
|
38
44
|
*/
|
|
39
45
|
export type MetricEvent =
|
|
40
46
|
| { event: "llm"; model: string; status: "ok" | "error"; ms: number; promptTokens: number; completionTokens: number }
|
|
41
|
-
| { event: "tool"; tool: string; source: string; isError: boolean; ms: number }
|
|
47
|
+
| { event: "tool"; tool: string; source: string; isError: boolean; ms: number; pending?: boolean }
|
|
42
48
|
| { event: "run"; model: string; turns: number; toolCalls: number; totalTokens: number; ms: number; error?: string }
|
|
43
49
|
|
|
44
50
|
/**
|
|
@@ -117,6 +123,10 @@ export interface RunResult {
|
|
|
117
123
|
usage: Usage
|
|
118
124
|
/** The model used. */
|
|
119
125
|
model: string
|
|
126
|
+
/** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10). */
|
|
127
|
+
status: "done" | "pending"
|
|
128
|
+
/** The unresolved suspension — present iff status === "pending" (§10). */
|
|
129
|
+
pending?: Request
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
/** Events yielded by client.stream(). */
|
|
@@ -125,6 +135,7 @@ export type StreamEvent =
|
|
|
125
135
|
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
|
|
126
136
|
| { type: "tool_result"; id: string; name: string; output: string; isError: boolean }
|
|
127
137
|
| { type: "usage"; usage: Usage }
|
|
138
|
+
| { type: "pending"; request: Request }
|
|
128
139
|
| { type: "done"; result: RunResult }
|
|
129
140
|
|
|
130
141
|
function addUsage(acc: Usage, raw: any, style: ClientStyle): void {
|
|
@@ -216,7 +227,7 @@ class MetricsRegistry {
|
|
|
216
227
|
}
|
|
217
228
|
observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000)
|
|
218
229
|
} else if (ev.event === "tool") {
|
|
219
|
-
inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)]]))
|
|
230
|
+
inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)], ["pending", String(ev.pending ?? false)]]))
|
|
220
231
|
observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000)
|
|
221
232
|
} else if (ev.event === "run") {
|
|
222
233
|
if (ev.error) inc(this.runErrors, labelStr([["model", ev.model]]))
|
|
@@ -391,21 +402,31 @@ export class Client {
|
|
|
391
402
|
if (h?.beforeTool) {
|
|
392
403
|
const ov = await h.beforeTool({ name, args: a, id, turn })
|
|
393
404
|
if (ov?.result) {
|
|
394
|
-
// short-circuit (deny/cache/dry-run
|
|
395
|
-
|
|
405
|
+
// short-circuit (deny/cache/dry-run, or a guard-raised suspension — path B); still a tool
|
|
406
|
+
// call for metrics, but a suspension is classified pending, never an error.
|
|
407
|
+
this.emitTool(name, source, ov.result, t0)
|
|
396
408
|
return { args: a, result: ov.result }
|
|
397
409
|
}
|
|
398
410
|
if (ov?.args) a = ov.args
|
|
399
411
|
}
|
|
400
412
|
let result = await toolkit.execute(name, a)
|
|
401
|
-
|
|
413
|
+
// A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
|
|
414
|
+
// result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
|
|
415
|
+
// a tool error.
|
|
416
|
+
if (h?.afterTool && !pendingOf(result)) {
|
|
402
417
|
const ov = await h.afterTool({ name, args: a, result, id, turn })
|
|
403
418
|
if (ov?.result) result = ov.result
|
|
404
419
|
}
|
|
405
|
-
this.
|
|
420
|
+
this.emitTool(name, source, result, t0)
|
|
406
421
|
return { args: a, result }
|
|
407
422
|
}
|
|
408
423
|
|
|
424
|
+
/** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
|
|
425
|
+
private emitTool(name: string, source: string, result: ToolResult, t0: number) {
|
|
426
|
+
const req = pendingOf(result)
|
|
427
|
+
this.emit({ event: "tool", tool: name, source, isError: req ? false : result.isError, ms: Date.now() - t0, ...(req ? { pending: true } : {}) })
|
|
428
|
+
}
|
|
429
|
+
|
|
409
430
|
/** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
|
|
410
431
|
private async llmCallJson(url: string, init: RequestInit, signal: AbortSignal, style: ClientStyle): Promise<any> {
|
|
411
432
|
const t0 = Date.now()
|
|
@@ -458,14 +479,27 @@ export class Client {
|
|
|
458
479
|
const calls = msg.tool_calls ?? []
|
|
459
480
|
if (calls.length === 0) return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage)
|
|
460
481
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
461
|
-
const
|
|
482
|
+
const settled = await Promise.all(
|
|
462
483
|
calls.map(async (call: any) => {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
484
|
+
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
|
|
485
|
+
let halted: Request | undefined
|
|
486
|
+
const req = pendingOf(result)
|
|
487
|
+
if (req) {
|
|
488
|
+
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn)
|
|
489
|
+
result = r.result
|
|
490
|
+
halted = r.halted
|
|
491
|
+
}
|
|
492
|
+
return { call, args, result, halted }
|
|
466
493
|
}),
|
|
467
494
|
)
|
|
468
|
-
|
|
495
|
+
// Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
|
|
496
|
+
// and the later suspensions' placeholder results never enter the transcript (they re-suspend
|
|
497
|
+
// on resume). Mirrors the streaming path.
|
|
498
|
+
for (const s of settled) {
|
|
499
|
+
toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
|
|
500
|
+
messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output })
|
|
501
|
+
if (s.halted) return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage)
|
|
502
|
+
}
|
|
469
503
|
}
|
|
470
504
|
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage)
|
|
471
505
|
} catch (e) {
|
|
@@ -475,7 +509,35 @@ export class Client {
|
|
|
475
509
|
}
|
|
476
510
|
|
|
477
511
|
private result(text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
|
|
478
|
-
return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model }
|
|
512
|
+
return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
516
|
+
private pendingRun(runStart: number, request: Request, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
|
|
517
|
+
this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
|
|
518
|
+
return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
|
|
523
|
+
* tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
|
|
524
|
+
* no `waitFor` is configured (the run should stop and surface the request).
|
|
525
|
+
*/
|
|
526
|
+
private async resolvePending(
|
|
527
|
+
toolkit: Toolkit, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
|
|
528
|
+
): Promise<{ result: ToolResult; halted?: Request }> {
|
|
529
|
+
if (!this.opts.waitFor) return { result: { output: request.prompt, isError: true }, halted: request }
|
|
530
|
+
const answer = await this.opts.waitFor(request)
|
|
531
|
+
if (!answer?.ok) return { result: { output: `declined/expired: ${request.prompt}`, isError: true } }
|
|
532
|
+
const t0 = Date.now()
|
|
533
|
+
let result = await toolkit.execute(name, args, { answer })
|
|
534
|
+
if (this.opts.hooks?.afterTool) {
|
|
535
|
+
const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
|
|
536
|
+
if (ov?.result) result = ov.result
|
|
537
|
+
}
|
|
538
|
+
this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0)
|
|
539
|
+
if (pendingOf(result)) result = { output: `unresolved: ${request.prompt}`, isError: true } // never loop forever
|
|
540
|
+
return { result }
|
|
479
541
|
}
|
|
480
542
|
|
|
481
543
|
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
@@ -519,14 +581,31 @@ export class Client {
|
|
|
519
581
|
return this.endRun(runStart, text, messages, toolCalls, turns, usage)
|
|
520
582
|
}
|
|
521
583
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
522
|
-
const
|
|
584
|
+
const settled = await Promise.all(
|
|
523
585
|
uses.map(async (use: any) => {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
586
|
+
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
|
|
587
|
+
let halted: Request | undefined
|
|
588
|
+
const req = pendingOf(result)
|
|
589
|
+
if (req) {
|
|
590
|
+
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn)
|
|
591
|
+
result = r.result
|
|
592
|
+
halted = r.halted
|
|
593
|
+
}
|
|
594
|
+
return { use, args, result, halted }
|
|
527
595
|
}),
|
|
528
596
|
)
|
|
529
|
-
|
|
597
|
+
// Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
|
|
598
|
+
// building the content, and surface it — deterministic (G3), later suspensions re-suspend on
|
|
599
|
+
// resume. Mirrors the OpenAI path and the streaming loops.
|
|
600
|
+
const content: any[] = []
|
|
601
|
+
let halted: Request | undefined
|
|
602
|
+
for (const s of settled) {
|
|
603
|
+
toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
|
|
604
|
+
content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError })
|
|
605
|
+
if (s.halted) { halted = s.halted; break }
|
|
606
|
+
}
|
|
607
|
+
messages.push({ role: "user", content })
|
|
608
|
+
if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
|
|
530
609
|
}
|
|
531
610
|
return this.endRun(runStart, "", messages, toolCalls, turns, usage)
|
|
532
611
|
} catch (e) {
|
|
@@ -605,7 +684,20 @@ export class Client {
|
|
|
605
684
|
for (const c of calls) yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
|
|
606
685
|
const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
|
|
607
686
|
for (let i = 0; i < calls.length; i++) {
|
|
608
|
-
const c = calls[i]
|
|
687
|
+
const c = calls[i]
|
|
688
|
+
let { args, result } = settled[i]
|
|
689
|
+
const req = pendingOf(result)
|
|
690
|
+
if (req) {
|
|
691
|
+
yield { type: "pending", request: req } // surface BEFORE waitFor so a channel can push the link
|
|
692
|
+
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn)
|
|
693
|
+
result = r.result
|
|
694
|
+
if (r.halted) {
|
|
695
|
+
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
696
|
+
messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
|
|
697
|
+
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
}
|
|
609
701
|
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
610
702
|
messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
|
|
611
703
|
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
|
|
@@ -683,7 +775,21 @@ export class Client {
|
|
|
683
775
|
const settled = await Promise.all((uses as any[]).map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)))
|
|
684
776
|
const results: any[] = []
|
|
685
777
|
for (let i = 0; i < uses.length; i++) {
|
|
686
|
-
const u = uses[i] as any
|
|
778
|
+
const u = uses[i] as any
|
|
779
|
+
let { args, result } = settled[i]
|
|
780
|
+
const req = pendingOf(result)
|
|
781
|
+
if (req) {
|
|
782
|
+
yield { type: "pending", request: req }
|
|
783
|
+
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn)
|
|
784
|
+
result = r.result
|
|
785
|
+
if (r.halted) {
|
|
786
|
+
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
787
|
+
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
|
|
788
|
+
messages.push({ role: "user", content: results })
|
|
789
|
+
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
}
|
|
687
793
|
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
688
794
|
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
|
|
689
795
|
yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
|
package/src/mcp.ts
CHANGED
|
@@ -8,8 +8,36 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
9
9
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
10
10
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
11
|
-
import type { Tool as McpToolDef } from "@modelcontextprotocol/sdk/types.js"
|
|
12
|
-
import {
|
|
11
|
+
import type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js"
|
|
12
|
+
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
|
13
|
+
import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
17
|
+
// (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
|
|
18
|
+
// URL mode → kind:"authorization". The two mappings are pure + byte-parity-tested.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
let _elcSeq = 0
|
|
22
|
+
/** Map an MCP `elicitation/create` request onto a §10 `Request`. */
|
|
23
|
+
export function elicitationToRequest(params: ElicitRequest["params"]): Request {
|
|
24
|
+
const p = params as { message?: string; requestedSchema?: unknown; mode?: string; url?: string }
|
|
25
|
+
const isUrl = p.mode === "url"
|
|
26
|
+
const req: Request = {
|
|
27
|
+
id: `elc-${Date.now().toString(36)}-${++_elcSeq}`,
|
|
28
|
+
kind: isUrl ? "authorization" : "input",
|
|
29
|
+
prompt: String(p.message ?? ""),
|
|
30
|
+
}
|
|
31
|
+
if (isUrl && p.url) req.url = p.url
|
|
32
|
+
if (!isUrl && p.requestedSchema) req.data = { schema: p.requestedSchema }
|
|
33
|
+
return req
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
|
|
37
|
+
export function answerToElicitResult(answer: Answer): ElicitResult {
|
|
38
|
+
if (answer.ok) return { action: "accept", content: (answer.data ?? {}) as ElicitResult["content"] }
|
|
39
|
+
return { action: answer.reason === "declined" ? "decline" : "cancel" }
|
|
40
|
+
}
|
|
13
41
|
|
|
14
42
|
const DEFAULT_TIMEOUT = 30_000
|
|
15
43
|
const MAX_LIST_PAGES = 1_000
|
|
@@ -155,11 +183,12 @@ export interface McpSource {
|
|
|
155
183
|
}
|
|
156
184
|
|
|
157
185
|
/** Connect to every enabled server, list + convert tools. Failures are isolated. */
|
|
158
|
-
export async function loadMcp(input: string | object): Promise<McpSource> {
|
|
186
|
+
export async function loadMcp(input: string | object, opts?: { waitFor?: (request: Request) => Promise<Answer> }): Promise<McpSource> {
|
|
159
187
|
const config = parseMcpConfig(input)
|
|
160
188
|
const tools: Tool[] = []
|
|
161
189
|
const status: Record<string, McpStatus> = {}
|
|
162
190
|
const clients: Client[] = []
|
|
191
|
+
const waitFor = opts?.waitFor
|
|
163
192
|
|
|
164
193
|
await Promise.all(
|
|
165
194
|
Object.entries(config).map(async ([name, cfg]) => {
|
|
@@ -168,7 +197,14 @@ export async function loadMcp(input: string | object): Promise<McpSource> {
|
|
|
168
197
|
return
|
|
169
198
|
}
|
|
170
199
|
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
|
|
171
|
-
|
|
200
|
+
// Advertise elicitation + register the bridge ONLY when a waitFor exists to satisfy it —
|
|
201
|
+
// a waitFor-less host degrades cleanly (the server won't elicit). (§10 elicitation bridge)
|
|
202
|
+
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} })
|
|
203
|
+
if (waitFor) {
|
|
204
|
+
client.setRequestHandler(ElicitRequestSchema, async (request) =>
|
|
205
|
+
answerToElicitResult(await waitFor(elicitationToRequest(request.params))),
|
|
206
|
+
)
|
|
207
|
+
}
|
|
172
208
|
try {
|
|
173
209
|
if (isRemote(cfg)) {
|
|
174
210
|
const url = new URL(cfg.url)
|
package/src/serve.ts
CHANGED
|
@@ -331,12 +331,22 @@ async function fulfil(
|
|
|
331
331
|
// contextId groups a peer's turns into one conversation — thread it so the
|
|
332
332
|
// client's ConversationStore remembers across tasks in the same context.
|
|
333
333
|
result = await runTask(text, contextId)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
334
|
+
if (result.status === "pending" && result.pending) {
|
|
335
|
+
// §10 suspension over A2A: the run halted waiting on out-of-band input. Surface the
|
|
336
|
+
// protocol's `input-required` state carrying the request prompt — never a false `completed`.
|
|
337
|
+
task = {
|
|
338
|
+
id,
|
|
339
|
+
status: { state: "input-required", message: { role: "agent", parts: [{ kind: "text", text: result.pending.prompt }] } },
|
|
340
|
+
}
|
|
341
|
+
state = "input-required"
|
|
342
|
+
} else {
|
|
343
|
+
const artifact: A2AArtifact = {
|
|
344
|
+
artifactId: randomUUID(),
|
|
345
|
+
parts: [{ kind: "text", text: result.text }],
|
|
346
|
+
}
|
|
347
|
+
task = { id, status: { state: "completed" }, artifacts: [artifact] }
|
|
348
|
+
state = "completed"
|
|
337
349
|
}
|
|
338
|
-
task = { id, status: { state: "completed" }, artifacts: [artifact] }
|
|
339
|
-
state = "completed"
|
|
340
350
|
} catch (e) {
|
|
341
351
|
const detail = e instanceof Error ? e.message : String(e)
|
|
342
352
|
task = { id, status: { state: "failed", message: { role: "agent", parts: [{ kind: "text", text: detail }] } } }
|
package/src/toolkit.ts
CHANGED
|
@@ -20,6 +20,13 @@ export interface ToolkitOptions {
|
|
|
20
20
|
builtins?: BuiltinsConfig
|
|
21
21
|
/** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
|
|
22
22
|
agents?: Agent[]
|
|
23
|
+
/**
|
|
24
|
+
* Host resolver for out-of-band input (§10). When set, connected MCP servers may elicit input
|
|
25
|
+
* from the human mid-`tools/call` and it is bridged onto this `waitFor` (form→kind:"input",
|
|
26
|
+
* URL→kind:"authorization"). Typically the same function passed to the client. Omit ⇒ MCP
|
|
27
|
+
* elicitation is not advertised.
|
|
28
|
+
*/
|
|
29
|
+
waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export class Toolkit {
|
|
@@ -51,7 +58,7 @@ export class Toolkit {
|
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
static async create(opts: ToolkitOptions): Promise<Toolkit> {
|
|
54
|
-
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined
|
|
61
|
+
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined
|
|
55
62
|
const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined
|
|
56
63
|
// The toggle comes from the `builtins` option, or a top-level `builtins`
|
|
57
64
|
// key on a parsed config object — same precedence as MCP's isEnabled.
|
package/src/types.ts
CHANGED
|
@@ -21,6 +21,56 @@ export interface ToolResult {
|
|
|
21
21
|
export interface ToolContext {
|
|
22
22
|
signal?: AbortSignal
|
|
23
23
|
timeout?: number
|
|
24
|
+
/** Present ONLY on a post-waitFor retry (§10): the resolution of a prior suspension. */
|
|
25
|
+
answer?: Answer
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* §10 Suspension. A tool that needs an out-of-band, async resolution (login, approval,
|
|
30
|
+
* input) returns a normal ToolResult carrying `metadata.pending = Request`. Byte-identical
|
|
31
|
+
* wire data — it crosses languages, processes, and agents unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export interface Request {
|
|
34
|
+
id: string
|
|
35
|
+
kind: string // "authorization" | "approval" | "input" | ... (open vocabulary)
|
|
36
|
+
prompt: string
|
|
37
|
+
url?: string
|
|
38
|
+
data?: Record<string, unknown>
|
|
39
|
+
expiresAt?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Answer {
|
|
43
|
+
id: string
|
|
44
|
+
ok: boolean
|
|
45
|
+
data?: Record<string, unknown>
|
|
46
|
+
/**
|
|
47
|
+
* When `ok` is false, why (advisory — the loop rule branches only on `ok`). Distinguishes an
|
|
48
|
+
* explicit refusal from a dismissal/timeout; the MCP elicitation bridge maps `declined`→`decline`
|
|
49
|
+
* and everything else→`cancel`. (R1)
|
|
50
|
+
*/
|
|
51
|
+
reason?: "declined" | "cancelled" | "expired"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let _pendingSeq = 0
|
|
55
|
+
/** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
|
|
56
|
+
export function pending(request: Omit<Request, "id"> & { id?: string }): ToolResult {
|
|
57
|
+
const id = request.id ?? `pnd-${Date.now().toString(36)}-${++_pendingSeq}`
|
|
58
|
+
const req: Request = { ...request, id }
|
|
59
|
+
return {
|
|
60
|
+
output: req.prompt + (req.url ? `\n${req.url}` : ""),
|
|
61
|
+
isError: true,
|
|
62
|
+
metadata: { pending: req },
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sugar for the common case: `kind:"authorization"` at a login URL. */
|
|
67
|
+
export function authRequired(url: string, prompt = "Authorization required to continue"): ToolResult {
|
|
68
|
+
return pending({ kind: "authorization", prompt, url })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read the suspension off a result, if any. */
|
|
72
|
+
export function pendingOf(result: ToolResult): Request | undefined {
|
|
73
|
+
return (result.metadata as { pending?: Request } | undefined)?.pending
|
|
24
74
|
}
|
|
25
75
|
|
|
26
76
|
export interface Tool {
|