From 43914f225d7809accaedda4d7aab0e2366e1de71 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Thu, 25 Jun 2026 07:59:51 +0530 Subject: [PATCH] feat(subconscious,triage): route background workloads through the subconscious provider (#4082) --- .../agent/harness/session/builder/factory.rs | 107 +++++++++++-- src/openhuman/agent/triage/routing.rs | 140 +++++++++++++----- src/openhuman/agent/triage/routing_tests.rs | 106 ++++++++++++- src/openhuman/inference/provider/factory.rs | 41 +++-- .../inference/provider/factory_tests.rs | 95 ++++++++++++ src/openhuman/subconscious/README.md | 2 +- src/openhuman/subconscious/engine.rs | 11 ++ 7 files changed, 433 insertions(+), 69 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 6609309ee..216bc4743 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -469,13 +469,7 @@ impl Agent { // chat to the `reasoning` workload regardless of their // `chat_provider` selection. Subagents still set their own role // through `ModelSpec::Hint(...)` in the subagent runner. - let provider_role = match config.default_model.as_deref().map(str::trim) { - Some("hint:agentic") => "agentic", - Some("hint:coding") => "coding", - Some("hint:summarization") => "summarization", - Some("hint:reasoning") => "reasoning", - _ => "chat", - }; + let provider_role = provider_role_for(agent_id, config.default_model.as_deref()); let (provider, mut model_name): (Box, String) = crate::openhuman::inference::provider::create_chat_provider(provider_role, config)?; log::info!( @@ -491,13 +485,29 @@ impl Agent { let target_is_lead = target_def .map(|def| !def.subagents.is_empty()) .unwrap_or(true); - if let Some(pinned_model) = config.configured_agent_model(target_agent_id, target_is_lead) { + // The `subconscious` workload's model is governed by `subconscious_provider` + // + the managed-tier registry — NOT by an interactive agent model pin. + // The tick reuses the orchestrator definition (agent_id="orchestrator"), + // so without this guard a configured `[orchestrator].model` pin would + // clobber the resolved subconscious model and send an unrelated tier to + // the Subconscious provider (Codex P2). + if provider_role != "subconscious" { + if let Some(pinned_model) = + config.configured_agent_model(target_agent_id, target_is_lead) + { + log::debug!( + "[session-builder] agent_id={} using config-level model pin model={}", + target_agent_id, + pinned_model + ); + model_name = pinned_model.to_string(); + } + } else { log::debug!( - "[session-builder] agent_id={} using config-level model pin model={}", - target_agent_id, - pinned_model + "[session-builder] agent_id={} provider_role=subconscious — skipping agent model \ + pin so the subconscious provider/registry model is preserved", + target_agent_id ); - model_name = pinned_model.to_string(); } // Resolve the user-configured vision flag for the (now-final) model while @@ -1212,3 +1222,76 @@ fn definition_disallows_tool(disallowed: &[String], name: &str) -> bool { } }) } + +/// Resolve the provider/workload role for a session build. +/// +/// The `subconscious` workload has two entry points and both must route here: +/// - the cloud tick builds via `Agent::from_config` (agent_id `"orchestrator"`) +/// with `default_model = "hint:subconscious"`; +/// - the event-driven long-lived session builds via +/// `Agent::from_config_for_agent(_, "subconscious")` and does NOT set the hint. +/// +/// Routing on `agent_id == "subconscious"` covers the second case (Codex P2: +/// otherwise promoted background turns fall through to `chat_provider` and ignore +/// Settings → AI "Subconscious"). Other explicit `hint:` markers route to +/// their workload; everything else (incl. the legacy `default_model` tier the +/// bootstrap pinned) falls through to `chat` so `chat_provider` drives the +/// user-facing turn. +pub(crate) fn provider_role_for(agent_id: &str, default_model: Option<&str>) -> &'static str { + if agent_id.trim() == "subconscious" { + return "subconscious"; + } + match default_model.map(str::trim) { + Some("hint:agentic") => "agentic", + Some("hint:coding") => "coding", + Some("hint:summarization") => "summarization", + Some("hint:reasoning") => "reasoning", + Some("hint:subconscious") => "subconscious", + _ => "chat", + } +} + +#[cfg(test)] +mod provider_role_tests { + use super::provider_role_for; + + #[test] + fn orchestrator_defaults_to_chat() { + assert_eq!(provider_role_for("orchestrator", Some("chat-v1")), "chat"); + assert_eq!(provider_role_for("orchestrator", None), "chat"); + // A legacy heavy default_model tier still falls through to chat. + assert_eq!( + provider_role_for("orchestrator", Some("reasoning-v1")), + "chat" + ); + } + + #[test] + fn explicit_hints_route_to_workload() { + assert_eq!( + provider_role_for("orchestrator", Some("hint:agentic")), + "agentic" + ); + assert_eq!( + provider_role_for("orchestrator", Some("hint:reasoning")), + "reasoning" + ); + // The cloud tick: orchestrator agent_id + the subconscious hint. + assert_eq!( + provider_role_for("orchestrator", Some("hint:subconscious")), + "subconscious" + ); + } + + #[test] + fn subconscious_agent_id_routes_to_subconscious_without_hint() { + // The event-driven long-lived session builds with agent_id="subconscious" + // and no hint — it must still resolve the subconscious workload (Codex P2). + assert_eq!(provider_role_for("subconscious", None), "subconscious"); + assert_eq!( + provider_role_for("subconscious", Some("chat-v1")), + "subconscious" + ); + assert_eq!(provider_role_for(" subconscious ", None), "subconscious"); + } +} diff --git a/src/openhuman/agent/triage/routing.rs b/src/openhuman/agent/triage/routing.rs index 838863b8c..bd3056a16 100644 --- a/src/openhuman/agent/triage/routing.rs +++ b/src/openhuman/agent/triage/routing.rs @@ -14,9 +14,7 @@ use std::sync::Arc; use anyhow::Context; use crate::openhuman::config::Config; -use crate::openhuman::inference::provider::{ - self, Provider, ProviderRuntimeOptions, INFERENCE_BACKEND_ID, -}; +use crate::openhuman::inference::provider::{self, Provider, INFERENCE_BACKEND_ID}; /// The concrete provider + metadata that [`crate::openhuman::agent::triage::evaluator::run_triage`] /// should use for this particular triage turn. @@ -135,46 +133,110 @@ pub fn build_local_provider_with_config(config: &Config) -> Option`). These carry their own auth and +/// spawn a local process, so — like the local HTTP runtimes — they must never be +/// the provider for a triage turn (#1257). Kept separate from +/// `is_local_provider_string`, which only classifies the local HTTP runtimes. +fn is_local_cli_route(provider_string: &str) -> bool { + use crate::openhuman::inference::provider::claude_code; + use crate::openhuman::inference::provider::factory::{ + CLAUDE_AGENT_SDK_PREFIX, CLAUDE_AGENT_SDK_PROVIDER, + }; + let s = provider_string.trim(); + s == CLAUDE_AGENT_SDK_PROVIDER + || s.starts_with(CLAUDE_AGENT_SDK_PREFIX) + || s.starts_with(claude_code::PROVIDER_PREFIX) +} + // ── Provider builder ──────────────────────────────────────────────────── -/// Build the default remote routed backend provider. Same wiring as -/// `inference::local::ops::agent_chat_simple` uses so we stay consistent with -/// the existing direct-chat path. +/// Build the remote provider for a triage turn, routed through the +/// **`subconscious`** background workload so the Settings → AI → Advanced +/// "Subconscious" provider control governs triage classification. +/// +/// The managed model id comes from `make_openhuman_backend` → +/// [`managed_tier_for_role`]`("subconscious")` (i.e. `chat-v1`), the same +/// registry the subconscious tick and the agent harness use — NOT from +/// `default_model`. So triage stays consistent with the tick: one place pins +/// the managed model. +/// +/// #1257 invariant — *triage never goes local*: when the resolved +/// `subconscious_provider` is a local runtime (Ollama/LM Studio/MLX/…) or a +/// BYOK-incomplete sentinel, we force the managed backend so a trigger never +/// errors because a local model is down. Only a concrete BYOK **cloud** route is +/// honoured as-is. A build failure also falls back to the managed backend. fn build_remote_provider(config: &Config) -> anyhow::Result { - let default_model = config - .default_model - .clone() - .unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.to_string()); - let options = ProviderRuntimeOptions { - auth_profile_override: None, - openhuman_dir: config.config_path.parent().map(std::path::PathBuf::from), - secrets_encrypt: config.secrets.encrypt, - reasoning_enabled: config.runtime.reasoning_enabled, + use crate::openhuman::inference::local::profile::is_local_provider_string; + use crate::openhuman::inference::provider::factory::{ + create_chat_provider_from_string, PROVIDER_OPENHUMAN, }; - let provider_box = provider::create_routed_provider_with_options( - config.inference_url.as_deref(), - config.api_url.as_deref(), - config.api_key.as_deref(), - &config.reliability, - &config.model_routes, - default_model.as_str(), - &options, - ) - .context("building routed remote provider for triage")?; - // `Box` → `Arc` is a single reallocation - // — the `Provider` trait is `Send + Sync` so this is type-safe. - let provider: Arc = Arc::from(provider_box); - tracing::debug!( - provider = %INFERENCE_BACKEND_ID, - model = %default_model, - "[triage::routing] resolved remote provider" - ); - Ok(ResolvedProvider { - provider, - provider_name: INFERENCE_BACKEND_ID.to_string(), - model: default_model, - used_local: false, - }) + + let resolved = provider::provider_for_role("subconscious", config); + let r = resolved.trim(); + + // #1257: triage must never depend on a local model/CLI being up, and a + // half-configured BYOK route must not error a trigger — force the managed + // backend in all those cases. `is_local_provider_string` only covers the + // local HTTP runtimes (Ollama/LM Studio/MLX/local-openai), so the local CLI + // delegates (`claude_agent_sdk`, `claude-code:`) are excluded + // explicitly here (Codex P2) — otherwise they'd be treated as BYOK cloud and + // triage could hang on an unauthenticated/missing CLI. + let force_managed = is_local_provider_string(r) + || is_local_cli_route(r) + || r == provider::BYOK_INCOMPLETE_SENTINEL; + let effective = if force_managed { PROVIDER_OPENHUMAN } else { r }; + if force_managed { + tracing::info!( + resolved = %r, + "[triage::routing] subconscious workload not usable for triage (local/incomplete) — \ + forcing managed backend (#1257: triage never goes local)" + ); + } + + // Build through the per-workload factory: managed routes resolve their model + // id via `make_openhuman_backend` → `managed_tier_for_role`, BYOK cloud routes + // via the slug's configured model. + let build = |provider_string: &str| -> anyhow::Result { + let (provider_box, model) = + create_chat_provider_from_string("subconscious", provider_string, config)?; + let provider: Arc = Arc::from(provider_box); + let provider_name = if provider_string == PROVIDER_OPENHUMAN { + INFERENCE_BACKEND_ID.to_string() + } else { + provider_string + .split(':') + .next() + .unwrap_or(provider_string) + .to_string() + }; + Ok(ResolvedProvider { + provider, + provider_name, + model, + used_local: false, + }) + }; + + match build(effective) { + Ok(rp) => { + tracing::debug!( + provider = %rp.provider_name, + model = %rp.model, + "[triage::routing] resolved remote provider via subconscious workload" + ); + Ok(rp) + } + Err(err) => { + tracing::warn!( + resolved = %effective, + error = %err, + "[triage::routing] subconscious workload provider build failed — \ + falling back to managed backend" + ); + build(PROVIDER_OPENHUMAN) + } + } } // ── Tests ─────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/triage/routing_tests.rs b/src/openhuman/agent/triage/routing_tests.rs index c128f2183..44027ba29 100644 --- a/src/openhuman/agent/triage/routing_tests.rs +++ b/src/openhuman/agent/triage/routing_tests.rs @@ -1,27 +1,117 @@ use super::*; +use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds}; fn test_config() -> Config { Config::default() } +fn openai_entry(id: &str, slug: &str) -> CloudProviderCreds { + CloudProviderCreds { + id: id.to_string(), + slug: slug.to_string(), + label: "OpenAI".to_string(), + endpoint: "https://api.openai.com/v1".to_string(), + auth_style: AuthStyle::Bearer, + default_model: Some("gpt-4o".to_string()), + ..Default::default() + } +} + #[test] -fn build_remote_provider_uses_backend_id_and_default_model() { +fn build_remote_provider_managed_uses_backend_id_and_chat_tier() { + // Default config → subconscious workload resolves to the managed backend → + // model pinned to the subconscious registry tier (chat-v1, == DEFAULT_MODEL). let config = test_config(); let resolved = build_remote_provider(&config).expect("remote provider should build"); assert_eq!(resolved.provider_name, INFERENCE_BACKEND_ID); - assert_eq!( - resolved.model, - crate::openhuman::config::DEFAULT_MODEL.to_string() - ); + assert_eq!(resolved.model, crate::openhuman::config::MODEL_CHAT_V1); assert!(!resolved.used_local, "used_local is always false"); } #[test] -fn build_remote_provider_uses_configured_default_model() { +fn build_remote_provider_managed_pins_chat_tier_regardless_of_default_model() { + // Triage's managed arm now resolves its model via the subconscious registry + // (make_openhuman_backend -> managed_tier_for_role = chat-v1), NOT + // `default_model` — consistent with the subconscious tick. A heavy + // `default_model` must not drag triage off the lightweight chat tier. let mut config = test_config(); - config.default_model = Some("custom-model-v1".to_string()); + config.default_model = Some("reasoning-v1".to_string()); let resolved = build_remote_provider(&config).expect("remote provider should build"); - assert_eq!(resolved.model, "custom-model-v1"); + assert_eq!(resolved.provider_name, INFERENCE_BACKEND_ID); + assert_eq!(resolved.model, "chat-v1"); + assert!(!resolved.used_local); +} + +#[test] +fn build_remote_provider_falls_back_to_managed_when_subconscious_local() { + // #1257: triage must never run on a local provider. A local + // `subconscious_provider` falls back to the managed backend so a trigger + // never errors because Ollama is down. + let mut config = test_config(); + config.subconscious_provider = Some("ollama:llama3.2:3b".to_string()); + config.default_model = Some("chat-v1".to_string()); + let resolved = build_remote_provider(&config).expect("remote provider should build"); + assert_eq!(resolved.provider_name, INFERENCE_BACKEND_ID); + assert_eq!(resolved.model, "chat-v1"); + assert!(!resolved.used_local, "triage never goes local"); +} + +#[test] +fn build_remote_provider_forces_managed_for_local_cli_routes() { + // #1257 (Codex P2): local CLI delegates (claude_agent_sdk / claude-code:) + // are not caught by `is_local_provider_string`, but triage must not depend on + // a local CLI — force the managed backend (chat-v1) for them too. + for route in [ + "claude_agent_sdk", + "claude_agent_sdk:sonnet", + "claude-code:opus", + ] { + let mut config = test_config(); + config.subconscious_provider = Some(route.to_string()); + let resolved = build_remote_provider(&config) + .unwrap_or_else(|e| panic!("route {route} should build, got {e}")); + assert_eq!( + resolved.provider_name, INFERENCE_BACKEND_ID, + "route {route} must force managed backend" + ); + assert_eq!(resolved.model, "chat-v1", "route {route} must pin chat-v1"); + assert!(!resolved.used_local); + } +} + +#[test] +fn is_local_cli_route_classifies_cli_delegates_only() { + assert!(is_local_cli_route("claude_agent_sdk")); + assert!(is_local_cli_route("claude_agent_sdk:sonnet")); + assert!(is_local_cli_route("claude-code:opus")); + assert!(!is_local_cli_route("openai:gpt-4o")); + assert!(!is_local_cli_route("openhuman")); + assert!(!is_local_cli_route("ollama:phi3")); +} + +#[test] +fn build_remote_provider_routes_through_byok_subconscious() { + // A concrete BYOK cloud subconscious provider governs triage classification. + let mut config = test_config(); + config.cloud_providers.push(openai_entry("p_oai", "openai")); + config.subconscious_provider = Some("openai:gpt-4o-mini".to_string()); + let resolved = build_remote_provider(&config).expect("remote provider should build"); + assert_eq!(resolved.provider_name, "openai"); + assert_eq!(resolved.model, "gpt-4o-mini"); + assert!(!resolved.used_local); +} + +#[test] +fn build_remote_provider_falls_back_when_byok_build_fails() { + // A non-local, non-managed subconscious provider whose slug has no matching + // `cloud_providers` entry fails to build — triage falls back to the managed + // backend rather than erroring the turn. + let mut config = test_config(); + config.subconscious_provider = Some("groq:llama3".to_string()); + config.default_model = Some("chat-v1".to_string()); + let resolved = build_remote_provider(&config).expect("remote provider should build"); + assert_eq!(resolved.provider_name, INFERENCE_BACKEND_ID); + assert_eq!(resolved.model, "chat-v1"); assert!(!resolved.used_local); } diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index ea718bbd4..b9ba47e1e 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -113,6 +113,10 @@ pub fn resolve_model_for_hint(hint_or_tier: &str, config: &Config) -> String { ("coding", crate::openhuman::config::MODEL_CODING_V1), ("vision", crate::openhuman::config::MODEL_VISION_V1), ("summarization", "summarization-v1"), + // Background subconscious workload rides the lightweight chat tier on the + // managed backend; its `subconscious` *role* (handled below) still selects + // the provider via `subconscious_provider`. + ("subconscious", crate::openhuman::config::MODEL_CHAT_V1), ]; let tier_to_role: &[(&str, &str)] = &[ (crate::openhuman::config::MODEL_REASONING_V1, "reasoning"), @@ -130,11 +134,17 @@ pub fn resolve_model_for_hint(hint_or_tier: &str, config: &Config) -> String { .find(|(k, _)| *k == hint_key) .map(|(_, v)| *v) .unwrap_or(hint_or_tier); - let role = tier_to_role - .iter() - .find(|(k, _)| *k == tier) - .map(|(_, v)| *v) - .unwrap_or(hint_key); + // Background workloads map to a tier *model* but must keep their own + // role so `provider_for_role` reads their dedicated `*_provider` field + // rather than the chat-tier provider their model happens to share. + let role = match hint_key { + "subconscious" => "subconscious", + _ => tier_to_role + .iter() + .find(|(k, _)| *k == tier) + .map(|(_, v)| *v) + .unwrap_or(hint_key), + }; (tier, role) } else { let role = tier_to_role @@ -828,10 +838,11 @@ pub(crate) fn create_local_chat_provider_from_string( /// be mapped explicitly. /// /// Returns `Some(tier)` for the specialised roles that map 1:1 to a managed -/// tier (`reasoning`, `agentic`, `coding`, `vision`). Returns `None` for: +/// tier (`reasoning`, `agentic`, `coding`, `vision`, `subconscious`). Returns +/// `None` for: /// -/// - the generic `chat` role (and any background/unknown role), which keeps -/// inheriting `default_model`: the front-line chat turn and legacy +/// - the generic `chat` role (and any other background/unknown role), which +/// keeps inheriting `default_model`: the front-line chat turn and legacy /// `default_model = "reasoning-v1"` installs deliberately fall through to the /// `chat` role (see the session builder) and rely on `default_model` driving /// the model — pinning `chat` here would regress them. @@ -841,19 +852,31 @@ pub(crate) fn create_local_chat_provider_from_string( /// user-configurable `memory_tree.cloud_llm_model`. Pinning `summarization` /// to a fixed tier would silently ignore that override. /// +/// `subconscious` IS pinned (to the lightweight `chat-v1` tier) even though it +/// is a background workload: the cloud subconscious tick builds via the session +/// builder with `default_model = "hint:subconscious"` (a role-routing marker, not +/// a real tier), so "inherit `default_model`" would forward that marker to the +/// backend. Pinning here resolves the managed model declaratively to `chat-v1` — +/// the cheap monitoring tier the workload wants — independent of `default_model`, +/// while [`provider_for_role`] still lets `subconscious_provider` choose the +/// provider (managed / BYOK / local). +/// /// For `vision` the default-inheritance mismatch is not just suboptimal but /// fatal: an unset `vision_provider` would resolve to `chat-v1`, /// `model_supports_vision` would report `false`, and the turn engine would strip /// every attached image — leaving the managed vision sub-agent blind. fn managed_tier_for_role(role: &str) -> Option<&'static str> { use crate::openhuman::config::{ - MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1, MODEL_VISION_V1, + MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_V1, MODEL_VISION_V1, }; match role { "reasoning" => Some(MODEL_REASONING_V1), "agentic" => Some(MODEL_AGENTIC_V1), "coding" => Some(MODEL_CODING_V1), "vision" => Some(MODEL_VISION_V1), + // Background subconscious tick/triage: pinned to the lightweight chat + // tier (see the doc above for why it is pinned despite being background). + "subconscious" => Some(MODEL_CHAT_V1), _ => None, } } diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index 043191ce7..8dee38d1d 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -1068,6 +1068,67 @@ fn make_openhuman_backend_translates_summarization_hint() { assert_eq!(model, crate::openhuman::config::MODEL_SUMMARIZATION_V1); } +#[test] +fn managed_backend_pins_subconscious_role_to_chat_tier() { + // Subconscious is pinned to chat-v1 on the managed backend via + // `managed_tier_for_role`, *independent of* `default_model`. The cloud tick + // routes through this role with `default_model` overwritten to the + // "hint:subconscious" marker, so it must NOT inherit `default_model` (which + // would forward the raw marker to the backend → HTTP 400). + let mut config = Config::default(); + config.default_model = Some("hint:subconscious".to_string()); + let (_, model) = + make_openhuman_backend("subconscious", &config).expect("factory should succeed"); + assert_eq!(model, crate::openhuman::config::MODEL_CHAT_V1); + + // Even with a heavy `default_model`, the subconscious role stays on chat-v1. + config.default_model = Some("reasoning-v1".to_string()); + let (_, model) = + make_openhuman_backend("subconscious", &config).expect("factory should succeed"); + assert_eq!(model, crate::openhuman::config::MODEL_CHAT_V1); +} + +#[test] +fn create_chat_provider_subconscious_managed_resolves_chat_v1() { + // End-to-end of the managed tick path: provider role `subconscious` with the + // hint default_model, no BYOK subconscious_provider → managed backend, model + // pinned to chat-v1 (no regression vs the pre-change chat-role behaviour). + let mut config = Config::default(); + config.default_model = Some("hint:subconscious".to_string()); + let (_, model) = + create_chat_provider("subconscious", &config).expect("create_chat_provider must succeed"); + assert_eq!(model, crate::openhuman::config::MODEL_CHAT_V1); +} + +#[test] +fn create_chat_provider_subconscious_honours_byok_route() { + // When the user pins a concrete cloud provider for the subconscious workload + // in Settings → AI → Advanced, the factory builds that provider and returns + // its exact model id. + let mut config = Config::default(); + config.cloud_providers.push(openai_entry("p_oai", "openai")); + config.subconscious_provider = Some("openai:gpt-4o-mini".to_string()); + let (_, model) = + create_chat_provider("subconscious", &config).expect("create_chat_provider must succeed"); + assert_eq!(model, "gpt-4o-mini"); +} + +#[test] +fn provider_for_role_subconscious_override_respected() { + let mut config = Config::default(); + config.subconscious_provider = Some("ollama:llama3.2:3b".to_string()); + assert_eq!( + provider_for_role("subconscious", &config), + "ollama:llama3.2:3b" + ); + // Unset → managed backend (background workloads never inherit BYOK). + let default_config = Config::default(); + assert_eq!( + provider_for_role("subconscious", &default_config), + "openhuman" + ); +} + #[test] fn make_openhuman_backend_reports_vision_capability() { let config = Config::default(); @@ -2199,6 +2260,40 @@ fn resolve_model_for_hint_handles_unknown_hint_passthrough() { assert_eq!(result, "hint:unknown_tier"); } +#[test] +fn resolve_model_for_hint_subconscious_managed_is_chat_v1() { + // Managed (no BYOK subconscious_provider) resolves to the chat tier model so + // the RPC `inference.resolve_model` reports the model the tick actually runs. + let config = Config::default(); + assert_eq!( + resolve_model_for_hint("hint:subconscious", &config), + "chat-v1" + ); + + // An explicit managed sentinel still resolves to the tier, not the raw hint. + let mut config = Config::default(); + config.subconscious_provider = Some("openhuman".to_string()); + assert_eq!( + resolve_model_for_hint("hint:subconscious", &config), + "chat-v1" + ); +} + +#[test] +fn resolve_model_for_hint_subconscious_reads_subconscious_provider() { + // The `subconscious` hint must read `subconscious_provider` — NOT the + // chat-tier provider it shares a model with — so a BYOK subconscious route + // surfaces its own model id. + let mut config = Config::default(); + config.subconscious_provider = Some("openai:gpt-4o-mini".to_string()); + // A different chat_provider must not leak into the subconscious resolution. + config.chat_provider = Some("anthropic:claude-sonnet-4-20250514".to_string()); + assert_eq!( + resolve_model_for_hint("hint:subconscious", &config), + "gpt-4o-mini" + ); +} + #[test] fn omlx_provider_builds_with_bearer_key() { let mut config = crate::openhuman::config::Config::default(); diff --git a/src/openhuman/subconscious/README.md b/src/openhuman/subconscious/README.md index 6ebc85d8b..d7b078205 100644 --- a/src/openhuman/subconscious/README.md +++ b/src/openhuman/subconscious/README.md @@ -6,7 +6,7 @@ The subconscious is OpenHuman's background-awareness layer: a SQLite-backed loop - Maintain a list of `SubconsciousTask`s (system-seeded + user-added) in SQLite; seed three default system tasks on init. - Run a tick: load due tasks → log them `in_progress` → build a situation report → call the configured LLM → execute `act` tasks, create escalations for `escalate`/`UnapprovedWrite`, mark `noop` otherwise → update log entries in place. -- Route the per-tick evaluation and task execution to a local Ollama/LM Studio model or the OpenHuman cloud, based on config (`workload_local_model("subconscious")` / `subconscious_provider`). +- Route the per-tick evaluation and task execution to a local Ollama/LM Studio model or the OpenHuman cloud, based on config (`workload_local_model("subconscious")` / `subconscious_provider`). The tick builds its agent through the **`subconscious`** workload role — `run_agent` sets `default_model = "hint:subconscious"` so the session builder resolves `subconscious_provider` (not the `chat` role); on the managed backend that pins the lightweight `chat-v1` tier. - Classify task write-intent via keyword heuristics (`needs_tools` / `needs_agent`); run read-only tasks analysis-only and escalate any recommended write action for approval. - Emit, cap (`MAX_REFLECTIONS_PER_TICK = 5`), hydrate, and persist proactive reflections; resolve each reflection's `source_refs` into frozen `SourceChunk` snapshots at tick time. - Manage escalation lifecycle (pending → approved/dismissed); approving executes the task at full permissions. diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs index 5ff2bc2d3..1764882ce 100644 --- a/src/openhuman/subconscious/engine.rs +++ b/src/openhuman/subconscious/engine.rs @@ -352,6 +352,17 @@ impl SubconsciousEngine { let mut effective = config.clone(); effective.agent.agent_timeout_secs = TOOL_CALL_TIMEOUT_SECS; + // Route the tick build through the `subconscious` background workload so + // Settings → AI → Advanced "Subconscious" actually governs the cloud + // tick provider, instead of riding the `chat` role (the default-model + // fall-through in the session builder). The session builder maps + // `hint:subconscious` → the `subconscious` provider role; on the managed + // backend the model still resolves to `chat-v1` (no regression). + effective.default_model = Some("hint:subconscious".to_string()); + debug!( + "[subconscious] tick provider routed via hint:subconscious (subconscious_provider={:?})", + effective.subconscious_provider + ); match self.mode { SubconsciousMode::Simple => { effective.autonomy.level = crate::openhuman::security::AutonomyLevel::ReadOnly;