cli

package
v0.17.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 1, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package cli handles detection, invocation, and version extraction for LLM CLI tools.

Index

Constants

View Source
const DefaultProbeTimeout = 10 * time.Second

DefaultProbeTimeout is the per-LLM deadline for the pre-flight probe. 10s is long enough that a healthy CLI's startup + minimal round-trip fits comfortably (claude-code is the slowest in our roster at ~3-5s on a warm cache) and short enough that a hung CLI doesn't make the readiness block itself feel like the 4-minute gemini hang we're trying to eliminate.

Public constant so doctor and future callers can use the same value as the runner — if we tune it later, every consumer updates at once instead of drifting.

View Source
const DefaultTimeoutSec = 600

DefaultTimeoutSec is the per-agent timeout in seconds used when the resolved LLMConfig has no explicit value. Single source of truth shared across the runner's applyConfig fallback, the orchestrator's RunParallel fallback, the merge-step fallback, and the roster line that shows "timeout: Ns" on the pre-run roster. Centralising prevents drift between what the user sees ("timeout: Ns") and what actually fires.

10 minutes accommodates worst-case agents (Anthropic Sonnet on a thinking model) on worst-case diffs. Users wanting shorter timeouts override per-agent via `llms.<agent>.timeout_seconds:` in `.local-review.yml`.

Variables

View Source
var CanonicalAPIKeyEnv = map[string]string{
	"claude": "ANTHROPIC_API_KEY",
	"gemini": "GEMINI_API_KEY",
	"codex":  "OPENAI_API_KEY",

	"copilot": "COPILOT_GITHUB_TOKEN",
}

CanonicalAPIKeyEnv is the env var each CLI itself reads to find its API key. Doctor uses these as the default check when the user hasn't configured a custom APIKeyEnv; invokers use them as the injection target when the user *has* (so a key in $MY_GEMINI_KEY still ends up as $GEMINI_API_KEY for the gemini subprocess).

View Source
var GeminiSunsetDate = time.Date(2026, time.June, 18, 0, 0, 0, 0, time.UTC)

GeminiSunsetDate is the day after which Google stops serving the Gemini CLI's Pro / Ultra / free-tier requests. Announced 2026-04 as part of the Antigravity (`agy`) succession.

Hard-coded constant so the auto-disable behaviour is auditable without scraping a remote URL at startup (privacy / offline-first constraint) and survives a long-lived binary install across the cutoff. When Google moves the date, bump this constant.

UTC midnight. The sunset is treated as "any moment ON or AFTER this instant" — i.e. once `time.Now().UTC()` is at-or-past this point, gemini is treated as removed (unless force_after_sunset is set; see LLMConfig.ForceAfterSunset).

Functions

func AgentSunsetDate added in v0.15.0

func AgentSunsetDate(name string) time.Time

