UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

166 lines • 7.98 kB
/** * adapters/hosting/a2aWire — the A2A protocol, as an `HttpWire`. * * ── What this is ───────────────────────────────────────────────────────────── * **A2A** (agent-to-agent) is an open protocol for one agent calling another: * JSON-RPC 2.0 over HTTP, a `message/send` method carrying text parts, a result * carrying artifacts, and a discovery document — the **agent card** — that says * what the agent is and what it can do. It is nobody's product; several * runtimes speak it, so it lives here on its own and the runtimes that host it * configure this rather than reimplement it. * * ── The subset, stated plainly ─────────────────────────────────────────────── * One method: `message/send`, text parts only. Deliberately NOT carried: * `message/stream` and the rest of the task lifecycle (`tasks/get`, * `tasks/cancel`, push notifications), non-text parts, and multi-turn task * state. An agent card built here therefore declares `streaming: false` by * default — advertising a capability this wire cannot honour is how a caller * finds out by hanging. * * ── Why it fits `httpHost` without changing it ─────────────────────────────── * JSON-RPC's one hard requirement on a reply is that it ECHOES the request's * `id`. That is possible here only because `HttpWire`'s body methods receive * the request that produced them — a seam added for a different protocol * entirely, and the reason this one needed no new machinery. * * @example A2A on paths of your own choosing * httpHost({ * name: 'myA2AHost', * wire: a2aWire({ card: { name: 'triage', description: '…', version: '1.0.0' } }), * invokePath: '/', * healthPath: '/health', * }); */ import { WireRequestRefusal } from '../../hosting/errors.js'; /** The A2A protocol revision this wire's documents declare. */ export const A2A_PROTOCOL_VERSION = '0.3.0'; /** Where the A2A specification puts an agent's discovery document. */ export const A2A_AGENT_CARD_PATH = '/.well-known/agent-card.json'; /** The one method this wire carries. */ export const A2A_SEND_METHOD = 'message/send'; /** * JSON-RPC's own reserved codes, the two this wire can raise. * * `-32601` is "method not found" and `-32602` is "invalid params" — both from * the JSON-RPC specification rather than from A2A or any runtime, which is why * they are the only numbers this neutral file knows. A runtime's own error * codes belong to that runtime's adapter. */ export const JSONRPC_METHOD_NOT_FOUND = -32601; export const JSONRPC_INVALID_PARAMS = -32602; export const JSONRPC_INTERNAL_ERROR = -32603; function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** The request's JSON-RPC id, echoed onto every reply. `null` is legal and distinct from absent. */ function requestId(facts) { const id = facts?.body.id; return typeof id === 'string' || typeof id === 'number' ? id : null; } /** * The text of one A2A message — every part concatenated, refusing what is not text. * * A non-text part is refused rather than skipped for the reason every dialect * in this library refuses rather than skips: an answer produced from the parts * that happened to be understood is a wrong answer delivered confidently. */ export function readA2AMessageText(message) { if (!isRecord(message)) { throw new WireRequestRefusal('invalid_params', 'params.message must be an object', 400); } const parts = message.parts; if (!Array.isArray(parts) || parts.length === 0) { throw new WireRequestRefusal('invalid_params', 'params.message.parts must be a non-empty array', 400); } const text = []; for (const part of parts) { if (!isRecord(part) || part.kind !== 'text' || typeof part.text !== 'string') { const kind = isRecord(part) && typeof part.kind === 'string' ? part.kind : 'unknown'; throw new WireRequestRefusal('unsupported_part', `this agent carries text parts only; '${kind}' parts are not supported`, 400); } text.push(part.text); } return text.join(''); } /** * The agent card document, as the protocol prints it. * * Exported on its own because a deployment often has to serve the card from * somewhere this wire does not own — a CDN, a route in a framework, a runtime's * own discovery API — and the document should be built once, here, rather than * hand-copied into each of those places. */ export function a2aAgentCardDocument(card) { return { name: card.name, description: card.description, version: card.version, ...(card.url !== undefined && { url: card.url }), protocolVersion: A2A_PROTOCOL_VERSION, preferredTransport: 'JSONRPC', capabilities: { streaming: card.streaming === true }, defaultInputModes: card.defaultInputModes ?? ['text'], defaultOutputModes: card.defaultOutputModes ?? ['text'], skills: (card.skills ?? []).map((s) => ({ id: s.id, name: s.name, description: s.description, tags: s.tags ?? [], })), }; } /** An `HttpWire` speaking A2A's `message/send`. */ export function a2aWire(options) { const health = options.health ?? { status: 'ok' }; const name = options.name ?? 'a2aWire'; const codeFor = options.errorCodeFor ?? (() => JSONRPC_INTERNAL_ERROR); const envelope = (facts, body) => ({ jsonrpc: '2.0', id: requestId(facts), ...body, }); return { readRequest(facts) { const { body } = facts; if (body.jsonrpc !== '2.0') { throw new WireRequestRefusal('invalid_request', `${name}: every request must carry "jsonrpc": "2.0"`, 400); } if (body.method !== A2A_SEND_METHOD) { // Named rather than ignored: a caller using a method this agent does // not implement learns which one it does, instead of receiving an // empty answer to a question that was never asked. throw new WireRequestRefusal('method_not_found', `${name}: only '${A2A_SEND_METHOD}' is implemented; received '${String(body.method)}'`, 404); } const params = isRecord(body.params) ? body.params : undefined; const input = readA2AMessageText(params?.message); if (input.trim() === '') { throw new WireRequestRefusal('invalid_params', `${name}: message text must not be empty`, 400); } // A2A carries no session of its own — the transport hosting it does, and // the runtime adapter reads it from wherever that transport puts it. return { input }; }, health: () => health, output: (output, facts) => envelope(facts, { result: { artifacts: [ { artifactId: String(facts?.body.id ?? 'artifact'), name: 'agent_response', parts: [{ kind: 'text', text: output }], }, ], }, }), chunk: (text) => ({ kind: 'text', text }), failure: (message, code, facts, origin) => envelope(facts, { error: { code: codeFor(code), // A thrown exception's words are the author's note to their own logs. // A refusal this library authored was written to be read. message: origin === 'threw' ? 'The agent could not complete this request.' : message, }, }), }; } //# sourceMappingURL=a2aWire.js.map