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 +71 -1
- package/dist/a2a.d.ts +86 -0
- package/dist/a2a.js +254 -0
- package/dist/builtin.d.ts +25 -0
- package/dist/builtin.js +609 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp.js +7 -1
- package/dist/serve.d.ts +78 -0
- package/dist/serve.js +243 -0
- package/dist/skill.d.ts +5 -0
- package/dist/skill.js +62 -5
- package/dist/toolkit.d.ts +27 -0
- package/dist/toolkit.js +70 -3
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/a2a.ts +363 -0
- package/src/builtin.ts +643 -0
- package/src/index.ts +3 -0
- package/src/mcp.ts +6 -1
- package/src/serve.ts +324 -0
- package/src/skill.ts +58 -5
- package/src/toolkit.ts +85 -2
- package/src/types.ts +1 -1
package/src/a2a.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
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 { type JSONSchema, type Tool, type ToolContext, type ToolResult, sanitize } from "./types.js"
|
|
17
|
+
import { expandEnvHeaders } from "./mcp.js"
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUT = 300_000
|
|
20
|
+
const DEFAULT_POLL_EVERY = 1_000
|
|
21
|
+
|
|
22
|
+
/** Terminal A2A task states — polling stops once one of these is reached. */
|
|
23
|
+
const TERMINAL = new Set(["completed", "failed", "canceled"])
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Wire types (the A2A subset we speak — verified against a2a-python).
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export type A2ATaskState = "submitted" | "working" | "completed" | "failed" | "canceled" | string
|
|
30
|
+
|
|
31
|
+
export interface A2APart {
|
|
32
|
+
kind: string
|
|
33
|
+
text?: string
|
|
34
|
+
[k: string]: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface A2AMessage {
|
|
38
|
+
role: string
|
|
39
|
+
messageId?: string
|
|
40
|
+
parts?: A2APart[]
|
|
41
|
+
[k: string]: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface A2AArtifact {
|
|
45
|
+
parts?: A2APart[]
|
|
46
|
+
[k: string]: unknown
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface A2ATaskStatus {
|
|
50
|
+
state: A2ATaskState
|
|
51
|
+
message?: A2AMessage
|
|
52
|
+
[k: string]: unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface A2ATask {
|
|
56
|
+
id: string
|
|
57
|
+
status: A2ATaskStatus
|
|
58
|
+
artifacts?: A2AArtifact[]
|
|
59
|
+
history?: A2AMessage[]
|
|
60
|
+
[k: string]: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface A2ASkill {
|
|
64
|
+
id?: string
|
|
65
|
+
name?: string
|
|
66
|
+
description?: string
|
|
67
|
+
tags?: string[]
|
|
68
|
+
examples?: string[]
|
|
69
|
+
[k: string]: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AgentCard {
|
|
73
|
+
name: string
|
|
74
|
+
description?: string
|
|
75
|
+
version?: string
|
|
76
|
+
protocolVersion?: string
|
|
77
|
+
capabilities?: { streaming?: boolean; pushNotifications?: boolean; [k: string]: unknown }
|
|
78
|
+
defaultInputModes?: string[]
|
|
79
|
+
defaultOutputModes?: string[]
|
|
80
|
+
skills?: A2ASkill[]
|
|
81
|
+
/** The JSON-RPC endpoint the agent listens on. */
|
|
82
|
+
url?: string
|
|
83
|
+
[k: string]: unknown
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Agent descriptor + factory.
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export interface Agent {
|
|
91
|
+
/** URL of the Agent Card (`/.well-known/agent-card.json`). */
|
|
92
|
+
card: string
|
|
93
|
+
/** `${ENV_VAR}` header values are expanded at call time and never logged. */
|
|
94
|
+
headers?: Record<string, string>
|
|
95
|
+
/** Overall poll budget in ms (default 300000). */
|
|
96
|
+
timeout?: number
|
|
97
|
+
/** Interval between `GetTask` polls in ms (default 1000). */
|
|
98
|
+
pollEvery?: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Build an Agent descriptor pointing at a remote Agent Card URL. */
|
|
102
|
+
export function agent(opts: Agent): Agent {
|
|
103
|
+
return { card: opts.card, headers: opts.headers, timeout: opts.timeout, pollEvery: opts.pollEvery }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Normalize either an Agent descriptor or a bare card URL into an Agent. */
|
|
107
|
+
function toAgent(agentOrCardUrl: Agent | string, opts?: Omit<Agent, "card">): Agent {
|
|
108
|
+
if (typeof agentOrCardUrl === "string") return agent({ card: agentOrCardUrl, ...opts })
|
|
109
|
+
return agentOrCardUrl
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Config parsing — an `agents` block mirroring `mcpServers` (§2).
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export interface AgentConfig {
|
|
117
|
+
card: string
|
|
118
|
+
headers?: Record<string, string>
|
|
119
|
+
timeout?: number
|
|
120
|
+
pollEvery?: number
|
|
121
|
+
enabled?: boolean
|
|
122
|
+
disabled?: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type AgentsConfig = Record<string, AgentConfig>
|
|
126
|
+
|
|
127
|
+
/** MCP `isEnabled` precedence: `disabled:true` wins, then `enabled:false`. */
|
|
128
|
+
function isEnabled(cfg: AgentConfig): boolean {
|
|
129
|
+
if (cfg.disabled === true) return false
|
|
130
|
+
if (cfg.enabled === false) return false
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse an `agents` block (Record<name, AgentConfig>) into Agent descriptors,
|
|
136
|
+
* skipping disabled entries. The config key is just an identifier — a tool's
|
|
137
|
+
* name prefix comes from the fetched card's `name`, not the key.
|
|
138
|
+
*/
|
|
139
|
+
export function parseAgentsConfig(block: AgentsConfig | undefined): Agent[] {
|
|
140
|
+
if (!block) return []
|
|
141
|
+
const out: Agent[] = []
|
|
142
|
+
for (const cfg of Object.values(block)) {
|
|
143
|
+
if (!cfg || typeof cfg.card !== "string") continue
|
|
144
|
+
if (!isEnabled(cfg)) continue
|
|
145
|
+
out.push(agent({ card: cfg.card, headers: cfg.headers, timeout: cfg.timeout, pollEvery: cfg.pollEvery }))
|
|
146
|
+
}
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// JSON-RPC transport (single POST) + card fetch. Reuses httpTool's ${ENV}
|
|
152
|
+
// header expansion, timeout handling, and non-2xx → error mapping.
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/** POST one JSON-RPC 2.0 request and return `result` (throws on error/non-2xx). */
|
|
156
|
+
async function jsonRpc(
|
|
157
|
+
endpoint: string,
|
|
158
|
+
method: string,
|
|
159
|
+
params: unknown,
|
|
160
|
+
headers: Record<string, string>,
|
|
161
|
+
timeout: number,
|
|
162
|
+
signal?: AbortSignal,
|
|
163
|
+
): Promise<unknown> {
|
|
164
|
+
const controller = new AbortController()
|
|
165
|
+
const timer = setTimeout(() => controller.abort(), timeout)
|
|
166
|
+
if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true })
|
|
167
|
+
try {
|
|
168
|
+
const res = await fetch(endpoint, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
171
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: randomUUID(), method, params }),
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
})
|
|
174
|
+
const text = await res.text()
|
|
175
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`)
|
|
176
|
+
const payload = safeParse(text) as { result?: unknown; error?: { message?: string; code?: number } }
|
|
177
|
+
if (payload && payload.error) {
|
|
178
|
+
throw new Error(payload.error.message ?? `JSON-RPC error ${payload.error.code ?? ""}`.trim())
|
|
179
|
+
}
|
|
180
|
+
return payload?.result
|
|
181
|
+
} finally {
|
|
182
|
+
clearTimeout(timer)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Fetch and parse the Agent Card at its URL (GET). */
|
|
187
|
+
async function fetchCard(
|
|
188
|
+
cardUrl: string,
|
|
189
|
+
headers: Record<string, string>,
|
|
190
|
+
timeout: number,
|
|
191
|
+
signal?: AbortSignal,
|
|
192
|
+
): Promise<AgentCard> {
|
|
193
|
+
const controller = new AbortController()
|
|
194
|
+
const timer = setTimeout(() => controller.abort(), timeout)
|
|
195
|
+
if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true })
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch(cardUrl, { method: "GET", headers, signal: controller.signal })
|
|
198
|
+
const text = await res.text()
|
|
199
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`)
|
|
200
|
+
return JSON.parse(text) as AgentCard
|
|
201
|
+
} finally {
|
|
202
|
+
clearTimeout(timer)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function safeParse(text: string): unknown {
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(text)
|
|
209
|
+
} catch {
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Abortable sleep — resolves early (without error) if `signal` fires. */
|
|
215
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
216
|
+
return new Promise((resolve) => {
|
|
217
|
+
if (signal?.aborted) return resolve()
|
|
218
|
+
const onAbort = () => {
|
|
219
|
+
clearTimeout(timer)
|
|
220
|
+
resolve()
|
|
221
|
+
}
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
signal?.removeEventListener("abort", onAbort)
|
|
224
|
+
resolve()
|
|
225
|
+
}, ms)
|
|
226
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Concatenate a Task's text output: artifact text parts, else last agent message. */
|
|
231
|
+
function extractOutput(task: A2ATask): string {
|
|
232
|
+
const parts: string[] = []
|
|
233
|
+
for (const artifact of task.artifacts ?? []) {
|
|
234
|
+
for (const part of artifact.parts ?? []) {
|
|
235
|
+
if (part.kind === "text" && typeof part.text === "string") parts.push(part.text)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (parts.length > 0) return parts.join("\n")
|
|
239
|
+
// Fallback: the last agent message in history.
|
|
240
|
+
const history = task.history ?? []
|
|
241
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
242
|
+
const msg = history[i]
|
|
243
|
+
if (msg.role !== "agent") continue
|
|
244
|
+
const text = (msg.parts ?? [])
|
|
245
|
+
.filter((p) => p.kind === "text" && typeof p.text === "string")
|
|
246
|
+
.map((p) => p.text)
|
|
247
|
+
.join("\n")
|
|
248
|
+
if (text) return text
|
|
249
|
+
}
|
|
250
|
+
return ""
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Text of a Task's status.message (used for failed/canceled error output). */
|
|
254
|
+
function statusMessageText(task: A2ATask): string {
|
|
255
|
+
const msg = task.status?.message
|
|
256
|
+
if (!msg) return ""
|
|
257
|
+
return (msg.parts ?? [])
|
|
258
|
+
.filter((p) => p.kind === "text" && typeof p.text === "string")
|
|
259
|
+
.map((p) => p.text)
|
|
260
|
+
.join("\n")
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Skill → Tool.
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
const TASK_SCHEMA: JSONSchema = {
|
|
268
|
+
type: "object",
|
|
269
|
+
properties: { task: { type: "string", description: "The task to send to the agent, in natural language." } },
|
|
270
|
+
required: ["task"],
|
|
271
|
+
additionalProperties: false,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Build one `source:"a2a"` Tool for a single advertised skill of an agent. */
|
|
275
|
+
function skillTool(agentName: string, endpoint: string, skill: A2ASkill, ag: Agent): Tool {
|
|
276
|
+
const skillId = skill.id ?? skill.name ?? ""
|
|
277
|
+
const name = sanitize(agentName) + "_" + sanitize(skillId)
|
|
278
|
+
const timeout = ag.timeout ?? DEFAULT_TIMEOUT
|
|
279
|
+
const pollEvery = ag.pollEvery ?? DEFAULT_POLL_EVERY
|
|
280
|
+
return {
|
|
281
|
+
name,
|
|
282
|
+
description: skill.description ?? skill.name ?? skillId,
|
|
283
|
+
inputSchema: TASK_SCHEMA,
|
|
284
|
+
source: "a2a",
|
|
285
|
+
async execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
|
|
286
|
+
const start = Date.now()
|
|
287
|
+
const budget = ctx?.timeout ?? timeout
|
|
288
|
+
const headers = expandEnvHeaders(ag.headers) ?? {}
|
|
289
|
+
const taskText = String(args?.task ?? "")
|
|
290
|
+
let polls = 0
|
|
291
|
+
let taskId = ""
|
|
292
|
+
let state: A2ATaskState = "submitted"
|
|
293
|
+
const meta = () => ({ agent: agentName, taskId, state, polls, ms: Date.now() - start })
|
|
294
|
+
try {
|
|
295
|
+
// 1. SendMessage (non-blocking) → a submitted Task.
|
|
296
|
+
let task = (await jsonRpc(
|
|
297
|
+
endpoint,
|
|
298
|
+
"SendMessage",
|
|
299
|
+
{
|
|
300
|
+
message: { role: "user", messageId: randomUUID(), parts: [{ kind: "text", text: taskText }] },
|
|
301
|
+
configuration: { blocking: false },
|
|
302
|
+
},
|
|
303
|
+
headers,
|
|
304
|
+
budget,
|
|
305
|
+
ctx?.signal,
|
|
306
|
+
)) as A2ATask
|
|
307
|
+
taskId = task?.id ?? ""
|
|
308
|
+
state = task?.status?.state ?? "submitted"
|
|
309
|
+
|
|
310
|
+
// 2. Poll GetTask until terminal / timeout / cancel.
|
|
311
|
+
while (!TERMINAL.has(state)) {
|
|
312
|
+
if (ctx?.signal?.aborted) {
|
|
313
|
+
state = "canceled"
|
|
314
|
+
return { output: `A2A task ${taskId} canceled`, isError: true, metadata: meta() }
|
|
315
|
+
}
|
|
316
|
+
if (Date.now() - start >= budget) {
|
|
317
|
+
return {
|
|
318
|
+
output: `A2A task ${taskId} timed out after ${budget}ms (state=${state})`,
|
|
319
|
+
isError: true,
|
|
320
|
+
metadata: meta(),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
await sleep(pollEvery, ctx?.signal)
|
|
324
|
+
// Cancelled during the wait → stop before another GetTask.
|
|
325
|
+
if (ctx?.signal?.aborted) {
|
|
326
|
+
state = "canceled"
|
|
327
|
+
return { output: `A2A task ${taskId} canceled`, isError: true, metadata: meta() }
|
|
328
|
+
}
|
|
329
|
+
task = (await jsonRpc(endpoint, "GetTask", { id: taskId }, headers, budget, ctx?.signal)) as A2ATask
|
|
330
|
+
polls++
|
|
331
|
+
state = task?.status?.state ?? state
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 3. Map the terminal Task → ToolResult.
|
|
335
|
+
if (state === "completed") {
|
|
336
|
+
return { output: extractOutput(task), isError: false, metadata: meta() }
|
|
337
|
+
}
|
|
338
|
+
const detail = statusMessageText(task)
|
|
339
|
+
return {
|
|
340
|
+
output: `A2A task ${taskId} ${state}${detail ? `: ${detail}` : ""}`,
|
|
341
|
+
isError: true,
|
|
342
|
+
metadata: meta(),
|
|
343
|
+
}
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: meta() }
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Resolve an Agent to its tools: fetch the card, read `skills[]`, and produce
|
|
353
|
+
* one Tool per skill. The agent name prefix comes from the card's `name`.
|
|
354
|
+
*/
|
|
355
|
+
export async function agentTools(ag: Agent): Promise<Tool[]> {
|
|
356
|
+
const timeout = ag.timeout ?? DEFAULT_TIMEOUT
|
|
357
|
+
const headers = expandEnvHeaders(ag.headers) ?? {}
|
|
358
|
+
const card = await fetchCard(ag.card, headers, timeout)
|
|
359
|
+
const agentName = card.name ?? "agent"
|
|
360
|
+
// The card's `url` is the JSON-RPC endpoint; fall back to the card origin.
|
|
361
|
+
const endpoint = card.url ?? new URL(ag.card).origin
|
|
362
|
+
return (card.skills ?? []).map((skill) => skillTool(agentName, endpoint, skill, ag))
|
|
363
|
+
}
|