differential_engine/
llm.rs1use 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
51pub trait LlmBackend: Send + Sync {
53 fn name(&self) -> &str;
55 fn complete(&self, prompt: &str) -> Result<String, LlmError>;
56}
57
58pub struct CommandBackend {
60 argv: Vec<String>,
61 timeout: Duration,
62 name: String,
63 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 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 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 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 });
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 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 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 let b = CommandBackend::new(vec!["cat".into()], Duration::from_secs(30));
294 let big = "line of prompt text\n".repeat(60_000); 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}