toolnexus 0.1.2 → 0.2.1

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,78 @@
1
+ import type { SkillInfo } from "./skill.js";
2
+ import type { RunResult } from "./client.js";
3
+ import type { A2ATask, A2ATaskState, AgentCard } from "./a2a.js";
4
+ /** Pluggable Task persistence. The-factory can plug NATS/JetStream the same way. */
5
+ export interface TaskStore {
6
+ get(id: string): Promise<A2ATask | undefined>;
7
+ save(task: A2ATask): Promise<void>;
8
+ }
9
+ /** Default in-memory store — Tasks live only for the lifetime of the process. */
10
+ export declare class InMemoryTaskStore implements TaskStore {
11
+ private readonly tasks;
12
+ get(id: string): Promise<A2ATask | undefined>;
13
+ save(task: A2ATask): Promise<void>;
14
+ }
15
+ /** File-backed store — one JSON file per Task id, under `dir`. */
16
+ export declare class FileTaskStore implements TaskStore {
17
+ private readonly dir;
18
+ constructor(dir: string);
19
+ private file;
20
+ get(id: string): Promise<A2ATask | undefined>;
21
+ save(task: A2ATask): Promise<void>;
22
+ }
23
+ /** Map a `store` selector to a concrete TaskStore. `"file:<dir>"` → FileTaskStore. */
24
+ export declare function resolveStore(store?: TaskStore | "memory" | string): TaskStore;
25
+ export interface A2AProvider {
26
+ organization: string;
27
+ url: string;
28
+ }
29
+ /** Opt-in A2A profile for `toolkit.serve` — configures the Agent Card + store. */
30
+ export interface A2AConfig {
31
+ name?: string;
32
+ description?: string;
33
+ version?: string;
34
+ provider?: A2AProvider;
35
+ /** Subset of the toolkit's skill ids/names to advertise; omit ⇒ all. */
36
+ skills?: string[];
37
+ /** `"memory"` (default) | `"file:<dir>"` | a custom TaskStore. */
38
+ store?: TaskStore | "memory" | string;
39
+ }
40
+ /** Surfaced on each Task's terminal state, carrying the RunResult telemetry. */
41
+ export interface OnTaskEvent {
42
+ id: string;
43
+ skill?: string;
44
+ task: A2ATask;
45
+ result?: RunResult;
46
+ state: A2ATaskState;
47
+ }
48
+ export type OnTask = (ev: OnTaskEvent) => void | Promise<void>;
49
+ /** Handle returned by `toolkit.serve` — the base URL plus a stop/close method. */
50
+ export interface ServeHandle {
51
+ /** Base URL of the server, e.g. `http://127.0.0.1:PORT`. */
52
+ url: string;
53
+ stop(): Promise<void>;
54
+ /** Alias for `stop()`. */
55
+ close(): Promise<void>;
56
+ }
57
+ /**
58
+ * Build an Agent Card from the toolkit's skills. `skills` are the SkillSource's
59
+ * `SkillInfo`s (never raw tools); filtered to `cfg.skills` when given. `url` is
60
+ * the JSON-RPC endpoint peers POST to.
61
+ */
62
+ export declare function buildAgentCard(cfg: A2AConfig, skills: SkillInfo[], url: string): AgentCard;
63
+ export interface StartServerOptions {
64
+ /** `host:port` (port 0 ⇒ ephemeral). */
65
+ addr: string;
66
+ /** When present, mounts the A2A card + JSON-RPC routes; absent ⇒ no A2A routes. */
67
+ a2a?: A2AConfig;
68
+ /** The toolkit's skills, for the card. */
69
+ skills: SkillInfo[];
70
+ /** Runs one served Task through the client loop. */
71
+ runTask: (text: string) => Promise<RunResult>;
72
+ onTask?: OnTask;
73
+ }
74
+ /**
75
+ * Start the HTTP server. Delegated to by `Toolkit.serve`. When `a2a` is absent,
76
+ * the server answers 404 to everything (a minimal base for now).
77
+ */
78
+ export declare function startA2AServer(opts: StartServerOptions): Promise<ServeHandle>;
package/dist/serve.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * A2A (agent-to-agent) — INBOUND: serve a toolkit as a remote A2A agent.
3
+ *
4
+ * `toolkit.serve(addr, { client, a2a })` stands up a minimal HTTP server that,
5
+ * when the `a2a` profile is present, exposes:
6
+ *
7
+ * GET /.well-known/agent-card.json → an Agent Card built from the toolkit's
8
+ * **skills** (SKILL.md name+description), never raw tools.
9
+ * POST / → JSON-RPC 2.0: `SendMessage` (submit a
10
+ * Task, fulfil it asynchronously via `client.run`) + `GetTask` (poll).
11
+ *
12
+ * This is the inbound counterpart to a2a.ts (outbound) and speaks the same wire
13
+ * subset (verified against a2a-python). Task persistence is a pluggable
14
+ * `TaskStore` (in-memory default; file / custom selectable via `a2a.store`).
15
+ * See ../../SPEC.md and openspec/changes/add-a2a-agents.
16
+ */
17
+ import http from "node:http";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { randomUUID } from "node:crypto";
21
+ import { sanitize } from "./types.js";
22
+ /** Default in-memory store — Tasks live only for the lifetime of the process. */
23
+ export class InMemoryTaskStore {
24
+ tasks = new Map();
25
+ async get(id) {
26
+ return this.tasks.get(id);
27
+ }
28
+ async save(task) {
29
+ this.tasks.set(task.id, task);
30
+ }
31
+ }
32
+ /** File-backed store — one JSON file per Task id, under `dir`. */
33
+ export class FileTaskStore {
34
+ dir;
35
+ constructor(dir) {
36
+ this.dir = dir;
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ }
39
+ file(id) {
40
+ return path.join(this.dir, `${sanitize(id)}.json`);
41
+ }
42
+ async get(id) {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(this.file(id), "utf8"));
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ async save(task) {
51
+ fs.writeFileSync(this.file(task.id), JSON.stringify(task));
52
+ }
53
+ }
54
+ /** Map a `store` selector to a concrete TaskStore. `"file:<dir>"` → FileTaskStore. */
55
+ export function resolveStore(store) {
56
+ if (store === undefined || store === "memory")
57
+ return new InMemoryTaskStore();
58
+ if (typeof store === "string") {
59
+ if (store.startsWith("file:"))
60
+ return new FileTaskStore(store.slice("file:".length));
61
+ throw new Error(`Unknown A2A store: "${store}" (expected "memory" or "file:<dir>")`);
62
+ }
63
+ return store;
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // Card builder.
67
+ // ---------------------------------------------------------------------------
68
+ const PROTOCOL_VERSION = "0.3.0";
69
+ const DEFAULT_VERSION = "0.1.0";
70
+ /**
71
+ * Build an Agent Card from the toolkit's skills. `skills` are the SkillSource's
72
+ * `SkillInfo`s (never raw tools); filtered to `cfg.skills` when given. `url` is
73
+ * the JSON-RPC endpoint peers POST to.
74
+ */
75
+ export function buildAgentCard(cfg, skills, url) {
76
+ const wanted = cfg.skills;
77
+ const selected = wanted ? skills.filter((s) => wanted.includes(s.name)) : skills;
78
+ const card = {
79
+ name: cfg.name ?? "toolnexus-agent",
80
+ description: cfg.description ?? "",
81
+ version: cfg.version ?? DEFAULT_VERSION,
82
+ protocolVersion: PROTOCOL_VERSION,
83
+ capabilities: { streaming: false, pushNotifications: false },
84
+ defaultInputModes: ["text"],
85
+ defaultOutputModes: ["text"],
86
+ skills: selected.map((s) => ({ id: s.name, name: s.name, description: s.description ?? "" })),
87
+ url,
88
+ };
89
+ if (cfg.provider)
90
+ card.provider = cfg.provider;
91
+ return card;
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // Server.
95
+ // ---------------------------------------------------------------------------
96
+ const CARD_PATH = "/.well-known/agent-card.json";
97
+ /** Extract the concatenated text of a JSON-RPC `SendMessage` message's parts. */
98
+ function messageText(params) {
99
+ const parts = params?.message?.parts;
100
+ if (!Array.isArray(parts))
101
+ return "";
102
+ return parts
103
+ .filter((p) => p && p.kind === "text" && typeof p.text === "string")
104
+ .map((p) => p.text)
105
+ .join("");
106
+ }
107
+ function readBody(req) {
108
+ return new Promise((resolve) => {
109
+ let body = "";
110
+ req.on("data", (c) => (body += c));
111
+ req.on("end", () => resolve(body));
112
+ });
113
+ }
114
+ /**
115
+ * Start the HTTP server. Delegated to by `Toolkit.serve`. When `a2a` is absent,
116
+ * the server answers 404 to everything (a minimal base for now).
117
+ */
118
+ export async function startA2AServer(opts) {
119
+ const { a2a, skills, runTask, onTask } = opts;
120
+ const [host, portStr] = splitAddr(opts.addr);
121
+ const store = a2a ? resolveStore(a2a.store) : undefined;
122
+ const server = http.createServer((req, res) => {
123
+ const rpcRes = (id, payload) => {
124
+ res.writeHead(200, { "content-type": "application/json" });
125
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: id ?? null, ...payload }));
126
+ };
127
+ const rpcOk = (id, result) => rpcRes(id, { result });
128
+ const rpcErr = (id, code, message) => rpcRes(id, { error: { code, message } });
129
+ // No A2A profile ⇒ no routes.
130
+ if (!a2a || !store) {
131
+ res.writeHead(404);
132
+ res.end("not found");
133
+ return;
134
+ }
135
+ if (req.method === "GET" && req.url === CARD_PATH) {
136
+ const url = baseUrl(host, server) + "/";
137
+ res.writeHead(200, { "content-type": "application/json" });
138
+ res.end(JSON.stringify(buildAgentCard(a2a, skills, url)));
139
+ return;
140
+ }
141
+ if (req.method === "POST") {
142
+ readBody(req).then((body) => {
143
+ let rpc;
144
+ try {
145
+ rpc = JSON.parse(body);
146
+ }
147
+ catch {
148
+ return rpcErr(null, -32700, "Parse error");
149
+ }
150
+ if (rpc?.method === "SendMessage") {
151
+ const text = messageText(rpc.params);
152
+ const id = randomUUID();
153
+ const submitted = { id, status: { state: "submitted" } };
154
+ // Persist submitted, kick fulfilment async, return the id immediately.
155
+ store.save(submitted).then(() => {
156
+ void fulfil(id, text, store, runTask, onTask);
157
+ rpcOk(rpc.id, submitted);
158
+ });
159
+ return;
160
+ }
161
+ if (rpc?.method === "GetTask") {
162
+ const id = String(rpc.params?.id ?? "");
163
+ store.get(id).then((task) => {
164
+ if (!task)
165
+ return rpcErr(rpc.id, -32001, `Task not found: ${id}`);
166
+ rpcOk(rpc.id, task);
167
+ });
168
+ return;
169
+ }
170
+ return rpcErr(rpc?.id ?? null, -32601, `Method not found: ${rpc?.method}`);
171
+ });
172
+ return;
173
+ }
174
+ res.writeHead(404);
175
+ res.end("not found");
176
+ });
177
+ await new Promise((resolve) => server.listen(Number(portStr), host, resolve));
178
+ return {
179
+ url: baseUrl(host, server),
180
+ stop() {
181
+ return new Promise((resolve) => server.close(() => resolve()));
182
+ },
183
+ close() {
184
+ return new Promise((resolve) => server.close(() => resolve()));
185
+ },
186
+ };
187
+ }
188
+ /**
189
+ * Background fulfilment: submitted → working → (completed | failed). Never
190
+ * throws — a fulfilment error becomes a `failed` Task, keeping the server alive.
191
+ */
192
+ async function fulfil(id, text, store, runTask, onTask) {
193
+ let task;
194
+ let result;
195
+ let state;
196
+ try {
197
+ await store.save({ id, status: { state: "working" } });
198
+ result = await runTask(text);
199
+ const artifact = {
200
+ artifactId: randomUUID(),
201
+ parts: [{ kind: "text", text: result.text }],
202
+ };
203
+ task = { id, status: { state: "completed" }, artifacts: [artifact] };
204
+ state = "completed";
205
+ }
206
+ catch (e) {
207
+ const detail = e instanceof Error ? e.message : String(e);
208
+ task = { id, status: { state: "failed", message: { role: "agent", parts: [{ kind: "text", text: detail }] } } };
209
+ state = "failed";
210
+ }
211
+ try {
212
+ await store.save(task);
213
+ }
214
+ catch {
215
+ // A store write failure must not crash the server.
216
+ }
217
+ if (onTask) {
218
+ try {
219
+ await onTask({ id, task, result, state });
220
+ }
221
+ catch {
222
+ // Host callback errors are isolated.
223
+ }
224
+ }
225
+ }
226
+ // ---------------------------------------------------------------------------
227
+ // Address helpers.
228
+ // ---------------------------------------------------------------------------
229
+ /** Split `host:port` on the last colon. Missing host ⇒ 127.0.0.1. */
230
+ function splitAddr(addr) {
231
+ const idx = addr.lastIndexOf(":");
232
+ if (idx === -1)
233
+ return ["127.0.0.1", addr];
234
+ const host = addr.slice(0, idx) || "127.0.0.1";
235
+ return [host, addr.slice(idx + 1)];
236
+ }
237
+ /** Base URL for a listening server; 0.0.0.0 is reported as 127.0.0.1 for callers. */
238
+ function baseUrl(host, server) {
239
+ const addr = server.address();
240
+ const port = typeof addr === "object" && addr ? addr.port : 0;
241
+ const h = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
242
+ return `http://${h}:${port}`;
243
+ }
package/dist/skill.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import type { Tool } from "./types.js";
2
2
  export declare const SKILL_TOOL_DESCRIPTION = "Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.\n\nUse this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.\n\nThe skill name must match one of the skills listed in your system prompt.";
