toolnexus 0.20.0 → 0.22.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.
Files changed (49) hide show
  1. package/README.md +40 -0
  2. package/dist/agents/agent.d.ts +12 -1
  3. package/dist/agents/agent.js +43 -7
  4. package/dist/agents/compaction.d.ts +19 -0
  5. package/dist/agents/compaction.js +26 -4
  6. package/dist/agents/runtime.d.ts +5 -0
  7. package/dist/agents/runtime.js +13 -2
  8. package/dist/batteries.d.ts +223 -0
  9. package/dist/batteries.js +488 -0
  10. package/dist/builtin.d.ts +16 -0
  11. package/dist/builtin.js +315 -17
  12. package/dist/builtin_extras.d.ts +80 -0
  13. package/dist/builtin_extras.js +248 -0
  14. package/dist/classifier.d.ts +14 -0
  15. package/dist/classifier.js +34 -1
  16. package/dist/client.d.ts +91 -15
  17. package/dist/client.js +377 -110
  18. package/dist/index.d.ts +8 -0
  19. package/dist/index.js +11 -0
  20. package/dist/judge.d.ts +98 -0
  21. package/dist/judge.js +199 -0
  22. package/dist/mcp.d.ts +32 -1
  23. package/dist/mcp.js +184 -6
  24. package/dist/models.d.ts +20 -0
  25. package/dist/models.js +46 -0
  26. package/dist/promptcache.d.ts +21 -0
  27. package/dist/promptcache.js +59 -0
  28. package/dist/store.d.ts +32 -0
  29. package/dist/store.js +131 -0
  30. package/dist/tooltiers.d.ts +49 -0
  31. package/dist/tooltiers.js +197 -0
  32. package/dist/types.d.ts +19 -0
  33. package/package.json +2 -2
  34. package/src/agents/agent.ts +39 -8
  35. package/src/agents/compaction.ts +45 -4
  36. package/src/agents/runtime.ts +16 -2
  37. package/src/batteries.ts +614 -0
  38. package/src/builtin.ts +333 -18
  39. package/src/builtin_extras.ts +285 -0
  40. package/src/classifier.ts +36 -1
  41. package/src/client.ts +415 -115
  42. package/src/index.ts +31 -0
  43. package/src/judge.ts +305 -0
  44. package/src/mcp.ts +204 -7
  45. package/src/models.ts +56 -0
  46. package/src/promptcache.ts +63 -0
  47. package/src/store.ts +142 -0
  48. package/src/tooltiers.ts +221 -0
  49. package/src/types.ts +20 -0
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
+ }
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Tool tiers (SPEC §8 "Prompt caching" → Tool tiers, change add-prompt-cache phase 2). With
3
+ * `ClientOptions.toolTiers` set, only CORE tools are sent with full schemas. LISTED tools appear
4
+ * as "name — one line" and DEFERRED tools by name only, both inside the description of a
5
+ * `tool_search` tool that returns full schemas on demand, as a tool result: the tools array
6
+ * never changes mid-session, so the cached prefix survives. A loaded tool is called by name, or
7
+ * through `call_tool {name, arguments}`, which the loop unwraps BEFORE hooks.
8
+ */
9
+ import type { Tool, ToolResult } from "./types.js";
10
+ /** `ClientOptions.toolTiers`. */
11
+ export interface ToolTierOptions {
12
+ /** Always sent with their full schema. */
13
+ core?: string[];
14
+ /** Named only. Every other tool is listed (name + one line). A tool registered after the
15
+ * plan froze is deferred and found by search. */
16
+ deferred?: string[];
17
+ /** Caps the tokens sent up front (core schemas + tool_search + call_tool). Absent/0 ⇒ 5% of
18
+ * `contextWindow`; both absent ⇒ no budget. Over budget, the least-used core tools move to
19
+ * listed, then the least-used listed tools to deferred. */
20
+ budgetTokens?: number;
21
+ contextWindow?: number;
22
+ /** Host per-tool usage stat (higher = more used), read only when the plan is built. */
23
+ usageCount?: (name: string) => number;
24
+ /** Token estimate of a JSON-encodable value; default ceil(bytes/4). */
25
+ countTokens?: (v: unknown) => number;
26
+ }
27
+ export type ToolTier = "core" | "listed" | "deferred";
28
+ /** The frozen tier assignment for one toolkit. */
29
+ export interface ToolPlan {
30
+ tier: Map<string, ToolTier>;
31
+ core: string[];
32
+ listed: string[];
33
+ deferred: string[];
34
+ search: Tool;
35
+ call: Tool;
36
+ }
37
+ export declare function defaultCountTokens(v: unknown): number;
38
+ export declare function toolDef(t: Tool): Record<string, unknown>;
39
+ /** Build a tier plan over `tools` (exported for tests and host UIs). */
40
+ export declare function buildToolPlan(o: ToolTierOptions, tools: Tool[]): ToolPlan;
41
+ /** `call_tool {name, arguments}` → the real call; `undefined` when `name` is not call_tool. */
42
+ export declare function unwrapCallTool(name: string, args: Record<string, unknown>): {
43
+ name: string;
44
+ args: Record<string, unknown>;
45
+ } | undefined;
46
+ /** Go's json escapes `<`, `>`, `&`, U+2028 and U+2029 inside strings; they never occur outside one. */
47
+ export declare function goHtmlEscape(json: string): string;
48
+ /** Run `tool_search` over every tool (any tier). */
49
+ export declare function toolSearch(all: Tool[], get: (name: string) => Tool | undefined, args: Record<string, unknown>): ToolResult;
@@ -0,0 +1,197 @@
1
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
2
+ export function defaultCountTokens(v) {
3
+ return Math.ceil(Buffer.byteLength(JSON.stringify(v) ?? "", "utf8") / 4);
4
+ }
5
+ export function toolDef(t) {
6
+ return { name: t.name, description: t.description, inputSchema: t.inputSchema };
7
+ }
8
+ function firstLine(s) {
9
+ const i = s.indexOf("\n");
10
+ if (i >= 0)
11
+ s = s.slice(0, i);
12
+ // Go cuts at 120 BYTES; cut at 120 UTF-8 bytes too, without splitting a character.
13
+ if (Buffer.byteLength(s, "utf8") > 120) {
14
+ let out = "";
15
+ for (const ch of s) {
16
+ if (Buffer.byteLength(out + ch, "utf8") > 120)
17
+ break;
18
+ out += ch;
19
+ }
20
+ s = out + "…";
21
+ }
22
+ return s;
23
+ }
24
+ const notRun = async () => ({ output: "engine-internal tool: run by the client loop", isError: true });
25
+ function rebuild(p, byName) {
26
+ p.core = [];
27
+ p.listed = [];
28
+ p.deferred = [];
29
+ for (const [n, t] of p.tier)
30
+ (t === "core" ? p.core : t === "listed" ? p.listed : p.deferred).push(n);
31
+ p.core.sort(cmp);
32
+ p.listed.sort(cmp);
33
+ p.deferred.sort(cmp);
34
+ let d = 'Load the full schema of tools that are not shown in full, then call them by name (or with call_tool). Query by keywords, or "select:a,b" for exact names.';
35
+ if (p.listed.length) {
36
+ d += "\n\nAvailable tools:";
37
+ for (const n of p.listed) {
38
+ d += "\n- " + n;
39
+ const line = firstLine(byName.get(n)?.description ?? "");
40
+ if (line)
41
+ d += " — " + line;
42
+ }
43
+ }
44
+ if (p.deferred.length)
45
+ d += "\n\nMore tools (name only): " + p.deferred.join(", ");
46
+ p.search = {
47
+ name: "tool_search",
48
+ description: d,
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {
52
+ query: { type: "string", description: "Keywords, or select:name1,name2" },
53
+ limit: { type: "number", description: "Maximum results for a keyword query (default 5)" },
54
+ },
55
+ required: ["query"],
56
+ additionalProperties: false,
57
+ },
58
+ source: "builtin",
59
+ hints: { readOnly: true, destructive: false, openWorld: false },
60
+ execute: notRun,
61
+ };
62
+ p.call = {
63
+ name: "call_tool",
64
+ description: "Call a tool loaded with tool_search, by name, with its arguments.",
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: { name: { type: "string" }, arguments: { type: "object" } },
68
+ required: ["name"],
69
+ additionalProperties: false,
70
+ },
71
+ source: "builtin",
72
+ execute: notRun,
73
+ };
74
+ }
75
+ /** Build a tier plan over `tools` (exported for tests and host UIs). */
76
+ export function buildToolPlan(o, tools) {
77
+ const count = o.countTokens ?? defaultCountTokens;
78
+ const usage = (n) => (o.usageCount ? o.usageCount(n) : 0);
79
+ const byName = new Map(tools.map((t) => [t.name, t]));
80
+ const tier = new Map();
81
+ for (const t of tools)
82
+ tier.set(t.name, "listed");
83
+ for (const n of o.deferred ?? [])
84
+ if (tier.has(n))
85
+ tier.set(n, "deferred");
86
+ for (const n of o.core ?? [])
87
+ if (tier.has(n))
88
+ tier.set(n, "core");
89
+ const p = { tier };
90
+ rebuild(p, byName);
91
+ let budget = o.budgetTokens ?? 0;
92
+ if (!budget && o.contextWindow && o.contextWindow > 0)
93
+ budget = Math.floor(o.contextWindow / 20);
94
+ if (budget > 0) {
95
+ const cost = () => {
96
+ let n = count(toolDef(p.search)) + count(toolDef(p.call));
97
+ for (const name of p.core)
98
+ n += count(toolDef(byName.get(name)));
99
+ return n;
100
+ };
101
+ // least used first; ties: bigger schema first, then name
102
+ const order = (names) => [...names].sort((a, b) => {
103
+ const ua = usage(a), ub = usage(b);
104
+ if (ua !== ub)
105
+ return ua - ub;
106
+ const sa = count(toolDef(byName.get(a))), sb = count(toolDef(byName.get(b)));
107
+ if (sa !== sb)
108
+ return sb - sa;
109
+ return cmp(a, b);
110
+ });
111
+ for (const n of order(p.core)) {
112
+ if (cost() <= budget)
113
+ break;
114
+ tier.set(n, "listed");
115
+ rebuild(p, byName);
116
+ }
117
+ for (const n of order(p.listed)) {
118
+ if (cost() <= budget)
119
+ break;
120
+ tier.set(n, "deferred");
121
+ rebuild(p, byName);
122
+ }
123
+ }
124
+ return p;
125
+ }
126
+ /** `call_tool {name, arguments}` → the real call; `undefined` when `name` is not call_tool. */
127
+ export function unwrapCallTool(name, args) {
128
+ if (name !== "call_tool")
129
+ return undefined;
130
+ const real = typeof args?.name === "string" ? args.name : "";
131
+ const inner = args?.arguments && typeof args.arguments === "object" && !Array.isArray(args.arguments) ? args.arguments : {};
132
+ return { name: real, args: inner };
133
+ }
134
+ /** Keys sorted at every level, so the loaded definitions print the same bytes as the Go port. */
135
+ function sortKeys(v) {
136
+ if (Array.isArray(v))
137
+ return v.map(sortKeys);
138
+ if (v && typeof v === "object") {
139
+ const out = {};
140
+ for (const k of Object.keys(v).sort(cmp))
141
+ out[k] = sortKeys(v[k]);
142
+ return out;
143
+ }
144
+ return v;
145
+ }
146
+ /** Go's json escapes `<`, `>`, `&`, U+2028 and U+2029 inside strings; they never occur outside one. */
147
+ export function goHtmlEscape(json) {
148
+ return json.replace(/[<>&\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
149
+ }
150
+ /** Run `tool_search` over every tool (any tier). */
151
+ export function toolSearch(all, get, args) {
152
+ const q = (typeof args.query === "string" ? args.query : "").trim();
153
+ if (!q)
154
+ return { output: "tool_search: query is required", isError: true };
155
+ let picked = [];
156
+ if (q.startsWith("select:")) {
157
+ const missing = [];
158
+ for (const raw of q.slice("select:".length).split(",")) {
159
+ const n = raw.trim();
160
+ if (!n)
161
+ continue;
162
+ const t = get(n);
163
+ if (t)
164
+ picked.push(t);
165
+ else
166
+ missing.push(n);
167
+ }
168
+ if (missing.length && !picked.length)
169
+ return { output: "tool_search: no such tool: " + missing.join(", "), isError: true };
170
+ }
171
+ else {
172
+ const limit = typeof args.limit === "number" && args.limit >= 1 ? Math.trunc(args.limit) : 5;
173
+ const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
174
+ const hits = [];
175
+ for (const t of all) {
176
+ const name = t.name.toLowerCase(), desc = t.description.toLowerCase();
177
+ let s = 0;
178
+ for (const term of terms) {
179
+ if (name.includes(term))
180
+ s += 3;
181
+ if (desc.includes(term))
182
+ s += 1;
183
+ }
184
+ if (s > 0)
185
+ hits.push({ t, score: s });
186
+ }
187
+ hits.sort((a, b) => b.score - a.score || cmp(a.t.name, b.t.name));
188
+ picked = hits.slice(0, limit).map((h) => h.t);
189
+ }
190
+ if (!picked.length)
191
+ return { output: "No matching tools.", isError: false, metadata: { count: 0 } };
192
+ return {
193
+ output: "Loaded tools (call by name, or with call_tool):\n" + goHtmlEscape(JSON.stringify(sortKeys(picked.map(toolDef)), null, 2)),
194
+ isError: false,
195
+ metadata: { count: picked.length, tools: picked.map((t) => t.name) },
196
+ };
197
+ }
package/dist/types.d.ts CHANGED
@@ -27,6 +27,22 @@ export interface ToolContext {
27
27
  timeout?: number;
28
28
  /** Present ONLY on a post-waitFor retry (§10): the resolution of a prior suspension. */
29
29
  answer?: Answer;
30
+ /** The model's id for this tool call, set by the client loop (§4C). A message's calls run
31
+ * concurrently, so this is how a tool tells its sibling calls apart. Absent outside the loop. */
32
+ callId?: string;
33
+ }
34
+ /**
35
+ * Advisory behavior hints (named after MCP tool annotations, §4C), for host permission gates
36
+ * built on `hooks.beforeTool`. Absent on a tool = unknown; treat it as mutating. Never sent to
37
+ * the model: no adapter or wire output carries them.
38
+ */
39
+ export interface ToolHints {
40
+ /** The tool does not change its environment. */
41
+ readOnly: boolean;
42
+ /** The tool may overwrite or delete things (meaningful when not readOnly). */
43
+ destructive: boolean;
44
+ /** The tool reaches outside the host (network, external systems). */
45
+ openWorld: boolean;
30
46
  }
31
47
  /**
32
48
  * §10 Suspension. A tool that needs an out-of-band, async resolution (login, approval,
@@ -77,6 +93,9 @@ export interface Tool {
77
93
  inputSchema: JSONSchema;
78
94
  source: ToolSource;
79
95
  execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>;
96
+ /** What the tool may do (§4C). Builtins always carry hints; MCP tools carry them when the
97
+ * server sends annotations. Advisory: a server can mislabel its tools. */
98
+ hints?: ToolHints;
80
99
  }
81
100
  export type McpStatus = "connected" | "disabled" | "failed";
82
101
  /** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
- "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts test/host-boundary.test.ts",
20
+ "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts",
21
21
  "example": "node --experimental-strip-types examples/basic.ts",
22
22
  "example:judge": "node --experimental-strip-types examples/judge.ts",
23
23
  "example:multimodal": "node --experimental-strip-types examples/multimodal.ts"
@@ -10,7 +10,7 @@
10
10
  import type { PromptInput } from "../content.js"
11
11
  import fs from "node:fs"
12
12
  import { defineTool } from "../native.js"
13
- import type { Answer, Request, Tool } from "../types.js"
13
+ import type { Answer, Request, Tool, ToolContext } from "../types.js"
14
14
  import { AgentRuntime, isVerbError, S, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
15
15
  import { Loop, guardedHooks, type Completion, type Guardrail } from "./loop.js"
16
16
  import type { ClientOptions } from "../client.js"
@@ -141,6 +141,27 @@ export class Agent {
141
141
  return { ...r, runtime }
142
142
  }
143
143
 
144
+ /** `run` that interrupts the child when `signal` aborts. */
145
+ private async runSignal(prompt: string, opts: AgentRunOptions, signal?: AbortSignal): Promise<{ text: string; isError: boolean; turns: number; totalTokens: number; cancelled?: boolean }> {
146
+ if (!signal) return this.run(prompt, opts)
147
+ const reason = () => (signal.reason instanceof Error ? signal.reason.message : String(signal.reason ?? "aborted"))
148
+ if (signal.aborted) return { text: reason(), isError: true, turns: 0, totalTokens: 0, cancelled: true }
149
+ const runtime = this.createRuntime(opts)
150
+ const h = runtime.spawn(runtime.root, this.name)
151
+ if (isVerbError(h)) return { text: h.error, isError: true, turns: 0, totalTokens: 0 }
152
+ const onAbort = () => void runtime.close(runtime.root, { force: true }).catch(() => {})
153
+ signal.addEventListener("abort", onAbort, { once: true })
154
+ try {
155
+ const woke = runtime.wake(h, prompt)
156
+ const r = await runtime.wait(h as Handle)
157
+ if (signal.aborted) return { text: reason(), isError: true, turns: r.turns, totalTokens: r.totalTokens, cancelled: true }
158
+ if (woke.ok && r.status !== "pending") await runtime.close(runtime.root)
159
+ return r
160
+ } finally {
161
+ signal.removeEventListener("abort", onAbort)
162
+ }
163
+ }
164
+
144
165
  /**
145
166
  * The bridge: an Agent IS a Tool. Drop the result into the classic API's
146
167
  * `extraTools`; the agent's own loop runs in isolation and the classic run
@@ -148,6 +169,18 @@ export class Agent {
148
169
  * `{agent, turns, totalTokens}`.
149
170
  */
150
171
  asTool(opts: AgentRunOptions = {}): Tool {
172
+ return this.asToolWith(() => opts)
173
+ }
174
+
175
+ /**
176
+ * `asTool` with options built per call from the calling tool's context (SPEC §4C), so the
177
+ * child runs under the parent turn's gates: set `hooks` (the same beforeTool permission gate —
178
+ * it also sees the child's tool calls), `waitFor` (the same ask bridge) and `onMetric`. A
179
+ * `hooks` on the agent's own spec replaces `opts.hooks` for that agent, so leave it unset when
180
+ * the gate must hold. When the calling turn is cancelled (`ctx.signal`), the child is
181
+ * interrupted and the tool returns a `cancelled:` error result.
182
+ */
183
+ asToolWith(per: (ctx?: ToolContext) => AgentRunOptions): Tool {
151
184
  return defineTool({
152
185
  name: this.name,
153
186
  description: this.spec.does,
@@ -156,13 +189,11 @@ export class Agent {
156
189
  properties: { prompt: { type: "string", description: "The task for this agent" } },
157
190
  required: ["prompt"],
158
191
  },
159
- run: async (args: Record<string, unknown>) => {
160
- const r = await this.run(String(args.prompt), opts)
161
- return {
162
- output: r.text,
163
- isError: r.isError,
164
- metadata: { agent: this.name, turns: r.turns, totalTokens: r.totalTokens },
165
- }
192
+ run: async (args: Record<string, unknown>, ctx?: ToolContext) => {
193
+ const r = await this.runSignal(String(args.prompt), per(ctx), ctx?.signal)
194
+ const metadata = { agent: this.name, turns: r.turns, totalTokens: r.totalTokens }
195
+ if (r.cancelled) return { output: `cancelled: ${r.text}`, isError: true, metadata }
196
+ return { output: r.text, isError: r.isError, metadata }
166
197
  },
167
198
  })
168
199
  }