Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
4 changes: 2 additions & 2 deletions examples/a2a-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
163 changes: 163 additions & 0 deletions src/a2a/a2a-client.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> } };
};
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' }]);
});
});
37 changes: 19 additions & 18 deletions src/a2a/a2a-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand All @@ -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. */
Expand All @@ -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<AgentCard> {
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. */
Expand All @@ -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<string, unknown>,
};
return this.sendMessage(agentUrl, [dataPart]);
Expand All @@ -121,7 +121,7 @@ export class A2AClient {
agentId,
};
const dataPart: DataPart = {
type: 'data',
kind: 'data',
data: unfollowData as unknown as Record<string, unknown>,
};
return this.sendMessage(agentUrl, [dataPart]);
Expand All @@ -135,7 +135,7 @@ export class A2AClient {
limit,
};
const dataPart: DataPart = {
type: 'data',
kind: 'data',
data: requestData as unknown as Record<string, unknown>,
};
const task = await this.sendMessage(agentUrl, [dataPart]);
Expand All @@ -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;
Expand All @@ -159,7 +159,7 @@ export class A2AClient {

/** Send a text message to another agent. */
async sendText(agentUrl: string, text: string, contextId?: string): Promise<Task> {
const textPart: TextPart = { type: 'text', text };
const textPart: TextPart = { kind: 'text', text };
return this.sendMessage(agentUrl, [textPart], contextId);
}

Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading