From ed2bb1ead74ceed79eb1e273928b1139489f4c3d Mon Sep 17 00:00:00 2001 From: ismeth Date: Sat, 28 Feb 2026 20:26:35 +0100 Subject: [PATCH] fix(athena): harden council flow and resilience config --- src/agents/AGENTS.md | 1 - .../athena/athena-config-injection.test.ts | 73 +++++++++++++++++++ src/agents/athena/council-intent-addendums.ts | 2 +- src/agents/athena/council-member-agent.ts | 22 +----- src/agents/builtin-agents.ts | 4 +- .../council-member-agents.test.ts | 4 + src/config/schema/athena.test.ts | 19 +++++ src/config/schema/athena.ts | 1 + 8 files changed, 105 insertions(+), 21 deletions(-) diff --git a/src/agents/AGENTS.md b/src/agents/AGENTS.md index 27bf53c80..23f0b2334 100644 --- a/src/agents/AGENTS.md +++ b/src/agents/AGENTS.md @@ -34,7 +34,6 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each | Multimodal-Looker | ALL except read | | Atlas | task, call_omo_agent | | Momus | write, edit, task | -| Athena | write, edit, call_omo_agent | | Council-Member | ALL except read, grep, glob, lsp_*, ast_grep_search (allow-list) | ## STRUCTURE diff --git a/src/agents/athena/athena-config-injection.test.ts b/src/agents/athena/athena-config-injection.test.ts index 5e2195c6e..cc2155b22 100644 --- a/src/agents/athena/athena-config-injection.test.ts +++ b/src/agents/athena/athena-config-injection.test.ts @@ -14,6 +14,10 @@ describe("Athena prompt config injection placeholders", () => { expect(athenaConfig.prompt).toContain("{STUCK_THRESHOLD_SECONDS}") }) + it("#then contains MEMBER_MAX_RUNNING_SECONDS placeholder", () => { + expect(athenaConfig.prompt).toContain("{MEMBER_MAX_RUNNING_SECONDS}") + }) + it("#then contains quorum reference", () => { expect(athenaConfig.prompt).toContain("quorum") }) @@ -29,6 +33,75 @@ describe("Athena prompt config injection placeholders", () => { it("#then contains CANCEL_RETRYING_ON_QUORUM placeholder", () => { expect(athenaConfig.prompt).toContain("{CANCEL_RETRYING_ON_QUORUM}") }) + + it("#then avoids session_id continuation retry instruction", () => { + expect(athenaConfig.prompt).not.toContain("Retries: use task(session_id=") + }) + + it("#then uses sequential workflow step numbering", () => { + expect(athenaConfig.prompt).toContain("Step 12: Synthesize") + expect(athenaConfig.prompt).toContain("Step 13: Determine the follow-up path") + expect(athenaConfig.prompt).toContain("Step 14: ACTIONABLE findings") + expect(athenaConfig.prompt).toContain("Step 15: INFORMATIONAL findings") + }) + + it("#then omits legacy mixed step labels", () => { + expect(athenaConfig.prompt).not.toContain("Step 1.5") + expect(athenaConfig.prompt).not.toContain("Step 4.5") + expect(athenaConfig.prompt).not.toContain("Step 7A") + expect(athenaConfig.prompt).not.toContain("Step 7B") + }) + + it("#then uses tagged prompt sections for consistency", () => { + expect(athenaConfig.prompt).toContain("") + expect(athenaConfig.prompt).toContain("") + expect(athenaConfig.prompt).toContain("") + expect(athenaConfig.prompt).toContain("") + expect(athenaConfig.prompt).toContain("") + }) + + it("#then places council setup after step 1 and removes first-action wording", () => { + const prompt = athenaConfig.prompt ?? "" + expect(prompt).not.toContain("CRITICAL: Council Setup (Your First Action)") + + const step1Index = prompt.indexOf("Step 1: Understand the question and decide the route.") + const step2Index = prompt.indexOf("Step 2: Council setup (default flow before launch).") + + expect(step1Index).toBeGreaterThan(-1) + expect(step2Index).toBeGreaterThan(step1Index) + }) + + it("#then keeps edge-case disambiguation inside step 3 classification", () => { + const prompt = athenaConfig.prompt ?? "" + expect(prompt).toContain("Step 3: Classify the question intent.") + expect(prompt).toContain("Classification disambiguation rule:") + expect(prompt).not.toContain("Before selecting a route, apply this interpretation rule") + }) + + it("#then requires question tool routing in self-answerable path", () => { + expect(athenaConfig.prompt).toContain("Athena MUST ask the user to choose direct answer vs council using the Question tool") + expect(athenaConfig.prompt).toContain("header: \"Routing\"") + expect(athenaConfig.prompt).toContain("label: \"Answer directly\"") + expect(athenaConfig.prompt).toContain("label: \"Consult council\"") + }) + + it("#then requires question tool clarifications in ambiguous council path", () => { + expect(athenaConfig.prompt).toContain("Use the Question tool (not open-ended free text) for 1-2 targeted clarifications") + expect(athenaConfig.prompt).toContain("Formulate the questions and options dynamically") + expect(athenaConfig.prompt).not.toContain("header: \"Output Type\"") + expect(athenaConfig.prompt).not.toContain("header: \"Scope\"") + }) + + it("#then keeps intent classification anchored to step 3 wording", () => { + expect(athenaConfig.prompt).toContain("Step 3: Classify the question intent.") + expect(athenaConfig.prompt).toContain("Then proceed to Step 2.") + expect(athenaConfig.prompt).not.toContain("Then classify intent and proceed to Step 2.") + expect(athenaConfig.prompt).not.toContain("Classify intent immediately and proceed to Step 2.") + }) + + it("#then excludes non-interactive mode branch from runtime prompt", () => { + expect(athenaConfig.prompt).not.toContain("Non-interactive mode (Question tool unavailable)") + }) }) }) }) diff --git a/src/agents/athena/council-intent-addendums.ts b/src/agents/athena/council-intent-addendums.ts index 01aef7b0c..e0a7df705 100644 --- a/src/agents/athena/council-intent-addendums.ts +++ b/src/agents/athena/council-intent-addendums.ts @@ -63,7 +63,7 @@ You are conducting an **audit** — your goal is to find discrete issues, risks, - Severity determines priority: critical (blocks/breaks), high (significant risk), medium (should fix), low (nice to fix) - For each finding, provide the specific location (reference, section, or component where it occurs) - State your confidence: high (clear evidence), medium (likely but needs verification), low (suspicion, investigate further) -- **This is a broad sweep, not a targeted trace.** If you are starting from a specific symptom and need to find its root cause, that is DIAGNOSE — not AUDIT. +- **This is a broad sweep, not a targeted trace.**. **Required output fields per finding:** - Title, Severity (critical/high/medium/low), Location, Confidence (high/medium/low) diff --git a/src/agents/athena/council-member-agent.ts b/src/agents/athena/council-member-agent.ts index 669bf592a..26d6b8f03 100644 --- a/src/agents/athena/council-member-agent.ts +++ b/src/agents/athena/council-member-agent.ts @@ -31,23 +31,7 @@ You MUST wrap your final analysis in tags. This is how - Exploration logs and intermediate reasoning - Step-by-step search process -**Example structure:** -\`\`\` - -## Point 1: [Title] -- **Observation**: [what you found] -- **Confidence**: high -- **Details**: [supporting evidence or reasoning] - -## Point 2: [Title] -... - -## Summary -[Overall assessment with confidence levels] - -\`\`\` - -If you do not wrap your response in these tags, your analysis will not be included in the synthesis.` +If you do not wrap your response in tags, your analysis will not be included in the synthesis.` export const COUNCIL_SOLO_ADDENDUM = ` ## Solo Analysis Mode @@ -85,7 +69,9 @@ background_output(task_id="") - Use \`librarian\` for documentation and external references - Keep targeted file reads (Read tool) for yourself — delegate broad searches - Collect results with \`background_output\` after \`background_wait\` returns -- Before generating your final \`\`, cancel any remaining pending tasks with \`background_cancel\`` +- Before generating your final \`\`, wait for all the background tasks to finish. +- If you decide to form your final response before background tasks finishes, cancel any remaining pending tasks with \`background_cancel\` +` export function createCouncilMemberAgent(model: string): AgentConfig { // Allow-list: only read-only analysis tools + optional delegation. diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts index 4288d659c..8d525b107 100644 --- a/src/agents/builtin-agents.ts +++ b/src/agents/builtin-agents.ts @@ -206,6 +206,7 @@ export async function createBuiltinAgents( const retryIfFinished = councilConfig.retry_failed_if_others_finished ?? false const cancelOnQuorum = councilConfig.cancel_retrying_on_quorum ?? true const stuckThreshold = councilConfig.stuck_threshold_seconds ?? 120 + const memberMaxRunning = councilConfig.member_max_running_seconds ?? 1800 let athenaPrompt = (result["athena"].prompt ?? "") + councilTaskInstructions athenaPrompt = athenaPrompt @@ -213,7 +214,8 @@ export async function createBuiltinAgents( .replace(/\{RETRY_FAILED_IF_OTHERS_FINISHED\}/g, String(retryIfFinished)) .replace(/\{CANCEL_RETRYING_ON_QUORUM\}/g, String(cancelOnQuorum)) .replace(/\{STUCK_THRESHOLD_SECONDS\}/g, String(stuckThreshold)) - athenaPrompt += `\n\n## Council Resilience Config\n- retry_on_fail: ${retryOnFail}\n- retry_failed_if_others_finished: ${retryIfFinished}\n- cancel_retrying_on_quorum: ${cancelOnQuorum}\n- stuck_threshold_seconds: ${stuckThreshold}` + .replace(/\{MEMBER_MAX_RUNNING_SECONDS\}/g, String(memberMaxRunning)) + athenaPrompt += `\n\n## Council Resilience Config\n- retry_on_fail: ${retryOnFail}\n- retry_failed_if_others_finished: ${retryIfFinished}\n- cancel_retrying_on_quorum: ${cancelOnQuorum}\n- stuck_threshold_seconds: ${stuckThreshold}\n- member_max_running_seconds: ${memberMaxRunning}` result["athena"] = { ...result["athena"], diff --git a/src/agents/builtin-agents/council-member-agents.test.ts b/src/agents/builtin-agents/council-member-agents.test.ts index 7ee985d7d..39ddd7956 100644 --- a/src/agents/builtin-agents/council-member-agents.test.ts +++ b/src/agents/builtin-agents/council-member-agents.test.ts @@ -13,6 +13,7 @@ describe("council-member-agents", () => { retry_failed_if_others_finished: false, cancel_retrying_on_quorum: true, stuck_threshold_seconds: 120, + member_max_running_seconds: 1800, } //#when const result = registerCouncilMemberAgents(config) @@ -32,6 +33,7 @@ describe("council-member-agents", () => { retry_failed_if_others_finished: false, cancel_retrying_on_quorum: true, stuck_threshold_seconds: 120, + member_max_running_seconds: 1800, } //#when const result = registerCouncilMemberAgents(config) @@ -52,6 +54,7 @@ describe("council-member-agents", () => { retry_failed_if_others_finished: false, cancel_retrying_on_quorum: true, stuck_threshold_seconds: 120, + member_max_running_seconds: 1800, } //#when const result = registerCouncilMemberAgents(config) @@ -72,6 +75,7 @@ describe("council-member-agents", () => { retry_failed_if_others_finished: false, cancel_retrying_on_quorum: true, stuck_threshold_seconds: 120, + member_max_running_seconds: 1800, } //#when const result = registerCouncilMemberAgents(config) diff --git a/src/config/schema/athena.test.ts b/src/config/schema/athena.test.ts index 1e12f5670..ead14cc81 100644 --- a/src/config/schema/athena.test.ts +++ b/src/config/schema/athena.test.ts @@ -469,6 +469,14 @@ describe("CouncilConfigSchema — resilience fields", () => { expect(result.data.stuck_threshold_seconds).toBe(120) } }) + + it("#then applies default member_max_running_seconds of 1800", () => { + const result = CouncilConfigSchema.safeParse({ members: validMembers }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.member_max_running_seconds).toBe(1800) + } + }) }) }) @@ -481,6 +489,7 @@ describe("CouncilConfigSchema — resilience fields", () => { retry_failed_if_others_finished: true, cancel_retrying_on_quorum: false, stuck_threshold_seconds: 60, + member_max_running_seconds: 2400, } const result = CouncilConfigSchema.safeParse(config) expect(result.success).toBe(true) @@ -489,6 +498,7 @@ describe("CouncilConfigSchema — resilience fields", () => { expect(result.data.retry_failed_if_others_finished).toBe(true) expect(result.data.cancel_retrying_on_quorum).toBe(false) expect(result.data.stuck_threshold_seconds).toBe(60) + expect(result.data.member_max_running_seconds).toBe(2400) } }) }) @@ -521,6 +531,15 @@ describe("CouncilConfigSchema — resilience fields", () => { }) }) + describe("#given member_max_running_seconds below minimum", () => { + describe("#when parsed with 30", () => { + it("#then fails validation", () => { + const result = CouncilConfigSchema.safeParse({ members: validMembers, member_max_running_seconds: 30 }) + expect(result.success).toBe(false) + }) + }) + }) + describe("#given backward-compatible config with only members", () => { describe("#when parsed", () => { it("#then succeeds without errors — new fields are optional", () => { diff --git a/src/config/schema/athena.ts b/src/config/schema/athena.ts index 69800a30f..2b87201ce 100644 --- a/src/config/schema/athena.ts +++ b/src/config/schema/athena.ts @@ -25,6 +25,7 @@ export const CouncilConfigSchema = z.object({ retry_failed_if_others_finished: z.boolean().default(false), cancel_retrying_on_quorum: z.boolean().default(true), stuck_threshold_seconds: z.number().min(30).default(120), + member_max_running_seconds: z.number().min(60).default(1800), }).strict() export type CouncilMemberConfig = z.infer