feat(06-01): add athena council execution tool

- add athena_council tool scaffolding and runtime execution bridge

- poll background tasks before returning synthesized council output
This commit is contained in:
ismeth
2026-02-12 16:03:20 +01:00
committed by YeonGyu-Kim
parent 5ef5a5ac4d
commit 362f446b46
6 changed files with 229 additions and 0 deletions

View File

@@ -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.`

View File

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

View File

@@ -0,0 +1 @@
export { createAthenaCouncilTool } from "./tools"

View File

@@ -0,0 +1,62 @@
/// <reference types="bun-types" />
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<string, unknown> }).args.question).toBeDefined()
})
})

View File

@@ -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<BackgroundTaskStatus> = 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<Map<string, BackgroundTask>> {
const settledIds = new Set<string>()
const latestTasks = new Map<string, BackgroundTask>()
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)
},
})
}

View File

@@ -0,0 +1,3 @@
export interface AthenaCouncilToolArgs {
question: string
}