differential_engine/llm.rs
1//! The model port: one-shot completion, prompt in, raw text out (ADR 0016; an
2//! engine module since ADR 0018).
3//!
4//! This file is the trait and its error, and nothing that runs anything. The
5//! one implementation — an agent CLI spawned with an argv per agent — is
6//! `llmio`, the adapter, on the pattern of `forge`/`forgeio`. The split is what
7//! lets the layering test check this file as domain: grouping and the pipeline
8//! consume only `LlmBackend` from here, and the subprocess machinery stays
9//! behind the adapter's door.
10//!
11//! The grouping stage needs exactly one capability from a model: one-shot text
12//! completion — prompt in, raw text out. The contract is deliberately that
13//! narrow: no streaming, no chat state, no conversation to manage.
14//!
15//! It stays that narrow now that the model reads for itself (ADR 0022). Tools
16//! run inside the CLI the adapter spawns, so what crosses this seam is still a
17//! prompt and a string. What changed is a flag in the adapter's argv, not the
18//! trait.
19
20use std::time::Duration;
21
22#[derive(Debug, thiserror::Error)]
23pub enum LlmError {
24 #[error("failed to spawn {command}: {source}")]
25 Spawn {
26 command: String,
27 #[source]
28 source: std::io::Error,
29 },
30
31 #[error("{command} exited with {code:?}: {stderr}")]
32 Failed {
33 command: String,
34 code: Option<i32>,
35 stderr: String,
36 },
37
38 #[error("{command} produced no output")]
39 Empty { command: String },
40
41 #[error("{command} exceeded the {timeout:?} deadline and was killed")]
42 Timeout { command: String, timeout: Duration },
43
44 #[error("{command} was cancelled and killed")]
45 Cancelled { command: String },
46
47 #[error("io error talking to {command}: {source}")]
48 Io {
49 command: String,
50 #[source]
51 source: std::io::Error,
52 },
53}
54
55/// One-shot completion: prompt in, raw text out.
56pub trait LlmBackend: Send + Sync {
57 /// What to call this agent on screen, for a reviewer waiting on it.
58 ///
59 /// A product name, not a command line: "Claude Code", not `claude -p
60 /// --output-format text --allowed-tools Bash(...),...`. The reviewer is
61 /// waiting to learn *which agent* is thinking, and the argv answers a
62 /// different question at four times the width — it overran the splash line
63 /// the moment the allowlist grew.
64 ///
65 /// The command as it will actually run is still reported where it is the
66 /// answer: `LlmError` carries it, because a spawn failure is debugged with
67 /// the whole argv and nothing less.
68 fn name(&self) -> &str;
69
70 /// Everything about this backend that could change the grouping, and
71 /// nothing that could not. The grouping cache key hashes this (ADR 0009).
72 ///
73 /// Separate from `name` because the two answer different questions. `name`
74 /// is what to show a reviewer, so it is a product name. This is what
75 /// determines the answer, so it is the argv — minus the parts that say
76 /// where this machine keeps things. Hashing a path put the absolute
77 /// location of `dfr` into the key, so a debug build, a release build and
78 /// two checkouts of one commit each re-ran a four-hundred-second call for
79 /// an identical class partition, and the worktree-shared cache
80 /// `plan::grouping_cache_dir` promises was defeated.
81 ///
82 /// Defaults to `name`, which is right for any backend whose identity has no
83 /// environment in it.
84 fn identity(&self) -> &str {
85 self.name()
86 }
87
88 fn complete(&self, prompt: &str) -> Result<String, LlmError>;
89}