AgentSunsetDate returns the manufacturer-announced sunset date for an agent, or the zero time when no sunset is known. Today only gemini has one; this indirection exists so adding a future sunset (or removing gemini's once the cutoff passes) is a one-line edit here instead of a grep across detector + doctor + runner.

func ClassifyExit added in v0.6.3

func ClassifyExit(ctx context.Context, err error, combinedOutput []byte, agent string) string

ClassifyExit produces a short, user-facing summary of why a CLI subprocess failed, suitable for the per-LLM line:

[1/3] claude ✗ <ClassifyExit output>

Every classification that we recognise ends with an *actionable* next step — "try a smaller diff", "raise timeout_sec", etc. — so a user staring at a failure has a path forward without diving into stderr or strace. Pre-fix the failure line was the bare wrapped error ("claude review failed: signal: killed (output: )"), which reliably triggered "delete the tool" frustration.

agent is the LLM name ("claude", "gemini", "codex") — embedded into hints that reference per-agent config (e.g. `llms.<agent>.timeout_seconds`). The YAML key is `timeout_seconds` (see internal/config/config.go LLMConfig struct tag). A previous version of this hint said `timeout_sec` — wrong, and would silently leave the user's config untouched if they pasted it.

func ContextWindow added in v0.6.5

func ContextWindow(agent string) int

ContextWindow returns the conservative per-LLM context window we preflight against, in tokens. Values are the smallest current- stable model in each vendor's lineup so we never falsely pass a diff that would 4xx on a smaller model.

Why a static table instead of probing the CLI: each CLI exposes model context differently (or not at all), and the values change monthly. A small table here, conservative by design, fails-safe if a vendor *shrinks* a context window (rare but happens during rate-limit shifts) at the cost of over-skipping when a vendor *grows* one. Users can override per-agent via `llms.<agent>.context_window:` once that config field lands.

Returns 0 for unknown agents — caller should treat that as "don't preflight, let the agent decide" so a future agent type doesn't get silently filtered.

func DaysUntilAgentSunset added in v0.15.0

func DaysUntilAgentSunset(name string, now time.Time) int

DaysUntilAgentSunset returns the integer number of days between `now` and the agent's sunset date, using *ceiling* semantics for positive durations: any remaining time before the sunset rounds up to at least 1, so the doctor countdown never reads "0 days until sunset" while time still remains. Once `now` is at or past the sunset (IsAgentSunset == true), the return value is 0 or negative — caller uses IsAgentSunset to switch banner modes, this function just supplies the magnitude.

Pre-v0.15 the math was a floor (`int(hours / 24)`), which read like a boundary bug for the last ~24h before cutoff. Self-review caught this on PR 2/4.

Returns noSunsetSentinel (a distinctly impossible value) when the agent has no sunset; callers can use IsAgentSunset or AgentSunsetDate to distinguish "no sunset known" from "sunset just happened" if they need to.

func EstimatePromptPayload added in v0.6.5

func EstimatePromptPayload(systemPrompt, diff string) int

EstimatePromptPayload returns an upper-bound token estimate for the exact bytes the invokers ship to the CLI: the wrapped system prompt + separator + diff. Using this — rather than estimating systemPrompt + diff in isolation — prevents preflight from passing an agent that will then 4xx because the actual sent payload was bigger than what we measured.

func EstimateTokens added in v0.6.5

func EstimateTokens(s string) int

EstimateTokens approximates the token count of s using a fixed byte-to-token ratio. Returns a true upper bound (math.Ceil over the byte/3.5 division) so preflight biases toward skipping rather than feeding an oversized prompt to a model that will 4xx.

We don't pull in a real tokeniser (tiktoken, sentencepiece): they require SDK dependencies we deliberately avoid (vendor lock-in, binary bloat) and the preflight only needs an upper bound to decide "fit or skip". Off-by-20% in the conservative direction is fine.

func FormatSkipReason added in v0.6.5

func FormatSkipReason(s SkippedAgent) string

FormatSkipReason returns the user-facing one-liner explaining why an agent was preflight-skipped, with the same actionable-hint shape as ClassifyExit's failure messages: numbers + concrete fix.

"prompt+diff" rather than "diff" because the estimate covers the full payload (system prompt + glue + diff) — saying only "diff is X tokens" misled users into thinking the raw diff alone exceeded the context window. Both the prompt+diff total AND the response-reservation appear because the gate is "payload + margin > window" — saying only "payload exceeds window" is factually wrong near the limit.

func IsAgentSunset added in v0.15.0

func IsAgentSunset(name string, now time.Time) bool

IsAgentSunset reports whether the agent's sunset date has passed relative to `now`. `now` is a parameter so tests can inject a fixed clock — production callers pass `time.Now().UTC()`.

Returns false for agents with no sunset (every agent except gemini today).

func IsReviewCapable added in v0.11.0

func IsReviewCapable(name string) bool

IsReviewCapable reports whether a detected LLM may join the review fan-out. Detection (binary present + authenticated) is necessary but not sufficient: an experimental CLI can be detected yet excluded — see experimentalLLMs.

func PreflightFilter added in v0.6.5

func PreflightFilter(active []LLM, systemPrompt, diff string) (kept []LLM, skipped []SkippedAgent, promptDiffTokens int)

PreflightFilter takes the active agent list and the system-prompt + diff that will be sent to each agent, returns the subset that fits in each agent's context window plus a list of skipped agents with their numbers.

The filter is purely based on length — we don't know the user's model precisely (CLIs may pick their own default) so we use the conservative-floor table from ContextWindow. An agent whose name isn't in the table is passed through unchanged ("don't preflight what we don't know").

Returns the filtered active list, the skipped-agent details, and the prompt+diff token estimate (useful for the caller's summary line on the all-skipped path).

func SkipSummary added in v0.6.5

func SkipSummary(skipped []SkippedAgent) string

SkipSummary returns a multi-line summary block listing every skipped agent and what the user should do. Empty string when none were skipped.

func SplitReady added in v0.10.1

func SplitReady(results []ProbeResult) (ready, notReady []string)

SplitReady partitions a ProbeResult slice into "ready" and "not ready" subsets, preserving order within each subset. Used by the runner to filter the active LLM list to the ones that passed pre-flight, while keeping the not-ready ones around for rendering the readiness block.

Operates on names so the caller can correlate with their own LLM slice without a second lookup. Returned slices are nil when empty (Go idiom) — callers should `len(...)`-check rather than `!= nil`-check.

Types

type AntigravityInvoker added in v0.11.0

type AntigravityInvoker struct {
	// contains filtered or unexported fields
}

AntigravityInvoker runs Google's Antigravity CLI (`agy`), the successor to the Gemini CLI. Google announced (2026-05) that the Gemini CLI stops serving Pro/Ultra/free-tier requests on 2026-06-18, with Antigravity as the replacement.

NOT WIRED INTO THE REVIEW FAN-OUT (cli.IsReviewCapable("antigravity") == false). The 2026-05 authenticated dogfood found agy's `--print` mode runs a full autonomous agent loop: it explores the repo, runs git/python, reconstructs its OWN diff (ignoring the one we hand it), and streams tool-step narration ("I will run git diff…") to stdout rather than a clean review. A real `local-review review --only antigravity` produced 6.5 KB of narration, zero findings, and an empty merged report. So agy is detected + surfaced in `doctor` (statusExperimental) but excluded from the active set. This invoker is retained as the starting point for a future structured-output integration; until then it's only reachable via NewInvoker for the type/interface tests, never from a real review run.

Three ways agy differs from the other invokers, all confirmed by probing `agy --help` / `agy --version` on v1.0.2:

  1. The prompt is a POSITIONAL ARG to `-p`, not stdin. agy's `-p` flag "needs an argument" — piping a prompt to it errors. So unlike claude/gemini/codex (all stdin-fed), agy gets the whole prompt+diff on argv. macOS ARG_MAX is 1 MiB; run() guards with an explicit 256 KiB ceiling so an oversized prompt fails with a clear message instead of a cryptic "argument list too long" from exec. (In real review runs PreflightFilter would cap prompt size first, but agy is excluded from the fan-out, so that backstop never runs here — hence the in-invoker guard.)

  2. No structured-output flag. agy has no `-o json` / `--output-format` (verified against `agy --help` v1.0.2), so there's nothing to parse token usage from — RunPrompt returns TokenUsage{}. Display callers already handle the zero case ("we couldn't determine usage"); agy reviews render without a token count, same as codex pre-v0.128.

  3. `--dangerously-skip-permissions` is passed so the headless call doesn't hang on a tool-permission prompt. Our prompt is self-contained (the diff is in the prompt text; agy doesn't need filesystem tools to review it), but agy is agentic and may still try to read files / run tools unless told to auto-approve. Without the flag a `-p` run can block forever waiting on a permission prompt that has no TTY to answer it.

Auth is OAuth (Google login via `agy`), like claude — there's no API-key env var, so CanonicalAPIKeyEnv has no "antigravity" entry and apiKey is unused here. An unauthenticated agy surfaces "Authentication required. Please visit the URL..." which the pre-flight probe captures and renders as the readiness-block reason; no special-casing needed.

func (*AntigravityInvoker) PartialStderr added in v0.11.0

func (p *AntigravityInvoker) PartialStderr() string

PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).

