feat(teams): add run_team_phase tool and TeamsManager for phased multi-agent workflows

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 <noreply@anthropic.com>
This commit is contained in:
davincidreams 2026-04-25 06:29:24 -04:00
parent 5aecb08211
commit c2a549a1dc
11 changed files with 460 additions and 1 deletions

66
.roo/teams/fullstack.json Normal file
View file

@ -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."
}
]
}
]
}

View file

@ -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"

View file

@ -0,0 +1,67 @@
/**
* Team configuration — defines a named, phased multi-agent workflow.
* Team configs live in .roo/teams/<slug>.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 <team_conventions> 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
}

View file

@ -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",

View file

@ -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,

View file

@ -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),

View file

@ -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/<team_slug>.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/<slug>.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

View file

@ -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<Task>
spawnConcurrentChildren(params: {
parentTaskId: string
tasks: Array<{ mode: string; message: string; worktree?: string }>
abortOnChildFailure?: boolean
}): Promise<Array<{ taskId: string; summary: string; error?: string }>>
}
export class RunTeamPhaseTool extends BaseTool<"run_team_phase"> {
readonly name = "run_team_phase" as const
async execute(params: RunTeamPhaseParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
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 = `<team_conventions>\n${content.trim()}\n</team_conventions>\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<void> {
const partialMessage = JSON.stringify({ tool: "runTeamPhase" })
await task.ask("tool", partialMessage, block.partial).catch(() => {})
}
}
export const runTeamPhaseTool = new RunTeamPhaseTool()

View file

@ -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 <most> 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

View file

@ -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<string, TeamConfig> = new Map()
private providerRef: WeakRef<ClineProvider>
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
}
async initialize(): Promise<void> {
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<void> {
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<void> {
await this.discoverTeams()
}
}

View file

@ -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<string, unknown> }
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<ToolName, string> = {
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<ToolGroup, ToolGroupConfig> = {
tools: ["use_mcp_tool", "access_mcp_resource"],
},
modes: {
tools: ["switch_mode", "new_task"],
tools: ["switch_mode", "new_task", "run_team_phase"],
alwaysAvailable: true,
},
}