/*
RFC-1114: the bounded A2A v1.0 subset the gate speaks — one JSON-RPC method
(SendMessage), the message/task shapes, and the protocol-version pin. Owning
this small subset (rather than a full A2A SDK) bounds the protocol surface to
exactly what conformance fixtures pin — the same pattern as the MCP subset
(RFC-0290).
- Do not model GetTask, CancelTask, streaming (SSE), push notifications,
or task persistence — the server is synchronous; a SendMessage resolves
to a terminal-state Task in one round-trip.
- Do not retain A2A 0.x semantics — the card pins protocolVersion 1.0;
older clients get VersionNotSupportedError.
- RFC-1114: initial A2A protocol subset — SendMessage, message/task shapes, version pin + header gate.
- RFC-1114: minimal real A2A endpoint + honest agent card
*/
import {
JSON_RPC_ERROR,
jsonRpcError,
type JsonRpcError,
type JsonRpcId,
} from "../mcp/protocol.ts";
/** RFC-1114: the only A2A protocol version this gate serves. */
export const PINNED_A2A_PROTOCOL_VERSION = "1.0";
/** The HTTP header carrying the client's A2A protocol version (A2A v1.0 spec). */
export const A2A_VERSION_HEADER = "A2A-Version";
/** The single JSON-RPC method of the pinned subset (A2A v1.0 name — `message/send` is the 0.3 name). */
export const A2A_METHOD_SEND_MESSAGE = "SendMessage";
/** A2A v1.0 error code for a request whose declared protocol version is unsupported. */
export const A2A_ERROR_VERSION_NOT_SUPPORTED = -32008;
/**
* RFC-1114: the A2A-Version header gate. A present header must match `1.x`;
* an absent header is served as 1.0 (a 1.0-only server has no 0.3 semantics
* to apply — documented deviation from the spec's "empty means 0.3" default).
*/
export function isSupportedA2aVersionHeader(headerValue: string | null): boolean {
if (headerValue === null) return true;
const trimmed = headerValue.trim();
return trimmed === "1" || trimmed.startsWith("1.");
}
/** A2A v1.0 VersionNotSupportedError — names the version this server speaks. */
export function a2aVersionNotSupported(id: JsonRpcId, requested: string): JsonRpcError {
return jsonRpcError(id, A2A_ERROR_VERSION_NOT_SUPPORTED, "A2A protocol version not supported", {
supported: [PINNED_A2A_PROTOCOL_VERSION],
requested,
});
}
// ---------------------------------------------------------------------------
// Message + task shapes (A2A v1.0)
// ---------------------------------------------------------------------------
export interface A2ATextPart {
kind: "text";
text: string;
}
export interface A2ADataPart {
kind: "data";
data: Record;
}
export type A2APart = A2ATextPart | A2ADataPart;
export interface A2AMessage {
/** Required in v1.0 — a missing messageId is -32602. */
messageId: string;
role: "user";
/** Echoed back on the task when provided; no multi-turn state is kept. */
contextId?: string;
parts: A2APart[];
metadata?: Record;
}
export type A2ATaskState = "completed" | "failed" | "rejected";
export interface A2ATaskStatus {
state: A2ATaskState;
/** ISO 8601 — stamped from ports.now(). */
timestamp: string;
}
export interface A2AArtifact {
name: string;
parts: { kind: "data"; data: unknown }[];
}
export interface A2ATask {
/** Derived from ActionReceipt.receiptId — correlatable with receipts. */
id: string;
contextId?: string;
status: A2ATaskStatus;
artifacts?: A2AArtifact[];
}
/**
* Skill routing convention: a DataPart `{ "capability": "", "input": {...} }`
* selects the action. A message with no capability DataPart is rejected with
* -32602 — free-text search is already served by interfaces.search; the A2A
* surface stays honest about what it can execute.
*/
export interface A2ACapabilityInvocation {
capabilityId: string;
input: Record;
}
/** Extract the capability invocation from message parts, or null when absent/malformed. */
export function readCapabilityInvocation(message: A2AMessage): A2ACapabilityInvocation | null {
for (const part of message.parts) {
if (part.kind !== "data") continue;
const capability = part.data["capability"];
if (typeof capability !== "string" || capability.length === 0) continue;
const input = part.data["input"];
return {
capabilityId: capability,
input:
typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record)
: {},
};
}
return null;
}
/** Minimal structural check for the SendMessage `message` param. */
export function isA2aMessage(value: unknown): value is A2AMessage {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const v = value as Record;
if (typeof v.messageId !== "string" || v.messageId.length === 0) return false;
if (v.role !== "user") return false;
if (!Array.isArray(v.parts)) return false;
return v.parts.every((part) => {
if (typeof part !== "object" || part === null) return false;
const p = part as Record;
if (p.kind === "text") return typeof p.text === "string";
if (p.kind === "data") return typeof p.data === "object" && p.data !== null;
return false;
});
}
/** Build a terminal-state task. `id` is the receiptId on dispatch, a fresh UUID otherwise. */
export function buildA2aTask(input: {
id: string;
contextId?: string;
state: A2ATaskState;
timestamp: string;
artifacts?: A2AArtifact[];
}): A2ATask {
return {
id: input.id,
...(input.contextId !== undefined ? { contextId: input.contextId } : {}),
status: { state: input.state, timestamp: input.timestamp },
...(input.artifacts && input.artifacts.length > 0 ? { artifacts: input.artifacts } : {}),
};
}
export { JSON_RPC_ERROR, jsonRpcError };
export type { JsonRpcId, JsonRpcRequest, JsonRpcResponse } from "../mcp/protocol.ts";