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/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
+ }