diff --git a/.changeset/bright-badgers-confess.md b/.changeset/bright-badgers-confess.md new file mode 100644 index 000000000..181327d85 --- /dev/null +++ b/.changeset/bright-badgers-confess.md @@ -0,0 +1,13 @@ +--- +"@voltagent/a2a-server": patch +"@voltagent/server-core": patch +"@voltagent/server-hono": patch +"@voltagent/server-elysia": patch +--- + +fix: point A2A agent cards at the JSON-RPC endpoint + +A2A agent cards now advertise `/a2a/{serverId}` instead of the internal +`/.well-known/{serverId}/agent-card.json` discovery document. When the card is +served through the Hono or Elysia integrations, VoltAgent also resolves that +endpoint to an absolute URL based on the incoming request. diff --git a/examples/with-a2a-server/README.md b/examples/with-a2a-server/README.md index 31942a26c..f7cb475f0 100644 --- a/examples/with-a2a-server/README.md +++ b/examples/with-a2a-server/README.md @@ -79,13 +79,21 @@ pnpm --filter voltagent-example-with-a2a-server dev The Hono server listens on `http://localhost:3141`. Check the discovery document: ```bash -curl http://localhost:3141/.well-known/support/agent-card.json | jq +curl http://localhost:3141/.well-known/supportagent/agent-card.json | jq +``` + +The returned card advertises the JSON-RPC endpoint via its `url` field: + +```json +{ + "url": "http://localhost:3141/a2a/supportagent" +} ``` Send a JSON-RPC request to the agent: ```bash -curl -X POST http://localhost:3141/a2a/support \ +curl -X POST http://localhost:3141/a2a/supportagent \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", @@ -110,7 +118,7 @@ There is a helper that exercises the example end-to-end. Start the dev server in pnpm --filter voltagent-example-with-a2a-server test:smoke ``` -The script fetches the agent card, sends a message via `/a2a`, and asserts that the resulting task transitions to `completed`. +The script fetches the agent card, asserts that it points to the absolute `/a2a` endpoint, sends a message via `/a2a`, and asserts that the resulting task transitions to `completed`. ## Next steps diff --git a/examples/with-a2a-server/scripts/smoke-test.mjs b/examples/with-a2a-server/scripts/smoke-test.mjs index 3d6df66e7..f74c27c1c 100755 --- a/examples/with-a2a-server/scripts/smoke-test.mjs +++ b/examples/with-a2a-server/scripts/smoke-test.mjs @@ -151,6 +151,7 @@ async function run() { console.log("🔎 Fetching agent card..."); const card = await getAgentCard(); assert.equal(card.name, "supportagent"); + assert.equal(card.url, new URL(`/a2a/${AGENT_ID}`, BASE_URL).toString()); assert.equal(Array.isArray(card.skills), true); console.log("✅ Agent card retrieved"); diff --git a/packages/a2a-server/src/server.spec.ts b/packages/a2a-server/src/server.spec.ts index d250c02f9..e981f9099 100644 --- a/packages/a2a-server/src/server.spec.ts +++ b/packages/a2a-server/src/server.spec.ts @@ -176,6 +176,43 @@ describe("A2AServer", () => { }); }); + it("uses the A2A endpoint for agent card URLs", () => { + const agent: StubAgent = { + id: "support-agent", + purpose: "Answer support questions", + generateText: vi.fn(), + streamText: vi.fn(), + }; + + const server = createServer(agent); + + expect(server.getAgentCard("support-agent").url).toBe("/a2a/support-agent"); + expect( + server.getAgentCard("support-agent", { + requestUrl: "https://agents.example/.well-known/support-agent/agent-card.json", + }).url, + ).toBe("https://agents.example/a2a/support-agent"); + }); + + it("encodes reserved characters in A2A endpoint URLs without removing spaces", () => { + const agentId = "support agent/ops?"; + const agent: StubAgent = { + id: agentId, + purpose: "Answer support questions", + generateText: vi.fn(), + streamText: vi.fn(), + }; + + const server = createServer(agent); + + expect(server.getAgentCard(agentId).url).toBe("/a2a/support%20agent%2Fops%3F"); + expect( + server.getAgentCard(agentId, { + requestUrl: "https://agents.example/.well-known/support%20agent%2Fops%3F/agent-card.json", + }).url, + ).toBe("https://agents.example/a2a/support%20agent%2Fops%3F"); + }); + it("streams incremental updates and completes the task", async () => { const streamText = vi.fn().mockImplementation(async () => ({ text: Promise.resolve("Final response"), diff --git a/packages/a2a-server/src/server.ts b/packages/a2a-server/src/server.ts index 11d904c4e..eb801f063 100644 --- a/packages/a2a-server/src/server.ts +++ b/packages/a2a-server/src/server.ts @@ -31,6 +31,30 @@ import type { } from "./types"; import { VoltA2AError } from "./types"; +const DEFAULT_A2A_ROUTE_PREFIX = "/a2a"; + +function sanitizeSegment(segment: string): string { + return encodeURIComponent(segment.replace(/^\/+|\/+$/g, "")); +} + +function buildA2AEndpointPath(serverId: string): string { + return `${DEFAULT_A2A_ROUTE_PREFIX}/${sanitizeSegment(serverId)}`; +} + +function resolveAgentCardUrl(serverId: string, requestUrl?: string): string { + const endpointPath = buildA2AEndpointPath(serverId); + + if (!requestUrl) { + return endpointPath; + } + + try { + return new URL(endpointPath, requestUrl).toString(); + } catch { + return endpointPath; + } +} + export class A2AServer { private deps?: Required; private readonly config: A2AServerConfig; @@ -69,9 +93,9 @@ export class A2AServer { }; } - getAgentCard(agentId: string, _context: A2ARequestContext = {}): AgentCard { - const agent = this.resolveAgent(agentId, _context); - const url = `/.well-known/${agentId}/agent-card.json`; + getAgentCard(agentId: string, context: A2ARequestContext = {}): AgentCard { + const agent = this.resolveAgent(agentId, context); + const url = resolveAgentCardUrl(agentId, context.requestUrl); return buildAgentCard(agent, { url, diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts index e7ceaf342..352db4eeb 100644 --- a/packages/a2a-server/src/types.ts +++ b/packages/a2a-server/src/types.ts @@ -100,6 +100,7 @@ export interface A2ARequestContext { userId?: string; sessionId?: string; metadata?: Record; + requestUrl?: string; } export interface A2AFilterParams { diff --git a/packages/server-core/src/a2a/routes.spec.ts b/packages/server-core/src/a2a/routes.spec.ts new file mode 100644 index 000000000..e0d6bea5b --- /dev/null +++ b/packages/server-core/src/a2a/routes.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { buildA2AEndpointPath, buildAgentCardPath } from "./routes"; + +describe("A2A route helpers", () => { + it("encodes reserved characters without removing internal spaces", () => { + expect(buildA2AEndpointPath("support agent/ops?")).toBe("/a2a/support%20agent%2Fops%3F"); + expect(buildAgentCardPath("support agent/ops?")).toBe( + "/.well-known/support%20agent%2Fops%3F/agent-card.json", + ); + }); +}); diff --git a/packages/server-core/src/a2a/routes.ts b/packages/server-core/src/a2a/routes.ts index 22d911619..56bd29b7b 100644 --- a/packages/server-core/src/a2a/routes.ts +++ b/packages/server-core/src/a2a/routes.ts @@ -10,5 +10,5 @@ export function buildA2AEndpointPath(serverId: string): string { } function sanitizeSegment(segment: string): string { - return segment.replace(/^\/+|\/+$|\s+/g, ""); + return encodeURIComponent(segment.replace(/^\/+|\/+$/g, "")); } diff --git a/packages/server-core/src/a2a/types.ts b/packages/server-core/src/a2a/types.ts index d18e1a664..cbd53ebe8 100644 --- a/packages/server-core/src/a2a/types.ts +++ b/packages/server-core/src/a2a/types.ts @@ -36,6 +36,7 @@ export interface A2ARequestContext { userId?: string; sessionId?: string; metadata?: Record; + requestUrl?: string; } export interface AgentCardSkill { diff --git a/packages/server-elysia/src/routes/a2a.routes.spec.ts b/packages/server-elysia/src/routes/a2a.routes.spec.ts index a7c76b957..97f3d6937 100644 --- a/packages/server-elysia/src/routes/a2a.routes.spec.ts +++ b/packages/server-elysia/src/routes/a2a.routes.spec.ts @@ -80,14 +80,13 @@ describe("A2A Routes", () => { }); it("should handle agent card request", async () => { - vi.mocked(serverCore.resolveAgentCard).mockResolvedValue({ + vi.mocked(serverCore.resolveAgentCard).mockReturnValue({ name: "agent", description: "desc", } as any); - const response = await app.handle( - new Request("http://localhost/.well-known/server1/agent-card.json"), - ); + const requestUrl = "http://localhost/.well-known/server1/agent-card.json"; + const response = await app.handle(new Request(requestUrl)); expect(response.status).toBe(200); expect(await response.json()).toEqual({ @@ -98,7 +97,7 @@ describe("A2A Routes", () => { mockDeps.a2a.registry, "server1", "server1", - {}, + { requestUrl }, ); }); diff --git a/packages/server-elysia/src/routes/a2a.routes.ts b/packages/server-elysia/src/routes/a2a.routes.ts index 4542ddb63..d9f96d0b2 100644 --- a/packages/server-elysia/src/routes/a2a.routes.ts +++ b/packages/server-elysia/src/routes/a2a.routes.ts @@ -187,9 +187,11 @@ export function registerA2ARoutes(app: Elysia, deps: ServerProviderDeps, logger: // GET /a2a/:serverId/card - Get agent card app.get( A2A_ROUTES.agentCard.path, - async ({ params, set }) => { + async ({ params, request, set }) => { try { - const card = resolveAgentCard(typedRegistry, params.serverId, params.serverId, {}); + const card = resolveAgentCard(typedRegistry, params.serverId, params.serverId, { + requestUrl: request.url, + }); return card; } catch (error) { const response = normalizeError(null, error); diff --git a/packages/server-hono/src/routes/a2a.routes.spec.ts b/packages/server-hono/src/routes/a2a.routes.spec.ts new file mode 100644 index 000000000..63497e788 --- /dev/null +++ b/packages/server-hono/src/routes/a2a.routes.spec.ts @@ -0,0 +1,166 @@ +import { A2AServerRegistry, type ServerProviderDeps, TriggerRegistry } from "@voltagent/core"; +import type { Logger } from "@voltagent/internal"; +import * as serverCore from "@voltagent/server-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { OpenAPIHono } from "../zod-openapi-compat"; +import { registerA2ARoutes } from "./a2a.routes"; + +vi.mock("@voltagent/server-core", async () => { + const actual = await vi.importActual("@voltagent/server-core"); + return { + ...actual, + executeA2ARequest: vi.fn(), + resolveAgentCard: vi.fn(), + A2A_ROUTES: actual.A2A_ROUTES, + }; +}); + +vi.mock("@voltagent/a2a-server", async () => { + return { + normalizeError: vi.fn().mockImplementation((id, error) => ({ + jsonrpc: "2.0", + id, + error: { + code: error.code || -32603, + message: error.message, + }, + })), + }; +}); + +function createMockA2ARegistry() { + const registry = new A2AServerRegistry(); + registry.register( + { + getMetadata() { + return { + id: "server1", + name: "server1", + version: "1.0.0", + }; + }, + }, + { + agentRegistry: { + getAgent() { + return undefined; + }, + getAllAgents() { + return []; + }, + }, + }, + ); + return registry; +} + +function createMockDeps(): ServerProviderDeps { + return { + agentRegistry: { + getAgent() { + return undefined; + }, + getAllAgents() { + return []; + }, + getAgentCount() { + return 0; + }, + removeAgent() { + return false; + }, + registerAgent() {}, + getGlobalVoltOpsClient() { + return undefined; + }, + getGlobalLogger() { + return undefined; + }, + }, + workflowRegistry: { + getWorkflow() { + return undefined; + }, + getWorkflowsForApi() { + return []; + }, + getWorkflowDetailForApi() { + return undefined; + }, + getWorkflowCount() { + return 0; + }, + getAllWorkflowIds() { + return []; + }, + on() {}, + off() {}, + activeExecutions: new Map(), + async resumeSuspendedWorkflow() { + return undefined; + }, + }, + triggerRegistry: new TriggerRegistry(), + a2a: { + registry: createMockA2ARegistry(), + }, + }; +} + +function createMockLogger(): Logger { + const logger: Logger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + child: vi.fn(() => logger), + }; + + return logger; +} + +describe("A2A Routes", () => { + let app: InstanceType; + let mockDeps: ServerProviderDeps; + let mockLogger: Logger; + + beforeEach(() => { + app = new OpenAPIHono(); + mockDeps = createMockDeps(); + mockLogger = createMockLogger(); + registerA2ARoutes(app, mockDeps, mockLogger); + vi.clearAllMocks(); + }); + + it("passes the request URL when resolving the agent card", async () => { + const card = { + name: "agent", + description: "desc", + url: "https://agents.example/a2a/server1", + version: "1.0.0", + capabilities: { + streaming: true, + pushNotifications: false, + stateTransitionHistory: false, + }, + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + skills: [], + }; + vi.mocked(serverCore.resolveAgentCard).mockReturnValue(card); + + const requestUrl = "http://agents.example/.well-known/server1/agent-card.json"; + const response = await app.request(requestUrl); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(card); + expect(serverCore.resolveAgentCard).toHaveBeenCalledWith( + mockDeps.a2a?.registry, + "server1", + "server1", + { requestUrl }, + ); + }); +}); diff --git a/packages/server-hono/src/routes/a2a.routes.ts b/packages/server-hono/src/routes/a2a.routes.ts index 64dde969c..f70d5c0e1 100644 --- a/packages/server-hono/src/routes/a2a.routes.ts +++ b/packages/server-hono/src/routes/a2a.routes.ts @@ -99,7 +99,9 @@ export function registerA2ARoutes(app: OpenAPIHonoType, deps: ServerProviderDeps app.openapi(agentCardRoute as any, (c) => { const serverId = requirePathParam(c, "serverId"); try { - const card = resolveAgentCard(typedRegistry, serverId, serverId, {}); + const card = resolveAgentCard(typedRegistry, serverId, serverId, { + requestUrl: c.req.url, + }); return c.json(card, 200); } catch (error) { const response = normalizeError(null, error); diff --git a/website/docs/agents/a2a/a2a-server.md b/website/docs/agents/a2a/a2a-server.md index 6bd2542cb..f8dcb1c17 100644 --- a/website/docs/agents/a2a/a2a-server.md +++ b/website/docs/agents/a2a/a2a-server.md @@ -48,7 +48,7 @@ export const a2aServer = new A2AServer({ }); ``` -The server metadata feeds the discovery card served from `/.well-known/{agentId}/agent-card.json`. +The server metadata feeds the discovery card served from `/.well-known/{serverId}/agent-card.json`. The card's `url` field points to `/a2a/{serverId}`, and when the card is fetched over HTTP VoltAgent returns that URL as an absolute address. ## Register The Server With VoltAgent @@ -71,17 +71,17 @@ export const voltAgent = new VoltAgent({ With this in place, VoltAgent automatically exposes: -- `GET /.well-known/{agentId}/agent-card.json` -- `POST /a2a/{agentId}` +- `GET /.well-known/{serverId}/agent-card.json` +- `POST /a2a/{serverId}` The JSON-RPC handler accepts `message/send`, `message/stream`, `tasks/get`, and `tasks/cancel` requests. ## Available Endpoints -| Method | Path | Description | -| ------ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `GET` | `/.well-known/{agentId}/agent-card.json` | Returns the discovery card for the specified agent. | -| `POST` | `/a2a/{agentId}` | Accepts JSON-RPC 2.0 requests. Supported methods: `message/send`, `message/stream`, `tasks/get`, `tasks/cancel`. | +| Method | Path | Description | +| ------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `GET` | `/.well-known/{serverId}/agent-card.json` | Returns the discovery card for the specified A2A server. The card's `url` points to `/a2a/{serverId}`. | +| `POST` | `/a2a/{serverId}` | Accepts JSON-RPC 2.0 requests. Supported methods: `message/send`, `message/stream`, `tasks/get`, `tasks/cancel`. | Example JSON-RPC payload for `message/send`: @@ -223,6 +223,6 @@ The test sends a `message/send`, streams a `message/stream`, and exercises `task ## Troubleshooting checklist -- **404 for discovery card**: ensure the agent ID you request matches the key inside `VoltAgent({ agents: { ... } })`. +- **404 for discovery card**: ensure the `serverId` in `/.well-known/{serverId}/agent-card.json` matches `A2AServer({ id })`, or the normalized `name` when `id` is omitted. It does not come from `VoltAgent({ agents: { ... } })` or the `a2aServers` map key. - **Unexpected JSON in SSE**: confirm you are stripping the `\x1E` prefix before parsing the JSON payload. - **Cancellation not propagating**: verify you call `tasks/cancel` with the task ID from the stream and that your TaskStore preserves the `activeCancellations` set.