Skip to main content

oxicode_agent/runtime/
eval_kernel.rs

1//! Persistent eval kernels — the reference [`EvalKernel`] implementations
2//! behind the `coding-omp-v1` "Eval kernel" extension.
3//!
4//! - [`PythonEvalKernel`]: one long-lived `python3 -q -u -i` process. Each
5//!   cell ships inline — triple-quoted with backslash escaping, wrapped in
6//!   `exec(compile(...))` — so multi-line and indented cells work verbatim;
7//!   namespace state persists across cells.
8//! - [`JavaScriptEvalKernel`]: one long-lived `node -i --no-warnings` (or
9//!   `bun -i`) REPL. Cells are fed through stdin; top-level declarations
10//!   persist in the REPL context. Cells must be complete statements (an
11//!   unclosed brace would swallow the trailing marker line).
12//!
13//! Both delimit a cell with a stdout marker line, bound output, surface
14//! interpreter diagnostics on error, and support explicit reset (child
15//! killed; respawned lazily on the next cell).
16
17use super::{EvalKernel, EvalLanguage, EvalOutput};
18use async_trait::async_trait;
19use parking_lot::Mutex;
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
23use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout};
24
25const MARKER: &str = "__OXI_EVAL_END__";
26const DEFAULT_MAX_OUTPUT: usize = 256 * 1024;
27const STDERR_TAIL_LIMIT: usize = 16 * 1024;
28
29struct KernelProc {
30    child: Child,
31    stdin: ChildStdin,
32    stdout: BufReader<ChildStdout>,
33}
34
35/// Shared per-kernel session state: the live interpreter process plus a
36/// bounded stderr tail used for error diagnostics.
37struct KernelShared {
38    proc: Mutex<Option<KernelProc>>,
39    stderr_tail: Arc<Mutex<String>>,
40    program: &'static str,
41    args: &'static [&'static str],
42}
43
44impl std::fmt::Debug for KernelShared {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("KernelShared")
47            .field("program", &self.program)
48            .field("alive", &self.proc.lock().is_some())
49            .finish()
50    }
51}
52
53impl KernelShared {
54    fn new(program: &'static str, args: &'static [&'static str]) -> Self {
55        Self {
56            proc: Mutex::new(None),
57            stderr_tail: Arc::new(Mutex::new(String::new())),
58            program,
59            args,
60        }
61    }
62
63    /// Take the live interpreter out, respawning if dead or absent. The
64    /// lock is never held across `.await`.
65    async fn take(&self) -> Result<KernelProc, String> {
66        let existing = self.proc.lock().take();
67        match existing {
68            Some(p) => {
69                let mut p = p;
70                let alive = p.child.try_wait().map_or(true, |s| s.is_none());
71                if alive { Ok(p) } else { self.spawn().await }
72            }
73            None => self.spawn().await,
74        }
75    }
76
77    async fn spawn(&self) -> Result<KernelProc, String> {
78        use tokio::process::Command;
79        let mut cmd = Command::new(self.program);
80        cmd.args(self.args)
81            .stdin(std::process::Stdio::piped())
82            .stdout(std::process::Stdio::piped())
83            .stderr(std::process::Stdio::piped())
84            .kill_on_drop(true);
85        let mut child = cmd
86            .spawn()
87            .map_err(|e| format!("spawn {}: {e}", self.program))?;
88        let stdin = child.stdin.take().ok_or("interpreter: no stdin")?;
89        let stdout = child.stdout.take().ok_or("interpreter: no stdout")?;
90        if let Some(stderr) = child.stderr.take() {
91            drain_stderr(Arc::clone(&self.stderr_tail), stderr);
92        }
93        Ok(KernelProc {
94            child,
95            stdin,
96            stdout: BufReader::new(stdout),
97        })
98    }
99
100    fn clear_tail(&self) {
101        self.stderr_tail.lock().clear();
102    }
103
104    fn snapshot_tail(&self) -> String {
105        self.stderr_tail.lock().clone()
106    }
107}
108
109/// Forward interpreter stderr into the bounded shared tail. The task ends
110/// when the child's stderr closes (child exit).
111fn drain_stderr(tail: Arc<Mutex<String>>, stderr: ChildStderr) {
112    tokio::spawn(async move {
113        let mut reader = BufReader::new(stderr);
114        let mut line = String::new();
115        loop {
116            match reader.read_line(&mut line).await {
117                Ok(0) | Err(_) => break,
118                Ok(_) => {
119                    let mut buf = tail.lock();
120                    if buf.len() + line.len() > STDERR_TAIL_LIMIT {
121                        buf.clear();
122                    }
123                    buf.push_str(&line);
124                    drop(buf);
125                    line.clear();
126                }
127            }
128        }
129    });
130}
131
132fn last_nonempty(s: &str) -> &str {
133    s.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("")
134}
135
136/// Write the cell, read stdout until the marker. Returns
137/// `(stdout, truncated, completed)`; kills the child on I/O failure.
138async fn write_and_read(
139    shared: &KernelShared,
140    proc: &mut KernelProc,
141    cell: &str,
142    timeout: Duration,
143    max_output: usize,
144) -> Result<(String, bool, bool), String> {
145    let deadline = Instant::now() + timeout;
146    if let Err(e) = proc.stdin.write_all(cell.as_bytes()).await {
147        let msg = format!("{} stdin write: {e}", shared.program);
148        let _ = proc.child.kill().await;
149        return Err(msg);
150    }
151    if let Err(e) = proc.stdin.flush().await {
152        let msg = format!("{} stdin flush: {e}", shared.program);
153        let _ = proc.child.kill().await;
154        return Err(msg);
155    }
156    let mut stdout = String::new();
157    let mut truncated = false;
158    loop {
159        if Instant::now() >= deadline {
160            return Ok((stdout, truncated, false));
161        }
162        let mut line = String::new();
163        let read = tokio::time::timeout_at(
164            tokio::time::Instant::from(deadline),
165            proc.stdout.read_line(&mut line),
166        )
167        .await;
168        match read {
169            Err(_elapsed) => return Ok((stdout, truncated, false)),
170            Ok(Err(e)) => {
171                let msg = format!("{} stdout read: {e}", shared.program);
172                let _ = proc.child.kill().await;
173                return Err(msg);
174            }
175            Ok(Ok(0)) => {
176                let msg = format!("{} exited mid-cell", shared.program);
177                let _ = proc.child.kill().await;
178                return Err(msg);
179            }
180            Ok(Ok(_)) => {
181                if line.trim_end().ends_with(MARKER) {
182                    return Ok((stdout, truncated, true));
183                }
184                if stdout.len() + line.len() > max_output {
185                    truncated = true;
186                } else {
187                    stdout.push_str(&line);
188                }
189            }
190        }
191    }
192}
193
194// ── Python ───────────────────────────────────────────────────────────────
195
196/// Persistent `python3 -q -u -i` kernel.
197#[derive(Debug)]
198pub struct PythonEvalKernel {
199    shared: KernelShared,
200    max_output: usize,
201}
202
203impl Default for PythonEvalKernel {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209impl PythonEvalKernel {
210    /// Creates a kernel that lazily spawns `python3 -q -u -i` on first use.
211    pub fn new() -> Self {
212        Self {
213            shared: KernelShared::new("python3", &["-q", "-u", "-i"]),
214            max_output: DEFAULT_MAX_OUTPUT,
215        }
216    }
217
218    /// Override the output bound (bytes).
219    pub fn with_max_output(mut self, max: usize) -> Self {
220        self.max_output = max;
221        self
222    }
223}
224
225#[async_trait]
226impl EvalKernel for PythonEvalKernel {
227    fn language(&self) -> EvalLanguage {
228        EvalLanguage::Python
229    }
230
231    async fn execute(&self, code: &str, timeout: Duration) -> Result<EvalOutput, String> {
232        // The cell ships inline (no temp file): triple-quoted with backslash
233        // escaping, so arbitrary multi-line and indented code works verbatim
234        // while the interactive namespace keeps state across cells.
235        let escaped = code.replace('\\', "\\\\").replace("'''", "\\'\\'\\'");
236        let cell = format!(
237            "exec(compile('''{escaped}'''.encode('utf-8'), 'cell', 'exec'))\nprint(\"{MARKER}\")\n"
238        );
239        self.shared.clear_tail();
240
241        let mut proc = self.shared.take().await?;
242        let (stdout, truncated, completed) =
243            write_and_read(&self.shared, &mut proc, &cell, timeout, self.max_output).await?;
244        // Settle the stderr drain so tracebacks racing the stdout marker are
245        // captured for diagnostics.
246        tokio::time::sleep(Duration::from_millis(30)).await;
247        let stderr = self.shared.snapshot_tail();
248        if !completed {
249            let _ = proc.child.kill().await;
250            *self.shared.proc.lock() = None;
251            return Err(if stderr.trim().is_empty() {
252                "python cell timed out before completing".to_string()
253            } else {
254                format!("python cell failed: {}", last_nonempty(&stderr))
255            });
256        }
257        let stderr = self.shared.snapshot_tail();
258        *self.shared.proc.lock() = Some(proc);
259        let error = stderr
260            .contains("Traceback")
261            .then(|| last_nonempty(&stderr).to_string());
262        Ok(EvalOutput {
263            result: String::new(),
264            stdout,
265            stderr,
266            error,
267            truncated,
268        })
269    }
270
271    async fn reset(&self) -> Result<(), String> {
272        let taken = self.shared.proc.lock().take();
273        if let Some(mut p) = taken {
274            let _ = p.child.kill().await;
275        }
276        Ok(())
277    }
278}
279
280// ── JavaScript ───────────────────────────────────────────────────────────
281
282/// Persistent JavaScript REPL kernel. Prefers `node`, falls back to `bun`.
283#[derive(Debug)]
284pub struct JavaScriptEvalKernel {
285    shared: KernelShared,
286    max_output: usize,
287}
288
289impl Default for JavaScriptEvalKernel {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295impl JavaScriptEvalKernel {
296    /// Detects `node`, then `bun` (first found on PATH wins).
297    pub fn new() -> Self {
298        let (program, args): (&'static str, &'static [&'static str]) = if runtime_present("node") {
299            ("node", &["-i", "--no-warnings"])
300        } else {
301            ("bun", &["-i"])
302        };
303        Self {
304            shared: KernelShared::new(program, args),
305            max_output: DEFAULT_MAX_OUTPUT,
306        }
307    }
308
309    /// Override the output bound (bytes).
310    pub fn with_max_output(mut self, max: usize) -> Self {
311        self.max_output = max;
312        self
313    }
314}
315
316fn runtime_present(program: &str) -> bool {
317    std::process::Command::new(program)
318        .arg("--version")
319        .stdout(std::process::Stdio::null())
320        .stderr(std::process::Stdio::null())
321        .status()
322        .map(|s| s.success())
323        .unwrap_or(false)
324}
325
326#[async_trait]
327impl EvalKernel for JavaScriptEvalKernel {
328    fn language(&self) -> EvalLanguage {
329        EvalLanguage::JavaScript
330    }
331    async fn execute(&self, code: &str, timeout: Duration) -> Result<EvalOutput, String> {
332        let cell = format!("{code}\nconsole.log(\"{MARKER}\");\n");
333        self.shared.clear_tail();
334
335        let mut proc = self.shared.take().await?;
336        let (stdout, truncated, completed) =
337            write_and_read(&self.shared, &mut proc, &cell, timeout, self.max_output).await?;
338        if !completed {
339            let _ = proc.child.kill().await;
340            *self.shared.proc.lock() = None;
341            return Err(format!(
342                "js cell timed out before completing ({})",
343                self.shared.program
344            ));
345        }
346        let stderr = self.shared.snapshot_tail();
347        *self.shared.proc.lock() = Some(proc);
348        // Node's REPL prints uncaught errors on stdout (unlike Python's
349        // stderr tracebacks), so scan both streams.
350        let error = if stderr.contains("Uncaught") {
351            Some(last_nonempty(&stderr).to_string())
352        } else if stdout.contains("Uncaught") {
353            Some(
354                stdout
355                    .lines()
356                    .rev()
357                    .find(|l| l.contains("Uncaught"))
358                    .unwrap_or_default()
359                    .to_string(),
360            )
361        } else {
362            None
363        };
364        Ok(EvalOutput {
365            result: String::new(),
366            stdout,
367            stderr,
368            error,
369            truncated,
370        })
371    }
372
373    async fn reset(&self) -> Result<(), String> {
374        let taken = self.shared.proc.lock().take();
375        if let Some(mut p) = taken {
376            let _ = p.child.kill().await;
377        }
378        Ok(())
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[tokio::test]
387    async fn python_state_persists_and_reports_errors() {
388        let kernel = PythonEvalKernel::new();
389        let first = kernel
390            .execute("fixture_x = 41", Duration::from_secs(15))
391            .await
392            .unwrap();
393        assert!(first.error.is_none(), "cell 1 errored: {:?}", first.stderr);
394        let out = kernel
395            .execute("print(fixture_x + 1)", Duration::from_secs(15))
396            .await
397            .unwrap();
398        assert!(out.error.is_none(), "cell 2 errored: {:?}", out.stderr);
399        assert!(
400            out.stdout.contains("42"),
401            "state must persist: {}",
402            out.stdout
403        );
404        let err = kernel
405            .execute("raise ValueError('oxi-boom')", Duration::from_secs(15))
406            .await
407            .unwrap();
408        assert!(
409            err.error
410                .as_deref()
411                .unwrap_or_default()
412                .contains("oxi-boom")
413        );
414        kernel.reset().await.unwrap();
415        let gone = kernel
416            .execute("print(fixture_x)", Duration::from_secs(15))
417            .await
418            .unwrap();
419        assert!(gone.error.is_some(), "reset must clear the namespace");
420    }
421
422    #[tokio::test]
423    async fn javascript_state_persists() {
424        let kernel = JavaScriptEvalKernel::new();
425        kernel
426            .execute("globalThis.fixture_y = 41", Duration::from_secs(15))
427            .await
428            .unwrap();
429        let out = kernel
430            .execute("console.log(fixture_y + 1)", Duration::from_secs(15))
431            .await
432            .unwrap();
433        assert!(
434            out.stdout.contains("42"),
435            "state must persist: {}",
436            out.stdout
437        );
438    }
439}