toolnexus 0.1.2 → 0.2.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
@@ -77,7 +77,7 @@ Don't want the host loop? Use the schema adapters and execute calls yourself:
77
77
 
78
78
  ```ts
79
79
  const tools = tk.toOpenAI() // or tk.toAnthropic() / tk.toGemini()
80
- const system = tk.skillsPrompt() // markdown skills catalog for your system prompt
80
+ const system = tk.skillsPrompt() // skills catalog for your system prompt (opens with a preamble telling the model to use the skill tool)
81
81
  // when the model returns a tool call { name, arguments }:
82
82
  const res = await tk.execute(name, args) // -> { output, isError, metadata }
83
83
  ```
@@ -94,6 +94,76 @@ const res = await tk.execute(name, args) // -> { output, isError, metadata }
94
94
  All four appear as one uniform `Tool` in `tk.tools()`. Each is à la carte — use just `loadMcp(config)`
95
95
  or `loadSkills(dir)` if that's all you need.
96
96
 
97
+ ## Built-in tools
98
+
99
+ A fifth source ships **10 built-in tools** — `bash`, `read`, `write`, `edit`, `grep`, `glob`,
100
+ `webfetch`, `question`, `apply_patch`, `todowrite` (names + input schemas match
101
+ opencode) — so an agent can act with zero wiring. They appear in the tool schema
102
+ (`toOpenAI()`/`toAnthropic()`/`toGemini()`), like MCP tools — not the system prompt.
103
+
104
+ **On by default.** One global toggle turns the whole source off, or a per-tool `tools` map
105
+ disables individual builtins on the all-on baseline:
106
+
107
+ ```ts
108
+ const tk = await createToolkit({ mcpConfig: "./mcp.json", builtins: false })
109
+ // also accepts { disabled: true } or { enabled: false }
110
+
111
+ // per-tool: drop bash, keep the other nine (unknown names ignored; whole-source-off still wins)
112
+ const tk2 = await createToolkit({ mcpConfig: "./mcp.json", builtins: { tools: { bash: false } } })
113
+ ```
114
+
115
+ `bash`/`write`/`edit`/`apply_patch` run commands and mutate the filesystem — the toggle is the
116
+ off-switch for locked-down hosts.
117
+
118
+ ## A2A agents (agent-to-agent)
119
+
120
+ Call **remote A2A agents** (each of their skills becomes a tool) and serve your own toolkit as an
121
+ agent other A2A peers can call. A genuine, minimal subset of real A2A (JSON-RPC 2.0; Agent Card at
122
+ `/.well-known/agent-card.json`; `SendMessage` → poll `GetTask`). No streaming / push / auth in v1.
123
+
124
+ **Outbound — call a remote agent.** Each advertised skill becomes a tool named `<agent>_<skill>`
125
+ (`source: "a2a"`):
126
+
127
+ ```ts
128
+ import { createToolkit, agent } from "toolnexus"
129
+
130
+ const tk = await createToolkit({
131
+ agents: [agent({ card: "https://researcher.example.com/.well-known/agent-card.json" })],
132
+ })
133
+
134
+ // or add one at runtime (an Agent descriptor or a bare card URL):
135
+ await tk.addAgent("https://writer.example.com/.well-known/agent-card.json")
136
+ ```
137
+
138
+ `agent({ card, headers?, timeout?, pollEvery? })` — `headers` support `${ENV}` expansion (never
139
+ logged); `timeout` defaults to 300000ms, `pollEvery` to 1000ms. A config file can also carry an
140
+ `agents` block (mirrors `mcpServers`). A failing agent is isolated — logged, contributes no tools,
141
+ never fatal.
142
+
143
+ **Inbound — serve your toolkit as an agent.** Opt in with the `a2a` profile; the Agent Card is
144
+ built from your **SKILL.md skills** (never raw tools):
145
+
146
+ ```ts
147
+ import { createClient } from "toolnexus"
148
+
149
+ const agent = createClient({ baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" })
150
+
151
+ const handle = await tk.serve("127.0.0.1:0", {
152
+ client: agent,
153
+ a2a: {
154
+ name: "research-agent",
155
+ description: "Answers research questions.",
156
+ // skills: ["hello-world"], // subset of skills to advertise; omit ⇒ all
157
+ store: "memory", // "memory" (default) | "file:<dir>" | a custom TaskStore
158
+ },
159
+ })
160
+ console.log(handle.url) // GET /.well-known/agent-card.json ; POST / (SendMessage / GetTask)
161
+ await handle.stop()
162
+ ```
163
+
164
+ `serve(addr, { client, a2a?, onTask? })` fulfils each inbound `SendMessage` task via `client.run`.
165
+ Task persistence is a pluggable `TaskStore` — in-memory default, `"file:<dir>"`, or your own.
166
+
97
167
  ## API
