Skip to main content

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}