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

1"""Scaffold a ``jury.toml`` from agent selections (issue #107). 

2 

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). 

8 

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""" 

13 

14from __future__ import annotations 

15 

16from .config import DEFAULT_CONFIG 

17 

18_LOCAL_TEMPLATE = { 

19 "name": "qwen", 

20 "vendor": "local", 

21 "model": "qwen2.5-coder:7b", 

22 "endpoint": "http://localhost:11434/v1", 

23} 

24 

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} 

60 

61 

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 

67 

68 

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 

85 

86 

87KNOWN_AGENTS: tuple[str, ...] = ( 

88 "claude", "codex", "agy", "qwen", "claude-api", "codex-api", "gemini-api", 

89) 

90 

91# Substrings that hint a local model is code-oriented (preferred for reviews). 

92_CODER_HINTS: tuple[str, ...] = ("coder", "code", "deepseek", "qwen") 

93 

94 

95def pick_default_model(models: list[str]) -> str | None: 

96 """Choose a sensible default from discovered local models (issue #109). 

97 

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] 

108 

109 

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} 

120 

121 

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. 

138 

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. 

142 

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) 

165 

166 if not chosen: 

167 raise ValueError("select at least one agent") 

168 

169 if chair is None: 

170 chair = chosen[0]["name"] 

171 

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} 

189 

190 

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__}") 

200 

201 

202def _render_value(value) -> str: 

203 if isinstance(value, list): 

204 return "[" + ", ".join(_scalar(v) for v in value) + "]" 

205 return _scalar(value) 

206 

207 

208# Stable key order for agent tables so output is deterministic and readable. 

209_AGENT_KEY_ORDER = ("name", "vendor", "command", "endpoint", "model", "extra_args") 

210 

211 

212def render_toml(config: dict) -> str: 

213 """Render a jury config dict to ``jury.toml`` text (minimal, typed). 

214 

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("") 

232 

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("") 

246 

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("") 

257 

258 return "\n".join(lines).rstrip() + "\n"