feat(athena-junior): add athena_council bulk launch tool
- Create src/tools/athena-council/ with council-launcher, tool factory, types - Launch all council members in parallel via BackgroundManager with writeOutputToFile: true - Wire tool into registry with backgroundManager + councilConfig dependencies - Grant athena-junior athena_council permission, remove task permission - Update non-interactive prompt to use athena_council instead of task tool - Add explicit stop-after-result instruction to prevent post-output text - 23 tests (14 tool + 9 launcher) using real temp files, no mock.module
This commit is contained in:
@@ -20,8 +20,8 @@ background_wait_timeout_ms: {BACKGROUND_WAIT_TIMEOUT_MS}
|
||||
</runtime_config>
|
||||
|
||||
<registered_council_members>
|
||||
Council members are listed at the end of this prompt after config injection.
|
||||
Use EXACTLY the subagent_type names listed there.
|
||||
Council members are listed in the athena_council tool description.
|
||||
Use member names from there when filtering with the members parameter.
|
||||
</registered_council_members>
|
||||
|
||||
<workflow>
|
||||
@@ -55,14 +55,12 @@ Precedence for ambiguous cases: DIAGNOSE > AUDIT > PLAN > EVALUATE > EXPLAIN > C
|
||||
- mode: from Step 2
|
||||
- intent: from Step 3
|
||||
|
||||
#### Step 4.2: For each resolved member, call the task tool with:
|
||||
- subagent_type: the exact member name from registered council members
|
||||
- run_in_background: true
|
||||
- write_output_to_file: true
|
||||
- prompt: "Read <path> for your instructions." (path from Step 4.1)
|
||||
- load_skills: []
|
||||
- description: the member name
|
||||
Launch ALL members before collecting results. Track every task_id.
|
||||
#### Step 4.2: Call athena_council to launch ALL members at once:
|
||||
- prompt_file: the path returned from Step 4.1
|
||||
- members: the resolved member names from Step 1 (omit to launch all configured members)
|
||||
|
||||
athena_council launches all members in parallel and returns JSON with task IDs.
|
||||
Track every task_id from the response for use in Step 5.
|
||||
|
||||
### Step 5: Track progress with background_wait.
|
||||
- Call background_wait(task_ids=[...all task IDs...], timeout={BACKGROUND_WAIT_TIMEOUT_MS}).
|
||||
@@ -134,11 +132,13 @@ After synthesis, you MUST output EXACTLY this structured format:
|
||||
}
|
||||
</athena_council_result>
|
||||
|
||||
IMPORTANT: </athena_council_result> is your FINAL output. Do NOT output anything after this tag.
|
||||
No commentary, no summary, no follow-up text. The closing tag IS the end of your response.
|
||||
|
||||
Status values:
|
||||
- "complete": Quorum met, synthesis performed
|
||||
- "partial": Some members failed but quorum met
|
||||
- "failed": Quorum not met (<2 successful members)
|
||||
</output_contract>
|
||||
|
||||
<constraints>
|
||||
- NEVER use the Question tool — it is unavailable in non-interactive mode.
|
||||
@@ -148,6 +148,6 @@ Status values:
|
||||
- ALWAYS auto-select analysis mode from config (Step 2).
|
||||
- ALWAYS return the <athena_council_result> structured output.
|
||||
- Use background_wait for progress tracking and council_finalize for result collection.
|
||||
- Preserve confidence caveats (especially single-member claims) in synthesis.
|
||||
- After outputting </athena_council_result>, STOP IMMEDIATELY. No text after the closing tag.
|
||||
</constraints>
|
||||
`
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createTaskUpdateTool,
|
||||
createHashlineEditTool,
|
||||
createPrepareCouncilPromptTool,
|
||||
createAthenaCouncilTool,
|
||||
} from "../tools"
|
||||
import { createCouncilFinalize } from "../tools/council-archive"
|
||||
import { getMainSessionID } from "../features/claude-code-session-state"
|
||||
@@ -282,6 +283,10 @@ export function createToolRegistry(args: {
|
||||
...hashlineToolsRecord,
|
||||
prepare_council_prompt: createPrepareCouncilPromptTool(ctx.directory),
|
||||
council_finalize: createCouncilFinalize(ctx.directory),
|
||||
athena_council: createAthenaCouncilTool({
|
||||
backgroundManager: managers.backgroundManager,
|
||||
councilConfig: pluginConfig.agents?.athena?.council,
|
||||
}),
|
||||
}
|
||||
|
||||
for (const toolDefinition of Object.values(allTools)) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test"
|
||||
import { launchCouncilMember } from "./council-launcher"
|
||||
import type { CouncilLaunchContext } from "./council-launcher"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { CouncilMemberConfig } from "../../config/schema/athena"
|
||||
|
||||
const makeManager = (overrides?: Partial<BackgroundManager>): BackgroundManager =>
|
||||
({
|
||||
launch: mock(async () => ({
|
||||
id: "task-123",
|
||||
sessionID: undefined,
|
||||
status: "running",
|
||||
})),
|
||||
getTask: mock(() => undefined),
|
||||
...overrides,
|
||||
}) as unknown as BackgroundManager
|
||||
|
||||
const makeContext = (): CouncilLaunchContext => ({
|
||||
parentSessionID: "ses-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
parentAgent: "athena",
|
||||
})
|
||||
|
||||
describe("launchCouncilMember", () => {
|
||||
describe("#given a valid member with provider/model format", () => {
|
||||
describe("#when launched successfully", () => {
|
||||
let manager: BackgroundManager
|
||||
let result: Awaited<ReturnType<typeof launchCouncilMember>>
|
||||
|
||||
beforeEach(async () => {
|
||||
manager = makeManager()
|
||||
const member: CouncilMemberConfig = {
|
||||
name: "Claude Opus",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
}
|
||||
result = await launchCouncilMember(member, "analyze this", manager, makeContext())
|
||||
})
|
||||
|
||||
it("#then returns the member in the outcome", () => {
|
||||
expect(result.member.name).toBe("Claude Opus")
|
||||
})
|
||||
|
||||
it("#then returns a task with an id", () => {
|
||||
expect(result.task.id).toBe("task-123")
|
||||
})
|
||||
|
||||
it("#then calls manager.launch once", () => {
|
||||
expect(manager.launch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("#then passes the correct agent key with Council: prefix", () => {
|
||||
const launchArgs = (manager.launch as ReturnType<typeof mock>).mock.calls[0][0]
|
||||
expect(launchArgs.agent).toBe("Council: Claude Opus")
|
||||
})
|
||||
|
||||
it("#then passes the prompt content", () => {
|
||||
const launchArgs = (manager.launch as ReturnType<typeof mock>).mock.calls[0][0]
|
||||
expect(launchArgs.prompt).toBe("analyze this")
|
||||
})
|
||||
|
||||
it("#then passes the correct providerID", () => {
|
||||
const launchArgs = (manager.launch as ReturnType<typeof mock>).mock.calls[0][0]
|
||||
expect(launchArgs.model.providerID).toBe("anthropic")
|
||||
})
|
||||
|
||||
it("#then passes the correct modelID", () => {
|
||||
const launchArgs = (manager.launch as ReturnType<typeof mock>).mock.calls[0][0]
|
||||
expect(launchArgs.model.modelID).toBe("claude-opus-4-6")
|
||||
})
|
||||
|
||||
it("#then passes the parent session ID", () => {
|
||||
const launchArgs = (manager.launch as ReturnType<typeof mock>).mock.calls[0][0]
|
||||
expect(launchArgs.parentSessionID).toBe("ses-parent")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a member with a variant", () => {
|
||||
describe("#when launched", () => {
|
||||
it("#then passes the variant through to the model config", async () => {
|
||||
const manager = makeManager()
|
||||
const member: CouncilMemberConfig = {
|
||||
name: "GPT Codex",
|
||||
model: "openai/gpt-5.3-codex",
|
||||
variant: "medium",
|
||||
}
|
||||
await launchCouncilMember(member, "prompt", manager, makeContext())
|
||||
const launchArgs = (manager.launch as ReturnType<typeof mock>).mock.calls[0][0]
|
||||
expect(launchArgs.model.variant).toBe("medium")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a member without a variant", () => {
|
||||
describe("#when launched", () => {
|
||||
it("#then does not include variant in the model config", async () => {
|
||||
const manager = makeManager()
|
||||
const member: CouncilMemberConfig = {
|
||||
name: "Gemini Flash",
|
||||
model: "google/gemini-3-flash",
|
||||
}
|
||||
await launchCouncilMember(member, "prompt", manager, makeContext())
|
||||
const launchArgs = (manager.launch as ReturnType<typeof mock>).mock.calls[0][0]
|
||||
expect(launchArgs.model.variant).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a member with an invalid model format (no slash)", () => {
|
||||
describe("#when launched", () => {
|
||||
it("#then throws an error about invalid model format", async () => {
|
||||
const manager = makeManager()
|
||||
const member: CouncilMemberConfig = {
|
||||
name: "Bad Model",
|
||||
model: "not-a-valid-model",
|
||||
}
|
||||
await expect(launchCouncilMember(member, "prompt", manager, makeContext())).rejects.toThrow(
|
||||
'Invalid model format: "not-a-valid-model"',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a member with an empty model string", () => {
|
||||
describe("#when launched", () => {
|
||||
it("#then throws an error about invalid model format", async () => {
|
||||
const manager = makeManager()
|
||||
const member: CouncilMemberConfig = {
|
||||
name: "Empty Model",
|
||||
model: "",
|
||||
}
|
||||
await expect(launchCouncilMember(member, "prompt", manager, makeContext())).rejects.toThrow(
|
||||
"Invalid model format",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { BackgroundTask } from "../../features/background-agent/types"
|
||||
import type { CouncilMemberConfig } from "../../config/schema/athena"
|
||||
import { parseModelString } from "../delegate-task/model-string-parser"
|
||||
import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents"
|
||||
|
||||
export interface CouncilLaunchContext {
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
parentAgent?: string
|
||||
}
|
||||
|
||||
interface LaunchOutcome {
|
||||
member: CouncilMemberConfig
|
||||
task: BackgroundTask
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches a single council member as a background task.
|
||||
* The agent key follows the "Council: <name>" pattern used by council-member-agents.ts.
|
||||
*/
|
||||
export async function launchCouncilMember(
|
||||
member: CouncilMemberConfig,
|
||||
prompt: string,
|
||||
manager: BackgroundManager,
|
||||
context: CouncilLaunchContext,
|
||||
): Promise<LaunchOutcome> {
|
||||
const parsed = parseModelString(member.model)
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid model format: "${member.model}" (expected "provider/model-id")`)
|
||||
}
|
||||
|
||||
const agentKey = `${COUNCIL_MEMBER_KEY_PREFIX}${member.name}`
|
||||
const memberName = member.name ?? member.model
|
||||
|
||||
const task = await manager.launch({
|
||||
description: `Council member: ${memberName}`,
|
||||
prompt,
|
||||
agent: agentKey,
|
||||
parentSessionID: context.parentSessionID,
|
||||
parentMessageID: context.parentMessageID,
|
||||
parentAgent: context.parentAgent,
|
||||
writeOutputToFile: true,
|
||||
model: {
|
||||
providerID: parsed.providerID,
|
||||
modelID: parsed.modelID,
|
||||
...(member.variant ? { variant: member.variant } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
return { member, task }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createAthenaCouncilTool } from "./tools"
|
||||
export type { AthenaCouncilToolArgs, AthenaCouncilResult, LaunchedMemberInfo } from "./types"
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it, mock, beforeEach, afterAll } from "bun:test"
|
||||
import { writeFile, mkdir, rm } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
const mockLaunchCouncilMember = mock(async (member: { name: string; model: string }) => ({
|
||||
member,
|
||||
task: { id: `task-${member.name}`, sessionID: undefined, status: "running" },
|
||||
}))
|
||||
|
||||
mock.module("./council-launcher", () => ({
|
||||
launchCouncilMember: mockLaunchCouncilMember,
|
||||
}))
|
||||
|
||||
import { createAthenaCouncilTool } from "./tools"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { CouncilConfig } from "../../config/schema/athena"
|
||||
|
||||
const TEST_TMP_DIR = join(tmpdir(), "athena-council-test")
|
||||
const PROMPT_FILE = join(TEST_TMP_DIR, "test-prompt.md")
|
||||
|
||||
const makeManager = (): BackgroundManager =>
|
||||
({
|
||||
launch: mock(async () => ({ id: "task-123", sessionID: undefined, status: "running" })),
|
||||
getTask: mock((taskId: string) => ({ id: taskId, sessionID: `ses-${taskId}`, status: "running" })),
|
||||
}) as unknown as BackgroundManager
|
||||
|
||||
const makeToolContext = () => ({
|
||||
sessionID: "ses-test",
|
||||
messageID: "msg-test",
|
||||
agent: "athena",
|
||||
abort: undefined,
|
||||
})
|
||||
|
||||
const makeCouncilConfig = (members?: Array<{ name: string; model: string; variant?: string }>): CouncilConfig => ({
|
||||
members: members ?? [
|
||||
{ name: "Claude Opus", model: "anthropic/claude-opus-4-6" },
|
||||
{ name: "GPT Codex", model: "openai/gpt-5.3-codex" },
|
||||
],
|
||||
retry_on_fail: 0,
|
||||
retry_failed_if_others_finished: false,
|
||||
cancel_retrying_on_quorum: true,
|
||||
stuck_threshold_seconds: 300,
|
||||
member_max_running_seconds: 600,
|
||||
})
|
||||
|
||||
describe("createAthenaCouncilTool", () => {
|
||||
beforeEach(async () => {
|
||||
await mkdir(TEST_TMP_DIR, { recursive: true })
|
||||
await writeFile(PROMPT_FILE, "prompt file content", "utf-8")
|
||||
mockLaunchCouncilMember.mockImplementation(async (member: { name: string; model: string }) => ({
|
||||
member,
|
||||
task: { id: `task-${member.name}`, sessionID: undefined, status: "running" },
|
||||
}))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(TEST_TMP_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("#given council is not configured (undefined)", () => {
|
||||
describe("#when execute is called", () => {
|
||||
it("#then returns error message about council not configured", async () => {
|
||||
const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: undefined })
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("Council not configured")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given council has zero members", () => {
|
||||
describe("#when execute is called", () => {
|
||||
it("#then returns error message about council not configured", async () => {
|
||||
const config = makeCouncilConfig([])
|
||||
const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: config })
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("Council not configured")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given prompt_file does not exist", () => {
|
||||
describe("#when execute is called", () => {
|
||||
it("#then returns error message about failed file read", async () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: "/nonexistent/prompt.md" }, makeToolContext())
|
||||
expect(result).toContain("Failed to read prompt file")
|
||||
expect(result).toContain("/nonexistent/prompt.md")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an unknown member name in the members filter", () => {
|
||||
describe("#when execute is called with that unknown name", () => {
|
||||
it("#then returns error listing unknown member and available members", async () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
})
|
||||
const result = await tool.execute(
|
||||
{ prompt_file: PROMPT_FILE, members: ["NonExistentMember"] },
|
||||
makeToolContext(),
|
||||
)
|
||||
expect(result).toContain("Unknown council members: NonExistentMember")
|
||||
expect(result).toContain("Available:")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a valid subset of member names in the filter", () => {
|
||||
describe("#when execute is called with one member name", () => {
|
||||
it("#then launches only the specified member", async () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
})
|
||||
const result = await tool.execute(
|
||||
{ prompt_file: PROMPT_FILE, members: ["Claude Opus"] },
|
||||
makeToolContext(),
|
||||
)
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
expect(jsonMatch).not.toBeNull()
|
||||
const parsed = JSON.parse(jsonMatch![0])
|
||||
expect(parsed.total_requested).toBe(1)
|
||||
expect(parsed.launched).toHaveLength(1)
|
||||
expect(parsed.launched[0].member_name).toBe("Claude Opus")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given all members are valid and prompt file is readable", () => {
|
||||
describe("#when execute is called without member filter", () => {
|
||||
it("#then returns JSON with all launched members", async () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
expect(jsonMatch).not.toBeNull()
|
||||
const parsed = JSON.parse(jsonMatch![0])
|
||||
expect(parsed.total_requested).toBe(2)
|
||||
expect(parsed.launched).toHaveLength(2)
|
||||
expect(parsed.failures).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("#then includes task IDs and background_wait instructions in the output", async () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("background_wait")
|
||||
expect(result).toContain("task_ids=")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given some members fail to launch", () => {
|
||||
describe("#when execute is called", () => {
|
||||
beforeEach(() => {
|
||||
let callCount = 0
|
||||
mockLaunchCouncilMember.mockImplementation(async (member: { name: string; model: string }) => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return { member, task: { id: `task-${member.name}`, sessionID: undefined, status: "running" } }
|
||||
}
|
||||
throw new Error("Launch failed for member")
|
||||
})
|
||||
})
|
||||
|
||||
it("#then includes successful launches in the result", async () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
expect(jsonMatch).not.toBeNull()
|
||||
const parsed = JSON.parse(jsonMatch![0])
|
||||
expect(parsed.launched).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("#then includes failures in the result", async () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
expect(jsonMatch).not.toBeNull()
|
||||
const parsed = JSON.parse(jsonMatch![0])
|
||||
expect(parsed.failures).toHaveLength(1)
|
||||
expect(parsed.failures[0].error).toContain("Launch failed for member")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given all members fail to launch", () => {
|
||||
describe("#when execute is called", () => {
|
||||
beforeEach(() => {
|
||||
mockLaunchCouncilMember.mockImplementation(async () => {
|
||||
throw new Error("All launches failed")
|
||||
})
|
||||
})
|
||||
|
||||
it("#then returns a plain error string (not JSON)", async () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("All council member launches failed")
|
||||
expect(result).not.toContain('"launched"')
|
||||
})
|
||||
|
||||
it("#then lists each failure in the error message", async () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("Claude Opus")
|
||||
expect(result).toContain("GPT Codex")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { CouncilConfig, CouncilMemberConfig } from "../../config/schema/athena"
|
||||
import { launchCouncilMember, type CouncilLaunchContext } from "./council-launcher"
|
||||
import type { AthenaCouncilToolArgs, LaunchedMemberInfo, AthenaCouncilResult } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const SESSION_WAIT_INTERVAL_MS = 100
|
||||
const SESSION_WAIT_TIMEOUT_MS = 30_000
|
||||
|
||||
function buildToolDescription(councilConfig: CouncilConfig | undefined): string {
|
||||
const memberList = councilConfig?.members.length
|
||||
? councilConfig.members.map((m) => `- ${m.name} (${m.model})`).join("\n")
|
||||
: "No members configured."
|
||||
|
||||
return `Launch all council members in parallel for multi-model analysis.
|
||||
|
||||
Takes a prompt file path (from prepare_council_prompt) and launches all specified council members
|
||||
as background tasks. Returns an array of task IDs for use with background_wait and background_output.
|
||||
|
||||
Available council members:
|
||||
${memberList}
|
||||
|
||||
Returns JSON with launched task IDs and any launch failures.`
|
||||
}
|
||||
|
||||
function filterMembers(
|
||||
allMembers: CouncilMemberConfig[],
|
||||
selectedNames: string[] | undefined,
|
||||
): { members: CouncilMemberConfig[]; error?: string } {
|
||||
if (!selectedNames || selectedNames.length === 0) {
|
||||
return { members: allMembers }
|
||||
}
|
||||
|
||||
const lookup = new Map<string, CouncilMemberConfig>()
|
||||
for (const member of allMembers) {
|
||||
lookup.set(member.model.toLowerCase(), member)
|
||||
if (member.name) {
|
||||
lookup.set(member.name.toLowerCase(), member)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered: CouncilMemberConfig[] = []
|
||||
const seen = new Set<CouncilMemberConfig>()
|
||||
const unresolved: string[] = []
|
||||
|
||||
for (const name of selectedNames) {
|
||||
const match = lookup.get(name.toLowerCase())
|
||||
if (!match) {
|
||||
unresolved.push(name)
|
||||
continue
|
||||
}
|
||||
if (!seen.has(match)) {
|
||||
seen.add(match)
|
||||
filtered.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
const available = allMembers.map((m) => m.name ?? m.model).join(", ")
|
||||
return { members: [], error: `Unknown council members: ${unresolved.join(", ")}. Available: ${available}` }
|
||||
}
|
||||
|
||||
return { members: filtered }
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits briefly for background sessions to acquire session IDs.
|
||||
* Non-blocking — returns whatever is available within the timeout.
|
||||
*/
|
||||
async function waitForSessionIds(
|
||||
taskIds: string[],
|
||||
manager: BackgroundManager,
|
||||
abort?: AbortSignal,
|
||||
): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>()
|
||||
const pending = new Set(taskIds)
|
||||
const deadline = Date.now() + SESSION_WAIT_TIMEOUT_MS
|
||||
|
||||
while (pending.size > 0 && Date.now() < deadline) {
|
||||
if (abort?.aborted) break
|
||||
|
||||
for (const taskId of pending) {
|
||||
const task = manager.getTask(taskId)
|
||||
if (task?.sessionID) {
|
||||
result.set(taskId, task.sessionID)
|
||||
pending.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.size > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SESSION_WAIT_INTERVAL_MS))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function createAthenaCouncilTool(args: {
|
||||
backgroundManager: BackgroundManager
|
||||
councilConfig: CouncilConfig | undefined
|
||||
}): ToolDefinition {
|
||||
const { backgroundManager, councilConfig } = args
|
||||
const description = buildToolDescription(councilConfig)
|
||||
|
||||
return tool({
|
||||
description,
|
||||
args: {
|
||||
prompt_file: tool.schema.string().describe("Path to the prompt file created by prepare_council_prompt"),
|
||||
members: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Optional list of council member names to launch. Defaults to all configured members."),
|
||||
},
|
||||
async execute(toolArgs: AthenaCouncilToolArgs, toolContext) {
|
||||
if (!councilConfig || councilConfig.members.length === 0) {
|
||||
return "Council not configured. Add agents.athena.council.members to your config."
|
||||
}
|
||||
|
||||
const { members, error } = filterMembers(councilConfig.members, toolArgs.members)
|
||||
if (error) return error
|
||||
if (members.length === 0) return "No council members to launch."
|
||||
|
||||
let promptContent: string
|
||||
try {
|
||||
promptContent = await readFile(toolArgs.prompt_file, "utf-8")
|
||||
} catch (err) {
|
||||
return `Failed to read prompt file: ${toolArgs.prompt_file}. Error: ${String(err)}`
|
||||
}
|
||||
|
||||
const context: CouncilLaunchContext = {
|
||||
parentSessionID: toolContext.sessionID,
|
||||
parentMessageID: toolContext.messageID,
|
||||
parentAgent: toolContext.agent,
|
||||
}
|
||||
|
||||
log("[athena_council] Launching council members", { count: members.length })
|
||||
|
||||
const launchResults = await Promise.allSettled(
|
||||
members.map((member) => launchCouncilMember(member, promptContent, backgroundManager, context)),
|
||||
)
|
||||
|
||||
const launched: Array<{ taskId: string; member: CouncilMemberConfig }> = []
|
||||
const failures: AthenaCouncilResult["failures"] = []
|
||||
|
||||
launchResults.forEach((result, index) => {
|
||||
const member = members[index]
|
||||
if (result.status === "fulfilled") {
|
||||
launched.push({ taskId: result.value.task.id, member: result.value.member })
|
||||
} else {
|
||||
failures.push({
|
||||
member_name: member.name ?? member.model,
|
||||
model: member.model,
|
||||
error: String(result.reason),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (launched.length === 0) {
|
||||
return `All council member launches failed:\n${failures.map((f) => `- ${f.member_name}: ${f.error}`).join("\n")}`
|
||||
}
|
||||
|
||||
const sessionMap = await waitForSessionIds(
|
||||
launched.map((l) => l.taskId),
|
||||
backgroundManager,
|
||||
toolContext.abort,
|
||||
)
|
||||
|
||||
const launchedInfo: LaunchedMemberInfo[] = launched.map((l) => ({
|
||||
task_id: l.taskId,
|
||||
session_id: sessionMap.get(l.taskId),
|
||||
member_name: l.member.name ?? l.member.model,
|
||||
model: l.member.model,
|
||||
}))
|
||||
|
||||
const output: AthenaCouncilResult = {
|
||||
launched: launchedInfo,
|
||||
failures,
|
||||
total_requested: members.length,
|
||||
}
|
||||
|
||||
log("[athena_council] Launch complete", {
|
||||
launched: launchedInfo.length,
|
||||
failed: failures.length,
|
||||
})
|
||||
|
||||
const taskIdList = launchedInfo.map((l) => l.task_id)
|
||||
return `${JSON.stringify(output, null, 2)}
|
||||
|
||||
Use background_wait with task_ids=${JSON.stringify(taskIdList)} to wait for completion.
|
||||
Then use background_output for each task_id to collect individual results.`
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface AthenaCouncilToolArgs {
|
||||
prompt_file: string
|
||||
members?: string[]
|
||||
}
|
||||
|
||||
export interface LaunchedMemberInfo {
|
||||
task_id: string
|
||||
session_id: string | undefined
|
||||
member_name: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface AthenaCouncilResult {
|
||||
launched: LaunchedMemberInfo[]
|
||||
failures: Array<{ member_name: string; model: string; error: string }>
|
||||
total_requested: number
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export {
|
||||
export { createHashlineEditTool } from "./hashline-edit"
|
||||
export { createPrepareCouncilPromptTool } from "./prepare-council-prompt"
|
||||
export { createCouncilFinalize } from "./council-archive"
|
||||
export { createAthenaCouncilTool } from "./athena-council"
|
||||
|
||||
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
|
||||
const outputManager: BackgroundOutputManager = manager
|
||||
|
||||
Reference in New Issue
Block a user