Coverage for src/ai_jury/scaffold.py: 100%

190 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-30 06:29 +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 

16import math 

17from urllib.parse import urlsplit 

18 

19from .config import AGY_AGENT, DEFAULT_CONFIG, GENERIC_CLI_VENDORS, adapter_key 

20 

21#: The one list of model ids the shipped samples name, keyed by the kind of seat 

22#: (#870). `jury init` writes the hosted and local ones below; the README, the 

23#: site's integration cards and "Build your jury" demo, `examples/jury.toml` and 

24#: the docs name the same ids, and ``tests/test_sample_configs.py`` holds every 

25#: one of them to this table — so a stale id is changed here and the test lists 

26#: each copy that still disagrees. Each was checked against the vendor's public 

27#: model list on 2026-09-28 (no API key used): Anthropic's models overview, 

28#: OpenAI's models page, Google's Gemini API models page, xAI's models page, 

29#: DeepSeek's pricing page, Groq's models page, Moonshot's pricing page, 

30#: OpenRouter's public ``/api/v1/models``, Together's serverless model list and 

31#: Ollama's library tags. 

32SAMPLE_MODELS: dict[str, str] = { 

33 "anthropic": "claude-opus-5-5", 

34 "openai": "gpt-6-sol", 

35 "google": "gemini-3.8-flash", 

36 "xai": "grok-4.7", 

37 "deepseek": "deepseek-v4-pro", 

38 "groq": "llama-3.3-70b-versatile", 

39 "moonshot": "kimi-k3", 

40 "together": "meta-llama/Llama-3.3-70B-Instruct-Turbo", 

41 "openrouter": "anthropic/claude-opus-5.5", 

42 "local": "qwen2.5-coder:7b", 

43} 

44 

45_LOCAL_TEMPLATE = { 

46 "name": "qwen", 

47 "vendor": "local", 

48 "model": SAMPLE_MODELS["local"], 

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

50} 

51 

52# Hosted-API templates (issue #430): no `command`/`endpoint` — see 

53# adapters._HostedApiAdapter. `model` is left for the user to fill in (a 

54# hardcoded model id here would go stale as vendors deprecate/rename models; 

55# `validate_config` already warns when it's missing). 

56_ANTHROPIC_API_TEMPLATE = {"name": "claude-api", "vendor": "anthropic-api", "model": ""} 

57_OPENAI_API_TEMPLATE = {"name": "codex-api", "vendor": "openai-api", "model": ""} 

58_GOOGLE_API_TEMPLATE = {"name": "gemini-api", "vendor": "google-api", "model": ""} 

59_OPENROUTER_TEMPLATE = { 

60 "name": "openrouter", 

61 "vendor": "openai-compatible", 

62 "endpoint": "https://openrouter.ai/api/v1", 

63 "api_key_env": "OPENROUTER_API_KEY", 

64 "model": SAMPLE_MODELS["openrouter"], 

65} 

66_DEEPSEEK_TEMPLATE = { 

67 "name": "deepseek", 

68 "vendor": "openai-compatible", 

69 "endpoint": "https://api.deepseek.com/v1", 

70 "api_key_env": "DEEPSEEK_API_KEY", 

71 "model": SAMPLE_MODELS["deepseek"], 

72} 

73_GROQ_TEMPLATE = { 

74 "name": "groq", 

75 "vendor": "openai-compatible", 

76 "endpoint": "https://api.groq.com/openai/v1", 

77 "api_key_env": "GROQ_API_KEY", 

78 "model": SAMPLE_MODELS["groq"], 

79} 

80#: aider with the flags that ask it not to edit (#859). `--read-only` is not an aider 

81#: option (its options reference has `--read FILE`), so the template it replaces 

82#: wrote a seat aider refuses to start. Ask mode "never make[s] changes", 

83#: `--dry-run` stops it applying edits or committing, and the rest turn off its 

84#: commits, shell-command suggestions, post-edit lint run, URL fetches and 

85#: `.gitignore` edit. `--message` is last: with `prompt_mode = "arg"` the prompt 

86#: is appended after it. What no flag controls: aider reads `.aider.conf.yml` and 

87#: `.env` from the checkout it runs in, and those can turn on its test, lint or 

88#: load commands, which it runs before the message (``--test``/``--lint`` have no 

89#: ``--no-`` form, and ``--config``/``--env-file`` add a file rather than replace 

