Skip to main content

somatize_mcp/
exec.rs

1//! Running a graph the model described.
2//!
3//! `run_pipeline` and `run_study` used to echo their arguments back. The
4//! reason given was that the server cannot load user code — true of the
5//! *server*, which is a Rust binary, and irrelevant: Soma already runs
6//! Python filters in a subprocess everywhere else it needs to (that is
7//! what `soma-worker` is). This does the same thing, with the project
8//! directory on `sys.path`, so a model can build a graph out of the
9//! filters it just read with `read_filter_source` and actually run it.
10//!
11//! The subprocess writes its answer to a file rather than stdout,
12//! because a `print` inside a user's filter is the most natural thing in
13//! the world to write and must not corrupt the reply. Stdout and stderr
14//! come back as diagnostics either way.
15//!
16//! **This executes code from the project directory.** So does
17//! `write_filter_source`, which the same server has always offered — the
18//! model could already write a filter and ask the user to run it. What
19//! changes is the loop's length, not who is trusted.
20
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::process::{Command, Stdio};
24
25/// The Python driver, embedded. Reads a spec on stdin, writes a JSON
26/// result to `spec["result_path"]`.
27const DRIVER: &str = r#"
28import base64, importlib, importlib.util, json, os, sys, traceback
29
30spec = json.loads(sys.stdin.read())
31sys.path.insert(0, os.getcwd())
32result_path = spec["result_path"]
33
34def _fail(message, detail=None):
35    with open(result_path, "w") as fh:
36        json.dump({"ok": False, "error": message, "detail": detail}, fh)
37    sys.exit(0)
38
39def _load_from_file(path, cls_name):
40    mod_name = "_soma_mcp_" + os.path.basename(path).replace(".", "_")
41    spec_ = importlib.util.spec_from_file_location(mod_name, path)
42    if spec_ is None or spec_.loader is None:
43        raise ImportError("cannot load %s" % path)
44    module = importlib.util.module_from_spec(spec_)
45    sys.modules[mod_name] = module
46    spec_.loader.exec_module(module)
47    return getattr(module, cls_name)
48
49def _resolve(ref, candidates):
50    """`module.Class`, `path/to/file.py:Class`, or a bare class name.
51
52    A bare name is only looked for in the files the server already found
53    with `list_filters` — importing every .py under the project to go
54    looking would run whatever else is down there.
55    """
56    if ":" in ref:
57        path, _, cls_name = ref.partition(":")
58        return _load_from_file(path, cls_name)
59    if "." in ref:
60        mod_name, _, cls_name = ref.rpartition(".")
61        try:
62            return getattr(importlib.import_module(mod_name), cls_name)
63        except (ImportError, AttributeError):
64            pass  # may still be a bare dotted name; fall through to the scan
65    tried = []
66    for path in candidates:
67        try:
68            obj = _load_from_file(path, ref.rpartition(".")[2] or ref)
69        except Exception as e:
70            tried.append("%s: %s" % (path, e))
71            continue
72        return obj
73    raise ImportError(
74        "cannot resolve filter %r. Give it as `module.Class` or "
75        "`path/to/file.py:Class`. Tried: %s" % (ref, "; ".join(tried) or "nothing")
76    )
77
78def _config(value):
79    """Config values, with `{"__search__": {...}}` becoming a search dimension.
80
81    That is what makes a study expressible without a second vocabulary:
82    the same node spec describes a fixed value and a searched one, and
83    `graph.search_space()` picks the dimension up on its own.
84    """
85    import soma
86    if isinstance(value, dict):
87        if "__search__" in value:
88            return soma.search(**value["__search__"])
89        return {k: _config(v) for k, v in value.items()}
90    if isinstance(value, list):
91        return [_config(v) for v in value]
92    return value
93
94def _searchable(cls, config):
95    """Move `{"__search__": …}` values onto a subclass, where they count.
96
97    `search()` is a CLASS-level descriptor — `FilterMeta` collects the
98    ones it finds in a class body into `_soma_search_space`, and that is
99    what `graph.search_space()` reads. Passing one to a constructor sets
100    an instance attribute holding a descriptor, which is not a dimension
101    and cannot be hashed into a cache key either.
102
103    So the searched values become a subclass's body, and the constructor
104    is called WITHOUT them: its own default supplies a concrete value for
105    the identity, and each trial supplies the sampled one.
106    """
107    from soma.search import SearchDescriptor
108    searched = {k: v for k, v in config.items() if isinstance(v, SearchDescriptor)}
109    if not searched:
110        return cls, config
111    subclass = type(cls)(cls.__name__, (cls,), dict(searched))
112    return subclass, {k: v for k, v in config.items() if k not in searched}
113
114def _build(spec, overrides=None):
115    import soma
116    overrides = overrides or {}
117    g = soma.Graph(cache=spec.get("cache", "memory"))
118    candidates = spec.get("filter_files", [])
119    for node in spec["nodes"]:
120        cls = _resolve(node["filter"], candidates)
121        config = {k: _config(v) for k, v in (node.get("config") or {}).items()}
122        cls, config = _searchable(cls, config)
123        for key, value in overrides.get(node["id"], {}).items():
124            config[key] = value
125        try:
126            instance = cls(**config) if config else cls()
127        except TypeError as e:
128            raise TypeError(
129                "constructing node %r as %s(%s): %s"
130                % (node["id"], getattr(cls, "__name__", node["filter"]),
131                   ", ".join("%s=%r" % kv for kv in config.items()), e))
132        if node.get("target"):
133            g.node(node["id"], instance, target=node["target"])
134        else:
135            g.node(node["id"], instance)
136    for edge in spec.get("edges", []):
137        g.connect(edge[0], edge[1])
138    return g
139
140def _run_once(spec, g):
141    """Fit when asked (or when targets were given), then forward.
142
143    A graph with a trainable node refuses to forward before it is fitted,
144    so `fit` defaults to true rather than to "only when y is present".
145    """
146    x, y = spec.get("input"), spec.get("y")
147    if spec.get("fit", True):
148        g.fit(x, y)
149    return g.forward(x)
150
151def _jsonable(value, limit=2000):
152    """Outputs can be large; the model needs the shape more than the tail."""
153    if isinstance(value, list):
154        if len(value) > limit:
155            return {"truncated": True, "length": len(value),
156                    "head": _jsonable(value[:limit], limit)}
157        return [_jsonable(v, limit) for v in value]
158    if isinstance(value, dict):
159        return {k: _jsonable(v, limit) for k, v in value.items()}
160    if isinstance(value, (int, float, str, bool)) or value is None:
161        return value
162    for attr in ("tolist", "item"):
163        if hasattr(value, attr):
164            try:
165                return _jsonable(getattr(value, attr)(), limit)
166            except Exception:
167                pass
168    return repr(value)
169
170try:
171    import soma
172except ImportError as e:
173    _fail("soma is not importable by %s: %s" % (sys.executable, e),
174          "Install it in the interpreter the server uses, or set SOMA_PYTHON "
175          "to one that has it.")
176
177try:
178    if spec["kind"] == "pipeline":
179        g = _build(spec)
180        payload = {"plan": str(g.compile())}
181        if spec.get("track", True):
182            with g.track_run(spec.get("name", "mcp-run"),
183                             tags=list(spec.get("tags", [])),
184                             params=spec.get("params") or None) as run:
185                out = _run_once(spec, g)
186                # Absolute: the contract is that a model with file tools
187                # can go and read the directory, and it does not share
188                # this subprocess's working directory.
189                payload["run_dir"] = os.path.abspath(run.dir)
190        else:
191            out = _run_once(spec, g)
192        payload["ok"] = True
193        payload["output"] = _jsonable(out)
194        payload["state"] = _jsonable(g.state())
195        payload["mermaid"] = g.to_mermaid()
196    else:
197        g = _build(spec)
198        space = g.search_space()
199        if not space:
200            _fail("this graph has no search space: no node config used "
201                  '{"__search__": {...}}, so every trial would be identical',
202                  "Mark the dimensions to search in the node configs.")
203        metric = spec.get("metric", "score")
204        study = soma.Study(
205            spec.get("name", "mcp-study"),
206            search_space=space,
207            strategy=spec.get("strategy", "random"),
208            n_trials=int(spec.get("n_trials", 10)),
209            objectives=[(metric, spec.get("direction", "minimize"))],
210            seed=spec.get("seed"),
211        )
212
213        def executor(trial):
214            # A trial names its dimensions `node.field` — the same names
215            # `search_space()` produced — so the graph is rebuilt with the
216            # sampled values in place rather than mutated afterwards.
217            overrides = {}
218            for key in trial.keys():
219                node_id, _, field = key.rpartition(".")
220                overrides.setdefault(node_id, {})[field] = trial[key]
221            out = _run_once(spec, _build(spec, overrides))
222            value = out
223            if isinstance(value, dict):
224                if metric not in value:
225                    raise KeyError(
226                        "the graph produced %s, which has no %r to optimize. "
227                        "Name the metric your last node emits, or end the graph "
228                        "with one that emits it (soma.library.Eval does)."
229                        % (sorted(value), metric))
230                value = value[metric]
231            if isinstance(value, list):
232                if len(value) != 1:
233                    raise ValueError(
234                        "the graph produced %d values; an objective needs one. "
235                        "End it with a node that reduces to a single number."
236                        % len(value))
237                value = value[0]
238            return {metric: float(value)}
239
240        study.run(executor)
241        payload = {
242            "ok": True,
243            "n_trials": study.n_trials,
244            "best_trial": _jsonable(study.best_trial),
245            "trials": _jsonable(study.trials),
246            "run_dir": os.path.abspath(study.run_dir) if study.run_dir else None,
247            "objectives": [list(o) for o in study.objectives],
248        }
249    with open(result_path, "w") as fh:
250        json.dump(payload, fh)
251except BaseException as e:
252    _fail("%s: %s" % (type(e).__name__, e), traceback.format_exc())
253"#;
254
255/// Runs a graph spec in a Python subprocess rooted at the project.
256pub struct GraphRunner {
257    project_dir: PathBuf,
258    python: String,
259}
260
261impl GraphRunner {
262    /// A runner for `project_dir`, using `$SOMA_PYTHON` when set.
263    ///
264    /// The interpreter matters: it must be one that can `import soma` and
265    /// import the project's own modules. Defaulting to `python3` is right
266    /// far more often than guessing, and when it is wrong the error says
267    /// which interpreter could not find soma.
268    pub fn new(project_dir: impl Into<PathBuf>) -> Self {
269        Self {
270            project_dir: project_dir.into(),
271            python: std::env::var("SOMA_PYTHON").unwrap_or_else(|_| "python3".into()),
272        }
273    }
274
275    /// Run `spec`, returning the driver's JSON payload.
276    ///
277    /// `Err` is for the subprocess itself going wrong — no interpreter, a
278    /// crash, no answer written. A graph that failed to build or to run
279    /// comes back as `Ok` with `"ok": false`, because that is a result the
280    /// model can act on rather than an infrastructure problem.
281    pub fn run(&self, spec: &serde_json::Value) -> Result<serde_json::Value, String> {
282        let result_file = std::env::temp_dir().join(format!(
283            "soma-mcp-{}-{}.json",
284            std::process::id(),
285            somatize_core::util::timestamp_id("r")
286        ));
287        let mut spec = spec.clone();
288        spec["result_path"] = serde_json::json!(result_file.to_string_lossy());
289        spec["filter_files"] = serde_json::json!(self.filter_files());
290
291        let mut child = Command::new(&self.python)
292            .arg("-c")
293            .arg(DRIVER)
294            .current_dir(&self.project_dir)
295            .stdin(Stdio::piped())
296            .stdout(Stdio::piped())
297            .stderr(Stdio::piped())
298            .spawn()
299            .map_err(|e| {
300                format!(
301                    "cannot start `{}`: {e}. Set SOMA_PYTHON to an interpreter \
302                     that can import soma",
303                    self.python
304                )
305            })?;
306
307        {
308            let stdin = child
309                .stdin
310                .as_mut()
311                .ok_or_else(|| "the driver has no stdin".to_string())?;
312            stdin
313                .write_all(spec.to_string().as_bytes())
314                .map_err(|e| format!("sending the spec to the driver: {e}"))?;
315        }
316
317        let out = child
318            .wait_with_output()
319            .map_err(|e| format!("waiting for the driver: {e}"))?;
320        let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
321        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
322
323        let raw = std::fs::read_to_string(&result_file).map_err(|_| {
324            format!(
325                "the driver wrote no result (exit {}).\nstderr:\n{}\nstdout:\n{}",
326                out.status,
327                if stderr.is_empty() {
328                    "(empty)"
329                } else {
330                    &stderr
331                },
332                if stdout.is_empty() {
333                    "(empty)"
334                } else {
335                    &stdout
336                },
337            )
338        })?;
339        let _ = std::fs::remove_file(&result_file);
340
341        let mut payload: serde_json::Value = serde_json::from_str(&raw)
342            .map_err(|e| format!("the driver's result is not JSON ({e}): {raw}"))?;
343        // A filter's own prints are worth keeping — they are often the
344        // only trace of what a run was doing.
345        if !stdout.is_empty() {
346            payload["stdout"] = serde_json::json!(stdout);
347        }
348        if !stderr.is_empty() {
349            payload["stderr"] = serde_json::json!(stderr);
350        }
351        Ok(payload)
352    }
353
354    /// The project's filter files, so a node may name a bare class.
355    fn filter_files(&self) -> Vec<String> {
356        crate::context::find_filter_files(&self.project_dir)
357            .unwrap_or_default()
358            .iter()
359            .map(|p| relative_to(p, &self.project_dir))
360            .collect()
361    }
362}
363
364/// Paths the driver can open from its own working directory.
365fn relative_to(path: &Path, base: &Path) -> String {
366    path.strip_prefix(base)
367        .unwrap_or(path)
368        .to_string_lossy()
369        .into_owned()
370}