refactor(athena): delete athena_council tool directory
Remove the entire custom tool implementation (constants, launcher, session-waiter, tool-helpers, tools, types, and all tests). Council members are now launched via the standard task tool. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
70f074f579
commit
1349948957
@ -1,10 +0,0 @@
|
|||||||
export const ATHENA_COUNCIL_TOOL_DESCRIPTION_TEMPLATE = `Execute Athena's multi-model council for exactly ONE member per call.
|
|
||||||
|
|
||||||
Pass members as a single-item array containing one member name or model ID. Athena should call this tool once per selected member.
|
|
||||||
|
|
||||||
This tool launches the selected member as a background task and returns task/session metadata immediately.
|
|
||||||
After launching ALL members, use background_wait(task_ids=[...all IDs...]) to wait for results. It blocks until the next member finishes and returns progress. Repeat with remaining IDs until all complete.
|
|
||||||
|
|
||||||
{members}
|
|
||||||
|
|
||||||
IMPORTANT: This tool is designed for Athena agent use only. It requires council configuration to be present.`
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
|
||||||
import type { BackgroundTask, LaunchInput } from "../../features/background-agent/types"
|
|
||||||
import { createCouncilLauncher } from "./council-launcher"
|
|
||||||
|
|
||||||
function createMockTask(id: string): BackgroundTask {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
parentSessionID: "session-1",
|
|
||||||
parentMessageID: "message-1",
|
|
||||||
description: "test",
|
|
||||||
prompt: "test",
|
|
||||||
agent: "athena",
|
|
||||||
status: "running",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("createCouncilLauncher", () => {
|
|
||||||
//#given a council launch input with all fields
|
|
||||||
//#when launch is called
|
|
||||||
//#then all fields are forwarded to the background manager
|
|
||||||
test("forwards all launch input fields to background manager", async () => {
|
|
||||||
const capturedInputs: LaunchInput[] = []
|
|
||||||
const mockManager = {
|
|
||||||
launch: async (input: LaunchInput) => {
|
|
||||||
capturedInputs.push(input)
|
|
||||||
return createMockTask("bg-1")
|
|
||||||
},
|
|
||||||
getTask: () => undefined,
|
|
||||||
} as unknown as BackgroundManager
|
|
||||||
|
|
||||||
const launcher = createCouncilLauncher(mockManager)
|
|
||||||
|
|
||||||
await launcher.launch({
|
|
||||||
description: "Council member: test",
|
|
||||||
prompt: "Analyze this",
|
|
||||||
agent: "athena",
|
|
||||||
parentSessionID: "session-1",
|
|
||||||
parentMessageID: "message-1",
|
|
||||||
model: { providerID: "openai", modelID: "gpt-5.3-codex" },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(capturedInputs).toHaveLength(1)
|
|
||||||
expect(capturedInputs[0]?.description).toBe("Council member: test")
|
|
||||||
expect(capturedInputs[0]?.prompt).toBe("Analyze this")
|
|
||||||
expect(capturedInputs[0]?.agent).toBe("athena")
|
|
||||||
expect(capturedInputs[0]?.parentSessionID).toBe("session-1")
|
|
||||||
expect(capturedInputs[0]?.parentMessageID).toBe("message-1")
|
|
||||||
expect(capturedInputs[0]?.model).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { createAthenaCouncilTool } from "./tools"
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
/// <reference types="bun-types" />
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
|
||||||
import { waitForCouncilSessions } from "./session-waiter"
|
|
||||||
|
|
||||||
describe("waitForCouncilSessions", () => {
|
|
||||||
test("resolves all sessions when tasks have sessionIDs immediately", async () => {
|
|
||||||
//#given
|
|
||||||
const launched = [
|
|
||||||
{ member: { model: "openai/gpt-5.3-codex", name: "GPT" }, taskId: "task-1" },
|
|
||||||
{ member: { model: "anthropic/claude-opus-4-6" }, taskId: "task-2" },
|
|
||||||
]
|
|
||||||
const manager = {
|
|
||||||
getTask: (id: string) => ({ sessionID: `ses-${id}` }),
|
|
||||||
} as unknown as BackgroundManager
|
|
||||||
|
|
||||||
//#when
|
|
||||||
const result = await waitForCouncilSessions(launched, manager)
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(result.sessions).toHaveLength(2)
|
|
||||||
expect(result.timedOut).toBe(false)
|
|
||||||
expect(result.aborted).toBe(false)
|
|
||||||
expect(result.sessions[0].taskId).toBe("task-1")
|
|
||||||
expect(result.sessions[0].memberName).toBe("GPT")
|
|
||||||
expect(result.sessions[1].taskId).toBe("task-2")
|
|
||||||
expect(result.sessions[1].memberName).toBe("anthropic/claude-opus-4-6")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns empty sessions for empty launched list", async () => {
|
|
||||||
//#given
|
|
||||||
const manager = { getTask: () => undefined } as unknown as BackgroundManager
|
|
||||||
|
|
||||||
//#when
|
|
||||||
const result = await waitForCouncilSessions([], manager)
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(result.sessions).toHaveLength(0)
|
|
||||||
expect(result.timedOut).toBe(false)
|
|
||||||
expect(result.aborted).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sets aborted flag when abort signal fires", async () => {
|
|
||||||
//#given
|
|
||||||
const launched = [
|
|
||||||
{ member: { model: "openai/gpt-5.3-codex" }, taskId: "task-1" },
|
|
||||||
]
|
|
||||||
const manager = { getTask: () => undefined } as unknown as BackgroundManager
|
|
||||||
const controller = new AbortController()
|
|
||||||
// Abort immediately
|
|
||||||
controller.abort()
|
|
||||||
|
|
||||||
//#when
|
|
||||||
const result = await waitForCouncilSessions(launched, manager, controller.signal)
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(result.sessions).toHaveLength(0)
|
|
||||||
expect(result.aborted).toBe(true)
|
|
||||||
expect(result.timedOut).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resolves partial sessions when some tasks get sessionIDs", async () => {
|
|
||||||
//#given
|
|
||||||
const launched = [
|
|
||||||
{ member: { model: "openai/gpt-5.3-codex", name: "GPT" }, taskId: "task-1" },
|
|
||||||
{ member: { model: "anthropic/claude-opus-4-6" }, taskId: "task-2" },
|
|
||||||
]
|
|
||||||
const controller = new AbortController()
|
|
||||||
let callCount = 0
|
|
||||||
const manager = {
|
|
||||||
getTask: (id: string) => {
|
|
||||||
callCount++
|
|
||||||
// Only task-1 gets a session, task-2 never does
|
|
||||||
if (id === "task-1") return { sessionID: "ses-task-1" }
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
} as unknown as BackgroundManager
|
|
||||||
|
|
||||||
// Abort after a short delay to avoid waiting full 30s
|
|
||||||
setTimeout(() => controller.abort(), 200)
|
|
||||||
|
|
||||||
//#when
|
|
||||||
const result = await waitForCouncilSessions(launched, manager, controller.signal)
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(result.sessions).toHaveLength(1)
|
|
||||||
expect(result.sessions[0].taskId).toBe("task-1")
|
|
||||||
expect(result.aborted).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses member model as memberName when name is not provided", async () => {
|
|
||||||
//#given
|
|
||||||
const launched = [
|
|
||||||
{ member: { model: "google/gemini-3-pro" }, taskId: "task-1" },
|
|
||||||
]
|
|
||||||
const manager = {
|
|
||||||
getTask: () => ({ sessionID: "ses-1" }),
|
|
||||||
} as unknown as BackgroundManager
|
|
||||||
|
|
||||||
//#when
|
|
||||||
const result = await waitForCouncilSessions(launched, manager)
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(result.sessions[0].memberName).toBe("google/gemini-3-pro")
|
|
||||||
expect(result.sessions[0].model).toBe("google/gemini-3-pro")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import type { BackgroundManager } from "../../features/background-agent"
|
|
||||||
import type { CouncilLaunchedMember } from "../../agents/athena/types"
|
|
||||||
|
|
||||||
const WAIT_INTERVAL_MS = 100
|
|
||||||
const WAIT_TIMEOUT_MS = 30_000
|
|
||||||
|
|
||||||
interface CouncilSessionInfo {
|
|
||||||
taskId: string
|
|
||||||
memberName: string
|
|
||||||
model: string
|
|
||||||
sessionId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CouncilSessionWaitResult {
|
|
||||||
sessions: CouncilSessionInfo[]
|
|
||||||
timedOut: boolean
|
|
||||||
aborted: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Waits for background sessions to be created for launched council members.
|
|
||||||
* Returns session info for each member whose session became available within the timeout.
|
|
||||||
*/
|
|
||||||
export async function waitForCouncilSessions(
|
|
||||||
launched: CouncilLaunchedMember[],
|
|
||||||
manager: BackgroundManager,
|
|
||||||
abort?: AbortSignal
|
|
||||||
): Promise<CouncilSessionWaitResult> {
|
|
||||||
const results: CouncilSessionInfo[] = []
|
|
||||||
const pending = new Map(
|
|
||||||
launched.map((entry) => [entry.taskId, entry])
|
|
||||||
)
|
|
||||||
|
|
||||||
const deadline = Date.now() + WAIT_TIMEOUT_MS
|
|
||||||
let timedOut = false
|
|
||||||
let aborted = false
|
|
||||||
|
|
||||||
while (pending.size > 0 && Date.now() < deadline) {
|
|
||||||
if (abort?.aborted) {
|
|
||||||
aborted = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [taskId, entry] of pending) {
|
|
||||||
const task = manager.getTask(taskId)
|
|
||||||
if (task?.sessionID) {
|
|
||||||
results.push({
|
|
||||||
taskId,
|
|
||||||
memberName: entry.member.name ?? entry.member.model,
|
|
||||||
model: entry.member.model,
|
|
||||||
sessionId: task.sessionID,
|
|
||||||
})
|
|
||||||
pending.delete(taskId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pending.size > 0) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pending.size > 0 && !aborted) {
|
|
||||||
timedOut = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sessions: results, timedOut, aborted }
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
import type { CouncilConfig, CouncilMemberConfig } from "../../agents/athena/types"
|
|
||||||
import { ATHENA_COUNCIL_TOOL_DESCRIPTION_TEMPLATE } from "./constants"
|
|
||||||
|
|
||||||
function isCouncilConfigured(councilConfig: CouncilConfig | undefined): councilConfig is CouncilConfig {
|
|
||||||
return Boolean(councilConfig && councilConfig.members.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterCouncilMembersResult {
|
|
||||||
members: CouncilMemberConfig[]
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSingleMemberSelectionError(members: CouncilMemberConfig[]): string {
|
|
||||||
const availableNames = members.map((member) => member.name ?? member.model).join(", ")
|
|
||||||
return `athena_council runs one member per call. Pass exactly one member in members (single-item array). Available members: ${availableNames}.`
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterCouncilMembers(
|
|
||||||
members: CouncilMemberConfig[],
|
|
||||||
selectedNames: string[] | undefined
|
|
||||||
): FilterCouncilMembersResult {
|
|
||||||
if (!selectedNames || selectedNames.length === 0) {
|
|
||||||
return {
|
|
||||||
members: [],
|
|
||||||
error: buildSingleMemberSelectionError(members),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberLookup = new Map<string, CouncilMemberConfig>()
|
|
||||||
members.forEach((member) => {
|
|
||||||
memberLookup.set(member.model.toLowerCase(), member)
|
|
||||||
if (member.name) {
|
|
||||||
memberLookup.set(member.name.toLowerCase(), member)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unresolved: string[] = []
|
|
||||||
const filteredMembers: CouncilMemberConfig[] = []
|
|
||||||
const includedMembers = new Set<CouncilMemberConfig>()
|
|
||||||
|
|
||||||
selectedNames.forEach((selectedName) => {
|
|
||||||
const selectedKey = selectedName.toLowerCase()
|
|
||||||
const matchedMember = memberLookup.get(selectedKey)
|
|
||||||
if (!matchedMember) {
|
|
||||||
unresolved.push(selectedName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includedMembers.has(matchedMember)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
includedMembers.add(matchedMember)
|
|
||||||
filteredMembers.push(matchedMember)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (unresolved.length > 0) {
|
|
||||||
const availableDescriptions = members
|
|
||||||
.map((member) => {
|
|
||||||
if (member.name) {
|
|
||||||
return `${member.name} (${member.model})`
|
|
||||||
}
|
|
||||||
return member.model
|
|
||||||
})
|
|
||||||
.join(", ")
|
|
||||||
return {
|
|
||||||
members: [],
|
|
||||||
error: `Unknown council members: ${unresolved.join(", ")}. Available: ${availableDescriptions}.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { members: filteredMembers }
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ATHENA_COUNCIL_TOOL_DESCRIPTION_TEMPLATE.replace("{members}", `Available council members:\n${memberList}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCouncilLaunchFailure(
|
|
||||||
failures: Array<{ member: { name?: string; model: string }; error: string }>
|
|
||||||
): string {
|
|
||||||
const failureLines = failures
|
|
||||||
.map((failure) => `- **${failure.member.name ?? failure.member.model}**: ${failure.error}`)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
return failureLines
|
|
||||||
? `Failed to launch council member.\n\n### Launch Failures\n\n${failureLines}`
|
|
||||||
: "Failed to launch council member."
|
|
||||||
}
|
|
||||||
|
|
||||||
export { isCouncilConfigured, filterCouncilMembers, buildSingleMemberSelectionError, buildToolDescription, formatCouncilLaunchFailure }
|
|
||||||
@ -1,205 +0,0 @@
|
|||||||
/// <reference types="bun-types" />
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
|
||||||
import type { BackgroundTask } from "../../features/background-agent/types"
|
|
||||||
import { createAthenaCouncilTool } from "./tools"
|
|
||||||
import { filterCouncilMembers } from "./tool-helpers"
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuredMembers = [
|
|
||||||
{ name: "Claude", model: "anthropic/claude-sonnet-4-5" },
|
|
||||||
{ name: "GPT", model: "openai/gpt-5.3-codex" },
|
|
||||||
{ model: "google/gemini-3-pro" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function createRunningTask(id: string, sessionID = `ses-${id}`): BackgroundTask {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
parentSessionID: "session-1",
|
|
||||||
parentMessageID: "message-1",
|
|
||||||
description: `Council member task ${id}`,
|
|
||||||
prompt: "prompt",
|
|
||||||
agent: "council-member",
|
|
||||||
status: "running",
|
|
||||||
sessionID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("filterCouncilMembers", () => {
|
|
||||||
test("returns selection error when selection is undefined", () => {
|
|
||||||
const result = filterCouncilMembers(configuredMembers, undefined)
|
|
||||||
expect(result.members).toEqual([])
|
|
||||||
expect(result.error).toBe(
|
|
||||||
"athena_council runs one member per call. Pass exactly one member in members (single-item array). Available members: Claude, GPT, google/gemini-3-pro."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns selection error when selection is empty", () => {
|
|
||||||
const result = filterCouncilMembers(configuredMembers, [])
|
|
||||||
expect(result.members).toEqual([])
|
|
||||||
expect(result.error).toBe(
|
|
||||||
"athena_council runs one member per call. Pass exactly one member in members (single-item array). Available members: Claude, GPT, google/gemini-3-pro."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("filters members using case-insensitive name and model matching", () => {
|
|
||||||
const result = filterCouncilMembers(configuredMembers, ["gpt", "GOOGLE/GEMINI-3-PRO"])
|
|
||||||
expect(result.members).toEqual([configuredMembers[1], configuredMembers[2]])
|
|
||||||
expect(result.error).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns helpful error when selected members are not configured", () => {
|
|
||||||
const result = filterCouncilMembers(configuredMembers, ["mistral", "xai/grok-3"])
|
|
||||||
expect(result.members).toEqual([])
|
|
||||||
expect(result.error).toBe(
|
|
||||||
"Unknown council members: mistral, xai/grok-3. Available: Claude (anthropic/claude-sonnet-4-5), GPT (openai/gpt-5.3-codex), google/gemini-3-pro."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("deduplicates when same member is selected by both name and model", () => {
|
|
||||||
const result = filterCouncilMembers(configuredMembers, ["Claude", "anthropic/claude-sonnet-4-5"])
|
|
||||||
expect(result.members).toEqual([configuredMembers[0]])
|
|
||||||
expect(result.error).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("createAthenaCouncilTool", () => {
|
|
||||||
test("returns error when councilConfig is undefined", async () => {
|
|
||||||
const athenaCouncilTool = createAthenaCouncilTool({
|
|
||||||
backgroundManager: mockManager,
|
|
||||||
councilConfig: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await athenaCouncilTool.execute({ question: "How should we proceed?" }, mockToolContext)
|
|
||||||
|
|
||||||
expect(result).toBe("Athena council is not configured. Add council members to agents.athena.council.members in .opencode/oh-my-opencode.jsonc.")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns error when councilConfig has empty members", async () => {
|
|
||||||
const athenaCouncilTool = createAthenaCouncilTool({
|
|
||||||
backgroundManager: mockManager,
|
|
||||||
councilConfig: { members: [] },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await athenaCouncilTool.execute({ question: "Any concerns?" }, mockToolContext)
|
|
||||||
|
|
||||||
expect(result).toBe("Athena council is not configured. Add council members to agents.athena.council.members in .opencode/oh-my-opencode.jsonc.")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns helpful error when members contains invalid names", async () => {
|
|
||||||
const athenaCouncilTool = createAthenaCouncilTool({
|
|
||||||
backgroundManager: mockManager,
|
|
||||||
councilConfig: { members: configuredMembers },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await athenaCouncilTool.execute(
|
|
||||||
{ question: "Who should investigate this?", members: ["unknown-model"] },
|
|
||||||
mockToolContext
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toBe("Unknown council members: unknown-model. Available: Claude (anthropic/claude-sonnet-4-5), GPT (openai/gpt-5.3-codex), google/gemini-3-pro.")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns selection error when members are omitted", async () => {
|
|
||||||
const athenaCouncilTool = createAthenaCouncilTool({
|
|
||||||
backgroundManager: mockManager,
|
|
||||||
councilConfig: { members: configuredMembers },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await athenaCouncilTool.execute({ question: "How should we proceed?" }, mockToolContext)
|
|
||||||
|
|
||||||
expect(result).toBe(
|
|
||||||
"athena_council runs one member per call. Pass exactly one member in members (single-item array). Available members: Claude, GPT, google/gemini-3-pro."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns selection error when multiple members are provided", async () => {
|
|
||||||
const athenaCouncilTool = createAthenaCouncilTool({
|
|
||||||
backgroundManager: mockManager,
|
|
||||||
councilConfig: { members: configuredMembers },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await athenaCouncilTool.execute(
|
|
||||||
{ question: "How should we proceed?", members: ["Claude", "GPT"] },
|
|
||||||
mockToolContext
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toBe(
|
|
||||||
"athena_council runs one member per call. Pass exactly one member in members (single-item array). Available members: Claude, GPT, google/gemini-3-pro."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("launches selected member and returns background task metadata", async () => {
|
|
||||||
let launchCount = 0
|
|
||||||
const taskStore = new Map<string, BackgroundTask>()
|
|
||||||
const launchManager = {
|
|
||||||
launch: async () => {
|
|
||||||
launchCount += 1
|
|
||||||
const task = createRunningTask(`bg-${launchCount}`)
|
|
||||||
taskStore.set(task.id, task)
|
|
||||||
return task
|
|
||||||
},
|
|
||||||
getTask: (id: string) => taskStore.get(id),
|
|
||||||
} as unknown as BackgroundManager
|
|
||||||
|
|
||||||
const athenaCouncilTool = createAthenaCouncilTool({
|
|
||||||
backgroundManager: launchManager,
|
|
||||||
councilConfig: { members: configuredMembers },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await athenaCouncilTool.execute(
|
|
||||||
{ question: "Who should investigate this?", members: ["GPT"] },
|
|
||||||
mockToolContext
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(launchCount).toBe(1)
|
|
||||||
expect(result).toContain("Council member launched in background.")
|
|
||||||
expect(result).toContain("Task ID: bg-1")
|
|
||||||
expect(result).toContain("Session ID: ses-bg-1")
|
|
||||||
expect(result).toContain("Member: GPT")
|
|
||||||
expect(result).toContain("Model: openai/gpt-5.3-codex")
|
|
||||||
expect(result).toContain("Status: running")
|
|
||||||
expect(result).toContain("background_output")
|
|
||||||
expect(result).toContain("task_id=\"bg-1\"")
|
|
||||||
expect(result).toContain("<task_metadata>")
|
|
||||||
expect(result).toContain("session_id: ses-bg-1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns launch failure details when selected member fails", async () => {
|
|
||||||
const launchManager = {
|
|
||||||
launch: async () => {
|
|
||||||
throw new Error("provider outage")
|
|
||||||
},
|
|
||||||
getTask: () => undefined,
|
|
||||||
} as unknown as BackgroundManager
|
|
||||||
|
|
||||||
const athenaCouncilTool = createAthenaCouncilTool({
|
|
||||||
backgroundManager: launchManager,
|
|
||||||
councilConfig: { members: configuredMembers },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await athenaCouncilTool.execute(
|
|
||||||
{ question: "Any concerns?", members: ["GPT"] },
|
|
||||||
mockToolContext
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toContain("Failed to launch council member.")
|
|
||||||
expect(result).toContain("### Launch Failures")
|
|
||||||
expect(result).toContain("**GPT**")
|
|
||||||
expect(result).toContain("provider outage")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
|
||||||
import { executeCouncil } from "../../agents/athena/council-orchestrator"
|
|
||||||
import type { CouncilConfig } from "../../agents/athena/types"
|
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
|
||||||
import { createCouncilLauncher } from "./council-launcher"
|
|
||||||
import { waitForCouncilSessions } from "./session-waiter"
|
|
||||||
import type { AthenaCouncilToolArgs, AthenaCouncilToolContext } from "./types"
|
|
||||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
import {
|
|
||||||
isCouncilConfigured,
|
|
||||||
filterCouncilMembers,
|
|
||||||
buildSingleMemberSelectionError,
|
|
||||||
buildToolDescription,
|
|
||||||
formatCouncilLaunchFailure,
|
|
||||||
} from "./tool-helpers"
|
|
||||||
|
|
||||||
export function createAthenaCouncilTool(args: {
|
|
||||||
backgroundManager: BackgroundManager
|
|
||||||
councilConfig: CouncilConfig | undefined
|
|
||||||
}): ToolDefinition {
|
|
||||||
const { backgroundManager, councilConfig } = args
|
|
||||||
const description = buildToolDescription(councilConfig)
|
|
||||||
|
|
||||||
return 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("Single-item list containing exactly one council member name or model ID."),
|
|
||||||
},
|
|
||||||
async execute(toolArgs: AthenaCouncilToolArgs, toolContext: AthenaCouncilToolContext) {
|
|
||||||
if (!isCouncilConfigured(councilConfig)) {
|
|
||||||
return "Athena council is not configured. Add council members to agents.athena.council.members in .opencode/oh-my-opencode.jsonc."
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredMembers = filterCouncilMembers(councilConfig.members, toolArgs.members)
|
|
||||||
if (filteredMembers.error) {
|
|
||||||
return filteredMembers.error
|
|
||||||
}
|
|
||||||
if (filteredMembers.members.length !== 1) {
|
|
||||||
return buildSingleMemberSelectionError(councilConfig.members)
|
|
||||||
}
|
|
||||||
|
|
||||||
const execution = await executeCouncil({
|
|
||||||
question: toolArgs.question,
|
|
||||||
council: { members: filteredMembers.members },
|
|
||||||
launcher: createCouncilLauncher(backgroundManager),
|
|
||||||
parentSessionID: toolContext.sessionID,
|
|
||||||
parentMessageID: toolContext.messageID,
|
|
||||||
parentAgent: toolContext.agent,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (execution.launched.length === 0) {
|
|
||||||
return formatCouncilLaunchFailure(execution.failures)
|
|
||||||
}
|
|
||||||
|
|
||||||
const launched = execution.launched[0]
|
|
||||||
const launchedMemberName = launched?.member.name ?? launched?.member.model
|
|
||||||
const launchedMemberModel = launched?.member.model ?? "unknown"
|
|
||||||
const launchedTaskId = launched?.taskId ?? "unknown"
|
|
||||||
|
|
||||||
log("[athena-council] Launching council member", { member: launchedMemberName, model: launchedMemberModel, taskId: launchedTaskId })
|
|
||||||
|
|
||||||
const waitResult = await waitForCouncilSessions(execution.launched, backgroundManager, toolContext.abort)
|
|
||||||
const launchedSession = waitResult.sessions.find((session) => session.taskId === launchedTaskId)
|
|
||||||
const sessionId = launchedSession?.sessionId ?? "pending"
|
|
||||||
|
|
||||||
let statusNote = ""
|
|
||||||
if (waitResult.timedOut) {
|
|
||||||
statusNote = "\nNote: Session creation timed out. The task is still running — use background_output to check status."
|
|
||||||
} else if (waitResult.aborted) {
|
|
||||||
statusNote = "\nNote: Session wait was aborted. The task may still be running."
|
|
||||||
}
|
|
||||||
|
|
||||||
log("[athena-council] Session resolved", { taskId: launchedTaskId, sessionId })
|
|
||||||
|
|
||||||
if (toolContext.metadata) {
|
|
||||||
const memberMetadata = {
|
|
||||||
title: `Council: ${launchedMemberName}`,
|
|
||||||
metadata: {
|
|
||||||
sessionId,
|
|
||||||
agent: "council-member",
|
|
||||||
model: launchedMemberModel,
|
|
||||||
description: `Council member: ${launchedMemberName}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await toolContext.metadata(memberMetadata)
|
|
||||||
|
|
||||||
if (toolContext.callID) {
|
|
||||||
storeToolMetadata(toolContext.sessionID, toolContext.callID, memberMetadata)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log("[athena-council] Metadata storage failed (best-effort)", { error: error instanceof Error ? error.message : String(error) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Council member launched in background.
|
|
||||||
|
|
||||||
Task ID: ${launchedTaskId}
|
|
||||||
Session ID: ${sessionId}
|
|
||||||
Member: ${launchedMemberName}
|
|
||||||
Model: ${launchedMemberModel}
|
|
||||||
Status: running${statusNote}
|
|
||||||
|
|
||||||
Use \`background_output\` with task_id="${launchedTaskId}" to collect this member's result.
|
|
||||||
- block=true: Wait for completion and return the result
|
|
||||||
- full_session=true: Include full session messages when needed
|
|
||||||
|
|
||||||
<task_metadata>
|
|
||||||
session_id: ${sessionId}
|
|
||||||
</task_metadata>`
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
export interface AthenaCouncilToolArgs {
|
|
||||||
question: string
|
|
||||||
members?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AthenaCouncilToolContext {
|
|
||||||
sessionID: string
|
|
||||||
messageID: string
|
|
||||||
agent: string
|
|
||||||
abort: AbortSignal
|
|
||||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void | Promise<void>
|
|
||||||
callID?: string
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user