返回 DeepSeek-TUI-2026
runtime.rs
根目录 / crates / tui / src / repl / runtime.rs
1 //! Long-lived Python REPL runtime.
2 //!
3 //! One `python3 -u` subprocess lives for the duration of an RLM turn (or an
4 //! inline `repl` block sequence in the agent loop). Code blocks are sent
5 //! over stdin framed by `__RLM_RUN__`/`__RLM_END__` sentinels; the bootstrap
6 //! `exec()`s them into the same global namespace so variables, imports,
7 //! and even open file handles persist naturally across rounds.
8 //!
9 //! Sub-LLM helpers (`llm_query`, `llm_query_batched`, `rlm_query`,
10 //! `rlm_query_batched`) are wired through a stdin/stdout RPC protocol:
11 //! Python emits `__RLM_REQ_<sid>__::{json}` on stdout, Rust dispatches the
12 //! request and writes `__RLM_RESP_<sid>__::{json}` back on stdin. No HTTP
13 //! sidecar, no temp ports — the same pipes carry both control and data.
14 //!
15 //! The session id (`<sid>`) is a UUID generated per spawn, so user output
16 //! that happens to contain "REQ" or "FINAL" can't be confused with control
17 //! messages.
18
19 use std::path::{Path, PathBuf};
20 use std::process::Stdio;
21 use std::time::{Duration, Instant};
22
23 use serde::{Deserialize, Serialize};
24 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
25 use tokio::process::{Child, ChildStdin, ChildStdout, Command};
26 use uuid::Uuid;
27
28 // ---------------------------------------------------------------------------
29 // Public types
30 // ---------------------------------------------------------------------------
31
32 /// Result of executing one code block.
33 #[derive(Debug, Clone)]
34 pub struct ReplRound {
35 /// Stdout shown to the model as metadata next round.
36 pub stdout: String,
37 /// Full stdout (with sentinels stripped, but otherwise raw).
38 pub full_stdout: String,
39 /// Stderr from this round (if any).
40 pub stderr: String,
41 /// `True` if the user code raised an unhandled Python exception.
42 pub has_error: bool,
43 /// Captured `FINAL(value)` payload, if any.
44 pub final_value: Option<String>,
45 /// Number of `llm_query`/`rlm_query` RPCs the round issued.
46 pub rpc_count: u32,
47 /// Wall-clock duration of the round.
48 pub elapsed: Duration,
49 }
50
51 /// One RPC request emitted by Python during a round.
52 #[derive(Debug, Clone, Serialize, Deserialize)]
53 #[serde(tag = "type", rename_all = "snake_case")]
54 pub enum RpcRequest {
55 /// `llm_query(prompt, model=None, max_tokens=None, system=None)`
56 Llm {
57 prompt: String,
58 #[serde(default)]
59 model: Option<String>,
60 #[serde(default)]
61 max_tokens: Option<u32>,
62 #[serde(default)]
63 system: Option<String>,
64 },
65 /// `llm_query_batched(prompts, model=None)`
66 LlmBatch {
67 prompts: Vec<String>,
68 #[serde(default)]
69 model: Option<String>,
70 },
71 /// `rlm_query(prompt, model=None)` — recursive sub-RLM (paper's `sub_RLM`).
72 Rlm {
73 prompt: String,
74 #[serde(default)]
75 model: Option<String>,
76 },
77 /// `rlm_query_batched(prompts, model=None)`
78 RlmBatch {
79 prompts: Vec<String>,
80 #[serde(default)]
81 model: Option<String>,
82 },
83 }
84
85 /// Response for one RPC request.
86 #[derive(Debug, Clone, Serialize, Deserialize)]
87 #[serde(untagged)]
88 pub enum RpcResponse {
89 /// Single-text reply (Llm / Rlm).
90 Single(SingleResp),
91 /// Batch reply (LlmBatch / RlmBatch).
92 Batch(BatchResp),
93 }
94
95 #[derive(Debug, Clone, Serialize, Deserialize)]
96 pub struct SingleResp {
97 #[serde(default)]
98 pub text: String,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub error: Option<String>,
101 }
102
103 #[derive(Debug, Clone, Serialize, Deserialize)]
104 pub struct BatchResp {
105 pub results: Vec<SingleResp>,
106 }
107
108 /// Trait-object handle for dispatching Python RPCs back into Rust.
109 ///
110 /// Each RLM turn supplies one. Implementations forward to the LLM client
111 /// (and recursively into `run_rlm_turn_inner` for `Rlm` / `RlmBatch`).
112 pub trait RpcDispatcher: Send + Sync {
113 fn dispatch<'a>(
114 &'a self,
115 req: RpcRequest,
116 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RpcResponse> + Send + 'a>>;
117 }
118
119 // ---------------------------------------------------------------------------
120 // Constants
121 // ---------------------------------------------------------------------------
122
123 const DEFAULT_STDOUT_LIMIT: usize = 8_192;
124 const ROUND_TIMEOUT: Duration = Duration::from_secs(180);
125 #[cfg(not(windows))]
126 const SPAWN_READY_TIMEOUT: Duration = Duration::from_secs(10);
127 #[cfg(windows)]
128 const SPAWN_READY_TIMEOUT: Duration = Duration::from_secs(30);
129
130 // ---------------------------------------------------------------------------
131 // PythonRuntime
132 // ---------------------------------------------------------------------------
133
134 /// Long-lived Python REPL.
135 #[derive(Debug)]
136 pub struct PythonRuntime {
137 child: Child,
138 stdin: ChildStdin,
139 stdout: BufReader<ChildStdout>,
140 /// Per-spawn session id used in protocol sentinels.
141 session_id: String,
142 /// Path to the file holding `context` (kept around for cleanup).
143 context_path: Option<PathBuf>,
144 stdout_limit: usize,
145 round_count: u64,
146 started: Instant,
147 }
148
149 impl PythonRuntime {
150 /// Spawn a REPL with no `context` variable and no LLM helpers wired up.
151 /// Used by the agent loop for inline `repl` blocks the model emits in
152 /// regular conversation.
153 pub async fn new() -> Result<Self, String> {
154 Self::spawn_inner(None).await
155 }
156
157 /// Compatibility shim — older RLM code path used to pass a state file.
158 /// The state file is no longer used, but the path doubles as an extra
159 /// scratch location callers can rely on for cleanup symmetry.
160 pub fn with_state_path(_path: PathBuf) -> Self {
161 // Synchronous constructor is no longer meaningful: spawning Python
162 // is async. Callers in turn.rs already use `spawn_with_context` —
163 // this stub is kept only so the public surface compiles for any
164 // out-of-tree user. It returns a deliberately broken runtime that
165 // panics on first use, which is preferable to silently lying.
166 unreachable!(
167 "PythonRuntime::with_state_path is deprecated — \
168 use PythonRuntime::new() or PythonRuntime::spawn_with_context()"
169 )
170 }
171
172 /// Spawn a REPL with `context` (and `ctx`) preloaded from a file. Used
173 /// by the RLM turn loop.
174 pub async fn spawn_with_context(context_path: &Path) -> Result<Self, String> {
175 Self::spawn_inner(Some(context_path)).await
176 }
177
178 async fn spawn_inner(context_path: Option<&Path>) -> Result<Self, String> {
179 let session_id = Uuid::new_v4().simple().to_string();
180 let bootstrap = render_bootstrap(&session_id);
181
182 let mut cmd = Command::new("python3");
183 cmd.arg("-u")
184 .arg("-c")
185 .arg(&bootstrap)
186 .stdin(Stdio::piped())
187 .stdout(Stdio::piped())
188 .stderr(Stdio::piped())
189 .kill_on_drop(true);
190
191 if let Some(path) = context_path {
192 cmd.env("RLM_CONTEXT_FILE", path);
193 }
194
195 let mut child = cmd
196 .spawn()
197 .map_err(|e| format!("failed to spawn python3: {e}"))?;
198
199 let stdin = child
200 .stdin
201 .take()
202 .ok_or_else(|| "python3 stdin pipe missing".to_string())?;
203 let raw_stdout = child
204 .stdout
205 .take()
206 .ok_or_else(|| "python3 stdout pipe missing".to_string())?;
207 let stdout = BufReader::new(raw_stdout);
208
209 let mut rt = Self {
210 child,
211 stdin,
212 stdout,
213 session_id: session_id.clone(),
214 context_path: context_path.map(Path::to_path_buf),
215 stdout_limit: DEFAULT_STDOUT_LIMIT,
216 round_count: 0,
217 started: Instant::now(),
218 };
219
220 // Wait for `__RLM_READY_<sid>__` before handing control back. If
221 // Python failed to start (missing module, syntax error in the
222 // bootstrap, etc.), this is where we'll find out.
223 let ready_sentinel = format!("__RLM_READY_{session_id}__");
224 match tokio::time::timeout(SPAWN_READY_TIMEOUT, rt.read_until_ready(&ready_sentinel)).await
225 {
226 Ok(Ok(())) => Ok(rt),
227 Ok(Err(e)) => {
228 let _ = rt.child.kill().await;
229 Err(format!("python3 bootstrap failed: {e}"))
230 }
231 Err(_) => {
232 let _ = rt.child.kill().await;
233 Err(format!(
234 "python3 bootstrap did not signal ready within {}s",
235 SPAWN_READY_TIMEOUT.as_secs()
236 ))
237 }
238 }
239 }
240
241 async fn read_until_ready(&mut self, ready_sentinel: &str) -> Result<(), String> {
242 loop {
243 let mut line = String::new();
244 let n = self
245 .stdout
246 .read_line(&mut line)
247 .await
248 .map_err(|e| format!("stdout read: {e}"))?;
249 if n == 0 {
250 return Err("python3 closed stdout before ready signal".to_string());
251 }
252 let trimmed = line.trim_end_matches(['\n', '\r']);
253 if trimmed == ready_sentinel {
254 return Ok(());
255 }
256 // Pre-ready output is rare; ignore it.
257 }
258 }
259
260 /// Execute a Python code block with no RPC dispatcher. Used for inline
261 /// `repl` blocks where `llm_query()` should fall back to a sentinel.
262 pub async fn execute(&mut self, code: &str) -> Result<ReplRound, String> {
263 self.run(code, None::<&dyn RpcDispatcher>).await
264 }
265
266 /// Execute a code block, dispatching any sub-LLM RPCs through `bridge`.
267 ///
268 /// Returns once Python emits `__RLM_DONE_<sid>__` or the round timeout
269 /// elapses (whichever happens first).
270 pub async fn run<D>(&mut self, code: &str, bridge: Option<&D>) -> Result<ReplRound, String>
271 where
272 D: RpcDispatcher + ?Sized,
273 {
274 let started = Instant::now();
275 self.round_count += 1;
276 let round_id = self.round_count;
277
278 // Send the code header + body + end marker in one write.
279 let header = format!("__RLM_RUN_{}__::{round_id}\n", self.session_id);
280 let footer = format!("__RLM_END_{}__\n", self.session_id);
281 let payload = format!("{header}{code}\n{footer}");
282 self.stdin
283 .write_all(payload.as_bytes())
284 .await
285 .map_err(|e| format!("stdin write: {e}"))?;
286 self.stdin
287 .flush()
288 .await
289 .map_err(|e| format!("stdin flush: {e}"))?;
290
291 // Sentinels for this session.
292 let req_prefix = format!("__RLM_REQ_{}__::", self.session_id);
293 let final_prefix = format!("__RLM_FINAL_{}__::", self.session_id);
294 let err_prefix = format!("__RLM_ERR_{}__::", self.session_id);
295 let done_prefix = format!("__RLM_DONE_{}__::", self.session_id);
296
297 let mut stdout_buf = String::new();
298 let mut final_value: Option<String> = None;
299 let mut had_error = false;
300 let mut rpc_count: u32 = 0;
301
302 let read_loop = async {
303 loop {
304 let mut line = String::new();
305 let n = self
306 .stdout
307 .read_line(&mut line)
308 .await
309 .map_err(|e| format!("stdout read: {e}"))?;
310 if n == 0 {
311 return Err("python3 closed stdout mid-round".to_string());
312 }
313 let trimmed = line.trim_end_matches(['\n', '\r']);
314
315 if let Some(rest) = trimmed.strip_prefix(&done_prefix) {
316 let _ = rest;
317 break;
318 }
319 if let Some(rest) = trimmed.strip_prefix(&final_prefix) {
320 // Stored as a JSON-encoded string.
321 let v =
322 serde_json::from_str::<String>(rest).unwrap_or_else(|_| rest.to_string());
323 final_value = Some(v);
324 continue;
325 }
326 if let Some(rest) = trimmed.strip_prefix(&err_prefix) {
327 let traceback =
328 serde_json::from_str::<String>(rest).unwrap_or_else(|_| rest.to_string());
329 had_error = true;
330 stdout_buf.push_str(&format!("[traceback]\n{traceback}\n"));
331 continue;
332 }
333 if let Some(rest) = trimmed.strip_prefix(&req_prefix) {
334 rpc_count = rpc_count.saturating_add(1);
335 let req: RpcRequest = match serde_json::from_str(rest) {
336 Ok(r) => r,
337 Err(e) => {
338 // Send an error response so Python isn't blocked.
339 self.send_resp(&RpcResponse::Single(SingleResp {
340 text: String::new(),
341 error: Some(format!("malformed RPC: {e}")),
342 }))
343 .await?;
344 continue;
345 }
346 };
347 let resp = match bridge {
348 Some(b) => b.dispatch(req).await,
349 None => RpcResponse::Single(SingleResp {
350 text: String::new(),
351 error: Some("no LLM bridge bound to this REPL".to_string()),
352 }),
353 };
354 self.send_resp(&resp).await?;
355 continue;
356 }
357
358 stdout_buf.push_str(&line);
359 }
360 Ok::<_, String>(())
361 };
362
363 match tokio::time::timeout(ROUND_TIMEOUT, read_loop).await {
364 Ok(Ok(())) => {}
365 Ok(Err(e)) => return Err(e),
366 Err(_) => {
367 return Err(format!(
368 "REPL round timed out after {}s",
369 ROUND_TIMEOUT.as_secs()
370 ));
371 }
372 }
373
374 let stderr = self.drain_stderr().await;
375 let display = truncate_stdout(stdout_buf.trim_end_matches('\n'), self.stdout_limit);
376
377 Ok(ReplRound {
378 stdout: display,
379 full_stdout: stdout_buf,
380 stderr,
381 has_error: had_error,
382 final_value,
383 rpc_count,
384 elapsed: started.elapsed(),
385 })
386 }
387
388 async fn send_resp(&mut self, resp: &RpcResponse) -> Result<(), String> {
389 let body = serde_json::to_string(resp).map_err(|e| format!("encode rpc resp: {e}"))?;
390 let line = format!("__RLM_RESP_{}__::{body}\n", self.session_id);
391 self.stdin
392 .write_all(line.as_bytes())
393 .await
394 .map_err(|e| format!("stdin write resp: {e}"))?;
395 self.stdin
396 .flush()
397 .await
398 .map_err(|e| format!("stdin flush resp: {e}"))?;
399 Ok(())
400 }
401
402 async fn drain_stderr(&mut self) -> String {
403 // We don't continuously read stderr — drain whatever's pending after
404 // a round so it can show up in error reports without deadlocking
405 // anything during normal operation.
406 let Some(stderr) = self.child.stderr.as_mut() else {
407 return String::new();
408 };
409 use tokio::io::AsyncReadExt;
410 let mut buf = Vec::new();
411 // Best-effort read with a tight deadline; we don't want to block.
412 let fut = async {
413 let mut chunk = [0u8; 4096];
414 loop {
415 match tokio::time::timeout(Duration::from_millis(20), stderr.read(&mut chunk)).await
416 {
417 Ok(Ok(0)) => break,
418 Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
419 _ => break,
420 }
421 }
422 };
423 let _ = fut.await;
424 String::from_utf8_lossy(&buf).to_string()
425 }
426
427 /// Total rounds executed.
428 pub fn round_count(&self) -> u64 {
429 self.round_count
430 }
431
432 /// Wall-clock uptime since spawn.
433 pub fn uptime(&self) -> Duration {
434 self.started.elapsed()
435 }
436
437 /// Cleanly tear down the subprocess.
438 pub async fn shutdown(mut self) {
439 let _ = self.stdin.shutdown().await;
440 let _ = self.child.kill().await;
441 if let Some(path) = self.context_path.take() {
442 let _ = tokio::fs::remove_file(path).await;
443 }
444 }
445 }
446
447 impl Drop for PythonRuntime {
448 fn drop(&mut self) {
449 // tokio sets `kill_on_drop(true)` on the child; the context file
450 // (if any) is removed on `shutdown()` — drop is best-effort.
451 if let Some(path) = self.context_path.take() {
452 let _ = std::fs::remove_file(path);
453 }
454 }
455 }
456
457 // ---------------------------------------------------------------------------
458 // Bootstrap script
459 // ---------------------------------------------------------------------------
460
461 /// Render the Python bootstrap with session-specific sentinels baked in.
462 /// The sentinels include a UUID to prevent user prints from being mistaken
463 /// for control messages.
464 fn render_bootstrap(session_id: &str) -> String {
465 BOOTSTRAP_TEMPLATE.replace("__SID__", session_id)
466 }
467
468 const BOOTSTRAP_TEMPLATE: &str = r#"
469 import json as _json
470 import os as _os
471 import sys as _sys
472 import traceback as _traceback
473
474 _SID = "__SID__"
475 _REQ = f"__RLM_REQ_{_SID}__::"
476 _RESP = f"__RLM_RESP_{_SID}__::"
477 _FINAL = f"__RLM_FINAL_{_SID}__::"
478 _ERR = f"__RLM_ERR_{_SID}__::"
479 _RUN = f"__RLM_RUN_{_SID}__::"
480 _END = f"__RLM_END_{_SID}__"
481 _DONE = f"__RLM_DONE_{_SID}__::"
482 _READY = f"__RLM_READY_{_SID}__"
483
484 def _rpc(req):
485 _sys.stdout.write(_REQ + _json.dumps(req) + "\n")
486 _sys.stdout.flush()
487 line = _sys.stdin.readline()
488 if not line:
489 return {"error": "rust driver closed stdin"}
490 if line.startswith(_RESP):
491 try:
492 return _json.loads(line[len(_RESP):])
493 except Exception as e:
494 return {"error": f"malformed rpc resp: {e}"}
495 return {"error": f"unexpected protocol line: {line[:120]!r}"}
496
497 def llm_query(prompt, model=None, max_tokens=None, system=None):
498 """One-shot sub-LLM call. The model arg is accepted for compatibility but ignored by Rust."""
499 resp = _rpc({"type":"llm","prompt":str(prompt),"model":model,
500 "max_tokens":max_tokens,"system":system})
501 if isinstance(resp, dict) and resp.get("error"):
502 return f"[llm_query error: {resp['error']}]"
503 if isinstance(resp, dict):
504 return resp.get("text","")
505 return str(resp)
506
507 def llm_query_batched(prompts, model=None):
508 """Run multiple sub-LLM calls concurrently. The model arg is accepted for compatibility but ignored."""
509 if not isinstance(prompts, (list, tuple)):
510 return ["[llm_query_batched: prompts must be a list]"]
511 resp = _rpc({"type":"llm_batch","prompts":[str(p) for p in prompts],"model":model})
512 if isinstance(resp, dict) and resp.get("error"):
513 return [f"[llm_query_batched: {resp['error']}]" for _ in prompts]
514 results = (resp or {}).get("results", []) if isinstance(resp, dict) else []
515 if len(results) != len(prompts):
516 return [f"[llm_query_batched: size mismatch ({len(results)}/{len(prompts)})]" for _ in prompts]
517 out = []
518 for r in results:
519 if r.get("error"):
520 out.append(f"[child err: {r['error']}]")
521 else:
522 out.append(r.get("text",""))
523 return out
524
525 def rlm_query(prompt, model=None):
526 """Recursive sub-RLM. The model arg is accepted for compatibility but ignored by Rust."""
527 resp = _rpc({"type":"rlm","prompt":str(prompt),"model":model})
528 if isinstance(resp, dict) and resp.get("error"):
529 return f"[rlm_query error: {resp['error']}]"
530 if isinstance(resp, dict):
531 return resp.get("text","")
532 return str(resp)
533
534 def rlm_query_batched(prompts, model=None):
535 """Run multiple recursive sub-RLMs in parallel. The model arg is accepted for compatibility but ignored."""
536 if not isinstance(prompts, (list, tuple)):
537 return ["[rlm_query_batched: prompts must be a list]"]
538 resp = _rpc({"type":"rlm_batch","prompts":[str(p) for p in prompts],"model":model})
539 if isinstance(resp, dict) and resp.get("error"):
540 return [f"[rlm_query_batched: {resp['error']}]" for _ in prompts]
541 results = (resp or {}).get("results", []) if isinstance(resp, dict) else []
542 if len(results) != len(prompts):
543 return [f"[rlm_query_batched: size mismatch ({len(results)}/{len(prompts)})]" for _ in prompts]
544 out = []
545 for r in results:
546 if r.get("error"):
547 out.append(f"[child err: {r['error']}]")
548 else:
549 out.append(r.get("text",""))
550 return out
551
552 def FINAL(value):
553 """Signal the loop to stop with this final answer."""
554 _sys.stdout.write(_FINAL + _json.dumps(str(value)) + "\n")
555 _sys.stdout.flush()
556
557 def FINAL_VAR(name):
558 """Signal the loop to stop, returning the value of a named variable."""
559 name_str = str(name).strip().strip("'\"")
560 if name_str in globals():
561 FINAL(globals()[name_str])
562 else:
563 print(f"FINAL_VAR error: variable '{name_str}' not found. "
564 f"Use SHOW_VARS() to list available variables.", flush=True)
565
566 def SHOW_VARS():
567 """Return a dict of {name: type-name} for all user variables in the REPL."""
568 out = {}
569 for k, v in list(globals().items()):
570 if k.startswith('_') or k in _BOOTSTRAP_NAMES:
571 continue
572 out[k] = type(v).__name__
573 return out
574
575 def repl_get(name, default=None):
576 return globals().get(str(name), default)
577
578 def repl_set(name, value):
579 globals()[str(name)] = value
580
581 # Load the long input as `context` (and `ctx`) from a file. This keeps the
582 # big string out of the process command-line and out of the LLM's window.
583 _ctx_file = _os.environ.get("RLM_CONTEXT_FILE","")
584 context = ""
585 if _ctx_file:
586 try:
587 with open(_ctx_file, "r", encoding="utf-8", errors="replace") as f:
588 context = f.read()
589 except Exception as e:
590 _sys.stderr.write(f"[bootstrap] failed to load context: {e}\n")
591 ctx = context # short alias matching aleph
592
593 _BOOTSTRAP_NAMES = {
594 "_SID","_REQ","_RESP","_FINAL","_ERR","_RUN","_END","_DONE","_READY",
595 "_rpc","_ctx_file","_BOOTSTRAP_NAMES","_main_loop",
596 "llm_query","llm_query_batched","rlm_query","rlm_query_batched",
597 "FINAL","FINAL_VAR","SHOW_VARS","repl_get","repl_set",
598 "context","ctx",
599 "_json","_os","_sys","_traceback",
600 }
601
602 def _main_loop():
603 _sys.stdout.write(_READY + "\n")
604 _sys.stdout.flush()
605 while True:
606 header = _sys.stdin.readline()
607 if not header:
608 return
609 if not header.startswith(_RUN):
610 continue
611 round_id = header.rstrip("\n")[len(_RUN):]
612 code_lines = []
613 while True:
614 line = _sys.stdin.readline()
615 if not line:
616 return
617 if line.rstrip("\n") == _END:
618 break
619 code_lines.append(line)
620 code = "".join(code_lines)
621 try:
622 exec(compile(code, f"<repl-{round_id}>", "exec"), globals())
623 except SystemExit:
624 _sys.stdout.write(_DONE + round_id + "\n")
625 _sys.stdout.flush()
626 return
627 except BaseException:
628 tb = _traceback.format_exc()
629 _sys.stdout.write(_ERR + _json.dumps(tb) + "\n")
630 _sys.stdout.flush()
631 _sys.stdout.write(_DONE + round_id + "\n")
632 _sys.stdout.flush()
633
634 _main_loop()
635 "#;
636
637 // ---------------------------------------------------------------------------
638 // Helpers
639 // ---------------------------------------------------------------------------
640
641 fn truncate_stdout(stdout: &str, limit: usize) -> String {
642 if stdout.len() <= limit {
643 return stdout.to_string();
644 }
645 let take = limit.saturating_sub(80);
646 let mut out: String = stdout.chars().take(take).collect();
647 let omitted = stdout.len().saturating_sub(out.len());
648 out.push_str(&format!(
649 "\n\n[... REPL output truncated: {omitted} bytes omitted ...]\n"
650 ));
651 out
652 }
653
654 // ---------------------------------------------------------------------------
655 // Tests
656 // ---------------------------------------------------------------------------
657
658 #[cfg(test)]
659 mod tests {
660 use super::*;
661 use std::sync::Arc;
662 use std::sync::atomic::{AtomicU32, Ordering};
663 use tokio::sync::Mutex;
664
665 /// In-process dispatcher that records what was asked and replies with
666 /// canned text. Lets tests verify the round-trip without real network.
667 struct StubBridge {
668 calls: Arc<Mutex<Vec<RpcRequest>>>,
669 canned: Arc<AtomicU32>,
670 }
671
672 impl StubBridge {
673 fn new() -> Self {
674 Self {
675 calls: Arc::new(Mutex::new(Vec::new())),
676 canned: Arc::new(AtomicU32::new(0)),
677 }
678 }
679 }
680
681 impl RpcDispatcher for StubBridge {
682 fn dispatch<'a>(
683 &'a self,
684 req: RpcRequest,
685 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RpcResponse> + Send + 'a>> {
686 Box::pin(async move {
687 self.calls.lock().await.push(req.clone());
688 let n = self.canned.fetch_add(1, Ordering::Relaxed);
689 match req {
690 RpcRequest::Llm { prompt, .. } | RpcRequest::Rlm { prompt, .. } => {
691 RpcResponse::Single(SingleResp {
692 text: format!("stub#{n}: {prompt}"),
693 error: None,
694 })
695 }
696 RpcRequest::LlmBatch { prompts, .. } | RpcRequest::RlmBatch { prompts, .. } => {
697 let results = prompts
698 .into_iter()
699 .enumerate()
700 .map(|(i, p)| SingleResp {
701 text: format!("stub#{n}.{i}: {p}"),
702 error: None,
703 })
704 .collect();
705 RpcResponse::Batch(BatchResp { results })
706 }
707 }
708 })
709 }
710 }
711
712 fn write_temp_context(body: &str) -> std::path::PathBuf {
713 let dir = std::env::temp_dir().join("deepseek_repl_runtime_tests");
714 std::fs::create_dir_all(&dir).unwrap();
715 let path = dir.join(format!("ctx_{}_{}.txt", std::process::id(), Uuid::new_v4()));
716 std::fs::write(&path, body).unwrap();
717 path
718 }
719
720 #[tokio::test]
721 async fn spawns_and_executes_simple_print() {
722 let mut rt = PythonRuntime::new().await.expect("spawn");
723 let round = rt.execute("print('hello world')").await.expect("execute");
724 assert!(round.stdout.contains("hello world"));
725 assert!(!round.has_error);
726 assert!(round.final_value.is_none());
727 assert_eq!(round.rpc_count, 0);
728 rt.shutdown().await;
729 }
730
731 #[tokio::test]
732 async fn variables_persist_across_rounds() {
733 let mut rt = PythonRuntime::new().await.expect("spawn");
734 rt.execute("x = [1, 2, 3]").await.expect("r1");
735 rt.execute("x.append(99)").await.expect("r2");
736 let round = rt.execute("print(x)").await.expect("r3");
737 assert!(round.stdout.contains("[1, 2, 3, 99]"));
738 rt.shutdown().await;
739 }
740
741 #[tokio::test]
742 async fn imports_persist_across_rounds() {
743 let mut rt = PythonRuntime::new().await.expect("spawn");
744 rt.execute("import math").await.expect("r1");
745 let round = rt.execute("print(math.pi)").await.expect("r2");
746 assert!(round.stdout.contains("3.14"));
747 rt.shutdown().await;
748 }
749
750 #[tokio::test]
751 async fn context_loads_from_file() {
752 let path = write_temp_context("the quick brown fox");
753 let mut rt = PythonRuntime::spawn_with_context(&path)
754 .await
755 .expect("spawn");
756 let round = rt
757 .execute("print(len(context), context[:5])")
758 .await
759 .expect("execute");
760 assert!(round.stdout.contains("19"));
761 assert!(round.stdout.contains("the q"));
762 rt.shutdown().await;
763 }
764
765 #[tokio::test]
766 async fn ctx_alias_works() {
767 let path = write_temp_context("aleph-style");
768 let mut rt = PythonRuntime::spawn_with_context(&path)
769 .await
770 .expect("spawn");
771 let round = rt.execute("print(ctx)").await.expect("execute");
772 assert!(round.stdout.contains("aleph-style"));
773 rt.shutdown().await;
774 }
775
776 #[tokio::test]
777 async fn final_is_captured() {
778 let mut rt = PythonRuntime::new().await.expect("spawn");
779 let round = rt
780 .execute("FINAL('the answer is 42')")
781 .await
782 .expect("execute");
783 assert_eq!(round.final_value.as_deref(), Some("the answer is 42"));
784 rt.shutdown().await;
785 }
786
787 #[tokio::test]
788 async fn final_var_is_captured() {
789 let mut rt = PythonRuntime::new().await.expect("spawn");
790 rt.execute("answer = 'computed'").await.expect("r1");
791 let round = rt.execute("FINAL_VAR('answer')").await.expect("r2");
792 assert_eq!(round.final_value.as_deref(), Some("computed"));
793 rt.shutdown().await;
794 }
795
796 #[tokio::test]
797 async fn errors_are_reported_without_killing_runtime() {
798 let mut rt = PythonRuntime::new().await.expect("spawn");
799 let r1 = rt.execute("raise ValueError('boom')").await.expect("r1");
800 assert!(r1.has_error);
801 assert!(r1.full_stdout.contains("boom") || r1.stdout.contains("boom"));
802 // The runtime is still alive — next round should work.
803 let r2 = rt.execute("print('still here')").await.expect("r2");
804 assert!(r2.stdout.contains("still here"));
805 rt.shutdown().await;
806 }
807
808 #[tokio::test]
809 async fn rpc_dispatcher_round_trips_llm_query() {
810 let bridge = StubBridge::new();
811 let calls = Arc::clone(&bridge.calls);
812
813 let mut rt = PythonRuntime::new().await.expect("spawn");
814 let round = rt
815 .run("print(llm_query('hello'))", Some(&bridge))
816 .await
817 .expect("execute");
818 assert!(
819 round.stdout.contains("stub#0: hello"),
820 "stdout: {:?}",
821 round.stdout
822 );
823 assert_eq!(round.rpc_count, 1);
824
825 let recorded = calls.lock().await;
826 assert_eq!(recorded.len(), 1);
827 match &recorded[0] {
828 RpcRequest::Llm { prompt, .. } => assert_eq!(prompt, "hello"),
829 other => panic!("expected Llm request, got {other:?}"),
830 }
831 drop(recorded);
832 rt.shutdown().await;
833 }
834
835 #[tokio::test]
836 async fn rpc_dispatcher_round_trips_batch() {
837 let bridge = StubBridge::new();
838 let mut rt = PythonRuntime::new().await.expect("spawn");
839 let round = rt
840 .run(
841 "outs = llm_query_batched(['a','b','c']); print('|'.join(outs))",
842 Some(&bridge),
843 )
844 .await
845 .expect("execute");
846 assert!(round.stdout.contains("stub#0.0: a"));
847 assert!(round.stdout.contains("stub#0.1: b"));
848 assert!(round.stdout.contains("stub#0.2: c"));
849 assert_eq!(round.rpc_count, 1);
850 rt.shutdown().await;
851 }
852
853 #[tokio::test]
854 async fn no_dispatcher_returns_unavailable_sentinel() {
855 let mut rt = PythonRuntime::new().await.expect("spawn");
856 let round = rt.execute("print(llm_query('hi'))").await.expect("execute");
857 assert!(
858 round.stdout.contains("[llm_query error:") || round.stdout.contains("no LLM bridge"),
859 "stdout: {:?}",
860 round.stdout
861 );
862 rt.shutdown().await;
863 }
864
865 #[test]
866 fn truncate_keeps_short_unchanged() {
867 assert_eq!(truncate_stdout("hello", 100), "hello");
868 }
869
870 #[test]
871 fn truncate_clips_long() {
872 let long = "a".repeat(10_000);
873 let out = truncate_stdout(&long, 1024);
874 assert!(out.len() < 1500);
875 assert!(out.contains("truncated"));
876 }
877 }
878
878 lines RUST