mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(subconscious,triage): route background workloads through the subconscious provider (#4082)
This commit is contained in:
@@ -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<dyn Provider>, 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:<role>` 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ResolvedProvi
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a resolved provider string targets a local CLI delegate
|
||||
/// (`claude_agent_sdk` / `claude-code:<model>`). 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<ResolvedProvider> {
|
||||
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<dyn Provider>` → `Arc<dyn Provider>` is a single reallocation
|
||||
// — the `Provider` trait is `Send + Sync` so this is type-safe.
|
||||
let provider: Arc<dyn Provider> = 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:<model>`) 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<ResolvedProvider> {
|
||||
let (provider_box, model) =
|
||||
create_chat_provider_from_string("subconscious", provider_string, config)?;
|
||||
let provider: Arc<dyn Provider> = 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user