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}