From 5816cdddc66ec8aed7b9697cb02ed5b0f4a40fb4 Mon Sep 17 00:00:00 2001 From: ismeth Date: Fri, 13 Feb 2026 11:19:25 +0100 Subject: [PATCH] feat(08-01): return council task ids without blocking - make athena_council launch-only and remove internal polling/formatting - return JSON payload with running task mappings and launch failures - update tool tests for task-id visibility, filtering, failure reporting, and dedup --- src/tools/athena-council/constants.ts | 4 +- src/tools/athena-council/tools.test.ts | 159 ++++++++++++++++++++++++- src/tools/athena-council/tools.ts | 126 ++++---------------- src/tools/athena-council/types.ts | 19 +++ 4 files changed, 204 insertions(+), 104 deletions(-) diff --git a/src/tools/athena-council/constants.ts b/src/tools/athena-council/constants.ts index 00e7f7240..af11f7e00 100644 --- a/src/tools/athena-council/constants.ts +++ b/src/tools/athena-council/constants.ts @@ -1,9 +1,9 @@ -export const ATHENA_COUNCIL_TOOL_DESCRIPTION_TEMPLATE = `Execute Athena's multi-model council. Sends the question to all configured council members in parallel and returns their collected responses. +export const ATHENA_COUNCIL_TOOL_DESCRIPTION_TEMPLATE = `Execute Athena's multi-model council. Launches council members as background tasks and returns their task IDs immediately. Optionally pass a members array of member names or model IDs to consult only specific council members. If omitted, all configured members are consulted. {members} -Returns council member responses with status, response text, and timing. Use this output for synthesis. +Use background_output(task_id=...) to retrieve each member's response. The system will notify you when tasks complete. IMPORTANT: This tool is designed for Athena agent use only. It requires council configuration to be present.` diff --git a/src/tools/athena-council/tools.test.ts b/src/tools/athena-council/tools.test.ts index 7c678e4d4..6ba73ffb3 100644 --- a/src/tools/athena-council/tools.test.ts +++ b/src/tools/athena-council/tools.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" -import { ATHENA_COUNCIL_TOOL_DESCRIPTION_TEMPLATE } from "./constants" +import type { BackgroundTask } from "../../features/background-agent/types" import { createAthenaCouncilTool, filterCouncilMembers } from "./tools" const mockManager = { @@ -25,6 +25,27 @@ const configuredMembers = [ { model: "google/gemini-3-pro" }, ] +function createRunningTask(id: string): BackgroundTask { + return { + id, + parentSessionID: "session-1", + parentMessageID: "message-1", + description: `Council member task ${id}`, + prompt: "prompt", + agent: "athena", + status: "running", + } +} + +function parseLaunchResult(result: unknown): { + launched: number + members: Array<{ task_id: string; name: string; model: string; status: string }> + failed: Array<{ name: string; model: string; error: string }> +} { + expect(typeof result).toBe("string") + return JSON.parse(result as string) +} + describe("filterCouncilMembers", () => { test("returns all members when selection is undefined", () => { // #given @@ -151,4 +172,140 @@ describe("createAthenaCouncilTool", () => { // #then expect(result).toBe("Unknown council members: unknown-model. Available members: Claude, GPT, google/gemini-3-pro.") }) + + test("returns launched task_ids and member mapping for configured council", async () => { + // #given + let launchCount = 0 + const launchManager = { + launch: async () => { + launchCount += 1 + return createRunningTask(`bg-${launchCount}`) + }, + getTask: () => undefined, + } as unknown as BackgroundManager + const athenaCouncilTool = createAthenaCouncilTool({ + backgroundManager: launchManager, + councilConfig: { members: configuredMembers }, + }) + + // #when + const result = await athenaCouncilTool.execute({ question: "How should we proceed?" }, mockToolContext) + const parsed = parseLaunchResult(result) + + // #then + expect(parsed.launched).toBe(3) + expect(parsed.failed).toEqual([]) + expect(parsed.members).toEqual([ + { + task_id: "bg-1", + name: "Claude", + model: "anthropic/claude-sonnet-4-5", + status: "running", + }, + { + task_id: "bg-2", + name: "GPT", + model: "openai/gpt-5.3-codex", + status: "running", + }, + { + task_id: "bg-3", + name: "google/gemini-3-pro", + model: "google/gemini-3-pro", + status: "running", + }, + ]) + }) + + test("returns task_ids length matching selected members", async () => { + // #given + let launchCount = 0 + const launchManager = { + launch: async () => { + launchCount += 1 + return createRunningTask(`bg-${launchCount}`) + }, + getTask: () => undefined, + } as unknown as BackgroundManager + const athenaCouncilTool = createAthenaCouncilTool({ + backgroundManager: launchManager, + councilConfig: { members: configuredMembers }, + }) + + // #when + const result = await athenaCouncilTool.execute( + { + question: "Who should investigate this?", + members: ["GPT", "google/gemini-3-pro"], + }, + mockToolContext + ) + const parsed = parseLaunchResult(result) + + // #then + expect(parsed.launched).toBe(2) + expect(parsed.members).toHaveLength(2) + expect(parsed.members.map((member) => member.name)).toEqual(["GPT", "google/gemini-3-pro"]) + }) + + test("returns failed launches inline while keeping successful task mappings", async () => { + // #given + let launchCount = 0 + const launchManager = { + launch: async () => { + launchCount += 1 + if (launchCount === 2) { + throw new Error("provider outage") + } + + return createRunningTask(`bg-${launchCount}`) + }, + getTask: () => undefined, + } as unknown as BackgroundManager + const athenaCouncilTool = createAthenaCouncilTool({ + backgroundManager: launchManager, + councilConfig: { members: configuredMembers }, + }) + + // #when + const result = await athenaCouncilTool.execute({ question: "Any concerns?" }, mockToolContext) + const parsed = parseLaunchResult(result) + + // #then + expect(parsed.launched).toBe(2) + expect(parsed.members).toHaveLength(2) + expect(parsed.failed).toHaveLength(1) + expect(parsed.failed[0]).toEqual({ + name: "GPT", + model: "openai/gpt-5.3-codex", + error: "Launch failed: Error: provider outage", + }) + }) + + test("returns dedup error when council is already running in same session", async () => { + // #given + let resolveLaunch: ((task: BackgroundTask) => void) | undefined + const pendingLaunch = new Promise((resolve) => { + resolveLaunch = resolve + }) + const launchManager = { + launch: async () => pendingLaunch, + getTask: () => undefined, + } as unknown as BackgroundManager + const athenaCouncilTool = createAthenaCouncilTool({ + backgroundManager: launchManager, + councilConfig: { members: [{ model: "openai/gpt-5.3-codex" }] }, + }) + + // #when + const firstExecution = athenaCouncilTool.execute({ question: "First run" }, mockToolContext) + const secondExecution = await athenaCouncilTool.execute({ question: "Second run" }, mockToolContext) + + resolveLaunch?.(createRunningTask("bg-dedup")) + const firstResult = parseLaunchResult(await firstExecution) + + // #then + expect(secondExecution).toBe("Council is already running for this session. Wait for the current council execution to complete.") + expect(firstResult.launched).toBe(1) + }) }) diff --git a/src/tools/athena-council/tools.ts b/src/tools/athena-council/tools.ts index 6eaa937dd..15e6205c0 100644 --- a/src/tools/athena-council/tools.ts +++ b/src/tools/athena-council/tools.ts @@ -1,14 +1,10 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" import { executeCouncil } from "../../agents/athena/council-orchestrator" -import type { CouncilConfig, CouncilMemberConfig, CouncilMemberResponse } from "../../agents/athena/types" -import type { BackgroundManager, BackgroundTask, BackgroundTaskStatus } from "../../features/background-agent" +import type { CouncilConfig, CouncilMemberConfig } from "../../agents/athena/types" +import type { BackgroundManager } from "../../features/background-agent" import { ATHENA_COUNCIL_TOOL_DESCRIPTION_TEMPLATE } from "./constants" import { createCouncilLauncher } from "./council-launcher" -import type { AthenaCouncilToolArgs } from "./types" - -const WAIT_INTERVAL_MS = 500 -const WAIT_TIMEOUT_MS = 600000 -const TERMINAL_STATUSES: Set = new Set(["completed", "error", "cancelled", "interrupt"]) +import type { AthenaCouncilLaunchResult, AthenaCouncilToolArgs } from "./types" /** Tracks active council executions per session to prevent duplicate launches. */ const activeCouncilSessions = new Set() @@ -17,90 +13,6 @@ function isCouncilConfigured(councilConfig: CouncilConfig | undefined): councilC 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")}` -} - interface FilterCouncilMembersResult { members: CouncilMemberConfig[] error?: string @@ -201,18 +113,30 @@ export function createAthenaCouncilTool(args: { parentAgent: toolContext.agent, }) - const taskIds = execution.responses - .map((response) => response.taskId) - .filter((taskId) => taskId.length > 0) + const launchResult: AthenaCouncilLaunchResult = { + launched: execution.responses.filter((response) => response.taskId.length > 0).length, + members: execution.responses + .filter((response) => response.taskId.length > 0) + .map((response) => ({ + task_id: response.taskId, + name: response.member.name ?? response.member.model, + model: response.member.model, + status: "running", + })), + failed: execution.responses + .filter((response) => response.taskId.length === 0) + .map((response) => ({ + name: response.member.name ?? response.member.model, + model: response.member.model, + error: response.error ?? "Launch failed", + })), + } - 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) - } finally { activeCouncilSessions.delete(toolContext.sessionID) + return JSON.stringify(launchResult) + } catch (error) { + activeCouncilSessions.delete(toolContext.sessionID) + throw error } }, }) diff --git a/src/tools/athena-council/types.ts b/src/tools/athena-council/types.ts index c1d14ef30..72c2d4745 100644 --- a/src/tools/athena-council/types.ts +++ b/src/tools/athena-council/types.ts @@ -2,3 +2,22 @@ export interface AthenaCouncilToolArgs { question: string members?: string[] } + +export interface AthenaCouncilLaunchedMember { + task_id: string + name: string + model: string + status: "running" +} + +export interface AthenaCouncilFailedMember { + name: string + model: string + error: string +} + +export interface AthenaCouncilLaunchResult { + launched: number + members: AthenaCouncilLaunchedMember[] + failed: AthenaCouncilFailedMember[] +}