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

141 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-05 20:29 +0000

1"""Local diagnostics for the agent review jury (``jury --doctor``). 

2 

3The ``--doctor`` command reports local readiness and common configuration 

4problems. Its output is intentionally SAFE to share: 

5 

6- It includes tool/Python/OS versions, a redacted config summary, agent 

7 availability (which agent CLIs are on PATH), each agent's detected CLI 

8 version and capability summary, and detected config warnings. 

9- It NEVER includes the raw diff under review or any agent output. 

10- Secret-like values in the config summary are redacted via 

11 :func:`ai_jury.redaction.redact`. 

12 

13This project collects and transmits NO telemetry. Diagnostics are built 

14locally and only written where you explicitly ask (stdout, or ``--write``). 

15""" 

16 

17from __future__ import annotations 

18 

19import platform 

20import sys 

21import tomllib 

22from pathlib import Path 

23 

24from . import __version__ 

25from .adapters import make_adapter 

26from .config import load_config 

27from .redaction import redact 

28 

29 

30def _redact_value(value): 

31 """Redact a single config value if it looks secret-like. 

32 

33 ``redact`` operates on text and returns ``(text, count)``; non-string 

34 values are returned unchanged. 

35 """ 

36 if isinstance(value, str): 

37 return redact(value)[0] 

38 return value 

39 

40 

41def _detect_capabilities(spec): 

42 """Best-effort capability/version probe for one agent spec. 

43 

44 Uses the real adapter (NOT the mock) so doctor reports actual installed 

45 versions, but guards against any failure: an unavailable CLI just reports 

46 ``status="unavailable"`` and a crashing probe degrades to ``unknown_version``. 

47 This must stay fast (short subprocess timeout) and never crash doctor. 

48 """ 

49 try: 

50 adapter = make_adapter(spec) 

51 return adapter.detect_capabilities() 

52 except Exception as exc: # noqa: BLE001 - diagnostics must never crash 

53 return { 

54 "version": None, 

55 "supports_headless": None, 

56 "supports_model_selection": None, 

57 "raw_version_output": "", 

58 "status": "unknown_version", 

59 "warnings": [f"capability probe raised: {exc}"], 

60 } 

61 

62 

63def _is_available(spec) -> bool: 

64 """Whether an agent is reachable, via its adapter's own check. 

65 

66 Uses ``adapter.available()`` rather than ``shutil.which`` so a local/HTTP 

67 agent (issue #43), which has no ``command`` and probes its endpoint instead, 

68 is reported correctly. Guarded — any failure reads as unavailable. 

69 """ 

70 try: 

71 return make_adapter(spec).available() 

72 except Exception: # noqa: BLE001 - diagnostics must never crash 

73 return False 

74 

75 

76def _agent_entry(spec): 

77 caps = _detect_capabilities(spec) 

78 return { 

79 "name": _redact_value(spec.name), 

80 "command": _redact_value(spec.command), 

81 "vendor": _redact_value(spec.vendor), 

82 "available": _is_available(spec), 

83 "version": _redact_value(caps.get("version")), 

84 "capabilities": { 

85 "supports_headless": caps.get("supports_headless"), 

86 "supports_model_selection": caps.get("supports_model_selection"), 

87 "status": caps.get("status"), 

88 }, 

89 "capability_warnings": [_redact_value(w) for w in caps.get("warnings", [])], 

90 } 

91 

92 

93def _config_summary(cfg): 

94 """Build a redacted, secret-free summary of the loaded config.""" 

95 return { 

96 "rounds": cfg.rounds, 

97 "chair": _redact_value(cfg.chair), 

98 "context_mode": _redact_value(cfg.context.mode), 

99 "enabled_agents": [_redact_value(a.name) for a in cfg.enabled_agents], 

100 } 

101 

102 

103def _detect_warnings(cfg) -> list[str]: 

104 """Best-effort config sanity checks reported to the user.""" 

105 warnings: list[str] = [] 

106 if not cfg.agents: 

107 warnings.append("no agents are configured") 

