toolnexus 0.21.0 → 0.23.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 +50 -4
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +43 -7
- package/dist/agents/compaction.d.ts +19 -0
- package/dist/agents/compaction.js +26 -4
- package/dist/agents/runtime.d.ts +5 -0
- package/dist/agents/runtime.js +13 -2
- package/dist/builtin.d.ts +16 -0
- package/dist/builtin.js +315 -17
- package/dist/builtin_extras.d.ts +80 -0
- package/dist/builtin_extras.js +248 -0
- package/dist/client.d.ts +77 -2
- package/dist/client.js +304 -61
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp.d.ts +32 -1
- package/dist/mcp.js +184 -6
- package/dist/models.d.ts +20 -0
- package/dist/models.js +46 -0
- package/dist/promptcache.d.ts +21 -0
- package/dist/promptcache.js +59 -0
- package/dist/store.d.ts +32 -0
- package/dist/store.js +131 -0
- package/dist/toolkit.d.ts +2 -2
- package/dist/toolkit.js +2 -2
- package/dist/tooltiers.d.ts +49 -0
- package/dist/tooltiers.js +197 -0
- package/dist/types.d.ts +19 -0
- package/package.json +4 -3
- package/src/agents/agent.ts +39 -8
- package/src/agents/compaction.ts +45 -4
- package/src/agents/runtime.ts +16 -2
- package/src/builtin.ts +333 -18
- package/src/builtin_extras.ts +285 -0
- package/src/client.ts +330 -66
- package/src/index.ts +5 -0
- package/src/mcp.ts +204 -7
- package/src/models.ts +56 -0
- package/src/promptcache.ts +63 -0
- package/src/store.ts +142 -0
- package/src/toolkit.ts +2 -2
- package/src/tooltiers.ts +221 -0
- package/src/types.ts +20 -0
package/dist/index.d.ts
CHANGED
|
@@ -14,8 +14,13 @@ export * from "./adapters.js";
|
|
|
14
14
|
export * from "./translate.js";
|
|
15
15
|
export * from "./toolkit.js";
|
|
16
16
|
export * from "./client.js";
|
|
17
|
+
export * from "./store.js";
|
|
17
18
|
export * from "./classifier.js";
|
|
18
19
|
export * as agents from "./agents/index.js";
|
|
19
20
|
export * as judge from "./judge.js";
|
|
20
21
|
export * from "./batteries.js";
|
|
21
22
|
export { State, context, questionMap, ask, gate, decide, applyGate, applyPolicy, answersOf, staticClassifier, Tape, DEFAULT_BANDS, type NamedQuestion, type Band, type Bands, type Answers, type Rule, type GateOutcome, type Policy, } from "./judge.js";
|
|
23
|
+
export * from "./promptcache.js";
|
|
24
|
+
export * from "./tooltiers.js";
|
|
25
|
+
export * from "./models.js";
|
|
26
|
+
export * from "./builtin_extras.js";
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./adapters.js";
|
|
|
14
14
|
export * from "./translate.js";
|
|
15
15
|
export * from "./toolkit.js";
|
|
16
16
|
export * from "./client.js";
|
|
17
|
+
export * from "./store.js";
|
|
17
18
|
export * from "./classifier.js";
|
|
18
19
|
export * as agents from "./agents/index.js";
|
|
19
20
|
// Simple judgments (§8B, add-judge-adapters). `judge.noul/choice/score` are the NAMED builders
|
|
@@ -22,3 +23,7 @@ export * as judge from "./judge.js";
|
|
|
22
23
|
// Judge batteries (§8B "Batteries", add-judge-batteries): eight `*Classifier` values + latestUserText.
|
|
23
24
|
export * from "./batteries.js";
|
|
24
25
|
export { State, context, questionMap, ask, gate, decide, applyGate, applyPolicy, answersOf, staticClassifier, Tape, DEFAULT_BANDS, } from "./judge.js";
|
|
26
|
+
export * from "./promptcache.js";
|
|
27
|
+
export * from "./tooltiers.js";
|
|
28
|
+
export * from "./models.js";
|
|
29
|
+
export * from "./builtin_extras.js";
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
-
import { type JSONSchema, type Tool, type McpStatus, type Request, type Answer } from "./types.js";
|
|
2
|
+
import { type JSONSchema, type Tool, type ToolHints, type McpStatus, type Request, type Answer } from "./types.js";
|
|
3
3
|
import { type ContentPart } from "./content.js";
|
|
4
4
|
/** Map an MCP `elicitation/create` request onto a §10 `Request`. */
|
|
5
5
|
export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
|
|
@@ -50,13 +50,44 @@ export interface McpSource {
|
|
|
50
50
|
tools: Tool[];
|
|
51
51
|
status: Record<string, McpStatus>;
|
|
52
52
|
close(): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* `list_mcp_resources` and `read_mcp_resource` bound to this source's connected servers
|
|
55
|
+
* (SPEC §4C). Both read-only. Opt-in: register them on a toolkit. They stop working once the
|
|
56
|
+
* source is closed.
|
|
57
|
+
*/
|
|
58
|
+
resourceTools(): Tool[];
|
|
53
59
|
}
|
|
60
|
+
/** One server's outcome, reported by `loadMcp`'s `onServer` the moment that server finishes. */
|
|
61
|
+
export interface McpServerResult {
|
|
62
|
+
name: string;
|
|
63
|
+
status: McpStatus;
|
|
64
|
+
/** This server's converted tools (empty unless connected). Usable until the source closes. */
|
|
65
|
+
tools: Tool[];
|
|
66
|
+
/** Why the server failed (absent when connected or disabled). */
|
|
67
|
+
error?: Error;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* MCP tool annotations → ToolHints (SPEC §4C). No hint fields at all ⇒ undefined (unknown).
|
|
71
|
+
* Defaults follow the MCP spec: readOnlyHint false, destructiveHint and openWorldHint true.
|
|
72
|
+
*/
|
|
73
|
+
export declare function mcpHints(a: {
|
|
74
|
+
readOnlyHint?: boolean;
|
|
75
|
+
destructiveHint?: boolean;
|
|
76
|
+
openWorldHint?: boolean;
|
|
77
|
+
} | undefined): ToolHints | undefined;
|
|
54
78
|
/** Connect to every enabled server, list + convert tools. Failures are isolated.
|
|
55
79
|
* §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
|
|
56
80
|
* whole load promptly on parent cancellation. */
|
|
57
81
|
export declare function loadMcp(input: string | object, opts?: {
|
|
58
82
|
waitFor?: (request: Request) => Promise<Answer>;
|
|
59
83
|
signal?: AbortSignal;
|
|
84
|
+
/**
|
|
85
|
+
* Called once per server the moment it is connected, failed or found disabled (SPEC §4C),
|
|
86
|
+
* so a host can register a fast server's tools while a slow one is still connecting.
|
|
87
|
+
* Calls are serialized (an async callback is awaited before the next one runs); the load
|
|
88
|
+
* still resolves only after every server and every callback has finished.
|
|
89
|
+
*/
|
|
90
|
+
onServer?: (result: McpServerResult) => void | Promise<void>;
|
|
60
91
|
}): Promise<McpSource>;
|
|
61
92
|
/** §2 Gap 6. One listed tool definition (original, unprefixed name). */
|
|
62
93
|
export interface ToolInfo {
|
package/dist/mcp.js
CHANGED
|
@@ -59,12 +59,36 @@ export function parseMcpConfig(input) {
|
|
|
59
59
|
const raw = typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input;
|
|
60
60
|
const wrapped = raw.mcpServers ?? raw.servers ?? raw.mcp;
|
|
61
61
|
if (wrapped !== undefined)
|
|
62
|
-
return wrapped;
|
|
62
|
+
return normalizeServers(wrapped);
|
|
63
63
|
// Bare map of servers — but strip sibling top-level config keys (builtins/
|
|
64
64
|
// agents/a2a/mcpServer) so they are not mistaken for MCP servers when no
|
|
65
65
|
// wrapper key is present.
|
|
66
66
|
const { builtins: _builtins, agents: _agents, a2a: _a2a, mcpServer: _mcpServer, ...servers } = raw;
|
|
67
|
-
return servers;
|
|
67
|
+
return normalizeServers(servers);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* §2 `command` + `args`. A local server's `command` may be a non-empty string or a non-empty
|
|
71
|
+
* string array; an optional `args` string array is appended. The result is always the argv form
|
|
72
|
+
* (`command: string[]`, no `args`). Spawned with no shell. A remote server ignores `args`.
|
|
73
|
+
*/
|
|
74
|
+
function normalizeServers(servers) {
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const [name, cfg] of Object.entries(servers ?? {})) {
|
|
77
|
+
if (cfg === null || typeof cfg !== "object" || isRemote(cfg)) {
|
|
78
|
+
out[name] = cfg;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const { args, command, ...rest } = cfg;
|
|
82
|
+
const head = typeof command === "string" ? [command] : Array.isArray(command) ? command : undefined;
|
|
83
|
+
if (!head || head.length === 0 || head[0] === "" || head.some((c) => typeof c !== "string")) {
|
|
84
|
+
throw new Error(`mcp server "${name}": invalid command`);
|
|
85
|
+
}
|
|
86
|
+
if (args !== undefined && (!Array.isArray(args) || args.some((a) => typeof a !== "string"))) {
|
|
87
|
+
throw new Error(`mcp server "${name}": invalid args`);
|
|
88
|
+
}
|
|
89
|
+
out[name] = { ...rest, command: [...head, ...(args ?? [])] };
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
68
92
|
}
|
|
69
93
|
/**
|
|
70
94
|
* Expand ${ENV_VAR} in header values from process.env so tokens live in the
|
|
@@ -212,9 +236,14 @@ function convertTool(server, def, client, timeout) {
|
|
|
212
236
|
description: def.description ?? "",
|
|
213
237
|
inputSchema,
|
|
214
238
|
source: "mcp",
|
|
239
|
+
...(mcpHints(def.annotations) ? { hints: mcpHints(def.annotations) } : {}),
|
|
215
240
|
async execute(args, ctx) {
|
|
216
241
|
try {
|
|
217
|
-
const result = await client.callTool({ name: def.name, arguments: args ?? {} }, undefined,
|
|
242
|
+
const result = await client.callTool({ name: def.name, arguments: args ?? {} }, undefined,
|
|
243
|
+
// The SDK only attaches `_meta.progressToken` when `onprogress` is supplied; without it
|
|
244
|
+
// `resetTimeoutOnProgress` never fires (add-mcp-progress-timeout). Notifications for a
|
|
245
|
+
// token that matches no in-flight request are dropped by the SDK, so they never reset.
|
|
246
|
+
{ onprogress: () => { }, resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout });
|
|
218
247
|
// Parts are collected BEFORE any branch returns: the isError and structuredContent
|
|
219
248
|
// short-circuits both read `content[]` on the way past, so neither reintroduces the
|
|
220
249
|
// silent drop this mapping exists to remove (§2).
|
|
@@ -235,6 +264,120 @@ function convertTool(server, def, client, timeout) {
|
|
|
235
264
|
},
|
|
236
265
|
};
|
|
237
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* MCP tool annotations → ToolHints (SPEC §4C). No hint fields at all ⇒ undefined (unknown).
|
|
269
|
+
* Defaults follow the MCP spec: readOnlyHint false, destructiveHint and openWorldHint true.
|
|
270
|
+
*/
|
|
271
|
+
export function mcpHints(a) {
|
|
272
|
+
if (!a || (a.readOnlyHint === undefined && a.destructiveHint === undefined && a.openWorldHint === undefined))
|
|
273
|
+
return undefined;
|
|
274
|
+
const h = { readOnly: a.readOnlyHint ?? false, destructive: a.destructiveHint ?? true, openWorld: a.openWorldHint ?? true };
|
|
275
|
+
if (h.readOnly)
|
|
276
|
+
h.destructive = false;
|
|
277
|
+
return h;
|
|
278
|
+
}
|
|
279
|
+
function resourceTools(servers) {
|
|
280
|
+
const ro = { readOnly: true, destructive: false, openWorld: false };
|
|
281
|
+
const names = () => [...servers.keys()].sort();
|
|
282
|
+
const conn = (server) => {
|
|
283
|
+
const c = servers.get(server);
|
|
284
|
+
if (!c)
|
|
285
|
+
throw new Error(`no connected MCP server "${server}" (connected: ${names().join(", ")})`);
|
|
286
|
+
return c;
|
|
287
|
+
};
|
|
288
|
+
const list = {
|
|
289
|
+
name: "list_mcp_resources",
|
|
290
|
+
description: "List the resources MCP servers offer (uri, name, description, mimeType). Optionally for one server.",
|
|
291
|
+
inputSchema: {
|
|
292
|
+
type: "object",
|
|
293
|
+
properties: { server: { type: "string", description: "Only this server (default: all)" } },
|
|
294
|
+
additionalProperties: false,
|
|
295
|
+
},
|
|
296
|
+
source: "mcp",
|
|
297
|
+
hints: ro,
|
|
298
|
+
async execute(args, ctx) {
|
|
299
|
+
let targets = names();
|
|
300
|
+
const s = typeof args?.server === "string" ? args.server : "";
|
|
301
|
+
if (s) {
|
|
302
|
+
try {
|
|
303
|
+
conn(s);
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
return { output: `list_mcp_resources: ${e.message}`, isError: true };
|
|
307
|
+
}
|
|
308
|
+
targets = [s];
|
|
309
|
+
}
|
|
310
|
+
const lines = [];
|
|
311
|
+
const items = [];
|
|
312
|
+
for (const name of targets) {
|
|
313
|
+
const c = conn(name);
|
|
314
|
+
try {
|
|
315
|
+
const res = await c.client.listResources({}, { timeout: c.timeout, signal: ctx?.signal });
|
|
316
|
+
for (const r of res.resources ?? []) {
|
|
317
|
+
let line = `${name} ${r.uri}`;
|
|
318
|
+
if (r.name)
|
|
319
|
+
line += ` — ${r.name}`;
|
|
320
|
+
if (r.mimeType)
|
|
321
|
+
line += ` (${r.mimeType})`;
|
|
322
|
+
if (r.description)
|
|
323
|
+
line += `: ${r.description}`;
|
|
324
|
+
lines.push(line);
|
|
325
|
+
items.push({ server: name, uri: r.uri, name: r.name ?? "", mimeType: r.mimeType ?? "", description: r.description ?? "" });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
lines.push(`${name}: error: ${e instanceof Error ? e.message : String(e)}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (!lines.length)
|
|
333
|
+
return { output: "No resources.", isError: false, metadata: { count: 0 } };
|
|
334
|
+
return { output: lines.join("\n"), isError: false, metadata: { count: items.length, resources: items } };
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
const read = {
|
|
338
|
+
name: "read_mcp_resource",
|
|
339
|
+
description: "Read one MCP resource by server and uri. Text comes back as text; binary content is described, not inlined.",
|
|
340
|
+
inputSchema: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: {
|
|
343
|
+
server: { type: "string", description: "The MCP server name" },
|
|
344
|
+
uri: { type: "string", description: "The resource uri" },
|
|
345
|
+
},
|
|
346
|
+
required: ["server", "uri"],
|
|
347
|
+
additionalProperties: false,
|
|
348
|
+
},
|
|
349
|
+
source: "mcp",
|
|
350
|
+
hints: ro,
|
|
351
|
+
async execute(args, ctx) {
|
|
352
|
+
const server = typeof args?.server === "string" ? args.server : "";
|
|
353
|
+
const uri = typeof args?.uri === "string" ? args.uri : "";
|
|
354
|
+
let c;
|
|
355
|
+
try {
|
|
356
|
+
c = conn(server);
|
|
357
|
+
}
|
|
358
|
+
catch (e) {
|
|
359
|
+
return { output: `read_mcp_resource: ${e.message}`, isError: true };
|
|
360
|
+
}
|
|
361
|
+
if (!uri)
|
|
362
|
+
return { output: "read_mcp_resource: uri is required", isError: true };
|
|
363
|
+
try {
|
|
364
|
+
const res = await c.client.readResource({ uri }, { timeout: c.timeout, signal: ctx?.signal });
|
|
365
|
+
const parts = [];
|
|
366
|
+
for (const rc of res.contents ?? []) {
|
|
367
|
+
if (typeof rc.text === "string")
|
|
368
|
+
parts.push(rc.text);
|
|
369
|
+
else if (typeof rc.blob === "string")
|
|
370
|
+
parts.push(`[binary ${rc.mimeType ?? ""}, ${rc.blob.length} base64 chars]`);
|
|
371
|
+
}
|
|
372
|
+
return { output: parts.join("\n"), isError: false, metadata: { server, uri } };
|
|
373
|
+
}
|
|
374
|
+
catch (e) {
|
|
375
|
+
return { output: `read_mcp_resource: ${e instanceof Error ? e.message : String(e)}`, isError: true };
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
return [list, read];
|
|
380
|
+
}
|
|
238
381
|
/** Create a fresh (not-yet-connected) client, with the elicitation bridge attached if a waitFor
|
|
239
382
|
* exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
|
|
240
383
|
* connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
|
|
@@ -279,11 +422,28 @@ export async function loadMcp(input, opts) {
|
|
|
279
422
|
const tools = [];
|
|
280
423
|
const status = {};
|
|
281
424
|
const clients = [];
|
|
425
|
+
const servers = new Map();
|
|
282
426
|
const waitFor = opts?.waitFor;
|
|
283
427
|
const signal = opts?.signal;
|
|
284
|
-
|
|
428
|
+
const onServer = opts?.onServer;
|
|
429
|
+
let reports = Promise.resolve();
|
|
430
|
+
let reportError;
|
|
431
|
+
const report = (r) => {
|
|
432
|
+
if (!onServer)
|
|
433
|
+
return;
|
|
434
|
+
reports = reports.then(async () => {
|
|
435
|
+
try {
|
|
436
|
+
await onServer(r);
|
|
437
|
+
}
|
|
438
|
+
catch (e) {
|
|
439
|
+
reportError ??= e;
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
const loading = Promise.all(Object.entries(config).map(async ([name, cfg]) => {
|
|
285
444
|
if (!isEnabled(cfg)) {
|
|
286
445
|
status[name] = "disabled";
|
|
446
|
+
report({ name, status: "disabled", tools: [] });
|
|
287
447
|
return;
|
|
288
448
|
}
|
|
289
449
|
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
|
|
@@ -294,10 +454,12 @@ export async function loadMcp(input, opts) {
|
|
|
294
454
|
await raceTimeout(connectTransport(client, cfg), timeout, signal);
|
|
295
455
|
const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal);
|
|
296
456
|
const kept = applyToolsFilter(name, defs, cfg.tools);
|
|
297
|
-
|
|
298
|
-
|
|
457
|
+
const converted = kept.map((def) => convertTool(name, def, client, timeout));
|
|
458
|
+
tools.push(...converted);
|
|
299
459
|
clients.push(client);
|
|
460
|
+
servers.set(name, { client, timeout });
|
|
300
461
|
status[name] = "connected";
|
|
462
|
+
report({ name, status: "connected", tools: converted });
|
|
301
463
|
}
|
|
302
464
|
catch (e) {
|
|
303
465
|
status[name] = "failed";
|
|
@@ -306,15 +468,31 @@ export async function loadMcp(input, opts) {
|
|
|
306
468
|
// within budget isolates just this server.
|
|
307
469
|
if (signal?.aborted)
|
|
308
470
|
throw e;
|
|
471
|
+
report({ name, status: "failed", tools: [], error: e instanceof Error ? e : new Error(String(e)) });
|
|
309
472
|
console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
310
473
|
}
|
|
311
474
|
}));
|
|
475
|
+
try {
|
|
476
|
+
await loading;
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
// Parent cancellation: close every client, including ones whose tools onServer handed out.
|
|
480
|
+
await reports;
|
|
481
|
+
await Promise.all(clients.map((c) => c.close().catch(() => { })));
|
|
482
|
+
throw e;
|
|
483
|
+
}
|
|
484
|
+
await reports;
|
|
485
|
+
if (reportError) {
|
|
486
|
+
await Promise.all(clients.map((c) => c.close().catch(() => { })));
|
|
487
|
+
throw reportError;
|
|
488
|
+
}
|
|
312
489
|
return {
|
|
313
490
|
tools,
|
|
314
491
|
status,
|
|
315
492
|
async close() {
|
|
316
493
|
await Promise.all(clients.map((c) => c.close().catch(() => { })));
|
|
317
494
|
},
|
|
495
|
+
resourceTools: () => resourceTools(servers),
|
|
318
496
|
};
|
|
319
497
|
}
|
|
320
498
|
/**
|
package/dist/models.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* listModels (SPEC §4C): the models a provider offers, for model switching. One GET per page,
|
|
3
|
+
* no retries. Same key rules as the client (apiKey, apiKeyEnv, or the host's own env var).
|
|
4
|
+
*/
|
|
5
|
+
import { type ClientOptions } from "./client.js";
|
|
6
|
+
/** One model a provider lists. */
|
|
7
|
+
export interface ModelInfo {
|
|
8
|
+
id: string;
|
|
9
|
+
/** The provider's display name, when it gives one. */
|
|
10
|
+
name?: string;
|
|
11
|
+
/** Context window in tokens when the provider reports it (OpenRouter does). */
|
|
12
|
+
contextLength?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* List the models at `opts.baseUrl` in `opts.style`: openai `GET {base}/models`; anthropic
|
|
16
|
+
* `GET {base}/v1/models`, following `has_more`/`last_id`. Sorted by id.
|
|
17
|
+
*/
|
|
18
|
+
export declare function listModels(opts: Pick<ClientOptions, "baseUrl" | "style" | "apiKey" | "apiKeyEnv" | "headers" | "fetch">, ctx?: {
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}): Promise<ModelInfo[]>;
|
package/dist/models.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* listModels (SPEC §4C): the models a provider offers, for model switching. One GET per page,
|
|
3
|
+
* no retries. Same key rules as the client (apiKey, apiKeyEnv, or the host's own env var).
|
|
4
|
+
*/
|
|
5
|
+
import { resolveKey } from "./client.js";
|
|
6
|
+
async function getJson(f, url, headers, signal) {
|
|
7
|
+
const res = await f(url, { method: "GET", headers, signal });
|
|
8
|
+
const text = await res.text();
|
|
9
|
+
if (!res.ok)
|
|
10
|
+
throw new Error(`list models: HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
11
|
+
return JSON.parse(text);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* List the models at `opts.baseUrl` in `opts.style`: openai `GET {base}/models`; anthropic
|
|
15
|
+
* `GET {base}/v1/models`, following `has_more`/`last_id`. Sorted by id.
|
|
16
|
+
*/
|
|
17
|
+
export async function listModels(opts, ctx) {
|
|
18
|
+
const key = resolveKey(opts);
|
|
19
|
+
const f = opts.fetch ?? fetch;
|
|
20
|
+
const base = opts.baseUrl.replace(/\/+$/, "");
|
|
21
|
+
const out = [];
|
|
22
|
+
if (opts.style === "anthropic") {
|
|
23
|
+
const endpoint = base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`;
|
|
24
|
+
let after = "";
|
|
25
|
+
for (let page = 0; page < 100; page++) {
|
|
26
|
+
const url = `${endpoint}?limit=1000${after ? `&after_id=${encodeURIComponent(after)}` : ""}`;
|
|
27
|
+
const body = await getJson(f, url, { "x-api-key": key, "anthropic-version": "2023-06-01", ...opts.headers }, ctx?.signal);
|
|
28
|
+
for (const m of body?.data ?? [])
|
|
29
|
+
out.push({ id: m.id, ...(m.display_name ? { name: m.display_name } : {}) });
|
|
30
|
+
if (!body?.has_more || !body?.last_id)
|
|
31
|
+
break;
|
|
32
|
+
after = body.last_id;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const body = await getJson(f, `${base}/models`, { Authorization: `Bearer ${key}`, ...opts.headers }, ctx?.signal);
|
|
37
|
+
for (const m of body?.data ?? []) {
|
|
38
|
+
out.push({
|
|
39
|
+
id: m.id,
|
|
40
|
+
...(m.name ? { name: m.name } : {}),
|
|
41
|
+
...(typeof m.context_length === "number" && m.context_length > 0 ? { contextLength: m.context_length } : {}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt caching (SPEC §8 "Prompt caching", change add-prompt-cache). The engine keeps the
|
|
3
|
+
* request prefix stable and marks it for the provider's cache.
|
|
4
|
+
*/
|
|
5
|
+
/** `ClientOptions.promptCache`. */
|
|
6
|
+
export interface PromptCacheOptions {
|
|
7
|
+
/** Groups requests that share a prefix (one per session). Sent as openai `prompt_cache_key`.
|
|
8
|
+
* Ignored by anthropic, which caches by prefix. */
|
|
9
|
+
key?: string;
|
|
10
|
+
/** Ask for the provider's longer cache lifetime where one exists (anthropic `ttl: "1h"`). */
|
|
11
|
+
longTTL?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Mark the cacheable prefix in an assembled body, in place. The transcript's own objects are
|
|
15
|
+
* never mutated: every touched entry is copied.
|
|
16
|
+
* - anthropic: `cache_control` on the last tool, the system prompt (a string becomes one text
|
|
17
|
+
* block) and the last content block of the latest message (3 of the 4 allowed breakpoints).
|
|
18
|
+
* - openai: `prompt_cache_key = key` when set; the prefix is cached automatically.
|
|
19
|
+
* - gemini: nothing yet (explicit cachedContent is deferred).
|
|
20
|
+
*/
|
|
21
|
+
export declare function applyPromptCache(body: Record<string, any>, style: string, o: PromptCacheOptions): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt caching (SPEC §8 "Prompt caching", change add-prompt-cache). The engine keeps the
|
|
3
|
+
* request prefix stable and marks it for the provider's cache.
|
|
4
|
+
*/
|
|
5
|
+
function withCacheControl(v, mark) {
|
|
6
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
7
|
+
return v;
|
|
8
|
+
return { ...v, cache_control: mark };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Mark the cacheable prefix in an assembled body, in place. The transcript's own objects are
|
|
12
|
+
* never mutated: every touched entry is copied.
|
|
13
|
+
* - anthropic: `cache_control` on the last tool, the system prompt (a string becomes one text
|
|
14
|
+
* block) and the last content block of the latest message (3 of the 4 allowed breakpoints).
|
|
15
|
+
* - openai: `prompt_cache_key = key` when set; the prefix is cached automatically.
|
|
16
|
+
* - gemini: nothing yet (explicit cachedContent is deferred).
|
|
17
|
+
*/
|
|
18
|
+
export function applyPromptCache(body, style, o) {
|
|
19
|
+
if (style === "anthropic") {
|
|
20
|
+
const mark = { type: "ephemeral" };
|
|
21
|
+
if (o.longTTL)
|
|
22
|
+
mark.ttl = "1h";
|
|
23
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
24
|
+
const out = [...body.tools];
|
|
25
|
+
out[out.length - 1] = withCacheControl(out[out.length - 1], mark);
|
|
26
|
+
body.tools = out;
|
|
27
|
+
}
|
|
28
|
+
if (typeof body.system === "string") {
|
|
29
|
+
if (body.system !== "")
|
|
30
|
+
body.system = [{ type: "text", text: body.system, cache_control: mark }];
|
|
31
|
+
}
|
|
32
|
+
else if (Array.isArray(body.system) && body.system.length > 0) {
|
|
33
|
+
const out = [...body.system];
|
|
34
|
+
out[out.length - 1] = withCacheControl(out[out.length - 1], mark);
|
|
35
|
+
body.system = out;
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(body.messages) && body.messages.length > 0) {
|
|
38
|
+
const out = [...body.messages];
|
|
39
|
+
const last = out[out.length - 1];
|
|
40
|
+
if (last && typeof last === "object" && !Array.isArray(last)) {
|
|
41
|
+
const m = { ...last };
|
|
42
|
+
if (typeof m.content === "string") {
|
|
43
|
+
m.content = [{ type: "text", text: m.content, cache_control: mark }];
|
|
44
|
+
}
|
|
45
|
+
else if (Array.isArray(m.content) && m.content.length > 0) {
|
|
46
|
+
const blocks = [...m.content];
|
|
47
|
+
blocks[blocks.length - 1] = withCacheControl(blocks[blocks.length - 1], mark);
|
|
48
|
+
m.content = blocks;
|
|
49
|
+
}
|
|
50
|
+
out[out.length - 1] = m;
|
|
51
|
+
}
|
|
52
|
+
body.messages = out;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (style === "openai") {
|
|
56
|
+
if (o.key)
|
|
57
|
+
body.prompt_cache_key = o.key;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ConversationEntry, type ConversationStore } from "./client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Durable {@link ConversationStore}: one file `<root>/<id>.json` per conversation holding
|
|
4
|
+
* `{"version":1,"messages":[...]}`. `save` writes a temp file (0600), fsyncs and renames it over
|
|
5
|
+
* the old one, so a reader sees the old or the new transcript, never a torn one (power-loss
|
|
6
|
+
* durability of the rename is best-effort). Same-id saves are serialized in-process; across
|
|
7
|
+
* processes the last rename wins.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Durability mode (SPEC §8 "Durability mode"): `full` (default) flushes the file then fsyncs the
|
|
11
|
+
* directory best-effort; `fsync` flushes the file only; `none` skips every flush. Temp-then-rename
|
|
12
|
+
* holds in every mode. (On macOS Node's `fh.sync()` is libuv's fsync, which already issues
|
|
13
|
+
* `F_FULLFSYNC`; Node exposes no plainer flush, so `fsync` differs from `full` only by the dir fsync.)
|
|
14
|
+
*/
|
|
15
|
+
export type FileStoreSync = "full" | "fsync" | "none";
|
|
16
|
+
export interface FileStoreOptions {
|
|
17
|
+
sync?: FileStoreSync;
|
|
18
|
+
}
|
|
19
|
+
export declare class FileConversationStore implements ConversationStore {
|
|
20
|
+
readonly root: string;
|
|
21
|
+
readonly sync: FileStoreSync;
|
|
22
|
+
private readonly chains;
|
|
23
|
+
constructor(root: string, opts?: FileStoreOptions);
|
|
24
|
+
private file;
|
|
25
|
+
get(id: string): Promise<any[] | undefined>;
|
|
26
|
+
save(id: string, messages: any[]): Promise<void>;
|
|
27
|
+
private write;
|
|
28
|
+
/** Every stored conversation, newest first (mtime; ties by id ascending). Bodies are not parsed. */
|
|
29
|
+
list(): Promise<ConversationEntry[]>;
|
|
30
|
+
/** Remove a conversation; absent ids are not an error. */
|
|
31
|
+
delete(id: string): Promise<void>;
|
|
32
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { promises as fsp } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { sortEntries } from "./client.js";
|
|
5
|
+
const ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
6
|
+
const VERSION = 1;
|
|
7
|
+
function checkId(id) {
|
|
8
|
+
if (typeof id !== "string" || !ID_RE.test(id) || id === "." || id === "..") {
|
|
9
|
+
throw new Error(`invalid conversation id: ${JSON.stringify(id)}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class FileConversationStore {
|
|
13
|
+
root;
|
|
14
|
+
sync;
|
|
15
|
+
chains = new Map();
|
|
16
|
+
constructor(root, opts = {}) {
|
|
17
|
+
if (typeof root !== "string" || root === "")
|
|
18
|
+
throw new Error("FileConversationStore: root is required");
|
|
19
|
+
const mode = opts.sync ?? "full";
|
|
20
|
+
if (mode !== "full" && mode !== "fsync" && mode !== "none") {
|
|
21
|
+
throw new Error(`FileConversationStore: unknown sync mode ${JSON.stringify(opts.sync)}`);
|
|
22
|
+
}
|
|
23
|
+
this.root = root;
|
|
24
|
+
this.sync = mode;
|
|
25
|
+
}
|
|
26
|
+
file(id) {
|
|
27
|
+
return path.join(this.root, id + ".json");
|
|
28
|
+
}
|
|
29
|
+
async get(id) {
|
|
30
|
+
checkId(id);
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = await fsp.readFile(this.file(id), "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e?.code === "ENOENT")
|
|
37
|
+
return undefined;
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
let body;
|
|
41
|
+
try {
|
|
42
|
+
body = JSON.parse(text);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
throw new Error(`conversation "${id}": corrupt file: ${e instanceof Error ? e.message : String(e)}`);
|
|
46
|
+
}
|
|
47
|
+
if (body === null || typeof body !== "object" || body.version !== VERSION) {
|
|
48
|
+
throw new Error(`conversation "${id}": unsupported version ${JSON.stringify(body?.version)}`);
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(body.messages))
|
|
51
|
+
throw new Error(`conversation "${id}": corrupt file: messages is not an array`);
|
|
52
|
+
return body.messages;
|
|
53
|
+
}
|
|
54
|
+
async save(id, messages) {
|
|
55
|
+
checkId(id);
|
|
56
|
+
const prev = this.chains.get(id) ?? Promise.resolve();
|
|
57
|
+
const next = prev.catch(() => { }).then(() => this.write(id, messages));
|
|
58
|
+
this.chains.set(id, next);
|
|
59
|
+
try {
|
|
60
|
+
await next;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
if (this.chains.get(id) === next)
|
|
64
|
+
this.chains.delete(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async write(id, messages) {
|
|
68
|
+
await fsp.mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
69
|
+
const tmp = path.join(this.root, `.${id}.${randomBytes(6).toString("hex")}.tmp`);
|
|
70
|
+
const data = JSON.stringify({ version: VERSION, messages });
|
|
71
|
+
let fh;
|
|
72
|
+
try {
|
|
73
|
+
fh = await fsp.open(tmp, "w", 0o600);
|
|
74
|
+
await fh.writeFile(data, "utf8");
|
|
75
|
+
if (this.sync !== "none")
|
|
76
|
+
await fh.sync();
|
|
77
|
+
await fh.close();
|
|
78
|
+
fh = undefined;
|
|
79
|
+
await fsp.rename(tmp, this.file(id));
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
await fh?.close().catch(() => { });
|
|
83
|
+
await fsp.rm(tmp, { force: true }).catch(() => { });
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
// Best-effort parent-dir fsync so the rename itself survives power loss where supported.
|
|
87
|
+
if (this.sync !== "full")
|
|
88
|
+
return;
|
|
89
|
+
try {
|
|
90
|
+
const dir = await fsp.open(this.root, "r");
|
|
91
|
+
await dir.sync().catch(() => { });
|
|
92
|
+
await dir.close();
|
|
93
|
+
}
|
|
94
|
+
catch { }
|
|
95
|
+
}
|
|
96
|
+
/** Every stored conversation, newest first (mtime; ties by id ascending). Bodies are not parsed. */
|
|
97
|
+
async list() {
|
|
98
|
+
let names;
|
|
99
|
+
try {
|
|
100
|
+
names = await fsp.readdir(this.root);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if (e?.code === "ENOENT")
|
|
104
|
+
return [];
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const name of names) {
|
|
109
|
+
if (!name.endsWith(".json"))
|
|
110
|
+
continue;
|
|
111
|
+
const id = name.slice(0, -5);
|
|
112
|
+
if (!ID_RE.test(id) || id === "." || id === "..")
|
|
113
|
+
continue;
|
|
114
|
+
try {
|
|
115
|
+
const st = await fsp.stat(path.join(this.root, name));
|
|
116
|
+
if (st.isFile())
|
|
117
|
+
out.push({ id, updatedAt: st.mtime });
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
if (e?.code !== "ENOENT")
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return sortEntries(out);
|
|
125
|
+
}
|
|
126
|
+
/** Remove a conversation; absent ids are not an error. */
|
|
127
|
+
async delete(id) {
|
|
128
|
+
checkId(id);
|
|
129
|
+
await fsp.rm(this.file(id), { force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/toolkit.d.ts
CHANGED
|
@@ -52,7 +52,7 @@ export declare class Toolkit {
|
|
|
52
52
|
private readonly mcpServerConfig;
|
|
53
53
|
private byName;
|
|
54
54
|
private constructor();
|
|
55
|
-
static create(opts
|
|
55
|
+
static create(opts?: ToolkitOptions): Promise<Toolkit>;
|
|
56
56
|
tools(): Tool[];
|
|
57
57
|
get(name: string): Tool | undefined;
|
|
58
58
|
/** Add a tool at runtime (native, http, or any custom Tool). First name wins. */
|
|
@@ -104,4 +104,4 @@ export declare class Toolkit {
|
|
|
104
104
|
}[];
|
|
105
105
|
close(): Promise<void>;
|
|
106
106
|
}
|
|
107
|
-
export declare function createToolkit(opts
|
|
107
|
+
export declare function createToolkit(opts?: ToolkitOptions): Promise<Toolkit>;
|