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/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..58fef75c3f16 --- /dev/null +++ b/packages/opencode/src/a2a/card.ts @@ -0,0 +1,79 @@ +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 { + 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" }) + + export async function generate(serverUrl: string): Promise { + const version = 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: A2ATypes.PROTOCOL_VERSION, + version: version ?? "1.0.0", + url: serverUrl, + skills, + capabilities: { + pushNotifications: false, + }, + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + additionalInterfaces: [ + { + url: (() => { + const url = new URL(serverUrl) + // 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", + }, + { 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..50432fb05e18 --- /dev/null +++ b/packages/opencode/src/a2a/executor.ts @@ -0,0 +1,183 @@ +import { ulid } from "ulid" +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 { + 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() + + private getLog() { + return this.log + } + + 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 is { kind: "text"; text: string } => p.kind === "text") ?? [] + const text = textParts.map((p) => p.text).join("\n") + + const log = this.getLog() + log.info("executing task", { + taskId, + contextId: requestContext.contextId, + messageLength: text.length, + }) + + const directory = Instance.directory + + await Instance.provide({ + directory, + init: async () => { + log.info("initializing instance", { directory }) + return {} + }, + async fn() { + // 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" }, + } + + // 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 = "" + + // 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 + } + } + + const responseMessage: Message = { + kind: "message", + messageId: ulid(), + role: "agent", + parts: [{ kind: "text", text: responseText || "Task completed" }], + contextId: requestContext.contextId, + } + + eventBus.publish(responseMessage) + eventBus.finished() + + log.info("task completed", { taskId }) + }, + }) + } catch (error) { + const log = this.getLog() + 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.getLog().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..5dc84831a347 --- /dev/null +++ b/packages/opencode/src/a2a/index.ts @@ -0,0 +1,39 @@ +import { A2AExecutor } from "./executor" +import { A2ACard } from "./card" +import * as A2ATypes from "./types" +import { Log } from "../util/log" +import { Server } from "../server/server" + +export { A2AExecutor, A2ACard } +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" }) + + // A2A Protocol version implemented + export const PROTOCOL_VERSION = A2ATypes.A2A.PROTOCOL_VERSION + + 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..0f887e3023bf --- /dev/null +++ b/packages/opencode/src/a2a/types.ts @@ -0,0 +1,25 @@ +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 + + 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/cli/cmd/a2a.ts b/packages/opencode/src/cli/cmd/a2a.ts new file mode 100644 index 000000000000..739142078234 --- /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 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:") + 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) diff --git a/packages/opencode/src/server/routes/a2a.ts b/packages/opencode/src/server/routes/a2a.ts new file mode 100644 index 000000000000..2621a764a2f6 --- /dev/null +++ b/packages/opencode/src/server/routes/a2a.ts @@ -0,0 +1,247 @@ +import { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import z from "zod" +import { A2A, A2AExecutor } from "../../a2a" +import { errors } from "../error" +import { lazy } from "../../util/lazy" +import { Log } from "../../util/log" +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 +} + +// 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() + + // Initialize A2A components + const executor = A2A.getExecutor() + + // 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 { + const body = await c.req.json() + log.info("jsonrpc request", { method: body.method }) + + // Handle JSON-RPC methods directly + const result = await handleJsonRpc(body, executor) + 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: JsonRpcRequest, executor: A2AExecutor): Promise { + 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 executor.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", 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") +}) 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 new file mode 100644 index 000000000000..6c26c6da271d --- /dev/null +++ b/packages/web/src/content/docs/a2a.mdx @@ -0,0 +1,308 @@ +--- +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", + "messageId": "msg-001", + "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", + "messageId": "msg-001", + "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', + messageId: 'msg-001', + 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. + +### 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: + +```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