Skip to content

fix: adversarial mode survives a failed proposer (closes #9)#36

Merged
ernestprovo23 merged 1 commit into
mainfrom
fix/adversarial-proposer-resilience
Jun 9, 2026
Merged

fix: adversarial mode survives a failed proposer (closes #9)#36
ernestprovo23 merged 1 commit into
mainfrom
fix/adversarial-proposer-resilience

Conversation

@ernestprovo23

Copy link
Copy Markdown
Member

Summary

--mode adversarial had a single point of failure: when the lone proposer returned a malformed response (observed: gemini missing candidates[0].content.partsKeyError), 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

  1. Proposer fallback (run_adversarial): members are tried as proposer in council order, starting with the requested one. A member returning an unusable answer (ModelAnswer.error set / 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.

  2. 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 on CouncilResult.synthesis_error (the existing CouncilResult-level degrade signal, rendered yellow by the CLI) and mirrored to result.adversarial.verdict_error so field-specific consumers (CLI adversarial renderer, library callers) also see it. No new schema — reuses existing fields.

  3. Adapter hardening (adapters/gemini.py): the adapter already converts a missing candidates[0].content.parts (and blocked/empty candidates) into a typed, redact-safe ProviderError, which call_model records as ModelAnswer.error — it never raises out of the run. Added regression tests pinning this at both the adapter unit level and end-to-end through call_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 --proposer that fails still falls back.
  • test_adversarial_all_proposers_fail_degrades_to_synthesize — all proposers fail → graceful degrade, warning surfaced on synthesis_error + mirrored to verdict_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 via call_model, proves .error set, no raise.

Local: 126 passed (was 121). ruff check . and ruff format --check . both clean.

🤖 Generated with Claude Code

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.
@ernestprovo23 ernestprovo23 merged commit ebcdfd5 into main Jun 9, 2026
5 checks passed
@ernestprovo23 ernestprovo23 deleted the fix/adversarial-proposer-resilience branch June 10, 2026 00:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

adversarial mode: survive a failed proposer (don't abort the whole run)

1 participant