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.
- multi_model_debate/__init__.py +4 -0
- multi_model_debate/__main__.py +6 -0
- multi_model_debate/cli.py +290 -0
- multi_model_debate/config.py +271 -0
- multi_model_debate/exceptions.py +83 -0
- multi_model_debate/models/__init__.py +71 -0
- multi_model_debate/models/claude.py +168 -0
- multi_model_debate/models/cli_wrapper.py +233 -0
- multi_model_debate/models/gemini.py +66 -0
- multi_model_debate/models/openai.py +66 -0
- multi_model_debate/models/protocols.py +35 -0
- multi_model_debate/orchestrator.py +465 -0
- multi_model_debate/phases/__init__.py +22 -0
- multi_model_debate/phases/base.py +236 -0
- multi_model_debate/phases/baseline.py +117 -0
- multi_model_debate/phases/debate.py +154 -0
- multi_model_debate/phases/defense.py +186 -0
- multi_model_debate/phases/final_position.py +307 -0
- multi_model_debate/phases/judge.py +177 -0
- multi_model_debate/phases/synthesis.py +162 -0
- multi_model_debate/pre_debate.py +83 -0
- multi_model_debate/prompts/arbiter_prompt.md.j2 +24 -0
- multi_model_debate/prompts/arbiter_summary.md.j2 +102 -0
- multi_model_debate/prompts/baseline_critique.md.j2 +5 -0
- multi_model_debate/prompts/critic_1_lens.md.j2 +52 -0
- multi_model_debate/prompts/critic_2_lens.md.j2 +52 -0
- multi_model_debate/prompts/debate_round.md.j2 +14 -0
- multi_model_debate/prompts/defense_initial.md.j2 +9 -0
- multi_model_debate/prompts/defense_round.md.j2 +8 -0
- multi_model_debate/prompts/judge.md.j2 +34 -0
- multi_model_debate/prompts/judge_prompt.md.j2 +13 -0
- multi_model_debate/prompts/strategist_proxy_lens.md.j2 +33 -0
- multi_model_debate/prompts/synthesis_prompt.md.j2 +16 -0
- multi_model_debate/prompts/synthesis_template.md.j2 +44 -0
- multi_model_debate/prompts/winner_response.md.j2 +17 -0
- multi_model_debate/response_parser.py +268 -0
- multi_model_debate/roles.py +163 -0
- multi_model_debate/storage/__init__.py +17 -0
- multi_model_debate/storage/run.py +509 -0
- multi_model_debate-1.0.1.dist-info/METADATA +572 -0
- multi_model_debate-1.0.1.dist-info/RECORD +44 -0
- multi_model_debate-1.0.1.dist-info/WHEEL +4 -0
- multi_model_debate-1.0.1.dist-info/entry_points.txt +2 -0
- multi_model_debate-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|