From ca31ed1c550fa7a973c014c5688cddb8884997a7 Mon Sep 17 00:00:00 2001 From: Aqil Aziz Date: Thu, 21 May 2026 06:57:21 +0700 Subject: [PATCH] fix(subconscious): pause when provider unavailable ## Summary - Skip Subconscious ticks before writing per-task activity rows when no OpenHuman session/local provider path is available. - Add provider availability fields to `subconscious_status` and show a paused/configuration banner in Intelligence > Subconscious. - Route the tick evaluator through `subconscious_provider` while reusing the existing memory-tree chat provider plumbing. Closes #1374 ## Testing - [x] `cargo fmt --all --check` - [x] `pnpm --filter openhuman-app test -- src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx` - [x] `pnpm --filter openhuman-app compile` - [x] `pnpm i18n:check` - [x] `git diff --check` - [x] `cargo test -p openhuman subconscious::engine::tests --lib` attempted locally, blocked before tests by `whisper-rs-sys` missing `libclang` (`LIBCLANG_PATH` unset). ## PR Checklist - [x] I linked the relevant issue(s) above. - [x] I added or updated tests for the changed behavior, or explained why this is not needed. - [x] I ran the relevant local checks, or documented why they could not be run. - [x] I kept the change scoped to the issue. ## Summary by CodeRabbit * **New Features** * Show an amber warning when the AI provider is unavailable, display the unavailable reason, provide a quick link to AI settings, disable the "Run Now" button, and show a translated tooltip explaining the unavailable status. * **Internationalization** * Added provider-unavailable title and settings label translations across multiple languages. * **Tests** * Added tests covering the provider-unavailable UI, disabled Run Now behavior, and settings navigation. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2314?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) Co-authored-by: aqilaziz Co-authored-by: Steven Enamakel --- .../IntelligenceSubconsciousTab.tsx | 28 ++- .../IntelligenceSubconsciousTab.test.tsx | 35 +++- app/src/lib/i18n/chunks/ar-3.ts | 2 + app/src/lib/i18n/chunks/bn-3.ts | 2 + app/src/lib/i18n/chunks/en-3.ts | 2 + app/src/lib/i18n/chunks/es-3.ts | 2 + app/src/lib/i18n/chunks/fr-3.ts | 2 + app/src/lib/i18n/chunks/hi-3.ts | 2 + app/src/lib/i18n/chunks/id-3.ts | 2 + app/src/lib/i18n/chunks/it-3.ts | 2 + app/src/lib/i18n/chunks/ko-3.ts | 2 + app/src/lib/i18n/chunks/pt-3.ts | 2 + app/src/lib/i18n/chunks/ru-3.ts | 2 + app/src/lib/i18n/chunks/zh-CN-3.ts | 2 + app/src/lib/i18n/en.ts | 2 + app/src/utils/tauriCommands/subconscious.ts | 2 + src/openhuman/subconscious/engine.rs | 179 +++++++++++++----- src/openhuman/subconscious/engine_tests.rs | 126 ++++++++++++ src/openhuman/subconscious/schemas.rs | 7 + src/openhuman/subconscious/types.rs | 2 + 20 files changed, 360 insertions(+), 45 deletions(-) diff --git a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx index 79a691174..cd82ffa55 100644 --- a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx +++ b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx @@ -59,6 +59,10 @@ export default function IntelligenceSubconsciousTab({ const { t } = useT(); const navigate = useNavigate(); const dispatch = useDispatch(); + const providerUnavailable = status?.provider_available === false; + const providerUnavailableReason = providerUnavailable + ? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle')) + : null; // Reflection "Act" callback — sets the freshly-spawned thread as the // selected one and navigates the user to the chat surface so they @@ -239,7 +243,8 @@ export default function IntelligenceSubconsciousTab({ + + + )} + ['status'], tasks: [], toggleSubconsciousTask: vi.fn(), triggerTick: vi.fn(), @@ -79,4 +80,36 @@ describe('IntelligenceSubconsciousTab', () => { // and the user lands somewhere unexpected. expect(mockNavigate).toHaveBeenCalledWith('/chat'); }); + + it('shows provider unavailable state and blocks manual ticks', () => { + const triggerTick = vi.fn(); + render( + + ); + + expect(screen.getByText('Subconscious is paused')).toBeInTheDocument(); + expect(screen.getByText(/configure a local Subconscious provider/i)).toBeInTheDocument(); + + const runNow = screen.getByRole('button', { name: /Run Now/i }); + expect(runNow).toBeDisabled(); + fireEvent.click(runNow); + expect(triggerTick).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /AI settings/i })); + expect(mockNavigate).toHaveBeenCalledWith('/settings/llm'); + }); }); diff --git a/app/src/lib/i18n/chunks/ar-3.ts b/app/src/lib/i18n/chunks/ar-3.ts index 91fe1c30a..8d9d7ab24 100644 --- a/app/src/lib/i18n/chunks/ar-3.ts +++ b/app/src/lib/i18n/chunks/ar-3.ts @@ -99,6 +99,8 @@ const ar3: TranslationMap = { 'subconscious.failed': 'فشل', 'subconscious.tickInterval': 'فترة الدورة', 'subconscious.runNow': 'تشغيل الآن', + 'subconscious.providerUnavailableTitle': 'تم إيقاف اللاوعي مؤقتًا', + 'subconscious.providerSettings': 'إعدادات الذكاء الاصطناعي', 'subconscious.approvalNeeded': 'يلزم الموافقة', 'subconscious.requiresApproval': 'يتطلب الموافقة', 'subconscious.fixInConnections': 'إصلاح في الاتصالات', diff --git a/app/src/lib/i18n/chunks/bn-3.ts b/app/src/lib/i18n/chunks/bn-3.ts index 6f1af6bf1..64d9b6353 100644 --- a/app/src/lib/i18n/chunks/bn-3.ts +++ b/app/src/lib/i18n/chunks/bn-3.ts @@ -101,6 +101,8 @@ const bn3: TranslationMap = { 'subconscious.failed': 'ব্যর্থ', 'subconscious.tickInterval': 'টিক ইন্টারভাল', 'subconscious.runNow': 'এখনই চালান', + 'subconscious.providerUnavailableTitle': 'Subconscious বিরত আছে', + 'subconscious.providerSettings': 'AI সেটিংস', 'subconscious.approvalNeeded': 'অনুমোদন প্রয়োজন', 'subconscious.requiresApproval': 'অনুমোদন প্রয়োজন', 'subconscious.fixInConnections': 'সংযোগে ঠিক করুন', diff --git a/app/src/lib/i18n/chunks/en-3.ts b/app/src/lib/i18n/chunks/en-3.ts index 1d0f5c904..d908ae96e 100644 --- a/app/src/lib/i18n/chunks/en-3.ts +++ b/app/src/lib/i18n/chunks/en-3.ts @@ -100,6 +100,8 @@ const en3: TranslationMap = { 'subconscious.failed': 'failed', 'subconscious.tickInterval': 'Tick Interval', 'subconscious.runNow': 'Run Now', + 'subconscious.providerUnavailableTitle': 'Subconscious is paused', + 'subconscious.providerSettings': 'AI settings', 'subconscious.approvalNeeded': 'Approval Needed', 'subconscious.requiresApproval': 'Requires approval', 'subconscious.fixInConnections': 'Fix in Connections', diff --git a/app/src/lib/i18n/chunks/es-3.ts b/app/src/lib/i18n/chunks/es-3.ts index 081ebd40f..dc622b2f5 100644 --- a/app/src/lib/i18n/chunks/es-3.ts +++ b/app/src/lib/i18n/chunks/es-3.ts @@ -103,6 +103,8 @@ const es3: TranslationMap = { 'subconscious.failed': 'fallido', 'subconscious.tickInterval': 'Intervalo de tick', 'subconscious.runNow': 'Ejecutar ahora', + 'subconscious.providerUnavailableTitle': 'Subconsciente en pausa', + 'subconscious.providerSettings': 'Ajustes de IA', 'subconscious.approvalNeeded': 'Se necesita aprobación', 'subconscious.requiresApproval': 'Requiere aprobación', 'subconscious.fixInConnections': 'Corregir en Conexiones', diff --git a/app/src/lib/i18n/chunks/fr-3.ts b/app/src/lib/i18n/chunks/fr-3.ts index 346749fae..943fd2706 100644 --- a/app/src/lib/i18n/chunks/fr-3.ts +++ b/app/src/lib/i18n/chunks/fr-3.ts @@ -104,6 +104,8 @@ const fr3: TranslationMap = { 'subconscious.failed': 'échoué', 'subconscious.tickInterval': 'Intervalle de tick', 'subconscious.runNow': 'Exécuter maintenant', + 'subconscious.providerUnavailableTitle': 'Subconscient en pause', + 'subconscious.providerSettings': 'Paramètres IA', 'subconscious.approvalNeeded': 'Approbation requise', 'subconscious.requiresApproval': 'Nécessite une approbation', 'subconscious.fixInConnections': 'Corriger dans Connexions', diff --git a/app/src/lib/i18n/chunks/hi-3.ts b/app/src/lib/i18n/chunks/hi-3.ts index 7d6ef268a..62f3fabb6 100644 --- a/app/src/lib/i18n/chunks/hi-3.ts +++ b/app/src/lib/i18n/chunks/hi-3.ts @@ -100,6 +100,8 @@ const hi3: TranslationMap = { 'subconscious.failed': 'विफल', 'subconscious.tickInterval': 'टिक इंटरवल', 'subconscious.runNow': 'अभी चलाएं', + 'subconscious.providerUnavailableTitle': 'Subconscious रुका हुआ है', + 'subconscious.providerSettings': 'AI सेटिंग्स', 'subconscious.approvalNeeded': 'अनुमति चाहिए', 'subconscious.requiresApproval': 'अनुमति ज़रूरी है', 'subconscious.fixInConnections': 'Connections में ठीक करें', diff --git a/app/src/lib/i18n/chunks/id-3.ts b/app/src/lib/i18n/chunks/id-3.ts index 3675a5722..f776d0c1f 100644 --- a/app/src/lib/i18n/chunks/id-3.ts +++ b/app/src/lib/i18n/chunks/id-3.ts @@ -101,6 +101,8 @@ const id3: TranslationMap = { 'subconscious.failed': 'gagal', 'subconscious.tickInterval': 'Interval Tick', 'subconscious.runNow': 'Jalankan Sekarang', + 'subconscious.providerUnavailableTitle': 'Subconscious dijeda', + 'subconscious.providerSettings': 'Pengaturan AI', 'subconscious.approvalNeeded': 'Persetujuan Diperlukan', 'subconscious.requiresApproval': 'Memerlukan persetujuan', 'subconscious.fixInConnections': 'Perbaiki di Koneksi', diff --git a/app/src/lib/i18n/chunks/it-3.ts b/app/src/lib/i18n/chunks/it-3.ts index bec792d21..4e4486067 100644 --- a/app/src/lib/i18n/chunks/it-3.ts +++ b/app/src/lib/i18n/chunks/it-3.ts @@ -104,6 +104,8 @@ const it3: TranslationMap = { 'subconscious.failed': 'fallito', 'subconscious.tickInterval': 'Intervallo di tick', 'subconscious.runNow': 'Esegui ora', + 'subconscious.providerUnavailableTitle': 'Subconscio in pausa', + 'subconscious.providerSettings': 'Impostazioni IA', 'subconscious.approvalNeeded': 'Approvazione necessaria', 'subconscious.requiresApproval': 'Richiede approvazione', 'subconscious.fixInConnections': 'Correggi in Connessioni', diff --git a/app/src/lib/i18n/chunks/ko-3.ts b/app/src/lib/i18n/chunks/ko-3.ts index 7823efda3..eedb56ffb 100644 --- a/app/src/lib/i18n/chunks/ko-3.ts +++ b/app/src/lib/i18n/chunks/ko-3.ts @@ -100,6 +100,8 @@ const ko3: TranslationMap = { 'subconscious.failed': '실패', 'subconscious.tickInterval': '틱 간격', 'subconscious.runNow': '지금 실행', + 'subconscious.providerUnavailableTitle': 'Subconscious 일시 중지됨', + 'subconscious.providerSettings': 'AI 설정', 'subconscious.approvalNeeded': '승인 필요', 'subconscious.requiresApproval': '승인이 필요함', 'subconscious.fixInConnections': '연결에서 수정', diff --git a/app/src/lib/i18n/chunks/pt-3.ts b/app/src/lib/i18n/chunks/pt-3.ts index a7ff13c41..51e976ec9 100644 --- a/app/src/lib/i18n/chunks/pt-3.ts +++ b/app/src/lib/i18n/chunks/pt-3.ts @@ -102,6 +102,8 @@ const pt3: TranslationMap = { 'subconscious.failed': 'falhou', 'subconscious.tickInterval': 'Intervalo de Tick', 'subconscious.runNow': 'Executar Agora', + 'subconscious.providerUnavailableTitle': 'Subconsciente pausado', + 'subconscious.providerSettings': 'Configurações de IA', 'subconscious.approvalNeeded': 'Aprovação Necessária', 'subconscious.requiresApproval': 'Requer aprovação', 'subconscious.fixInConnections': 'Corrigir em Conexões', diff --git a/app/src/lib/i18n/chunks/ru-3.ts b/app/src/lib/i18n/chunks/ru-3.ts index c76346802..311378135 100644 --- a/app/src/lib/i18n/chunks/ru-3.ts +++ b/app/src/lib/i18n/chunks/ru-3.ts @@ -101,6 +101,8 @@ const ru3: TranslationMap = { 'subconscious.failed': 'ошибка', 'subconscious.tickInterval': 'Интервал тика', 'subconscious.runNow': 'Запустить сейчас', + 'subconscious.providerUnavailableTitle': 'Подсознание приостановлено', + 'subconscious.providerSettings': 'Настройки ИИ', 'subconscious.approvalNeeded': 'Требуется подтверждение', 'subconscious.requiresApproval': 'Требует подтверждения', 'subconscious.fixInConnections': 'Исправить в подключениях', diff --git a/app/src/lib/i18n/chunks/zh-CN-3.ts b/app/src/lib/i18n/chunks/zh-CN-3.ts index 889f1a073..1846dbc5b 100644 --- a/app/src/lib/i18n/chunks/zh-CN-3.ts +++ b/app/src/lib/i18n/chunks/zh-CN-3.ts @@ -98,6 +98,8 @@ const zhCN3: TranslationMap = { 'subconscious.failed': '失败', 'subconscious.tickInterval': '滴答间隔', 'subconscious.runNow': '立即运行', + 'subconscious.providerUnavailableTitle': '潜意识已暂停', + 'subconscious.providerSettings': 'AI 设置', 'subconscious.approvalNeeded': '需要审批', 'subconscious.requiresApproval': '需要审批', 'subconscious.fixInConnections': '在连接中修复', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a6ce1e683..83ab8b5d4 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1034,6 +1034,8 @@ const en: TranslationMap = { 'subconscious.failed': 'failed', 'subconscious.tickInterval': 'Tick Interval', 'subconscious.runNow': 'Run Now', + 'subconscious.providerUnavailableTitle': 'Subconscious is paused', + 'subconscious.providerSettings': 'AI settings', 'subconscious.approvalNeeded': 'Approval Needed', 'subconscious.requiresApproval': 'Requires approval', 'subconscious.fixInConnections': 'Fix in Connections', diff --git a/app/src/utils/tauriCommands/subconscious.ts b/app/src/utils/tauriCommands/subconscious.ts index 7dcd85df6..2de962efc 100644 --- a/app/src/utils/tauriCommands/subconscious.ts +++ b/app/src/utils/tauriCommands/subconscious.ts @@ -42,6 +42,8 @@ export interface SubconsciousEscalation { export interface SubconsciousStatus { enabled: boolean; + provider_available: boolean; + provider_unavailable_reason: string | null; interval_minutes: number; last_tick_at: number | null; total_ticks: number; diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs index f7d924a08..ed0469cdc 100644 --- a/src/openhuman/subconscious/engine.rs +++ b/src/openhuman/subconscious/engine.rs @@ -19,6 +19,8 @@ use super::types::{ EscalationPriority, EvaluationResponse, SubconsciousStatus, SubconsciousTask, TaskEvaluation, TaskRecurrence, TaskSource, TickDecision, TickResult, }; +use crate::openhuman::config::Config; +use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER}; use crate::openhuman::memory::tree::chat::{ build_chat_provider, ChatConsumer, ChatPrompt, ChatProvider, }; @@ -48,6 +50,7 @@ struct EngineState { last_tick_at: f64, total_ticks: u64, consecutive_failures: u64, + provider_unavailable_reason: Option, seeded: bool, } @@ -107,6 +110,7 @@ impl SubconsciousEngine { last_tick_at, total_ticks: 0, consecutive_failures: 0, + provider_unavailable_reason: None, seeded, }), tick_generation: AtomicU64::new(0), @@ -193,6 +197,39 @@ impl SubconsciousEngine { debug!("[subconscious] {} due tasks", due_tasks.len()); + let config_for_report = match Config::load_or_init().await { + Ok(c) => c, + Err(e) => { + warn!("[subconscious] config load for situation report failed: {e}"); + let reason = format!("Config unavailable: {e}"); + state.provider_unavailable_reason = Some(reason); + state.consecutive_failures += 1; + state.total_ticks += 1; + return Ok(TickResult { + tick_at, + evaluations: vec![], + executed: 0, + escalated: 0, + duration_ms: started.elapsed().as_millis() as u64, + }); + } + }; + + if let Some(reason) = subconscious_provider_unavailable_reason(&config_for_report) { + info!("[subconscious] provider unavailable, skipping tick: {reason}"); + state.provider_unavailable_reason = Some(reason); + state.consecutive_failures += 1; + state.total_ticks += 1; + return Ok(TickResult { + tick_at, + evaluations: vec![], + executed: 0, + escalated: 0, + duration_ms: started.elapsed().as_millis() as u64, + }); + } + state.provider_unavailable_reason = None; + // 2. Insert in_progress log entries for each due task let log_ids: HashMap = store::with_connection(&self.workspace_dir, |conn| { @@ -221,17 +258,6 @@ impl SubconsciousEngine { })?; // 3. Build situation report — memory-tree-derived sections (#623). - let config_for_report = match crate::openhuman::config::Config::load_or_init().await { - Ok(c) => c, - Err(e) => { - warn!("[subconscious] config load for situation report failed: {e}"); - // Without config we cannot read memory_tree tables — but we - // can still build the env+tasks+reflections sections by - // passing a default config. The signal sections will report - // themselves as unavailable. - crate::openhuman::config::Config::default() - } - }; // Fetch last 8 reflections for anti-double-emit context. let recent_reflections = super::store::with_connection(&self.workspace_dir, |conn| { super::reflection_store::list_recent(conn, 8, None) @@ -257,7 +283,7 @@ impl SubconsciousEngine { // 5. Evaluate tasks + emit reflections via cloud chat (#623). let (evaluations, reflection_drafts) = self - .evaluate_tasks_and_reflections(&due_tasks, &report, &identity) + .evaluate_tasks_and_reflections(&config_for_report, &due_tasks, &report, &identity) .await; // Check if we were superseded by a newer tick @@ -388,6 +414,8 @@ impl SubconsciousEngine { SubconsciousStatus { enabled: self.enabled, + provider_available: state.provider_unavailable_reason.is_none(), + provider_unavailable_reason: state.provider_unavailable_reason.clone(), interval_minutes: self.interval_minutes, last_tick_at: if state.last_tick_at > 0.0 { Some(state.last_tick_at) @@ -504,29 +532,34 @@ impl SubconsciousEngine { } } - /// Run the per-tick LLM call. Routes to cloud `summarization-v1` via - /// the memory_tree chat provider (#623). On failure returns + /// Run the per-tick LLM call. Routes via `subconscious_provider` + /// while reusing the memory-tree chat provider plumbing (#623). On failure returns /// `(empty_evaluations, empty_drafts)` so `last_tick_at` is NOT /// advanced — the next tick re-fetches from the same point. async fn evaluate_tasks_and_reflections( &self, + config: &Config, tasks: &[SubconsciousTask], report: &str, identity: &str, ) -> (Vec, Vec) { let prompt_text = prompt::build_evaluation_prompt(tasks, report, identity); - let config = match crate::openhuman::config::Config::load_or_init().await { - Ok(c) => c, + // Build the chat provider. The subconscious tick uses + // `ChatConsumer::Summarise` because the per-tick payload is + // closer in shape to a structured-summary call than a per-chunk + // entity extraction. + let provider: Arc = match build_subconscious_chat_provider(config) { + Ok(p) => p, Err(e) => { - warn!("[subconscious] config load failed: {e}"); + warn!("[subconscious] chat provider init failed: {e}"); return ( tasks .iter() .map(|t| TaskEvaluation { task_id: t.id.clone(), decision: TickDecision::Noop, - reason: format!("Evaluation failed: config load: {e}"), + reason: format!("Evaluation failed: provider init: {e}"), }) .collect(), vec![], @@ -534,31 +567,6 @@ impl SubconsciousEngine { } }; - // Build the cloud chat provider. The subconscious tick uses - // `ChatConsumer::Summarise` because the per-tick payload is - // closer in shape to a structured-summary call than a per-chunk - // entity extraction. No local fallback (per #623): if cloud is - // unreachable, return empty results so the tick is treated as a - // skip rather than a malformed advance. - let provider: Arc = - match build_chat_provider(&config, ChatConsumer::Summarise) { - Ok(p) => p, - Err(e) => { - warn!("[subconscious] cloud chat provider init failed: {e}"); - return ( - tasks - .iter() - .map(|t| TaskEvaluation { - task_id: t.id.clone(), - decision: TickDecision::Noop, - reason: format!("Evaluation failed: provider init: {e}"), - }) - .collect(), - vec![], - ); - } - }; - let chat_prompt = ChatPrompt { system: prompt_text, user: "Evaluate the due tasks and surface reflections. Reply with JSON only." @@ -780,6 +788,91 @@ impl SubconsciousEngine { } } +#[derive(Clone, Debug, Eq, PartialEq)] +enum SubconsciousProviderRoute { + LocalOllama { endpoint_set: bool, model: String }, + OpenHumanCloud, + Other(String), +} + +pub(crate) fn subconscious_provider_unavailable_reason(config: &Config) -> Option { + match resolve_subconscious_route(config) { + SubconsciousProviderRoute::LocalOllama { + endpoint_set: true, .. + } => None, + SubconsciousProviderRoute::LocalOllama { + endpoint_set: false, + .. + } => Some( + "Configure the Ollama summarizer endpoint for Subconscious in Settings > AI." + .to_string(), + ), + SubconsciousProviderRoute::OpenHumanCloud => { + if crate::openhuman::scheduler_gate::is_signed_out() { + return Some( + "Sign in to use the OpenHuman cloud Subconscious provider.".to_string(), + ); + } + + let state_dir = config + .config_path + .parent() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| config.workspace_dir.clone()); + let auth = AuthService::new(&state_dir, config.secrets.encrypt); + match auth.get_provider_bearer_token(APP_SESSION_PROVIDER, None) { + Ok(Some(token)) if !token.trim().is_empty() => None, + Ok(_) => Some( + "Sign in or configure a local Subconscious provider in Settings > AI." + .to_string(), + ), + Err(e) => Some(format!("Unable to read the OpenHuman session: {e}")), + } + } + SubconsciousProviderRoute::Other(_) => None, + } +} + +fn resolve_subconscious_route(config: &Config) -> SubconsciousProviderRoute { + if let Some(model) = config.workload_local_model("subconscious") { + let endpoint_set = config + .memory_tree + .llm_summariser_endpoint + .as_deref() + .map(str::trim) + .is_some_and(|s| !s.is_empty()); + return SubconsciousProviderRoute::LocalOllama { + endpoint_set, + model, + }; + } + + let raw = config + .subconscious_provider + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("cloud"); + let is_openhuman_cloud = raw.eq_ignore_ascii_case("cloud") + || raw.eq_ignore_ascii_case("openhuman") + || raw.to_ascii_lowercase().starts_with("openhuman:"); + if is_openhuman_cloud { + SubconsciousProviderRoute::OpenHumanCloud + } else { + SubconsciousProviderRoute::Other(raw.to_string()) + } +} + +fn build_subconscious_chat_provider(config: &Config) -> Result> { + let mut routed = config.clone(); + routed.memory_provider = match resolve_subconscious_route(config) { + SubconsciousProviderRoute::LocalOllama { model, .. } => Some(format!("ollama:{model}")), + SubconsciousProviderRoute::OpenHumanCloud => Some("cloud".to_string()), + SubconsciousProviderRoute::Other(provider) => Some(provider), + }; + build_chat_provider(&routed, ChatConsumer::Summarise) +} + /// Parse the per-tick LLM response into evaluations + reflection drafts. /// /// Best-effort: if the JSON has only `evaluations`, `reflections` is diff --git a/src/openhuman/subconscious/engine_tests.rs b/src/openhuman/subconscious/engine_tests.rs index c9558fe00..94c32fd1e 100644 --- a/src/openhuman/subconscious/engine_tests.rs +++ b/src/openhuman/subconscious/engine_tests.rs @@ -1,5 +1,27 @@ use super::*; +struct EnvVarGuard { + key: &'static str, + old: Option, +} + +impl EnvVarGuard { + fn set_to_path(key: &'static str, path: &std::path::Path) -> Self { + let old = std::env::var_os(key); + std::env::set_var(key, path); + Self { key, old } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.old { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + fn test_tasks() -> Vec { vec![ SubconsciousTask { @@ -27,6 +49,110 @@ fn test_tasks() -> Vec { ] } +#[tokio::test] +async fn tick_skips_unavailable_provider_without_activity_log_spam() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().expect("tempdir"); + let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path()); + let config = Config::load_or_init().await.expect("load test config"); + let engine = SubconsciousEngine::from_heartbeat_config( + &config.heartbeat, + config.workspace_dir.clone(), + None, + ); + + let result = engine.tick().await.expect("tick should skip cleanly"); + + assert!(result.evaluations.is_empty()); + let logs = store::with_connection(&config.workspace_dir, |conn| { + store::list_log_entries(conn, None, 20) + }) + .expect("list logs"); + assert!( + logs.is_empty(), + "provider skip must not append per-task failure log entries" + ); + + let status = engine.status().await; + assert_eq!(status.consecutive_failures, 1); + assert!(!status.provider_available); + assert!(status + .provider_unavailable_reason + .as_deref() + .unwrap_or_default() + .contains("Sign in")); + + let _second = engine.tick().await.expect("repeat skip should be clean"); + let logs = store::with_connection(&config.workspace_dir, |conn| { + store::list_log_entries(conn, None, 20) + }) + .expect("list logs after repeat"); + assert!(logs.is_empty(), "repeat skips must not spam activity log"); +} + +#[test] +fn local_subconscious_provider_with_endpoint_is_available() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut config = Config::default(); + config.config_path = tmp.path().join("config.toml"); + config.workspace_dir = tmp.path().join("workspace"); + config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into()); + config.memory_tree.llm_summariser_endpoint = Some("http://localhost:11434".into()); + + assert!(subconscious_provider_unavailable_reason(&config).is_none()); +} + +#[test] +fn local_subconscious_route_preserves_ollama_model() { + let mut config = Config::default(); + config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into()); + config.memory_tree.llm_summariser_endpoint = Some("http://localhost:11434".into()); + + assert_eq!( + resolve_subconscious_route(&config), + SubconsciousProviderRoute::LocalOllama { + endpoint_set: true, + model: "qwen2.5:0.5b".into(), + } + ); +} + +#[test] +fn local_subconscious_provider_without_endpoint_is_unavailable() { + let mut config = Config::default(); + config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into()); + config.memory_tree.llm_summariser_endpoint = None; + + let reason = subconscious_provider_unavailable_reason(&config).expect("unavailable reason"); + + assert!(reason.contains("Ollama summarizer endpoint")); +} + +#[test] +fn openhuman_subconscious_alias_uses_cloud_route() { + let mut config = Config::default(); + config.subconscious_provider = Some("openhuman:summarization".into()); + + assert_eq!( + resolve_subconscious_route(&config), + SubconsciousProviderRoute::OpenHumanCloud + ); +} + +#[test] +fn explicit_subconscious_provider_uses_other_route() { + let mut config = Config::default(); + config.subconscious_provider = Some("custom-provider".into()); + + assert_eq!( + resolve_subconscious_route(&config), + SubconsciousProviderRoute::Other("custom-provider".into()) + ); + assert!(subconscious_provider_unavailable_reason(&config).is_none()); +} + #[test] fn parse_evaluation_response() { let json = r#"{"evaluations": [ diff --git a/src/openhuman/subconscious/schemas.rs b/src/openhuman/subconscious/schemas.rs index 3af146ff5..07b984a6c 100644 --- a/src/openhuman/subconscious/schemas.rs +++ b/src/openhuman/subconscious/schemas.rs @@ -291,8 +291,15 @@ fn handle_status(_params: Map) -> ControllerFuture { }) .map_err(|e| e.to_string())?; + let provider_unavailable_reason = if hb.enabled && hb.inference_enabled { + super::engine::subconscious_provider_unavailable_reason(&config) + } else { + None + }; let status = super::types::SubconsciousStatus { enabled: hb.enabled && hb.inference_enabled, + provider_available: provider_unavailable_reason.is_none(), + provider_unavailable_reason, interval_minutes: hb.interval_minutes.max(5), last_tick_at, total_ticks, diff --git a/src/openhuman/subconscious/types.rs b/src/openhuman/subconscious/types.rs index 050edd1e1..9cac3c441 100644 --- a/src/openhuman/subconscious/types.rs +++ b/src/openhuman/subconscious/types.rs @@ -148,6 +148,8 @@ pub enum EscalationStatus { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubconsciousStatus { pub enabled: bool, + pub provider_available: bool, + pub provider_unavailable_reason: Option, pub interval_minutes: u32, pub last_tick_at: Option, pub total_ticks: u64,