108 enabled = cfg.enabled_agents 

109 if cfg.agents and not enabled: 

110 warnings.append("all configured agents are disabled") 

111 names = {a.name for a in cfg.agents} 

112 if cfg.chair not in names: 

113 warnings.append( 

114 f"chair '{_redact_value(cfg.chair)}' does not match any configured agent" 

115 ) 

116 for agent in enabled: 

117 if _is_available(agent): 

118 continue 

119 if agent.vendor == "local": 

120 warnings.append( 

121 f"agent '{_redact_value(agent.name)}' (local) endpoint " 

122 f"'{_redact_value(agent.endpoint or 'http://localhost:11434/v1')}' " 

123 f"is not reachable" 

124 ) 

125 else: 

126 warnings.append( 

127 f"agent '{_redact_value(agent.name)}' command " 

128 f"'{_redact_value(agent.command)}' is not on PATH" 

129 ) 

130 return warnings 

131 

132 

133def _recommendations(config_path, config_summary, agents) -> dict: 

134 """Build actionable next-steps from the diagnostics (issue: doctor UX). 

135 

136 Returns ``{"ready": bool, "steps": [str, ...]}``. ``ready`` is true when at 

137 least one agent is reachable. Steps point the user at the cheapest fix: 

138 scaffold a config, install a CLI, or use a reachable local model. 

139 """ 

140 steps: list[str] = [] 

141 available = [a for a in agents if a.get("available")] 

142 ready = bool(available) 

143 

144 # No config file in play -> suggest scaffolding one. 

145 if config_path is None and not Path("jury.toml").exists(): 

146 steps.append("No jury.toml found — run `jury init` to create one.") 

147 

148 if not ready: 

149 from .adapters import list_local_models 

150 

151 models = list_local_models() 

152 if models: 

153 steps.append( 

154 f"No agent CLI is available, but a local model server is reachable " 

155 f"({len(models)} model(s): {', '.join(models[:3])}). Add a free local " 

156 f"reviewer: `jury init --preset offline` (or `--list-models`)." 

157 ) 

158 else: 

159 steps.append( 

160 "No reviewer is available. Install an agent CLI (claude / codex / agy), " 

161 "or run a local model (e.g. `ollama serve` + `ollama pull " 

162 "qwen2.5-coder:7b`) and add a `vendor = \"local\"` agent — or use " 

163 "`--mock` for an offline demo." 

164 ) 

165 else: 

166 missing = [ 

167 a["name"] for a in agents 

168 if not a.get("available") 

169 and config_summary 

170 and a["name"] in config_summary.get("enabled_agents", []) 

171 ] 

172 if missing: 

173 steps.append( 

174 f"Enabled but unavailable (will be skipped): {', '.join(missing)}. " 

175 f"Install them or run with `--strict` to fail instead." 

176 ) 

177 

178 return {"ready": ready, "steps": steps} 

179 

180 

181def build_diagnostics(config_path=None): 

182 """Build a SAFE diagnostics dict for the given config path. 

183 

184 Best-effort: if the config cannot be loaded, the error is captured as a 

185 string under ``config_warnings`` and ``config`` is left ``None``. Never 

186 raises for a bad/missing config. The returned dict never contains the raw 

187 diff or any agent output. 

188 """ 

189 config_summary = None 

190 config_warnings: list[str] = [] 

191 agents: list = [] 

192 

193 try: 

194 cfg = load_config(config_path) 

195 except FileNotFoundError as exc: 

196 config_warnings.append(f"config error: {exc}") 

197 except tomllib.TOMLDecodeError as exc: 

198 config_warnings.append(f"config error: invalid TOML: {exc}") 

199 except (KeyError, ValueError, TypeError) as exc: 

200 config_warnings.append(f"config error: {exc}") 

201 else: 

202 config_summary = _config_summary(cfg) 

203 agents = [_agent_entry(spec) for spec in cfg.agents] 

204 config_warnings = _detect_warnings(cfg) 

205 # Fold capability/version probe warnings (e.g. an available CLI whose 

