fix: adversarial mode survives a failed proposer (closes #9)#36
Merged
Conversation
The single proposer was a single point of failure: a malformed proposer response (e.g. gemini missing candidates[0].content.parts) aborted the whole run with "no verdict produced" -- critics and judge never ran. Make the adversarial flow resilient in three layers: 1. Proposer fallback: try requested members as proposer in council order; a member returning an unusable answer (error set / no text) is recorded and the next member is tried until one produces a usable proposal. 2. Graceful degrade: if no member can propose, degrade to a plain synthesize over the survivors and surface an actionable warning on CouncilResult.synthesis_error (mirrored to adversarial.verdict_error) instead of silently emitting "no verdict produced". 3. Adapter hardening: confirmed + regression-tested that the gemini adapter turns a missing candidates[0].content.parts (or blocked/empty candidate) into a typed, redact-safe ProviderError so call_model records it as ModelAnswer.error and never raises out of the run. A failed proposer attempt is excluded from the critic set so a member is never both proposer and critic. No new result schema -- reuses existing synthesis_error / verdict_error fields. Tests: proposer fallback (default + explicit), all-proposers-fail degrade, gemini missing-parts at adapter unit level and end-to-end via call_model. Full suite 126 passed; ruff check + format clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
--mode adversarialhad a single point of failure: when the lone proposer returned a malformed response (observed: gemini missingcandidates[0].content.parts→KeyError), the proposal failed and the entire run aborted with "no verdict produced" — critics and judge never ran. This makes the adversarial flow resilient in three layered behaviors.Closes #9.
Three behaviors implemented
Proposer fallback (
run_adversarial): members are tried as proposer in council order, starting with the requested one. A member returning an unusable answer (ModelAnswer.errorset / no text) is recorded and the next member is tried until one produces a usable proposal. The failed attempt is excluded from the critic set so a member is never both proposer and critic.Graceful degrade (
_degrade_to_synthesize): if no member can propose, the run degrades to a plain synthesize over the survivors and surfaces an actionable warning instead of silently emitting "no verdict produced". The warning is carried onCouncilResult.synthesis_error(the existing CouncilResult-level degrade signal, rendered yellow by the CLI) and mirrored toresult.adversarial.verdict_errorso field-specific consumers (CLI adversarial renderer, library callers) also see it. No new schema — reuses existing fields.Adapter hardening (
adapters/gemini.py): the adapter already converts a missingcandidates[0].content.parts(and blocked/empty candidates) into a typed, redact-safeProviderError, whichcall_modelrecords asModelAnswer.error— it never raises out of the run. Added regression tests pinning this at both the adapter unit level and end-to-end throughcall_model.BYO-keys posture
No keys stored/logged/serialized. All error text flows through the existing
ProviderError/redact()path.call_model's never-raise-for-member-failure invariant is preserved.Tests
test_adversarial_failed_proposer_falls_back_to_next_member— chosen proposer fails → next member used → real verdict produced.test_adversarial_explicit_failed_proposer_falls_back— explicit--proposerthat fails still falls back.test_adversarial_all_proposers_fail_degrades_to_synthesize— all proposers fail → graceful degrade, warning surfaced onsynthesis_error+ mirrored toverdict_error, critics never run.test_gemini_parse_missing_content_parts_raises_provider_error(parametrized) — adapter unit level.test_call_model_gemini_missing_parts_becomes_error— end-to-end viacall_model, proves.errorset, no raise.Local: 126 passed (was 121).
ruff check .andruff format --check .both clean.🤖 Generated with Claude Code