Skip to main content

differential_engine/
llm.rs

1//! LLM backend abstraction (ADR 0016; an engine module since ADR 0018).
2//!
3//! Nothing else in the engine may reach into subprocess machinery: grouping
4//! and the pipeline consume only `LlmBackend`/`CommandBackend` from here.
5//!
6//! The grouping stage needs exactly one capability from a model: one-shot text
7//! completion — prompt in, raw text out. The contract is deliberately that
8//! narrow: no streaming, no chat state, no conversation to manage.
9//!
10//! It stays that narrow now that the model reads for itself (ADR 0022). Tools
11//! run inside the CLI this spawns, so what crosses this seam is still a prompt
12//! and a string. What changed is a flag in the argv below, not the trait.
13
14use std::io::Write;
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::time::Duration;
20
21#[derive(Debug, thiserror::Error)]
22pub enum LlmError {
23    #[error("failed to spawn {command}: {source}")]
24    Spawn {
25        command: String,
26        #[source]
27        source: std::io::Error,
28    },
29
30    #[error("{command} exited with {code:?}: {stderr}")]
31    Failed {
32        command: String,
33        code: Option<i32>,
34        stderr: String,
35    },
36
37    #[error("{command} produced no output")]
38    Empty { command: String },
39
40    #[error("{command} exceeded the {timeout:?} deadline and was killed")]
41    Timeout { command: String, timeout: Duration },
42
43    #[error("{command} was cancelled and killed")]
44    Cancelled { command: String },
45
46    #[error("io error talking to {command}: {source}")]
47    Io {
48        command: String,
49        #[source]
50        source: std::io::Error,
51    },
52}
53
54/// One-shot completion: prompt in, raw text out.
55pub trait LlmBackend: Send + Sync {
56    /// What to call this agent on screen, for a reviewer waiting on it.
57    ///
58    /// A product name, not a command line: "Claude Code", not `claude -p
59    /// --output-format text --allowed-tools Bash(...),...`. The reviewer is
60    /// waiting to learn *which agent* is thinking, and the argv answers a
61    /// different question at four times the width — it overran the splash line
62    /// the moment the allowlist grew.
63    ///
64    /// The command as it will actually run is still reported where it is the
65    /// answer: `LlmError` carries it, because a spawn failure is debugged with
66    /// the whole argv and nothing less.
67    fn name(&self) -> &str;
68
69    /// Everything about this backend that could change the grouping, and
70    /// nothing that could not. The grouping cache key hashes this (ADR 0009).
71    ///
72    /// Separate from `name` because the two answer different questions. `name`
73    /// is what to show a reviewer, so it is a product name. This is what
74    /// determines the answer, so it is the argv — minus the parts that say
75    /// where this machine keeps things. Hashing a path put the absolute
76    /// location of `dfr` into the key, so a debug build, a release build and
77    /// two checkouts of one commit each re-ran a four-hundred-second call for
78    /// an identical class partition, and the worktree-shared cache
79    /// `plan::grouping_cache_dir` promises was defeated.
80    ///
81    /// Defaults to `name`, which is right for any backend whose identity has no
82    /// environment in it.
83    fn identity(&self) -> &str {
84        self.name()
85    }
86
87    fn complete(&self, prompt: &str) -> Result<String, LlmError>;
88}
89
90/// A subprocess backend: prompt on stdin, completion on stdout.
91pub struct CommandBackend {
92    argv: Vec<String>,
93    timeout: Duration,
94    /// What a reviewer is shown: see [`LlmBackend::name`].
95    name: String,
96    /// The argv as it will actually run, for error text only. A spawn failure
97    /// is debugged with the whole command, and neither `name` nor `identity`
98    /// is that: one is a product name, the other stands a placeholder where
99    /// the executable's path was.
100    command: String,
101    /// See [`LlmBackend::identity`].
102    identity: String,
103    /// Where the child runs.
104    ///
105    /// The prompt hands the model `git diff <base> <head> -- <path>` with paths
106    /// as the document records them, which is relative to the repository root.
107    /// Git resolves a bare pathspec against the **current directory**, not the
108    /// root, so a child inheriting `dfr`'s cwd matches nothing whenever `dfr`
109    /// was run from a subdirectory — and matching nothing is an empty diff and
110    /// exit 0, not an error. The model would then rate a class having seen no
111    /// diff at all, and nothing anywhere would say so.
112    ///
113    /// `None` means inherit, which is right for a child that reads no repository
114    /// (the tests here, and any future backend that takes its whole input on
115    /// stdin).
116    working_dir: Option<PathBuf>,
117    /// Set from another thread to kill an in-flight child (a reviewer
118    /// abandoning the wait). Without this the subprocess would outlive the
119    /// process that asked for it, up to the whole timeout.
120    cancel: Option<Arc<AtomicBool>>,
121}
122
123impl CommandBackend {
124    /// A backend named by its own command line.
125    ///
126    /// The named constructors below are the production path; this is for a
127    /// backend with nothing better to call itself, which in practice means a
128    /// test double.
129    pub fn new(argv: Vec<String>, timeout: Duration) -> Self {
130        assert!(!argv.is_empty(), "CommandBackend needs a program to run");
131        let command = argv.join(" ");
132        CommandBackend {
133            argv,
134            timeout,
135            name: command.clone(),
136            identity: command.clone(),
137            command,
138            working_dir: None,
139            cancel: None,
140        }
141    }
142
143    /// Run the child in `dir`.
144    ///
145    /// The repository root, for any backend whose prompt names repo-relative
146    /// paths — which the default one does. See the field for what goes wrong
147    /// without it, and why it goes wrong silently.
148    pub fn with_working_dir(mut self, dir: &Path) -> Self {
149        self.working_dir = Some(dir.to_path_buf());
150        self
151    }
152
153    /// Kill the child as soon as `flag` is set.
154    pub fn with_cancel(mut self, flag: Arc<AtomicBool>) -> Self {
155        self.cancel = Some(flag);
156        self
157    }
158
159    pub fn with_timeout(mut self, timeout: Duration) -> Self {
160        self.timeout = timeout;
161        self
162    }
163
164    fn cancelled(&self) -> bool {
165        self.cancel
166            .as_ref()
167            .is_some_and(|c| c.load(Ordering::Relaxed))
168    }
169
170    /// The default: headless, text output, and read-only tools (ADR 0022).
171    ///
172    /// ADR 0010 denied tools outright, because the evaluated grouping tool kept
173    /// exiting 1 on `stop_reason: "tool_use"`. Denying them cured it by sending
174    /// no tool definitions at all, so the model could not ask. An allowlist is
175    /// the other cure: it can ask, and the answer is yes.
176    ///
177    /// `fetch` is the executable the prompt tells the model to run — normally
178    /// this process. The allowlist is derived from it, so the two cannot
179    /// disagree about what the model is allowed to invoke.
180    ///
181    /// Nothing here can write. The fetch command reads the document the engine
182    /// just wrote; the rest read the repository. `git log` and `git show` are
183    /// what reach the *reason* a change was made, which no prompt can carry.
184    ///
185    /// **`git diff` is advertised; the rest are not.** The prompt names the
186    /// fetch command and `git diff`, and nothing else.
187    ///
188    /// That is a change of rule, and it is worth saying why. `git diff` is
189    /// advertised because it is now the only way to see what a hunk says: the
190    /// fetch command's `diff` query is gone, having duplicated `class` except
191    /// for the text. A tool the model must use and is not told about is a tool
192    /// it will not use.
193    ///
194    /// It costs an invitation to read the whole repository, and the prompt is
195    /// what pays for that: it says to read what decides a label and then stop.
196    ///
197    /// It no longer costs a route around the generated content this stage folds
198    /// away, though it did when it was written. `generated` is part of the
199    /// shape-class key now (ADR 0004), so no class the model is given contains
200    /// a generated file and there is nothing folded left for it to ask
201    /// `git diff` about by accident. The prompt still says not to go looking.
202    ///
203    /// `Read`, `Grep`, `Glob`, `git log` and `git show` stay unadvertised for
204    /// the original reason: a model that needs the code around a hunk can go
205    /// and read it, but it is not sent looking. If you add a tool here, do not
206    /// add a line about it to the prompt.
207    ///
208    /// The allowlist is this function's business, not the user's, and there is
209    /// no config that replaces it. `[grouping].agent` picks between agents by
210    /// name; it used to take a free argv, which handed a stranger's process the
211    /// prompt and none of the allowlist, fetch command or read path the prompt
212    /// is written for.
213    ///
214    /// `fetch` is where a binary lives, so it is the one part of this argv that
215    /// says nothing about what the model will do. The cache identity stands a
216    /// placeholder in its place: change the allowlist and every cached grouping
217    /// is rightly invalidated, move the binary and none of them are.
218    pub fn claude_cli(fetch: &str) -> Self {
219        let mut b = Self::new(Self::claude_argv(fetch), Duration::from_secs(1200));
220        b.name = "Claude Code".to_string();
221        b.identity = Self::claude_argv("<fetch>").join(" ");
222        b
223    }
224
225    fn claude_argv(fetch: &str) -> Vec<String> {
226        vec![
227            "claude".to_string(),
228            "-p".to_string(),
229            "--output-format".to_string(),
230            "text".to_string(),
231            "--allowed-tools".to_string(),
232            format!(
233                "Bash({fetch} agent:*),Bash(git diff:*),Read,Grep,Glob,\
234                 Bash(git log:*),Bash(git show:*)"
235            ),
236        ]
237    }
238}
239
240impl LlmBackend for CommandBackend {
241    fn name(&self) -> &str {
242        &self.name
243    }
244
245    fn identity(&self) -> &str {
246        &self.identity
247    }
248
249    fn complete(&self, prompt: &str) -> Result<String, LlmError> {
250        let mut cmd = Command::new(&self.argv[0]);
251        if let Some(dir) = &self.working_dir {
252            cmd.current_dir(dir);
253        }
254        let mut child = cmd
255            .args(&self.argv[1..])
256            .stdin(Stdio::piped())
257            .stdout(Stdio::piped())
258            .stderr(Stdio::piped())
259            .spawn()
260            .map_err(|source| LlmError::Spawn {
261                command: self.command.clone(),
262                source,
263            })?;
264
265        // Prompt in and output out run on their own threads: a large prompt
266        // must not deadlock against a child that writes before it finishes
267        // reading, and a large completion must not fill the pipe while the
268        // watchdog waits for exit.
269        let mut stdin = child.stdin.take().expect("stdin piped");
270        let prompt_owned = prompt.as_bytes().to_vec();
271        let writer = std::thread::spawn(move || {
272            let _ = stdin.write_all(&prompt_owned);
273            // stdin closes on drop
274        });
275        use std::io::Read;
276        let mut out_pipe = child.stdout.take().expect("stdout piped");
277        let stdout_reader = std::thread::spawn(move || {
278            let mut buf = Vec::new();
279            let res = out_pipe.read_to_end(&mut buf);
280            res.map(|_| buf)
281        });
282        let mut err_pipe = child.stderr.take().expect("stderr piped");
283        let stderr_reader = std::thread::spawn(move || {
284            let mut buf = Vec::new();
285            let _ = err_pipe.read_to_end(&mut buf);
286            buf
287        });
288
289        // Watchdog: poll for exit until the deadline, then kill. The poll is
290        // not just the deadline's — the cancel flag has to be read too, which
291        // is why this is a loop and not a `wait` with a timeout.
292        let deadline = std::time::Instant::now() + self.timeout;
293        let status = loop {
294            // Decide first, tear down once. The two ways out used to carry a
295            // copy each of the same five-line teardown, so a sixth thing to
296            // clean up would have had to be remembered twice.
297            let give_up = match child.try_wait().map_err(|source| LlmError::Io {
298                command: self.command.clone(),
299                source,
300            })? {
301                Some(status) => break status,
302                None if self.cancelled() => Some(LlmError::Cancelled {
303                    command: self.command.clone(),
304                }),
305                None if std::time::Instant::now() >= deadline => Some(LlmError::Timeout {
306                    command: self.command.clone(),
307                    timeout: self.timeout,
308                }),
309                None => None,
310            };
311            if let Some(err) = give_up {
312                let _ = child.kill();
313                let _ = child.wait();
314                let _ = writer.join();
315                let _ = stdout_reader.join();
316                let _ = stderr_reader.join();
317                return Err(err);
318            }
319            std::thread::sleep(Duration::from_millis(25));
320        };
321        let _ = writer.join();
322
323        let stdout = stdout_reader
324            .join()
325            .expect("stdout reader panicked")
326            .map_err(|source| LlmError::Io {
327                command: self.command.clone(),
328                source,
329            })?;
330        let stderr = stderr_reader.join().expect("stderr reader panicked");
331
332        if !status.success() {
333            return Err(LlmError::Failed {
334                command: self.command.clone(),
335                code: status.code(),
336                stderr: String::from_utf8_lossy(&stderr[..stderr.len().min(600)]).into_owned(),
337            });
338        }
339        let text = String::from_utf8_lossy(&stdout).into_owned();
340        if text.trim().is_empty() {
341            return Err(LlmError::Empty {
342                command: self.command.clone(),
343            });
344        }
345        Ok(text)
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn cat_echoes_the_prompt() {
355        let b = CommandBackend::new(vec!["cat".into()], Duration::from_secs(10));
356        let out = b.complete("hello prompt\n").unwrap();
357        assert_eq!(out, "hello prompt\n");
358    }
359
360    #[test]
361    fn nonzero_exit_is_failed() {
362        let b = CommandBackend::new(vec!["false".into()], Duration::from_secs(10));
363        match b.complete("x") {
364            Err(LlmError::Failed { code, .. }) => assert_eq!(code, Some(1)),
365            other => panic!("expected Failed, got {other:?}"),
366        }
367    }
368
369    #[test]
370    fn empty_output_is_an_error() {
371        let b = CommandBackend::new(vec!["true".into()], Duration::from_secs(10));
372        match b.complete("x") {
373            Err(LlmError::Empty { .. }) => {}
374            other => panic!("expected Empty, got {other:?}"),
375        }
376    }
377
378    #[test]
379    fn cancel_kills_the_child() {
380        // A long sleep with a generous deadline: only the cancel flag can end
381        // this, and it must do so promptly rather than leaving the child to
382        // outlive the caller.
383        let flag = Arc::new(AtomicBool::new(false));
384        let backend =
385            CommandBackend::new(vec!["sleep".into(), "600".into()], Duration::from_secs(600))
386                .with_cancel(Arc::clone(&flag));
387        let started = std::time::Instant::now();
388        std::thread::spawn(move || {
389            std::thread::sleep(Duration::from_millis(100));
390            flag.store(true, Ordering::Relaxed);
391        });
392        let err = backend.complete("hello").unwrap_err();
393        assert!(
394            matches!(err, LlmError::Cancelled { .. }),
395            "expected cancellation, got {err:?}"
396        );
397        assert!(
398            started.elapsed() < Duration::from_secs(5),
399            "child was not killed promptly"
400        );
401    }
402
403    #[test]
404    fn deadline_kills_the_child() {
405        let b = CommandBackend::new(
406            vec!["sleep".into(), "30".into()],
407            Duration::from_millis(200),
408        );
409        let started = std::time::Instant::now();
410        match b.complete("x") {
411            Err(LlmError::Timeout { .. }) => {}
412            other => panic!("expected Timeout, got {other:?}"),
413        }
414        assert!(
415            started.elapsed() < Duration::from_secs(5),
416            "child was not killed promptly"
417        );
418    }
419
420    #[test]
421    fn large_prompt_does_not_deadlock() {
422        // A prompt bigger than the pipe buffer, against a child that echoes
423        // while reading: the writer thread prevents the classic deadlock.
424        let b = CommandBackend::new(vec!["cat".into()], Duration::from_secs(30));
425        let big = "line of prompt text\n".repeat(60_000); // ~1.2 MB
426        let out = b.complete(&big).unwrap();
427        assert_eq!(out.len(), big.len());
428    }
429
430    #[test]
431    fn where_the_binary_lives_is_not_part_of_the_cache_identity() {
432        // The grouping cache key hashes `identity`. If it hashed the argv the
433        // absolute path would be in the key, and a debug build, a release build
434        // and a second checkout of the same commit would each re-run a
435        // four-hundred-second call over an identical class partition.
436        let a = CommandBackend::claude_cli("/Users/someone/.cargo/bin/dfr");
437        let b = CommandBackend::claude_cli("/srv/ci/target/release/dfr");
438        assert_eq!(a.identity(), b.identity());
439        assert!(!a.identity().contains(".cargo"), "{}", a.identity());
440
441        // A backend with nothing better to call itself is its own identity, and
442        // two different agents must never share a cache entry.
443        let one = CommandBackend::new(vec!["agent-one".into()], Duration::from_secs(1));
444        let two = CommandBackend::new(vec!["agent-two".into()], Duration::from_secs(1));
445        assert_eq!(one.identity(), one.name());
446        assert_ne!(one.identity(), two.identity());
447    }
448
449    #[test]
450    fn the_child_runs_where_it_was_told_to() {
451        // The prompt hands the model repo-root-relative paths for `git diff`.
452        // Git resolves a bare pathspec against the CURRENT DIRECTORY, so a child
453        // inheriting this process's cwd matches nothing whenever `dfr` ran from
454        // a subdirectory — and matching nothing is an empty diff and exit 0, not
455        // an error. The model would rate a class having seen no diff, and
456        // nothing would say so. Hence a test on the cwd itself.
457        let dir = tempfile::TempDir::new().unwrap();
458        // The temp dir may be a symlink (/var -> /private/var on macOS), so
459        // compare what the child reports against the canonical form.
460        let want = dir.path().canonicalize().unwrap();
461        let b = CommandBackend::new(vec!["pwd".into()], Duration::from_secs(10))
462            .with_working_dir(dir.path());
463        let got = b.complete("x").unwrap();
464        assert_eq!(
465            std::path::Path::new(got.trim()).canonicalize().unwrap(),
466            want,
467            "the child must run in the directory it was given"
468        );
469
470        // Without it, the child inherits — which is right for a backend that
471        // reads no repository, and wrong for one whose prompt names paths.
472        let inherit = CommandBackend::new(vec!["pwd".into()], Duration::from_secs(10));
473        assert_ne!(
474            std::path::Path::new(inherit.complete("x").unwrap().trim())
475                .canonicalize()
476                .unwrap(),
477            want
478        );
479    }
480
481    #[test]
482    fn the_reviewer_sees_a_product_name_and_an_error_sees_the_command() {
483        // The splash prints `name` on one line. The argv is four times the
484        // width and answers a different question, so it lives where it is the
485        // answer: a spawn failure.
486        let b = CommandBackend::claude_cli("/opt/bin/dfr");
487        assert_eq!(b.name(), "Claude Code");
488
489        let missing = CommandBackend::new(
490            vec!["definitely-not-a-real-program".into()],
491            Duration::from_secs(1),
492        );
493        match missing.complete("x") {
494            Err(LlmError::Spawn { command, .. }) => {
495                assert_eq!(command, "definitely-not-a-real-program");
496            }
497            other => panic!("expected Spawn, got {other:?}"),
498        }
499    }
500
501    #[test]
502    fn changing_the_allowlist_does_change_the_cache_identity() {
503        // The other half of the rule: the allowlist shapes what the model can
504        // see, so it must stay in the key even though the path does not.
505        let b = CommandBackend::claude_cli("/opt/bin/dfr");
506        assert!(b.identity().contains("Read,Grep,Glob"), "{}", b.identity());
507        assert!(!b.identity().contains("/opt/bin"), "{}", b.identity());
508    }
509
510    #[test]
511    fn claude_cli_default_allows_reading_and_nothing_else() {
512        let b = CommandBackend::claude_cli("/opt/bin/dfr");
513        let argv = &b.command;
514        assert!(
515            argv.contains("Bash(/opt/bin/dfr agent:*)"),
516            "the allowlist names the same executable the prompt does"
517        );
518        assert!(
519            argv.contains("Bash(git diff:*)"),
520            "the prompt tells the model to run git diff, so it must be permitted"
521        );
522        // The whole list, exactly. The argv is built with a line continuation,
523        // and a stray space inside one would produce an allowlist that parses
524        // as something else. This is the security boundary, and a broken fetch
525        // costs minutes of a model working around it, so it fails here loudly
526        // rather than there silently.
527        assert!(
528            argv.ends_with(
529                "--allowed-tools Bash(/opt/bin/dfr agent:*),Bash(git diff:*),Read,Grep,Glob,Bash(git log:*),Bash(git show:*)"
530            ),
531            "{argv}"
532        );
533        // The allowlist is the security boundary, so the test states what must
534        // stay OUT of it, not merely what is in it.
535        for forbidden in [
536            "Write",
537            "Edit",
538            "Bash(git commit",
539            "Bash(git push",
540            "WebFetch",
541        ] {
542            assert!(!argv.contains(forbidden), "{forbidden} must not be allowed");
543        }
544    }
545}