func (*AntigravityInvoker) Review added in v0.11.0

func (a *AntigravityInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)

func (*AntigravityInvoker) RunPrompt added in v0.11.0

func (a *AntigravityInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)

type ClaudeInvoker

type ClaudeInvoker struct {
	// contains filtered or unexported fields
}

ClaudeInvoker runs the Anthropic Claude CLI. Uses stdin pipe similar to Gemini.

func (*ClaudeInvoker) PartialStderr added in v0.10.6

func (p *ClaudeInvoker) PartialStderr() string

PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).

func (*ClaudeInvoker) Review

func (c *ClaudeInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)

func (*ClaudeInvoker) RunPrompt

func (c *ClaudeInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)

type CodexInvoker

type CodexInvoker struct {
	// contains filtered or unexported fields
}

CodexInvoker runs the OpenAI Codex CLI.

Bare `codex` (no subcommand) opens an interactive TUI — that's what the pre-v0.5.1 invoker was doing, which is why every codex review failed with `exit status 1`. We use `codex exec` (non-interactive), pipe the prompt over stdin, and have codex write only the final assistant message to a temp file via --output-last-message. That sidesteps both the interactive-TUI failure AND the noisy "session id / tokens used" preamble that codex exec normally prints to stdout.

We deliberately don't use `codex review` (the dedicated review subcommand) because it re-extracts the diff itself from the local git tree, conflicting with the orchestrator's "extract once, fan out to all LLMs with the same diff string" contract.

