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
« 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``).
3The ``--doctor`` command reports local readiness and common configuration
4problems. Its output is intentionally SAFE to share:
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`.
13This project collects and transmits NO telemetry. Diagnostics are built
14locally and only written where you explicitly ask (stdout, or ``--write``).
15"""
17from __future__ import annotations
19import platform
20import sys
21import tomllib
22from pathlib import Path
24from . import __version__
25from .adapters import make_adapter
26from .config import load_config
27from .redaction import redact
30def _redact_value(value):
31 """Redact a single config value if it looks secret-like.
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
41def _detect_capabilities(spec):
42 """Best-effort capability/version probe for one agent spec.
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 }
63def _is_available(spec) -> bool:
64 """Whether an agent is reachable, via its adapter's own check.
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
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 }
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 }
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
133def _recommendations(config_path, config_summary, agents) -> dict:
134 """Build actionable next-steps from the diagnostics (issue: doctor UX).
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)
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.")
148 if not ready:
149 from .adapters import list_local_models
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 )
178 return {"ready": ready, "steps": steps}
181def build_diagnostics(config_path=None):
182 """Build a SAFE diagnostics dict for the given config path.
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 = []
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 )
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 }
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("")
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("")
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("")
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("")
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("")
304 lines.append(
305 "Privacy: no telemetry is collected or sent. This report is "
306 "local-only and redacts secret-like values."
307 )
309 return "\n".join(lines)