From 647f691fe27df141435a16c4d5739bf4f73edfb5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 12:59:22 +0900 Subject: [PATCH] feat(agents): add Athena council foundation --- assets/oh-my-opencode.schema.json | 318 ++++++++++++++++++ src/agents/athena.ts | 16 + src/agents/athena/council-contract.ts | 36 ++ src/agents/athena/council-members.ts | 24 ++ src/agents/athena/council-quorum.test.ts | 38 +++ src/agents/athena/council-quorum.ts | 36 ++ .../athena/council-response-parser.test.ts | 71 ++++ src/agents/athena/council-response-parser.ts | 159 +++++++++ src/agents/athena/council-retry.test.ts | 50 +++ src/agents/athena/council-retry.ts | 68 ++++ src/agents/athena/council-synthesis.test.ts | 43 +++ src/agents/athena/council-synthesis.ts | 141 ++++++++ src/agents/athena/prompt.ts | 68 ++++ src/agents/builtin-agents.ts | 4 + src/agents/builtin-agents/general-agents.ts | 6 +- src/agents/council-member.ts | 51 +++ src/agents/types.ts | 6 +- src/agents/utils.test.ts | 26 ++ .../__snapshots__/model-fallback.test.ts.snap | 147 ++++++++ src/config/index.ts | 1 + src/config/schema.ts | 1 + src/config/schema/agent-names.ts | 3 + src/config/schema/agent-overrides.ts | 1 + src/config/schema/athena-config.test.ts | 82 +++++ src/config/schema/athena-config.ts | 39 +++ src/config/schema/oh-my-opencode-config.ts | 2 + src/plugin-handlers/agent-config-handler.ts | 6 + src/plugin-handlers/agent-priority-order.ts | 5 +- .../athena-council-agent-wiring.test.ts | 36 ++ .../athena-council-agent-wiring.ts | 42 +++ .../tool-config-handler.test.ts | 46 +++ src/plugin-handlers/tool-config-handler.ts | 40 +++ src/shared/agent-display-names.test.ts | 4 +- src/shared/agent-display-names.ts | 4 +- src/shared/agent-tool-restrictions.test.ts | 44 +++ src/shared/agent-tool-restrictions.ts | 19 ++ src/shared/migration/agent-names.ts | 6 + src/shared/model-requirements.test.ts | 7 +- src/shared/model-requirements.ts | 26 ++ 39 files changed, 1710 insertions(+), 12 deletions(-) create mode 100644 src/agents/athena.ts create mode 100644 src/agents/athena/council-contract.ts create mode 100644 src/agents/athena/council-members.ts create mode 100644 src/agents/athena/council-quorum.test.ts create mode 100644 src/agents/athena/council-quorum.ts create mode 100644 src/agents/athena/council-response-parser.test.ts create mode 100644 src/agents/athena/council-response-parser.ts create mode 100644 src/agents/athena/council-retry.test.ts create mode 100644 src/agents/athena/council-retry.ts create mode 100644 src/agents/athena/council-synthesis.test.ts create mode 100644 src/agents/athena/council-synthesis.ts create mode 100644 src/agents/athena/prompt.ts create mode 100644 src/agents/council-member.ts create mode 100644 src/config/schema/athena-config.test.ts create mode 100644 src/config/schema/athena-config.ts create mode 100644 src/plugin-handlers/athena-council-agent-wiring.test.ts create mode 100644 src/plugin-handlers/athena-council-agent-wiring.ts create mode 100644 src/shared/agent-tool-restrictions.test.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 3ab34f714..0be00dbfb 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -1211,6 +1211,289 @@ }, "additionalProperties": false }, + "athena": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "sisyphus-junior": { "type": "object", "properties": { @@ -4044,6 +4327,41 @@ }, "additionalProperties": false }, + "athena": { + "type": "object", + "properties": { + "model": { + "type": "string", + "pattern": "^[^/\\s]+\\/[^/\\s]+$" + }, + "members": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "pattern": "^[^/\\s]+\\/[^/\\s]+$" + } + }, + "required": [ + "name", + "model" + ], + "additionalProperties": false + } + } + }, + "required": [ + "members" + ], + "additionalProperties": false + }, "categories": { "type": "object", "propertyNames": { diff --git a/src/agents/athena.ts b/src/agents/athena.ts new file mode 100644 index 000000000..2a8955192 --- /dev/null +++ b/src/agents/athena.ts @@ -0,0 +1,16 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import type { AgentMode } from "./types" +import { buildAthenaPrompt, type AthenaPromptOptions } from "./athena/prompt" + +const MODE: AgentMode = "primary" + +export function createAthenaAgent(model: string, options?: AthenaPromptOptions): AgentConfig { + return { + description: "Primary council orchestrator for Athena workflows. (Athena - OhMyOpenCode)", + mode: MODE, + model, + temperature: 0.1, + prompt: buildAthenaPrompt(options), + } +} +createAthenaAgent.mode = MODE diff --git a/src/agents/athena/council-contract.ts b/src/agents/athena/council-contract.ts new file mode 100644 index 000000000..c9d79505e --- /dev/null +++ b/src/agents/athena/council-contract.ts @@ -0,0 +1,36 @@ +export const COUNCIL_MEMBER_RESPONSE_TAG = "COUNCIL_MEMBER_RESPONSE" + +export type CouncilVerdict = "support" | "oppose" | "mixed" | "abstain" + +export interface CouncilEvidenceItem { + source: string + detail: string +} + +export interface CouncilMemberResponse { + member: string + verdict: CouncilVerdict + confidence: number + rationale: string + risks: string[] + evidence: CouncilEvidenceItem[] + proposed_actions: string[] + missing_information: string[] +} + +export interface AthenaCouncilMember { + name: string + model: string +} + +export interface ParsedCouncilMemberResponse { + ok: true + value: CouncilMemberResponse + source: "raw_json" | "tagged_json" +} + +export interface CouncilResponseParseFailure { + ok: false + error: string + source: "raw_json" | "tagged_json" | "none" +} diff --git a/src/agents/athena/council-members.ts b/src/agents/athena/council-members.ts new file mode 100644 index 000000000..a43a6033b --- /dev/null +++ b/src/agents/athena/council-members.ts @@ -0,0 +1,24 @@ +import type { AthenaCouncilMember } from "./council-contract" + +function slugify(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export function toCouncilMemberAgentName(memberName: string): string { + const slug = slugify(memberName) + return `council-member-${slug || "member"}` +} + +export function buildCouncilRosterSection(members: AthenaCouncilMember[]): string { + if (members.length === 0) { + return "- No configured council roster. Use default subagent_type=\"council-member\"." + } + + return members + .map((member) => `- ${member.name} | model=${member.model} | subagent_type=${toCouncilMemberAgentName(member.name)}`) + .join("\n") +} diff --git a/src/agents/athena/council-quorum.test.ts b/src/agents/athena/council-quorum.test.ts new file mode 100644 index 000000000..38a5f38a4 --- /dev/null +++ b/src/agents/athena/council-quorum.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { evaluateCouncilQuorum } from "./council-quorum" + +describe("evaluateCouncilQuorum", () => { + test("#given partial failures with enough successful members #when evaluating #then quorum reached with graceful degradation", () => { + // given + const input = { + totalMembers: 5, + successfulMembers: 3, + failedMembers: 2, + } + + // when + const result = evaluateCouncilQuorum(input) + + // then + expect(result.required).toBe(3) + expect(result.reached).toBe(true) + expect(result.gracefulDegradation).toBe(true) + }) + + test("#given too many failures #when evaluating #then quorum is unreachable", () => { + // given + const input = { + totalMembers: 4, + successfulMembers: 1, + failedMembers: 3, + } + + // when + const result = evaluateCouncilQuorum(input) + + // then + expect(result.required).toBe(2) + expect(result.reached).toBe(false) + expect(result.canStillReach).toBe(false) + }) +}) diff --git a/src/agents/athena/council-quorum.ts b/src/agents/athena/council-quorum.ts new file mode 100644 index 000000000..923505dc9 --- /dev/null +++ b/src/agents/athena/council-quorum.ts @@ -0,0 +1,36 @@ +export interface CouncilQuorumInput { + totalMembers: number + successfulMembers: number + failedMembers: number + requestedQuorum?: number +} + +export interface CouncilQuorumResult { + required: number + reached: boolean + canStillReach: boolean + gracefulDegradation: boolean +} + +function clampMinimumQuorum(totalMembers: number, requestedQuorum?: number): number { + if (requestedQuorum && requestedQuorum > 0) { + return Math.min(totalMembers, requestedQuorum) + } + + return Math.max(1, Math.ceil(totalMembers / 2)) +} + +export function evaluateCouncilQuorum(input: CouncilQuorumInput): CouncilQuorumResult { + const required = clampMinimumQuorum(input.totalMembers, input.requestedQuorum) + const reached = input.successfulMembers >= required + const remainingPossible = input.totalMembers - input.failedMembers + const canStillReach = remainingPossible >= required + const gracefulDegradation = reached && input.failedMembers > 0 + + return { + required, + reached, + canStillReach, + gracefulDegradation, + } +} diff --git a/src/agents/athena/council-response-parser.test.ts b/src/agents/athena/council-response-parser.test.ts new file mode 100644 index 000000000..4cff836c8 --- /dev/null +++ b/src/agents/athena/council-response-parser.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" +import { parseCouncilMemberResponse } from "./council-response-parser" + +describe("parseCouncilMemberResponse", () => { + test("#given valid raw json #when parsing #then returns parsed council payload", () => { + // given + const raw = JSON.stringify({ + member: "architect", + verdict: "support", + confidence: 0.9, + rationale: "Matches existing module boundaries", + risks: ["Regression in edge-case parser"], + evidence: [{ source: "src/agents/athena.ts", detail: "Current prompt is too generic" }], + proposed_actions: ["Add strict orchestration workflow"], + missing_information: ["Need runtime timeout budget"], + }) + + // when + const result = parseCouncilMemberResponse(raw) + + // then + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.source).toBe("raw_json") + expect(result.value.member).toBe("architect") + expect(result.value.verdict).toBe("support") + }) + + test("#given tagged json payload #when parsing #then extracts from COUNCIL_MEMBER_RESPONSE tag", () => { + // given + const raw = [ + "analysis intro", + "", + JSON.stringify({ + member: "skeptic", + verdict: "mixed", + confidence: 0.62, + rationale: "Quorum logic exists but retry handling is weak", + risks: ["Timeout blind spot"], + evidence: [{ source: "src/tools/background-task/create-background-wait.ts", detail: "No nudge semantics" }], + proposed_actions: ["Add stuck detection policy"], + missing_information: [], + }), + "", + ].join("\n") + + // when + const result = parseCouncilMemberResponse(raw) + + // then + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.source).toBe("tagged_json") + expect(result.value.member).toBe("skeptic") + expect(result.value.proposed_actions).toEqual(["Add stuck detection policy"]) + }) + + test("#given malformed payload #when parsing #then returns structured parse failure", () => { + // given + const raw = "Council says: maybe this works" + + // when + const result = parseCouncilMemberResponse(raw) + + // then + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.source).toBe("none") + expect(result.error.length).toBeGreaterThan(0) + }) +}) diff --git a/src/agents/athena/council-response-parser.ts b/src/agents/athena/council-response-parser.ts new file mode 100644 index 000000000..14213c813 --- /dev/null +++ b/src/agents/athena/council-response-parser.ts @@ -0,0 +1,159 @@ +import { + COUNCIL_MEMBER_RESPONSE_TAG, + type CouncilMemberResponse, + type CouncilResponseParseFailure, + type ParsedCouncilMemberResponse, +} from "./council-contract" + +type ParseResult = ParsedCouncilMemberResponse | CouncilResponseParseFailure + +function normalizeJsonPayload(input: string): string { + const trimmed = input.trim() + if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) { + return trimmed + } + + const firstNewLine = trimmed.indexOf("\n") + if (firstNewLine < 0) { + return trimmed + } + + return trimmed.slice(firstNewLine + 1, -3).trim() +} + +function tryParseJsonObject(input: string): unknown { + const normalized = normalizeJsonPayload(input) + if (!normalized.startsWith("{")) { + return null + } + + try { + return JSON.parse(normalized) + } catch { + return null + } +} + +function extractTaggedPayload(raw: string): string | null { + const xmlLike = new RegExp( + `<${COUNCIL_MEMBER_RESPONSE_TAG}>([\\s\\S]*?)<\\/${COUNCIL_MEMBER_RESPONSE_TAG}>`, + "i", + ) + const xmlMatch = raw.match(xmlLike) + if (xmlMatch?.[1]) { + return xmlMatch[1].trim() + } + + const prefixed = new RegExp(`${COUNCIL_MEMBER_RESPONSE_TAG}\\s*:\\s*`, "i") + const prefixMatch = raw.match(prefixed) + if (!prefixMatch) { + return null + } + + const matchIndex = prefixMatch.index + if (matchIndex === undefined) { + return null + } + + const rest = raw.slice(matchIndex + prefixMatch[0].length) + const firstBrace = rest.indexOf("{") + if (firstBrace < 0) { + return null + } + + return rest.slice(firstBrace).trim() +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string") +} + +function isEvidenceArray(value: unknown): value is CouncilMemberResponse["evidence"] { + return Array.isArray(value) + && value.every( + (item) => + typeof item === "object" + && item !== null + && typeof (item as { source?: unknown }).source === "string" + && typeof (item as { detail?: unknown }).detail === "string", + ) +} + +function validateCouncilMemberResponse(payload: unknown): CouncilMemberResponse | null { + if (typeof payload !== "object" || payload === null) { + return null + } + + const candidate = payload as Record + const verdict = candidate.verdict + const confidence = candidate.confidence + + if ( + typeof candidate.member !== "string" + || (verdict !== "support" && verdict !== "oppose" && verdict !== "mixed" && verdict !== "abstain") + || typeof confidence !== "number" + || confidence < 0 + || confidence > 1 + || typeof candidate.rationale !== "string" + || !isStringArray(candidate.risks) + || !isEvidenceArray(candidate.evidence) + || !isStringArray(candidate.proposed_actions) + || !isStringArray(candidate.missing_information) + ) { + return null + } + + return { + member: candidate.member, + verdict, + confidence, + rationale: candidate.rationale, + risks: candidate.risks, + evidence: candidate.evidence, + proposed_actions: candidate.proposed_actions, + missing_information: candidate.missing_information, + } +} + +function parseValidated(payload: unknown, source: ParsedCouncilMemberResponse["source"]): ParseResult { + const validated = validateCouncilMemberResponse(payload) + if (!validated) { + return { + ok: false, + error: "Council member response does not match required contract", + source, + } + } + + return { + ok: true, + value: validated, + source, + } +} + +export function parseCouncilMemberResponse(raw: string): ParseResult { + const directJson = tryParseJsonObject(raw) + if (directJson) { + return parseValidated(directJson, "raw_json") + } + + const taggedPayload = extractTaggedPayload(raw) + if (taggedPayload) { + const taggedJson = tryParseJsonObject(taggedPayload) + if (taggedJson) { + return parseValidated(taggedJson, "tagged_json") + } + return { + ok: false, + error: "Tagged council response found, but JSON payload is invalid", + source: "tagged_json", + } + } + + return { + ok: false, + error: "No parseable council response payload found", + source: "none", + } +} diff --git a/src/agents/athena/council-retry.test.ts b/src/agents/athena/council-retry.test.ts new file mode 100644 index 000000000..35916634c --- /dev/null +++ b/src/agents/athena/council-retry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { decideCouncilRecoveryAction } from "./council-retry" + +describe("decideCouncilRecoveryAction", () => { + test("#given running member with stale progress and nudge budget #when deciding #then nudge", () => { + // given + const now = 10_000 + const decision = decideCouncilRecoveryAction( + { + status: "running", + attempts: 1, + nudges: 0, + startedAt: 1_000, + lastProgressAt: 1_000, + }, + { + maxAttempts: 2, + maxNudges: 1, + stuckAfterMs: 2_000, + }, + now, + ) + + // then + expect(decision.action).toBe("nudge") + }) + + test("#given stuck member after nudge with retry budget #when deciding #then retry", () => { + // given + const now = 20_000 + const decision = decideCouncilRecoveryAction( + { + status: "running", + attempts: 1, + nudges: 1, + startedAt: 1_000, + lastProgressAt: 1_000, + }, + { + maxAttempts: 3, + maxNudges: 1, + stuckAfterMs: 5_000, + }, + now, + ) + + // then + expect(decision.action).toBe("retry") + }) +}) diff --git a/src/agents/athena/council-retry.ts b/src/agents/athena/council-retry.ts new file mode 100644 index 000000000..2a370bc82 --- /dev/null +++ b/src/agents/athena/council-retry.ts @@ -0,0 +1,68 @@ +export type CouncilMemberTaskStatus = + | "pending" + | "running" + | "completed" + | "failed" + | "cancelled" + | "timed_out" + +export interface CouncilMemberTaskState { + status: CouncilMemberTaskStatus + attempts: number + nudges: number + startedAt: number + lastProgressAt: number +} + +export interface CouncilRetryPolicy { + maxAttempts: number + maxNudges: number + stuckAfterMs: number +} + +export type CouncilRecoveryAction = "wait" | "nudge" | "retry" | "give_up" + +export interface CouncilRecoveryDecision { + action: CouncilRecoveryAction + reason: string +} + +export function isCouncilMemberStuck( + now: number, + lastProgressAt: number, + stuckAfterMs: number, +): boolean { + return now - lastProgressAt >= stuckAfterMs +} + +export function decideCouncilRecoveryAction( + state: CouncilMemberTaskState, + policy: CouncilRetryPolicy, + now: number, +): CouncilRecoveryDecision { + if (state.status === "completed" || state.status === "cancelled") { + return { action: "give_up", reason: "Task already reached terminal status" } + } + + if (state.status === "failed" || state.status === "timed_out") { + if (state.attempts < policy.maxAttempts) { + return { action: "retry", reason: "Terminal failure with retries remaining" } + } + return { action: "give_up", reason: "Terminal failure and retry budget exhausted" } + } + + const stuck = isCouncilMemberStuck(now, state.lastProgressAt, policy.stuckAfterMs) + if (!stuck) { + return { action: "wait", reason: "Task is still making progress" } + } + + if (state.nudges < policy.maxNudges) { + return { action: "nudge", reason: "Task appears stuck and nudge budget remains" } + } + + if (state.attempts < policy.maxAttempts) { + return { action: "retry", reason: "Task stuck after nudges, retrying with fresh run" } + } + + return { action: "give_up", reason: "Task stuck and all recovery budgets exhausted" } +} diff --git a/src/agents/athena/council-synthesis.test.ts b/src/agents/athena/council-synthesis.test.ts new file mode 100644 index 000000000..6407de75a --- /dev/null +++ b/src/agents/athena/council-synthesis.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { synthesizeCouncilOutcome } from "./council-synthesis" +import type { CouncilMemberResponse } from "./council-contract" + +function response(overrides: Partial): CouncilMemberResponse { + return { + member: "member-a", + verdict: "support", + confidence: 0.8, + rationale: "default rationale", + risks: [], + evidence: [{ source: "file.ts", detail: "detail" }], + proposed_actions: ["Ship with tests"], + missing_information: [], + ...overrides, + } +} + +describe("synthesizeCouncilOutcome", () => { + test("#given majority support with one failure #when synthesizing #then reports agreement and graceful degradation", () => { + // given + const responses = [ + response({ member: "architect", verdict: "support", proposed_actions: ["Ship with tests"] }), + response({ member: "skeptic", verdict: "support", proposed_actions: ["Ship with tests"] }), + response({ member: "critic", verdict: "oppose", risks: ["Parser drift"] }), + ] + + // when + const result = synthesizeCouncilOutcome({ + responses, + failedMembers: ["perf"], + quorumReached: true, + }) + + // then + expect(result.majorityVerdict).toBe("support") + expect(result.agreementMembers).toEqual(["architect", "skeptic"]) + expect(result.disagreementMembers).toContain("critic") + expect(result.disagreementMembers).toContain("perf") + expect(result.commonActions).toEqual(["Ship with tests"]) + expect(result.gracefulDegradation).toBe(true) + }) +}) diff --git a/src/agents/athena/council-synthesis.ts b/src/agents/athena/council-synthesis.ts new file mode 100644 index 000000000..c321ca1db --- /dev/null +++ b/src/agents/athena/council-synthesis.ts @@ -0,0 +1,141 @@ +import type { CouncilMemberResponse, CouncilVerdict } from "./council-contract" + +export interface CouncilSynthesisInput { + responses: CouncilMemberResponse[] + failedMembers: string[] + quorumReached: boolean +} + +export interface CouncilSynthesisResult { + majorityVerdict: CouncilVerdict + consensusLevel: "unanimous" | "strong" | "split" | "fragmented" + agreementMembers: string[] + disagreementMembers: string[] + commonActions: string[] + contestedRisks: string[] + unresolvedQuestions: string[] + gracefulDegradation: boolean +} + +function normalizeKey(value: string): string { + return value.trim().toLowerCase() +} + +function getMajorityVerdict(responses: CouncilMemberResponse[]): CouncilVerdict { + const counts = new Map() + for (const response of responses) { + counts.set(response.verdict, (counts.get(response.verdict) ?? 0) + 1) + } + + const orderedVerdicts: CouncilVerdict[] = ["support", "mixed", "oppose", "abstain"] + let winner: CouncilVerdict = "abstain" + let winnerCount = -1 + + for (const verdict of orderedVerdicts) { + const count = counts.get(verdict) ?? 0 + if (count > winnerCount) { + winner = verdict + winnerCount = count + } + } + + return winner +} + +function deriveConsensusLevel(agreementCount: number, totalCount: number): CouncilSynthesisResult["consensusLevel"] { + if (totalCount === 0) { + return "fragmented" + } + + if (agreementCount === totalCount) { + return "unanimous" + } + + const ratio = agreementCount / totalCount + if (ratio >= 0.75) { + return "strong" + } + if (ratio >= 0.5) { + return "split" + } + return "fragmented" +} + +function collectCommonActions(responses: CouncilMemberResponse[]): string[] { + const counts = new Map() + for (const response of responses) { + for (const action of response.proposed_actions) { + const key = normalizeKey(action) + const existing = counts.get(key) + if (!existing) { + counts.set(key, { text: action, count: 1 }) + continue + } + existing.count += 1 + } + } + + const threshold = Math.max(2, Math.ceil(responses.length / 2)) + return [...counts.values()] + .filter((item) => item.count >= threshold) + .map((item) => item.text) +} + +function collectContestedRisks(responses: CouncilMemberResponse[]): string[] { + const counts = new Map() + for (const response of responses) { + for (const risk of response.risks) { + const key = normalizeKey(risk) + const existing = counts.get(key) + if (!existing) { + counts.set(key, { text: risk, count: 1 }) + continue + } + existing.count += 1 + } + } + + return [...counts.values()] + .filter((item) => item.count === 1) + .map((item) => item.text) +} + +function collectUnresolvedQuestions(responses: CouncilMemberResponse[]): string[] { + const seen = new Set() + const questions: string[] = [] + + for (const response of responses) { + for (const question of response.missing_information) { + const key = normalizeKey(question) + if (seen.has(key)) { + continue + } + seen.add(key) + questions.push(question) + } + } + + return questions +} + +export function synthesizeCouncilOutcome(input: CouncilSynthesisInput): CouncilSynthesisResult { + const majorityVerdict = getMajorityVerdict(input.responses) + const agreementMembers = input.responses + .filter((response) => response.verdict === majorityVerdict) + .map((response) => response.member) + const disagreementMembers = input.responses + .filter((response) => response.verdict !== majorityVerdict) + .map((response) => response.member) + .concat(input.failedMembers) + + return { + majorityVerdict, + consensusLevel: deriveConsensusLevel(agreementMembers.length, input.responses.length), + agreementMembers, + disagreementMembers, + commonActions: collectCommonActions(input.responses), + contestedRisks: collectContestedRisks(input.responses), + unresolvedQuestions: collectUnresolvedQuestions(input.responses), + gracefulDegradation: input.quorumReached && input.failedMembers.length > 0, + } +} diff --git a/src/agents/athena/prompt.ts b/src/agents/athena/prompt.ts new file mode 100644 index 000000000..3f67a0a20 --- /dev/null +++ b/src/agents/athena/prompt.ts @@ -0,0 +1,68 @@ +import type { AthenaCouncilMember } from "./council-contract" +import { COUNCIL_MEMBER_RESPONSE_TAG } from "./council-contract" +import { buildCouncilRosterSection } from "./council-members" + +export interface AthenaPromptOptions { + members?: AthenaCouncilMember[] +} + +export function buildAthenaPrompt(options: AthenaPromptOptions = {}): string { + const roster = buildCouncilRosterSection(options.members ?? []) + + return `You are Athena, a primary council orchestrator agent. + +Operate as a strict multi-model council coordinator. + +Core workflow: +1) Receive user request and define a concise decision question for the council. +2) Fan out council-member tasks in parallel with task(..., run_in_background=true). +3) Collect with background_wait first, then background_output for completed IDs. +4) Parse each member output as strict JSON contract; fallback to ${COUNCIL_MEMBER_RESPONSE_TAG} tag extraction. +5) Apply quorum, retries, and graceful degradation. +6) Synthesize agreement vs disagreement explicitly, then provide final recommendation. + +Council roster: +${roster} + +Execution protocol: +- Always run council fan-out in parallel. Never sequentially wait on one member before launching others. +- Use subagent_type="council-member" if no named roster is configured. +- For named roster entries, use that exact subagent_type so each member runs on its assigned model. +- Keep prompts evidence-oriented and read-only. Members must inspect code, tests, logs, and config references. +- Never ask members to edit files, delegate, or switch agents. + +Member response contract (required): +- Preferred: raw JSON only. +- Fallback allowed: wrap JSON in <${COUNCIL_MEMBER_RESPONSE_TAG}>.... +- Required JSON keys: + { + "member": string, + "verdict": "support" | "oppose" | "mixed" | "abstain", + "confidence": number (0..1), + "rationale": string, + "risks": string[], + "evidence": [{ "source": string, "detail": string }], + "proposed_actions": string[], + "missing_information": string[] + } + +Failure and stuck handling: +- Track per-member attempts, nudges, and progress timestamps. +- Detect stuck tasks when no progress appears within expected interval. +- First recovery action for stuck: nudge through continuation prompt. +- If still stuck or failed: retry with a fresh background task, bounded by retry limit. +- If a member remains failed after retry budget, mark as failed and continue. + +Quorum and degradation: +- Default quorum: ceil(total_members / 2), minimum 1. +- If quorum reached, continue synthesis even when some members failed. +- If quorum cannot be reached after retries, report partial findings and explicit uncertainty. + +Synthesis output requirements: +- Separate "agreement" and "disagreement" sections. +- Name which members support the majority view and which dissent or failed. +- Call out unresolved questions and evidence gaps. +- End with one executable recommendation and a confidence statement. + +Do not expose internal operational noise. Report concise structured findings.` +} diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts index 350d69e54..410046b01 100644 --- a/src/agents/builtin-agents.ts +++ b/src/agents/builtin-agents.ts @@ -12,6 +12,8 @@ import { createMetisAgent, metisPromptMetadata } from "./metis" import { createAtlasAgent, atlasPromptMetadata } from "./atlas" import { createMomusAgent, momusPromptMetadata } from "./momus" import { createHephaestusAgent } from "./hephaestus" +import { createAthenaAgent } from "./athena" +import { createCouncilMemberAgent } from "./council-member" import { createSisyphusJuniorAgentWithOverrides } from "./sisyphus-junior" import type { AvailableCategory } from "./dynamic-agent-prompt-builder" import { @@ -33,6 +35,7 @@ type AgentSource = AgentFactory | AgentConfig const agentSources: Record = { sisyphus: createSisyphusAgent, hephaestus: createHephaestusAgent, + athena: createAthenaAgent, oracle: createOracleAgent, librarian: createLibrarianAgent, explore: createExploreAgent, @@ -43,6 +46,7 @@ const agentSources: Record = { // because it needs OrchestratorContext, not just a model string atlas: createAtlasAgent as AgentFactory, "sisyphus-junior": createSisyphusJuniorAgentWithOverrides as unknown as AgentFactory, + "council-member": createCouncilMemberAgent, } /** diff --git a/src/agents/builtin-agents/general-agents.ts b/src/agents/builtin-agents/general-agents.ts index f5fd1d920..d38aa14ca 100644 --- a/src/agents/builtin-agents/general-agents.ts +++ b/src/agents/builtin-agents/general-agents.ts @@ -39,7 +39,6 @@ export function collectPendingBuiltinAgents(input: { browserProvider, uiSelectedModel, availableModels, - isFirstRunNoCache, disabledSkills, disableOmoEnv = false, } = input @@ -56,8 +55,9 @@ export function collectPendingBuiltinAgents(input: { if (agentName === "sisyphus-junior") continue if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue - const override = agentOverrides[agentName] - ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] + const override = Object.entries(agentOverrides).find( + ([key]) => key.toLowerCase() === agentName.toLowerCase(), + )?.[1] const requirement = AGENT_MODEL_REQUIREMENTS[agentName] // Check if agent requires a specific model diff --git a/src/agents/council-member.ts b/src/agents/council-member.ts new file mode 100644 index 000000000..7ce7f81e6 --- /dev/null +++ b/src/agents/council-member.ts @@ -0,0 +1,51 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import type { AgentMode } from "./types" +import { createAgentToolRestrictions } from "../shared/permission-compat" +import { COUNCIL_MEMBER_RESPONSE_TAG } from "./athena/council-contract" + +const MODE: AgentMode = "subagent" + +const councilMemberRestrictions = createAgentToolRestrictions([ + "write", + "edit", + "apply_patch", + "task", + "task_*", + "teammate", + "call_omo_agent", + "switch_agent", +]) + +export function createCouncilMemberAgent(model: string): AgentConfig { + return { + description: "Internal hidden council member used by Athena. Read-only analysis only.", + mode: MODE, + model, + temperature: 0.1, + hidden: true, + ...councilMemberRestrictions, + prompt: `You are an internal council-member for Athena. + +You are strictly read-only and evidence-oriented. +You must not modify files, delegate, or switch agents. +You must cite concrete evidence from files, tests, logs, or tool output. + +Output contract: +- Preferred output: raw JSON only. +- Fallback output: wrap JSON with <${COUNCIL_MEMBER_RESPONSE_TAG}>.... +- Required JSON schema: + { + "member": string, + "verdict": "support" | "oppose" | "mixed" | "abstain", + "confidence": number (0..1), + "rationale": string, + "risks": string[], + "evidence": [{ "source": string, "detail": string }], + "proposed_actions": string[], + "missing_information": string[] + } + +Do not include markdown explanations outside the contract unless Athena asks for it explicitly.`, + } +} +createCouncilMemberAgent.mode = MODE diff --git a/src/agents/types.ts b/src/agents/types.ts index 5f5fa6bfe..f9f4f2d0c 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -112,6 +112,7 @@ export function isGeminiModel(model: string): boolean { export type BuiltinAgentName = | "sisyphus" | "hephaestus" + | "athena" | "oracle" | "librarian" | "explore" @@ -119,9 +120,10 @@ export type BuiltinAgentName = | "metis" | "momus" | "atlas" - | "sisyphus-junior"; + | "sisyphus-junior" + | "council-member"; -export type OverridableAgentName = "build" | BuiltinAgentName; +export type OverridableAgentName = "build" | Exclude; export type AgentName = BuiltinAgentName; diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index b0280f52e..cbdd58447 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -11,6 +11,32 @@ import * as shared from "../shared" const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6" describe("createBuiltinAgents with model overrides", () => { + test("registers athena as builtin primary agent", async () => { + // #given + + // #when + const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) + + // #then + expect(agents.athena).toBeDefined() + expect(agents.athena.mode).toBe("primary") + }) + + test("registers council-member as hidden internal subagent", async () => { + // #given + + // #when + const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) + + // #then + expect(agents["council-member"]).toBeDefined() + expect(agents["council-member"].mode).toBe("subagent") + expect((agents["council-member"] as AgentConfig & { hidden?: boolean }).hidden).toBe(true) + expect(agents.sisyphus.prompt).not.toContain("council-member") + expect(agents.hephaestus.prompt).not.toContain("council-member") + expect(agents.atlas.prompt).not.toContain("council-member") + }) + test("Sisyphus with default model has thinking config when all models available", async () => { // #given const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( diff --git a/src/cli/__snapshots__/model-fallback.test.ts.snap b/src/cli/__snapshots__/model-fallback.test.ts.snap index 29eef9db0..8569dfdef 100644 --- a/src/cli/__snapshots__/model-fallback.test.ts.snap +++ b/src/cli/__snapshots__/model-fallback.test.ts.snap @@ -4,9 +4,15 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/gpt-5-nano", + }, "atlas": { "model": "opencode/gpt-5-nano", }, + "council-member": { + "model": "opencode/gpt-5-nano", + }, "explore": { "model": "opencode/gpt-5-nano", }, @@ -68,9 +74,15 @@ exports[`generateModelConfig single native provider uses Claude models when only { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-sonnet-4-6", + }, "atlas": { "model": "anthropic/claude-sonnet-4-6", }, + "council-member": { + "model": "anthropic/claude-sonnet-4-6", + }, "explore": { "model": "anthropic/claude-haiku-4-5", }, @@ -130,9 +142,15 @@ exports[`generateModelConfig single native provider uses Claude models with isMa { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-sonnet-4-6", + }, "atlas": { "model": "anthropic/claude-sonnet-4-6", }, + "council-member": { + "model": "anthropic/claude-sonnet-4-6", + }, "explore": { "model": "anthropic/claude-haiku-4-5", }, @@ -193,10 +211,18 @@ exports[`generateModelConfig single native provider uses OpenAI models when only { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "openai/gpt-5.4", + "variant": "medium", + }, "atlas": { "model": "openai/gpt-5.4", "variant": "medium", }, + "council-member": { + "model": "openai/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "openai/gpt-5.4", "variant": "medium", @@ -278,10 +304,18 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "openai/gpt-5.4", + "variant": "medium", + }, "atlas": { "model": "openai/gpt-5.4", "variant": "medium", }, + "council-member": { + "model": "openai/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "openai/gpt-5.4", "variant": "medium", @@ -363,9 +397,15 @@ exports[`generateModelConfig single native provider uses Gemini models when only { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/gpt-5-nano", + }, "atlas": { "model": "opencode/gpt-5-nano", }, + "council-member": { + "model": "opencode/gpt-5-nano", + }, "explore": { "model": "opencode/gpt-5-nano", }, @@ -423,9 +463,15 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/gpt-5-nano", + }, "atlas": { "model": "opencode/gpt-5-nano", }, + "council-member": { + "model": "opencode/gpt-5-nano", + }, "explore": { "model": "opencode/gpt-5-nano", }, @@ -483,9 +529,16 @@ exports[`generateModelConfig all native providers uses preferred models from fal { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-sonnet-4-6", + }, "atlas": { "model": "anthropic/claude-sonnet-4-6", }, + "council-member": { + "model": "openai/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "anthropic/claude-haiku-4-5", }, @@ -558,9 +611,16 @@ exports[`generateModelConfig all native providers uses preferred models with isM { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-sonnet-4-6", + }, "atlas": { "model": "anthropic/claude-sonnet-4-6", }, + "council-member": { + "model": "openai/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "anthropic/claude-haiku-4-5", }, @@ -634,9 +694,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/claude-sonnet-4-6", + }, "atlas": { "model": "opencode/claude-sonnet-4-6", }, + "council-member": { + "model": "opencode/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "opencode/claude-haiku-4-5", }, @@ -709,9 +776,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/claude-sonnet-4-6", + }, "atlas": { "model": "opencode/claude-sonnet-4-6", }, + "council-member": { + "model": "opencode/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "opencode/claude-haiku-4-5", }, @@ -785,9 +859,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "github-copilot/claude-sonnet-4.6", + }, "atlas": { "model": "github-copilot/claude-sonnet-4.6", }, + "council-member": { + "model": "github-copilot/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "github-copilot/gpt-5-mini", }, @@ -855,9 +936,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "github-copilot/claude-sonnet-4.6", + }, "atlas": { "model": "github-copilot/claude-sonnet-4.6", }, + "council-member": { + "model": "github-copilot/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "github-copilot/gpt-5-mini", }, @@ -926,9 +1014,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/gpt-5-nano", + }, "atlas": { "model": "opencode/gpt-5-nano", }, + "council-member": { + "model": "opencode/gpt-5-nano", + }, "explore": { "model": "opencode/gpt-5-nano", }, @@ -984,9 +1078,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/gpt-5-nano", + }, "atlas": { "model": "opencode/gpt-5-nano", }, + "council-member": { + "model": "opencode/gpt-5-nano", + }, "explore": { "model": "opencode/gpt-5-nano", }, @@ -1042,9 +1142,16 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-sonnet-4-6", + }, "atlas": { "model": "anthropic/claude-sonnet-4-6", }, + "council-member": { + "model": "opencode/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "anthropic/claude-haiku-4-5", }, @@ -1117,9 +1224,16 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "github-copilot/claude-sonnet-4.6", + }, "atlas": { "model": "github-copilot/claude-sonnet-4.6", }, + "council-member": { + "model": "openai/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "github-copilot/gpt-5-mini", }, @@ -1192,9 +1306,15 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-sonnet-4-6", + }, "atlas": { "model": "anthropic/claude-sonnet-4-6", }, + "council-member": { + "model": "anthropic/claude-sonnet-4-6", + }, "explore": { "model": "anthropic/claude-haiku-4-5", }, @@ -1256,9 +1376,15 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-sonnet-4-6", + }, "atlas": { "model": "anthropic/claude-sonnet-4-6", }, + "council-member": { + "model": "anthropic/claude-sonnet-4-6", + }, "explore": { "model": "anthropic/claude-haiku-4-5", }, @@ -1322,9 +1448,16 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "github-copilot/claude-sonnet-4.6", + }, "atlas": { "model": "github-copilot/claude-sonnet-4.6", }, + "council-member": { + "model": "github-copilot/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "opencode/claude-haiku-4-5", }, @@ -1400,9 +1533,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-sonnet-4-6", + }, "atlas": { "model": "anthropic/claude-sonnet-4-6", }, + "council-member": { + "model": "openai/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "anthropic/claude-haiku-4-5", }, @@ -1478,9 +1618,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-sonnet-4-6", + }, "atlas": { "model": "anthropic/claude-sonnet-4-6", }, + "council-member": { + "model": "openai/gpt-5.4", + "variant": "medium", + }, "explore": { "model": "anthropic/claude-haiku-4-5", }, diff --git a/src/config/index.ts b/src/config/index.ts index 57a347d3a..d25b8fcf1 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -4,6 +4,7 @@ export { export type { OhMyOpenCodeConfig, + AthenaConfig, AgentOverrideConfig, AgentOverrides, McpName, diff --git a/src/config/schema.ts b/src/config/schema.ts index 04dd0b15b..a379d24e4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,5 +1,6 @@ export * from "./schema/agent-names" export * from "./schema/agent-overrides" +export * from "./schema/athena-config" export * from "./schema/babysitting" export * from "./schema/background-task" export * from "./schema/browser-automation" diff --git a/src/config/schema/agent-names.ts b/src/config/schema/agent-names.ts index 73e4f1f80..08f323f3f 100644 --- a/src/config/schema/agent-names.ts +++ b/src/config/schema/agent-names.ts @@ -3,6 +3,7 @@ import { z } from "zod" export const BuiltinAgentNameSchema = z.enum([ "sisyphus", "hephaestus", + "athena", "prometheus", "oracle", "librarian", @@ -12,6 +13,7 @@ export const BuiltinAgentNameSchema = z.enum([ "momus", "atlas", "sisyphus-junior", + "council-member", ]) export const BuiltinSkillNameSchema = z.enum([ @@ -27,6 +29,7 @@ export const OverridableAgentNameSchema = z.enum([ "plan", "sisyphus", "hephaestus", + "athena", "sisyphus-junior", "OpenCode-Builder", "prometheus", diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts index ac560cbd5..5e3cb240c 100644 --- a/src/config/schema/agent-overrides.ts +++ b/src/config/schema/agent-overrides.ts @@ -62,6 +62,7 @@ export const AgentOverridesSchema = z.object({ hephaestus: AgentOverrideConfigSchema.extend({ allow_non_gpt_model: z.boolean().optional(), }).optional(), + athena: AgentOverrideConfigSchema.optional(), "sisyphus-junior": AgentOverrideConfigSchema.optional(), "OpenCode-Builder": AgentOverrideConfigSchema.optional(), prometheus: AgentOverrideConfigSchema.optional(), diff --git a/src/config/schema/athena-config.test.ts b/src/config/schema/athena-config.test.ts new file mode 100644 index 000000000..276d205b7 --- /dev/null +++ b/src/config/schema/athena-config.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { AthenaConfigSchema } from "./athena-config" +import { OhMyOpenCodeConfigSchema } from "./oh-my-opencode-config" + +describe("AthenaConfigSchema", () => { + test("accepts athena config with required members", () => { + // given + const config = { + model: "openai/gpt-5.4", + members: [ + { name: "Socrates", model: "openai/gpt-5.4" }, + { name: "Plato", model: "anthropic/claude-sonnet-4-6" }, + ], + } + + // when + const result = AthenaConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(true) + }) + + test("rejects athena config when members are missing", () => { + // given + const config = { + model: "openai/gpt-5.4", + } + + // when + const result = AthenaConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) + + test("rejects case-insensitive duplicate member names", () => { + // given + const config = { + members: [ + { name: "Socrates", model: "openai/gpt-5.4" }, + { name: "socrates", model: "anthropic/claude-sonnet-4-6" }, + ], + } + + // when + const result = AthenaConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) + + test("rejects member model without provider prefix", () => { + // given + const config = { + members: [{ name: "Socrates", model: "gpt-5.4" }], + } + + // when + const result = AthenaConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) +}) + +describe("OhMyOpenCodeConfigSchema athena field", () => { + test("accepts athena config at root", () => { + // given + const config = { + athena: { + model: "openai/gpt-5.4", + members: [{ name: "Socrates", model: "openai/gpt-5.4" }], + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(true) + }) +}) diff --git a/src/config/schema/athena-config.ts b/src/config/schema/athena-config.ts new file mode 100644 index 000000000..6aaee8f69 --- /dev/null +++ b/src/config/schema/athena-config.ts @@ -0,0 +1,39 @@ +import { z } from "zod" + +const PROVIDER_MODEL_PATTERN = /^[^/\s]+\/[^/\s]+$/ + +const ProviderModelSchema = z + .string() + .regex(PROVIDER_MODEL_PATTERN, "Model must use provider/model format") + +const AthenaCouncilMemberSchema = z.object({ + name: z.string().trim().min(1), + model: ProviderModelSchema, +}) + +export const AthenaConfigSchema = z + .object({ + model: ProviderModelSchema.optional(), + members: z.array(AthenaCouncilMemberSchema).min(1), + }) + .superRefine((value, ctx) => { + const seen = new Map() + + for (const [index, member] of value.members.entries()) { + const normalizedName = member.name.trim().toLowerCase() + const existingIndex = seen.get(normalizedName) + + if (existingIndex !== undefined) { + ctx.addIssue({ + code: "custom", + path: ["members", index, "name"], + message: `Duplicate member name '${member.name}' (case-insensitive). First seen at members[${existingIndex}]`, + }) + continue + } + + seen.set(normalizedName, index) + } + }) + +export type AthenaConfig = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index 434bfe7ee..8e7269733 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -2,6 +2,7 @@ import { z } from "zod" import { AnyMcpNameSchema } from "../../mcp/types" import { BuiltinSkillNameSchema } from "./agent-names" import { AgentOverridesSchema } from "./agent-overrides" +import { AthenaConfigSchema } from "./athena-config" import { BabysittingConfigSchema } from "./babysitting" import { BackgroundTaskConfigSchema } from "./background-task" import { BrowserAutomationConfigSchema } from "./browser-automation" @@ -41,6 +42,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ /** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */ model_fallback: z.boolean().optional(), agents: AgentOverridesSchema.optional(), + athena: AthenaConfigSchema.optional(), categories: CategoriesConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(), sisyphus_agent: SisyphusAgentConfigSchema.optional(), diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 33f15f233..1ebb4662e 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -21,6 +21,7 @@ import { } from "./agent-override-protection"; import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; import { buildPlanDemoteConfig } from "./plan-model-inheritance"; +import { applyAthenaCouncilAgentWiring } from "./athena-council-agent-wiring" type AgentConfigRecord = Record | undefined> & { build?: Record; @@ -273,6 +274,11 @@ export async function applyAgentConfig(params: { } if (params.config.agent) { + applyAthenaCouncilAgentWiring( + params.config.agent as Record, + params.pluginConfig.athena, + ) + params.config.agent = remapAgentKeysToDisplayNames( params.config.agent as Record, ); diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts index c315ad76a..023d24af5 100644 --- a/src/plugin-handlers/agent-priority-order.ts +++ b/src/plugin-handlers/agent-priority-order.ts @@ -3,8 +3,9 @@ import { getAgentDisplayName } from "../shared/agent-display-names"; const CORE_AGENT_ORDER: ReadonlyArray<{ displayName: string; order: number }> = [ { displayName: getAgentDisplayName("sisyphus"), order: 1 }, { displayName: getAgentDisplayName("hephaestus"), order: 2 }, - { displayName: getAgentDisplayName("prometheus"), order: 3 }, - { displayName: getAgentDisplayName("atlas"), order: 4 }, + { displayName: getAgentDisplayName("athena"), order: 3 }, + { displayName: getAgentDisplayName("prometheus"), order: 4 }, + { displayName: getAgentDisplayName("atlas"), order: 5 }, ]; function injectOrderField( diff --git a/src/plugin-handlers/athena-council-agent-wiring.test.ts b/src/plugin-handlers/athena-council-agent-wiring.test.ts new file mode 100644 index 000000000..d67d900c3 --- /dev/null +++ b/src/plugin-handlers/athena-council-agent-wiring.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { applyAthenaCouncilAgentWiring } from "./athena-council-agent-wiring" + +describe("applyAthenaCouncilAgentWiring", () => { + test("#given athena config with roster #when wiring agents #then injects dynamic council member agents", () => { + // given + const agentConfig: Record = { + athena: { + model: "openai/gpt-5.4", + prompt: "placeholder", + }, + } + + // when + applyAthenaCouncilAgentWiring(agentConfig, { + model: "anthropic/claude-opus-4-6", + members: [ + { name: "Architect", model: "openai/gpt-5.4" }, + { name: "Skeptic", model: "anthropic/claude-sonnet-4-6" }, + ], + }) + + // then + const athena = agentConfig.athena as Record + expect(athena.model).toBe("anthropic/claude-opus-4-6") + expect(typeof athena.prompt).toBe("string") + expect((athena.prompt as string).includes("council-member-architect")).toBe(true) + + const architect = agentConfig["council-member-architect"] as Record + const skeptic = agentConfig["council-member-skeptic"] as Record + expect(architect).toBeDefined() + expect(skeptic).toBeDefined() + expect(architect.model).toBe("openai/gpt-5.4") + expect(skeptic.model).toBe("anthropic/claude-sonnet-4-6") + }) +}) diff --git a/src/plugin-handlers/athena-council-agent-wiring.ts b/src/plugin-handlers/athena-council-agent-wiring.ts new file mode 100644 index 000000000..cf49af50f --- /dev/null +++ b/src/plugin-handlers/athena-council-agent-wiring.ts @@ -0,0 +1,42 @@ +import type { AthenaConfig } from "../config" +import { createCouncilMemberAgent } from "../agents/council-member" +import { buildAthenaPrompt } from "../agents/athena/prompt" +import { toCouncilMemberAgentName } from "../agents/athena/council-members" + +export function applyAthenaCouncilAgentWiring( + agentConfig: Record, + athenaConfig?: AthenaConfig, +): void { + const members = athenaConfig?.members ?? [] + const athena = agentConfig.athena as Record | undefined + + if (athenaConfig?.model) { + if (athena) { + athena.model = athenaConfig.model + } + } + + if (!athena) { + return + } + + if (members.length > 0) { + athena.prompt = buildAthenaPrompt({ + members: members.map((member) => ({ name: member.name, model: member.model })), + }) + } + + for (const member of members) { + const dynamicAgentName = toCouncilMemberAgentName(member.name) + if (agentConfig[dynamicAgentName]) { + continue + } + + const memberAgent = createCouncilMemberAgent(member.model) + agentConfig[dynamicAgentName] = { + ...memberAgent, + description: `Athena council member (${member.name}) using ${member.model}.`, + hidden: true, + } + } +} diff --git a/src/plugin-handlers/tool-config-handler.test.ts b/src/plugin-handlers/tool-config-handler.test.ts index 3eaa8afe3..046e5a3ee 100644 --- a/src/plugin-handlers/tool-config-handler.test.ts +++ b/src/plugin-handlers/tool-config-handler.test.ts @@ -67,6 +67,7 @@ describe("applyToolConfig", () => { it.each([ "atlas", + "athena", "sisyphus", "hephaestus", "prometheus", @@ -195,6 +196,7 @@ describe("applyToolConfig", () => { describe("#when applying tool config", () => { it.each([ "atlas", + "athena", "sisyphus", "hephaestus", "prometheus", @@ -216,6 +218,50 @@ describe("applyToolConfig", () => { }) }) + describe("#given council-member agent exists", () => { + describe("#when applying tool config", () => { + it("#then should enforce read-only and non-delegating permissions", () => { + const params = createParams({ agents: ["council-member"] }) + + applyToolConfig(params) + + const agent = params.agentResult["council-member"] as { + permission: Record + } + expect(agent.permission.write).toBe("deny") + expect(agent.permission.edit).toBe("deny") + expect(agent.permission.apply_patch).toBe("deny") + expect(agent.permission.task).toBe("deny") + expect(agent.permission["task_*"]).toBe("deny") + expect(agent.permission.call_omo_agent).toBe("deny") + expect(agent.permission.switch_agent).toBe("deny") + expect(agent.permission.teammate).toBe("deny") + }) + }) + }) + + describe("#given dynamic council-member agent exists", () => { + describe("#when applying tool config", () => { + it("#then should enforce read-only and non-delegating permissions", () => { + const params = createParams({ agents: ["council-member-architect"] }) + + applyToolConfig(params) + + const agent = params.agentResult["council-member-architect"] as { + permission: Record + } + expect(agent.permission.write).toBe("deny") + expect(agent.permission.edit).toBe("deny") + expect(agent.permission.apply_patch).toBe("deny") + expect(agent.permission.task).toBe("deny") + expect(agent.permission["task_*"]).toBe("deny") + expect(agent.permission.call_omo_agent).toBe("deny") + expect(agent.permission.switch_agent).toBe("deny") + expect(agent.permission.teammate).toBe("deny") + }) + }) + }) + describe("#given disabled_tools includes 'question'", () => { let originalConfigContent: string | undefined let originalCliRunMode: string | undefined diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts index 4be1f2937..ac49ff88e 100644 --- a/src/plugin-handlers/tool-config-handler.ts +++ b/src/plugin-handlers/tool-config-handler.ts @@ -3,6 +3,22 @@ import { getAgentDisplayName } from "../shared/agent-display-names"; type AgentWithPermission = { permission?: Record }; +const COUNCIL_MEMBER_AGENT_PREFIX = "council-member-" + +function applyCouncilMemberRestrictions(agent: AgentWithPermission): void { + agent.permission = { + ...agent.permission, + write: "deny", + edit: "deny", + apply_patch: "deny", + task: "deny", + "task_*": "deny", + teammate: "deny", + call_omo_agent: "deny", + switch_agent: "deny", + } +} + function getConfigQuestionPermission(): string | null { const configContent = process.env.OPENCODE_CONFIG_CONTENT; if (!configContent) return null; @@ -114,6 +130,30 @@ export function applyToolConfig(params: { ...denyTodoTools, }; } + const athena = agentByKey(params.agentResult, "athena") + if (athena) { + athena.permission = { + ...athena.permission, + call_omo_agent: "deny", + task: "allow", + question: questionPermission, + "task_*": "allow", + teammate: "allow", + ...denyTodoTools, + } + } + const councilMember = agentByKey(params.agentResult, "council-member"); + if (councilMember) { + applyCouncilMemberRestrictions(councilMember) + } + + for (const [agentName, agentConfig] of Object.entries(params.agentResult)) { + if (!agentName.toLowerCase().startsWith(COUNCIL_MEMBER_AGENT_PREFIX)) { + continue + } + + applyCouncilMemberRestrictions(agentConfig as AgentWithPermission) + } params.config.permission = { webfetch: "allow", diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts index 3d7276a1b..18ac3a49f 100644 --- a/src/shared/agent-display-names.test.ts +++ b/src/shared/agent-display-names.test.ts @@ -180,10 +180,12 @@ describe("AGENT_DISPLAY_NAMES", () => { it("contains all expected agent mappings", () => { // given expected mappings const expectedMappings = { + athena: "Athena", sisyphus: "Sisyphus (Ultraworker)", hephaestus: "Hephaestus (Deep Agent)", prometheus: "Prometheus (Plan Builder)", atlas: "Atlas (Plan Executor)", + "council-member": "council-member", "sisyphus-junior": "Sisyphus-Junior", metis: "Metis (Plan Consultant)", momus: "Momus (Plan Critic)", @@ -197,4 +199,4 @@ describe("AGENT_DISPLAY_NAMES", () => { // then contains all expected mappings expect(AGENT_DISPLAY_NAMES).toEqual(expectedMappings) }) -}) \ No newline at end of file +}) diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index a0bda2241..2c3c9a005 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -6,6 +6,7 @@ export const AGENT_DISPLAY_NAMES: Record = { sisyphus: "Sisyphus (Ultraworker)", hephaestus: "Hephaestus (Deep Agent)", + athena: "Athena", prometheus: "Prometheus (Plan Builder)", atlas: "Atlas (Plan Executor)", "sisyphus-junior": "Sisyphus-Junior", @@ -15,6 +16,7 @@ export const AGENT_DISPLAY_NAMES: Record = { librarian: "librarian", explore: "explore", "multimodal-looker": "multimodal-looker", + "council-member": "council-member", } /** @@ -51,4 +53,4 @@ export function getAgentConfigKey(agentName: string): string { if (reversed !== undefined) return reversed if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower return lower -} \ No newline at end of file +} diff --git a/src/shared/agent-tool-restrictions.test.ts b/src/shared/agent-tool-restrictions.test.ts new file mode 100644 index 000000000..751720f80 --- /dev/null +++ b/src/shared/agent-tool-restrictions.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { getAgentToolRestrictions, hasAgentToolRestrictions } from "./agent-tool-restrictions" + +describe("agent-tool-restrictions council-member", () => { + test("returns council-member restrictions as read-only and non-delegating", () => { + // given + + // when + const restrictions = getAgentToolRestrictions("council-member") + + // then + expect(restrictions.write).toBe(false) + expect(restrictions.edit).toBe(false) + expect(restrictions.apply_patch).toBe(false) + expect(restrictions.task).toBe(false) + expect(restrictions["task_*"]).toBe(false) + expect(restrictions.call_omo_agent).toBe(false) + expect(restrictions.switch_agent).toBe(false) + expect(restrictions.teammate).toBe(false) + }) + + test("matches council-member case-insensitively", () => { + // given + + // when + const restrictions = getAgentToolRestrictions("Council-Member") + + // then + expect(restrictions.write).toBe(false) + expect(hasAgentToolRestrictions("Council-Member")).toBe(true) + }) + + test("matches dynamic council-member names by prefix", () => { + // given + + // when + const restrictions = getAgentToolRestrictions("council-member-architect") + + // then + expect(restrictions.write).toBe(false) + expect(restrictions.task).toBe(false) + expect(hasAgentToolRestrictions("council-member-architect")).toBe(true) + }) +}) diff --git a/src/shared/agent-tool-restrictions.ts b/src/shared/agent-tool-restrictions.ts index 865251d6f..fe89c6f64 100644 --- a/src/shared/agent-tool-restrictions.ts +++ b/src/shared/agent-tool-restrictions.ts @@ -42,15 +42,34 @@ const AGENT_RESTRICTIONS: Record> = { "sisyphus-junior": { task: false, }, + + "council-member": { + write: false, + edit: false, + apply_patch: false, + task: false, + "task_*": false, + teammate: false, + call_omo_agent: false, + switch_agent: false, + }, } export function getAgentToolRestrictions(agentName: string): Record { + if (agentName.toLowerCase().startsWith("council-member-")) { + return AGENT_RESTRICTIONS["council-member"] + } + return AGENT_RESTRICTIONS[agentName] ?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] ?? {} } export function hasAgentToolRestrictions(agentName: string): boolean { + if (agentName.toLowerCase().startsWith("council-member-")) { + return true + } + const restrictions = AGENT_RESTRICTIONS[agentName] ?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] return restrictions !== undefined && Object.keys(restrictions).length > 0 diff --git a/src/shared/migration/agent-names.ts b/src/shared/migration/agent-names.ts index 3321b0b84..82f252fe1 100644 --- a/src/shared/migration/agent-names.ts +++ b/src/shared/migration/agent-names.ts @@ -5,6 +5,9 @@ export const AGENT_NAME_MAP: Record = { Sisyphus: "sisyphus", sisyphus: "sisyphus", + Athena: "athena", + athena: "athena", + // Prometheus variants → "prometheus" "OmO-Plan": "prometheus", "omo-plan": "prometheus", @@ -37,10 +40,12 @@ export const AGENT_NAME_MAP: Record = { librarian: "librarian", explore: "explore", "multimodal-looker": "multimodal-looker", + "council-member": "council-member", } export const BUILTIN_AGENT_NAMES = new Set([ "sisyphus", // was "Sisyphus" + "athena", "oracle", "librarian", "explore", @@ -49,6 +54,7 @@ export const BUILTIN_AGENT_NAMES = new Set([ "momus", // was "Momus (Plan Reviewer)" "prometheus", // was "Prometheus (Planner)" "atlas", // was "Atlas" + "council-member", "build", ]) diff --git a/src/shared/model-requirements.test.ts b/src/shared/model-requirements.test.ts index d09a4530a..e6c67192f 100644 --- a/src/shared/model-requirements.test.ts +++ b/src/shared/model-requirements.test.ts @@ -265,9 +265,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => { expect(hephaestus.requiresModel).toBeUndefined() }) - test("all 11 builtin agents have valid fallbackChain arrays", () => { - // #given - list of 11 agent names + test("all builtin and internal agents have valid fallbackChain arrays", () => { const expectedAgents = [ + "athena", "sisyphus", "hephaestus", "oracle", @@ -278,6 +278,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => { "metis", "momus", "atlas", + "council-member", "sisyphus-junior", ] @@ -285,7 +286,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => { const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS) // #then - all agents present with valid fallbackChain - expect(definedAgents).toHaveLength(11) + expect(definedAgents).toHaveLength(expectedAgents.length) for (const agent of expectedAgents) { const requirement = AGENT_MODEL_REQUIREMENTS[agent] expect(requirement).toBeDefined() diff --git a/src/shared/model-requirements.ts b/src/shared/model-requirements.ts index 541c26d00..4be398a96 100644 --- a/src/shared/model-requirements.ts +++ b/src/shared/model-requirements.ts @@ -55,6 +55,19 @@ export const AGENT_MODEL_REQUIREMENTS: Record = { ], requiresProvider: ["openai", "github-copilot", "venice", "opencode"], }, + athena: { + fallbackChain: [ + { + providers: ["anthropic", "github-copilot", "opencode"], + model: "claude-sonnet-4-6", + }, + { + providers: ["openai", "github-copilot", "opencode"], + model: "gpt-5.4", + variant: "medium", + }, + ], + }, oracle: { fallbackChain: [ { @@ -180,6 +193,19 @@ export const AGENT_MODEL_REQUIREMENTS: Record = { { providers: ["opencode"], model: "big-pickle" }, ], }, + "council-member": { + fallbackChain: [ + { + providers: ["openai", "github-copilot", "opencode"], + model: "gpt-5.4", + variant: "medium", + }, + { + providers: ["anthropic", "github-copilot", "opencode"], + model: "claude-sonnet-4-6", + }, + ], + }, }; export const CATEGORY_MODEL_REQUIREMENTS: Record = {