UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

681 lines (680 loc) • 31.5 kB
import { randomUUID } from "node:crypto"; import { Type } from "typebox"; import { A2A_TASK_STATES } from "./a2a-task-state.js"; import { assertEndpointBound, redirectPolicy, resolveProtocolHttpHeaders, underDeclaredHeaders } from "./protocol-http-auth.js"; import { describeHttpTransportFailure } from "./mcp-failure.js"; import { findNamespacePrefixCollision, mintNamespacePrefix, mintNamespacedToolName } from "./protocol-naming.js"; import { A2A_NAMESPACE } from "./protocol-table.js"; import { judgeEntryToolAxes, refusedDeclarer, snapshotDeclaredEntry } from "./locked-config.js"; import { delimitUntrusted, inlineUntrusted } from "./untrusted-text.js"; import { thrownText } from "./thrown-value.js"; const A2A_REQUEST_TIMEOUT_MS = 30_000; const A2A_CARD_TIMEOUT_MS = 10_000; const A2A_CALL_TOTAL_TIMEOUT_MS = 10 * 60_000; const A2A_STATE_IDLE_TIMEOUT_MS = 120_000; const A2A_POLL_FIRST_DELAY_MS = 500; const A2A_POLL_MAX_DELAY_MS = 5_000; const A2A_POLL_BACKOFF_FACTOR = 2; const A2A_RESPONSE_MAX_BYTES = 8 * 1024 * 1024; const A2A_SKILL_NAME_MAX_CHARS = 200; const A2A_SKILL_TEXT_MAX_CHARS = 1_200; const A2A_SKILL_TAGS_MAX_CHARS = 240; const A2A_SKILL_EXAMPLES_MAX_CHARS = 320; export const A2A_SKILL_DESCRIPTION_MAX_CHARS = 2_048; const A2A_CARD_NAME_MAX_CHARS = 160; const A2A_CARD_DESCRIPTION_MAX_CHARS = 240; const A2A_ID_MAX_CHARS = 160; const A2A_RESULT_BODY_MAX_CHARS = 100_000; const A2A_ERROR_TEXT_MAX_CHARS = 240; const A2A_RESULT_FENCE_REASON = "the peer is an agent acting on its own, so its output is worker text, not tool data"; const A2A_CARD_PATHS = ["/.well-known/agent-card.json", "/.well-known/agent.json"]; const A2A_PROTOCOL_VERSION = "1.0"; const A2A_JSONRPC_TRANSPORT = "JSONRPC"; const A2A_ERROR_CODE_NAMES = new Map([ [-32700, "ParseError"], [-32600, "InvalidRequest"], [-32601, "MethodNotFound"], [-32602, "InvalidParams"], [-32603, "InternalError"], [-32001, "TaskNotFound"], [-32002, "TaskNotCancelable"], [-32003, "PushNotificationNotSupported"], [-32004, "UnsupportedOperation"], [-32005, "ContentTypeNotSupported"], [-32006, "InvalidAgentResponse"], ]); const A2A_TERMINAL_STATES = new Set(["completed", "canceled", "failed", "rejected"]); const A2A_CALLER_TURN_STATES = new Set(["input-required", "auth-required"]); const A2A_STATE_SET = new Set(A2A_TASK_STATES); export class A2aRpcError extends Error { code; constructor(peer, method, code, message) { const named = A2A_ERROR_CODE_NAMES.get(code); super(`a2a: peer "${inlineUntrusted(peer, A2A_ID_MAX_CHARS)}" refused ${method} with ${code}${named !== undefined ? ` (${named})` : ""}: ${inlineUntrusted(message, A2A_ERROR_TEXT_MAX_CHARS)}`); this.name = "A2aRpcError"; this.code = code; } } function asRecord(v) { return typeof v === "object" && v !== null && !Array.isArray(v) ? v : undefined; } function asString(v) { return typeof v === "string" ? v : undefined; } function asArray(v) { if (!Array.isArray(v)) return undefined; const items = v; return items; } function discriminantOf(v) { return asString(v["kind"]) ?? asString(v["type"]); } function safeId(v) { const s = asString(v); if (s === undefined || s === "") return undefined; return inlineUntrusted(s, A2A_ID_MAX_CHARS); } function withRequestDeadline(outer, timeoutMs) { const controller = new AbortController(); let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); const onOuterAbort = () => controller.abort(); if (outer !== undefined) { if (outer.aborted) controller.abort(); else outer.addEventListener("abort", onOuterAbort, { once: true }); } return { signal: controller.signal, timedOut: () => timedOut, dispose: () => { clearTimeout(timer); outer?.removeEventListener("abort", onOuterAbort); }, }; } function resolveFetch() { const f = globalThis.fetch; if (typeof f !== "function") { throw new Error("a2a: this runtime has no global fetch — the A2A client speaks HTTP over the built-in fetch (Node 20+)"); } return f; } async function readBoundedText(res, what) { const stream = res.body; if (stream === null || typeof stream.getReader !== "function") { const text = await res.text(); if (text.length > A2A_RESPONSE_MAX_BYTES) { throw new Error(`a2a: ${what} exceeded the ${A2A_RESPONSE_MAX_BYTES}-byte response limit`); } return text; } const reader = stream.getReader(); const decoder = new TextDecoder(); const parts = []; let received = 0; try { for (;;) { const { done, value } = await reader.read(); if (done) break; if (!value || value.length === 0) continue; received += value.length; if (received > A2A_RESPONSE_MAX_BYTES) { await reader.cancel().catch(() => undefined); throw new Error(`a2a: ${what} exceeded the ${A2A_RESPONSE_MAX_BYTES}-byte response limit — the transfer was stopped`); } parts.push(decoder.decode(value, { stream: true })); } } finally { reader.releaseLock(); } parts.push(decoder.decode()); return parts.join(""); } async function requestJson(t, what, init) { const doFetch = resolveFetch(); const deadline = withRequestDeadline(init.signal, init.timeoutMs); try { const res = await doFetch(init.url, { method: init.method, signal: deadline.signal, redirect: redirectPolicy(t.headers), headers: underDeclaredHeaders({ accept: "application/json", "a2a-version": A2A_PROTOCOL_VERSION, ...(init.body !== undefined ? { "content-type": "application/json" } : {}) }, t.headers), ...(init.body !== undefined ? { body: init.body } : {}), }); const text = await readBoundedText(res, `${what} from A2A peer "${inlineUntrusted(t.peer, A2A_ID_MAX_CHARS)}"`); return { status: res.status, text }; } catch (err) { if (init.signal?.aborted === true) throw err; const peerLabel = inlineUntrusted(t.peer, A2A_ID_MAX_CHARS); if (deadline.timedOut()) { throw new Error(`a2a: ${what} on peer "${peerLabel}" got no answer within ${init.timeoutMs}ms. The client stopped waiting — the peer may still be acting on the request, so treat the outcome as UNKNOWN and verify before retrying.`, { cause: err }); } const classified = describeHttpTransportFailure(err); if (classified !== undefined) { throw new Error(`a2a: ${what} on peer "${peerLabel}" failed — ${classified.condition}. ${classified.delivered === "no" ? "The request was NOT delivered." : "It is not known whether the peer received the request; treat the outcome as UNKNOWN."}`, { cause: err }); } throw err; } finally { deadline.dispose(); } } async function jsonRpc(t, method, params, signal, timeoutMs = A2A_REQUEST_TIMEOUT_MS) { const peerLabel = inlineUntrusted(t.peer, A2A_ID_MAX_CHARS); const { status, text } = await requestJson(t, method, { method: "POST", url: t.endpoint, body: JSON.stringify({ jsonrpc: "2.0", id: randomUUID(), method, params }), timeoutMs, ...(signal !== undefined ? { signal } : {}), }); if (status < 200 || status >= 300) { throw new Error(`a2a: peer "${peerLabel}" answered ${status} to ${method} instead of a JSON-RPC response`); } let parsed; try { parsed = JSON.parse(text); } catch (err) { throw new Error(`a2a: peer "${peerLabel}" answered ${method} with a body that is not JSON`, { cause: err }); } const envelope = asRecord(parsed); if (envelope === undefined) { throw new Error(`a2a: peer "${peerLabel}" answered ${method} with a JSON value that is not a JSON-RPC envelope`); } const errorMember = asRecord(envelope["error"]); if (errorMember !== undefined) { const code = errorMember["code"]; throw new A2aRpcError(t.peer, method, typeof code === "number" ? code : -32603, asString(errorMember["message"]) ?? "(no message)"); } if (!("result" in envelope)) { throw new Error(`a2a: peer "${peerLabel}" answered ${method} with neither a result nor an error member`); } return envelope["result"]; } async function fetchCardAt(t, url, signal) { const { status, text } = await requestJson(t, "agent-card discovery", { method: "GET", url, timeoutMs: A2A_CARD_TIMEOUT_MS, ...(signal !== undefined ? { signal } : {}) }); if (status === 404 || status === 410) return undefined; const peerLabel = inlineUntrusted(t.peer, A2A_ID_MAX_CHARS); if (status < 200 || status >= 300) { throw new Error(`a2a: peer "${peerLabel}" answered ${status} for its agent card at ${url}`); } try { return JSON.parse(text); } catch (err) { throw new Error(`a2a: peer "${peerLabel}" served an agent card at ${url} that is not JSON`, { cause: err }); } } async function discoverCardBody(spec, t, signal) { const peerLabel = inlineUntrusted(spec.name, A2A_ID_MAX_CHARS); if (spec.cardUrl !== undefined) { const body = await fetchCardAt(t, spec.cardUrl, signal); if (body === undefined) throw new Error(`a2a: peer "${peerLabel}" has no agent card at its declared cardUrl (${spec.cardUrl} answered 404)`); return body; } const tried = []; for (const path of A2A_CARD_PATHS) { const url = new URL(path, spec.url).toString(); tried.push(url); const body = await fetchCardAt(t, url, signal); if (body !== undefined) return body; } throw new Error(`a2a: peer "${peerLabel}" published no agent card — none of ${tried.join(", ")} answered`); } function negotiateEndpoint(peer, card, fallbackUrl) { const offered = []; const primaryUrl = asString(card["url"]) ?? fallbackUrl; offered.push({ url: primaryUrl, transport: asString(card["preferredTransport"]) ?? A2A_JSONRPC_TRANSPORT }); for (const entry of asArray(card["additionalInterfaces"]) ?? []) { const rec = asRecord(entry); if (rec === undefined) continue; const url = asString(rec["url"]); const transport = asString(rec["transport"]); if (url !== undefined && transport !== undefined) offered.push({ url, transport }); } for (const candidate of offered) { if (candidate.transport.toUpperCase() !== A2A_JSONRPC_TRANSPORT) continue; let absolute; try { absolute = new URL(candidate.url, fallbackUrl); } catch { continue; } if (absolute.protocol !== "http:" && absolute.protocol !== "https:") continue; return absolute.toString(); } const names = offered.map((o) => inlineUntrusted(o.transport, 40)).join(", "); throw new Error(`a2a: peer "${inlineUntrusted(peer, A2A_ID_MAX_CHARS)}" advertises no ${A2A_JSONRPC_TRANSPORT} interface (offered: ${names || "none"}) — this client speaks ${A2A_JSONRPC_TRANSPORT} only`); } function readCard(peer, body, fallbackUrl) { const card = asRecord(body); if (card === undefined) { throw new Error(`a2a: peer "${inlineUntrusted(peer, A2A_ID_MAX_CHARS)}" served an agent card that is not a JSON object`); } const skills = []; for (const entry of asArray(card["skills"]) ?? []) { const rec = asRecord(entry); if (rec === undefined) continue; const id = asString(rec["id"]); if (id === undefined || id === "") continue; const tags = (asArray(rec["tags"]) ?? []).filter((v) => typeof v === "string"); const examples = (asArray(rec["examples"]) ?? []).filter((v) => typeof v === "string"); skills.push({ id, ...(asString(rec["name"]) !== undefined ? { name: asString(rec["name"]) } : {}), ...(asString(rec["description"]) !== undefined ? { description: asString(rec["description"]) } : {}), ...(tags.length > 0 ? { tags } : {}), ...(examples.length > 0 ? { examples } : {}), }); } return { ...(asString(card["name"]) !== undefined ? { name: inlineUntrusted(asString(card["name"]), A2A_CARD_NAME_MAX_CHARS) } : {}), ...(asString(card["description"]) !== undefined ? { description: inlineUntrusted(asString(card["description"]), A2A_CARD_DESCRIPTION_MAX_CHARS) } : {}), endpoint: negotiateEndpoint(peer, card, fallbackUrl), skills, }; } function a2aToolParameters() { return Type.Object({ message: Type.String({ description: "What to ask this agent to do, in natural language." }), taskId: Type.Optional(Type.String({ description: "Continue an existing task on this agent — pass the taskId a previous call returned (e.g. when it asked for more input)." })), contextId: Type.Optional(Type.String({ description: "Keep this call in an existing conversation — pass the contextId a previous call returned." })), }); } function skillDescription(peerLabel, skill) { const lines = []; const title = skill.name !== undefined ? inlineUntrusted(skill.name, A2A_SKILL_NAME_MAX_CHARS) : skill.id; lines.push(`Ask the remote agent "${peerLabel}" to perform its "${title}" skill.`); if (skill.description !== undefined) lines.push(inlineUntrusted(skill.description, A2A_SKILL_TEXT_MAX_CHARS)); if (skill.tags !== undefined) lines.push(`Tags: ${inlineUntrusted(skill.tags.join(", "), A2A_SKILL_TAGS_MAX_CHARS)}`); if (skill.examples !== undefined) lines.push(`Examples: ${inlineUntrusted(skill.examples.join(" | "), A2A_SKILL_EXAMPLES_MAX_CHARS)}`); lines.push("The agent acts on its own side; its reply is returned as untrusted external data."); return lines.join("\n"); } function a2aAxisFor(name, override) { const axis = { name, egress: true }; if (override === undefined) return axis; if (override.effect !== undefined) axis.effect = override.effect; if (override.egress === false) axis.egress = false; if (override.irreversibility !== undefined) axis.irreversibility = override.irreversibility; return axis; } function renderParts(parts) { const lines = []; for (const raw of parts) { const part = asRecord(raw); if (part === undefined) continue; const kind = discriminantOf(part); if (kind === "text") { const text = asString(part["text"]); if (text !== undefined) lines.push(text); continue; } if (kind === "file") { const file = asRecord(part["file"]) ?? {}; const name = asString(file["name"]) ?? "(unnamed)"; const mime = asString(file["mimeType"]) ?? "unknown type"; const uri = asString(file["uri"]); if (uri !== undefined) lines.push(`[file part: ${name} (${mime}) at ${uri} — NOT downloaded by this client]`); else if (typeof file["bytes"] === "string") lines.push(`[file part: ${name} (${mime}), inline bytes not decoded by this client]`); else lines.push(`[file part: ${name} (${mime}), no content]`); continue; } if (kind === "data") { lines.push(`[data part] ${JSON.stringify(part["data"]) ?? "undefined"}`); continue; } lines.push(`[part of unsupported kind ${JSON.stringify(kind ?? null)}]`); } return lines; } function renderMessage(message) { const parts = asArray(message["parts"]); return parts === undefined ? [] : renderParts(parts); } function renderTask(task) { const lines = []; const status = asRecord(task["status"]); const statusMessage = status !== undefined ? asRecord(status["message"]) : undefined; if (statusMessage !== undefined) lines.push(...renderMessage(statusMessage)); for (const entry of asArray(task["artifacts"]) ?? []) { const artifact = asRecord(entry); if (artifact === undefined) continue; const name = asString(artifact["name"]) ?? asString(artifact["artifactId"]) ?? "(unnamed artifact)"; lines.push(`--- artifact: ${name} ---`); const parts = asArray(artifact["parts"]); if (parts !== undefined) lines.push(...renderParts(parts)); } return lines; } function taskState(task) { const status = asRecord(task["status"]); const raw = status !== undefined ? asString(status["state"]) : undefined; if (raw === undefined) return { unrecognized: "(absent)" }; if (!A2A_STATE_SET.has(raw)) return { unrecognized: raw }; return { state: raw }; } function fenceResult(peerLabel, skillId, body) { return delimitUntrusted(`a2a ${peerLabel} ${inlineUntrusted(skillId, A2A_ID_MAX_CHARS)} — ${A2A_RESULT_FENCE_REASON}`, body, A2A_RESULT_BODY_MAX_CHARS); } function continuationLine(taskId, contextId) { const keys = []; if (taskId !== undefined) keys.push(`taskId ${taskId}`); if (contextId !== undefined) keys.push(`contextId ${contextId}`); return keys.length > 0 ? ` Continue it by calling this tool again with ${keys.join(" and ")}.` : ""; } function delay(ms, signal) { return new Promise((resolve) => { const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); function onAbort() { clearTimeout(timer); resolve(); } if (signal !== undefined) { if (signal.aborted) { clearTimeout(timer); resolve(); return; } signal.addEventListener("abort", onAbort, { once: true }); } }); } async function awaitTaskOutcome(t, peerLabel, skillId, firstTask, signal) { const startedAt = Date.now(); let task = firstTask; let observed = taskState(task); let idleSince = startedAt; let pollDelayMs = A2A_POLL_FIRST_DELAY_MS; for (;;) { if ("unrecognized" in observed) { throw new Error(`a2a: peer "${peerLabel}" reported task state ${JSON.stringify(inlineUntrusted(observed.unrecognized, A2A_ID_MAX_CHARS))} for skill "${inlineUntrusted(skillId, A2A_ID_MAX_CHARS)}", which is not a state this client knows. The outcome is UNKNOWN.${continuationLine(safeId(task["id"]), safeId(task["contextId"]))}`); } const state = observed.state; const taskId = safeId(task["id"]); const contextId = safeId(task["contextId"]); if (A2A_CALLER_TURN_STATES.has(state)) { const body = renderTask(task).join("\n").trim(); return { content: [ { type: "text", text: `The remote agent needs more from you before it can continue (state: ${state}).${continuationLine(taskId, contextId)}\n` + fenceResult(peerLabel, skillId, body || "(the agent sent no message with its request)"), }, ], details: { type: "a2a", state, ...(taskId !== undefined ? { taskId } : {}), ...(contextId !== undefined ? { contextId } : {}) }, terminate: false, }; } if (A2A_TERMINAL_STATES.has(state)) { const body = renderTask(task).join("\n").trim(); if (state === "completed") { return { content: [{ type: "text", text: fenceResult(peerLabel, skillId, body || "(the agent completed the task and returned no content)") }], details: { type: "a2a", state, ...(taskId !== undefined ? { taskId } : {}), ...(contextId !== undefined ? { contextId } : {}) }, terminate: false, }; } throw new Error(`a2a: the remote agent "${peerLabel}" ended task state ${state} for skill "${inlineUntrusted(skillId, A2A_ID_MAX_CHARS)}".${continuationLine(taskId, contextId)}\n` + fenceResult(peerLabel, skillId, body || "(the agent gave no reason)")); } const now = Date.now(); if (now - startedAt >= A2A_CALL_TOTAL_TIMEOUT_MS) { throw new Error(`a2a: the remote agent "${peerLabel}" was still ${state} after ${Math.round((now - startedAt) / 1000)}s (ceiling ${A2A_CALL_TOTAL_TIMEOUT_MS}ms). The client stopped waiting; the agent is still acting, so treat the outcome as UNKNOWN.${continuationLine(taskId, contextId)}`); } if (now - idleSince >= A2A_STATE_IDLE_TIMEOUT_MS) { throw new Error(`a2a: the remote agent "${peerLabel}" answered every poll but never left state ${state} for ${A2A_STATE_IDLE_TIMEOUT_MS}ms. The client stopped waiting; treat the outcome as UNKNOWN.${continuationLine(taskId, contextId)}`); } await delay(pollDelayMs, signal); if (signal?.aborted === true) throw new Error(`a2a: the call to "${peerLabel}" was aborted while waiting for the remote agent.${continuationLine(taskId, contextId)}`); pollDelayMs = Math.min(pollDelayMs * A2A_POLL_BACKOFF_FACTOR, A2A_POLL_MAX_DELAY_MS); const rawId = asString(task["id"]); if (rawId === undefined) { throw new Error(`a2a: the remote agent "${peerLabel}" returned a non-terminal task with no id, so it cannot be polled. The outcome is UNKNOWN.`); } const polled = asRecord(await jsonRpc(t, "tasks/get", { id: rawId }, signal)); if (polled === undefined) { throw new Error(`a2a: peer "${peerLabel}" answered tasks/get with something that is not a task object`); } task = polled; const next = taskState(task); if ("state" in next && "state" in observed && next.state !== observed.state) idleSince = Date.now(); observed = next; } } function buildSkillTool(spec, t, skill, lifecycle) { const peerLabel = inlineUntrusted(spec.name, A2A_ID_MAX_CHARS); const name = mintNamespacedToolName(A2A_NAMESPACE, spec.name, skill.id); return { name, label: `${spec.name}:${skill.id}`, description: skillDescription(peerLabel, skill), parameters: a2aToolParameters(), permissionSeat: "passthrough", execute: async (_toolCallId, params, signal) => { if (lifecycle.disposed) { throw new Error(`a2a: the task that mounted peer "${peerLabel}" has ended — this tool no longer sends anything to the remote agent.`); } const args = asRecord(params) ?? {}; const message = asString(args["message"]); if (message === undefined || message.trim() === "") { throw new Error(`a2a: "message" is required — say what the remote agent "${peerLabel}" should do.`); } const taskId = asString(args["taskId"]); const contextId = asString(args["contextId"]); const result = await jsonRpc(t, "message/send", { message: { role: "user", kind: "message", messageId: randomUUID(), parts: [{ kind: "text", text: message }], ...(taskId !== undefined && taskId !== "" ? { taskId } : {}), ...(contextId !== undefined && contextId !== "" ? { contextId } : {}), }, configuration: { blocking: true }, }, signal); const answer = asRecord(result); if (answer === undefined) { throw new Error(`a2a: peer "${peerLabel}" answered message/send with something that is not a protocol object`); } const kind = discriminantOf(answer); if (kind === "message" || (kind === undefined && answer["parts"] !== undefined)) { const body = renderMessage(answer).join("\n").trim(); return { content: [{ type: "text", text: fenceResult(peerLabel, skill.id, body || "(the agent replied with no content)") }], details: { type: "a2a", kind: "message", ...(safeId(answer["contextId"]) !== undefined ? { contextId: safeId(answer["contextId"]) } : {}) }, terminate: false, }; } if (kind === "task" || (kind === undefined && answer["status"] !== undefined)) { return await awaitTaskOutcome(t, peerLabel, skill.id, answer, signal); } throw new Error(`a2a: peer "${peerLabel}" answered message/send with ${JSON.stringify(inlineUntrusted(kind ?? "(no kind)", A2A_ID_MAX_CHARS))}, which is neither a message nor a task`); }, }; } function declaredPeerOf(declared, index) { let snapshot; try { snapshot = snapshotDeclaredEntry(declared, "a2a"); } catch (err) { return { spec: { name: refusedDeclarer(err) ?? `(peer #${index + 1}, name unreadable)` }, refusal: err }; } const judged = judgeEntryToolAxes(snapshot, "a2a"); return judged.ok ? { spec: judged.entry } : { spec: snapshot, refusal: judged.error }; } async function materializePeer(peer, principal, lifecycle, signal) { if ("refusal" in peer) throw peer.refusal; const { spec } = peer; const declaredAxes = spec.toolAxes; const headers = resolveProtocolHttpHeaders(A2A_NAMESPACE.id, spec, principal); const discovery = { peer: spec.name, endpoint: spec.url, headers }; const card = readCard(spec.name, await discoverCardBody(spec, discovery, signal), spec.url); assertEndpointBound(A2A_NAMESPACE.id, spec.name, card.endpoint, spec.cardUrl === undefined ? [spec.url] : [spec.url, spec.cardUrl], headers); const transport = { peer: spec.name, endpoint: card.endpoint, headers }; const tools = []; const skillAxes = []; for (const skill of card.skills) { if (spec.allowSkills !== undefined && !spec.allowSkills.includes(skill.id)) continue; const tool = buildSkillTool(spec, transport, skill, lifecycle); tools.push(tool); skillAxes.push(a2aAxisFor(tool.name, declaredAxes?.[skill.id])); } return { tools, axes: skillAxes, status: { name: spec.name, status: "ready", ...(card.name !== undefined ? { agentName: card.name } : {}), ...(card.description !== undefined ? { agentDescription: card.description } : {}), endpoint: card.endpoint, toolNames: tools.map((tool) => tool.name), }, }; } function asPeerWarning(spec, err) { const detail = thrownText(err); const warning = new Error(`a2a: peer "${spec.name}" could not be materialized — skipped (${detail})`, { cause: err }); warning.code = "a2a.peer_unavailable"; return warning; } export async function materializeA2aTools(specs, principal, signal) { const peers = specs.map((spec, index) => declaredPeerOf(spec, index)); if (peers.length > 0) { const collision = findNamespacePrefixCollision(A2A_NAMESPACE, peers.map((p) => p.spec.name)); if (collision) { const [a, b] = collision.peers; const e = new Error(a === b ? `A2A peer "${a}" is declared twice — each peer needs its own name (both mount under "${collision.prefix}").` : `A2A peer names "${a}" and "${b}" both mount under "${collision.prefix}" — the namespaced tool name keeps only [a-zA-Z0-9_-], so they are the same domain to this engine. Rename one.`); e.code = "config.a2a_peer_name_collision"; throw e; } } const lifecycle = { disposed: false }; const tools = []; const toolAxes = []; const warnings = []; const statuses = []; const settled = await Promise.allSettled(peers.map((peer) => materializePeer(peer, principal, lifecycle, signal))); for (let i = 0; i < peers.length; i++) { const spec = peers[i].spec; const outcome = settled[i]; if (outcome.status === "fulfilled") { tools.push(...outcome.value.tools); toolAxes.push(...outcome.value.axes); statuses.push(outcome.value.status); } else { warnings.push(asPeerWarning(spec, outcome.reason)); statuses.push({ name: spec.name, status: "failed", error: inlineUntrusted(thrownText(outcome.reason), A2A_ERROR_TEXT_MAX_CHARS) }); } } return { tools, toolAxes, warnings, statuses, refresh: async (peer) => { const prefixOf = (name) => mintNamespacePrefix(A2A_NAMESPACE, name); const targets = peer !== undefined ? peers.filter((p) => p.spec.name === peer) : peers; if (peer !== undefined && targets.length === 0) { return [{ peer, prefix: prefixOf(peer), status: "failed", toolCount: 0, added: [], removed: [], error: "no peer of that name is declared for this task" }]; } const results = []; for (const target of targets) { const { spec } = target; const prefix = prefixOf(spec.name); if (lifecycle.disposed) { results.push({ peer: spec.name, prefix, status: "disposed", toolCount: 0, added: [], removed: [] }); continue; } const previous = statuses.find((s) => s.name === spec.name)?.toolNames ?? []; try { const fresh = await materializePeer(target, principal, lifecycle, signal); const names = fresh.tools.map((t) => t.name); results.push({ peer: spec.name, prefix, status: "refreshed", toolCount: names.length, added: names.filter((n) => !previous.includes(n)), removed: previous.filter((n) => !names.includes(n)), tools: fresh.tools, axes: fresh.axes, }); const at = statuses.findIndex((s) => s.name === spec.name); if (at >= 0) statuses[at] = fresh.status; } catch (err) { results.push({ peer: spec.name, prefix, status: "failed", toolCount: previous.length, added: [], removed: [], error: inlineUntrusted(thrownText(err), A2A_ERROR_TEXT_MAX_CHARS), }); } } return results; }, dispose: async () => { lifecycle.disposed = true; }, }; }