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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions packages/opencode/src/a2a/README.md
Original file line number Diff line number Diff line change
@@ -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
79 changes: 79 additions & 0 deletions packages/opencode/src/a2a/card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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" })

export async function generate(serverUrl: string): Promise<AgentCard> {
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: (() => {
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
}
}
134 changes: 134 additions & 0 deletions packages/opencode/src/a2a/executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { ulid } from "ulid"
import { Log } from "../util/log"
import { Session } from "../session"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
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<void>
cancelTask(taskId: string): Promise<void>
}

export class A2AExecutor implements AgentExecutor {
private log = Log.create({ service: "a2a-executor" })
private activeTasks = new Map<string, AbortController>()

async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> {
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,
})

// 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 })
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<void> {
const abort = this.activeTasks.get(taskId)
if (abort) {
abort.abort()
this.activeTasks.delete(taskId)
this.log.info("task cancelled", { taskId })
}
}
}
32 changes: 32 additions & 0 deletions packages/opencode/src/a2a/index.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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")
}
}
Loading