mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2314?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: aqilaziz <gonzes7@gmail.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
08bbe3bc42
commit
ca31ed1c55
@@ -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({
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleRunTick()}
|
||||
disabled={triggering}
|
||||
disabled={triggering || providerUnavailable}
|
||||
title={providerUnavailable ? t('subconscious.providerUnavailableTitle') : undefined}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-40 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-600 dark:text-neutral-300 transition-colors">
|
||||
{triggering ? (
|
||||
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
|
||||
@@ -258,6 +263,27 @@ export default function IntelligenceSubconsciousTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{providerUnavailable && (
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{t('subconscious.providerUnavailableTitle')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300 break-words">
|
||||
{providerUnavailableReason}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings/llm')}
|
||||
className="flex-shrink-0 rounded-md bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-amber-700 transition-colors">
|
||||
{t('subconscious.providerSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubconsciousReflectionCards
|
||||
onNavigateToThread={handleNavigateToReflectionThread}
|
||||
pollIntervalMs={15_000}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* the full Redux/router stack.
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { setSelectedThread } from '../../../store/threadSlice';
|
||||
@@ -48,7 +49,7 @@ function baseProps() {
|
||||
removeSubconsciousTask: vi.fn(),
|
||||
setExpandedLogIds: vi.fn(),
|
||||
setNewTaskTitle: vi.fn(),
|
||||
status: null,
|
||||
status: null as ComponentProps<typeof 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(
|
||||
<IntelligenceSubconsciousTab
|
||||
{...baseProps()}
|
||||
triggerTick={triggerTick}
|
||||
status={{
|
||||
enabled: true,
|
||||
provider_available: false,
|
||||
provider_unavailable_reason: 'Sign in or configure a local Subconscious provider.',
|
||||
interval_minutes: 5,
|
||||
last_tick_at: null,
|
||||
total_ticks: 0,
|
||||
task_count: 3,
|
||||
pending_escalations: 0,
|
||||
consecutive_failures: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,8 @@ const ar3: TranslationMap = {
|
||||
'subconscious.failed': 'فشل',
|
||||
'subconscious.tickInterval': 'فترة الدورة',
|
||||
'subconscious.runNow': 'تشغيل الآن',
|
||||
'subconscious.providerUnavailableTitle': 'تم إيقاف اللاوعي مؤقتًا',
|
||||
'subconscious.providerSettings': 'إعدادات الذكاء الاصطناعي',
|
||||
'subconscious.approvalNeeded': 'يلزم الموافقة',
|
||||
'subconscious.requiresApproval': 'يتطلب الموافقة',
|
||||
'subconscious.fixInConnections': 'إصلاح في الاتصالات',
|
||||
|
||||
@@ -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': 'সংযোগে ঠিক করুন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 में ठीक करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '연결에서 수정',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -101,6 +101,8 @@ const ru3: TranslationMap = {
|
||||
'subconscious.failed': 'ошибка',
|
||||
'subconscious.tickInterval': 'Интервал тика',
|
||||
'subconscious.runNow': 'Запустить сейчас',
|
||||
'subconscious.providerUnavailableTitle': 'Подсознание приостановлено',
|
||||
'subconscious.providerSettings': 'Настройки ИИ',
|
||||
'subconscious.approvalNeeded': 'Требуется подтверждение',
|
||||
'subconscious.requiresApproval': 'Требует подтверждения',
|
||||
'subconscious.fixInConnections': 'Исправить в подключениях',
|
||||
|
||||
@@ -98,6 +98,8 @@ const zhCN3: TranslationMap = {
|
||||
'subconscious.failed': '失败',
|
||||
'subconscious.tickInterval': '滴答间隔',
|
||||
'subconscious.runNow': '立即运行',
|
||||
'subconscious.providerUnavailableTitle': '潜意识已暂停',
|
||||
'subconscious.providerSettings': 'AI 设置',
|
||||
'subconscious.approvalNeeded': '需要审批',
|
||||
'subconscious.requiresApproval': '需要审批',
|
||||
'subconscious.fixInConnections': '在连接中修复',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
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<String, String> =
|
||||
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<TaskEvaluation>, Vec<ReflectionDraft>) {
|
||||
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<dyn ChatProvider> = 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<dyn ChatProvider> =
|
||||
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<String> {
|
||||
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<Arc<dyn ChatProvider>> {
|
||||
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
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
use super::*;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
old: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
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<SubconsciousTask> {
|
||||
vec![
|
||||
SubconsciousTask {
|
||||
@@ -27,6 +49,110 @@ fn test_tasks() -> Vec<SubconsciousTask> {
|
||||
]
|
||||
}
|
||||
|
||||
#[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": [
|
||||
|
||||
@@ -291,8 +291,15 @@ fn handle_status(_params: Map<String, Value>) -> 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,
|
||||
|
||||
@@ -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<String>,
|
||||
pub interval_minutes: u32,
|
||||
pub last_tick_at: Option<f64>,
|
||||
pub total_ticks: u64,
|
||||
|
||||
Reference in New Issue
Block a user