98
168
 
99
169
  | Member | Description |
package/dist/a2a.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { type Tool } from "./types.js";
2
+ export type A2ATaskState = "submitted" | "working" | "completed" | "failed" | "canceled" | string;
3
+ export interface A2APart {
4
+ kind: string;
5
+ text?: string;
6
+ [k: string]: unknown;
7
+ }
8
+ export interface A2AMessage {
9
+ role: string;
10
+ messageId?: string;
11
+ parts?: A2APart[];
12
+ [k: string]: unknown;
13
+ }
14
+ export interface A2AArtifact {
15
+ parts?: A2APart[];
16
+ [k: string]: unknown;
17
+ }
18
+ export interface A2ATaskStatus {
19
+ state: A2ATaskState;
20
+ message?: A2AMessage;
21
+ [k: string]: unknown;
22
+ }
23
+ export interface A2ATask {
24
+ id: string;
25
+ status: A2ATaskStatus;
26
+ artifacts?: A2AArtifact[];
27
+ history?: A2AMessage[];
28
+ [k: string]: unknown;
29
+ }
30
+ export interface A2ASkill {
31
+ id?: string;
32
+ name?: string;
33
+ description?: string;
34
+ tags?: string[];
35
+ examples?: string[];
36
+ [k: string]: unknown;
37
+ }
38
+ export interface AgentCard {
39
+ name: string;
40
+ description?: string;
41
+ version?: string;
42
+ protocolVersion?: string;
43
+ capabilities?: {
44
+ streaming?: boolean;
45
+ pushNotifications?: boolean;
46
+ [k: string]: unknown;
47
+ };
48
+ defaultInputModes?: string[];
49
+ defaultOutputModes?: string[];
50
+ skills?: A2ASkill[];
51
+ /** The JSON-RPC endpoint the agent listens on. */
52
+ url?: string;
53
+ [k: string]: unknown;
54
+ }
55
+ export interface Agent {
56
+ /** URL of the Agent Card (`/.well-known/agent-card.json`). */
57
+ card: string;
58
+ /** `${ENV_VAR}` header values are expanded at call time and never logged. */
59
+ headers?: Record<string, string>;
60
+ /** Overall poll budget in ms (default 300000). */
61
+ timeout?: number;
62
+ /** Interval between `GetTask` polls in ms (default 1000). */
63
+ pollEvery?: number;
64
+ }
65
+ /** Build an Agent descriptor pointing at a remote Agent Card URL. */
66
+ export declare function agent(opts: Agent): Agent;
67
+ export interface AgentConfig {
68
+ card: string;
69
+ headers?: Record<string, string>;
70
+ timeout?: number;
71
+ pollEvery?: number;
72
+ enabled?: boolean;
73
+ disabled?: boolean;
74
+ }
75
+ export type AgentsConfig = Record<string, AgentConfig>;
76
+ /**
77
+ * Parse an `agents` block (Record<name, AgentConfig>) into Agent descriptors,
78
+ * skipping disabled entries. The config key is just an identifier — a tool's
79
+ * name prefix comes from the fetched card's `name`, not the key.
80
+ */
81
+ export declare function parseAgentsConfig(block: AgentsConfig | undefined): Agent[];
82
+ /**
83
+ * Resolve an Agent to its tools: fetch the card, read `skills[]`, and produce
84
+ * one Tool per skill. The agent name prefix comes from the card's `name`.
85
+ */
86
+ export declare function agentTools(ag: Agent): Promise<Tool[]>;
package/dist/a2a.js ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * A2A (agent-to-agent) — OUTBOUND: call remote A2A agents.
3
+ *
4
+ * A remote agent publishes an Agent Card at `/.well-known/agent-card.json`
5
+ * describing its skills. Each advertised skill becomes a uniform `Tool`
6
+ * (source:"a2a") whose `execute` performs one JSON-RPC `SendMessage`
7
+ * (non-blocking) to get a Task id, then polls `GetTask` until the Task reaches
8
+ * a terminal state — mapping the Task's artifact/message text to a ToolResult.
9
+ *
10
+ * This is a genuine subset of real A2A (JSON-RPC 2.0 over one HTTP POST
11
+ * endpoint), so a toolnexus agent interoperates with real A2A peers. See
12
+ * ../../SPEC.md and openspec/changes/add-a2a-agents. Reuses the `${ENV}` header
13
+ * expansion from mcp.ts; secrets live in the environment and are never logged.
14
+ */
15
+ import { randomUUID } from "node:crypto";
16
+ import { sanitize } from "./types.js";
17
+ import { expandEnvHeaders } from "./mcp.js";
18
+ const DEFAULT_TIMEOUT = 300_000;
19
+ const DEFAULT_POLL_EVERY = 1_000;
20
+ /** Terminal A2A task states — polling stops once one of these is reached. */
21
+ const TERMINAL = new Set(["completed", "failed", "canceled"]);
22
+ /** Build an Agent descriptor pointing at a remote Agent Card URL. */
23
+ export function agent(opts) {
24
+ return { card: opts.card, headers: opts.headers, timeout: opts.timeout, pollEvery: opts.pollEvery };
25
+ }
26
+ /** Normalize either an Agent descriptor or a bare card URL into an Agent. */
27
+ function toAgent(agentOrCardUrl, opts) {
28
+ if (typeof agentOrCardUrl === "string")
29
+ return agent({ card: agentOrCardUrl, ...opts });
30
+ return agentOrCardUrl;
31
+ }
32
+ /** MCP `isEnabled` precedence: `disabled:true` wins, then `enabled:false`. */
33
+ function isEnabled(cfg) {
34
+ if (cfg.disabled === true)
35
+ return false;
36
+ if (cfg.enabled === false)
37
+ return false;
38
+ return true;
39
+ }
40
+ /**
41
+ * Parse an `agents` block (Record<name, AgentConfig>) into Agent descriptors,
42
+ * skipping disabled entries. The config key is just an identifier — a tool's
43
+ * name prefix comes from the fetched card's `name`, not the key.
44
+ */
45
+ export function parseAgentsConfig(block) {
46
+ if (!block)
47
+ return [];
48
+ const out = [];
49
+ for (const cfg of Object.values(block)) {
50
+ if (!cfg || typeof cfg.card !== "string")
51
+ continue;
52
+ if (!isEnabled(cfg))
53
+ continue;
54
+ out.push(agent({ card: cfg.card, headers: cfg.headers, timeout: cfg.timeout, pollEvery: cfg.pollEvery }));
55
+ }
56
+ return out;
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // JSON-RPC transport (single POST) + card fetch. Reuses httpTool's ${ENV}
60
+ // header expansion, timeout handling, and non-2xx → error mapping.
61
+ // ---------------------------------------------------------------------------
62
+ /** POST one JSON-RPC 2.0 request and return `result` (throws on error/non-2xx). */
63
+ async function jsonRpc(endpoint, method, params, headers, timeout, signal) {
64
+ const controller = new AbortController();
65
+ const timer = setTimeout(() => controller.abort(), timeout);
66
+ if (signal)
67
+ signal.addEventListener("abort", () => controller.abort(), { once: true });
68
+ try {
69
+ const res = await fetch(endpoint, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json", ...headers },
72
+ body: JSON.stringify({ jsonrpc: "2.0", id: randomUUID(), method, params }),
73
+ signal: controller.signal,
74
+ });
75
+ const text = await res.text();
76
+ if (!res.ok)
77
+ throw new Error(`HTTP ${res.status}: ${text}`);
78
+ const payload = safeParse(text);
79
+ if (payload && payload.error) {
80
+ throw new Error(payload.error.message ?? `JSON-RPC error ${payload.error.code ?? ""}`.trim());
81
+ }
82
+ return payload?.result;
83
+ }
84
+ finally {
85
+ clearTimeout(timer);
86
+ }
87
+ }
88
+ /** Fetch and parse the Agent Card at its URL (GET). */
89
+ async function fetchCard(cardUrl, headers, timeout, signal) {
90
+ const controller = new AbortController();
91
+ const timer = setTimeout(() => controller.abort(), timeout);
92
+ if (signal)
93
+ signal.addEventListener("abort", () => controller.abort(), { once: true });
94
+ try {
95
+ const res = await fetch(cardUrl, { method: "GET", headers, signal: controller.signal });
96
+ const text = await res.text();
97
+ if (!res.ok)
98
+ throw new Error(`HTTP ${res.status}: ${text}`);
99
+ return JSON.parse(text);
100
+ }
101
+ finally {
102
+ clearTimeout(timer);
103
+ }
104
+ }
105
+ function safeParse(text) {
106
+ try {
107
+ return JSON.parse(text);
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ }
113
+ /** Abortable sleep — resolves early (without error) if `signal` fires. */
114
+ function sleep(ms, signal) {
115
+ return new Promise((resolve) => {
116
+ if (signal?.aborted)
117
+ return resolve();
118
+ const onAbort = () => {
119
+ clearTimeout(timer);
120
+ resolve();
121
+ };
122
+ const timer = setTimeout(() => {
123
+ signal?.removeEventListener("abort", onAbort);
124
+ resolve();
125
+ }, ms);
126
+ signal?.addEventListener("abort", onAbort, { once: true });
127
+ });
128
+ }
129
+ /** Concatenate a Task's text output: artifact text parts, else last agent message. */
130
+ function extractOutput(task) {
131
+ const parts = [];
132
+ for (const artifact of task.artifacts ?? []) {
133
+ for (const part of artifact.parts ?? []) {
134
+ if (part.kind === "text" && typeof part.text === "string")
135
+ parts.push(part.text);
136
+ }
137
+ }
138
+ if (parts.length > 0)
139
+ return parts.join("\n");
140
+ // Fallback: the last agent message in history.
141
+ const history = task.history ?? [];
142
+ for (let i = history.length - 1; i >= 0; i--) {
143
+ const msg = history[i];
144
+ if (msg.role !== "agent")
145
+ continue;
146
+ const text = (msg.parts ?? [])
147
+ .filter((p) => p.kind === "text" && typeof p.text === "string")
148
+ .map((p) => p.text)
149
+ .join("\n");
150
+ if (text)
151
+ return text;
152
+ }
153
+ return "";
154
+ }
155
+ /** Text of a Task's status.message (used for failed/canceled error output). */
156
+ function statusMessageText(task) {
157
+ const msg = task.status?.message;
158
+ if (!msg)
159
+ return "";
160
+ return (msg.parts ?? [])
161
+ .filter((p) => p.kind === "text" && typeof p.text === "string")
162
+ .map((p) => p.text)
163
+ .join("\n");
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // Skill → Tool.
167
+ // ---------------------------------------------------------------------------
168
+ const TASK_SCHEMA = {
169
+ type: "object",
170
+ properties: { task: { type: "string", description: "The task to send to the agent, in natural language." } },
171
+ required: ["task"],
172
+ additionalProperties: false,
173
+ };
174
+ /** Build one `source:"a2a"` Tool for a single advertised skill of an agent. */
175
+ function skillTool(agentName, endpoint, skill, ag) {
176
+ const skillId = skill.id ?? skill.name ?? "";
177
+ const name = sanitize(agentName) + "_" + sanitize(skillId);
178
+ const timeout = ag.timeout ?? DEFAULT_TIMEOUT;
179
+ const pollEvery = ag.pollEvery ?? DEFAULT_POLL_EVERY;
180
+ return {
181
+ name,
182
+ description: skill.description ?? skill.name ?? skillId,
183
+ inputSchema: TASK_SCHEMA,
184
+ source: "a2a",
185
+ async execute(args, ctx) {
186
+ const start = Date.now();
187
+ const budget = ctx?.timeout ?? timeout;
188
+ const headers = expandEnvHeaders(ag.headers) ?? {};
189
+ const taskText = String(args?.task ?? "");
190
+ let polls = 0;
191
+ let taskId = "";
192
+ let state = "submitted";
193
+ const meta = () => ({ agent: agentName, taskId, state, polls, ms: Date.now() - start });
194
+ try {
195
+ // 1. SendMessage (non-blocking) → a submitted Task.
196
+ let task = (await jsonRpc(endpoint, "SendMessage", {
197
+ message: { role: "user", messageId: randomUUID(), parts: [{ kind: "text", text: taskText }] },
198
+ configuration: { blocking: false },
199
+ }, headers, budget, ctx?.signal));
200
+ taskId = task?.id ?? "";
201
+ state = task?.status?.state ?? "submitted";
202
+ // 2. Poll GetTask until terminal / timeout / cancel.
203
+ while (!TERMINAL.has(state)) {
204
+ if (ctx?.signal?.aborted) {
205
+ state = "canceled";
206
+ return { output: `A2A task ${taskId} canceled`, isError: true, metadata: meta() };
207
+ }
208
+ if (Date.now() - start >= budget) {
209
+ return {
210
+ output: `A2A task ${taskId} timed out after ${budget}ms (state=${state})`,
211
+ isError: true,
212
+ metadata: meta(),
213
+ };
214
+ }
215
+ await sleep(pollEvery, ctx?.signal);
216
+ // Cancelled during the wait → stop before another GetTask.
217
+ if (ctx?.signal?.aborted) {
218
+ state = "canceled";
219
+ return { output: `A2A task ${taskId} canceled`, isError: true, metadata: meta() };
220
+ }
221
+ task = (await jsonRpc(endpoint, "GetTask", { id: taskId }, headers, budget, ctx?.signal));
222
+ polls++;
223
+ state = task?.status?.state ?? state;
224
+ }
225
+ // 3. Map the terminal Task → ToolResult.
226
+ if (state === "completed") {
227
+ return { output: extractOutput(task), isError: false, metadata: meta() };
228
+ }
229
+ const detail = statusMessageText(task);
230
+ return {
231
+ output: `A2A task ${taskId} ${state}${detail ? `: ${detail}` : ""}`,
232
+ isError: true,
233
+ metadata: meta(),
234
+ };
235
+ }
236
+ catch (e) {
237
+ return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: meta() };
238
+ }
239
+ },
240
+ };
241
+ }
242
+ /**
243
+ * Resolve an Agent to its tools: fetch the card, read `skills[]`, and produce
244
+ * one Tool per skill. The agent name prefix comes from the card's `name`.
245
+ */
246
+ export async function agentTools(ag) {
247
+ const timeout = ag.timeout ?? DEFAULT_TIMEOUT;
248
+ const headers = expandEnvHeaders(ag.headers) ?? {};
249
+ const card = await fetchCard(ag.card, headers, timeout);
250
+ const agentName = card.name ?? "agent";
251
+ // The card's `url` is the JSON-RPC endpoint; fall back to the card origin.
252
+ const endpoint = card.url ?? new URL(ag.card).origin;
253
+ return (card.skills ?? []).map((skill) => skillTool(agentName, endpoint, skill, ag));
254
+ }
@@ -0,0 +1,25 @@
1
+ import type { Tool } from "./types.js";
2
+ /** Config for the single global builtin toggle (mirrors MCP isEnabled precedence). */
3
+ export type BuiltinsConfig = boolean | {
4
+ enabled?: boolean;
5
+ disabled?: boolean;
6
+ tools?: Record<string, boolean>;
7
+ };
8
+ /**
9
+ * Whether the builtin source is on. Default ON. Same precedence as MCP:
10
+ * `disabled:true` wins, else `enabled:false` disables, otherwise enabled.
11
+ */
12
+ export declare function builtinsEnabled(cfg: BuiltinsConfig | undefined): boolean;
13
+ /**
14
+ * Resolve the active builtin tools for a config. Whole-source-off wins and
15
+ * returns `[]`. Otherwise all ten are on; a `tools` name→bool map drops any
16
+ * tool mapped to `false` (all-on baseline; `true`/absent stay on; unknown names
17
+ * are ignored). SPEC §4A.
18
+ */
19
+ export declare function selectBuiltins(cfg: BuiltinsConfig | undefined): Tool[];
20
+ /**
21
+ * Build the ten built-in tools (each source:"builtin"). The order is fixed
22
+ * for parity: bash, read, write, edit, grep, glob, webfetch, question,
23
+ * apply_patch, todowrite.
24
+ */
25
+ export declare function createBuiltinTools(): Tool[];