diff --git a/src/tools/athena-council/tools.test.ts b/src/tools/athena-council/tools.test.ts index fb008c9a..166213a2 100644 --- a/src/tools/athena-council/tools.test.ts +++ b/src/tools/athena-council/tools.test.ts @@ -3,7 +3,7 @@ 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" +import { createAthenaCouncilTool, filterCouncilMembers } from "./tools" const mockManager = { getTask: () => undefined, @@ -19,6 +19,78 @@ const mockToolContext = { abort: new AbortController().signal, } +const configuredMembers = [ + { name: "Claude", model: "anthropic/claude-sonnet-4-5" }, + { name: "GPT", model: "openai/gpt-5.3-codex" }, + { model: "google/gemini-3-pro" }, +] + +describe("filterCouncilMembers", () => { + test("returns all members when selection is undefined", () => { + // #given + const selectedMembers = undefined + + // #when + const result = filterCouncilMembers(configuredMembers, selectedMembers) + + // #then + expect(result.members).toEqual(configuredMembers) + expect(result.error).toBeUndefined() + }) + + test("returns all members when selection is empty", () => { + // #given + const selectedMembers: string[] = [] + + // #when + const result = filterCouncilMembers(configuredMembers, selectedMembers) + + // #then + expect(result.members).toEqual(configuredMembers) + expect(result.error).toBeUndefined() + }) + + test("filters members using case-insensitive name and model matching", () => { + // #given + const selectedMembers = ["gpt", "GOOGLE/GEMINI-3-PRO"] + + // #when + const result = filterCouncilMembers(configuredMembers, selectedMembers) + + // #then + expect(result.members).toEqual([configuredMembers[1], configuredMembers[2]]) + expect(result.error).toBeUndefined() + }) + + test("returns helpful error when selected members are not configured", () => { + // #given + const selectedMembers = ["mistral", "xai/grok-3"] + + // #when + const result = filterCouncilMembers(configuredMembers, selectedMembers) + + // #then + expect(result.members).toEqual([]) + expect(result.error).toBe( + "Unknown council members: mistral, xai/grok-3. Available members: Claude, GPT, google/gemini-3-pro." + ) + }) + + test("returns error listing only unmatched names when partially matched", () => { + // #given + const selectedMembers = ["claude", "non-existent"] + + // #when + const result = filterCouncilMembers(configuredMembers, selectedMembers) + + // #then + expect(result.members).toEqual([]) + expect(result.error).toBe( + "Unknown council members: non-existent. Available members: Claude, GPT, google/gemini-3-pro." + ) + }) +}) + describe("createAthenaCouncilTool", () => { test("returns error when councilConfig is undefined", async () => { // #given @@ -58,5 +130,24 @@ describe("createAthenaCouncilTool", () => { // #then expect(athenaCouncilTool.description).toBe(ATHENA_COUNCIL_TOOL_DESCRIPTION) expect((athenaCouncilTool as { args: Record }).args.question).toBeDefined() + expect((athenaCouncilTool as { args: Record }).args.members).toBeDefined() + }) + + test("returns helpful error when members contains invalid names", async () => { + // #given + const athenaCouncilTool = createAthenaCouncilTool({ + backgroundManager: mockManager, + councilConfig: { members: configuredMembers }, + }) + const toolArgs = { + question: "Who should investigate this?", + members: ["unknown-model"], + } + + // #when + const result = await athenaCouncilTool.execute(toolArgs, mockToolContext) + + // #then + expect(result).toBe("Unknown council members: unknown-model. Available members: Claude, GPT, google/gemini-3-pro.") }) }) diff --git a/src/tools/athena-council/tools.ts b/src/tools/athena-council/tools.ts index 6cb631f6..0560a7ff 100644 --- a/src/tools/athena-council/tools.ts +++ b/src/tools/athena-council/tools.ts @@ -1,6 +1,6 @@ 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 { CouncilConfig, CouncilMemberConfig, 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" @@ -101,6 +101,57 @@ function formatCouncilOutput(responses: CouncilMemberResponse[], totalMembers: n return `${completedCount}/${totalMembers} council members completed.\n\n${lines.join("\n\n")}` } +interface FilterCouncilMembersResult { + members: CouncilMemberConfig[] + error?: string +} + +export function filterCouncilMembers( + members: CouncilMemberConfig[], + selectedNames: string[] | undefined +): FilterCouncilMembersResult { + if (!selectedNames || selectedNames.length === 0) { + return { members } + } + + const memberLookup = new Map() + members.forEach((member) => { + const key = (member.name ?? member.model).toLowerCase() + memberLookup.set(key, member) + }) + + const unresolved: string[] = [] + const filteredMembers: CouncilMemberConfig[] = [] + const includedMemberKeys = new Set() + + selectedNames.forEach((selectedName) => { + const selectedKey = selectedName.toLowerCase() + const matchedMember = memberLookup.get(selectedKey) + if (!matchedMember) { + unresolved.push(selectedName) + return + } + + const memberKey = matchedMember.model + if (includedMemberKeys.has(memberKey)) { + return + } + + includedMemberKeys.add(memberKey) + filteredMembers.push(matchedMember) + }) + + if (unresolved.length > 0) { + const availableNames = members.map((member) => member.name ?? member.model).join(", ") + return { + members: [], + error: `Unknown council members: ${unresolved.join(", ")}. Available members: ${availableNames}.`, + } + } + + return { members: filteredMembers } +} + export function createAthenaCouncilTool(args: { backgroundManager: BackgroundManager councilConfig: CouncilConfig | undefined @@ -111,12 +162,21 @@ export function createAthenaCouncilTool(args: { description: ATHENA_COUNCIL_TOOL_DESCRIPTION, args: { question: tool.schema.string().describe("The question to send to all council members"), + members: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Optional list of council member names or models to consult. Defaults to all configured members."), }, async execute(toolArgs: AthenaCouncilToolArgs, toolContext) { if (!isCouncilConfigured(councilConfig)) { return "Athena council not configured. Add agents.athena.council.members to your config." } + const filteredMembers = filterCouncilMembers(councilConfig.members, toolArgs.members) + if (filteredMembers.error) { + return filteredMembers.error + } + if (activeCouncilSessions.has(toolContext.sessionID)) { return "Council is already running for this session. Wait for the current council execution to complete." } @@ -125,7 +185,7 @@ export function createAthenaCouncilTool(args: { try { const execution = await executeCouncil({ question: toolArgs.question, - council: councilConfig, + council: { members: filteredMembers.members }, launcher: createCouncilLauncher(backgroundManager), parentSessionID: toolContext.sessionID, parentMessageID: toolContext.messageID, diff --git a/src/tools/athena-council/types.ts b/src/tools/athena-council/types.ts index 1593fcc9..c1d14ef3 100644 --- a/src/tools/athena-council/types.ts +++ b/src/tools/athena-council/types.ts @@ -1,3 +1,4 @@ export interface AthenaCouncilToolArgs { question: string + members?: string[] }