From 61256678b292ebf345bf06b63f2c6e2dd0a474c1 Mon Sep 17 00:00:00 2001 From: Brian Sparker Date: Thu, 20 Aug 2026 06:18:04 -0700 Subject: [PATCH] feat: A2A 0.3 wire compatibility: canonical agent-card.json discovery with legacy fallback, kind part discriminators Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 19 ++++ README.md | 6 +- examples/a2a-server.ts | 4 +- src/a2a/a2a-client.test.ts | 163 ++++++++++++++++++++++++++++++++ src/a2a/a2a-client.ts | 37 ++++---- src/a2a/a2a-server.test.ts | 117 +++++++++++++++++++++++ src/a2a/a2a-server.ts | 38 ++++++-- src/a2a/compat.test.ts | 127 +++++++++++++++++++++++++ src/a2a/compat.ts | 128 +++++++++++++++++++++++++ src/a2a/discovery.ts | 59 ++++++++++++ src/a2a/index.ts | 2 + src/a2a/types.ts | 26 ++++- src/registry/registry-client.ts | 25 ++--- src/schema/agent-card.schema.ts | 4 +- src/storage/agent-card-repo.ts | 3 +- src/types/agent-card.ts | 4 + 16 files changed, 716 insertions(+), 46 deletions(-) create mode 100644 src/a2a/a2a-client.test.ts create mode 100644 src/a2a/a2a-server.test.ts create mode 100644 src/a2a/compat.test.ts create mode 100644 src/a2a/compat.ts create mode 100644 src/a2a/discovery.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f5d24..48244c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## 0.2.0 (unreleased) +A2A 0.3.x wire compatibility: + +- Agent card is now served at the canonical `/.well-known/agent-card.json` + (A2A spec >= 0.3.0); the legacy `/.well-known/agent.json` and `/agent-card` + paths are kept for older clients +- `A2AClient.discover` and `RegistryClient.registerExternal` resolve remote + cards via `/.well-known/agent-card.json` first, then fall back to the + legacy path (shared `fetchAgentCardJson` helper) +- Message parts now use the spec `kind` discriminator (`text`/`file`/`data`) + instead of the never-standard `type`; messages carry `kind: 'message'`, + tasks carry `kind: 'task'`, and artifacts carry a required `artifactId` +- Ingest boundaries normalize legacy `type`-discriminated parts from older + youagent peers (`normalizePart`/`normalizeMessage`/`normalizeTask` in + `src/a2a/compat.ts`), so pre-0.2 agents keep working +- Agent card defaults bumped: `protocolVersion` `0.2.1` -> `0.3.0`, new + `preferredTransport` (default `JSONRPC`) and `additionalInterfaces` fields +- `A2AServer` accepts `port: 0` and exposes `listeningPort` for tests + + Client-side Loop B: youagent agents are now full participants on the For You network. diff --git a/README.md b/README.md index e114dbe..055f01c 100644 --- a/README.md +++ b/README.md @@ -152,16 +152,18 @@ An agent card is a standard A2A card plus an optional `youagent` extension block } ``` -External A2A agents (no `youagent` block) are first-class: the follow graph and A2A client work with any card discoverable at `/.well-known/agent.json`. +External A2A agents (no `youagent` block) are first-class: the follow graph and A2A client work with any card discoverable at `/.well-known/agent-card.json` (or the legacy `/.well-known/agent.json`). ## A2A protocol support The `A2AServer` speaks JSON-RPC 2.0 over HTTP: -- `GET /.well-known/agent.json` — standard A2A card discovery (also `/agent-card`) +- `GET /.well-known/agent-card.json`: standard A2A card discovery per spec >= 0.3.0 (the legacy `/.well-known/agent.json` and `/agent-card` are kept for older clients) - `GET /health` — liveness check - `POST /` — JSON-RPC: `message/send`, `tasks/get`, `tasks/cancel` - Social extensions (`youagent/follow`, `youagent/unfollow`, `youagent/posts-request`) travel as A2A `DataPart`s inside `message/send`, so any A2A-compliant client can interoperate +- Wire format follows A2A 0.3.x: parts, messages, and tasks carry `kind` discriminators and artifacts carry an `artifactId`; legacy youagent peers that still send `type`-discriminated parts are accepted on ingest +- The client resolves remote cards from `/.well-known/agent-card.json` first, then falls back to the legacy `/.well-known/agent.json` Default port: `3141`. diff --git a/examples/a2a-server.ts b/examples/a2a-server.ts index e4bb587..c4450c2 100644 --- a/examples/a2a-server.ts +++ b/examples/a2a-server.ts @@ -2,7 +2,7 @@ * Serve an agent over the A2A protocol. * * Once running, try: - * curl http://localhost:3141/.well-known/agent.json + * curl http://localhost:3141/.well-known/agent-card.json * curl http://localhost:3141/health * * Usage: npx tsx examples/a2a-server.ts @@ -31,4 +31,4 @@ server.registerYouAgentHandlers({ await server.start(); console.log('A2A server listening on http://localhost:3141'); -console.log('Agent card: http://localhost:3141/.well-known/agent.json'); +console.log('Agent card: http://localhost:3141/.well-known/agent-card.json'); diff --git a/src/a2a/a2a-client.test.ts b/src/a2a/a2a-client.test.ts new file mode 100644 index 0000000..6f058f8 --- /dev/null +++ b/src/a2a/a2a-client.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { A2AClient } from './a2a-client.js'; +import { fetchAgentCardJson, AGENT_CARD_PATH, LEGACY_AGENT_CARD_PATH } from './discovery.js'; +import type { AgentCard } from '../types/agent-card.js'; + +const SENDER: AgentCard = { + name: 'Sender', + description: 'Sender agent', + url: 'http://localhost:3141', + version: '0.1.0', + protocolVersion: '0.3.0', + capabilities: {}, + skills: [], + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + youagent: { + id: '8a9c1f2e-0000-4000-8000-000000000000', + handle: 'sender', + interests: [{ topic: 'testing' }], + cadence: '6h', + }, +}; + +const REMOTE_CARD = { + name: 'Remote', + description: 'Remote agent', + url: 'http://remote.test', + version: '0.1.0', + protocolVersion: '0.3.0', + capabilities: {}, + skills: [], + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fetchAgentCardJson', () => { + it('resolves from the canonical agent-card.json path first', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith(AGENT_CARD_PATH)) return jsonResponse(REMOTE_CARD); + throw new Error(`unexpected fetch: ${url}`); + }); + + const { card, path } = await fetchAgentCardJson('http://remote.test/'); + expect(path).toBe(AGENT_CARD_PATH); + expect(card).toMatchObject({ name: 'Remote' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toBe(`http://remote.test${AGENT_CARD_PATH}`); + }); + + it('falls back to the legacy agent.json path on 404', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith(AGENT_CARD_PATH)) return jsonResponse({ error: 'not found' }, 404); + if (url.endsWith(LEGACY_AGENT_CARD_PATH)) return jsonResponse(REMOTE_CARD); + throw new Error(`unexpected fetch: ${url}`); + }); + + const { card, path } = await fetchAgentCardJson('http://remote.test'); + expect(path).toBe(LEGACY_AGENT_CARD_PATH); + expect(card).toMatchObject({ name: 'Remote' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('falls back when the canonical path fails at the network level', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith(AGENT_CARD_PATH)) throw new Error('connection refused'); + return jsonResponse(REMOTE_CARD); + }); + + const { path } = await fetchAgentCardJson('http://remote.test'); + expect(path).toBe(LEGACY_AGENT_CARD_PATH); + }); + + it('reports both attempted paths when discovery fails entirely', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({}, 500)); + + await expect(fetchAgentCardJson('http://remote.test')).rejects.toThrow( + /agent-card\.json.*agent\.json/s, + ); + }); +}); + +describe('A2AClient', () => { + it('discover uses canonical-then-legacy resolution', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith(AGENT_CARD_PATH)) return jsonResponse({ error: 'nope' }, 404); + if (url.endsWith(LEGACY_AGENT_CARD_PATH)) return jsonResponse(REMOTE_CARD); + throw new Error(`unexpected fetch: ${url}`); + }); + + const client = new A2AClient(SENDER); + const card = await client.discover('http://remote.test'); + expect(card.name).toBe('Remote'); + }); + + it('sends spec-shaped kind parts on the wire', async () => { + let sentBody: string | undefined; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + sentBody = String(init?.body); + return jsonResponse({ + jsonrpc: '2.0', + id: '1', + result: { + id: 't1', + contextId: 'c1', + status: { state: 'completed', timestamp: '2026-08-20T00:00:00.000Z' }, + }, + }); + }); + + const client = new A2AClient(SENDER); + await client.sendText('http://remote.test', 'hello'); + + const parsed = JSON.parse(sentBody ?? '{}') as { + params: { message: { kind: string; parts: Array> } }; + }; + expect(parsed.params.message.kind).toBe('message'); + expect(parsed.params.message.parts[0]).toEqual({ kind: 'text', text: 'hello' }); + }); + + it('normalizes legacy-shaped task responses, including getPosts artifacts', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + jsonrpc: '2.0', + id: '1', + result: { + id: 't1', + contextId: 'c1', + status: { state: 'completed', timestamp: '2026-08-20T00:00:00.000Z' }, + artifacts: [ + { + name: 'posts', + parts: [ + { + type: 'data', + data: { type: 'youagent/posts-response', posts: [{ id: 'p1' }] }, + }, + ], + }, + ], + }, + }), + ); + + const client = new A2AClient(SENDER); + const posts = await client.getPosts('http://remote.test'); + expect(posts).toEqual([{ id: 'p1' }]); + }); +}); diff --git a/src/a2a/a2a-client.ts b/src/a2a/a2a-client.ts index 7dfa37c..1a8282e 100644 --- a/src/a2a/a2a-client.ts +++ b/src/a2a/a2a-client.ts @@ -6,6 +6,8 @@ import { v4 as uuidv4 } from 'uuid'; import type { AgentCard } from '../types/agent-card.js'; import { isYouAgent, getAgentIdentifier } from '../types/agent-card.js'; import type { Post } from '../types/post.js'; +import { normalizeTask } from './compat.js'; +import { fetchAgentCardJson } from './discovery.js'; import type { JsonRpcRequest, JsonRpcResponse, @@ -41,7 +43,7 @@ export class A2AClient { throw new Error(`message/send failed: ${response.error.message}`); } - return response.result as Task; + return normalizeTask(response.result); } /** Get a task by ID from a remote agent. */ @@ -53,7 +55,7 @@ export class A2AClient { throw new Error(`tasks/get failed: ${response.error.message}`); } - return response.result as Task; + return normalizeTask(response.result); } /** Cancel a task on a remote agent. */ @@ -65,20 +67,18 @@ export class A2AClient { throw new Error(`tasks/cancel failed: ${response.error.message}`); } - return response.result as Task; + return normalizeTask(response.result); } - /** Discover a remote agent by fetching its agent card. */ + /** + * Discover a remote agent by fetching its agent card. + * + * Tries the canonical /.well-known/agent-card.json (A2A >= 0.3.0) first, + * then falls back to the legacy /.well-known/agent.json. + */ async discover(agentUrl: string): Promise { - const url = agentUrl.replace(/\/+$/, ''); - const res = await fetch(`${url}/.well-known/agent.json`); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Discovery failed (HTTP ${res.status}): ${text}`); - } - - return (await res.json()) as AgentCard; + const { card } = await fetchAgentCardJson(agentUrl); + return card as AgentCard; } /** Ping a remote agent. */ @@ -105,7 +105,7 @@ export class A2AClient { handle: this.senderCard.youagent.handle, }; const dataPart: DataPart = { - type: 'data', + kind: 'data', data: followData as unknown as Record, }; return this.sendMessage(agentUrl, [dataPart]); @@ -121,7 +121,7 @@ export class A2AClient { agentId, }; const dataPart: DataPart = { - type: 'data', + kind: 'data', data: unfollowData as unknown as Record, }; return this.sendMessage(agentUrl, [dataPart]); @@ -135,7 +135,7 @@ export class A2AClient { limit, }; const dataPart: DataPart = { - type: 'data', + kind: 'data', data: requestData as unknown as Record, }; const task = await this.sendMessage(agentUrl, [dataPart]); @@ -144,7 +144,7 @@ export class A2AClient { if (task.artifacts) { for (const artifact of task.artifacts) { for (const part of artifact.parts) { - if (part.type === 'data') { + if (part.kind === 'data') { const payload = part.data as unknown as YouAgentPostsResponseData; if (payload.type === 'youagent/posts-response') { return payload.posts; @@ -159,7 +159,7 @@ export class A2AClient { /** Send a text message to another agent. */ async sendText(agentUrl: string, text: string, contextId?: string): Promise { - const textPart: TextPart = { type: 'text', text }; + const textPart: TextPart = { kind: 'text', text }; return this.sendMessage(agentUrl, [textPart], contextId); } @@ -202,6 +202,7 @@ export class A2AClient { private buildMessage(parts: Part[], contextId?: string): Message { const { id, handle } = getAgentIdentifier(this.senderCard); return { + kind: 'message', role: 'user', parts, messageId: uuidv4(), diff --git a/src/a2a/a2a-server.test.ts b/src/a2a/a2a-server.test.ts new file mode 100644 index 0000000..d87968b --- /dev/null +++ b/src/a2a/a2a-server.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { A2AServer } from './a2a-server.js'; +import { AGENT_CARD_PATH, LEGACY_AGENT_CARD_PATH } from './discovery.js'; +import type { AgentCard } from '../types/agent-card.js'; +import type { Task } from './types.js'; + +const CARD: AgentCard = { + name: 'Test Agent', + description: 'An agent under test', + url: 'http://localhost:0', + version: '0.1.0', + protocolVersion: '0.3.0', + preferredTransport: 'JSONRPC', + capabilities: {}, + skills: [], + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], +}; + +let server: A2AServer | undefined; + +afterEach(async () => { + if (server) { + await server.stop(); + server = undefined; + } +}); + +async function startServer(): Promise { + server = new A2AServer({ port: 0, agentCard: CARD }); + server.registerYouAgentHandlers({ + onPostsRequest: async () => [], + onMessage: async (message) => ({ + kind: 'message', + role: 'agent', + messageId: 'reply-1', + parts: [{ kind: 'text', text: `echo: ${message.parts.length} part(s)` }], + }), + }); + await server.start(); + return `http://127.0.0.1:${server.listeningPort}`; +} + +describe('A2AServer agent card discovery', () => { + it('serves the card at the canonical /.well-known/agent-card.json path', async () => { + const base = await startServer(); + const res = await fetch(`${base}${AGENT_CARD_PATH}`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ name: 'Test Agent', protocolVersion: '0.3.0' }); + }); + + it('keeps serving the deprecated legacy /.well-known/agent.json path', async () => { + const base = await startServer(); + const res = await fetch(`${base}${LEGACY_AGENT_CARD_PATH}`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ name: 'Test Agent' }); + }); + + it('keeps the /agent-card convenience alias', async () => { + const base = await startServer(); + const res = await fetch(`${base}/agent-card`); + expect(res.status).toBe(200); + }); +}); + +describe('A2AServer message ingest compatibility', () => { + async function send(base: string, parts: unknown[]): Promise { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'req-1', + method: 'message/send', + params: { message: { role: 'user', messageId: 'm1', parts } }, + }), + }); + const body = (await res.json()) as { result?: Task; error?: { message: string } }; + if (body.error) throw new Error(body.error.message); + return body.result as Task; + } + + it('accepts spec-shaped kind parts', async () => { + const base = await startServer(); + const task = await send(base, [{ kind: 'text', text: 'hello' }]); + expect(task.kind).toBe('task'); + expect(task.status.state).toBe('completed'); + }); + + it('accepts legacy type parts from pre-0.3 peers', async () => { + const base = await startServer(); + const task = await send(base, [ + { type: 'data', data: { type: 'youagent/posts-request', limit: 5 } }, + ]); + expect(task.status.state).toBe('completed'); + expect(task.artifacts).toHaveLength(1); + }); + + it('emits spec-shaped tasks: task kind, artifactId, and kind-discriminated parts', async () => { + const base = await startServer(); + const task = await send(base, [ + { kind: 'data', data: { type: 'youagent/posts-request' } }, + ]); + expect(task.kind).toBe('task'); + const artifact = task.artifacts?.[0]; + expect(artifact?.artifactId).toBeTruthy(); + expect(artifact?.parts[0]).toMatchObject({ kind: 'data' }); + expect(artifact?.parts[0]).not.toHaveProperty('type'); + }); + + it('normalizes stored history to kind parts', async () => { + const base = await startServer(); + const task = await send(base, [{ type: 'text', text: 'legacy hello' }]); + expect(task.history?.[0].parts[0]).toEqual({ kind: 'text', text: 'legacy hello' }); + expect(task.history?.[0].kind).toBe('message'); + }); +}); diff --git a/src/a2a/a2a-server.ts b/src/a2a/a2a-server.ts index 3c71888..d704489 100644 --- a/src/a2a/a2a-server.ts +++ b/src/a2a/a2a-server.ts @@ -6,6 +6,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import { v4 as uuidv4 } from 'uuid'; import type { AgentCard } from '../types/agent-card.js'; import type { Post } from '../types/post.js'; +import { normalizeMessage } from './compat.js'; +import { AGENT_CARD_PATH, LEGACY_AGENT_CARD_PATH } from './discovery.js'; import type { JsonRpcRequest, JsonRpcResponse, @@ -14,7 +16,6 @@ import type { Task, TaskStatus, Artifact, - Part, DataPart, MessageSendParams, TaskQueryParams, @@ -30,9 +31,12 @@ export type A2AMethodHandler = (params: unknown, request: JsonRpcRequest) => Pro /** Configuration for the A2A server. */ export interface A2AServerConfig { - /** Port to listen on. Defaults to 3141. */ + /** Port to listen on. Defaults to 3141. Pass 0 to bind an ephemeral port. */ port?: number; - /** The agent card to serve at GET /.well-known/agent.json. */ + /** + * The agent card to serve at GET /.well-known/agent-card.json + * (and the deprecated legacy path /.well-known/agent.json). + */ agentCard: AgentCard; } @@ -69,11 +73,13 @@ export class A2AServer { onMessage?: (message: Message) => Promise; }): void { this.onMethod('message/send', async (params: unknown) => { - const { message } = params as MessageSendParams; + const { message: rawMessage } = params as MessageSendParams; + // Accept both spec-shaped (`kind`) and legacy (`type`) parts on ingest. + const message = normalizeMessage(rawMessage); // Find YouAgent DataParts and route to social handlers for (const part of message.parts) { - if (part.type === 'data') { + if (part.kind === 'data') { const dataType = (part.data as Record).type as string | undefined; if (dataType === 'youagent/follow' && options.onFollow) { @@ -96,10 +102,11 @@ export class A2AServer { posts, }; const artifact: Artifact = { + artifactId: uuidv4(), name: 'posts', parts: [ { - type: 'data', + kind: 'data', data: responseData as unknown as Record, } satisfies DataPart, ], @@ -154,6 +161,15 @@ export class A2AServer { }); } + /** + * The port the server is actually bound to, or undefined when not + * listening. Useful with `port: 0` (ephemeral port) in tests. + */ + get listeningPort(): number | undefined { + const address = this.server.address(); + return address !== null && typeof address === 'object' ? address.port : undefined; + } + /** Stop the server gracefully. */ async stop(): Promise { return new Promise((resolve, reject) => { @@ -188,6 +204,7 @@ export class A2AServer { } const task: Task = { + kind: 'task', id: taskId, contextId, status, @@ -209,8 +226,13 @@ export class A2AServer { return; } - // GET /.well-known/agent.json (A2A standard discovery) - if (method === 'GET' && (url === '/.well-known/agent.json' || url === '/agent-card')) { + // GET /.well-known/agent-card.json (A2A standard discovery, spec >= 0.3.0). + // The legacy /.well-known/agent.json path (spec <= 0.2.x) and the + // /agent-card convenience alias are kept for older clients. + if ( + method === 'GET' && + (url === AGENT_CARD_PATH || url === LEGACY_AGENT_CARD_PATH || url === '/agent-card') + ) { this.sendJson(res, 200, this.config.agentCard); return; } diff --git a/src/a2a/compat.test.ts b/src/a2a/compat.test.ts new file mode 100644 index 0000000..b92cd1a --- /dev/null +++ b/src/a2a/compat.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeArtifact, + normalizeMessage, + normalizePart, + normalizeTask, +} from './compat.js'; + +describe('normalizePart', () => { + it('passes through spec-shaped kind parts', () => { + expect(normalizePart({ kind: 'text', text: 'hi' })).toEqual({ kind: 'text', text: 'hi' }); + expect(normalizePart({ kind: 'data', data: { a: 1 } })).toEqual({ + kind: 'data', + data: { a: 1 }, + }); + expect(normalizePart({ kind: 'file', file: { uri: 'https://x.test/f' } })).toEqual({ + kind: 'file', + file: { uri: 'https://x.test/f' }, + }); + }); + + it('converts legacy type-discriminated parts to kind', () => { + expect(normalizePart({ type: 'text', text: 'hi' })).toEqual({ kind: 'text', text: 'hi' }); + expect(normalizePart({ type: 'data', data: { a: 1 } })).toEqual({ + kind: 'data', + data: { a: 1 }, + }); + }); + + it('strips the legacy type field from the normalized part', () => { + const normalized = normalizePart({ type: 'text', text: 'hi' }); + expect('type' in normalized).toBe(false); + }); + + it('prefers kind when both discriminators are present', () => { + expect(normalizePart({ kind: 'text', type: 'data', text: 'hi' })).toEqual({ + kind: 'text', + text: 'hi', + }); + }); + + it('preserves metadata', () => { + expect(normalizePart({ type: 'text', text: 'hi', metadata: { m: 1 } })).toEqual({ + kind: 'text', + text: 'hi', + metadata: { m: 1 }, + }); + }); + + it('throws on unknown or missing discriminators', () => { + expect(() => normalizePart({ text: 'hi' })).toThrow(/unknown kind/); + expect(() => normalizePart({ kind: 'audio', text: 'hi' })).toThrow(/unknown kind/); + expect(() => normalizePart(null)).toThrow(/expected an object/); + }); + + it('throws on structurally invalid parts', () => { + expect(() => normalizePart({ kind: 'text' })).toThrow(/missing text/); + expect(() => normalizePart({ kind: 'data' })).toThrow(/missing data/); + expect(() => normalizePart({ kind: 'file' })).toThrow(/missing file/); + }); +}); + +describe('normalizeMessage', () => { + it('adds the message kind discriminator and normalizes parts', () => { + const message = normalizeMessage({ + role: 'user', + messageId: 'm1', + parts: [{ type: 'text', text: 'hello' }], + }); + expect(message.kind).toBe('message'); + expect(message.parts).toEqual([{ kind: 'text', text: 'hello' }]); + expect(message.messageId).toBe('m1'); + }); + + it('rejects non-object messages', () => { + expect(() => normalizeMessage('nope')).toThrow(/expected an object/); + }); +}); + +describe('normalizeArtifact', () => { + it('keeps an existing artifactId', () => { + const artifact = normalizeArtifact({ + artifactId: 'a1', + parts: [{ kind: 'text', text: 'x' }], + }); + expect(artifact.artifactId).toBe('a1'); + }); + + it('generates an artifactId when a legacy peer omits it', () => { + const artifact = normalizeArtifact({ parts: [{ type: 'text', text: 'x' }] }); + expect(artifact.artifactId).toMatch(/^[0-9a-f-]{36}$/); + expect(artifact.parts).toEqual([{ kind: 'text', text: 'x' }]); + }); +}); + +describe('normalizeTask', () => { + it('normalizes a legacy-shaped task end to end', () => { + const task = normalizeTask({ + id: 't1', + contextId: 'c1', + status: { + state: 'completed', + timestamp: '2026-08-20T00:00:00.000Z', + message: { role: 'agent', messageId: 'm2', parts: [{ type: 'text', text: 'done' }] }, + }, + history: [{ role: 'user', messageId: 'm1', parts: [{ type: 'text', text: 'go' }] }], + artifacts: [{ name: 'posts', parts: [{ type: 'data', data: { posts: [] } }] }], + }); + + expect(task.kind).toBe('task'); + expect(task.status.message?.kind).toBe('message'); + expect(task.status.message?.parts[0]).toEqual({ kind: 'text', text: 'done' }); + expect(task.history?.[0].parts[0]).toEqual({ kind: 'text', text: 'go' }); + expect(task.artifacts?.[0].artifactId).toBeTruthy(); + expect(task.artifacts?.[0].parts[0]).toEqual({ kind: 'data', data: { posts: [] } }); + }); + + it('passes through spec-shaped tasks unchanged apart from the discriminator', () => { + const task = normalizeTask({ + kind: 'task', + id: 't2', + contextId: 'c2', + status: { state: 'working', timestamp: '2026-08-20T00:00:00.000Z' }, + }); + expect(task).toMatchObject({ kind: 'task', id: 't2', contextId: 'c2' }); + }); +}); diff --git a/src/a2a/compat.ts b/src/a2a/compat.ts new file mode 100644 index 0000000..cdcbcbd --- /dev/null +++ b/src/a2a/compat.ts @@ -0,0 +1,128 @@ +/** + * A2A wire-format compatibility helpers. + * + * The A2A spec (v0.1 through v0.3.x) discriminates message parts with a + * `kind` field, and v0.3 requires `kind` object discriminators on Message + * and Task plus a required `artifactId` on Artifact. youagent <= 0.1 emitted + * a non-standard `type` discriminator on parts and omitted the object + * discriminators entirely. + * + * These normalizers are applied at every ingest boundary (server request + * handling, client responses) so youagent speaks spec-shaped A2A on the way + * out while remaining liberal in what it accepts from older peers. + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { Artifact, DataPart, FilePart, Message, Part, Task, TextPart } from './types.js'; + +/** + * Normalize a single part from the wire into a spec-shaped `Part`. + * + * Accepts both the spec `kind` discriminator and the legacy youagent `type` + * discriminator. When both are present, `kind` wins. Throws on unknown or + * missing discriminators so malformed parts fail loudly at the boundary. + */ +export function normalizePart(part: unknown): Part { + if (part === null || typeof part !== 'object') { + throw new Error('Invalid A2A part: expected an object'); + } + const raw = part as Record; + const discriminator = (raw.kind ?? raw.type) as string | undefined; + const metadata = raw.metadata as Record | undefined; + + switch (discriminator) { + case 'text': { + if (typeof raw.text !== 'string') { + throw new Error('Invalid A2A text part: missing text'); + } + const normalized: TextPart = { kind: 'text', text: raw.text }; + if (metadata) normalized.metadata = metadata; + return normalized; + } + case 'file': { + if (raw.file === null || typeof raw.file !== 'object') { + throw new Error('Invalid A2A file part: missing file'); + } + const normalized: FilePart = { kind: 'file', file: raw.file as FilePart['file'] }; + if (metadata) normalized.metadata = metadata; + return normalized; + } + case 'data': { + if (raw.data === null || typeof raw.data !== 'object') { + throw new Error('Invalid A2A data part: missing data'); + } + const normalized: DataPart = { kind: 'data', data: raw.data as Record }; + if (metadata) normalized.metadata = metadata; + return normalized; + } + default: + throw new Error(`Invalid A2A part: unknown kind ${JSON.stringify(discriminator ?? null)}`); + } +} + +/** Normalize an array of wire parts. */ +export function normalizeParts(parts: unknown): Part[] { + if (!Array.isArray(parts)) { + throw new Error('Invalid A2A message: parts must be an array'); + } + return parts.map(normalizePart); +} + +/** Normalize a message from the wire: ensure `kind` and spec-shaped parts. */ +export function normalizeMessage(message: unknown): Message { + if (message === null || typeof message !== 'object') { + throw new Error('Invalid A2A message: expected an object'); + } + const raw = message as Record; + return { + ...(raw as unknown as Message), + kind: 'message', + parts: normalizeParts(raw.parts ?? []), + }; +} + +/** Normalize an artifact from the wire: ensure `artifactId` and spec-shaped parts. */ +export function normalizeArtifact(artifact: unknown): Artifact { + if (artifact === null || typeof artifact !== 'object') { + throw new Error('Invalid A2A artifact: expected an object'); + } + const raw = artifact as Record; + return { + ...(raw as unknown as Artifact), + artifactId: typeof raw.artifactId === 'string' && raw.artifactId !== '' ? raw.artifactId : uuidv4(), + parts: normalizeParts(raw.parts ?? []), + }; +} + +/** + * Normalize a task received from a remote agent: ensure `kind`, and + * normalize the status message, history, and artifact parts so downstream + * code can rely on spec-shaped `part.kind` checks regardless of how old the + * remote implementation is. + */ +export function normalizeTask(task: unknown): Task { + if (task === null || typeof task !== 'object') { + throw new Error('Invalid A2A task: expected an object'); + } + const raw = task as Record; + const normalized: Task = { + ...(raw as unknown as Task), + kind: 'task', + }; + + const status = raw.status as Record | undefined; + if (status && status.message) { + normalized.status = { + ...(status as unknown as Task['status']), + message: normalizeMessage(status.message), + }; + } + if (Array.isArray(raw.history)) { + normalized.history = raw.history.map(normalizeMessage); + } + if (Array.isArray(raw.artifacts)) { + normalized.artifacts = raw.artifacts.map(normalizeArtifact); + } + + return normalized; +} diff --git a/src/a2a/discovery.ts b/src/a2a/discovery.ts new file mode 100644 index 0000000..3756df9 --- /dev/null +++ b/src/a2a/discovery.ts @@ -0,0 +1,59 @@ +/** + * A2A agent card discovery. + * + * A2A spec v0.3.0 (July 2025) renamed the well-known agent card path from + * `/.well-known/agent.json` to `/.well-known/agent-card.json`. Current + * clients and tooling (a2a-js, a2a-python, a2a-inspector) resolve the new + * path first; many servers in the wild still serve only the legacy one. + * + * `fetchAgentCardJson` implements the ecosystem convention: try the + * canonical path first, then fall back to the legacy path. + */ + +/** Canonical well-known agent card path (A2A >= 0.3.0). */ +export const AGENT_CARD_PATH = '/.well-known/agent-card.json'; + +/** Legacy well-known agent card path (A2A <= 0.2.x). Deprecated. */ +export const LEGACY_AGENT_CARD_PATH = '/.well-known/agent.json'; + +/** Result of a successful discovery fetch. */ +export interface AgentCardFetchResult { + /** The raw parsed JSON agent card (unvalidated). */ + card: unknown; + /** The well-known path that answered. */ + path: string; +} + +/** + * Fetch an agent card from a remote agent's base URL. + * + * Tries the canonical `/.well-known/agent-card.json` first and falls back to + * the legacy `/.well-known/agent.json`. Throws when neither path yields a + * parseable card, reporting the first failure encountered. + */ +export async function fetchAgentCardJson(agentUrl: string): Promise { + const base = agentUrl.replace(/\/+$/, ''); + let firstFailure: string | undefined; + + for (const path of [AGENT_CARD_PATH, LEGACY_AGENT_CARD_PATH]) { + try { + const res = await fetch(`${base}${path}`); + if (res.ok) { + return { card: await res.json(), path }; + } + if (!firstFailure) { + const text = await res.text().catch(() => ''); + firstFailure = `HTTP ${res.status}${text ? ` ${text}` : ''} at ${path}`; + } + } catch (err) { + if (!firstFailure) { + firstFailure = `${err instanceof Error ? err.message : String(err)} at ${path}`; + } + } + } + + throw new Error( + `Agent card discovery failed for ${base} ` + + `(tried ${AGENT_CARD_PATH}, then legacy ${LEGACY_AGENT_CARD_PATH}): ${firstFailure}`, + ); +} diff --git a/src/a2a/index.ts b/src/a2a/index.ts index 1cabceb..3c394d0 100644 --- a/src/a2a/index.ts +++ b/src/a2a/index.ts @@ -1,3 +1,5 @@ export * from './types.js'; +export * from './compat.js'; +export * from './discovery.js'; export * from './a2a-client.js'; export * from './a2a-server.js'; diff --git a/src/a2a/types.ts b/src/a2a/types.ts index e4f0b22..07df34b 100644 --- a/src/a2a/types.ts +++ b/src/a2a/types.ts @@ -37,26 +37,42 @@ export type A2AMethod = | 'tasks/resubscribe'; export interface TextPart { - type: 'text'; + kind: 'text'; text: string; metadata?: Record; } export interface FilePart { - type: 'file'; + kind: 'file'; file: { name?: string; mimeType?: string; uri?: string; bytes?: string }; metadata?: Record; } export interface DataPart { - type: 'data'; + kind: 'data'; data: Record; metadata?: Record; } export type Part = TextPart | FilePart | DataPart; +/** + * Legacy part shapes emitted by youagent <= 0.1, which used a `type` + * discriminator. The A2A spec has always used `kind` (v0.1 through v0.3.x), + * so these exist only so older youagent peers keep working. Accepted on + * ingest via `normalizePart`, never emitted. + */ +export type LegacyPart = + | (Omit & { type: 'text' }) + | (Omit & { type: 'file' }) + | (Omit & { type: 'data' }); + +/** A part as it may arrive off the wire: spec-shaped or legacy-shaped. */ +export type WirePart = Part | LegacyPart; + export interface Message { + /** A2A v0.3 object discriminator. Always emitted; tolerated missing on ingest. */ + kind: 'message'; role: 'user' | 'agent'; parts: Part[]; messageId: string; @@ -81,6 +97,8 @@ export interface TaskStatus { } export interface Task { + /** A2A v0.3 object discriminator. Always emitted; tolerated missing on ingest. */ + kind: 'task'; id: string; contextId: string; status: TaskStatus; @@ -90,6 +108,8 @@ export interface Task { } export interface Artifact { + /** Required unique artifact identifier per A2A v0.3. Generated when a legacy peer omits it. */ + artifactId: string; name?: string; description?: string; parts: Part[]; diff --git a/src/registry/registry-client.ts b/src/registry/registry-client.ts index 6a5505c..167b7fd 100644 --- a/src/registry/registry-client.ts +++ b/src/registry/registry-client.ts @@ -5,6 +5,7 @@ import type { AgentCard } from "../types/agent-card.js"; import { getAgentIdentifier } from "../types/agent-card.js"; import { agentCardSchema } from "../schema/agent-card.schema.js"; +import { fetchAgentCardJson } from "../a2a/discovery.js"; /** Default registry: the hosted For You network. Override with YOUAGENT_REGISTRY_URL. */ export const DEFAULT_REGISTRY_URL = "https://for.you.com"; @@ -188,25 +189,26 @@ export class RegistryClient { } /** - * Register an external A2A agent by fetching its card from `/.well-known/agent.json`. + * Register an external A2A agent by fetching its card from the well-known + * location: `/.well-known/agent-card.json` first (A2A >= 0.3.0), then the + * legacy `/.well-known/agent.json` fallback. * * @param agentUrl The base URL of the remote agent. * @returns The validated and registered agent card. */ async registerExternal(agentUrl: string): Promise { - const url = agentUrl.replace(/\/+$/, ''); - const res = await fetch(`${url}/.well-known/agent.json`); - - if (!res.ok) { - const text = await res.text().catch(() => ''); + let raw: unknown; + try { + ({ card: raw } = await fetchAgentCardJson(agentUrl)); + } catch (err) { + // 0 = no single HTTP status: discovery tried both well-known paths. throw new RegistryError( - `Failed to fetch agent card from ${url}: HTTP ${res.status} ${text}`, - res.status, - res.status >= 500, + err instanceof Error ? err.message : `Failed to fetch agent card from ${agentUrl}`, + 0, + false, ); } - const raw = await res.json(); const card = agentCardSchema.parse(raw) as unknown as AgentCard; await this.register(card); return card; @@ -393,7 +395,8 @@ export class RegistryClient { description: entry.description ?? "", url: `${this.baseUrl}/api/a2a/${handle}`, version: "0.0.0", - protocolVersion: "0.2.0", + protocolVersion: "0.3.0", + preferredTransport: "JSONRPC", capabilities: {}, skills: [], defaultInputModes: ["text"], diff --git a/src/schema/agent-card.schema.ts b/src/schema/agent-card.schema.ts index 97c8e7e..9ce20f3 100644 --- a/src/schema/agent-card.schema.ts +++ b/src/schema/agent-card.schema.ts @@ -146,7 +146,8 @@ export const agentCardSchema = z.object({ description: z.string().min(1), url: z.string().url(), version: z.string().default('0.1.0'), - protocolVersion: z.string().default('0.2.1'), + protocolVersion: z.string().default('0.3.0'), + preferredTransport: z.string().default('JSONRPC'), provider: a2aProviderSchema.optional(), capabilities: a2aCapabilitiesSchema.default({ streaming: false, @@ -156,6 +157,7 @@ export const agentCardSchema = z.object({ skills: z.array(a2aSkillSchema).default([]), defaultInputModes: z.array(z.string()).default(['text/plain']), defaultOutputModes: z.array(z.string()).default(['text/plain']), + additionalInterfaces: z.array(a2aInterfaceSchema).optional(), supportedInterfaces: z.array(a2aInterfaceSchema).optional(), securitySchemes: z.record(a2aSecuritySchemeSchema).optional(), security: z.array(z.record(z.array(z.string()))).optional(), diff --git a/src/storage/agent-card-repo.ts b/src/storage/agent-card-repo.ts index 6c01db1..0e2ce42 100644 --- a/src/storage/agent-card-repo.ts +++ b/src/storage/agent-card-repo.ts @@ -30,7 +30,8 @@ function rowToAgentCard(row: AgentCardRow): AgentCard { description: row.description ?? '', url: 'http://localhost:3141', version: '0.1.0', - protocolVersion: '0.2.1', + protocolVersion: '0.3.0', + preferredTransport: 'JSONRPC', capabilities: row.capabilities ? JSON.parse(row.capabilities) : { streaming: false, pushNotifications: false, diff --git a/src/types/agent-card.ts b/src/types/agent-card.ts index ac9c0cd..f69cd9b 100644 --- a/src/types/agent-card.ts +++ b/src/types/agent-card.ts @@ -63,11 +63,15 @@ export interface A2AAgentCard { url: string; version: string; protocolVersion: string; + /** Preferred transport binding for the main url (A2A 0.3.x, defaults to 'JSONRPC'). */ + preferredTransport?: string; provider?: A2AAgentProvider; capabilities: A2AAgentCapabilities; skills: A2AAgentSkill[]; defaultInputModes: string[]; defaultOutputModes: string[]; + /** Additional transport/URL combinations beyond the main url (A2A 0.3.x name). */ + additionalInterfaces?: A2AAgentInterface[]; supportedInterfaces?: A2AAgentInterface[]; securitySchemes?: Record; security?: Record[];