diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 1e02e343dd..97ef5bbec3 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -401,7 +401,7 @@ jobs: cache-on-failure: true shared-key: pr-rust-feature-gate-smoke - - name: Check core builds with the voice + media gates disabled + - name: Check core builds with the default domain 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 364dedf352..14b63465a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -241,6 +241,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | | `meet` | ON | `openhuman::meet` (join-URL validation) + `openhuman::meet_agent` (live STT/LLM/TTS loop) + `openhuman::agent_meetings` (backend-delegated Meet bot over Socket.IO) | none — see note | | `skills` | ON | `openhuman::skills` + `openhuman::skill_runtime` + `openhuman::skill_registry` domains — SKILL.md discovery/parse/install, workflow execution + run logs, remote catalogs, the `skill_setup` / `skill_executor` builtin agents, and the 16 skill agent tools | none (see below) | +| `flows` | ON | `openhuman::flows` (saved automation graphs — create/run/schedule, the `workflow_builder` + `flow_discovery` agents), `openhuman::tinyflows` (engine seam), `openhuman::rhai_workflows` (`.ragsh` language-workflow tool) | `tinyflows`, `jaq-core`, `jaq-std`, `jaq-json`, `rhai` | **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. @@ -276,6 +277,12 @@ Two places the carve-out doesn't reach, and why they are `#[cfg]` at the call si When skills are off: the `skills` / `skill_runtime` / `skill_registry` controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the 16 skill agent tools (incl. `run_workflow` / `await_workflow`) are **absent** from the tool list rather than degraded to an error, the `skill_setup` / `skill_executor` builtin agents are gone, and the boot-time remote catalog refresh is skipped. Composes with the runtime `DomainSet::skills` flag (#4796) — that axis needed no change here; #4798 is compile-time only. +**Leaf-gate pattern (`flows`).** Where `voice` needs a stub facade, `flows` needs **none** — and deliberately so. Every symbol reached from outside the gate is a *registration site* (controller push in `src/core/all.rs`, the `FlowTriggerSubscriber` in `src/core/jsonrpc.rs`, boot reconcile in `src/core/runtime/services.rs`, agent-tool `vec!` elements in `src/openhuman/tools/ops.rs`, `BuiltinAgent` entries in `agent_registry/agents/loader.rs`). Registration sites want **absence**: a stub that registered a controller returning `Err("flows disabled")` would make `flows.*` a *known* method that fails at runtime — the opposite of the intended "unknown method / omitted tool". So the three modules are `#[cfg(feature = "flows")]` at their `pub mod` declaration and each call site carries its own `#[cfg]`. Nothing inside `flows/`, `tinyflows/`, or `rhai_workflows/` is modified. There is no `openhuman flows` CLI subcommand, so no CLI stub is needed either. When flows is off: the `flows.*` controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), all 25 flow agent tools + `rhai_workflows` are absent, and the `workflow_builder` / `flow_discovery` built-in agents are not advertised. + +**Scope note (`flows` deps):** the gate sheds `tinyflows` + its `jaq-core` / `jaq-std` / `jaq-json` JSON-query stack, and `rhai`. It does **not** shed `tinyagents` — 26+ domains consume that crate. The issue-level DoD line reading "sheds the rhai scripting engine" is therefore true only at the **feature** level: `rhai` arrives via `tinyagents/repl`, which the root `Cargo.toml` no longer enables directly — the `flows` feature turns it on. Dropping `flows` drops `repl`, which drops `rhai`; `tinyagents` itself stays. Verify a claimed shed with `cargo tree -i --no-default-features --features tokenjuice-treesitter` (must return nothing) — compiling clean is **not** proof that a dep was dropped. + +**Testing gotcha (applies to every gate).** The CI smoke lane runs `cargo check` only — it never runs `cargo test --no-default-features`, so CI stays green while the disabled-build **test** suite is broken. Tests that hard-assert a gated family (`.expect("a flows.* method exists")`, `assert!(full_ns.contains("flows"))`, `group_for_namespace("flows")`, built-in-agent id lists) must be `#[cfg]`-gated in lockstep with the feature. Run `GGML_NATIVE=OFF cargo test --lib --no-default-features --features tokenjuice-treesitter core::` locally before pushing any gate change. + ### 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 000e22a42a..b21b8ac941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,10 @@ tinyplace = "2.0" # deterministic in-memory capability bundle the flows `dry_run_workflow` agent # tool (Phase 5b) runs a *draft* graph against so the workflow-builder agent can # self-verify a proposal without any real side effects (no real LLM/tool/HTTP/code). -tinyflows = { version = "0.5", features = ["mock"] } +# +# Optional: exclusive to the default-ON `flows` feature (#4797). A slim build +# without `flows` drops this crate and its `jaq-*` JSON-query stack entirely. +tinyflows = { version = "0.5", features = ["mock"], optional = true } # TinyJuice — host-agnostic TokenJuice compression engine. OpenHuman keeps # config/RPC/tool/runtime adapters in `src/openhuman/tokenjuice/` and patches # this dependency to the vendored submodule below. @@ -77,9 +80,12 @@ tinyjuice = { version = "0.2.1", default-features = false } # aligned to 0.40, avoiding duplicate `links = "sqlite3"` native bindings. # Durable graph checkpoints still use `SqlRunLedgerCheckpointer` until the # migration re-points those rows to the crate checkpointer. -# The `repl` feature adds the embedded Rhai `.ragsh` session runtime powering -# the `rhai_workflows` language-workflow tool (`src/openhuman/rhai_workflows/`). -tinyagents = { version = "1.7", features = ["sqlite", "repl"] } +# The `repl` feature (embedded Rhai `.ragsh` session runtime powering the +# `rhai_workflows` language-workflow tool, `src/openhuman/rhai_workflows/`) is +# NOT enabled here — the default-ON `flows` feature turns it on via +# `tinyagents/repl` (#4797), so a slim build without `flows` sheds `rhai`. +# The crate itself can never be dropped: 26+ domains consume tinyagents. +tinyagents = { version = "1.7", features = ["sqlite"] } # TinyCortex — Rust core for the memory engine (store/chunks/tree/retrieval/ # queue/ingest/score + long tail), vendored as a git submodule and patched # below to `vendor/tinycortex`. OpenHuman's memory subsystem migrates onto this @@ -341,7 +347,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows"] # 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 = [ @@ -384,6 +390,18 @@ web3 = ["dep:bitcoin", "dep:curve25519-dalek"] # controllers / stores / subscribers tagged `Media` (agent tools only), and # `openhuman::image` is currently unwired scaffold (added #2997). media = [] +# Flows domains: the `flows::` automation surface (saved tinyflows graphs — +# create/run/schedule + the workflow_builder / flow_discovery agents), the +# `tinyflows::` adapter seam, and the `rhai_workflows::` language-workflow tool. +# Default-ON — the desktop app always ships with Workflows. Slim / headless +# builds opt out via `--no-default-features --features ""`, +# which drops `tinyflows` + its `jaq-core`/`jaq-std`/`jaq-json` JSON-query stack +# and, via `tinyagents/repl`, the `rhai` scripting engine. Composes with the +# runtime `DomainSet::flows` flag (#4796): the feature narrows the compile-time +# surface, `DomainSet` gates it at runtime. +# NOTE: this gate does NOT drop `tinyagents` itself — 26+ domains consume it. +# Only its `repl` feature (⇒ `rhai`) is exclusive to flows. +flows = ["dep:tinyflows", "tinyagents/repl"] # Meet domains: `meet` (join-URL validation), `meet_agent` (live STT/LLM/TTS # loop over an open call), and `agent_meetings` (backend-delegated Meet bot via # Socket.IO). Default-ON — the desktop app always ships with Meet. Slim / diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 1677ee9280..70f2403053 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -166,6 +166,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal "voice", "tokenjuice-treesitter", "web3", + "flows", "meet", "skills", ] } diff --git a/src/core/all.rs b/src/core/all.rs index 948c916e0f..d686517cca 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -232,6 +232,8 @@ fn build_registered_controllers() -> Vec { crate::openhuman::cron::all_cron_registered_controllers(), ); // Saved automation workflows (tinyflows graphs): create/get/list/update/delete/run + // (gated with flows). + #[cfg(feature = "flows")] push( &mut controllers, DomainGroup::Flows, diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 6049ffebbf..68715e682b 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -820,6 +820,7 @@ async fn harness_excludes_gated_namespaces() { .iter() .map(|s| s.namespace) .collect(); + #[cfg(feature = "flows")] assert!(full_ns.contains("flows"), "full() must expose flows"); assert!(full_ns.contains("voice"), "full() must expose voice"); @@ -859,6 +860,12 @@ async fn harness_excludes_gated_namespaces() { ); } +// Uses a `flows.*` method as its gated-family vehicle, so the whole test is +// `#[cfg(feature = "flows")]`: without the feature there is no flows controller +// in the registry at all and the `.expect()` below would panic. The runtime +// gating this proves is orthogonal to the compile-time gate, and CI runs the +// test suite on default features (flows ON), so no coverage is lost there. +#[cfg(feature = "flows")] #[tokio::test] async fn dispatch_returns_none_for_gated_method() { // A method whose group is gated OFF under the ambient DomainSet must @@ -890,6 +897,8 @@ async fn dispatch_returns_none_for_gated_method() { ); } +// Same flows-vehicle reasoning as `dispatch_returns_none_for_gated_method`. +#[cfg(feature = "flows")] #[tokio::test] async fn schema_lookup_is_gated_in_lockstep_with_dispatch() { // #4808 review: `schema_for_rpc_method` must gate identically to @@ -939,7 +948,10 @@ fn group_mapping_smoke() { assert_eq!(group_for_namespace("config"), Some(DomainGroup::Config)); assert_eq!(group_for_namespace("security"), Some(DomainGroup::Security)); assert_eq!(group_for_namespace("agent"), Some(DomainGroup::Agent)); - // …and a representative gated one maps to its gate group. + // …and a representative gated one maps to its gate group. `group_for_namespace` + // reads the real controller registry, so a compile-time-gated family has no + // entry to map when its feature is off. + #[cfg(feature = "flows")] assert_eq!(group_for_namespace("flows"), Some(DomainGroup::Flows)); // `group_for_namespace` is registry-derived, so a compile-time-gated domain // has no controller to map. Skip when its Cargo feature is off. @@ -956,6 +968,39 @@ fn group_mapping_smoke() { assert_eq!(group_for_namespace("mcp_audit"), Some(DomainGroup::Mcp)); } +// --- #4797: `flows` compile-time gate (directional proof) ------------------- +// +// One namespace, not three: `tinyflows` registers no controllers, and +// `rhai_workflows` is `scope() = AgentOnly` (no controller schemas in v1), so +// `flows` is the gate's entire controller surface. + +#[cfg(feature = "flows")] +#[test] +fn flows_controllers_registered_when_feature_on() { + let namespaces: Vec<&str> = all_controller_schemas() + .iter() + .map(|s| s.namespace) + .collect(); + assert!( + namespaces.contains(&"flows"), + "with the `flows` feature ON the flows controllers must be registered" + ); +} + +#[cfg(not(feature = "flows"))] +#[test] +fn flows_controllers_absent_when_feature_off() { + let namespaces: Vec<&str> = all_controller_schemas() + .iter() + .map(|s| s.namespace) + .collect(); + assert!( + !namespaces.contains(&"flows"), + "with the `flows` feature OFF the flows controllers must be absent \ + (unknown-method over /rpc, omitted from /schema)" + ); +} + /// All three Meet namespaces register when the `meet` feature is on (#4800). /// /// Paired with `meet_controllers_absent_when_feature_off` below: together they diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 4a553ab456..8bcabec2f3 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2024,6 +2024,10 @@ fn register_domain_subscribers( // runs `flows::ops::flows_run`, so schedule/app-event workflows still // dispatch when no realtime channel is configured or // `OPENHUMAN_DISABLE_CHANNEL_LISTENERS` short-circuits `start_channels`. + // The `plan.flows` runtime guard cannot stand in for the compile-time gate: + // the `flows::bus::FlowTriggerSubscriber` type path below must still resolve + // for this to compile, so the whole block is `#[cfg]`-gated too. + #[cfg(feature = "flows")] if plan.flows { if group_first_time(DomainGroup::Flows) { if let Some(handle) = crate::core::event_bus::subscribe_global(Arc::new( @@ -2039,6 +2043,10 @@ fn register_domain_subscribers( } else { log::debug!("[event_bus] flows trigger subscriber SKIPPED — Flows domain disabled"); } + #[cfg(not(feature = "flows"))] + log::debug!( + "[event_bus] flows trigger subscriber SKIPPED — flows feature disabled at compile time" + ); // Memory: conversation-persistence + sync-stage bridge. if plan.memory { diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index fdb88798c1..c711fc3ace 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -243,6 +243,11 @@ async fn invoke_doctor_models_rejects_unknown_param() { assert!(err.contains("unknown param 'invalid'")); } +// Uses a `flows.*` method as its gated-family vehicle: without the `flows` +// feature there is no flows controller in the registry and the `.expect()` +// below would panic. The transport-layer gating it proves is orthogonal to the +// compile-time gate (#4797). +#[cfg(feature = "flows")] #[tokio::test] async fn gated_method_is_unknown_at_transport_even_with_malformed_params() { // #4808 review (CodeRabbit): prove the schema-gate fix at the JSON-RPC diff --git a/src/core/runtime/services.rs b/src/core/runtime/services.rs index 89b36521e1..3b814b1d37 100644 --- a/src/core/runtime/services.rs +++ b/src/core/runtime/services.rs @@ -127,6 +127,8 @@ pub fn spawn_cron_service() { // flow (issue B2) — idempotent, so a flow whose binding // predates this feature (or was otherwise lost) gets its // schedule re-registered without the user re-toggling it. + // Gated with flows — absent entirely from a slim build. + #[cfg(feature = "flows")] if let Err(e) = crate::openhuman::flows::ops::reconcile_schedule_triggers_on_boot(&config).await { diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs index 8764788832..f1cd08eeda 100644 --- a/src/openhuman/agent/harness/builtin_definitions.rs +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -266,7 +266,10 @@ mod tests { "critic", "archivist", "summarizer", + // Gated with `flows` (#4797) — absent from a slim build. + #[cfg(feature = "flows")] "workflow_builder", + #[cfg(feature = "flows")] "flow_discovery", ] { assert!(ids.contains(&expected.to_string()), "missing {expected}"); diff --git a/src/openhuman/agent/harness/definition_tests.rs b/src/openhuman/agent/harness/definition_tests.rs index d6d8ebc0f7..70ac7f7d68 100644 --- a/src/openhuman/agent/harness/definition_tests.rs +++ b/src/openhuman/agent/harness/definition_tests.rs @@ -374,7 +374,10 @@ fn all_builtin_agent_definitions_have_expected_effective_max_iterations() { ("skill_creator", 50), ("task_manager_agent", 50), ("tools_agent", 50), + // Gated with `flows` (#4797) — absent from a slim build. + #[cfg(feature = "flows")] ("flow_discovery", 50), + #[cfg(feature = "flows")] ("workflow_builder", 50), // Compiled out with the `skills` gate — see `openhuman::skills::stub`. #[cfg(feature = "skills")] diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index c8ea3415a6..e23fb71d75 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -295,6 +295,9 @@ pub const BUILTINS: &[BuiltinAgent] = &[ // Workflow-authoring specialist (Phase 5a): builds tinyflows automation // graphs from natural language and returns a validated PROPOSAL — it never // persists or enables a flow. Deliberately narrow propose-or-read tool belt. + // Gated with `flows`: a slim build must not advertise an agent whose entire + // tool belt is absent, so the entry (and its `include_str!`) is stripped. + #[cfg(feature = "flows")] BuiltinAgent { id: "workflow_builder", toml: include_str!("../../flows/agents/workflow_builder/agent.toml"), @@ -306,7 +309,9 @@ pub const BUILTINS: &[BuiltinAgent] = &[ // `suggest_workflows` to record concrete, buildable automation ideas for // the Flows page "Suggested for you" section. It never persists or enables // a flow — the read-only counterpart to `workflow_builder`, which turns a - // picked suggestion into a real graph proposal. + // picked suggestion into a real graph proposal. Gated with `flows` (same + // reasoning as `workflow_builder` above). + #[cfg(feature = "flows")] BuiltinAgent { id: "flow_discovery", toml: include_str!("../../flows/agents/flow_discovery/agent.toml"), @@ -1005,6 +1010,9 @@ mod tests { assert!(matches!(def.tools, ToolScope::Wildcard)); } + // Both flows agents are `#[cfg(feature = "flows")]` entries in `BUILTINS` + // (#4797), so these tests only apply when the gate is on. + #[cfg(feature = "flows")] #[test] fn workflow_builder_is_registered_worker_with_bounded_authoring_scope() { // Phase 5a/5b: the workflow-builder must be a Worker-tier leaf whose @@ -1135,6 +1143,7 @@ mod tests { ); } + #[cfg(feature = "flows")] #[test] fn flow_discovery_is_registered_readonly_reasoning_scout() { // The Flow Scout must be a read-only reasoning leaf: it reads the diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 83df574557..d364e6c923 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -53,6 +53,7 @@ pub mod emergency_stop; pub mod encryption; pub mod file_state; pub mod file_storage; +#[cfg(feature = "flows")] pub mod flows; pub mod harness_init; pub mod health; @@ -102,6 +103,7 @@ pub mod provider_surfaces; pub mod recall_calendar; pub mod redirect_links; pub mod referral; +#[cfg(feature = "flows")] pub mod rhai_workflows; pub mod routing; pub mod runtime_node; @@ -131,6 +133,7 @@ pub mod thread_goals; pub mod threads; pub mod tinyagents; pub mod tinycortex; +#[cfg(feature = "flows")] pub mod tinyflows; pub mod tinyplace; pub mod tls; diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 074838b02a..994fd344b5 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -26,8 +26,11 @@ pub use crate::openhuman::credentials::tools::*; pub use crate::openhuman::cron::tools::*; pub use crate::openhuman::dashboard::tools::*; pub use crate::openhuman::doctor::tools::*; +#[cfg(feature = "flows")] pub use crate::openhuman::flows::builder_tools::*; +#[cfg(feature = "flows")] pub use crate::openhuman::flows::discovery_tools::*; +#[cfg(feature = "flows")] pub use crate::openhuman::flows::tools::*; pub use crate::openhuman::health::tools::*; pub use crate::openhuman::integrations::tools::*; @@ -41,6 +44,7 @@ pub use crate::openhuman::monitor::tools::*; pub use crate::openhuman::orchestration::tools::*; pub use crate::openhuman::people::tools::*; pub use crate::openhuman::referral::tools::*; +#[cfg(feature = "flows")] pub use crate::openhuman::rhai_workflows::tools::*; pub use crate::openhuman::screen_intelligence::tools::*; pub use crate::openhuman::search::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 7369298d41..7696ceac4b 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -284,6 +284,7 @@ pub fn all_tools_with_runtime( // graph and returns a proposal summary — never creates/enables a // flow itself. Only the chat UI's WorkflowProposalCard "Save & // enable" action calls `flows_create`. + #[cfg(feature = "flows")] Box::new(ProposeWorkflowTool::new(config.clone())), // workflow-builder agent tool belt (Phase 5b). A deliberately narrow, // propose-or-read surface: revise a draft (validate-only), read saved @@ -292,38 +293,53 @@ pub fn all_tools_with_runtime( // or enable a flow (only the user's own `flows_create` click does); the // read tools are `PermissionLevel::None`, and `dry_run_workflow` is // autonomy-tier gated + wired to deterministic mock capabilities. + #[cfg(feature = "flows")] Box::new(ReviseWorkflowTool::new(config.clone())), // Structured incremental edits (F1): apply a small ops[] list to a base // graph (saved flow or inline) instead of re-emitting the whole graph, // then validate + gate + return a proposal (same contract as revise). // Proposal-only — never persists. + #[cfg(feature = "flows")] Box::new(EditWorkflowTool::new(config.clone())), // Standalone validate (F3): run the SAME structural + hard-gate stack // the propose/save tools use, without emitting a proposal — a pure // check so the agent can self-verify a draft mid-build. Read-only. + #[cfg(feature = "flows")] Box::new(ValidateWorkflowTool::new(config.clone())), // Read a saved flow's revision history (F6) — prior graph snapshots the // agent can inspect / pick a rollback target from. Read-only. + #[cfg(feature = "flows")] Box::new(GetFlowHistoryTool::new(config.clone())), // Phase 4 self-debug loop (F4): find a failing run, resume a parked // run (approval-gated), or cancel a runaway one. + #[cfg(feature = "flows")] Box::new(ListFlowRunsTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(ResumeFlowRunTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(CancelFlowRunTool::new(config.clone())), // Gated create (F4/F12): create a NEW flow — born disabled, approval // gated — and duplicate an existing one (disabled copy) for // clone-then-edit. Behind the Phase 3 safety rails. + #[cfg(feature = "flows")] Box::new(CreateWorkflowTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(DuplicateFlowTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(ListFlowsTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(GetFlowTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(GetFlowRunTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(ListFlowConnectionsTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(SearchToolCatalogTool::new(config.clone())), // Full live contract (schemas, real required_args/output_fields, // primary_array_path) for one action slug found via // search_tool_catalog — the grounding step before WIRING a node's // args/downstream bindings (systemic tool-contract fix, Part 1). + #[cfg(feature = "flows")] Box::new(GetToolContractTool::new(config.clone())), // B12: ONE bounded, READ-ONLY, REAL Composio call to derive the real // primary_array_path/output_fields when the live listing publishes no @@ -333,36 +349,45 @@ pub fn all_tools_with_runtime( // toolkits only — see builder_tools.rs's module doc for the carve-out // this makes in the workflow-builder agent's "no composio_execute" // invariant. + #[cfg(feature = "flows")] Box::new(GetToolOutputSampleTool::new(config.clone())), // Ground an `agent` node's `agent_ref` in real registered agent-kind ids // (researcher / code_executor / …) — the agent analogue of // search_tool_catalog. Read-only. + #[cfg(feature = "flows")] Box::new(ListAgentProfilesTool::new()), // Steer toolkit choice toward what's already connected + surface which // toolkits a flow still needs (Phase 5, item 19). Read-only. + #[cfg(feature = "flows")] Box::new(ListConnectableToolkitsTool::new(config.clone())), // Queryable DSL schema (F2): enumerate the 12 node kinds and fetch one // kind's full config-field/port/example/gotcha contract — the DSL // analogue of search_tool_catalog + get_tool_contract, so an agent need // not rely on prompt prose or memory for node config shapes. Read-only. + #[cfg(feature = "flows")] Box::new(ListNodeKindsTool::new()), + #[cfg(feature = "flows")] Box::new(GetNodeKindContractTool::new()), + #[cfg(feature = "flows")] Box::new(DryRunWorkflowTool::new(security.clone(), config.clone())), // Real end-to-end test run of a SAVED flow (Write / external-effect). The // workflow-builder prompt requires it to ask the user for confirmation // first, and the flow's own approval gate still pauses outbound nodes. + #[cfg(feature = "flows")] Box::new(RunFlowTool::new(config.clone())), // Persist a built graph onto an EXISTING saved flow (Write). Used only // when the USER explicitly asks the agent to save; the seeded build // turn from the Flows prompt bar is propose-only (see #4596) — Accept // + the canvas's own Save persist the graph. The tool itself can // never create a flow or change enabled/require_approval. + #[cfg(feature = "flows")] Box::new(SaveWorkflowTool::new(config.clone())), // Flow Scout discovery: the `flow_discovery` agent's terminal emit // sink. Read-only reasoning over the user's data ends by calling // `suggest_workflows`, which persists workflow ideas for the Flows page // "Suggested for you" section. `PermissionLevel::None`, no external // effect — writes only to the agent's own suggestions store. + #[cfg(feature = "flows")] Box::new(SuggestWorkflowsTool::new(config.clone())), // Wallet tools — expose wallet operations to the agent tool-call pipeline // so the crypto sub-agent can prepare transfers, check status, etc. @@ -1112,12 +1137,15 @@ pub fn all_tools_with_runtime( // tiers only — dark on `readonly` (it can drive effectful tools/sub-agents) // and behind the `OPENHUMAN_RHAI_WORKFLOWS=0` kill switch. Every effectful inner call // still re-gates itself in the Rhai bridge, so this surface adds no new - // ungated capability. + // ungated capability. Gated with `flows` — the whole tool (and the `rhai` + // engine behind it, via `tinyagents/repl`) is absent from a slim build. + #[cfg(feature = "flows")] let rhai_workflows_enabled = std::env::var("OPENHUMAN_RHAI_WORKFLOWS") .or_else(|_| std::env::var("OPENHUMAN_RHAI")) .or_else(|_| std::env::var("OPENHUMAN_RLM")) .map(|v| v != "0") .unwrap_or(true); + #[cfg(feature = "flows")] if rhai_workflows_enabled && security.autonomy != crate::openhuman::security::policy::AutonomyLevel::ReadOnly { @@ -1130,6 +1158,10 @@ pub fn all_tools_with_runtime( "[rhai_workflows] rhai_workflows tool not registered (readonly tier or OPENHUMAN_RHAI_WORKFLOWS=0)" ); } + #[cfg(not(feature = "flows"))] + tracing::debug!( + "[rhai_workflows] rhai_workflows tool not registered — flows feature disabled at compile time" + ); // DomainSet post-filter (#4796): drop tools whose DomainGroup is disabled // under the ambient CoreContext. With no active context, or under @@ -1196,13 +1228,28 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { "skill_registry_uninstall", "skill_runtime_resolve_runtimes", ]; + // Flows has no clean tool-name prefix, so it MUST list every flow-owned + // tool explicitly — a missing name falls through to `Platform` below and + // stays callable under a custom `DomainSet { platform: true, flows: false }`, + // leaking the flows surface past the runtime gate (#4808 review; #4797 + // maintainer review). Keep this in lockstep with the `#[cfg(feature = + // "flows")]` registrations in `all_tools_with_runtime` above — the same 26 + // names asserted by `default_tools_omits_flows_tools_when_feature_off`. const FLOWS: &[&str] = &[ "propose_workflow", "revise_workflow", + "edit_workflow", + "validate_workflow", + "get_flow_history", "dry_run_workflow", "save_workflow", "suggest_workflows", "run_flow", + "list_flow_runs", + "resume_flow_run", + "cancel_flow_run", + "create_workflow", + "duplicate_flow", "list_flows", "get_flow", "get_flow_run", @@ -1211,6 +1258,12 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { "get_tool_contract", "get_tool_output_sample", "list_agent_profiles", + "list_connectable_toolkits", + "list_node_kinds", + "get_node_kind_contract", + // The `rhai_workflows` (.ragsh) tool is compile-gated with `flows` and + // belongs to the same runtime domain — drop it when Flows is off too. + "rhai_workflows", ]; // Voice family agent tools (audio_toolkit) — no `voice_`/`tts_`/`stt_` // prefix, so they must be listed explicitly or they fall through to diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 9ddb2cfe28..96d74a2363 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -2224,8 +2224,45 @@ fn tool_group_classifies_gate_and_harness_families() { assert_eq!(tool_group("run_workflow"), DomainGroup::Skills); assert_eq!(tool_group("skill_registry_browse"), DomainGroup::Skills); assert_eq!(tool_group("list_workflows"), DomainGroup::Skills); - assert_eq!(tool_group("propose_workflow"), DomainGroup::Flows); - assert_eq!(tool_group("list_flows"), DomainGroup::Flows); + // Flows has no name prefix, so EVERY flow-owned tool must be classified + // explicitly — a missing one falls through to Platform and stays callable + // when the Flows domain is runtime-gated off (#4797 maintainer review). + // This list mirrors the compile-time `#[cfg(feature = "flows")]` + // registrations and `default_tools_omits_flows_tools_when_feature_off`. + for flow_tool in [ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "validate_workflow", + "get_flow_history", + "dry_run_workflow", + "save_workflow", + "suggest_workflows", + "run_flow", + "list_flow_runs", + "resume_flow_run", + "cancel_flow_run", + "create_workflow", + "duplicate_flow", + "list_flows", + "get_flow", + "get_flow_run", + "list_flow_connections", + "search_tool_catalog", + "get_tool_contract", + "get_tool_output_sample", + "list_agent_profiles", + "list_connectable_toolkits", + "list_node_kinds", + "get_node_kind_contract", + "rhai_workflows", + ] { + assert_eq!( + tool_group(flow_tool), + DomainGroup::Flows, + "flow-owned tool `{flow_tool}` must classify as Flows, not fall through to Platform" + ); + } assert_eq!(tool_group("media_generate_image"), DomainGroup::Media); // Voice audio_* tools have no voice_/tts_/stt_ prefix — must be classified // explicitly, not fall through to Platform (#4808 review). @@ -2303,3 +2340,54 @@ fn no_gate_family_tool_silently_defaults_to_platform() { ); } } + +// --- #4797: `flows` compile-time gate --------------------------------------- + +/// With the `flows` feature off, every flows-owned agent tool — and the +/// `rhai_workflows` tool whose engine the gate sheds via `tinyagents/repl` — is +/// compiled out of the default registry entirely. +/// +/// `SecurityPolicy::default()` is `Supervised` (not `ReadOnly`), so the +/// `rhai_workflows` assertion is a real one: that tool *would* be registered at +/// this tier if the feature were on. +#[test] +#[cfg(not(feature = "flows"))] +fn default_tools_omits_flows_tools_when_feature_off() { + let security = Arc::new(SecurityPolicy::default()); + let tools = default_tools(security); + let names = tool_names(&tools); + + for absent in [ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "validate_workflow", + "get_flow_history", + "list_flow_runs", + "resume_flow_run", + "cancel_flow_run", + "create_workflow", + "duplicate_flow", + "list_flows", + "get_flow", + "get_flow_run", + "list_flow_connections", + "search_tool_catalog", + "get_tool_contract", + "get_tool_output_sample", + "list_agent_profiles", + "list_connectable_toolkits", + "list_node_kinds", + "get_node_kind_contract", + "dry_run_workflow", + "run_flow", + "save_workflow", + "suggest_workflows", + "rhai_workflows", + ] { + assert!( + !names.iter().any(|n| n == absent), + "tool `{absent}` must be compiled out when the `flows` feature is off; got: {names:?}" + ); + } +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 81d6134168..e374fa9184 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -12595,6 +12595,7 @@ async fn json_rpc_workflows_lifecycle_round_trip() { /// Shared boot for a flows E2E: isolates `HOME`, seeds a minimal config against /// a mock upstream, and stands up the core HTTP router. Returns the rpc base /// URL plus the join handles + tempdir the caller must keep alive/abort. +#[cfg(feature = "flows")] async fn boot_flows_rpc_env() -> ( String, tempfile::TempDir, @@ -12627,6 +12628,7 @@ async fn boot_flows_rpc_env() -> ( /// The smallest valid graph with a human-in-the-loop approval gate: /// `trigger → gate(requires_approval) → downstream`. A run pauses at `gate`; /// approving it via `flows_resume` runs `downstream`. +#[cfg(feature = "flows")] fn approval_gated_graph_json() -> Value { json!({ "name": "approval-gated", @@ -12649,6 +12651,7 @@ fn approval_gated_graph_json() -> Value { /// cancel operates on a run id (checkpoint thread id), so we cancel a *fresh* /// parked run rather than the already-completed one (cancelling a terminal run /// is an error). +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_lifecycle_round_trip() { let _env_lock = json_rpc_e2e_env_lock(); @@ -12865,6 +12868,7 @@ async fn json_rpc_flows_lifecycle_round_trip() { /// `false`. This pins that the four new controllers are registered and dispatch /// end-to-end (schema + handler wiring), independent of the agent-backed /// `flows_discover`, which needs a provider. +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_suggestion_lifecycle_methods_are_wired() { let _env_lock = json_rpc_e2e_env_lock(); @@ -12935,6 +12939,7 @@ async fn json_rpc_flows_suggestion_lifecycle_methods_are_wired() { /// resolves to `chat-v1` on the managed backend while a **reasoning**-tier node /// resolves to `reasoning-v1` — letting the full-arc test assert the two nodes /// routed to distinct managed tiers. +#[cfg(feature = "flows")] fn write_flows_tier_config(openhuman_dir: &Path, api_origin: &str) { let cfg = format!( r#"api_url = "{api_origin}" @@ -12975,6 +12980,7 @@ compaction_enabled = false /// `trigger` feeds a reasoning-tier `planner` (structured `{plan, angle}`) into a /// chat-tier `drafter` that references `nodes.planner.item.json.plan`, then a /// `transform` shapes `{topic, plan, draft}`. +#[cfg(feature = "flows")] fn opus_sonnet_demo_graph() -> Value { json!({ "schema_version": 1, @@ -13049,6 +13055,7 @@ fn opus_sonnet_demo_graph() -> Value { /// /// Runs on the agent-sized worker stack because the builder/scout turns and the /// agent-node run drive the full harness (deep async stacks). +#[cfg(feature = "flows")] #[test] fn json_rpc_flows_full_arc_discover_build_create_run() { run_json_rpc_e2e_on_agent_stack( @@ -13057,6 +13064,7 @@ fn json_rpc_flows_full_arc_discover_build_create_run() { ); } +#[cfg(feature = "flows")] async fn json_rpc_flows_full_arc_discover_build_create_run_inner() { let _env_lock = json_rpc_e2e_env_lock(); // Drain the scripted-completion FIFO even if an assertion below panics, so a @@ -13300,6 +13308,7 @@ async fn json_rpc_flows_full_arc_discover_build_create_run_inner() { /// edge (→ `downstream`) and an `error` edge (→ `recover`). Resuming with the /// gate in `rejections` routes the denied gate's error item to `recover`, and /// `downstream` must not run. +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_resume_deny_routes_to_error_port() { let _env_lock = json_rpc_e2e_env_lock(); @@ -13388,6 +13397,7 @@ async fn json_rpc_flows_resume_deny_routes_to_error_port() { /// clean but returns a loud, non-fatal warning; a `schedule` trigger (which /// does fire) warns nothing; a graph with no trigger is `valid: false` with a /// structural error and no warnings. +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_validate_reports_warnings_and_errors() { let _env_lock = json_rpc_e2e_env_lock(); @@ -13498,6 +13508,7 @@ async fn json_rpc_flows_validate_reports_warnings_and_errors() { /// becomes an annotated placeholder, and the approximations come back as /// warnings — all WITHOUT persisting (the returned payload is a graph, not a /// saved Flow row; `flows_list` stays empty afterwards). +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_import_native_and_n8n() { let _env_lock = json_rpc_e2e_env_lock(); @@ -13609,6 +13620,7 @@ async fn json_rpc_flows_import_native_and_n8n() { /// fault-tolerance: the mock upstream has no connected-accounts route, so the /// Composio source fails and is tolerated (the RPC still returns the HTTP half /// rather than erroring). +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_list_connections_aggregates_secret_free() { let _env_lock = json_rpc_e2e_env_lock();