diff --git a/packages/types/src/a2a.ts b/packages/types/src/a2a.ts new file mode 100644 index 00000000000..a27a2c09821 --- /dev/null +++ b/packages/types/src/a2a.ts @@ -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 + +/** + * 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 + +// ============================================================================ +// 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 +} + +export type A2aPart = A2aTextPart | A2aFilePart | A2aDataPart + +/** + * A2A Message - a message in a task conversation. + */ +export type A2aMessage = { + role: "user" | "agent" + parts: A2aPart[] + metadata?: Record +} + +/** + * A2A Artifact - output produced by an agent. + */ +export type A2aArtifact = { + name?: string + description?: string + parts: A2aPart[] + index?: number + append?: boolean + lastChunk?: boolean + metadata?: Record +} + +/** + * 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 +} + +// ============================================================================ +// A2A JSON-RPC Types +// ============================================================================ + +export type A2aJsonRpcRequest = { + jsonrpc: "2.0" + id: string | number + method: string + params?: Record +} + +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 +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index cd5804aecb7..3df12e6d954 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,3 +1,4 @@ +export * from "./a2a.js" export * from "./api.js" export * from "./cli.js" export * from "./cloud.js" diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index f981ba7bf9a..cead64af32b 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -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", diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9fc..ce7de7fa8ac 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -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) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index bda7c71eb8d..1ce66ac8120 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -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 + } + break + case "access_mcp_resource": if (args.server_name !== undefined && args.uri !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be154..44baff37a40 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -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, diff --git a/src/core/prompts/sections/capabilities.ts b/src/core/prompts/sections/capabilities.ts index c871636b9b2..837d02b160e 100644 --- a/src/core/prompts/sections/capabilities.ts +++ b/src/core/prompts/sections/capabilities.ts @@ -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(", ")} ` : "" }` diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 0d6071644a9..60a70999c11 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -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 { 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 => { 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, ) } diff --git a/src/core/prompts/tools/native-tools/delegate_to_agent.ts b/src/core/prompts/tools/native-tools/delegate_to_agent.ts new file mode 100644 index 00000000000..50885b42078 --- /dev/null +++ b/src/core/prompts/tools/native-tools/delegate_to_agent.ts @@ -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 diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d65..adc1b50f545 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -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, diff --git a/src/core/tools/DelegateToAgentTool.ts b/src/core/tools/DelegateToAgentTool.ts new file mode 100644 index 00000000000..3f0bce3e70d --- /dev/null +++ b/src/core/tools/DelegateToAgentTool.ts @@ -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 { + 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 { + 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() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7bd969e52d0..3496c1b89cc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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 } diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 8af2f5ff5d5..139fc85b70b 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -64,6 +64,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web undefined, // todoList undefined, // modelId provider.getSkillsManager(), + provider.getA2aHub(), ) return systemPrompt diff --git a/src/services/a2a/A2aHub.ts b/src/services/a2a/A2aHub.ts new file mode 100644 index 00000000000..c82bb5ab8fe --- /dev/null +++ b/src/services/a2a/A2aHub.ts @@ -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 +} + +// ============================================================================ +// A2aHub - Manages A2A agent connections +// ============================================================================ + +export class A2aHub { + private providerRef: WeakRef + 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 { + 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 { + this.isDisposed = true + for (const watcher of this.fileWatchers) { + await watcher.close() + } + this.fileWatchers = [] + this.agents = [] + } + + registerClient(): void { + this.clientCount++ + } + + async unregisterClient(): Promise { + 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 { + 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 { + 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 { + 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 { + try { + const url = new URL(config.url) + const cardUrl = `${url.origin}/.well-known/agent.json` + + const headers: Record = { + "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 { + return this.settingsFilePath + } + + // ======================================================================== + // Private Methods + // ======================================================================== + + /** + * Make an HTTP request to an A2A agent. + */ + private async makeRequest(config: A2aAgentConfig, request: A2aJsonRpcRequest): Promise { + const headers: Record = { + "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 { + 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() + + 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 { + 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 { + 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 { + 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}`) + } + } +} diff --git a/src/services/a2a/__tests__/A2aHub.spec.ts b/src/services/a2a/__tests__/A2aHub.spec.ts new file mode 100644 index 00000000000..f77f622d2a2 --- /dev/null +++ b/src/services/a2a/__tests__/A2aHub.spec.ts @@ -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 + 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("") + }) + }) +}) diff --git a/src/shared/globalFileNames.ts b/src/shared/globalFileNames.ts index 0b54ff6809c..5c179cc0ef4 100644 --- a/src/shared/globalFileNames.ts +++ b/src/shared/globalFileNames.ts @@ -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", diff --git a/src/shared/tools.ts b/src/shared/tools.ts index d2dd9907b17..a6e637a6020 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -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 @@ -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 } + 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 = { 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 = { mcp: { tools: ["use_mcp_tool", "access_mcp_resource"], }, + a2a: { + tools: ["delegate_to_agent"], + }, modes: { tools: ["switch_mode", "new_task"], alwaysAvailable: true,