Skip to main content

mnemo_engine/
embed_cmd.rs

1//! [`EmbedCmd`] — spawn a child process once, speak a line-delimited
2//! JSON protocol to it, reuse it for every subsequent embed (Phase 3,
3//! PR 10b + 10b.2).
4//!
5//! Motivation: a Python embedder loading `sentence-transformers` can
6//! take seconds just to import, so the obvious design — spawn one
7//! child per embed — makes MCP unusably slow. Instead the child runs
8//! once per [`crate::Mnemo`] handle, reads a line of JSON per request
9//! from stdin, writes a line of JSON per response to stdout, and keeps
10//! running until the handle is dropped (closing stdin, which the
11//! child uses as its exit signal).
12//!
13//! ### Protocol
14//! - **Input (one per line):** a JSON-encoded string. Newlines inside
15//!   the string are escaped as `\n` by JSON, so framing is unambiguous.
16//! - **Output (one per line):** a JSON array of floats, in the same
17//!   order as the inputs.
18//! - The child **must `flush()` stdout after each line** or the next
19//!   [`EmbedCmd::embed`] call will time out (default 60s, configurable
20//!   via [`EmbedCmd::with_timeout`] and the `--embed-timeout` CLI
21//!   flag).
22//! - **Stdout is reserved for replies only.** Any line written to
23//!   stdout outside a reply (startup banners, log messages, warnings)
24//!   is treated as a protocol violation — EmbedCmd restarts the child
25//!   and surfaces a clear error. Log to **stderr** instead; stderr is
26//!   inherited to the parent and is safe to use.
27//! - Dimensionality is enforced per-vector; wrong-width or malformed
28//!   replies cause the child to be killed and respawned on the next
29//!   call, so no mismatched vector is ever returned.
30//!
31//! ### Reference embedder (Python + sentence-transformers)
32//! ```python
33//! import sys, json
34//! from sentence_transformers import SentenceTransformer
35//! m = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
36//! for line in sys.stdin:
37//!     text = json.loads(line)                       # one string
38//!     v = m.encode([text], normalize_embeddings=True)[0].tolist()
39//!     print(json.dumps(v), flush=True)              # one JSON array
40//! # Any logs: print("…", file=sys.stderr, flush=True)
41//! ```
42//! Then start the engine with:
43//! `mnemo serve --mcp agent.mnemo --embed-cmd "python3 embedder.py" --embed-name "sentence-transformers/all-MiniLM-L6-v2"`
44//!
45//! ### Correctness guarantees (PR 10b.2)
46//! - **Real timeouts.** A background thread owns the child's stdout
47//!   and drains lines into a channel; [`EmbedCmd::embed`] reads via
48//!   `recv_timeout`, so a silent-but-alive child trips the timeout
49//!   deterministically. The reader thread also keeps stdout drained
50//!   while we write stdin, which prevents the large-batch deadlock a
51//!   write-all-then-read-all design would have.
52//! - **Never a mismatched vector.** Any protocol error (write failure,
53//!   timeout, EOF, malformed JSON, wrong width, zero vector, or
54//!   unsolicited stdout output before a request) kills the child, drops
55//!   the reader thread, and marks the adapter for restart. The next
56//!   [`EmbedCmd::embed`] call spawns a fresh child + reader thread
57//!   with the same argv/env/timeout before writing anything, and logs
58//!   one line to stderr saying it restarted and why. The alternative
59//!   — reading leftover output as a new reply — would silently
60//!   corrupt recall quality, which is strictly worse than any error.
61
62use std::io::{BufRead, BufReader, Write};
63use std::process::{Child, ChildStdin, Command, Stdio};
64use std::sync::mpsc::{self, Receiver, RecvTimeoutError, TryRecvError};
65use std::sync::Mutex;
66use std::thread::JoinHandle;
67use std::time::{Duration, Instant};
68
69use crate::embed::{Embedder, EmbedderIdentity};
70use crate::error::{MnemoError, Result};
71
72/// Default per-request timeout. Set generously because a cold-start
73/// `sentence-transformers` call can genuinely take a few seconds the
74/// first time; configurable via [`EmbedCmd::with_timeout`] (Rust API)
75/// or the `--embed-timeout` CLI flag.
76pub const DEFAULT_EMBED_TIMEOUT: Duration = Duration::from_secs(60);
77
78/// What the reader thread sends over the channel. One `Line` per
79/// stdout line the child wrote, then exactly one terminal `Eof` or
80/// `Err(_)` and the thread exits.
81enum ReaderMsg {
82    Line(String),
83    Eof,
84    Err(String),
85}
86
87/// Persistent-child-process adapter for a user-supplied embedder
88/// command. See the module docs for the protocol.
89pub struct EmbedCmd {
90    identity: EmbedderIdentity,
91    /// Running-child state lives inside a `Mutex` so [`Embedder`] can
92    /// be `Sync` (the engine holds the embedder as `Arc<dyn Embedder>`
93    /// and the trait's `embed(&self, …)` is called via `&self`). The
94    /// lock is only held for the duration of one batch, which the
95    /// `&mut Mnemo` serialization already enforces at the call site.
96    state: Mutex<Option<ChildState>>,
97    timeout: Duration,
98    /// Pretty command string for error messages — the raw argv as
99    /// parsed, joined back for display. Not used for execution.
100    cmd_display: String,
101    /// Argv the next spawn will use. Captured from `cmd` at
102    /// construction so a respawn after error reuses identical args.
103    argv: Vec<String>,
104}
105
106/// All the handles belonging to one running child + its reader thread.
107struct ChildState {
108    child: Child,
109    stdin: ChildStdin,
110    /// Receives each stdout line (or the terminal `Eof`/`Err`) from
111    /// the reader thread. Dropping the sender inside the thread lets
112    /// `recv_timeout` detect EOF after the terminal message.
113    stdout_rx: Receiver<ReaderMsg>,
114    /// Reader thread handle, kept for `Drop`. Joining is best-effort —
115    /// the thread exits once the child's stdout closes, which happens
116    /// when we kill the child in [`EmbedCmd::teardown`].
117    reader: Option<JoinHandle<()>>,
118}
119
120impl EmbedCmd {
121    /// Spawn `cmd` (a shell-style command string: `"python3 embedder.py"`
122    /// or `"my-tool --model=foo"`) and prepare it to accept embed
123    /// requests. The command is split by whitespace and single/double
124    /// quotes — see [`shlex_split`] — and the program is launched
125    /// directly, **not** through a shell. On Windows this means
126    /// forward-slash paths work; on Unix it means no `~` expansion
127    /// and no environment-variable substitution.
128    ///
129    /// `identity` is the [`EmbedderIdentity`] this adapter will report;
130    /// it is matched against the database's manifest by
131    /// [`crate::Mnemo::set_embedder`]. Pick values that describe what
132    /// the child process actually computes — mismatched identities
133    /// would corrupt recall silently.
134    pub fn new(cmd: &str, identity: EmbedderIdentity) -> Result<Self> {
135        let argv = shlex_split(cmd).ok_or_else(|| {
136            MnemoError::Invalid(format!(
137                "could not parse embed-cmd (unbalanced quote?): {cmd:?}"
138            ))
139        })?;
140        if argv.is_empty() {
141            return Err(MnemoError::Invalid(
142                "embed-cmd is empty — pass a program path".into(),
143            ));
144        }
145        let state = spawn_child(&argv)?;
146        Ok(Self {
147            identity,
148            state: Mutex::new(Some(state)),
149            timeout: DEFAULT_EMBED_TIMEOUT,
150            cmd_display: cmd.to_string(),
151            argv,
152        })
153    }
154
155    /// Replace the per-request timeout. See [`DEFAULT_EMBED_TIMEOUT`].
156    pub fn with_timeout(mut self, timeout: Duration) -> Self {
157        self.timeout = timeout;
158        self
159    }
160}
161
162impl Drop for EmbedCmd {
163    fn drop(&mut self) {
164        if let Ok(mut slot) = self.state.lock() {
165            if let Some(st) = slot.take() {
166                teardown(st);
167            }
168        }
169    }
170}
171
172/// Kill the child, best-effort reap, drop the stdin / receiver, and
173/// join the reader thread. Used both on `Drop` and whenever a protocol
174/// error marks the adapter for restart.
175fn teardown(mut st: ChildState) {
176    let _ = st.stdin.flush();
177    // Dropping stdin by moving it out ends the child's own read loop
178    // on EOF for well-behaved embedders.
179    drop(st.stdin);
180    // Give the child a brief moment to exit on its own.
181    let _ = st.child.try_wait();
182    // Then kill and reap. `wait()` is required to avoid a zombie.
183    let _ = st.child.kill();
184    let _ = st.child.wait();
185    // Dropping the receiver unblocks the reader thread's send on the
186    // next line (channel closed → send errors → thread exits). Joining
187    // is best-effort: if the thread was blocked in `read_line` and the
188    // child-killed stdout close didn't wake it fast enough, we don't
189    // want to hold Drop indefinitely.
190    drop(st.stdout_rx);
191    if let Some(j) = st.reader.take() {
192        // Discard the join result — any panic in the reader thread is
193        // already behind us; we can't propagate it from Drop anyway.
194        let _ = j.join();
195    }
196}
197
198/// Spawn one child + reader thread. Returns a fresh [`ChildState`].
199fn spawn_child(argv: &[String]) -> Result<ChildState> {
200    let (program, args) = argv.split_first().expect("argv non-empty by caller");
201    let mut child = Command::new(program)
202        .args(args)
203        .stdin(Stdio::piped())
204        .stdout(Stdio::piped())
205        .stderr(Stdio::inherit())
206        .spawn()
207        .map_err(|e| {
208            MnemoError::Invalid(format!(
209                "could not spawn embed-cmd {program:?}: {e} \
210                 (program must be on PATH and executable)"
211            ))
212        })?;
213    let stdin = child.stdin.take().expect("stdin was piped above");
214    let stdout = BufReader::new(child.stdout.take().expect("stdout was piped above"));
215    let (tx, rx) = mpsc::channel::<ReaderMsg>();
216    let reader = std::thread::Builder::new()
217        .name("mnemo-embed-cmd-reader".into())
218        .spawn(move || {
219            let mut rdr = stdout;
220            let mut buf = String::new();
221            loop {
222                buf.clear();
223                match rdr.read_line(&mut buf) {
224                    Ok(0) => {
225                        let _ = tx.send(ReaderMsg::Eof);
226                        break;
227                    }
228                    Ok(_) => {
229                        if tx.send(ReaderMsg::Line(buf.clone())).is_err() {
230                            // Receiver dropped — nothing to do but exit.
231                            break;
232                        }
233                    }
234                    Err(e) => {
235                        let _ = tx.send(ReaderMsg::Err(e.to_string()));
236                        break;
237                    }
238                }
239            }
240        })
241        .map_err(|e| {
242            MnemoError::Invalid(format!("could not start embed-cmd reader thread: {e}"))
243        })?;
244    Ok(ChildState {
245        child,
246        stdin,
247        stdout_rx: rx,
248        reader: Some(reader),
249    })
250}
251
252impl EmbedCmd {
253    /// Ensure a running child is available, respawning if necessary.
254    /// On respawn, logs one stderr line explaining why.
255    fn ensure_child<'a>(
256        &self,
257        slot: &'a mut Option<ChildState>,
258        reason_if_respawn: &str,
259    ) -> Result<&'a mut ChildState> {
260        if slot.is_none() {
261            eprintln!(
262                "mnemo embed-cmd: restarting {} ({})",
263                self.cmd_display, reason_if_respawn
264            );
265            *slot = Some(spawn_child(&self.argv)?);
266        }
267        Ok(slot.as_mut().expect("just filled"))
268    }
269
270    /// Fail the current request with `msg`, kill the child, mark for
271    /// restart. The next `embed()` call spawns a fresh child.
272    fn fail_and_restart(&self, slot: &mut Option<ChildState>, msg: String) -> MnemoError {
273        if let Some(st) = slot.take() {
274            teardown(st);
275        }
276        MnemoError::Invalid(msg)
277    }
278}
279
280impl Embedder for EmbedCmd {
281    fn identity(&self) -> EmbedderIdentity {
282        self.identity.clone()
283    }
284
285    fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
286        if texts.is_empty() {
287            return Ok(Vec::new());
288        }
289        let mut slot = self.state.lock().map_err(|_| {
290            MnemoError::Invalid("embed-cmd state poisoned by an earlier panic".into())
291        })?;
292
293        // Respawn if the previous call tore the child down.
294        self.ensure_child(&mut slot, "previous request errored")?;
295
296        // Before writing, probe the channel. Any message waiting here
297        // is unsolicited (the previous call either read all its replies
298        // or restarted); treat it as a protocol violation. One probe
299        // is enough — the first stale message already means the pipe
300        // state is wrong, and we're going to kill + respawn anyway.
301        {
302            let st = slot.as_mut().expect("ensure_child above");
303            match st.stdout_rx.try_recv() {
304                Ok(ReaderMsg::Line(line)) => {
305                    return Err(self.fail_and_restart(
306                        &mut slot,
307                        format!(
308                            "embed-cmd {} wrote to stdout outside a reply (line: {:?}). \
309                             Stdout is reserved for replies only; log to stderr instead. \
310                             The child has been killed; the next call will respawn.",
311                            self.cmd_display,
312                            line.trim_end()
313                        ),
314                    ));
315                }
316                Ok(ReaderMsg::Eof) => {
317                    return Err(self.fail_and_restart(
318                        &mut slot,
319                        format!(
320                            "embed-cmd {} closed stdout before this request. \
321                             The next call will respawn.",
322                            self.cmd_display
323                        ),
324                    ));
325                }
326                Ok(ReaderMsg::Err(e)) => {
327                    return Err(self.fail_and_restart(
328                        &mut slot,
329                        format!(
330                            "embed-cmd {} stdout read error before this request: {e}. \
331                             The next call will respawn.",
332                            self.cmd_display
333                        ),
334                    ));
335                }
336                Err(TryRecvError::Empty) => {}
337                Err(TryRecvError::Disconnected) => {
338                    // Reader thread gone; respawn next call.
339                    return Err(self.fail_and_restart(
340                        &mut slot,
341                        format!(
342                            "embed-cmd {} reader thread died. The next call will respawn.",
343                            self.cmd_display
344                        ),
345                    ));
346                }
347            }
348        }
349
350        // Also probe for a dead child (OS-level reap).
351        {
352            let st = slot.as_mut().expect("ensure_child above");
353            if let Some(status) = st.child.try_wait().map_err(|e| {
354                MnemoError::Invalid(format!(
355                    "could not probe embed-cmd {}: {e}",
356                    self.cmd_display
357                ))
358            })? {
359                return Err(self.fail_and_restart(
360                    &mut slot,
361                    format!(
362                        "embed-cmd {} exited before this request: {status}. \
363                         The next call will respawn.",
364                        self.cmd_display
365                    ),
366                ));
367            }
368        }
369
370        // Write all inputs. The reader thread drains stdout in parallel
371        // so we don't deadlock on a large batch where the child's
372        // stdout fills before our stdin writes finish.
373        {
374            let st = slot.as_mut().expect("ensure_child above");
375            for t in texts {
376                let line = serde_json::to_string(t).map_err(|e| {
377                    MnemoError::Invalid(format!("could not encode embed-cmd input: {e}"))
378                })?;
379                if let Err(e) = writeln!(st.stdin, "{line}") {
380                    return Err(self.fail_and_restart(
381                        &mut slot,
382                        format!(
383                            "could not write to embed-cmd {}: {e}. The next call will respawn.",
384                            self.cmd_display
385                        ),
386                    ));
387                }
388            }
389            if let Err(e) = st.stdin.flush() {
390                return Err(self.fail_and_restart(
391                    &mut slot,
392                    format!(
393                        "could not flush embed-cmd {}: {e}. The next call will respawn.",
394                        self.cmd_display
395                    ),
396                ));
397            }
398        }
399
400        // Read exactly `texts.len()` reply lines, honoring the timeout.
401        // Any failure (timeout, EOF mid-batch, malformed JSON, wrong
402        // width, zero vector) kills the child so stale leftover output
403        // can't be mis-attributed to a later call.
404        let deadline = Instant::now() + self.timeout;
405        let mut out = Vec::with_capacity(texts.len());
406        for i in 0..texts.len() {
407            let remaining = deadline
408                .checked_duration_since(Instant::now())
409                .unwrap_or(Duration::ZERO);
410            let msg = {
411                let st = slot.as_mut().expect("ensure_child above");
412                st.stdout_rx.recv_timeout(remaining)
413            };
414            let line = match msg {
415                Ok(ReaderMsg::Line(l)) => l,
416                Ok(ReaderMsg::Eof) => {
417                    return Err(self.fail_and_restart(
418                        &mut slot,
419                        format!(
420                            "embed-cmd {} closed stdout while waiting for reply {}/{}. \
421                             The next call will respawn.",
422                            self.cmd_display,
423                            i + 1,
424                            texts.len()
425                        ),
426                    ));
427                }
428                Ok(ReaderMsg::Err(e)) => {
429                    return Err(self.fail_and_restart(
430                        &mut slot,
431                        format!(
432                            "embed-cmd {} stdout read error on reply {}/{}: {e}. \
433                             The next call will respawn.",
434                            self.cmd_display,
435                            i + 1,
436                            texts.len()
437                        ),
438                    ));
439                }
440                Err(RecvTimeoutError::Timeout) => {
441                    return Err(self.fail_and_restart(
442                        &mut slot,
443                        format!(
444                            "embed-cmd {} timed out after {:?} waiting for reply {}/{}. \
445                             The next call will respawn.",
446                            self.cmd_display,
447                            self.timeout,
448                            i + 1,
449                            texts.len()
450                        ),
451                    ));
452                }
453                Err(RecvTimeoutError::Disconnected) => {
454                    return Err(self.fail_and_restart(
455                        &mut slot,
456                        format!(
457                            "embed-cmd {} reader thread exited mid-request. \
458                             The next call will respawn.",
459                            self.cmd_display
460                        ),
461                    ));
462                }
463            };
464            let v: Vec<f32> = match serde_json::from_str(line.trim()) {
465                Ok(v) => v,
466                Err(e) => {
467                    return Err(self.fail_and_restart(
468                        &mut slot,
469                        format!(
470                            "embed-cmd {} returned malformed JSON on reply {}/{}: {e} (line: {:?}). \
471                             The next call will respawn.",
472                            self.cmd_display,
473                            i + 1,
474                            texts.len(),
475                            line.trim_end()
476                        ),
477                    ));
478                }
479            };
480            if v.len() != self.identity.dimensions {
481                return Err(self.fail_and_restart(
482                    &mut slot,
483                    format!(
484                        "embed-cmd {} returned {} dims on reply {}/{} but identity says {}. \
485                         The next call will respawn.",
486                        self.cmd_display,
487                        v.len(),
488                        i + 1,
489                        texts.len(),
490                        self.identity.dimensions
491                    ),
492                ));
493            }
494            // FIX-1 R-001.6: the module doc (Correctness guarantees)
495            // already promises "any protocol error (… zero vector …)
496            // kills the child", but the check was missing before this
497            // PR — a buggy embedder returning `[0, 0, 0, …]` or
498            // `[NaN, …]` on one text would silently poison recall
499            // (Mnemo::remember refuses all-zero itself, but a cache
500            // write via `cache_get_semantic` or a `recall --text` call
501            // happily consumed the result). Reject both shapes
502            // through the same `fail_and_restart` path as a wrong
503            // width so the child is killed, the reader thread drops,
504            // and the next call spawns a fresh child.
505            if v.iter().all(|&x| x == 0.0) {
506                return Err(self.fail_and_restart(
507                    &mut slot,
508                    format!(
509                        "embed-cmd {} returned an all-zero vector on reply {}/{} \
510                         (an all-zero vector cannot be scored by cosine or dot-product \
511                         similarity; the module docs promise this is a protocol error). \
512                         The next call will respawn.",
513                        self.cmd_display,
514                        i + 1,
515                        texts.len()
516                    ),
517                ));
518            }
519            if v.iter().any(|x| !x.is_finite()) {
520                return Err(self.fail_and_restart(
521                    &mut slot,
522                    format!(
523                        "embed-cmd {} returned a non-finite value (NaN or ±∞) on \
524                         reply {}/{}. The next call will respawn.",
525                        self.cmd_display,
526                        i + 1,
527                        texts.len()
528                    ),
529                ));
530            }
531            out.push(v);
532        }
533        Ok(out)
534    }
535}
536
537/// Minimal shell-style tokenizer: splits on whitespace, honors
538/// single and double quotes, no backslash-escaping, no shell
539/// expansions. Returns `None` on an unbalanced quote.
540///
541/// Enough for the embedder-cmd use case where users pass something
542/// like `python3 embedder.py --model foo` or
543/// `"C:\Program Files\myembed.exe" --model foo`.
544pub(crate) fn shlex_split(input: &str) -> Option<Vec<String>> {
545    let mut out: Vec<String> = Vec::new();
546    let mut cur = String::new();
547    let mut in_single = false;
548    let mut in_double = false;
549    let mut had_content = false;
550    for c in input.chars() {
551        match c {
552            '\'' if !in_double => {
553                in_single = !in_single;
554                had_content = true;
555            }
556            '"' if !in_single => {
557                in_double = !in_double;
558                had_content = true;
559            }
560            c if c.is_whitespace() && !in_single && !in_double => {
561                if had_content {
562                    out.push(std::mem::take(&mut cur));
563                    had_content = false;
564                }
565            }
566            c => {
567                cur.push(c);
568                had_content = true;
569            }
570        }
571    }
572    if in_single || in_double {
573        return None;
574    }
575    if had_content {
576        out.push(cur);
577    }
578    Some(out)
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn shlex_handles_bare_quoted_mixed() {
587        assert_eq!(shlex_split("a b c").unwrap(), vec!["a", "b", "c"]);
588        assert_eq!(
589            shlex_split("python3 'my script.py' --model foo").unwrap(),
590            vec!["python3", "my script.py", "--model", "foo"]
591        );
592        assert_eq!(
593            shlex_split(r#""C:\Program Files\x.exe" --flag"#).unwrap(),
594            vec![r"C:\Program Files\x.exe", "--flag"]
595        );
596    }
597
598    #[test]
599    fn shlex_rejects_unbalanced() {
600        assert!(shlex_split("python 'oops").is_none());
601        assert!(shlex_split("python \"oops").is_none());
602    }
603
604    #[test]
605    fn shlex_handles_empty() {
606        assert_eq!(shlex_split("").unwrap(), Vec::<String>::new());
607        assert_eq!(shlex_split("   ").unwrap(), Vec::<String>::new());
608    }
609}