3
+ /**
4
+ * Instruction preamble prepended to skillsPrompt() when ≥1 described skill
5
+ * exists. Byte-identical across all four ports — do not reword. See SPEC.md §3.
6
+ */
7
+ export declare const SKILLS_PROMPT_PREAMBLE: string;
3
8
  export interface SkillInfo {
4
9
  name: string;
5
10
  description?: string;
package/dist/skill.js CHANGED
@@ -4,38 +4,55 @@
4
4
  * loads a skill's instructions + sampled resources on demand (progressive
5
5
  * disclosure).
6
6
  */
7
- import { readFileSync, readdirSync, statSync } from "node:fs";
7
+ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
+ import { parse as parseYaml } from "yaml";
10
11
  export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
11
12
 
12
13
  Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
13
14
 
14
15
  The skill name must match one of the skills listed in your system prompt.`;
15
- /** Minimal YAML frontmatter parser — only flat `key: value` pairs are needed. */
16
+ /**
17
+ * Instruction preamble prepended to skillsPrompt() when ≥1 described skill
18
+ * exists. Byte-identical across all four ports — do not reword. See SPEC.md §3.
19
+ */
20
+ export const SKILLS_PROMPT_PREAMBLE = "Skills provide specialized instructions and workflows for specific tasks.\n" +
21
+ "Use the skill tool to load a skill when a task matches its description.";
22
+ /**
23
+ * Parse YAML frontmatter (between the leading `---` fences) with a real YAML
24
+ * parser, so folded (`>`)/literal (`|`) block scalars, quoting, and multi-line
25
+ * values all resolve correctly. Scalar values are coerced to strings.
26
+ */
16
27
  function parseFrontmatter(text) {
17
28
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
18
29
  if (!match)
19
30
  return { data: {}, content: text };
20
31
  const data = {};
21
- for (const line of match[1].split(/\r?\n/)) {
22
- const idx = line.indexOf(":");
23
- if (idx === -1)
24
- continue;
25
- const key = line.slice(0, idx).trim();
26
- let value = line.slice(idx + 1).trim();
27
- if ((value.startsWith('"') && value.endsWith('"')) ||
28
- (value.startsWith("'") && value.endsWith("'"))) {
29
- value = value.slice(1, -1);
32
+ let parsed;
33
+ try {
34
+ parsed = parseYaml(match[1]);
35
+ }
36
+ catch {
37
+ parsed = null;
38
+ }
39
+ if (parsed && typeof parsed === "object") {
40
+ for (const [key, value] of Object.entries(parsed)) {
41
+ // Trim so block-scalar trailing newlines (chomping differs subtly between
42
+ // YAML libs) don't leak — keeps the five ports byte-identical.
43
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
44
+ data[key] = String(value).trim();
45
+ }
30
46
  }
31
- if (key)
32
- data[key] = value;
33
47
  }
34
48
  return { data, content: match[2] };
35
49
  }
36
50
  function walkSkillFiles(root) {
37
51
  const out = [];
38
52
  const stack = [root];
53
+ // Follow symlinked directories (like opencode's `symlink: true` glob); guard
54
+ // against symlink cycles by tracking resolved real paths already visited.
55
+ const seen = new Set();
39
56
  while (stack.length) {
40
57
  const dir = stack.pop();
41
58
  let entries;
@@ -47,12 +64,35 @@ function walkSkillFiles(root) {
47
64
  }
48
65
  for (const entry of entries) {
49
66
  const full = path.join(dir, entry.name);
50
- if (entry.isDirectory()) {
67
+ let isDir = entry.isDirectory();
68
+ let isFile = entry.isFile();
69
+ if (entry.isSymbolicLink()) {
70
+ // Resolve the link target to decide whether to descend / collect it.
71
+ try {
72
+ const st = statSync(full);
73
+ isDir = st.isDirectory();
74
+ isFile = st.isFile();
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ }
80
+ if (isDir) {
51
81
  if (entry.name === "node_modules" || entry.name === ".git")
52
82
  continue;
83
+ let real;
84
+ try {
85
+ real = realpathSync(full);
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ if (seen.has(real))
91
+ continue;
92
+ seen.add(real);
53
93
  stack.push(full);
54
94
  }
55
- else if (entry.isFile() && entry.name === "SKILL.md") {
95
+ else if (isFile && entry.name === "SKILL.md") {
56
96
  out.push(full);
57
97
  }
58
98
  }
@@ -62,6 +102,7 @@ function walkSkillFiles(root) {
62
102
  function sampleSiblingFiles(dir, limit = 10) {
63
103
  const out = [];
64
104
  const stack = [dir];
105
+ const seen = new Set();
65
106
  while (stack.length && out.length < limit) {
66
107
  const cur = stack.pop();
67
108
  let entries;
@@ -75,12 +116,34 @@ function sampleSiblingFiles(dir, limit = 10) {
75
116
  if (out.length >= limit)
76
117
  break;
77
118
  const full = path.join(cur, entry.name);
78
- if (entry.isDirectory()) {
119
+ let isDir = entry.isDirectory();
120
+ let isFile = entry.isFile();
121
+ if (entry.isSymbolicLink()) {
122
+ try {
123
+ const st = statSync(full);
124
+ isDir = st.isDirectory();
125
+ isFile = st.isFile();
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ }
131
+ if (isDir) {
79
132
  if (entry.name === "node_modules" || entry.name === ".git")
80
133
  continue;
134
+ let real;
135
+ try {
136
+ real = realpathSync(full);
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ if (seen.has(real))
142
+ continue;
143
+ seen.add(real);
81
144
  stack.push(full);
82
145
  }
83
- else if (entry.isFile() && entry.name !== "SKILL.md") {
146
+ else if (isFile && entry.name !== "SKILL.md") {
84
147
  out.push(full);
85
148
  }
86
149
  }
@@ -176,6 +239,8 @@ export function loadSkills(dirs) {
176
239
  if (described.length === 0)
177
240
  return "No skills are currently available.";
178
241
  return [
242
+ SKILLS_PROMPT_PREAMBLE,
243
+ "",
179
244
  "## Available Skills",
180
245
  ...described.map((s) => `- **${s.name}**: ${s.description}`),
181
246
  ].join("\n");
package/dist/toolkit.d.ts CHANGED
@@ -3,14 +3,24 @@
3
3
  * single uniform list, with provider adapters and a single execute() router.
4
4
  */
5
5
  import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./types.js";
6
+ import { type BuiltinsConfig } from "./builtin.js";
7
+ import { type Agent } from "./a2a.js";
8
+ import { type A2AConfig, type OnTask, type ServeHandle } from "./serve.js";
9
+ import type { Client } from "./client.js";
6
10
  export interface ToolkitOptions {
7
11
  mcpConfig?: string | object;
8
12
  skillsDir?: string | string[];
9
13
  extraTools?: Tool[];
14
+ /** Built-in tools (§4A). On by default. false | { disabled:true } | { enabled:false } ⇒ off. */
15
+ builtins?: BuiltinsConfig;
16
+ /** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
17
+ agents?: Agent[];
10
18
  }
11
19
  export declare class Toolkit {
12
20
  private readonly mcp;
13
21
  private readonly skill;
22
+ /** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
23
+ private readonly a2aConfig;
14
24
  private byName;
15
25
  private constructor();
16
26
  static create(opts: ToolkitOptions): Promise<Toolkit>;
@@ -18,6 +28,23 @@ export declare class Toolkit {
18
28
  get(name: string): Tool | undefined;
19
29
  /** Add a tool at runtime (native, http, or any custom Tool). First name wins. */
20
30
  register(...tools: Tool[]): this;
31
+ /**
32
+ * Fetch a remote A2A agent's card at runtime and register its skills as tools.
33
+ * Accepts an Agent descriptor or a bare card URL. First-name-wins dedupe.
34
+ */
35
+ addAgent(agentOrCardUrl: Agent | string, opts?: Omit<Agent, "card">): Promise<this>;
36
+ /**
37
+ * Serve this toolkit as an agent over HTTP. When the `a2a` profile is present
38
+ * (inline, or a top-level `a2a` config block the toolkit was built from), it
39
+ * mounts the A2A Agent Card (`/.well-known/agent-card.json`, built from skills)
40
+ * and a JSON-RPC endpoint (`SendMessage` + `GetTask`) fulfilled by `client.run`.
41
+ * When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
42
+ */
43
+ serve(addr: string, opts: {
44
+ client: Client;
45
+ a2a?: A2AConfig;
46
+ onTask?: OnTask;
47
+ }): Promise<ServeHandle>;
21
48
  execute(name: string, args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>;
22
49
  /** Markdown skill catalog for the system prompt. */
23
50
  skillsPrompt(): string;
package/dist/toolkit.js CHANGED
@@ -1,14 +1,26 @@
1
1
  import { loadMcp } from "./mcp.js";
2
2
  import { loadSkills } from "./skill.js";
3
+ import { selectBuiltins } from "./builtin.js";
4
+ import { agentTools, parseAgentsConfig } from "./a2a.js";
5
+ import { startA2AServer } from "./serve.js";
3
6
  import { toAnthropic, toGemini, toOpenAI } from "./adapters.js";
4
7
  export class Toolkit {
5
8
  mcp;
6
9
  skill;
10
+ a2aConfig;
7
11
  byName = new Map();
8
- constructor(mcp, skill, extraTools) {
12
+ constructor(mcp, skill, builtins, agents, extraTools,
13
+ /** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
14
+ a2aConfig) {
9
15
  this.mcp = mcp;
10
16
  this.skill = skill;
11
- const all = [...(mcp?.tools ?? []), ...(skill ? [skill.tool] : []), ...extraTools];
17
+ this.a2aConfig = a2aConfig;
18
+ // Builtins are the lowest-precedence source: a host extraTools entry with
19
+ // the same name shadows a builtin (SPEC §4). Drop shadowed builtins up
20
+ // front, then apply the normal first-wins dedupe for MCP/skill/agents/extras.
21
+ const extraNames = new Set(extraTools.map((t) => t.name));
22
+ const activeBuiltins = builtins.filter((b) => !extraNames.has(b.name));
23
+ const all = [...(mcp?.tools ?? []), ...(skill ? [skill.tool] : []), ...activeBuiltins, ...agents, ...extraTools];
12
24
  for (const t of all) {
13
25
  if (this.byName.has(t.name)) {
14
26
  console.warn(`[toolnexus] duplicate tool name "${t.name}" — keeping first`);
@@ -20,7 +32,35 @@ export class Toolkit {
20
32
  static async create(opts) {
21
33
  const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined;
22
34
  const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined;
23
- return new Toolkit(mcp, skill, opts.extraTools ?? []);
35
+ // The toggle comes from the `builtins` option, or a top-level `builtins`
36
+ // key on a parsed config object — same precedence as MCP's isEnabled.
37
+ let builtinsCfg = opts.builtins;
38
+ if (builtinsCfg === undefined &&
39
+ opts.mcpConfig &&
40
+ typeof opts.mcpConfig === "object" &&
41
+ "builtins" in opts.mcpConfig) {
42
+ builtinsCfg = opts.mcpConfig.builtins;
43
+ }
44
+ const builtins = selectBuiltins(builtinsCfg);
45
+ // Remote A2A agents come from the `agents:[...]` option plus a top-level
46
+ // `agents` block on a parsed config object (mirrors mcpServers). Each is
47
+ // resolved to its skill tools; a failing agent never breaks the toolkit.
48
+ const agentDescriptors = [...(opts.agents ?? [])];
49
+ if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "agents" in opts.mcpConfig) {
50
+ agentDescriptors.push(...parseAgentsConfig(opts.mcpConfig.agents));
51
+ }
52
+ const agentToolLists = await Promise.all(agentDescriptors.map((a) => agentTools(a).catch((e) => {
53
+ console.warn(`[toolnexus] A2A agent "${a.card}" failed: ${e instanceof Error ? e.message : String(e)}`);
54
+ return [];
55
+ })));
56
+ const agentToolsFlat = agentToolLists.flat();
57
+ // A2A inbound profile: a top-level `a2a` block on a parsed config object
58
+ // (mirrors builtins/agents). serve() prefers its inline `a2a` over this.
59
+ let a2aConfig;
60
+ if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "a2a" in opts.mcpConfig) {
61
+ a2aConfig = opts.mcpConfig.a2a;
62
+ }
63
+ return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig);
24
64
  }
25
65
  tools() {
26
66
  return [...this.byName.values()];
@@ -39,6 +79,33 @@ export class Toolkit {
39
79
  }
40
80
  return this;
41
81
  }
82
+ /**
83
+ * Fetch a remote A2A agent's card at runtime and register its skills as tools.
84
+ * Accepts an Agent descriptor or a bare card URL. First-name-wins dedupe.
85
+ */
86
+ async addAgent(agentOrCardUrl, opts) {
87
+ const ag = typeof agentOrCardUrl === "string" ? { card: agentOrCardUrl, ...opts } : agentOrCardUrl;
88
+ const tools = await agentTools(ag);
89
+ return this.register(...tools);
90
+ }
91
+ /**
92
+ * Serve this toolkit as an agent over HTTP. When the `a2a` profile is present
93
+ * (inline, or a top-level `a2a` config block the toolkit was built from), it
94
+ * mounts the A2A Agent Card (`/.well-known/agent-card.json`, built from skills)
95
+ * and a JSON-RPC endpoint (`SendMessage` + `GetTask`) fulfilled by `client.run`.
96
+ * When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
97
+ */
98
+ serve(addr, opts) {
99
+ const a2a = opts.a2a ?? this.a2aConfig;
100
+ const skills = this.skill ? Object.values(this.skill.skills) : [];
101
+ return startA2AServer({
102
+ addr,
103
+ a2a,
104
+ skills,
105
+ runTask: (text) => opts.client.run(text, { toolkit: this }),
106
+ onTask: opts.onTask,
107
+ });
108
+ }
42
109
  async execute(name, args, ctx) {
43
110
  const tool = this.byName.get(name);
44
111
  if (!tool) {
package/dist/types.d.ts CHANGED
@@ -8,7 +8,7 @@ export type JSONSchema = {
8
8
  additionalProperties?: boolean;
9
9
  [k: string]: unknown;
10
10
  };
11
- export type ToolSource = "mcp" | "skill" | "native" | "http" | "custom";
11
+ export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom";
12
12
  export interface ToolResult {
13
13
  output: string;
14
14
  isError: boolean;