@sema-agent/core
Version:
Stateless, task-oriented AI agent core
681 lines (680 loc) • 31.5 kB
JavaScript
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;
},
};
}