func (*CodexInvoker) PartialStderr added in v0.10.6

func (p *CodexInvoker) PartialStderr() string

PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).

func (*CodexInvoker) Review

func (c *CodexInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)

func (*CodexInvoker) RunPrompt

func (c *CodexInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)

type CopilotInvoker added in v0.12.0

type CopilotInvoker struct {
	// contains filtered or unexported fields
}

CopilotInvoker runs the GitHub Copilot CLI (`copilot`), GitHub's agentic coding assistant, in non-interactive mode as a review agent.

Unlike Antigravity — the other agentic CLI we evaluated, gated out because its `--print` emits agent narration instead of a review — Copilot's `-p` mode returns a clean answer: the review prose goes to STDOUT while agent/usage telemetry (MCP status, "Requests N Premium", the token summary) goes to STDERR. The 2026-05 dogfood confirmed it reviews the diff it's handed (`Changes +0 -0` — it does NOT reconstruct its own) and catches planted bugs, so it joins the active fan-out as a first-class agent.

Invocation specifics (verified against `copilot --help` v1.0.54):

  • `-p, --prompt <text>` runs one prompt non-interactively. The prompt is a POSITIONAL value to the flag (not stdin), so it rides on argv — run() guards against ARG_MAX, with PreflightFilter as the upstream cap.
  • `--available-tools=` (empty whitelist) disables ALL tools. This is the security boundary: the diff in the prompt is untrusted, so we must NOT let a prompt-injecting diff drive Copilot's shell/write/url tools. With no tools available there's nothing to approve, so non-interactive mode needs no `--allow-all-tools` and can't hang on a permission prompt. See run() for the full note.
  • `--no-ask-user` stops the agent from blocking on a clarifying question in non-interactive mode.
  • `--no-color` strips ANSI so the captured stdout is clean markdown.
  • `--model <id>` selects the model (e.g. gpt-5.3-codex); empty = CLI default.

Auth: a `copilot login` session stored under ~/.copilot (or $COPILOT_HOME), OR a token in COPILOT_GITHUB_TOKEN. We inject the resolved apiKey (when configured) as COPILOT_GITHUB_TOKEN. The copilot CLI also reads GH_TOKEN / GITHUB_TOKEN, but doctor will NOT auto-enable Copilot on those generic tokens (see checkCopilotAuth) — they're too common in CI to safely activate a paid reviewer.