90#: the ones it finds). jury adds or checks no sandbox for an arbitrary CLI, so the 

91#: seat is written under :data:`UNSANDBOXED_LABEL` and :data:`UNSANDBOXED_HINT`, 

92#: and the least-privilege audit still warns about it. 

93_GENERIC_CLI_TEMPLATE = { 

94 "name": "aider", 

95 "vendor": "cli", 

96 "command": "aider", 

97 "prompt_mode": "arg", 

98 "extra_args": [ 

99 "--chat-mode", 

100 "ask", 

101 "--dry-run", 

102 "--no-auto-commits", 

103 "--no-dirty-commits", 

104 "--no-suggest-shell-commands", 

105 "--no-auto-lint", 

106 "--no-detect-urls", 

107 "--no-gitignore", 

108 "--message", 

109 ], 

110} 

111 

112#: Written above every bring-your-own CLI seat `jury init` scaffolds (#859): jury 

113#: adds or checks no sandbox for one, so it runs with whatever its own flags 

114#: allow. The same words label the seat on the site and in the docs. 

115UNSANDBOXED_LABEL = "unsandboxed \u2014 runs with your permissions" 

116 

117#: Written under the label: the CLI runs in the checkout jury starts in and obeys 

118#: that checkout's own config (aider's `.aider.conf.yml`/`.env`, Cursor's 

119#: `.cursor/` hooks), which no flag in the seat turns off. 

120UNSANDBOXED_HINT = ( 

121 "# It reads its own config from the checkout it runs in, which can run commands;", 

122 "# seat it only on checkouts you trust. See docs/configuration.md.", 

123) 

124 

125 

126def _from_default(name: str) -> dict | None: 

127 for a in [*DEFAULT_CONFIG.get("agent", []), AGY_AGENT]: 

128 if a.get("name") == name: 

129 return dict(a) 

130 return None 

131 

132 

133#: Templates `jury init` writes only when they are named: never picked by 

134#: "detected", "all", or an interactive default. agy cannot be confined for a 

135#: reviewer of untrusted diffs (see ``config.AGY_AGENT``), so seating it is the 

136#: operator's explicit decision, and `jury init` says so when it writes one. 

137OPT_IN_AGENTS: tuple[str, ...] = ("agy",) 

138 

139 

140def implicit_choices(names) -> list[str]: 

141 """*names* without the opt-in-only agents: what a default may pick.""" 

142 return [n for n in names if n not in OPT_IN_AGENTS] 

143 

144 

145def agent_templates() -> dict[str, dict]: 

146 """Built-in agent templates keyed by short name (a fresh copy each call).""" 

147 templates: dict[str, dict] = {} 

148 for name in ("claude", "codex", "agy"): 

149 tmpl = _from_default(name) 

150 if tmpl is not None: 

151 templates[name] = tmpl 

152 templates["qwen"] = dict(_LOCAL_TEMPLATE) 

153 templates["claude-api"] = dict(_ANTHROPIC_API_TEMPLATE) 

154 templates["codex-api"] = dict(_OPENAI_API_TEMPLATE) 

155 templates["gemini-api"] = dict(_GOOGLE_API_TEMPLATE) 

156 templates["openrouter"] = dict(_OPENROUTER_TEMPLATE) 

157 templates["deepseek"] = dict(_DEEPSEEK_TEMPLATE) 

158 templates["groq"] = dict(_GROQ_TEMPLATE) 

159 templates["aider"] = dict(_GENERIC_CLI_TEMPLATE) 

160 return templates 

161 

162 

163_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"}) 

164 

165 

166def agents_needing_remote_opt_in() -> tuple[str, ...]: 

167 """Templates whose endpoint the config validator refuses without an opt-in. 

168 

169 `config` accepts a loopback endpoint and refuses every other host unless 

170 ``JURY_ALLOW_REMOTE_ENDPOINT`` is set — a deliberate default-closed posture, 

171 since a config-supplied URL is otherwise a request-forgery primitive. Three 

172 hosted templates point at real vendors, so a preset that silently includes 

173 them produces a config `jury init` then refuses to write. 

174 

175 Derived from the templates rather than listed, so a new hosted template is 

176 covered the day it lands. 

177 """ 

178 remote = [] 

179 for name, template in agent_templates().items(): 

180 endpoint = template.get("endpoint") 

