From 8e5bbf3e038172673ed5e642f1ef490899733abc Mon Sep 17 00:00:00 2001 From: Marcus Pamelia Date: Sun, 22 Mar 2026 10:01:57 +0100 Subject: [PATCH 1/7] feat: add multi-agent team coordination with spec review improvements Add teams & agents subsystem enabling parallel multi-agent workflows: - Team management with lead/member lifecycle, auto-disambiguation of duplicate agent names - Background agent execution with configurable concurrency limit (max_agents) - Cross-session message injection with race-safe Bus subscription - Agent memory with 100KB cap, team task board, 5 new tools - Failure propagation: crashed agents notify lead, cascade to tasks - Member status updates on normal exit (completed) and crash (failed) - Session-end auto-disband, server-startup reconciliation - TUI sidebar shows active teams and agent status - Spec updated to document all lifecycle, cleanup, and config behaviors --- .../20260321160000_add_teams/migration.sql | 47 +++ .../20260321160000_add_teams/snapshot.json | 7 + packages/opencode/src/agent/agent.ts | 2 + packages/opencode/src/agent/memory.ts | 109 +++++++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 80 +++++ .../cli/cmd/tui/routes/session/sidebar.tsx | 79 ++++- packages/opencode/src/config/config.ts | 23 ++ packages/opencode/src/id/id.ts | 3 + packages/opencode/src/server/routes/team.ts | 226 +++++++++++++ packages/opencode/src/server/server.ts | 5 + packages/opencode/src/session/inject.ts | 100 ++++++ packages/opencode/src/session/message-v2.ts | 7 + packages/opencode/src/session/prompt.ts | 83 +++++ packages/opencode/src/session/system.ts | 13 + packages/opencode/src/team/index.ts | 306 ++++++++++++++++++ packages/opencode/src/team/schema.ts | 37 +++ packages/opencode/src/team/task.ts | 126 ++++++++ packages/opencode/src/team/team.sql.ts | 85 +++++ packages/opencode/src/tool/agent-memory.ts | 65 ++++ packages/opencode/src/tool/agent-memory.txt | 11 + packages/opencode/src/tool/registry.ts | 10 + packages/opencode/src/tool/send-message.ts | 63 ++++ packages/opencode/src/tool/send-message.txt | 13 + packages/opencode/src/tool/task.ts | 177 ++++++++-- packages/opencode/src/tool/task.txt | 5 + packages/opencode/src/tool/team-create.ts | 39 +++ packages/opencode/src/tool/team-create.txt | 11 + packages/opencode/src/tool/team-delete.ts | 33 ++ packages/opencode/src/tool/team-delete.txt | 5 + packages/opencode/src/tool/team-task.ts | 87 +++++ packages/opencode/src/tool/team-task.txt | 11 + teams-agents-feature.md | 198 ++++++++++++ 32 files changed, 2036 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/migration/20260321160000_add_teams/migration.sql create mode 100644 packages/opencode/migration/20260321160000_add_teams/snapshot.json create mode 100644 packages/opencode/src/agent/memory.ts create mode 100644 packages/opencode/src/server/routes/team.ts create mode 100644 packages/opencode/src/session/inject.ts create mode 100644 packages/opencode/src/team/index.ts create mode 100644 packages/opencode/src/team/schema.ts create mode 100644 packages/opencode/src/team/task.ts create mode 100644 packages/opencode/src/team/team.sql.ts create mode 100644 packages/opencode/src/tool/agent-memory.ts create mode 100644 packages/opencode/src/tool/agent-memory.txt create mode 100644 packages/opencode/src/tool/send-message.ts create mode 100644 packages/opencode/src/tool/send-message.txt create mode 100644 packages/opencode/src/tool/team-create.ts create mode 100644 packages/opencode/src/tool/team-create.txt create mode 100644 packages/opencode/src/tool/team-delete.ts create mode 100644 packages/opencode/src/tool/team-delete.txt create mode 100644 packages/opencode/src/tool/team-task.ts create mode 100644 packages/opencode/src/tool/team-task.txt create mode 100644 teams-agents-feature.md diff --git a/packages/opencode/migration/20260321160000_add_teams/migration.sql b/packages/opencode/migration/20260321160000_add_teams/migration.sql new file mode 100644 index 000000000000..9cec987f8e24 --- /dev/null +++ b/packages/opencode/migration/20260321160000_add_teams/migration.sql @@ -0,0 +1,47 @@ +CREATE TABLE `team` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `name` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE TABLE `team_member` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `team_id` text NOT NULL, + `session_id` text NOT NULL, + `agent` text NOT NULL, + `role` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE TABLE `team_task` ( + `id` text PRIMARY KEY NOT NULL, + `team_id` text NOT NULL, + `subject` text NOT NULL, + `description` text, + `owner` text, + `status` text DEFAULT 'pending' NOT NULL, + `metadata` text, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE TABLE `agent_memory` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `agent` text NOT NULL, + `content` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE INDEX `team_session_idx` ON `team` (`session_id`);--> statement-breakpoint +CREATE INDEX `team_member_team_idx` ON `team_member` (`team_id`);--> statement-breakpoint +CREATE INDEX `team_member_session_idx` ON `team_member` (`session_id`);--> statement-breakpoint +CREATE INDEX `team_task_team_idx` ON `team_task` (`team_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `agent_memory_project_agent_idx` ON `agent_memory` (`project_id`,`agent`); \ No newline at end of file diff --git a/packages/opencode/migration/20260321160000_add_teams/snapshot.json b/packages/opencode/migration/20260321160000_add_teams/snapshot.json new file mode 100644 index 000000000000..1c1e5292d9cc --- /dev/null +++ b/packages/opencode/migration/20260321160000_add_teams/snapshot.json @@ -0,0 +1,7 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "add-teams-migration", + "prevIds": [], + "ddl": [] +} diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 2ae18aaaed5c..b415626e131e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -45,6 +45,7 @@ export namespace Agent { prompt: z.string().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), + memory: z.enum(["none", "local"]).optional(), }) .meta({ ref: "Agent", @@ -256,6 +257,7 @@ export namespace Agent { item.hidden = value.hidden ?? item.hidden item.name = value.name ?? item.name item.steps = value.steps ?? item.steps + item.memory = value.memory ?? item.memory item.options = mergeDeep(item.options, value.options ?? {}) item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } diff --git a/packages/opencode/src/agent/memory.ts b/packages/opencode/src/agent/memory.ts new file mode 100644 index 000000000000..522c44d6e2c1 --- /dev/null +++ b/packages/opencode/src/agent/memory.ts @@ -0,0 +1,109 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq, and } from "../storage/db" +import { AgentMemoryTable } from "../team/team.sql" +import { MemoryID } from "../team/schema" +import { Instance } from "../project/instance" +import { Log } from "../util/log" + +const log = Log.create({ service: "agent.memory" }) + +const MAX_SIZE = 102_400 // 100KB + +export namespace AgentMemory { + export const Info = z + .object({ + id: MemoryID.zod, + projectID: z.string(), + agent: z.string(), + content: z.string(), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ ref: "AgentMemory" }) + export type Info = z.infer + + export const Event = { + Updated: BusEvent.define( + "agent.memory.updated", + z.object({ + agent: z.string(), + projectID: z.string(), + }), + ), + } + + function toInfo(row: typeof AgentMemoryTable.$inferSelect): Info { + return { + id: row.id, + projectID: row.project_id, + agent: row.agent, + content: row.content, + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + export function read(agent: string): Info | undefined { + const pid = Instance.project.id + const row = Database.use((db) => + db + .select() + .from(AgentMemoryTable) + .where(and(eq(AgentMemoryTable.project_id, pid), eq(AgentMemoryTable.agent, agent))) + .get(), + ) + if (!row) return undefined + return toInfo(row) + } + + export function write(agent: string, content: string) { + if (Buffer.byteLength(content, "utf8") > MAX_SIZE) { + content = content.slice(0, MAX_SIZE) + log.warn("memory truncated to 100KB", { agent }) + } + const pid = Instance.project.id + const now = Date.now() + const existing = read(agent) + if (existing) { + Database.use((db) => + db + .update(AgentMemoryTable) + .set({ content, time_updated: now }) + .where(eq(AgentMemoryTable.id, existing.id)) + .run(), + ) + } else { + const id = MemoryID.ascending() + Database.use((db) => + db + .insert(AgentMemoryTable) + .values({ + id, + project_id: pid, + agent, + content, + time_created: now, + time_updated: now, + }) + .run(), + ) + } + log.info("written", { agent, projectID: pid }) + Database.effect(() => Bus.publish(Event.Updated, { agent, projectID: pid })) + } + + export function append(agent: string, content: string) { + const existing = read(agent) + if (existing) { + write(agent, existing.content + "\n\n" + content) + } else { + write(agent, content) + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 3b296a927aa4..6c8f0c72c997 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -18,6 +18,27 @@ import type { ProviderAuthMethod, VcsInfo, } from "@opencode-ai/sdk/v2" + +export interface TeamMember { + teamID: string + sessionID: string + agent: string + role: "lead" | "member" + status: "active" | "completed" | "failed" | "cancelled" +} + +export interface TeamInfo { + id: string + sessionID: string + name: string + status: "active" | "disbanded" + time: { created: number; updated: number } +} + +export interface TeamWithMembers { + team: TeamInfo + members: TeamMember[] +} import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" @@ -72,6 +93,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [key: string]: McpResource } formatter: FormatterStatus[] + teams: TeamWithMembers[] vcs: VcsInfo | undefined path: Path workspaceList: Workspace[] @@ -100,6 +122,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: {}, mcp_resource: {}, formatter: [], + teams: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, workspaceList: [], @@ -113,8 +136,64 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("workspaceList", reconcile(result.data)) } + async function syncTeams() { + const result = (await sdk + .fetch(`${sdk.url}/team/active`) + .then((r) => r.json()) + .catch(() => undefined)) as TeamWithMembers[] | undefined + if (!result) return + setStore("teams", reconcile(result)) + } + sdk.event.listen((e) => { const event = e.details + // Handle team events (not yet in SDK types) + const type = event.type as string + if (type === "team.created") { + const team = (event as any).properties.team as TeamInfo + setStore( + "teams", + produce((draft) => { + draft.push({ team, members: [] }) + }), + ) + return + } + if (type === "team.disbanded") { + const teamID = (event as any).properties.teamID as string + setStore( + "teams", + produce((draft) => { + const idx = draft.findIndex((t) => t.team.id === teamID) + if (idx !== -1) draft.splice(idx, 1) + }), + ) + return + } + if (type === "team.member.added") { + const member = (event as any).properties.member as TeamMember + setStore( + "teams", + produce((draft) => { + const entry = draft.find((t) => t.team.id === member.teamID) + if (entry) entry.members.push(member) + }), + ) + return + } + if (type === "team.member.updated") { + const member = (event as any).properties.member as TeamMember + setStore( + "teams", + produce((draft) => { + const entry = draft.find((t) => t.team.id === member.teamID) + if (!entry) return + const idx = entry.members.findIndex((m) => m.sessionID === member.sessionID) + if (idx !== -1) entry.members[idx] = member + }), + ) + return + } switch (event.type) { case "server.instance.disposed": bootstrap() @@ -423,6 +502,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), syncWorkspaces(), + syncTeams(), ]).then(() => { setStore("status", "complete") }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 42ac5fbe080a..081e40659b4b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,4 +1,4 @@ -import { useSync } from "@tui/context/sync" +import { useSync, type TeamWithMembers } from "@tui/context/sync" import { createMemo, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" @@ -25,6 +25,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { diff: true, todo: true, lsp: true, + teams: true, }) // Sort MCP servers alphabetically for consistent display order @@ -60,6 +61,17 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { } }) + const teams = createMemo(() => sync.data.teams) + const agents = createMemo(() => { + const all: { agent: string; status: string; team: string }[] = [] + for (const t of teams()) { + for (const m of t.members) { + if (m.role === "member") all.push({ agent: m.agent, status: m.status, team: t.team.name }) + } + } + return all + }) + const directory = useDirectory() const kv = useKV() @@ -106,6 +118,71 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.percentage ?? 0}% used {cost()} spent + 0}> + + setExpanded("teams", !expanded.teams)}> + {expanded.teams ? "▼" : "▶"} + + Teams + + + {" "} + ({teams().length} active, {agents().filter((a) => a.status === "active").length} agents) + + + + + + + {(entry) => ( + + + {entry.team.name} + + m.role === "member")}> + {(member) => ( + + + )[member.status], + }} + > + {member.status === "active" + ? "◌" + : member.status === "completed" + ? "●" + : member.status === "failed" + ? "✕" + : "○"} + + + @{member.agent}{" "} + + + working + done + failed + cancelled + + + + + )} + + + )} + + + + 0}> { @@ -762,6 +766,7 @@ export namespace Config { "permission", "disable", "tools", + "memory", ]) // Extract unknown properties into options @@ -1205,6 +1210,24 @@ export namespace Config { .describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."), }) .optional(), + team: z + .object({ + max_agents: z + .number() + .int() + .positive() + .optional() + .default(10) + .describe("Maximum number of concurrent background agents (default: 10)"), + member_timeout: z + .number() + .int() + .positive() + .optional() + .default(300_000) + .describe("Timeout in milliseconds for team members waiting for messages (default: 300000 = 5 minutes)"), + }) + .optional(), experimental: z .object({ disable_paste_summary: z.boolean().optional(), diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 9e324962bf7a..78f73bf995d1 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -13,6 +13,9 @@ export namespace Identifier { pty: "pty", tool: "tool", workspace: "wrk", + team: "tem", + team_task: "ttk", + memory: "mem", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/server/routes/team.ts b/packages/opencode/src/server/routes/team.ts new file mode 100644 index 000000000000..ea7c2528d75d --- /dev/null +++ b/packages/opencode/src/server/routes/team.ts @@ -0,0 +1,226 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Team } from "../../team" +import { TeamTask } from "../../team/task" +import { TeamID, TeamTaskID } from "../../team/schema" +import { SessionID } from "@/session/schema" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const TeamRoutes = lazy(() => + new Hono() + .get( + "/active", + describeRoute({ + summary: "List active teams with members", + description: "Get all active teams and their members for the TUI status panel.", + operationId: "team.active", + responses: { + 200: { + description: "Active teams with members", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + team: Team.Info, + members: Team.Member.array(), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + const teams = Team.active() + const result = teams.map((team) => ({ + team, + members: Team.members(team.id), + })) + return c.json(result) + }, + ) + .get( + "/", + describeRoute({ + summary: "List teams", + description: "Get teams for a session.", + operationId: "team.list", + responses: { + 200: { + description: "List of teams", + content: { + "application/json": { + schema: resolver(Team.Info.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + session_id: z.string().meta({ description: "Session ID of the team lead" }), + }), + ), + async (c) => { + const query = c.req.valid("query") + const teams = Team.bySession(SessionID.make(query.session_id)) + return c.json(teams) + }, + ) + .get( + "/:id", + describeRoute({ + summary: "Get team", + operationId: "team.get", + responses: { + 200: { + description: "Team details", + content: { + "application/json": { + schema: resolver(Team.Info), + }, + }, + }, + ...errors(404), + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + const team = Team.get(id) + if (!team) return c.json({ error: "Team not found" }, 404) + return c.json(team) + }, + ) + .get( + "/:id/members", + describeRoute({ + summary: "List team members", + operationId: "team.members", + responses: { + 200: { + description: "Team members", + content: { + "application/json": { + schema: resolver(Team.Member.array()), + }, + }, + }, + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + return c.json(Team.members(id)) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create team", + operationId: "team.create", + responses: { + 200: { + description: "Created team", + content: { + "application/json": { + schema: resolver(Team.Info), + }, + }, + }, + }, + }), + validator( + "json", + z.object({ + name: z.string(), + session_id: z.string(), + agent: z.string().optional(), + }), + ), + async (c) => { + const body = c.req.valid("json") + const team = Team.create({ + name: body.name, + sessionID: SessionID.make(body.session_id), + agent: body.agent, + }) + return c.json(team) + }, + ) + .post( + "/:id/disband", + describeRoute({ + summary: "Disband team", + operationId: "team.disband", + responses: { + 200: { + description: "Team disbanded", + }, + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + Team.disband(id) + return c.json({ ok: true }) + }, + ) + .get( + "/:id/tasks", + describeRoute({ + summary: "List team tasks", + operationId: "team.task.list", + responses: { + 200: { + description: "Team tasks", + content: { + "application/json": { + schema: resolver(TeamTask.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + return c.json(TeamTask.list(id)) + }, + ) + .post( + "/:id/tasks", + describeRoute({ + summary: "Create team task", + operationId: "team.task.create", + responses: { + 200: { + description: "Created task", + content: { + "application/json": { + schema: resolver(TeamTask.Info), + }, + }, + }, + }, + }), + validator( + "json", + z.object({ + subject: z.string(), + description: z.string().optional(), + owner: z.string().optional(), + }), + ), + async (c) => { + const id = TeamID.make(c.req.param("id")) + const body = c.req.valid("json") + const task = TeamTask.create({ + teamID: id, + ...body, + }) + return c.json(task) + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index d53074a80fea..676c64390543 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -43,6 +43,7 @@ import { Snapshot } from "@/snapshot" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" +import { TeamRoutes } from "./routes/team" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" import { initProjectors } from "./projectors" @@ -258,6 +259,7 @@ export namespace Server { .route("/", FileRoutes()) .route("/", EventRoutes()) .route("/mcp", McpRoutes()) + .route("/team", TeamRoutes()) .route("/tui", TuiRoutes()) .post( "/instance/dispose", @@ -596,6 +598,9 @@ export namespace Server { const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + // Reconcile stale teams from previous sessions + import("../team").then(({ Team }) => Team.reconcile()).catch(() => {}) + const shouldPublishMDNS = opts.mdns && server.port && diff --git a/packages/opencode/src/session/inject.ts b/packages/opencode/src/session/inject.ts new file mode 100644 index 000000000000..6046cc42935b --- /dev/null +++ b/packages/opencode/src/session/inject.ts @@ -0,0 +1,100 @@ +import { Session } from "." +import { MessageV2 } from "./message-v2" +import { SessionID, MessageID, PartID } from "./schema" +import { Bus } from "../bus" +import { Log } from "../util/log" +import { BusEvent } from "@/bus/bus-event" +import z from "zod" + +const log = Log.create({ service: "session.inject" }) + +export namespace SessionInject { + export const Event = { + MessageInjected: BusEvent.define( + "session.message.injected", + z.object({ + sessionID: SessionID.zod, + from: z.string(), + fromSessionID: SessionID.zod, + }), + ), + } + + /** + * Inject a message into a session from another agent/session. + * This creates a synthetic user message that the prompt loop + * picks up on its next iteration. + * + * For sessions that have already completed their loop, we publish + * an injection event that the prompt loop can listen for to wake up. + */ + export async function send(input: { + sessionID: SessionID + from: string + fromSessionID: SessionID + content: string + teamID?: string + }) { + const id = MessageID.ascending() + log.info("injecting", { + sessionID: input.sessionID, + from: input.from, + }) + + // Resolve the target session's agent from its last user message + const agent = await lastAgent(input.sessionID) + + // Create a synthetic user message tagged as injected + const msg: MessageV2.User = { + id, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent, + model: await lastModel(input.sessionID), + system: undefined, + injected: { + from: input.from, + fromSessionID: input.fromSessionID, + teamID: input.teamID, + }, + } + + await Session.updateMessage(msg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text", + text: `[Message from @${input.from}]\n\n${input.content}`, + synthetic: true, + } satisfies MessageV2.TextPart) + + Bus.publish(Event.MessageInjected, { + sessionID: input.sessionID, + from: input.from, + fromSessionID: input.fromSessionID, + }) + + log.info("injected", { + sessionID: input.sessionID, + messageID: id, + }) + } + + async function lastModel(sessionID: SessionID) { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.model) return item.info.model + } + // Fallback: use a placeholder that will be resolved by the prompt loop + const { Provider } = await import("../provider/provider") + return Provider.defaultModel() + } + + async function lastAgent(sessionID: SessionID): Promise { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.agent) return item.info.agent + } + return "build" + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 86e43156523b..7905b426d70b 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -374,6 +374,13 @@ export namespace MessageV2 { system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), variant: z.string().optional(), + injected: z + .object({ + from: z.string(), + fromSessionID: z.string(), + teamID: z.string().optional(), + }) + .optional(), }).meta({ ref: "UserMessage", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3c34539e77e..e698d3253ac5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -34,6 +34,7 @@ import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" +import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" @@ -268,9 +269,80 @@ export namespace SessionPrompt { match.abort.abort() delete s[sessionID] await SessionStatus.set(sessionID, { type: "idle" }) + // Auto-disband any teams owned by this session + import("../team") + .then(({ Team }) => Team.disbandBySession(sessionID)) + .catch((e) => log.error("failed to disband teams on cancel", { sessionID, error: e })) return } + /** + * Check if a session is a background team member that should stay alive + * waiting for injected messages (cross-review, challenges, etc.) + */ + async function shouldWaitForMessages(sessionID: SessionID, abort: AbortSignal): Promise { + if (abort.aborted) return false + const session = await Session.get(sessionID) + if (!session.parentID) return false + + // Check if this session is registered as a team member + const { TeamMemberTable } = await import("../team/team.sql") + const { Database, eq } = await import("../storage/db") + const member = Database.use((db) => + db.select().from(TeamMemberTable).where(eq(TeamMemberTable.session_id, sessionID)).get(), + ) + return !!member && member.status === "active" + } + + /** + * Wait for an injected message to arrive in this session. + * Returns true if a message was injected, false if aborted or timed out. + */ + function waitForInjection(sessionID: SessionID, abort: AbortSignal): Promise { + return new Promise(async (resolve) => { + const config = await Config.get() + const duration = config.team?.member_timeout ?? 300_000 + + const timeout = setTimeout(() => { + cleanup() + resolve(false) + }, duration) + + const { SessionInject } = await import("./inject") + let resolved = false + const unsub = Bus.subscribe(SessionInject.Event.MessageInjected, (event) => { + if (event.properties.sessionID === sessionID && !resolved) { + resolved = true + cleanup() + resolve(true) + } + }) + + // Check for messages injected before subscription was established (race fix) + const msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const last = msgs.findLast((m) => m.info.role === "assistant") + const pending = msgs.find((m) => m.info.role === "user" && last && m.info.id > last.info.id) + if (pending && !resolved) { + resolved = true + cleanup() + resolve(true) + return + } + + function onAbort() { + cleanup() + resolve(false) + } + abort.addEventListener("abort", onAbort) + + function cleanup() { + clearTimeout(timeout) + unsub() + abort.removeEventListener("abort", onAbort) + } + }) + } + export const LoopInput = z.object({ sessionID: SessionID.zod, resume_existing: z.boolean().optional(), @@ -324,6 +396,15 @@ export namespace SessionPrompt { !["tool-calls", "unknown"].includes(lastAssistant.finish) && lastUser.id < lastAssistant.id ) { + // Check if this session is a background team member that should stay alive + const shouldWait = await shouldWaitForMessages(sessionID, abort) + if (shouldWait) { + log.info("team member waiting for messages", { sessionID }) + const injected = await waitForInjection(sessionID, abort) + if (injected) continue + // Mark member as completed on normal exit (timeout or no more messages) + import("../team").then(({ Team }) => Team.completeMember(sessionID)).catch(() => {}) + } log.info("exiting loop", { sessionID }) break } @@ -674,9 +755,11 @@ export namespace SessionPrompt { // Build system prompt, adding structured output instruction if needed const skills = await SystemPrompt.skills(agent) + const mem = SystemPrompt.memory(agent) const system = [ ...(await SystemPrompt.environment(model)), ...(skills ? [skills] : []), + ...(mem ? [mem] : []), ...(await InstructionPrompt.system()), ] const format = lastUser.format ?? { type: "text" } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index ca324652d9dc..e8c985a163d2 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -13,6 +13,7 @@ import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" import { Permission } from "@/permission" import { Skill } from "@/skill" +import { AgentMemory } from "@/agent/memory" export namespace SystemPrompt { export function provider(model: Provider.Model) { @@ -65,4 +66,16 @@ export namespace SystemPrompt { Skill.fmt(list, { verbose: true }), ].join("\n") } + + export function memory(agent: Agent.Info): string | undefined { + if (agent.memory !== "local") return undefined + const mem = AgentMemory.read(agent.name) + if (!mem) return undefined + return [ + ``, + `The following is your persistent memory for this project. Use the agent_memory tool to update it.`, + mem.content, + ``, + ].join("\n") + } } diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts new file mode 100644 index 000000000000..cc84c5fc0ebc --- /dev/null +++ b/packages/opencode/src/team/index.ts @@ -0,0 +1,306 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq, and, inArray } from "../storage/db" +import { TeamTable, TeamMemberTable, TeamTaskTable } from "./team.sql" +import { TeamID } from "./schema" +import { SessionID } from "../session/schema" +import { Log } from "../util/log" + +const log = Log.create({ service: "team" }) + +export namespace Team { + export const Info = z + .object({ + id: TeamID.zod, + sessionID: SessionID.zod, + name: z.string(), + status: z.enum(["active", "disbanded"]), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ ref: "Team" }) + export type Info = z.infer + + export const Member = z + .object({ + teamID: TeamID.zod, + sessionID: SessionID.zod, + agent: z.string(), + role: z.enum(["lead", "member"]), + status: z.enum(["active", "completed", "failed", "cancelled"]), + }) + .meta({ ref: "TeamMember" }) + export type Member = z.infer + + export const Event = { + Created: BusEvent.define( + "team.created", + z.object({ + team: Info, + }), + ), + Updated: BusEvent.define( + "team.updated", + z.object({ + team: Info, + }), + ), + Disbanded: BusEvent.define( + "team.disbanded", + z.object({ + teamID: TeamID.zod, + }), + ), + MemberAdded: BusEvent.define( + "team.member.added", + z.object({ + member: Member, + }), + ), + MemberUpdated: BusEvent.define( + "team.member.updated", + z.object({ + member: Member, + }), + ), + } + + function toInfo(row: typeof TeamTable.$inferSelect): Info { + return { + id: row.id, + sessionID: row.session_id, + name: row.name, + status: row.status as Info["status"], + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + function toMember(row: typeof TeamMemberTable.$inferSelect): Member { + return { + teamID: row.team_id, + sessionID: row.session_id, + agent: row.agent, + role: row.role as Member["role"], + status: row.status as Member["status"], + } + } + + export function create(input: { name: string; sessionID: SessionID; agent?: string }): Info { + const id = TeamID.ascending() + const now = Date.now() + const row = Database.transaction((db) => { + const row = db + .insert(TeamTable) + .values({ + id, + session_id: input.sessionID, + name: input.name, + status: "active", + time_created: now, + time_updated: now, + }) + .returning() + .get() + // Add the lead as first member + db.insert(TeamMemberTable) + .values({ + team_id: id, + session_id: input.sessionID, + agent: input.agent ?? "lead", + role: "lead", + status: "active", + time_created: now, + time_updated: now, + }) + .run() + return row + }) + const info = toInfo(row) + log.info("created", { id: info.id, name: info.name }) + Database.effect(() => Bus.publish(Event.Created, { team: info })) + return info + } + + export function disband(id: TeamID) { + const now = Date.now() + Database.transaction((db) => { + db.update(TeamTable).set({ status: "disbanded", time_updated: now }).where(eq(TeamTable.id, id)).run() + db.update(TeamMemberTable) + .set({ status: "cancelled", time_updated: now }) + .where(and(eq(TeamMemberTable.team_id, id), eq(TeamMemberTable.status, "active"))) + .run() + // Cascade: mark in-progress and pending tasks as failed + db.update(TeamTaskTable) + .set({ status: "failed", time_updated: now }) + .where(and(eq(TeamTaskTable.team_id, id), inArray(TeamTaskTable.status, ["in_progress", "pending"]))) + .run() + }) + log.info("disbanded", { id }) + Database.effect(() => Bus.publish(Event.Disbanded, { teamID: id })) + } + + export function get(id: TeamID): Info | undefined { + const row = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.id, id)).get()) + if (!row) return undefined + return toInfo(row) + } + + export function bySession(sessionID: SessionID): Info[] { + const rows = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.session_id, sessionID)).all()) + return rows.map(toInfo) + } + + export function members(id: TeamID): Member[] { + const rows = Database.use((db) => db.select().from(TeamMemberTable).where(eq(TeamMemberTable.team_id, id)).all()) + return rows.map(toMember) + } + + export function addMember(input: { teamID: TeamID; sessionID: SessionID; agent: string }): Member { + const team = get(input.teamID) + if (!team) throw new Error(`Team not found: ${input.teamID}`) + if (team.status === "disbanded") throw new Error(`Cannot add member to disbanded team: ${input.teamID}`) + + // Disambiguate duplicate agent names by appending a suffix + let agent = input.agent + const existing = members(input.teamID).filter((m) => m.role === "member") + const taken = new Set(existing.map((m) => m.agent)) + if (taken.has(agent)) { + let i = 2 + while (taken.has(`${input.agent}-${i}`)) i++ + agent = `${input.agent}-${i}` + } + + const now = Date.now() + const row = Database.use((db) => + db + .insert(TeamMemberTable) + .values({ + team_id: input.teamID, + session_id: input.sessionID, + agent, + role: "member", + status: "active", + time_created: now, + time_updated: now, + }) + .returning() + .get(), + ) + const member = toMember(row) + Database.effect(() => Bus.publish(Event.MemberAdded, { member })) + return member + } + + export function updateMember(input: { teamID: TeamID; sessionID: SessionID; status: Member["status"] }) { + const now = Date.now() + Database.use((db) => + db + .update(TeamMemberTable) + .set({ status: input.status, time_updated: now }) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.session_id, input.sessionID))) + .run(), + ) + } + + export function findMemberSession(input: { teamID: TeamID; agent: string }): Member | undefined { + const row = Database.use((db) => + db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.agent, input.agent))) + .get(), + ) + if (!row) return undefined + return toMember(row) + } + + export function leadSession(id: TeamID): Member | undefined { + const row = Database.use((db) => + db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.team_id, id), eq(TeamMemberTable.role, "lead"))) + .get(), + ) + if (!row) return undefined + return toMember(row) + } + + /** Mark a member as completed (normal exit) */ + export function completeMember(sessionID: SessionID) { + const now = Date.now() + const row = Database.use((db) => + db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.session_id, sessionID), eq(TeamMemberTable.status, "active"))) + .get(), + ) + if (!row) return + Database.use((db) => + db + .update(TeamMemberTable) + .set({ status: "completed", time_updated: now }) + .where(and(eq(TeamMemberTable.session_id, sessionID), eq(TeamMemberTable.status, "active"))) + .run(), + ) + const member = toMember({ ...row, status: "completed" }) + Database.effect(() => Bus.publish(Event.MemberUpdated, { member })) + } + + /** Disband all active teams owned by a session (cleanup on session end) */ + export function disbandBySession(sessionID: SessionID) { + const teams = bySession(sessionID).filter((t) => t.status === "active") + for (const team of teams) { + log.info("auto-disbanding on session end", { teamID: team.id, sessionID }) + disband(team.id) + } + } + + /** Mark a member as failed and cascade failure to owned team tasks */ + export function failMember(input: { teamID: TeamID; sessionID: SessionID; agent: string }) { + const now = Date.now() + Database.transaction((db) => { + db.update(TeamMemberTable) + .set({ status: "failed", time_updated: now }) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.session_id, input.sessionID))) + .run() + // Cascade: mark in-progress tasks owned by this agent as failed + db.update(TeamTaskTable) + .set({ status: "failed", time_updated: now }) + .where( + and( + eq(TeamTaskTable.team_id, input.teamID), + eq(TeamTaskTable.owner, input.agent), + eq(TeamTaskTable.status, "in_progress"), + ), + ) + .run() + }) + const member = findMemberSession({ teamID: input.teamID, agent: input.agent }) + if (member) Database.effect(() => Bus.publish(Event.MemberUpdated, { member })) + } + + /** Reconcile stale teams on startup — mark active teams as disbanded */ + export function reconcile() { + const now = Date.now() + const stale = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.status, "active")).all()) + for (const row of stale) { + log.info("reconciling stale team", { id: row.id, name: row.name }) + disband(row.id as TeamID) + } + if (stale.length > 0) log.info("reconciled teams", { count: stale.length }) + } + + /** List all active teams (across all sessions) */ + export function active(): Info[] { + const rows = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.status, "active")).all()) + return rows.map(toInfo) + } +} diff --git a/packages/opencode/src/team/schema.ts b/packages/opencode/src/team/schema.ts new file mode 100644 index 000000000000..67b1b96ad0e9 --- /dev/null +++ b/packages/opencode/src/team/schema.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect" +import z from "zod" +import { Identifier } from "@/id/id" +import { withStatics } from "@/util/schema" + +export const TeamID = Schema.String.pipe( + Schema.brand("TeamID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("team", id)), + zod: Identifier.schema("team").pipe(z.custom>()), + })), +) + +export type TeamID = Schema.Schema.Type + +export const TeamTaskID = Schema.String.pipe( + Schema.brand("TeamTaskID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("team_task", id)), + zod: Identifier.schema("team_task").pipe(z.custom>()), + })), +) + +export type TeamTaskID = Schema.Schema.Type + +export const MemoryID = Schema.String.pipe( + Schema.brand("MemoryID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("memory", id)), + zod: Identifier.schema("memory").pipe(z.custom>()), + })), +) + +export type MemoryID = Schema.Schema.Type diff --git a/packages/opencode/src/team/task.ts b/packages/opencode/src/team/task.ts new file mode 100644 index 000000000000..8025db66da72 --- /dev/null +++ b/packages/opencode/src/team/task.ts @@ -0,0 +1,126 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq } from "../storage/db" +import { TeamTaskTable } from "./team.sql" +import { TeamID, TeamTaskID } from "./schema" +import { Log } from "../util/log" + +const log = Log.create({ service: "team.task" }) + +export namespace TeamTask { + export const Info = z + .object({ + id: TeamTaskID.zod, + teamID: TeamID.zod, + subject: z.string(), + description: z.string().optional(), + owner: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed", "failed"]), + metadata: z.record(z.string(), z.unknown()).optional(), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ ref: "TeamTask" }) + export type Info = z.infer + + export const Event = { + Created: BusEvent.define( + "team.task.created", + z.object({ + task: Info, + }), + ), + Updated: BusEvent.define( + "team.task.updated", + z.object({ + task: Info, + }), + ), + } + + function toInfo(row: typeof TeamTaskTable.$inferSelect): Info { + return { + id: row.id, + teamID: row.team_id, + subject: row.subject, + description: row.description ?? undefined, + owner: row.owner ?? undefined, + status: row.status as Info["status"], + metadata: (row.metadata as Record) ?? undefined, + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + export function create(input: { + teamID: TeamID + subject: string + description?: string + owner?: string + metadata?: Record + }): Info { + const id = TeamTaskID.ascending() + const now = Date.now() + const row = Database.use((db) => + db + .insert(TeamTaskTable) + .values({ + id, + team_id: input.teamID, + subject: input.subject, + description: input.description, + owner: input.owner, + metadata: input.metadata, + time_created: now, + time_updated: now, + }) + .returning() + .get(), + ) + const info = toInfo(row) + log.info("created", { id: info.id, subject: info.subject }) + Database.effect(() => Bus.publish(Event.Created, { task: info })) + return info + } + + export function update( + id: TeamTaskID, + input: Partial>, + ): Info | undefined { + const now = Date.now() + const values: Record = { time_updated: now } + if (input.status !== undefined) values.status = input.status + if (input.owner !== undefined) values.owner = input.owner + if (input.description !== undefined) values.description = input.description + if (input.metadata !== undefined) values.metadata = input.metadata + + Database.use((db) => + db.update(TeamTaskTable).set(values).where(eq(TeamTaskTable.id, id)).run(), + ) + const updated = get(id) + if (updated) { + Database.effect(() => Bus.publish(Event.Updated, { task: updated })) + } + return updated + } + + export function get(id: TeamTaskID): Info | undefined { + const row = Database.use((db) => + db.select().from(TeamTaskTable).where(eq(TeamTaskTable.id, id)).get(), + ) + if (!row) return undefined + return toInfo(row) + } + + export function list(teamID: TeamID): Info[] { + const rows = Database.use((db) => + db.select().from(TeamTaskTable).where(eq(TeamTaskTable.team_id, teamID)).all(), + ) + return rows.map(toInfo) + } +} diff --git a/packages/opencode/src/team/team.sql.ts b/packages/opencode/src/team/team.sql.ts new file mode 100644 index 000000000000..fe146bfb590e --- /dev/null +++ b/packages/opencode/src/team/team.sql.ts @@ -0,0 +1,85 @@ +import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core" +import { SessionTable } from "../session/session.sql" +import { ProjectTable } from "../project/project.sql" +import { Timestamps } from "../storage/schema.sql" +import type { TeamID, TeamTaskID, MemoryID } from "./schema" +import type { SessionID } from "../session/schema" +import type { ProjectID } from "../project/schema" + +export const TeamTable = sqliteTable( + "team", + { + id: text().$type().primaryKey(), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + name: text().notNull(), + status: text({ enum: ["active", "disbanded"] }) + .notNull() + .default("active"), + ...Timestamps, + }, + (table) => [index("team_session_idx").on(table.session_id)], +) + +export const TeamMemberTable = sqliteTable( + "team_member", + { + id: integer().primaryKey({ autoIncrement: true }), + team_id: text() + .$type() + .notNull() + .references(() => TeamTable.id, { onDelete: "cascade" }), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + agent: text().notNull(), + role: text({ enum: ["lead", "member"] }).notNull(), + status: text({ enum: ["active", "completed", "failed", "cancelled"] }) + .notNull() + .default("active"), + ...Timestamps, + }, + (table) => [ + index("team_member_team_idx").on(table.team_id), + index("team_member_session_idx").on(table.session_id), + uniqueIndex("team_member_team_agent_idx").on(table.team_id, table.agent), + ], +) + +export const TeamTaskTable = sqliteTable( + "team_task", + { + id: text().$type().primaryKey(), + team_id: text() + .$type() + .notNull() + .references(() => TeamTable.id, { onDelete: "cascade" }), + subject: text().notNull(), + description: text(), + owner: text(), + status: text({ enum: ["pending", "in_progress", "completed", "failed"] }) + .notNull() + .default("pending"), + metadata: text({ mode: "json" }).$type>(), + ...Timestamps, + }, + (table) => [index("team_task_team_idx").on(table.team_id)], +) + +export const AgentMemoryTable = sqliteTable( + "agent_memory", + { + id: text().$type().primaryKey(), + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + agent: text().notNull(), + content: text().notNull(), + ...Timestamps, + }, + (table) => [uniqueIndex("agent_memory_project_agent_idx").on(table.project_id, table.agent)], +) diff --git a/packages/opencode/src/tool/agent-memory.ts b/packages/opencode/src/tool/agent-memory.ts new file mode 100644 index 000000000000..5684a8588d77 --- /dev/null +++ b/packages/opencode/src/tool/agent-memory.ts @@ -0,0 +1,65 @@ +import z from "zod" +import { Tool } from "./tool" +import { AgentMemory } from "../agent/memory" +import DESCRIPTION from "./agent-memory.txt" + +export const AgentMemoryTool = Tool.define("agent_memory", { + description: DESCRIPTION, + parameters: z.object({ + operation: z + .enum(["read", "write", "append"]) + .describe("The operation to perform"), + content: z + .string() + .optional() + .describe("Content to write or append (required for write/append)"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "agent_memory", + patterns: [params.operation], + always: ["*"], + metadata: {}, + }) + + if (params.operation === "read") { + const memory = AgentMemory.read(ctx.agent) + if (!memory) { + return { + title: "No memory found", + output: `No stored memory found for agent "${ctx.agent}" in this project. This is a fresh start.`, + metadata: { agent: ctx.agent }, + } + } + return { + title: "Memory loaded", + output: memory.content, + metadata: { + agent: ctx.agent, + updated: memory.time.updated, + }, + } + } + + if (params.operation === "write") { + if (!params.content) + throw new Error("content is required for write operation") + AgentMemory.write(ctx.agent, params.content) + return { + title: "Memory updated", + output: `Memory for agent "${ctx.agent}" has been updated.`, + metadata: { agent: ctx.agent }, + } + } + + // append + if (!params.content) + throw new Error("content is required for append operation") + AgentMemory.append(ctx.agent, params.content) + return { + title: "Memory appended", + output: `New content appended to memory for agent "${ctx.agent}".`, + metadata: { agent: ctx.agent }, + } + }, +}) diff --git a/packages/opencode/src/tool/agent-memory.txt b/packages/opencode/src/tool/agent-memory.txt new file mode 100644 index 000000000000..ea73f0c3484f --- /dev/null +++ b/packages/opencode/src/tool/agent-memory.txt @@ -0,0 +1,11 @@ +Read or write persistent per-agent memory for this project. + +Agent memory persists across sessions — use it to store learned patterns, conventions, recurring issues, and domain knowledge specific to this project. + +Operations: +- **read**: Read your stored memory for this project. Call this at the start of a task to recall past learnings. +- **write**: Replace your entire memory content. Use this to store a curated set of learnings. +- **append**: Add new content to existing memory without replacing it. Use this to accumulate learnings incrementally. + +Memory is scoped to (agent, project) — each agent has independent memory per project. +Keep memory concise and actionable. Focus on patterns and conventions, not raw data. \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6381fcfbc0c2..8c4a4ff1c32b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -12,6 +12,11 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" +import { TeamCreateTool } from "./team-create" +import { TeamDeleteTool } from "./team-delete" +import { TeamTaskTool } from "./team-task" +import { SendMessageTool } from "./send-message" +import { AgentMemoryTool } from "./agent-memory" import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Config } from "../config/config" @@ -129,6 +134,11 @@ export namespace ToolRegistry { CodeSearchTool, SkillTool, ApplyPatchTool, + TeamCreateTool, + TeamDeleteTool, + TeamTaskTool, + SendMessageTool, + AgentMemoryTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(cfg.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), diff --git a/packages/opencode/src/tool/send-message.ts b/packages/opencode/src/tool/send-message.ts new file mode 100644 index 000000000000..39478e3f9d0f --- /dev/null +++ b/packages/opencode/src/tool/send-message.ts @@ -0,0 +1,63 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import { TeamID } from "../team/schema" +import { SessionInject } from "../session/inject" +import DESCRIPTION from "./send-message.txt" + +export const SendMessageTool = Tool.define("send_message", { + description: DESCRIPTION, + parameters: z.object({ + team_id: z.string().describe("The team ID"), + recipient: z.string().default("lead").describe("Agent name to send to, or 'lead' for the team lead"), + content: z.string().describe("The message content"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "send_message", + patterns: [params.recipient], + always: ["*"], + metadata: {}, + }) + + const teamID = TeamID.make(params.team_id) + const team = Team.get(teamID) + if (!team) throw new Error(`Team not found: ${params.team_id}`) + if (team.status === "disbanded") throw new Error(`Team has been disbanded: ${params.team_id}`) + + // Validate sender is a member of this team + const members = Team.members(teamID) + const sender = members.find((m) => m.sessionID === ctx.sessionID) + if (!sender) throw new Error(`You are not a member of team "${team.name}"`) + + // Resolve recipient session + let target: Team.Member | undefined + if (params.recipient === "lead") { + target = Team.leadSession(teamID) + } else { + target = Team.findMemberSession({ + teamID, + agent: params.recipient, + }) + } + + if (!target) throw new Error(`Recipient "${params.recipient}" not found in team "${team.name}"`) + + await SessionInject.send({ + sessionID: target.sessionID, + from: ctx.agent, + fromSessionID: ctx.sessionID, + content: params.content, + teamID: params.team_id, + }) + + return { + title: `Message sent to ${params.recipient}`, + output: `Message delivered to @${params.recipient} in team "${team.name}".`, + metadata: { + teamID: params.team_id, + recipient: params.recipient, + }, + } + }, +}) diff --git a/packages/opencode/src/tool/send-message.txt b/packages/opencode/src/tool/send-message.txt new file mode 100644 index 000000000000..b7d600308193 --- /dev/null +++ b/packages/opencode/src/tool/send-message.txt @@ -0,0 +1,13 @@ +Send a message to another agent in your team. + +This enables inter-agent communication within a team: +- **Member → Lead**: Send findings, status updates, or responses to the team lead +- **Lead → Member**: Route challenges, requests for elaboration, or cross-review findings to specific agents + +Usage: +- You must be part of a team to use this tool +- Specify the team_id and either a specific agent name or "lead" as the recipient +- The message content should be clear and actionable +- Messages are delivered asynchronously — the recipient processes them on their next turn + +When receiving messages, respond substantively. For cross-review challenges, provide evidence from the spec or codebase. \ No newline at end of file diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f2a83507fa6f..4d6f56b2c366 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -11,6 +11,16 @@ import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { Permission } from "@/permission" +import { Team } from "../team" +import { TeamID } from "../team/schema" +import { Instance } from "@/project/instance" +import { SessionInject } from "@/session/inject" +import { Log } from "@/util/log" + +const log = Log.create({ service: "tool.task" }) + +/** Tracks active background agent count per instance for concurrency limiting */ +let running = 0 const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -23,6 +33,18 @@ const parameters = z.object({ ) .optional(), command: z.string().describe("The command that triggered this task").optional(), + background: z + .boolean() + .describe( + "If true, the agent runs in the background and returns immediately with the task_id. Use this with team_id for parallel multi-agent workflows.", + ) + .optional(), + team_id: z + .string() + .describe( + "The team ID to register this agent as a member. Required when background is true for team-based workflows.", + ) + .optional(), }) export const TaskTool = Tool.define("task", async (ctx) => { @@ -65,6 +87,37 @@ export const TaskTool = Tool.define("task", async (ctx) => { const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") + // Build permission overrides for child session + const childPermissions = [ + { + permission: "todowrite" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ...(hasTaskPermission + ? [] + : [ + { + permission: "task" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(config.experimental?.primary_tools?.map((t) => ({ + pattern: "*" as const, + action: "allow" as const, + permission: t, + })) ?? []), + ] + + // For team members, grant team communication permissions + if (params.team_id) { + childPermissions.push( + { permission: "send_message", pattern: "*", action: "allow" }, + { permission: "team_task", pattern: "*", action: "allow" }, + ) + } + const session = await iife(async () => { if (params.task_id) { const found = await Session.get(SessionID.make(params.task_id)).catch(() => {}) @@ -74,29 +127,20 @@ export const TaskTool = Tool.define("task", async (ctx) => { return await Session.create({ parentID: ctx.sessionID, title: params.description + ` (@${agent.name} subagent)`, - permission: [ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - ...(hasTaskPermission - ? [] - : [ - { - permission: "task" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(config.experimental?.primary_tools?.map((t) => ({ - pattern: "*", - action: "allow" as const, - permission: t, - })) ?? []), - ], + permission: childPermissions, }) }) + + // Register as team member if team_id provided + if (params.team_id) { + const teamID = TeamID.make(params.team_id) + Team.addMember({ + teamID, + sessionID: session.id, + agent: agent.name, + }) + } + const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") @@ -110,19 +154,15 @@ export const TaskTool = Tool.define("task", async (ctx) => { metadata: { sessionId: session.id, model, + background: params.background, + teamID: params.team_id, }, }) const messageID = MessageID.ascending() - - function cancel() { - SessionPrompt.cancel(session.id) - } - ctx.abort.addEventListener("abort", cancel) - using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) - const result = await SessionPrompt.prompt({ + const promptInput = { messageID, sessionID: session.id, model: { @@ -136,7 +176,86 @@ export const TaskTool = Tool.define("task", async (ctx) => { ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), }, parts: promptParts, - }) + } + + // Background execution — launch and return immediately + if (params.background) { + const limit = config.team?.max_agents ?? 10 + if (running >= limit) { + throw new Error( + `Max concurrent background agents reached (${limit}). Wait for existing agents to complete or increase team.max_agents in config.`, + ) + } + + running++ + const teamID = params.team_id ? TeamID.make(params.team_id) : undefined + const bound = Instance.bind(() => { + SessionPrompt.prompt(promptInput) + .catch(async (err) => { + log.error("background agent failed", { + sessionID: session.id, + agent: agent.name, + error: err, + }) + if (teamID) { + Team.failMember({ + teamID, + sessionID: session.id, + agent: agent.name, + }) + // Notify the lead about the failure + const lead = Team.leadSession(teamID) + if (lead) { + await SessionInject.send({ + sessionID: lead.sessionID, + from: agent.name, + fromSessionID: session.id, + content: `[AGENT FAILURE] @${agent.name} crashed with error: ${err instanceof Error ? err.message : String(err)}`, + teamID: params.team_id, + }).catch((e) => log.error("failed to notify lead of agent failure", { error: e })) + } + } + }) + .finally(() => { + running-- + }) + }) + bound() + + // Wire up abort to cancel the child session + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + + return { + title: `${params.description} (background)`, + metadata: { + sessionId: session.id, + model, + background: true, + teamID: params.team_id, + }, + output: [ + `task_id: ${session.id} (background agent launched)`, + "", + `Agent @${agent.name} is now running in the background.`, + params.team_id ? `Registered as member of team ${params.team_id}.` : "", + "The agent will send messages via SendMessage when it has findings.", + ] + .filter(Boolean) + .join("\n"), + } + } + + // Synchronous execution — existing behavior + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) + + const result = await SessionPrompt.prompt(promptInput) const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 585cce8f9d0a..8231bbf5bddc 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -23,6 +23,11 @@ Usage notes: 5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). 6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +Team workflows: +7. For multi-agent team workflows, first create a team with team_create, then spawn agents with `background: true` and `team_id` set. Background agents run concurrently and communicate via send_message. +8. When `background: true`, the task returns immediately with the task_id. The agent runs in the background and sends results via the send_message tool. +9. Use `team_id` to register the agent as a team member. Team members can use send_message and team_task tools to coordinate. + Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above): diff --git a/packages/opencode/src/tool/team-create.ts b/packages/opencode/src/tool/team-create.ts new file mode 100644 index 000000000000..6d4c6bcc3a5c --- /dev/null +++ b/packages/opencode/src/tool/team-create.ts @@ -0,0 +1,39 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import DESCRIPTION from "./team-create.txt" + +export const TeamCreateTool = Tool.define("team_create", { + description: DESCRIPTION, + parameters: z.object({ + name: z.string().describe("A descriptive name for the team (e.g., 'spec-review-auth-service')"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "team_create", + patterns: [params.name], + always: ["*"], + metadata: { name: params.name }, + }) + + const team = Team.create({ + name: params.name, + sessionID: ctx.sessionID, + agent: ctx.agent, + }) + + return { + title: `Team created: ${params.name}`, + output: JSON.stringify( + { + team_id: team.id, + name: team.name, + status: team.status, + }, + null, + 2, + ), + metadata: { team }, + } + }, +}) diff --git a/packages/opencode/src/tool/team-create.txt b/packages/opencode/src/tool/team-create.txt new file mode 100644 index 000000000000..bd27121911d4 --- /dev/null +++ b/packages/opencode/src/tool/team-create.txt @@ -0,0 +1,11 @@ +Create a named agent team for coordinating multiple specialist sub-agents. + +Teams enable multi-agent workflows where a lead orchestrator spawns specialist agents that work in parallel and communicate findings back through the lead. + +Usage: +- Create a team before spawning background agents that need to coordinate +- The team name should be descriptive (e.g., "spec-review-auth-service") +- After creating a team, use the Task tool with `background: true` and `team_id` to spawn team members +- Use TeamDelete to disband the team when all work is complete + +Returns the team ID which must be passed to subsequent team operations (TaskCreate, SendMessage, TeamDelete). \ No newline at end of file diff --git a/packages/opencode/src/tool/team-delete.ts b/packages/opencode/src/tool/team-delete.ts new file mode 100644 index 000000000000..da71c844db9d --- /dev/null +++ b/packages/opencode/src/tool/team-delete.ts @@ -0,0 +1,33 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import { TeamID } from "../team/schema" +import DESCRIPTION from "./team-delete.txt" + +export const TeamDeleteTool = Tool.define("team_delete", { + description: DESCRIPTION, + parameters: z.object({ + team_id: z.string().describe("The team ID returned by TeamCreate"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "team_delete", + patterns: [params.team_id], + always: ["*"], + metadata: {}, + }) + + const id = TeamID.make(params.team_id) + const team = Team.get(id) + if (!team) throw new Error(`Team not found: ${params.team_id}`) + if (team.status === "disbanded") throw new Error(`Team already disbanded: ${params.team_id}`) + + Team.disband(id) + + return { + title: `Team disbanded: ${team.name}`, + output: `Team "${team.name}" (${id}) has been disbanded. All active members marked as cancelled.`, + metadata: { teamID: id }, + } + }, +}) diff --git a/packages/opencode/src/tool/team-delete.txt b/packages/opencode/src/tool/team-delete.txt new file mode 100644 index 000000000000..4001fd7eef33 --- /dev/null +++ b/packages/opencode/src/tool/team-delete.txt @@ -0,0 +1,5 @@ +Disband a team and mark all active members as completed. + +Call this after all team work is done. Agents persist any learnings via their memory — they do not need to stay alive for context retention. + +This does NOT terminate running background agents immediately — it marks the team as disbanded and updates member statuses. Background agents will complete their current work naturally. \ No newline at end of file diff --git a/packages/opencode/src/tool/team-task.ts b/packages/opencode/src/tool/team-task.ts new file mode 100644 index 000000000000..472a8eac7b31 --- /dev/null +++ b/packages/opencode/src/tool/team-task.ts @@ -0,0 +1,87 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import { TeamTask } from "../team/task" +import { TeamID, TeamTaskID } from "../team/schema" +import DESCRIPTION from "./team-task.txt" + +const params = z.object({ + operation: z.enum(["create", "update", "get", "list"]).describe("The operation to perform"), + team_id: z.string().describe("The team ID"), + task_id: z.string().optional().describe("The task ID (required for get/update)"), + subject: z.string().optional().describe("Task subject (required for create)"), + description: z.string().optional().describe("Task description"), + owner: z.string().optional().describe("Agent name that owns this task"), + status: z.enum(["pending", "in_progress", "completed", "failed"]).optional().describe("Task status (for update)"), + metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary metadata"), +}) + +export const TeamTaskTool = Tool.define("team_task", { + description: DESCRIPTION, + parameters: params, + async execute(args, ctx) { + await ctx.ask({ + permission: "team_task", + patterns: [args.operation], + always: ["*"], + metadata: {}, + }) + + const teamID = TeamID.make(args.team_id) + const team = Team.get(teamID) + if (!team) throw new Error(`Team not found: ${args.team_id}`) + if (team.status === "disbanded") throw new Error(`Team has been disbanded: ${args.team_id}`) + + if (args.operation === "create") { + if (!args.subject) throw new Error("subject is required for create operation") + const task = TeamTask.create({ + teamID, + subject: args.subject, + description: args.description, + owner: args.owner, + metadata: args.metadata, + }) + return { + title: `Task created: ${task.subject}`, + output: JSON.stringify(task, null, 2), + metadata: { task }, + } + } + + if (args.operation === "update") { + if (!args.task_id) throw new Error("task_id is required for update operation") + const id = TeamTaskID.make(args.task_id) + const task = TeamTask.update(id, { + status: args.status, + owner: args.owner, + description: args.description, + metadata: args.metadata, + }) + if (!task) throw new Error(`Task not found: ${args.task_id}`) + return { + title: `Task updated: ${task.subject}`, + output: JSON.stringify(task, null, 2), + metadata: { task }, + } + } + + if (args.operation === "get") { + if (!args.task_id) throw new Error("task_id is required for get operation") + const task = TeamTask.get(TeamTaskID.make(args.task_id)) + if (!task) throw new Error(`Task not found: ${args.task_id}`) + return { + title: `Task: ${task.subject}`, + output: JSON.stringify(task, null, 2), + metadata: { task }, + } + } + + // list + const tasks = TeamTask.list(teamID) + return { + title: `${tasks.length} tasks`, + output: JSON.stringify(tasks, null, 2), + metadata: { tasks }, + } + }, +}) diff --git a/packages/opencode/src/tool/team-task.txt b/packages/opencode/src/tool/team-task.txt new file mode 100644 index 000000000000..e6256d1a6d2b --- /dev/null +++ b/packages/opencode/src/tool/team-task.txt @@ -0,0 +1,11 @@ +Manage tasks on a team's shared task board. Team tasks are visible to all team members and provide coordination state. + +Operations: +- **create**: Create a new task on the team board. Use this to assign work items to specialist agents. +- **update**: Update a task's status, owner, or description. Agents should update their tasks to "in_progress" when starting and "completed" when done. +- **get**: Read a specific task by ID. +- **list**: List all tasks for a team. + +Task statuses: pending, in_progress, completed, failed. + +Tasks are distinct from the Todo tool — Todos track an individual agent's work plan, while Team Tasks coordinate across multiple agents in a team. \ No newline at end of file diff --git a/teams-agents-feature.md b/teams-agents-feature.md new file mode 100644 index 000000000000..763cdf7a7e40 --- /dev/null +++ b/teams-agents-feature.md @@ -0,0 +1,198 @@ +# Teams & Agents Feature — Design Spec + +## Summary + +Add multi-agent team coordination primitives to opencode, enabling workflows where a lead agent orchestrates multiple specialist sub-agents that work in parallel, communicate findings, and produce synthesized results. + +This unlocks the same class of workflows that Claude Code supports via TeamCreate/TaskCreate/SendMessage primitives, but designed natively for opencode's architecture (Sessions, MessageV2, Bus, Instance state, Drizzle DB). + +## Problem + +opencode's current `TaskTool` only supports synchronous sub-agent execution: spawn a child session, block until it completes, return the result. This is insufficient for: + +1. **Parallel specialist reviews** — spawning 8 agents simultaneously, each reviewing a spec from a different angle +2. **Inter-agent communication** — agents sending findings to a lead, or challenging each other's findings +3. **Persistent agent memory** — agents learning project-specific patterns across sessions +4. **Coordinated task boards** — shared state visible to all team members + +## Solution + +### New Subsystems + +#### 1. Team Management (`src/team/`) + +A **Team** is a named group of sessions with a lead and members. DB-backed via `team` and `team_member` tables. + +- `Team.create({ name, sessionID })` — creates team and registers lead in a single transaction +- `Team.disband(id)` — marks team disbanded, all active members cancelled, all pending/in-progress tasks failed +- `Team.addMember({ teamID, sessionID, agent })` — registers a member (rejects disbanded teams; auto-disambiguates duplicate agent names with `-N` suffix) +- `Team.findMemberSession({ teamID, agent })` — resolve agent → session +- `Team.leadSession(teamID)` — get the lead's session +- `Team.completeMember(sessionID)` — marks a member as completed on normal exit +- `Team.failMember({ teamID, sessionID, agent })` — marks member as failed, cascades to owned in-progress tasks +- `Team.disbandBySession(sessionID)` — auto-disbands all active teams owned by a session +- `Team.reconcile()` — marks all active teams as disbanded on server startup (stale state cleanup) + +**Team status values:** `active`, `disbanded`. + +**Member status values:** `active`, `completed`, `failed`, `cancelled`. + +Member status transitions: + +- `active` → `completed` — member exits normally (timeout, no more work) +- `active` → `failed` — background agent crashes +- `active` → `cancelled` — team is disbanded while member is still active + +**Agent name uniqueness:** A unique index enforces `UNIQUE(team_id, agent)` on `team_member`. When `addMember` is called with a duplicate agent name, it auto-disambiguates by appending a numeric suffix (e.g., `general-2`, `general-3`). This supports workflows that spawn multiple agents of the same type. + +#### 2. Team Task Board (`src/team/task.ts`) + +Shared task state scoped to a team. Any member can read/update. Tools validate the team exists and is active before operating. + +- `TeamTask.create({ teamID, subject, owner })` — create a task +- `TeamTask.update(id, { status, owner })` — update status +- `TeamTask.list(teamID)` — list all team tasks + +Statuses: `pending`, `in_progress`, `completed`, `failed`. + +#### 3. Message Injection (`src/session/inject.ts`) + +Cross-session message passing. A synthetic user message is written into a target session's DB, and the prompt loop picks it up on its next iteration. + +- `SessionInject.send({ sessionID, from, fromSessionID, content })` — inject message +- Publishes `session.message.injected` event on the Bus +- Messages are tagged with `injected: { from, fromSessionID, teamID }` on the User message schema +- The target session's agent type is resolved dynamically from its last user message (not hardcoded) + +#### 4. Background Agent Execution (modified `src/tool/task.ts`) + +Extended TaskTool with two new parameters: + +- `background: boolean` — when true, launches the child session in a detached async context via `Instance.bind()` and returns immediately with the `task_id` +- `team_id: string` — registers the child session as a team member; grants `send_message` and `team_task` permissions to the child + +**Concurrency limit:** A process-wide counter limits the number of concurrent background agents. Default: 10, configurable via `team.max_agents`. Throws a descriptive error when the limit is exceeded. + +**Failure handling:** When a background agent crashes: + +1. The member is marked `failed` via `Team.failMember()` +2. The agent's owned in-progress tasks are cascaded to `failed` +3. A `[AGENT FAILURE]` notification is injected into the lead session via `SessionInject.send()` + +**Sender validation:** The `send_message` tool validates that the sending session is a member of the target team before allowing message injection. + +#### 5. Agent Memory (`src/agent/memory.ts`) + +Persistent per-agent, per-project memory stored in `agent_memory` table with a unique index on `(project_id, agent)`. + +- `AgentMemory.read(agent)` — read stored memory +- `AgentMemory.write(agent, content)` — replace memory (capped at 100KB; truncated with warning if exceeded) +- `AgentMemory.append(agent, content)` — append to memory (same 100KB cap applied after append) + +Injected into the system prompt when `memory: local` is set on the agent definition. + +#### 6. Prompt Loop Changes (`src/session/prompt.ts`) + +When a background team member's loop would normally exit (assistant finished, no pending user message), it now: + +1. Checks if the session is registered as an active team member +2. If yes, waits for an injected message (via typed `Bus.subscribe` on `SessionInject.Event.MessageInjected`) +3. To avoid a race where a message is injected before the subscription is established, the wait also checks the DB for pending user messages immediately after subscribing +4. If a message arrives, continues the loop +5. If timeout (configurable via `team.member_timeout`, default 5 minutes) or abort, marks the member as `completed` and exits normally + +### Lifecycle & Cleanup + +- **Session cancellation** — when a lead session is cancelled, `Team.disbandBySession()` auto-disbands all its active teams. Members are marked `cancelled`, pending/in-progress tasks are marked `failed`. +- **Server restart** — `Team.reconcile()` runs on startup and marks all active teams as disbanded (stale state from a previous process). +- **Background member normal exit** — member's `team_member` status is updated to `completed` before exiting. The lead can observe this via `Team.members()`. +- **Background member crash** — member is marked `failed`, tasks cascaded, lead is notified via injected message. +- **Explicit disband** — `team_delete` tool sets all active members to `cancelled`. Members waiting for messages will time out after `member_timeout` and exit (the disband event does not immediately wake waiting members; this is accepted behavior with a bounded worst-case delay). + +### New Tools + +| Tool | Permission | Description | +| -------------- | -------------- | ----------------------------------------- | +| `team_create` | `team_create` | Create a named agent team | +| `team_delete` | `team_delete` | Disband a team | +| `team_task` | `team_task` | CRUD operations on the team task board | +| `send_message` | `send_message` | Send a message to another team member | +| `agent_memory` | `agent_memory` | Read/write/append persistent agent memory | + +### DB Schema (Migration) + +Four new tables: + +- `team` — id, session_id (lead), name, status (`active` | `disbanded`), timestamps +- `team_member` — team_id, session_id, agent, role (`lead` | `member`), status (`active` | `completed` | `failed` | `cancelled`), timestamps. Unique index on `(team_id, agent)`. +- `team_task` — id, team_id, subject, description, owner, status (`pending` | `in_progress` | `completed` | `failed`), metadata (JSON), timestamps +- `agent_memory` — id, project_id, agent, content (capped at 100KB), timestamps. Unique index on `(project_id, agent)`. + +### Config Schema Changes + +- `Agent.Info` gains `memory: "none" | "local"` field +- `Config.Agent` gains `memory` in schema and `knownKeys` set +- `Config` gains optional `team` section: + - `max_agents: number` (default 10) — process-wide max concurrent background agents + - `member_timeout: number` (default 300000ms / 5 minutes) — how long a team member waits for injected messages before exiting + +### Server Routes + +New `/team` route group: list teams by session, get team, get members, create team, disband team, list/create tasks. Also `/team/active` for TUI bootstrap (returns all active teams with their members). + +## Design Decisions + +1. **Async message passing via DB writes** — fits opencode's existing pattern where the prompt loop re-reads messages from DB each iteration. No in-memory queues needed. + +2. **Teams scoped to lead session** — multiple concurrent reviews don't interfere. Lead session owns the lifecycle. + +3. **Agent memory in SQLite** — consistent with all other opencode storage. Unique index on (project_id, agent) ensures one memory entry per agent per project. Content capped at 100KB to prevent context window overflow. + +4. **Background execution via `Instance.bind()`** — preserves ALS context (project, directory, worktree) for the child session. No worker threads needed. + +5. **Configurable wait timeout for team members** — prevents zombie sessions. Default 5 minutes, configurable via `team.member_timeout`. If no message arrives, the member marks itself `completed` and exits. + +6. **New tools are always registered** — permission-gated at execution time, not at registration. The explore agent's `"*": deny` naturally blocks all team tools. + +7. **Agent name auto-disambiguation** — unique index on `(team_id, agent)` enforces uniqueness. `addMember` automatically appends a numeric suffix (`-2`, `-3`, ...) when a duplicate agent type is added to the same team. + +8. **Process-wide concurrency limit** — the `max_agents` counter is module-level (not per-instance). This is intentional for resource management — the limit governs total system load regardless of how many project instances are open. + +9. **Failure notification to lead** — when a background agent crashes, the lead receives an injected `[AGENT FAILURE]` message so it can adapt its workflow. Member status and owned tasks are also cascaded to `failed`. + +## Files Changed + +### New (15 files) + +- `src/team/schema.ts` — TeamID, TeamTaskID, MemoryID branded types +- `src/team/team.sql.ts` — Drizzle table definitions +- `src/team/index.ts` — Team namespace +- `src/team/task.ts` — TeamTask namespace +- `src/agent/memory.ts` — AgentMemory namespace +- `src/session/inject.ts` — SessionInject namespace +- `src/tool/team-create.{ts,txt}` — TeamCreate tool +- `src/tool/team-delete.{ts,txt}` — TeamDelete tool +- `src/tool/team-task.{ts,txt}` — TeamTask tool +- `src/tool/send-message.{ts,txt}` — SendMessage tool +- `src/tool/agent-memory.{ts,txt}` — AgentMemory tool +- `src/server/routes/team.ts` — Team API routes +- `migration/20260321160000_add_teams/migration.sql` — DB migration + +### Modified (10 files) + +- `src/id/id.ts` — 3 new prefixes +- `src/tool/task.ts` — background + team_id params, concurrency limit, failure notification +- `src/tool/task.txt` — team workflow docs +- `src/tool/registry.ts` — 5 new tools registered +- `src/agent/agent.ts` — memory field +- `src/config/config.ts` — memory in Agent schema, `team` config section +- `src/session/message-v2.ts` — injected field on User +- `src/session/prompt.ts` — team-aware loop exit + wait, member completion on exit, race-safe injection wait +- `src/session/system.ts` — memory injection into system prompt +- `src/server/server.ts` — TeamRoutes registered, startup reconciliation + +## Risks & Open Questions + +1. **No rate limiting on SendMessage** — an agent could spam messages. Mitigated by the natural LLM turn structure (each message requires an LLM turn to produce). +2. **Disband does not immediately wake waiting members** — members in `waitForInjection` only listen for `session.message.injected`, not `team.disbanded`. They will idle until `member_timeout` expires. This is accepted behavior — the bounded worst case is the configured timeout (default 5 min). +3. **Migration backward compat** — additive only (new tables), safe to alternate between versions. From 12f289607435572f67c990ba58a3c2a57c0f2f2e Mon Sep 17 00:00:00 2001 From: Marcus Pamelia Date: Mon, 23 Mar 2026 10:02:04 +0100 Subject: [PATCH 2/7] Address code review feedback (turn 1) - Fix async Promise executor in waitForInjection (blocker: silent error swallowing) - Fix running counter leak with try/catch around Instance.bind (blocker) - Fix completeMember TOCTOU race using RETURNING on UPDATE - Fix memory truncation to use byte-based slicing for UTF-8 safety - Add error logging for team reconciliation and disband failures --- packages/opencode/src/agent/memory.ts | 2 +- packages/opencode/src/server/server.ts | 4 +- packages/opencode/src/session/prompt.ts | 44 +++++++++--------- packages/opencode/src/team/index.ts | 16 ++----- packages/opencode/src/tool/task.ts | 61 +++++++++++++------------ 5 files changed, 65 insertions(+), 62 deletions(-) diff --git a/packages/opencode/src/agent/memory.ts b/packages/opencode/src/agent/memory.ts index 522c44d6e2c1..693608738779 100644 --- a/packages/opencode/src/agent/memory.ts +++ b/packages/opencode/src/agent/memory.ts @@ -64,7 +64,7 @@ export namespace AgentMemory { export function write(agent: string, content: string) { if (Buffer.byteLength(content, "utf8") > MAX_SIZE) { - content = content.slice(0, MAX_SIZE) + content = Buffer.from(content, "utf8").subarray(0, MAX_SIZE).toString("utf8") log.warn("memory truncated to 100KB", { agent }) } const pid = Instance.project.id diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 676c64390543..5d31ddac8164 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -599,7 +599,9 @@ export namespace Server { if (!server) throw new Error(`Failed to start server on port ${opts.port}`) // Reconcile stale teams from previous sessions - import("../team").then(({ Team }) => Team.reconcile()).catch(() => {}) + import("../team") + .then(({ Team }) => Team.reconcile()) + .catch((e) => log.error("team reconciliation failed", { error: e })) const shouldPublishMDNS = opts.mdns && diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e698d3253ac5..df6fd42225b4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -298,18 +298,28 @@ export namespace SessionPrompt { * Wait for an injected message to arrive in this session. * Returns true if a message was injected, false if aborted or timed out. */ - function waitForInjection(sessionID: SessionID, abort: AbortSignal): Promise { - return new Promise(async (resolve) => { - const config = await Config.get() - const duration = config.team?.member_timeout ?? 300_000 + async function waitForInjection(sessionID: SessionID, abort: AbortSignal): Promise { + const config = await Config.get() + const duration = config.team?.member_timeout ?? 300_000 + const { SessionInject } = await import("./inject") + + // Check for messages already injected before we start waiting (race fix) + const msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const last = msgs.findLast((m) => m.info.role === "assistant") + const pending = msgs.find((m) => m.info.role === "user" && last && m.info.id > last.info.id) + if (pending) return true + + return new Promise((resolve) => { + let resolved = false const timeout = setTimeout(() => { - cleanup() - resolve(false) + if (!resolved) { + resolved = true + cleanup() + resolve(false) + } }, duration) - const { SessionInject } = await import("./inject") - let resolved = false const unsub = Bus.subscribe(SessionInject.Event.MessageInjected, (event) => { if (event.properties.sessionID === sessionID && !resolved) { resolved = true @@ -318,20 +328,12 @@ export namespace SessionPrompt { } }) - // Check for messages injected before subscription was established (race fix) - const msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const last = msgs.findLast((m) => m.info.role === "assistant") - const pending = msgs.find((m) => m.info.role === "user" && last && m.info.id > last.info.id) - if (pending && !resolved) { - resolved = true - cleanup() - resolve(true) - return - } - function onAbort() { - cleanup() - resolve(false) + if (!resolved) { + resolved = true + cleanup() + resolve(false) + } } abort.addEventListener("abort", onAbort) diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts index cc84c5fc0ebc..f67398d87e27 100644 --- a/packages/opencode/src/team/index.ts +++ b/packages/opencode/src/team/index.ts @@ -235,22 +235,16 @@ export namespace Team { /** Mark a member as completed (normal exit) */ export function completeMember(sessionID: SessionID) { const now = Date.now() - const row = Database.use((db) => - db - .select() - .from(TeamMemberTable) - .where(and(eq(TeamMemberTable.session_id, sessionID), eq(TeamMemberTable.status, "active"))) - .get(), - ) - if (!row) return - Database.use((db) => + const updated = Database.use((db) => db .update(TeamMemberTable) .set({ status: "completed", time_updated: now }) .where(and(eq(TeamMemberTable.session_id, sessionID), eq(TeamMemberTable.status, "active"))) - .run(), + .returning() + .get(), ) - const member = toMember({ ...row, status: "completed" }) + if (!updated) return + const member = toMember(updated) Database.effect(() => Bus.publish(Event.MemberUpdated, { member })) } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 4d6f56b2c366..5ea18c7fd581 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -189,38 +189,43 @@ export const TaskTool = Tool.define("task", async (ctx) => { running++ const teamID = params.team_id ? TeamID.make(params.team_id) : undefined - const bound = Instance.bind(() => { - SessionPrompt.prompt(promptInput) - .catch(async (err) => { - log.error("background agent failed", { - sessionID: session.id, - agent: agent.name, - error: err, - }) - if (teamID) { - Team.failMember({ - teamID, + try { + const bound = Instance.bind(() => { + SessionPrompt.prompt(promptInput) + .catch(async (err) => { + log.error("background agent failed", { sessionID: session.id, agent: agent.name, + error: err, }) - // Notify the lead about the failure - const lead = Team.leadSession(teamID) - if (lead) { - await SessionInject.send({ - sessionID: lead.sessionID, - from: agent.name, - fromSessionID: session.id, - content: `[AGENT FAILURE] @${agent.name} crashed with error: ${err instanceof Error ? err.message : String(err)}`, - teamID: params.team_id, - }).catch((e) => log.error("failed to notify lead of agent failure", { error: e })) + if (teamID) { + Team.failMember({ + teamID, + sessionID: session.id, + agent: agent.name, + }) + // Notify the lead about the failure + const lead = Team.leadSession(teamID) + if (lead) { + await SessionInject.send({ + sessionID: lead.sessionID, + from: agent.name, + fromSessionID: session.id, + content: `[AGENT FAILURE] @${agent.name} crashed with error: ${err instanceof Error ? err.message : String(err)}`, + teamID: params.team_id, + }).catch((e) => log.error("failed to notify lead of agent failure", { error: e })) + } } - } - }) - .finally(() => { - running-- - }) - }) - bound() + }) + .finally(() => { + running-- + }) + }) + bound() + } catch (e) { + running-- + throw e + } // Wire up abort to cancel the child session function cancel() { From 93f94e16466521af79049a69823812b6e3860176 Mon Sep 17 00:00:00 2001 From: Marcus Pamelia Date: Mon, 23 Mar 2026 10:07:33 +0100 Subject: [PATCH 3/7] Address code review feedback (turn 2) - Fix double-decrement bug: move Instance.bind outside try/catch, only catch bound() throw - Clean up abort listener on background agent completion via .finally() - Fix misleading comment on running counter (process-wide, not per-instance) --- packages/opencode/src/tool/task.ts | 60 +++++++++++++++--------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 5ea18c7fd581..02d0a2d83a27 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -19,7 +19,7 @@ import { Log } from "@/util/log" const log = Log.create({ service: "tool.task" }) -/** Tracks active background agent count per instance for concurrency limiting */ +/** Tracks active background agent count (process-wide) for concurrency limiting */ let running = 0 const parameters = z.object({ @@ -189,38 +189,40 @@ export const TaskTool = Tool.define("task", async (ctx) => { running++ const teamID = params.team_id ? TeamID.make(params.team_id) : undefined - try { - const bound = Instance.bind(() => { - SessionPrompt.prompt(promptInput) - .catch(async (err) => { - log.error("background agent failed", { + const bound = Instance.bind(() => { + SessionPrompt.prompt(promptInput) + .catch(async (err) => { + log.error("background agent failed", { + sessionID: session.id, + agent: agent.name, + error: err, + }) + if (teamID) { + Team.failMember({ + teamID, sessionID: session.id, agent: agent.name, - error: err, }) - if (teamID) { - Team.failMember({ - teamID, - sessionID: session.id, - agent: agent.name, - }) - // Notify the lead about the failure - const lead = Team.leadSession(teamID) - if (lead) { - await SessionInject.send({ - sessionID: lead.sessionID, - from: agent.name, - fromSessionID: session.id, - content: `[AGENT FAILURE] @${agent.name} crashed with error: ${err instanceof Error ? err.message : String(err)}`, - teamID: params.team_id, - }).catch((e) => log.error("failed to notify lead of agent failure", { error: e })) - } + // Notify the lead about the failure + const lead = Team.leadSession(teamID) + if (lead) { + await SessionInject.send({ + sessionID: lead.sessionID, + from: agent.name, + fromSessionID: session.id, + content: `[AGENT FAILURE] @${agent.name} crashed with error: ${err instanceof Error ? err.message : String(err)}`, + teamID: params.team_id, + }).catch((e) => log.error("failed to notify lead of agent failure", { error: e })) } - }) - .finally(() => { - running-- - }) - }) + } + }) + .finally(() => { + running-- + // Clean up the abort listener when the agent completes + ctx.abort.removeEventListener("abort", cancel) + }) + }) + try { bound() } catch (e) { running-- From dfe486bc4fbd6db0f2dfdfd75769a654ea32013c Mon Sep 17 00:00:00 2001 From: Marcus Pamelia Date: Mon, 23 Mar 2026 10:13:46 +0100 Subject: [PATCH 4/7] Address code review feedback (turn 3) - Move running++ after Instance.bind to avoid leak if bind construction fails - Log errors instead of swallowing them in completeMember and disband paths - Remove unused variable in reconcile() --- packages/opencode/src/session/prompt.ts | 4 +++- packages/opencode/src/team/index.ts | 1 - packages/opencode/src/tool/task.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index df6fd42225b4..323793c80619 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -405,7 +405,9 @@ export namespace SessionPrompt { const injected = await waitForInjection(sessionID, abort) if (injected) continue // Mark member as completed on normal exit (timeout or no more messages) - import("../team").then(({ Team }) => Team.completeMember(sessionID)).catch(() => {}) + import("../team") + .then(({ Team }) => Team.completeMember(sessionID)) + .catch((e) => log.error("failed to complete team member", { sessionID, error: e })) } log.info("exiting loop", { sessionID }) break diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts index f67398d87e27..b2901f41e0e7 100644 --- a/packages/opencode/src/team/index.ts +++ b/packages/opencode/src/team/index.ts @@ -283,7 +283,6 @@ export namespace Team { /** Reconcile stale teams on startup — mark active teams as disbanded */ export function reconcile() { - const now = Date.now() const stale = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.status, "active")).all()) for (const row of stale) { log.info("reconciling stale team", { id: row.id, name: row.name }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 02d0a2d83a27..e72987c900d7 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -187,7 +187,6 @@ export const TaskTool = Tool.define("task", async (ctx) => { ) } - running++ const teamID = params.team_id ? TeamID.make(params.team_id) : undefined const bound = Instance.bind(() => { SessionPrompt.prompt(promptInput) @@ -222,6 +221,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { ctx.abort.removeEventListener("abort", cancel) }) }) + running++ try { bound() } catch (e) { From 99067980d32cbcb358bd74c08cd7e824381c5f6e Mon Sep 17 00:00:00 2001 From: Marcus Pamelia Date: Mon, 23 Mar 2026 10:41:26 +0100 Subject: [PATCH 5/7] Address code review feedback (post-rebase turn 1) - Move abort listener setup before bound() to ensure cancellation works immediately - Fix race in waitForInjection: subscribe to Bus before checking DB for pending messages - Wrap addMember disambiguation in Database.transaction to prevent TOCTOU - Fix team-delete.txt description: members are cancelled, not completed --- packages/opencode/src/session/prompt.ts | 46 +++++++++++----------- packages/opencode/src/team/index.ts | 31 ++++++++------- packages/opencode/src/tool/task.ts | 13 +++--- packages/opencode/src/tool/team-delete.txt | 2 +- 4 files changed, 47 insertions(+), 45 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 323793c80619..92dc23f5f1a3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -303,37 +303,25 @@ export namespace SessionPrompt { const duration = config.team?.member_timeout ?? 300_000 const { SessionInject } = await import("./inject") - // Check for messages already injected before we start waiting (race fix) - const msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const last = msgs.findLast((m) => m.info.role === "assistant") - const pending = msgs.find((m) => m.info.role === "user" && last && m.info.id > last.info.id) - if (pending) return true - - return new Promise((resolve) => { + return new Promise(async (resolve) => { let resolved = false - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true - cleanup() - resolve(false) - } - }, duration) + function done(value: boolean) { + if (resolved) return + resolved = true + cleanup() + resolve(value) + } + + const timeout = setTimeout(() => done(false), duration) + // Subscribe FIRST so no events are missed const unsub = Bus.subscribe(SessionInject.Event.MessageInjected, (event) => { - if (event.properties.sessionID === sessionID && !resolved) { - resolved = true - cleanup() - resolve(true) - } + if (event.properties.sessionID === sessionID) done(true) }) function onAbort() { - if (!resolved) { - resolved = true - cleanup() - resolve(false) - } + done(false) } abort.addEventListener("abort", onAbort) @@ -342,6 +330,16 @@ export namespace SessionPrompt { unsub() abort.removeEventListener("abort", onAbort) } + + // THEN check DB for messages injected before subscribe was established + try { + const msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const last = msgs.findLast((m) => m.info.role === "assistant") + const pending = msgs.find((m) => m.info.role === "user" && last && m.info.id > last.info.id) + if (pending) done(true) + } catch (e) { + log.error("failed to check pending messages", { sessionID, error: e }) + } }) } diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts index b2901f41e0e7..7fcd37b09e08 100644 --- a/packages/opencode/src/team/index.ts +++ b/packages/opencode/src/team/index.ts @@ -166,19 +166,22 @@ export namespace Team { if (!team) throw new Error(`Team not found: ${input.teamID}`) if (team.status === "disbanded") throw new Error(`Cannot add member to disbanded team: ${input.teamID}`) - // Disambiguate duplicate agent names by appending a suffix - let agent = input.agent - const existing = members(input.teamID).filter((m) => m.role === "member") - const taken = new Set(existing.map((m) => m.agent)) - if (taken.has(agent)) { - let i = 2 - while (taken.has(`${input.agent}-${i}`)) i++ - agent = `${input.agent}-${i}` - } - const now = Date.now() - const row = Database.use((db) => - db + // Disambiguate + insert in a transaction to prevent TOCTOU races + const row = Database.transaction((db) => { + let agent = input.agent + const existing = db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.role, "member"))) + .all() + const taken = new Set(existing.map((m) => m.agent)) + if (taken.has(agent)) { + let i = 2 + while (taken.has(`${input.agent}-${i}`)) i++ + agent = `${input.agent}-${i}` + } + return db .insert(TeamMemberTable) .values({ team_id: input.teamID, @@ -190,8 +193,8 @@ export namespace Team { time_updated: now, }) .returning() - .get(), - ) + .get() + }) const member = toMember(row) Database.effect(() => Bus.publish(Event.MemberAdded, { member })) return member diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e72987c900d7..880d567d26f2 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -221,20 +221,21 @@ export const TaskTool = Tool.define("task", async (ctx) => { ctx.abort.removeEventListener("abort", cancel) }) }) + // Wire up abort before launching so cancellation works immediately + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + running++ try { bound() } catch (e) { running-- + ctx.abort.removeEventListener("abort", cancel) throw e } - // Wire up abort to cancel the child session - function cancel() { - SessionPrompt.cancel(session.id) - } - ctx.abort.addEventListener("abort", cancel) - return { title: `${params.description} (background)`, metadata: { diff --git a/packages/opencode/src/tool/team-delete.txt b/packages/opencode/src/tool/team-delete.txt index 4001fd7eef33..281241c0405d 100644 --- a/packages/opencode/src/tool/team-delete.txt +++ b/packages/opencode/src/tool/team-delete.txt @@ -1,4 +1,4 @@ -Disband a team and mark all active members as completed. +Disband a team and mark all active members as cancelled. Call this after all team work is done. Agents persist any learnings via their memory — they do not need to stay alive for context retention. From a16695c88f6131f1d98f647694a74b800cbae943 Mon Sep 17 00:00:00 2001 From: Marcus Pamelia Date: Mon, 23 Mar 2026 10:50:48 +0100 Subject: [PATCH 6/7] fix: resolve typecheck errors in tool metadata return types - agent-memory: make metadata.updated consistently typed across branches - task: add background/teamID fields to synchronous return path - team-task: use shared Meta type for consistent task/tasks metadata shape --- packages/opencode/src/tool/agent-memory.ts | 23 ++++++++-------------- packages/opencode/src/tool/task.ts | 2 ++ packages/opencode/src/tool/team-task.ts | 10 ++++++---- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/tool/agent-memory.ts b/packages/opencode/src/tool/agent-memory.ts index 5684a8588d77..2132d18149df 100644 --- a/packages/opencode/src/tool/agent-memory.ts +++ b/packages/opencode/src/tool/agent-memory.ts @@ -6,13 +6,8 @@ import DESCRIPTION from "./agent-memory.txt" export const AgentMemoryTool = Tool.define("agent_memory", { description: DESCRIPTION, parameters: z.object({ - operation: z - .enum(["read", "write", "append"]) - .describe("The operation to perform"), - content: z - .string() - .optional() - .describe("Content to write or append (required for write/append)"), + operation: z.enum(["read", "write", "append"]).describe("The operation to perform"), + content: z.string().optional().describe("Content to write or append (required for write/append)"), }), async execute(params, ctx) { await ctx.ask({ @@ -28,7 +23,7 @@ export const AgentMemoryTool = Tool.define("agent_memory", { return { title: "No memory found", output: `No stored memory found for agent "${ctx.agent}" in this project. This is a fresh start.`, - metadata: { agent: ctx.agent }, + metadata: { agent: ctx.agent, updated: undefined as number | undefined }, } } return { @@ -36,30 +31,28 @@ export const AgentMemoryTool = Tool.define("agent_memory", { output: memory.content, metadata: { agent: ctx.agent, - updated: memory.time.updated, + updated: memory.time.updated as number | undefined, }, } } if (params.operation === "write") { - if (!params.content) - throw new Error("content is required for write operation") + if (!params.content) throw new Error("content is required for write operation") AgentMemory.write(ctx.agent, params.content) return { title: "Memory updated", output: `Memory for agent "${ctx.agent}" has been updated.`, - metadata: { agent: ctx.agent }, + metadata: { agent: ctx.agent, updated: undefined as number | undefined }, } } // append - if (!params.content) - throw new Error("content is required for append operation") + if (!params.content) throw new Error("content is required for append operation") AgentMemory.append(ctx.agent, params.content) return { title: "Memory appended", output: `New content appended to memory for agent "${ctx.agent}".`, - metadata: { agent: ctx.agent }, + metadata: { agent: ctx.agent, updated: undefined as number | undefined }, } }, }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 880d567d26f2..8fd5244c001a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -280,6 +280,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { metadata: { sessionId: session.id, model, + background: false as boolean, + teamID: undefined as string | undefined, }, output, } diff --git a/packages/opencode/src/tool/team-task.ts b/packages/opencode/src/tool/team-task.ts index 472a8eac7b31..c947f4ed47f6 100644 --- a/packages/opencode/src/tool/team-task.ts +++ b/packages/opencode/src/tool/team-task.ts @@ -32,6 +32,8 @@ export const TeamTaskTool = Tool.define("team_task", { if (!team) throw new Error(`Team not found: ${args.team_id}`) if (team.status === "disbanded") throw new Error(`Team has been disbanded: ${args.team_id}`) + type Meta = { task?: TeamTask.Info; tasks?: TeamTask.Info[] } + if (args.operation === "create") { if (!args.subject) throw new Error("subject is required for create operation") const task = TeamTask.create({ @@ -44,7 +46,7 @@ export const TeamTaskTool = Tool.define("team_task", { return { title: `Task created: ${task.subject}`, output: JSON.stringify(task, null, 2), - metadata: { task }, + metadata: { task } as Meta, } } @@ -61,7 +63,7 @@ export const TeamTaskTool = Tool.define("team_task", { return { title: `Task updated: ${task.subject}`, output: JSON.stringify(task, null, 2), - metadata: { task }, + metadata: { task } as Meta, } } @@ -72,7 +74,7 @@ export const TeamTaskTool = Tool.define("team_task", { return { title: `Task: ${task.subject}`, output: JSON.stringify(task, null, 2), - metadata: { task }, + metadata: { task } as Meta, } } @@ -81,7 +83,7 @@ export const TeamTaskTool = Tool.define("team_task", { return { title: `${tasks.length} tasks`, output: JSON.stringify(tasks, null, 2), - metadata: { tasks }, + metadata: { tasks } as Meta, } }, }) From 04ec993a7db5eaffa6dc716f39c4b28d70c1fd7a Mon Sep 17 00:00:00 2001 From: Marcus Pamelia Date: Wed, 25 Mar 2026 21:17:11 +0100 Subject: [PATCH 7/7] test: add tests for team, task, and agent memory subsystems - 14 tests for Team (create, disband, addMember disambiguation, failMember, completeMember, reconcile, disbandBySession, findMemberSession) - 8 tests for TeamTask (CRUD, disband cascade, failMember cascade) - 8 tests for AgentMemory (read, write, append, 100KB cap, per-agent scoping) - Remove teams-agents-feature.md from repo root (design doc doesn't belong in PR) --- packages/opencode/test/agent/memory.test.ts | 117 +++++++++ packages/opencode/test/team/task.test.ts | 158 ++++++++++++ packages/opencode/test/team/team.test.ts | 271 ++++++++++++++++++++ teams-agents-feature.md | 198 -------------- 4 files changed, 546 insertions(+), 198 deletions(-) create mode 100644 packages/opencode/test/agent/memory.test.ts create mode 100644 packages/opencode/test/team/task.test.ts create mode 100644 packages/opencode/test/team/team.test.ts delete mode 100644 teams-agents-feature.md diff --git a/packages/opencode/test/agent/memory.test.ts b/packages/opencode/test/agent/memory.test.ts new file mode 100644 index 000000000000..f6026308be2d --- /dev/null +++ b/packages/opencode/test/agent/memory.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { AgentMemory } from "../../src/agent/memory" +import { Bus } from "../../src/bus" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" + +const root = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("AgentMemory", () => { + test("read returns undefined when no memory exists", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const result = AgentMemory.read("nonexistent-agent") + expect(result).toBeUndefined() + }, + }) + }) + + test("write creates new memory entry", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.write("test-writer", "some learned patterns") + const result = AgentMemory.read("test-writer") + + expect(result).toBeDefined() + expect(result!.agent).toBe("test-writer") + expect(result!.content).toBe("some learned patterns") + }, + }) + }) + + test("write updates existing memory", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.write("updater", "version 1") + AgentMemory.write("updater", "version 2") + + const result = AgentMemory.read("updater") + expect(result!.content).toBe("version 2") + }, + }) + }) + + test("write emits Updated event", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + let received: { agent: string; projectID: string } | undefined + const unsub = Bus.subscribe(AgentMemory.Event.Updated, (e) => { + received = e.properties + }) + AgentMemory.write("evt-agent", "data") + await new Promise((r) => setTimeout(r, 50)) + unsub() + + expect(received).toBeDefined() + expect(received!.agent).toBe("evt-agent") + }, + }) + }) + + test("append creates memory if none exists", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.append("appender-new", "first line") + const result = AgentMemory.read("appender-new") + expect(result!.content).toBe("first line") + }, + }) + }) + + test("append adds to existing memory", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.write("appender", "line 1") + AgentMemory.append("appender", "line 2") + + const result = AgentMemory.read("appender") + expect(result!.content).toBe("line 1\n\nline 2") + }, + }) + }) + + test("write truncates content exceeding 100KB", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const big = "x".repeat(200_000) + AgentMemory.write("big-agent", big) + + const result = AgentMemory.read("big-agent") + expect(result).toBeDefined() + expect(Buffer.byteLength(result!.content, "utf8")).toBeLessThanOrEqual(102_400) + }, + }) + }) + + test("memory is scoped per agent", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.write("agent-a", "data for a") + AgentMemory.write("agent-b", "data for b") + + expect(AgentMemory.read("agent-a")!.content).toBe("data for a") + expect(AgentMemory.read("agent-b")!.content).toBe("data for b") + }, + }) + }) +}) diff --git a/packages/opencode/test/team/task.test.ts b/packages/opencode/test/team/task.test.ts new file mode 100644 index 000000000000..e535526a3f5b --- /dev/null +++ b/packages/opencode/test/team/task.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Team } from "../../src/team" +import { TeamTask } from "../../src/team/task" +import { TeamTaskID } from "../../src/team/schema" +import { Bus } from "../../src/bus" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" + +const root = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("TeamTask", () => { + test("create returns a pending task", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "task-team", sessionID: session.id }) + const task = TeamTask.create({ teamID: team.id, subject: "review spec" }) + + expect(task.subject).toBe("review spec") + expect(task.status).toBe("pending") + expect(task.teamID).toBe(team.id) + await Session.remove(session.id) + }, + }) + }) + + test("create with owner and metadata", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "meta-team", sessionID: session.id }) + const task = TeamTask.create({ + teamID: team.id, + subject: "check", + owner: "clarity-reviewer", + description: "Review for clarity", + metadata: { priority: "high" }, + }) + + expect(task.owner).toBe("clarity-reviewer") + expect(task.description).toBe("Review for clarity") + expect(task.metadata).toEqual({ priority: "high" }) + await Session.remove(session.id) + }, + }) + }) + + test("update changes status", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "update-team", sessionID: session.id }) + const task = TeamTask.create({ teamID: team.id, subject: "updatable" }) + + const updated = TeamTask.update(task.id, { status: "in_progress" }) + expect(updated).toBeDefined() + expect(updated!.status).toBe("in_progress") + await Session.remove(session.id) + }, + }) + }) + + test("update returns undefined for missing task", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const result = TeamTask.update(TeamTaskID.make("ttk_nope"), { status: "completed" }) + expect(result).toBeUndefined() + }, + }) + }) + + test("get returns task by id", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "get-task-team", sessionID: session.id }) + const task = TeamTask.create({ teamID: team.id, subject: "gettable" }) + + const found = TeamTask.get(task.id) + expect(found).toBeDefined() + expect(found!.subject).toBe("gettable") + await Session.remove(session.id) + }, + }) + }) + + test("list returns all tasks for a team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "list-team", sessionID: session.id }) + TeamTask.create({ teamID: team.id, subject: "task-a" }) + TeamTask.create({ teamID: team.id, subject: "task-b" }) + TeamTask.create({ teamID: team.id, subject: "task-c" }) + + const tasks = TeamTask.list(team.id) + expect(tasks.length).toBe(3) + expect(tasks.map((t) => t.subject).sort()).toEqual(["task-a", "task-b", "task-c"]) + await Session.remove(session.id) + }, + }) + }) + + test("disband cascades pending and in_progress tasks to failed", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "cascade-team", sessionID: session.id }) + const pending = TeamTask.create({ teamID: team.id, subject: "pending-task" }) + const progress = TeamTask.create({ teamID: team.id, subject: "progress-task" }) + TeamTask.update(progress.id, { status: "in_progress" }) + const done = TeamTask.create({ teamID: team.id, subject: "done-task" }) + TeamTask.update(done.id, { status: "completed" }) + + Team.disband(team.id) + + expect(TeamTask.get(pending.id)!.status).toBe("failed") + expect(TeamTask.get(progress.id)!.status).toBe("failed") + expect(TeamTask.get(done.id)!.status).toBe("completed") + await Session.remove(session.id) + }, + }) + }) + + test("failMember cascades in-progress tasks owned by agent", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "fail-cascade", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "worker" }) + + const t1 = TeamTask.create({ teamID: team.id, subject: "owned", owner: "worker" }) + TeamTask.update(t1.id, { status: "in_progress" }) + const t2 = TeamTask.create({ teamID: team.id, subject: "other", owner: "someone-else" }) + TeamTask.update(t2.id, { status: "in_progress" }) + + Team.failMember({ teamID: team.id, sessionID: s2.id, agent: "worker" }) + + expect(TeamTask.get(t1.id)!.status).toBe("failed") + expect(TeamTask.get(t2.id)!.status).toBe("in_progress") + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) +}) diff --git a/packages/opencode/test/team/team.test.ts b/packages/opencode/test/team/team.test.ts new file mode 100644 index 000000000000..eae8024d0d16 --- /dev/null +++ b/packages/opencode/test/team/team.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Team } from "../../src/team" +import { TeamID } from "../../src/team/schema" +import { Bus } from "../../src/bus" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" + +const root = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("Team", () => { + test("create returns active team with lead member", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "test-team", sessionID: session.id }) + expect(team.name).toBe("test-team") + expect(team.status).toBe("active") + expect(team.sessionID).toBe(session.id) + + const lead = Team.leadSession(team.id) + expect(lead).toBeDefined() + expect(lead!.role).toBe("lead") + expect(lead!.agent).toBe("lead") + expect(lead!.status).toBe("active") + await Session.remove(session.id) + }, + }) + }) + + test("create emits Created event", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + let received: Team.Info | undefined + const unsub = Bus.subscribe(Team.Event.Created, (e) => { + received = e.properties.team + }) + const team = Team.create({ name: "evt-team", sessionID: session.id }) + await new Promise((r) => setTimeout(r, 50)) + unsub() + expect(received).toBeDefined() + expect(received!.id).toBe(team.id) + await Session.remove(session.id) + }, + }) + }) + + test("get returns team by id", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "get-team", sessionID: session.id }) + const found = Team.get(team.id) + expect(found).toBeDefined() + expect(found!.id).toBe(team.id) + expect(found!.name).toBe("get-team") + await Session.remove(session.id) + }, + }) + }) + + test("get returns undefined for missing team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const found = Team.get(TeamID.make("tem_nonexistent")) + expect(found).toBeUndefined() + }, + }) + }) + + test("addMember adds a member to the team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "member-team", sessionID: s1.id }) + const member = Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "reviewer" }) + + expect(member.agent).toBe("reviewer") + expect(member.role).toBe("member") + expect(member.status).toBe("active") + + const all = Team.members(team.id) + expect(all.length).toBe(2) // lead + member + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("addMember disambiguates duplicate agent names", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const s3 = await Session.create({}) + const s4 = await Session.create({}) + const team = Team.create({ name: "dup-team", sessionID: s1.id }) + const m1 = Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "general" }) + const m2 = Team.addMember({ teamID: team.id, sessionID: s3.id, agent: "general" }) + const m3 = Team.addMember({ teamID: team.id, sessionID: s4.id, agent: "general" }) + + expect(m1.agent).toBe("general") + expect(m2.agent).toBe("general-2") + expect(m3.agent).toBe("general-3") + await Session.remove(s1.id) + await Session.remove(s2.id) + await Session.remove(s3.id) + await Session.remove(s4.id) + }, + }) + }) + + test("addMember rejects disbanded team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "dead-team", sessionID: s1.id }) + Team.disband(team.id) + expect(() => Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "x" })).toThrow( + "Cannot add member to disbanded team", + ) + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("addMember rejects nonexistent team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + expect(() => Team.addMember({ teamID: TeamID.make("tem_nope"), sessionID: s1.id, agent: "x" })).toThrow( + "Team not found", + ) + await Session.remove(s1.id) + }, + }) + }) + + test("disband sets team disbanded and members cancelled", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "disband-team", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "worker" }) + + Team.disband(team.id) + + const found = Team.get(team.id) + expect(found!.status).toBe("disbanded") + + const all = Team.members(team.id) + for (const m of all) { + expect(m.status).toBe("cancelled") + } + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("completeMember marks active member as completed", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "complete-team", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "worker" }) + + Team.completeMember(s2.id) + + const member = Team.findMemberSession({ teamID: team.id, agent: "worker" }) + expect(member!.status).toBe("completed") + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("failMember marks member as failed", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "fail-team", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "crasher" }) + + Team.failMember({ teamID: team.id, sessionID: s2.id, agent: "crasher" }) + + const member = Team.findMemberSession({ teamID: team.id, agent: "crasher" }) + expect(member!.status).toBe("failed") + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("disbandBySession disbands all active teams for a session", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const t1 = Team.create({ name: "dbs-1", sessionID: s1.id }) + const t2 = Team.create({ name: "dbs-2", sessionID: s1.id }) + + Team.disbandBySession(s1.id) + + expect(Team.get(t1.id)!.status).toBe("disbanded") + expect(Team.get(t2.id)!.status).toBe("disbanded") + await Session.remove(s1.id) + }, + }) + }) + + test("reconcile disbands all stale active teams", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const t1 = Team.create({ name: "stale-1", sessionID: s1.id }) + const t2 = Team.create({ name: "stale-2", sessionID: s2.id }) + + Team.reconcile() + + expect(Team.get(t1.id)!.status).toBe("disbanded") + expect(Team.get(t2.id)!.status).toBe("disbanded") + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("findMemberSession returns member by agent name", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "find-team", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "finder" }) + + const found = Team.findMemberSession({ teamID: team.id, agent: "finder" }) + expect(found).toBeDefined() + expect(found!.sessionID).toBe(s2.id) + + const missing = Team.findMemberSession({ teamID: team.id, agent: "nonexistent" }) + expect(missing).toBeUndefined() + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) +}) diff --git a/teams-agents-feature.md b/teams-agents-feature.md deleted file mode 100644 index 763cdf7a7e40..000000000000 --- a/teams-agents-feature.md +++ /dev/null @@ -1,198 +0,0 @@ -# Teams & Agents Feature — Design Spec - -## Summary - -Add multi-agent team coordination primitives to opencode, enabling workflows where a lead agent orchestrates multiple specialist sub-agents that work in parallel, communicate findings, and produce synthesized results. - -This unlocks the same class of workflows that Claude Code supports via TeamCreate/TaskCreate/SendMessage primitives, but designed natively for opencode's architecture (Sessions, MessageV2, Bus, Instance state, Drizzle DB). - -## Problem - -opencode's current `TaskTool` only supports synchronous sub-agent execution: spawn a child session, block until it completes, return the result. This is insufficient for: - -1. **Parallel specialist reviews** — spawning 8 agents simultaneously, each reviewing a spec from a different angle -2. **Inter-agent communication** — agents sending findings to a lead, or challenging each other's findings -3. **Persistent agent memory** — agents learning project-specific patterns across sessions -4. **Coordinated task boards** — shared state visible to all team members - -## Solution - -### New Subsystems - -#### 1. Team Management (`src/team/`) - -A **Team** is a named group of sessions with a lead and members. DB-backed via `team` and `team_member` tables. - -- `Team.create({ name, sessionID })` — creates team and registers lead in a single transaction -- `Team.disband(id)` — marks team disbanded, all active members cancelled, all pending/in-progress tasks failed -- `Team.addMember({ teamID, sessionID, agent })` — registers a member (rejects disbanded teams; auto-disambiguates duplicate agent names with `-N` suffix) -- `Team.findMemberSession({ teamID, agent })` — resolve agent → session -- `Team.leadSession(teamID)` — get the lead's session -- `Team.completeMember(sessionID)` — marks a member as completed on normal exit -- `Team.failMember({ teamID, sessionID, agent })` — marks member as failed, cascades to owned in-progress tasks -- `Team.disbandBySession(sessionID)` — auto-disbands all active teams owned by a session -- `Team.reconcile()` — marks all active teams as disbanded on server startup (stale state cleanup) - -**Team status values:** `active`, `disbanded`. - -**Member status values:** `active`, `completed`, `failed`, `cancelled`. - -Member status transitions: - -- `active` → `completed` — member exits normally (timeout, no more work) -- `active` → `failed` — background agent crashes -- `active` → `cancelled` — team is disbanded while member is still active - -**Agent name uniqueness:** A unique index enforces `UNIQUE(team_id, agent)` on `team_member`. When `addMember` is called with a duplicate agent name, it auto-disambiguates by appending a numeric suffix (e.g., `general-2`, `general-3`). This supports workflows that spawn multiple agents of the same type. - -#### 2. Team Task Board (`src/team/task.ts`) - -Shared task state scoped to a team. Any member can read/update. Tools validate the team exists and is active before operating. - -- `TeamTask.create({ teamID, subject, owner })` — create a task -- `TeamTask.update(id, { status, owner })` — update status -- `TeamTask.list(teamID)` — list all team tasks - -Statuses: `pending`, `in_progress`, `completed`, `failed`. - -#### 3. Message Injection (`src/session/inject.ts`) - -Cross-session message passing. A synthetic user message is written into a target session's DB, and the prompt loop picks it up on its next iteration. - -- `SessionInject.send({ sessionID, from, fromSessionID, content })` — inject message -- Publishes `session.message.injected` event on the Bus -- Messages are tagged with `injected: { from, fromSessionID, teamID }` on the User message schema -- The target session's agent type is resolved dynamically from its last user message (not hardcoded) - -#### 4. Background Agent Execution (modified `src/tool/task.ts`) - -Extended TaskTool with two new parameters: - -- `background: boolean` — when true, launches the child session in a detached async context via `Instance.bind()` and returns immediately with the `task_id` -- `team_id: string` — registers the child session as a team member; grants `send_message` and `team_task` permissions to the child - -**Concurrency limit:** A process-wide counter limits the number of concurrent background agents. Default: 10, configurable via `team.max_agents`. Throws a descriptive error when the limit is exceeded. - -**Failure handling:** When a background agent crashes: - -1. The member is marked `failed` via `Team.failMember()` -2. The agent's owned in-progress tasks are cascaded to `failed` -3. A `[AGENT FAILURE]` notification is injected into the lead session via `SessionInject.send()` - -**Sender validation:** The `send_message` tool validates that the sending session is a member of the target team before allowing message injection. - -#### 5. Agent Memory (`src/agent/memory.ts`) - -Persistent per-agent, per-project memory stored in `agent_memory` table with a unique index on `(project_id, agent)`. - -- `AgentMemory.read(agent)` — read stored memory -- `AgentMemory.write(agent, content)` — replace memory (capped at 100KB; truncated with warning if exceeded) -- `AgentMemory.append(agent, content)` — append to memory (same 100KB cap applied after append) - -Injected into the system prompt when `memory: local` is set on the agent definition. - -#### 6. Prompt Loop Changes (`src/session/prompt.ts`) - -When a background team member's loop would normally exit (assistant finished, no pending user message), it now: - -1. Checks if the session is registered as an active team member -2. If yes, waits for an injected message (via typed `Bus.subscribe` on `SessionInject.Event.MessageInjected`) -3. To avoid a race where a message is injected before the subscription is established, the wait also checks the DB for pending user messages immediately after subscribing -4. If a message arrives, continues the loop -5. If timeout (configurable via `team.member_timeout`, default 5 minutes) or abort, marks the member as `completed` and exits normally - -### Lifecycle & Cleanup - -- **Session cancellation** — when a lead session is cancelled, `Team.disbandBySession()` auto-disbands all its active teams. Members are marked `cancelled`, pending/in-progress tasks are marked `failed`. -- **Server restart** — `Team.reconcile()` runs on startup and marks all active teams as disbanded (stale state from a previous process). -- **Background member normal exit** — member's `team_member` status is updated to `completed` before exiting. The lead can observe this via `Team.members()`. -- **Background member crash** — member is marked `failed`, tasks cascaded, lead is notified via injected message. -- **Explicit disband** — `team_delete` tool sets all active members to `cancelled`. Members waiting for messages will time out after `member_timeout` and exit (the disband event does not immediately wake waiting members; this is accepted behavior with a bounded worst-case delay). - -### New Tools - -| Tool | Permission | Description | -| -------------- | -------------- | ----------------------------------------- | -| `team_create` | `team_create` | Create a named agent team | -| `team_delete` | `team_delete` | Disband a team | -| `team_task` | `team_task` | CRUD operations on the team task board | -| `send_message` | `send_message` | Send a message to another team member | -| `agent_memory` | `agent_memory` | Read/write/append persistent agent memory | - -### DB Schema (Migration) - -Four new tables: - -- `team` — id, session_id (lead), name, status (`active` | `disbanded`), timestamps -- `team_member` — team_id, session_id, agent, role (`lead` | `member`), status (`active` | `completed` | `failed` | `cancelled`), timestamps. Unique index on `(team_id, agent)`. -- `team_task` — id, team_id, subject, description, owner, status (`pending` | `in_progress` | `completed` | `failed`), metadata (JSON), timestamps -- `agent_memory` — id, project_id, agent, content (capped at 100KB), timestamps. Unique index on `(project_id, agent)`. - -### Config Schema Changes - -- `Agent.Info` gains `memory: "none" | "local"` field -- `Config.Agent` gains `memory` in schema and `knownKeys` set -- `Config` gains optional `team` section: - - `max_agents: number` (default 10) — process-wide max concurrent background agents - - `member_timeout: number` (default 300000ms / 5 minutes) — how long a team member waits for injected messages before exiting - -### Server Routes - -New `/team` route group: list teams by session, get team, get members, create team, disband team, list/create tasks. Also `/team/active` for TUI bootstrap (returns all active teams with their members). - -## Design Decisions - -1. **Async message passing via DB writes** — fits opencode's existing pattern where the prompt loop re-reads messages from DB each iteration. No in-memory queues needed. - -2. **Teams scoped to lead session** — multiple concurrent reviews don't interfere. Lead session owns the lifecycle. - -3. **Agent memory in SQLite** — consistent with all other opencode storage. Unique index on (project_id, agent) ensures one memory entry per agent per project. Content capped at 100KB to prevent context window overflow. - -4. **Background execution via `Instance.bind()`** — preserves ALS context (project, directory, worktree) for the child session. No worker threads needed. - -5. **Configurable wait timeout for team members** — prevents zombie sessions. Default 5 minutes, configurable via `team.member_timeout`. If no message arrives, the member marks itself `completed` and exits. - -6. **New tools are always registered** — permission-gated at execution time, not at registration. The explore agent's `"*": deny` naturally blocks all team tools. - -7. **Agent name auto-disambiguation** — unique index on `(team_id, agent)` enforces uniqueness. `addMember` automatically appends a numeric suffix (`-2`, `-3`, ...) when a duplicate agent type is added to the same team. - -8. **Process-wide concurrency limit** — the `max_agents` counter is module-level (not per-instance). This is intentional for resource management — the limit governs total system load regardless of how many project instances are open. - -9. **Failure notification to lead** — when a background agent crashes, the lead receives an injected `[AGENT FAILURE]` message so it can adapt its workflow. Member status and owned tasks are also cascaded to `failed`. - -## Files Changed - -### New (15 files) - -- `src/team/schema.ts` — TeamID, TeamTaskID, MemoryID branded types -- `src/team/team.sql.ts` — Drizzle table definitions -- `src/team/index.ts` — Team namespace -- `src/team/task.ts` — TeamTask namespace -- `src/agent/memory.ts` — AgentMemory namespace -- `src/session/inject.ts` — SessionInject namespace -- `src/tool/team-create.{ts,txt}` — TeamCreate tool -- `src/tool/team-delete.{ts,txt}` — TeamDelete tool -- `src/tool/team-task.{ts,txt}` — TeamTask tool -- `src/tool/send-message.{ts,txt}` — SendMessage tool -- `src/tool/agent-memory.{ts,txt}` — AgentMemory tool -- `src/server/routes/team.ts` — Team API routes -- `migration/20260321160000_add_teams/migration.sql` — DB migration - -### Modified (10 files) - -- `src/id/id.ts` — 3 new prefixes -- `src/tool/task.ts` — background + team_id params, concurrency limit, failure notification -- `src/tool/task.txt` — team workflow docs -- `src/tool/registry.ts` — 5 new tools registered -- `src/agent/agent.ts` — memory field -- `src/config/config.ts` — memory in Agent schema, `team` config section -- `src/session/message-v2.ts` — injected field on User -- `src/session/prompt.ts` — team-aware loop exit + wait, member completion on exit, race-safe injection wait -- `src/session/system.ts` — memory injection into system prompt -- `src/server/server.ts` — TeamRoutes registered, startup reconciliation - -## Risks & Open Questions - -1. **No rate limiting on SendMessage** — an agent could spam messages. Mitigated by the natural LLM turn structure (each message requires an LLM turn to produce). -2. **Disband does not immediately wake waiting members** — members in `waitForInjection` only listen for `session.message.injected`, not `team.disbanded`. They will idle until `member_timeout` expires. This is accepted behavior — the bounded worst case is the configured timeout (default 5 min). -3. **Migration backward compat** — additive only (new tables), safe to alternate between versions.