openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
843 lines (842 loc) • 29.8 kB
JavaScript
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { At as boolean, Et as array, Nn as record, Rn as string, St as _null, Tn as object, Xn as union, Zn as unknown, dn as literal, wn as number, yt as _enum } from "./schemas-zxit8y5H.js";
import { c as resolveAgentConfig, o as listAgentIds } from "./agent-scope-config-DcbEhP0R.js";
import { t as DEFAULT_ACCOUNT_ID } from "./account-id-CETVCrTz.js";
import { a as isRequestBodyLimitError, p as sendHttpRequestRejection, s as readRequestBodyWithLimit } from "./http-body-D3IMwTJJ.js";
import { m as readProviderJsonResponse } from "./provider-http-errors-U-nhuk_f.js";
import { b as ssrfPolicyFromHttpBaseUrlAllowedOrigin } from "./ssrf-0QyXWOVG.js";
import { i as fetchWithSsrFGuard } from "./fetch-guard-BMdGQhbb.js";
import { t as boundedJsonUtf8Bytes } from "./json-utf8-bytes-fm9i4b7G.js";
import { p as defineChannelMessageAdapter } from "./channel-outbound-Dw9bfhFK.js";
import { t as createMessageReceiptFromOutboundResults } from "./receipt-DdpXkxJo.js";
import { d as createDefaultChannelRuntimeState, u as createComputedAccountStatusAdapter } from "./status-helpers-DtxKUBHF.js";
import "./string-coerce-runtime-GQa0ehRA.js";
import "./agent-scope-runtime-h57Nypqc.js";
import { i as createChatChannelPlugin, t as buildChannelOutboundSessionRoute } from "./core-C-nFxxsj.js";
import { r as resolveChannelInboundRouteEnvelope } from "./envelope-CYYWJCqv.js";
import "./channel-inbound-DreWTphY.js";
import { a as waitUntilAbort } from "./channel-lifecycle.core-Bfr1S-LZ.js";
import "./ssrf-runtime-Bum5C6NN.js";
import { i as channelStoppedPatch, r as channelReadyPatch } from "./gateway-runtime-BkyVf3uU.js";
import "./channel-core-BPNgmH_x.js";
import { s as resolveStableChannelMessageIngress } from "./channel-ingress-runtime-Dk8tJwPs.js";
import "./provider-http-k9RMI7iG.js";
import "./text-utility-runtime-BjzvUG99.js";
import "./webhook-ingress-CpRSOilv.js";
import { l as runDetachedWebhookWork } from "./webhook-request-guards-BoKMzZgq.js";
import { r as registerPluginHttpRoute } from "./http-registry-BDq8iS_8.js";
import { r as resolveA2aChannelAccount, t as createA2aChannelPluginBase } from "./channel-base-DXGOAZg0.js";
import { t as getA2aChannelRuntime } from "./runtime-vVzCzqsn.js";
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
//#region extensions/a2a/src/protocol.ts
const A2A_CONTEXT_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
const A2A_MESSAGE_MAX_BYTES = 65536;
const A2A_TRUNCATION_MARKER = `\n[message truncated at ${A2A_MESSAGE_MAX_BYTES} bytes]`;
const A2aRpcRequestSchema = object({
jsonrpc: literal("2.0"),
id: union([
string(),
number().finite(),
_null()
]).optional(),
method: string().min(1),
params: unknown().optional()
});
const A2aMetadataSchema = record(string(), unknown());
const A2aInboundPartSchema = object({
text: string().optional(),
data: unknown().optional()
});
const A2aSendMessageParamsSchema = object({
message: object({
messageId: string().min(1).optional(),
contextId: string().regex(A2A_CONTEXT_PATTERN).optional(),
taskId: string().min(1).optional(),
role: _enum([
"ROLE_USER",
"ROLE_AGENT",
"user",
"agent"
]),
parts: array(unknown()),
metadata: A2aMetadataSchema.optional()
}),
configuration: object({
acceptedOutputModes: array(string()).optional(),
historyLength: number().int().nonnegative().optional(),
returnImmediately: boolean().optional()
}).optional(),
tenant: string().optional(),
metadata: A2aMetadataSchema.optional()
});
const A2aTaskRequestParamsSchema = object({
id: string().min(1),
historyLength: number().int().nonnegative().optional(),
tenant: string().optional()
});
const A2A_METHOD_ALIASES = {
SendMessage: "SendMessage",
GetTask: "GetTask",
"message/send": "SendMessage",
"tasks/get": "GetTask"
};
const A2A_UNSUPPORTED_METHODS = /* @__PURE__ */ new Set([
"CancelTask",
"tasks/cancel",
"ListTasks",
"SendStreamingMessage",
"SubscribeToTask",
"CreateTaskPushNotificationConfig",
"SetTaskPushNotificationConfig",
"GetTaskPushNotificationConfig",
"ListTaskPushNotificationConfig",
"ListTaskPushNotificationConfigs",
"DeleteTaskPushNotificationConfig",
"GetExtendedAgentCard"
]);
function resolveA2aRpcMethod(method) {
if (Object.hasOwn(A2A_METHOD_ALIASES, method)) return A2A_METHOD_ALIASES[method];
return A2A_UNSUPPORTED_METHODS.has(method) ? "unsupported" : void 0;
}
function isA2aContextId(value) {
return A2A_CONTEXT_PATTERN.test(value);
}
function extractA2aMessageText(parts) {
const textParts = [];
for (const candidate of parts) {
const parsed = A2aInboundPartSchema.safeParse(candidate);
if (!parsed.success) continue;
if (typeof parsed.data.text === "string") textParts.push(parsed.data.text);
else if (Object.hasOwn(parsed.data, "data") && parsed.data.data !== void 0) textParts.push(JSON.stringify(parsed.data.data));
}
const text = textParts.join("\n");
if (!text.trim()) return;
if (Buffer.byteLength(text) <= A2A_MESSAGE_MAX_BYTES) return text;
const encoded = Buffer.from(text);
let prefixBytes = A2A_MESSAGE_MAX_BYTES - Buffer.byteLength(A2A_TRUNCATION_MARKER);
const decoder = new TextDecoder("utf-8", { fatal: true });
while (prefixBytes > 0) try {
return decoder.decode(encoded.subarray(0, prefixBytes)) + A2A_TRUNCATION_MARKER;
} catch {
prefixBytes -= 1;
}
return A2A_TRUNCATION_MARKER.trimStart();
}
var A2aProtocolError = class extends Error {
constructor(code, message) {
super(message);
this.code = code;
this.name = "A2aProtocolError";
}
};
//#endregion
//#region extensions/a2a/src/http.ts
const MAX_REQUEST_BODY_BYTES = 1048576;
const MAX_RESPONSE_BODY_BYTES = 1048576;
const MAX_BATCH_REQUESTS = 30;
const DEFAULT_REPLY_TIMEOUT_MS = 12e4;
const DEFAULT_RATE_LIMIT_PER_MINUTE = 30;
const RATE_LIMIT_WINDOW_MS = 6e4;
function writeJsonResponse(response, statusCode, value) {
writeJsonBody(response, statusCode, JSON.stringify(value));
}
function writeJsonBody(response, statusCode, body) {
response.statusCode = statusCode;
response.setHeader("content-type", "application/json; charset=utf-8");
response.setHeader("cache-control", "no-store");
response.end(body);
}
function createRpcError(id, code, message) {
return {
jsonrpc: "2.0",
id,
error: {
code,
message
}
};
}
function writeRpcResponse(response, value) {
if (boundedJsonUtf8Bytes(value, MAX_RESPONSE_BODY_BYTES).complete) {
writeJsonBody(response, 200, JSON.stringify(value));
return;
}
const errorMessage = "A2A response exceeds the 1 MiB limit";
const bounded = Array.isArray(value) ? value.map((entry) => createRpcError(entry.id, -32e3, errorMessage)) : createRpcError(value.id, -32e3, errorMessage);
writeJsonResponse(response, 200, boundedJsonUtf8Bytes(bounded, MAX_RESPONSE_BODY_BYTES).complete ? bounded : Array.isArray(bounded) ? bounded.map(() => createRpcError(null, -32e3, errorMessage)) : createRpcError(null, -32e3, errorMessage));
}
function resolvePeerName(request, config) {
const token = request.headers.authorization?.match(/^Bearer\s+([^\s]+)$/i)?.[1];
if (!token) return;
const presentedDigest = createHash("sha256").update(token).digest();
for (const [peerName, peer] of Object.entries(config.peers ?? {})) {
const configuredDigest = createHash("sha256").update(peer.token).digest();
if (timingSafeEqual(presentedDigest, configuredDigest)) return peerName;
}
}
function resolveRequestOrigin(request) {
const encrypted = "encrypted" in request.socket && request.socket.encrypted;
try {
return new URL(`${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`).origin;
} catch {
return `${encrypted ? "https" : "http"}://localhost`;
}
}
function createAgentCard(params, request) {
const exposed = params.a2aConfig.exposeAgents;
const agentIds = listAgentIds(params.config).filter((agentId) => !exposed?.length || exposed.includes(agentId));
return {
name: (agentIds[0] ? resolveAgentConfig(params.config, agentIds[0])?.name?.trim() : void 0) || "OpenClaw",
description: "OpenClaw agent gateway using the Agent2Agent protocol.",
supportedInterfaces: [{
url: `${(params.a2aConfig.advertisedUrl ?? resolveRequestOrigin(request)).replace(/\/+$/, "")}/a2a/v1`,
protocolBinding: "JSONRPC",
protocolVersion: "1.0"
}],
version: params.version,
capabilities: {
streaming: false,
pushNotifications: false
},
defaultInputModes: ["text/plain"],
defaultOutputModes: ["text/plain"],
skills: agentIds.map((agentId) => ({
id: agentId,
name: agentId,
description: `OpenClaw agent ${agentId}.`,
tags: ["openclaw"]
}))
};
}
function createA2aHttpHandler(params) {
const peerRequestTimes = /* @__PURE__ */ new Map();
function isRateLimited(peerName) {
const maximum = params.a2aConfig.rateLimitPerMinute ?? DEFAULT_RATE_LIMIT_PER_MINUTE;
if (maximum === 0) return false;
const now = Date.now();
const requests = (peerRequestTimes.get(peerName) ?? []).filter((timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS);
if (requests.length >= maximum) {
peerRequestTimes.set(peerName, requests);
return true;
}
requests.push(now);
peerRequestTimes.set(peerName, requests);
return false;
}
async function processRpcRequest(input, peerName) {
const parsed = A2aRpcRequestSchema.safeParse(input);
const candidateId = isRecord(input) ? input.id : void 0;
const id = typeof candidateId === "string" || typeof candidateId === "number" ? candidateId : null;
const notification = parsed.success && !Object.hasOwn(parsed.data, "id");
if (isRateLimited(peerName)) return notification ? void 0 : createRpcError(id, -32e3, "Peer is rate limited");
if (!parsed.success) return createRpcError(id, -32600, "Invalid JSON-RPC request");
const request = parsed.data;
let result;
try {
const method = resolveA2aRpcMethod(request.method);
if (method === void 0) throw new A2aProtocolError(-32601, `Method not found: ${request.method}`);
if (method === "unsupported") throw new A2aProtocolError(-32004, "Unsupported operation; supported methods are SendMessage and GetTask");
if (method === "SendMessage") {
const send = A2aSendMessageParamsSchema.safeParse(request.params);
if (!send.success) throw new A2aProtocolError(-32602, "Invalid SendMessage params: message and parts required");
const message = send.data.message;
const text = extractA2aMessageText(message.parts);
if (!text) throw new A2aProtocolError(-32602, "Message must contain at least one usable text part");
const contextId = message.contextId ?? `ctx-${randomUUID()}`;
if (!isA2aContextId(contextId)) throw new A2aProtocolError(-32602, "Invalid message contextId");
const task = params.taskStore.create(contextId, peerName);
params.taskStore.start(task.id);
runDetachedWebhookWork(async () => {
await params.dispatchInbound({
taskId: task.id,
contextId,
messageId: message.messageId ?? randomUUID(),
peerName,
text
});
}).catch((error) => params.taskStore.fail(task.id, error));
if (send.data.configuration?.returnImmediately) result = { task: params.taskStore.get(task.id, peerName) ?? task };
else {
const timeoutMs = params.a2aConfig.replyTimeoutMs ?? DEFAULT_REPLY_TIMEOUT_MS;
result = { task: await params.taskStore.wait(task.id, timeoutMs) ?? params.taskStore.get(task.id, peerName) ?? task };
}
} else {
const taskParams = A2aTaskRequestParamsSchema.safeParse(request.params);
if (!taskParams.success) throw new A2aProtocolError(-32602, "Invalid task params: id is required");
const task = params.taskStore.get(taskParams.data.id, peerName);
if (!task) throw new A2aProtocolError(-32001, "Task not found");
result = task;
}
} catch (error) {
if (notification) return;
if (error instanceof A2aProtocolError) return createRpcError(id, error.code, error.message);
return createRpcError(id, -32e3, "A2A request could not be processed");
}
return notification ? void 0 : {
jsonrpc: "2.0",
id,
result
};
}
return async (request, response) => {
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
if (request.method === "GET" && (pathname === "/.well-known/agent-card.json" || pathname === "/.well-known/agent.json")) {
writeJsonResponse(response, 200, createAgentCard(params, request));
return true;
}
if (request.method !== "POST" || pathname !== "/a2a/v1") {
writeJsonResponse(response, 404, { error: "Not found" });
return true;
}
const peerName = resolvePeerName(request, params.a2aConfig);
if (!peerName) {
writeJsonResponse(response, 401, { error: "Unauthorized; configure channels.a2a.peers with a matching Bearer token" });
return true;
}
let body;
try {
body = await readRequestBodyWithLimit(request, {
maxBytes: MAX_REQUEST_BODY_BYTES,
destroyOnLimit: false
});
} catch (error) {
const bodyRejection = isRequestBodyLimitError(error, "PAYLOAD_TOO_LARGE") ? {
statusCode: 413,
body: { error: "Request body exceeds the 1 MiB limit" }
} : isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT") ? {
statusCode: 200,
body: createRpcError(null, -32e3, "Request body could not be read")
} : void 0;
if (bodyRejection) {
response.setHeader("cache-control", "no-store");
await sendHttpRequestRejection(request, response, bodyRejection.statusCode, JSON.stringify(bodyRejection.body), "application/json; charset=utf-8");
return true;
}
writeJsonResponse(response, 200, createRpcError(null, -32e3, "Request body could not be read"));
return true;
}
let payload;
try {
payload = JSON.parse(body);
} catch {
writeJsonResponse(response, 200, createRpcError(null, -32700, "Parse error"));
return true;
}
if (Array.isArray(payload)) {
if (payload.length === 0) {
writeJsonResponse(response, 200, createRpcError(null, -32600, "Invalid JSON-RPC request"));
return true;
}
if (payload.length > MAX_BATCH_REQUESTS) {
writeRpcResponse(response, isRateLimited(peerName) ? createRpcError(null, -32e3, "Peer is rate limited") : createRpcError(null, -32e3, `A2A batch exceeds the ${MAX_BATCH_REQUESTS} request limit`));
return true;
}
const responses = (await Promise.all(payload.map((entry) => processRpcRequest(entry, peerName)))).filter((entry) => entry !== void 0);
if (responses.length > 0) writeRpcResponse(response, responses);
else {
response.statusCode = 200;
response.end();
}
return true;
}
const result = await processRpcRequest(payload, peerName);
if (result) writeRpcResponse(response, result);
else {
response.statusCode = 200;
response.end();
}
return true;
};
}
//#endregion
//#region extensions/a2a/src/inbound.ts
async function dispatchA2aInbound(params) {
try {
if (params.text.trimStart().startsWith("/")) {
params.store.reject(params.taskId, "A2A peers cannot execute slash commands. Send a task in plain text; only users can issue commands.");
return;
}
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: params.config,
channel: "a2a",
accountId: params.account.accountId,
peer: {
kind: "direct",
id: `${params.peerName}:${params.contextId}`
},
dmScope: "per-account-channel-peer"
});
const ingress = await resolveStableChannelMessageIngress({
channelId: "a2a",
accountId: params.account.accountId,
cfg: params.config,
identity: {
key: "sender",
entryIdPrefix: "a2a-entry"
},
subject: { stableId: params.peerName },
conversation: {
kind: "direct",
id: params.contextId
},
contextBinding: {
agentId: route.agentId,
sessionKey: route.sessionKey,
messageId: params.messageId,
inboundEventKind: "user_request"
},
dmPolicy: "allowlist",
allowFrom: Object.keys(params.account.config.peers ?? {})
});
if (ingress.ingress.admission !== "dispatch") {
params.store.reject(params.taskId, "A2A peer was blocked by channel ingress policy");
return;
}
const timestamp = Date.now();
const target = `a2a:${params.peerName}`;
const body = buildEnvelope({
channel: "A2A",
from: params.peerName,
timestamp,
body: params.text
});
const ctxPayload = params.buildContext({
channel: "a2a",
accountId: route.accountId ?? params.account.accountId,
messageId: params.messageId,
messageIdFull: params.messageId,
timestamp,
from: target,
sender: {
id: params.peerName,
name: params.peerName
},
conversation: {
kind: "direct",
id: params.contextId,
label: params.peerName
},
route: {
agentId: route.agentId,
dmScope: route.dmScope,
accountId: route.accountId,
routeSessionKey: route.sessionKey,
dispatchSessionKey: route.sessionKey
},
reply: {
to: target,
originatingTo: target
},
message: {
body,
bodyForAgent: params.text,
rawBody: params.text,
commandBody: params.text
},
channelIngress: ingress,
extra: { CommandInterpretationSuppressed: true }
});
const dispatch = await params.channelRuntime.inbound.dispatch({
cfg: params.config,
channel: "a2a",
accountId: params.account.accountId,
route: {
agentId: route.agentId,
dmScope: route.dmScope,
sessionKey: route.sessionKey
},
ctxPayload,
delivery: {
deliver: async (payload, info) => {
if (info.kind !== "final") return;
params.store.completeNext(params.contextId, payload.text, params.peerName);
},
onError: (error) => {
params.store.fail(params.taskId, error);
}
},
replyPipeline: {}
});
if (dispatch.admission.kind !== "dispatch") params.store.reject(params.taskId, `A2A channel declined the turn: ${dispatch.admission.kind}`);
else if (!dispatch.dispatched) params.store.fail(params.taskId, "A2A channel accepted the turn without dispatching it");
} catch (error) {
params.store.fail(params.taskId, error);
}
}
//#endregion
//#region extensions/a2a/src/task-store.ts
const A2A_TERMINAL_MAX_TASKS = 500;
const A2A_TERMINAL_RETENTION_MS = 864e5;
const A2A_ERROR_MAX_LENGTH = 512;
function isTerminalTask(task) {
return task.status.state !== "TASK_STATE_SUBMITTED" && task.status.state !== "TASK_STATE_WORKING";
}
function createStatusMessage(contextId, text) {
return {
messageId: randomUUID(),
contextId,
role: "ROLE_AGENT",
parts: [{ text: truncateUtf16Safe(text, A2A_ERROR_MAX_LENGTH) }]
};
}
var A2aTaskStore = class {
#tasks = /* @__PURE__ */ new Map();
#taskOwners = /* @__PURE__ */ new Map();
#pendingByContext = /* @__PURE__ */ new Map();
#terminalTasks = /* @__PURE__ */ new Map();
#waiters = /* @__PURE__ */ new Map();
create(contextId, ownerPeer) {
this.#pruneTerminalTasks();
const task = {
id: randomUUID(),
contextId,
status: {
state: "TASK_STATE_SUBMITTED",
timestamp: (/* @__PURE__ */ new Date()).toISOString()
},
artifacts: [],
history: []
};
this.#tasks.set(task.id, task);
if (ownerPeer !== void 0) this.#taskOwners.set(task.id, ownerPeer);
const conversationKey = this.#conversationKey(contextId, ownerPeer);
const pending = this.#pendingByContext.get(conversationKey) ?? [];
pending.push(task.id);
this.#pendingByContext.set(conversationKey, pending);
return task;
}
get(taskId, ownerPeer) {
this.#pruneTerminalTasks();
if (ownerPeer !== void 0 && this.#taskOwners.get(taskId) !== ownerPeer) return;
return this.#tasks.get(taskId);
}
start(taskId) {
const task = this.#tasks.get(taskId);
if (task?.status.state === "TASK_STATE_SUBMITTED") task.status = {
state: "TASK_STATE_WORKING",
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
return task;
}
completeNext(contextId, text, ownerPeer) {
const conversationKey = this.#conversationKey(contextId, ownerPeer);
const queue = this.#pendingByContext.get(conversationKey);
if (!queue?.length) return;
const nextTaskId = queue.shift();
if (queue.length === 0) this.#pendingByContext.delete(conversationKey);
if (!nextTaskId) return;
const task = this.#tasks.get(nextTaskId);
if (!task || isTerminalTask(task)) return;
if (text?.trim()) task.artifacts = [{
artifactId: randomUUID(),
parts: [{ text }]
}];
task.status = {
state: "TASK_STATE_COMPLETED",
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
...!text?.trim() ? { message: createStatusMessage(contextId, "Agent completed without reply text") } : {}
};
return this.#finishTask(task);
}
fail(taskId, error) {
const reason = error instanceof Error ? error.message : String(error);
return this.#finishWithMessage(taskId, "TASK_STATE_FAILED", reason);
}
reject(taskId, reason) {
return this.#finishWithMessage(taskId, "TASK_STATE_REJECTED", reason);
}
wait(taskId, timeoutMs) {
const task = this.get(taskId);
if (!task || isTerminalTask(task)) return Promise.resolve(task);
return new Promise((resolve) => {
const waiters = this.#waiters.get(taskId) ?? /* @__PURE__ */ new Set();
const waiter = {
resolve,
timer: setTimeout(() => {
waiters.delete(waiter);
if (waiters.size === 0) this.#waiters.delete(taskId);
resolve(task);
}, timeoutMs)
};
waiters.add(waiter);
this.#waiters.set(taskId, waiters);
});
}
stop() {
for (const [taskId, waiters] of this.#waiters) {
const task = this.#tasks.get(taskId);
for (const waiter of waiters) {
clearTimeout(waiter.timer);
if (task) waiter.resolve(task);
}
}
this.#waiters.clear();
this.#pendingByContext.clear();
this.#terminalTasks.clear();
this.#taskOwners.clear();
this.#tasks.clear();
}
#finishWithMessage(taskId, state, reason) {
const task = this.#tasks.get(taskId);
if (!task || isTerminalTask(task)) return task;
const conversationKey = this.#conversationKey(task.contextId, this.#taskOwners.get(task.id));
const queue = this.#pendingByContext.get(conversationKey);
if (queue) {
const position = queue.indexOf(taskId);
if (position !== -1) queue.splice(position, 1);
if (queue.length === 0) this.#pendingByContext.delete(conversationKey);
}
task.status = {
state,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
message: createStatusMessage(task.contextId, reason)
};
return this.#finishTask(task);
}
#finishTask(task) {
this.#terminalTasks.set(task.id, Date.now());
const waiters = this.#waiters.get(task.id);
if (waiters) {
this.#waiters.delete(task.id);
for (const waiter of waiters) {
clearTimeout(waiter.timer);
waiter.resolve(task);
}
}
this.#pruneTerminalTasks();
return task;
}
#pruneTerminalTasks() {
const expiresBefore = Date.now() - A2A_TERMINAL_RETENTION_MS;
for (const [taskId, finishedAt] of this.#terminalTasks) {
if (finishedAt > expiresBefore && this.#terminalTasks.size <= A2A_TERMINAL_MAX_TASKS) break;
this.#terminalTasks.delete(taskId);
this.#taskOwners.delete(taskId);
this.#tasks.delete(taskId);
}
}
#conversationKey(contextId, ownerPeer) {
return ownerPeer === void 0 ? contextId : `${ownerPeer}\0${contextId}`;
}
};
//#endregion
//#region extensions/a2a/src/gateway.ts
const a2aGatewayRoutePaths = [
"/.well-known/agent-card.json",
"/.well-known/agent.json",
"/a2a/v1"
];
async function startA2aGatewayAccount(ctx) {
const { account } = ctx;
if (!account.configured) throw new Error(`A2A channel is not configured for account "${account.accountId}"`);
ctx.setStatus({
accountId: account.accountId,
running: true,
lifecycle: "starting",
configured: true,
enabled: account.enabled
});
const runtime = getA2aChannelRuntime();
const channelRuntime = ctx.channelRuntime ?? runtime.channel;
const store = new A2aTaskStore();
const unregisterRoutes = [];
try {
const handler = createA2aHttpHandler({
config: ctx.cfg,
a2aConfig: account.config,
version: runtime.version,
taskStore: store,
dispatchInbound: async (message) => {
await dispatchA2aInbound({
...message,
account,
config: ctx.cfg,
channelRuntime,
buildContext: channelRuntime.inbound.buildContext,
store
});
}
});
for (const routePath of a2aGatewayRoutePaths) unregisterRoutes.push(registerPluginHttpRoute({
path: routePath,
auth: "plugin",
match: "exact",
pluginId: "a2a",
source: "a2a-gateway",
accountId: account.accountId,
throwOnFailure: true,
handler
}));
ctx.setStatus(channelReadyPatch({ accountId: account.accountId }));
await waitUntilAbort(ctx.abortSignal);
} finally {
for (const unregister of unregisterRoutes.toReversed()) unregister();
store.stop();
ctx.setStatus(channelStoppedPatch({ accountId: account.accountId }));
}
}
//#endregion
//#region extensions/a2a/src/outbound.ts
const A2A_OUTBOUND_TIMEOUT_MS = 3e4;
const A2aOutboundResponseSchema = object({
error: object({
code: number(),
message: string()
}).optional(),
result: object({ task: object({ id: string().optional() }).optional() }).optional()
});
async function sendA2aChannelText(params) {
const account = resolveA2aChannelAccount({
cfg: params.cfg,
accountId: params.accountId
});
const peerName = params.to.replace(/^a2a:/i, "").trim();
const peer = account.config.peers?.[peerName];
if (!peer?.url) throw new Error(`peer ${peerName} has no url configured for outbound A2A`);
const messageId = randomUUID();
const requestId = randomUUID();
const headers = { "content-type": "application/json" };
if (peer.outboundToken) headers.authorization = `Bearer ${peer.outboundToken}`;
const request = {
jsonrpc: "2.0",
id: requestId,
method: "SendMessage",
params: {
message: {
messageId,
role: "ROLE_USER",
contextId: `ctx-oc-${peerName}`,
parts: [{ text: params.text }]
},
configuration: { returnImmediately: true }
}
};
const signal = AbortSignal.timeout(A2A_OUTBOUND_TIMEOUT_MS);
for (let attempt = 0; attempt < 2; attempt += 1) {
const { response, release } = await fetchWithSsrFGuard({
url: peer.url,
timeoutMs: A2A_OUTBOUND_TIMEOUT_MS,
signal,
policy: ssrfPolicyFromHttpBaseUrlAllowedOrigin(peer.url),
auditContext: "a2a.outbound_send",
maxRedirects: 0,
init: {
method: "POST",
headers,
body: JSON.stringify(request)
}
});
try {
if (!response.ok) throw new Error(`outbound A2A request to peer ${peerName} failed (HTTP ${response.status})`);
const parsed = A2aOutboundResponseSchema.safeParse(await readProviderJsonResponse(response, `peer ${peerName} A2A response`, { requestHeaders: headers }));
if (!parsed.success) throw new Error(`peer ${peerName} returned an invalid A2A JSON-RPC response`);
if (parsed.data.error) {
if (attempt === 0 && parsed.data.error.code === -32601) {
request.method = "message/send";
continue;
}
throw new Error(`outbound A2A request to peer ${peerName} failed: ${parsed.data.error.message}`);
}
if (!parsed.data.result) throw new Error(`peer ${peerName} returned an A2A response without a result`);
return {
to: params.to,
messageId: parsed.data.result.task?.id ?? messageId
};
} finally {
await release();
}
}
throw new Error(`outbound A2A request to peer ${peerName} exhausted its compatibility retry`);
}
//#endregion
//#region extensions/a2a/src/status.ts
const a2aChannelStatus = createComputedAccountStatusAdapter({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false
}),
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
extra: { peerCount: Object.keys(account.config.peers ?? {}).length }
})
});
//#endregion
//#region extensions/a2a/src/channel.ts
function normalizeA2aChannelTarget(raw) {
const target = raw.trim().replace(/^a2a:/i, "");
return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(target) ? target : void 0;
}
const a2aChannelMessageAdapter = defineChannelMessageAdapter({
id: "a2a",
durableFinal: { capabilities: { text: true } },
send: { text: async (ctx) => {
const result = await sendA2aChannelText({
cfg: ctx.cfg,
accountId: ctx.accountId,
to: ctx.to,
text: ctx.text
});
return {
messageId: result.messageId,
receipt: createMessageReceiptFromOutboundResults({
results: [{
channel: "a2a",
messageId: result.messageId
}],
kind: "text"
})
};
} }
});
const a2aChannelPlugin = createChatChannelPlugin({
base: {
...createA2aChannelPluginBase(),
messaging: {
normalizeTarget: normalizeA2aChannelTarget,
inferTargetChatType: () => "direct",
targetResolver: {
looksLikeId: (raw) => normalizeA2aChannelTarget(raw) !== void 0,
hint: "<a2a-peer-name>"
},
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
const peerName = normalizeA2aChannelTarget(target);
if (!peerName) return null;
return buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: "a2a",
accountId,
recipientSessionExact: true,
peer: {
kind: "direct",
id: peerName
},
chatType: "direct",
from: `a2a:${accountId ?? "default"}`,
to: peerName
});
}
},
status: a2aChannelStatus,
gateway: { startAccount: async (ctx) => await startA2aGatewayAccount(ctx) },
message: a2aChannelMessageAdapter
},
outbound: {
base: { deliveryMode: "direct" },
attachedResults: {
channel: "a2a",
sendText: async ({ cfg, to, text, accountId }) => await sendA2aChannelText({
cfg,
accountId,
to,
text
})
}
}
});
//#endregion
export { a2aChannelPlugin as t };