/* RFC-1114: the JSON-RPC method router for the bounded A2A v1.0 subset. Handles exactly SendMessage: a capability DataPart selects the action, the shared validate→dispatch→receipt pipeline (RFC-1112) executes it, and the answer is a terminal-state Task whose artifact carries the ActionReceipt. Everything else is method-not-found (batching is rejected one level up, at the transport boundary in index.ts). Do not implement GetTask/CancelTask/streaming/push — out of the pinned subset. Do not enforce auth — only rate limits and size caps (RFC-0291 parity). Do not reroute free-text messages to search — a missing capability DataPart is -32602, honest about what the surface can execute. RFC-1114: initial A2A SendMessage handler over the shared action pipeline. RFC-1114: minimal real A2A endpoint + honest agent card RFC-1114: review findings — schema-violation for malformed message, drop unreachable fallback */ import type { AgentSurfaceManifest } from "@warpgogol/werkstatt-shared/agent"; import { buildAgentProblem, type AgentProblemDetails } from "@warpgogol/werkstatt-shared/agent"; import type { CapabilityRecord } from "@warpgogol/werkstatt-shared/ontology"; import type { AgentGatePorts } from "../ports.ts"; import type { VerifiedAgent } from "@warpgogol/werkstatt-shared/agent"; import type { RateLimiter } from "../limits.ts"; import { validateAgainstCapabilitySchema } from "../actions.ts"; import { buildPreviewFailure, checkIdempotencyReplay, executeAction, mcpBodyHash, rateLimitKey, rateLimitMax, } from "../action-pipeline.ts"; import { A2A_METHOD_SEND_MESSAGE, buildA2aTask, isA2aMessage, readCapabilityInvocation, type A2AMessage, type A2ATask, } from "./protocol.ts"; import { JSON_RPC_ERROR, jsonRpcError, jsonRpcSuccess, type JsonRpcRequest, type JsonRpcResponse, } from "../mcp/protocol.ts"; export interface A2aHandlerContext { manifest: AgentSurfaceManifest; catalog: CapabilityRecord[]; ports: AgentGatePorts; /** RFC-0291: identity headers extracted from the HTTP request (observe-only). */ agentIdentity?: Record; /** RFC-0291: client IP for rate-limit keying. */ clientIp?: string; /** RFC-1112: the Idempotency-Key HTTP header, passed through by the transport boundary. */ idempotencyKey?: string; /** * RFC-1187: Bearer identity resolved at the transport boundary — selects * the elevated per-agent bucket and stamps verifiedAgentId, same contract * as the HTTP action route. */ verifiedAgent?: VerifiedAgent; /** RFC-1213: verifiable presentation extracted at the transport boundary (VP header). */ presentation?: unknown; } // RFC-0291: module-level limiter cache for the A2A path (mirrors mcp/handler.ts). const a2aLimiterCache = new Map(); function getA2aLimiter( capabilityId: string, maxPerWindow: number, factory: (max: number) => RateLimiter, ): RateLimiter { const cacheKey = `${capabilityId}:${maxPerWindow}`; let limiter = a2aLimiterCache.get(cacheKey); if (!limiter) { limiter = factory(maxPerWindow); a2aLimiterCache.set(cacheKey, limiter); } return limiter; } /** Reset the A2A limiter cache — test-only. */ export function __resetA2aLimiterCache(): void { a2aLimiterCache.clear(); } function problemError(id: JsonRpcRequest["id"], problem: AgentProblemDetails): JsonRpcResponse { return jsonRpcError(id ?? null, JSON_RPC_ERROR.SERVER_ERROR, problem.title, problem); } /** Wrap a terminal-state task in a JSON-RPC success envelope. */ function taskResult(id: JsonRpcRequest["id"], task: A2ATask): JsonRpcResponse { return jsonRpcSuccess(id ?? null, task); } async function handleSendMessage( req: JsonRpcRequest, ctx: A2aHandlerContext, ): Promise { const id = req.id ?? null; const params = (req.params ?? {}) as { message?: unknown; configuration?: unknown }; // SendMessageConfiguration.blocking is ignored — the server is always synchronous. if (!isA2aMessage(params.message)) { const problem = buildAgentProblem("schema-violation", { detail: 'params.message must be an A2A Message: messageId (string), role "user", parts[].', }); return jsonRpcError(id, JSON_RPC_ERROR.INVALID_PARAMS, problem.title, problem); } const message: A2AMessage = params.message; const timestamp = ctx.ports.now().toISOString(); const invocation = readCapabilityInvocation(message); if (!invocation) { const problem = buildAgentProblem("schema-violation", { detail: 'No capability DataPart — send a part { kind: "data", data: { capability: "", input: {...} } }.', }); return jsonRpcError(id, JSON_RPC_ERROR.INVALID_PARAMS, problem.title, problem); } const capability = ctx.catalog.find((c) => c.id === invocation.capabilityId); const ref = ctx.manifest.actions.find((a) => a.id === invocation.capabilityId); if (!capability || !ref) { const problem = buildAgentProblem("unknown-capability", { detail: `No active capability "${invocation.capabilityId}".`, }); return jsonRpcError(id, JSON_RPC_ERROR.INVALID_PARAMS, problem.title, problem); } // RFC-0291: payload size cap on the action input (MCP-path parity). const argsBytes = new TextEncoder().encode(JSON.stringify(invocation.input)).length; if (argsBytes > capability.limits.maxPayloadBytes) { const problem = buildAgentProblem("payload-too-large", { detail: `Input is ${argsBytes} bytes; the limit is ${capability.limits.maxPayloadBytes}.`, }); return problemError(id, problem); } // RFC-1112: the Idempotency-Key HTTP header travels via ctx (A2A has no // per-message key field in the pinned subset); the body hash is the // canonical JSON hash of the input — same as the MCP path. const bodyHash = ctx.idempotencyKey ? mcpBodyHash(invocation.input) : undefined; // RFC-1112: replay check runs before rate limiting — a replay must not // consume budget. A replayed receipt completes the task as a duplicate. const replay = await checkIdempotencyReplay({ ports: ctx.ports, capability, idempotencyKey: ctx.idempotencyKey, bodyHash, }); if (replay !== null) { if ("receiptId" in replay) { return taskResult( id, buildA2aTask({ id: replay.receiptId, contextId: message.contextId, state: "completed", timestamp, artifacts: [{ name: "receipt", parts: [{ kind: "data", data: replay }] }], }), ); } return problemError(id, replay as AgentProblemDetails); } // RFC-1187: elevated per-agent bucket when a Bearer token verified and the // capability declares perMinutePerAgent — same helpers as the HTTP path. if (ctx.ports.createRateLimiter) { const subject = { capabilityId: capability.id, capability, clientIp: ctx.clientIp ?? "unknown", verifiedAgent: ctx.verifiedAgent, }; const limiter = getA2aLimiter( capability.id, rateLimitMax(subject), ctx.ports.createRateLimiter, ); const result = limiter.check(rateLimitKey(subject)); if (!result.allowed) { const problem = buildAgentProblem("rate-limited", { retryAfterSeconds: result.retryAfterSeconds, }); return problemError(id, problem); } } const validated = validateAgainstCapabilitySchema(capability.input, invocation.input); if (!validated.ok) { // RFC-1169: preview capabilities answer validation failures with the // readiness envelope — the dry-run itself completed, so the task is // "completed" with a ready:false preview artifact (identical fields to // the HTTP path), not a rejected task. if (capability.sideEffect === "none") { return taskResult( id, buildA2aTask({ id: crypto.randomUUID(), contextId: message.contextId, state: "completed", timestamp, artifacts: [ { name: "preview", parts: [{ kind: "data", data: buildPreviewFailure(validated.errors) }], }, ], }), ); } // The message was understood; the task is refused before dispatch — // a rejected task carries the problem details in its artifact. const problem = buildAgentProblem("schema-violation", { schemaRef: `/.well-known/agent.openapi.json#/components/schemas/${capability.id}-input`, errors: validated.errors, }); return taskResult( id, buildA2aTask({ id: crypto.randomUUID(), contextId: message.contextId, state: "rejected", timestamp, artifacts: [{ name: "problem", parts: [{ kind: "data", data: problem }] }], }), ); } // RFC-1112: shared execution core — preview for sideEffect "none", // dispatch + receipt otherwise. const result = await executeAction({ capability, manifest: ctx.manifest, ports: ctx.ports, validatedValue: validated.value, agentIdentity: ctx.agentIdentity ?? {}, idempotencyKey: ctx.idempotencyKey, bodyHash, presentation: ctx.presentation, }); switch (result.kind) { case "receipt": return taskResult( id, buildA2aTask({ id: result.receipt.receiptId, contextId: message.contextId, state: "completed", timestamp, artifacts: [{ name: "receipt", parts: [{ kind: "data", data: result.receipt }] }], }), ); case "preview": return taskResult( id, buildA2aTask({ id: crypto.randomUUID(), contextId: message.contextId, state: "completed", timestamp, artifacts: [ { name: "preview", parts: [{ kind: "data", data: result.envelope }], }, ], }), ); case "problem": // Dispatch-level failure is a failed task, never a silent 200. return taskResult( id, buildA2aTask({ id: crypto.randomUUID(), contextId: message.contextId, state: "failed", timestamp, artifacts: [{ name: "problem", parts: [{ kind: "data", data: result.problem }] }], }), ); } } /** Route one JSON-RPC request to its handler. The transport boundary (index.ts) owns batching/HTTP-method rejection and the A2A-Version header gate. */ export async function handleA2aRequest( req: JsonRpcRequest, ctx: A2aHandlerContext, ): Promise { if (req.method !== A2A_METHOD_SEND_MESSAGE) { return jsonRpcError( req.id ?? null, JSON_RPC_ERROR.METHOD_NOT_FOUND, `Method "${req.method}" not found.`, ); } return handleSendMessage(req, ctx); }