Cost: each run consumes a Copilot "Premium request" from the user's subscription — it is NOT BYOK-free like a Gemini API key. The token summary on stderr is vendor-rounded; parseCopilotStderrTokens reads it best-effort and degrades to zero usage if the format shifts.

func (*CopilotInvoker) PartialStderr added in v0.12.0

func (p *CopilotInvoker) PartialStderr() string

PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).

func (*CopilotInvoker) Review added in v0.12.0

func (c *CopilotInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)

func (*CopilotInvoker) RunPrompt added in v0.12.0

func (c *CopilotInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)

type GeminiInvoker

type GeminiInvoker struct {
	// contains filtered or unexported fields
}

GeminiInvoker runs the Google Gemini CLI. Uses: git diff | gemini -p "Review these changes for bugs and security issues"

func (*GeminiInvoker) PartialStderr added in v0.10.6

func (p *GeminiInvoker) PartialStderr() string

PartialStderr implements PartialStderrCapturer for any invoker that embeds partialStderrField. Returns whatever's been buffered from the most-recent subprocess invocation, or "" if no run has started yet (capture pointer is still nil).

func (*GeminiInvoker) Review

func (g *GeminiInvoker) Review(ctx context.Context, systemPrompt, diff string) (string, TokenUsage, error)

func (*GeminiInvoker) RunPrompt

func (g *GeminiInvoker) RunPrompt(ctx context.Context, prompt string) (string, TokenUsage, error)

type Invoker

type Invoker = agents.Invoker

Invoker is the review-agent contract. Moved to internal/agents in v0.14 so the HTTP provider invoker (internal/agents/provider) could share it without depending on this CLI-subprocess package. CLI callers continue to use cli.Invoker unchanged via this alias.

See agents.Invoker for the full contract; the CLI subprocess invokers below all satisfy it (they shell out to the vendor CLI, parse its structured output for tokens, and return markdown or JSON depending on what the resolved system prompt asked for).

func NewInvoker

func NewInvoker(llm LLM) Invoker

NewInvoker creates an invoker for the given LLM. The Model and APIKey fields on LLM are threaded into each invoker so per-agent --claude-model / --gemini-model / --codex-model flag overrides actually reach the CLI command line, and so a key sourced from a user-named env var (cfg.LLMs[name].APIKeyEnv) still reaches the subprocess under the canonical name the CLI itself expects. An empty Model leaves the CLI on its default; an empty APIKey means "rely on the CLI's own auth flow / OAuth session."

Returns nil if the LLM name is unknown (for CLI agents) — provider agents (BaseURL set) are always recognised, so they never return nil here regardless of name.

type LLM

type LLM struct {
	// Name is the agent's identifier. CLI agents use the well-known
	// keys "claude" / "gemini" / "codex" / "antigravity" / "copilot".
	// Provider agents (BaseURL set) use the free-form user-chosen name
	// from cfg.LLMs (e.g. "qwen", "local-fast", "air-gapped").
	Name    string
	Path    string // full path to the binary (e.g., "/usr/local/bin/claude")
	Version string // version string (e.g., "2.1.0"), or "unknown" if version probe failed
	// Available is true only when both the binary is in PATH AND the
	// `--version` probe returned a parseable string. A binary that
	// resolves but whose version probe fails (broken symlink, corrupted
	// install, missing runtime) is reported Available=false so callers
	// don't try to invoke an unusable tool.
	Available bool
	// Model is the agent-specific model id (e.g., "claude-opus-4-7",
	// "gemini-2.0-flash", "gpt-5"). Threaded through from cfg.LLMs[*]
	// .Model so per-agent model overrides on the runner actually reach
	// the invoker — pre-fix the field was set in config and printed in
	// the roster but the invoker only got Path, so users got false
	// confirmation that the requested model ran.
	Model string
	// APIKey is the resolved API-key value sourced from
	// cfg.LLMs[name].APIKeyEnv (or the legacy hard-coded env var when
	// APIKeyEnv is empty). The invoker injects it into the subprocess
	// env under the *canonical* variable name each CLI expects
	// (ANTHROPIC_API_KEY / GEMINI_API_KEY / OPENAI_API_KEY) so users
	// can stash the key under any env name they like and the agent
	// still authenticates.
	APIKey string
	// BaseURL is the OpenAI-compatible HTTP endpoint for a *provider*
	// agent (Ollama, vLLM, any /v1/chat/completions endpoint). Empty
	// for CLI subprocess agents — that's the kind-discriminator across
	// the codebase: BaseURL != "" → provider; else CLI. cli.NewInvoker
	// dispatches on it, doctor renders a different row shape for it,
	// and the probe layer picks an HTTP vs subprocess probe accordingly.
	// Populated by DetectProviders (this package) from the matching
	// cfg.LLMs[name].BaseURL entry.
	BaseURL string
	// APIKeyEnv is the NAME of the env var the provider key was sourced
	// from (cfg.LLMs[name].APIKeyEnv), threaded through so an auth-miss
	// error names the variable the user actually configured rather than
	// a generic/removed default. Only meaningful for provider agents;
	// CLI agents inject under CanonicalAPIKeyEnv instead.
	APIKeyEnv string
	// TimeoutSec is the per-call timeout (from config).
	TimeoutSec int
}

