diff --git a/src/tools/agent-teams/inbox-store.ts b/src/tools/agent-teams/inbox-store.ts new file mode 100644 index 000000000..b351db1cd --- /dev/null +++ b/src/tools/agent-teams/inbox-store.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs" +import { z } from "zod" +import { acquireLock, ensureDir } from "../../features/claude-tasks/storage" +import { getTeamInboxDir, getTeamInboxPath } from "./paths" +import { TeamInboxMessage, TeamInboxMessageSchema } from "./types" + +const TeamInboxListSchema = z.array(TeamInboxMessageSchema) + +function nowIso(): string { + return new Date().toISOString() +} + +function withInboxLock(teamName: string, operation: () => T): T { + const inboxDir = getTeamInboxDir(teamName) + ensureDir(inboxDir) + const lock = acquireLock(inboxDir) + if (!lock.acquired) { + throw new Error("inbox_lock_unavailable") + } + + try { + return operation() + } finally { + lock.release() + } +} + +function parseInboxFile(content: string): TeamInboxMessage[] { + try { + const parsed = JSON.parse(content) + const result = TeamInboxListSchema.safeParse(parsed) + return result.success ? result.data : [] + } catch { + return [] + } +} + +function readInboxMessages(teamName: string, agentName: string): TeamInboxMessage[] { + const path = getTeamInboxPath(teamName, agentName) + if (!existsSync(path)) { + return [] + } + return parseInboxFile(readFileSync(path, "utf-8")) +} + +function writeInboxMessages(teamName: string, agentName: string, messages: TeamInboxMessage[]): void { + const path = getTeamInboxPath(teamName, agentName) + writeFileSync(path, JSON.stringify(messages), "utf-8") +} + +export function ensureInbox(teamName: string, agentName: string): void { + withInboxLock(teamName, () => { + const path = getTeamInboxPath(teamName, agentName) + if (!existsSync(path)) { + writeFileSync(path, "[]", "utf-8") + } + }) +} + +export function appendInboxMessage(teamName: string, agentName: string, message: TeamInboxMessage): void { + withInboxLock(teamName, () => { + const path = getTeamInboxPath(teamName, agentName) + if (!existsSync(path)) { + writeFileSync(path, "[]", "utf-8") + } + const messages = readInboxMessages(teamName, agentName) + messages.push(TeamInboxMessageSchema.parse(message)) + writeInboxMessages(teamName, agentName, messages) + }) +} + +export function sendPlainInboxMessage( + teamName: string, + from: string, + to: string, + text: string, + summary: string, + color?: string, +): void { + appendInboxMessage(teamName, to, { + from, + text, + timestamp: nowIso(), + read: false, + summary, + ...(color ? { color } : {}), + }) +} + +export function sendStructuredInboxMessage( + teamName: string, + from: string, + to: string, + payload: Record, + summary?: string, +): void { + appendInboxMessage(teamName, to, { + from, + text: JSON.stringify(payload), + timestamp: nowIso(), + read: false, + ...(summary ? { summary } : {}), + }) +} + +export function readInbox( + teamName: string, + agentName: string, + unreadOnly = false, + markAsRead = true, +): TeamInboxMessage[] { + return withInboxLock(teamName, () => { + const messages = readInboxMessages(teamName, agentName) + + const selected = unreadOnly ? messages.filter((message) => !message.read) : [...messages] + + if (!markAsRead || selected.length === 0) { + return selected + } + + const updated = messages.map((message) => { + if (!unreadOnly) { + return { ...message, read: true } + } + + if (selected.some((selectedMessage) => selectedMessage.timestamp === message.timestamp && selectedMessage.from === message.from && selectedMessage.text === message.text)) { + return { ...message, read: true } + } + return message + }) + + writeInboxMessages(teamName, agentName, updated) + return selected + }) +} + +export function buildShutdownRequestId(recipient: string): string { + return `shutdown-${Date.now()}@${recipient}` +} diff --git a/src/tools/agent-teams/index.ts b/src/tools/agent-teams/index.ts new file mode 100644 index 000000000..2b66c3710 --- /dev/null +++ b/src/tools/agent-teams/index.ts @@ -0,0 +1 @@ +export { createAgentTeamsTools } from "./tools" diff --git a/src/tools/agent-teams/messaging-tools.ts b/src/tools/agent-teams/messaging-tools.ts new file mode 100644 index 000000000..6608a0978 --- /dev/null +++ b/src/tools/agent-teams/messaging-tools.ts @@ -0,0 +1,199 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import type { BackgroundManager } from "../../features/background-agent" +import { buildShutdownRequestId, readInbox, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-store" +import { getTeamMember, listTeammates, readTeamConfigOrThrow } from "./team-config-store" +import { resumeTeammateWithMessage } from "./teammate-runtime" +import { + TeamReadInboxInputSchema, + TeamSendMessageInputSchema, + TeamToolContext, + isTeammateMember, +} from "./types" + +function nowIso(): string { + return new Date().toISOString() +} + +export function createSendMessageTool(manager: BackgroundManager): ToolDefinition { + return tool({ + description: "Send direct or broadcast team messages and protocol responses.", + args: { + team_name: tool.schema.string().describe("Team name"), + type: tool.schema.enum(["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]), + recipient: tool.schema.string().optional().describe("Message recipient"), + content: tool.schema.string().optional().describe("Message body"), + summary: tool.schema.string().optional().describe("Short summary"), + request_id: tool.schema.string().optional().describe("Protocol request id"), + approve: tool.schema.boolean().optional().describe("Approval flag"), + sender: tool.schema.string().optional().describe("Sender name (default: team-lead)"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamSendMessageInputSchema.parse(args) + const sender = input.sender ?? "team-lead" + const config = readTeamConfigOrThrow(input.team_name) + const memberNames = new Set(config.members.map((member) => member.name)) + + if (input.type === "message") { + if (!input.recipient || !input.summary || !input.content) { + return JSON.stringify({ error: "message_requires_recipient_summary_content" }) + } + if (!memberNames.has(input.recipient)) { + return JSON.stringify({ error: "message_recipient_not_found" }) + } + + const targetMember = getTeamMember(config, input.recipient) + const color = targetMember && isTeammateMember(targetMember) ? targetMember.color : undefined + sendPlainInboxMessage(input.team_name, sender, input.recipient, input.content, input.summary, color) + + if (targetMember && isTeammateMember(targetMember)) { + await resumeTeammateWithMessage(manager, context, input.team_name, targetMember, input.summary, input.content) + } + + return JSON.stringify({ success: true, message: `message_sent:${input.recipient}` }) + } + + if (input.type === "broadcast") { + if (!input.summary) { + return JSON.stringify({ error: "broadcast_requires_summary" }) + } + const teammates = listTeammates(config) + for (const teammate of teammates) { + sendPlainInboxMessage(input.team_name, sender, teammate.name, input.content ?? "", input.summary) + await resumeTeammateWithMessage(manager, context, input.team_name, teammate, input.summary, input.content ?? "") + } + return JSON.stringify({ success: true, message: `broadcast_sent:${teammates.length}` }) + } + + if (input.type === "shutdown_request") { + if (!input.recipient) { + return JSON.stringify({ error: "shutdown_request_requires_recipient" }) + } + if (input.recipient === "team-lead") { + return JSON.stringify({ error: "cannot_shutdown_team_lead" }) + } + const targetMember = getTeamMember(config, input.recipient) + if (!targetMember || !isTeammateMember(targetMember)) { + return JSON.stringify({ error: "shutdown_recipient_not_found" }) + } + + const requestId = buildShutdownRequestId(input.recipient) + sendStructuredInboxMessage( + input.team_name, + "team-lead", + input.recipient, + { + type: "shutdown_request", + requestId, + from: "team-lead", + reason: input.content ?? "", + timestamp: nowIso(), + }, + "shutdown_request", + ) + + await resumeTeammateWithMessage( + manager, + context, + input.team_name, + targetMember, + "shutdown_request", + input.content ?? "Shutdown requested", + ) + + return JSON.stringify({ success: true, request_id: requestId, target: input.recipient }) + } + + if (input.type === "shutdown_response") { + if (!input.request_id) { + return JSON.stringify({ error: "shutdown_response_requires_request_id" }) + } + if (input.approve) { + sendStructuredInboxMessage( + input.team_name, + sender, + "team-lead", + { + type: "shutdown_approved", + requestId: input.request_id, + from: sender, + timestamp: nowIso(), + backendType: "native", + }, + "shutdown_approved", + ) + return JSON.stringify({ success: true, message: `shutdown_approved:${input.request_id}` }) + } + + sendPlainInboxMessage( + input.team_name, + sender, + "team-lead", + input.content ?? "Shutdown rejected", + "shutdown_rejected", + ) + return JSON.stringify({ success: true, message: `shutdown_rejected:${input.request_id}` }) + } + + if (!input.recipient) { + return JSON.stringify({ error: "plan_response_requires_recipient" }) + } + if (!memberNames.has(input.recipient)) { + return JSON.stringify({ error: "plan_response_recipient_not_found" }) + } + + if (input.approve) { + sendStructuredInboxMessage( + input.team_name, + sender, + input.recipient, + { + type: "plan_approval", + approved: true, + requestId: input.request_id, + }, + "plan_approved", + ) + return JSON.stringify({ success: true, message: `plan_approved:${input.recipient}` }) + } + + sendPlainInboxMessage( + input.team_name, + sender, + input.recipient, + input.content ?? "Plan rejected", + "plan_rejected", + ) + return JSON.stringify({ success: true, message: `plan_rejected:${input.recipient}` }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "send_message_failed" }) + } + }, + }) +} + +export function createReadInboxTool(): ToolDefinition { + return tool({ + description: "Read inbox messages for a team member.", + args: { + team_name: tool.schema.string().describe("Team name"), + agent_name: tool.schema.string().describe("Member name"), + unread_only: tool.schema.boolean().optional().describe("Return only unread messages"), + mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"), + }, + execute: async (args: Record): Promise => { + try { + const input = TeamReadInboxInputSchema.parse(args) + const messages = readInbox( + input.team_name, + input.agent_name, + input.unread_only ?? false, + input.mark_as_read ?? true, + ) + return JSON.stringify(messages) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/name-validation.ts b/src/tools/agent-teams/name-validation.ts new file mode 100644 index 000000000..10d868fae --- /dev/null +++ b/src/tools/agent-teams/name-validation.ts @@ -0,0 +1,29 @@ +const VALID_NAME_RE = /^[A-Za-z0-9_-]+$/ +const MAX_NAME_LENGTH = 64 + +function validateName(value: string, label: "team" | "agent"): string | null { + if (!value || !value.trim()) { + return `${label}_name_required` + } + + if (!VALID_NAME_RE.test(value)) { + return `${label}_name_invalid` + } + + if (value.length > MAX_NAME_LENGTH) { + return `${label}_name_too_long` + } + + return null +} + +export function validateTeamName(teamName: string): string | null { + return validateName(teamName, "team") +} + +export function validateAgentName(agentName: string): string | null { + if (agentName === "team-lead") { + return "agent_name_reserved" + } + return validateName(agentName, "agent") +} diff --git a/src/tools/agent-teams/paths.ts b/src/tools/agent-teams/paths.ts new file mode 100644 index 000000000..9902fb07d --- /dev/null +++ b/src/tools/agent-teams/paths.ts @@ -0,0 +1,40 @@ +import { join } from "node:path" +import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" + +const AGENT_TEAMS_DIR = "agent-teams" + +export function getAgentTeamsRootDir(): string { + return join(getOpenCodeConfigDir({ binary: "opencode" }), AGENT_TEAMS_DIR) +} + +export function getTeamsRootDir(): string { + return join(getAgentTeamsRootDir(), "teams") +} + +export function getTeamTasksRootDir(): string { + return join(getAgentTeamsRootDir(), "tasks") +} + +export function getTeamDir(teamName: string): string { + return join(getTeamsRootDir(), teamName) +} + +export function getTeamConfigPath(teamName: string): string { + return join(getTeamDir(teamName), "config.json") +} + +export function getTeamInboxDir(teamName: string): string { + return join(getTeamDir(teamName), "inboxes") +} + +export function getTeamInboxPath(teamName: string, agentName: string): string { + return join(getTeamInboxDir(teamName), `${agentName}.json`) +} + +export function getTeamTaskDir(teamName: string): string { + return join(getTeamTasksRootDir(), teamName) +} + +export function getTeamTaskPath(teamName: string, taskId: string): string { + return join(getTeamTaskDir(teamName), `${taskId}.json`) +} diff --git a/src/tools/agent-teams/team-config-store.ts b/src/tools/agent-teams/team-config-store.ts new file mode 100644 index 000000000..e5a3d3ca0 --- /dev/null +++ b/src/tools/agent-teams/team-config-store.ts @@ -0,0 +1,159 @@ +import { existsSync, rmSync } from "node:fs" +import { + acquireLock, + ensureDir, + readJsonSafe, + writeJsonAtomic, +} from "../../features/claude-tasks/storage" +import { + getTeamConfigPath, + getTeamDir, + getTeamInboxDir, + getTeamTaskDir, + getTeamTasksRootDir, + getTeamsRootDir, +} from "./paths" +import { + TEAM_COLOR_PALETTE, + TeamConfig, + TeamConfigSchema, + TeamLeadMember, + TeamMember, + TeamTeammateMember, + isTeammateMember, +} from "./types" + +function nowMs(): number { + return Date.now() +} + +function withTeamLock(teamName: string, operation: () => T): T { + const teamDir = getTeamDir(teamName) + ensureDir(teamDir) + const lock = acquireLock(teamDir) + if (!lock.acquired) { + throw new Error("team_lock_unavailable") + } + + try { + return operation() + } finally { + lock.release() + } +} + +function createLeadMember(teamName: string, leadSessionId: string, cwd: string, model: string): TeamLeadMember { + return { + agentId: `team-lead@${teamName}`, + name: "team-lead", + agentType: "team-lead", + model, + joinedAt: nowMs(), + cwd, + subscriptions: [], + } +} + +export function ensureTeamStorageDirs(teamName: string): void { + ensureDir(getTeamsRootDir()) + ensureDir(getTeamTasksRootDir()) + ensureDir(getTeamDir(teamName)) + ensureDir(getTeamInboxDir(teamName)) + ensureDir(getTeamTaskDir(teamName)) +} + +export function teamExists(teamName: string): boolean { + return existsSync(getTeamConfigPath(teamName)) +} + +export function createTeamConfig( + teamName: string, + description: string, + leadSessionId: string, + cwd: string, + model: string, +): TeamConfig { + ensureTeamStorageDirs(teamName) + + if (teamExists(teamName)) { + throw new Error("team_already_exists") + } + + const leadAgentId = `team-lead@${teamName}` + const config: TeamConfig = { + name: teamName, + description, + createdAt: nowMs(), + leadAgentId, + leadSessionId, + members: [createLeadMember(teamName, leadSessionId, cwd, model)], + } + + return withTeamLock(teamName, () => { + writeJsonAtomic(getTeamConfigPath(teamName), TeamConfigSchema.parse(config)) + return config + }) +} + +export function readTeamConfig(teamName: string): TeamConfig | null { + return readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema) +} + +export function readTeamConfigOrThrow(teamName: string): TeamConfig { + const config = readTeamConfig(teamName) + if (!config) { + throw new Error("team_not_found") + } + return config +} + +export function writeTeamConfig(teamName: string, config: TeamConfig): TeamConfig { + return withTeamLock(teamName, () => { + const validated = TeamConfigSchema.parse(config) + writeJsonAtomic(getTeamConfigPath(teamName), validated) + return validated + }) +} + +export function listTeammates(config: TeamConfig): TeamTeammateMember[] { + return config.members.filter(isTeammateMember) +} + +export function getTeamMember(config: TeamConfig, name: string): TeamMember | undefined { + return config.members.find((member) => member.name === name) +} + +export function upsertTeammate(config: TeamConfig, teammate: TeamTeammateMember): TeamConfig { + const members = config.members.filter((member) => member.name !== teammate.name) + members.push(teammate) + return { ...config, members } +} + +export function removeTeammate(config: TeamConfig, agentName: string): TeamConfig { + if (agentName === "team-lead") { + throw new Error("cannot_remove_team_lead") + } + + return { + ...config, + members: config.members.filter((member) => member.name !== agentName), + } +} + +export function assignNextColor(config: TeamConfig): string { + const teammateCount = listTeammates(config).length + return TEAM_COLOR_PALETTE[teammateCount % TEAM_COLOR_PALETTE.length] +} + +export function deleteTeamData(teamName: string): void { + const teamDir = getTeamDir(teamName) + const taskDir = getTeamTaskDir(teamName) + + if (existsSync(teamDir)) { + rmSync(teamDir, { recursive: true, force: true }) + } + + if (existsSync(taskDir)) { + rmSync(taskDir, { recursive: true, force: true }) + } +} diff --git a/src/tools/agent-teams/team-lifecycle-tools.ts b/src/tools/agent-teams/team-lifecycle-tools.ts new file mode 100644 index 000000000..4d281f889 --- /dev/null +++ b/src/tools/agent-teams/team-lifecycle-tools.ts @@ -0,0 +1,95 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { getTeamConfigPath } from "./paths" +import { validateTeamName } from "./name-validation" +import { ensureInbox } from "./inbox-store" +import { + TeamCreateInputSchema, + TeamDeleteInputSchema, + TeamReadConfigInputSchema, + TeamToolContext, +} from "./types" +import { createTeamConfig, deleteTeamData, listTeammates, readTeamConfig, readTeamConfigOrThrow } from "./team-config-store" + +export function createTeamCreateTool(): ToolDefinition { + return tool({ + description: "Create a team workspace with config, inboxes, and task storage.", + args: { + team_name: tool.schema.string().describe("Team name (letters, numbers, hyphens, underscores)"), + description: tool.schema.string().optional().describe("Team description"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamCreateInputSchema.parse(args) + const nameError = validateTeamName(input.team_name) + if (nameError) { + return JSON.stringify({ error: nameError }) + } + + const config = createTeamConfig( + input.team_name, + input.description ?? "", + context.sessionID, + process.cwd(), + context.agent ?? "native", + ) + ensureInbox(config.name, "team-lead") + + return JSON.stringify({ + team_name: config.name, + team_file_path: getTeamConfigPath(config.name), + lead_agent_id: config.leadAgentId, + }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_create_failed" }) + } + }, + }) +} + +export function createTeamDeleteTool(): ToolDefinition { + return tool({ + description: "Delete a team and its stored data. Fails if teammates still exist.", + args: { + team_name: tool.schema.string().describe("Team name"), + }, + execute: async (args: Record): Promise => { + try { + const input = TeamDeleteInputSchema.parse(args) + const config = readTeamConfigOrThrow(input.team_name) + const teammates = listTeammates(config) + if (teammates.length > 0) { + return JSON.stringify({ + error: "team_has_active_members", + members: teammates.map((member) => member.name), + }) + } + + deleteTeamData(input.team_name) + return JSON.stringify({ success: true, team_name: input.team_name }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_delete_failed" }) + } + }, + }) +} + +export function createTeamReadConfigTool(): ToolDefinition { + return tool({ + description: "Read team configuration and member list.", + args: { + team_name: tool.schema.string().describe("Team name"), + }, + execute: async (args: Record): Promise => { + try { + const input = TeamReadConfigInputSchema.parse(args) + const config = readTeamConfig(input.team_name) + if (!config) { + return JSON.stringify({ error: "team_not_found" }) + } + return JSON.stringify(config) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_read_config_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/team-task-dependency.ts b/src/tools/agent-teams/team-task-dependency.ts new file mode 100644 index 000000000..0edec7949 --- /dev/null +++ b/src/tools/agent-teams/team-task-dependency.ts @@ -0,0 +1,93 @@ +import type { TeamTask, TeamTaskStatus } from "./types" + +type PendingEdges = Record> + +export const TEAM_TASK_STATUS_ORDER: Record = { + pending: 0, + in_progress: 1, + completed: 2, + deleted: 3, +} + +export type TaskReader = (taskId: string) => TeamTask | null + +export function wouldCreateCycle( + fromTaskId: string, + toTaskId: string, + pendingEdges: PendingEdges, + readTask: TaskReader, +): boolean { + const visited = new Set() + const queue: string[] = [toTaskId] + + while (queue.length > 0) { + const current = queue.shift() + if (!current) { + continue + } + + if (current === fromTaskId) { + return true + } + + if (visited.has(current)) { + continue + } + visited.add(current) + + const task = readTask(current) + if (task) { + for (const dep of task.blockedBy) { + if (!visited.has(dep)) { + queue.push(dep) + } + } + } + + const pending = pendingEdges[current] + if (pending) { + for (const dep of pending) { + if (!visited.has(dep)) { + queue.push(dep) + } + } + } + } + + return false +} + +export function ensureForwardStatusTransition(current: TeamTaskStatus, next: TeamTaskStatus): void { + const currentOrder = TEAM_TASK_STATUS_ORDER[current] + const nextOrder = TEAM_TASK_STATUS_ORDER[next] + if (nextOrder < currentOrder) { + throw new Error(`invalid_status_transition:${current}->${next}`) + } +} + +export function ensureDependenciesCompleted( + status: TeamTaskStatus, + blockedBy: string[], + readTask: TaskReader, +): void { + if (status !== "in_progress" && status !== "completed") { + return + } + + for (const blockerId of blockedBy) { + const blocker = readTask(blockerId) + if (blocker && blocker.status !== "completed") { + throw new Error(`blocked_by_incomplete:${blockerId}:${blocker.status}`) + } + } +} + +export function createPendingEdgeMap(): PendingEdges { + return {} +} + +export function addPendingEdge(pendingEdges: PendingEdges, from: string, to: string): void { + const existing = pendingEdges[from] ?? new Set() + existing.add(to) + pendingEdges[from] = existing +} diff --git a/src/tools/agent-teams/team-task-store.ts b/src/tools/agent-teams/team-task-store.ts new file mode 100644 index 000000000..abc2d8691 --- /dev/null +++ b/src/tools/agent-teams/team-task-store.ts @@ -0,0 +1,127 @@ +import { existsSync, readdirSync, unlinkSync } from "node:fs" +import { join } from "node:path" +import { + acquireLock, + ensureDir, + generateTaskId, + readJsonSafe, + writeJsonAtomic, +} from "../../features/claude-tasks/storage" +import { getTeamTaskDir, getTeamTaskPath } from "./paths" +import { TeamTask, TeamTaskSchema } from "./types" + +function withTaskLock(teamName: string, operation: () => T): T { + const taskDir = getTeamTaskDir(teamName) + ensureDir(taskDir) + const lock = acquireLock(taskDir) + if (!lock.acquired) { + throw new Error("team_task_lock_unavailable") + } + + try { + return operation() + } finally { + lock.release() + } +} + +export function readTeamTask(teamName: string, taskId: string): TeamTask | null { + return readJsonSafe(getTeamTaskPath(teamName, taskId), TeamTaskSchema) +} + +export function readTeamTaskOrThrow(teamName: string, taskId: string): TeamTask { + const task = readTeamTask(teamName, taskId) + if (!task) { + throw new Error("team_task_not_found") + } + return task +} + +export function listTeamTasks(teamName: string): TeamTask[] { + const taskDir = getTeamTaskDir(teamName) + if (!existsSync(taskDir)) { + return [] + } + + const files = readdirSync(taskDir) + .filter((file) => file.endsWith(".json") && file.startsWith("T-")) + .sort((a, b) => a.localeCompare(b)) + + const tasks: TeamTask[] = [] + for (const file of files) { + const taskId = file.replace(/\.json$/, "") + const task = readTeamTask(teamName, taskId) + if (task) { + tasks.push(task) + } + } + + return tasks +} + +export function createTeamTask( + teamName: string, + subject: string, + description: string, + activeForm?: string, + metadata?: Record, +): TeamTask { + if (!subject.trim()) { + throw new Error("team_task_subject_required") + } + + return withTaskLock(teamName, () => { + const task: TeamTask = { + id: generateTaskId(), + subject, + description, + activeForm, + status: "pending", + blocks: [], + blockedBy: [], + ...(metadata ? { metadata } : {}), + } + + const validated = TeamTaskSchema.parse(task) + writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated) + return validated + }) +} + +export function writeTeamTask(teamName: string, task: TeamTask): TeamTask { + const validated = TeamTaskSchema.parse(task) + writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated) + return validated +} + +export function deleteTeamTaskFile(teamName: string, taskId: string): void { + const taskPath = getTeamTaskPath(teamName, taskId) + if (existsSync(taskPath)) { + unlinkSync(taskPath) + } +} + +export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null { + return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema) +} + +export function resetOwnerTasks(teamName: string, ownerName: string): void { + withTaskLock(teamName, () => { + const tasks = listTeamTasks(teamName) + for (const task of tasks) { + if (task.owner !== ownerName) { + continue + } + const next: TeamTask = { + ...task, + owner: undefined, + status: task.status === "completed" ? "completed" : "pending", + } + writeTeamTask(teamName, next) + } + }) +} + +export function withTeamTaskLock(teamName: string, operation: () => T): T { + return withTaskLock(teamName, operation) +} diff --git a/src/tools/agent-teams/team-task-tools.ts b/src/tools/agent-teams/team-task-tools.ts new file mode 100644 index 000000000..36a2f0dcf --- /dev/null +++ b/src/tools/agent-teams/team-task-tools.ts @@ -0,0 +1,106 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { sendStructuredInboxMessage } from "./inbox-store" +import { readTeamConfigOrThrow } from "./team-config-store" +import { + TeamTaskCreateInputSchema, + TeamTaskGetInputSchema, + TeamTaskListInputSchema, + TeamTask, +} from "./types" +import { createTeamTask, listTeamTasks, readTeamTask } from "./team-task-store" + +function buildTaskAssignmentPayload(task: TeamTask): Record { + return { + type: "task_assignment", + taskId: task.id, + subject: task.subject, + description: task.description, + timestamp: new Date().toISOString(), + } +} + +export function createTeamTaskCreateTool(): ToolDefinition { + return tool({ + description: "Create a task in team-scoped storage.", + args: { + team_name: tool.schema.string().describe("Team name"), + subject: tool.schema.string().describe("Task subject"), + description: tool.schema.string().describe("Task description"), + active_form: tool.schema.string().optional().describe("Present-continuous form"), + metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Task metadata"), + }, + execute: async (args: Record): Promise => { + try { + const input = TeamTaskCreateInputSchema.parse(args) + readTeamConfigOrThrow(input.team_name) + + const task = createTeamTask( + input.team_name, + input.subject, + input.description, + input.active_form, + input.metadata, + ) + + return JSON.stringify(task) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_create_failed" }) + } + }, + }) +} + +export function createTeamTaskListTool(): ToolDefinition { + return tool({ + description: "List tasks for one team.", + args: { + team_name: tool.schema.string().describe("Team name"), + }, + execute: async (args: Record): Promise => { + try { + const input = TeamTaskListInputSchema.parse(args) + readTeamConfigOrThrow(input.team_name) + return JSON.stringify(listTeamTasks(input.team_name)) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_list_failed" }) + } + }, + }) +} + +export function createTeamTaskGetTool(): ToolDefinition { + return tool({ + description: "Get one task from team-scoped storage.", + args: { + team_name: tool.schema.string().describe("Team name"), + task_id: tool.schema.string().describe("Task id"), + }, + execute: async (args: Record): Promise => { + try { + const input = TeamTaskGetInputSchema.parse(args) + readTeamConfigOrThrow(input.team_name) + const task = readTeamTask(input.team_name, input.task_id) + if (!task) { + return JSON.stringify({ error: "team_task_not_found" }) + } + return JSON.stringify(task) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_get_failed" }) + } + }, + }) +} + +export function notifyOwnerAssignment(teamName: string, task: TeamTask): void { + if (!task.owner || task.status === "deleted") { + return + } + + sendStructuredInboxMessage( + teamName, + "team-lead", + task.owner, + buildTaskAssignmentPayload(task), + "task_assignment", + ) +} diff --git a/src/tools/agent-teams/team-task-update-tool.ts b/src/tools/agent-teams/team-task-update-tool.ts new file mode 100644 index 000000000..95bf3bb94 --- /dev/null +++ b/src/tools/agent-teams/team-task-update-tool.ts @@ -0,0 +1,48 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { readTeamConfigOrThrow } from "./team-config-store" +import { TeamTaskUpdateInputSchema } from "./types" +import { updateTeamTask } from "./team-task-update" +import { notifyOwnerAssignment } from "./team-task-tools" + +export function createTeamTaskUpdateTool(): ToolDefinition { + return tool({ + description: "Update task status, owner, dependencies, and metadata in a team task list.", + args: { + team_name: tool.schema.string().describe("Team name"), + task_id: tool.schema.string().describe("Task id"), + status: tool.schema.enum(["pending", "in_progress", "completed", "deleted"]).optional().describe("Task status"), + owner: tool.schema.string().optional().describe("Task owner"), + subject: tool.schema.string().optional().describe("Task subject"), + description: tool.schema.string().optional().describe("Task description"), + active_form: tool.schema.string().optional().describe("Present-continuous form"), + add_blocks: tool.schema.array(tool.schema.string()).optional().describe("Add task ids this task blocks"), + add_blocked_by: tool.schema.array(tool.schema.string()).optional().describe("Add blocker task ids"), + metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Metadata patch (null removes key)"), + }, + execute: async (args: Record): Promise => { + try { + const input = TeamTaskUpdateInputSchema.parse(args) + readTeamConfigOrThrow(input.team_name) + + const task = updateTeamTask(input.team_name, input.task_id, { + status: input.status, + owner: input.owner, + subject: input.subject, + description: input.description, + activeForm: input.active_form, + addBlocks: input.add_blocks, + addBlockedBy: input.add_blocked_by, + metadata: input.metadata, + }) + + if (input.owner !== undefined) { + notifyOwnerAssignment(input.team_name, task) + } + + return JSON.stringify(task) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_update_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/team-task-update.ts b/src/tools/agent-teams/team-task-update.ts new file mode 100644 index 000000000..d7171ee73 --- /dev/null +++ b/src/tools/agent-teams/team-task-update.ts @@ -0,0 +1,210 @@ +import { existsSync, readdirSync, unlinkSync } from "node:fs" +import { join } from "node:path" +import { readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage" +import { getTeamTaskDir, getTeamTaskPath } from "./paths" +import { + addPendingEdge, + createPendingEdgeMap, + ensureDependenciesCompleted, + ensureForwardStatusTransition, + wouldCreateCycle, +} from "./team-task-dependency" +import { TeamTask, TeamTaskSchema, TeamTaskStatus } from "./types" +import { withTeamTaskLock } from "./team-task-store" + +export interface TeamTaskUpdatePatch { + status?: TeamTaskStatus + owner?: string + subject?: string + description?: string + activeForm?: string + addBlocks?: string[] + addBlockedBy?: string[] + metadata?: Record +} + +function writeTaskToPath(path: string, task: TeamTask): void { + writeJsonAtomic(path, TeamTaskSchema.parse(task)) +} + +export function updateTeamTask(teamName: string, taskId: string, patch: TeamTaskUpdatePatch): TeamTask { + return withTeamTaskLock(teamName, () => { + const taskDir = getTeamTaskDir(teamName) + const taskPath = getTeamTaskPath(teamName, taskId) + const currentTask = readJsonSafe(taskPath, TeamTaskSchema) + if (!currentTask) { + throw new Error("team_task_not_found") + } + + const cache = new Map() + cache.set(taskId, currentTask) + + const readTask = (id: string): TeamTask | null => { + if (cache.has(id)) { + return cache.get(id) ?? null + } + const loaded = readJsonSafe(join(taskDir, `${id}.json`), TeamTaskSchema) + cache.set(id, loaded) + return loaded + } + + const pendingEdges = createPendingEdgeMap() + + if (patch.addBlocks) { + for (const blockedTaskId of patch.addBlocks) { + if (blockedTaskId === taskId) { + throw new Error("team_task_self_block") + } + if (!readTask(blockedTaskId)) { + throw new Error(`team_task_reference_not_found:${blockedTaskId}`) + } + addPendingEdge(pendingEdges, blockedTaskId, taskId) + } + + for (const blockedTaskId of patch.addBlocks) { + if (wouldCreateCycle(blockedTaskId, taskId, pendingEdges, readTask)) { + throw new Error(`team_task_cycle_detected:${taskId}->${blockedTaskId}`) + } + } + } + + if (patch.addBlockedBy) { + for (const blockerId of patch.addBlockedBy) { + if (blockerId === taskId) { + throw new Error("team_task_self_dependency") + } + if (!readTask(blockerId)) { + throw new Error(`team_task_reference_not_found:${blockerId}`) + } + addPendingEdge(pendingEdges, taskId, blockerId) + } + + for (const blockerId of patch.addBlockedBy) { + if (wouldCreateCycle(taskId, blockerId, pendingEdges, readTask)) { + throw new Error(`team_task_cycle_detected:${taskId}<-${blockerId}`) + } + } + } + + if (patch.status && patch.status !== "deleted") { + ensureForwardStatusTransition(currentTask.status, patch.status) + const effectiveBlockedBy = Array.from(new Set([...(currentTask.blockedBy ?? []), ...(patch.addBlockedBy ?? [])])) + ensureDependenciesCompleted(patch.status, effectiveBlockedBy, readTask) + } + + let nextTask: TeamTask = { ...currentTask } + + if (patch.subject !== undefined) { + nextTask.subject = patch.subject + } + if (patch.description !== undefined) { + nextTask.description = patch.description + } + if (patch.activeForm !== undefined) { + nextTask.activeForm = patch.activeForm + } + if (patch.owner !== undefined) { + nextTask.owner = patch.owner + } + + const pendingWrites = new Map() + + if (patch.addBlocks) { + const existingBlocks = new Set(nextTask.blocks) + for (const blockedTaskId of patch.addBlocks) { + if (!existingBlocks.has(blockedTaskId)) { + nextTask.blocks.push(blockedTaskId) + existingBlocks.add(blockedTaskId) + } + + const otherPath = getTeamTaskPath(teamName, blockedTaskId) + const other = pendingWrites.get(otherPath) ?? readTask(blockedTaskId) + if (other && !other.blockedBy.includes(taskId)) { + pendingWrites.set(otherPath, { ...other, blockedBy: [...other.blockedBy, taskId] }) + } + } + } + + if (patch.addBlockedBy) { + const existingBlockedBy = new Set(nextTask.blockedBy) + for (const blockerId of patch.addBlockedBy) { + if (!existingBlockedBy.has(blockerId)) { + nextTask.blockedBy.push(blockerId) + existingBlockedBy.add(blockerId) + } + + const otherPath = getTeamTaskPath(teamName, blockerId) + const other = pendingWrites.get(otherPath) ?? readTask(blockerId) + if (other && !other.blocks.includes(taskId)) { + pendingWrites.set(otherPath, { ...other, blocks: [...other.blocks, taskId] }) + } + } + } + + if (patch.metadata !== undefined) { + const merged: Record = { ...(nextTask.metadata ?? {}) } + for (const [key, value] of Object.entries(patch.metadata)) { + if (value === null) { + delete merged[key] + } else { + merged[key] = value + } + } + nextTask.metadata = Object.keys(merged).length > 0 ? merged : undefined + } + + if (patch.status !== undefined) { + nextTask.status = patch.status + } + + const allTaskFiles = readdirSync(taskDir).filter((file) => file.endsWith(".json") && file.startsWith("T-")) + + if (nextTask.status === "completed") { + for (const file of allTaskFiles) { + const otherId = file.replace(/\.json$/, "") + if (otherId === taskId) continue + + const otherPath = getTeamTaskPath(teamName, otherId) + const other = pendingWrites.get(otherPath) ?? readTask(otherId) + if (other?.blockedBy.includes(taskId)) { + pendingWrites.set(otherPath, { + ...other, + blockedBy: other.blockedBy.filter((id) => id !== taskId), + }) + } + } + } + + if (patch.status === "deleted") { + for (const file of allTaskFiles) { + const otherId = file.replace(/\.json$/, "") + if (otherId === taskId) continue + + const otherPath = getTeamTaskPath(teamName, otherId) + const other = pendingWrites.get(otherPath) ?? readTask(otherId) + if (!other) continue + + const nextOther = { + ...other, + blockedBy: other.blockedBy.filter((id) => id !== taskId), + blocks: other.blocks.filter((id) => id !== taskId), + } + pendingWrites.set(otherPath, nextOther) + } + } + + for (const [path, task] of pendingWrites.entries()) { + writeTaskToPath(path, task) + } + + if (patch.status === "deleted") { + if (existsSync(taskPath)) { + unlinkSync(taskPath) + } + return TeamTaskSchema.parse({ ...nextTask, status: "deleted" }) + } + + writeTaskToPath(taskPath, nextTask) + return TeamTaskSchema.parse(nextTask) + }) +} diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts new file mode 100644 index 000000000..052a58c60 --- /dev/null +++ b/src/tools/agent-teams/teammate-runtime.ts @@ -0,0 +1,148 @@ +import type { BackgroundManager } from "../../features/background-agent" +import { ensureInbox, sendPlainInboxMessage } from "./inbox-store" +import { assignNextColor, getTeamMember, readTeamConfigOrThrow, removeTeammate, upsertTeammate, writeTeamConfig } from "./team-config-store" +import type { TeamTeammateMember, TeamToolContext } from "./types" + +function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined { + if (!model) { + return undefined + } + const [providerID, modelID] = model.split("/", 2) + if (!providerID || !modelID) { + return undefined + } + return { providerID, modelID } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function buildLaunchPrompt(teamName: string, teammateName: string, userPrompt: string): string { + return [ + `You are teammate "${teammateName}" in team "${teamName}".`, + `When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`, + "Initial assignment:", + userPrompt, + ].join("\n\n") +} + +function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string { + return [ + `New team message for "${teammateName}" in team "${teamName}".`, + `Summary: ${summary}`, + "Content:", + content, + ].join("\n\n") +} + +export interface SpawnTeammateParams { + teamName: string + name: string + prompt: string + subagentType: string + model?: string + planModeRequired: boolean + context: TeamToolContext + manager: BackgroundManager +} + +export async function spawnTeammate(params: SpawnTeammateParams): Promise { + const config = readTeamConfigOrThrow(params.teamName) + + if (getTeamMember(config, params.name)) { + throw new Error("teammate_already_exists") + } + + const teammate: TeamTeammateMember = { + agentId: `${params.name}@${params.teamName}`, + name: params.name, + agentType: params.subagentType, + model: params.model ?? "native", + prompt: params.prompt, + color: assignNextColor(config), + planModeRequired: params.planModeRequired, + joinedAt: Date.now(), + cwd: process.cwd(), + subscriptions: [], + backendType: "native", + isActive: false, + } + + writeTeamConfig(params.teamName, upsertTeammate(config, teammate)) + ensureInbox(params.teamName, params.name) + sendPlainInboxMessage(params.teamName, "team-lead", params.name, params.prompt, "initial_prompt", teammate.color) + + try { + const resolvedModel = parseModel(params.model) + const launched = await params.manager.launch({ + description: `[team:${params.teamName}] ${params.name}`, + prompt: buildLaunchPrompt(params.teamName, params.name, params.prompt), + agent: params.subagentType, + parentSessionID: params.context.sessionID, + parentMessageID: params.context.messageID, + ...(resolvedModel ? { model: resolvedModel } : {}), + parentAgent: params.context.agent, + }) + + const start = Date.now() + let sessionID = launched.sessionID + while (!sessionID && Date.now() - start < 30_000) { + await delay(50) + const task = params.manager.getTask(launched.id) + sessionID = task?.sessionID + } + + const nextMember: TeamTeammateMember = { + ...teammate, + isActive: true, + backgroundTaskID: launched.id, + ...(sessionID ? { sessionID } : {}), + } + + const current = readTeamConfigOrThrow(params.teamName) + writeTeamConfig(params.teamName, upsertTeammate(current, nextMember)) + return nextMember + } catch (error) { + const rollback = readTeamConfigOrThrow(params.teamName) + writeTeamConfig(params.teamName, removeTeammate(rollback, params.name)) + throw error + } +} + +export async function resumeTeammateWithMessage( + manager: BackgroundManager, + context: TeamToolContext, + teamName: string, + teammate: TeamTeammateMember, + summary: string, + content: string, +): Promise { + if (!teammate.sessionID) { + return + } + + try { + await manager.resume({ + sessionId: teammate.sessionID, + prompt: buildDeliveryPrompt(teamName, teammate.name, summary, content), + parentSessionID: context.sessionID, + parentMessageID: context.messageID, + parentAgent: context.agent, + }) + } catch { + return + } +} + +export async function cancelTeammateRun(manager: BackgroundManager, teammate: TeamTeammateMember): Promise { + if (!teammate.backgroundTaskID) { + return + } + + await manager.cancelTask(teammate.backgroundTaskID, { + source: "team_force_kill", + abortSession: true, + skipNotification: true, + }) +} diff --git a/src/tools/agent-teams/teammate-tools.ts b/src/tools/agent-teams/teammate-tools.ts new file mode 100644 index 000000000..062be0256 --- /dev/null +++ b/src/tools/agent-teams/teammate-tools.ts @@ -0,0 +1,121 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import type { BackgroundManager } from "../../features/background-agent" +import { validateAgentName, validateTeamName } from "./name-validation" +import { + TeamForceKillInputSchema, + TeamProcessShutdownInputSchema, + TeamSpawnInputSchema, + TeamToolContext, + isTeammateMember, +} from "./types" +import { getTeamMember, readTeamConfigOrThrow, removeTeammate, writeTeamConfig } from "./team-config-store" +import { cancelTeammateRun, spawnTeammate } from "./teammate-runtime" +import { resetOwnerTasks } from "./team-task-store" + +export function createSpawnTeammateTool(manager: BackgroundManager): ToolDefinition { + return tool({ + description: "Spawn a teammate using native internal agent execution.", + args: { + team_name: tool.schema.string().describe("Team name"), + name: tool.schema.string().describe("Teammate name"), + prompt: tool.schema.string().describe("Initial teammate prompt"), + subagent_type: tool.schema.string().optional().describe("Agent name to run (default: sisyphus-junior)"), + model: tool.schema.string().optional().describe("Optional model override in provider/model format"), + plan_mode_required: tool.schema.boolean().optional().describe("Enable plan mode flag in teammate metadata"), + }, + execute: async (args: Record, context: TeamToolContext): Promise => { + try { + const input = TeamSpawnInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + + const agentError = validateAgentName(input.name) + if (agentError) { + return JSON.stringify({ error: agentError }) + } + + const teammate = await spawnTeammate({ + teamName: input.team_name, + name: input.name, + prompt: input.prompt, + subagentType: input.subagent_type ?? "sisyphus-junior", + model: input.model, + planModeRequired: input.plan_mode_required ?? false, + context, + manager, + }) + + return JSON.stringify({ + agent_id: teammate.agentId, + name: teammate.name, + team_name: input.team_name, + session_id: teammate.sessionID, + task_id: teammate.backgroundTaskID, + }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "spawn_teammate_failed" }) + } + }, + }) +} + +export function createForceKillTeammateTool(manager: BackgroundManager): ToolDefinition { + return tool({ + description: "Force stop a teammate and clean up ownership state.", + args: { + team_name: tool.schema.string().describe("Team name"), + agent_name: tool.schema.string().describe("Teammate name"), + }, + execute: async (args: Record): Promise => { + try { + const input = TeamForceKillInputSchema.parse(args) + const config = readTeamConfigOrThrow(input.team_name) + const member = getTeamMember(config, input.agent_name) + if (!member || !isTeammateMember(member)) { + return JSON.stringify({ error: "teammate_not_found" }) + } + + await cancelTeammateRun(manager, member) + writeTeamConfig(input.team_name, removeTeammate(config, input.agent_name)) + resetOwnerTasks(input.team_name, input.agent_name) + + return JSON.stringify({ success: true, message: `${input.agent_name} stopped` }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "force_kill_teammate_failed" }) + } + }, + }) +} + +export function createProcessShutdownTool(): ToolDefinition { + return tool({ + description: "Finalize an approved shutdown by removing teammate and resetting owned tasks.", + args: { + team_name: tool.schema.string().describe("Team name"), + agent_name: tool.schema.string().describe("Teammate name"), + }, + execute: async (args: Record): Promise => { + try { + const input = TeamProcessShutdownInputSchema.parse(args) + if (input.agent_name === "team-lead") { + return JSON.stringify({ error: "cannot_shutdown_team_lead" }) + } + + const config = readTeamConfigOrThrow(input.team_name) + const member = getTeamMember(config, input.agent_name) + if (!member || !isTeammateMember(member)) { + return JSON.stringify({ error: "teammate_not_found" }) + } + + writeTeamConfig(input.team_name, removeTeammate(config, input.agent_name)) + resetOwnerTasks(input.team_name, input.agent_name) + + return JSON.stringify({ success: true, message: `${input.agent_name} removed` }) + } catch (error) { + return JSON.stringify({ error: error instanceof Error ? error.message : "process_shutdown_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/tools.ts b/src/tools/agent-teams/tools.ts new file mode 100644 index 000000000..dd4a26e44 --- /dev/null +++ b/src/tools/agent-teams/tools.ts @@ -0,0 +1,24 @@ +import type { ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../features/background-agent" +import { createReadInboxTool, createSendMessageTool } from "./messaging-tools" +import { createTeamCreateTool, createTeamDeleteTool, createTeamReadConfigTool } from "./team-lifecycle-tools" +import { createTeamTaskCreateTool, createTeamTaskGetTool, createTeamTaskListTool } from "./team-task-tools" +import { createTeamTaskUpdateTool } from "./team-task-update-tool" +import { createForceKillTeammateTool, createProcessShutdownTool, createSpawnTeammateTool } from "./teammate-tools" + +export function createAgentTeamsTools(manager: BackgroundManager): Record { + return { + team_create: createTeamCreateTool(), + team_delete: createTeamDeleteTool(), + spawn_teammate: createSpawnTeammateTool(manager), + send_message: createSendMessageTool(manager), + read_inbox: createReadInboxTool(), + read_config: createTeamReadConfigTool(), + team_task_create: createTeamTaskCreateTool(), + team_task_update: createTeamTaskUpdateTool(), + team_task_list: createTeamTaskListTool(), + team_task_get: createTeamTaskGetTool(), + force_kill_teammate: createForceKillTeammateTool(manager), + process_shutdown_approved: createProcessShutdownTool(), + } +} diff --git a/src/tools/agent-teams/types.ts b/src/tools/agent-teams/types.ts new file mode 100644 index 000000000..cfa76d76f --- /dev/null +++ b/src/tools/agent-teams/types.ts @@ -0,0 +1,180 @@ +import { z } from "zod" + +export const TEAM_COLOR_PALETTE = [ + "blue", + "green", + "yellow", + "purple", + "orange", + "pink", + "cyan", + "red", +] as const + +export const TeamTaskStatusSchema = z.enum(["pending", "in_progress", "completed", "deleted"]) +export type TeamTaskStatus = z.infer + +export const TeamLeadMemberSchema = z.object({ + agentId: z.string(), + name: z.literal("team-lead"), + agentType: z.literal("team-lead"), + model: z.string(), + joinedAt: z.number(), + cwd: z.string(), + subscriptions: z.array(z.unknown()).default([]), +}).strict() + +export const TeamTeammateMemberSchema = z.object({ + agentId: z.string(), + name: z.string(), + agentType: z.string(), + model: z.string(), + prompt: z.string(), + color: z.string(), + planModeRequired: z.boolean().default(false), + joinedAt: z.number(), + cwd: z.string(), + subscriptions: z.array(z.unknown()).default([]), + backendType: z.literal("native").default("native"), + isActive: z.boolean().default(false), + sessionID: z.string().optional(), + backgroundTaskID: z.string().optional(), +}).strict() + +export const TeamMemberSchema = z.union([TeamLeadMemberSchema, TeamTeammateMemberSchema]) + +export const TeamConfigSchema = z.object({ + name: z.string(), + description: z.string().default(""), + createdAt: z.number(), + leadAgentId: z.string(), + leadSessionId: z.string(), + members: z.array(TeamMemberSchema), +}).strict() + +export const TeamInboxMessageSchema = z.object({ + from: z.string(), + text: z.string(), + timestamp: z.string(), + read: z.boolean().default(false), + summary: z.string().optional(), + color: z.string().optional(), +}).strict() + +export const TeamTaskSchema = z.object({ + id: z.string(), + subject: z.string(), + description: z.string(), + activeForm: z.string().optional(), + status: TeamTaskStatusSchema, + blocks: z.array(z.string()).default([]), + blockedBy: z.array(z.string()).default([]), + owner: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}).strict() + +export const TeamSendMessageTypeSchema = z.enum([ + "message", + "broadcast", + "shutdown_request", + "shutdown_response", + "plan_approval_response", +]) + +export type TeamLeadMember = z.infer +export type TeamTeammateMember = z.infer +export type TeamMember = z.infer +export type TeamConfig = z.infer +export type TeamInboxMessage = z.infer +export type TeamTask = z.infer +export type TeamSendMessageType = z.infer + +export const TeamCreateInputSchema = z.object({ + team_name: z.string(), + description: z.string().optional(), +}) + +export const TeamDeleteInputSchema = z.object({ + team_name: z.string(), +}) + +export const TeamReadConfigInputSchema = z.object({ + team_name: z.string(), +}) + +export const TeamSpawnInputSchema = z.object({ + team_name: z.string(), + name: z.string(), + prompt: z.string(), + subagent_type: z.string().optional(), + model: z.string().optional(), + plan_mode_required: z.boolean().optional(), +}) + +export const TeamSendMessageInputSchema = z.object({ + team_name: z.string(), + type: TeamSendMessageTypeSchema, + recipient: z.string().optional(), + content: z.string().optional(), + summary: z.string().optional(), + request_id: z.string().optional(), + approve: z.boolean().optional(), + sender: z.string().optional(), +}) + +export const TeamReadInboxInputSchema = z.object({ + team_name: z.string(), + agent_name: z.string(), + unread_only: z.boolean().optional(), + mark_as_read: z.boolean().optional(), +}) + +export const TeamTaskCreateInputSchema = z.object({ + team_name: z.string(), + subject: z.string(), + description: z.string(), + active_form: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +export const TeamTaskUpdateInputSchema = z.object({ + team_name: z.string(), + task_id: z.string(), + status: TeamTaskStatusSchema.optional(), + owner: z.string().optional(), + subject: z.string().optional(), + description: z.string().optional(), + active_form: z.string().optional(), + add_blocks: z.array(z.string()).optional(), + add_blocked_by: z.array(z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +export const TeamTaskListInputSchema = z.object({ + team_name: z.string(), +}) + +export const TeamTaskGetInputSchema = z.object({ + team_name: z.string(), + task_id: z.string(), +}) + +export const TeamForceKillInputSchema = z.object({ + team_name: z.string(), + agent_name: z.string(), +}) + +export const TeamProcessShutdownInputSchema = z.object({ + team_name: z.string(), + agent_name: z.string(), +}) + +export interface TeamToolContext { + sessionID: string + messageID: string + agent?: string +} + +export function isTeammateMember(member: TeamMember): member is TeamTeammateMember { + return member.agentType !== "team-lead" +} diff --git a/src/tools/index.ts b/src/tools/index.ts index a38a6c74c..9287d9568 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -37,6 +37,7 @@ type OpencodeClient = PluginInput["client"] export { createCallOmoAgent } from "./call-omo-agent" export { createLookAt } from "./look-at" export { createDelegateTask } from "./delegate-task" +export { createAgentTeamsTools } from "./agent-teams" export { createTaskCreateTool, createTaskGetTool,