toolnexus 0.1.1 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Muthukumaran Navaneethakrishnan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,8 +1,19 @@
1
- # toolnexus (JavaScript / TypeScript)
1
+ # toolnexus
2
2
 
3
- Dynamic MCP servers + agent skills as a uniform tool list for any LLM. Built on
4
- `@modelcontextprotocol/sdk` (the same MCP SDK opencode uses). See the root
5
- [SPEC.md](../SPEC.md) for the language-independent contract.
3
+ [![npm](https://img.shields.io/npm/v/toolnexus?logo=npm&label=npm)](https://www.npmjs.com/package/toolnexus)
4
+ [![license](https://img.shields.io/badge/license-MIT-green)](https://github.com/muthuishere/toolnexus/blob/main/LICENSE)
5
+
6
+ **Build an agent in a few lines.** Point at an `mcp.json` and a `skills/` folder, call `run()`,
7
+ and you have a working agent — MCP servers, agent skills, your own functions, and HTTP endpoints
8
+ unified as one tool set, driving any LLM.
9
+
10
+ > **Right-sized.** Not a framework (no builders, advisors, runnables, config), not a toy that
11
+ > falls over the moment you need streaming or a retry. Everything a real agent needs — the loop,
12
+ > hooks, streaming, retries, memory — and nothing it doesn't.
13
+
14
+ The JS/TypeScript port of [toolnexus](https://github.com/muthuishere/toolnexus) — the same
15
+ library, byte-identical, also in **Python, Go, Java, and C#**. Built on
16
+ `@modelcontextprotocol/sdk` (the MCP SDK opencode uses).
6
17
 
7
18
  ## Install
8
19
 
@@ -10,107 +21,166 @@ Dynamic MCP servers + agent skills as a uniform tool list for any LLM. Built on
10
21
  npm install toolnexus
11
22
  ```
12
23
 
13
- ## Use
24
+ ## An agent in 3 steps
14
25
 
15
26
  ```ts
16
- import { createToolkit } from "toolnexus"
27
+ import { createToolkit, createClient } from "toolnexus"
17
28
 
18
- const tk = await createToolkit({
19
- mcpConfig: "./mcp.json", // path or parsed object; key may be mcpServers/servers/mcp
20
- skillsDir: "./skills", // folder of <name>/SKILL.md (or an array of folders)
21
- // extraTools: [...] // your own custom tools, optional
29
+ // 1. tools from an mcp.json + a skills/ folder
30
+ const tk = await createToolkit({ mcpConfig: "./mcp.json", skillsDir: "./skills" })
31
+
32
+ // 2. point at any OpenAI- or Anthropic-style endpoint
33
+ const agent = createClient({
34
+ baseUrl: "https://openrouter.ai/api/v1",
35
+ style: "openai", // or "anthropic"
36
+ model: "openai/gpt-4o-mini",
22
37
  })
23
38
 
24
- // 1. give the model the tool schema in its native format
25
- const tools = tk.toOpenAI() // or tk.toAnthropic() / tk.toGemini()
26
- const system = tk.skillsPrompt() // markdown catalog of skills for the system prompt
39
+ // 3. run — skills are injected into the system prompt, tools are called for you, looped to an answer
40
+ const { text } = await agent.run("Refund order 1234 for the customer.", { toolkit: tk })
41
+ console.log(text)
42
+ await tk.close()
43
+ ```
27
44
 
28
- // 2. when the model returns a tool call { name, arguments }:
29
- const res = await tk.execute(name, args) // -> { output, isError, metadata }
30
- // feed res.output back as the tool result, loop.
45
+ `createClient` reads the key from `OPENROUTER_API_KEY` / `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`
46
+ by default. The loop runs call → execute tools → feed back → repeat (up to `maxTurns`, default 10),
47
+ with hooks, streaming (`agent.stream(...)`), retries/backoff, and conversation memory available.
31
48
 
32
- console.log(tk.mcpStatus()) // { server: "connected" | "failed" | "disabled" }
33
- await tk.close()
49
+ ## Add your own tools
50
+
51
+ ```ts
52
+ import { defineTool, httpTool } from "toolnexus"
53
+
54
+ tk.register(
55
+ // a plain function → a tool
56
+ defineTool({
57
+ name: "add", description: "Add two numbers",
58
+ inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] },
59
+ run: ({ a, b }) => `${(a as number) + (b as number)}`,
60
+ }),
61
+ // a REST endpoint → a tool
62
+ httpTool({
63
+ name: "create_ticket", description: "Create a ticket", method: "POST",
64
+ url: "https://api.example.com/tickets",
65
+ headers: { Authorization: "Bearer ${API_TOKEN}" }, // ${ENV} expands from process.env, never logged
66
+ inputSchema: { type: "object", properties: { title: { type: "string" } }, required: ["title"] },
67
+ }),
68
+ )
34
69
  ```
35
70
 
36
- ## Run the examples
71
+ URL `{placeholders}` are filled from args; `query` args go to the querystring; the rest become
72
+ the JSON body. Non-2xx → `{ isError: true, output: "HTTP <status>: <body>" }`.
37
73
 
38
- ```sh
39
- npm install && npm run build
74
+ ## Bring your own loop
40
75
 
41
- # offline: connects to @modelcontextprotocol/server-everything via npx, lists tools, loads a skill
42
- node --experimental-strip-types examples/basic.ts
76
+ Don't want the host loop? Use the schema adapters and execute calls yourself:
43
77
 
44
- # live: real OpenRouter tool-calling round trip (needs OPENROUTER_API_KEY)
45
- OPENROUTER_API_KEY=... node --experimental-strip-types examples/openrouter_test.ts
78
+ ```ts
79
+ const tools = tk.toOpenAI() // or tk.toAnthropic() / tk.toGemini()
80
+ const system = tk.skillsPrompt() // skills catalog for your system prompt (opens with a preamble telling the model to use the skill tool)
81
+ // when the model returns a tool call { name, arguments }:
82
+ const res = await tk.execute(name, args) // -> { output, isError, metadata }
46
83
  ```
47
84
 
48
- ## API
85
+ ## The four sources
49
86
 
50
- | Member | Description |
51
- |--------|-------------|
52
- | `createToolkit(opts)` | async factory → `Toolkit` |
53
- | `tk.tools()` | all uniform `Tool`s (mcp + skill + extras) |
54
- | `tk.get(name)` | one tool by name |
55
- | `tk.execute(name, args, ctx?)` | run a tool → `ToolResult` |
56
- | `tk.skillsPrompt()` | system-prompt skill catalog |
57
- | `tk.mcpStatus()` | per-server connection status |
58
- | `tk.toOpenAI()` / `toAnthropic()` / `toGemini()` | provider tool schemas |
59
- | `tk.register(...tools)` | add native/http/custom tools at runtime |
60
- | `tk.close()` | disconnect MCP servers |
87
+ | Source | How |
88
+ |--------|-----|
89
+ | **MCP servers** | an `mcp.json` (`mcpServers`/`servers`/`mcp`); local stdio + remote streamable-HTTP, `headers` for auth |
90
+ | **Agent skills** | a folder of `<name>/SKILL.md`; a `skill` tool loads each on demand + a system-prompt catalog |
91
+ | **Native tools** | `defineTool({...})` — a function becomes a tool |
92
+ | **HTTP / REST** | `httpTool({...})` — an endpoint becomes a tool, `${ENV}` headers |
61
93
 
62
- You can also use the sources directly: `loadMcp(config)`, `loadSkills(dir)`.
94
+ All four appear as one uniform `Tool` in `tk.tools()`. Each is à la carte — use just `loadMcp(config)`
95
+ or `loadSkills(dir)` if that's all you need.
63
96
 
64
- ## Native tools (a function → a tool)
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:
65
106
 
66
107
  ```ts
67
- import { defineTool } from "toolnexus"
108
+ const tk = await createToolkit({ mcpConfig: "./mcp.json", builtins: false })
109
+ // also accepts { disabled: true } or { enabled: false }
68
110
 
69
- const add = defineTool({
70
- name: "add", description: "Add two numbers",
71
- inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] },
72
- run: ({ a, b }) => `${(a as number) + (b as number)}`, // string or ToolResult
73
- })
74
- tk.register(add)
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 } } })
75
113
  ```
76
114
 
77
- ## HTTP / REST tools (an endpoint → a tool)
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"`):
78
126
 
79
127
  ```ts
80
- import { httpTool } from "toolnexus"
81
-
82
- tk.register(httpTool({
83
- name: "create_ticket", description: "Create a ticket", method: "POST",
84
- url: "https://api.example.com/tickets",
85
- headers: { Authorization: "Bearer ${API_TOKEN}" }, // ${ENV} expands from process.env, never logged
86
- inputSchema: { type: "object", properties: { title: { type: "string" } }, required: ["title"] },
87
- }))
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")
88
136
  ```
89
137
 
90
- URL `{placeholders}` are filled from args; `query` args go to the querystring; the rest
91
- become the JSON body. Non-2xx → `{ isError: true, output: "HTTP <status>: <body>" }`.
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.
92
142
 
93
- ## Unified client (the host loop)
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):
94
145
 
95
146
  ```ts
96
147
  import { createClient } from "toolnexus"
97
148
 
98
- const agent = createClient({
99
- baseUrl: "https://openrouter.ai/api/v1", // any OpenAI- or Anthropic-style endpoint
100
- style: "openai", // or "anthropic"
101
- model: "openai/gpt-4o-mini",
102
- // apiKey defaults to OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY
103
- systemPrompt: "You are a helpful agent.",
104
- })
149
+ const agent = createClient({ baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" })
105
150
 
106
- const { text, toolCalls } = await agent.run("do the thing", { toolkit: tk })
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()
107
162
  ```
108
163
 
109
- It injects `tk.skillsPrompt()` into the system message and runs the full
110
- call → execute tools → feed back → repeat loop (up to `maxTurns`, default 10).
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
+
167
+ ## API
168
+
169
+ | Member | Description |
170
+ |--------|-------------|
171
+ | `createToolkit(opts)` | async factory → `Toolkit` |
172
+ | `createClient(opts)` | the unified host loop (`run` / `stream`) |
173
+ | `tk.tools()` / `tk.get(name)` | the uniform tools |
174
+ | `tk.execute(name, args, ctx?)` | run a tool → `ToolResult` |
175
+ | `tk.skillsPrompt()` | system-prompt skill catalog |
176
+ | `tk.mcpStatus()` | per-server connection status |
177
+ | `tk.toOpenAI()` / `toAnthropic()` / `toGemini()` | provider tool schemas |
178
+ | `tk.register(...tools)` | add native/http/custom tools |
179
+ | `tk.close()` | disconnect MCP servers |
180
+
181
+ ## More
111
182
 
112
- ## Examples
183
+ Full docs, the other four language ports, the shared behavior spec, and runnable examples:
184
+ **https://github.com/muthuishere/toolnexus**
113
185
 
114
- - `examples/basic.ts` — toolkit + adapters + skill load (offline).
115
- - `examples/agent.ts` — native + http tools driven by the unified client (live with `OPENROUTER_API_KEY`).
116
- - `examples/openrouter_test.ts` — minimal live tool-calling round trip.
186
+ MIT licensed.
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[];