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 tools, no streaming, no chat state. Every observed hard failure
9//! of the evaluated grouping tools was a model CLI stopping to call a tool
10//! (ADR 0010); a backend that cannot express tools cannot fail that way.
11
12use std::io::Write;
13use std::process::{Command, Stdio};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::time::Duration;
17
18#[derive(Debug, thiserror::Error)]
19pub enum LlmError {
20    #[error("failed to spawn {command}: {source}")]
21    Spawn {
22        command: String,
23        #[source]
24        source: std::io::Error,
25    },
26
27    #[error("{command} exited with {code:?}: {stderr}")]
28    Failed {
29        command: String,
30        code: Option<i32>,
31        stderr: String,
32    },
33
34    #[error("{command} produced no output")]
35    Empty { command: String },
36
37    #[error("{command} exceeded the {timeout:?} deadline and was killed")]
38    Timeout { command: String, timeout: Duration },
39
40    #[error("{command} was cancelled and killed")]
41    Cancelled { command: String },
42
43    #[error("io error talking to {command}: {source}")]
44    Io {
45        command: String,
46        #[source]
47        source: std::io::Error,
48    },
49}
50
51/// One-shot completion: prompt in, raw text out.
52pub trait LlmBackend: Send + Sync {
53    /// Human-readable backend name, for logs and audit output.
54    fn name(&self) -> &str;
55    fn complete(&self, prompt: &str) -> Result<String, LlmError>;
56}
57
58/// A subprocess backend: prompt on stdin, completion on stdout.
59pub struct CommandBackend {
60    argv: Vec<String>,
61    timeout: Duration,
62    name: String,
63    /// Set from another thread to kill an in-flight child (a reviewer
64    /// abandoning the wait). Without this the subprocess would outlive the
65    /// process that asked for it, up to the whole timeout.
66    cancel: Option<Arc<AtomicBool>>,
67}
68
69impl CommandBackend {
70    pub fn new(argv: Vec<String>, timeout: Duration) -> Self {
71        assert!(!argv.is_empty(), "CommandBackend needs a program to run");
72        let name = argv.join(" ");
73        CommandBackend {
74            argv,
75            timeout,
76            name,
77            cancel: None,
78        }
79    }
80
81    /// Kill the child as soon as `flag` is set.
82    pub fn with_cancel(mut self, flag: Arc<AtomicBool>) -> Self {
83        self.cancel = Some(flag);
84        self
85    }
86
87    pub fn with_timeout(mut self, timeout: Duration) -> Self {
88        self.timeout = timeout;
89        self
90    }
91
92    fn cancelled(&self) -> bool {
93        self.cancel
94            .as_ref()
95            .is_some_and(|c| c.load(Ordering::Relaxed))
96    }
97
98    /// The validated default (ADR 0010): headless, text output, tools denied.
99    pub fn claude_cli() -> Self {
100        Self::new(
101            [
102                "claude",
103                "-p",
104                "--output-format",
105                "text",
106                "--allowed-tools",
107                "",
108            ]
109            .into_iter()
110            .map(String::from)
111            .collect(),
112            Duration::from_secs(1200),
113        )
114    }
115}
116
117impl LlmBackend for CommandBackend {
118    fn name(&self) -> &str {
119        &self.name
120    }
121
122    fn complete(&self, prompt: &str) -> Result<String, LlmError> {
123        let mut child = Command::new(&self.argv[0])
124            .args(&self.argv[1..])
125            .stdin(Stdio::piped())
126            .stdout(Stdio::piped())
127            .stderr(Stdio::piped())
128            .spawn()
129            .map_err(|source| LlmError::Spawn {
130                command: self.name.clone(),
131                source,
132            })?;
133
134        // Prompt in and output out run on their own threads: a large prompt
135        // must not deadlock against a child that writes before it finishes
136        // reading, and a large completion must not fill the pipe while the
137        // watchdog waits for exit.
138        let mut stdin = child.stdin.take().expect("stdin piped");
139        let prompt_owned = prompt.as_bytes().to_vec();
140        let writer = std::thread::spawn(move || {
141            let _ = stdin.write_all(&prompt_owned);
142            // stdin closes on drop
143        });
144        use std::io::Read;
145        let mut out_pipe = child.stdout.take().expect("stdout piped");
146        let stdout_reader = std::thread::spawn(move || {
147            let mut buf = Vec::new();
148            let res = out_pipe.read_to_end(&mut buf);
149            res.map(|_| buf)
150        });
151        let mut err_pipe = child.stderr.take().expect("stderr piped");
152        let stderr_reader = std::thread::spawn(move || {
153            let mut buf = Vec::new();
154            let _ = err_pipe.read_to_end(&mut buf);
155            buf
156        });
157
158        // Watchdog: poll for exit until the deadline, then kill.
159        let deadline = std::time::Instant::now() + self.timeout;
160        let status = loop {
161            match child.try_wait().map_err(|source| LlmError::Io {
162                command: self.name.clone(),
163                source,
164            })? {
165                Some(status) => break status,
166                None if self.cancelled() => {
167                    let _ = child.kill();
168                    let _ = child.wait();
169                    let _ = writer.join();
170                    let _ = stdout_reader.join();
171                    let _ = stderr_reader.join();
172                    return Err(LlmError::Cancelled {
173                        command: self.name.clone(),
174                    });
175                }
176                None if std::time::Instant::now() >= deadline => {
177                    let _ = child.kill();
178                    let _ = child.wait();
179                    let _ = writer.join();
180                    let _ = stdout_reader.join();
181                    let _ = stderr_reader.join();
182                    return Err(LlmError::Timeout {
183                        command: self.name.clone(),
184                        timeout: self.timeout,
185                    });
186                }
187                None => std::thread::sleep(Duration::from_millis(25)),
188            }
189        };
190        let _ = writer.join();
191
192        let stdout = stdout_reader
193            .join()
194            .expect("stdout reader panicked")
195            .map_err(|source| LlmError::Io {
196                command: self.name.clone(),
197                source,
198            })?;
199        let stderr = stderr_reader.join().expect("stderr reader panicked");
200
201        if !status.success() {
202            return Err(LlmError::Failed {
203                command: self.name.clone(),
204                code: status.code(),
205                stderr: String::from_utf8_lossy(&stderr[..stderr.len().min(600)]).into_owned(),
206            });
207        }
208        let text = String::from_utf8_lossy(&stdout).into_owned();
209        if text.trim().is_empty() {
210            return Err(LlmError::Empty {
211                command: self.name.clone(),
212            });
213        }
214        Ok(text)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn cat_echoes_the_prompt() {
224        let b = CommandBackend::new(vec!["cat".into()], Duration::from_secs(10));
225        let out = b.complete("hello prompt\n").unwrap();
226        assert_eq!(out, "hello prompt\n");
227    }
228
229    #[test]
230    fn nonzero_exit_is_failed() {
231        let b = CommandBackend::new(vec!["false".into()], Duration::from_secs(10));
232        match b.complete("x") {
233            Err(LlmError::Failed { code, .. }) => assert_eq!(code, Some(1)),
234            other => panic!("expected Failed, got {other:?}"),
235        }
236    }
237
238    #[test]
239    fn empty_output_is_an_error() {
240        let b = CommandBackend::new(vec!["true".into()], Duration::from_secs(10));
241        match b.complete("x") {
242            Err(LlmError::Empty { .. }) => {}
243            other => panic!("expected Empty, got {other:?}"),
244        }
245    }
246
247    #[test]
248    fn cancel_kills_the_child() {
249        // A long sleep with a generous deadline: only the cancel flag can end
250        // this, and it must do so promptly rather than leaving the child to
251        // outlive the caller.
252        let flag = Arc::new(AtomicBool::new(false));
253        let backend =
254            CommandBackend::new(vec!["sleep".into(), "600".into()], Duration::from_secs(600))
255                .with_cancel(Arc::clone(&flag));
256        let started = std::time::Instant::now();
257        std::thread::spawn(move || {
258            std::thread::sleep(Duration::from_millis(100));
259            flag.store(true, Ordering::Relaxed);
260        });
261        let err = backend.complete("hello").unwrap_err();
262        assert!(
263            matches!(err, LlmError::Cancelled { .. }),
264            "expected cancellation, got {err:?}"
265        );
266        assert!(
267            started.elapsed() < Duration::from_secs(5),
268            "child was not killed promptly"
269        );
270    }
271
272    #[test]
273    fn deadline_kills_the_child() {
274        let b = CommandBackend::new(
275            vec!["sleep".into(), "30".into()],
276            Duration::from_millis(200),
277        );
278        let started = std::time::Instant::now();
279        match b.complete("x") {
280            Err(LlmError::Timeout { .. }) => {}
281            other => panic!("expected Timeout, got {other:?}"),
282        }
283        assert!(
284            started.elapsed() < Duration::from_secs(5),
285            "child was not killed promptly"
286        );
287    }
288
289    #[test]
290    fn large_prompt_does_not_deadlock() {
291        // A prompt bigger than the pipe buffer, against a child that echoes
292        // while reading: the writer thread prevents the classic deadlock.
293        let b = CommandBackend::new(vec!["cat".into()], Duration::from_secs(30));
294        let big = "line of prompt text\n".repeat(60_000); // ~1.2 MB
295        let out = b.complete(&big).unwrap();
296        assert_eq!(out.len(), big.len());
297    }
298
299    #[test]
300    fn claude_cli_default_denies_tools() {
301        let b = CommandBackend::claude_cli();
302        assert!(b.name().contains("--allowed-tools"));
303    }
304}