feat(athena): harden council config — mandatory name, guard prompt, no-crash duplicates

- Add council config guard prompt: when Athena has no valid council members,
  inject a STOP instruction telling the user how to configure council members
  instead of failing messily with generic agents
- Make council member 'name' field mandatory (was optional with auto-naming)
- Remove humanizeModelId and UPPERCASE_TOKENS — no more fragile auto-naming
- Replace throw on duplicate names with log + skip (graceful degradation)
- Update schema, types, tests (87 pass), and documentation
This commit is contained in:
ismeth
2026-02-20 13:15:23 +01:00
committed by YeonGyu-Kim
parent 6c98677d22
commit e44354e98e
9 changed files with 144 additions and 65 deletions

View File

@@ -3325,6 +3325,18 @@
"providerOptions": {
"type": "object",
"additionalProperties": {}
},
"ultrawork": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -3499,6 +3511,18 @@
"type": "object",
"additionalProperties": {}
},
"ultrawork": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
},
"council": {
"type": "object",
"properties": {
@@ -3516,7 +3540,8 @@
"type": "string"
},
"name": {
"type": "string"
"type": "string",
"minLength": 1
},
"temperature": {
"type": "number",
@@ -3525,7 +3550,8 @@
}
},
"required": [
"model"
"model",
"name"
],
"additionalProperties": false
}

View File

@@ -1,7 +1,7 @@
export interface CouncilMemberConfig {
model: string
variant?: string
name?: string
name: string
temperature?: number
}

View File

@@ -30,6 +30,7 @@ import { maybeCreateHephaestusConfig } from "./builtin-agents/hephaestus-agent"
import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent"
import { buildCustomAgentMetadata, parseRegisteredAgentSummaries } from "./custom-agent-summaries"
import { registerCouncilMemberAgents } from "./builtin-agents/council-member-agents"
import { appendMissingCouncilPrompt } from "./builtin-agents/athena-council-guard"
import type { CouncilConfig } from "./athena/types"
type AgentSource = AgentFactory | AgentConfig
@@ -215,9 +216,13 @@ export async function createBuiltinAgents(
...result["athena"],
prompt: (result["athena"].prompt ?? "") + councilTaskInstructions,
}
} else {
result["athena"] = appendMissingCouncilPrompt(result["athena"])
}
} else if (councilConfig && councilConfig.members.length >= 2 && !result["athena"]) {
log("[builtin-agents] Skipping council member registration — Athena is disabled")
} else if (result["athena"]) {
result["athena"] = appendMissingCouncilPrompt(result["athena"])
}
return result

View File

@@ -0,0 +1,50 @@
import type { AgentConfig } from "@opencode-ai/sdk"
const MISSING_COUNCIL_PROMPT = `
## CRITICAL: No Council Members Configured
**STOP. Do NOT attempt to launch any council members or use the task tool.**
You have no council members registered. This means the Athena council config is either missing or invalid in the oh-my-opencode configuration.
**Your ONLY action**: Inform the user with this exact message:
---
**Athena council is not configured.** To use Athena, add council members to your oh-my-opencode config:
**Config file**: \`.opencode/oh-my-opencode.jsonc\` (project) or \`~/.config/opencode/oh-my-opencode.jsonc\` (user)
\`\`\`jsonc
{
"agents": {
"athena": {
"council": {
"members": [
{ "model": "anthropic/claude-opus-4-6", "name": "Claude" },
{ "model": "openai/gpt-5.2", "name": "GPT" },
{ "model": "google/gemini-3-pro", "name": "Gemini" }
]
}
}
}
}
\`\`\`
Each member requires \`model\` (\`"provider/model-id"\` format) and \`name\` (display name). Minimum 2 members required. Optional fields: \`variant\`, \`temperature\`.
---
After informing the user, **end your turn**. Do NOT try to work around this by using generic agents, the council-member agent, or any other fallback.`
/**
* Replaces Athena's prompt with a guard that tells the user to configure council members.
* Used when Athena is registered but no valid council config exists.
*/
export function appendMissingCouncilPrompt(athenaConfig: AgentConfig): AgentConfig {
return {
...athenaConfig,
prompt: (athenaConfig.prompt ?? "") + MISSING_COUNCIL_PROMPT,
}
}

