diff --git a/src/tools/athena-council/constants.ts b/src/tools/athena-council/constants.ts new file mode 100644 index 00000000..ef4552a8 --- /dev/null +++ b/src/tools/athena-council/constants.ts @@ -0,0 +1,7 @@ +export const ATHENA_COUNCIL_TOOL_DESCRIPTION = `Execute Athena's multi-model council. Sends the question to all configured council members in parallel and returns their collected responses. + +This tool reads council member configuration from the plugin config (agents.athena.council.members). Each member runs as an independent background agent with their configured model, variant, and temperature. + +Returns council member responses with status, response text, and timing. Use this output for synthesis. + +IMPORTANT: This tool is designed for Athena agent use only. It requires council configuration to be present.` diff --git a/src/tools/athena-council/council-launcher.ts b/src/tools/athena-council/council-launcher.ts new file mode 100644 index 00000000..0f83ba5c --- /dev/null +++ b/src/tools/athena-council/council-launcher.ts @@ -0,0 +1,18 @@ +import type { CouncilLauncher, CouncilLaunchInput } from "../../agents/athena/council-orchestrator" +import type { BackgroundManager } from "../../features/background-agent" + +export function createCouncilLauncher(manager: BackgroundManager): CouncilLauncher { + return { + launch(input: CouncilLaunchInput) { + return manager.launch({ + description: input.description, + prompt: input.prompt, + agent: input.agent, + parentSessionID: input.parentSessionID, + parentMessageID: input.parentMessageID, + parentAgent: input.parentAgent, + model: input.model, + }) + }, + } +} diff --git a/src/tools/athena-council/index.ts b/src/tools/athena-council/index.ts new file mode 100644 index 00000000..ef1a860e --- /dev/null +++ b/src/tools/athena-council/index.ts @@ -0,0 +1 @@ +export { createAthenaCouncilTool } from "./tools" diff --git a/src/tools/athena-council/tools.test.ts b/src/tools/athena-council/tools.test.ts new file mode 100644 index 00000000..fb008c9a --- /dev/null +++ b/src/tools/athena-council/tools.test.ts @@ -0,0 +1,62 @@ +/// + +import { describe, expect, test } from "bun:test" +import type { BackgroundManager } from "../../features/background-agent" +import { ATHENA_COUNCIL_TOOL_DESCRIPTION } from "./constants" +import { createAthenaCouncilTool } from "./tools" + +const mockManager = { + getTask: () => undefined, + launch: async () => { + throw new Error("launch should not be called in config validation tests") + }, +} as unknown as BackgroundManager + +const mockToolContext = { + sessionID: "session-1", + messageID: "message-1", + agent: "athena", + abort: new AbortController().signal, +} + +describe("createAthenaCouncilTool", () => { + test("returns error when councilConfig is undefined", async () => { + // #given + const athenaCouncilTool = createAthenaCouncilTool({ + backgroundManager: mockManager, + councilConfig: undefined, + }) + + // #when + const result = await athenaCouncilTool.execute({ question: "How should we proceed?" }, mockToolContext) + + // #then + expect(result).toBe("Athena council not configured. Add agents.athena.council.members to your config.") + }) + + test("returns error when councilConfig has empty members", async () => { + // #given + const athenaCouncilTool = createAthenaCouncilTool({ + backgroundManager: mockManager, + councilConfig: { members: [] }, + }) + + // #when + const result = await athenaCouncilTool.execute({ question: "Any concerns?" }, mockToolContext) + + // #then + expect(result).toBe("Athena council not configured. Add agents.athena.council.members to your config.") + }) + + test("uses expected description and question arg schema", () => { + // #given + const athenaCouncilTool = createAthenaCouncilTool({ + backgroundManager: mockManager, + councilConfig: { members: [{ model: "openai/gpt-5.3-codex" }] }, + }) + + // #then + expect(athenaCouncilTool.description).toBe(ATHENA_COUNCIL_TOOL_DESCRIPTION) + expect((athenaCouncilTool as { args: Record }).args.question).toBeDefined() + }) +}) diff --git a/src/tools/athena-council/tools.ts b/src/tools/athena-council/tools.ts new file mode 100644 index 00000000..8dbebd98 --- /dev/null +++ b/src/tools/athena-council/tools.ts @@ -0,0 +1,138 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import { executeCouncil } from "../../agents/athena/council-orchestrator" +import type { CouncilConfig, CouncilMemberResponse } from "../../agents/athena/types" +import type { BackgroundManager, BackgroundTask, BackgroundTaskStatus } from "../../features/background-agent" +import { ATHENA_COUNCIL_TOOL_DESCRIPTION } from "./constants" +import { createCouncilLauncher } from "./council-launcher" +import type { AthenaCouncilToolArgs } from "./types" + +const WAIT_INTERVAL_MS = 200 +const WAIT_TIMEOUT_MS = 120000 +const TERMINAL_STATUSES: Set = new Set(["completed", "error", "cancelled", "interrupt"]) + +function isCouncilConfigured(councilConfig: CouncilConfig | undefined): councilConfig is CouncilConfig { + return Boolean(councilConfig && councilConfig.members.length > 0) +} + +async function waitForTasksToSettle( + taskIds: string[], + manager: BackgroundManager, + abortSignal: AbortSignal +): Promise> { + const settledIds = new Set() + const latestTasks = new Map() + const startedAt = Date.now() + + while (settledIds.size < taskIds.length && Date.now() - startedAt < WAIT_TIMEOUT_MS) { + if (abortSignal.aborted) { + break + } + + for (const taskId of taskIds) { + if (settledIds.has(taskId)) { + continue + } + + const task = manager.getTask(taskId) + if (!task) { + continue + } + + latestTasks.set(taskId, task) + if (TERMINAL_STATUSES.has(task.status)) { + settledIds.add(taskId) + } + } + + if (settledIds.size < taskIds.length) { + await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS)) + } + } + + return latestTasks +} + +function mapTaskStatus(status: BackgroundTaskStatus): CouncilMemberResponse["status"] { + if (status === "completed") { + return "completed" + } + + if (status === "cancelled" || status === "interrupt") { + return "timeout" + } + + return "error" +} + +function refreshResponse(response: CouncilMemberResponse, task: BackgroundTask | undefined): CouncilMemberResponse { + if (!task) { + return response + } + + const status = mapTaskStatus(task.status) + const durationMs = + task.startedAt && task.completedAt + ? Math.max(0, task.completedAt.getTime() - task.startedAt.getTime()) + : response.durationMs + + return { + ...response, + status, + response: status === "completed" ? task.result : undefined, + error: status === "completed" ? undefined : (task.error ?? `Task status: ${task.status}`), + durationMs, + } +} + +function formatCouncilOutput(responses: CouncilMemberResponse[], totalMembers: number): string { + const completedCount = responses.filter((item) => item.status === "completed").length + const lines = responses.map((item, index) => { + const model = item.member.name ?? item.member.model + const content = item.status === "completed" + ? (item.response ?? "(no response)") + : (item.error ?? "Unknown error") + + return `${index + 1}. ${model}\n Status: ${item.status}\n Result: ${content}` + }) + + return `${completedCount}/${totalMembers} council members completed.\n\n${lines.join("\n\n")}` +} + +export function createAthenaCouncilTool(args: { + backgroundManager: BackgroundManager + councilConfig: CouncilConfig | undefined +}): ToolDefinition { + const { backgroundManager, councilConfig } = args + + return tool({ + description: ATHENA_COUNCIL_TOOL_DESCRIPTION, + args: { + question: tool.schema.string().describe("The question to send to all council members"), + }, + async execute(toolArgs: AthenaCouncilToolArgs, toolContext) { + if (!isCouncilConfigured(councilConfig)) { + return "Athena council not configured. Add agents.athena.council.members to your config." + } + + const execution = await executeCouncil({ + question: toolArgs.question, + council: councilConfig, + launcher: createCouncilLauncher(backgroundManager), + parentSessionID: toolContext.sessionID, + parentMessageID: toolContext.messageID, + parentAgent: toolContext.agent, + }) + + const taskIds = execution.responses + .map((response) => response.taskId) + .filter((taskId) => taskId.length > 0) + + const latestTasks = await waitForTasksToSettle(taskIds, backgroundManager, toolContext.abort) + const refreshedResponses = execution.responses.map((response) => + response.taskId ? refreshResponse(response, latestTasks.get(response.taskId)) : response + ) + + return formatCouncilOutput(refreshedResponses, execution.totalMembers) + }, + }) +} diff --git a/src/tools/athena-council/types.ts b/src/tools/athena-council/types.ts new file mode 100644 index 00000000..1593fcc9 --- /dev/null +++ b/src/tools/athena-council/types.ts @@ -0,0 +1,3 @@ +export interface AthenaCouncilToolArgs { + question: string +}