Coverage for src/ai_jury/scaffold.py: 100%
124 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 21:31 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 21:31 +0000
1"""Scaffold a ``jury.toml`` from agent selections (issue #107).
3Backs the ``jury init`` command: instead of hand-editing TOML, a user (or a
4script) picks agents/rounds/chair and this renders a valid config. The cloud
5agent templates reuse the **secure-by-default** entries from
6:data:`config.DEFAULT_CONFIG` (issue #100) so generated configs are safe; a
7``local`` template targets an OpenAI-compatible server (Ollama by default).
9Pure and deterministic: building the config dict and rendering it to TOML are
10side-effect-free, so they are fully unit-testable; the CLI layer owns prompting,
11availability detection, and writing the file.
12"""
14from __future__ import annotations
16from .config import DEFAULT_CONFIG
18_LOCAL_TEMPLATE = {
19 "name": "qwen",
20 "vendor": "local",
21 "model": "qwen2.5-coder:7b",
22 "endpoint": "http://localhost:11434/v1",
23}
25# Hosted-API templates (issue #430): no `command`/`endpoint` — see
26# adapters._HostedApiAdapter. `model` is left for the user to fill in (a
27# hardcoded model id here would go stale as vendors deprecate/rename models;
28# `validate_config` already warns when it's missing).
29_ANTHROPIC_API_TEMPLATE = {"name": "claude-api", "vendor": "anthropic-api", "model": ""}
30_OPENAI_API_TEMPLATE = {"name": "codex-api", "vendor": "openai-api", "model": ""}
31_GOOGLE_API_TEMPLATE = {"name": "gemini-api", "vendor": "google-api", "model": ""}
32_OPENROUTER_TEMPLATE = {
33 "name": "openrouter",
34 "vendor": "openai-compatible",
35 "endpoint": "https://openrouter.ai/api/v1",
36 "api_key_env": "OPENROUTER_API_KEY",
37 "model": "anthropic/claude-3.5-sonnet",
38}
39_DEEPSEEK_TEMPLATE = {
40 "name": "deepseek",
41 "vendor": "openai-compatible",
42 "endpoint": "https://api.deepseek.com/v1",
43 "api_key_env": "DEEPSEEK_API_KEY",
44 "model": "deepseek-coder",
45}
46_GROQ_TEMPLATE = {
47 "name": "groq",
48 "vendor": "openai-compatible",
49 "endpoint": "https://api.groq.com/openai/v1",
50 "api_key_env": "GROQ_API_KEY",
51 "model": "llama-3.3-70b-versatile",
52}
53_GENERIC_CLI_TEMPLATE = {
54 "name": "aider",
55 "vendor": "cli",
56 "command": "aider",
57 "prompt_mode": "stdin",
58 "extra_args": ["--no-auto-commits", "--read-only"],
59}
62def _from_default(name: str) -> dict | None:
63 for a in DEFAULT_CONFIG.get("agent", []):
64 if a.get("name") == name:
65 return dict(a)
66 return None
69def agent_templates() -> dict[str, dict]:
70 """Built-in agent templates keyed by short name (a fresh copy each call)."""
71 templates: dict[str, dict] = {}
72 for name in ("claude", "codex", "agy"):
73 tmpl = _from_default(name)
74 if tmpl is not None:
75 templates[name] = tmpl
76 templates["qwen"] = dict(_LOCAL_TEMPLATE)
77 templates["claude-api"] = dict(_ANTHROPIC_API_TEMPLATE)
78 templates["codex-api"] = dict(_OPENAI_API_TEMPLATE)
79 templates["gemini-api"] = dict(_GOOGLE_API_TEMPLATE)
80 templates["openrouter"] = dict(_OPENROUTER_TEMPLATE)
81 templates["deepseek"] = dict(_DEEPSEEK_TEMPLATE)
82 templates["groq"] = dict(_GROQ_TEMPLATE)
83 templates["aider"] = dict(_GENERIC_CLI_TEMPLATE)
84 return templates
87KNOWN_AGENTS: tuple[str, ...] = (
88 "claude", "codex", "agy", "qwen", "claude-api", "codex-api", "gemini-api",
89)
91# Substrings that hint a local model is code-oriented (preferred for reviews).
92_CODER_HINTS: tuple[str, ...] = ("coder", "code", "deepseek", "qwen")
95def pick_default_model(models: list[str]) -> str | None:
96 """Choose a sensible default from discovered local models (issue #109).
98 Prefers a code-oriented model (name contains 'coder'/'code'/etc.), else the
99 first listed; returns None for an empty list.
100 """
101 if not models:
102 return None
103 for m in models:
104 low = m.lower()
105 if any(h in low for h in _CODER_HINTS):
106 return m
107 return models[0]
110# Named setup presets (issue: easier config). Each gives default agents +
111# settings for a common intent; explicit flags / detected agents override the
112# `agents` value ("detected" = the agents available right now, "all" = every
113# known agent). Resolved by the CLI, which knows availability.
114PRESETS: dict[str, dict] = {
115 "offline": {"agents": ["qwen"], "rounds": 1, "verify": False},
116 "fast": {"agents": "detected", "rounds": 1, "verify": False},
117 "balanced": {"agents": "detected", "rounds": 2, "verify": True, "early_stop": True},
118 "thorough": {"agents": "all", "rounds": 2, "verify": True},
119}
122def build_config(
123 agents: list[str],
124 *,
125 rounds: int = 2,
126 chair: str | None = None,
127 verify: bool = True,
128 early_stop: bool | None = None,
129 local_model: str | None = None,
130 local_endpoint: str | None = None,
131 decision: str | None = None,
132 auto_depth: bool | None = None,
133 context_mode: str | None = None,
134 redact_secrets: bool | None = None,
135 ci_fail_on: list[str] | None = None,
136) -> dict:
137 """Build a jury config dict from selected agent names.
139 Raises ``ValueError`` on an unknown agent name or an empty selection. The
140 chair defaults to the first selected agent. Local agents pick up the
141 optional model/endpoint overrides.
143 The optional ``decision``/``auto_depth``/``context_mode``/``redact_secrets``/
144 ``ci_fail_on`` knobs (used by ``jury init --wizard``) are written ONLY when
145 not ``None`` — callers that omit them produce byte-identical output to before,
146 keeping the scaffolded file free of redundant built-in defaults.
147 """
148 templates = agent_templates()
149 chosen: list[dict] = []
150 seen: set[str] = set()
151 for name in agents:
152 if name in seen:
153 continue
154 tmpl = templates.get(name)
155 if tmpl is None:
156 raise ValueError(f"unknown agent '{name}'; choose from {', '.join(KNOWN_AGENTS)}")
157 entry = dict(tmpl)
158 if entry.get("vendor") == "local":
159 if local_model:
160 entry["model"] = local_model
161 if local_endpoint:
162 entry["endpoint"] = local_endpoint
163 chosen.append(entry)
164 seen.add(name)
166 if not chosen:
167 raise ValueError("select at least one agent")
169 if chair is None:
170 chair = chosen[0]["name"]
172 jury: dict = {"rounds": int(rounds), "chair": chair, "verify": bool(verify)}
173 if early_stop:
174 jury["early_stop"] = True
175 if auto_depth is not None:
176 jury["auto_depth"] = bool(auto_depth)
177 if decision is not None:
178 jury["decision"] = decision
179 if context_mode is not None or redact_secrets is not None:
180 context: dict = {}
181 if context_mode is not None:
182 context["mode"] = context_mode
183 if redact_secrets is not None:
184 context["redact_secrets"] = bool(redact_secrets)
185 jury["context"] = context
186 if ci_fail_on is not None:
187 jury["ci"] = {"fail_on": list(ci_fail_on)}
188 return {"jury": jury, "agent": chosen}
191def _scalar(value) -> str:
192 if isinstance(value, bool):
193 return "true" if value else "false"
194 if isinstance(value, int):
195 return str(value)
196 if isinstance(value, str):
197 escaped = value.replace("\\", "\\\\").replace('"', '\\"')
198 return f'"{escaped}"'
199 raise TypeError(f"cannot render TOML scalar of type {type(value).__name__}")
202def _render_value(value) -> str:
203 if isinstance(value, list):
204 return "[" + ", ".join(_scalar(v) for v in value) + "]"
205 return _scalar(value)
208# Stable key order for agent tables so output is deterministic and readable.
209_AGENT_KEY_ORDER = ("name", "vendor", "command", "endpoint", "model", "extra_args")
212def render_toml(config: dict) -> str:
213 """Render a jury config dict to ``jury.toml`` text (minimal, typed).
215 Handles exactly the value types this config uses (str/int/bool/list[str]).
216 Empty/None values are omitted so a local agent (no ``command``/``extra_args``)
217 stays clean.
218 """
219 lines = [
220 "# Generated by `jury init`. Edit freely — see docs/configuration.md",
221 "# for the full schema (rounds, ci gate, context policy, diff handling).",
222 "",
223 "[jury]",
224 ]
225 jury = config["jury"]
226 # Scalar [jury] keys in a stable, readable order. ``decision``/``auto_depth``
227 # are emitted here only when present (the wizard sets them on a non-default).
228 for key in ("rounds", "chair", "verify", "decision", "auto_depth", "early_stop", "max_rounds"):
229 if key in jury:
230 lines.append(f"{key} = {_render_value(jury[key])}")
231 lines.append("")
233 # Optional nested tables, written only when the wizard captured a non-default.
234 context = jury.get("context")
235 if context:
236 lines.append("[jury.context]")
237 for key in ("mode", "redact_secrets"):
238 if key in context:
239 lines.append(f"{key} = {_render_value(context[key])}")
240 lines.append("")
241 ci = jury.get("ci")
242 if ci and "fail_on" in ci:
243 lines.append("[jury.ci]")
244 lines.append(f"fail_on = {_render_value(ci['fail_on'])}")
245 lines.append("")
247 for agent in config["agent"]:
248 lines.append("[[agent]]")
249 for key in _AGENT_KEY_ORDER:
250 if key not in agent:
251 continue
252 value = agent[key]
253 if value in (None, "", []):
254 continue
255 lines.append(f"{key} = {_render_value(value)}")
256 lines.append("")
258 return "\n".join(lines).rstrip() + "\n"