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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Opt-in builtins for coding-agent hosts (SPEC §4C): background shells (`bash`
3
+ * run_in_background + `bash_output` + `kill_shell`), `websearch` over a host-supplied provider,
4
+ * and `notebook_edit`. None is on by default: a host that sets nothing sees the same ten builtins.
5
+ */
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ const err = (output, metadata) => ({ output, isError: true, metadata });
8
+ const ok = (output, metadata) => ({ output, isError: false, metadata });
9
+ /**
10
+ * Owns the commands `bash` started with `run_in_background`. Pass one as
11
+ * `builtins.background`, and call `closeAll()` when the host shuts down — a background command
12
+ * is NOT tied to the turn that started it, so nothing else stops it.
13
+ */
14
+ export class BackgroundShells {
15
+ next = 0;
16
+ jobs = new Map();
17
+ /** @internal Start `command` under `argv` in its own process group. */
18
+ start(argv, command, workdir, grace) {
19
+ const child = spawn(argv[0], [...argv.slice(1), command], { cwd: workdir, detached: process.platform !== "win32" });
20
+ let resolveDone;
21
+ const done = new Promise((r) => (resolveDone = r));
22
+ const j = {
23
+ id: `bg${++this.next}`, command, started: new Date(), child, pgid: child.pid, grace,
24
+ out: "", read: 0, running: true, exitCode: 0, killed: false, done,
25
+ };
26
+ child.stdout?.on("data", (d) => (j.out += d.toString()));
27
+ child.stderr?.on("data", (d) => (j.out += d.toString()));
28
+ const finish = (code) => {
29
+ if (!j.running)
30
+ return;
31
+ j.running = false;
32
+ j.exitCode = code ?? -1;
33
+ resolveDone();
34
+ };
35
+ child.on("error", (e) => {
36
+ j.out += `bash: ${e.message}\n`;
37
+ finish(-1);
38
+ });
39
+ child.on("close", (code) => finish(code));
40
+ // A background job must never keep the host's event loop alive on its own.
41
+ child.unref();
42
+ child.stdout?.unref?.();
43
+ child.stderr?.unref?.();
44
+ this.jobs.set(j.id, j);
45
+ return j;
46
+ }
47
+ /** @internal */
48
+ get(id) {
49
+ return this.jobs.get(id);
50
+ }
51
+ signal(j, graceful) {
52
+ const pgid = j.pgid;
53
+ if (typeof pgid !== "number" || pgid <= 1 || !j.running)
54
+ return;
55
+ try {
56
+ if (process.platform === "win32")
57
+ spawnSync("taskkill", ["/T", "/F", "/PID", String(pgid)]);
58
+ else
59
+ process.kill(-pgid, graceful ? "SIGTERM" : "SIGKILL");
60
+ }
61
+ catch {
62
+ /* already gone */
63
+ }
64
+ }
65
+ /** @internal Stop the job's whole process group: SIGTERM, the grace window, SIGKILL.
66
+ * Resolves whether it was still running. */
67
+ async kill(j) {
68
+ if (!j.running)
69
+ return false;
70
+ j.killed = true;
71
+ // The child and its pipes are unref'd so an idle job never pins the host. While we are
72
+ // WAITING on it, though, they must be ref'd: otherwise, once the grace timer is cleared,
73
+ // nothing holds the event loop open until 'close' fires and Node exits with this promise
74
+ // still pending (seen on Linux, where 'close' lands an iteration later than on macOS).
75
+ j.child.ref();
76
+ j.child.stdout?.ref?.();
77
+ j.child.stderr?.ref?.();
78
+ this.signal(j, true);
79
+ let t;
80
+ const timedOut = await Promise.race([j.done.then(() => false), new Promise((r) => (t = setTimeout(() => r(true), j.grace)))]);
81
+ if (t)
82
+ clearTimeout(t);
83
+ if (timedOut) {
84
+ this.signal(j, false);
85
+ await j.done;
86
+ }
87
+ return true;
88
+ }
89
+ static info(j) {
90
+ return { id: j.id, command: j.command, started: j.started, running: j.running, exitCode: j.exitCode, killed: j.killed };
91
+ }
92
+ /** Every background command, oldest first. */
93
+ list() {
94
+ return [...this.jobs.values()].sort((a, b) => a.started.getTime() - b.started.getTime()).map(BackgroundShells.info);
95
+ }
96
+ /** Stop one background command's process group. Unknown id ⇒ throws. */
97
+ async killShell(id) {
98
+ const j = this.jobs.get(id);
99
+ if (!j)
100
+ throw new Error(`no background shell "${id}"`);
101
+ await this.kill(j);
102
+ }
103
+ /** Stop every background command still running, concurrently. */
104
+ async closeAll() {
105
+ await Promise.all([...this.jobs.values()].map((j) => this.kill(j)));
106
+ }
107
+ }
108
+ export function bgStatus(i) {
109
+ if (i.running)
110
+ return "running";
111
+ if (i.killed)
112
+ return "killed";
113
+ return `exited with code ${i.exitCode}`;
114
+ }
115
+ /** `bash_output(id)`. */
116
+ export function bashOutput(bg, id) {
117
+ const j = bg.get(id);
118
+ if (!j)
119
+ return err(`bash_output: no background shell "${id}"`);
120
+ const fresh = j.out.slice(j.read);
121
+ j.read = j.out.length;
122
+ const info = BackgroundShells.info(j);
123
+ const status = bgStatus(info);
124
+ const meta = { id, status: status.split(" ")[0] };
125
+ if (!info.running && !info.killed)
126
+ meta.exitCode = info.exitCode;
127
+ return ok(`${fresh}\n[status: ${status}]`, meta);
128
+ }
129
+ /** `kill_shell(id)`. */
130
+ export async function killShell(bg, id) {
131
+ const j = bg.get(id);
132
+ if (!j)
133
+ return err(`kill_shell: no background shell "${id}"`);
134
+ if (!(await bg.kill(j))) {
135
+ return ok(`Background shell ${id} had already ${bgStatus(BackgroundShells.info(j))}`, { id, killed: false });
136
+ }
137
+ return ok(`Killed background shell ${id}`, { id, killed: true });
138
+ }
139
+ export async function runWebSearch(search, args, signal) {
140
+ const q = typeof args.query === "string" ? args.query : "";
141
+ if (!q.trim())
142
+ return err("websearch: query is required");
143
+ const limit = typeof args.limit === "number" && args.limit >= 1 ? Math.trunc(args.limit) : 5;
144
+ let results;
145
+ try {
146
+ results = await search(q, limit, { signal });
147
+ }
148
+ catch (e) {
149
+ return err(`websearch: ${e instanceof Error ? e.message : String(e)}`);
150
+ }
151
+ results = (results ?? []).slice(0, limit);
152
+ if (results.length === 0)
153
+ return ok("No results.", { count: 0 });
154
+ const text = results
155
+ .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}` + (r.snippet ? `\n ${r.snippet}` : ""))
156
+ .join("\n\n");
157
+ return ok(text, { count: results.length, results: results.map((r) => ({ title: r.title, url: r.url, snippet: r.snippet ?? "" })) });
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // notebook_edit
161
+ // ---------------------------------------------------------------------------
162
+ /** Split text into nbformat's list of lines, each keeping its "\n". */
163
+ export function notebookSource(s) {
164
+ if (!s)
165
+ return [];
166
+ return s.split(/(?<=\n)/).filter((p) => p !== "");
167
+ }
168
+ /** Go's json.MarshalIndent(v, "", " ") with sorted keys, plus Go's HTML escaping. */
169
+ export function goJsonIndent(v) {
170
+ const sort = (x) => {
171
+ if (Array.isArray(x))
172
+ return x.map(sort);
173
+ if (x && typeof x === "object") {
174
+ const out = {};
175
+ for (const k of Object.keys(x).sort())
176
+ out[k] = sort(x[k]);
177
+ return out;
178
+ }
179
+ return x;
180
+ };
181
+ return JSON.stringify(sort(v), null, " ")
182
+ .replace(/</g, "\\u003c")
183
+ .replace(/>/g, "\\u003e")
184
+ .replace(/&/g, "\\u0026")
185
+ .replace(new RegExp(String.fromCharCode(0x2028), "g"), "\\u2028")
186
+ .replace(new RegExp(String.fromCharCode(0x2029), "g"), "\\u2029");
187
+ }
188
+ /** Apply one notebook_edit to the parsed notebook, in place. Returns the summary, or an error result. */
189
+ export function editNotebook(nb, args, p) {
190
+ let cells = Array.isArray(nb.cells) ? nb.cells : [];
191
+ const mode = typeof args.edit_mode === "string" && args.edit_mode ? args.edit_mode : "replace";
192
+ let idx = -1;
193
+ if (typeof args.cell_id === "string" && args.cell_id) {
194
+ idx = cells.findIndex((c) => c && typeof c === "object" && c.id === args.cell_id);
195
+ if (idx < 0)
196
+ return err(`notebook_edit: no cell with id "${args.cell_id}"`);
197
+ }
198
+ else if (typeof args.cell_index === "number") {
199
+ idx = Math.trunc(args.cell_index);
200
+ if (idx < 0 || idx >= cells.length)
201
+ return err(`notebook_edit: cell_index ${idx} out of range (0-${cells.length - 1})`);
202
+ }
203
+ else if (mode !== "insert") {
204
+ return err("notebook_edit: cell_id or cell_index is required");
205
+ }
206
+ const src = typeof args.new_source === "string" ? args.new_source : "";
207
+ let cellType = typeof args.cell_type === "string" ? args.cell_type : "";
208
+ let summary;
209
+ switch (mode) {
210
+ case "replace": {
211
+ const cm = cells[idx];
212
+ cm.source = notebookSource(src);
213
+ if (cellType)
214
+ cm.cell_type = cellType;
215
+ if (cm.cell_type === "code") {
216
+ cm.outputs = [];
217
+ cm.execution_count = null;
218
+ }
219
+ else {
220
+ delete cm.outputs;
221
+ delete cm.execution_count;
222
+ }
223
+ summary = `Replaced cell ${idx} in ${p}`;
224
+ break;
225
+ }
226
+ case "insert": {
227
+ if (!cellType)
228
+ cellType = "code";
229
+ const cell = { cell_type: cellType, metadata: {}, source: notebookSource(src) };
230
+ if (cellType === "code") {
231
+ cell.outputs = [];
232
+ cell.execution_count = null;
233
+ }
234
+ const at = idx + 1;
235
+ cells = [...cells.slice(0, at), cell, ...cells.slice(at)];
236
+ summary = `Inserted ${cellType} cell at ${at} in ${p}`;
237
+ break;
238
+ }
239
+ case "delete":
240
+ cells = [...cells.slice(0, idx), ...cells.slice(idx + 1)];
241
+ summary = `Deleted cell ${idx} from ${p}`;
242
+ break;
243
+ default:
244
+ return err(`notebook_edit: unknown edit_mode "${mode}"`);
245
+ }
246
+ nb.cells = cells;
247
+ return { summary };
248
+ }
package/dist/client.d.ts CHANGED
@@ -4,7 +4,9 @@
4
4
  * Toolkit. See ../../SPEC.md §8.
5
5
  */
6
6
  import type { Toolkit } from "./toolkit.js";
7
- import type { ToolResult, Request, Answer } from "./types.js";
7
+ import type { ToolHints, ToolResult, Request, Answer } from "./types.js";
8
+ import { type PromptCacheOptions } from "./promptcache.js";
9
+ import { type ToolTier, type ToolTierOptions } from "./tooltiers.js";
8
10
  import type { PromptInput, UnsupportedPartMode } from "./content.js";
9
11
  import type { TranslateRequest, TranslateResult } from "./translate.js";
10
12
  export type ClientStyle = "openai" | "anthropic";
@@ -12,7 +14,15 @@ export interface ClientOptions {
12
14
  baseUrl: string;
13
15
  style: ClientStyle;
14
16
  model: string;
17
+ /** The key, used as-is. Absent => `apiKeyEnv`, else ONLY the env var that belongs to the
18
+ * `baseUrl` host over https: api.openai.com -> OPENAI_API_KEY, api.anthropic.com ->
19
+ * ANTHROPIC_API_KEY, openrouter.ai (and subdomains) -> OPENROUTER_API_KEY. Any other host (or
20
+ * plain http) needs `apiKey` or `apiKeyEnv` - a key is never sent to a host it does not belong
21
+ * to (SPEC 4C). Never logged. */
15
22
  apiKey?: string;
23
+ /** The NAME of the env var to read the key from when `apiKey` is absent - for a host not in
24
+ * the table above. Read at call time; the value is never logged. */
25
+ apiKeyEnv?: string;
16
26
  headers?: Record<string, string>;
17
27
  systemPrompt?: string;
18
28
  maxTurns?: number;
@@ -53,7 +63,19 @@ export interface ClientOptions {
53
63
  * Replaying the leaf's stored transcript instead is DEFERRED to its own change; it would not
54
64
  * remove this requirement anyway.
55
65
  */
56
- waitFor?: (request: Request) => Promise<Answer>;
66
+ /** The second argument carries the turn's abort signal (SPEC 4C), so a pending ask can end
67
+ * when the turn is cancelled: settle once `ctx.signal` aborts. */
68
+ waitFor?: (request: Request, ctx: {
69
+ signal: AbortSignal;
70
+ }) => Promise<Answer>;
71
+ /** Prompt caching (SPEC 8): anthropic `cache_control` breakpoints on the last tool, the system
72
+ * prompt and the latest message; openai `prompt_cache_key`. Applied before `requestParams`
73
+ * and `bodyTransform`, so both can still override it. Absent => body byte-identical. */
74
+ promptCache?: PromptCacheOptions;
75
+ /** Tool tiers (SPEC 8): send only core tool schemas plus a `tool_search` tool that loads the
76
+ * rest on demand (and `call_tool`). The plan is frozen per toolkit until `resetToolTiers`.
77
+ * Absent => every tool is sent, as before. */
78
+ toolTiers?: ToolTierOptions;
57
79
  /** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
58
80
  * its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
59
81
  * default). `messages`/`tools`/`stream` are forbidden here (stripped + warned) — rewrite
@@ -200,9 +222,21 @@ export interface ConversationStore {
200
222
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
201
223
  export declare class InMemoryConversationStore implements ConversationStore {
202
224
  private readonly map;
225
+ private readonly updated;
203
226
  get(id: string): Promise<any[] | undefined>;
204
227
  save(id: string, messages: any[]): Promise<void>;
228
+ /** Every stored conversation, newest first (ties by id ascending). */
229
+ list(): Promise<ConversationEntry[]>;
230
+ /** Remove a conversation; absent ids are not an error. */
231
+ delete(id: string): Promise<void>;
205
232
  }
233
+ /** One `list()` row of a shipped conversation store. */
234
+ export interface ConversationEntry {
235
+ id: string;
236
+ updatedAt: Date;
237
+ }
238
+ /** @internal Newest first, ties by id ascending (SPEC.md "Conversation memory"). */
239
+ export declare function sortEntries(entries: ConversationEntry[]): ConversationEntry[];
206
240
  /**
207
241
  * Lifecycle hooks around the agent loop. Each may observe, and (where noted)
208
242
  * mutate or short-circuit. All may be async.
@@ -237,6 +271,7 @@ export interface Hooks {
237
271
  args: Record<string, unknown>;
238
272
  id?: string;
239
273
  turn: number;
274
+ hints?: ToolHints;
240
275
  }): void | {
241
276
  args?: Record<string, unknown>;
242
277
  result?: ToolResult;
@@ -270,6 +305,14 @@ export interface Usage {
270
305
  promptTokens: number;
271
306
  completionTokens: number;
272
307
  totalTokens: number;
308
+ /** Prompt tokens served from the provider's prompt cache (anthropic cache_read_input_tokens;
309
+ * openai prompt_tokens_details.cached_tokens). Present once a provider reports any.
310
+ * Under anthropic `promptTokens` EXCLUDES cached tokens: the context sent is
311
+ * promptTokens + cacheReadTokens + cacheWriteTokens. Under openai `promptTokens` already
312
+ * includes cacheReadTokens. */
313
+ cacheReadTokens?: number;
314
+ /** Tokens written to the prompt cache (anthropic cache_creation_input_tokens). */
315
+ cacheWriteTokens?: number;
273
316
  }
274
317
  export interface RunResult {
275
318
  text: string;
@@ -302,12 +345,15 @@ export type StreamEvent = {
302
345
  id: string;
303
346
  name: string;
304
347
  args: Record<string, unknown>;
348
+ via?: "call_tool";
305
349
  } | {
306
350
  type: "tool_result";
307
351
  id: string;
308
352
  name: string;
309
353
  output: string;
310
354
  isError: boolean;
355
+ metadata?: Record<string, unknown>;
356
+ via?: "call_tool";
311
357
  } | {
312
358
  type: "usage";
313
359
  usage: Usage;
@@ -318,12 +364,22 @@ export type StreamEvent = {
318
364
  type: "done";
319
365
  result: RunResult;
320
366
  };
367
+ /**
368
+ * Pick the API key (SPEC 4C, security). An explicit `apiKey` is used as-is; else `apiKeyEnv`;
369
+ * else ONLY the env var that belongs to the `baseUrl` host. There is no cross-provider fallback:
370
+ * any other host needs `apiKey` or `apiKeyEnv`. The value is never logged.
371
+ */
372
+ export declare function resolveKey(opts: Pick<ClientOptions, "apiKey" | "apiKeyEnv" | "baseUrl">): string;
373
+ /** The env var whose key may be sent to `baseUrl`'s host, or "" when none may. */
374
+ export declare function keyEnvForBaseUrl(baseUrl: string): string;
321
375
  export declare class Client {
322
376
  private readonly opts;
323
377
  /** Conversation provider for `ask()` — from opts.store, else in-memory. */
324
378
  private readonly store;
325
379
  /** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
326
380
  private readonly registry;
381
+ /** The frozen ToolTiers plan per toolkit (SPEC 8 Tool tiers). */
382
+ private tierPlans;
327
383
  constructor(opts: ClientOptions);
328
384
  /**
329
385
  * §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
@@ -434,6 +490,25 @@ export declare class Client {
434
490
  private llmFetch;
435
491
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
436
492
  private runTool;
493
+ private runToolDirect;
494
+ /** ToolTiers `call_tool {name, arguments}` -> the real call; undefined when tiers are off. */
495
+ private unwrapCall;
496
+ /** The RunResult.toolCalls entry: a `call_tool` call is recorded as the real tool with its
497
+ * inner arguments, the name hooks and stream events see (the transcript keeps the wrapper). */
498
+ private toolCallRecord;
499
+ /** The stream's tool_call event, naming the real tool for a `call_tool` call. */
500
+ private callEvent;
501
+ private resultEvent;
502
+ private plan;
503
+ /** The tools array for a request: core tools (sorted) then tool_search and call_tool. Without
504
+ * tiers it is every tool, as before. */
505
+ private requestTools;
506
+ /** The frozen tier plan for `toolkit` (built on first use): tool name -> tier. Undefined when
507
+ * `toolTiers` is not set. For a host UI or log. */
508
+ toolTierPlan(toolkit: Toolkit): Record<string, ToolTier> | undefined;
509
+ /** Forget `toolkit`'s plan so the next request rebuilds it - call at a new session start.
510
+ * Mid-session it breaks the cached prefix. */
511
+ resetToolTiers(toolkit: Toolkit): void;
437
512
  /** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
438
513
  private emitTool;
439
514
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */