multi-model-debate 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. multi_model_debate/__init__.py +4 -0
  2. multi_model_debate/__main__.py +6 -0
  3. multi_model_debate/cli.py +290 -0
  4. multi_model_debate/config.py +271 -0
  5. multi_model_debate/exceptions.py +83 -0
  6. multi_model_debate/models/__init__.py +71 -0
  7. multi_model_debate/models/claude.py +168 -0
  8. multi_model_debate/models/cli_wrapper.py +233 -0
  9. multi_model_debate/models/gemini.py +66 -0
  10. multi_model_debate/models/openai.py +66 -0
  11. multi_model_debate/models/protocols.py +35 -0
  12. multi_model_debate/orchestrator.py +465 -0
  13. multi_model_debate/phases/__init__.py +22 -0
  14. multi_model_debate/phases/base.py +236 -0
  15. multi_model_debate/phases/baseline.py +117 -0
  16. multi_model_debate/phases/debate.py +154 -0
  17. multi_model_debate/phases/defense.py +186 -0
  18. multi_model_debate/phases/final_position.py +307 -0
  19. multi_model_debate/phases/judge.py +177 -0
  20. multi_model_debate/phases/synthesis.py +162 -0
  21. multi_model_debate/pre_debate.py +83 -0
  22. multi_model_debate/prompts/arbiter_prompt.md.j2 +24 -0
  23. multi_model_debate/prompts/arbiter_summary.md.j2 +102 -0
  24. multi_model_debate/prompts/baseline_critique.md.j2 +5 -0
  25. multi_model_debate/prompts/critic_1_lens.md.j2 +52 -0
  26. multi_model_debate/prompts/critic_2_lens.md.j2 +52 -0
  27. multi_model_debate/prompts/debate_round.md.j2 +14 -0
  28. multi_model_debate/prompts/defense_initial.md.j2 +9 -0
  29. multi_model_debate/prompts/defense_round.md.j2 +8 -0
  30. multi_model_debate/prompts/judge.md.j2 +34 -0
  31. multi_model_debate/prompts/judge_prompt.md.j2 +13 -0
  32. multi_model_debate/prompts/strategist_proxy_lens.md.j2 +33 -0
  33. multi_model_debate/prompts/synthesis_prompt.md.j2 +16 -0
  34. multi_model_debate/prompts/synthesis_template.md.j2 +44 -0
  35. multi_model_debate/prompts/winner_response.md.j2 +17 -0
  36. multi_model_debate/response_parser.py +268 -0
  37. multi_model_debate/roles.py +163 -0
  38. multi_model_debate/storage/__init__.py +17 -0
  39. multi_model_debate/storage/run.py +509 -0
  40. multi_model_debate-1.0.1.dist-info/METADATA +572 -0
  41. multi_model_debate-1.0.1.dist-info/RECORD +44 -0
  42. multi_model_debate-1.0.1.dist-info/WHEEL +4 -0
  43. multi_model_debate-1.0.1.dist-info/entry_points.txt +2 -0
  44. multi_model_debate-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,4 @@
1
+ """Multi-Model Debate: Let AI models argue so you don't have to."""
2
+
3
+ __version__ = "1.0.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m multi_model_debate."""
2
+
3
+ from multi_model_debate.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,290 @@
1
+ """CLI interface for adversarial critique."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from multi_model_debate import __version__
14
+ from multi_model_debate.config import load_config
15
+ from multi_model_debate.exceptions import AdversarialReviewError
16
+ from multi_model_debate.orchestrator import Orchestrator
17
+
18
+ app = typer.Typer(
19
+ name="multi-model-debate",
20
+ help="Multi-model debate engine for stress-testing proposals before implementation.",
21
+ add_completion=False,
22
+ )
23
+ console = Console()
24
+
25
+
26
+ def version_callback(value: bool) -> None:
27
+ """Print version and exit."""
28
+ if value:
29
+ console.print(f"multi-model-debate {__version__}")
30
+ raise typer.Exit()
31
+
32
+
33
+ @app.callback()
34
+ def main(
35
+ version: Annotated[
36
+ bool | None,
37
+ typer.Option(
38
+ "--version",
39
+ "-V",
40
+ help="Show version and exit.",
41
+ callback=version_callback,
42
+ is_eager=True,
43
+ ),
44
+ ] = None,
45
+ ) -> None:
46
+ """Multi-Model Debate - Multi-model debate for stress-testing proposals."""
47
+ pass
48
+
49
+
50
+ @app.command()
51
+ def start(
52
+ game_plan: Annotated[
53
+ Path | None,
54
+ typer.Argument(
55
+ help="Path to game plan file, or '-' for stdin.",
56
+ ),
57
+ ] = None,
58
+ stdin: Annotated[
59
+ bool,
60
+ typer.Option(
61
+ "--stdin",
62
+ help="Read game plan from stdin.",
63
+ ),
64
+ ] = False,
65
+ config: Annotated[
66
+ Path | None,
67
+ typer.Option(
68
+ "--config",
69
+ "-c",
70
+ help="Path to config file (default: auto-detect).",
71
+ ),
72
+ ] = None,
73
+ runs_dir: Annotated[
74
+ Path | None,
75
+ typer.Option(
76
+ "--runs-dir",
77
+ "-r",
78
+ help="Directory for run outputs (default: ./runs).",
79
+ ),
80
+ ] = None,
81
+ skip_protocol: Annotated[
82
+ bool,
83
+ typer.Option(
84
+ "--skip-protocol",
85
+ help="Skip the pre-debate protocol entirely.",
86
+ ),
87
+ ] = False,
88
+ verbose: Annotated[
89
+ bool,
90
+ typer.Option(
91
+ "--verbose",
92
+ "-v",
93
+ help="Enable verbose logging.",
94
+ ),
95
+ ] = False,
96
+ ) -> None:
97
+ """Start a new adversarial review.
98
+
99
+ The game plan should be a markdown document describing the proposal
100
+ to be stress-tested. Provide via file path or stdin:
101
+
102
+ multi-model-debate start proposal.md
103
+ multi-model-debate start --stdin < proposal.md
104
+ multi-model-debate start - # alias for --stdin
105
+ """
106
+ # Handle '-' as alias for --stdin
107
+ use_stdin = stdin or (game_plan is not None and str(game_plan) == "-")
108
+
109
+ # Validate mutual exclusion
110
+ if use_stdin and game_plan is not None and str(game_plan) != "-":
111
+ console.print("[red]Error:[/red] Cannot use --stdin with a file path")
112
+ raise typer.Exit(1)
113
+
114
+ if not use_stdin and game_plan is None:
115
+ console.print("[red]Error:[/red] Provide a game plan file or use --stdin")
116
+ raise typer.Exit(1)
117
+
118
+ try:
119
+ cfg = load_config(config)
120
+ runs = runs_dir or Path.cwd() / "runs"
121
+
122
+ orchestrator = Orchestrator(config=cfg, runs_dir=runs)
123
+
124
+ # Create run directory FIRST so progress is tracked and resumable
125
+ if use_stdin:
126
+ content = sys.stdin.read()
127
+ if not content.strip():
128
+ console.print("[red]Error:[/red] stdin is empty")
129
+ raise typer.Exit(1)
130
+ context = orchestrator.start_from_content(content)
131
+ else:
132
+ # Type narrowing: game_plan is not None here (checked at line 114-116)
133
+ assert game_plan is not None
134
+ # Validate file exists (since we removed exists=True from Argument)
135
+ if not game_plan.exists():
136
+ console.print(f"[red]Error:[/red] File not found: {game_plan}")
137
+ raise typer.Exit(1)
138
+ if not game_plan.is_file():
139
+ console.print(f"[red]Error:[/red] Not a file: {game_plan}")
140
+ raise typer.Exit(1)
141
+ context = orchestrator.start(game_plan)
142
+
143
+ # Run pre-debate protocol (saved to run directory for resume)
144
+ orchestrator.run_pre_debate_protocol(
145
+ context=context,
146
+ skip_protocol=skip_protocol,
147
+ )
148
+
149
+ orchestrator.execute(context)
150
+
151
+ except AdversarialReviewError as e:
152
+ console.print(f"[red]Error:[/red] {e}")
153
+ raise typer.Exit(1) from None
154
+ except KeyboardInterrupt:
155
+ console.print("\n[yellow]Interrupted by user[/yellow]")
156
+ raise typer.Exit(130) from None
157
+
158
+
159
+ @app.command()
160
+ def resume(
161
+ run_dir: Annotated[
162
+ Path | None,
163
+ typer.Option(
164
+ "--run",
165
+ "-r",
166
+ help="Specific run directory to resume (default: latest incomplete).",
167
+ ),
168
+ ] = None,
169
+ config: Annotated[
170
+ Path | None,
171
+ typer.Option(
172
+ "--config",
173
+ "-c",
174
+ help="Path to config file (default: auto-detect).",
175
+ ),
176
+ ] = None,
177
+ runs_dir: Annotated[
178
+ Path | None,
179
+ typer.Option(
180
+ "--runs-dir",
181
+ help="Directory containing runs (default: ./runs).",
182
+ ),
183
+ ] = None,
184
+ ) -> None:
185
+ """Resume an incomplete adversarial review.
186
+
187
+ Continues from the last checkpoint, skipping already-completed phases.
188
+ Useful after interruptions or failures.
189
+ """
190
+ try:
191
+ cfg = load_config(config)
192
+ runs = runs_dir or Path.cwd() / "runs"
193
+
194
+ orchestrator = Orchestrator(config=cfg, runs_dir=runs)
195
+ context = orchestrator.resume(run_dir)
196
+
197
+ # Run pre-debate protocol if not already complete
198
+ # This handles runs that were interrupted during pre-debate
199
+ orchestrator.run_pre_debate_protocol(context=context)
200
+
201
+ orchestrator.execute(context)
202
+
203
+ except AdversarialReviewError as e:
204
+ console.print(f"[red]Error:[/red] {e}")
205
+ raise typer.Exit(1) from None
206
+ except KeyboardInterrupt:
207
+ console.print("\n[yellow]Interrupted by user[/yellow]")
208
+ raise typer.Exit(130) from None
209
+
210
+
211
+ @app.command()
212
+ def status(
213
+ runs_dir: Annotated[
214
+ Path | None,
215
+ typer.Option(
216
+ "--runs-dir",
217
+ "-r",
218
+ help="Directory containing runs (default: ./runs).",
219
+ ),
220
+ ] = None,
221
+ config: Annotated[
222
+ Path | None,
223
+ typer.Option(
224
+ "--config",
225
+ "-c",
226
+ help="Path to config file (default: auto-detect).",
227
+ ),
228
+ ] = None,
229
+ ) -> None:
230
+ """Show status of the most recent run.
231
+
232
+ Displays the current state, completed phases, and game plan
233
+ for the latest review run.
234
+ """
235
+ try:
236
+ cfg = load_config(config)
237
+ runs = runs_dir or Path.cwd() / "runs"
238
+
239
+ orchestrator = Orchestrator(config=cfg, runs_dir=runs)
240
+ run_status = orchestrator.status()
241
+
242
+ if run_status is None:
243
+ console.print("[yellow]No runs found[/yellow]")
244
+ raise typer.Exit(0)
245
+
246
+ # Display status
247
+ console.print()
248
+ console.print("[bold]Latest Run Status[/bold]")
249
+ console.print()
250
+
251
+ table = Table(show_header=False, box=None)
252
+ table.add_column("Key", style="cyan")
253
+ table.add_column("Value")
254
+
255
+ table.add_row("Run", run_status["run_dir"])
256
+ table.add_row("Status", _format_status(run_status["status"]))
257
+ table.add_row("Game Plan", run_status.get("game_plan") or "N/A")
258
+
259
+ console.print(table)
260
+ console.print()
261
+
262
+ # Completed phases
263
+ phases = run_status.get("completed_phases", [])
264
+ if phases:
265
+ console.print("[bold]Completed Phases:[/bold]")
266
+ for phase in phases:
267
+ console.print(f" [green]\u2713[/green] {phase}")
268
+ else:
269
+ console.print("[dim]No phases completed yet[/dim]")
270
+
271
+ console.print()
272
+
273
+ except AdversarialReviewError as e:
274
+ console.print(f"[red]Error:[/red] {e}")
275
+ raise typer.Exit(1) from None
276
+
277
+
278
+ def _format_status(status: str) -> str:
279
+ """Format status with color."""
280
+ colors = {
281
+ "completed": "[green]completed[/green]",
282
+ "in_progress": "[yellow]in progress[/yellow]",
283
+ "failed": "[red]failed[/red]",
284
+ "unknown": "[dim]unknown[/dim]",
285
+ }
286
+ return colors.get(status, status)
287
+
288
+
289
+ if __name__ == "__main__":
290
+ app()
@@ -0,0 +1,271 @@
1
+ """Configuration management with Pydantic and TOML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field, model_validator
10
+
11
+ from multi_model_debate.exceptions import ConfigError
12
+
13
+
14
+ class DebateSettings(BaseModel):
15
+ """Settings for debate rounds."""
16
+
17
+ critic_rounds: int = Field(default=4, ge=1, le=10)
18
+ strategist_rounds: int = Field(default=4, ge=1, le=10)
19
+ # Backwards compatibility aliases
20
+ gpt_gemini_rounds: int | None = Field(default=None, exclude=True)
21
+ strategist_winner_rounds: int | None = Field(default=None, exclude=True)
22
+
23
+ def model_post_init(self, __context: Any) -> None:
24
+ """Handle backwards compatibility for renamed fields."""
25
+ if self.gpt_gemini_rounds is not None:
26
+ object.__setattr__(self, "critic_rounds", self.gpt_gemini_rounds)
27
+ if self.strategist_winner_rounds is not None:
28
+ object.__setattr__(self, "strategist_rounds", self.strategist_winner_rounds)
29
+
30
+
31
+ class RetrySettings(BaseModel):
32
+ """Settings for retry logic with exponential backoff."""
33
+
34
+ max_attempts: int = Field(default=3, ge=1)
35
+ base_delay: int = Field(default=30, ge=1, description="Base delay in seconds")
36
+
37
+
38
+ class ModelSettings(BaseModel):
39
+ """Settings for model invocations."""
40
+
41
+ available: list[str] = Field(
42
+ default_factory=lambda: ["claude", "gemini", "codex"],
43
+ description="Available model families for debates",
44
+ )
45
+ default_timeout: int = Field(default=300, ge=30, description="Timeout in seconds")
46
+ min_response_length: int = Field(default=100, ge=10)
47
+
48
+
49
+ class CLICommandConfig(BaseModel):
50
+ """Configuration for a CLI command."""
51
+
52
+ command: str
53
+ subcommand: str | None = None
54
+ flags: list[str] = Field(default_factory=list)
55
+ input_mode: str = Field(default="positional", pattern="^(positional|stdin)$")
56
+ timeout: int | None = Field(
57
+ default=None,
58
+ ge=30,
59
+ description="Per-model timeout in seconds. If not set, uses models.default_timeout.",
60
+ )
61
+
62
+
63
+ class CLISettings(BaseModel):
64
+ """CLI command configurations for each model.
65
+
66
+ Supports dynamic model names via __getitem__ access.
67
+ Default configurations provided for claude, gemini, codex.
68
+ """
69
+
70
+ model_config = {"extra": "allow"} # Allow dynamic model names
71
+
72
+ codex: CLICommandConfig = Field(
73
+ default_factory=lambda: CLICommandConfig(
74
+ command="codex",
75
+ subcommand="exec",
76
+ input_mode="stdin",
77
+ )
78
+ )
79
+ gemini: CLICommandConfig = Field(
80
+ default_factory=lambda: CLICommandConfig(
81
+ command="gemini",
82
+ input_mode="positional",
83
+ )
84
+ )
85
+ claude: CLICommandConfig = Field(
86
+ default_factory=lambda: CLICommandConfig(
87
+ command="claude",
88
+ input_mode="positional",
89
+ flags=[
90
+ "-p",
91
+ "--tools",
92
+ "",
93
+ "--",
94
+ ], # Print mode, disable built-in tools, -- terminates options
95
+ )
96
+ )
97
+
98
+ def __getitem__(self, name: str) -> CLICommandConfig:
99
+ """Get CLI config for a model by name."""
100
+ if hasattr(self, name):
101
+ value = getattr(self, name)
102
+ if isinstance(value, CLICommandConfig):
103
+ return value
104
+ # Check for extra fields (dynamic models)
105
+ extra = self.model_extra or {}
106
+ if name in extra:
107
+ return CLICommandConfig.model_validate(extra[name])
108
+ raise KeyError(f"No CLI configuration for model: {name}")
109
+
110
+ def get(self, name: str, default: CLICommandConfig | None = None) -> CLICommandConfig | None:
111
+ """Get CLI config for a model, with optional default."""
112
+ try:
113
+ return self[name]
114
+ except KeyError:
115
+ return default
116
+
117
+
118
+ class NotificationSettings(BaseModel):
119
+ """Settings for desktop notifications."""
120
+
121
+ enabled: bool = True
122
+ command: str = "notify-send"
123
+
124
+
125
+ class RolesSettings(BaseModel):
126
+ """Settings for dynamic role assignment.
127
+
128
+ Supports two modes:
129
+ - Legacy: Only `strategist` set, derive critics from models.available
130
+ - Explicit: `critics` list set, use explicit assignments
131
+
132
+ DESIGN DECISION: Judge defaults to Strategist's model family (isolated instance)
133
+
134
+ The Judge evaluates CRITICS, not the Strategist's plan.
135
+ Judge reads Critic A vs Critic B arguments and picks winner.
136
+ Since Judge is different family from both Critics, no bias.
137
+ """
138
+
139
+ strategist: str | None = Field(
140
+ default=None,
141
+ description="Override strategist model family. If not set, auto-detect from environment.",
142
+ )
143
+ critics: list[str] | None = Field(
144
+ default=None,
145
+ description="Explicit list of critic model families. If not set, derived from available.",
146
+ )
147
+ judge: str | None = Field(
148
+ default=None,
149
+ description="Judge model family. If not set, defaults to strategist.",
150
+ )
151
+
152
+ @model_validator(mode="after")
153
+ def validate_explicit_critics(self) -> RolesSettings:
154
+ """Validate explicit critic configuration."""
155
+ if self.critics is not None:
156
+ if len(self.critics) < 2:
157
+ raise ValueError("At least 2 critics required for adversarial debate")
158
+ if len(self.critics) != len(set(self.critics)):
159
+ raise ValueError("Duplicate critics not allowed")
160
+ return self
161
+
162
+
163
+ class PreDebateSettings(BaseModel):
164
+ """Settings for the pre-debate protocol.
165
+
166
+ The pre-debate protocol injects the current date context so models
167
+ can assess proposal relevance against current technology.
168
+ """
169
+
170
+ enabled: bool = Field(
171
+ default=True,
172
+ description="Enable the pre-debate protocol.",
173
+ )
174
+
175
+
176
+ class Config(BaseModel):
177
+ """Main configuration container."""
178
+
179
+ debate: DebateSettings = Field(default_factory=DebateSettings)
180
+ retry: RetrySettings = Field(default_factory=RetrySettings)
181
+ models: ModelSettings = Field(default_factory=ModelSettings)
182
+ cli: CLISettings = Field(default_factory=CLISettings)
183
+ notification: NotificationSettings = Field(default_factory=NotificationSettings)
184
+ roles: RolesSettings = Field(default_factory=RolesSettings)
185
+ pre_debate: PreDebateSettings = Field(default_factory=PreDebateSettings)
186
+
187
+ @classmethod
188
+ def from_toml(cls, path: Path) -> Config:
189
+ """Load configuration from a TOML file."""
190
+ if not path.exists():
191
+ raise ConfigError(f"Config file not found: {path}")
192
+
193
+ try:
194
+ with open(path, "rb") as f:
195
+ data = tomllib.load(f)
196
+ return cls.model_validate(data)
197
+ except tomllib.TOMLDecodeError as e:
198
+ raise ConfigError(f"Invalid TOML in {path}: {e}") from e
199
+ except Exception as e:
200
+ raise ConfigError(f"Failed to load config from {path}: {e}") from e
201
+
202
+ @classmethod
203
+ def from_dict(cls, data: dict[str, Any]) -> Config:
204
+ """Create configuration from a dictionary."""
205
+ return cls.model_validate(data)
206
+
207
+ @classmethod
208
+ def default(cls) -> Config:
209
+ """Create default configuration."""
210
+ return cls()
211
+
212
+
213
+ def find_config_file(start_dir: Path | None = None) -> Path | None:
214
+ """Search for config file in current directory and parents.
215
+
216
+ Looks for:
217
+ - multi_model_debate.toml
218
+ - .multi_model_debate.toml
219
+ - pyproject.toml (with [tool.multi-model-debate] section)
220
+ """
221
+ if start_dir is None:
222
+ start_dir = Path.cwd()
223
+
224
+ current = start_dir.resolve()
225
+
226
+ while current != current.parent:
227
+ # Check for dedicated config files
228
+ for name in ["multi_model_debate.toml", ".multi_model_debate.toml"]:
229
+ config_path = current / name
230
+ if config_path.exists():
231
+ return config_path
232
+
233
+ # Check pyproject.toml for tool section
234
+ pyproject = current / "pyproject.toml"
235
+ if pyproject.exists():
236
+ try:
237
+ with open(pyproject, "rb") as f:
238
+ data = tomllib.load(f)
239
+ if "tool" in data and "multi-model-debate" in data["tool"]:
240
+ return pyproject
241
+ except tomllib.TOMLDecodeError:
242
+ pass
243
+
244
+ current = current.parent
245
+
246
+ return None
247
+
248
+
249
+ def load_config(config_path: Path | None = None) -> Config:
250
+ """Load configuration from file or use defaults.
251
+
252
+ Args:
253
+ config_path: Explicit path to config file. If None, searches for config.
254
+
255
+ Returns:
256
+ Loaded or default configuration.
257
+ """
258
+ if config_path is None:
259
+ config_path = find_config_file()
260
+
261
+ if config_path is None:
262
+ return Config.default()
263
+
264
+ # Handle pyproject.toml specially
265
+ if config_path.name == "pyproject.toml":
266
+ with open(config_path, "rb") as f:
267
+ data = tomllib.load(f)
268
+ tool_config = data.get("tool", {}).get("multi-model-debate", {})
269
+ return Config.from_dict(tool_config)
270
+
271
+ return Config.from_toml(config_path)
@@ -0,0 +1,83 @@
1
+ """Exception hierarchy for adversarial critique."""
2
+
3
+
4
+ class AdversarialReviewError(Exception):
5
+ """Base exception for all adversarial review errors."""
6
+
7
+ pass
8
+
9
+
10
+ class ConfigError(AdversarialReviewError):
11
+ """Configuration loading or validation error."""
12
+
13
+ pass
14
+
15
+
16
+ class ModelError(AdversarialReviewError):
17
+ """Model invocation failed."""
18
+
19
+ pass
20
+
21
+
22
+ class ModelTimeoutError(ModelError):
23
+ """Model did not respond within timeout."""
24
+
25
+ pass
26
+
27
+
28
+ class ModelValidationError(ModelError):
29
+ """Model response failed validation."""
30
+
31
+ pass
32
+
33
+
34
+ class PhaseError(AdversarialReviewError):
35
+ """Phase execution error."""
36
+
37
+ pass
38
+
39
+
40
+ class CheckpointError(AdversarialReviewError):
41
+ """Checkpoint loading or saving error."""
42
+
43
+ pass
44
+
45
+
46
+ class ReviewError(AdversarialReviewError):
47
+ """General orchestration error."""
48
+
49
+ pass
50
+
51
+
52
+ class InsufficientCriticsError(ConfigError):
53
+ """No critics available for adversarial debate.
54
+
55
+ Raised when all configured models belong to the same family as
56
+ the Strategist, leaving zero models to serve as critics.
57
+ """
58
+
59
+ def __init__(self, strategist: str, available: list[str]) -> None:
60
+ """Initialize with configuration details for actionable error message.
61
+
62
+ Args:
63
+ strategist: The strategist model name.
64
+ available: List of available model names from config.
65
+ """
66
+ self.strategist = strategist
67
+ self.available = available
68
+ message = self._build_message()
69
+ super().__init__(message)
70
+
71
+ def _build_message(self) -> str:
72
+ """Build actionable error message."""
73
+ available_str = str(self.available)
74
+ return (
75
+ "Only one model family configured.\n\n"
76
+ "Adversarial critique requires at least 2 different model families.\n\n"
77
+ f"Current config: [models].available = {available_str}\n"
78
+ f"Strategist: {self.strategist}\n\n"
79
+ 'Fix: Add models from other families (e.g., "codex", "gemini")\n\n'
80
+ "Tip: For single-model review, skip this tool and prompt directly. For example:\n"
81
+ "\"Review this proposal from 3 perspectives: devil's advocate, "
82
+ 'domain expert, and end user."'
83
+ )