diff --git a/app/src/components/intelligence/SubconsciousTriggersPanel.tsx b/app/src/components/intelligence/SubconsciousTriggersPanel.tsx new file mode 100644 index 000000000..df888d8a7 --- /dev/null +++ b/app/src/components/intelligence/SubconsciousTriggersPanel.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { + setSubconsciousTriggersEnabled, + subconsciousTriggersStatus, + type SubconsciousTriggersStatus, +} from '../../utils/tauriCommands/subconscious'; + +const cardClass = + 'rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900'; + +/** + * Debug / manage panel for the event-driven subconscious trigger pipeline. + * Surfaces the `subconscious_triggers.status` RPC: whether the pipeline is + * enabled, the effective mode, the promotion budget, and live orchestrator + * runtime state (running flag + pending queue depth). Provides an + * enable/disable toggle (via `heartbeat_settings_set`). Polls every 5s. + * + * Works over any core transport (Tauri or cloud/tunnel) — `callCoreRpc` + * resolves the transport, so there is no `isTauri()` gate here. + */ +export default function SubconsciousTriggersPanel() { + const { t } = useT(); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [toggling, setToggling] = useState(false); + const inFlight = useRef(false); + + const refresh = useCallback(async () => { + if (inFlight.current) return; + inFlight.current = true; + try { + const res = await subconsciousTriggersStatus(); + setStatus(res.result ?? null); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + inFlight.current = false; + } + }, []); + + const toggle = useCallback(async () => { + if (toggling || !status) return; + setToggling(true); + try { + await setSubconsciousTriggersEnabled(!status.triggers_enabled); + setError(null); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setToggling(false); + } + }, [toggling, status, refresh]); + + useEffect(() => { + // refresh() only setStates asynchronously (after an await); the initial + // poll + 5s interval mirror the useSubconscious pattern. + // eslint-disable-next-line react-hooks/set-state-in-effect + void refresh(); + const id = setInterval(() => void refresh(), 5000); + return () => clearInterval(id); + }, [refresh]); + + return ( +
+
+
+

+ {t('subconsciousTriggers.title')} +

+

+ {t('subconsciousTriggers.subtitle')} +

+
+
+ {status && ( + + )} + +
+
+ + {loading && !status ? ( +

+ {t('common.loading')} +

+ ) : error ? ( +

+ {t('common.error')}: {error} +

+ ) : status ? ( +
+ + + + + + + + + {!status.triggers_enabled && ( +

+ {t('subconsciousTriggers.disabledHint')} +

+ )} +
+ ) : null} +
+ ); +} + +interface StatusRowProps { + label: string; + value: string; + tone?: 'default' | 'good' | 'muted'; + mono?: boolean; + testid?: string; +} + +function StatusRow({ label, value, tone = 'default', mono = false, testid }: StatusRowProps) { + const toneClass = + tone === 'good' + ? 'text-sage-600 dark:text-sage-400' + : tone === 'muted' + ? 'text-stone-400 dark:text-neutral-500' + : 'text-stone-800 dark:text-neutral-200'; + return ( +
+ {label} + {value} +
+ ); +} diff --git a/app/src/components/intelligence/__tests__/SubconsciousTriggersPanel.test.tsx b/app/src/components/intelligence/__tests__/SubconsciousTriggersPanel.test.tsx new file mode 100644 index 000000000..0c622650d --- /dev/null +++ b/app/src/components/intelligence/__tests__/SubconsciousTriggersPanel.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import SubconsciousTriggersPanel from '../SubconsciousTriggersPanel'; + +// Mock the lowest level (callCoreRpc) so the REAL tauriCommands wrappers +// (subconsciousTriggersStatus / setSubconsciousTriggersEnabled) execute and +// get covered, while the HTTP/transport call is stubbed. +vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); +// Identity translations keep assertions locale-independent (data-testid based). +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +const STATUS_METHOD = 'openhuman.subconscious_triggers_status'; +const SETTINGS_METHOD = 'openhuman.heartbeat_settings_set'; + +const DISABLED = { + triggers_enabled: false, + mode: 'off', + max_promotions_per_hour: 30, + orchestrator_running: false, + queue_depth: null as number | null, + orchestrator_thread_id: 'subconscious:orchestrator', + user_thread_id: 'subconscious:user', +}; +const ENABLED = { + ...DISABLED, + triggers_enabled: true, + mode: 'event_driven', + orchestrator_running: true, + queue_depth: 0, +}; + +let current: typeof DISABLED; +let statusShouldThrow = false; + +async function wireRpc() { + const { callCoreRpc } = await import('../../../services/coreRpcClient'); + vi.mocked(callCoreRpc).mockImplementation(async ({ method }: { method: string }) => { + if (method === STATUS_METHOD) { + if (statusShouldThrow) throw new Error('boom'); + return { result: current, logs: [] } as never; + } + if (method === SETTINGS_METHOD) { + return { result: { settings: {} }, logs: [] } as never; + } + return { result: {}, logs: [] } as never; + }); +} + +describe('SubconsciousTriggersPanel', () => { + beforeEach(async () => { + vi.clearAllMocks(); + current = { ...DISABLED }; + statusShouldThrow = false; + await wireRpc(); + }); + + it('renders the disabled baseline with the activation hint', async () => { + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('subconscious-triggers-status')).toBeTruthy()); + expect(screen.getByTestId('row-pipeline').textContent).toContain('common.disabled'); + expect(screen.getByTestId('row-orchestrator').textContent).toContain( + 'subconsciousTriggers.stopped' + ); + expect(screen.getByTestId('row-queue').textContent).toContain('—'); + expect(screen.getByTestId('row-orchestrator-thread').textContent).toContain( + 'subconscious:orchestrator' + ); + expect(screen.getByTestId('row-user-thread').textContent).toContain('subconscious:user'); + expect(screen.getByTestId('subconscious-triggers-disabled-hint')).toBeTruthy(); + }); + + it('enabling toggles the pipeline on and re-fetches', async () => { + const { callCoreRpc } = await import('../../../services/coreRpcClient'); + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('subconscious-triggers-toggle')).toBeTruthy()); + + // After enabling, subsequent status reads return the enabled snapshot. + current = { ...ENABLED }; + fireEvent.click(screen.getByTestId('subconscious-triggers-toggle')); + + expect(vi.mocked(callCoreRpc)).toHaveBeenCalledWith( + expect.objectContaining({ + method: SETTINGS_METHOD, + params: { triggers_enabled: true, subconscious_mode: 'event_driven' }, + }) + ); + await waitFor(() => + expect(screen.getByTestId('row-pipeline').textContent).toContain('common.enabled') + ); + expect(screen.getByTestId('row-orchestrator').textContent).toContain( + 'subconsciousTriggers.running' + ); + expect(screen.getByTestId('row-queue').textContent).not.toContain('—'); + }); + + it('disabling sends triggers_enabled=false and mode=off', async () => { + const { callCoreRpc } = await import('../../../services/coreRpcClient'); + current = { ...ENABLED }; + renderWithProviders(); + await waitFor(() => + expect(screen.getByTestId('row-pipeline').textContent).toContain('common.enabled') + ); + + fireEvent.click(screen.getByTestId('subconscious-triggers-toggle')); + expect(vi.mocked(callCoreRpc)).toHaveBeenCalledWith( + expect.objectContaining({ + method: SETTINGS_METHOD, + params: { triggers_enabled: false, subconscious_mode: 'off' }, + }) + ); + }); + + it('refresh re-fetches status', async () => { + const { callCoreRpc } = await import('../../../services/coreRpcClient'); + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('subconscious-triggers-status')).toBeTruthy()); + + const before = vi + .mocked(callCoreRpc) + .mock.calls.filter(([arg]) => (arg as { method: string }).method === STATUS_METHOD).length; + fireEvent.click(screen.getByTestId('subconscious-triggers-refresh')); + await waitFor(() => { + const after = vi + .mocked(callCoreRpc) + .mock.calls.filter(([arg]) => (arg as { method: string }).method === STATUS_METHOD).length; + expect(after).toBeGreaterThan(before); + }); + }); + + it('shows an error state when the status fetch fails', async () => { + statusShouldThrow = true; + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('subconscious-triggers-error')).toBeTruthy()); + }); +}); diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx index 31928ade9..c9bda0c46 100644 --- a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx @@ -148,7 +148,9 @@ const baseHeartbeatSettings = { meeting_lookahead_minutes: 60, max_calendar_connections_per_tick: 2, reminder_lookahead_minutes: 30, - subconscious_mode: 'off' as 'off' | 'simple' | 'aggressive', + subconscious_mode: 'off' as 'off' | 'simple' | 'aggressive' | 'event_driven', + triggers_enabled: false, + max_promotions_per_hour: 30, }; const baseUsage = { diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 7aa15da41..515c1e862 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2401,6 +2401,21 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'فشل', 'subconscious.decision.cancelled': 'ملغي', 'subconscious.decision.skipped': 'تم تخطيه', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'محفزات اللاوعي', + 'subconsciousTriggers.subtitle': 'منسق خلفي مدفوع بالأحداث', + 'subconsciousTriggers.pipeline': 'خط المعالجة', + 'subconsciousTriggers.mode': 'الوضع', + 'subconsciousTriggers.orchestrator': 'المنسق', + 'subconsciousTriggers.running': 'قيد التشغيل', + 'subconsciousTriggers.stopped': 'متوقف', + 'subconsciousTriggers.promotionsPerHour': 'الترقيات / ساعة', + 'subconsciousTriggers.queueDepth': 'عمق الطابور', + 'subconsciousTriggers.orchestratorThread': 'سلسلة المنسق', + 'subconsciousTriggers.userThread': 'سلسلة المستخدم', + 'subconsciousTriggers.disabledHint': 'فعّل الوضع المدفوع بالأحداث لتنشيط خط المعالجة.', + 'subconsciousTriggers.enable': 'تفعيل', + 'subconsciousTriggers.disable': 'تعطيل', 'actionable.complete': 'إتمام', 'actionable.dismiss': 'تجاهل', 'actionable.snooze': 'تأجيل', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 6a537a5b7..c9bb21c7a 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2453,6 +2453,21 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'ব্যর্থ', 'subconscious.decision.cancelled': 'বাতিল', 'subconscious.decision.skipped': 'এড়িয়ে গেছে', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'সাবকনশাস ট্রিগার', + 'subconsciousTriggers.subtitle': 'ইভেন্ট-চালিত ব্যাকগ্রাউন্ড অর্কেস্ট্রেটর', + 'subconsciousTriggers.pipeline': 'পাইপলাইন', + 'subconsciousTriggers.mode': 'মোড', + 'subconsciousTriggers.orchestrator': 'অর্কেস্ট্রেটর', + 'subconsciousTriggers.running': 'চলছে', + 'subconsciousTriggers.stopped': 'বন্ধ', + 'subconsciousTriggers.promotionsPerHour': 'প্রমোশন / ঘণ্টা', + 'subconsciousTriggers.queueDepth': 'সারির গভীরতা', + 'subconsciousTriggers.orchestratorThread': 'অর্কেস্ট্রেটর থ্রেড', + 'subconsciousTriggers.userThread': 'ব্যবহারকারী থ্রেড', + 'subconsciousTriggers.disabledHint': 'পাইপলাইন সক্রিয় করতে ইভেন্ট-চালিত মোড সক্ষম করুন।', + 'subconsciousTriggers.enable': 'সক্ষম করুন', + 'subconsciousTriggers.disable': 'নিষ্ক্রিয় করুন', 'actionable.complete': 'সম্পন্ন', 'actionable.dismiss': 'বাদ দিন', 'actionable.snooze': 'স্নুজ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4fc0e2422..6152fe31a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2509,6 +2509,22 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'Fehlgeschlagen', 'subconscious.decision.cancelled': 'Abgesagt', 'subconscious.decision.skipped': 'Übersprungen', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'Unterbewusstsein-Auslöser', + 'subconsciousTriggers.subtitle': 'Ereignisgesteuerter Hintergrund-Orchestrator', + 'subconsciousTriggers.pipeline': 'Pipeline', + 'subconsciousTriggers.mode': 'Modus', + 'subconsciousTriggers.orchestrator': 'Orchestrator', + 'subconsciousTriggers.running': 'Aktiv', + 'subconsciousTriggers.stopped': 'Gestoppt', + 'subconsciousTriggers.promotionsPerHour': 'Beförderungen / Stunde', + 'subconsciousTriggers.queueDepth': 'Warteschlangentiefe', + 'subconsciousTriggers.orchestratorThread': 'Orchestrator-Thread', + 'subconsciousTriggers.userThread': 'Benutzer-Thread', + 'subconsciousTriggers.disabledHint': + 'Aktiviere den ereignisgesteuerten Modus, um die Pipeline zu starten.', + 'subconsciousTriggers.enable': 'Aktivieren', + 'subconsciousTriggers.disable': 'Deaktivieren', 'actionable.complete': 'Komplett', 'actionable.dismiss': 'Entlassen', 'actionable.snooze': 'Schlummern', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ca146bcf3..0cc151a18 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2935,6 +2935,22 @@ const en: TranslationMap = { 'subconscious.decision.cancelled': 'Cancelled', 'subconscious.decision.skipped': 'Skipped', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'Subconscious Triggers', + 'subconsciousTriggers.subtitle': 'Event-driven background orchestrator', + 'subconsciousTriggers.pipeline': 'Pipeline', + 'subconsciousTriggers.mode': 'Mode', + 'subconsciousTriggers.orchestrator': 'Orchestrator', + 'subconsciousTriggers.running': 'Running', + 'subconsciousTriggers.stopped': 'Stopped', + 'subconsciousTriggers.promotionsPerHour': 'Promotions / hour', + 'subconsciousTriggers.queueDepth': 'Queue depth', + 'subconsciousTriggers.orchestratorThread': 'Orchestrator thread', + 'subconsciousTriggers.userThread': 'User thread', + 'subconsciousTriggers.disabledHint': 'Enable event-driven mode to activate the pipeline.', + 'subconsciousTriggers.enable': 'Enable', + 'subconsciousTriggers.disable': 'Disable', + // Actionable 'actionable.complete': 'Complete', 'actionable.dismiss': 'Dismiss', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 5ff7800bc..5a21c49f9 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2497,6 +2497,22 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'Fallido', 'subconscious.decision.cancelled': 'Cancelado', 'subconscious.decision.skipped': 'Omitido', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'Disparadores del subconsciente', + 'subconsciousTriggers.subtitle': 'Orquestador en segundo plano basado en eventos', + 'subconsciousTriggers.pipeline': 'Canalización', + 'subconsciousTriggers.mode': 'Modo', + 'subconsciousTriggers.orchestrator': 'Orquestador', + 'subconsciousTriggers.running': 'En ejecución', + 'subconsciousTriggers.stopped': 'Detenido', + 'subconsciousTriggers.promotionsPerHour': 'Promociones / hora', + 'subconsciousTriggers.queueDepth': 'Profundidad de la cola', + 'subconsciousTriggers.orchestratorThread': 'Hilo del orquestador', + 'subconsciousTriggers.userThread': 'Hilo del usuario', + 'subconsciousTriggers.disabledHint': + 'Activa el modo basado en eventos para iniciar la canalización.', + 'subconsciousTriggers.enable': 'Activar', + 'subconsciousTriggers.disable': 'Desactivar', 'actionable.complete': 'Completar', 'actionable.dismiss': 'Descartar', 'actionable.snooze': 'Posponer', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 7810a2d6c..b126aa399 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2510,6 +2510,22 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'Échoué', 'subconscious.decision.cancelled': 'Annulé', 'subconscious.decision.skipped': 'Ignoré', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'Déclencheurs du subconscient', + 'subconsciousTriggers.subtitle': "Orchestrateur d'arrière-plan piloté par événements", + 'subconsciousTriggers.pipeline': 'Pipeline', + 'subconsciousTriggers.mode': 'Mode', + 'subconsciousTriggers.orchestrator': 'Orchestrateur', + 'subconsciousTriggers.running': 'En cours', + 'subconsciousTriggers.stopped': 'Arrêté', + 'subconsciousTriggers.promotionsPerHour': 'Promotions / heure', + 'subconsciousTriggers.queueDepth': 'Profondeur de la file', + 'subconsciousTriggers.orchestratorThread': "Fil de l'orchestrateur", + 'subconsciousTriggers.userThread': "Fil de l'utilisateur", + 'subconsciousTriggers.disabledHint': + 'Activez le mode piloté par événements pour démarrer le pipeline.', + 'subconsciousTriggers.enable': 'Activer', + 'subconsciousTriggers.disable': 'Désactiver', 'actionable.complete': 'Terminer', 'actionable.dismiss': 'Ignorer', 'actionable.snooze': 'Reporter', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index b73db29bb..4b1076229 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2448,6 +2448,21 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'विफल', 'subconscious.decision.cancelled': 'रद्द हुआ', 'subconscious.decision.skipped': 'स्किप हुआ', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'अवचेतन ट्रिगर', + 'subconsciousTriggers.subtitle': 'इवेंट-संचालित बैकग्राउंड ऑर्केस्ट्रेटर', + 'subconsciousTriggers.pipeline': 'पाइपलाइन', + 'subconsciousTriggers.mode': 'मोड', + 'subconsciousTriggers.orchestrator': 'ऑर्केस्ट्रेटर', + 'subconsciousTriggers.running': 'चल रहा है', + 'subconsciousTriggers.stopped': 'रुका हुआ', + 'subconsciousTriggers.promotionsPerHour': 'प्रमोशन / घंटा', + 'subconsciousTriggers.queueDepth': 'क़तार की गहराई', + 'subconsciousTriggers.orchestratorThread': 'ऑर्केस्ट्रेटर थ्रेड', + 'subconsciousTriggers.userThread': 'उपयोगकर्ता थ्रेड', + 'subconsciousTriggers.disabledHint': 'पाइपलाइन सक्रिय करने के लिए इवेंट-संचालित मोड सक्षम करें।', + 'subconsciousTriggers.enable': 'सक्षम करें', + 'subconsciousTriggers.disable': 'अक्षम करें', 'actionable.complete': 'पूरा करें', 'actionable.dismiss': 'हटाएं', 'actionable.snooze': 'स्नूज़ करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 93ed9f276..8f4373a77 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2453,6 +2453,21 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'Gagal', 'subconscious.decision.cancelled': 'Dibatalkan', 'subconscious.decision.skipped': 'Dilewati', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'Pemicu Bawah Sadar', + 'subconsciousTriggers.subtitle': 'Orkestrator latar belakang berbasis peristiwa', + 'subconsciousTriggers.pipeline': 'Alur', + 'subconsciousTriggers.mode': 'Mode', + 'subconsciousTriggers.orchestrator': 'Orkestrator', + 'subconsciousTriggers.running': 'Berjalan', + 'subconsciousTriggers.stopped': 'Berhenti', + 'subconsciousTriggers.promotionsPerHour': 'Promosi / jam', + 'subconsciousTriggers.queueDepth': 'Kedalaman antrean', + 'subconsciousTriggers.orchestratorThread': 'Utas orkestrator', + 'subconsciousTriggers.userThread': 'Utas pengguna', + 'subconsciousTriggers.disabledHint': 'Aktifkan mode berbasis peristiwa untuk mengaktifkan alur.', + 'subconsciousTriggers.enable': 'Aktifkan', + 'subconsciousTriggers.disable': 'Nonaktifkan', 'actionable.complete': 'Selesai', 'actionable.dismiss': 'Abaikan', 'actionable.snooze': 'Tunda', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 62cd3050a..11dded23b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2492,6 +2492,22 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'Fallito', 'subconscious.decision.cancelled': 'Annullato', 'subconscious.decision.skipped': 'Saltato', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'Trigger del subconscio', + 'subconsciousTriggers.subtitle': 'Orchestratore in background guidato dagli eventi', + 'subconsciousTriggers.pipeline': 'Pipeline', + 'subconsciousTriggers.mode': 'Modalità', + 'subconsciousTriggers.orchestrator': 'Orchestratore', + 'subconsciousTriggers.running': 'In esecuzione', + 'subconsciousTriggers.stopped': 'Arrestato', + 'subconsciousTriggers.promotionsPerHour': 'Promozioni / ora', + 'subconsciousTriggers.queueDepth': 'Profondità della coda', + 'subconsciousTriggers.orchestratorThread': "Thread dell'orchestratore", + 'subconsciousTriggers.userThread': "Thread dell'utente", + 'subconsciousTriggers.disabledHint': + 'Attiva la modalità guidata dagli eventi per avviare la pipeline.', + 'subconsciousTriggers.enable': 'Attiva', + 'subconsciousTriggers.disable': 'Disattiva', 'actionable.complete': 'Completa', 'actionable.dismiss': 'Ignora', 'actionable.snooze': 'Posticipa', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 47f0f7ed6..5c1333ba9 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2427,6 +2427,21 @@ const messages: TranslationMap = { 'subconscious.decision.failed': '실패', 'subconscious.decision.cancelled': '취소됨', 'subconscious.decision.skipped': '건너뜀', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': '잠재의식 트리거', + 'subconsciousTriggers.subtitle': '이벤트 기반 백그라운드 오케스트레이터', + 'subconsciousTriggers.pipeline': '파이프라인', + 'subconsciousTriggers.mode': '모드', + 'subconsciousTriggers.orchestrator': '오케스트레이터', + 'subconsciousTriggers.running': '실행 중', + 'subconsciousTriggers.stopped': '중지됨', + 'subconsciousTriggers.promotionsPerHour': '승격 / 시간', + 'subconsciousTriggers.queueDepth': '큐 깊이', + 'subconsciousTriggers.orchestratorThread': '오케스트레이터 스레드', + 'subconsciousTriggers.userThread': '사용자 스레드', + 'subconsciousTriggers.disabledHint': '파이프라인을 활성화하려면 이벤트 기반 모드를 켜세요.', + 'subconsciousTriggers.enable': '활성화', + 'subconsciousTriggers.disable': '비활성화', 'actionable.complete': '완료', 'actionable.dismiss': '닫기', 'actionable.snooze': '다시 알림', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index e81bcb04d..5ffa31d4a 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2476,6 +2476,21 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'Niepowodzenie', 'subconscious.decision.cancelled': 'Anulowane', 'subconscious.decision.skipped': 'Pominięte', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'Wyzwalacze podświadomości', + 'subconsciousTriggers.subtitle': 'Sterowany zdarzeniami orkiestrator w tle', + 'subconsciousTriggers.pipeline': 'Potok', + 'subconsciousTriggers.mode': 'Tryb', + 'subconsciousTriggers.orchestrator': 'Orkiestrator', + 'subconsciousTriggers.running': 'Działa', + 'subconsciousTriggers.stopped': 'Zatrzymany', + 'subconsciousTriggers.promotionsPerHour': 'Awanse / godzina', + 'subconsciousTriggers.queueDepth': 'Głębokość kolejki', + 'subconsciousTriggers.orchestratorThread': 'Wątek orkiestratora', + 'subconsciousTriggers.userThread': 'Wątek użytkownika', + 'subconsciousTriggers.disabledHint': 'Włącz tryb sterowany zdarzeniami, aby uruchomić potok.', + 'subconsciousTriggers.enable': 'Włącz', + 'subconsciousTriggers.disable': 'Wyłącz', 'actionable.complete': 'Zakończ', 'actionable.dismiss': 'Odrzuć', 'actionable.snooze': 'Odłóż', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 963a1c068..9cc2db572 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2497,6 +2497,21 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'Falhou', 'subconscious.decision.cancelled': 'Cancelado', 'subconscious.decision.skipped': 'Pulado', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'Gatilhos do subconsciente', + 'subconsciousTriggers.subtitle': 'Orquestrador em segundo plano baseado em eventos', + 'subconsciousTriggers.pipeline': 'Pipeline', + 'subconsciousTriggers.mode': 'Modo', + 'subconsciousTriggers.orchestrator': 'Orquestrador', + 'subconsciousTriggers.running': 'Em execução', + 'subconsciousTriggers.stopped': 'Parado', + 'subconsciousTriggers.promotionsPerHour': 'Promoções / hora', + 'subconsciousTriggers.queueDepth': 'Profundidade da fila', + 'subconsciousTriggers.orchestratorThread': 'Thread do orquestrador', + 'subconsciousTriggers.userThread': 'Thread do usuário', + 'subconsciousTriggers.disabledHint': 'Ative o modo baseado em eventos para iniciar o pipeline.', + 'subconsciousTriggers.enable': 'Ativar', + 'subconsciousTriggers.disable': 'Desativar', 'actionable.complete': 'Concluir', 'actionable.dismiss': 'Dispensar', 'actionable.snooze': 'Adiar', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 071f421c3..a77635811 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2470,6 +2470,22 @@ const messages: TranslationMap = { 'subconscious.decision.failed': 'Ошибка', 'subconscious.decision.cancelled': 'Отменено', 'subconscious.decision.skipped': 'Пропущено', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': 'Триггеры подсознания', + 'subconsciousTriggers.subtitle': 'Фоновый оркестратор на основе событий', + 'subconsciousTriggers.pipeline': 'Конвейер', + 'subconsciousTriggers.mode': 'Режим', + 'subconsciousTriggers.orchestrator': 'Оркестратор', + 'subconsciousTriggers.running': 'Работает', + 'subconsciousTriggers.stopped': 'Остановлен', + 'subconsciousTriggers.promotionsPerHour': 'Продвижения / час', + 'subconsciousTriggers.queueDepth': 'Глубина очереди', + 'subconsciousTriggers.orchestratorThread': 'Поток оркестратора', + 'subconsciousTriggers.userThread': 'Поток пользователя', + 'subconsciousTriggers.disabledHint': + 'Включите режим на основе событий, чтобы запустить конвейер.', + 'subconsciousTriggers.enable': 'Включить', + 'subconsciousTriggers.disable': 'Отключить', 'actionable.complete': 'Выполнить', 'actionable.dismiss': 'Закрыть', 'actionable.snooze': 'Отложить', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 18a1ce40d..89ed6ea65 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2334,6 +2334,21 @@ const messages: TranslationMap = { 'subconscious.decision.failed': '失败', 'subconscious.decision.cancelled': '已取消', 'subconscious.decision.skipped': '已跳过', + // Subconscious triggers (event-driven orchestrator) debug panel + 'subconsciousTriggers.title': '潜意识触发器', + 'subconsciousTriggers.subtitle': '事件驱动的后台编排器', + 'subconsciousTriggers.pipeline': '流水线', + 'subconsciousTriggers.mode': '模式', + 'subconsciousTriggers.orchestrator': '编排器', + 'subconsciousTriggers.running': '运行中', + 'subconsciousTriggers.stopped': '已停止', + 'subconsciousTriggers.promotionsPerHour': '提升 / 小时', + 'subconsciousTriggers.queueDepth': '队列深度', + 'subconsciousTriggers.orchestratorThread': '编排器线程', + 'subconsciousTriggers.userThread': '用户线程', + 'subconsciousTriggers.disabledHint': '启用事件驱动模式以激活流水线。', + 'subconsciousTriggers.enable': '启用', + 'subconsciousTriggers.disable': '停用', 'actionable.complete': '完成', 'actionable.dismiss': '忽略', 'actionable.snooze': '稍后提醒', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index d2fef526d..ef73f846d 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -13,6 +13,7 @@ import { MemoryControls } from '../components/intelligence/MemoryControls'; import { MemoryGraph } from '../components/intelligence/MemoryGraph'; import { MemorySourcesRegistry } from '../components/intelligence/MemorySourcesRegistry'; import { MemoryTreeStatusPanel } from '../components/intelligence/MemoryTreeStatusPanel'; +import SubconsciousTriggersPanel from '../components/intelligence/SubconsciousTriggersPanel'; import { ToastContainer } from '../components/intelligence/Toast'; import PanelPage from '../components/layout/PanelPage'; import { SidebarContent } from '../components/layout/shell/SidebarSlot'; @@ -307,6 +308,7 @@ export default function Brain() { setIntervalMinutes={sub.setIntervalMinutes} /> + )} diff --git a/app/src/utils/tauriCommands/heartbeat.ts b/app/src/utils/tauriCommands/heartbeat.ts index 0454d369b..9f3568357 100644 --- a/app/src/utils/tauriCommands/heartbeat.ts +++ b/app/src/utils/tauriCommands/heartbeat.ts @@ -4,7 +4,7 @@ import { callCoreRpc } from '../../services/coreRpcClient'; import { type CommandResponse, isTauri } from './common'; -export type SubconsciousMode = 'off' | 'simple' | 'aggressive'; +export type SubconsciousMode = 'off' | 'simple' | 'aggressive' | 'event_driven'; export interface HeartbeatSettings { enabled: boolean; @@ -18,6 +18,8 @@ export interface HeartbeatSettings { max_calendar_connections_per_tick: number; reminder_lookahead_minutes: number; subconscious_mode: SubconsciousMode; + triggers_enabled: boolean; + max_promotions_per_hour: number; } export type HeartbeatSettingsPatch = Partial; diff --git a/app/src/utils/tauriCommands/subconscious.ts b/app/src/utils/tauriCommands/subconscious.ts index 1fb7b0b6a..428ebd58c 100644 --- a/app/src/utils/tauriCommands/subconscious.ts +++ b/app/src/utils/tauriCommands/subconscious.ts @@ -6,12 +6,13 @@ */ import { callCoreRpc } from '../../services/coreRpcClient'; import { type CommandResponse, isTauri } from './common'; +import type { HeartbeatSettings } from './heartbeat'; // ── Types ──────────────────────────────────────────────────────────────────── export interface SubconsciousStatus { enabled: boolean; - mode: 'off' | 'simple' | 'aggressive'; + mode: 'off' | 'simple' | 'aggressive' | 'event_driven'; provider_available: boolean; provider_unavailable_reason: string | null; interval_minutes: number; @@ -26,6 +27,17 @@ export interface TickResult { response_chars?: number; } +/** Status of the event-driven subconscious trigger pipeline. */ +export interface SubconsciousTriggersStatus { + triggers_enabled: boolean; + mode: string; + max_promotions_per_hour: number; + orchestrator_running: boolean; + queue_depth: number | null; + orchestrator_thread_id: string; + user_thread_id: string; +} + // ── Status & Trigger ───────────────────────────────────────────────────────── export async function subconsciousStatus(): Promise> { @@ -41,3 +53,33 @@ export async function subconsciousTrigger(): Promise method: 'openhuman.subconscious_trigger', }); } + +// The trigger-pipeline status + toggle work over any core transport (Tauri +// invoke or cloud/tunnel HTTP), so they intentionally do NOT gate on +// `isTauri()` — `callCoreRpc` resolves the active transport itself. + +export async function subconsciousTriggersStatus(): Promise< + CommandResponse +> { + return await callCoreRpc>({ + method: 'openhuman.subconscious_triggers_status', + }); +} + +/** + * Enable or disable the event-driven trigger pipeline. + * + * Enabling flips the subconscious into `event_driven` mode so the orchestrator + * bootstraps. Disabling also resets the mode to `off` — otherwise the earlier + * enable would leave `event_driven`/`inference_enabled` set and the legacy + * heartbeat tick would keep running every 5 min after the pipeline is turned + * off. The core restarts the heartbeat loop on this change. + */ +export async function setSubconsciousTriggersEnabled( + enabled: boolean +): Promise> { + return await callCoreRpc>({ + method: 'openhuman.heartbeat_settings_set', + params: { triggers_enabled: enabled, subconscious_mode: enabled ? 'event_driven' : 'off' }, + }); +} diff --git a/app/test/playwright/specs/subconscious-triggers.spec.ts b/app/test/playwright/specs/subconscious-triggers.spec.ts new file mode 100644 index 000000000..a8922fc29 --- /dev/null +++ b/app/test/playwright/specs/subconscious-triggers.spec.ts @@ -0,0 +1,92 @@ +import { expect, type Page, test } from '@playwright/test'; + +import { bootAuthenticatedPage, callCoreRpc, waitForAppReady } from '../helpers/core-rpc'; + +const USER_ID = 'pw-subconscious-triggers'; +const BRAIN_SUBCONSCIOUS = '/brain?tab=subconscious'; + +/** Reset the trigger pipeline to a known disabled baseline (config-driven). */ +async function resetTriggersDisabled(): Promise { + await callCoreRpc('openhuman.heartbeat_settings_set', { + triggers_enabled: false, + subconscious_mode: 'off', + max_promotions_per_hour: 30, + }); +} + +async function openPanel(page: Page): Promise { + await bootAuthenticatedPage(page, USER_ID, BRAIN_SUBCONSCIOUS); + await waitForAppReady(page); + await expect(page.getByTestId('subconscious-triggers-panel')).toBeVisible({ timeout: 20_000 }); + // Status resolves once the first poll returns. + await expect(page.getByTestId('subconscious-triggers-status')).toBeVisible({ timeout: 20_000 }); +} + +test.describe('Brain — Subconscious Triggers panel', () => { + test.beforeEach(async () => { + await resetTriggersDisabled(); + }); + + test('renders the status rows with the disabled baseline', async ({ page }) => { + await openPanel(page); + + // Pipeline shows disabled + the activation hint, orchestrator stopped-by-config. + await expect(page.getByTestId('row-pipeline')).toContainText(/Disabled/i); + await expect(page.getByTestId('subconscious-triggers-disabled-hint')).toBeVisible(); + + // Structural rows are present. + await expect(page.getByTestId('row-mode')).toBeVisible(); + await expect(page.getByTestId('row-promotions')).toContainText('30'); + await expect(page.getByTestId('row-queue')).toBeVisible(); + // Reserved thread ids surface verbatim from the core. + await expect(page.getByTestId('row-orchestrator-thread')).toContainText( + 'subconscious:orchestrator' + ); + await expect(page.getByTestId('row-user-thread')).toContainText('subconscious:user'); + }); + + test('enabling the pipeline flips it to event-driven and starts the orchestrator', async ({ + page, + }) => { + await openPanel(page); + await expect(page.getByTestId('row-pipeline')).toContainText(/Disabled/i); + + // Toggle on → core enables triggers + event_driven mode and bootstraps. + await page.getByTestId('subconscious-triggers-toggle').click(); + + await expect(page.getByTestId('row-pipeline')).toContainText(/Enabled/i, { timeout: 20_000 }); + await expect(page.getByTestId('row-mode')).toContainText('event_driven'); + await expect(page.getByTestId('row-orchestrator')).toContainText(/Running/i, { + timeout: 20_000, + }); + // Once running, the queue depth is a number (0 when idle), not the em-dash. + await expect(page.getByTestId('row-queue')).not.toContainText('—'); + // The toggle now offers the inverse action and the hint is gone. + await expect(page.getByTestId('subconscious-triggers-toggle')).toContainText(/Disable/i); + await expect(page.getByTestId('subconscious-triggers-disabled-hint')).toHaveCount(0); + }); + + test('disabling the pipeline returns it to disabled', async ({ page }) => { + await openPanel(page); + + // Enable first. + await page.getByTestId('subconscious-triggers-toggle').click(); + await expect(page.getByTestId('row-pipeline')).toContainText(/Enabled/i, { timeout: 20_000 }); + + // Then disable. + await page.getByTestId('subconscious-triggers-toggle').click(); + await expect(page.getByTestId('row-pipeline')).toContainText(/Disabled/i, { timeout: 20_000 }); + await expect(page.getByTestId('subconscious-triggers-toggle')).toContainText(/Enable/i); + }); + + test('refresh re-fetches without error', async ({ page }) => { + await openPanel(page); + + await page.getByTestId('subconscious-triggers-refresh').click(); + + // Still showing status, no error surfaced. + await expect(page.getByTestId('subconscious-triggers-status')).toBeVisible(); + await expect(page.getByTestId('subconscious-triggers-error')).toHaveCount(0); + await expect(page.getByTestId('row-pipeline')).toBeVisible(); + }); +}); diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 238c08056..e861ff257 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -279,7 +279,12 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | 6.3.1 | Steer a running sub-agent | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/steer_subagent.rs` | ✅ | `steer_subagent` injects a steer/collect message into a running async sub-agent's run-queue; registry enforces parent ownership + terminal guard. | | 6.3.2 | Wait for a sub-agent result | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/wait_subagent.rs` | ✅ | `wait_subagent` blocks on the completion `watch` with a timeout; prunes terminal entries, leaves entries intact on timeout. | | 6.3.3 | Steer lands in child history | RU | `src/openhuman/agent/harness/subagent_runner/ops_tests.rs::run_queue_steer_lands_in_subagent_history` | ✅ | End-to-end: a queued steer is drained by the child `run_turn_engine` and appears as a `[User steering message]` user turn in the provider request. | -| 6.3.4 | Vision sub-agent reads attached images | RU | `src/openhuman/agent_registry/agents/loader.rs::vision_agent_loads_on_vision_hint`, `src/openhuman/inference/provider/factory_tests.rs::vision_tier_is_vision_capable`, `src/openhuman/agent/harness/engine/core.rs::gate_tests`, `src/openhuman/agent/multimodal_tests.rs::extract_image_placeholders_pulls_att_tokens_in_order` | ✅ | Orchestrator (non-vision `chat-v1`) keeps the image as a placeholder, delegates to `vision_agent` on the `vision-v1` tier, which rehydrates the on-disk attachment and reads it. Engine gate prefers per-tier `current_model_vision`; turn placeholders forwarded into the sub-agent prompt. | +| 6.3.4 | Subconscious trigger pipeline (normalize → dedupe/rate → gate → queue) | RU+RI | `src/openhuman/subconscious_triggers/`, `tests/subconscious_triggers_e2e.rs` | ✅ | Event→Trigger normalization for cron/user/composio/sub-agent, dedupe TTL + per-source rate limit, LLM gate over `agent::triage`, priority queue with overflow eviction. | +| 6.3.5 | Long-lived subconscious orchestrator session | RU | `src/openhuman/subconscious/session.rs`, `src/openhuman/subconscious/user_thread.rs` | ✅ | Persistent compressed session backed by a reserved thread; `notify_user` handoff to the user-facing thread; mode→autonomy config parity. | +| 6.3.6 | Multi-party human↔subconscious↔sub-agent conversation | RI | `tests/subconscious_conversation_e2e.rs` | ✅ | Scripted Gate/SessionExecutor seam drives delegate→sub-agent→merge, failure/retry, interleaving, dedupe, and rate-limit scenarios through the real orchestrator. | +| 6.3.7 | Full-stack trigger pipeline with mocked LLM | RI | `tests/subconscious_fullstack_e2e.rs` (feature `e2e-test-support`) | ✅ | Real `GatePass`+`LongLivedSession`+`Agent`+sub-agent run against a provider-layer mock (no network); promote/drop, persistence, real `spawn_subagent`. | +| 6.3.8 | Subconscious Triggers debug/manage panel (Brain) | WD | `app/test/playwright/specs/subconscious-triggers.spec.ts` | ✅ | Brain→Subconscious panel: renders disabled baseline + hint + reserved thread ids; enable toggle → Pipeline Enabled + event_driven + orchestrator running; disable; refresh re-fetches. | +| 6.3.9 | Vision sub-agent reads attached images | RU | `src/openhuman/agent_registry/agents/loader.rs::vision_agent_loads_on_vision_hint`, `src/openhuman/inference/provider/factory_tests.rs::vision_tier_is_vision_capable`, `src/openhuman/agent/harness/engine/core.rs::gate_tests`, `src/openhuman/agent/multimodal_tests.rs::extract_image_placeholders_pulls_att_tokens_in_order` | ✅ | Orchestrator (non-vision `chat-v1`) keeps the image as a placeholder, delegates to `vision_agent` on the `vision-v1` tier, which rehydrates the on-disk attachment and reads it. Engine gate prefers per-tier `current_model_vision`; turn placeholders forwarded into the sub-agent prompt. | --- diff --git a/src/core/all.rs b/src/core/all.rs index 5704b0c53..4e1898e4c 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -261,6 +261,9 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::voice::all_voice_registered_controllers()); // Background awareness and autonomous tasks controllers.extend(crate::openhuman::subconscious::all_subconscious_registered_controllers()); + controllers.extend( + crate::openhuman::subconscious_triggers::all_subconscious_triggers_registered_controllers(), + ); // Webhook tunnel management controllers.extend(crate::openhuman::webhooks::all_webhooks_registered_controllers()); // Core binary update management @@ -416,6 +419,9 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::text_input::all_text_input_controller_schemas()); schemas.extend(crate::openhuman::voice::all_voice_controller_schemas()); schemas.extend(crate::openhuman::subconscious::all_subconscious_controller_schemas()); + schemas.extend( + crate::openhuman::subconscious_triggers::all_subconscious_triggers_controller_schemas(), + ); schemas.extend(crate::openhuman::webhooks::all_webhooks_controller_schemas()); schemas.extend(crate::openhuman::update::all_update_controller_schemas()); schemas.extend(crate::openhuman::memory_tree::all_tree_summarizer_controller_schemas()); @@ -565,6 +571,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { ), "voice" => Some("Speech-to-text and text-to-speech using local models."), "subconscious" => Some("Periodic local-model background awareness loop."), + "subconscious_triggers" => { + Some("Event-driven trigger pipeline feeding the background orchestrator.") + } "text_input" => Some("Read, insert, and preview text in the OS-focused input field."), "webhooks" => { Some("Webhook tunnel registrations and captured request/response debug logs.") diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index b02d19801..29f1d610d 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -130,6 +130,21 @@ pub enum DomainEvent { reason: Option, }, + // ── Subconscious orchestrator ─────────────────────────────────────── + /// A subconscious trigger finished gate evaluation (promote or drop). + /// Observability only — lets dashboards see ingestion volume and the + /// gate's promote/drop ratio without reading logs. + SubconsciousTriggerProcessed { + /// Trigger source family (`cron` / `user_message` / …). + source: String, + /// Gate decision (`promote` / `drop`). + decision: String, + /// Whether the trigger was promoted into the long-lived session. + promoted: bool, + /// Gate evaluation latency in milliseconds. + latency_ms: u64, + }, + // ── Run Queue ────────────────────────────────────────────────────── /// A message was queued into the active-run queue instead of interrupting. RunQueueMessageQueued { @@ -1211,6 +1226,8 @@ impl DomainEvent { Self::TaskPlanAwaitingApproval { .. } | Self::TaskRunReclaimed { .. } => "agent", + Self::SubconsciousTriggerProcessed { .. } => "subconscious", + Self::Voice(_) => "voice", Self::ApprovalRequested { .. } @@ -1263,6 +1280,7 @@ impl DomainEvent { Self::AgentOrchestrationCompleted { .. } => "AgentOrchestrationCompleted", Self::AgentOrchestrationFailed { .. } => "AgentOrchestrationFailed", Self::AgentOrchestrationClosed { .. } => "AgentOrchestrationClosed", + Self::SubconsciousTriggerProcessed { .. } => "SubconsciousTriggerProcessed", Self::RunQueueMessageQueued { .. } => "RunQueueMessageQueued", Self::RunQueueMessageDelivered { .. } => "RunQueueMessageDelivered", Self::RunQueueFollowupDispatched { .. } => "RunQueueFollowupDispatched", diff --git a/src/openhuman/config/schema/heartbeat_cron.rs b/src/openhuman/config/schema/heartbeat_cron.rs index 527cafb57..5271a64e0 100644 --- a/src/openhuman/config/schema/heartbeat_cron.rs +++ b/src/openhuman/config/schema/heartbeat_cron.rs @@ -16,6 +16,11 @@ pub enum SubconsciousMode { /// Full tool access every 5 minutes. Can write, spawn sub-agents, /// and delegate tasks to the orchestrator. Aggressive, + /// Event-driven: full tool access, woken by the trigger pipeline (cron / + /// user message / Composio webhook / sub-agent conclusion) rather than a + /// fixed interval. The cron cadence is the periodic-heartbeat fallback. + #[serde(rename = "event_driven")] + EventDriven, } impl SubconsciousMode { @@ -28,6 +33,9 @@ impl SubconsciousMode { Self::Off => 5, Self::Simple => 30, Self::Aggressive => 5, + // Periodic heartbeat fallback cadence; real reactivity comes + // from the event pipeline, not the timer. + Self::EventDriven => 5, } } @@ -35,11 +43,17 @@ impl SubconsciousMode { matches!(self, Self::Simple) } + /// Whether this mode runs the event-driven trigger pipeline. + pub fn is_event_driven(self) -> bool { + matches!(self, Self::EventDriven) + } + pub fn as_str(self) -> &'static str { match self { Self::Off => "off", Self::Simple => "simple", Self::Aggressive => "aggressive", + Self::EventDriven => "event_driven", } } @@ -47,6 +61,7 @@ impl SubconsciousMode { match s { "simple" => Self::Simple, "aggressive" => Self::Aggressive, + "event_driven" => Self::EventDriven, _ => Self::Off, } } @@ -101,6 +116,20 @@ pub struct HeartbeatConfig { /// Aggressive = full access every 5 min. #[serde(default)] pub subconscious_mode: SubconsciousMode, + /// Enable the event-driven subconscious trigger pipeline (cron / user + /// message / Composio webhook / sub-agent conclusion → LLM gate → + /// long-lived orchestrator session). Opt-in: when false, the legacy + /// interval-only heartbeat path is unchanged. + #[serde(default)] + pub triggers_enabled: bool, + /// Per-hour cap on trigger promotions (long-lived session runs). Bounds + /// the always-on loop's spend ceiling. + #[serde(default = "default_max_promotions_per_hour")] + pub max_promotions_per_hour: u32, +} + +fn default_max_promotions_per_hour() -> u32 { + 30 } fn default_context_budget() -> u32 { @@ -148,6 +177,8 @@ impl Default for HeartbeatConfig { max_calendar_connections_per_tick: default_max_calendar_connections_per_tick(), reminder_lookahead_minutes: default_reminder_lookahead_minutes(), subconscious_mode: SubconsciousMode::Off, + triggers_enabled: false, + max_promotions_per_hour: default_max_promotions_per_hour(), } } } @@ -186,6 +217,44 @@ mod tests { assert_eq!(config.interval_minutes, 5); assert_eq!(config.max_calendar_connections_per_tick, 2); assert_eq!(config.subconscious_mode, SubconsciousMode::Off); + // Event-driven trigger pipeline is opt-in (back-compat: old configs + // without these keys deserialize to the legacy interval-only path). + assert!(!config.triggers_enabled); + assert_eq!(config.max_promotions_per_hour, 30); + } + + #[test] + fn legacy_config_without_trigger_keys_deserializes() { + // A config file predating the trigger pipeline must still parse and + // default to the disabled (legacy) path. + let legacy = r#"{ "enabled": true, "inference_enabled": true }"#; + let config: HeartbeatConfig = serde_json::from_str(legacy).unwrap(); + assert!(!config.triggers_enabled); + assert_eq!(config.max_promotions_per_hour, 30); + assert_eq!( + config.effective_subconscious_mode(), + SubconsciousMode::Simple + ); + } + + #[test] + fn event_driven_mode_serde_and_helpers() { + assert_eq!( + serde_json::to_string(&SubconsciousMode::EventDriven).unwrap(), + r#""event_driven""# + ); + assert_eq!( + serde_json::from_str::(r#""event_driven""#).unwrap(), + SubconsciousMode::EventDriven + ); + assert!(SubconsciousMode::EventDriven.is_enabled()); + assert!(SubconsciousMode::EventDriven.is_event_driven()); + assert!(!SubconsciousMode::Aggressive.is_event_driven()); + assert!(!SubconsciousMode::EventDriven.is_read_only()); + assert_eq!( + SubconsciousMode::from_str_lossy("event_driven"), + SubconsciousMode::EventDriven + ); } #[test] diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 9d415d170..3381733bd 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -408,11 +408,11 @@ pub(crate) fn resolve_byok_fallback_provider_string(config: &Config) -> Option) -> InstallGuard { + pub fn install(provider: Arc) -> InstallGuard { *cell().lock().unwrap() = Some(provider); InstallGuard } - pub(crate) struct InstallGuard; + pub struct InstallGuard; impl Drop for InstallGuard { fn drop(&mut self) { *cell().lock().unwrap() = None; @@ -482,8 +482,9 @@ pub fn create_chat_provider( config: &Config, ) -> anyhow::Result<(Box, String)> { // Test-only: a scripted mock provider injected by an e2e test wins over - // anything config-derived. Never compiled into release builds. - #[cfg(test)] + // anything config-derived. Gated on cfg(test) / the off-by-default + // `e2e-test-support` feature; never consulted in shipped builds. + #[cfg(any(test, feature = "e2e-test-support"))] if let Some(p) = test_provider_override::current() { return Ok(( Box::new(test_provider_override::ProviderHandle(p)), diff --git a/src/openhuman/inference/provider/ops/provider_factory.rs b/src/openhuman/inference/provider/ops/provider_factory.rs index 09dc0436b..2e49462b9 100644 --- a/src/openhuman/inference/provider/ops/provider_factory.rs +++ b/src/openhuman/inference/provider/ops/provider_factory.rs @@ -152,6 +152,16 @@ pub fn create_routed_provider_with_options( default_model: &str, options: &ProviderRuntimeOptions, ) -> anyhow::Result> { + // Test-only: a mock provider injected by an e2e test wins over any + // config-derived routing (covers the triage remote arm). Gated on + // cfg(test) / the off-by-default `e2e-test-support` feature. + #[cfg(any(test, feature = "e2e-test-support"))] + if let Some(p) = super::super::factory::test_provider_override::current() { + return Ok(Box::new( + super::super::factory::test_provider_override::ProviderHandle(p), + )); + } + if model_routes.is_empty() { return create_resilient_provider_with_options( inference_url, diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 560935f45..8611156ad 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -105,6 +105,7 @@ pub mod skill_runtime; pub mod socket; pub mod startup; pub mod subconscious; +pub mod subconscious_triggers; pub mod task_sources; pub mod team; #[cfg(feature = "e2e-test-support")] diff --git a/src/openhuman/subconscious/agent/agent.toml b/src/openhuman/subconscious/agent/agent.toml index 74bdc1b2c..fc2e2fcde 100644 --- a/src/openhuman/subconscious/agent/agent.toml +++ b/src/openhuman/subconscious/agent/agent.toml @@ -29,4 +29,5 @@ named = [ "scratchpad_edit", "scratchpad_remove", "spawn_subagent", + "notify_user", ] diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs index 6156ffe97..f9ba16f94 100644 --- a/src/openhuman/subconscious/engine.rs +++ b/src/openhuman/subconscious/engine.rs @@ -335,7 +335,7 @@ impl SubconsciousEngine { effective.autonomy.level = crate::openhuman::security::AutonomyLevel::ReadOnly; effective.agent.max_tool_iterations = 15; } - SubconsciousMode::Aggressive => { + SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => { effective.autonomy.level = crate::openhuman::security::AutonomyLevel::Full; effective.agent.max_tool_iterations = 30; } diff --git a/src/openhuman/subconscious/global.rs b/src/openhuman/subconscious/global.rs index 14fbebbd1..bbb6fc812 100644 --- a/src/openhuman/subconscious/global.rs +++ b/src/openhuman/subconscious/global.rs @@ -92,9 +92,50 @@ pub async fn bootstrap_after_login() -> Result<(), String> { config.heartbeat.interval_minutes ); + // Opt-in event-driven trigger pipeline. Require BOTH the flag and an + // event-driven effective mode: if the user enables triggers and later + // switches the subconscious mode to Off/Simple/Aggressive via the mode + // selector (which doesn't clear `triggers_enabled`), the stale flag must + // not silently reactivate background trigger processing. + if config.heartbeat.triggers_enabled + && config + .heartbeat + .effective_subconscious_mode() + .is_event_driven() + { + bootstrap_trigger_orchestrator(&config); + } + Ok(()) } +/// Spawn the background trigger orchestrator (event loop) and register its +/// bus subscriber. Idempotent via the orchestrator's process-global slot. +fn bootstrap_trigger_orchestrator(config: &crate::openhuman::config::Config) { + use crate::openhuman::subconscious_triggers::{ + init_orchestrator, register_subconscious_triggers_subscriber, OrchestratorConfig, + TriggerOrchestrator, + }; + + let mode = config.heartbeat.effective_subconscious_mode(); + let session = Arc::new(super::LongLivedSession::new( + config.workspace_dir.clone(), + mode, + )); + let orch_config = OrchestratorConfig { + max_promotions_per_hour: config.heartbeat.max_promotions_per_hour, + ..OrchestratorConfig::default() + }; + let orchestrator = init_orchestrator(Arc::new(TriggerOrchestrator::new(session, orch_config))); + register_subconscious_triggers_subscriber(orchestrator); + tracing::info!( + workspace = %config.workspace_dir.display(), + mode = %mode.as_str(), + max_promotions_per_hour = config.heartbeat.max_promotions_per_hour, + "[subconscious_triggers] event-driven orchestrator bootstrapped" + ); +} + pub async fn stop_heartbeat_loop() { if let Some(handle) = heartbeat_slot().lock().await.take() { handle.abort(); @@ -111,6 +152,13 @@ pub async fn stop_heartbeat_loop() { } } + // Tear down the event-driven trigger orchestrator + its bus subscriber on + // every stop (disable, mode change, user switch) so a stale session/loop + // never keeps routing trigger work after the pipeline is turned off or the + // workspace changes. A subsequent bootstrap re-creates them when enabled. + crate::openhuman::subconscious_triggers::shutdown_orchestrator(); + crate::openhuman::subconscious_triggers::unregister_subconscious_triggers_subscriber(); + BOOTSTRAPPED.store(false, Ordering::SeqCst); } diff --git a/src/openhuman/subconscious/heartbeat/rpc.rs b/src/openhuman/subconscious/heartbeat/rpc.rs index 3f5f6dd66..233804487 100644 --- a/src/openhuman/subconscious/heartbeat/rpc.rs +++ b/src/openhuman/subconscious/heartbeat/rpc.rs @@ -21,6 +21,10 @@ pub struct HeartbeatSettingsPatch { pub max_calendar_connections_per_tick: Option, pub reminder_lookahead_minutes: Option, pub subconscious_mode: Option, + /// Enable the event-driven trigger pipeline (opt-in). + pub triggers_enabled: Option, + /// Per-hour cap on trigger promotions. + pub max_promotions_per_hour: Option, } #[derive(Debug, Clone, Serialize)] @@ -36,6 +40,8 @@ pub struct HeartbeatSettingsView { pub max_calendar_connections_per_tick: u32, pub reminder_lookahead_minutes: u32, pub subconscious_mode: String, + pub triggers_enabled: bool, + pub max_promotions_per_hour: u32, } pub async fn settings_get() -> Result, String> { @@ -99,6 +105,23 @@ pub async fn settings_set( config.heartbeat.enabled = mode.is_enabled() || config.heartbeat.enabled; config.heartbeat.inference_enabled = mode.is_enabled(); config.heartbeat.interval_minutes = mode.default_interval_minutes(); + // Switching to a non-event-driven mode (e.g. via the Subconscious mode + // selector) must also retire the trigger pipeline, otherwise a stale + // `triggers_enabled` flag would reactivate it on the next bootstrap. + if !mode.is_event_driven() { + config.heartbeat.triggers_enabled = false; + } + } + if let Some(triggers_enabled) = patch.triggers_enabled { + config.heartbeat.triggers_enabled = triggers_enabled; + // Enabling the trigger pipeline requires the heartbeat loop to be + // running so the orchestrator can bootstrap. + if triggers_enabled { + config.heartbeat.enabled = true; + } + } + if let Some(max_promotions_per_hour) = patch.max_promotions_per_hour { + config.heartbeat.max_promotions_per_hour = max_promotions_per_hour; } config.save().await.map_err(|e| { @@ -107,8 +130,17 @@ pub async fn settings_set( })?; // Mode change requires a full engine restart so the new mode's interval - // and tool restrictions take effect. stop + bootstrap is idempotent. - if patch.subconscious_mode.is_some() || patch.enabled.is_some() { + // and tool restrictions take effect. Toggling the trigger pipeline also + // restarts so the orchestrator bootstraps/stops. stop + bootstrap is + // idempotent. + // `max_promotions_per_hour` is included so a cap change re-bootstraps the + // orchestrator (which rebuilds `GatePass` with the new `PromotionBudget`); + // otherwise the running gate would keep enforcing the old cap. + if patch.subconscious_mode.is_some() + || patch.enabled.is_some() + || patch.triggers_enabled.is_some() + || patch.max_promotions_per_hour.is_some() + { crate::openhuman::subconscious::global::stop_heartbeat_loop().await; if config.heartbeat.effective_subconscious_mode().is_enabled() { debug!("[heartbeat][rpc] settings_set: (re)starting for mode change"); @@ -167,5 +199,7 @@ fn view(config: &Config) -> HeartbeatSettingsView { .effective_subconscious_mode() .as_str() .to_string(), + triggers_enabled: config.heartbeat.triggers_enabled, + max_promotions_per_hour: config.heartbeat.max_promotions_per_hour, } } diff --git a/src/openhuman/subconscious/heartbeat/schemas.rs b/src/openhuman/subconscious/heartbeat/schemas.rs index 1f7ed485e..c6f81dae6 100644 --- a/src/openhuman/subconscious/heartbeat/schemas.rs +++ b/src/openhuman/subconscious/heartbeat/schemas.rs @@ -83,7 +83,15 @@ pub fn schemas(function: &str) -> ControllerSchema { ), optional_string( "subconscious_mode", - "Subconscious operating mode: off, simple, or aggressive.", + "Subconscious operating mode: off, simple, aggressive, or event_driven.", + ), + optional_bool( + "triggers_enabled", + "Enable the event-driven subconscious trigger pipeline.", + ), + optional_u64( + "max_promotions_per_hour", + "Per-hour cap on trigger promotions (long-lived session runs).", ), ], outputs: vec![FieldSchema { diff --git a/src/openhuman/subconscious/mod.rs b/src/openhuman/subconscious/mod.rs index ada2091d7..fc1188002 100644 --- a/src/openhuman/subconscious/mod.rs +++ b/src/openhuman/subconscious/mod.rs @@ -4,15 +4,19 @@ pub mod global; pub mod heartbeat; mod schemas; pub mod scratchpad; +pub mod session; pub mod situation_report; pub mod source_chunk; pub mod store; pub mod types; +pub mod user_thread; pub use engine::SubconsciousEngine; pub use schemas::{ all_controller_schemas as all_subconscious_controller_schemas, all_registered_controllers as all_subconscious_registered_controllers, }; +pub use session::{LongLivedSession, ProcessOutcome, ORCHESTRATOR_THREAD_ID}; pub use source_chunk::SourceChunk; pub use types::{SubconsciousStatus, TickResult}; +pub use user_thread::{notify_user, NotifyUserTool, USER_THREAD_ID}; diff --git a/src/openhuman/subconscious/session.rs b/src/openhuman/subconscious/session.rs new file mode 100644 index 000000000..96fdaf3eb --- /dev/null +++ b/src/openhuman/subconscious/session.rs @@ -0,0 +1,379 @@ +//! The long-lived, context-compressed orchestrator session. +//! +//! Unlike the legacy per-tick path (`engine::run_agent`), which builds a +//! fresh `Agent`, runs it once, and discards the history, a +//! [`LongLivedSession`] keeps a single `Agent` alive across promoted +//! triggers. Its in-memory history accumulates and is compressed by the +//! `Agent`'s own context pipeline (microcompact / autocompact); the full +//! transcript is persisted to a reserved conversation thread for audit and +//! cold-boot resume. +//! +//! Concurrency: a `run_lock` serializes promoted-trigger processing so at +//! most one session run is in flight (matching the legacy single-tick +//! invariant). A monotonic `generation` counter labels each run and is the +//! hook the event loop's interrupt path (slice 5) uses to detect a +//! superseded run. +//! +//! Only *promoted* triggers reach this session — the gate has already +//! decided they're worth a reasoning-tier run. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use tokio::sync::Mutex; +use tracing::{debug, info, warn}; + +use crate::openhuman::agent::Agent; +use crate::openhuman::config::schema::SubconsciousMode; +use crate::openhuman::config::Config; +use crate::openhuman::memory_conversations::ConversationMessage; +use crate::openhuman::security::AutonomyLevel; + +use super::engine::tick_origin_source; + +/// Reserved conversation thread backing the background orchestrator's +/// internal reasoning. Distinct from the user-facing thread (slice 6). +pub const ORCHESTRATOR_THREAD_ID: &str = "subconscious:orchestrator"; + +/// Per-tool-call timeout injected into the session agent config (mirrors +/// the legacy tick path). +const TOOL_CALL_TIMEOUT_SECS: u64 = 5 * 60; + +/// Outcome of processing one promoted trigger. +#[derive(Debug, Clone)] +pub struct ProcessOutcome { + /// The run's monotonic generation number. + pub generation: u64, + /// The agent's final response text. + pub response: String, + pub response_chars: usize, +} + +/// A persistent orchestrator session bound to one reserved thread. +pub struct LongLivedSession { + workspace_dir: PathBuf, + thread_id: String, + mode: SubconsciousMode, + /// Built lazily on first promoted trigger, then reused so history + /// (and its compaction) persists across triggers. + agent: Mutex>, + /// Serializes promoted-trigger processing. + run_lock: Mutex<()>, + /// Monotonic run counter / supersession hook. + generation: AtomicU64, + /// Sticky taint: once any tainted (external-content) trigger is processed, + /// the untrusted payload lives on in the persistent history, so every + /// subsequent run stays `SubconsciousTainted` — otherwise a later benign + /// cron run could regain external-effect tool access while the context + /// still contains the earlier untrusted content. + tainted: AtomicBool, +} + +impl LongLivedSession { + /// Create a session backed by the reserved orchestrator thread. + pub fn new(workspace_dir: PathBuf, mode: SubconsciousMode) -> Self { + Self::with_thread(workspace_dir, mode, ORCHESTRATOR_THREAD_ID.to_string()) + } + + /// Create a session backed by an explicit thread id (used by the + /// user-facing thread in slice 6 and by tests). + pub fn with_thread(workspace_dir: PathBuf, mode: SubconsciousMode, thread_id: String) -> Self { + Self { + workspace_dir, + thread_id, + mode, + agent: Mutex::new(None), + run_lock: Mutex::new(()), + generation: AtomicU64::new(0), + tainted: AtomicBool::new(false), + } + } + + pub fn thread_id(&self) -> &str { + &self.thread_id + } + + /// The generation number of the most recently *started* run (0 before + /// any run). Used by the event loop to detect supersession. + pub fn latest_generation(&self) -> u64 { + self.generation.load(Ordering::SeqCst) + } + + /// Process one promoted trigger: persist it to the reserved thread, + /// run the (persistent, compacting) agent, persist the reply. + /// + /// Serialized via `run_lock`. `external_content` escalates the turn + /// origin to the tainted automation source so the approval gate + /// refuses external-effect tools. + pub async fn process_promoted( + &self, + summary: &str, + external_content: bool, + ) -> Result { + let _run = self.run_lock.lock().await; + let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + debug!( + "[subconscious::session] processing promoted trigger gen={} thread={} external={}", + generation, self.thread_id, external_content + ); + + let config = Config::load_or_init() + .await + .map_err(|e| format!("load config: {e}"))?; + + let response = { + let mut guard = self.agent.lock().await; + if guard.is_none() { + // Cold boot: build the agent and restore the persisted taint + // marker from the reserved thread so untrusted history from a + // previous process keeps the session tainted. + let agent = self.build_agent(&config, summary)?; + *guard = Some(agent); + } + + // Sticky taint: tainted if the trigger carries external content OR + // the session has *ever* ingested external content (still in the + // reused/restored history). + if external_content { + self.tainted.store(true, Ordering::SeqCst); + } + let effective_tainted = self.tainted.load(Ordering::SeqCst); + + // Persist the promoted user-turn (with its taint marker) before the + // run so the audit log + cold-boot taint restore are correct even + // if the run fails mid-way. + self.persist_message("user", summary, effective_tainted); + + let agent = guard.as_mut().expect("agent built above"); + + let origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation { + job_id: format!("subconscious:session:{}:{}", self.thread_id, generation), + source: tick_origin_source(effective_tainted), + }; + crate::openhuman::agent::turn_origin::with_origin(origin, agent.run_single(summary)) + .await + .map_err(|e| { + warn!("[subconscious::session] agent run failed gen={generation}: {e}"); + format!("agent run: {e}") + })? + }; + + self.persist_message("agent", &response, false); + + let response_chars = response.chars().count(); + info!( + "[subconscious::session] promoted trigger done gen={} thread={} response_chars={}", + generation, self.thread_id, response_chars + ); + Ok(ProcessOutcome { + generation, + response, + response_chars, + }) + } + + /// Build the session agent with mode-appropriate autonomy + iteration + /// caps, seeding history from the reserved thread for cold-boot resume. + fn build_agent(&self, config: &Config, current_message: &str) -> Result { + let effective = effective_config(config, self.mode); + // Build as the `subconscious` agent (not the default orchestrator) so + // the session's promoted turns get the subconscious tool surface — + // scratchpad + spawn_subagent + the notify_user user-handoff tool. + let mut agent = Agent::from_config_for_agent(&effective, "subconscious").map_err(|e| { + warn!("[subconscious::session] agent init failed: {e}"); + format!("agent init: {e}") + })?; + agent.set_event_context(self.thread_id.clone(), "subconscious"); + + // Cold-boot resume: prime history from the reserved thread. + match crate::openhuman::memory_conversations::get_messages( + self.workspace_dir.clone(), + &self.thread_id, + ) { + Ok(prior) if !prior.is_empty() => { + // Restore the persisted taint marker: if any prior turn was + // tainted, the untrusted content is about to be seeded back + // into history, so the session must stay tainted. + if prior.iter().any(|m| { + m.extra_metadata.get("tainted").and_then(|v| v.as_bool()) == Some(true) + }) { + self.tainted.store(true, Ordering::SeqCst); + } + let pairs: Vec<(String, String)> = + prior.into_iter().map(|m| (m.sender, m.content)).collect(); + if let Err(err) = agent.seed_resume_from_messages(pairs, current_message) { + warn!( + "[subconscious::session] seed resume failed thread={} err={}", + self.thread_id, err + ); + } + } + Ok(_) => { + debug!( + "[subconscious::session] no prior messages to seed thread={} — first run", + self.thread_id + ); + } + Err(err) => { + warn!( + "[subconscious::session] reading prior messages failed thread={} err={}", + self.thread_id, err + ); + } + } + Ok(agent) + } + + /// Best-effort append to the reserved thread. Persistence failures are + /// logged but never fail the run (the in-memory history is the working + /// set; the thread is audit/resume only). + fn persist_message(&self, sender: &str, content: &str, tainted: bool) { + // `append_message` requires the thread to exist; the reserved + // orchestrator thread is created lazily here (idempotent). + ensure_reserved_thread( + &self.workspace_dir, + &self.thread_id, + "Subconscious Orchestrator", + ); + let message = new_message(sender, content, tainted); + if let Err(err) = crate::openhuman::memory_conversations::append_message( + self.workspace_dir.clone(), + &self.thread_id, + message, + ) { + warn!( + "[subconscious::session] persist {} message failed thread={} err={}", + sender, self.thread_id, err + ); + } + } +} + +/// Apply mode-appropriate autonomy + iteration caps to a config clone. +/// Mirrors `engine::run_agent`'s effective-config logic so the legacy tick +/// and the long-lived session behave identically per mode. +pub(crate) fn effective_config(config: &Config, mode: SubconsciousMode) -> Config { + let mut effective = config.clone(); + effective.agent.agent_timeout_secs = TOOL_CALL_TIMEOUT_SECS; + match mode { + SubconsciousMode::Simple => { + effective.autonomy.level = AutonomyLevel::ReadOnly; + effective.agent.max_tool_iterations = 15; + } + SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => { + effective.autonomy.level = AutonomyLevel::Full; + effective.agent.max_tool_iterations = 30; + } + SubconsciousMode::Off => {} + } + effective +} + +/// Ensure a reserved conversation thread exists before appending to it. +/// Idempotent — safe to call before every append. Failures are logged and +/// swallowed (persistence is best-effort audit, never load-bearing). +pub(crate) fn ensure_reserved_thread( + workspace_dir: &std::path::Path, + thread_id: &str, + title: &str, +) { + use crate::openhuman::memory_conversations::CreateConversationThread; + let req = CreateConversationThread { + id: thread_id.to_string(), + title: title.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + parent_thread_id: None, + labels: None, + personality_id: None, + }; + if let Err(err) = + crate::openhuman::memory_conversations::ensure_thread(workspace_dir.to_path_buf(), req) + { + warn!( + "[subconscious::session] ensure reserved thread failed thread={} err={}", + thread_id, err + ); + } +} + +/// Construct a `ConversationMessage` for the reserved thread with a fresh +/// uuid and an RFC3339 timestamp. `sender` is `"user"` or `"agent"`. The +/// `tainted` marker is persisted so a cold-boot restore can keep the session +/// tainted when untrusted history is seeded back in. +fn new_message(sender: &str, content: &str, tainted: bool) -> ConversationMessage { + ConversationMessage { + id: uuid::Uuid::new_v4().to_string(), + content: content.to_string(), + message_type: "text".to_string(), + extra_metadata: serde_json::json!({ + "origin": "subconscious_session", + "tainted": tainted, + }), + sender: sender.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reserved_thread_id_is_stable() { + assert_eq!(ORCHESTRATOR_THREAD_ID, "subconscious:orchestrator"); + let s = LongLivedSession::new(PathBuf::from("/tmp/ws"), SubconsciousMode::Simple); + assert_eq!(s.thread_id(), "subconscious:orchestrator"); + assert_eq!(s.latest_generation(), 0); + } + + #[test] + fn with_thread_overrides_id() { + let s = LongLivedSession::with_thread( + PathBuf::from("/tmp/ws"), + SubconsciousMode::Aggressive, + "subconscious:user".to_string(), + ); + assert_eq!(s.thread_id(), "subconscious:user"); + } + + #[test] + fn new_message_roundtrips_sender_and_content() { + let user = new_message("user", "hello", true); + assert_eq!(user.sender, "user"); + assert_eq!(user.content, "hello"); + assert_eq!(user.message_type, "text"); + assert!(!user.id.is_empty()); + assert_eq!( + user.extra_metadata.get("tainted").and_then(|v| v.as_bool()), + Some(true) + ); + let agent = new_message("agent", "reply", false); + assert_eq!(agent.sender, "agent"); + assert_eq!( + agent + .extra_metadata + .get("tainted") + .and_then(|v| v.as_bool()), + Some(false) + ); + // Distinct ids per message. + assert_ne!(user.id, agent.id); + } + + #[test] + fn effective_config_simple_is_readonly_15_iters() { + let cfg = Config::default(); + let eff = effective_config(&cfg, SubconsciousMode::Simple); + assert_eq!(eff.autonomy.level, AutonomyLevel::ReadOnly); + assert_eq!(eff.agent.max_tool_iterations, 15); + assert_eq!(eff.agent.agent_timeout_secs, TOOL_CALL_TIMEOUT_SECS); + } + + #[test] + fn effective_config_aggressive_is_full_30_iters() { + let cfg = Config::default(); + let eff = effective_config(&cfg, SubconsciousMode::Aggressive); + assert_eq!(eff.autonomy.level, AutonomyLevel::Full); + assert_eq!(eff.agent.max_tool_iterations, 30); + } +} diff --git a/src/openhuman/subconscious/user_thread.rs b/src/openhuman/subconscious/user_thread.rs new file mode 100644 index 000000000..b46ea84ec --- /dev/null +++ b/src/openhuman/subconscious/user_thread.rs @@ -0,0 +1,176 @@ +//! The user-facing thread + the `notify_user` handoff tool. +//! +//! The background orchestrator's reasoning lives in its own reserved thread +//! (`subconscious:orchestrator`). When it decides to actually *say something* +//! to the user, it must not write to that internal thread or emit to a +//! channel directly — it hands off through this separate, long-lived +//! user-facing thread (`subconscious:user`), which is where agent↔user +//! communication is recorded. +//! +//! The handoff is the [`NotifyUserTool`]: it persists the message to the +//! user-facing thread and publishes [`DomainEvent::ProactiveMessageRequested`], +//! which the channels domain already routes to the active channel. Keeping +//! delivery on the existing proactive path (rather than synthesizing an +//! inbound message) means the orchestrator can never trigger itself via its +//! own output. + +use async_trait::async_trait; +use serde_json::json; +use tracing::{info, warn}; + +use crate::core::event_bus::{publish_global, DomainEvent}; +use crate::openhuman::memory_conversations::ConversationMessage; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult, ToolScope}; + +/// Reserved conversation thread for agent↔user communication, distinct from +/// the orchestrator's internal reasoning thread. +pub const USER_THREAD_ID: &str = "subconscious:user"; + +/// Source tag on proactive deliveries originating from the subconscious. +/// Mirrors [`crate::openhuman::subconscious_triggers::SUBCONSCIOUS_SENDER_MARKER`] +/// so the trigger fan-in can recognise (and skip) the orchestrator's own +/// output. +pub const SUBCONSCIOUS_PROACTIVE_SOURCE: &str = "subconscious"; + +/// Persist a message to the user-facing thread and request its proactive +/// delivery. Best-effort persistence: a storage failure is logged but does +/// not prevent delivery. +pub fn notify_user(workspace_dir: std::path::PathBuf, message: &str, subject: Option<&str>) { + let record = ConversationMessage { + id: uuid::Uuid::new_v4().to_string(), + content: message.to_string(), + message_type: "text".to_string(), + extra_metadata: json!({ "origin": "subconscious_notify_user" }), + sender: "agent".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }; + // `append_message` requires the thread to exist; create the reserved + // user-facing thread lazily (idempotent). + super::session::ensure_reserved_thread(&workspace_dir, USER_THREAD_ID, "Subconscious → You"); + if let Err(err) = crate::openhuman::memory_conversations::append_message( + workspace_dir, + USER_THREAD_ID, + record, + ) { + warn!("[subconscious::user_thread] persist notify_user message failed: {err}"); + } + + publish_global(DomainEvent::ProactiveMessageRequested { + source: SUBCONSCIOUS_PROACTIVE_SOURCE.to_string(), + message: message.to_string(), + job_name: subject.map(|s| s.to_string()), + }); + info!( + "[subconscious::user_thread] notify_user delivered ({} chars)", + message.chars().count() + ); +} + +/// Agent tool: hand a message off to the user via the user-facing thread. +pub struct NotifyUserTool; + +#[async_trait] +impl Tool for NotifyUserTool { + fn name(&self) -> &str { + "notify_user" + } + + fn description(&self) -> &str { + "Proactively send a message to the user. Use this when the background \ + loop has something worth surfacing — a finding, a reminder, a heads-up. \ + The message is delivered to the user's active channel and recorded in \ + the user-facing conversation thread. Keep it concise and high-signal; \ + do not narrate routine background bookkeeping." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string", + "description": "The message to send to the user." + }, + "subject": { + "type": "string", + "description": "Optional short subject/label for threading and display." + } + } + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::System + } + + fn permission_level(&self) -> PermissionLevel { + // Outward-facing: surfaces content to the user. Treated as a write + // so policy/approval can gate it under stricter autonomy tiers. + PermissionLevel::Write + } + + fn scope(&self) -> ToolScope { + ToolScope::AgentOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("notify_user: `message` is required and non-empty"))?; + let subject = args.get("subject").and_then(|v| v.as_str()); + + let config = crate::openhuman::config::load_config_with_timeout() + .await + .map_err(|e| anyhow::anyhow!("config load: {e}"))?; + + notify_user(config.workspace_dir, message, subject); + Ok(ToolResult::success( + "Message delivered to the user.".to_string(), + )) + } +} + +/// All user-facing-thread tools, for registration into the tool registry. +pub fn all_user_thread_tools() -> Vec> { + vec![Box::new(NotifyUserTool)] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_thread_id_is_distinct_from_orchestrator() { + assert_eq!(USER_THREAD_ID, "subconscious:user"); + assert_ne!(USER_THREAD_ID, super::super::ORCHESTRATOR_THREAD_ID); + } + + #[test] + fn notify_user_tool_metadata() { + let tool = NotifyUserTool; + assert_eq!(tool.name(), "notify_user"); + assert_eq!(tool.scope(), ToolScope::AgentOnly); + assert_eq!(tool.permission_level(), PermissionLevel::Write); + let schema = tool.parameters_schema(); + assert_eq!(schema["required"][0], "message"); + } + + #[tokio::test] + async fn notify_user_rejects_empty_message() { + let tool = NotifyUserTool; + let err = tool.execute(json!({ "message": " " })).await.unwrap_err(); + assert!(err.to_string().contains("message")); + } + + #[test] + fn proactive_source_matches_trigger_marker() { + assert_eq!( + SUBCONSCIOUS_PROACTIVE_SOURCE, + crate::openhuman::subconscious_triggers::SUBCONSCIOUS_SENDER_MARKER + ); + } +} diff --git a/src/openhuman/subconscious_triggers/bus.rs b/src/openhuman/subconscious_triggers/bus.rs new file mode 100644 index 000000000..72093222d --- /dev/null +++ b/src/openhuman/subconscious_triggers/bus.rs @@ -0,0 +1,118 @@ +//! Event-bus integration: fans the four v1 trigger sources into the +//! background orchestrator. +//! +//! The subscriber observes (it does not consume) — inbound user messages, +//! cron ticks, Composio webhooks, and sub-agent conclusions all keep +//! flowing through their existing handlers. This subscriber simply makes the +//! background orchestrator *aware* of them. + +use std::sync::{Arc, Mutex, OnceLock}; + +use async_trait::async_trait; + +use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle}; + +use super::runtime::TriggerOrchestrator; + +/// Resettable subscription slot: the handle is dropped (cancelling the +/// subscription) on teardown so a user/workspace switch can re-register +/// against a fresh orchestrator instead of leaking the stale binding. +static SUBSCRIPTION: OnceLock>> = OnceLock::new(); + +fn subscription_slot() -> &'static Mutex> { + SUBSCRIPTION.get_or_init(|| Mutex::new(None)) +} + +/// Domain filter — only the four v1 trigger-source families. `agent` +/// carries sub-agent conclusion events. +const DOMAINS: &[&str] = &["cron", "channel", "composio", "agent"]; + +/// Forwards relevant bus events into the [`TriggerOrchestrator`]. +pub struct SubconsciousTriggerSubscriber { + orchestrator: Arc, +} + +impl SubconsciousTriggerSubscriber { + pub fn new(orchestrator: Arc) -> Self { + Self { orchestrator } + } +} + +#[async_trait] +impl EventHandler for SubconsciousTriggerSubscriber { + fn name(&self) -> &str { + "subconscious_triggers::ingest" + } + + fn domains(&self) -> Option<&[&str]> { + Some(DOMAINS) + } + + async fn handle(&self, event: &DomainEvent) { + // `ingest` is non-blocking: it normalizes + admits synchronously and + // spawns the gate task itself, so we never block event dispatch. + self.orchestrator.ingest(event); + } +} + +/// Register the trigger subscriber. Idempotent — the handle is held so the +/// subscription stays live; re-registering while already subscribed is a no-op. +pub fn register_subconscious_triggers_subscriber(orchestrator: Arc) { + let mut guard = subscription_slot() + .lock() + .expect("subscription slot poisoned"); + if guard.is_some() { + return; + } + match subscribe_global(Arc::new(SubconsciousTriggerSubscriber::new(orchestrator))) { + Some(handle) => { + *guard = Some(handle); + tracing::debug!("[subconscious_triggers:bus] subscriber registered"); + } + None => { + tracing::warn!( + "[subconscious_triggers:bus] event bus not initialized; subscriber not registered" + ); + } + } +} + +/// Drop the trigger subscriber (cancels the subscription). Used on user/ +/// workspace switch teardown so the next bootstrap re-binds cleanly. +pub fn unregister_subconscious_triggers_subscriber() { + if subscription_slot() + .lock() + .expect("subscription slot poisoned") + .take() + .is_some() + { + tracing::debug!("[subconscious_triggers:bus] subscriber unregistered"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::schema::SubconsciousMode; + use crate::openhuman::subconscious::LongLivedSession; + use crate::openhuman::subconscious_triggers::{OrchestratorConfig, TriggerOrchestrator}; + use std::path::PathBuf; + + #[test] + fn subscriber_watches_v1_domains_only() { + let session = Arc::new(LongLivedSession::new( + PathBuf::from("/tmp/ws"), + SubconsciousMode::Simple, + )); + let orch = Arc::new(TriggerOrchestrator::new( + session, + OrchestratorConfig::default(), + )); + let sub = SubconsciousTriggerSubscriber::new(orch); + assert_eq!(sub.name(), "subconscious_triggers::ingest"); + assert_eq!( + sub.domains(), + Some(["cron", "channel", "composio", "agent"].as_slice()) + ); + } +} diff --git a/src/openhuman/subconscious_triggers/gate.rs b/src/openhuman/subconscious_triggers/gate.rs new file mode 100644 index 000000000..32940591b --- /dev/null +++ b/src/openhuman/subconscious_triggers/gate.rs @@ -0,0 +1,464 @@ +//! The LLM gate: decides whether a [`Trigger`] is *promoted* into the +//! long-lived orchestrator session or *dropped*. +//! +//! The gate reuses the existing `agent::triage` pipeline wholesale — a +//! cheap, local-first classifier with cloud retry + fallback (see +//! `agent/triage/evaluator.rs`). We do **not** reinvent the model call: we +//! build a [`TriggerEnvelope`] from the redacted trigger summary, run the +//! triage chain, and map its four-way [`TriageAction`] onto our two-way +//! [`GateDecision`]: +//! +//! | triage action | gate decision | +//! |---------------|----------------------------------| +//! | `Drop` | `Drop { acknowledge: false }` | +//! | `Acknowledge` | `Drop { acknowledge: true }` | +//! | `React` | `Promote` (keep trigger priority)| +//! | `Escalate` | `Promote` (priority ≥ High) | +//! +//! Crucially the gate model only ever sees the **redacted** `gate_summary`, +//! never the raw third-party body — that stays in `trigger.payload.raw` for +//! promotion synthesis inside the session (slice 4). +//! +//! A per-hour promotion budget caps the always-on loop's spend: when the +//! budget is exhausted, a would-be `Promote` is downgraded to +//! `Drop { acknowledge: true }` so the trigger is still noted but no +//! reasoning-tier session run is spent. + +use std::sync::Mutex; + +use crate::openhuman::agent::triage::decision::{TriageAction, TriageDecision}; +use crate::openhuman::agent::triage::envelope::{TriggerEnvelope, TriggerSource as TriageSource}; +use crate::openhuman::agent::triage::evaluator::{run_triage, TriageOutcome}; + +use super::types::{GateDecision, Trigger, TriggerPriority, TriggerSource}; + +/// Per-hour cap on promotions (long-lived session runs). Deterministic +/// under test via injected `now` (epoch seconds). +#[derive(Debug)] +pub struct PromotionBudget { + max_per_hour: u32, + /// Epoch-second timestamps of recent promotions, pruned to a 1-hour + /// sliding window on each check. + recent: Mutex>, +} + +const HOUR_SECS: f64 = 3600.0; + +impl PromotionBudget { + pub fn new(max_per_hour: u32) -> Self { + Self { + max_per_hour, + recent: Mutex::new(Vec::new()), + } + } + + /// Try to consume one promotion slot. Returns `true` if a slot was + /// available (and records it), `false` if the hourly cap is reached. + /// `max_per_hour == 0` disables promotion entirely. + pub fn try_consume(&self, now: f64) -> bool { + if self.max_per_hour == 0 { + return false; + } + let mut recent = self.recent.lock().expect("budget mutex poisoned"); + recent.retain(|&ts| now - ts < HOUR_SECS); + if (recent.len() as u32) < self.max_per_hour { + recent.push(now); + true + } else { + false + } + } + + /// Promotions used in the current sliding hour. Diagnostic aid. + pub fn used(&self, now: f64) -> usize { + let recent = self.recent.lock().expect("budget mutex poisoned"); + recent.iter().filter(|&&ts| now - ts < HOUR_SECS).count() + } +} + +/// The gate. Holds the promotion budget; the model call is delegated to +/// `agent::triage`. +#[derive(Debug)] +pub struct GatePass { + budget: PromotionBudget, +} + +impl GatePass { + pub fn new(max_promotions_per_hour: u32) -> Self { + Self { + budget: PromotionBudget::new(max_promotions_per_hour), + } + } + + /// Evaluate a trigger through the triage LLM and the promotion budget. + /// + /// `now` is epoch seconds (injected for deterministic budget accounting). + /// This is the integration entry point — it performs a network/model + /// call via `run_triage`; pure mapping is covered by the unit tests on + /// [`map_triage_to_gate`] / [`apply_budget`]. + pub async fn evaluate(&self, trigger: &Trigger, now: f64) -> GateDecision { + tracing::debug!( + source = trigger.source.family(), + label = %trigger.display_label, + dedupe_key = trigger.dedupe_key.as_str(), + external = trigger.external_content, + "[subconscious_triggers::gate] evaluating trigger" + ); + let envelope = build_envelope(trigger); + let decision = match run_triage(&envelope).await { + Ok(TriageOutcome::Decision(run)) => { + tracing::debug!( + action = run.decision.action.as_str(), + label = %trigger.display_label, + "[subconscious_triggers::gate] triage decision" + ); + map_triage_to_gate(&run.decision, trigger) + } + Ok(TriageOutcome::Deferred { reason, .. }) => { + // Couldn't reach a verdict (both arms down / budget / guard). + // Acknowledge so the trigger isn't silently lost, but spend + // no session run. + tracing::debug!( + reason = %reason, + label = %trigger.display_label, + "[subconscious_triggers::gate] triage deferred → drop(ack)" + ); + GateDecision::Drop { + acknowledge: true, + reason: format!("triage deferred: {reason}"), + } + } + Err(err) => { + tracing::warn!( + error = %err, + label = %trigger.display_label, + "[subconscious_triggers::gate] triage error → drop(ack)" + ); + GateDecision::Drop { + acknowledge: true, + reason: format!("triage error: {err}"), + } + } + }; + let final_decision = apply_budget(decision, &self.budget, now); + tracing::debug!( + decision = final_decision.as_str(), + label = %trigger.display_label, + "[subconscious_triggers::gate] gate verdict" + ); + final_decision + } +} + +/// Downgrade a `Promote` to an acknowledged `Drop` when the hourly +/// promotion budget is exhausted. `Drop`s pass through untouched. +pub fn apply_budget(decision: GateDecision, budget: &PromotionBudget, now: f64) -> GateDecision { + match decision { + GateDecision::Promote { + synthesized_summary, + priority, + reason, + } => { + if budget.try_consume(now) { + GateDecision::Promote { + synthesized_summary, + priority, + reason, + } + } else { + GateDecision::Drop { + acknowledge: true, + reason: format!("promotion budget exhausted (would have promoted: {reason})"), + } + } + } + drop @ GateDecision::Drop { .. } => drop, + } +} + +/// Pure mapping from a parsed [`TriageDecision`] to a [`GateDecision`]. +pub fn map_triage_to_gate(decision: &TriageDecision, trigger: &Trigger) -> GateDecision { + match decision.action { + TriageAction::Drop => GateDecision::Drop { + acknowledge: false, + reason: decision.reason.clone(), + }, + TriageAction::Acknowledge => GateDecision::Drop { + acknowledge: true, + reason: decision.reason.clone(), + }, + TriageAction::React => GateDecision::Promote { + synthesized_summary: synthesize(trigger, decision), + // A narrow single-step reaction keeps the trigger's own priority. + priority: trigger.priority, + reason: decision.reason.clone(), + }, + TriageAction::Escalate => GateDecision::Promote { + synthesized_summary: synthesize(trigger, decision), + // Multi-step work is at least High so it pre-empts Low cron noise. + priority: trigger.priority.max(TriggerPriority::High), + reason: decision.reason.clone(), + }, + } +} + +/// Build the user-turn text appended to the long-lived session on promotion. +/// Combines the redacted trigger context with the gate's synthesized +/// instruction (the triage `prompt` field) when present. +fn synthesize(trigger: &Trigger, decision: &TriageDecision) -> String { + let mut out = format!( + "[{}] {}", + trigger.display_label, trigger.payload.gate_summary + ); + if let Some(prompt) = decision.prompt.as_deref() { + if !prompt.trim().is_empty() { + out.push_str("\n\nGate guidance: "); + out.push_str(prompt.trim()); + } + } + out +} + +/// Build a triage [`TriggerEnvelope`] from our [`Trigger`]. +/// +/// The envelope payload carries the **redacted** `gate_summary` — never the +/// raw third-party body — so the cheap gate model can't be used to exfiltrate +/// untrusted content. The full `raw` payload is reserved for the long-lived +/// session at promotion time. +fn build_envelope(trigger: &Trigger) -> TriggerEnvelope { + let payload = serde_json::json!({ + "summary": trigger.payload.gate_summary, + "source": trigger.source.family(), + "priority": trigger.priority.as_str(), + "external_content": trigger.external_content, + }); + + let source = match &trigger.source { + TriggerSource::Cron { job_id, job_name } => TriageSource::Cron { + job_id: job_id.clone(), + job_name: job_name.clone(), + }, + TriggerSource::ComposioWebhook { + toolkit, + trigger: t, + .. + } => TriageSource::Composio { + toolkit: toolkit.clone(), + trigger: t.clone(), + }, + TriggerSource::UserMessage { channel, .. } => TriageSource::External { + caller_id: format!("user:{channel}"), + reason: "user_message".to_string(), + }, + TriggerSource::SubagentConclusion { agent_id, .. } => TriageSource::External { + caller_id: format!("subagent:{agent_id}"), + reason: "subagent_conclusion".to_string(), + }, + }; + + // Preserve the original trigger receipt time (epoch seconds) so triage + // latency/recency reflects when the event arrived, not gate-eval time. + let received_at = chrono::DateTime::::from_timestamp( + trigger.received_at.trunc() as i64, + (trigger.received_at.fract() * 1_000_000_000.0) as u32, + ) + .unwrap_or_else(chrono::Utc::now); + + TriggerEnvelope { + source, + external_id: trigger.id.clone(), + display_label: trigger.display_label.clone(), + payload, + received_at, + card_link: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::subconscious_triggers::types::{DedupeKey, TriggerPayload}; + + fn trigger(priority: TriggerPriority) -> Trigger { + Trigger { + id: "id".into(), + source: TriggerSource::ComposioWebhook { + toolkit: "gmail".into(), + trigger: "GMAIL_NEW".into(), + metadata_id: "m".into(), + }, + display_label: "composio/gmail/GMAIL_NEW".into(), + payload: TriggerPayload { + gate_summary: "Composio webhook (2 fields).".into(), + raw: serde_json::Value::Null, + }, + priority, + dedupe_key: DedupeKey("k".into()), + external_content: true, + received_at: 0.0, + } + } + + fn decision(action: TriageAction, prompt: Option<&str>) -> TriageDecision { + TriageDecision { + action, + target_agent: prompt.map(|_| "orchestrator".to_string()), + prompt: prompt.map(|p| p.to_string()), + reason: "because".into(), + } + } + + #[test] + fn drop_maps_to_non_ack_drop() { + let g = map_triage_to_gate( + &decision(TriageAction::Drop, None), + &trigger(TriggerPriority::Normal), + ); + assert_eq!( + g, + GateDecision::Drop { + acknowledge: false, + reason: "because".into() + } + ); + } + + #[test] + fn acknowledge_maps_to_ack_drop() { + let g = map_triage_to_gate( + &decision(TriageAction::Acknowledge, None), + &trigger(TriggerPriority::Normal), + ); + assert_eq!( + g, + GateDecision::Drop { + acknowledge: true, + reason: "because".into() + } + ); + } + + #[test] + fn react_promotes_keeping_priority() { + let g = map_triage_to_gate( + &decision(TriageAction::React, Some("send an ack")), + &trigger(TriggerPriority::Normal), + ); + match g { + GateDecision::Promote { + priority, + synthesized_summary, + .. + } => { + assert_eq!(priority, TriggerPriority::Normal); + assert!(synthesized_summary.contains("send an ack")); + assert!(synthesized_summary.contains("composio/gmail/GMAIL_NEW")); + } + other => panic!("expected promote, got {other:?}"), + } + } + + #[test] + fn escalate_promotes_at_least_high() { + let g = map_triage_to_gate( + &decision(TriageAction::Escalate, Some("draft a reply")), + &trigger(TriggerPriority::Low), + ); + match g { + GateDecision::Promote { priority, .. } => { + assert_eq!(priority, TriggerPriority::High); + } + other => panic!("expected promote, got {other:?}"), + } + } + + #[test] + fn escalate_keeps_urgent_when_already_urgent() { + let g = map_triage_to_gate( + &decision(TriageAction::Escalate, Some("x")), + &trigger(TriggerPriority::Urgent), + ); + match g { + GateDecision::Promote { priority, .. } => { + assert_eq!(priority, TriggerPriority::Urgent); + } + other => panic!("expected promote, got {other:?}"), + } + } + + // ── promotion budget ──────────────────────────────────────────────── + + #[test] + fn budget_allows_up_to_cap_then_downgrades() { + let budget = PromotionBudget::new(2); + let promote = || GateDecision::Promote { + synthesized_summary: "s".into(), + priority: TriggerPriority::High, + reason: "r".into(), + }; + assert!(apply_budget(promote(), &budget, 0.0).is_promote()); + assert!(apply_budget(promote(), &budget, 0.0).is_promote()); + // Third within the hour → downgraded to ack-drop. + match apply_budget(promote(), &budget, 0.0) { + GateDecision::Drop { + acknowledge, + reason, + } => { + assert!(acknowledge); + assert!(reason.contains("budget exhausted")); + } + other => panic!("expected drop, got {other:?}"), + } + } + + #[test] + fn budget_refills_after_an_hour() { + let budget = PromotionBudget::new(1); + let promote = || GateDecision::Promote { + synthesized_summary: "s".into(), + priority: TriggerPriority::High, + reason: "r".into(), + }; + assert!(apply_budget(promote(), &budget, 0.0).is_promote()); + assert!(!apply_budget(promote(), &budget, 100.0).is_promote()); + // One hour later the window slides → promote again. + assert!(apply_budget(promote(), &budget, 3700.0).is_promote()); + } + + #[test] + fn budget_zero_blocks_all_promotions() { + let budget = PromotionBudget::new(0); + let g = apply_budget( + GateDecision::Promote { + synthesized_summary: "s".into(), + priority: TriggerPriority::High, + reason: "r".into(), + }, + &budget, + 0.0, + ); + assert!(!g.is_promote()); + } + + #[test] + fn apply_budget_passes_drops_through_untouched() { + let budget = PromotionBudget::new(5); + let g = apply_budget( + GateDecision::Drop { + acknowledge: false, + reason: "noise".into(), + }, + &budget, + 0.0, + ); + assert_eq!( + g, + GateDecision::Drop { + acknowledge: false, + reason: "noise".into() + } + ); + // A pass-through drop must not consume a promotion slot. + assert_eq!(budget.used(0.0), 0); + } +} diff --git a/src/openhuman/subconscious_triggers/mod.rs b/src/openhuman/subconscious_triggers/mod.rs new file mode 100644 index 000000000..b55915259 --- /dev/null +++ b/src/openhuman/subconscious_triggers/mod.rs @@ -0,0 +1,45 @@ +//! Subconscious trigger ingestion + gate front-end. +//! +//! This domain turns heterogeneous bus events (cron tick, user message, +//! Composio webhook, sub-agent conclusion) into a unified [`Trigger`], +//! collapses duplicates / rate-limits storms ([`TriggerRegistry`]), runs a +//! cheap LLM gate to decide *promote vs drop*, and enqueues promotions onto +//! the background orchestrator's [`OrchestratorQueue`]. +//! +//! It is the *ingestion front-end* for the long-lived subconscious +//! orchestrator session; the session/engine itself lives in the +//! `subconscious` domain. +//! +//! Module shape follows the canonical layout: +//! - [`types`] — `Trigger`, `TriggerSource`, `GateDecision`, … +//! - [`queue`] — bounded priority queue of promoted triggers +//! - [`registry`] — dedupe + rate-limit admission front-end +//! - gate / bus / ops / schemas land in later slices. + +pub mod bus; +pub mod gate; +pub mod normalize; +pub mod ops; +pub mod queue; +pub mod registry; +pub mod runtime; +pub mod schemas; +pub mod types; + +pub use bus::{ + register_subconscious_triggers_subscriber, unregister_subconscious_triggers_subscriber, +}; +pub use gate::{GatePass, PromotionBudget}; +pub use normalize::{normalize, SUBCONSCIOUS_SENDER_MARKER}; +pub use queue::{EnqueueOutcome, OrchestratorQueue}; +pub use registry::{AdmitOutcome, DedupeWindow, RateLimiter, TriggerRegistry}; +pub use runtime::{ + global as orchestrator_global, init_global as init_orchestrator, + shutdown_global as shutdown_orchestrator, Gate, OrchestratorConfig, SessionExecutor, + TriggerOrchestrator, +}; +pub use schemas::{ + all_controller_schemas as all_subconscious_triggers_controller_schemas, + all_registered_controllers as all_subconscious_triggers_registered_controllers, +}; +pub use types::{DedupeKey, GateDecision, Trigger, TriggerPayload, TriggerPriority, TriggerSource}; diff --git a/src/openhuman/subconscious_triggers/normalize.rs b/src/openhuman/subconscious_triggers/normalize.rs new file mode 100644 index 000000000..12fd19ec6 --- /dev/null +++ b/src/openhuman/subconscious_triggers/normalize.rs @@ -0,0 +1,443 @@ +//! `DomainEvent` → [`Trigger`] normalization. +//! +//! This is the *only* place that knows the shape of the raw bus events. It +//! maps the four v1 trigger sources into a uniform [`Trigger`], producing a +//! redacted `gate_summary` (what the cheap gate model sees) while retaining +//! the full structured payload in `raw` for promotion synthesis. +//! +//! Events we don't care about return `None`. +//! +//! ## Self-trigger guard +//! Proactive deliveries the orchestrator itself emits flow back as channel +//! events; [`normalize`] drops channel messages whose sender marks them as +//! originating from the subconscious so the orchestrator can't trigger +//! itself into an infinite loop. (The richer guard lands with the +//! user-facing thread in slice 6; the hook is here from the start.) + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use crate::core::event_bus::DomainEvent; +use crate::openhuman::subconscious::ORCHESTRATOR_THREAD_ID; + +use super::types::{DedupeKey, Trigger, TriggerPayload, TriggerPriority, TriggerSource}; + +/// Sender marker used by orchestrator-originated proactive messages so the +/// fan-in can skip them (anti self-trigger). Kept in sync with the +/// `notify_user` handoff added in slice 6. +pub const SUBCONSCIOUS_SENDER_MARKER: &str = "subconscious"; + +/// Max characters of any content preview shown to the gate model. +const PREVIEW_CHARS: usize = 200; + +/// Normalize a raw bus event into a [`Trigger`], or `None` if the event is +/// not a registered trigger source. `now` is epoch seconds (injected for +/// determinism); `new_id` supplies the correlation id (injected so tests can +/// assert on it — production passes a fresh uuid). +pub fn normalize_with_id(event: &DomainEvent, now: f64, new_id: String) -> Option { + match event { + DomainEvent::CronJobTriggered { + job_id, + job_name, + job_type, + } => Some(Trigger { + id: new_id, + display_label: format!("cron/{job_name}"), + payload: TriggerPayload { + gate_summary: format!( + "Heartbeat/cron tick for job '{job_name}' (type: {job_type})." + ), + raw: serde_json::json!({ + "job_id": job_id, + "job_name": job_name, + "job_type": job_type, + }), + }, + priority: TriggerPriority::Low, + // Each firing is intrinsically unique → encode the time so the + // dedupe window never collapses legitimate repeat ticks. + dedupe_key: DedupeKey(format!("cron:{job_id}:{}", millis(now))), + external_content: false, + received_at: now, + source: TriggerSource::Cron { + job_id: job_id.clone(), + job_name: job_name.clone(), + }, + }), + + DomainEvent::ChannelInboundMessage { + channel, + message, + sender, + thread_ts, + reply_target, + .. + } => { + // Anti self-trigger: skip messages the orchestrator itself sent. + if sender + .as_deref() + .is_some_and(|s| s == SUBCONSCIOUS_SENDER_MARKER) + { + return None; + } + let thread_id = thread_ts + .clone() + .or_else(|| reply_target.clone()) + .unwrap_or_default(); + let sender_label = sender.as_deref().unwrap_or("unknown"); + Some(Trigger { + id: new_id, + display_label: format!("user/{channel}"), + payload: TriggerPayload { + gate_summary: format!( + "User message on '{channel}' from {sender_label}: {}", + preview(message) + ), + raw: serde_json::json!({ + "channel": channel, + "sender": sender, + "thread_id": thread_id, + "message": message, + }), + }, + priority: TriggerPriority::High, + dedupe_key: DedupeKey(format!( + "user:{channel}:{thread_id}:{sender_label}:{}", + short_hash(message) + )), + // Inbound channel messages are untrusted third-party content + // (a co-channel/remote sender could otherwise drive a + // full-autonomy promoted run). Taint them so the promoted + // session runs under `SubconsciousTainted` and the approval + // gate refuses external-effect tools. + external_content: true, + received_at: now, + source: TriggerSource::UserMessage { + channel: channel.clone(), + sender: sender.clone(), + thread_id, + }, + }) + } + + DomainEvent::ComposioTriggerReceived { + toolkit, + trigger, + metadata_id, + payload, + .. + } => Some(Trigger { + id: new_id, + display_label: format!("composio/{toolkit}/{trigger}"), + payload: TriggerPayload { + // Redacted: shape only, never the raw third-party body. + gate_summary: format!( + "Composio webhook '{trigger}' from '{toolkit}' ({} payload fields).", + payload_field_count(payload) + ), + raw: serde_json::json!({ + "toolkit": toolkit, + "trigger": trigger, + "metadata_id": metadata_id, + "payload": payload, + }), + }, + priority: TriggerPriority::Normal, + // Same metadata_id ⇒ duplicate delivery ⇒ collapse. + dedupe_key: DedupeKey(format!("composio:{toolkit}:{trigger}:{metadata_id}")), + // Third-party content → tainted automation origin downstream. + external_content: true, + received_at: now, + source: TriggerSource::ComposioWebhook { + toolkit: toolkit.clone(), + trigger: trigger.clone(), + metadata_id: metadata_id.clone(), + }, + }), + + // Only sub-agents spawned by the subconscious orchestrator itself feed + // back into the pipeline — otherwise unrelated chat/workflow sub-agent + // completions (the whole `agent` domain) would contaminate the reserved + // thread and trigger spurious follow-up work. + DomainEvent::SubagentCompleted { + parent_session, + task_id, + agent_id, + output_chars, + iterations, + .. + } if parent_session == ORCHESTRATOR_THREAD_ID => Some(Trigger { + id: new_id, + display_label: format!("subagent/{agent_id}/done"), + payload: TriggerPayload { + gate_summary: format!( + "Sub-agent '{agent_id}' (task {task_id}) completed in \ + {iterations} iteration(s), {output_chars} chars of output." + ), + raw: serde_json::json!({ + "task_id": task_id, + "agent_id": agent_id, + "ok": true, + "output_chars": output_chars, + "iterations": iterations, + }), + }, + priority: TriggerPriority::Normal, + // task_id is unique per spawn; completion fires once. + dedupe_key: DedupeKey(format!("subagent:{task_id}:done")), + // The sub-agent may have processed untrusted content (e.g. a + // researcher reading a webhook/email). The completion event does + // not carry the parent's taint, so fail safe: treat conclusions as + // tainted so a promoted follow-up turn can't launder untrusted + // output back into trusted (external-effect) tool access. + external_content: true, + received_at: now, + source: TriggerSource::SubagentConclusion { + task_id: task_id.clone(), + agent_id: agent_id.clone(), + ok: true, + }, + }), + + DomainEvent::SubagentFailed { + parent_session, + task_id, + agent_id, + error, + .. + } if parent_session == ORCHESTRATOR_THREAD_ID => Some(Trigger { + id: new_id, + display_label: format!("subagent/{agent_id}/failed"), + payload: TriggerPayload { + gate_summary: format!( + "Sub-agent '{agent_id}' (task {task_id}) FAILED: {}", + preview(error) + ), + raw: serde_json::json!({ + "task_id": task_id, + "agent_id": agent_id, + "ok": false, + "error": error, + }), + }, + priority: TriggerPriority::Normal, + dedupe_key: DedupeKey(format!("subagent:{task_id}:failed")), + // Fail safe: conclusions are tainted (see SubagentCompleted above). + external_content: true, + received_at: now, + source: TriggerSource::SubagentConclusion { + task_id: task_id.clone(), + agent_id: agent_id.clone(), + ok: false, + }, + }), + + _ => None, + } +} + +/// Production entry point: normalize with a fresh uuid correlation id. +pub fn normalize(event: &DomainEvent, now: f64) -> Option { + normalize_with_id(event, now, uuid::Uuid::new_v4().to_string()) +} + +/// Truncate `text` to a bounded preview on a char boundary, appending `…` +/// when truncated. Newlines collapsed to spaces for single-line summaries. +fn preview(text: &str) -> String { + let flat: String = text.split_whitespace().collect::>().join(" "); + if flat.chars().count() <= PREVIEW_CHARS { + flat + } else { + let truncated: String = flat.chars().take(PREVIEW_CHARS).collect(); + format!("{truncated}…") + } +} + +/// Stable-within-process short hash for dedupe keys. +fn short_hash(s: &str) -> u64 { + let mut h = DefaultHasher::new(); + s.hash(&mut h); + h.finish() +} + +/// Epoch seconds → integer milliseconds for unique-per-firing dedupe keys. +fn millis(now: f64) -> i64 { + (now * 1000.0) as i64 +} + +/// Count top-level fields in a JSON payload (0 for non-objects). +fn payload_field_count(payload: &serde_json::Value) -> usize { + payload.as_object().map(|o| o.len()).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cron_event_maps_to_low_internal_trigger() { + let ev = DomainEvent::CronJobTriggered { + job_id: "job-1".into(), + job_name: "morning brief".into(), + job_type: "agent".into(), + }; + let t = normalize_with_id(&ev, 1.5, "id".into()).unwrap(); + assert_eq!(t.priority, TriggerPriority::Low); + assert!(!t.external_content); + assert_eq!(t.source.family(), "cron"); + assert_eq!(t.dedupe_key.as_str(), "cron:job-1:1500"); + assert!(matches!(t.source, TriggerSource::Cron { .. })); + } + + #[test] + fn cron_firings_at_different_times_are_not_deduped() { + let ev = DomainEvent::CronJobTriggered { + job_id: "job-1".into(), + job_name: "n".into(), + job_type: "agent".into(), + }; + let a = normalize_with_id(&ev, 10.0, "a".into()).unwrap(); + let b = normalize_with_id(&ev, 70.0, "b".into()).unwrap(); + assert_ne!(a.dedupe_key, b.dedupe_key); + } + + #[test] + fn user_message_maps_to_high_tainted_trigger() { + let ev = DomainEvent::ChannelInboundMessage { + event_name: "msg".into(), + channel: "slack".into(), + message: "can you summarize the thread?".into(), + sender: Some("U123".into()), + reply_target: Some("D456".into()), + thread_ts: Some("T789".into()), + raw_data: serde_json::Value::Null, + }; + let t = normalize_with_id(&ev, 0.0, "id".into()).unwrap(); + assert_eq!(t.priority, TriggerPriority::High); + assert!(t.external_content, "inbound channel messages are untrusted"); + assert!(t.payload.gate_summary.contains("summarize the thread")); + match t.source { + TriggerSource::UserMessage { + channel, thread_id, .. + } => { + assert_eq!(channel, "slack"); + assert_eq!(thread_id, "T789"); + } + other => panic!("wrong source {other:?}"), + } + } + + #[test] + fn user_message_from_subconscious_is_dropped() { + let ev = DomainEvent::ChannelInboundMessage { + event_name: "msg".into(), + channel: "slack".into(), + message: "proactive note".into(), + sender: Some(SUBCONSCIOUS_SENDER_MARKER.into()), + reply_target: None, + thread_ts: None, + raw_data: serde_json::Value::Null, + }; + assert!(normalize_with_id(&ev, 0.0, "id".into()).is_none()); + } + + #[test] + fn composio_webhook_is_tainted_and_redacted() { + let ev = DomainEvent::ComposioTriggerReceived { + toolkit: "gmail".into(), + trigger: "GMAIL_NEW_GMAIL_MESSAGE".into(), + metadata_id: "evt-99".into(), + metadata_uuid: "uuid-99".into(), + payload: serde_json::json!({"subject": "secret", "body": "do not leak"}), + }; + let t = normalize_with_id(&ev, 0.0, "id".into()).unwrap(); + assert!(t.external_content); + assert_eq!(t.priority, TriggerPriority::Normal); + // Redaction: raw body must NOT appear in the gate summary. + assert!(!t.payload.gate_summary.contains("do not leak")); + assert!(t.payload.gate_summary.contains("2 payload fields")); + assert_eq!( + t.dedupe_key.as_str(), + "composio:gmail:GMAIL_NEW_GMAIL_MESSAGE:evt-99" + ); + // But raw payload is retained for promotion synthesis. + assert_eq!(t.payload.raw["payload"]["body"], "do not leak"); + } + + #[test] + fn composio_duplicate_metadata_shares_dedupe_key() { + let mk = |id: &str| DomainEvent::ComposioTriggerReceived { + toolkit: "gmail".into(), + trigger: "GMAIL_NEW".into(), + metadata_id: id.into(), + metadata_uuid: "u".into(), + payload: serde_json::Value::Null, + }; + let a = normalize_with_id(&mk("same"), 0.0, "a".into()).unwrap(); + let b = normalize_with_id(&mk("same"), 5.0, "b".into()).unwrap(); + assert_eq!(a.dedupe_key, b.dedupe_key); + } + + #[test] + fn subagent_completed_maps_to_ok_conclusion() { + let ev = DomainEvent::SubagentCompleted { + parent_session: ORCHESTRATOR_THREAD_ID.into(), + task_id: "task-7".into(), + agent_id: "researcher".into(), + elapsed_ms: 1000, + output_chars: 42, + iterations: 3, + }; + let t = normalize_with_id(&ev, 0.0, "id".into()).unwrap(); + assert!(t.external_content, "conclusions are tainted (fail-safe)"); + assert_eq!(t.dedupe_key.as_str(), "subagent:task-7:done"); + match t.source { + TriggerSource::SubagentConclusion { ok, agent_id, .. } => { + assert!(ok); + assert_eq!(agent_id, "researcher"); + } + other => panic!("wrong source {other:?}"), + } + } + + #[test] + fn subagent_failed_maps_to_failed_conclusion() { + let ev = DomainEvent::SubagentFailed { + parent_session: ORCHESTRATOR_THREAD_ID.into(), + task_id: "task-8".into(), + agent_id: "orchestrator".into(), + error: "max iterations exceeded".into(), + }; + let t = normalize_with_id(&ev, 0.0, "id".into()).unwrap(); + assert_eq!(t.dedupe_key.as_str(), "subagent:task-8:failed"); + assert!(t.payload.gate_summary.contains("FAILED")); + match t.source { + TriggerSource::SubagentConclusion { ok, .. } => assert!(!ok), + other => panic!("wrong source {other:?}"), + } + } + + #[test] + fn unrelated_event_returns_none() { + let ev = DomainEvent::ChannelConnected { + channel: "slack".into(), + }; + assert!(normalize_with_id(&ev, 0.0, "id".into()).is_none()); + } + + #[test] + fn long_message_preview_is_truncated() { + let long = "word ".repeat(100); + let ev = DomainEvent::ChannelInboundMessage { + event_name: "m".into(), + channel: "c".into(), + message: long, + sender: None, + reply_target: None, + thread_ts: None, + raw_data: serde_json::Value::Null, + }; + let t = normalize_with_id(&ev, 0.0, "id".into()).unwrap(); + assert!(t.payload.gate_summary.contains('…')); + } +} diff --git a/src/openhuman/subconscious_triggers/ops.rs b/src/openhuman/subconscious_triggers/ops.rs new file mode 100644 index 000000000..317003b51 --- /dev/null +++ b/src/openhuman/subconscious_triggers/ops.rs @@ -0,0 +1,60 @@ +//! Business logic for the `subconscious_triggers` RPC surface. + +use serde::{Deserialize, Serialize}; + +use crate::openhuman::subconscious::{ORCHESTRATOR_THREAD_ID, USER_THREAD_ID}; + +use super::runtime::global as orchestrator_global; + +/// Snapshot of the event-driven trigger pipeline for diagnostics / UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TriggerStatus { + /// Whether the trigger pipeline is enabled in config. + pub triggers_enabled: bool, + /// Effective subconscious mode string. + pub mode: String, + /// Per-hour promotion cap. + pub max_promotions_per_hour: u32, + /// Whether the orchestrator runtime has been bootstrapped this session. + pub orchestrator_running: bool, + /// Pending promoted triggers awaiting the session loop, if running. + pub queue_depth: Option, + /// Reserved internal reasoning thread id. + pub orchestrator_thread_id: String, + /// Reserved user-facing thread id. + pub user_thread_id: String, +} + +/// Build the current trigger-pipeline status from config + runtime state. +pub async fn build_status() -> Result { + let config = crate::openhuman::config::load_config_with_timeout().await?; + let hb = &config.heartbeat; + let orchestrator = orchestrator_global(); + + Ok(TriggerStatus { + triggers_enabled: hb.triggers_enabled, + mode: hb.effective_subconscious_mode().as_str().to_string(), + max_promotions_per_hour: hb.max_promotions_per_hour, + orchestrator_running: orchestrator.is_some(), + queue_depth: orchestrator.as_ref().map(|o| o.queue_depth()), + orchestrator_thread_id: ORCHESTRATOR_THREAD_ID.to_string(), + user_thread_id: USER_THREAD_ID.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn status_reports_reserved_thread_ids() { + let status = build_status().await.expect("status builds"); + assert_eq!(status.orchestrator_thread_id, "subconscious:orchestrator"); + assert_eq!(status.user_thread_id, "subconscious:user"); + // `orchestrator_running`/`queue_depth` reflect a process-global slot that + // another test in this binary may have initialized, so assert only the + // invariant between them rather than a fixed value: a running + // orchestrator reports a queue depth; a stopped one reports none. + assert_eq!(status.orchestrator_running, status.queue_depth.is_some()); + } +} diff --git a/src/openhuman/subconscious_triggers/queue.rs b/src/openhuman/subconscious_triggers/queue.rs new file mode 100644 index 000000000..0232dd041 --- /dev/null +++ b/src/openhuman/subconscious_triggers/queue.rs @@ -0,0 +1,296 @@ +//! Bounded priority queue of promoted triggers awaiting the orchestrator's +//! serial event loop. +//! +//! Ordering: highest [`TriggerPriority`] first; within the same priority, +//! earliest-enqueued first (FIFO fairness via a monotonic sequence number). +//! +//! Overflow policy ("drop-Low-on-overflow"): when the queue is at capacity +//! the incoming trigger is compared against the *worst* item currently held +//! (lowest priority, and among ties the most-recently enqueued). If the +//! incoming trigger is strictly more important than that worst item, the +//! worst item is evicted to make room; otherwise the incoming trigger is +//! dropped. This guarantees a storm of `Low` triggers can never starve a +//! later `Urgent` one. +//! +//! The queue is `Send + Sync` (guarded by a `std::sync::Mutex`) so the bus +//! subscriber and the event loop can share it via `Arc`. It is deliberately +//! synchronous — the async wait is layered on top in `engine.rs` via a +//! notify primitive once the loop is wired (slice 5). + +use std::sync::Mutex; + +use super::types::{Trigger, TriggerPriority}; + +/// Outcome of a [`OrchestratorQueue::push`]. +/// +/// `Eq` is not derived because [`Trigger`] (carried in `EvictedLowest`) +/// holds an `f64`; `PartialEq` is sufficient for assertions. +#[derive(Debug, Clone, PartialEq)] +pub enum EnqueueOutcome { + /// The trigger was enqueued and nothing was evicted. + Accepted, + /// The queue was full; `evicted` was dropped to make room for the + /// (more important) incoming trigger. + EvictedLowest { evicted: Box }, + /// The queue was full and the incoming trigger was no more important + /// than the worst held item, so the incoming trigger was dropped. + DroppedIncoming, +} + +/// Internal slot pairing a trigger with its enqueue sequence number. +#[derive(Debug)] +struct Slot { + seq: u64, + trigger: Trigger, +} + +#[derive(Debug)] +struct Inner { + slots: Vec, + next_seq: u64, +} + +/// A bounded, priority-ordered queue of triggers. +#[derive(Debug)] +pub struct OrchestratorQueue { + capacity: usize, + inner: Mutex, +} + +impl OrchestratorQueue { + /// Create a queue holding at most `capacity` triggers. A capacity of 0 + /// is clamped to 1 so the queue is always usable. + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + inner: Mutex::new(Inner { + slots: Vec::new(), + next_seq: 0, + }), + } + } + + /// Number of triggers currently queued. + pub fn len(&self) -> usize { + self.inner.lock().expect("queue mutex poisoned").slots.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn capacity(&self) -> usize { + self.capacity + } + + /// Enqueue `trigger`, applying the overflow policy if at capacity. + pub fn push(&self, trigger: Trigger) -> EnqueueOutcome { + let mut inner = self.inner.lock().expect("queue mutex poisoned"); + + if inner.slots.len() < self.capacity { + let seq = inner.next_seq; + inner.next_seq += 1; + inner.slots.push(Slot { seq, trigger }); + return EnqueueOutcome::Accepted; + } + + // At capacity: locate the worst held item (lowest priority; among + // equal priority the most-recently enqueued / highest seq). + let worst_idx = inner + .slots + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + a.trigger + .priority + .cmp(&b.trigger.priority) + .then(b.seq.cmp(&a.seq)) + }) + .map(|(i, _)| i); + + let Some(worst_idx) = worst_idx else { + // capacity >= 1 guarantees at least one slot here, but stay safe. + let seq = inner.next_seq; + inner.next_seq += 1; + inner.slots.push(Slot { seq, trigger }); + return EnqueueOutcome::Accepted; + }; + + if trigger.priority > inner.slots[worst_idx].trigger.priority { + let seq = inner.next_seq; + inner.next_seq += 1; + let evicted = std::mem::replace(&mut inner.slots[worst_idx], Slot { seq, trigger }); + EnqueueOutcome::EvictedLowest { + evicted: Box::new(evicted.trigger), + } + } else { + EnqueueOutcome::DroppedIncoming + } + } + + /// Remove and return the most important queued trigger (highest + /// priority; FIFO within a priority), or `None` if empty. + pub fn pop(&self) -> Option { + let mut inner = self.inner.lock().expect("queue mutex poisoned"); + let best_idx = inner + .slots + .iter() + .enumerate() + .max_by(|(_, a), (_, b)| { + a.trigger + .priority + .cmp(&b.trigger.priority) + .then(b.seq.cmp(&a.seq)) + }) + .map(|(i, _)| i)?; + Some(inner.slots.remove(best_idx).trigger) + } + + /// Snapshot of queued priorities, most-important-first. Test/diagnostic aid. + pub fn priorities(&self) -> Vec { + let inner = self.inner.lock().expect("queue mutex poisoned"); + let mut slots: Vec<&Slot> = inner.slots.iter().collect(); + slots.sort_by(|a, b| { + b.trigger + .priority + .cmp(&a.trigger.priority) + .then(a.seq.cmp(&b.seq)) + }); + slots.iter().map(|s| s.trigger.priority).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::subconscious_triggers::types::{ + DedupeKey, TriggerPayload, TriggerSource, + }; + + fn trigger(label: &str, priority: TriggerPriority) -> Trigger { + Trigger { + id: format!("id-{label}"), + source: TriggerSource::Cron { + job_id: "j".into(), + job_name: "n".into(), + }, + display_label: label.into(), + payload: TriggerPayload { + gate_summary: label.into(), + raw: serde_json::Value::Null, + }, + priority, + dedupe_key: DedupeKey(label.into()), + external_content: false, + received_at: 0.0, + } + } + + #[test] + fn pops_in_priority_order() { + let q = OrchestratorQueue::new(8); + q.push(trigger("a", TriggerPriority::Low)); + q.push(trigger("b", TriggerPriority::Urgent)); + q.push(trigger("c", TriggerPriority::Normal)); + q.push(trigger("d", TriggerPriority::High)); + + let order: Vec = std::iter::from_fn(|| q.pop()) + .map(|t| t.display_label) + .collect(); + assert_eq!(order, vec!["b", "d", "c", "a"]); + } + + #[test] + fn fifo_within_same_priority() { + let q = OrchestratorQueue::new(8); + q.push(trigger("first", TriggerPriority::Normal)); + q.push(trigger("second", TriggerPriority::Normal)); + q.push(trigger("third", TriggerPriority::Normal)); + + assert_eq!(q.pop().unwrap().display_label, "first"); + assert_eq!(q.pop().unwrap().display_label, "second"); + assert_eq!(q.pop().unwrap().display_label, "third"); + } + + #[test] + fn overflow_drops_incoming_when_not_more_important() { + let q = OrchestratorQueue::new(2); + assert_eq!( + q.push(trigger("a", TriggerPriority::Normal)), + EnqueueOutcome::Accepted + ); + assert_eq!( + q.push(trigger("b", TriggerPriority::High)), + EnqueueOutcome::Accepted + ); + // Queue full; incoming Low is not more important than worst (Normal). + assert_eq!( + q.push(trigger("c", TriggerPriority::Low)), + EnqueueOutcome::DroppedIncoming + ); + assert_eq!(q.len(), 2); + // Both originals survive. + let labels: Vec = std::iter::from_fn(|| q.pop()) + .map(|t| t.display_label) + .collect(); + assert_eq!(labels, vec!["b", "a"]); + } + + #[test] + fn overflow_evicts_lowest_for_more_important_incoming() { + let q = OrchestratorQueue::new(2); + q.push(trigger("low", TriggerPriority::Low)); + q.push(trigger("normal", TriggerPriority::Normal)); + // Queue full; incoming Urgent beats worst (Low) → evict "low". + match q.push(trigger("urgent", TriggerPriority::Urgent)) { + EnqueueOutcome::EvictedLowest { evicted } => { + assert_eq!(evicted.display_label, "low"); + } + other => panic!("expected eviction, got {other:?}"), + } + assert_eq!(q.len(), 2); + let labels: Vec = std::iter::from_fn(|| q.pop()) + .map(|t| t.display_label) + .collect(); + assert_eq!(labels, vec!["urgent", "normal"]); + } + + #[test] + fn overflow_evicts_newest_among_equal_lowest() { + let q = OrchestratorQueue::new(2); + q.push(trigger("low-old", TriggerPriority::Low)); + q.push(trigger("low-new", TriggerPriority::Low)); + // Incoming High beats Low; worst tie broken by newest (highest seq). + match q.push(trigger("high", TriggerPriority::High)) { + EnqueueOutcome::EvictedLowest { evicted } => { + assert_eq!(evicted.display_label, "low-new"); + } + other => panic!("expected eviction, got {other:?}"), + } + let labels: Vec = std::iter::from_fn(|| q.pop()) + .map(|t| t.display_label) + .collect(); + assert_eq!(labels, vec!["high", "low-old"]); + } + + #[test] + fn capacity_zero_is_clamped_to_one() { + let q = OrchestratorQueue::new(0); + assert_eq!(q.capacity(), 1); + assert_eq!( + q.push(trigger("a", TriggerPriority::Normal)), + EnqueueOutcome::Accepted + ); + assert_eq!( + q.push(trigger("b", TriggerPriority::Normal)), + EnqueueOutcome::DroppedIncoming + ); + } + + #[test] + fn empty_pop_returns_none() { + let q = OrchestratorQueue::new(4); + assert!(q.is_empty()); + assert!(q.pop().is_none()); + } +} diff --git a/src/openhuman/subconscious_triggers/registry.rs b/src/openhuman/subconscious_triggers/registry.rs new file mode 100644 index 000000000..085a3ea4b --- /dev/null +++ b/src/openhuman/subconscious_triggers/registry.rs @@ -0,0 +1,286 @@ +//! Trigger ingestion front-end: dedupe + per-source rate limiting. +//! +//! This slice provides the pure admission primitives ([`DedupeWindow`], +//! [`RateLimiter`]) and the [`TriggerRegistry`] shell that composes them. +//! Event normalization (`DomainEvent` → [`Trigger`]) is added in slice 2; +//! the gate + queue wiring in later slices. +//! +//! All time is injected as epoch seconds (`now`) so the logic is fully +//! deterministic under test — no wall-clock reads here. + +use std::collections::HashMap; +use std::sync::Mutex; + +use super::types::Trigger; + +/// Result of admitting a trigger past dedupe + rate limiting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdmitOutcome { + /// The trigger passed both gates and may proceed to the LLM gate. + Admitted, + /// A trigger with the same dedupe key was seen within the TTL window. + Duplicate, + /// The per-source rate limit was exhausted. + RateLimited, +} + +/// Collapses triggers sharing a [`DedupeKey`] within a TTL window so a burst +/// of identical webhooks costs at most one gate call. +#[derive(Debug)] +pub struct DedupeWindow { + ttl_secs: f64, + seen: Mutex>, +} + +impl DedupeWindow { + pub fn new(ttl_secs: f64) -> Self { + Self { + ttl_secs: ttl_secs.max(0.0), + seen: Mutex::new(HashMap::new()), + } + } + + /// Return `true` if `key` is fresh (and record it), or `false` if a + /// non-expired entry already exists. Expired entries are evicted lazily + /// on each call to keep the map bounded. + pub fn check_and_record(&self, key: &str, now: f64) -> bool { + let mut seen = self.seen.lock().expect("dedupe mutex poisoned"); + // Evict expired entries. + seen.retain(|_, &mut ts| now - ts < self.ttl_secs); + + if let Some(&ts) = seen.get(key) { + if now - ts < self.ttl_secs { + return false; + } + } + seen.insert(key.to_string(), now); + true + } + + /// Current number of live (un-evicted) entries. Diagnostic aid. + pub fn live_len(&self, now: f64) -> usize { + let seen = self.seen.lock().expect("dedupe mutex poisoned"); + seen.values() + .filter(|&&ts| now - ts < self.ttl_secs) + .count() + } +} + +#[derive(Debug, Clone, Copy)] +struct Bucket { + tokens: f64, + last: f64, +} + +/// Per-key token-bucket rate limiter. Keyed by trigger source family so a +/// flood from one source can't starve the gate for others. +#[derive(Debug)] +pub struct RateLimiter { + capacity: f64, + refill_per_sec: f64, + buckets: Mutex>, +} + +impl RateLimiter { + /// `capacity` tokens, refilling at `refill_per_sec`. A bucket starts + /// full so the first `capacity` triggers per key are always admitted. + pub fn new(capacity: f64, refill_per_sec: f64) -> Self { + Self { + capacity: capacity.max(1.0), + refill_per_sec: refill_per_sec.max(0.0), + buckets: Mutex::new(HashMap::new()), + } + } + + /// Consume one token for `key`. Returns `true` if a token was available. + pub fn allow(&self, key: &str, now: f64) -> bool { + let mut buckets = self.buckets.lock().expect("rate mutex poisoned"); + let bucket = buckets.entry(key.to_string()).or_insert(Bucket { + tokens: self.capacity, + last: now, + }); + + // Refill based on elapsed time, capped at capacity. + let elapsed = (now - bucket.last).max(0.0); + bucket.tokens = (bucket.tokens + elapsed * self.refill_per_sec).min(self.capacity); + bucket.last = now; + + if bucket.tokens >= 1.0 { + bucket.tokens -= 1.0; + true + } else { + false + } + } +} + +/// Composes dedupe + rate limiting. Normalization and gate/queue dispatch +/// are layered on in later slices. +#[derive(Debug)] +pub struct TriggerRegistry { + dedupe: DedupeWindow, + rate: RateLimiter, +} + +impl TriggerRegistry { + pub fn new(dedupe: DedupeWindow, rate: RateLimiter) -> Self { + Self { dedupe, rate } + } + + /// Construct with sensible defaults: 5-minute dedupe TTL, and a rate + /// bucket of 30 triggers refilling at 1/sec per source family. + pub fn with_defaults() -> Self { + Self::new(DedupeWindow::new(300.0), RateLimiter::new(30.0, 1.0)) + } + + /// Run a normalized trigger through dedupe then the per-source rate + /// limit. Dedupe is checked first so duplicates never consume rate + /// tokens. + pub fn admit(&self, trigger: &Trigger, now: f64) -> AdmitOutcome { + if !self + .dedupe + .check_and_record(trigger.dedupe_key.as_str(), now) + { + return AdmitOutcome::Duplicate; + } + if !self.rate.allow(trigger.source.family(), now) { + return AdmitOutcome::RateLimited; + } + AdmitOutcome::Admitted + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::subconscious_triggers::types::{ + DedupeKey, TriggerPayload, TriggerPriority, TriggerSource, + }; + + fn trigger(key: &str, family: TriggerSource) -> Trigger { + Trigger { + id: "id".into(), + source: family, + display_label: key.into(), + payload: TriggerPayload { + gate_summary: key.into(), + raw: serde_json::Value::Null, + }, + priority: TriggerPriority::Normal, + dedupe_key: DedupeKey(key.into()), + external_content: false, + received_at: 0.0, + } + } + + fn cron() -> TriggerSource { + TriggerSource::Cron { + job_id: "j".into(), + job_name: "n".into(), + } + } + + // ── DedupeWindow ──────────────────────────────────────────────────── + + #[test] + fn dedupe_collapses_within_ttl() { + let w = DedupeWindow::new(60.0); + assert!(w.check_and_record("k", 0.0)); + assert!(!w.check_and_record("k", 30.0)); // within TTL → duplicate + assert!(!w.check_and_record("k", 59.9)); + } + + #[test] + fn dedupe_allows_again_after_ttl() { + let w = DedupeWindow::new(60.0); + assert!(w.check_and_record("k", 0.0)); + assert!(w.check_and_record("k", 60.1)); // past TTL → fresh again + } + + #[test] + fn dedupe_distinct_keys_are_independent() { + let w = DedupeWindow::new(60.0); + assert!(w.check_and_record("a", 0.0)); + assert!(w.check_and_record("b", 0.0)); + assert_eq!(w.live_len(0.0), 2); + } + + #[test] + fn dedupe_evicts_expired_entries() { + let w = DedupeWindow::new(10.0); + w.check_and_record("a", 0.0); + w.check_and_record("b", 0.0); + // Touch at t=20 → both expired and evicted; only "c" remains. + w.check_and_record("c", 20.0); + assert_eq!(w.live_len(20.0), 1); + } + + // ── RateLimiter ───────────────────────────────────────────────────── + + #[test] + fn rate_allows_up_to_capacity_then_blocks() { + let r = RateLimiter::new(3.0, 0.0); // no refill + assert!(r.allow("s", 0.0)); + assert!(r.allow("s", 0.0)); + assert!(r.allow("s", 0.0)); + assert!(!r.allow("s", 0.0)); // bucket empty + } + + #[test] + fn rate_refills_over_time() { + let r = RateLimiter::new(2.0, 1.0); // 1 token/sec + assert!(r.allow("s", 0.0)); + assert!(r.allow("s", 0.0)); + assert!(!r.allow("s", 0.0)); + assert!(r.allow("s", 1.0)); // 1 sec → 1 token back + } + + #[test] + fn rate_keys_are_independent() { + let r = RateLimiter::new(1.0, 0.0); + assert!(r.allow("a", 0.0)); + assert!(!r.allow("a", 0.0)); + assert!(r.allow("b", 0.0)); // separate bucket + } + + // ── TriggerRegistry::admit ────────────────────────────────────────── + + #[test] + fn admit_passes_fresh_trigger() { + let reg = TriggerRegistry::with_defaults(); + assert_eq!( + reg.admit(&trigger("k", cron()), 0.0), + AdmitOutcome::Admitted + ); + } + + #[test] + fn admit_rejects_duplicate_before_consuming_rate() { + let reg = TriggerRegistry::new(DedupeWindow::new(300.0), RateLimiter::new(1.0, 0.0)); + assert_eq!( + reg.admit(&trigger("k", cron()), 0.0), + AdmitOutcome::Admitted + ); + // Same key → Duplicate (must NOT have consumed the single rate token + // on the first call beyond the one admit, so a *different* key still + // works within capacity). + assert_eq!( + reg.admit(&trigger("k", cron()), 1.0), + AdmitOutcome::Duplicate + ); + } + + #[test] + fn admit_rate_limits_distinct_keys_same_family() { + let reg = TriggerRegistry::new(DedupeWindow::new(300.0), RateLimiter::new(1.0, 0.0)); + assert_eq!( + reg.admit(&trigger("a", cron()), 0.0), + AdmitOutcome::Admitted + ); + // Distinct dedupe key (passes dedupe) but same family → rate limited. + assert_eq!( + reg.admit(&trigger("b", cron()), 0.0), + AdmitOutcome::RateLimited + ); + } +} diff --git a/src/openhuman/subconscious_triggers/runtime.rs b/src/openhuman/subconscious_triggers/runtime.rs new file mode 100644 index 000000000..941b1024a --- /dev/null +++ b/src/openhuman/subconscious_triggers/runtime.rs @@ -0,0 +1,397 @@ +//! The background orchestrator runtime: fan-in → gate → queue → session. +//! +//! [`TriggerOrchestrator`] ties the slices together: +//! 1. `ingest` (called from the bus subscriber, non-blocking): normalize the +//! event, run dedupe + rate limiting, and — for admitted triggers — +//! spawn a gate task so the runtime never blocks event dispatch. +//! 2. the gate task runs the LLM gate; on `Promote` it pushes onto the +//! [`OrchestratorQueue`] and wakes the loop. +//! 3. `run_loop` drains the queue serially, handing each promoted trigger to +//! the [`LongLivedSession`]. +//! +//! A process-global singleton holds the runtime so the bus subscriber and +//! the spawned loop share one instance. + +use std::sync::{Arc, OnceLock}; +use std::time::Instant; + +use async_trait::async_trait; +use tokio::sync::Notify; +use tracing::{debug, info, warn}; + +use crate::core::event_bus::{publish_global, DomainEvent}; +use crate::openhuman::subconscious::LongLivedSession; + +use super::gate::GatePass; +use super::normalize::normalize; +use super::queue::{EnqueueOutcome, OrchestratorQueue}; +use super::registry::{AdmitOutcome, DedupeWindow, RateLimiter, TriggerRegistry}; +use super::types::{GateDecision, Trigger}; + +/// The promote/drop gate. The production implementation is [`GatePass`] +/// (LLM-backed via `agent::triage`); tests inject a scripted gate so the +/// orchestration flow can be driven deterministically. +#[async_trait] +pub trait Gate: Send + Sync { + async fn evaluate(&self, trigger: &Trigger, now: f64) -> GateDecision; +} + +#[async_trait] +impl Gate for GatePass { + async fn evaluate(&self, trigger: &Trigger, now: f64) -> GateDecision { + GatePass::evaluate(self, trigger, now).await + } +} + +/// Executes a promoted trigger as a long-lived-session turn. The production +/// implementation is [`LongLivedSession`]; tests inject a scripted executor +/// to simulate agent / sub-agent behaviour and back-and-forth comms. +#[async_trait] +pub trait SessionExecutor: Send + Sync { + /// Run the promoted trigger. `summary` is the synthesized user-turn; + /// `external_content` marks third-party-tainted input. Returns the + /// session's response text. + async fn execute(&self, summary: &str, external_content: bool) -> Result; + + /// Reserved thread id this executor writes to (for logging). + fn thread_id(&self) -> &str; +} + +#[async_trait] +impl SessionExecutor for LongLivedSession { + async fn execute(&self, summary: &str, external_content: bool) -> Result { + self.process_promoted(summary, external_content) + .await + .map(|outcome| outcome.response) + } + + fn thread_id(&self) -> &str { + LongLivedSession::thread_id(self) + } +} + +/// Tunables for the trigger pipeline. Sourced from `HeartbeatConfig` in +/// slice 7; sensible defaults until then. +#[derive(Debug, Clone)] +pub struct OrchestratorConfig { + pub queue_capacity: usize, + pub dedupe_ttl_secs: f64, + pub rate_capacity: f64, + pub rate_refill_per_sec: f64, + pub max_promotions_per_hour: u32, +} + +impl Default for OrchestratorConfig { + fn default() -> Self { + Self { + queue_capacity: 256, + dedupe_ttl_secs: 300.0, + rate_capacity: 30.0, + rate_refill_per_sec: 1.0, + max_promotions_per_hour: 30, + } + } +} + +/// Background orchestrator: trigger ingestion front-end + serial event loop. +pub struct TriggerOrchestrator { + registry: TriggerRegistry, + gate: Arc, + queue: Arc, + session: Arc, + notify: Arc, +} + +impl TriggerOrchestrator { + /// Production constructor: real LLM gate ([`GatePass`]) over the given + /// session executor (normally a [`LongLivedSession`]). + pub fn new(session: Arc, config: OrchestratorConfig) -> Self { + let gate = Arc::new(GatePass::new(config.max_promotions_per_hour)); + Self::with_components(session, gate, config) + } + + /// Constructor with an injected [`Gate`] — used by tests to drive the + /// orchestration flow deterministically without an LLM. + pub fn with_components( + session: Arc, + gate: Arc, + config: OrchestratorConfig, + ) -> Self { + let registry = TriggerRegistry::new( + DedupeWindow::new(config.dedupe_ttl_secs), + RateLimiter::new(config.rate_capacity, config.rate_refill_per_sec), + ); + Self { + registry, + gate, + queue: Arc::new(OrchestratorQueue::new(config.queue_capacity)), + session, + notify: Arc::new(Notify::new()), + } + } + + /// Non-blocking ingestion entry point for the bus subscriber. Normalizes + /// + admits synchronously, then spawns the gate task for admitted + /// triggers so event dispatch is never blocked on an LLM call. + pub fn ingest(self: &Arc, event: &DomainEvent) { + let now = now_secs(); + let Some(trigger) = normalize(event, now) else { + return; + }; + match self.registry.admit(&trigger, now) { + AdmitOutcome::Admitted => { + let this = Arc::clone(self); + tokio::spawn(async move { + this.gate_and_enqueue(trigger).await; + }); + } + AdmitOutcome::Duplicate => { + debug!( + "[subconscious_triggers] dropped duplicate trigger source={} key={}", + trigger.source.family(), + trigger.dedupe_key.as_str() + ); + } + AdmitOutcome::RateLimited => { + debug!( + "[subconscious_triggers] rate-limited trigger source={}", + trigger.source.family() + ); + } + } + } + + /// Run the gate on an admitted trigger and enqueue promotions. + async fn gate_and_enqueue(self: Arc, trigger: super::types::Trigger) { + let started = Instant::now(); + let now = now_secs(); + let source = trigger.source.family().to_string(); + let decision = self.gate.evaluate(&trigger, now).await; + let latency_ms = started.elapsed().as_millis() as u64; + let promoted = decision.is_promote(); + + publish_global(DomainEvent::SubconsciousTriggerProcessed { + source: source.clone(), + decision: decision.as_str().to_string(), + promoted, + latency_ms, + }); + + match decision { + GateDecision::Promote { + synthesized_summary, + priority, + reason, + } => { + info!( + "[subconscious_triggers] promoting trigger source={} priority={} reason={}", + source, + priority.as_str(), + reason + ); + // Reuse the trigger as the queue item: stamp the gate's + // priority and carry the synthesized user-turn in + // `gate_summary`. The gate only ever saw the *redacted* + // summary; the long-lived session needs the actionable + // content, so append a bounded rendering of the full payload + // here. This is safe because promoted runs from external + // triggers execute under the tainted automation origin (the + // approval gate refuses external-effect tools). + let mut item = trigger; + item.priority = priority; + item.payload.gate_summary = + augment_with_payload(&synthesized_summary, &item.payload.raw); + match self.queue.push(item) { + EnqueueOutcome::Accepted => {} + EnqueueOutcome::EvictedLowest { evicted } => { + warn!( + "[subconscious_triggers] queue full — evicted lower-priority trigger {}", + evicted.display_label + ); + } + EnqueueOutcome::DroppedIncoming => { + warn!( + "[subconscious_triggers] queue full — dropped promoted trigger source={source}" + ); + return; + } + } + self.notify.notify_one(); + } + GateDecision::Drop { + acknowledge, + reason, + } => { + debug!( + "[subconscious_triggers] dropped trigger source={source} ack={acknowledge} reason={reason}" + ); + } + } + } + + /// Serial event loop: drain the queue, process each promoted trigger + /// through the long-lived session, then wait for the next wake. + /// + /// Runs until the process exits. + pub async fn run_loop(self: Arc) { + info!( + "[subconscious_triggers] orchestrator loop started thread={}", + self.session.thread_id() + ); + loop { + while let Some(item) = self.queue.pop() { + let summary = item.payload.gate_summary.clone(); + let external = item.external_content; + debug!( + "[subconscious_triggers] processing promoted item source={} label={}", + item.source.family(), + item.display_label + ); + if let Err(err) = self.session.execute(&summary, external).await { + warn!( + "[subconscious_triggers] session run failed label={} err={}", + item.display_label, err + ); + } + } + self.notify.notified().await; + } + } + + /// Test/diagnostic accessor for the pending queue depth. + pub fn queue_depth(&self) -> usize { + self.queue.len() + } +} + +/// Live orchestrator + the handle to its spawned event loop, so the loop can +/// be aborted on teardown (user switch). +struct OrchestratorSlot { + orchestrator: Arc, + loop_handle: tokio::task::JoinHandle<()>, +} + +/// Process-global orchestrator slot. The `OnceLock` only initializes the +/// mutex; the contained `Option` is mutable so the orchestrator can be torn +/// down and re-bound across user/workspace switches. +static ORCHESTRATOR: OnceLock>> = OnceLock::new(); + +fn slot() -> &'static std::sync::Mutex> { + ORCHESTRATOR.get_or_init(|| std::sync::Mutex::new(None)) +} + +/// Initialize the global orchestrator and spawn its event loop. Idempotent and +/// race-safe: the check-and-set happens under one lock, so a concurrent caller +/// never spawns a second loop. Returns the shared handle. +pub fn init_global(orch: Arc) -> Arc { + let mut guard = slot().lock().expect("orchestrator slot poisoned"); + if let Some(existing) = guard.as_ref() { + return Arc::clone(&existing.orchestrator); + } + let loop_handle = { + let this = Arc::clone(&orch); + tokio::spawn(async move { this.run_loop().await }) + }; + *guard = Some(OrchestratorSlot { + orchestrator: Arc::clone(&orch), + loop_handle, + }); + orch +} + +/// Get the global orchestrator if it has been initialized. +pub fn global() -> Option> { + slot() + .lock() + .expect("orchestrator slot poisoned") + .as_ref() + .map(|s| Arc::clone(&s.orchestrator)) +} + +/// Tear down the global orchestrator: abort its event loop and clear the slot +/// so a subsequent [`init_global`] (e.g. after a user/workspace switch) binds a +/// fresh session instead of routing through the stale one. +pub fn shutdown_global() { + if let Some(s) = slot().lock().expect("orchestrator slot poisoned").take() { + s.loop_handle.abort(); + info!("[subconscious_triggers] orchestrator loop shut down"); + } +} + +/// Max characters of the raw payload appended to a promoted session turn. +const PROMOTED_PAYLOAD_MAX_CHARS: usize = 4000; + +/// Append a bounded rendering of the trigger's full payload to the gate's +/// synthesized summary so the long-lived session can act on the actual +/// content (subject/body/etc.) the redacted gate summary omitted. No-op for a +/// null payload. +fn augment_with_payload(summary: &str, raw: &serde_json::Value) -> String { + if raw.is_null() { + return summary.to_string(); + } + let mut rendered = serde_json::to_string_pretty(raw).unwrap_or_else(|_| raw.to_string()); + if rendered.chars().count() > PROMOTED_PAYLOAD_MAX_CHARS { + rendered = rendered.chars().take(PROMOTED_PAYLOAD_MAX_CHARS).collect(); + rendered.push_str("\n…(truncated)"); + } + format!("{summary}\n\nTrigger payload:\n{rendered}") +} + +/// Epoch seconds with sub-second precision. +fn now_secs() -> f64 { + chrono::Utc::now().timestamp_millis() as f64 / 1000.0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::schema::SubconsciousMode; + use crate::openhuman::subconscious::LongLivedSession; + use std::path::PathBuf; + + fn orchestrator() -> Arc { + let session = Arc::new(LongLivedSession::new( + PathBuf::from("/tmp/subconscious-triggers-test"), + SubconsciousMode::Simple, + )); + Arc::new(TriggerOrchestrator::new( + session, + OrchestratorConfig::default(), + )) + } + + #[tokio::test] + async fn ingest_ignores_unrelated_events() { + let orch = orchestrator(); + // ChannelConnected is in a watched domain but is not a trigger source + // → normalize returns None → no gate task, no enqueue. + orch.ingest(&DomainEvent::ChannelConnected { + channel: "slack".into(), + }); + assert_eq!(orch.queue_depth(), 0); + } + + #[tokio::test] + async fn ingest_skips_self_authored_user_messages() { + let orch = orchestrator(); + // A message the orchestrator itself emitted (anti self-trigger). + orch.ingest(&DomainEvent::ChannelInboundMessage { + event_name: "msg".into(), + channel: "slack".into(), + message: "proactive".into(), + sender: Some(super::super::SUBCONSCIOUS_SENDER_MARKER.into()), + reply_target: None, + thread_ts: None, + raw_data: serde_json::Value::Null, + }); + assert_eq!(orch.queue_depth(), 0); + } + + #[test] + fn default_config_is_sane() { + let c = OrchestratorConfig::default(); + assert!(c.queue_capacity > 0); + assert!(c.dedupe_ttl_secs > 0.0); + assert!(c.max_promotions_per_hour > 0); + } +} diff --git a/src/openhuman/subconscious_triggers/schemas.rs b/src/openhuman/subconscious_triggers/schemas.rs new file mode 100644 index 000000000..cc415fa7d --- /dev/null +++ b/src/openhuman/subconscious_triggers/schemas.rs @@ -0,0 +1,66 @@ +//! RPC endpoints for the `subconscious_triggers` domain. + +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::rpc::RpcOutcome; + +use super::ops; + +pub fn all_controller_schemas() -> Vec { + vec![schemas("status")] +} + +pub fn all_registered_controllers() -> Vec { + vec![RegisteredController { + schema: schemas("status"), + handler: handle_status, + }] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "status" => ControllerSchema { + namespace: "subconscious_triggers", + function: "status", + description: "Status of the event-driven subconscious trigger pipeline.", + inputs: vec![], + outputs: vec![field( + "result", + TypeSchema::Json, + "Trigger pipeline status.", + )], + }, + _other => ControllerSchema { + namespace: "subconscious_triggers", + function: "unknown", + description: "Unknown subconscious_triggers function.", + inputs: vec![], + outputs: vec![field("error", TypeSchema::String, "Error details.")], + }, + } +} + +fn handle_status(_params: Map) -> ControllerFuture { + Box::pin(async move { + let status = ops::build_status().await?; + to_json(RpcOutcome::single_log( + status, + "subconscious_triggers status", + )) + }) +} + +fn field(name: &'static str, ty: TypeSchema, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty, + comment, + required: true, + } +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} diff --git a/src/openhuman/subconscious_triggers/types.rs b/src/openhuman/subconscious_triggers/types.rs new file mode 100644 index 000000000..a5bc11143 --- /dev/null +++ b/src/openhuman/subconscious_triggers/types.rs @@ -0,0 +1,163 @@ +//! Core types for the subconscious trigger pipeline. +//! +//! A [`Trigger`] is the normalized, source-agnostic representation of an +//! external event (cron tick, user message, Composio webhook, sub-agent +//! conclusion) that the background orchestrator may decide to act on. The +//! ingestion front-end (`registry.rs`) turns raw [`crate::core::event_bus::DomainEvent`]s +//! into `Trigger`s; the LLM gate (`gate.rs`) turns each `Trigger` into a +//! [`GateDecision`]. +//! +//! These types are intentionally free of any async / bus machinery so they +//! can be unit-tested in isolation. + +use serde::{Deserialize, Serialize}; + +/// Relative importance of a trigger. Drives queue ordering and may be +/// upgraded/downgraded by the gate. `Ord` is derived from declaration +/// order — `Low < Normal < High < Urgent` — so a max-selection picks the +/// most important trigger. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TriggerPriority { + Low, + Normal, + High, + Urgent, +} + +impl TriggerPriority { + /// Stable string for logs and the observability event. + pub fn as_str(&self) -> &'static str { + match self { + Self::Low => "low", + Self::Normal => "normal", + Self::High => "high", + Self::Urgent => "urgent", + } + } +} + +/// Where a trigger came from. Each variant carries the minimal identifying +/// fields needed for dedupe, labelling, and downstream synthesis — not the +/// full raw payload (that lives in [`TriggerPayload::raw`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TriggerSource { + /// The periodic heartbeat tick, routed through the registry as one + /// trigger type so the orchestrator's event loop has a single entry path. + Cron { job_id: String, job_name: String }, + /// An inbound user message observed on a channel. The background + /// orchestrator becomes *aware* of it; the existing chat path still + /// produces the direct reply. + UserMessage { + channel: String, + sender: Option, + thread_id: String, + }, + /// A Composio trigger webhook (e.g. new Gmail message). + ComposioWebhook { + toolkit: String, + trigger: String, + metadata_id: String, + }, + /// A spawned sub-agent reached a terminal state — the design's + /// "Conclusion Handshake". `ok = false` for failures. + SubagentConclusion { + task_id: String, + agent_id: String, + ok: bool, + }, +} + +impl TriggerSource { + /// Stable family slug used for per-source rate limiting and logs. + pub fn family(&self) -> &'static str { + match self { + Self::Cron { .. } => "cron", + Self::UserMessage { .. } => "user_message", + Self::ComposioWebhook { .. } => "composio_webhook", + Self::SubagentConclusion { .. } => "subagent_conclusion", + } + } +} + +/// The trigger's content, split into the redacted view the gate LLM sees +/// and the full structured payload retained only for promotion synthesis. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TriggerPayload { + /// Short, redacted text the gate LLM is shown. For external sources + /// this is shape/counts, never raw third-party bodies — mirroring the + /// `ApprovalRequested` redaction contract. + pub gate_summary: String, + /// Full structured payload, used only when a trigger is *promoted* into + /// the long-lived session. Never sent to the cheap gate model. + pub raw: serde_json::Value, +} + +/// Source-stable identity used to collapse duplicates within a TTL window +/// (e.g. a burst of webhooks for the same Gmail thread). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct DedupeKey(pub String); + +impl DedupeKey { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A normalized trigger ready for dedupe → rate-limit → gate. +/// +/// `Eq` is intentionally not derived: `received_at` is an `f64`. Equality +/// in tests is via the derived `PartialEq`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Trigger { + /// Correlation id (uuid) carried across gate and session. + pub id: String, + pub source: TriggerSource, + /// Human-readable label for logs/UI, e.g. `composio/gmail/GMAIL_NEW_GMAIL_MESSAGE`. + pub display_label: String, + pub payload: TriggerPayload, + pub priority: TriggerPriority, + pub dedupe_key: DedupeKey, + /// Whether this trigger carries third-party content, which forces the + /// promoted session run under the tainted automation origin (the + /// approval gate then refuses external-effect tools). + pub external_content: bool, + /// Epoch seconds when the trigger was received (used for FIFO tie-break + /// and observability latency). + pub received_at: f64, +} + +/// The gate's verdict for a single trigger. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "lowercase")] +pub enum GateDecision { + /// Append a synthesized note to the long-lived session and let it run. + /// This is the *only* path that mutates the orchestrator's rolling + /// context. + Promote { + /// The user-turn text appended to the session. + synthesized_summary: String, + /// The gate may upgrade/downgrade the incoming priority. + priority: TriggerPriority, + reason: String, + }, + /// Do not touch the rolling context. Optionally still write a scratchpad + /// acknowledge note (no LLM session run). + Drop { acknowledge: bool, reason: String }, +} + +impl GateDecision { + /// Stable string for the observability event / logs. + pub fn as_str(&self) -> &'static str { + match self { + Self::Promote { .. } => "promote", + Self::Drop { .. } => "drop", + } + } + + /// Whether this decision results in a long-lived session run. + pub fn is_promote(&self) -> bool { + matches!(self, Self::Promote { .. }) + } +} diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index ea18f1fd0..2ee952698 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -529,6 +529,9 @@ pub fn all_tools_with_runtime( // Subconscious scratchpad tools — persistent working memory across ticks. tools.extend(crate::openhuman::subconscious::scratchpad::tools::all_scratchpad_tools()); + // Subconscious user-facing handoff — notify_user proactive delivery. + tools.extend(crate::openhuman::subconscious::user_thread::all_user_thread_tools()); + // tiny.place agent surface. These wrap the internal tiny.place controllers // so the dedicated tinyplace subagent can register identities, inspect // inbox/DM state, trade marketplace assets, manage groups, and work jobs diff --git a/tests/subconscious_conversation_e2e.rs b/tests/subconscious_conversation_e2e.rs new file mode 100644 index 000000000..8b4e2fd6c --- /dev/null +++ b/tests/subconscious_conversation_e2e.rs @@ -0,0 +1,474 @@ +//! Multi-party conversation e2e: **human ↔ subconscious orchestrator ↔ sub-agent**. +//! +//! This drives the *real* [`TriggerOrchestrator`] (ingest → dedupe/rate → +//! gate → priority queue → serial run loop) with two injected seams — a +//! scripted [`Gate`] and a scripted [`SessionExecutor`] — so we can simulate +//! many back-and-forth comms patterns deterministically, without an LLM: +//! +//! - **human → subconscious**: a `ChannelInboundMessage` is normalized, +//! gated, promoted, and run. +//! - **subconscious → sub-agent**: the scripted session "spawns" a sub-agent +//! by emitting a follow-on `SubagentCompleted`/`SubagentFailed` event that +//! re-enters the real pipeline. +//! - **sub-agent → subconscious**: that conclusion is normalized, gated, and +//! merged by the session. +//! - **subconscious → human**: the session calls the real `notify_user`, +//! which we capture off the event bus. +//! +//! Everything between the two seams is the production code path: event +//! normalization, the dedupe window, per-source rate limiting, the promotion +//! gate budget, the priority queue, the serial loop, and the anti-self-trigger +//! guard. The harness re-injects emitted follow-on events through the same +//! `ingest` entry point the bus subscriber uses, so cascades are exercised +//! exactly as in production. +//! +//! Run with a visible transcript: +//! `cargo test --test subconscious_conversation_e2e -- --nocapture` + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::time::Duration; + +use async_trait::async_trait; + +use openhuman_core::core::event_bus::{global, init_global, DomainEvent}; +use openhuman_core::openhuman::subconscious_triggers::types::{ + GateDecision, Trigger, TriggerPriority, TriggerSource, +}; +use openhuman_core::openhuman::subconscious_triggers::{ + Gate, OrchestratorConfig, SessionExecutor, TriggerOrchestrator, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared conversation transcript — the record of "what happened". +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct Transcript(Arc>>); + +impl Transcript { + fn new() -> Self { + Self(Arc::new(StdMutex::new(Vec::new()))) + } + fn push(&self, line: impl Into) { + self.0.lock().unwrap().push(line.into()); + } + fn lines(&self) -> Vec { + self.0.lock().unwrap().clone() + } + fn count(&self, needle: &str) -> usize { + self.0 + .lock() + .unwrap() + .iter() + .filter(|l| l.contains(needle)) + .count() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scripted gate — promote/drop driven by trigger source + content rules. +// ───────────────────────────────────────────────────────────────────────────── + +struct ScriptedGate { + transcript: Transcript, +} + +#[async_trait] +impl Gate for ScriptedGate { + async fn evaluate(&self, trigger: &Trigger, _now: f64) -> GateDecision { + let summary = &trigger.payload.gate_summary; + // Routine cron noise is dropped. + let drop = + matches!(trigger.source, TriggerSource::Cron { .. }) || summary.contains("[ignore]"); + if drop { + self.transcript + .push(format!("GATE drop {}", trigger.display_label)); + return GateDecision::Drop { + acknowledge: false, + reason: "routine/no-op".into(), + }; + } + // Everything else promotes. Urgent if the human flagged it. + let priority = if summary.to_lowercase().contains("urgent") { + TriggerPriority::Urgent + } else { + trigger.priority + }; + self.transcript.push(format!( + "GATE promote {} (prio={})", + trigger.display_label, + priority.as_str() + )); + GateDecision::Promote { + // Keep the original summary so the session can branch on it. + synthesized_summary: summary.clone(), + priority, + reason: "actionable".into(), + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scripted session — models the subconscious agent's behaviour, including +// spawning sub-agents (via emitted follow-on events) and notifying the human. +// ───────────────────────────────────────────────────────────────────────────── + +/// A follow-on event the scripted session wants to feed back into the +/// orchestrator (simulating the bus fan-in for sub-agent conclusions). +type Emitter = Arc>>; + +struct ScriptedSession { + transcript: Transcript, + workspace: std::path::PathBuf, + emit: Emitter, + /// Monotonic id for spawned sub-agent tasks. + next_task: Arc, +} + +impl ScriptedSession { + fn emit(&self, event: DomainEvent) { + self.emit.lock().unwrap().push_back(event); + } + fn spawn_subagent(&self, agent_id: &str, ok: bool) -> String { + let n = self + .next_task + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let task_id = format!("task-{n}"); + self.transcript + .push(format!("SESSION spawn sub-agent '{agent_id}' ({task_id})")); + let event = if ok { + DomainEvent::SubagentCompleted { + parent_session: "subconscious:orchestrator".into(), + task_id: task_id.clone(), + agent_id: agent_id.into(), + elapsed_ms: 5, + output_chars: 120, + iterations: 2, + } + } else { + DomainEvent::SubagentFailed { + parent_session: "subconscious:orchestrator".into(), + task_id: task_id.clone(), + agent_id: agent_id.into(), + error: "tool timeout".into(), + } + }; + self.emit(event); + task_id + } +} + +#[async_trait] +impl SessionExecutor for ScriptedSession { + async fn execute(&self, summary: &str, _external: bool) -> Result { + let s = summary.to_lowercase(); + + // Branch on what the promoted turn is about — modelling the + // orchestrator agent's decisions. ORDER MATTERS: sub-agent conclusion + // summaries mention the agent id ("researcher" ⊃ "research"), so the + // conclusion branches must be checked before the delegate branch. + if s.contains("failed") { + // A sub-agent failed → recover, escalate a retry sub-agent. + self.transcript + .push("SESSION handle sub-agent FAILURE → retry"); + self.spawn_subagent("researcher", true); + Ok("Retrying after failure.".into()) + } else if s.contains("completed in") || s.contains("chars of output") { + // A sub-agent conclusion came back → merge + tell the human. + self.transcript + .push("SESSION merge sub-agent conclusion → notifying human"); + openhuman_core::openhuman::subconscious::notify_user( + self.workspace.clone(), + "Your research is ready — here's the summary.", + Some("research done"), + ); + self.transcript.push("SESSION notify human (done)"); + Ok("Merged conclusion; user notified.".into()) + } else if s.contains("research") || s.contains("prep") || s.contains("deck") { + // Human asked for deep work → delegate to a sub-agent. + self.transcript + .push(format!("SESSION run (delegating) :: {summary}")); + self.spawn_subagent("researcher", true); + Ok("Delegated to researcher.".into()) + } else if s.contains("status") || s.contains("what") { + // Simple Q → answer the human directly, no sub-agent. + self.transcript + .push(format!("SESSION reply directly :: {summary}")); + openhuman_core::openhuman::subconscious::notify_user( + self.workspace.clone(), + "Here's your status.", + None, + ); + Ok("Answered directly.".into()) + } else { + self.transcript.push(format!("SESSION note :: {summary}")); + Ok("Noted.".into()) + } + } + + fn thread_id(&self) -> &str { + "subconscious:orchestrator" + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Harness. +// ───────────────────────────────────────────────────────────────────────────── + +/// Serializes tests that touch the process-global event bus. +fn bus_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| StdMutex::new(())) + .lock() + .unwrap_or_else(|p| p.into_inner()) +} + +struct Harness { + orch: Arc, + transcript: Transcript, + emit: Emitter, + notifications: Arc>>, + _loop: tokio::task::JoinHandle<()>, + _sub: openhuman_core::core::event_bus::SubscriptionHandle, + _tmp: tempfile::TempDir, +} + +impl Harness { + fn new(config: OrchestratorConfig) -> Self { + init_global(128); + let transcript = Transcript::new(); + let emit: Emitter = Arc::new(StdMutex::new(VecDeque::new())); + let tmp = tempfile::tempdir().expect("tempdir"); + + let session = Arc::new(ScriptedSession { + transcript: transcript.clone(), + workspace: tmp.path().to_path_buf(), + emit: Arc::clone(&emit), + next_task: Arc::new(std::sync::atomic::AtomicU64::new(1)), + }); + let gate = Arc::new(ScriptedGate { + transcript: transcript.clone(), + }); + let orch = Arc::new(TriggerOrchestrator::with_components(session, gate, config)); + + // Capture proactive (subconscious → human) deliveries off the bus. + let notifications = Arc::new(StdMutex::new(Vec::::new())); + let sink = Arc::clone(¬ifications); + let sub = global().expect("bus").on("conv-e2e-notify", move |event| { + let sink = Arc::clone(&sink); + let event = event.clone(); + Box::pin(async move { + if let DomainEvent::ProactiveMessageRequested { + source, message, .. + } = &event + { + if source == "subconscious" { + sink.lock().unwrap().push(message.clone()); + } + } + }) + }); + + let loop_handle = Arc::clone(&orch); + let task = tokio::spawn(async move { loop_handle.run_loop().await }); + + Self { + orch, + transcript, + emit, + notifications, + _loop: task, + _sub: sub, + _tmp: tmp, + } + } + + /// Feed an inbound event into the orchestrator (as the bus subscriber would). + fn ingest(&self, event: DomainEvent) { + self.orch.ingest(&event); + } + + /// Pump the cascade until quiescent: repeatedly drain follow-on events the + /// scripted session emitted back into `ingest`, waiting for the queue + + /// gate tasks to settle between rounds. Bounded so a runaway loop fails + /// fast instead of hanging. + async fn settle(&self) { + for _ in 0..200 { + // Let in-flight gate tasks + the run loop make progress. + tokio::time::sleep(Duration::from_millis(10)).await; + let pending: Vec = { + let mut q = self.emit.lock().unwrap(); + q.drain(..).collect() + }; + for ev in &pending { + self.orch.ingest(ev); + } + // Quiescent when nothing is queued and nothing new was emitted. + if pending.is_empty() && self.orch.queue_depth() == 0 { + // One more grace round to catch a just-finished session run + // that emitted a trailing event. + tokio::time::sleep(Duration::from_millis(15)).await; + if self.emit.lock().unwrap().is_empty() && self.orch.queue_depth() == 0 { + return; + } + } + } + panic!("conversation did not settle — possible runaway cascade"); + } + + fn notifications(&self) -> Vec { + self.notifications.lock().unwrap().clone() + } + + fn print(&self, title: &str) { + println!("\n=== {title} ==="); + for line in self.transcript.lines() { + println!(" {line}"); + } + for n in self.notifications() { + println!(" >> to human: {n}"); + } + } +} + +fn human_msg(channel: &str, sender: &str, message: &str) -> DomainEvent { + DomainEvent::ChannelInboundMessage { + event_name: "msg".into(), + channel: channel.into(), + message: message.into(), + sender: Some(sender.into()), + reply_target: Some("dm".into()), + thread_ts: Some(format!("t-{}", message.len())), + raw_data: serde_json::Value::Null, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 1 — full round trip: human → session → sub-agent → session → human. +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn conversation_human_delegates_then_subagent_reports_back() { + let _g = bus_lock(); + let h = Harness::new(OrchestratorConfig::default()); + + // Human asks for deep work. + h.ingest(human_msg( + "slack", + "U1", + "can you prep the Q3 research deck?", + )); + h.settle().await; + h.print("scenario 1: delegate → sub-agent → report back"); + + // The session delegated (spawned a sub-agent) … + assert_eq!( + h.transcript.count("spawn sub-agent"), + 1, + "one sub-agent spawned" + ); + // … the conclusion came back through the real pipeline and was merged … + assert_eq!(h.transcript.count("merge sub-agent conclusion"), 1); + // … and the human was notified exactly once. + let notes = h.notifications(); + assert_eq!(notes.len(), 1, "exactly one human notification"); + assert!(notes[0].contains("research is ready")); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 2 — sub-agent failure → recovery → retry → success → human. +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn conversation_subagent_failure_recovers_with_retry() { + let _g = bus_lock(); + let h = Harness::new(OrchestratorConfig::default()); + + // Inject a sub-agent FAILURE conclusion directly (as if a prior spawn failed). + h.ingest(DomainEvent::SubagentFailed { + parent_session: "subconscious:orchestrator".into(), + task_id: "task-0".into(), + agent_id: "researcher".into(), + error: "tool timeout".into(), + }); + h.settle().await; + h.print("scenario 2: failure → retry → success → human"); + + // Failure handled, a retry sub-agent spawned, its success merged, human told. + assert_eq!(h.transcript.count("handle sub-agent FAILURE"), 1); + assert_eq!(h.transcript.count("spawn sub-agent"), 1, "one retry spawn"); + assert_eq!(h.transcript.count("merge sub-agent conclusion"), 1); + assert_eq!(h.notifications().len(), 1); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 3 — interleaved traffic: cron noise dropped, two humans, dedupe. +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn conversation_interleaved_traffic_is_handled() { + let _g = bus_lock(); + let h = Harness::new(OrchestratorConfig::default()); + + // Routine cron tick — gate should drop it (no session run). + h.ingest(DomainEvent::CronJobTriggered { + job_id: "nightly".into(), + job_name: "nightly recap".into(), + job_type: "agent".into(), + }); + // Human A asks a simple question → direct reply, no sub-agent. + h.ingest(human_msg("slack", "U1", "what's my status today?")); + // Human B duplicate-sends the exact same message twice (transport retry). + let dup = human_msg("discord", "U2", "please prep the deck"); + h.ingest(dup.clone()); + h.ingest(dup); + h.settle().await; + h.print("scenario 3: interleaved cron + two humans + dedupe"); + + // Cron was dropped (no session run for it). + assert_eq!(h.transcript.count("GATE drop"), 1); + // Human A answered directly (no sub-agent for a status question). + assert_eq!(h.transcript.count("reply directly"), 1); + // Human B's duplicate collapsed → only ONE deck delegation despite two sends. + assert_eq!( + h.transcript.count("(delegating)"), + 1, + "duplicate human msg collapsed" + ); + // Two human-facing notifications: status answer + research-ready. + assert_eq!(h.notifications().len(), 2); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 4 — back-pressure: promotion budget caps a burst of human asks. +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn conversation_promotion_budget_caps_a_burst() { + let _g = bus_lock(); + // The scripted gate intentionally has no promotion budget (that lives in + // the real GatePass), so this scenario isolates the *rate limiter*: a + // flood of distinct user messages from one source is capped by the token + // bucket (capacity 3, no refill). + let config = OrchestratorConfig { + rate_capacity: 3.0, + rate_refill_per_sec: 0.0, + ..OrchestratorConfig::default() + }; + let h = Harness::new(config); + + for i in 0..6 { + h.ingest(human_msg("slack", "U1", &format!("status check #{i}"))); + } + h.settle().await; + h.print("scenario 4: rate-limited burst of human messages"); + + // Only 3 of the 6 distinct messages passed the rate limiter → 3 replies. + assert_eq!( + h.transcript.count("reply directly"), + 3, + "rate limiter capped the burst" + ); + assert_eq!(h.notifications().len(), 3); +} diff --git a/tests/subconscious_fullstack_e2e.rs b/tests/subconscious_fullstack_e2e.rs new file mode 100644 index 000000000..0f9fcd892 --- /dev/null +++ b/tests/subconscious_fullstack_e2e.rs @@ -0,0 +1,431 @@ +//! Fully hermetic FULL-STACK e2e: the REAL gate + REAL long-lived session +//! run against a **mocked LLM provider** — no network, no Ollama, no real +//! model anywhere (cloud and local provider funnels are both overridden). +//! +//! Unlike `subconscious_conversation_e2e.rs` (which injects scripted Gate / +//! SessionExecutor *above* the model), this test mocks at the *provider* +//! layer, so the production code paths run for real: +//! - `GatePass::evaluate` → `agent::triage::run_triage` → real provider +//! funnel → mock → real triage parse → real promote/drop mapping. +//! - `LongLivedSession::process_promoted` → `Agent::from_config` (the real +//! orchestrator agent + tool loop) → mock → real reserved-thread persistence. +//! +//! The mock is installed via the factory's `test_provider_override` seam, +//! which both provider funnels consult first. +//! +//! Gated on the off-by-default `e2e-test-support` feature (the seam is only +//! compiled then). Run with: +//! `cargo test --features e2e-test-support --test subconscious_fullstack_e2e -- --nocapture` +#![cfg(feature = "e2e-test-support")] + +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; + +use async_trait::async_trait; + +use openhuman_core::core::event_bus::{init_global, DomainEvent}; +use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry; +use openhuman_core::openhuman::config::schema::SubconsciousMode; +use openhuman_core::openhuman::inference::provider::factory::test_provider_override; +use openhuman_core::openhuman::inference::provider::traits::{ + ChatRequest, ChatResponse, ProviderCapabilities, ToolCall, +}; +use openhuman_core::openhuman::inference::provider::Provider; +use openhuman_core::openhuman::subconscious::LongLivedSession; +use openhuman_core::openhuman::subconscious_triggers::{normalize, GatePass}; + +// ───────────────────────────────────────────────────────────────────────────── +// Mock LLM provider — deterministic, content-routed, no network. +// ───────────────────────────────────────────────────────────────────────────── + +/// Records every prompt the mock saw, and answers deterministically: +/// - triage turns (recognised by the triage envelope markers) → a JSON +/// decision (drop for cron, escalate otherwise); +/// - any other turn (the session/orchestrator agent) → a plain final reply. +struct MockLlm { + seen: StdMutex>, + /// When set, the orchestrator turn emits a real `spawn_subagent` tool + /// call so the harness runs an actual sub-agent (native tool mode). + spawn: bool, +} + +const SUBAGENT_MARKER: &str = "SUBAGENT_TASK"; +const RESEARCHER_FINDINGS: &str = "Researcher findings: Q3 numbers look healthy."; + +impl MockLlm { + fn new() -> Arc { + Arc::new(Self { + seen: StdMutex::new(Vec::new()), + spawn: false, + }) + } + fn with_spawning() -> Arc { + Arc::new(Self { + seen: StdMutex::new(Vec::new()), + spawn: true, + }) + } + + fn triage_decision(&self, joined: &str) -> String { + let drop = joined.contains("\"source\": \"cron\"") + || joined.contains("\"source\":\"cron\"") + || joined.to_lowercase().contains("ignore me"); + if drop { + "{\"action\":\"drop\",\"reason\":\"mock: routine noise\"}".to_string() + } else { + "{\"action\":\"escalate\",\"target_agent\":\"orchestrator\",\ + \"prompt\":\"handle this\",\"reason\":\"mock: actionable\"}" + .to_string() + } + } + + fn respond(&self, joined: &str) -> ChatResponse { + self.seen.lock().unwrap().push(joined.to_string()); + let is_triage = joined.contains("DISPLAY_LABEL:") && joined.contains("PAYLOAD:"); + + let text = if is_triage { + self.triage_decision(joined) + } else if joined.contains(RESEARCHER_FINDINGS) { + // Orchestrator's follow-up turn after the sub-agent returned → + // merge + finish. Checked BEFORE the marker because turn-2's + // history echoes the tool-call arguments (which carry the marker). + "Mock orchestrator merged the sub-agent findings.".to_string() + } else if joined.contains(SUBAGENT_MARKER) { + // The spawned researcher sub-agent's own turn → return findings, + // no further tool calls (prevents recursive spawning). + RESEARCHER_FINDINGS.to_string() + } else if self.spawn { + // Orchestrator's first turn → delegate via a real spawn_subagent + // tool call. + return ChatResponse { + text: Some("Delegating to the researcher.".to_string()), + tool_calls: vec![ToolCall { + id: "call-1".to_string(), + name: "spawn_subagent".to_string(), + arguments: serde_json::json!({ + "agent_id": "researcher", + "prompt": format!("{SUBAGENT_MARKER}: investigate the Q3 numbers"), + }) + .to_string(), + extra_content: None, + }], + usage: None, + reasoning_content: None, + }; + } else { + "Mock orchestrator handled the promoted trigger.".to_string() + }; + + ChatResponse { + text: Some(text), + tool_calls: Vec::new(), + usage: None, + reasoning_content: None, + } + } + + fn triage_turns(&self) -> usize { + self.seen + .lock() + .unwrap() + .iter() + .filter(|p| p.contains("DISPLAY_LABEL:") && p.contains("PAYLOAD:")) + .count() + } + + fn saw(&self, needle: &str) -> bool { + self.seen.lock().unwrap().iter().any(|p| p.contains(needle)) + } +} + +#[async_trait] +impl Provider for MockLlm { + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + // Native tools only when we want to emit a spawn tool call. + native_tool_calling: self.spawn, + vision: false, + } + } + + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + let joined = format!("{}\n{message}", system_prompt.unwrap_or("")); + Ok(self.respond(&joined).text.unwrap_or_default()) + } + + async fn chat( + &self, + request: ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + let joined = request + .messages + .iter() + .map(|m| format!("{}: {}", m.role, m.content)) + .collect::>() + .join("\n"); + Ok(self.respond(&joined)) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Hermetic harness: temp HOME + config + globals + installed mock provider. +// ───────────────────────────────────────────────────────────────────────────── + +/// Serializes these tests: they mutate process-global env + install a +/// process-global provider override. +fn serial() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| StdMutex::new(())) + .lock() + .unwrap_or_else(|p| p.into_inner()) +} + +struct EnvGuard { + key: &'static str, + old: Option, +} +impl EnvGuard { + fn set(key: &'static str, val: &str) -> Self { + let old = std::env::var(key).ok(); + std::env::set_var(key, val); + Self { key, old } + } + fn unset(key: &'static str) -> Self { + let old = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, old } + } +} +impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.old { + Some(v) => std::env::set_var(self.key, v), + None => std::env::remove_var(self.key), + } + } +} + +struct Harness { + workspace: std::path::PathBuf, + mock: Arc, + _install: test_provider_override::InstallGuard, + _home: EnvGuard, + _ws: EnvGuard, + _keyring: EnvGuard, + _tmp: tempfile::TempDir, +} + +fn write_config(openhuman_dir: &std::path::Path) { + // api_url is never dialed — the provider override intercepts creation + // before any URL is used — but config must parse and disable local AI so + // nothing reaches Ollama either. + let cfg = r#"api_url = "http://127.0.0.1:9" +default_model = "mock-model" +default_temperature = 0.2 +chat_onboarding_completed = true + +[secrets] +encrypt = false + +[local_ai] +enabled = false +runtime_enabled = false +"#; + let write = |dir: &std::path::Path| { + std::fs::create_dir_all(dir).expect("mkdir"); + std::fs::write(dir.join("config.toml"), cfg).expect("write config"); + }; + write(openhuman_dir); + write(&openhuman_dir.join("users").join("local")); + // Sanity: the config must match the schema. + let _: openhuman_core::openhuman::config::Config = toml::from_str(cfg).expect("config schema"); +} + +fn harness() -> Harness { + harness_with(MockLlm::new()) +} + +fn harness_spawning() -> Harness { + harness_with(MockLlm::with_spawning()) +} + +fn harness_with(mock: Arc) -> Harness { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = tmp.path().to_path_buf(); + let openhuman_dir = home.join(".openhuman"); + write_config(&openhuman_dir); + let workspace = home.join("workspace"); + std::fs::create_dir_all(&workspace).expect("mkdir ws"); + + let home_guard = EnvGuard::set("HOME", home.to_str().unwrap()); + let ws_guard = EnvGuard::set("OPENHUMAN_WORKSPACE", workspace.to_str().unwrap()); + let keyring_guard = EnvGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"); + + // Globals the real pipeline needs. + init_global(64); + openhuman_core::openhuman::agent::bus::register_agent_handlers(); + let _ = AgentDefinitionRegistry::init_global_builtins(); + + // Install the mock LLM — both provider funnels consult this first. + let install = test_provider_override::install(mock.clone()); + + Harness { + workspace, + mock, + _install: install, + _home: home_guard, + _ws: ws_guard, + _keyring: keyring_guard, + _tmp: tmp, + } +} + +fn human_event(message: &str) -> DomainEvent { + DomainEvent::ChannelInboundMessage { + event_name: "msg".into(), + channel: "slack".into(), + message: message.into(), + sender: Some("U1".into()), + reply_target: Some("dm".into()), + thread_ts: Some("t1".into()), + raw_data: serde_json::Value::Null, + } +} + +fn cron_event() -> DomainEvent { + DomainEvent::CronJobTriggered { + job_id: "nightly".into(), + job_name: "nightly recap".into(), + job_type: "agent".into(), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Gate (real triage pipeline) over the mock — promote vs drop. +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn fullstack_gate_promotes_human_message_via_real_triage() { + let _s = serial(); + let h = harness(); + let gate = GatePass::new(100); + + let trigger = normalize(&human_event("can you help with the Q3 plan?"), 1.0).unwrap(); + let decision = gate.evaluate(&trigger, 1.0).await; + + println!("\n[fullstack] human trigger gate decision: {decision:?}"); + assert!( + decision.is_promote(), + "real triage (mock-backed) should promote an actionable human message; got {decision:?}" + ); + assert!(h.mock.triage_turns() >= 1, "the real triage LLM path ran"); +} + +#[tokio::test] +async fn fullstack_gate_drops_cron_noise_via_real_triage() { + let _s = serial(); + let h = harness(); + let gate = GatePass::new(100); + + let trigger = normalize(&cron_event(), 1.0).unwrap(); + let decision = gate.evaluate(&trigger, 1.0).await; + + println!("[fullstack] cron trigger gate decision: {decision:?}"); + assert!( + !decision.is_promote(), + "routine cron noise should be dropped" + ); + assert!(h.mock.triage_turns() >= 1); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Session (real orchestrator agent + tool loop) over the mock. +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn fullstack_session_runs_real_agent_and_persists() { + let _s = serial(); + let h = harness(); + + let session = LongLivedSession::with_thread( + h.workspace.clone(), + SubconsciousMode::Aggressive, + "subconscious:orchestrator".into(), + ); + + let outcome = session + .process_promoted("[user/slack] Please look into the Q3 numbers.", false) + .await; + + println!("[fullstack] session outcome: {outcome:?}"); + let outcome = outcome.expect("real agent turn (mock-backed) should succeed"); + assert!( + outcome.response.contains("Mock orchestrator"), + "session returned the mock agent's reply: {}", + outcome.response + ); + + // Real reserved-thread persistence: the user turn + agent reply landed. + let msgs = openhuman_core::openhuman::memory_conversations::get_messages( + h.workspace.clone(), + "subconscious:orchestrator", + ) + .expect("read reserved thread"); + let senders: Vec<&str> = msgs.iter().map(|m| m.sender.as_str()).collect(); + assert!( + senders.contains(&"user"), + "user turn persisted: {senders:?}" + ); + assert!( + senders.contains(&"agent"), + "agent reply persisted: {senders:?}" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Full chain: human → subconscious session → REAL sub-agent → back → human. +// The mock makes the orchestrator emit a real `spawn_subagent` tool call, so +// the harness runs an actual researcher sub-agent (inheriting the mock +// provider), whose output is merged by the orchestrator's follow-up turn. +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn fullstack_session_spawns_real_subagent_and_merges() { + let _s = serial(); + let h = harness_spawning(); + + let session = LongLivedSession::with_thread( + h.workspace.clone(), + SubconsciousMode::Aggressive, + "subconscious:orchestrator".into(), + ); + + let outcome = session + .process_promoted("[user/slack] Please research the Q3 numbers.", false) + .await; + + println!("[fullstack] spawn outcome: {outcome:?}"); + let outcome = outcome.expect("orchestrator turn with a real sub-agent should succeed"); + + // The orchestrator delegated and a REAL researcher sub-agent ran (its turn + // carried our SUBAGENT_TASK marker), and its findings flowed back into the + // session result — the full human → subconscious → sub-agent → back chain, + // all production code, mock-backed model. + assert!( + h.mock.saw(SUBAGENT_MARKER), + "the real harness ran the spawned researcher sub-agent" + ); + assert!( + outcome.response.contains("Researcher findings") + || outcome.response.contains("merged the sub-agent findings"), + "the sub-agent's output flowed back to the session: {}", + outcome.response + ); +} diff --git a/tests/subconscious_triggers_e2e.rs b/tests/subconscious_triggers_e2e.rs new file mode 100644 index 000000000..5e1210eee --- /dev/null +++ b/tests/subconscious_triggers_e2e.rs @@ -0,0 +1,592 @@ +//! End-to-end scenario simulation for the subconscious **trigger pipeline**. +//! +//! This exercises the real public pipeline functions wired together — +//! `normalize` → `TriggerRegistry::admit` (dedupe + rate) → gate mapping +//! (`map_triage_to_gate` + `apply_budget`) → `OrchestratorQueue` — across +//! every trigger source and gate outcome, plus the real `notify_user` +//! handoff (event bus + reserved-thread persistence) and the reserved-thread +//! cold-boot contract the long-lived session depends on. +//! +//! It is hermetic (no network, no model) and deterministic: the LLM gate is +//! simulated by feeding the real `map_triage_to_gate` a `TriageDecision` for +//! each `TriageAction`, so we test *our* promote/drop/dedupe/budget/queue +//! logic exhaustively. The actual triage model call and the full agent turn +//! run through the native bus + global provider and are covered by the +//! `agent::triage` and agent-harness test suites. +//! +//! Run with a visible trace: +//! `cargo test --test subconscious_triggers_e2e -- --nocapture` + +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; + +use openhuman_core::core::event_bus::{global, init_global, DomainEvent}; +use openhuman_core::openhuman::agent::triage::{TriageAction, TriageDecision}; +use openhuman_core::openhuman::subconscious::{ + notify_user, ORCHESTRATOR_THREAD_ID, USER_THREAD_ID, +}; +use openhuman_core::openhuman::subconscious_triggers::gate::{apply_budget, map_triage_to_gate}; +use openhuman_core::openhuman::subconscious_triggers::{ + normalize, AdmitOutcome, DedupeWindow, EnqueueOutcome, GateDecision, OrchestratorQueue, + PromotionBudget, RateLimiter, Trigger, TriggerPriority, TriggerRegistry, TriggerSource, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Event constructors for the four v1 trigger sources. +// ───────────────────────────────────────────────────────────────────────────── + +fn cron_event(job_id: &str, name: &str) -> DomainEvent { + DomainEvent::CronJobTriggered { + job_id: job_id.into(), + job_name: name.into(), + job_type: "agent".into(), + } +} + +fn user_event(channel: &str, sender: Option<&str>, message: &str) -> DomainEvent { + DomainEvent::ChannelInboundMessage { + event_name: "msg".into(), + channel: channel.into(), + message: message.into(), + sender: sender.map(str::to_string), + reply_target: Some("dm".into()), + thread_ts: Some("t1".into()), + raw_data: serde_json::Value::Null, + } +} + +fn composio_event(metadata_id: &str, subject: &str) -> DomainEvent { + DomainEvent::ComposioTriggerReceived { + toolkit: "gmail".into(), + trigger: "GMAIL_NEW_GMAIL_MESSAGE".into(), + metadata_id: metadata_id.into(), + metadata_uuid: format!("{metadata_id}-uuid"), + payload: serde_json::json!({ "subject": subject, "body": "PRIVATE BODY" }), + } +} + +fn subagent_done_event(task_id: &str, agent_id: &str) -> DomainEvent { + DomainEvent::SubagentCompleted { + parent_session: "subconscious:orchestrator".into(), + task_id: task_id.into(), + agent_id: agent_id.into(), + elapsed_ms: 10, + output_chars: 100, + iterations: 2, + } +} + +fn triage(action: TriageAction, prompt: Option<&str>) -> TriageDecision { + TriageDecision { + action, + target_agent: prompt.map(|_| "orchestrator".into()), + prompt: prompt.map(str::to_string), + reason: "simulated gate verdict".into(), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section 1 — normalization: every source maps with the right shape. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn scenario_normalization_covers_all_sources() { + let now = 100.0; + + let cron = normalize(&cron_event("j1", "morning brief"), now).expect("cron normalizes"); + assert_eq!(cron.priority, TriggerPriority::Low); + assert!(!cron.external_content); + assert!(matches!(cron.source, TriggerSource::Cron { .. })); + + let user = normalize(&user_event("slack", Some("U1"), "what's on my plate?"), now) + .expect("user normalizes"); + assert_eq!(user.priority, TriggerPriority::High); + assert!( + user.external_content, + "inbound channel messages are untrusted" + ); + assert!(user.payload.gate_summary.contains("what's on my plate")); + + let composio = + normalize(&composio_event("evt-1", "Invoice #42"), now).expect("composio normalizes"); + assert_eq!(composio.priority, TriggerPriority::Normal); + assert!(composio.external_content, "third-party content is tainted"); + // Redaction: the private body never reaches the gate summary. + assert!(!composio.payload.gate_summary.contains("PRIVATE BODY")); + // …but is retained in raw for promotion synthesis. + assert_eq!(composio.payload.raw["payload"]["body"], "PRIVATE BODY"); + + let subagent = + normalize(&subagent_done_event("task-1", "researcher"), now).expect("subagent normalizes"); + assert!(matches!( + subagent.source, + TriggerSource::SubagentConclusion { ok: true, .. } + )); + + // A self-authored proactive message must NOT become a trigger (anti-loop). + assert!( + normalize(&user_event("slack", Some("subconscious"), "proactive"), now).is_none(), + "orchestrator's own output must not re-trigger it" + ); + + // Unrelated events are ignored. + assert!(normalize( + &DomainEvent::ChannelConnected { + channel: "slack".into() + }, + now + ) + .is_none()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section 2 — admission: dedupe collapses storms, rate limits floods. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn scenario_dedupe_collapses_webhook_storm() { + let reg = TriggerRegistry::with_defaults(); + // 50 identical Gmail webhooks (same metadata_id) within the TTL window. + let mut admitted = 0; + let mut duplicates = 0; + for i in 0..50 { + let ev = composio_event("same-evt", "dup"); + let t = normalize(&ev, 1000.0 + i as f64 * 0.01).expect("normalize"); + match reg.admit(&t, 1000.0 + i as f64 * 0.01) { + AdmitOutcome::Admitted => admitted += 1, + AdmitOutcome::Duplicate => duplicates += 1, + AdmitOutcome::RateLimited => {} + } + } + assert_eq!(admitted, 1, "only the first of the storm is admitted"); + assert_eq!(duplicates, 49); +} + +#[test] +fn scenario_rate_limit_caps_distinct_events_per_source() { + // Tiny bucket: capacity 2, no refill, so the 3rd distinct event of the + // same family is rate-limited even though it passes dedupe. + let reg = TriggerRegistry::new(DedupeWindow::new(600.0), RateLimiter::new(2.0, 0.0)); + let outcomes: Vec = (0..3) + .map(|i| { + let ev = composio_event(&format!("evt-{i}"), "x"); + let t = normalize(&ev, 5.0).expect("normalize"); + reg.admit(&t, 5.0) + }) + .collect(); + assert_eq!(outcomes[0], AdmitOutcome::Admitted); + assert_eq!(outcomes[1], AdmitOutcome::Admitted); + assert_eq!(outcomes[2], AdmitOutcome::RateLimited); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section 3 — gate decisions: every triage action maps correctly. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn scenario_gate_maps_every_triage_action() { + let now = 0.0; + let budget = PromotionBudget::new(100); + let base = normalize(&composio_event("g1", "subj"), now).expect("normalize"); + + // Drop → dropped, not acknowledged. + let d = apply_budget( + map_triage_to_gate(&triage(TriageAction::Drop, None), &base), + &budget, + now, + ); + assert!(matches!( + d, + GateDecision::Drop { + acknowledge: false, + .. + } + )); + + // Acknowledge → dropped but acknowledged. + let a = apply_budget( + map_triage_to_gate(&triage(TriageAction::Acknowledge, None), &base), + &budget, + now, + ); + assert!(matches!( + a, + GateDecision::Drop { + acknowledge: true, + .. + } + )); + + // React → promote, keeping the trigger's own priority. + let r = map_triage_to_gate(&triage(TriageAction::React, Some("ack it")), &base); + match r { + GateDecision::Promote { + priority, + synthesized_summary, + .. + } => { + assert_eq!(priority, base.priority); + assert!(synthesized_summary.contains("ack it")); + } + other => panic!("expected promote, got {other:?}"), + } + + // Escalate → promote at >= High. + let e = map_triage_to_gate(&triage(TriageAction::Escalate, Some("draft reply")), &base); + match e { + GateDecision::Promote { priority, .. } => assert!(priority >= TriggerPriority::High), + other => panic!("expected promote, got {other:?}"), + } +} + +#[test] +fn scenario_promotion_budget_exhaustion_downgrades_to_ack_drop() { + let now = 0.0; + let budget = PromotionBudget::new(1); // one promotion per hour + let base = normalize(&user_event("slack", Some("U1"), "urgent!"), now).expect("normalize"); + let escalate = || map_triage_to_gate(&triage(TriageAction::Escalate, Some("do it")), &base); + + // First escalation promotes. + assert!(apply_budget(escalate(), &budget, now).is_promote()); + // Second within the hour is downgraded to an acknowledged drop — noted, + // but no reasoning-tier session run is spent. + match apply_budget(escalate(), &budget, now) { + GateDecision::Drop { + acknowledge, + reason, + } => { + assert!(acknowledge); + assert!(reason.contains("budget exhausted")); + } + other => panic!("expected budget downgrade, got {other:?}"), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section 4 — queue: priority ordering + overflow eviction. +// ───────────────────────────────────────────────────────────────────────────── + +fn promoted(label: &str, priority: TriggerPriority) -> Trigger { + let mut t = normalize(&cron_event("j", label), 0.0).expect("normalize"); + t.display_label = label.into(); + t.priority = priority; + t +} + +#[test] +fn scenario_queue_serves_highest_priority_first() { + let q = OrchestratorQueue::new(16); + q.push(promoted("cron-low", TriggerPriority::Low)); + q.push(promoted("user-high", TriggerPriority::High)); + q.push(promoted("webhook-normal", TriggerPriority::Normal)); + q.push(promoted("interrupt-urgent", TriggerPriority::Urgent)); + + let drained: Vec = std::iter::from_fn(|| q.pop()) + .map(|t| t.display_label) + .collect(); + assert_eq!( + drained, + vec![ + "interrupt-urgent", + "user-high", + "webhook-normal", + "cron-low" + ] + ); +} + +#[test] +fn scenario_queue_overflow_sheds_lowest_priority() { + let q = OrchestratorQueue::new(2); + assert_eq!( + q.push(promoted("low", TriggerPriority::Low)), + EnqueueOutcome::Accepted + ); + assert_eq!( + q.push(promoted("normal", TriggerPriority::Normal)), + EnqueueOutcome::Accepted + ); + // Full; an Urgent arrival evicts the lowest-priority held item. + match q.push(promoted("urgent", TriggerPriority::Urgent)) { + EnqueueOutcome::EvictedLowest { evicted } => assert_eq!(evicted.display_label, "low"), + other => panic!("expected eviction, got {other:?}"), + } + // Full again; a Low arrival is dropped rather than evicting a better item. + assert_eq!( + q.push(promoted("late-low", TriggerPriority::Low)), + EnqueueOutcome::DroppedIncoming + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section 5 — full pipeline simulation across scenarios, with a printed trace. +// ───────────────────────────────────────────────────────────────────────────── + +/// One scenario: a raw event + the gate verdict the (simulated) LLM returns. +struct Scenario { + name: &'static str, + event: DomainEvent, + action: TriageAction, + prompt: Option<&'static str>, +} + +/// Outcome of running a scenario through the whole pipeline. +#[derive(Debug)] +enum Outcome { + Ignored, + Duplicate, + RateLimited, + Dropped, + Promoted { priority: TriggerPriority }, +} + +/// Drive one event through normalize → admit → gate → enqueue, returning what +/// happened and (on promotion) pushing onto `queue`. +fn run_scenario( + s: &Scenario, + reg: &TriggerRegistry, + budget: &PromotionBudget, + queue: &OrchestratorQueue, + now: f64, +) -> Outcome { + let Some(trigger) = normalize(&s.event, now) else { + return Outcome::Ignored; + }; + match reg.admit(&trigger, now) { + AdmitOutcome::Duplicate => return Outcome::Duplicate, + AdmitOutcome::RateLimited => return Outcome::RateLimited, + AdmitOutcome::Admitted => {} + } + let decision = apply_budget( + map_triage_to_gate(&triage(s.action, s.prompt), &trigger), + budget, + now, + ); + match decision { + GateDecision::Drop { .. } => Outcome::Dropped, + GateDecision::Promote { + priority, + synthesized_summary, + .. + } => { + let mut item = trigger; + item.priority = priority; + item.payload.gate_summary = synthesized_summary; + queue.push(item); + Outcome::Promoted { priority } + } + } +} + +#[test] +fn scenario_full_pipeline_simulation_with_trace() { + let reg = TriggerRegistry::with_defaults(); + let budget = PromotionBudget::new(2); // tight budget to exercise exhaustion + let queue = OrchestratorQueue::new(64); + + let scenarios = vec![ + Scenario { + name: "cron tick — gate drops routine noise", + event: cron_event("nightly", "nightly recap"), + action: TriageAction::Drop, + prompt: None, + }, + Scenario { + name: "user message — gate escalates (promote #1)", + event: user_event("slack", Some("U1"), "can you prep the Q3 deck?"), + action: TriageAction::Escalate, + prompt: Some("prepare the Q3 deck"), + }, + Scenario { + name: "composio gmail — gate reacts (promote #2)", + event: composio_event("inv-1", "Invoice overdue"), + action: TriageAction::React, + prompt: Some("flag the overdue invoice"), + }, + Scenario { + name: "composio gmail DUPLICATE — collapsed by dedupe", + event: composio_event("inv-1", "Invoice overdue"), + action: TriageAction::React, + prompt: Some("flag the overdue invoice"), + }, + Scenario { + name: "subagent conclusion — would promote but budget exhausted", + event: subagent_done_event("task-9", "researcher"), + action: TriageAction::Escalate, + prompt: Some("merge the research findings"), + }, + Scenario { + name: "self-authored proactive echo — ignored (anti-loop)", + event: user_event("slack", Some("subconscious"), "FYI: deck is ready"), + action: TriageAction::Escalate, + prompt: Some("should never run"), + }, + ]; + + println!("\n=== subconscious trigger pipeline — scenario trace ==="); + let mut promotions = 0; + let mut trace = Vec::new(); + for (i, s) in scenarios.iter().enumerate() { + let outcome = run_scenario(s, ®, &budget, &queue, 1000.0 + i as f64); + if matches!(outcome, Outcome::Promoted { .. }) { + promotions += 1; + } + println!(" [{i}] {:<55} -> {outcome:?}", s.name); + trace.push(outcome); + } + println!(" queue depth after gating: {}", queue.len()); + println!("=== drain (highest priority first) ==="); + while let Some(item) = queue.pop() { + println!( + " run -> {:<24} priority={}", + item.display_label, + item.priority.as_str() + ); + } + + // Assertions on what the pipeline decided. + assert!(matches!(trace[0], Outcome::Dropped), "cron noise dropped"); + assert!( + matches!(trace[1], Outcome::Promoted { priority } if priority >= TriggerPriority::High), + "user escalation promoted at >= High" + ); + assert!( + matches!(trace[2], Outcome::Promoted { .. }), + "gmail react promoted" + ); + assert!( + matches!(trace[3], Outcome::Duplicate), + "duplicate gmail collapsed" + ); + assert!( + matches!(trace[4], Outcome::Dropped), + "budget (2) exhausted → 3rd promotion downgraded to drop" + ); + assert!(matches!(trace[5], Outcome::Ignored), "self-echo ignored"); + assert_eq!(promotions, 2, "exactly two promotions within the budget"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section 6 — notify_user: real event bus + reserved user-thread persistence. +// ───────────────────────────────────────────────────────────────────────────── + +/// Serializes tests that touch the process-global event bus so concurrent +/// captures don't cross-talk. +fn bus_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| StdMutex::new(())) + .lock() + .unwrap_or_else(|p| p.into_inner()) +} + +#[tokio::test] +async fn scenario_notify_user_delivers_and_persists() { + let _guard = bus_lock(); + init_global(64); + + let captured: Arc>> = Arc::new(StdMutex::new(Vec::new())); + let sink = Arc::clone(&captured); + let _sub = global() + .expect("bus initialized") + .on("e2e-notify-capture", move |event| { + let sink = Arc::clone(&sink); + let event = event.clone(); + Box::pin(async move { + if let DomainEvent::ProactiveMessageRequested { .. } = &event { + sink.lock().unwrap().push(event); + } + }) + }); + + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().to_path_buf(); + + let unique = "E2E-NOTIFY-MARKER-7321"; + notify_user(workspace.clone(), unique, Some("heads-up")); + + // Give the async broadcast a moment to deliver. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // 1) The proactive delivery event fired, tagged as subconscious-sourced. + let events = captured.lock().unwrap(); + let found = events.iter().any(|e| match e { + DomainEvent::ProactiveMessageRequested { + source, message, .. + } => source == "subconscious" && message == unique, + _ => false, + }); + assert!( + found, + "notify_user must publish a ProactiveMessageRequested event" + ); + + // 2) The message landed in the reserved user-facing thread. + let persisted = + openhuman_core::openhuman::memory_conversations::get_messages(workspace, USER_THREAD_ID) + .expect("read user thread"); + assert!( + persisted + .iter() + .any(|m| m.content == unique && m.sender == "agent"), + "notify_user must persist to the user-facing thread" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section 7 — reserved-thread cold-boot contract (what the session resumes from). +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn scenario_reserved_threads_are_distinct_and_persist() { + use openhuman_core::openhuman::memory_conversations::{ + append_message, ensure_thread, get_messages, ConversationMessage, CreateConversationThread, + }; + + assert_ne!( + ORCHESTRATOR_THREAD_ID, USER_THREAD_ID, + "orchestrator and user threads must be distinct" + ); + + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().to_path_buf(); + + // The reserved thread must exist before appending (the production code + // ensures this lazily; mirror it here). + ensure_thread( + workspace.clone(), + CreateConversationThread { + id: ORCHESTRATOR_THREAD_ID.into(), + title: "Subconscious Orchestrator".into(), + created_at: "2026-06-12T00:00:00Z".into(), + parent_thread_id: None, + labels: None, + personality_id: None, + }, + ) + .expect("ensure thread"); + + // Simulate prior orchestrator history that a long-lived session would + // cold-boot resume from via seed_resume_from_messages. + for (sender, content) in [ + ("user", "[composio/gmail] new invoice"), + ("agent", "Noted; I'll watch for the follow-up."), + ] { + append_message( + workspace.clone(), + ORCHESTRATOR_THREAD_ID, + ConversationMessage { + id: format!("m-{sender}"), + content: content.into(), + message_type: "text".into(), + extra_metadata: serde_json::Value::Null, + sender: sender.into(), + created_at: "2026-06-12T00:00:00Z".into(), + }, + ) + .expect("append"); + } + + let history = get_messages(workspace, ORCHESTRATOR_THREAD_ID).expect("read"); + assert_eq!(history.len(), 2); + assert_eq!(history[0].sender, "user"); + assert_eq!(history[1].sender, "agent"); +}