From c2a549a1dc4559fdb2bd3946822e0107b019fd12 Mon Sep 17 00:00:00 2001 From: davincidreams Date: Sat, 25 Apr 2026 06:29:24 -0400 Subject: [PATCH] feat(teams): add run_team_phase tool and TeamsManager for phased multi-agent workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Atlas-Agent-Teams pattern for Roo-Code: - TeamConfig JSON schema in packages/types (slug, phases, agents, conventions) - TeamsManager scans .roo/teams/*.json and caches configs - RunTeamPhaseTool executes one phase: concurrent via spawnConcurrentChildren, sequential single via delegateParentAndOpenChild, sequential multiple via queue drain - LLM schema (run_team_phase.ts) with orchestrator loop description - Wired into ClineProvider (init, getTeamConfig, dispose), TOOL_GROUPS.modes, TOOL_DISPLAY_NAMES, NativeToolArgs, toolParamNames, and presentAssistantMessage - Sample .roo/teams/fullstack.json: discovery (concurrent) → implementation → review Co-Authored-By: Claude Sonnet 4.6 --- .roo/teams/fullstack.json | 66 +++++++ packages/types/src/index.ts | 1 + packages/types/src/team.ts | 67 +++++++ packages/types/src/tool.ts | 1 + .../presentAssistantMessage.ts | 10 + src/core/prompts/tools/native-tools/index.ts | 2 + .../tools/native-tools/run_team_phase.ts | 56 ++++++ src/core/tools/RunTeamPhaseTool.ts | 172 ++++++++++++++++++ src/core/webview/ClineProvider.ts | 13 ++ src/services/teams/TeamsManager.ts | 66 +++++++ src/shared/tools.ts | 7 +- 11 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 .roo/teams/fullstack.json create mode 100644 packages/types/src/team.ts create mode 100644 src/core/prompts/tools/native-tools/run_team_phase.ts create mode 100644 src/core/tools/RunTeamPhaseTool.ts create mode 100644 src/services/teams/TeamsManager.ts diff --git a/.roo/teams/fullstack.json b/.roo/teams/fullstack.json new file mode 100644 index 0000000000..df8e2bfba6 --- /dev/null +++ b/.roo/teams/fullstack.json @@ -0,0 +1,66 @@ +{ + "slug": "fullstack", + "name": "Full-Stack Feature Team", + "description": "Three-phase team for building full-stack features: discovery → implementation → review", + "orchestratorMode": "orchestrator", + "conventions": ".roo/teams/conventions/fullstack.md", + "phases": [ + { + "name": "discovery", + "label": "Discovery & Planning", + "concurrent": true, + "requireApproval": true, + "agents": [ + { + "mode": "architect", + "role": "Backend Architect", + "instruction": "Analyze the backend requirements for the following task and produce a detailed technical spec.\n\nTask: {{task}}\n\nDeliverables:\n- API endpoints needed (method, path, request/response shapes)\n- Database schema changes (if any)\n- Key implementation risks or unknowns\n\nOutput as structured markdown." + }, + { + "mode": "architect", + "role": "Frontend Architect", + "instruction": "Analyze the frontend requirements for the following task and produce a detailed technical spec.\n\nTask: {{task}}\n\nDeliverables:\n- Component tree and data flow\n- State management approach\n- API integration points\n- UX edge cases to handle\n\nOutput as structured markdown." + } + ] + }, + { + "name": "implementation", + "label": "Implementation", + "concurrent": false, + "requireApproval": false, + "abortOnChildFailure": true, + "agents": [ + { + "mode": "code", + "role": "Backend Engineer", + "instruction": "Implement the backend changes for this task.\n\nTask: {{task}}\n\nDiscovery results:\n{{context}}\n\nWrite production-quality code. Run tests after each significant change. Do not leave TODOs.", + "worktree": "feat/{{team}}-backend" + }, + { + "mode": "code", + "role": "Frontend Engineer", + "instruction": "Implement the frontend changes for this task.\n\nTask: {{task}}\n\nDiscovery results:\n{{context}}\n\nWrite production-quality code. Ensure the UI handles loading, error, and empty states. Do not leave TODOs.", + "worktree": "feat/{{team}}-frontend" + } + ] + }, + { + "name": "review", + "label": "Code Review", + "concurrent": true, + "requireApproval": false, + "agents": [ + { + "mode": "code", + "role": "Security Reviewer", + "instruction": "Review the implementation for security issues.\n\nTask: {{task}}\n\nImplementation summary:\n{{context}}\n\nFocus on: input validation, auth/authz, injection risks, sensitive data exposure. Output a list of findings (severity: critical/high/medium/low) with suggested fixes." + }, + { + "mode": "code", + "role": "QA Engineer", + "instruction": "Review the implementation for correctness and test coverage.\n\nTask: {{task}}\n\nImplementation summary:\n{{context}}\n\nFocus on: missing test cases, edge cases not handled, regression risks. Output a list of findings with suggested test additions." + } + ] + } + ] +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index cd5804aecb..3b95c84d1a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -23,6 +23,7 @@ export * from "./provider-settings.js" export * from "./task.js" export * from "./todo.js" export * from "./skills.js" +export * from "./team.js" export * from "./telemetry.js" export * from "./terminal.js" export * from "./tool.js" diff --git a/packages/types/src/team.ts b/packages/types/src/team.ts new file mode 100644 index 0000000000..49219d5c74 --- /dev/null +++ b/packages/types/src/team.ts @@ -0,0 +1,67 @@ +/** + * Team configuration — defines a named, phased multi-agent workflow. + * Team configs live in .roo/teams/.json inside the workspace. + */ + +export interface TeamAgentSpec { + /** Mode slug for this agent (e.g., "code", "architect") */ + mode: string + /** Role label shown in results (e.g., "frontend", "explorer"). Optional, for readability. */ + role?: string + /** + * Instruction template for this agent. + * Supports: {{task}}, {{context}}, {{phase}}, {{team}} + */ + instruction: string + /** + * Optional git worktree isolation. + * "auto" creates a new branch+worktree; any other string is used as the branch name. + */ + worktree?: string +} + +export interface TeamPhase { + /** Phase identifier used when calling run_team_phase (e.g., "discovery", "execution") */ + name: string + /** Human-readable label for UI display. Defaults to name if omitted. */ + label?: string + /** When true, all agents in this phase run concurrently. Default: false (sequential). */ + concurrent?: boolean + /** + * When true, the orchestrator should ask for user approval before this phase starts. + * The run_team_phase tool itself does not enforce this — it is a signal for the + * orchestrator mode to call ask_followup_question first. + */ + requireApproval?: boolean + /** + * When true and concurrent is also true, abort all remaining sibling agents as soon as + * one fails. Has no effect in sequential mode. + */ + abortOnChildFailure?: boolean + /** Agents to run in this phase. Must contain at least one entry. */ + agents: TeamAgentSpec[] +} + +export interface TeamConfig { + /** Auto-populated with the source file path at load time. Not set by user. */ + $source?: string + /** Unique identifier used in tool calls and skill registration (e.g., "fullstack"). */ + slug: string + /** Human-readable team name (e.g., "Full-Stack Dev Team"). */ + name: string + /** Short description of what this team does. Shown in team listings. */ + description?: string + /** Ordered list of phases. The orchestrator runs them in this order. */ + phases: TeamPhase[] + /** + * Path to a Markdown file containing shared conventions for all agents. + * Relative to the workspace root. Content is injected into every agent's message + * inside a block. + */ + conventions?: string + /** + * Mode slug for the orchestrating task. Defaults to "architect". + * This is informational — the skill / invocation mechanism uses it to set the initial mode. + */ + orchestratorMode?: string +} diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index d532c0dcbc..f71325798c 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -41,6 +41,7 @@ export const toolNames = [ "switch_mode", "new_task", "spawn_parallel_tasks", + "run_team_phase", "codebase_search", "update_todo_list", "run_slash_command", diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index b8b6d8e5ac..9c16d0a257 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -31,6 +31,7 @@ import { switchModeTool } from "../tools/SwitchModeTool" import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool" import { newTaskTool } from "../tools/NewTaskTool" import { spawnParallelTasksTool } from "../tools/SpawnParallelTasksTool" +import { runTeamPhaseTool } from "../tools/RunTeamPhaseTool" import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" import { skillTool } from "../tools/SkillTool" @@ -827,6 +828,15 @@ export async function presentAssistantMessage(cline: Task) { toolCallId: block.id, }) break + case "run_team_phase": + await checkpointSaveAndMark(cline) + await runTeamPhaseTool.handle(cline, block as ToolUse<"run_team_phase">, { + askApproval, + handleError, + pushToolResult, + toolCallId: block.id, + }) + break case "attempt_completion": { const completionCallbacks: AttemptCompletionCallbacks = { askApproval, diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 526eed80a7..da3e04f0e3 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -10,6 +10,7 @@ import executeCommand from "./execute_command" import generateImage from "./generate_image" import listFiles from "./list_files" import newTask from "./new_task" +import runTeamPhase from "./run_team_phase" import spawnParallelTasks from "./spawn_parallel_tasks" import readCommandOutput from "./read_command_output" import { createReadFileTool, type ReadFileToolOptions } from "./read_file" @@ -58,6 +59,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch generateImage, listFiles, newTask, + runTeamPhase, spawnParallelTasks, readCommandOutput, createReadFileTool(readFileOptions), diff --git a/src/core/prompts/tools/native-tools/run_team_phase.ts b/src/core/prompts/tools/native-tools/run_team_phase.ts new file mode 100644 index 0000000000..ed358781a4 --- /dev/null +++ b/src/core/prompts/tools/native-tools/run_team_phase.ts @@ -0,0 +1,56 @@ +import type OpenAI from "openai" + +const RUN_TEAM_PHASE_DESCRIPTION = `Run one phase of a pre-configured team workflow defined in .roo/teams/.json. + +A team config describes an ordered list of phases (e.g., discovery → execution → review). Each phase runs one or more specialist agents. This tool executes exactly ONE phase and returns its aggregated results. + +**Typical orchestrator loop:** +1. Read .roo/teams/.json (with read_file) to learn the phase names and requireApproval flags. +2. For each phase in order: + a. If requireApproval is true, call ask_followup_question to get user sign-off. + b. Call run_team_phase with the phase name, the original task, and accumulated context from prior phases. +3. After the last phase, call attempt_completion with the final summary. + +**Execution modes (set in the team config per-phase):** +- concurrent: false (default) — agents in the phase run one at a time; you receive results when all finish. +- concurrent: true — all agents start simultaneously; you receive results when all finish. + +Pass prior phase results as JSON in the \`context\` parameter so later phases (e.g., review agents) have full context. + +CRITICAL: This tool MUST be called alone. Do NOT call it alongside other tools in the same turn.` + +export default { + type: "function", + function: { + name: "run_team_phase", + description: RUN_TEAM_PHASE_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + team_slug: { + type: "string", + description: + 'Slug of the team to run (matches the "slug" field in the team config, e.g., "fullstack")', + }, + phase_name: { + type: "string", + description: + 'Name of the phase to execute (matches the "name" field in a phase entry, e.g., "discovery")', + }, + task: { + type: "string", + description: + "The original user task description. Injected into agent instructions via {{task}}. Pass the same value for every phase.", + }, + context: { + type: ["string", "null"], + description: + "JSON string of accumulated results from previous phases. Injected into agent instructions via {{context}}. Omit or pass null for the first phase.", + }, + }, + required: ["team_slug", "phase_name", "task", "context"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/tools/RunTeamPhaseTool.ts b/src/core/tools/RunTeamPhaseTool.ts new file mode 100644 index 0000000000..2ca21a571e --- /dev/null +++ b/src/core/tools/RunTeamPhaseTool.ts @@ -0,0 +1,172 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import type { TeamConfig } from "@roo-code/types" + +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import type { ToolUse } from "../../shared/tools" +import { BaseTool, ToolCallbacks } from "./BaseTool" + +interface RunTeamPhaseParams { + team_slug: string + phase_name: string + task: string + context?: string | null +} + +/** + * Minimal provider surface needed by RunTeamPhaseTool. + * Cast from providerRef.deref() using `provider as TeamPhaseProvider`. + */ +interface TeamPhaseProvider { + getTeamConfig(slug: string): TeamConfig | undefined + delegateParentAndOpenChild(params: { + parentTaskId: string + message: string + initialTodos: [] + mode: string + worktree?: string + parallelQueue?: Array<{ mode: string; message: string; worktree?: string }> + }): Promise + spawnConcurrentChildren(params: { + parentTaskId: string + tasks: Array<{ mode: string; message: string; worktree?: string }> + abortOnChildFailure?: boolean + }): Promise> +} + +export class RunTeamPhaseTool extends BaseTool<"run_team_phase"> { + readonly name = "run_team_phase" as const + + async execute(params: RunTeamPhaseParams, task: Task, callbacks: ToolCallbacks): Promise { + const { team_slug, phase_name, task: taskDesc, context } = params + const { askApproval, handleError, pushToolResult } = callbacks + + try { + const provider = task.providerRef.deref() as TeamPhaseProvider | undefined + if (!provider) { + pushToolResult(formatResponse.toolError("Provider reference lost")) + return + } + + // 1) Look up team config + const teamConfig = provider.getTeamConfig(team_slug) + if (!teamConfig) { + pushToolResult( + formatResponse.toolError( + `Team "${team_slug}" not found. Make sure .roo/teams/${team_slug}.json exists in the workspace.`, + ), + ) + return + } + + // 2) Find the phase + const phase = teamConfig.phases.find((p) => p.name === phase_name) + if (!phase) { + const available = teamConfig.phases.map((p) => p.name).join(", ") + pushToolResult( + formatResponse.toolError( + `Phase "${phase_name}" not found in team "${team_slug}". Available phases: ${available}`, + ), + ) + return + } + + if (!phase.agents || phase.agents.length === 0) { + pushToolResult(formatResponse.toolError(`Phase "${phase_name}" has no agents defined.`)) + return + } + + // 3) Load shared conventions (non-fatal if missing) + let conventionsPrefix = "" + if (teamConfig.conventions) { + try { + const conventionsPath = path.resolve(task.workspacePath, teamConfig.conventions) + const content = await fs.readFile(conventionsPath, "utf-8") + if (content.trim()) { + conventionsPrefix = `\n${content.trim()}\n\n\n` + } + } catch { + // Conventions file not found — proceed without it + } + } + + // 4) Interpolate {{task}}, {{context}}, {{phase}}, {{team}} in instructions + const interpolate = (template: string) => + template + .replace(/\{\{task\}\}/g, taskDesc) + .replace(/\{\{context\}\}/g, context ?? "") + .replace(/\{\{phase\}\}/g, phase_name) + .replace(/\{\{team\}\}/g, teamConfig.name) + + const agentSpecs = phase.agents.map((agent) => ({ + mode: agent.mode, + role: agent.role, + message: conventionsPrefix + interpolate(agent.instruction), + worktree: agent.worktree, + })) + + // 5) Ask approval + const toolMessage = JSON.stringify({ + tool: "runTeamPhase", + team: teamConfig.name, + phase: phase.label ?? phase_name, + agentCount: agentSpecs.length, + concurrent: phase.concurrent ?? false, + agents: agentSpecs.map((s) => ({ role: s.role ?? s.mode, mode: s.mode })), + }) + + const didApprove = await askApproval("tool", toolMessage) + if (!didApprove) return + + task.consecutiveMistakeCount = 0 + + // 6) Execute the phase + if (phase.concurrent) { + // Concurrent: all agents start simultaneously; parent stays alive. + const results = await provider.spawnConcurrentChildren({ + parentTaskId: task.taskId, + tasks: agentSpecs.map(({ mode, message, worktree }) => ({ mode, message, worktree })), + abortOnChildFailure: phase.abortOnChildFailure ?? false, + }) + pushToolResult(JSON.stringify(results, null, 2)) + } else if (agentSpecs.length === 1) { + // Single sequential agent — simple delegation (like new_task) + await provider.delegateParentAndOpenChild({ + parentTaskId: task.taskId, + message: agentSpecs[0].message, + initialTodos: [], + mode: agentSpecs[0].mode, + worktree: agentSpecs[0].worktree, + }) + // Parent is now suspended; this line is reached only if delegation didn't proceed. + pushToolResult(`Phase "${phase_name}" agent started. Awaiting completion...`) + } else { + // Multiple sequential agents — queue drain (first runs, rest queued) + const [first, ...rest] = agentSpecs + await provider.delegateParentAndOpenChild({ + parentTaskId: task.taskId, + message: first.message, + initialTodos: [], + mode: first.mode, + worktree: first.worktree, + parallelQueue: rest.map(({ mode, message, worktree }) => ({ mode, message, worktree })), + }) + // Parent is now suspended; this line is reached only if delegation didn't proceed. + pushToolResult( + `Phase "${phase_name}" started with ${agentSpecs.length} sequential agents. Awaiting all results...`, + ) + } + } catch (error) { + await handleError("running team phase", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"run_team_phase">): Promise { + const partialMessage = JSON.stringify({ tool: "runTeamPhase" }) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const runTeamPhaseTool = new RunTeamPhaseTool() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7b9739d258..436f9a5949 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -77,6 +77,7 @@ import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" +import { TeamsManager } from "../../services/teams/TeamsManager" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -152,6 +153,7 @@ export class ClineProvider private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager + protected teamsManager?: TeamsManager private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void @@ -241,6 +243,12 @@ export class ClineProvider this.log(`Failed to initialize Skills Manager: ${error}`) }) + // Initialize Teams Manager for team workflow discovery + this.teamsManager = new TeamsManager(this) + this.teamsManager.initialize().catch((error) => { + this.log(`Failed to initialize Teams Manager: ${error}`) + }) + this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) // Forward task events to the provider. @@ -732,6 +740,7 @@ export class ClineProvider this.mcpHub = undefined await this.skillsManager?.dispose() this.skillsManager = undefined + this.teamsManager = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.taskHistoryStore.dispose() @@ -2771,6 +2780,10 @@ export class ClineProvider return this.skillsManager } + public getTeamConfig(slug: string): import("@roo-code/types").TeamConfig | undefined { + return this.teamsManager?.getTeamConfig(slug) + } + /** * Check if the current state is compliant with MDM policy * @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant diff --git a/src/services/teams/TeamsManager.ts b/src/services/teams/TeamsManager.ts new file mode 100644 index 0000000000..abda7b9ab2 --- /dev/null +++ b/src/services/teams/TeamsManager.ts @@ -0,0 +1,66 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import type { TeamConfig } from "@roo-code/types" +import type { ClineProvider } from "../../core/webview/ClineProvider" + +export class TeamsManager { + private teams: Map = new Map() + private providerRef: WeakRef + + constructor(provider: ClineProvider) { + this.providerRef = new WeakRef(provider) + } + + async initialize(): Promise { + await this.discoverTeams() + } + + /** + * Scan .roo/teams/*.json in the current workspace and cache all valid TeamConfig objects. + * Non-fatal: missing directory or malformed files are silently skipped. + */ + async discoverTeams(): Promise { + this.teams.clear() + const provider = this.providerRef.deref() + if (!provider) return + + const teamsDir = path.join(provider.cwd, ".roo", "teams") + + let entries: import("fs").Dirent[] + try { + entries = await fs.readdir(teamsDir, { withFileTypes: true, encoding: "utf-8" }) + } catch { + // Directory doesn't exist — normal for projects without teams + return + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue + const filePath = path.join(teamsDir, entry.name) + try { + const raw = await fs.readFile(filePath, "utf-8") + const config = JSON.parse(raw) as TeamConfig + if (typeof config.slug === "string" && config.slug && Array.isArray(config.phases)) { + config.$source = filePath + this.teams.set(config.slug, config) + } + } catch { + // Malformed JSON or missing required fields — skip silently + } + } + } + + getTeamConfig(slug: string): TeamConfig | undefined { + return this.teams.get(slug) + } + + listTeams(): TeamConfig[] { + return Array.from(this.teams.values()) + } + + /** Reload all team configs from disk (e.g., after a file-system change). */ + async refresh(): Promise { + await this.discoverTeams() + } +} diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1cc0a362b1..423748666a 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -81,6 +81,9 @@ export const toolParamNames = [ // read_file legacy format parameter (backward compatibility) "files", "line_ranges", + // run_team_phase parameters + "team_slug", + "phase_name", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -116,6 +119,7 @@ export type NativeToolArgs = { update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_to_file: { path: string; content: string } + run_team_phase: { team_slug: string; phase_name: string; task: string; context?: string | null } // Add more tools as they are migrated to native protocol } @@ -285,6 +289,7 @@ export const TOOL_DISPLAY_NAMES: Record = { switch_mode: "switch modes", new_task: "create new task", spawn_parallel_tasks: "spawn parallel tasks", + run_team_phase: "run team phase", codebase_search: "codebase search", update_todo_list: "update todo list", run_slash_command: "run slash command", @@ -309,7 +314,7 @@ export const TOOL_GROUPS: Record = { tools: ["use_mcp_tool", "access_mcp_resource"], }, modes: { - tools: ["switch_mode", "new_task"], + tools: ["switch_mode", "new_task", "run_team_phase"], alwaysAvailable: true, }, }