LLM represents a detected CLI tool with its metadata.

func Detect

func Detect(name string) LLM

Detect checks if a specific LLM CLI is installed and returns its metadata. Available is true only if the binary is found AND its version probe succeeded; see the LLM.Available field doc for the precise contract.

func DetectAll

func DetectAll() []LLM

DetectAll checks for all supported LLM CLIs and returns their status. Returns a slice of LLM structs, one per supported CLI (even if not found). Runs detections concurrently to avoid sequential timeouts.

Equivalent to DetectAllWithOverrides(nil) — kept for callers that don't need cli_path overrides (e.g. tests).

func DetectAllWithOverrides added in v0.6.0

func DetectAllWithOverrides(overrides map[string]string) []LLM

DetectAllWithOverrides is DetectAll plus per-LLM cli_path overrides from config (cfg.LLMs[name].CLIPath). An override may be an absolute path (`/opt/corporate/bin/claude`) or a bare binary name; exec.LookPath handles both — anything with a slash bypasses $PATH per Go's docs.

Pre-fix the orchestrator detected only the hardcoded `claude` / `gemini` / `codex` binary names, so the cli_path field shipped in every example config was inert. Users with corporate installs at non-standard paths got "✗ not installed" with no path-override escape hatch.

func DetectProviders added in v0.14.0

func DetectProviders(ctx context.Context, specs []ProviderSpec) []LLM

DetectProviders runs an HTTP readiness probe per provider spec and returns one LLM per spec with Available set according to the probe outcome. Probes run in parallel — the same way DetectAll fans out the CLI version probes — so N providers don't serialise into N timeouts.

Available reflects ONLY endpoint reachability + auth-acceptance at /v1/models. It does NOT confirm the configured Model is loaded — that's the job of the strict probe (PR 3 wires --strict-probe), the pre-flight in the runner, or, ultimately, the real review call.

Returns an empty slice when specs is empty; never returns nil.

type PartialStderrCapturer added in v0.10.6

type PartialStderrCapturer interface {
	PartialStderr() string
}

PartialStderrCapturer is an OPTIONAL interface invokers can implement to expose whatever's been written to the subprocess's stderr so far — without waiting for the subprocess to actually exit. Used by Probe to surface the vendor's diagnostic line (e.g. "You have exhausted your capacity on this model.") in the readiness block when a probe times out, instead of the generic "timeout after 10s" we'd otherwise have to print because the subprocess is hung past SIGKILL on pipe drain.

The current production invokers (Claude / Gemini / Codex) all implement this — they buffer up to 4 KiB of stderr via a stderrCapture written in parallel to the normal cmd.Stderr destination. The interface is optional so tests + future invokers can opt out by simply not implementing it; Probe falls back to the generic timeout text in that case.

type ProbeResult added in v0.10.1

type ProbeResult struct {
	LLM      string
	Status   ProbeStatus
	Err      error         // populated iff Status != ProbeReady
	Duration time.Duration // wall-clock of the probe call
}

