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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface CouncilMemberConfig {
|
||||
model: string
|
||||
variant?: string
|
||||
name?: string
|
||||
name: string
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
50
src/agents/builtin-agents/athena-council-guard.ts
Normal file
50
src/agents/builtin-agents/athena-council-guard.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user