206 # version could not be detected) into the user-facing warnings list. 

207 # Probes already ran while building the agent entries above. 

208 enabled_names = {a.name for a in cfg.enabled_agents} 

209 for spec, entry in zip(cfg.agents, agents, strict=False): 

210 if spec.name not in enabled_names: 

211 continue 

212 for warning in entry.get("capability_warnings", []): 

213 config_warnings.append( 

214 f"agent '{entry['name']}': {warning}" 

215 ) 

216 

217 return { 

218 "tool_version": __version__, 

219 "python_version": platform.python_version(), 

220 "python_implementation": platform.python_implementation(), 

221 "python_executable": sys.executable, 

222 "os": platform.platform(), 

223 "config_path": str(config_path) if config_path else "(default)", 

224 "agents": agents, 

225 "config": config_summary, 

226 "config_warnings": config_warnings, 

227 "recommendations": _recommendations(config_path, config_summary, agents), 

228 } 

229 

230 

231def render_report(diagnostics) -> str: 

232 """Render a human-readable text report from a diagnostics dict.""" 

233 lines = [] 

234 lines.append("jury doctor") 

235 lines.append("=" * 40) 

236 lines.append(f"tool version: {diagnostics['tool_version']}") 

237 lines.append( 

238 f"python: {diagnostics['python_version']} " 

239 f"({diagnostics['python_implementation']})" 

240 ) 

241 lines.append(f"python exe: {diagnostics['python_executable']}") 

242 lines.append(f"os: {diagnostics['os']}") 

243 lines.append(f"config path: {diagnostics['config_path']}") 

244 lines.append("") 

245 

246 lines.append("Agents") 

247 lines.append("-" * 40) 

248 agents = diagnostics["agents"] 

249 if not agents: 

250 lines.append(" (no agents loaded)") 

251 else: 

252 for agent in agents: 

253 status = "available" if agent["available"] else "MISSING" 

254 lines.append( 

255 f" [{status:>9}] {agent['name']} " 

256 f"(vendor={agent['vendor']}, command={agent['command']})" 

257 ) 

258 version = agent.get("version") or "unknown" 

259 caps = agent.get("capabilities") or {} 

260 cap_bits = [] 

261 if caps.get("supports_headless"): 

262 cap_bits.append("headless") 

263 if caps.get("supports_model_selection"): 

264 cap_bits.append("model-selection") 

265 cap_summary = ", ".join(cap_bits) or "none" 

266 cap_status = caps.get("status") or "unknown" 

267 lines.append( 

268 f" version={version}, capabilities=[{cap_summary}] " 

269 f"(probe: {cap_status})" 

270 ) 

271 lines.append("") 

272 

273 lines.append("Config summary") 

274 lines.append("-" * 40) 

275 config = diagnostics["config"] 

276 if config is None: 

277 lines.append(" (config could not be loaded)") 

278 else: 

279 lines.append(f" rounds: {config['rounds']}") 

280 lines.append(f" chair: {config['chair']}") 

281 lines.append(f" context mode: {config['context_mode']}") 

282 enabled = ", ".join(config["enabled_agents"]) or "(none)" 

283 lines.append(f" enabled: {enabled}") 

284 lines.append("") 

285 

286 lines.append("Warnings") 

287 lines.append("-" * 40) 

288 warnings = diagnostics["config_warnings"] 

289 if not warnings: 

290 lines.append(" (none)") 

291 else: 

292 for warning in warnings: 

293 lines.append(f" - {warning}") 

294 lines.append("") 

295 

296 rec = diagnostics.get("recommendations") or {} 

297 lines.append("Next steps") 

298 lines.append("-" * 40) 

299 lines.append(f" ready to run: {'yes' if rec.get('ready') else 'no'}") 

300 for step in rec.get("steps", []): 

301 lines.append(f" - {step}") 

302 lines.append("") 

303 

304 lines.append( 

305 "Privacy: no telemetry is collected or sent. This report is " 

306 "local-only and redacts secret-like values." 

307 ) 

308 

309 return "\n".join(lines)