diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 9ba32cc92..b5ed23e8d 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -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 } diff --git a/src/agents/athena/types.ts b/src/agents/athena/types.ts index eb88861ef..55c5454f6 100644 --- a/src/agents/athena/types.ts +++ b/src/agents/athena/types.ts @@ -1,7 +1,7 @@ export interface CouncilMemberConfig { model: string variant?: string - name?: string + name: string temperature?: number } diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts index 2f2cd0c7b..89fc1f1d9 100644 --- a/src/agents/builtin-agents.ts +++ b/src/agents/builtin-agents.ts @@ -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 diff --git a/src/agents/builtin-agents/athena-council-guard.ts b/src/agents/builtin-agents/athena-council-guard.ts new file mode 100644 index 000000000..565bd7cde --- /dev/null +++ b/src/agents/builtin-agents/athena-council-guard.ts @@ -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, + } +} diff --git a/src/agents/builtin-agents/council-member-agents.test.ts b/src/agents/builtin-agents/council-member-agents.test.ts index 6f4e30f75..eead30483 100644 --- a/src/agents/builtin-agents/council-member-agents.test.ts +++ b/src/agents/builtin-agents/council-member-agents.test.ts @@ -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 diff --git a/src/agents/builtin-agents/council-member-agents.ts b/src/agents/builtin-agents/council-member-agents.ts index 0bdc1d3d2..d2b83c69e 100644 --- a/src/agents/builtin-agents/council-member-agents.ts +++ b/src/agents/builtin-agents/council-member-agents.ts @@ -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] = { diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index d8df6bffd..95fd48347 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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" }], }, }, }, diff --git a/src/config/schema/athena.test.ts b/src/config/schema/athena.test.ts index f3bf6d8fd..14a1ba369 100644 --- a/src/config/schema/athena.test.ts +++ b/src/config/schema/athena.test.ts @@ -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" }, + ], }, } diff --git a/src/config/schema/athena.ts b/src/config/schema/athena.ts index 3972aaf04..ad7d96d5c 100644 --- a/src/config/schema/athena.ts +++ b/src/config/schema/athena.ts @@ -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()