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