feat: add A2A (Agent-to-Agent) protocol support - Phase 1

Implements Phase 1 of A2A protocol support as discussed in #12007:
- A2A types (agent config, task, message, artifact types)
- A2aHub service for managing agent connections
- delegate_to_agent tool for delegating tasks to external agents
- Manual configuration via a2a_settings.json (global/project)
- Configurable headers for authentication (JSON key-value pairs)
- Integration with system prompt, tool parser, and ClineProvider
- A2A tool group added to code mode
- Tests for A2aHub service
This commit is contained in:
Roo Code 2026-03-26 23:08:10 +00:00
parent 137d3f4fd8
commit 5c1123a9bf
17 changed files with 1053 additions and 5 deletions

192
packages/types/src/a2a.ts Normal file
View file

@ -0,0 +1,192 @@
import { z } from "zod"
// ============================================================================
// A2A Agent Configuration (stored in a2a_settings.json)
// ============================================================================
/**
* Configuration for an A2A agent, stored in settings files.
* Follows the same pattern as MCP server configuration.
*/
export const a2aAgentConfigSchema = z.object({
/** The URL endpoint for the A2A agent */
url: z.string().url(),
/** Optional custom headers for authentication (JSON key-value pairs) */
headers: z.record(z.string()).optional(),
/** Whether this agent is disabled */
disabled: z.boolean().optional(),
/** Timeout in seconds for task operations (default: 300 for long-running tasks) */
timeout: z.number().min(1).max(7200).optional().default(300),
/** Human-readable description of what this agent does */
description: z.string().optional(),
})
export type A2aAgentConfig = z.infer<typeof a2aAgentConfigSchema>
/**
* The full A2A settings file schema (maps agent names to configs).
*/
export const a2aSettingsSchema = z.object({
a2aAgents: z.record(z.string(), a2aAgentConfigSchema).default({}),
})
export type A2aSettings = z.infer<typeof a2aSettingsSchema>
// ============================================================================
// A2A Agent Card (fetched from the agent's /.well-known/agent.json)
// ============================================================================
/**
* Represents an A2A Agent Card as defined in the A2A protocol spec.
* Contains metadata about an agent's capabilities.
*/
export type A2aAgentCard = {
name: string
description?: string
url: string
version?: string
capabilities?: {
streaming?: boolean
pushNotifications?: boolean
stateTransitionHistory?: boolean
}
skills?: A2aSkill[]
}
export type A2aSkill = {
id: string
name: string
description?: string
tags?: string[]
examples?: string[]
}
// ============================================================================
// A2A Task Types (JSON-RPC based protocol)
// ============================================================================
/**
* A2A message part - text, file, or data.
*/
export type A2aTextPart = {
type: "text"
text: string
}
export type A2aFilePart = {
type: "file"
file: {
name?: string
mimeType?: string
bytes?: string // base64 encoded
uri?: string
}
}
export type A2aDataPart = {
type: "data"
data: Record<string, unknown>
}
export type A2aPart = A2aTextPart | A2aFilePart | A2aDataPart
/**
* A2A Message - a message in a task conversation.
*/
export type A2aMessage = {
role: "user" | "agent"
parts: A2aPart[]
metadata?: Record<string, unknown>
}
/**
* A2A Artifact - output produced by an agent.
*/
export type A2aArtifact = {
name?: string
description?: string
parts: A2aPart[]
index?: number
append?: boolean
lastChunk?: boolean
metadata?: Record<string, unknown>
}
/**
* A2A Task state as defined in the protocol.
*/
export const a2aTaskStates = ["submitted", "working", "input-required", "completed", "canceled", "failed"] as const
export type A2aTaskState = (typeof a2aTaskStates)[number]
/**
* A2A Task Status
*/
export type A2aTaskStatus = {
state: A2aTaskState
message?: A2aMessage
timestamp?: string
}
/**
* A2A Task - represents a delegated unit of work.
*/
export type A2aTask = {
id: string
sessionId?: string
status: A2aTaskStatus
history?: A2aMessage[]
artifacts?: A2aArtifact[]
metadata?: Record<string, unknown>
}
// ============================================================================
// A2A JSON-RPC Types
// ============================================================================
export type A2aJsonRpcRequest = {
jsonrpc: "2.0"
id: string | number
method: string
params?: Record<string, unknown>
}
export type A2aJsonRpcResponse = {
jsonrpc: "2.0"
id: string | number
result?: A2aTask
error?: {
code: number
message: string
data?: unknown
}
}
// ============================================================================
// A2A Agent Runtime State (for UI display)
// ============================================================================
/**
* Runtime state of an A2A agent, used for display in the UI.
* Mirrors the pattern of McpServer type.
*/
export type A2aAgent = {
name: string
config: string // JSON stringified config
status: "connected" | "connecting" | "disconnected"
error?: string
disabled?: boolean
agentCard?: A2aAgentCard
activeTasks?: A2aTask[]
source?: "global" | "project"
projectPath?: string
}
/**
* ClineAsk type for A2A agent delegation approval.
*/
export type ClineAskDelegateToAgent = {
type: "delegate_to_agent"
agentName: string
message: string
}

