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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,7 @@ export namespace Config {
.object({
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
team_tool: z.boolean().optional().describe("Enable the team tool"),
openTelemetry: z
.boolean()
.optional()
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
import { TeamTool } from "./team"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect"
Expand Down Expand Up @@ -133,6 +134,7 @@ export namespace ToolRegistry {
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
...(cfg.experimental?.team_tool === true ? [TeamTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
...custom,
]
Expand Down
189 changes: 189 additions & 0 deletions packages/opencode/src/tool/team.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import z from "zod"
import { Tool } from "./tool"
import { TaskTool } from "./task"
import DESCRIPTION from "./team.txt"

const task = z.object({
id: z.string().optional(),
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the sub-agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z.string().describe("Resume an existing sub-agent session if provided").optional(),
})

const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the team execution"),
tasks: z.array(task).min(1).max(10).describe("Sub-agent tasks to execute"),
concurrency: z
.number()
.int()
.min(1)
.max(8)
.optional()
.describe("Maximum number of child tasks to run concurrently (default: 4)"),
})

function parse(input: string) {
const key = "task_id:"
const idx = input.indexOf(key)
if (idx < 0) return
const line = input
.slice(idx + key.length)
.split("\n")[0]
?.trim()
if (!line) return
const first = line.split(" ")[0]
return first || undefined
}

function getErr(err: unknown) {
if (err instanceof Error) return err.message
return String(err)
}

export const TeamTool = Tool.define("team", async (ctx) => {
const subtask = await TaskTool.init(ctx)

return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
const started = Date.now()
const jobs = params.tasks
const limit = params.concurrency ?? 4
const asks = Array.from(new Set(jobs.map((item) => item.subagent_type)))
const denied = new Map<string, string>()

for (const subagent_type of asks) {
try {
await ctx.ask({
permission: "task",
patterns: [subagent_type],
always: [subagent_type],
metadata: {
description: params.description,
subagent_type,
team: true,
},
})
} catch (err) {
denied.set(subagent_type, getErr(err))
}
}

const out = await Promise.all(
jobs.map(async (item, i) => ({
i,
item,
run: async () => {
const start = Date.now()
try {
if (denied.has(item.subagent_type)) {
return {
id: item.id ?? `task_${i + 1}`,
description: item.description,
subagent_type: item.subagent_type,
status: "error" as const,
error: denied.get(item.subagent_type),
task_id: item.task_id,
duration_ms: Date.now() - start,
}
}

const result = await subtask.execute(
{
description: item.description,
prompt: item.prompt,
subagent_type: item.subagent_type,
task_id: item.task_id,
},
{
...ctx,
extra: {
...ctx.extra,
bypassAgentCheck: true,
},
},
)

return {
id: item.id ?? `task_${i + 1}`,
description: item.description,
subagent_type: item.subagent_type,
status: "completed" as const,
output: result.output,
task_id:
(typeof result.metadata?.sessionId === "string" && result.metadata.sessionId) ||
parse(result.output) ||
item.task_id,
duration_ms: Date.now() - start,
}
} catch (err) {
return {
id: item.id ?? `task_${i + 1}`,
description: item.description,
subagent_type: item.subagent_type,
status: "error" as const,
error: getErr(err),
task_id: item.task_id,
duration_ms: Date.now() - start,
}
}
},
})),
)

const result = new Array<Awaited<ReturnType<(typeof out)[number]["run"]>>>(out.length)
let next = 0
const workers = Array.from({ length: Math.min(limit, out.length) }, async () => {
while (true) {
const idx = next
next += 1
const item = out[idx]
if (!item) return
if (ctx.abort.aborted) return
result[idx] = await item.run()
}
})
await Promise.all(workers)

const list = result.filter((item) => item !== undefined)

const good = list.filter((item) => item.status === "completed")
const bad = list.filter((item) => item.status === "error")

const body = [
`Team summary: ${good.length}/${list.length} tasks completed`,
...list.map((item) =>
item.status === "completed"
? [
``,
`## ${item.id} (${item.subagent_type})`,
`status: completed`,
`task_id: ${item.task_id ?? "unknown"}`,
`${item.output}`,
].join("\n")
: [
``,
`## ${item.id} (${item.subagent_type})`,
`status: error`,
`task_id: ${item.task_id ?? "unknown"}`,
`error: ${item.error}`,
].join("\n"),
),
].join("\n")

return {
title: params.description,
output: body,
metadata: {
total: result.length,
successful: good.length,
failed: bad.length,
duration_ms: Date.now() - started,
children: list,
},
}
},
}
})
22 changes: 22 additions & 0 deletions packages/opencode/src/tool/team.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Run multiple sub-agent tasks in parallel as an ephemeral team.

Use this tool when work can be safely split into independent subtasks that do not require strict ordering.

Key behavior:
- Spawns child sub-agent tasks and executes them concurrently (bounded by `concurrency`).
- Aggregates all child outputs into one combined result.
- Team state is ephemeral (runtime-only). Child sessions remain available via returned `task_id` values.
- If one child fails, other children still continue (collect-all behavior).

Use this tool for:
- Parallel research across files/directories
- Splitting implementation vs testing vs review work
- Running multiple independent specialist agents at once

Avoid this tool when:
- Tasks have hard dependencies on each other (run those sequentially with `task`)
- You need persistent shared memory between children

Tips:
- Keep each child task narrowly scoped.
- Set `concurrency` to a reasonable value to control resource usage.
36 changes: 36 additions & 0 deletions packages/opencode/test/tool/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,40 @@ describe("tool.registry", () => {
},
})
})

test("gates team tool behind experimental.team_tool", async () => {
await using off = await tmpdir({
git: true,
config: {
experimental: {
team_tool: false,
},
},
})

await Instance.provide({
directory: off.path,
fn: async () => {
const ids = await ToolRegistry.ids()
expect(ids).not.toContain("team")
},
})

await using on = await tmpdir({
git: true,
config: {
experimental: {
team_tool: true,
},
},
})

await Instance.provide({
directory: on.path,
fn: async () => {
const ids = await ToolRegistry.ids()
expect(ids).toContain("team")
},
})
})
})
Loading
Loading