181 if not endpoint: 

182 continue 

183 host = urlsplit(endpoint).hostname or "" 

184 if host.lower() not in _LOOPBACK_HOSTS: 

185 remote.append(name) 

186 return tuple(remote) 

187 

188 

189#: Every agent `jury init` can scaffold, in the order it offers them. 

190#: 

191#: Derived from :func:`agent_templates` rather than listed, because a second 

192#: hand-written copy of the same set is what #589 asked to be fixed and #590 

193#: did not: four templates — ``openrouter``, ``deepseek``, ``groq``, ``aider`` — 

194#: shipped without ever reaching this tuple, so ``jury init --list-agents``, the 

195#: wizard, and ``--preset all`` could not see them, while the error message for 

196#: an unknown agent named them. The CLI told users to choose from four options 

197#: it never offered. 

198#: 

199#: ``agent_templates`` reads only module constants, so this costs no I/O at 

200#: import and is deterministic. 

201#: 

202#: Read by ``cli._init_available``, ``cli._init_interactive``, ``cli._init_wizard`` 

203#: and ``cli._run_init`` — every path through which ``jury init`` offers, detects 

204#: or defaults an agent. Nothing in *this* module reads it, which is the whole of 

205#: what CodeQL's intra-module ``py/unused-global-variable`` sees (#696): the 

206#: readers are real, and deleting this tuple would take ``--list-agents``, the 

207#: wizard and ``--preset all`` with it. 

208KNOWN_AGENTS: tuple[str, ...] = tuple(agent_templates()) 

209 

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

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

212 

213 

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

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

216 

217 Prefers a code-oriented model (name contains 'coder'/'code'/etc.), else the 

218 first listed; returns None for an empty list. 