View file

@ -1,3 +1,4 @@
export * from "./a2a.js"
export * from "./api.js"
export * from "./cli.js"
export * from "./cloud.js"

View file

@ -186,7 +186,7 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [
whenToUse:
"Use this mode when you need to write, modify, or refactor code. Ideal for implementing features, fixing bugs, creating new files, or making code improvements across any programming language or framework.",
description: "Write, modify, and refactor code",
groups: ["read", "edit", "command", "mcp"],
groups: ["read", "edit", "command", "mcp", "a2a"],
},
{
slug: "ask",

View file

@ -4,7 +4,7 @@ import { z } from "zod"
* ToolGroup
*/
export const toolGroups = ["read", "edit", "command", "mcp", "modes"] as const
export const toolGroups = ["read", "edit", "command", "mcp", "a2a", "modes"] as const
export const toolGroupsSchema = z.enum(toolGroups)
@ -46,6 +46,7 @@ export const toolNames = [
"skill",
"generate_image",
"custom_tool",
"delegate_to_agent",
] as const
export const toolNamesSchema = z.enum(toolNames)

View file

@ -565,6 +565,15 @@ export class NativeToolCallParser {
}
break
case "delegate_to_agent":
if (partialArgs.agent_name !== undefined || partialArgs.message !== undefined) {
nativeArgs = {
agent_name: partialArgs.agent_name,
message: partialArgs.message,
}
}
break
case "apply_patch":
if (partialArgs.patch !== undefined) {
nativeArgs = {
@ -921,6 +930,15 @@ export class NativeToolCallParser {
}
break
case "delegate_to_agent":
if (args.agent_name !== undefined && args.message !== undefined) {
nativeArgs = {
agent_name: args.agent_name,
message: args.message,
} as NativeArgsFor<TName>
}
break
case "access_mcp_resource":
if (args.server_name !== undefined && args.uri !== undefined) {
nativeArgs = {

View file

@ -34,6 +34,7 @@ import { updateTodoListTool } from "../tools/UpdateTodoListTool"
import { runSlashCommandTool } from "../tools/RunSlashCommandTool"
import { skillTool } from "../tools/SkillTool"
import { generateImageTool } from "../tools/GenerateImageTool"
import { delegateToAgentTool } from "../tools/DelegateToAgentTool"
import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool"
import { isValidToolName, validateToolUse } from "../tools/validateToolUse"
import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
@ -789,6 +790,13 @@ export async function presentAssistantMessage(cline: Task) {
pushToolResult,
})
break
case "delegate_to_agent":
await delegateToAgentTool.handle(cline, block as ToolUse<"delegate_to_agent">, {
askApproval,
handleError,
pushToolResult,
})
break
case "ask_followup_question":
await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, {
askApproval,

View file

@ -1,6 +1,7 @@
import { McpHub } from "../../../services/mcp/McpHub"
import { A2aHub } from "../../../services/a2a/A2aHub"
export function getCapabilitiesSection(cwd: string, mcpHub?: McpHub): string {
export function getCapabilitiesSection(cwd: string, mcpHub?: McpHub, a2aHub?: A2aHub): string {
return `====
CAPABILITIES
@ -11,6 +12,18 @@ CAPABILITIES
mcpHub
? `
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
`
: ""
}${
a2aHub && a2aHub.getAgents().length > 0
? `
- You have access to A2A (Agent-to-Agent) agents that you can delegate tasks to. These are external agents that can handle specialized work. Use the delegate_to_agent tool to send tasks to them. Available agents: ${a2aHub
.getAgents()
.map((a) => {
const desc = a.agentCard?.description ?? ""
return `"${a.name}"${desc ? ` (${desc})` : ""}`
})
.join(", ")}
`
: ""
}`

View file

@ -8,6 +8,7 @@ import { formatLanguage } from "../../shared/language"
import { isEmpty } from "../../utils/object"
import { McpHub } from "../../services/mcp/McpHub"
import { A2aHub } from "../../services/a2a/A2aHub"
import { CodeIndexManager } from "../../services/code-index/manager"
import { SkillsManager } from "../../services/skills/SkillsManager"
@ -55,6 +56,7 @@ async function generatePrompt(
todoList?: TodoItem[],
modelId?: string,
skillsManager?: SkillsManager,
a2aHub?: A2aHub,
): Promise<string> {
if (!context) {
throw new Error("Extension context is required for generating system prompt")
@ -69,6 +71,11 @@ async function generatePrompt(
const hasMcpServers = mcpHub && mcpHub.getServers().length > 0
const shouldIncludeMcp = hasMcpGroup && hasMcpServers
// Check if A2A functionality should be included
const hasA2aGroup = modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "a2a")
const hasA2aAgents = a2aHub && a2aHub.getAgents().length > 0
const shouldIncludeA2a = hasA2aGroup && hasA2aAgents
const codeIndexManager = CodeIndexManager.getInstance(context, cwd)
// Tool calling is native-only.
@ -90,7 +97,7 @@ ${getSharedToolUseSection()}${toolsCatalog}
${getToolUseGuidelinesSection()}
${getCapabilitiesSection(cwd, shouldIncludeMcp ? mcpHub : undefined)}
${getCapabilitiesSection(cwd, shouldIncludeMcp ? mcpHub : undefined, shouldIncludeA2a ? a2aHub : undefined)}
${modesSection}
${skillsSection ? `\n${skillsSection}` : ""}
@ -126,6 +133,7 @@ export const SYSTEM_PROMPT = async (
todoList?: TodoItem[],
modelId?: string,
skillsManager?: SkillsManager,
a2aHub?: A2aHub,
): Promise<string> => {
if (!context) {
throw new Error("Extension context is required for generating system prompt")
@ -154,5 +162,6 @@ export const SYSTEM_PROMPT = async (
todoList,
modelId,
skillsManager,
a2aHub,
)
}

View file

@ -0,0 +1,33 @@
import type OpenAI from "openai"
const DELEGATE_TO_AGENT_DESCRIPTION = `Delegate a task to an external A2A (Agent-to-Agent) agent. Use this tool when you need to send work to a specialized agent that can handle a specific task. The agent will process the request and return results. This runs as a background operation - you can continue with other work while waiting for the agent to respond.
Parameters:
- agent_name: (required) The name of the A2A agent to delegate to, as configured in the A2A settings.
- message: (required) The task description or message to send to the agent. Be specific about what you need the agent to do.`
const delegate_to_agent: OpenAI.Chat.ChatCompletionTool = {
type: "function",
function: {
name: "delegate_to_agent",
description: DELEGATE_TO_AGENT_DESCRIPTION,
parameters: {
type: "object",
properties: {
agent_name: {
type: "string",
description: "The name of the A2A agent to delegate to, as configured in the A2A settings.",
},
message: {
type: "string",
description:
"The task description or message to send to the agent. Be specific about what you need the agent to do.",
},
},
required: ["agent_name", "message"],
additionalProperties: false,
},
},
}
export default delegate_to_agent

View file

@ -1,5 +1,6 @@
import type OpenAI from "openai"
import accessMcpResource from "./access_mcp_resource"
import delegateToAgent from "./delegate_to_agent"
import { apply_diff } from "./apply_diff"
import applyPatch from "./apply_patch"
import askFollowupQuestion from "./ask_followup_question"
@ -53,6 +54,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
askFollowupQuestion,
attemptCompletion,
codebaseSearch,
delegateToAgent,
executeCommand,
generateImage,
listFiles,

View file

@ -0,0 +1,162 @@
import type { ClineAskDelegateToAgent } from "@roo-code/types"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { t } from "../../i18n"
import type { ToolUse } from "../../shared/tools"
import { BaseTool, ToolCallbacks } from "./BaseTool"
interface DelegateToAgentParams {
agent_name: string
message: string
}
export class DelegateToAgentTool extends BaseTool<"delegate_to_agent"> {
readonly name = "delegate_to_agent" as const
async execute(params: DelegateToAgentParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { askApproval, handleError, pushToolResult } = callbacks
try {
// Validate required parameters
if (!params.agent_name) {
task.consecutiveMistakeCount++
task.recordToolError("delegate_to_agent")
task.didToolFailInCurrentTurn = true
pushToolResult(await task.sayAndCreateMissingParamError("delegate_to_agent", "agent_name"))
return
}
if (!params.message) {
task.consecutiveMistakeCount++
task.recordToolError("delegate_to_agent")
task.didToolFailInCurrentTurn = true
pushToolResult(await task.sayAndCreateMissingParamError("delegate_to_agent", "message"))
return
}
const agentName = params.agent_name
const message = params.message
// Validate that the agent exists
const provider = task.providerRef.deref()
if (!provider) {
pushToolResult(formatResponse.toolError("Provider reference lost"))
return
}
const a2aHub = provider.getA2aHub?.()
if (!a2aHub) {
pushToolResult(
formatResponse.toolError(
"A2A is not available. No A2A agents are configured. Please configure agents in a2a_settings.json.",
),
)
return
}
const agent = a2aHub.getAgent(agentName)
if (!agent) {
const availableAgents = a2aHub
.getAgents()
.map((a) => a.name)
.join(", ")
pushToolResult(
formatResponse.toolError(
`A2A agent "${agentName}" not found or is disabled. Available agents: ${availableAgents || "none"}`,
),
)
return
}
task.consecutiveMistakeCount = 0
// Get user approval
const completeMessage = JSON.stringify({
type: "delegate_to_agent",
agentName,
message,
} satisfies ClineAskDelegateToAgent)
const didApprove = await askApproval("delegate_to_agent" as any, completeMessage)
if (!didApprove) {
return
}
// Execute the A2A task
try {
const result = await a2aHub.sendTask(agentName, message)
// Format the result
const responseText = this.formatTaskResult(result)
pushToolResult(responseText)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
pushToolResult(formatResponse.toolError(`Failed to delegate to agent "${agentName}": ${errorMessage}`))
}
} catch (error) {
await handleError("delegating to A2A agent", error as Error)
}
}
override async handlePartial(task: Task, block: ToolUse<"delegate_to_agent">): Promise<void> {
const params = block.params
const partialMessage = JSON.stringify({
type: "delegate_to_agent",
agentName: params.agent_name ?? "",
message: params.message ?? "",
} satisfies ClineAskDelegateToAgent)
await task.ask("delegate_to_agent" as any, partialMessage, true).catch(() => {})
}
/**
* Format an A2A task result into a human-readable string.
*/
private formatTaskResult(task: import("@roo-code/types").A2aTask): string {
const parts: string[] = []
parts.push(`Task ID: ${task.id}`)
parts.push(`Status: ${task.status.state}`)
// Include the agent's response message if present
if (task.status.message) {
const textParts = task.status.message.parts
.filter((p) => p.type === "text")
.map((p) => (p as import("@roo-code/types").A2aTextPart).text)
if (textParts.length > 0) {
parts.push(`\nAgent Response:\n${textParts.join("\n")}`)
}
}
// Include artifacts if present
if (task.artifacts && task.artifacts.length > 0) {
parts.push("\nArtifacts:")
for (const artifact of task.artifacts) {
if (artifact.name) {
parts.push(` - ${artifact.name}${artifact.description ? `: ${artifact.description}` : ""}`)
}
const textParts = artifact.parts
.filter((p) => p.type === "text")
.map((p) => (p as import("@roo-code/types").A2aTextPart).text)
if (textParts.length > 0) {
parts.push(` ${textParts.join("\n ")}`)
}
}
}
// If the task requires input, note that
if (task.status.state === "input-required") {
parts.push(
"\nNote: The agent is requesting additional input. You can send another delegate_to_agent call with the same agent to continue the conversation.",
)
}
return parts.join("\n")
}
}
export const delegateToAgentTool = new DelegateToAgentTool()

View file

@ -70,6 +70,7 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { McpServerManager } from "../../services/mcp/McpServerManager"
import { A2aHub } from "../../services/a2a/A2aHub"
import { MarketplaceManager } from "../../services/marketplace"
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
import { CodeIndexManager } from "../../services/code-index/manager"
@ -141,6 +142,7 @@ export class ClineProvider
private codeIndexManager?: CodeIndexManager
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
protected mcpHub?: McpHub // Change from private to protected
protected a2aHub?: A2aHub
protected skillsManager?: SkillsManager
private marketplaceManager: MarketplaceManager
private mdmService?: MdmService
@ -225,6 +227,18 @@ export class ClineProvider
this.log(`Failed to initialize MCP Hub: ${error}`)
})
// Initialize A2A Hub for agent-to-agent communication
const a2aHub = new A2aHub(this)
a2aHub
.initialize()
.then(() => {
this.a2aHub = a2aHub
this.a2aHub.registerClient()
})
.catch((error) => {
this.log(`Failed to initialize A2A Hub: ${error}`)
})
// Initialize Skills Manager for skill discovery
this.skillsManager = new SkillsManager(this)
this.skillsManager.initialize().catch((error) => {
@ -707,6 +721,8 @@ export class ClineProvider
this._workspaceTracker = undefined
await this.mcpHub?.unregisterClient()
this.mcpHub = undefined
await this.a2aHub?.unregisterClient()
this.a2aHub = undefined
await this.skillsManager?.dispose()
this.skillsManager = undefined
this.marketplaceManager?.cleanup()
@ -2747,6 +2763,10 @@ export class ClineProvider
return this.mcpHub
}
public getA2aHub(): A2aHub | undefined {
return this.a2aHub
}
public getSkillsManager(): SkillsManager | undefined {
return this.skillsManager
}

View file

@ -64,6 +64,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
undefined, // todoList
undefined, // modelId
provider.getSkillsManager(),
provider.getA2aHub(),
)
return systemPrompt

451
src/services/a2a/A2aHub.ts Normal file
View file

@ -0,0 +1,451 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import chokidar, { FSWatcher } from "chokidar"
import deepEqual from "fast-deep-equal"
import type {
A2aAgent,
A2aAgentCard,
A2aAgentConfig,
A2aJsonRpcRequest,
A2aJsonRpcResponse,
A2aMessage,
A2aTask,
A2aTaskState,
} from "@roo-code/types"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { fileExistsAtPath } from "../../utils/fs"
import { getWorkspacePath } from "../../utils/path"
import { safeWriteJson } from "../../utils/safeWriteJson"
// ============================================================================
// A2A Settings Types
// ============================================================================
interface A2aSettingsFile {
a2aAgents?: Record<string, A2aAgentConfig>
}
// ============================================================================
// A2aHub - Manages A2A agent connections
// ============================================================================
export class A2aHub {
private providerRef: WeakRef<ClineProvider>
private agents: A2aAgent[] = []
private fileWatchers: FSWatcher[] = []
private settingsFilePath: string = ""
private projectSettingsFilePath: string = ""
private isDisposed = false
private clientCount = 0
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
}
// ========================================================================
// Lifecycle
// ========================================================================
async initialize(): Promise<void> {
const provider = this.providerRef.deref()
if (!provider) {
return
}
// Set up settings file paths
const globalStoragePath = provider.context.globalStorageUri.fsPath
this.settingsFilePath = path.join(globalStoragePath, GlobalFileNames.a2aSettings)
this.projectSettingsFilePath = path.join(getWorkspacePath() ?? "", ".roo", "a2a.json")
// Ensure default settings file exists
if (!(await fileExistsAtPath(this.settingsFilePath))) {
await safeWriteJson(this.settingsFilePath, { a2aAgents: {} })
}
// Load agents from settings
await this.loadAgents()
// Watch settings files for changes
this.watchSettingsFiles()
}
async dispose(): Promise<void> {
this.isDisposed = true
for (const watcher of this.fileWatchers) {
await watcher.close()
}
this.fileWatchers = []
this.agents = []
}
registerClient(): void {
this.clientCount++
}
async unregisterClient(): Promise<void> {
this.clientCount--
if (this.clientCount <= 0) {
await this.dispose()
}
}
// ========================================================================
// Agent Management
// ========================================================================
getAgents(): A2aAgent[] {
return this.agents.filter((a) => !a.disabled)
}
getAllAgents(): A2aAgent[] {
return [...this.agents]
}
getAgent(name: string): A2aAgent | undefined {
return this.agents.find((a) => a.name === name && !a.disabled)
}
// ========================================================================
// A2A Protocol Operations
// ========================================================================
/**
* Send a task to an A2A agent using the tasks/send method.
* Returns the task result with status and any artifacts.
*/
async sendTask(agentName: string, message: string, taskId?: string): Promise<A2aTask> {
const agent = this.getAgent(agentName)
if (!agent) {
throw new Error(`A2A agent "${agentName}" not found or is disabled`)
}
const config = JSON.parse(agent.config) as A2aAgentConfig
const id = taskId ?? `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
const request: A2aJsonRpcRequest = {
jsonrpc: "2.0",
id,
method: "tasks/send",
params: {
id,
message: {
role: "user",
parts: [{ type: "text", text: message }],
},
},
}
const response = await this.makeRequest(config, request)
if (response.error) {
throw new Error(`A2A agent error (${response.error.code}): ${response.error.message}`)
}
if (!response.result) {
throw new Error("A2A agent returned empty result")
}
// Update agent's active tasks
const agentIndex = this.agents.findIndex((a) => a.name === agentName)
if (agentIndex >= 0) {
const currentAgent = this.agents[agentIndex]
const activeTasks = currentAgent.activeTasks ?? []
const existingIdx = activeTasks.findIndex((t) => t.id === response.result!.id)
if (existingIdx >= 0) {
activeTasks[existingIdx] = response.result
} else {
activeTasks.push(response.result)
}
this.agents[agentIndex] = { ...currentAgent, activeTasks }
}
this.notifyWebview()
return response.result
}
/**
* Get the current status of a task from an A2A agent.
*/
async getTask(agentName: string, taskId: string): Promise<A2aTask> {
const agent = this.getAgent(agentName)
if (!agent) {
throw new Error(`A2A agent "${agentName}" not found or is disabled`)
}
const config = JSON.parse(agent.config) as A2aAgentConfig
const request: A2aJsonRpcRequest = {
jsonrpc: "2.0",
id: `get-${taskId}`,
method: "tasks/get",
params: { id: taskId },
}
const response = await this.makeRequest(config, request)
if (response.error) {
throw new Error(`A2A agent error (${response.error.code}): ${response.error.message}`)
}
if (!response.result) {
throw new Error("A2A agent returned empty result")
}
return response.result
}
/**
* Cancel a task on an A2A agent.
*/
async cancelTask(agentName: string, taskId: string): Promise<A2aTask> {
const agent = this.getAgent(agentName)
if (!agent) {
throw new Error(`A2A agent "${agentName}" not found or is disabled`)
}
const config = JSON.parse(agent.config) as A2aAgentConfig
const request: A2aJsonRpcRequest = {
jsonrpc: "2.0",
id: `cancel-${taskId}`,
method: "tasks/cancel",
params: { id: taskId },
}
const response = await this.makeRequest(config, request)
if (response.error) {
throw new Error(`A2A agent error (${response.error.code}): ${response.error.message}`)
}
return response.result ?? { id: taskId, status: { state: "canceled" } }
}
/**
* Fetch the agent card from the agent's well-known endpoint.
*/
async fetchAgentCard(config: A2aAgentConfig): Promise<A2aAgentCard | undefined> {
try {
const url = new URL(config.url)
const cardUrl = `${url.origin}/.well-known/agent.json`
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(config.headers ?? {}),
}
const response = await fetch(cardUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(10000),
})
if (response.ok) {
return (await response.json()) as A2aAgentCard
}
} catch {
// Agent card is optional; ignore errors
}
return undefined
}
// ========================================================================
// Settings Path Access
// ========================================================================
async getA2aSettingsFilePath(): Promise<string> {
return this.settingsFilePath
}
// ========================================================================
// Private Methods
// ========================================================================
/**
* Make an HTTP request to an A2A agent.
*/
private async makeRequest(config: A2aAgentConfig, request: A2aJsonRpcRequest): Promise<A2aJsonRpcResponse> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(config.headers ?? {}),
}
const timeoutMs = (config.timeout ?? 300) * 1000
const response = await fetch(config.url, {
method: "POST",
headers,
body: JSON.stringify(request),
signal: AbortSignal.timeout(timeoutMs),
})
if (!response.ok) {
throw new Error(`A2A HTTP error ${response.status}: ${response.statusText}`)
}
return (await response.json()) as A2aJsonRpcResponse
}
/**
* Load agents from settings files.
*/
private async loadAgents(): Promise<void> {
const globalAgents = await this.loadSettingsFile(this.settingsFilePath, "global")
const projectAgents = await this.loadSettingsFile(this.projectSettingsFilePath, "project")
// Project settings override global settings for agents with the same name
const agentMap = new Map<string, A2aAgent>()
for (const agent of globalAgents) {
agentMap.set(agent.name, agent)
}
for (const agent of projectAgents) {
agentMap.set(agent.name, agent)
}
const newAgents = Array.from(agentMap.values())
if (!deepEqual(this.agents, newAgents)) {
this.agents = newAgents
// Try to fetch agent cards for newly connected agents
for (const agent of this.agents) {
if (!agent.agentCard && !agent.disabled) {
const config = JSON.parse(agent.config) as A2aAgentConfig
this.fetchAgentCard(config).then((card) => {
if (card) {
agent.agentCard = card
agent.status = "connected"
this.notifyWebview()
}
})
}
}
this.notifyWebview()
}
}
/**
* Load agents from a single settings file.
*/
private async loadSettingsFile(filePath: string, source: "global" | "project"): Promise<A2aAgent[]> {
try {
if (!(await fileExistsAtPath(filePath))) {
return []
}
const content = await fs.readFile(filePath, "utf-8")
const settings: A2aSettingsFile = JSON.parse(content)
if (!settings.a2aAgents) {
return []
}
return Object.entries(settings.a2aAgents).map(([name, config]) => ({
name,
config: JSON.stringify(config),
status: "connecting" as const,
disabled: config.disabled,
description: config.description,
source,
projectPath: source === "project" ? getWorkspacePath() : undefined,
}))
} catch {
return []
}
}
/**
* Watch settings files for changes and reload.
*/
private watchSettingsFiles(): void {
const watchPaths = [this.settingsFilePath]
if (getWorkspacePath()) {
watchPaths.push(this.projectSettingsFilePath)
}
for (const watchPath of watchPaths) {
try {
const watcher = chokidar.watch(watchPath, {
persistent: true,
ignoreInitial: true,
})
watcher.on("change", () => {
if (!this.isDisposed) {
this.loadAgents()
}
})
watcher.on("add", () => {
if (!this.isDisposed) {
this.loadAgents()
}
})
this.fileWatchers.push(watcher)
} catch {
// Ignore watcher errors for non-existent paths
}
}
}
/**
* Notify the webview of agent state changes.
*/
private notifyWebview(): void {
const provider = this.providerRef.deref()
if (provider) {
provider.postMessageToWebview({
type: "a2aAgents",
a2aAgents: this.getAllAgents(),
} as any)
}
}
/**
* Toggle an agent's disabled state.
*/
async toggleAgentDisabled(agentName: string, source: "global" | "project", disabled: boolean): Promise<void> {
const filePath = source === "global" ? this.settingsFilePath : this.projectSettingsFilePath
try {
const content = await fs.readFile(filePath, "utf-8")
const settings: A2aSettingsFile = JSON.parse(content)
if (settings.a2aAgents?.[agentName]) {
settings.a2aAgents[agentName].disabled = disabled
await safeWriteJson(filePath, settings)
}
} catch (error) {
throw new Error(`Failed to toggle agent: ${error}`)
}
}
/**
* Delete an agent from settings.
*/
async deleteAgent(agentName: string, source: "global" | "project"): Promise<void> {
const filePath = source === "global" ? this.settingsFilePath : this.projectSettingsFilePath
try {
const content = await fs.readFile(filePath, "utf-8")
const settings: A2aSettingsFile = JSON.parse(content)
if (settings.a2aAgents?.[agentName]) {
delete settings.a2aAgents[agentName]
await safeWriteJson(filePath, settings)
}
} catch (error) {
throw new Error(`Failed to delete agent: ${error}`)
}
}
}

View file

@ -0,0 +1,123 @@
import { A2aHub } from "../A2aHub"
import type { ClineProvider } from "../../../core/webview/ClineProvider"
// Mock dependencies
vi.mock("fs/promises", () => ({
readFile: vi.fn().mockResolvedValue(JSON.stringify({ a2aAgents: {} })),
writeFile: vi.fn().mockResolvedValue(undefined),
mkdir: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("chokidar", () => ({
default: {
watch: vi.fn().mockReturnValue({
on: vi.fn().mockReturnThis(),
close: vi.fn().mockResolvedValue(undefined),
}),
},
}))
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockResolvedValue(false),
}))
vi.mock("../../../utils/path", () => ({
getWorkspacePath: vi.fn().mockReturnValue("/test/workspace"),
arePathsEqual: vi.fn().mockReturnValue(false),
}))
vi.mock("../../../utils/safeWriteJson", () => ({
safeWriteJson: vi.fn().mockResolvedValue(undefined),
}))
describe("A2aHub", () => {
let mockProvider: Partial<ClineProvider>
let a2aHub: A2aHub
beforeEach(() => {
mockProvider = {
context: {
globalStorageUri: { fsPath: "/test/storage" },
} as any,
postMessageToWebview: vi.fn(),
}
a2aHub = new A2aHub(mockProvider as ClineProvider)
})
afterEach(async () => {
await a2aHub.dispose()
})
describe("constructor", () => {
it("should create an A2aHub instance", () => {
expect(a2aHub).toBeInstanceOf(A2aHub)
})
})
describe("getAgents", () => {
it("should return empty array when no agents are configured", () => {
const agents = a2aHub.getAgents()
expect(agents).toEqual([])
})
})
describe("getAllAgents", () => {
it("should return empty array when no agents are configured", () => {
const agents = a2aHub.getAllAgents()
expect(agents).toEqual([])
})
})
describe("getAgent", () => {
it("should return undefined when agent does not exist", () => {
const agent = a2aHub.getAgent("nonexistent")
expect(agent).toBeUndefined()
})
})
describe("registerClient / unregisterClient", () => {
it("should track client count", () => {
a2aHub.registerClient()
// Should not throw
expect(a2aHub.getAgents()).toEqual([])
})
it("should dispose when last client unregisters", async () => {
a2aHub.registerClient()
await a2aHub.unregisterClient()
// After disposal, agents should be empty
expect(a2aHub.getAllAgents()).toEqual([])
})
})
describe("sendTask", () => {
it("should throw when agent is not found", async () => {
await expect(a2aHub.sendTask("nonexistent", "test message")).rejects.toThrow(
'A2A agent "nonexistent" not found or is disabled',
)
})
})
describe("getTask", () => {
it("should throw when agent is not found", async () => {
await expect(a2aHub.getTask("nonexistent", "task-1")).rejects.toThrow(
'A2A agent "nonexistent" not found or is disabled',
)
})
})
describe("cancelTask", () => {
it("should throw when agent is not found", async () => {
await expect(a2aHub.cancelTask("nonexistent", "task-1")).rejects.toThrow(
'A2A agent "nonexistent" not found or is disabled',
)
})
})
describe("getA2aSettingsFilePath", () => {
it("should return empty string before initialization", async () => {
const path = await a2aHub.getA2aSettingsFilePath()
expect(path).toBe("")
})
})
})

View file

@ -2,6 +2,7 @@ export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
mcpSettings: "mcp_settings.json",
a2aSettings: "a2a_settings.json",
customModes: "custom_modes.yaml",
taskMetadata: "task_metadata.json",
historyItem: "history_item.json",

View file

@ -1,6 +1,13 @@
import { Anthropic } from "@anthropic-ai/sdk"
import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName, GenerateImageParams } from "@roo-code/types"
import type {
ClineAsk,
ToolProgressStatus,
ToolGroup,
ToolName,
GenerateImageParams,
ClineAskDelegateToAgent,
} from "@roo-code/types"
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
@ -52,6 +59,7 @@ export const toolParamNames = [
"size",
"query",
"args",
"agent_name", // delegate_to_agent parameter
"skill", // skill tool parameter
"start_line",
"end_line",
@ -115,6 +123,7 @@ export type NativeToolArgs = {
switch_mode: { mode_slug: string; reason: string }
update_todo_list: { todos: string }
use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record<string, unknown> }
delegate_to_agent: { agent_name: string; message: string }
write_to_file: { path: string; content: string }
// Add more tools as they are migrated to native protocol
}
@ -290,6 +299,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
skill: "load skill",
generate_image: "generate images",
custom_tool: "use custom tools",
delegate_to_agent: "delegate to A2A agent",
} as const
// Define available tool groups.
@ -307,6 +317,9 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
mcp: {
tools: ["use_mcp_tool", "access_mcp_resource"],
},
a2a: {
tools: ["delegate_to_agent"],
},
modes: {
tools: ["switch_mode", "new_task"],
alwaysAvailable: true,