feat(core): compile-time media feature gate (media_generation + image) (#4804) (#4840)

Co-authored-by: M3gA-Mind <elvin@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-07-15 19:48:21 +05:30
committed by GitHub
co-authored by M3gA-Mind
parent 5ba512b201
commit 2dcaaba293
8 changed files with 82 additions and 21 deletions
+5 -3
View File
@@ -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:
+3
View File
@@ -233,11 +233,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.
+13 -1
View File
@@ -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 "<explicit list without media>"`.
# 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"]
+11 -1
View File
@@ -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]
+2
View File
@@ -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;
+3
View File
@@ -875,6 +875,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,
+34
View File
@@ -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();
@@ -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(
"<tool_call>{\"name\":\"round24_echo\",\"arguments\":{\"value\":\"again\"}}</tool_call>",
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, .. }
)));
}