ProbeResult is the per-LLM outcome of a pre-flight readiness probe. Symmetric with multi.ReviewResult in that we always emit one of these per LLM (even on failure) so callers can render a complete readiness block without back-tracking.

func Probe added in v0.10.1

func Probe(ctx context.Context, inv Invoker, name string) ProbeResult

Probe invokes inv with a tiny "reply OK" prompt and returns the outcome. The caller owns the context's deadline: this function does no inner timeout management so there's a single source of truth for "how long do we wait before declaring an LLM unresponsive" (the per-LLM ctx deadline set by ProbeAll, or by a test passing context.WithTimeout directly).

name is plumbed through to ProbeResult.LLM so the caller can build the readiness block without re-deriving identity from the invoker (Invoker is an interface and doesn't expose name).

func ProbeAll added in v0.10.1

func ProbeAll(ctx context.Context, llms []LLM, perLLMTimeout time.Duration, strict bool) []ProbeResult

ProbeAll runs Probe on each LLM in parallel and returns the results in roster order. Each LLM gets its own context with perLLMTimeout — slow LLMs don't hold up faster ones, and a hung CLI on one agent doesn't block the entire readiness block (which was the v0.10.0-RC observed failure: gemini's exhausted-capacity error surfacing after a ~4 min hang while claude and codex sat idle waiting for it).

Returns one ProbeResult per input LLM, indexed by roster position (not completion order, unlike orchestrator.RunParallel). Roster order matches the readiness block's left-to-right layout in the runner; reordering by completion would make the block non-deterministic across runs, hurting readability when users compare two runs side by side.

invokerFactory is an unexported test seam (see ProbeAllWithInvokerFactory). Production callers use ProbeAll which wires in cli.NewInvoker. strict toggles how provider agents are probed:

  • false (default): provider agents use a cheap HTTP `GET /v1/models` check — proves the endpoint is up and auth is accepted without loading the model or burning tokens. Right default for the common "is my Ollama tailnet box reachable?" question. CLI agents always use the existing `Reply OK` subprocess probe regardless (they have no lighter alternative).

  • true: every agent (provider AND CLI) does the full `Reply OK` via the Invoker contract — for providers that's an actual `POST /v1/chat/completions` exercising the named model, catching "endpoint up but model not loaded" cases the light probe misses. Useful when the configured model id matters; surfaced via the `--strict-probe` flag.

func ProbeAllWithInvokerFactory added in v0.10.1

func ProbeAllWithInvokerFactory(ctx context.Context, llms []LLM, perLLMTimeout time.Duration, factory func(LLM) Invoker, providerProbeFn ProviderProbeFunc, strict bool) []ProbeResult

ProbeAllWithInvokerFactory is the test-seam variant of ProbeAll. Production code calls ProbeAll; this is exported only so the runner's tests can substitute fake invokers (the same pattern internal/multi.Orchestrator uses for its unexported invokerFactory field). Kept as a public function rather than a struct field so callers don't have to construct a Prober type for a single override — the seam is the function pointer itself.

See ProbeAll for the `strict` semantics.

func (ProbeResult) IsReady added in v0.10.1

func (r ProbeResult) IsReady() bool

IsReady is a named predicate so call sites don't litter the runner with `r.Status == cli.ProbeReady` comparisons.

type ProbeStatus added in v0.10.1

type ProbeStatus int

ProbeStatus is the outcome of a pre-flight readiness probe.

Distinct from a per-LLM review error because the user-facing rendering differs: a probe failure prints a one-character marker (✗) in the readiness block and gets a single-line error summary, not the multi-line stderr-tail treatment a real-review failure gets. Conflating the two would surface noise in the readiness block (which is meant to read at a glance) and hide auth-vs- capacity-vs-network distinctions a user needs to act on.

const (
	// ProbeReady: the CLI returned a non-empty response within the
	// per-LLM timeout window. The LLM is fit to participate in the
	// real review run.
	ProbeReady ProbeStatus = iota

	// ProbeError: the invoker returned an error other than a context
	// deadline — typically auth-not-ready, capacity exhausted on the
	// model, or network/connectivity. The error message carries the
	// vendor's own string (ClassifyExit'd) so the user sees what to
	// fix without re-running the real review.
	ProbeError

	// ProbeTimeout: the probe context's deadline expired before the
	// CLI replied. Distinct from ProbeError because it usually means
	// "vendor SLOW today" rather than "permanently broken" — the
	// real-review timeout is much longer and might still complete.
	// Treated the same as ProbeError for run-or-skip purposes; the
	// distinct status exists so the readiness block can render a
	// clearer "(timeout after Ns)" hint instead of a generic
	// error string.
	ProbeTimeout

	// ProbeCanceled: the parent context was canceled — typically
	// the user pressing Ctrl+C, OR a parent timeout firing across
	// the whole run rather than the per-LLM probe budget. Distinct
	// from ProbeTimeout because the user-facing message is different
	// ("user interrupt" vs "vendor slow today") and the runner's
	// post-probe error handling needs to short-circuit on cancel
	// rather than complain about "every LLM failed pre-flight"
	// when the real cause was a deliberate stop signal.
	ProbeCanceled
)

func (ProbeStatus) String added in v0.10.1

func (s ProbeStatus) String() string

String returns a one-word identifier suitable for rendering / logging. The readiness block uses ✓/✗ glyphs at the call site; this is for log lines and tests.

type ProviderProbeFunc added in v0.14.0

type ProviderProbeFunc func(ctx context.Context, baseURL, apiKey string, timeout time.Duration) error

ProviderProbeFunc is the seam shape ProbeAllWithInvokerFactory uses to drive the cheap HTTP /v1/models check. Production code passes providerProbe (which adapts internal/agents/provider.Probe); tests pass deterministic fakes. Per-call (not a package global) so tests can run in parallel without seam reassignment races.

type ProviderSpec added in v0.14.0

type ProviderSpec struct {
	Name    string
	BaseURL string
	Model   string
	APIKey  string
	// APIKeyEnv is the NAME of the env var APIKey was resolved from
	// (cfg.LLMs[name].APIKeyEnv). Carried through so an auth-miss error
	// can name the variable the user configured. Empty for keyless
	// (local/LAN) providers.
	APIKeyEnv  string
	TimeoutSec int
}

ProviderSpec is the minimal config-derived shape DetectProviders needs per agent. The caller (cmd/local-review's pickAgents) builds these from cfg.LLMs[name] entries that have a BaseURL — that's the kind discriminator. APIKey is the already-resolved key (from config.resolveAPIKeys' env-var lookup); empty means "no auth configured" which the HTTP layer's isLocalURL bypass tolerates for local-or-LAN endpoints.

Why a separate spec type (vs taking config.Config directly): keeps this package free of an import on internal/config and matches the shape DetectAllWithOverrides already uses — caller owns the config vocabulary, this package owns the runtime-agent vocabulary.

type SkippedAgent added in v0.6.5

type SkippedAgent struct {
	Name             string
	PromptDiffTokens int
	ContextWindow    int
}

SkippedAgent describes one agent that was preflight-skipped, with enough detail for the user to understand why and what to do about it.

PromptDiffTokens is the upper-bound estimate of the *combined* payload sent to the CLI: the system prompt (after wrapping by buildReviewPrompt) plus the diff, with the "# Diff" header glue included. Named "PromptDiffTokens" rather than "EstimatedTokens" so the user-facing message can't accidentally claim "diff is X tokens" when X is actually prompt+diff.

type TokenUsage added in v0.6.6

type TokenUsage = agents.TokenUsage

TokenUsage is an alias for agents.TokenUsage. The type was moved to internal/agents in v0.14 so the HTTP provider invoker (internal/agents/provider) could share the same shape without depending on this CLI-subprocess package. CLI callers continue to use cli.TokenUsage unchanged via this alias; the methods (IsZero, Total) come along with the alias automatically. See agents.TokenUsage for the full doc.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL