feat(core): compile-time flows feature gate (#4797) (#4912)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
Co-authored-by: M3gA-Mind <elvin@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-07-16 17:29:13 +05:30
committed by GitHub
co-authored by Steven Enamakel M3gA-Mind
parent d09e8c1e5f
commit b625d735d0
17 changed files with 274 additions and 11 deletions
+1 -1
View File
@@ -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:
+7
View File
@@ -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 <crate> --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.
+23 -5
View File
@@ -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 "<list without flows>"`,
# 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 /
+1
View File
@@ -166,6 +166,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal
"voice",
"tokenjuice-treesitter",
"web3",
"flows",
"meet",
"skills",
] }
+2
View File
@@ -232,6 +232,8 @@ fn build_registered_controllers() -> Vec<GroupedController> {
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,
+46 -1
View File
@@ -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
+8
View File
@@ -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 {
+5
View File
@@ -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
+2
View File
@@ -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
{
@@ -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}");
@@ -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")]
+10 -1
View File
@@ -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
+3
View File
@@ -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;
+4
View File
@@ -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::*;
+54 -1
View File
@@ -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
+90 -2
View File
@@ -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:?}"
);
}
}
+12
View File
@@ -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();