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 CHANGED
@@ -12,7 +12,7 @@ unified as one tool set, driving any LLM.
12
12
  > loop, hooks, streaming, retries, memory — and nothing it doesn't.
13
13
 
14
14
  The JS/TypeScript port of [toolnexus](https://github.com/muthuishere/toolnexus) — the same
15
- library, byte-identical, also in **Python, Go, Java, C#, Elixir and Clojure**. Built on
15
+ library, byte-identical, also in **Python, Go, Java, C#, Elixir, Clojure and Rust**. Built on
16
16
  `@modelcontextprotocol/sdk` (the MCP SDK opencode uses).
17
17
 
18
18
  ## Install
@@ -28,11 +28,11 @@ Built-in tools are on by default, so an empty toolkit can already act:
28
28
  ```ts
29
29
  import { createToolkit, createClient } from "toolnexus"
30
30
 
31
- const tk = await createToolkit() // 10 built-in tools, on by default
31
+ const tk = await createToolkit({}) // 10 built-in tools, on by default
32
32
  const agent = createClient({
33
33
  baseUrl: "https://openrouter.ai/api/v1", // any OpenAI- or Anthropic-style endpoint
34
34
  style: "openai", // or "anthropic"
35
- model: "openai/gpt-4o-mini",
35
+ model: "openai/gpt-4o-mini", // key: OPENROUTER_API_KEY (matches the host)
36
36
  })
37
37
 
38
38
  const { text } = await agent.run("What files are in this folder?", { toolkit: tk })
@@ -49,6 +49,52 @@ const tk = await createToolkit({ mcp: "mcp.json", skills: ["skills"] })
49
49
  - `skills/` is a folder of `**/SKILL.md` files, loaded on demand through one `skill` tool.
50
50
  - Remote MCP `headers` values expand `${ENV_VAR}` at call time and are never logged.
51
51
 
52
+ ## API key by host (BREAKING in 0.22)
53
+
54
+ A key is only ever sent to the host it belongs to. `apiKey` wins; else `apiKeyEnv` (the NAME of
55
+ an env var); else only the env var of the `baseUrl` host, over https — `api.openai.com` →
56
+ `OPENAI_API_KEY`, `api.anthropic.com` → `ANTHROPIC_API_KEY`, `openrouter.ai` →
57
+ `OPENROUTER_API_KEY`. **A local server, a proxy or any other host needs `apiKey` or `apiKeyEnv`**
58
+ (before 0.22 the first provider key in the environment was sent to any `baseUrl`):
59
+
60
+ ```ts
61
+ createClient({ baseUrl: "http://localhost:11434/v1", style: "openai", model: "llama3", apiKey: "YOUR_KEY_HERE" })
62
+ createClient({ baseUrl: "https://my-proxy.example/v1", style: "openai", model: "m", apiKeyEnv: "MY_PROXY_KEY" })
63
+ ```
64
+
65
+ ## Prompt cache and tool tiers
66
+
67
+ ```ts
68
+ const agent = createClient({
69
+ baseUrl, style, model,
70
+ promptCache: { key: "session-42" }, // anthropic cache breakpoints / openai prompt_cache_key
71
+ toolTiers: { core: ["read", "grep"] }, // the rest load on demand via tool_search / call_tool
72
+ })
73
+ const res = await agent.run(prompt, { toolkit: tk })
74
+ res.usage.cacheReadTokens // cache hits; anthropic context = prompt + cacheRead + cacheWrite
75
+ agent.toolTierPlan(tk) // { read: "core", bash: "listed", … }, frozen per toolkit
76
+ agent.resetToolTiers(tk) // rebuild at session start
77
+ ```
78
+
79
+ `toolTiers` keeps the tools array identical on every request, so the cached prefix survives a
80
+ large or growing toolkit. `call_tool` is unwrapped before hooks, so permission gates see the real
81
+ tool. Runnable: `npm run example:prompt-cache` ([`examples/prompt_cache.ts`](examples/prompt_cache.ts)).
82
+
83
+ ## Host additions (0.22)
84
+
85
+ - `loadMcp(cfg, { onServer })` — each MCP server reported the moment it connects or fails;
86
+ `mcpSource.resourceTools()` adds `list_mcp_resources` / `read_mcp_resource`.
87
+ - `waitFor(request, { signal })` — a pending question ends with its turn.
88
+ - Tool `hints` (`readOnly` / `destructive` / `openWorld`) on every builtin and MCP tool, passed to
89
+ `hooks.beforeTool` — allow, deny with an error result, or ask via `pending`.
90
+ - Opt-in builtins: `builtins: { background: new BackgroundShells(), webSearch, notebookEdit: true }`
91
+ (`bash_output` / `kill_shell`, `websearch`, `notebook_edit`); `grep` takes `context` / `before` /
92
+ `after`; `read` / `grep` report truncation in metadata.
93
+ - `agents.compactor({ onCompact })`, `agent.asToolWith(ctx => ({ hooks, waitFor }))`, runtime
94
+ `waitFor`, `ctx.callId` on tools, `listModels(opts)`.
95
+
96
+ ACP host hooks (permission choice, update stream, cancel, streaming) are Go-only for now.
97
+
52
98
  ## Simple judgments
53
99
 
54
100
  A thin layer over any `Classifier` (SPEC.md §8B); the wire request is byte-identical to
@@ -102,4 +148,4 @@ Everything else — the full surface, with runnable examples — lives on the do
102
148
  | **API reference** | [JavaScript](https://muthuishere.github.io/toolnexus/api/javascript/) |
103
149
  | **Cookbook** | [Zero to agent](https://muthuishere.github.io/toolnexus/cookbook/zero-to-agent/) · [MCP servers](https://muthuishere.github.io/toolnexus/cookbook/mcp-servers/) · [Agent skills](https://muthuishere.github.io/toolnexus/cookbook/agent-skills/) · [Judge](https://muthuishere.github.io/toolnexus/cookbook/judge/) |
104
150
 
105
- Contract across all seven ports: [`SPEC.md`](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md).
151
+ Contract across all eight ports: [`SPEC.md`](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md).
@@ -8,7 +8,7 @@
8
8
  * Everything compiles down to the six host verbs in runtime.ts.
9
9
  */
10
10
  import type { PromptInput } from "../content.js";
11
- import type { Answer, Request, Tool } from "../types.js";
11
+ import type { Answer, Request, Tool, ToolContext } from "../types.js";
12
12
  import { AgentRuntime, type AgentDef, type Budget, type RuntimeOptions, type TaskResult } from "./runtime.js";
13
13
  import { Loop, type Completion, type Guardrail } from "./loop.js";
14
14
  import type { ClientOptions } from "../client.js";
@@ -93,6 +93,8 @@ export declare class Agent {
93
93
  * case the tree stays parked and `result.runtime.resume(answer)` continues it.
94
94
  */
95
95
  run(prompt: PromptInput, opts?: AgentRunOptions): Promise<AgentRunResult>;
96
+ /** `run` that interrupts the child when `signal` aborts. */
97
+ private runSignal;
96
98
  /**
97
99
  * The bridge: an Agent IS a Tool. Drop the result into the classic API's
98
100
  * `extraTools`; the agent's own loop runs in isolation and the classic run
@@ -100,6 +102,15 @@ export declare class Agent {
100
102
  * `{agent, turns, totalTokens}`.
101
103
  */
102
104
  asTool(opts?: AgentRunOptions): Tool;
105
+ /**
106
+ * `asTool` with options built per call from the calling tool's context (SPEC §4C), so the
107
+ * child runs under the parent turn's gates: set `hooks` (the same beforeTool permission gate —
108
+ * it also sees the child's tool calls), `waitFor` (the same ask bridge) and `onMetric`. A
109
+ * `hooks` on the agent's own spec replaces `opts.hooks` for that agent, so leave it unset when
110
+ * the gate must hold. When the calling turn is cancelled (`ctx.signal`), the child is
111
+ * interrupted and the tool returns a `cancelled:` error result.
112
+ */
113
+ asToolWith(per: (ctx?: ToolContext) => AgentRunOptions): Tool;
103
114
  }
104
115
  /** The one new noun: define an agent. */
105
116
  export declare function agent(name: string, spec: AgentSpec): Agent;
@@ -73,6 +73,32 @@ export class Agent {
73
73
  await runtime.close(runtime.root);
74
74
  return { ...r, runtime };
75
75
  }
76
+ /** `run` that interrupts the child when `signal` aborts. */
77
+ async runSignal(prompt, opts, signal) {
78
+ if (!signal)
79
+ return this.run(prompt, opts);
80
+ const reason = () => (signal.reason instanceof Error ? signal.reason.message : String(signal.reason ?? "aborted"));
81
+ if (signal.aborted)
82
+ return { text: reason(), isError: true, turns: 0, totalTokens: 0, cancelled: true };
83
+ const runtime = this.createRuntime(opts);
84
+ const h = runtime.spawn(runtime.root, this.name);
85
+ if (isVerbError(h))
86
+ return { text: h.error, isError: true, turns: 0, totalTokens: 0 };
87
+ const onAbort = () => void runtime.close(runtime.root, { force: true }).catch(() => { });
88
+ signal.addEventListener("abort", onAbort, { once: true });
89
+ try {
90
+ const woke = runtime.wake(h, prompt);
91
+ const r = await runtime.wait(h);
92
+ if (signal.aborted)
93
+ return { text: reason(), isError: true, turns: r.turns, totalTokens: r.totalTokens, cancelled: true };
94
+ if (woke.ok && r.status !== "pending")
95
+ await runtime.close(runtime.root);
96
+ return r;
97
+ }
98
+ finally {
99
+ signal.removeEventListener("abort", onAbort);
100
+ }
101
+ }
76
102
  /**
77
103
  * The bridge: an Agent IS a Tool. Drop the result into the classic API's
78
104
  * `extraTools`; the agent's own loop runs in isolation and the classic run
@@ -80,6 +106,17 @@ export class Agent {
80
106
  * `{agent, turns, totalTokens}`.
81
107
  */
82
108
  asTool(opts = {}) {
109
+ return this.asToolWith(() => opts);
110
+ }
111
+ /**
112
+ * `asTool` with options built per call from the calling tool's context (SPEC §4C), so the
113
+ * child runs under the parent turn's gates: set `hooks` (the same beforeTool permission gate —
114
+ * it also sees the child's tool calls), `waitFor` (the same ask bridge) and `onMetric`. A
115
+ * `hooks` on the agent's own spec replaces `opts.hooks` for that agent, so leave it unset when
116
+ * the gate must hold. When the calling turn is cancelled (`ctx.signal`), the child is
117
+ * interrupted and the tool returns a `cancelled:` error result.
118
+ */
119
+ asToolWith(per) {
83
120
  return defineTool({
84
121
  name: this.name,
85
122
  description: this.spec.does,
@@ -88,13 +125,12 @@ export class Agent {
88
125
  properties: { prompt: { type: "string", description: "The task for this agent" } },
89
126
  required: ["prompt"],
90
127
  },
91
- run: async (args) => {
92
- const r = await this.run(String(args.prompt), opts);
93
- return {
94
- output: r.text,
95
- isError: r.isError,
96
- metadata: { agent: this.name, turns: r.turns, totalTokens: r.totalTokens },
97
- };
128
+ run: async (args, ctx) => {
129
+ const r = await this.runSignal(String(args.prompt), per(ctx), ctx?.signal);
130
+ const metadata = { agent: this.name, turns: r.turns, totalTokens: r.totalTokens };
131
+ if (r.cancelled)
132
+ return { output: `cancelled: ${r.text}`, isError: true, metadata };
133
+ return { output: r.text, isError: r.isError, metadata };
98
134
  },
99
135
  });
100
136
  }
@@ -32,6 +32,25 @@ export interface CompactorOptions {
32
32
  * default. Composes with the memory builtin.
33
33
  */
34
34
  flushToMemory?: boolean;
35
+ /**
36
+ * Called after each compaction with what changed, so a host can tell its UI or log (SPEC §4C).
37
+ * Never called on a no-op. Awaited before the next model call.
38
+ */
39
+ onCompact?: (info: CompactInfo) => void | Promise<void>;
40
+ }
41
+ /** One compaction, for {@link CompactorOptions.onCompact}. Token counts come from `countTokens`. */
42
+ export interface CompactInfo {
43
+ tokensBefore: number;
44
+ tokensAfter: number;
45
+ messagesBefore: number;
46
+ messagesAfter: number;
47
+ /** How many messages were folded into the summary. */
48
+ summarized: number;
49
+ /** The text `summarize` returned (without the header line). */
50
+ summary: string;
51
+ /** The summarized messages, verbatim, so a host can persist them (the loop saves only the
52
+ * compacted transcript). */
53
+ folded: Message[];
35
54
  }
36
55
  /** The event a `beforeLLM` hook receives (§8). */
37
56
  export interface BeforeLLMEvent {
@@ -89,7 +89,8 @@ export function compactor(opts) {
89
89
  const keepTail = opts.keepTail ?? Math.floor(opts.maxTokens / 2);
90
90
  return async (ev) => {
91
91
  const msgs = ev.messages;
92
- if (count(msgs) <= opts.maxTokens)
92
+ const before = count(msgs);
93
+ if (before <= opts.maxTokens)
93
94
  return undefined; // under budget → no-op
94
95
  // Preserve a leading system prompt verbatim (identity / soul / skills — never summarized).
95
96
  const headEnd = msgs[0]?.role === "system" ? 1 : 0;
@@ -97,16 +98,37 @@ export function compactor(opts) {
97
98
  const split = findTailStart(msgs, headEnd, keepTail, count);
98
99
  if (split <= headEnd)
99
100
  return undefined; // nothing safely compactible
100
- const older = msgs.slice(headEnd, split);
101
+ let older = msgs.slice(headEnd, split);
102
+ const tail = msgs.slice(split);
103
+ // The latest user prompt is never summarized away. When the only clean boundary is the first
104
+ // message after the system prompt (one prompt followed by a long agentic run), keep that
105
+ // prompt verbatim and summarize the work after it: [system, prompt, summary].
106
+ let pinned = [];
107
+ if (split === msgs.length && headEnd < msgs.length && isBoundary(msgs[headEnd])) {
108
+ pinned = msgs.slice(headEnd, headEnd + 1);
109
+ older = msgs.slice(headEnd + 1);
110
+ }
101
111
  if (older.length === 0)
102
112
  return undefined;
103
- const tail = msgs.slice(split);
104
113
  const flushNote = opts.flushToMemory
105
114
  ? [{ role: "system", content: FLUSH_TO_MEMORY_REMINDER }]
106
115
  : [];
107
116
  const summary = await opts.summarize(older);
108
117
  const summaryMsg = { role: "system", content: `${SUMMARY_PREFIX}${summary}` };
109
- return { messages: [...system, summaryMsg, ...flushNote, ...tail] };
118
+ const out = [...system, ...pinned, summaryMsg, ...flushNote, ...tail];
119
+ const after = count(out);
120
+ if (after >= before)
121
+ return undefined; // compaction would not shrink the transcript → no-op
122
+ await opts.onCompact?.({
123
+ tokensBefore: before,
124
+ tokensAfter: after,
125
+ messagesBefore: msgs.length,
126
+ messagesAfter: out.length,
127
+ summarized: older.length,
128
+ summary,
129
+ folded: older,
130
+ });
131
+ return { messages: out };
110
132
  };
111
133
  }
112
134
  /**
@@ -293,6 +293,11 @@ export interface RuntimeOptions {
293
293
  /** §8 observability sink applied to EVERY agent's client, unless that agent's def
294
294
  * sets its own `onMetric`. Resolves independently of `hooks`. Forwarded verbatim. */
295
295
  onMetric?: (ev: MetricEvent) => void;
296
+ /** The runtime-level §10 interpreter (SPEC §4C): answers a suspension when no agent on the
297
+ * handle's chain has its own `waitFor`. Absent ⇒ unchanged (the suspension goes durable). A
298
+ * host running an agent as a tool (`asToolWith`) sets it so the child's asks reach the same
299
+ * bridge as the parent turn. */
300
+ waitFor?: (request: Request) => Promise<Answer>;
296
301
  }
297
302
  /**
298
303
  * The §7D agent runtime: six host verbs over a tree of Handles, three loud
@@ -595,12 +595,23 @@ export class AgentRuntime {
595
595
  * escalation traced (nearest interpreter wins; the Request's `data.path` names
596
596
  * the suspended subtree that is asking). */
597
597
  wrapWaitFor(h) {
598
- const waitFor = h.def.waitFor;
598
+ const own = h.def.waitFor;
599
+ let waitFor = own;
600
+ if (!waitFor && this.opts.waitFor) {
601
+ // The runtime-level bridge answers only when NO agent on the chain has its own waitFor;
602
+ // otherwise the ask escalates to that nearest interpreter, as before.
603
+ let a = h.parent;
604
+ while (a && !a.def.waitFor)
605
+ a = a.parent;
606
+ if (!a)
607
+ waitFor = this.opts.waitFor;
608
+ }
599
609
  if (!waitFor)
600
610
  return undefined;
611
+ const answerer = own ? h.id : this.root.id;
601
612
  return async (req) => {
602
613
  const path = Array.isArray(req.data?.path) ? req.data.path.join("/") : h.id;
603
- this.t(`${path}: escalate → ${h.id} answers ("nearest interpreter")`);
614
+ this.t(`${path}: escalate → ${answerer} answers ("nearest interpreter")`);
604
615
  return waitFor(req);
605
616
  };
606
617
  }
package/dist/builtin.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Tool } from "./types.js";
2
+ import { BackgroundShells, type WebSearchFunc } from "./builtin_extras.js";
2
3
  /**
3
4
  * Config for the single global builtin toggle (mirrors MCP isEnabled precedence),
4
5
  * plus the host boundary: which interpreter `bash` runs, what a relative path
@@ -28,6 +29,21 @@ export type BuiltinsConfig = boolean | {
28
29
  * builtins, NOT a sandbox — `bash` still reaches the whole filesystem.
29
30
  */
30
31
  confineToBaseDir?: boolean;
32
+ /**
33
+ * POSIX wait in ms between SIGTERM and SIGKILL of a timed-out or cancelled
34
+ * `bash` job's process group. Absent or 0 ⇒ 2000. No effect on Windows,
35
+ * which has no graceful stop for a console process (SPEC §4A).
36
+ */
37
+ killGraceMs?: number;
38
+ /**
39
+ * Opt-in (SPEC §4C): `bash` takes `run_in_background` and the `bash_output` / `kill_shell`
40
+ * tools exist. The host owns the registry: call `closeAll()` on shutdown.
41
+ */
42
+ background?: BackgroundShells;
43
+ /** Opt-in (SPEC §4C): adds a `websearch` tool backed by this provider. The library ships none. */
44
+ webSearch?: WebSearchFunc;
45
+ /** Opt-in (SPEC §4C): adds the `notebook_edit` tool for .ipynb cells. */
46
+ notebookEdit?: boolean;
31
47
  };
32
48
  /**
33
49
  * Whether the builtin source is on. Default ON. Same precedence as MCP: