From 821ffe3688b4bf1be40e7844f4d0b922f0cadea4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:34:11 +0000 Subject: [PATCH 01/12] Initial plan From 0a07ee0293ae66b9252b6c932d231a596454a7fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:40:17 +0000 Subject: [PATCH 02/12] Add A2A protocol implementation with core components and routes Co-authored-by: jgranesa <47306065+jgranesa@users.noreply.github.com> --- packages/opencode/package.json | 1 + packages/opencode/src/a2a/README.md | 146 ++++++++++++++ packages/opencode/src/a2a/card.ts | 43 ++++ packages/opencode/src/a2a/executor.ts | 111 +++++++++++ packages/opencode/src/a2a/index.ts | 32 +++ packages/opencode/src/a2a/types.ts | 22 +++ packages/opencode/src/server/routes/a2a.ts | 217 +++++++++++++++++++++ packages/opencode/src/server/server.ts | 2 + 8 files changed, 574 insertions(+) create mode 100644 packages/opencode/src/a2a/README.md create mode 100644 packages/opencode/src/a2a/card.ts create mode 100644 packages/opencode/src/a2a/executor.ts create mode 100644 packages/opencode/src/a2a/index.ts create mode 100644 packages/opencode/src/a2a/types.ts create mode 100644 packages/opencode/src/server/routes/a2a.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a0d6892f9d3f..b0da00552790 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -48,6 +48,7 @@ "zod-to-json-schema": "3.24.5" }, "dependencies": { + "@a2a-js/sdk": "0.3.10", "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.13.0", diff --git a/packages/opencode/src/a2a/README.md b/packages/opencode/src/a2a/README.md new file mode 100644 index 000000000000..b13cc3eecce1 --- /dev/null +++ b/packages/opencode/src/a2a/README.md @@ -0,0 +1,146 @@ +# Agent2Agent (A2A) Protocol Implementation + +This directory implements the Agent2Agent (A2A) protocol for OpenCode, allowing OpenCode to expose all its capabilities as an A2A-compliant agent server. + +## Overview + +The A2A protocol enables autonomous agents to communicate, collaborate, and delegate tasks to each other. This implementation exposes OpenCode's development tools and capabilities through standardized A2A interfaces. + +## Architecture + +### Components + +- **`types.ts`** - Type definitions and Zod schemas for A2A entities +- **`card.ts`** - Agent card generation describing OpenCode's capabilities +- **`executor.ts`** - Agent executor implementing task execution logic +- **`index.ts`** - Main entry point and exports + +### Endpoints + +The A2A implementation provides the following HTTP endpoints: + +#### 1. Agent Card Endpoint +- **Path**: `/a2a/.well-known/agent.json` +- **Method**: GET +- **Description**: Returns the agent card describing OpenCode's capabilities, supported skills, and available transports + +#### 2. JSON-RPC Endpoint +- **Path**: `/a2a/jsonrpc` +- **Method**: POST +- **Description**: JSON-RPC 2.0 interface for agent-to-agent communication +- **Supported Methods**: + - `agent/execute` - Execute a task with messages + - `agent/card` - Get the agent card + +#### 3. REST Endpoint +- **Path**: `/a2a/rest/execute` +- **Method**: POST +- **Description**: HTTP+JSON/REST interface for task execution + +## Exposed Capabilities + +OpenCode exposes the following capabilities as A2A skills: + +- **bash** - Execute bash commands +- **read** - Read file contents +- **write** - Write files +- **edit** - Edit files +- **grep** - Search file contents +- **glob** - Find files by pattern +- **task** - Delegate to sub-agents +- **webfetch** - Fetch web content +- **websearch** - Search the web +- **codesearch** - Search code +- **skill** - Execute custom skills +- **lsp** - Language server protocol operations +- And all other tools from the tool registry + +## Usage + +### Starting the A2A Server + +The A2A endpoints are automatically exposed when the OpenCode server starts. By default, they are available at: + +- JSON-RPC: `http://localhost:4096/a2a/jsonrpc` +- REST: `http://localhost:4096/a2a/rest/execute` +- Agent Card: `http://localhost:4096/a2a/.well-known/agent.json` + +### Connecting from Another Agent + +Other A2A-compliant agents can discover and communicate with OpenCode using the A2A client SDK: + +```typescript +import { ClientFactory } from "@a2a-js/sdk/client" + +// Discover the agent +const client = await ClientFactory.create("http://localhost:4096/a2a/jsonrpc") + +// Send a message +const response = await client.execute({ + messages: [ + { + kind: "message", + role: "user", + parts: [{ kind: "text", text: "List files in the current directory" }], + }, + ], +}) +``` + +### Example Request (JSON-RPC) + +```json +{ + "jsonrpc": "2.0", + "method": "agent/execute", + "params": { + "contextId": "task-123", + "messages": [ + { + "kind": "message", + "role": "user", + "parts": [ + { + "kind": "text", + "text": "Create a new file called test.txt with 'Hello World'" + } + ] + } + ] + }, + "id": 1 +} +``` + +## Implementation Details + +### Task Execution Flow + +1. **Request Reception**: A2A request is received via JSON-RPC or REST +2. **Context Creation**: An OpenCode session is created with the task context +3. **Message Processing**: User message is converted to OpenCode's internal format +4. **Agent Execution**: OpenCode's build agent processes the request +5. **Response Streaming**: Agent responses are streamed and collected +6. **Result Return**: Final result is returned in A2A message format + +### Session Management + +Each A2A task creates an isolated OpenCode session with: +- Unique session ID (`a2a-{taskId}`) +- Working directory from context or current directory +- Full access to OpenCode's tool registry +- Abort capability for task cancellation + +## Integration with OpenCode + +The A2A implementation integrates with existing OpenCode components: + +- **Tool Registry**: Exposes all registered tools as A2A skills +- **Session Management**: Uses OpenCode's session system for state +- **Agent System**: Leverages the "build" agent for execution +- **Server Framework**: Integrated with Hono HTTP server + +## Protocol Specification + +This implementation follows the A2A Protocol Specification v0.3.0: +https://a2a-protocol.org/v0.3.0/specification diff --git a/packages/opencode/src/a2a/card.ts b/packages/opencode/src/a2a/card.ts new file mode 100644 index 000000000000..4ab9051e5e0d --- /dev/null +++ b/packages/opencode/src/a2a/card.ts @@ -0,0 +1,43 @@ +import type { AgentCard } from "@a2a-js/sdk" +import { Installation } from "../installation" +import { ToolRegistry } from "../tool/registry" +import { Flag } from "../flag/flag" +import { Log } from "../util/log" + +export namespace A2ACard { + const log = Log.create({ service: "a2a-card" }) + + export async function generate(serverUrl: string): Promise { + const version = await Installation.version() + const tools = await ToolRegistry.ids() + + const skills = tools.map((toolId) => ({ + id: toolId, + name: toolId.charAt(0).toUpperCase() + toolId.slice(1), + description: `Execute ${toolId} tool`, + tags: ["development", "coding"], + })) + + const card: AgentCard = { + name: "OpenCode", + description: + "OpenCode is an AI-powered development tool with capabilities for code editing, file operations, bash execution, and project management.", + protocolVersion: "0.3.0", + version: version ?? "1.0.0", + url: serverUrl, + skills, + capabilities: { + pushNotifications: false, + }, + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + additionalInterfaces: [ + { url: serverUrl.replace("/jsonrpc", "/rest"), transport: "HTTP+JSON" }, + { url: serverUrl, transport: "JSONRPC" }, + ], + } + + log.info("generated agent card", { tools: tools.length }) + return card + } +} diff --git a/packages/opencode/src/a2a/executor.ts b/packages/opencode/src/a2a/executor.ts new file mode 100644 index 000000000000..cac153a4f8f8 --- /dev/null +++ b/packages/opencode/src/a2a/executor.ts @@ -0,0 +1,111 @@ +import type { AgentExecutor, RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server" +import type { Message } from "@a2a-js/sdk" +import { ulid } from "ulid" +import { Log } from "../util/log" +import { Session } from "../session" +import { Instance } from "../project/instance" +import { Agent } from "../agent/agent" +import { ToolRegistry } from "../tool/registry" +import { MessageV2 } from "../session/message-v2" + +export class A2AExecutor implements AgentExecutor { + private log = Log.create({ service: "a2a-executor" }) + private activeTasks = new Map() + + async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise { + const taskId = ulid() + const abort = new AbortController() + this.activeTasks.set(taskId, abort) + + try { + const messages = requestContext.messages ?? [] + const lastMessage = messages[messages.length - 1] + + if (!lastMessage) { + throw new Error("No message provided") + } + + const textParts = lastMessage.parts?.filter((p) => p.kind === "text") ?? [] + const text = textParts.map((p: any) => p.text).join("\n") + + this.log.info("executing task", { + taskId, + contextId: requestContext.contextId, + messageLength: text.length, + }) + + const directory = requestContext.contextId || Instance.directory + const sessionID = `a2a-${taskId}` + + await Instance.provide({ + directory, + init: async () => { + this.log.info("initializing instance", { directory }) + return {} + }, + async fn() { + const agent = await Agent.fromName("build") + const session = await Session.create({ id: sessionID, agent, directory }) + + const userMessage = MessageV2.user({ text }) + await Session.append(sessionID, userMessage) + + let responseText = "" + const stream = await Session.prompt(sessionID, abort.signal) + + for await (const event of stream) { + if (abort.signal.aborted) break + + if (event.type === "message") { + const msg = event.message + if (msg.role === "assistant") { + const textParts = msg.parts.filter((p) => p.type === "text") + responseText = textParts.map((p: any) => p.text).join("\n") + } + } + } + + const responseMessage: Message = { + kind: "message", + messageId: ulid(), + role: "agent", + parts: [{ kind: "text", text: responseText || "Task completed" }], + contextId: requestContext.contextId, + } + + eventBus.publish(responseMessage) + eventBus.finished() + + this.log.info("task completed", { taskId }) + }, + }) + } catch (error) { + this.log.error("task failed", { taskId, error }) + const errorMessage: Message = { + kind: "message", + messageId: ulid(), + role: "agent", + parts: [ + { + kind: "text", + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + contextId: requestContext.contextId, + } + eventBus.publish(errorMessage) + eventBus.finished() + } finally { + this.activeTasks.delete(taskId) + } + } + + async cancelTask(taskId: string): Promise { + const abort = this.activeTasks.get(taskId) + if (abort) { + abort.abort() + this.activeTasks.delete(taskId) + this.log.info("task cancelled", { taskId }) + } + } +} diff --git a/packages/opencode/src/a2a/index.ts b/packages/opencode/src/a2a/index.ts new file mode 100644 index 000000000000..c717f69f7872 --- /dev/null +++ b/packages/opencode/src/a2a/index.ts @@ -0,0 +1,32 @@ +import { A2AExecutor } from "./executor" +import { A2ACard } from "./card" +import type { A2A } from "./types" +import { Log } from "../util/log" +import { Server } from "../server/server" + +export { A2AExecutor, A2ACard } +export type { A2A } + +export namespace A2A { + const log = Log.create({ service: "a2a" }) + + let executor: A2AExecutor | undefined + + export function getExecutor(): A2AExecutor { + if (!executor) { + executor = new A2AExecutor() + } + return executor + } + + export async function getCard(): Promise { + const serverUrl = Server.url() + const baseUrl = `${serverUrl.protocol}//${serverUrl.host}` + const jsonRpcUrl = `${baseUrl}/a2a/jsonrpc` + return A2ACard.generate(jsonRpcUrl) + } + + export function initialize() { + log.info("A2A protocol initialized") + } +} diff --git a/packages/opencode/src/a2a/types.ts b/packages/opencode/src/a2a/types.ts new file mode 100644 index 000000000000..2f04107a1768 --- /dev/null +++ b/packages/opencode/src/a2a/types.ts @@ -0,0 +1,22 @@ +import z from "zod" + +export namespace A2A { + export const TaskStatus = z.enum(["running", "completed", "failed", "cancelled"]) + export type TaskStatus = z.infer + + export const TaskInfo = z.object({ + taskId: z.string(), + status: TaskStatus, + result: z.string().optional(), + error: z.string().optional(), + }) + export type TaskInfo = z.infer + + export const ExecutionContext = z.object({ + taskId: z.string(), + sessionId: z.string(), + directory: z.string(), + messageId: z.string(), + }) + export type ExecutionContext = z.infer +} diff --git a/packages/opencode/src/server/routes/a2a.ts b/packages/opencode/src/server/routes/a2a.ts new file mode 100644 index 000000000000..48e4edced791 --- /dev/null +++ b/packages/opencode/src/server/routes/a2a.ts @@ -0,0 +1,217 @@ +import { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import z from "zod" +import { A2A } from "../../a2a" +import { errors } from "../error" +import { lazy } from "../../util/lazy" +import { Log } from "../../util/log" +import { DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server" +import type { RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server" +import { UserBuilder } from "@a2a-js/sdk/server/express" +import type { Message } from "@a2a-js/sdk" +import { AGENT_CARD_PATH } from "@a2a-js/sdk" +import { ulid } from "ulid" + +const log = Log.create({ service: "a2a-routes" }) + +export const A2ARoutes = lazy(() => { + const app = new Hono() + + // Initialize A2A components + const executor = A2A.getExecutor() + let requestHandler: DefaultRequestHandler | undefined + + // Get agent card + app.get( + `/${AGENT_CARD_PATH}`, + describeRoute({ + summary: "Get A2A agent card", + description: "Get the Agent2Agent protocol agent card describing OpenCode capabilities.", + operationId: "a2a.card", + responses: { + 200: { + description: "Agent card", + content: { + "application/json": { + schema: resolver(z.any()), + }, + }, + }, + }, + }), + async (c) => { + const card = await A2A.getCard() + return c.json(card) + }, + ) + + // JSON-RPC endpoint + app.post( + "/jsonrpc", + describeRoute({ + summary: "A2A JSON-RPC endpoint", + description: "JSON-RPC interface for Agent2Agent protocol communication.", + operationId: "a2a.jsonrpc", + responses: { + 200: { + description: "JSON-RPC response", + content: { + "application/json": { + schema: resolver(z.any()), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + try { + if (!requestHandler) { + const card = await A2A.getCard() + requestHandler = new DefaultRequestHandler(card, new InMemoryTaskStore(), executor) + } + + const body = await c.req.json() + log.info("jsonrpc request", { method: body.method }) + + // Handle JSON-RPC methods manually since we're using Hono not Express + const result = await handleJsonRpc(body, requestHandler) + return c.json(result) + } catch (error) { + log.error("jsonrpc error", { error }) + return c.json( + { + jsonrpc: "2.0", + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + id: null, + }, + 500, + ) + } + }, + ) + + // REST endpoint + app.post( + "/rest/execute", + describeRoute({ + summary: "A2A REST execute", + description: "REST interface for Agent2Agent protocol task execution.", + operationId: "a2a.rest.execute", + responses: { + 200: { + description: "Execution result", + content: { + "application/json": { + schema: resolver(z.any()), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + try { + const body = await c.req.json() + const taskId = ulid() + + const requestContext: RequestContext = { + contextId: body.contextId || taskId, + messages: body.messages || [], + user: { userId: "anonymous" }, + } + + const messages: Message[] = [] + const eventBus: ExecutionEventBus = { + publish: (msg: Message) => { + messages.push(msg) + }, + finished: () => {}, + } + + await executor.execute(requestContext, eventBus) + + return c.json({ + taskId, + messages, + status: "completed", + }) + } catch (error) { + log.error("rest execute error", { error }) + return c.json( + { + error: error instanceof Error ? error.message : String(error), + status: "failed", + }, + 500, + ) + } + }, + ) + + return app +}) + +async function handleJsonRpc(body: any, handler: DefaultRequestHandler) { + const { method, params, id } = body + + try { + switch (method) { + case "agent/execute": { + const taskId = ulid() + const requestContext: RequestContext = { + contextId: params.contextId || taskId, + messages: params.messages || [], + user: { userId: params.userId || "anonymous" }, + } + + const messages: Message[] = [] + const eventBus: ExecutionEventBus = { + publish: (msg: Message) => { + messages.push(msg) + }, + finished: () => {}, + } + + await A2A.getExecutor().execute(requestContext, eventBus) + + return { + jsonrpc: "2.0", + result: { taskId, messages }, + id, + } + } + + case "agent/card": { + const card = await A2A.getCard() + return { + jsonrpc: "2.0", + result: card, + id, + } + } + + default: + return { + jsonrpc: "2.0", + error: { + code: -32601, + message: `Method not found: ${method}`, + }, + id, + } + } + } catch (error) { + return { + jsonrpc: "2.0", + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + id, + } + } +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 015553802a47..2b7fbf64a21a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -25,6 +25,7 @@ import { ProjectRoutes } from "./routes/project" import { SessionRoutes } from "./routes/session" import { PtyRoutes } from "./routes/pty" import { McpRoutes } from "./routes/mcp" +import { A2ARoutes } from "./routes/a2a" import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" @@ -226,6 +227,7 @@ export namespace Server { .route("/provider", ProviderRoutes()) .route("/", FileRoutes()) .route("/mcp", McpRoutes()) + .route("/a2a", A2ARoutes()) .route("/tui", TuiRoutes()) .post( "/instance/dispose", From 7857812c810d7ff1bf471db4abb61932a168e63a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:41:40 +0000 Subject: [PATCH 03/12] Remove A2A SDK runtime dependencies, implement protocol directly Co-authored-by: jgranesa <47306065+jgranesa@users.noreply.github.com> --- packages/opencode/src/a2a/card.ts | 25 ++++++++++++- packages/opencode/src/a2a/executor.ts | 27 ++++++++++++-- packages/opencode/src/server/routes/a2a.ts | 42 ++++++++++++++-------- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/a2a/card.ts b/packages/opencode/src/a2a/card.ts index 4ab9051e5e0d..4a732ac7f55c 100644 --- a/packages/opencode/src/a2a/card.ts +++ b/packages/opencode/src/a2a/card.ts @@ -1,9 +1,32 @@ -import type { AgentCard } from "@a2a-js/sdk" import { Installation } from "../installation" import { ToolRegistry } from "../tool/registry" import { Flag } from "../flag/flag" import { Log } from "../util/log" +// AgentCard interface matching A2A protocol spec +interface AgentCard { + name: string + description: string + protocolVersion: string + version: string + url: string + skills: Array<{ + id: string + name: string + description: string + tags: string[] + }> + capabilities: { + pushNotifications: boolean + } + defaultInputModes: string[] + defaultOutputModes: string[] + additionalInterfaces: Array<{ + url: string + transport: string + }> +} + export namespace A2ACard { const log = Log.create({ service: "a2a-card" }) diff --git a/packages/opencode/src/a2a/executor.ts b/packages/opencode/src/a2a/executor.ts index cac153a4f8f8..9545d54f120f 100644 --- a/packages/opencode/src/a2a/executor.ts +++ b/packages/opencode/src/a2a/executor.ts @@ -1,5 +1,3 @@ -import type { AgentExecutor, RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server" -import type { Message } from "@a2a-js/sdk" import { ulid } from "ulid" import { Log } from "../util/log" import { Session } from "../session" @@ -8,6 +6,31 @@ import { Agent } from "../agent/agent" import { ToolRegistry } from "../tool/registry" import { MessageV2 } from "../session/message-v2" +// A2A Protocol types matching the spec +interface Message { + kind: "message" + messageId: string + role: "user" | "agent" + parts: Array<{ kind: "text"; text: string }> + contextId?: string +} + +interface RequestContext { + contextId?: string + messages?: Message[] + user?: { userId: string } +} + +interface ExecutionEventBus { + publish: (message: Message) => void + finished: () => void +} + +interface AgentExecutor { + execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise + cancelTask(taskId: string): Promise +} + export class A2AExecutor implements AgentExecutor { private log = Log.create({ service: "a2a-executor" }) private activeTasks = new Map() diff --git a/packages/opencode/src/server/routes/a2a.ts b/packages/opencode/src/server/routes/a2a.ts index 48e4edced791..bd5e4abb8644 100644 --- a/packages/opencode/src/server/routes/a2a.ts +++ b/packages/opencode/src/server/routes/a2a.ts @@ -5,21 +5,38 @@ import { A2A } from "../../a2a" import { errors } from "../error" import { lazy } from "../../util/lazy" import { Log } from "../../util/log" -import { DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server" -import type { RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server" -import { UserBuilder } from "@a2a-js/sdk/server/express" -import type { Message } from "@a2a-js/sdk" -import { AGENT_CARD_PATH } from "@a2a-js/sdk" import { ulid } from "ulid" const log = Log.create({ service: "a2a-routes" }) +// Agent card path constant +const AGENT_CARD_PATH = ".well-known/agent.json" + +// A2A Protocol types +interface Message { + kind: "message" + messageId: string + role: "user" | "agent" + parts: Array<{ kind: "text"; text: string }> + contextId?: string +} + +interface RequestContext { + contextId?: string + messages?: Message[] + user?: { userId: string } +} + +interface ExecutionEventBus { + publish: (message: Message) => void + finished: () => void +} + export const A2ARoutes = lazy(() => { const app = new Hono() // Initialize A2A components const executor = A2A.getExecutor() - let requestHandler: DefaultRequestHandler | undefined // Get agent card app.get( @@ -66,16 +83,11 @@ export const A2ARoutes = lazy(() => { }), async (c) => { try { - if (!requestHandler) { - const card = await A2A.getCard() - requestHandler = new DefaultRequestHandler(card, new InMemoryTaskStore(), executor) - } - const body = await c.req.json() log.info("jsonrpc request", { method: body.method }) - // Handle JSON-RPC methods manually since we're using Hono not Express - const result = await handleJsonRpc(body, requestHandler) + // Handle JSON-RPC methods directly + const result = await handleJsonRpc(body, executor) return c.json(result) } catch (error) { log.error("jsonrpc error", { error }) @@ -155,7 +167,7 @@ export const A2ARoutes = lazy(() => { return app }) -async function handleJsonRpc(body: any, handler: DefaultRequestHandler) { +async function handleJsonRpc(body: any, executor: any) { const { method, params, id } = body try { @@ -176,7 +188,7 @@ async function handleJsonRpc(body: any, handler: DefaultRequestHandler) { finished: () => {}, } - await A2A.getExecutor().execute(requestContext, eventBus) + await executor.execute(requestContext, eventBus) return { jsonrpc: "2.0", From 89c9a04221676f432da40cd913503e583c071ff8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:42:32 +0000 Subject: [PATCH 04/12] Add A2A tests and remove unused dependency from package.json Co-authored-by: jgranesa <47306065+jgranesa@users.noreply.github.com> --- packages/opencode/package.json | 1 - packages/opencode/test/a2a/card.test.ts | 44 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/a2a/card.test.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index b0da00552790..a0d6892f9d3f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -48,7 +48,6 @@ "zod-to-json-schema": "3.24.5" }, "dependencies": { - "@a2a-js/sdk": "0.3.10", "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.13.0", diff --git a/packages/opencode/test/a2a/card.test.ts b/packages/opencode/test/a2a/card.test.ts new file mode 100644 index 000000000000..efb12302c2ec --- /dev/null +++ b/packages/opencode/test/a2a/card.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from "bun:test" +import { A2ACard } from "../../src/a2a/card" +import { A2AExecutor } from "../../src/a2a/executor" +import { A2A } from "../../src/a2a" + +test("A2A card generation", async () => { + const serverUrl = "http://localhost:4096/a2a/jsonrpc" + const card = await A2ACard.generate(serverUrl) + + expect(card).toBeDefined() + expect(card.name).toBe("OpenCode") + expect(card.protocolVersion).toBe("0.3.0") + expect(card.url).toBe(serverUrl) + expect(card.skills).toBeInstanceOf(Array) + expect(card.skills.length).toBeGreaterThan(0) + expect(card.additionalInterfaces).toBeInstanceOf(Array) + expect(card.additionalInterfaces.length).toBe(2) +}) + +test("A2A executor initialization", () => { + const executor = new A2AExecutor() + expect(executor).toBeDefined() + expect(typeof executor.execute).toBe("function") + expect(typeof executor.cancelTask).toBe("function") +}) + +test("A2A namespace exports", () => { + expect(A2A.getExecutor).toBeDefined() + expect(A2A.getCard).toBeDefined() + expect(A2A.initialize).toBeDefined() +}) + +test("A2A card describes available tools", async () => { + const serverUrl = "http://localhost:4096/a2a/jsonrpc" + const card = await A2ACard.generate(serverUrl) + + // Check that some common tools are exposed + const skillIds = card.skills.map((s) => s.id) + expect(skillIds).toContain("bash") + expect(skillIds).toContain("read") + expect(skillIds).toContain("edit") + expect(skillIds).toContain("grep") + expect(skillIds).toContain("glob") +}) From d73122a16847d60986581a3140711739473fb520 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:43:26 +0000 Subject: [PATCH 05/12] Add comprehensive A2A protocol documentation Co-authored-by: jgranesa <47306065+jgranesa@users.noreply.github.com> --- packages/web/src/content/docs/a2a.mdx | 278 ++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 packages/web/src/content/docs/a2a.mdx diff --git a/packages/web/src/content/docs/a2a.mdx b/packages/web/src/content/docs/a2a.mdx new file mode 100644 index 000000000000..7818483559a5 --- /dev/null +++ b/packages/web/src/content/docs/a2a.mdx @@ -0,0 +1,278 @@ +--- +title: Agent2Agent (A2A) Protocol +--- + +OpenCode implements the Agent2Agent (A2A) Protocol, allowing it to expose all its capabilities to other A2A-compliant agents. This enables agent-to-agent communication, task delegation, and collaborative workflows. + +## What is A2A? + +The [Agent2Agent (A2A) Protocol](https://a2a-protocol.org) is an open standard for agent-to-agent communication. It enables autonomous AI agents to discover, communicate with, and delegate tasks to each other in a standardized way. + +## How it Works + +OpenCode exposes its capabilities as an A2A-compliant agent server with three main components: + +### 1. Agent Card + +The agent card describes OpenCode's capabilities and available transports. It's accessible at: + +``` +GET http://localhost:4096/a2a/.well-known/agent.json +``` + +The card includes: +- **Agent metadata** (name, version, description) +- **Available skills** (all OpenCode tools as A2A skills) +- **Supported transports** (JSON-RPC, HTTP+JSON) +- **Protocol version** (v0.3.0) + +### 2. JSON-RPC Interface + +The primary communication interface using JSON-RPC 2.0: + +``` +POST http://localhost:4096/a2a/jsonrpc +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "method": "agent/execute", + "params": { + "contextId": "task-123", + "messages": [ + { + "kind": "message", + "role": "user", + "parts": [ + { + "kind": "text", + "text": "List all TypeScript files in the src directory" + } + ] + } + ] + }, + "id": 1 +} +``` + +### 3. REST Interface + +Alternative HTTP+JSON interface for task execution: + +``` +POST http://localhost:4096/a2a/rest/execute +Content-Type: application/json + +{ + "contextId": "task-123", + "messages": [ + { + "kind": "message", + "role": "user", + "parts": [{ "kind": "text", "text": "Create a new file called test.txt" }] + } + ] +} +``` + +## Exposed Capabilities + +OpenCode automatically exposes all registered tools as A2A skills: + +- **bash** - Execute shell commands +- **read** - Read file contents +- **write** - Create new files +- **edit** - Modify existing files +- **grep** - Search file contents +- **glob** - Find files by pattern +- **task** - Delegate to sub-agents +- **webfetch** - Fetch web content +- **websearch** - Search the web +- **codesearch** - Search code across repositories +- **skill** - Execute custom skills +- **lsp** - Language server protocol operations +- And more... + +## Using OpenCode as an A2A Client + +Other A2A-compliant agents can discover and interact with OpenCode: + +### Python Example + +```python +import requests + +# Discover agent capabilities +card = requests.get("http://localhost:4096/a2a/.well-known/agent.json").json() +print(f"Agent: {card['name']}") +print(f"Skills: {[skill['id'] for skill in card['skills']]}") + +# Execute a task +response = requests.post( + "http://localhost:4096/a2a/jsonrpc", + json={ + "jsonrpc": "2.0", + "method": "agent/execute", + "params": { + "messages": [{ + "kind": "message", + "role": "user", + "parts": [{"kind": "text", "text": "List files in current directory"}] + }] + }, + "id": 1 + } +).json() +``` + +### JavaScript Example + +```javascript +// Fetch agent card +const card = await fetch('http://localhost:4096/a2a/.well-known/agent.json') + .then(r => r.json()) + +console.log(`Agent: ${card.name}`) +console.log(`Skills: ${card.skills.map(s => s.id)}`) + +// Execute task via JSON-RPC +const result = await fetch('http://localhost:4096/a2a/jsonrpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'agent/execute', + params: { + messages: [{ + kind: 'message', + role: 'user', + parts: [{ kind: 'text', text: 'Show git status' }] + }] + }, + id: 1 + }) +}).then(r => r.json()) +``` + +## Configuration + +The A2A endpoints are automatically enabled when the OpenCode server starts. No additional configuration is required. + +### Custom Server URL + +If you're running OpenCode on a different host or port: + +```bash +OPENCODE_SERVER_URL=http://0.0.0.0:8080 opencode +``` + +The A2A endpoints will be available at: +- Agent Card: `http://0.0.0.0:8080/a2a/.well-known/agent.json` +- JSON-RPC: `http://0.0.0.0:8080/a2a/jsonrpc` +- REST: `http://0.0.0.0:8080/a2a/rest/execute` + +### Authentication + +If you've enabled server authentication: + +```bash +OPENCODE_SERVER_PASSWORD=secret opencode +``` + +All A2A requests must include basic authentication: + +```bash +curl -u opencode:secret http://localhost:4096/a2a/.well-known/agent.json +``` + +## Use Cases + +### 1. Multi-Agent Workflows + +Connect multiple specialized agents where one agent delegates specific tasks to OpenCode: + +``` +Planning Agent → OpenCode (code changes) + → Testing Agent (run tests) + → Deployment Agent (deploy) +``` + +### 2. IDE Integration + +Build custom IDE extensions that communicate with OpenCode via A2A: + +``` +Custom IDE Extension → OpenCode A2A Interface → Code Operations +``` + +### 3. CI/CD Pipelines + +Integrate OpenCode into CI/CD workflows as an A2A agent: + +``` +GitHub Actions → A2A Request → OpenCode → Code Review/Fixes +``` + +### 4. Agent Orchestration + +Use OpenCode as part of a larger agent orchestration system: + +``` +Orchestrator → Discovers OpenCode via A2A + → Delegates coding tasks + → Receives results +``` + +## Protocol Specification + +OpenCode implements [A2A Protocol v0.3.0](https://a2a-protocol.org/v0.3.0/specification). + +Key features: +- **Standard message format** for cross-agent communication +- **Agent discovery** via well-known endpoints +- **Multiple transports** (JSON-RPC, HTTP+JSON, gRPC) +- **Context preservation** across conversations +- **Task management** with cancellation support + +## Troubleshooting + +### Agent Card Not Accessible + +Make sure the OpenCode server is running: + +```bash +# Check if server is running +curl http://localhost:4096/a2a/.well-known/agent.json + +# If not, start OpenCode with server mode +opencode +``` + +### Connection Refused + +Verify the server is listening on the expected port: + +```bash +# Check what port OpenCode is using +lsof -i :4096 + +# Or specify a different port +OPENCODE_SERVER_PORT=8080 opencode +``` + +### Authentication Issues + +If using server authentication, include credentials: + +```bash +curl -u opencode:YOUR_PASSWORD \ + http://localhost:4096/a2a/.well-known/agent.json +``` + +## See Also + +- [ACP (Agent Client Protocol)](/docs/acp) - For IDE/editor integration +- [MCP (Model Context Protocol)](/docs/mcp-servers) - For tool/resource integration +- [Server Documentation](/docs/server) - OpenCode server configuration +- [SDK](/docs/sdk) - Programmatic access to OpenCode From 5edf968334fa8581671f52ddb39f552fdb5772de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:45:10 +0000 Subject: [PATCH 06/12] Fix code review issues: improve type safety and URL handling Co-authored-by: jgranesa <47306065+jgranesa@users.noreply.github.com> --- packages/opencode/src/a2a/card.ts | 9 ++++++++- packages/opencode/src/a2a/executor.ts | 4 ++-- packages/opencode/src/server/routes/a2a.ts | 22 ++++++++++++++++++++-- packages/web/src/content/docs/a2a.mdx | 3 +++ 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/a2a/card.ts b/packages/opencode/src/a2a/card.ts index 4a732ac7f55c..3102bb8b0c90 100644 --- a/packages/opencode/src/a2a/card.ts +++ b/packages/opencode/src/a2a/card.ts @@ -55,7 +55,14 @@ export namespace A2ACard { defaultInputModes: ["text"], defaultOutputModes: ["text"], additionalInterfaces: [ - { url: serverUrl.replace("/jsonrpc", "/rest"), transport: "HTTP+JSON" }, + { + url: (() => { + const url = new URL(serverUrl) + url.pathname = url.pathname.replace(/\/jsonrpc$/, "/rest") + return url.toString() + })(), + transport: "HTTP+JSON", + }, { url: serverUrl, transport: "JSONRPC" }, ], } diff --git a/packages/opencode/src/a2a/executor.ts b/packages/opencode/src/a2a/executor.ts index 9545d54f120f..d30b13f73c67 100644 --- a/packages/opencode/src/a2a/executor.ts +++ b/packages/opencode/src/a2a/executor.ts @@ -3,7 +3,6 @@ import { Log } from "../util/log" import { Session } from "../session" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" -import { ToolRegistry } from "../tool/registry" import { MessageV2 } from "../session/message-v2" // A2A Protocol types matching the spec @@ -57,7 +56,8 @@ export class A2AExecutor implements AgentExecutor { messageLength: text.length, }) - const directory = requestContext.contextId || Instance.directory + // Use the current instance directory, not the contextId + const directory = Instance.directory const sessionID = `a2a-${taskId}` await Instance.provide({ diff --git a/packages/opencode/src/server/routes/a2a.ts b/packages/opencode/src/server/routes/a2a.ts index bd5e4abb8644..2621a764a2f6 100644 --- a/packages/opencode/src/server/routes/a2a.ts +++ b/packages/opencode/src/server/routes/a2a.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import { describeRoute, resolver } from "hono-openapi" import z from "zod" -import { A2A } from "../../a2a" +import { A2A, A2AExecutor } from "../../a2a" import { errors } from "../error" import { lazy } from "../../util/lazy" import { Log } from "../../util/log" @@ -32,6 +32,24 @@ interface ExecutionEventBus { finished: () => void } +// JSON-RPC request/response types +interface JsonRpcRequest { + jsonrpc: "2.0" + method: string + params?: any + id: string | number | null +} + +interface JsonRpcResponse { + jsonrpc: "2.0" + result?: any + error?: { + code: number + message: string + } + id: string | number | null +} + export const A2ARoutes = lazy(() => { const app = new Hono() @@ -167,7 +185,7 @@ export const A2ARoutes = lazy(() => { return app }) -async function handleJsonRpc(body: any, executor: any) { +async function handleJsonRpc(body: JsonRpcRequest, executor: A2AExecutor): Promise { const { method, params, id } = body try { diff --git a/packages/web/src/content/docs/a2a.mdx b/packages/web/src/content/docs/a2a.mdx index 7818483559a5..02eaa9bb42c4 100644 --- a/packages/web/src/content/docs/a2a.mdx +++ b/packages/web/src/content/docs/a2a.mdx @@ -69,6 +69,7 @@ Content-Type: application/json "messages": [ { "kind": "message", + "messageId": "msg-001", "role": "user", "parts": [{ "kind": "text", "text": "Create a new file called test.txt" }] } @@ -117,6 +118,7 @@ response = requests.post( "params": { "messages": [{ "kind": "message", + "messageId": "msg-001", "role": "user", "parts": [{"kind": "text", "text": "List files in current directory"}] }] @@ -146,6 +148,7 @@ const result = await fetch('http://localhost:4096/a2a/jsonrpc', { params: { messages: [{ kind: 'message', + messageId: 'msg-001', role: 'user', parts: [{ kind: 'text', text: 'Show git status' }] }] From 3c9214ab4c24ff63381b7e73dd91680b53bd1618 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:45:59 +0000 Subject: [PATCH 07/12] Improve URL path manipulation for REST endpoint Co-authored-by: jgranesa <47306065+jgranesa@users.noreply.github.com> --- packages/opencode/src/a2a/card.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/a2a/card.ts b/packages/opencode/src/a2a/card.ts index 3102bb8b0c90..15e8daeee4e8 100644 --- a/packages/opencode/src/a2a/card.ts +++ b/packages/opencode/src/a2a/card.ts @@ -58,7 +58,13 @@ export namespace A2ACard { { url: (() => { const url = new URL(serverUrl) - url.pathname = url.pathname.replace(/\/jsonrpc$/, "/rest") + // Replace the last '/jsonrpc' segment with '/rest' + const parts = url.pathname.split("/") + const lastIndex = parts.lastIndexOf("jsonrpc") + if (lastIndex !== -1) { + parts[lastIndex] = "rest" + url.pathname = parts.join("/") + } return url.toString() })(), transport: "HTTP+JSON", From c159d2d106fad565a22a8c6a3f663adf48931e07 Mon Sep 17 00:00:00 2001 From: Jaume Grane <47306065+jgranesa@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:33:02 +0100 Subject: [PATCH 08/12] fix import and version --- bun.lock | 1 - packages/opencode/script/build.ts | 2 +- packages/opencode/src/a2a/card.ts | 3 +-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 91b9fa339987..b82654122878 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "opencode", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index f0b3fa828a78..46cf42a92f93 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin" +import solidPlugin from "@opentui/solid/bun-plugin" import path from "path" import fs from "fs" import { $ } from "bun" diff --git a/packages/opencode/src/a2a/card.ts b/packages/opencode/src/a2a/card.ts index 15e8daeee4e8..35ea162990db 100644 --- a/packages/opencode/src/a2a/card.ts +++ b/packages/opencode/src/a2a/card.ts @@ -1,6 +1,5 @@ import { Installation } from "../installation" import { ToolRegistry } from "../tool/registry" -import { Flag } from "../flag/flag" import { Log } from "../util/log" // AgentCard interface matching A2A protocol spec @@ -31,7 +30,7 @@ export namespace A2ACard { const log = Log.create({ service: "a2a-card" }) export async function generate(serverUrl: string): Promise { - const version = await Installation.version() + const version = Installation.VERSION const tools = await ToolRegistry.ids() const skills = tools.map((toolId) => ({ From 6ee8274b16b17cdbaca3117bd41f0ada1561e7e5 Mon Sep 17 00:00:00 2001 From: Jaume Grane <47306065+jgranesa@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:39:42 +0100 Subject: [PATCH 09/12] improve sessions --- packages/opencode/src/a2a/executor.ts | 95 ++++++++++++++++++++------- packages/opencode/src/a2a/index.ts | 8 ++- 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/a2a/executor.ts b/packages/opencode/src/a2a/executor.ts index d30b13f73c67..50432fb05e18 100644 --- a/packages/opencode/src/a2a/executor.ts +++ b/packages/opencode/src/a2a/executor.ts @@ -3,7 +3,10 @@ import { Log } from "../util/log" import { Session } from "../session" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" +import { LLM } from "../session/llm" +import { Provider } from "../provider/provider" import { MessageV2 } from "../session/message-v2" +import { Identifier } from "../id/id" // A2A Protocol types matching the spec interface Message { @@ -34,6 +37,10 @@ export class A2AExecutor implements AgentExecutor { private log = Log.create({ service: "a2a-executor" }) private activeTasks = new Map() + private getLog() { + return this.log + } + async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise { const taskId = ulid() const abort = new AbortController() @@ -47,44 +54,85 @@ export class A2AExecutor implements AgentExecutor { throw new Error("No message provided") } - const textParts = lastMessage.parts?.filter((p) => p.kind === "text") ?? [] - const text = textParts.map((p: any) => p.text).join("\n") + const textParts = lastMessage.parts?.filter((p): p is { kind: "text"; text: string } => p.kind === "text") ?? [] + const text = textParts.map((p) => p.text).join("\n") - this.log.info("executing task", { + const log = this.getLog() + log.info("executing task", { taskId, contextId: requestContext.contextId, messageLength: text.length, }) - // Use the current instance directory, not the contextId const directory = Instance.directory - const sessionID = `a2a-${taskId}` await Instance.provide({ directory, init: async () => { - this.log.info("initializing instance", { directory }) + log.info("initializing instance", { directory }) return {} }, async fn() { - const agent = await Agent.fromName("build") - const session = await Session.create({ id: sessionID, agent, directory }) + // Create a session + const session = await Session.create({ + title: `A2A Session ${taskId}`, + }) + + // Get the build agent + const agent = await Agent.get("build") + if (!agent) throw new Error("build agent not found") + + // Create a user message + const userMessageID = Identifier.ascending("message") + const userMessage: MessageV2.User = { + id: userMessageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" }, + } - const userMessage = MessageV2.user({ text }) - await Session.append(sessionID, userMessage) + // Store the user message in the session + await Session.updateMessage(userMessage) + const userPart: MessageV2.TextPart = { + id: Identifier.ascending("part"), + sessionID: session.id, + messageID: userMessageID, + type: "text", + text, + } + await Session.updatePart(userPart) let responseText = "" - const stream = await Session.prompt(sessionID, abort.signal) - - for await (const event of stream) { - if (abort.signal.aborted) break - if (event.type === "message") { - const msg = event.message - if (msg.role === "assistant") { - const textParts = msg.parts.filter((p) => p.type === "text") - responseText = textParts.map((p: any) => p.text).join("\n") - } + // Get the model + const model = await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID) + if (!model) throw new Error("model not found") + + // Stream the response from the LLM + const stream = await LLM.stream({ + sessionID: session.id, + agent, + model, + user: userMessage, + system: [], + abort: abort.signal, + messages: [ + { + role: "user", + content: text, + }, + ], + tools: {}, + }) + + // LLM.stream returns a StreamTextResult which has various properties + // We need to iterate through the text output + if (stream.textStream) { + for await (const chunk of stream.textStream) { + if (abort.signal.aborted) break + responseText += chunk } } @@ -99,11 +147,12 @@ export class A2AExecutor implements AgentExecutor { eventBus.publish(responseMessage) eventBus.finished() - this.log.info("task completed", { taskId }) + log.info("task completed", { taskId }) }, }) } catch (error) { - this.log.error("task failed", { taskId, error }) + const log = this.getLog() + log.error("task failed", { taskId, error }) const errorMessage: Message = { kind: "message", messageId: ulid(), @@ -128,7 +177,7 @@ export class A2AExecutor implements AgentExecutor { if (abort) { abort.abort() this.activeTasks.delete(taskId) - this.log.info("task cancelled", { taskId }) + this.getLog().info("task cancelled", { taskId }) } } } diff --git a/packages/opencode/src/a2a/index.ts b/packages/opencode/src/a2a/index.ts index c717f69f7872..0dc11b36db2f 100644 --- a/packages/opencode/src/a2a/index.ts +++ b/packages/opencode/src/a2a/index.ts @@ -1,13 +1,17 @@ import { A2AExecutor } from "./executor" import { A2ACard } from "./card" -import type { A2A } from "./types" +import * as A2ATypes from "./types" import { Log } from "../util/log" import { Server } from "../server/server" export { A2AExecutor, A2ACard } -export type { A2A } +export const A2ANamespace = A2ATypes export namespace A2A { + export type TaskStatus = A2ATypes.A2A.TaskStatus + export type TaskInfo = A2ATypes.A2A.TaskInfo + export type ExecutionContext = A2ATypes.A2A.ExecutionContext + const log = Log.create({ service: "a2a" }) let executor: A2AExecutor | undefined From 9a2572bae674b473215cde788a2d9d1c19cadf26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:58:37 +0000 Subject: [PATCH 10/12] Add A2A CLI command for Agent2Agent protocol management Co-authored-by: jgranesa <47306065+jgranesa@users.noreply.github.com> --- packages/opencode/src/cli/cmd/a2a.ts | 92 ++++++++++++++++++++++++++++ packages/opencode/src/index.ts | 2 + 2 files changed, 94 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/a2a.ts diff --git a/packages/opencode/src/cli/cmd/a2a.ts b/packages/opencode/src/cli/cmd/a2a.ts new file mode 100644 index 000000000000..b99352c718d1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/a2a.ts @@ -0,0 +1,92 @@ +import { Log } from "@/util/log" +import { bootstrap } from "../bootstrap" +import { cmd } from "./cmd" +import { Server } from "@/server/server" +import { withNetworkOptions, resolveNetworkOptions } from "../network" +import { A2A } from "@/a2a" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" + +const log = Log.create({ service: "a2a-command" }) + +export const A2ACommand = cmd({ + command: "a2a [action]", + describe: "manage A2A (Agent2Agent Protocol) server", + builder: (yargs) => { + return withNetworkOptions(yargs) + .positional("action", { + describe: "action to perform", + type: "string", + choices: ["start", "status", "info"], + default: "start", + }) + .option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) + }, + handler: async (args) => { + process.env.OPENCODE_CLIENT = "a2a" + + if (args.action === "info") { + prompts.intro(UI.gradient("A2A Protocol Info")) + console.log("\nAgent2Agent (A2A) Protocol v0.3.0") + console.log("\nThe A2A protocol enables agent-to-agent communication,") + console.log("allowing OpenCode to expose its capabilities to other agents.") + console.log("\nEndpoints:") + console.log(" • GET /a2a/.well-known/agent.json - Agent card") + console.log(" • POST /a2a/jsonrpc - JSON-RPC 2.0 interface") + console.log(" • POST /a2a/rest/execute - REST interface") + console.log("\nDocumentation: https://opencode.ai/docs/a2a") + prompts.outro("A2A is automatically enabled when the server starts") + return + } + + if (args.action === "status") { + prompts.intro(UI.gradient("A2A Server Status")) + await bootstrap(args.cwd, async () => { + const opts = await resolveNetworkOptions(args) + console.log(`\n✓ A2A server available at: http://${opts.hostname}:${opts.port}/a2a`) + console.log(`\nAgent card: http://${opts.hostname}:${opts.port}/a2a/.well-known/agent.json`) + console.log(`JSON-RPC: http://${opts.hostname}:${opts.port}/a2a/jsonrpc`) + console.log(`REST API: http://${opts.hostname}:${opts.port}/a2a/rest/execute`) + }) + prompts.outro("A2A endpoints are ready") + return + } + + // Default action: start + prompts.intro(UI.gradient("Starting A2A Server")) + await bootstrap(args.cwd, async () => { + const opts = await resolveNetworkOptions(args) + const server = Server.listen(opts) + + // Initialize A2A + A2A.initialize() + + log.info("A2A server started", { + hostname: server.hostname, + port: server.port, + }) + + console.log(`\n✓ A2A server running at http://${server.hostname}:${server.port}/a2a`) + console.log(`\nAgent card: http://${server.hostname}:${server.port}/a2a/.well-known/agent.json`) + console.log(`\nUse Ctrl+C to stop the server`) + + prompts.outro("A2A server is running") + + // Keep the process alive + await new Promise((resolve) => { + process.on("SIGINT", () => { + console.log("\n\nShutting down A2A server...") + resolve(null) + }) + process.on("SIGTERM", () => { + console.log("\n\nShutting down A2A server...") + resolve(null) + }) + }) + }) + }, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6dc5e99e91ef..2ffb30165ed6 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -22,6 +22,7 @@ import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/tui/attach" import { TuiThreadCommand } from "./cli/cmd/tui/thread" import { AcpCommand } from "./cli/cmd/acp" +import { A2ACommand } from "./cli/cmd/a2a" import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" @@ -78,6 +79,7 @@ const cli = yargs(hideBin(process.argv)) .usage("\n" + UI.logo()) .completion("completion", "generate shell completion script") .command(AcpCommand) + .command(A2ACommand) .command(McpCommand) .command(TuiThreadCommand) .command(AttachCommand) From 846b95f099b7bc436c1abd2837ebf5752a3fbb5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:59:18 +0000 Subject: [PATCH 11/12] Add A2A command tests and update documentation with CLI usage Co-authored-by: jgranesa <47306065+jgranesa@users.noreply.github.com> --- packages/opencode/test/cli/a2a.test.ts | 18 +++++++++++++++++ packages/web/src/content/docs/a2a.mdx | 27 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 packages/opencode/test/cli/a2a.test.ts diff --git a/packages/opencode/test/cli/a2a.test.ts b/packages/opencode/test/cli/a2a.test.ts new file mode 100644 index 000000000000..bdd16947dfdc --- /dev/null +++ b/packages/opencode/test/cli/a2a.test.ts @@ -0,0 +1,18 @@ +import { test, expect } from "bun:test" +import { A2ACommand } from "../../src/cli/cmd/a2a" + +test("A2A command is defined", () => { + expect(A2ACommand).toBeDefined() + expect(A2ACommand.command).toBe("a2a [action]") + expect(A2ACommand.describe).toBe("manage A2A (Agent2Agent Protocol) server") +}) + +test("A2A command has correct builder", () => { + expect(A2ACommand.builder).toBeDefined() + expect(typeof A2ACommand.builder).toBe("function") +}) + +test("A2A command has handler", () => { + expect(A2ACommand.handler).toBeDefined() + expect(typeof A2ACommand.handler).toBe("function") +}) diff --git a/packages/web/src/content/docs/a2a.mdx b/packages/web/src/content/docs/a2a.mdx index 02eaa9bb42c4..6c26c6da271d 100644 --- a/packages/web/src/content/docs/a2a.mdx +++ b/packages/web/src/content/docs/a2a.mdx @@ -162,6 +162,33 @@ const result = await fetch('http://localhost:4096/a2a/jsonrpc', { The A2A endpoints are automatically enabled when the OpenCode server starts. No additional configuration is required. +### Starting the A2A Server + +You can start the A2A server using the CLI command: + +```bash +# Start A2A server (default action) +opencode a2a + +# Or explicitly use the start action +opencode a2a start + +# Get information about A2A protocol +opencode a2a info + +# Check A2A server status and endpoints +opencode a2a status +``` + +The server will display the available endpoints: +``` +✓ A2A server running at http://localhost:4096/a2a + +Agent card: http://localhost:4096/a2a/.well-known/agent.json + +Use Ctrl+C to stop the server +``` + ### Custom Server URL If you're running OpenCode on a different host or port: From 17571a2fec36d2250b98d38ad8af59688f5c8ff8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:00:48 +0000 Subject: [PATCH 12/12] Extract A2A protocol version to constant for maintainability Co-authored-by: jgranesa <47306065+jgranesa@users.noreply.github.com> --- packages/opencode/src/a2a/card.ts | 3 ++- packages/opencode/src/a2a/index.ts | 3 +++ packages/opencode/src/a2a/types.ts | 3 +++ packages/opencode/src/cli/cmd/a2a.ts | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/a2a/card.ts b/packages/opencode/src/a2a/card.ts index 35ea162990db..58fef75c3f16 100644 --- a/packages/opencode/src/a2a/card.ts +++ b/packages/opencode/src/a2a/card.ts @@ -1,6 +1,7 @@ import { Installation } from "../installation" import { ToolRegistry } from "../tool/registry" import { Log } from "../util/log" +import { A2A as A2ATypes } from "./types" // AgentCard interface matching A2A protocol spec interface AgentCard { @@ -44,7 +45,7 @@ export namespace A2ACard { name: "OpenCode", description: "OpenCode is an AI-powered development tool with capabilities for code editing, file operations, bash execution, and project management.", - protocolVersion: "0.3.0", + protocolVersion: A2ATypes.PROTOCOL_VERSION, version: version ?? "1.0.0", url: serverUrl, skills, diff --git a/packages/opencode/src/a2a/index.ts b/packages/opencode/src/a2a/index.ts index 0dc11b36db2f..5dc84831a347 100644 --- a/packages/opencode/src/a2a/index.ts +++ b/packages/opencode/src/a2a/index.ts @@ -14,6 +14,9 @@ export namespace A2A { const log = Log.create({ service: "a2a" }) + // A2A Protocol version implemented + export const PROTOCOL_VERSION = A2ATypes.A2A.PROTOCOL_VERSION + let executor: A2AExecutor | undefined export function getExecutor(): A2AExecutor { diff --git a/packages/opencode/src/a2a/types.ts b/packages/opencode/src/a2a/types.ts index 2f04107a1768..0f887e3023bf 100644 --- a/packages/opencode/src/a2a/types.ts +++ b/packages/opencode/src/a2a/types.ts @@ -1,6 +1,9 @@ import z from "zod" export namespace A2A { + // A2A Protocol version implemented + export const PROTOCOL_VERSION = "0.3.0" + export const TaskStatus = z.enum(["running", "completed", "failed", "cancelled"]) export type TaskStatus = z.infer diff --git a/packages/opencode/src/cli/cmd/a2a.ts b/packages/opencode/src/cli/cmd/a2a.ts index b99352c718d1..739142078234 100644 --- a/packages/opencode/src/cli/cmd/a2a.ts +++ b/packages/opencode/src/cli/cmd/a2a.ts @@ -31,7 +31,7 @@ export const A2ACommand = cmd({ if (args.action === "info") { prompts.intro(UI.gradient("A2A Protocol Info")) - console.log("\nAgent2Agent (A2A) Protocol v0.3.0") + console.log(`\nAgent2Agent (A2A) Protocol v${A2A.PROTOCOL_VERSION}`) console.log("\nThe A2A protocol enables agent-to-agent communication,") console.log("allowing OpenCode to expose its capabilities to other agents.") console.log("\nEndpoints:")