View File

@@ -2,32 +2,35 @@ import { describe, expect, test } from "bun:test"
import { registerCouncilMemberAgents } from "./council-member-agents"
describe("council-member-agents", () => {
test("throws on duplicate model without name", () => {
test("skips duplicate names and disables council when below minimum", () => {
//#given
const config = {
members: [
{ model: "openai/gpt-5.3-codex" },
{ model: "openai/gpt-5.3-codex" },
{ model: "openai/gpt-5.3-codex", name: "GPT" },
{ model: "anthropic/claude-opus-4-6", name: "GPT" },
],
}
//#when + #then
expect(() => registerCouncilMemberAgents(config)).toThrow("already registered")
//#when
const result = registerCouncilMemberAgents(config)
//#then
expect(result.registeredKeys).toHaveLength(0)
expect(result.agents).toEqual({})
})
test("registers different models without error", () => {
//#given
const config = {
members: [
{ model: "openai/gpt-5.3-codex" },
{ model: "anthropic/claude-opus-4-6" },
{ model: "openai/gpt-5.3-codex", name: "GPT" },
{ model: "anthropic/claude-opus-4-6", name: "Claude" },
],
}
//#when
const result = registerCouncilMemberAgents(config)
//#then
expect(result.registeredKeys).toHaveLength(2)
expect(result.registeredKeys).toContain("Council: GPT 5.3 Codex")
expect(result.registeredKeys).toContain("Council: Claude Opus 4.6")
expect(result.registeredKeys).toContain("Council: GPT")
expect(result.registeredKeys).toContain("Council: Claude")
})
test("allows same model with different names", () => {
@@ -50,8 +53,8 @@ describe("council-member-agents", () => {
//#given - one valid model, one invalid (no slash separator)
const config = {
members: [
{ model: "openai/gpt-5.3-codex" },
{ model: "invalid-no-slash" },
{ model: "openai/gpt-5.3-codex", name: "GPT" },
{ model: "invalid-no-slash", name: "Invalid" },
],
}
//#when

View File

@@ -7,44 +7,11 @@ import { log } from "../../shared/logger"
/** Prefix used for all dynamically-registered council member agent keys. */
export const COUNCIL_MEMBER_KEY_PREFIX = "Council: "
const UPPERCASE_TOKENS = new Set(["gpt", "llm", "ai", "api"])
/**
* Derives a human-friendly display name from a model string.
* "anthropic/claude-opus-4-6" → "Claude Opus 4.6"
* "openai/gpt-5.3-codex" → "GPT 5.3 Codex"
*/
function humanizeModelId(model: string): string {
const modelId = model.includes("/") ? model.split("/").pop() ?? model : model
const parts = modelId.split("-")
const result: string[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (/^\d+$/.test(part)) {
const versionParts = [part]
while (i + 1 < parts.length && /^\d+$/.test(parts[i + 1])) {
i++
versionParts.push(parts[i])
}
result.push(versionParts.join("."))
} else if (UPPERCASE_TOKENS.has(part.toLowerCase())) {
result.push(part.toUpperCase())
} else {
result.push(part.charAt(0).toUpperCase() + part.slice(1))
}
}
return result.join(" ")
}
/**
* Generates a stable agent registration key from a council member config.
* Uses the member's name if present, otherwise derives a friendly name from the model ID.
* Generates a stable agent registration key from a council member's name.
*/
export function getCouncilMemberAgentKey(member: CouncilMemberConfig): string {
const displayName = member.name ?? humanizeModelId(member.model)
return `${COUNCIL_MEMBER_KEY_PREFIX}${displayName}`
return `${COUNCIL_MEMBER_KEY_PREFIX}${member.name}`
}
/**
@@ -68,14 +35,15 @@ export function registerCouncilMemberAgents(
const key = getCouncilMemberAgentKey(member)
const config = createCouncilMemberAgent(member.model)
const friendlyName = member.name ?? humanizeModelId(member.model)
const description = `Council member: ${friendlyName} (${member.model}). Independent read-only code analyst for Athena council. (OhMyOpenCode)`
const description = `Council member: ${member.name} (${member.model}). Independent read-only code analyst for Athena council. (OhMyOpenCode)`
if (agents[key]) {
const existingModel = agents[key].model ?? "unknown"
throw new Error(
`Council member key "${key}" is already registered (model: ${existingModel}). Use distinct "name" fields to avoid collisions.`
)
log("[council-member-agents] Skipping duplicate council member name", {
name: member.name,
model: member.model,
existingModel: agents[key].model ?? "unknown",
})
continue
}
agents[key] = {

View File

@@ -571,7 +571,7 @@ describe("Athena agent override", () => {
agents: {
athena: {
council: {
members: [{ model: "openai/gpt-5.3-codex" }],
members: [{ model: "openai/gpt-5.3-codex", name: "GPT" }],
},
},
},

View File

@@ -3,9 +3,9 @@ import { z } from "zod"
import { AthenaConfigSchema, CouncilConfigSchema, CouncilMemberSchema } from "./athena"
describe("CouncilMemberSchema", () => {
test("accepts model-only member config", () => {
test("accepts member config with model and name", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6" }
const config = { model: "anthropic/claude-opus-4-6", name: "member-a" }
//#when
const result = CouncilMemberSchema.safeParse(config)
@@ -103,20 +103,41 @@ describe("CouncilMemberSchema", () => {
test("optional fields are optional without runtime defaults", () => {
//#given
const config = { model: "xai/grok-code-fast-1" }
const config = { model: "xai/grok-code-fast-1", name: "member-x" }
//#when
const parsed = CouncilMemberSchema.parse(config)
//#then
expect(parsed.variant).toBeUndefined()
expect(parsed.name).toBeUndefined()
expect(parsed.temperature).toBeUndefined()
})
test("rejects member config missing name", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects member config with empty name", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: "" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("accepts member config with temperature", () => {
//#given
const config = { model: "openai/gpt-5.3-codex", temperature: 0.5 }
const config = { model: "openai/gpt-5.3-codex", name: "member-a", temperature: 0.5 }
//#when
const result = CouncilMemberSchema.safeParse(config)
@@ -166,7 +187,10 @@ describe("CouncilConfigSchema", () => {
test("accepts council with 2 members", () => {
//#given
const config = {
members: [{ model: "anthropic/claude-opus-4-6" }, { model: "openai/gpt-5.3-codex" }],
members: [
{ model: "anthropic/claude-opus-4-6", name: "member-a" },
{ model: "openai/gpt-5.3-codex", name: "member-b" },
],
}
//#when
@@ -206,7 +230,7 @@ describe("CouncilConfigSchema", () => {
test("rejects council with 1 member", () => {
//#given
const config = { members: [{ model: "anthropic/claude-opus-4-6" }] }
const config = { members: [{ model: "anthropic/claude-opus-4-6", name: "member-a" }] }
//#when
const result = CouncilConfigSchema.safeParse(config)
@@ -233,7 +257,10 @@ describe("AthenaConfigSchema", () => {
const config = {
model: "anthropic/claude-opus-4-6",
council: {
members: [{ model: "openai/gpt-5.3-codex" }, { model: "xai/grok-code-fast-1" }],
members: [
{ model: "openai/gpt-5.3-codex", name: "member-a" },
{ model: "xai/grok-code-fast-1", name: "member-b" },
],
},
}

View File

@@ -13,7 +13,7 @@ const ModelStringSchema = z
export const CouncilMemberSchema = z.object({
model: ModelStringSchema,
variant: z.string().optional(),
name: z.string().optional(),
name: z.string().min(1),
temperature: z.number().min(0).max(2).optional(),
}).strict()