diff --git a/src/openhuman/config/schema/storage_memory.rs b/src/openhuman/config/schema/storage_memory.rs index 6c8220347..943987bf8 100644 --- a/src/openhuman/config/schema/storage_memory.rs +++ b/src/openhuman/config/schema/storage_memory.rs @@ -366,7 +366,13 @@ pub struct MemoryTreeConfig { } fn default_memory_tree_spacy_enabled() -> bool { - true + // Opt-in (#5056). Default OFF so a fresh install never provisions the spaCy + // venv + `en_core_web_sm` model on first launch, and the runtime Python + // server is not spawned on every boot when no local NLP is configured. + // Query-entity extraction degrades to the in-Rust regex+LLM extractor + // (`score::extract`); operators opt in via config or + // `OPENHUMAN_MEMORY_TREE_SPACY_ENABLED=1`. + false } /// Returns `None` so that existing installs that never opted into Phase 4 @@ -513,6 +519,15 @@ mod tests { assert_eq!(DEFAULT_CLOUD_LLM_MODEL, "summarization-v1"); } + /// #5056: spaCy is opt-in — a fresh install must never provision the + /// spaCy venv / `en_core_web_sm` model, nor spawn the runtime Python + /// server, without an explicit config or env-var opt-in. + #[test] + fn spacy_enabled_defaults_to_false() { + assert!(!MemoryTreeConfig::default().spacy_enabled); + assert!(!default_memory_tree_spacy_enabled()); + } + #[test] fn memory_tree_config_default_content_dir_is_none() { let cfg = MemoryTreeConfig::default(); diff --git a/src/openhuman/harness_init/registry.rs b/src/openhuman/harness_init/registry.rs index 80a793d00..be05b624a 100644 --- a/src/openhuman/harness_init/registry.rs +++ b/src/openhuman/harness_init/registry.rs @@ -5,9 +5,15 @@ //! orchestrates and reports; it does not reimplement any download logic. //! //! Current steps (all non-required — failure degrades to a fallback): -//! 1. `python_runtime` — managed CPython (prerequisite for spaCy). -//! 2. `spacy` — spaCy venv + `en_core_web_sm` model. -//! 3. `runtime_python_server` — long-running Python backend host. +//! 1. `python_runtime` — managed CPython. Only provisions eagerly when a +//! Python backend is actually enabled (`enabled_backends` non-empty); +//! otherwise it is a no-op and lazy consumers (Python tools / skills) +//! resolve the interpreter on first use (#5056). +//! 2. `spacy` — spaCy venv + `en_core_web_sm` model. Opt-in +//! (`memory_tree.spacy_enabled`, default OFF) so a fresh install does not +//! provision it — nor spawn the runtime Python server — on launch. +//! 3. `runtime_python_server` — long-running Python backend host. Derived: +//! launches only when `enabled_backends` is non-empty. //! 4. `node_runtime` — managed Node.js (skills / MCP). //! //! Voice models (Whisper, Piper) and Ollama stay lazy/opt-in and are @@ -73,9 +79,20 @@ fn python_runtime_step() -> HarnessInitStep { } } +/// Whether the managed interpreter must be provisioned **eagerly at boot**. +/// True only when Python is enabled AND a Python backend (spaCy / Kompress) +/// actually needs it. When no backend is enabled we skip the speculative +/// managed-CPython download entirely (#5056) — lazy consumers (Python tools / +/// skills, Python MCP servers) still resolve the interpreter on first use. +fn python_needed_eagerly(config: &Config) -> bool { + config.runtime_python.enabled + && !crate::openhuman::runtime_python_server::enabled_backends(config).is_empty() +} + async fn python_is_done(config: &Config) -> bool { - if !config.runtime_python.enabled { - // Disabled → nothing to provision; treat as satisfied. + if !python_needed_eagerly(config) { + // Nothing at boot needs the interpreter → treat as satisfied. Never + // downloads CPython speculatively. return true; } // Durable on-disk probe: survives restarts (unlike the process-local @@ -89,7 +106,7 @@ async fn python_is_done(config: &Config) -> bool { } async fn python_run(config: &Config) -> Result<(), String> { - if !config.runtime_python.enabled { + if !python_needed_eagerly(config) { return Ok(()); } use crate::openhuman::runtime_python::PythonBootstrap; @@ -301,6 +318,46 @@ mod tests { } } + /// #5056: on a fresh install (`Config::default()`) `runtime_python.enabled` + /// is `true` but no Python backend (spaCy/Kompress) is on, so the + /// `python_runtime` step must report itself already `Done` and `run` must + /// be a no-op — proving the eager managed-CPython download is skipped + /// when nothing at boot needs it. This is a pure gating check + /// (`python_needed_eagerly` returns `false`): it never touches disk or + /// resolves a real interpreter, so it stays hermetic. + #[tokio::test] + async fn python_runtime_step_is_done_by_default_with_no_backend_enabled() { + let config = Config::default(); + assert!( + !python_needed_eagerly(&config), + "default config should not need Python eagerly (no backend enabled)" + ); + assert!( + python_is_done(&config).await, + "python_runtime step should be Done without provisioning when no backend is enabled" + ); + assert!( + python_run(&config).await.is_ok(), + "python_runtime run should no-op when no backend is enabled" + ); + } + + /// Inverse of the above: once a backend (spaCy) is enabled, the step must + /// no longer be trivially `Done` via the eager-skip branch — proving the + /// gate still allows provisioning when a backend genuinely needs Python. + /// We only assert the gating predicate here (not `is_done`/`run`), so the + /// test never attempts a real interpreter probe/download. + #[test] + fn python_needed_eagerly_true_when_spacy_backend_enabled() { + let mut config = Config::default(); + config.runtime_python.enabled = true; + config.memory_tree.spacy_enabled = true; + assert!( + python_needed_eagerly(&config), + "python should be needed eagerly once a Python backend is enabled" + ); + } + #[tokio::test] async fn disabled_runtimes_report_done_without_work() { let mut config = Config::default(); diff --git a/src/openhuman/runtime_python_server/registry.rs b/src/openhuman/runtime_python_server/registry.rs index e34760f4a..9a7b4d32f 100644 --- a/src/openhuman/runtime_python_server/registry.rs +++ b/src/openhuman/runtime_python_server/registry.rs @@ -35,6 +35,19 @@ pub fn enabled_backends(config: &Config) -> Vec { mod tests { use super::*; + /// #5056: a fresh install (`Config::default()`) must not enable any + /// Python backend, so the runtime Python server never launches — and the + /// managed CPython interpreter is never speculatively downloaded — on a + /// default boot. + #[test] + fn enabled_backends_is_empty_by_default() { + let config = Config::default(); + assert!( + enabled_backends(&config).is_empty(), + "default config must not enable any runtime Python backend" + ); + } + #[test] fn registry_respects_runtime_and_spacy_flags() { let mut config = Config::default();