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

View File

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

View File

@ -30,6 +30,7 @@ import { maybeCreateHephaestusConfig } from "./builtin-agents/hephaestus-agent"
import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent" import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent"
import { buildCustomAgentMetadata, parseRegisteredAgentSummaries } from "./custom-agent-summaries" import { buildCustomAgentMetadata, parseRegisteredAgentSummaries } from "./custom-agent-summaries"
import { registerCouncilMemberAgents } from "./builtin-agents/council-member-agents" import { registerCouncilMemberAgents } from "./builtin-agents/council-member-agents"
import { appendMissingCouncilPrompt } from "./builtin-agents/athena-council-guard"
import type { CouncilConfig } from "./athena/types" import type { CouncilConfig } from "./athena/types"
type AgentSource = AgentFactory | AgentConfig type AgentSource = AgentFactory | AgentConfig
@ -215,9 +216,13 @@ export async function createBuiltinAgents(
...result["athena"], ...result["athena"],
prompt: (result["athena"].prompt ?? "") + councilTaskInstructions, prompt: (result["athena"].prompt ?? "") + councilTaskInstructions,
} }
} else {
result["athena"] = appendMissingCouncilPrompt(result["athena"])
} }
} else if (councilConfig && councilConfig.members.length >= 2 && !result["athena"]) { } else if (councilConfig && councilConfig.members.length >= 2 && !result["athena"]) {
log("[builtin-agents] Skipping council member registration — Athena is disabled") log("[builtin-agents] Skipping council member registration — Athena is disabled")
} else if (result["athena"]) {
result["athena"] = appendMissingCouncilPrompt(result["athena"])
} }
return result 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" import { registerCouncilMemberAgents } from "./council-member-agents"
describe("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 //#given
const config = { const config = {
members: [ members: [
{ model: "openai/gpt-5.3-codex" }, { model: "openai/gpt-5.3-codex", name: "GPT" },
{ model: "openai/gpt-5.3-codex" }, { model: "anthropic/claude-opus-4-6", name: "GPT" },
], ],
} }
//#when + #then //#when
expect(() => registerCouncilMemberAgents(config)).toThrow("already registered") const result = registerCouncilMemberAgents(config)
//#then
expect(result.registeredKeys).toHaveLength(0)
expect(result.agents).toEqual({})
}) })
test("registers different models without error", () => { test("registers different models without error", () => {
//#given //#given
const config = { const config = {
members: [ members: [
{ model: "openai/gpt-5.3-codex" }, { model: "openai/gpt-5.3-codex", name: "GPT" },
{ model: "anthropic/claude-opus-4-6" }, { model: "anthropic/claude-opus-4-6", name: "Claude" },
], ],
} }
//#when //#when
const result = registerCouncilMemberAgents(config) const result = registerCouncilMemberAgents(config)
//#then //#then
expect(result.registeredKeys).toHaveLength(2) expect(result.registeredKeys).toHaveLength(2)
expect(result.registeredKeys).toContain("Council: GPT 5.3 Codex") expect(result.registeredKeys).toContain("Council: GPT")
expect(result.registeredKeys).toContain("Council: Claude Opus 4.6") expect(result.registeredKeys).toContain("Council: Claude")
}) })
test("allows same model with different names", () => { test("allows same model with different names", () => {
@ -50,8 +53,8 @@ describe("council-member-agents", () => {
//#given - one valid model, one invalid (no slash separator) //#given - one valid model, one invalid (no slash separator)
const config = { const config = {
members: [ members: [
{ model: "openai/gpt-5.3-codex" }, { model: "openai/gpt-5.3-codex", name: "GPT" },
{ model: "invalid-no-slash" }, { model: "invalid-no-slash", name: "Invalid" },
], ],
} }
//#when //#when

View File

@ -7,44 +7,11 @@ import { log } from "../../shared/logger"
/** Prefix used for all dynamically-registered council member agent keys. */ /** Prefix used for all dynamically-registered council member agent keys. */
export const COUNCIL_MEMBER_KEY_PREFIX = "Council: " 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. * Generates a stable agent registration key from a council member's name.
* "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.
*/ */
export function getCouncilMemberAgentKey(member: CouncilMemberConfig): string { export function getCouncilMemberAgentKey(member: CouncilMemberConfig): string {
const displayName = member.name ?? humanizeModelId(member.model) return `${COUNCIL_MEMBER_KEY_PREFIX}${member.name}`
return `${COUNCIL_MEMBER_KEY_PREFIX}${displayName}`
} }
/** /**
@ -68,14 +35,15 @@ export function registerCouncilMemberAgents(
const key = getCouncilMemberAgentKey(member) const key = getCouncilMemberAgentKey(member)
const config = createCouncilMemberAgent(member.model) const config = createCouncilMemberAgent(member.model)
const friendlyName = member.name ?? humanizeModelId(member.model) const description = `Council member: ${member.name} (${member.model}). Independent read-only code analyst for Athena council. (OhMyOpenCode)`
const description = `Council member: ${friendlyName} (${member.model}). Independent read-only code analyst for Athena council. (OhMyOpenCode)`
if (agents[key]) { if (agents[key]) {
const existingModel = agents[key].model ?? "unknown" log("[council-member-agents] Skipping duplicate council member name", {
throw new Error( name: member.name,
`Council member key "${key}" is already registered (model: ${existingModel}). Use distinct "name" fields to avoid collisions.` model: member.model,
) existingModel: agents[key].model ?? "unknown",
})
continue
} }
agents[key] = { agents[key] = {

View File

@ -571,7 +571,7 @@ describe("Athena agent override", () => {
agents: { agents: {
athena: { athena: {
council: { 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" import { AthenaConfigSchema, CouncilConfigSchema, CouncilMemberSchema } from "./athena"
describe("CouncilMemberSchema", () => { describe("CouncilMemberSchema", () => {
test("accepts model-only member config", () => { test("accepts member config with model and name", () => {
//#given //#given
const config = { model: "anthropic/claude-opus-4-6" } const config = { model: "anthropic/claude-opus-4-6", name: "member-a" }
//#when //#when
const result = CouncilMemberSchema.safeParse(config) const result = CouncilMemberSchema.safeParse(config)
@ -103,20 +103,41 @@ describe("CouncilMemberSchema", () => {
test("optional fields are optional without runtime defaults", () => { test("optional fields are optional without runtime defaults", () => {
//#given //#given
const config = { model: "xai/grok-code-fast-1" } const config = { model: "xai/grok-code-fast-1", name: "member-x" }
//#when //#when
const parsed = CouncilMemberSchema.parse(config) const parsed = CouncilMemberSchema.parse(config)
//#then //#then
expect(parsed.variant).toBeUndefined() expect(parsed.variant).toBeUndefined()
expect(parsed.name).toBeUndefined()
expect(parsed.temperature).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", () => { test("accepts member config with temperature", () => {
//#given //#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 //#when
const result = CouncilMemberSchema.safeParse(config) const result = CouncilMemberSchema.safeParse(config)
@ -166,7 +187,10 @@ describe("CouncilConfigSchema", () => {
test("accepts council with 2 members", () => { test("accepts council with 2 members", () => {
//#given //#given
const config = { 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 //#when
@ -206,7 +230,7 @@ describe("CouncilConfigSchema", () => {
test("rejects council with 1 member", () => { test("rejects council with 1 member", () => {
//#given //#given
const config = { members: [{ model: "anthropic/claude-opus-4-6" }] } const config = { members: [{ model: "anthropic/claude-opus-4-6", name: "member-a" }] }
//#when //#when
const result = CouncilConfigSchema.safeParse(config) const result = CouncilConfigSchema.safeParse(config)
@ -233,7 +257,10 @@ describe("AthenaConfigSchema", () => {
const config = { const config = {
model: "anthropic/claude-opus-4-6", model: "anthropic/claude-opus-4-6",
council: { 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({ export const CouncilMemberSchema = z.object({
model: ModelStringSchema, model: ModelStringSchema,
variant: z.string().optional(), variant: z.string().optional(),
name: z.string().optional(), name: z.string().min(1),
temperature: z.number().min(0).max(2).optional(), temperature: z.number().min(0).max(2).optional(),
}).strict() }).strict()