Coverage for src/ai_jury/adapters.py: 94%
528 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"""Agent adapters — each wraps one native coding-agent CLI in headless mode.
3Every adapter turns a prompt into a subprocess invocation and captures stdout as
4the agent's response. Adapters are intentionally thin: the orchestrator owns the
5prompt content and the round structure; an adapter only knows how to *invoke its
6CLI*.
8Headless invocations (verified against installed CLIs, early 2026). The prompt
9embeds the redacted diff, so it is delivered on STDIN (never argv) for every
10real adapter so it is not exposed in the process list (issue #287):
11 - Claude Code : ``claude -p --output-format text`` (prompt piped via stdin)
12 - Codex CLI : ``codex exec <args>`` (prompt piped via stdin)
13 - Antigravity : ``agy --print`` (prompt piped via stdin)
14"""
16from __future__ import annotations
18import contextlib
19import os
20import re
21import shutil
22import signal
23import subprocess
24import time
25from dataclasses import dataclass, field
27from . import privilege, redaction
28from .config import AgentSpec
30# Cap on a single local-model HTTP response body (issue #293/F-9). A chat
31# completion is small; an unbounded read from a malicious/buggy endpoint would
32# let it OOM the process.
33_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
36def _kill_process_group(proc: subprocess.Popen) -> None:
37 """Best-effort kill of the child's whole process group (issue #293/F-7)."""
38 try:
39 if hasattr(os, "killpg"): 39 ↛ 44line 39 didn't jump to line 44 because the condition on line 39 was always true
40 os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
41 return
42 except (ProcessLookupError, PermissionError, OSError):
43 pass
44 with contextlib.suppress(OSError):
45 proc.kill()
48def _spawn(argv: list[str], stdin: str | None, timeout: int) -> subprocess.CompletedProcess:
49 """Run a CLI with stdout/stderr captured, killing the whole group on timeout.
51 ``subprocess.run(timeout=…)`` SIGKILLs only the direct child, so an agent CLI
52 that wraps node/python can leak orphaned grandchildren (issue #293/F-7). The
53 child is started in its own session (process-group leader); on timeout the
54 entire group is killed before re-raising ``TimeoutExpired`` so the caller's
55 handling is unchanged. Returns a ``CompletedProcess``.
56 """
57 popen_kwargs: dict = {
58 "stdin": subprocess.PIPE if stdin is not None else None,
59 "stdout": subprocess.PIPE,
60 "stderr": subprocess.PIPE,
61 "text": True,
62 }
63 if hasattr(os, "setsid"): 63 ↛ 65line 63 didn't jump to line 65 because the condition on line 63 was always true
64 popen_kwargs["start_new_session"] = True
65 proc = subprocess.Popen(argv, **popen_kwargs)
66 try:
67 out, err = proc.communicate(input=stdin, timeout=timeout)
68 except subprocess.TimeoutExpired:
69 _kill_process_group(proc)
70 with contextlib.suppress(Exception):
71 proc.communicate() # reap the killed child
72 raise
73 return subprocess.CompletedProcess(argv, proc.returncode, out, err)
76def _read_only_extra_args(spec: AgentSpec) -> list[str]:
77 """The agent's ``extra_args`` with its mandatory read-only sandbox guaranteed.
79 Enforced at the adapter layer (issue #288) so a missing/misconfigured
80 ``extra_args`` cannot strip the sandbox: a reviewer of an attacker-controlled
81 diff is never write/tool-capable. Config may widen a codex sandbox knowingly,
82 but never remove the restriction.
83 """
84 return privilege.enforce_read_only(spec.vendor, spec.name, spec.extra_args)
87# Short timeout for capability/version probes. Detection is best-effort and must
88# never slow down or block a normal run, so probes are deliberately snappy.
89_VERSION_PROBE_TIMEOUT = 10
91# Matches a version-looking token, e.g. "1.2", "1.2.3", "v0.45.1".
92_VERSION_RE = re.compile(r"\d+\.\d+(?:\.\d+)?")
94# Capability/version probe statuses.
95CAP_OK = "ok"
96CAP_UNKNOWN_VERSION = "unknown_version"
97CAP_UNAVAILABLE = "unavailable"
99# Stable, typed error taxonomy for failed agent executions. These codes let
100# reports and CI/policy distinguish retryable from non-retryable failures
101# instead of pattern-matching free-text error strings.
102ERR_MISSING_CLI = "missing_cli"
103ERR_AUTH_REQUIRED = "auth_required"
104ERR_PERMISSION_PROMPT = "permission_prompt"
105ERR_TIMEOUT = "timeout"
106ERR_NONZERO_EXIT = "nonzero_exit"
107ERR_EMPTY_OUTPUT = "empty_output"
108ERR_SPAWN_FAILED = "spawn_failed"
109ERR_RATE_LIMITED = "rate_limited"
110# Local/HTTP adapter could not reach its server (issue #43): connection refused,
111# DNS failure, or the local model server is not running.
112ERR_CONNECTION = "connection_error"
113# Hosted-API adapter (issue #430): the vendor's API key env var is unset. Distinct
114# from ERR_AUTH_REQUIRED (a key was sent but the server rejected it) so a report
115# can tell "never configured" apart from "misconfigured/expired/revoked".
116ERR_MISSING_API_KEY = "missing_api_key"
117# Hosted-API adapter (issue #430): the configured key contains a control
118# character and was rejected BEFORE being sent as a header, rather than
119# letting http.client raise (and risk echoing a transformed/escaped copy of
120# the secret in its exception text — see _HostedApiAdapter._invalid_key_reason).
121ERR_INVALID_API_KEY = "invalid_api_key"
122ERR_UNKNOWN = "unknown"
124ERROR_CODES = frozenset(
125 {
126 ERR_MISSING_CLI,
127 ERR_AUTH_REQUIRED,
128 ERR_PERMISSION_PROMPT,
129 ERR_TIMEOUT,
130 ERR_NONZERO_EXIT,
131 ERR_EMPTY_OUTPUT,
132 ERR_SPAWN_FAILED,
133 ERR_RATE_LIMITED,
134 ERR_CONNECTION,
135 ERR_MISSING_API_KEY,
136 ERR_INVALID_API_KEY,
137 ERR_UNKNOWN,
138 }
139)
141# Failures that are worth retrying because they are typically transient (issue
142# #30): a timeout, a rate-limit, a process that failed to spawn, or a local
143# server that was briefly unreachable (#43). Auth, missing-CLI,
144# permission-prompt, empty-output, and generic nonzero-exit are treated as
145# deterministic — retrying them just burns time and tokens.
146RETRYABLE_ERROR_CODES = frozenset(
147 {
148 ERR_TIMEOUT,
149 ERR_RATE_LIMITED,
150 ERR_SPAWN_FAILED,
151 ERR_CONNECTION,
152 }
153)
156# Ordered keyword groups for classify_stderr. Each keyword is matched on word
157# boundaries (\b...\b) so incidental substrings do NOT trigger a false
158# classification: bare "auth" matches "auth error" but not "author identity",
159# and "login" matches "login required" but not "login_attempts" ("_" is a word
160# char, so there is no boundary inside "login_attempts"). Multi-word phrases
161# tolerate a space OR "_" between tokens (e.g. "rate limit"/"rate_limit").
162def _keyword_pattern(*keywords: str) -> re.Pattern[str]:
163 parts = [r"[ _]+".join(re.escape(tok) for tok in kw.split()) for kw in keywords]
164 return re.compile(r"\b(?:" + "|".join(parts) + r")\b")
167# Order matters: auth and rate-limit signals are checked before the generic
168# permission and nonzero-exit fallbacks.
169_AUTH_RE = _keyword_pattern(
170 "not authenticated",
171 "unauthenticated",
172 "authentication",
173 "unauthorized",
174 "api key",
175 "auth",
176 "log in",
177 "login",
178 "credential",
179 "credentials",
180)
181_RATE_LIMIT_RE = _keyword_pattern("rate limit", "429", "quota", "too many requests")
182_PERMISSION_RE = _keyword_pattern(
183 "permission",
184 "permissions",
185 "approve",
186 "approval",
187 "confirm",
188 "confirmation",
189)
192def classify_stderr(returncode: int, stderr: str) -> str:
193 """Classify a nonzero-exit failure into a typed error code from its stderr.
195 Token-aware matching against the lowercased stderr: each keyword group is a
196 word-boundary regex, so incidental substrings (e.g. "author" containing
197 "auth") never cause a misclassification. Ordering matters (auth and
198 rate-limit signals are checked before the generic permission and
199 nonzero-exit fallbacks). Returns one of the ``ERR_*`` codes.
200 """
201 text = (stderr or "").lower()
202 if _AUTH_RE.search(text):
203 return ERR_AUTH_REQUIRED
204 if _RATE_LIMIT_RE.search(text):
205 return ERR_RATE_LIMITED
206 if _PERMISSION_RE.search(text):
207 return ERR_PERMISSION_PROMPT
208 del returncode
209 return ERR_NONZERO_EXIT
212@dataclass
213class AgentResult:
214 agent: str
215 vendor: str
216 ok: bool
217 output: str
218 duration_s: float
219 error: str | None = None
220 findings: list = field(default_factory=list)
221 warnings: list = field(default_factory=list)
222 error_code: str | None = None
223 # Number of attempts made for this result (issue #30): 1 means no retry.
224 # >1 records that a transient failure was retried before this outcome.
225 attempts: int = 1
226 # Did this agent emit a structured findings block at all (issue #501)? A
227 # reviewer that examined the diff and found nothing still emits `[]`; one that
228 # produced no review emits prose and no block. Without this, both arrive as zero
229 # findings and the panel reports the same size either way.
230 structured: bool = False
233class Adapter:
234 """Base adapter. Subclasses build the argv for their CLI."""
236 # Declarative capability metadata. Real coding-agent CLIs support a headless
237 # (non-interactive) invocation and model selection; subclasses override where
238 # this differs. ``MockAdapter`` reports synthetic capabilities.
239 SUPPORTS_HEADLESS = True
240 SUPPORTS_MODEL_SELECTION = True
242 # Args passed to the CLI to print its version. Subclasses override if the CLI
243 # uses a different verb/flag (e.g. ``codex --version``).
244 _VERSION_ARGS = ("--version",)
246 def __init__(self, spec: AgentSpec):
247 self.spec = spec
249 @property
250 def name(self) -> str:
251 return self.spec.name
253 def available(self) -> bool:
254 return shutil.which(self.spec.command) is not None
256 def build_argv(self, prompt: str) -> list[str]: # pragma: no cover - overridden
257 raise NotImplementedError
259 def _stdin_for(self, prompt: str) -> str | None:
260 """Prompt to feed on stdin, or None to pass it in argv (the default)."""
261 del prompt
262 return None
264 def _version_argv(self) -> list[str]:
265 """Argv used to probe the CLI's version."""
266 return [self.spec.command, *self._VERSION_ARGS]
268 def detect_capabilities(self) -> dict:
269 """Best-effort probe of this agent's version and capabilities.
271 Returns a dict shaped like::
273 {
274 "version": "<str|None>",
275 "supports_headless": bool,
276 "supports_model_selection": bool,
277 "raw_version_output": "<short str>",
278 "status": "ok|unknown_version|unavailable",
279 "warnings": [...],
280 }
282 This is intentionally fast and forgiving: it runs ``<command> --version``
283 with a SHORT timeout and swallows ALL errors (missing CLI, timeout,
284 nonzero exit, garbage output). It NEVER raises, so it is safe to call
285 from diagnostics without blocking or crashing a run.
286 """
287 caps = {
288 "version": None,
289 "supports_headless": self.SUPPORTS_HEADLESS,
290 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION,
291 "raw_version_output": "",
292 "status": CAP_UNAVAILABLE,
293 "warnings": [],
294 }
296 # Not on PATH: report unavailable without spawning a subprocess.
297 if not self.available():
298 return caps
300 try:
301 # Via _spawn so the probe also runs in its own process group and the
302 # whole group is killed on timeout (issue #303/L-1) — matching the
303 # main run path; a bare subprocess.run would orphan grandchildren.
304 proc = _spawn(self._version_argv(), None, _VERSION_PROBE_TIMEOUT)
305 except subprocess.TimeoutExpired:
306 caps["status"] = CAP_UNKNOWN_VERSION
307 caps["warnings"].append(
308 f"version probe for '{self.spec.command}' timed out after {_VERSION_PROBE_TIMEOUT}s"
309 )
310 return caps
311 except Exception as exc: # noqa: BLE001 - swallow any spawn failure
312 caps["status"] = CAP_UNKNOWN_VERSION
313 caps["warnings"].append(
314 f"version probe for '{self.spec.command}' failed: {redaction.redact(str(exc))[0]}"
315 )
316 return caps
318 raw = ((proc.stdout or "") + (proc.stderr or "")).strip()
319 caps["raw_version_output"] = redaction.redact(raw[:200])[0]
320 match = _VERSION_RE.search(raw)
321 if proc.returncode == 0 and match:
322 caps["version"] = match.group(0)
323 caps["status"] = CAP_OK
324 else:
325 caps["status"] = CAP_UNKNOWN_VERSION
326 caps["warnings"].append(
327 f"could not determine version of '{self.spec.command}' "
328 f"(exit {proc.returncode}); capabilities assumed from vendor defaults"
329 )
330 return caps
332 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult:
333 del phase
334 if not self.available():
335 return AgentResult(
336 self.name,
337 self.spec.vendor,
338 False,
339 "",
340 0.0,
341 f"command not found on PATH: {self.spec.command}",
342 error_code=ERR_MISSING_CLI,
343 )
344 # The effective timeout is the caller's override (the run budget, issue
345 # #30) when smaller than the agent's own bound, else the agent timeout.
346 effective_timeout = self.spec.timeout
347 if timeout is not None:
348 effective_timeout = max(1, min(self.spec.timeout, int(timeout)))
349 argv = self.build_argv(prompt)
350 stdin = self._stdin_for(prompt)
351 start = time.monotonic()
352 try:
353 proc = _spawn(argv, stdin, effective_timeout)
354 except subprocess.TimeoutExpired:
355 return AgentResult(
356 self.name,
357 self.spec.vendor,
358 False,
359 "",
360 time.monotonic() - start,
361 f"timed out after {effective_timeout}s",
362 error_code=ERR_TIMEOUT,
363 )
364 except Exception as exc: # noqa: BLE001 - surface any spawn failure
365 return AgentResult(
366 self.name,
367 self.spec.vendor,
368 False,
369 "",
370 time.monotonic() - start,
371 f"spawn failed: {redaction.redact(str(exc))[0]}",
372 error_code=ERR_SPAWN_FAILED,
373 )
374 dur = time.monotonic() - start
375 out = (proc.stdout or "").strip()
376 # A nonzero exit is ALWAYS a failure, even with stdout (issue #101): a
377 # crashing CLI can still print partial or error output, and counting that
378 # as a clean review would silently feed it into consensus, synthesis, and
379 # the CI gate. We classify from stderr (falling back to any stdout) and
380 # keep a short snippet in the error for debugging — but ok=False, so the
381 # orchestrator excludes it.
382 if proc.returncode != 0:
383 stderr = (proc.stderr or "").strip()
384 detail = stderr or out
385 # Redact before embedding in the error: a crashing CLI can dump an
386 # env var / token into its stderr, and this string is rendered into
387 # the report and posted to the PR. Mirrors the LocalAdapter path
388 # (#293/F-8); the asymmetry was a secret-leak vector (audit
389 # 2026-06-13/N-1). Classify on the raw text (no secrets in codes).
390 safe_detail = redaction.redact(detail)[0]
391 return AgentResult(
392 self.name,
393 self.spec.vendor,
394 False,
395 "",
396 dur,
397 f"exit {proc.returncode}: {safe_detail[:500]}",
398 error_code=classify_stderr(proc.returncode, stderr or out),
399 )
400 if not out:
401 # Exit 0 but nothing on stdout: the agent produced no usable review.
402 return AgentResult(
403 self.name,
404 self.spec.vendor,
405 False,
406 "",
407 dur,
408 f"exit {proc.returncode}: empty output",
409 error_code=ERR_EMPTY_OUTPUT,
410 )
411 return AgentResult(self.name, self.spec.vendor, True, out, dur)
414class ClaudeAdapter(Adapter):
415 # The prompt embeds the (redacted) diff and PR/issue context; deliver it on
416 # STDIN rather than as a process argument so it is not exposed in `ps` /
417 # /proc/<pid>/cmdline to other local users (issue #287). `claude -p` reads
418 # the prompt from stdin when no positional prompt is given.
419 def build_argv(self, prompt: str) -> list[str]:
420 del prompt
421 argv = [self.spec.command, "-p"]
422 if self.spec.model:
423 argv += ["--model", self.spec.model]
424 return argv + _read_only_extra_args(self.spec)
426 def _stdin_for(self, prompt: str) -> str | None:
427 return prompt
430class CodexAdapter(Adapter):
431 # Pipe the prompt on stdin (not positionally) so ``codex exec`` never blocks
432 # waiting for input in non-interactive runs. Sandbox flags live in extra_args;
433 # the shipped default is ``-s read-only`` (secure by default, #100) — the
434 # reviewer only reads its prompt, since the jury fetches the diff via ``gh``.
435 def build_argv(self, prompt: str) -> list[str]:
436 del prompt
437 argv = [self.spec.command, "exec"]
438 if self.spec.model:
439 argv += ["-m", self.spec.model]
440 return argv + _read_only_extra_args(self.spec)
442 def _stdin_for(self, prompt: str) -> str | None:
443 return prompt
446class AgyAdapter(Adapter):
447 # Prompt on STDIN, not argv (issue #287): `agy --print` reads the prompt from
448 # stdin when no positional prompt is given (verified against agy 1.0.6), so the
449 # redacted diff is not exposed in the process list.
450 def build_argv(self, prompt: str) -> list[str]:
451 del prompt
452 argv = [self.spec.command, "--print"]
453 if self.spec.model:
454 argv += ["--model", self.spec.model]
455 return argv + _read_only_extra_args(self.spec)
457 def _stdin_for(self, prompt: str) -> str | None:
458 return prompt
461_DEFAULT_LOCAL_ENDPOINT = "http://localhost:11434/v1"
464def _http_only_opener():
465 """An opener that handles ONLY http/https (issue #291, SSRF defense).
467 The default ``urllib`` opener honors ``file://`` and ``ftp://``, so an
468 attacker-influenced ``endpoint`` could read local files or reach other
469 schemes. This OpenerDirector registers no ``FileHandler``/``FTPHandler``, so
470 any non-http(s) URL raises ``URLError("unknown url type")`` regardless of
471 config validation — defense in depth alongside ``config._endpoint_issues``.
473 It also registers NO ``HTTPRedirectHandler`` (review of #291): otherwise a
474 malicious/compromised endpoint could 302-redirect to an internal/metadata
475 host (e.g. ``169.254.169.254``) and the opener would follow it, bypassing the
476 configured-URL validation. Without the handler a 3xx surfaces as an
477 ``HTTPError`` (a failed review) and is never followed.
478 """
479 import urllib.request
481 opener = urllib.request.OpenerDirector()
482 for handler in (
483 urllib.request.HTTPHandler,
484 urllib.request.HTTPSHandler,
485 urllib.request.HTTPDefaultErrorHandler,
486 urllib.request.HTTPErrorProcessor,
487 # UnknownHandler raises URLError("unknown url type: …") for any scheme
488 # without a registered handler — so file://, ftp://, etc. fail loudly
489 # instead of silently resolving to None.
490 urllib.request.UnknownHandler,
491 ):
492 opener.add_handler(handler())
493 return opener
496def _open(target, timeout):
497 """Open an http/https URL or Request via the restricted opener (issue #291).
499 Single seam for every local-adapter HTTP call so the SSRF-safe opener (no
500 file/ftp handlers) is always used.
501 """
502 return _http_only_opener().open(target, timeout=timeout)
505def list_local_models(endpoint: str = _DEFAULT_LOCAL_ENDPOINT) -> list[str]:
506 """List model ids from a local OpenAI-compatible server (issue #109).
508 GETs ``{endpoint}/models`` (the OpenAI-compatible listing that Ollama,
509 vLLM, LM Studio, etc. expose) and returns the model ids in their reported
510 order. Best-effort and stdlib-only: any failure (server down, bad JSON)
511 returns ``[]`` so callers can fall back gracefully.
513 The endpoint is validated here at the seam (issue #309) so EVERY caller —
514 including the un-gated ``jury init --local-endpoint`` discovery path — gets
515 the same SSRF gate that ``config._endpoint_issues`` enforces for config-file
516 endpoints: a non-``http(s)`` scheme or a non-loopback host (without the
517 ``JURY_ALLOW_REMOTE_ENDPOINT`` opt-in) yields ``[]`` without any network call.
518 """
519 import json as _json
521 from .config import _endpoint_issues
523 base = (endpoint or _DEFAULT_LOCAL_ENDPOINT).rstrip("/")
524 try:
525 # SSRF gate INSIDE the try (review of #309): `_endpoint_issues` calls
526 # urlsplit, which raises ValueError on a malformed URL (e.g. `http://[::1`);
527 # keep the best-effort "any failure -> []" contract rather than crashing.
528 if _endpoint_issues(base, "local-endpoint")[0]: # hard-error issues -> refuse
529 return []
530 url = base if base.endswith("/models") else f"{base}/models"
531 with _open(url, _VERSION_PROBE_TIMEOUT) as resp: # noqa: S310
532 data = _json.loads(resp.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace"))
533 except Exception: # noqa: BLE001 - discovery is best-effort
534 return []
535 models = data.get("data") if isinstance(data, dict) else None
536 if not isinstance(models, list):
537 return []
538 ids = [m.get("id") for m in models if isinstance(m, dict) and m.get("id")]
539 return [str(i) for i in ids]
542class LocalAdapter(Adapter):
543 """Open-weight / local-model reviewer over an OpenAI-compatible API (issue #43).
545 Targets the ``/v1/chat/completions`` endpoint exposed by common local servers
546 (Ollama, llama.cpp ``llama-server``, vLLM, LM Studio). It talks plain HTTP via
547 the stdlib (``urllib``) — no new dependencies and no subprocess — so one panel
548 seat can run free and fully offline, adding model diversity (the load-bearing
549 advantage) at zero marginal cost.
551 Configure as a normal ``[[agent]]`` with ``vendor = "local"``, an
552 ``endpoint`` (base URL, default ``http://localhost:11434/v1``), and a
553 ``model``. ``extra_args`` is unused. An unreachable server fails with the
554 typed ``connection_error`` code (issue #29) rather than a crash.
555 """
557 SUPPORTS_HEADLESS = True
558 SUPPORTS_MODEL_SELECTION = True
560 @property
561 def endpoint(self) -> str:
562 return (self.spec.endpoint or _DEFAULT_LOCAL_ENDPOINT).rstrip("/")
564 def completions_url(self) -> str:
565 """Resolve the chat-completions URL from the configured base endpoint.
567 Accepts either a base URL (``…/v1``) or a full completions URL; pure so it
568 can be unit-tested without network.
569 """
570 base = self.endpoint
571 if base.endswith("/chat/completions"):
572 return base
573 return f"{base}/chat/completions"
575 def build_payload(self, prompt: str) -> dict:
576 """Build the OpenAI-compatible chat-completions request body (pure)."""
577 return {
578 "model": self.spec.model or "",
579 "messages": [{"role": "user", "content": prompt}],
580 "stream": False,
581 "temperature": 0,
582 }
584 @staticmethod
585 def parse_content(data: dict) -> str:
586 """Extract the assistant message text from a chat-completions response."""
587 choices = data.get("choices") or []
588 if not choices:
589 return ""
590 message = choices[0].get("message") or {}
591 return (message.get("content") or "").strip()
593 @staticmethod
594 def classify_http_status(status: int) -> str:
595 """Map an HTTP error status to a typed error code (issue #29)."""
596 if status in (401, 403):
597 return ERR_AUTH_REQUIRED
598 if status == 429:
599 return ERR_RATE_LIMITED
600 return ERR_NONZERO_EXIT
602 def available(self) -> bool:
603 """A local agent is 'available' when its server answers a quick probe.
605 Probes the OpenAI-compatible ``/v1/models`` (or the endpoint root) with a
606 short timeout. Network-only; never raises.
607 """
608 import urllib.error
609 import urllib.request
611 url = f"{self.endpoint}/models"
612 try:
613 with _open(url, _VERSION_PROBE_TIMEOUT) as resp: # noqa: S310
614 return 200 <= resp.status < 500
615 except urllib.error.HTTPError as exc:
616 # A 4xx (e.g. 404 on /models) still means the server is up.
617 return exc.code < 500
618 except Exception: # noqa: BLE001 - unreachable server -> not available
619 return False
621 def detect_capabilities(self) -> dict:
622 reachable = self.available()
623 return {
624 "version": None,
625 "supports_headless": self.SUPPORTS_HEADLESS,
626 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION,
627 "raw_version_output": f"local endpoint {self.endpoint}",
628 "status": CAP_OK if reachable else CAP_UNAVAILABLE,
629 "warnings": ([] if reachable else [f"local server unreachable at {self.endpoint}"]),
630 }
632 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult:
633 import json as _json
634 import urllib.error
635 import urllib.request
637 del phase
638 effective_timeout = self.spec.timeout
639 if timeout is not None:
640 effective_timeout = max(1, min(self.spec.timeout, int(timeout)))
641 body = _json.dumps(self.build_payload(prompt)).encode("utf-8")
642 req = urllib.request.Request(
643 self.completions_url(),
644 data=body,
645 headers={"Content-Type": "application/json"},
646 method="POST",
647 )
648 start = time.monotonic()
649 try:
650 with _open(req, effective_timeout) as resp: # noqa: S310
651 raw = resp.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")
652 data = _json.loads(raw)
653 except urllib.error.HTTPError as exc:
654 detail = ""
655 try:
656 detail = exc.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")[:300]
657 except Exception: # noqa: BLE001
658 detail = exc.reason or ""
659 # The body is from a possibly-untrusted endpoint and is surfaced in
660 # the report; redact recognized secrets before embedding (#293/F-8).
661 detail = redaction.redact(detail)[0]
662 return AgentResult(
663 self.name,
664 self.spec.vendor,
665 False,
666 "",
667 time.monotonic() - start,
668 f"HTTP {exc.code}: {detail}",
669 error_code=self.classify_http_status(exc.code),
670 )
671 except TimeoutError:
672 return AgentResult(
673 self.name,
674 self.spec.vendor,
675 False,
676 "",
677 time.monotonic() - start,
678 f"timed out after {effective_timeout}s",
679 error_code=ERR_TIMEOUT,
680 )
681 except urllib.error.URLError as exc:
682 return AgentResult(
683 self.name,
684 self.spec.vendor,
685 False,
686 "",
687 time.monotonic() - start,
688 f"could not reach local server at {self.endpoint}: {redaction.redact(str(exc.reason))[0]}",
689 error_code=ERR_CONNECTION,
690 )
691 except Exception as exc: # noqa: BLE001 - surface any other failure
692 return AgentResult(
693 self.name,
694 self.spec.vendor,
695 False,
696 "",
697 time.monotonic() - start,
698 f"local request failed: {redaction.redact(str(exc))[0]}",
699 error_code=ERR_UNKNOWN,
700 )
701 dur = time.monotonic() - start
702 content = self.parse_content(data)
703 if not content:
704 return AgentResult(
705 self.name,
706 self.spec.vendor,
707 False,
708 "",
709 dur,
710 "local model returned empty content",
711 error_code=ERR_EMPTY_OUTPUT,
712 )
713 return AgentResult(self.name, self.spec.vendor, True, content, dur)
716# Hosted vendor API endpoints (issue #430). Fixed, not configurable: unlike
717# `local`'s user-supplied `endpoint` (which needs the SSRF validation in
718# config._endpoint_issues), a hosted vendor's URL is a known constant, not an
719# attacker- or operator-influenceable value, so there is nothing to validate.
720_ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
721_ANTHROPIC_API_VERSION = "2023-06-01"
722_OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"
723# The Anthropic Messages API requires max_tokens on every request; there is no
724# server-side default. Generous enough for a review response, small enough to
725# bound cost/latency if a run is ever misconfigured to loop.
726_HOSTED_API_MAX_TOKENS = 4096
729def _hosted_api_status_code(status: int) -> str:
730 """Map a hosted-API HTTP status to a typed error code (issue #430).
732 Shared by every hosted-API adapter — identical mapping to
733 ``LocalAdapter.classify_http_status`` (401/403 → auth, 429 → rate limit),
734 kept as a free function since it has no per-adapter state.
735 """
736 if status in (401, 403):
737 return ERR_AUTH_REQUIRED
738 if status == 429:
739 return ERR_RATE_LIMITED
740 return ERR_NONZERO_EXIT
743def _post_json(
744 url: str, payload: dict, headers: dict[str, str], timeout: int
745) -> tuple[dict | None, str | None, str | None]:
746 """POST a JSON body and parse a JSON response (issue #430).
748 Shared HTTP mechanics for the hosted-API adapters: build the request,
749 route it through the SSRF-safe opener (``_open``, no file/ftp handlers, no
750 redirect following — the same seam ``LocalAdapter`` uses), cap the response
751 read at ``_MAX_RESPONSE_BYTES``, and classify any failure into a typed
752 error code. Returns ``(response_dict, None, None)`` on success or
753 ``(None, error_message, error_code)`` on failure — exactly one shape.
754 Response bodies are redacted before being returned in an error message
755 since they originate from the network and are surfaced in the report.
756 """
757 import json as _json
758 import urllib.error
759 import urllib.request
761 body = _json.dumps(payload).encode("utf-8")
762 req = urllib.request.Request(url, data=body, headers=headers, method="POST")
763 try:
764 with _open(req, timeout) as resp: # noqa: S310
765 raw = resp.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")
766 return _json.loads(raw), None, None
767 except urllib.error.HTTPError as exc:
768 detail = ""
769 try:
770 detail = exc.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")[:300]
771 except Exception: # noqa: BLE001 - reading the error body is best-effort
772 detail = exc.reason or ""
773 detail = redaction.redact(detail)[0]
774 return None, f"HTTP {exc.code}: {detail}", _hosted_api_status_code(exc.code)
775 except TimeoutError:
776 return None, f"timed out after {timeout}s", ERR_TIMEOUT
777 except urllib.error.URLError as exc:
778 return (
779 None,
780 f"could not reach {url}: {redaction.redact(str(exc.reason))[0]}",
781 ERR_CONNECTION,
782 )
783 except Exception as exc: # noqa: BLE001 - surface any other failure
784 return None, f"request failed: {redaction.redact(str(exc))[0]}", ERR_UNKNOWN
787class _HostedApiAdapter(Adapter):
788 """Shared base for hosted-vendor-API reviewers keyed by an env-var API key.
790 No CLI install, no interactive login, no subprocess: just an HTTP call
791 over stdlib ``urllib`` to the vendor's real hosted API (issue #430), the
792 same no-subprocess/no-new-dependency design as ``LocalAdapter`` but
793 pointed at a hosted endpoint instead of a local server. The API key is
794 read from the environment ONLY, never from ``jury.toml``, so it cannot
795 leak into a checked-in config; the endpoint is a fixed per-vendor
796 constant, not a config value, so there is no SSRF surface to guard the
797 way `local`'s `endpoint` needs.
798 """
800 SUPPORTS_HEADLESS = True
801 SUPPORTS_MODEL_SELECTION = True
803 # Subclasses override.
804 _API_KEY_ENV: str = ""
806 def _api_key_env(self) -> str:
807 return (getattr(self.spec, "api_key_env", None) or self._API_KEY_ENV) or "OPENAI_API_KEY"
809 def _api_key(self) -> str:
810 return os.environ.get(self._api_key_env(), "")
812 def _api_url(self) -> str: # pragma: no cover - overridden
813 raise NotImplementedError
815 def _invalid_key_reason(self) -> str | None:
816 """None if the key is safe to use as an HTTP header value; else why not.
818 A key containing a control character (most plausibly a stray
819 trailing ``\\n`` from a file/k8s-secret/`.env` mount) trips CPython's
820 ``http.client`` header-injection guard. That guard reports the
821 rejected value via ``repr()`` (e.g. an embedded newline becomes the
822 two literal characters ``\\`` ``n``), which does **not** byte-for-byte
823 match the raw key — so a literal substring scrub of the exception
824 text (see :meth:`_scrub_secret`) cannot reliably catch it; the
825 transformed text is no longer equal to the original secret. Validate
826 and reject *before* the key ever reaches a header instead of trying
827 to scrub it back out afterward.
828 """
829 if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in self._api_key()):
830 return (
831 f"{self._api_key_env()} contains a control character (e.g. a stray "
832 f"trailing newline from how the secret was loaded) and cannot be "
833 f"used as an HTTP header value"
834 )
835 return None
837 def _scrub_secret(self, text: str) -> str:
838 """Strip the literal API key value from an error message (issue #430).
840 Defense-in-depth alongside ``redaction.redact()`` (which only
841 recognizes known vendor-token *shapes* via regex) for any leak path
842 NOT already ruled out by :meth:`_invalid_key_reason` — e.g. a
843 well-formed key that still ends up quoted in some other library's
844 error text. Not a substitute for that check: once a value contains
845 control characters, downstream formatting (``repr()``, percent-
846 encoding, ...) can transform it before it reaches an error message,
847 and a literal match against the *original* key would then silently
848 miss it — which is exactly why control characters are rejected
849 upfront in :meth:`run` instead of relying on this alone.
850 """
851 key = self._api_key()
852 if key and key in text:
853 return text.replace(key, "[REDACTED]")
854 return text
856 def available(self) -> bool:
857 """Available when a *usable* API key is set — a fast, network-free check.
859 Unlike ``LocalAdapter.available()`` (which probes the server, since a
860 local endpoint's reachability is genuinely uncertain), a hosted
861 vendor's API is assumed reachable; the two real unknowns locally are
862 whether the operator configured a key at all, and whether it's
863 actually usable as a header value (see :meth:`_invalid_key_reason`) —
864 a key that will be rejected by :meth:`run` should not report as
865 available here either, or a capability check (``jury --doctor``)
866 would give a falsely reassuring answer.
867 """
868 return bool(self._api_key()) and self._invalid_key_reason() is None
870 def detect_capabilities(self) -> dict:
871 key_set = bool(self._api_key())
872 invalid_reason = self._invalid_key_reason() if key_set else None
873 has_key = key_set and invalid_reason is None
874 if not key_set:
875 warnings = [f"{self._api_key_env()} is not set in the environment"]
876 elif invalid_reason:
877 warnings = [invalid_reason]
878 else:
879 warnings = []
880 return {
881 "version": None,
882 "supports_headless": self.SUPPORTS_HEADLESS,
883 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION,
884 "raw_version_output": f"hosted API {self._api_url()}",
885 "status": CAP_OK if has_key else CAP_UNAVAILABLE,
886 "warnings": warnings,
887 }
889 def build_payload(self, prompt: str) -> dict: # pragma: no cover - overridden
890 raise NotImplementedError
892 def _headers(self) -> dict[str, str]: # pragma: no cover - overridden
893 raise NotImplementedError
895 @staticmethod
896 def parse_content(data: dict) -> str: # pragma: no cover - overridden
897 raise NotImplementedError
899 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult:
900 del phase
901 # Checked independently of available() (not just "not available()"):
902 # available() now also returns False for a key that IS set but
903 # invalid, and that case needs its own distinct error_code/message
904 # below rather than the misleading "is not set" one.
905 if not self._api_key():
906 return AgentResult(
907 self.name,
908 self.spec.vendor,
909 False,
910 "",
911 0.0,
912 f"{self._api_key_env()} is not set in the environment",
913 error_code=ERR_MISSING_API_KEY,
914 )
915 invalid_reason = self._invalid_key_reason()
916 if invalid_reason is not None:
917 # Reject before the key ever reaches a header — see
918 # _invalid_key_reason for why post-hoc scrubbing can't be trusted
919 # here. This message never echoes the key itself.
920 return AgentResult(
921 self.name,
922 self.spec.vendor,
923 False,
924 "",
925 0.0,
926 invalid_reason,
927 error_code=ERR_INVALID_API_KEY,
928 )
929 effective_timeout = self.spec.timeout
930 if timeout is not None:
931 effective_timeout = max(1, min(self.spec.timeout, int(timeout)))
932 start = time.monotonic()
933 data, err_msg, err_code = _post_json(
934 self._api_url(), self.build_payload(prompt), self._headers(), effective_timeout
935 )
936 dur = time.monotonic() - start
937 if err_msg is not None:
938 return AgentResult(
939 self.name,
940 self.spec.vendor,
941 False,
942 "",
943 dur,
944 self._scrub_secret(err_msg),
945 error_code=err_code,
946 )
947 content = self.parse_content(data or {})
948 if not content:
949 return AgentResult(
950 self.name,
951 self.spec.vendor,
952 False,
953 "",
954 dur,
955 "hosted API returned empty content",
956 error_code=ERR_EMPTY_OUTPUT,
957 )
958 return AgentResult(self.name, self.spec.vendor, True, content, dur)
961class AnthropicApiAdapter(_HostedApiAdapter):
962 """Hosted Anthropic Messages API reviewer, keyed by ``ANTHROPIC_API_KEY`` (issue #430).
964 Configure as a normal ``[[agent]]`` with ``vendor = "anthropic-api"`` and a
965 ``model`` (e.g. a current Claude model id) — no ``command``, no ``claude``
966 CLI install or interactive login needed.
967 """
969 _API_KEY_ENV = "ANTHROPIC_API_KEY"
971 def _api_url(self) -> str:
972 return _ANTHROPIC_API_URL
974 def build_payload(self, prompt: str) -> dict:
975 """Build the Anthropic Messages API request body (pure)."""
976 return {
977 "model": self.spec.model or "",
978 "max_tokens": _HOSTED_API_MAX_TOKENS,
979 "messages": [{"role": "user", "content": prompt}],
980 }
982 def _headers(self) -> dict[str, str]:
983 return {
984 "Content-Type": "application/json",
985 "x-api-key": self._api_key(),
986 "anthropic-version": _ANTHROPIC_API_VERSION,
987 }
989 @staticmethod
990 def parse_content(data: dict) -> str:
991 """Extract the assistant text from a Messages API response."""
992 if not isinstance(data, dict):
993 return ""
994 blocks = data.get("content") or []
995 texts = [
996 block.get("text", "")
997 for block in blocks
998 if isinstance(block, dict) and block.get("type") == "text"
999 ]
1000 return "".join(texts).strip()
1003class OpenAiApiAdapter(_HostedApiAdapter):
1004 """Hosted OpenAI Chat Completions API reviewer, keyed by ``OPENAI_API_KEY`` (issue #430).
1006 Configure as a normal ``[[agent]]`` with ``vendor = "openai-api"`` and a
1007 ``model`` (e.g. a current GPT model id) — no ``command``, no ``codex`` CLI
1008 install or interactive login needed. Same request/response shape as
1009 ``LocalAdapter`` (both are OpenAI-compatible chat completions), just
1010 against the real hosted API with an ``Authorization`` header.
1011 """
1013 _API_KEY_ENV = "OPENAI_API_KEY"
1015 def _api_url(self) -> str:
1016 return _OPENAI_API_URL
1018 def build_payload(self, prompt: str) -> dict:
1019 """Build the OpenAI chat-completions request body (pure)."""
1020 return {
1021 "model": self.spec.model or "",
1022 "messages": [{"role": "user", "content": prompt}],
1023 }
1025 def _headers(self) -> dict[str, str]:
1026 return {
1027 "Content-Type": "application/json",
1028 "Authorization": f"Bearer {self._api_key()}",
1029 }
1031 @staticmethod
1032 def parse_content(data: dict) -> str:
1033 """Extract the assistant message text from a chat-completions response."""
1034 if not isinstance(data, dict):
1035 return ""
1036 choices = data.get("choices") or []
1037 if not choices:
1038 return ""
1039 message = choices[0].get("message") or {}
1040 return (message.get("content") or "").strip()
1043_GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
1046class GoogleApiAdapter(_HostedApiAdapter):
1047 """Hosted Google Gemini API reviewer, keyed by ``GEMINI_API_KEY`` (issue #432).
1049 Configure as a normal ``[[agent]]`` with ``vendor = "google-api"`` and a
1050 ``model`` (e.g. a current Gemini model id) — no ``command``, no ``agy``
1051 CLI install or interactive login needed.
1053 Two differences from the other two hosted adapters:
1055 - The Gemini API embeds the model id in the URL **path**
1056 (``.../models/{model}:generateContent``), not the request body, so
1057 ``_api_url()`` is built from ``self.spec.model`` on every call rather
1058 than returning a fixed constant like the other two adapters.
1059 - The key is sent via the ``x-goog-api-key`` header. Gemini also accepts
1060 the key as a ``?key=...`` query parameter, but a query-string key is a
1061 much easier accidental-leak vector (proxy/access logs, anything that
1062 prints the request URL) than a header — deliberately not supported.
1064 A prompt blocked by Gemini's safety filters comes back with an empty
1065 ``candidates`` list (and a ``promptFeedback.blockReason``); this is not
1066 distinguished from a genuinely empty response and both currently surface
1067 as the same generic ``ERR_EMPTY_OUTPUT`` — a possible future refinement,
1068 not required for parity with the other two adapters.
1069 """
1071 _API_KEY_ENV = "GEMINI_API_KEY"
1073 def _api_url(self) -> str:
1074 # Escape the model id as a single path segment (issue #432 review): an
1075 # operator-configured model containing reserved URL characters
1076 # (`/`, `?`, `#`, ...) would otherwise change the request's path/query
1077 # semantics instead of staying a single `{model}` segment.
1078 import urllib.parse
1080 model = urllib.parse.quote(self.spec.model or "", safe="")
1081 return f"{_GEMINI_API_BASE}/{model}:generateContent"
1083 def build_payload(self, prompt: str) -> dict:
1084 """Build the Gemini ``generateContent`` request body (pure)."""
1085 return {"contents": [{"parts": [{"text": prompt}]}]}
1087 def _headers(self) -> dict[str, str]:
1088 return {
1089 "Content-Type": "application/json",
1090 "x-goog-api-key": self._api_key(),
1091 }
1093 @staticmethod
1094 def parse_content(data: dict) -> str:
1095 """Extract the assistant text from a ``generateContent`` response."""
1096 if not isinstance(data, dict):
1097 return ""
1098 candidates = data.get("candidates") or []
1099 if not candidates or not isinstance(candidates[0], dict):
1100 return ""
1101 content = candidates[0].get("content")
1102 if not isinstance(content, dict):
1103 return ""
1104 parts = content.get("parts") or []
1105 texts = [
1106 part.get("text", "")
1107 for part in parts
1108 if isinstance(part, dict) and isinstance(part.get("text", ""), str)
1109 ]
1110 return "".join(texts).strip()
1113class MockAdapter(Adapter):
1114 """Offline adapter for tests and ``--mock`` runs.
1116 Produces deterministic, phase-aware text so the full orchestration pipeline
1117 can run end-to-end without live CLIs, auth, or token spend.
1118 """
1120 # Synthetic capabilities: the mock is offline and runs no real CLI.
1121 SUPPORTS_HEADLESS = True
1122 SUPPORTS_MODEL_SELECTION = False
1124 def available(self) -> bool:
1125 return True
1127 def detect_capabilities(self) -> dict:
1128 """Deterministic fake capabilities so doctor/tests stay stable offline."""
1129 return {
1130 "version": "mock-1.0",
1131 "supports_headless": self.SUPPORTS_HEADLESS,
1132 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION,
1133 "raw_version_output": "mock-1.0",
1134 "status": CAP_OK,
1135 "warnings": [],
1136 }
1138 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult:
1139 del prompt, timeout
1140 n = self.name
1141 if phase == "review":
1142 body = (
1143 f"- **[major]** `src/example.py:42` — {n}: unchecked return value "
1144 f"may swallow an error.\n"
1145 f"- **[minor]** `src/example.py:7` — {n}: missing docstring.\n\n"
1146 "```json\n"
1147 "[\n"
1148 ' {"severity": "major", "file": "src/example.py", "line": 42, '
1149 f'"claim": "{n}: unchecked return value may swallow an error", '
1150 '"evidence": "the added code ignores the return value of int(x)", '
1151 '"suggested_fix": "check the result and raise on failure", '
1152 f'"confidence": "high", "reviewer": "{n}"}},\n'
1153 ' {"severity": "minor", "file": "src/example.py", "line": 7, '
1154 f'"claim": "{n}: missing docstring", '
1155 '"evidence": "the new function parse() has no docstring", '
1156 '"suggested_fix": "add a one-line docstring", '
1157 f'"confidence": "medium", "reviewer": "{n}"}}\n'
1158 "]\n"
1159 "```"
1160 )
1161 elif phase == "debate":
1162 body = (
1163 f"## AGREE\n- {n}: confirm the unchecked-return finding at "
1164 f"`src/example.py:42`.\n"
1165 f"## DISPUTE\n- {n}: the missing-docstring finding is a nit, not blocking.\n"
1166 f"## MISSED\n- {n}: no test covers the error branch."
1167 )
1168 elif phase == "verify":
1169 body = (
1170 "Verification: confirming the unchecked-return finding at "
1171 "`src/example.py:42`; the missing-docstring claim at `:7` is a nit "
1172 "not supported as blocking.\n\n"
1173 "```json\n"
1174 "[\n"
1175 ' {"file": "src/example.py", "line": 42, '
1176 '"claim": "unchecked return value may swallow an error", '
1177 '"status": "verified", '
1178 '"reasoning": "the added code ignores the return value of int(x)"},\n'
1179 ' {"file": "src/example.py", "line": 7, '
1180 '"claim": "missing docstring", '
1181 '"status": "unsupported", '
1182 '"reasoning": "a missing docstring is not a defect the diff introduces"}\n'
1183 "]\n"
1184 "```"
1185 )
1186 else: # synthesis
1187 body = (
1188 "## Verdict\nREQUEST CHANGES — one confirmed major issue.\n\n"
1189 "## Consensus findings\n- **[major]** `src/example.py:42` — unchecked "
1190 "return value (raised by all reviewers).\n\n"
1191 "## Disputed findings\n- Missing docstring: ruled non-blocking.\n\n"
1192 "## Notable single-reviewer findings\n- Missing test for the error branch."
1193 )
1194 return AgentResult(n, self.spec.vendor, True, body, 0.0)
1197class GenericOpenAICompatibleAdapter(_HostedApiAdapter):
1198 """Hosted OpenAI-compatible API reviewer (OpenRouter, DeepSeek, Groq, Mistral API, LiteLLM, etc.).
1200 Supports custom ``endpoint``, custom ``api_key_env``, and extra HTTP ``headers``.
1201 """
1203 _API_KEY_ENV = "OPENAI_API_KEY"
1205 def _api_url(self) -> str:
1206 endpoint = (self.spec.endpoint or _OPENAI_API_URL).rstrip("/")
1207 if endpoint.endswith("/chat/completions"): 1207 ↛ 1208line 1207 didn't jump to line 1208 because the condition on line 1207 was never true
1208 return endpoint
1209 return f"{endpoint}/chat/completions"
1211 def build_payload(self, prompt: str) -> dict:
1212 return {
1213 "model": self.spec.model or "",
1214 "messages": [{"role": "user", "content": prompt}],
1215 }
1217 def _headers(self) -> dict[str, str]:
1218 hdrs = {
1219 "Content-Type": "application/json",
1220 "Authorization": f"Bearer {self._api_key()}",
1221 }
1222 if self.spec.headers: 1222 ↛ 1224line 1222 didn't jump to line 1224 because the condition on line 1222 was always true
1223 hdrs.update(self.spec.headers)
1224 return hdrs
1226 @staticmethod
1227 def parse_content(data: dict) -> str:
1228 if not isinstance(data, dict): 1228 ↛ 1229line 1228 didn't jump to line 1229 because the condition on line 1228 was never true
1229 return ""
1230 choices = data.get("choices") or []
1231 if not choices or not isinstance(choices, list):
1232 return ""
1233 first = choices[0]
1234 if not isinstance(first, dict): 1234 ↛ 1235line 1234 didn't jump to line 1235 because the condition on line 1234 was never true
1235 return ""
1236 msg = first.get("message") or {}
1237 if not isinstance(msg, dict): 1237 ↛ 1238line 1237 didn't jump to line 1238 because the condition on line 1237 was never true
1238 return ""
1239 raw_content = msg.get("content")
1240 if isinstance(raw_content, str):
1241 return raw_content.strip()
1242 if isinstance(raw_content, list): 1242 ↛ 1253line 1242 didn't jump to line 1253 because the condition on line 1242 was always true
1243 parts = []
1244 for item in raw_content:
1245 if isinstance(item, dict): 1245 ↛ 1250line 1245 didn't jump to line 1250 because the condition on line 1245 was always true
1246 if (item.get("type") == "text" or "text" in item) and isinstance( 1246 ↛ 1244line 1246 didn't jump to line 1244 because the condition on line 1246 was always true
1247 item.get("text"), str
1248 ):
1249 parts.append(item["text"])
1250 elif isinstance(item, str):
1251 parts.append(item)
1252 return "".join(parts).strip()
1253 return ""
1256class GenericCLIAdapter(Adapter):
1257 """Generic CLI adapter for arbitrary coding-agent CLIs (Aider, Goose, OpenHands, Copilot CLI, etc.).
1259 Supports configurable prompt delivery modes:
1260 - ``prompt_mode = "stdin"`` (default): prompt passed via STDIN
1261 - ``prompt_mode = "arg"``: prompt passed as positional argument on argv
1262 """
1264 def available(self) -> bool:
1265 command = self.spec.command or ""
1266 if not command: 1266 ↛ 1267line 1266 didn't jump to line 1267 because the condition on line 1266 was never true
1267 return False
1268 return shutil.which(command) is not None
1270 def detect_capabilities(self) -> dict:
1271 if not self.available():
1272 return {
1273 "version": None,
1274 "supports_headless": None,
1275 "supports_model_selection": None,
1276 "raw_version_output": "",
1277 "status": CAP_UNAVAILABLE,
1278 "warnings": [f"command '{self.spec.command}' not found on PATH"],
1279 }
1280 return {
1281 "version": "generic-cli",
1282 "supports_headless": True,
1283 "supports_model_selection": bool(self.spec.model),
1284 "raw_version_output": "generic-cli",
1285 "status": CAP_OK,
1286 "warnings": [],
1287 }
1289 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult:
1290 del phase
1291 if not self.available(): 1291 ↛ 1292line 1291 didn't jump to line 1292 because the condition on line 1291 was never true
1292 return AgentResult.failed(
1293 self.spec.name,
1294 self.spec.vendor,
1295 ERR_MISSING_CLI,
1296 f"command '{self.spec.command}' not found on PATH.",
1297 )
1298 effective_timeout = timeout if timeout is not None else self.spec.timeout
1299 extra_args = _read_only_extra_args(self.spec)
1300 argv = [self.spec.command, *extra_args]
1302 mode = (self.spec.prompt_mode or "stdin").lower()
1303 stdin_content = None
1305 if mode == "arg":
1306 argv.append(prompt)
1307 else:
1308 stdin_content = prompt
1310 start = time.monotonic()
1311 try:
1312 res = _spawn(argv, stdin_content, timeout=effective_timeout)
1313 except subprocess.TimeoutExpired:
1314 return AgentResult(
1315 self.spec.name,
1316 self.spec.vendor,
1317 False,
1318 "",
1319 effective_timeout,
1320 f"execution timed out after {effective_timeout}s.",
1321 error_code=ERR_TIMEOUT,
1322 )
1323 except Exception as exc:
1324 duration = time.monotonic() - start
1325 return AgentResult(
1326 self.spec.name,
1327 self.spec.vendor,
1328 False,
1329 "",
1330 duration,
1331 f"failed to spawn '{self.spec.command}': {redaction.redact(str(exc))[0]}",
1332 error_code=ERR_SPAWN_FAILED,
1333 )
1335 duration = time.monotonic() - start
1336 out = (res.stdout or "").strip()
1337 err = (res.stderr or "").strip()
1339 if res.returncode != 0:
1340 detail = err or out or f"exited with code {res.returncode}"
1341 safe_detail = redaction.redact(detail)[0]
1342 err_code = classify_stderr(res.returncode, err or out)
1343 return AgentResult(
1344 self.spec.name,
1345 self.spec.vendor,
1346 False,
1347 "",
1348 duration,
1349 f"exit {res.returncode}: {safe_detail[:500]}",
1350 error_code=err_code,
1351 )
1353 if not out: 1353 ↛ 1354line 1353 didn't jump to line 1354 because the condition on line 1353 was never true
1354 return AgentResult(
1355 self.spec.name,
1356 self.spec.vendor,
1357 False,
1358 "",
1359 duration,
1360 "agent produced empty output",
1361 error_code=ERR_EMPTY_OUTPUT,
1362 )
1364 return AgentResult(self.spec.name, self.spec.vendor, True, out, duration)
1367_VENDOR_ADAPTERS: dict[str, type[Adapter]] = {
1368 "anthropic": ClaudeAdapter,
1369 "openai": CodexAdapter,
1370 "google": AgyAdapter,
1371 "local": LocalAdapter,
1372 "anthropic-api": AnthropicApiAdapter,
1373 "openai-api": OpenAiApiAdapter,
1374 "google-api": GoogleApiAdapter,
1375 "openai-compatible": GenericOpenAICompatibleAdapter,
1376 "cli": GenericCLIAdapter,
1377}
1380def register_adapter(vendor: str, adapter_cls: type[Adapter]) -> None:
1381 """Register a custom adapter class for a vendor string."""
1382 _VENDOR_ADAPTERS[vendor.lower()] = adapter_cls
1385def make_adapter(spec: AgentSpec, mock: bool = False) -> Adapter:
1386 if mock:
1387 return MockAdapter(spec)
1388 cls = _VENDOR_ADAPTERS.get((spec.vendor or "").lower())
1389 if cls is not None:
1390 return cls(spec)
1391 if spec.endpoint or (spec.api_key_env and not spec.command):
1392 return GenericOpenAICompatibleAdapter(spec)
1393 if spec.command: 1393 ↛ 1395line 1393 didn't jump to line 1395 because the condition on line 1393 was always true
1394 return GenericCLIAdapter(spec)
1395 return AgyAdapter(spec)