219 """ 

220 if not models: 

221 return None 

222 for m in models: 

223 low = m.lower() 

224 if any(h in low for h in _CODER_HINTS): 

225 return m 

226 return models[0] 

227 

228 

229#: Where the zero-config local fallback looks for a model (#863). 

230LOCAL_FALLBACK_ENDPOINT = "http://localhost:11434/v1" 

231 

232 

233def zero_config_local_seat( 

234 config_named, mock, config_file_present, any_seat_available, list_models 

235): 

236 """The local seat a run with no config reviews with, or ``None`` (#863). 

237 

238 The one decision a run's fallback and ``jury --doctor`` share, so the doctor 

239 predicts the panel the run forms rather than a copy of the rule that can 

240 drift. It fires only in the "fresh user" case: no ``--config``, not 

241 ``--mock``, no ``./jury.toml``, no configured seat available, and a local 

242 server that lists a model. The I/O is injected and called lazily, in that 

243 order: ``any_seat_available()`` only when the flags allow a fallback, and 

244 ``list_models()`` only when no seat can run, so neither path asks the local 

245 server when it would not use the answer. A probe that raises reads as "do 

246 not fall back": availability probing must never crash a run. 

247 """ 

248 from .config import AgentSpec 

249 

250 if config_named or mock or config_file_present: 

251 return None 

252 try: 

253 if any_seat_available(): 

254 return None 

255 except Exception: # noqa: BLE001 - availability probing must never crash a run 

256 return None 

257 model = pick_default_model(list_models()) 

258 if not model: 

259 return None 

260 return AgentSpec(name="local", vendor="local", model=model, endpoint=LOCAL_FALLBACK_ENDPOINT) 

261 

262 

263def seat_local_agents( 

264 agents: list[str], models: list[str] | None 

265) -> tuple[list[str], str | None, list[str]]: 

266 """Fill the local seats in *agents* from the models a server lists (issue #864). 

267 

268 Plain ``jury init`` wrote the local template's model whatever the server had, 

269 then warned about its own output. *models* is ``adapters.local_model_listing``'s 

270 answer: a list when the server answered, ``None`` when the listing failed. 

271 Returns ``(seated, model, left_out)``: 

272 

273 * the server lists models → every agent stays seated and *model* is the one 

274 :func:`pick_default_model` prefers; 

275 * the server lists none, other seats present → the local seats move to 

276 *left_out*, for the caller to write commented out, so the file names no model 

277 nobody has pulled and its hash is that of the panel that actually runs; 

278 * the server lists none, local seats only → they stay seated on the template's 

279 model. A config with no seat is invalid, and the pull hint `jury init` prints 

280 for an empty server names that same model, so pulling it completes the file; 

281 * the listing failed (refused, timed out, an error status, an endpoint the SSRF 

282 gate refuses) → every agent stays seated on the template's model, as before 

283 #864. No evidence is not evidence of a fault — the rule 

284 ``doctor._local_model_gap`` follows (#849): a server that is down says 

285 nothing about what is pulled on it. 

286 

287 Pure: the caller lists the models, and only when there is a local seat. 

288 """ 

289 templates = agent_templates() 

290 local = [a for a in agents if templates.get(a, {}).get("vendor") == "local"] 

291 model = pick_default_model(models or []) 

292 if models is None or model is not None or not local or set(local) == set(agents): 

293 return list(agents), model, [] 

294 return [a for a in agents if a not in local], None, list(dict.fromkeys(local)) 

295 

296 

297# Named setup presets (issue: easier config). Each gives default agents + 

298# settings for a common intent; explicit flags / detected agents override the 

299# `agents` value ("detected" = the agents available right now, "all" = every 

300# known agent). Resolved by the CLI, which knows availability. Neither 

301# "detected" nor "all" includes an OPT_IN_AGENTS entry: a preset is a default, 

302# and agy is only ever seated by name. 

303PRESETS: dict[str, dict] = { 

304 "offline": {"agents": ["qwen"], "rounds": 1, "verify": False}, 

305 "fast": {"agents": "detected", "rounds": 1, "verify": False}, 

306 "balanced": {"agents": "detected", "rounds": 2, "verify": True, "early_stop": True}, 

307 "thorough": {"agents": "all", "rounds": 2, "verify": True}, 

308} 

309 

310 

311def build_config( 

312 agents: list[str], 

313 *, 

314 rounds: int = 2, 

315 chair: str | None = None, 

316 verify: bool = True, 

317 early_stop: bool | None = None, 

318 local_model: str | None = None, 

319 local_endpoint: str | None = None, 

320 decision: str | None = None, 

321 auto_depth: bool | None = None, 

322 context_mode: str | None = None, 

323 redact_secrets: bool | None = None, 

324 ci_fail_on: list[str] | None = None, 

325 effort: str | None = None, 

326) -> dict: 

327 """Build a jury config dict from selected agent names. 

328 

329 Raises ``ValueError`` on an unknown agent name or an empty selection. The 

330 chair defaults to the first selected agent. Local agents pick up the 

331 optional model/endpoint overrides. 

332 

333 The optional ``decision``/``auto_depth``/``context_mode``/``redact_secrets``/ 

334 ``ci_fail_on`` knobs (used by ``jury init --wizard``) are written ONLY when 

335 not ``None`` — callers that omit them produce byte-identical output to before, 

336 keeping the scaffolded file free of redundant built-in defaults. 

337 

338 ``effort`` (issue #662) is written onto each selected agent whose vendor can 

339 act on it; agents whose vendor has no effort control are left alone rather 

340 than scaffolded with a setting that would only warn at run time. 

341 """ 

342 templates = agent_templates() 

343 chosen: list[dict] = [] 

344 seen: set[str] = set() 

345 for name in agents: 

346 if name in seen: 

347 continue 

348 tmpl = templates.get(name) 

349 if tmpl is None: 

350 raise ValueError(f"unknown agent '{name}'; choose from {', '.join(templates.keys())}") 

351 entry = dict(tmpl) 

352 # EXEMPT from `normalise_vendor` (issue #701, round 3): `entry` is a copy 

353 # of one of this module's OWN templates, whose vendor strings are literals 

354 # written here in normalised form. There is no operator spelling to 

355 # normalise — the value is not yet configuration, it is what this function 

356 # is about to write out as configuration. 

357 if entry.get("vendor") == "local": 

358 if local_model: 

359 entry["model"] = local_model 

360 if local_endpoint: 

361 entry["endpoint"] = local_endpoint 

362 if effort and _effort_supported(entry.get("vendor", "")): 

363 entry["effort"] = effort 

364 chosen.append(entry) 

365 seen.add(name) 

366 

367 if not chosen: 

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

369 

370 if chair is None: 

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

372 

373 jury: dict = {"rounds": int(rounds), "chair": chair, "verify": bool(verify)} 

374 if early_stop: 

375 jury["early_stop"] = True 

376 if auto_depth is not None: 

377 jury["auto_depth"] = bool(auto_depth) 

378 if decision is not None: 

379 jury["decision"] = decision 

380 if context_mode is not None or redact_secrets is not None: 

381 context: dict = {} 

382 if context_mode is not None: 

383 context["mode"] = context_mode 

384 if redact_secrets is not None: 

385 context["redact_secrets"] = bool(redact_secrets) 

386 jury["context"] = context 

387 if ci_fail_on is not None: 

388 jury["ci"] = {"fail_on": list(ci_fail_on)} 

389 return {"jury": jury, "agent": chosen} 

390 

391 

392def _effort_supported(vendor: str) -> bool: 

393 """Whether *vendor* has an effort control (see ``adapters.effort_args``). 

394 

395 Imported lazily so this module keeps its light import graph; ``adapters`` 

396 is the single owner of the vendor -> effort mapping. 

397 """ 

398 from .adapters import effort_supported 

399 

400 return effort_supported(vendor) 

401 

402 

403def _scalar(value) -> str: 

404 if isinstance(value, bool): 

405 return "true" if value else "false" 

406 if isinstance(value, int): 

407 return str(value) 

408 if isinstance(value, float) and math.isfinite(value): 

409 # `repr` is a valid TOML float for every finite value ("1.0", "0.7", 

410 # "1e-05"). A non-finite one is refused below: no config key accepts it. 

411 return repr(value) 

412 if isinstance(value, str): 

413 escaped = value.replace("\\", "\\\\").replace('"', '\\"') 

414 return f'"{escaped}"' 

415 raise TypeError(f"cannot render TOML scalar of type {type(value).__name__}") 

416 

417 

418def _render_value(value) -> str: 

419 if isinstance(value, list): 

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

421 return _scalar(value) 

422 

423 

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

425_AGENT_KEY_ORDER = ( 

426 "name", 

427 "vendor", 

428 "command", 

429 "endpoint", 

430 "model", 

431 "effort", 

432 "temperature", 

433 "extra_args", 

434 # Written when a template sets it (#859): the aider seat's argv ends in 

435 # `--message`, which only works when the prompt is appended after it. 

436 "prompt_mode", 

437) 

438 

439#: Commented hint written under every effort-capable agent that has no explicit 

440#: level, so the setting is discoverable from the generated file itself. 

441_EFFORT_HINT = '# effort = "medium" # low | medium | high' 

442 

443#: Commented hint written under every local seat with no explicit temperature. 

444#: Commented, not set: the default model does not need it, and a written value 

445#: would split every generated config's hash for nothing. It is here so the 

446#: operator who picks a model that loops at the greedy default finds the knob. 

447_TEMPERATURE_HINT = "# temperature = 1.0 # default 0; set 1.0 for models that loop at 0 (gpt-oss)" 

448 

449 

450#: Written under every scaffolded ``[jury.ci]`` (issue #682). Commented out, 

451#: because the shipped default already IS 2 — the hint exists so a reader 

452#: discovers the knob and its opt-out here rather than only after a run exits 3. 

453#: 

454#: The section it lives under is emitted UNCONDITIONALLY (issue #692). It used to 

455#: be written only when a caller passed ``ci_fail_on``, which no preset and no 

456#: plain ``jury init`` ever does — so the hint the module defines never reached a 

457#: generated file, and `--preset thorough` (three or four vendors, and therefore 

458#: the config most likely to exit 3 on a one-CLI machine) shipped with nothing 

459#: about the guard in it at all. 

460_MIN_VENDORS_HINT = ( 

461 "# Distinct vendors that must have contributed a review before the run can", 

462 "# stand as cross-vendor consensus (exit 3 otherwise). Defaults to 2 and only", 

463 "# applies when 2+ vendors are enabled HERE — including when one of their CLIs", 

464 "# is not installed; set 0 (or pass --no-min-vendors) to accept a panel that", 

465 "# collapsed to one vendor, or --strict to fail at startup on a missing CLI.", 

466 "# min_vendors = 2", 

467) 

468 

469 

470#: Written above a local seat `jury init` left out because its server listed no 

471#: model (issue #864). The block under it is the seat, commented, with the 

472#: template's model — the one the pull command below fetches. 

473_NO_LOCAL_MODEL_HINT = ( 

474 "# The local server listed no model when `jury init` ran, so this seat is", 

475 "# left out rather than named after a model nobody has pulled. Pull one (for", 

476 f"# Ollama: `ollama pull {_LOCAL_TEMPLATE['model']}`) and uncomment the block below,", 

477 "# or rerun `jury init` with `--local-model <id>`.", 

478) 

479 

480 

481def render_toml(config: dict, *, commented_agents: list[dict] | tuple = ()) -> str: 

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

483 

484 Handles exactly the value types this config uses (str/int/bool/list[str]). 

485 Empty/None values are omitted so a local agent (no ``command``/``extra_args``) 

486 stays clean. *commented_agents* are written after the panel as commented-out 

487 ``[[agent]]`` blocks under a hint — the local seats :func:`seat_local_agents` 

488 left out because the server listed no model. 

489 """ 

490 lines = [ 

491 "# Generated by `jury init`. Edit freely — see docs/configuration.md", 

492 "# for the full schema (rounds, ci gate, context policy, diff handling).", 

493 "", 

494 "[jury]", 

495 ] 

496 jury = config["jury"] 

497 # Scalar [jury] keys in a stable, readable order. ``decision``/``auto_depth`` 

498 # are emitted here only when present (the wizard sets them on a non-default). 

499 for key in ("rounds", "chair", "verify", "decision", "auto_depth", "early_stop", "max_rounds"): 

500 if key in jury: 

501 lines.append(f"{key} = {_render_value(jury[key])}") 

502 lines.append("") 

503 

504 # Optional nested tables, written only when the wizard captured a non-default. 

505 context = jury.get("context") 

506 if context: 

507 lines.append("[jury.context]") 

508 for key in ("mode", "redact_secrets"): 

509 if key in context: 

510 lines.append(f"{key} = {_render_value(context[key])}") 

511 lines.append("") 

512 # `[jury.ci]` is always written, with the cross-vendor hint under it (#692). 

513 # `fail_on` still appears only when a caller chose one, so the file keeps 

514 # stating no redundant defaults; an otherwise empty section is comments only 

515 # and parses to `{}`, which is exactly what the run resolves today. 

516 ci = jury.get("ci") or {} 

517 lines.append("[jury.ci]") 

518 if "fail_on" in ci: 

519 lines.append(f"fail_on = {_render_value(ci['fail_on'])}") 

520 lines.extend(_MIN_VENDORS_HINT) 

521 lines.append("") 

522 

523 for agent in config["agent"]: 

524 if adapter_key(agent.get("vendor", ""), agent.get("adapter")) in GENERIC_CLI_VENDORS: 

525 lines.append(f"# {UNSANDBOXED_LABEL}") 

526 lines.extend(UNSANDBOXED_HINT) 

527 lines.append("[[agent]]") 

528 lines.extend(_agent_keys(agent)) 

529 # Only hint at `effort` where the vendor can actually act on it; a hint 

530 # under the `claude`/`codex` CLI blocks would invite a setting that only 

531 # ever produces an "effort unsupported" warning. 

532 if not agent.get("effort") and _effort_supported(agent.get("vendor", "")): 

533 lines.append(_EFFORT_HINT) 

534 # Only local seats send a temperature, and which adapter a seat runs 

535 # through decides that, not its vendor, the same rule validation uses. 

536 if agent.get("temperature") is None and ( 

537 adapter_key(agent.get("vendor", ""), agent.get("adapter")) == "local" 

538 ): 

539 lines.append(_TEMPERATURE_HINT) 

540 lines.append("") 

541 

542 # A seat left out is still written, commented, so uncommenting it is the whole 

543 # fix once its model exists (#864). Comments parse to nothing, so the config 

544 # and its hash are those of the seats above. 

545 for agent in commented_agents: 

546 lines.extend(_NO_LOCAL_MODEL_HINT) 

547 lines.append("# [[agent]]") 

548 lines.extend(f"# {line}" for line in _agent_keys(agent)) 

549 lines.append("") 

550 

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

552 

553 

554def _agent_keys(agent: dict) -> list[str]: 

555 """The ``key = value`` lines of one ``[[agent]]`` table, in a stable order.""" 

556 lines = [] 

557 for key in _AGENT_KEY_ORDER: 

558 value = agent.get(key) 

559 if value in (None, "", []): 

560 continue 

561 lines.append(f"{key} = {_render_value(value)}") 

562 return lines