diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 870276ae6d..f807ef536c 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -354,8 +354,10 @@ jobs: # Feature-gate smoke: proves the core still compiles with a domain gate turned # OFF. The disabled build is the ONLY thing that catches stub-facade signature # drift (see AGENTS.md "Compile-time domain gates"), so it must run in CI, not - # just locally. Pathfinder lane for #4803 (voice); extend the --features list - # as sibling gates (#4797–#4802, #4804) land. + # just locally. Pathfinder lane for #4803 (voice); the explicit --features + # list (only tokenjuice-treesitter) turns every default gate OFF, so it also + # covers #4804 (media) and each sibling gate as it lands — no edit needed + # unless a new gate must stay ON here. rust-feature-gate-smoke: name: Rust Feature-Gate Smoke (gates off) needs: [changes] @@ -382,7 +384,7 @@ jobs: cache-on-failure: true shared-key: pr-rust-feature-gate-smoke - - name: Check core builds with the voice gate disabled + - name: Check core builds with the voice + media gates disabled run: bash scripts/ci-cancel-aware.sh cargo check --manifest-path Cargo.toml --no-default-features --features tokenjuice-treesitter rust-core-coverage: diff --git a/AGENTS.md b/AGENTS.md index e4d86eb785..69064d774e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -231,11 +231,14 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | Feature | Default | Gates | Drops deps | | ------- | ------- | ----- | ---------- | | `voice` | ON | `openhuman::voice` + `openhuman::audio_toolkit` domains — STT/TTS providers, dictation server, always-on listening, podcast audio + email | `hound`, `lettre` | +| `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. **Scope note:** the `voice` gate does **not** drop `whisper-rs` / `llama` / `cpal`. Those live in the inference domain (`src/openhuman/inference/local/service/whisper_engine.rs`; `cpal` is shared with accessibility) and await a separate future `inference` gate. The issue-level DoD line claiming whisper is dropped is superseded by this scope correction. +**Leaf-gate variant (`media`, #4804).** Unlike `voice`, the `media` gate needs **no** stub facade: `media_generation` has a single caller (the `build_media_tools` call in `src/openhuman/tools/ops.rs`, itself `#[cfg(feature = "media")]`) and `openhuman::image` is unwired scaffold (#2997), so both modules are simply `#[cfg(feature = "media")] pub mod …`. It is a **surface-only** gate: media generation is backend-proxied (`reqwest`, shared) and the `image` crate is shared with channel upload, so no exclusive deps are shed — the issue's "sheds media processing dependencies" / "controllers unregistered" DoD lines are superseded (Media is agent-tools-only; no controller/store/subscriber is tagged `Media`). When a gated domain is a true leaf, prefer this over the facade+stub. + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/Cargo.toml b/Cargo.toml index 4168dbe92d..ca000618eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -334,7 +334,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice"] +default = ["tokenjuice-treesitter", "voice", "media"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ @@ -351,6 +351,18 @@ tokenjuice-treesitter = [ # inference domain (shared with accessibility for cpal) and await a separate # `inference` gate. voice = ["dep:hound", "dep:lettre"] +# Media-generation + image domains: the `media_generate_*` agent tools +# (image/video via GMI through the backend) and the `openhuman::image` tool +# contracts scaffold. Default-ON. Slim builds opt out via +# `--no-default-features --features ""`. +# Composes with the runtime `DomainSet::media` flag (#4796). +# NOTE: this gate sheds NO exclusive dependencies — media generation is +# backend-proxied (reqwest, shared) and the `image` crate is shared with +# channel media upload. It is a surface-only gate (drops the tool code + +# module from the compile), not a dependency-shedding one. There are no +# controllers / stores / subscribers tagged `Media` (agent tools only), and +# `openhuman::image` is currently unwired scaffold (added #2997). +media = [] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 90d0b34bc5..4fd8845edd 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -132,7 +132,17 @@ cef = { version = "=146.4.1", default-features = false } # by tying the core's lifetime to the GUI process. The existing port-7788 # probe in `core_process::ensure_running` still attaches to a running # `openhuman-core run` harness when one is already listening. -openhuman_core = { path = "../..", package = "openhuman", default-features = false } +# +# `default-features = false` (set in #1061, before the compile-time domain +# gates existed) means the embedded core does NOT inherit the root crate's +# default gate set, so each default-ON gate must be forwarded explicitly to +# keep the shipped desktop build byte-identical (AGENTS.md "Compile-time +# domain gates"). `media` re-registers the `media_generate_*` agent tools +# that #4804 moved behind `#[cfg(feature = "media")]`; it sheds no deps, so +# this only restores the pre-gate desktop tool surface. +openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + "media", +] } tinyjuice = { version = "0.2.1", default-features = false } [target.'cfg(unix)'.dependencies] diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 74f8442046..601a569cfc 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -57,6 +57,7 @@ pub mod harness_init; pub mod health; pub mod heartbeat; pub mod http_host; +#[cfg(feature = "media")] pub mod image; pub mod inference; pub mod integrations; @@ -68,6 +69,7 @@ pub mod mcp_audit; pub mod mcp_client; pub mod mcp_registry; pub mod mcp_server; +#[cfg(feature = "media")] pub mod media_generation; pub mod meet; pub mod meet_agent; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index d96dac8688..b78c821097 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -844,6 +844,9 @@ pub fn all_tools_with_runtime( // Media generation (image/video via GMI through the backend). Skipped when // no integration client is configured; artifacts land under `action_dir`. + // Gated by the `media` compile-time feature (#4804); absent from slim + // builds. Runtime `DomainSet::media` (#4796) still gates it when compiled. + #[cfg(feature = "media")] tools.extend(crate::openhuman::media_generation::build_media_tools( root_config, action_dir, diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 1d762f46f3..96c63320e7 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -260,6 +260,40 @@ fn all_tools_always_registers_curl() { ); } +// Compile-time `media` feature gate (#4804). The media-generation agent tools +// (`media_generate_*`) are present only when the `media` feature is compiled +// in AND an integration client is configured. The disabled build proves the +// module + its single call site drop out entirely (leaf gate, no stub facade). +#[cfg(feature = "media")] +#[test] +fn media_tools_registered_when_feature_on() { + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, "http://127.0.0.1:1"); + store_test_session_token(&cfg); + let tools = integration_tools_for_config(&tmp, &cfg); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!( + names.contains(&"media_generate_image"), + "media tools must register with the `media` feature on + an integration \ + client; got: {names:?}" + ); +} + +#[cfg(not(feature = "media"))] +#[test] +fn media_tools_absent_when_feature_off() { + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, "http://127.0.0.1:1"); + store_test_session_token(&cfg); + let tools = integration_tools_for_config(&tmp, &cfg); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!( + !names.iter().any(|n| n.starts_with("media_")), + "no `media_*` tools may be registered when the `media` feature is off; \ + got: {names:?}" + ); +} + #[test] fn all_tools_registers_gitbooks_when_enabled() { let tmp = TempDir::new().unwrap(); diff --git a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs index 6865e8da0c..0e0d455b69 100644 --- a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs @@ -411,17 +411,13 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { let calls = Arc::new(AtomicUsize::new(0)); let hook_calls = Arc::new(AtomicUsize::new(0)); let hook_contexts = Arc::new(Mutex::new(Vec::new())); + // The wrap-up model call yields nothing (empty text + no streamed deltas), so + // `summarize_turn_wrapup` returns an empty summary and the cap path falls back + // to `build_deterministic_checkpoint` — the exact path this test covers. (A + // non-empty wrap-up would instead be surfaced verbatim as the answer.) let provider = ScriptedProvider::with_stream( - vec![ - xml_tool_response("alpha"), - text_response( - "{\"name\":\"round24_echo\",\"arguments\":{\"value\":\"again\"}}", - None, - ), - ], - vec![ProviderDelta::TextDelta { - delta: "checkpoint delta".to_string(), - }], + vec![xml_tool_response("alpha"), text_response("", None)], + vec![], ); let mut agent = Agent::builder() @@ -454,7 +450,7 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { let answer = agent.turn("hit the cap").await.unwrap(); assert!(answer.contains("I reached the tool-call limit for this turn (1 steps)")); - // The unified TurnEngine digest uses `- round24_echo [ok]: ...` format (no backticks). + // The deterministic checkpoint lists each executed tool, e.g. ``- `round24_echo` — ok``. assert!(answer.contains("round24_echo")); assert_eq!(calls.load(Ordering::SeqCst), 1); wait_for_hook_calls(&hook_calls, 1).await; @@ -480,12 +476,11 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { while let Ok(event) = progress_rx.try_recv() { streamed.push(event); } - assert!(streamed.iter().any(|event| matches!( + // The wrap-up produced no text, so no iteration-2 wrap-up delta is streamed; + // the deterministic fallback (asserted above) becomes the answer instead. + assert!(!streamed.iter().any(|event| matches!( event, - openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { - delta, - iteration: 2 - } if delta == "checkpoint delta" + openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { iteration: 2, .. } ))); }