feat(subconscious): factory — one reflection engine, many worlds (memory + tinyplace) (#4550)

This commit is contained in:
Steven Enamakel
2026-07-05 00:40:48 -07:00
committed by GitHub
parent dae2c0a2ab
commit 022653705d
71 changed files with 4565 additions and 1449 deletions
+6
View File
@@ -418,6 +418,12 @@ OPENHUMAN_ANALYTICS_ENABLED=true
# the UI; this var is for headless / fleet deployments. `0` means "Manual only"
# (periodic auto-sync disabled). Unset → 24h default.
# OPENHUMAN_MEMORY_SYNC_INTERVAL_SECS=86400
#
# Cadence (minutes) of the tiny.place "subconscious" steering review — the
# offline reflection over the orchestration layer's compressed execution history
# that emits STEERING_DIRECTIVEs. Unset → follows the heartbeat interval (the
# pre-factory behaviour, where the review ran once per memory tick).
# OPENHUMAN_ORCH_REVIEW_INTERVAL_MINUTES=15
# ---------------------------------------------------------------------------
# Logging
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import type { TriggerKind } from '../../hooks/useSubconscious';
import { useT } from '../../lib/i18n/I18nContext';
import type { SubconsciousMode } from '../../utils/tauriCommands/heartbeat';
import type { SubconsciousStatus } from '../../utils/tauriCommands/subconscious';
import type {
SubconsciousInstanceStatus,
SubconsciousStatus,
} from '../../utils/tauriCommands/subconscious';
import { settingsNavState } from '../settings/modal/settingsOverlay';
import Button from '../ui/Button';
import SubconsciousInstanceCard from './SubconsciousInstanceCard';
interface ModeOption {
id: SubconsciousMode;
@@ -48,34 +52,51 @@ function sliderToMinutes(value: number): number {
interface IntelligenceSubconsciousTabProps {
status: SubconsciousStatus | null;
/** Per-world status rows (falls back to [memory] on an older core). */
instances?: SubconsciousInstanceStatus[];
mode: SubconsciousMode;
intervalMinutes: number;
triggerTick: () => Promise<void>;
triggerTick: (kind?: TriggerKind) => Promise<void>;
triggering: boolean;
/** Per-kind in-flight state (two Run buttons must not share one spinner). */
isTriggering?: (kind: TriggerKind) => boolean;
settingMode: boolean;
setMode: (mode: SubconsciousMode) => Promise<void>;
setIntervalMinutes: (minutes: number) => Promise<void>;
/** Navigate to the TinyPlace Orchestration tab's Subconscious window. */
onViewDirectives?: () => void;
}
export default function IntelligenceSubconsciousTab({
status,
instances,
mode,
intervalMinutes,
triggerTick,
triggering,
isTriggering,
settingMode,
setMode,
setIntervalMinutes,
onViewDirectives,
}: IntelligenceSubconsciousTabProps) {
const { t } = useT();
const navigate = useNavigate();
const location = useLocation();
const providerUnavailable = status?.provider_available === false;
const providerUnavailableReason = providerUnavailable
? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle'))
: null;
const isEnabled = mode !== 'off';
// Derive the per-world rows, tolerating an older core (no `instances`).
const rows: SubconsciousInstanceStatus[] =
instances && instances.length > 0
? instances
: status
? [{ ...status, instance: status.instance ?? 'memory' }]
: [];
const memoryRow = rows.find(r => r.instance === 'memory') ?? status ?? undefined;
const tinyplaceRow = rows.find(r => r.instance === 'tinyplace');
const running = (kind: TriggerKind) => (isTriggering ? isTriggering(kind) : triggering);
const openProviderSettings = () => navigate('/settings/llm', settingsNavState(location));
const [localSlider, setLocalSlider] = useState(() => minutesToSlider(intervalMinutes));
// Keep the local slider in sync when the prop changes from outside (e.g. after a refresh).
@@ -95,14 +116,13 @@ export default function IntelligenceSubconsciousTab({
}
}, [localSlider, intervalMinutes, setIntervalMinutes]);
const handleRunTick = async () => {
try {
await triggerTick();
} catch (error) {
const runTick = (kind: TriggerKind) => {
Promise.resolve(triggerTick(kind)).catch(error => {
console.debug('[subconscious-ui] run tick:error', {
kind,
error: error instanceof Error ? error.message : String(error),
});
}
});
};
return (
@@ -171,76 +191,39 @@ export default function IntelligenceSubconsciousTab({
</div>
)}
{/* Status bar + Run Now */}
{/* Per-world instance cards */}
{isEnabled && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-content-faint">
{status && (
<>
<span>
{status.total_ticks} {t('subconscious.ticks')}
</span>
{status.last_tick_at && (
<>
<span className="text-content-faint dark:text-neutral-600">|</span>
<span>
{t('subconscious.last')}:{' '}
{new Date(status.last_tick_at * 1000).toLocaleTimeString()}
</span>
</>
)}
{status.consecutive_failures > 0 && (
<>
<span className="text-content-faint dark:text-neutral-600">|</span>
<span className="text-coral-500">
{status.consecutive_failures} {t('subconscious.failed')}
</span>
</>
)}
</>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => void handleRunTick()}
disabled={triggering || providerUnavailable}
title={providerUnavailable ? t('subconscious.providerUnavailableTitle') : undefined}>
{triggering ? (
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
)}
{t('subconscious.runNow')}
</Button>
</div>
)}
{isEnabled && providerUnavailable && (
<div className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t('subconscious.providerUnavailableTitle')}
</p>
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300 break-words">
{providerUnavailableReason}
</p>
</div>
<button
type="button"
onClick={() => navigate('/settings/llm', settingsNavState(location))}
className="flex-shrink-0 rounded-md bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-content-inverted hover:bg-amber-700 transition-colors">
{t('subconscious.providerSettings')}
</button>
</div>
<div className="space-y-3">
<SubconsciousInstanceCard
title={t('subconscious.instance.memory.title')}
subtitle={t('subconscious.instance.memory.subtitle')}
status={memoryRow}
runLabel={t('subconscious.runNow')}
triggering={running('memory')}
onRun={() => runTick('memory')}
onProviderSettings={openProviderSettings}
/>
<SubconsciousInstanceCard
title={t('subconscious.instance.tinyplace.title')}
subtitle={t('subconscious.instance.tinyplace.subtitle')}
status={tinyplaceRow}
disabled={!tinyplaceRow || tinyplaceRow.enabled === false}
disabledHint={t('subconscious.instance.tinyplace.disabledHint')}
runLabel={t('subconscious.runReviewNow')}
triggering={running('tinyplace')}
onRun={() => runTick('tinyplace')}
onProviderSettings={openProviderSettings}
footer={
onViewDirectives ? (
<button
type="button"
onClick={onViewDirectives}
className="text-primary-600 hover:text-primary-700 dark:text-primary-400">
{t('subconscious.instance.tinyplace.viewDirectives')}
</button>
) : undefined
}
/>
</div>
)}
@@ -0,0 +1,137 @@
import type { ReactNode } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { SubconsciousInstanceStatus } from '../../utils/tauriCommands/subconscious';
import Button from '../ui/Button';
interface SubconsciousInstanceCardProps {
title: string;
subtitle: string;
/** The status row for this world, or undefined when the core hasn't
* registered it (older core / disabled / not bootstrapped). */
status: SubconsciousInstanceStatus | undefined;
/** Shown as a disabled state with `disabledHint` when true. */
disabled?: boolean;
disabledHint?: string;
runLabel: string;
triggering: boolean;
onRun: () => void;
/** Navigate to the provider (LLM) settings from the unavailable banner. */
onProviderSettings?: () => void;
/** Optional footer, e.g. a "View directives →" cross-link. */
footer?: ReactNode;
}
/**
* One subconscious world's health card — total ticks, last tick, failures, a
* provider-unavailable banner, and a per-world "Run now". Shared so a third
* world later is a data change, not new JSX.
*/
export default function SubconsciousInstanceCard({
title,
subtitle,
status,
disabled = false,
disabledHint,
runLabel,
triggering,
onRun,
onProviderSettings,
footer,
}: SubconsciousInstanceCardProps) {
const { t } = useT();
const providerUnavailable = status?.provider_available === false;
const providerUnavailableReason = providerUnavailable
? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle'))
: null;
return (
<div className="rounded-lg border border-line bg-surface p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h4 className="text-sm font-semibold text-content">{title}</h4>
<p className="mt-0.5 text-xs text-content-muted">{subtitle}</p>
</div>
<span
className={`flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${
disabled
? 'bg-surface-strong text-content-faint'
: 'bg-sage-50 text-sage-700 dark:bg-sage-500/10 dark:text-sage-300'
}`}>
{disabled ? t('subconscious.instance.off') : t('subconscious.instance.on')}
</span>
</div>
{disabled ? (
<p className="mt-3 text-xs text-content-muted">{disabledHint}</p>
) : (
<>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-content-faint">
<span>
{status?.total_ticks ?? 0} {t('subconscious.ticks')}
</span>
{status?.last_tick_at != null && (
<>
<span className="text-content-faint dark:text-neutral-600">|</span>
<span>
{t('subconscious.last')}:{' '}
{new Date(status.last_tick_at * 1000).toLocaleTimeString()}
</span>
</>
)}
{(status?.consecutive_failures ?? 0) > 0 && (
<>
<span className="text-content-faint dark:text-neutral-600">|</span>
<span className="text-coral-500">
{status?.consecutive_failures} {t('subconscious.failed')}
</span>
</>
)}
</div>
{providerUnavailable && (
<div className="mt-3 rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3">
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 text-xs text-amber-700 dark:text-amber-300 break-words">
{providerUnavailableReason}
</p>
{onProviderSettings && (
<button
type="button"
onClick={onProviderSettings}
className="flex-shrink-0 rounded-md bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-content-inverted hover:bg-amber-700 transition-colors">
{t('subconscious.providerSettings')}
</button>
)}
</div>
</div>
)}
<div className="mt-3 flex items-center justify-between gap-2">
<div className="min-w-0 text-xs text-content-faint">{footer}</div>
<Button
variant="secondary"
size="sm"
onClick={onRun}
disabled={triggering || providerUnavailable}
title={providerUnavailable ? t('subconscious.providerUnavailableTitle') : undefined}>
{triggering ? (
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
)}
{runLabel}
</Button>
</div>
</>
)}
</div>
);
}
@@ -40,6 +40,10 @@ vi.mock('../../services/socketService', () => {
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../utils/tauriCommands/subconscious', () => ({
subconsciousTrigger: vi.fn(async () => ({ result: { triggered: true }, logs: [] })),
}));
const sessionsListMock = vi.mocked(orchestrationClient.sessionsList);
const sessionsCreateMock = vi.mocked(orchestrationClient.sessionsCreate);
const messagesListMock = vi.mocked(orchestrationClient.messagesList);
@@ -134,6 +138,29 @@ describe('TinyPlaceOrchestrationTab', () => {
});
});
it('steering header shows the directive and Run review triggers the tinyplace kind', async () => {
const { subconsciousTrigger } = await import('../../utils/tauriCommands/subconscious');
statusMock.mockResolvedValue({
steering: {
text: 'prioritize the billing migration',
createdAt: '2026-07-04T00:00:00.000Z',
expiresAfterCycles: 12,
},
lastTickAt: 1_700_000_000,
});
render(<TinyPlaceOrchestrationTab />);
// Open the pinned Subconscious window.
fireEvent.click(await screen.findByTestId('tinyplace-chat-subconscious'));
const header = await screen.findByTestId('tinyplace-steering-header');
expect(within(header).getByText('prioritize the billing migration')).toBeInTheDocument();
fireEvent.click(within(header).getByText('tinyplaceOrchestration.steeringHeader.runReview'));
await waitFor(() => expect(subconsciousTrigger).toHaveBeenCalledWith('tinyplace'));
});
it('renders pinned master and subconscious chats plus app sessions', async () => {
sessionsListMock.mockResolvedValue({
sessions: [
@@ -15,6 +15,7 @@ import {
MASTER_CHAT_KEY,
useOrchestrationChats,
} from '../../lib/orchestration/useOrchestrationChats';
import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious';
import Button from '../ui/Button';
const debug = debugFactory('brain:tinyplace-orchestration');
@@ -96,7 +97,9 @@ function ChatListButton({
</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-content-muted">
{chat.subtitle}
{chat.kind === 'subconscious'
? t('tinyplaceOrchestration.subconsciousBadge')
: chat.subtitle}
</span>
<span className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs text-content-faint">{chat.preview}</span>
@@ -391,6 +394,17 @@ export default function TinyPlaceOrchestrationTab() {
}, [directoryIdsKey]);
const steeringText = status?.steering?.text?.trim() || null;
const [runningReview, setRunningReview] = useState(false);
const runSteeringReview = useCallback(async () => {
setRunningReview(true);
try {
await subconsciousTrigger('tinyplace');
} catch (err) {
debug('steering review trigger failed: %o', err);
} finally {
setRunningReview(false);
}
}, []);
const isMasterSelected = selected?.id === MASTER_CHAT_KEY;
// The composer is available for the Master chat and for any per-contact
// session (session sends thread under that session id).
@@ -677,6 +691,46 @@ export default function TinyPlaceOrchestrationTab() {
) : null}
</div>
{/* Steering status header — the tinyplace subconscious instance's output. */}
{selected?.kind === 'subconscious' ? (
<div
data-testid="tinyplace-steering-header"
className="flex items-center justify-between gap-3 border-b border-line bg-amber-50/40 px-5 py-3 dark:bg-amber-500/5">
<div className="min-w-0">
<p className="text-xs font-medium text-content">
{steeringText
? t('tinyplaceOrchestration.steeringHeader.current')
: t('tinyplaceOrchestration.steeringHeader.none')}
</p>
{steeringText ? (
<p className="mt-0.5 truncate text-xs text-content-muted">{steeringText}</p>
) : null}
<p className="mt-0.5 text-[11px] text-content-faint">
{status?.steering
? t('tinyplaceOrchestration.steeringHeader.expires').replace(
'{n}',
String(status.steering.expiresAfterCycles)
)
: ''}
{status?.lastTickAt
? `${status?.steering ? ' · ' : ''}${t(
'tinyplaceOrchestration.steeringHeader.lastReview'
)}: ${new Date(status.lastTickAt * 1000).toLocaleTimeString()}`
: ''}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => void runSteeringReview()}
disabled={runningReview}>
{runningReview
? t('tinyplaceOrchestration.steeringHeader.running')
: t('tinyplaceOrchestration.steeringHeader.runReview')}
</Button>
</div>
) : null}
{sessionsState.status === 'loading' ? (
<div className="flex flex-1 items-center justify-center text-sm text-content-muted">
{t('tinyplaceOrchestration.loading')}
@@ -5,10 +5,26 @@ import { fireEvent, render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubconsciousInstanceStatus } from '../../../utils/tauriCommands/subconscious';
import IntelligenceSubconsciousTab from '../IntelligenceSubconsciousTab';
const mockNavigate = vi.fn();
function row(instance: 'memory' | 'tinyplace', over: Partial<SubconsciousInstanceStatus> = {}) {
return {
instance,
enabled: true,
mode: instance === 'memory' ? 'simple' : 'steering',
provider_available: true,
provider_unavailable_reason: null,
interval_minutes: 5,
last_tick_at: null,
total_ticks: 3,
consecutive_failures: 0,
...over,
} as SubconsciousInstanceStatus;
}
vi.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
useLocation: () => ({
@@ -70,4 +86,61 @@ describe('IntelligenceSubconsciousTab', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} mode="aggressive" />);
expect(screen.getByText(/full tool access including writes/)).toBeInTheDocument();
});
it('renders both instance cards from instances', () => {
render(
<IntelligenceSubconsciousTab
{...baseProps()}
mode="simple"
instances={[row('memory'), row('tinyplace')]}
/>
);
expect(screen.getByText('Your world')).toBeInTheDocument();
expect(screen.getByText('Orchestration steering')).toBeInTheDocument();
expect(screen.getByText('Run Now')).toBeInTheDocument();
expect(screen.getByText('Run review now')).toBeInTheDocument();
});
it('each card Run button dispatches its own kind', () => {
const triggerTick = vi.fn().mockResolvedValue(undefined);
render(
<IntelligenceSubconsciousTab
{...baseProps()}
mode="simple"
triggerTick={triggerTick}
instances={[row('memory'), row('tinyplace')]}
/>
);
fireEvent.click(screen.getByText('Run Now'));
expect(triggerTick).toHaveBeenCalledWith('memory');
fireEvent.click(screen.getByText('Run review now'));
expect(triggerTick).toHaveBeenCalledWith('tinyplace');
});
it('tinyplace card shows a disabled hint when orchestration is off', () => {
render(
<IntelligenceSubconsciousTab
{...baseProps()}
mode="simple"
instances={[row('memory'), row('tinyplace', { enabled: false })]}
/>
);
expect(screen.getByText(/Enable Orchestration/)).toBeInTheDocument();
// A disabled card exposes no run button.
expect(screen.queryByText('Run review now')).not.toBeInTheDocument();
});
it('per-kind spinner: only the triggering kind spins', () => {
render(
<IntelligenceSubconsciousTab
{...baseProps()}
mode="simple"
instances={[row('memory'), row('tinyplace')]}
isTriggering={(kind: string) => kind === 'tinyplace'}
/>
);
// Memory card's Run button stays enabled; only the tinyplace one is busy.
expect(screen.getByText('Run Now').closest('button')).toBeEnabled();
expect(screen.getByText('Run review now').closest('button')).toBeDisabled();
});
});
@@ -5,6 +5,7 @@ import { useSubconscious } from '../useSubconscious';
const mockStatus = {
result: {
instance: 'memory',
enabled: true,
mode: 'simple',
provider_available: true,
@@ -125,4 +126,38 @@ describe('useSubconscious', () => {
expect(subconsciousTrigger).toHaveBeenCalled();
});
it('triggerTick passes the kind through (no-arg = memory)', async () => {
const { subconsciousTrigger } = await import('../../utils/tauriCommands');
const { result } = renderHook(() => useSubconscious());
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await result.current.triggerTick('tinyplace');
});
expect(subconsciousTrigger).toHaveBeenLastCalledWith('tinyplace');
await act(async () => {
await result.current.triggerTick();
});
// A no-arg call maps to the legacy memory-only trigger.
expect(subconsciousTrigger).toHaveBeenLastCalledWith(undefined);
});
it('derives a memory instance row when the core omits instances[]', async () => {
const { result } = renderHook(() => useSubconscious());
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.instances).toHaveLength(1);
expect(result.current.instances[0].instance).toBe('memory');
// No kind is in flight initially.
expect(result.current.isTriggering('memory')).toBe(false);
expect(result.current.isTriggering('tinyplace')).toBe(false);
});
});
+53 -10
View File
@@ -14,28 +14,49 @@ import {
subconsciousTrigger,
} from '../utils/tauriCommands';
import type { SubconsciousMode } from '../utils/tauriCommands/heartbeat';
import type { SubconsciousStatus } from '../utils/tauriCommands/subconscious';
import type {
SubconsciousInstanceStatus,
SubconsciousKind,
SubconsciousStatus,
} from '../utils/tauriCommands/subconscious';
export type TriggerKind = SubconsciousKind | 'all';
export interface UseSubconsciousResult {
status: SubconsciousStatus | null;
/** One row per registered world, tolerant of an older core (falls back to
* the top-level memory fields when `instances` is absent). */
instances: SubconsciousInstanceStatus[];
mode: SubconsciousMode;
intervalMinutes: number;
loading: boolean;
/** True when the memory (default) tick is in flight — back-compat. */
triggering: boolean;
/** True when a tick for `kind` is in flight (two buttons ≠ one spinner). */
isTriggering: (kind: TriggerKind) => boolean;
settingMode: boolean;
refresh: () => Promise<void>;
triggerTick: () => Promise<void>;
triggerTick: (kind?: TriggerKind) => Promise<void>;
setMode: (mode: SubconsciousMode) => Promise<void>;
setIntervalMinutes: (minutes: number) => Promise<void>;
error: string | null;
}
/** Derive per-world rows, tolerating an older core that omits `instances`. */
function deriveInstances(status: SubconsciousStatus | null): SubconsciousInstanceStatus[] {
if (!status) return [];
if (status.instances && status.instances.length > 0) return status.instances;
// Older core: the top-level fields are the memory instance.
const { instances: _omit, ...row } = status;
return [{ ...row, instance: row.instance ?? 'memory' }];
}
export function useSubconscious(): UseSubconsciousResult {
const [status, setStatus] = useState<SubconsciousStatus | null>(null);
const [mode, setModeState] = useState<SubconsciousMode>('off');
const [intervalMinutes, setIntervalState] = useState(30);
const [loading, setLoading] = useState(false);
const [triggering, setTriggering] = useState(false);
const [triggeringKinds, setTriggeringKinds] = useState<Set<TriggerKind>>(new Set());
const [settingMode, setSettingMode] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchingRef = useRef(false);
@@ -72,17 +93,32 @@ export function useSubconscious(): UseSubconsciousResult {
}
}, []);
const triggerTick = useCallback(async () => {
if (!isTauri() || triggering) return;
setTriggering(true);
const triggerTick = useCallback(async (kind: TriggerKind = 'memory') => {
if (!isTauri()) return;
let alreadyInFlight = false;
setTriggeringKinds(prev => {
if (prev.has(kind)) {
alreadyInFlight = true;
return prev;
}
const next = new Set(prev);
next.add(kind);
return next;
});
if (alreadyInFlight) return;
try {
await subconsciousTrigger();
// A no-arg legacy call maps to memory; pass the kind through otherwise.
await subconsciousTrigger(kind === 'memory' ? undefined : kind);
} catch (err) {
console.warn('[subconscious] trigger failed:', err);
} finally {
setTriggering(false);
setTriggeringKinds(prev => {
const next = new Set(prev);
next.delete(kind);
return next;
});
}
}, [triggering]);
}, []);
const setMode = useCallback(
async (newMode: SubconsciousMode) => {
@@ -121,12 +157,19 @@ export function useSubconscious(): UseSubconsciousResult {
};
}, [refresh]);
const isTriggering = useCallback(
(kind: TriggerKind) => triggeringKinds.has(kind),
[triggeringKinds]
);
return {
status,
instances: deriveInstances(status),
mode,
intervalMinutes,
loading,
triggering,
triggering: triggeringKinds.has('memory'),
isTriggering,
settingMode,
refresh,
triggerTick,
+16
View File
@@ -2673,6 +2673,22 @@ const messages: TranslationMap = {
'subconscious.failed': 'فشل',
'subconscious.tickInterval': 'فترة الدورة',
'subconscious.runNow': 'تشغيل الآن',
'subconscious.instance.on': 'مُفعّل',
'subconscious.instance.off': 'مُعطّل',
'subconscious.instance.memory.title': 'عالمك',
'subconscious.instance.memory.subtitle': 'مصادر الذاكرة المتصلة',
'subconscious.instance.tinyplace.title': 'توجيه التنسيق',
'subconscious.instance.tinyplace.subtitle': 'مراجعة جلسات tiny.place',
'subconscious.instance.tinyplace.disabledHint': 'فعّل التنسيق لتوجيه الجلسات المُدارة.',
'subconscious.instance.tinyplace.viewDirectives': 'عرض التوجيهات ←',
'subconscious.runReviewNow': 'شغّل المراجعة الآن',
'tinyplaceOrchestration.subconsciousBadge': 'اللاوعي · التوجيه',
'tinyplaceOrchestration.steeringHeader.current': 'توجيه نشط',
'tinyplaceOrchestration.steeringHeader.none': 'لا يوجد توجيه نشط',
'tinyplaceOrchestration.steeringHeader.expires': 'ينتهي بعد {n} دورات',
'tinyplaceOrchestration.steeringHeader.lastReview': 'آخر مراجعة',
'tinyplaceOrchestration.steeringHeader.runReview': 'شغّل المراجعة الآن',
'tinyplaceOrchestration.steeringHeader.running': 'جارٍ التشغيل…',
'subconscious.providerUnavailableTitle': 'تم إيقاف اللاوعي مؤقتًا',
'subconscious.providerSettings': 'إعدادات الذكاء الاصطناعي',
'subconscious.scratchpadInfo':
+17
View File
@@ -2733,6 +2733,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'ব্যর্থ',
'subconscious.tickInterval': 'টিক ইন্টারভাল',
'subconscious.runNow': 'এখনই চালান',
'subconscious.instance.on': 'চালু',
'subconscious.instance.off': 'বন্ধ',
'subconscious.instance.memory.title': 'আপনার জগৎ',
'subconscious.instance.memory.subtitle': 'সংযুক্ত মেমরি উৎস',
'subconscious.instance.tinyplace.title': 'অর্কেস্ট্রেশন স্টিয়ারিং',
'subconscious.instance.tinyplace.subtitle': 'tiny.place সেশন পর্যালোচনা',
'subconscious.instance.tinyplace.disabledHint':
'র‍্যাপড সেশন পরিচালনায় অর্কেস্ট্রেশন সক্ষম করুন।',
'subconscious.instance.tinyplace.viewDirectives': 'নির্দেশাবলী দেখুন →',
'subconscious.runReviewNow': 'এখন পর্যালোচনা চালান',
'tinyplaceOrchestration.subconsciousBadge': 'অবচেতন · স্টিয়ারিং',
'tinyplaceOrchestration.steeringHeader.current': 'সক্রিয় নির্দেশ',
'tinyplaceOrchestration.steeringHeader.none': 'কোনো সক্রিয় নির্দেশ নেই',
'tinyplaceOrchestration.steeringHeader.expires': '{n} চক্র পরে মেয়াদ শেষ',
'tinyplaceOrchestration.steeringHeader.lastReview': 'শেষ পর্যালোচনা',
'tinyplaceOrchestration.steeringHeader.runReview': 'এখন পর্যালোচনা চালান',
'tinyplaceOrchestration.steeringHeader.running': 'চলছে…',
'subconscious.providerUnavailableTitle': 'Subconscious বিরত আছে',
'subconscious.providerSettings': 'AI সেটিংস',
'subconscious.scratchpadInfo':
+17
View File
@@ -2801,6 +2801,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'gescheitert',
'subconscious.tickInterval': 'Tick-Intervall',
'subconscious.runNow': 'Jetzt ausführen',
'subconscious.instance.on': 'An',
'subconscious.instance.off': 'Aus',
'subconscious.instance.memory.title': 'Deine Welt',
'subconscious.instance.memory.subtitle': 'Verbundene Speicherquellen',
'subconscious.instance.tinyplace.title': 'Orchestrierungssteuerung',
'subconscious.instance.tinyplace.subtitle': 'tiny.place-Sitzungsprüfung',
'subconscious.instance.tinyplace.disabledHint':
'Aktiviere die Orchestrierung, um umschlossene Sitzungen zu steuern.',
'subconscious.instance.tinyplace.viewDirectives': 'Anweisungen ansehen →',
'subconscious.runReviewNow': 'Prüfung jetzt ausführen',
'tinyplaceOrchestration.subconsciousBadge': 'Unterbewusstsein · Steuerung',
'tinyplaceOrchestration.steeringHeader.current': 'Aktive Anweisung',
'tinyplaceOrchestration.steeringHeader.none': 'Keine aktive Anweisung',
'tinyplaceOrchestration.steeringHeader.expires': 'läuft nach {n} Zyklen ab',
'tinyplaceOrchestration.steeringHeader.lastReview': 'Letzte Prüfung',
'tinyplaceOrchestration.steeringHeader.runReview': 'Prüfung jetzt ausführen',
'tinyplaceOrchestration.steeringHeader.running': 'Läuft…',
'subconscious.providerUnavailableTitle': 'Unterbewusstsein ist pausiert',
'subconscious.providerSettings': 'KI-Einstellungen',
'subconscious.scratchpadInfo':
+16
View File
@@ -3145,6 +3145,22 @@ const en: TranslationMap = {
'subconscious.failed': 'failed',
'subconscious.tickInterval': 'Tick Interval',
'subconscious.runNow': 'Run Now',
'subconscious.instance.on': 'On',
'subconscious.instance.off': 'Off',
'subconscious.instance.memory.title': 'Your world',
'subconscious.instance.memory.subtitle': 'Connected memory sources',
'subconscious.instance.tinyplace.title': 'Orchestration steering',
'subconscious.instance.tinyplace.subtitle': 'tiny.place session review',
'subconscious.instance.tinyplace.disabledHint': 'Enable Orchestration to steer wrapped sessions.',
'subconscious.instance.tinyplace.viewDirectives': 'View directives →',
'subconscious.runReviewNow': 'Run review now',
'tinyplaceOrchestration.subconsciousBadge': 'Subconscious · steering',
'tinyplaceOrchestration.steeringHeader.current': 'Active directive',
'tinyplaceOrchestration.steeringHeader.none': 'No active directive',
'tinyplaceOrchestration.steeringHeader.expires': 'expires after {n} cycles',
'tinyplaceOrchestration.steeringHeader.lastReview': 'Last review',
'tinyplaceOrchestration.steeringHeader.runReview': 'Run review now',
'tinyplaceOrchestration.steeringHeader.running': 'Running…',
'subconscious.providerUnavailableTitle': 'Subconscious is paused',
'subconscious.providerSettings': 'AI settings',
'subconscious.scratchpadInfo':
+17
View File
@@ -2785,6 +2785,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'fallido',
'subconscious.tickInterval': 'Intervalo de tick',
'subconscious.runNow': 'Ejecutar ahora',
'subconscious.instance.on': 'Activado',
'subconscious.instance.off': 'Desactivado',
'subconscious.instance.memory.title': 'Tu mundo',
'subconscious.instance.memory.subtitle': 'Fuentes de memoria conectadas',
'subconscious.instance.tinyplace.title': 'Dirección de orquestación',
'subconscious.instance.tinyplace.subtitle': 'Revisión de sesiones de tiny.place',
'subconscious.instance.tinyplace.disabledHint':
'Activa la Orquestación para dirigir las sesiones envueltas.',
'subconscious.instance.tinyplace.viewDirectives': 'Ver directivas →',
'subconscious.runReviewNow': 'Ejecutar revisión ahora',
'tinyplaceOrchestration.subconsciousBadge': 'Subconsciente · dirección',
'tinyplaceOrchestration.steeringHeader.current': 'Directiva activa',
'tinyplaceOrchestration.steeringHeader.none': 'Sin directiva activa',
'tinyplaceOrchestration.steeringHeader.expires': 'caduca tras {n} ciclos',
'tinyplaceOrchestration.steeringHeader.lastReview': 'Última revisión',
'tinyplaceOrchestration.steeringHeader.runReview': 'Ejecutar revisión ahora',
'tinyplaceOrchestration.steeringHeader.running': 'Ejecutando…',
'subconscious.providerUnavailableTitle': 'Subconsciente en pausa',
'subconscious.providerSettings': 'Ajustes de IA',
'subconscious.scratchpadInfo':
+17
View File
@@ -2799,6 +2799,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'échoué',
'subconscious.tickInterval': 'Intervalle de tick',
'subconscious.runNow': 'Exécuter maintenant',
'subconscious.instance.on': 'Activé',
'subconscious.instance.off': 'Désactivé',
'subconscious.instance.memory.title': 'Votre monde',
'subconscious.instance.memory.subtitle': 'Sources de mémoire connectées',
'subconscious.instance.tinyplace.title': 'Pilotage de lorchestration',
'subconscious.instance.tinyplace.subtitle': 'Revue des sessions tiny.place',
'subconscious.instance.tinyplace.disabledHint':
'Activez lOrchestration pour piloter les sessions encapsulées.',
'subconscious.instance.tinyplace.viewDirectives': 'Voir les directives →',
'subconscious.runReviewNow': 'Lancer la revue',
'tinyplaceOrchestration.subconsciousBadge': 'Subconscient · pilotage',
'tinyplaceOrchestration.steeringHeader.current': 'Directive active',
'tinyplaceOrchestration.steeringHeader.none': 'Aucune directive active',
'tinyplaceOrchestration.steeringHeader.expires': 'expire après {n} cycles',
'tinyplaceOrchestration.steeringHeader.lastReview': 'Dernière revue',
'tinyplaceOrchestration.steeringHeader.runReview': 'Lancer la revue',
'tinyplaceOrchestration.steeringHeader.running': 'En cours…',
'subconscious.providerUnavailableTitle': 'Subconscient en pause',
'subconscious.providerSettings': 'Paramètres IA',
'subconscious.scratchpadInfo':
+17
View File
@@ -2730,6 +2730,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'विफल',
'subconscious.tickInterval': 'टिक इंटरवल',
'subconscious.runNow': 'अभी चलाएं',
'subconscious.instance.on': 'चालू',
'subconscious.instance.off': 'बंद',
'subconscious.instance.memory.title': 'आपकी दुनिया',
'subconscious.instance.memory.subtitle': 'जुड़े हुए मेमोरी स्रोत',
'subconscious.instance.tinyplace.title': 'ऑर्केस्ट्रेशन स्टीयरिंग',
'subconscious.instance.tinyplace.subtitle': 'tiny.place सत्र समीक्षा',
'subconscious.instance.tinyplace.disabledHint':
'रैप किए गए सत्रों को नियंत्रित करने के लिए ऑर्केस्ट्रेशन सक्षम करें।',
'subconscious.instance.tinyplace.viewDirectives': 'निर्देश देखें →',
'subconscious.runReviewNow': 'अभी समीक्षा चलाएँ',
'tinyplaceOrchestration.subconsciousBadge': 'अवचेतन · स्टीयरिंग',
'tinyplaceOrchestration.steeringHeader.current': 'सक्रिय निर्देश',
'tinyplaceOrchestration.steeringHeader.none': 'कोई सक्रिय निर्देश नहीं',
'tinyplaceOrchestration.steeringHeader.expires': '{n} चक्रों के बाद समाप्त',
'tinyplaceOrchestration.steeringHeader.lastReview': 'अंतिम समीक्षा',
'tinyplaceOrchestration.steeringHeader.runReview': 'अभी समीक्षा चलाएँ',
'tinyplaceOrchestration.steeringHeader.running': 'चल रहा है…',
'subconscious.providerUnavailableTitle': 'Subconscious रुका हुआ है',
'subconscious.providerSettings': 'AI सेटिंग्स',
'subconscious.scratchpadInfo':
+17
View File
@@ -2739,6 +2739,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'gagal',
'subconscious.tickInterval': 'Interval Tick',
'subconscious.runNow': 'Jalankan Sekarang',
'subconscious.instance.on': 'Aktif',
'subconscious.instance.off': 'Nonaktif',
'subconscious.instance.memory.title': 'Dunia Anda',
'subconscious.instance.memory.subtitle': 'Sumber memori terhubung',
'subconscious.instance.tinyplace.title': 'Pengarahan orkestrasi',
'subconscious.instance.tinyplace.subtitle': 'Tinjauan sesi tiny.place',
'subconscious.instance.tinyplace.disabledHint':
'Aktifkan Orkestrasi untuk mengarahkan sesi terbungkus.',
'subconscious.instance.tinyplace.viewDirectives': 'Lihat arahan →',
'subconscious.runReviewNow': 'Jalankan tinjauan sekarang',
'tinyplaceOrchestration.subconsciousBadge': 'Bawah sadar · pengarahan',
'tinyplaceOrchestration.steeringHeader.current': 'Arahan aktif',
'tinyplaceOrchestration.steeringHeader.none': 'Tidak ada arahan aktif',
'tinyplaceOrchestration.steeringHeader.expires': 'kedaluwarsa setelah {n} siklus',
'tinyplaceOrchestration.steeringHeader.lastReview': 'Tinjauan terakhir',
'tinyplaceOrchestration.steeringHeader.runReview': 'Jalankan tinjauan sekarang',
'tinyplaceOrchestration.steeringHeader.running': 'Menjalankan…',
'subconscious.providerUnavailableTitle': 'Subconscious dijeda',
'subconscious.providerSettings': 'Pengaturan AI',
'subconscious.scratchpadInfo':
+17
View File
@@ -2781,6 +2781,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'fallito',
'subconscious.tickInterval': 'Intervallo di tick',
'subconscious.runNow': 'Esegui ora',
'subconscious.instance.on': 'Attivo',
'subconscious.instance.off': 'Disattivato',
'subconscious.instance.memory.title': 'Il tuo mondo',
'subconscious.instance.memory.subtitle': 'Fonti di memoria collegate',
'subconscious.instance.tinyplace.title': 'Guida dellorchestrazione',
'subconscious.instance.tinyplace.subtitle': 'Revisione delle sessioni tiny.place',
'subconscious.instance.tinyplace.disabledHint':
'Abilita lOrchestrazione per guidare le sessioni incapsulate.',
'subconscious.instance.tinyplace.viewDirectives': 'Vedi direttive →',
'subconscious.runReviewNow': 'Esegui revisione ora',
'tinyplaceOrchestration.subconsciousBadge': 'Subconscio · guida',
'tinyplaceOrchestration.steeringHeader.current': 'Direttiva attiva',
'tinyplaceOrchestration.steeringHeader.none': 'Nessuna direttiva attiva',
'tinyplaceOrchestration.steeringHeader.expires': 'scade dopo {n} cicli',
'tinyplaceOrchestration.steeringHeader.lastReview': 'Ultima revisione',
'tinyplaceOrchestration.steeringHeader.runReview': 'Esegui revisione ora',
'tinyplaceOrchestration.steeringHeader.running': 'In corso…',
'subconscious.providerUnavailableTitle': 'Subconscio in pausa',
'subconscious.providerSettings': 'Impostazioni IA',
'subconscious.scratchpadInfo':
+17
View File
@@ -2701,6 +2701,23 @@ const messages: TranslationMap = {
'subconscious.failed': '실패',
'subconscious.tickInterval': '틱 간격',
'subconscious.runNow': '지금 실행',
'subconscious.instance.on': '켜짐',
'subconscious.instance.off': '꺼짐',
'subconscious.instance.memory.title': '당신의 세계',
'subconscious.instance.memory.subtitle': '연결된 메모리 소스',
'subconscious.instance.tinyplace.title': '오케스트레이션 조정',
'subconscious.instance.tinyplace.subtitle': 'tiny.place 세션 검토',
'subconscious.instance.tinyplace.disabledHint':
'래핑된 세션을 조정하려면 오케스트레이션을 활성화하세요.',
'subconscious.instance.tinyplace.viewDirectives': '지시문 보기 →',
'subconscious.runReviewNow': '지금 검토 실행',
'tinyplaceOrchestration.subconsciousBadge': '잠재의식 · 조정',
'tinyplaceOrchestration.steeringHeader.current': '활성 지시문',
'tinyplaceOrchestration.steeringHeader.none': '활성 지시문 없음',
'tinyplaceOrchestration.steeringHeader.expires': '{n}주기 후 만료',
'tinyplaceOrchestration.steeringHeader.lastReview': '마지막 검토',
'tinyplaceOrchestration.steeringHeader.runReview': '지금 검토 실행',
'tinyplaceOrchestration.steeringHeader.running': '실행 중…',
'subconscious.providerUnavailableTitle': 'Subconscious 일시 중지됨',
'subconscious.providerSettings': 'AI 설정',
'subconscious.scratchpadInfo':
+17
View File
@@ -2763,6 +2763,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'nieudane',
'subconscious.tickInterval': 'Interwał tików',
'subconscious.runNow': 'Uruchom teraz',
'subconscious.instance.on': 'Wł.',
'subconscious.instance.off': 'Wył.',
'subconscious.instance.memory.title': 'Twój świat',
'subconscious.instance.memory.subtitle': 'Połączone źródła pamięci',
'subconscious.instance.tinyplace.title': 'Sterowanie orkiestracją',
'subconscious.instance.tinyplace.subtitle': 'Przegląd sesji tiny.place',
'subconscious.instance.tinyplace.disabledHint':
'Włącz Orkiestrację, aby sterować opakowanymi sesjami.',
'subconscious.instance.tinyplace.viewDirectives': 'Zobacz dyrektywy →',
'subconscious.runReviewNow': 'Uruchom przegląd teraz',
'tinyplaceOrchestration.subconsciousBadge': 'Podświadomość · sterowanie',
'tinyplaceOrchestration.steeringHeader.current': 'Aktywna dyrektywa',
'tinyplaceOrchestration.steeringHeader.none': 'Brak aktywnej dyrektywy',
'tinyplaceOrchestration.steeringHeader.expires': 'wygasa po {n} cyklach',
'tinyplaceOrchestration.steeringHeader.lastReview': 'Ostatni przegląd',
'tinyplaceOrchestration.steeringHeader.runReview': 'Uruchom przegląd teraz',
'tinyplaceOrchestration.steeringHeader.running': 'Trwa…',
'subconscious.providerUnavailableTitle': 'Podświadomość wstrzymana',
'subconscious.providerSettings': 'Ustawienia AI',
'subconscious.scratchpadInfo':
+17
View File
@@ -2784,6 +2784,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'falhou',
'subconscious.tickInterval': 'Intervalo de Tick',
'subconscious.runNow': 'Executar Agora',
'subconscious.instance.on': 'Ativado',
'subconscious.instance.off': 'Desativado',
'subconscious.instance.memory.title': 'Seu mundo',
'subconscious.instance.memory.subtitle': 'Fontes de memória conectadas',
'subconscious.instance.tinyplace.title': 'Direção da orquestração',
'subconscious.instance.tinyplace.subtitle': 'Revisão de sessões do tiny.place',
'subconscious.instance.tinyplace.disabledHint':
'Ative a Orquestração para direcionar as sessões encapsuladas.',
'subconscious.instance.tinyplace.viewDirectives': 'Ver diretrizes →',
'subconscious.runReviewNow': 'Executar revisão agora',
'tinyplaceOrchestration.subconsciousBadge': 'Subconsciente · direção',
'tinyplaceOrchestration.steeringHeader.current': 'Diretriz ativa',
'tinyplaceOrchestration.steeringHeader.none': 'Nenhuma diretriz ativa',
'tinyplaceOrchestration.steeringHeader.expires': 'expira após {n} ciclos',
'tinyplaceOrchestration.steeringHeader.lastReview': 'Última revisão',
'tinyplaceOrchestration.steeringHeader.runReview': 'Executar revisão agora',
'tinyplaceOrchestration.steeringHeader.running': 'Executando…',
'subconscious.providerUnavailableTitle': 'Subconsciente pausado',
'subconscious.providerSettings': 'Configurações de IA',
'subconscious.scratchpadInfo':
+17
View File
@@ -2758,6 +2758,23 @@ const messages: TranslationMap = {
'subconscious.failed': 'ошибка',
'subconscious.tickInterval': 'Интервал тика',
'subconscious.runNow': 'Запустить сейчас',
'subconscious.instance.on': 'Вкл.',
'subconscious.instance.off': 'Выкл.',
'subconscious.instance.memory.title': 'Ваш мир',
'subconscious.instance.memory.subtitle': 'Подключённые источники памяти',
'subconscious.instance.tinyplace.title': 'Управление оркестрацией',
'subconscious.instance.tinyplace.subtitle': 'Обзор сессий tiny.place',
'subconscious.instance.tinyplace.disabledHint':
'Включите Оркестрацию, чтобы управлять обёрнутыми сессиями.',
'subconscious.instance.tinyplace.viewDirectives': 'Показать директивы →',
'subconscious.runReviewNow': 'Запустить обзор',
'tinyplaceOrchestration.subconsciousBadge': 'Подсознание · управление',
'tinyplaceOrchestration.steeringHeader.current': 'Активная директива',
'tinyplaceOrchestration.steeringHeader.none': 'Нет активной директивы',
'tinyplaceOrchestration.steeringHeader.expires': 'истекает через {n} циклов',
'tinyplaceOrchestration.steeringHeader.lastReview': 'Последний обзор',
'tinyplaceOrchestration.steeringHeader.runReview': 'Запустить обзор',
'tinyplaceOrchestration.steeringHeader.running': 'Выполняется…',
'subconscious.providerUnavailableTitle': 'Подсознание приостановлено',
'subconscious.providerSettings': 'Настройки ИИ',
'subconscious.scratchpadInfo':
+16
View File
@@ -2586,6 +2586,22 @@ const messages: TranslationMap = {
'subconscious.failed': '失败',
'subconscious.tickInterval': '滴答间隔',
'subconscious.runNow': '立即运行',
'subconscious.instance.on': '开启',
'subconscious.instance.off': '关闭',
'subconscious.instance.memory.title': '你的世界',
'subconscious.instance.memory.subtitle': '已连接的记忆来源',
'subconscious.instance.tinyplace.title': '编排引导',
'subconscious.instance.tinyplace.subtitle': 'tiny.place 会话审查',
'subconscious.instance.tinyplace.disabledHint': '启用编排以引导封装的会话。',
'subconscious.instance.tinyplace.viewDirectives': '查看指令 →',
'subconscious.runReviewNow': '立即运行审查',
'tinyplaceOrchestration.subconsciousBadge': '潜意识 · 引导',
'tinyplaceOrchestration.steeringHeader.current': '当前指令',
'tinyplaceOrchestration.steeringHeader.none': '无活动指令',
'tinyplaceOrchestration.steeringHeader.expires': '{n} 个周期后过期',
'tinyplaceOrchestration.steeringHeader.lastReview': '上次审查',
'tinyplaceOrchestration.steeringHeader.runReview': '立即运行审查',
'tinyplaceOrchestration.steeringHeader.running': '运行中…',
'subconscious.providerUnavailableTitle': '潜意识已暂停',
'subconscious.providerSettings': 'AI 设置',
'subconscious.scratchpadInfo':
+4
View File
@@ -59,9 +59,11 @@ export default function Activity() {
// Subconscious engine data (used by the Background Activity tab).
const {
status: subconsciousEngineStatus,
instances: subconsciousInstances,
mode: subconsciousMode,
intervalMinutes: subconsciousInterval,
triggering: subconsciousTriggering,
isTriggering: subconsciousIsTriggering,
settingMode: subconsciousSettingMode,
triggerTick,
setMode: setSubconsciousMode,
@@ -154,10 +156,12 @@ export default function Activity() {
{activeTab === 'backgroundActivity' && (
<IntelligenceSubconsciousTab
status={subconsciousEngineStatus}
instances={subconsciousInstances}
mode={subconsciousMode}
intervalMinutes={subconsciousInterval}
triggerTick={triggerTick}
triggering={subconsciousTriggering}
isTriggering={subconsciousIsTriggering}
settingMode={subconsciousSettingMode}
setMode={setSubconsciousMode}
setIntervalMinutes={setSubconsciousInterval}
+2
View File
@@ -342,10 +342,12 @@ export default function Brain() {
<div className={cardClass}>
<IntelligenceSubconsciousTab
status={sub.status}
instances={sub.instances}
mode={sub.mode}
intervalMinutes={sub.intervalMinutes}
triggerTick={sub.triggerTick}
triggering={sub.triggering}
isTriggering={sub.isTriggering}
settingMode={sub.settingMode}
setMode={sub.setMode}
setIntervalMinutes={sub.setIntervalMinutes}
+5
View File
@@ -124,9 +124,11 @@ export default function Intelligence({ tabParamKey = 'tab' }: IntelligenceProps
// Subconscious engine data
const {
status: subconsciousEngineStatus,
instances: subconsciousInstances,
mode: subconsciousMode,
intervalMinutes: subconsciousInterval,
triggering: subconsciousTriggering,
isTriggering: subconsciousIsTriggering,
settingMode: subconsciousSettingMode,
triggerTick,
setMode: setSubconsciousMode,
@@ -270,13 +272,16 @@ export default function Intelligence({ tabParamKey = 'tab' }: IntelligenceProps
{activeTab === 'subconscious' && (
<IntelligenceSubconsciousTab
status={subconsciousEngineStatus}
instances={subconsciousInstances}
mode={subconsciousMode}
intervalMinutes={subconsciousInterval}
triggerTick={triggerTick}
triggering={subconsciousTriggering}
isTriggering={subconsciousIsTriggering}
settingMode={subconsciousSettingMode}
setMode={setSubconsciousMode}
setIntervalMinutes={setSubconsciousInterval}
onViewDirectives={() => setActiveTab('orchestration')}
/>
)}
@@ -48,6 +48,7 @@ function fixtures() {
});
mockSubStatus.mockResolvedValue({
result: {
instance: 'memory',
enabled: true,
mode: 'event_driven',
provider_available: true,
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../../../services/coreRpcClient';
import { isTauri } from '../common';
import { subconsciousStatus, subconsciousTrigger } from '../subconscious';
vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('../common', () => ({ isTauri: vi.fn(() => true), CommandResponse: undefined }));
describe('subconscious client', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isTauri).mockReturnValue(true);
});
it('trigger with no kind omits params (legacy memory behavior)', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { triggered: true } } as never);
await subconsciousTrigger();
expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.subconscious_trigger' });
});
it('trigger passes the kind through as params', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { triggered: true } } as never);
await subconsciousTrigger('tinyplace');
expect(callCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.subconscious_trigger',
params: { kind: 'tinyplace' },
});
});
it('status parses instances[] and tolerates its absence', async () => {
// With instances (new core).
vi.mocked(callCoreRpc).mockResolvedValueOnce({
result: {
instance: 'memory',
enabled: true,
instances: [
{ instance: 'memory', enabled: true },
{ instance: 'tinyplace', enabled: false },
],
},
} as never);
const withInstances = await subconsciousStatus();
expect(withInstances.result?.instances).toHaveLength(2);
// Without instances (older core) — the top-level fields still parse.
vi.mocked(callCoreRpc).mockResolvedValueOnce({
result: { instance: 'memory', enabled: true },
} as never);
const legacy = await subconsciousStatus();
expect(legacy.result?.instances).toBeUndefined();
expect(legacy.result?.instance).toBe('memory');
});
});
+30 -3
View File
@@ -11,9 +11,18 @@ import type { HeartbeatSettings } from './heartbeat';
// ── Types ────────────────────────────────────────────────────────────────────
export interface SubconsciousStatus {
/** Which subconscious world a status row / trigger targets. */
export type SubconsciousKind = 'memory' | 'tinyplace';
/** One subconscious world's health row. */
export interface SubconsciousInstanceStatus {
/**
* Which world this row describes. Defaulted to 'memory' by the core when
* absent, so an older core during rollout still parses.
*/
instance: SubconsciousKind;
enabled: boolean;
mode: 'off' | 'simple' | 'aggressive' | 'event_driven';
mode: 'off' | 'simple' | 'aggressive' | 'event_driven' | 'steering';
provider_available: boolean;
provider_unavailable_reason: string | null;
interval_minutes: number;
@@ -22,6 +31,16 @@ export interface SubconsciousStatus {
consecutive_failures: number;
}
/**
* The `subconscious.status` response. The legacy top-level fields mirror the
* memory instance (backward compatible); `instances` lists every registered
* world. `instances` may be absent when talking to an older core — treat it as
* `[]` and fall back to the top-level (memory) fields.
*/
export interface SubconsciousStatus extends SubconsciousInstanceStatus {
instances?: SubconsciousInstanceStatus[];
}
export interface TickResult {
tick_at: number;
duration_ms: number;
@@ -48,10 +67,18 @@ export async function subconsciousStatus(): Promise<CommandResponse<Subconscious
});
}
export async function subconsciousTrigger(): Promise<CommandResponse<TickResult>> {
/**
* Manually trigger a subconscious tick. `kind` selects the world: 'memory'
* (default — today's behavior), 'tinyplace', or 'all'. A no-arg call keeps the
* legacy memory-only behavior.
*/
export async function subconsciousTrigger(
kind?: SubconsciousKind | 'all'
): Promise<CommandResponse<TickResult>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<TickResult>>({
method: 'openhuman.subconscious_trigger',
...(kind ? { params: { kind } } : {}),
});
}
+129
View File
@@ -0,0 +1,129 @@
# Subconscious factory — one reflection engine, many worlds
Redesign `src/openhuman/subconscious/` around the split-brain spec
(`state (1).md`, "Autonomous Closed-Loop LangGraph Harness"): the subconscious
is the **Deep Reflection Layer** — an offline, cron-driven loop that consumes a
compressed view of how a world changed and emits short, dense outputs that
steer the rest of the system. Today that layer exists twice, fused together
inside one engine. This plan pulls it apart into a **factory** that can
instantiate a subconscious per *world*:
- **`memory`** — OpenHuman's internal high-level world: the user's connected
memory sources (Gmail/Slack/Notion/folders). Observes a `memory_diff`
against a baseline checkpoint; reflects with the slim decision agent
(to-dos, goals, `notify_user`, delegation).
- **`tinyplace`** — the tiny.place orchestration world: harness-session
interactions flowing through the `orchestration` domain. Observes the
20:1 compressed execution history + cumulative world-state diff; reflects
with a tool-free steering synthesis that emits `STEERING_DIRECTIVE`s for the
reasoning core.
New kinds (e.g. a per-team world, a channels world) become one new profile
file, not another engine.
## Where we are today
| Spec concept (state (1).md) | Existing implementation |
| --- | --- |
| Quick LLM / Front-End Agent | `orchestration/frontend_agent/` (`hint:chat`, two-pass) |
| Reasoning LLM / orchestration core | `orchestration/reasoning_agent/` (`hint:reasoning`), wake graph in `orchestration/graph/` |
| Bi-directional loop routing | `orchestration/graph/build.rs` + `ops::invoke_with_runtime` (debounce, idempotence cursor, dm_sent latch) |
| 20:1 compression engine | `orchestration/graph/compress.rs` + `ProductionRuntime::compress` |
| Cumulative world-state diff | `orchestration/graph/world_diff.rs` + `store::append_world_diff` |
| Context lifecycle hooks (8090%) | `context_guard`/`evict` nodes → memory-RAG eviction |
| Subconscious LLM / steering | `orchestration/steering.rs` + `ops::run_orchestration_review`**but invoked inline from `subconscious::engine::tick_inner`** |
| Cron trigger | `subconscious/heartbeat/` driving `SubconsciousEngine::tick` |
The problem is confined to `subconscious/engine.rs`: `tick_inner` is a
hard-wired composite — stage 0 calls `orchestration::ops::run_orchestration_review`
(the tiny.place world), then stages 13 run the memory world (memory_diff →
context scout → decision agent). One tick lock, one circuit breaker, one
status object, one baseline store — for two unrelated worlds. Neither can get
its own cadence, provider signature, halt state, or status, and a third world
cannot be added without growing the composite further.
## Target shape
```
subconscious/
├── mod.rs exports (unchanged public names + new factory surface)
├── profile.rs SubconsciousProfile trait + Observation/Reflection types
├── engine.rs generic SubconsciousInstance: tick graph (tinyagents CompiledGraph) + scheduler shell
├── factory.rs SubconsciousKind + make_subconscious(kind, config)
├── registry.rs (grown from global.rs) kind → instance map, lifecycle
├── profiles/
│ ├── memory.rs today's stages 13, extracted verbatim
│ └── tinyplace.rs wraps orchestration::ops::run_orchestration_review
├── store.rs instance-namespaced KV + legacy-key migration
├── heartbeat/ unchanged scheduler; drives every registered instance
├── agent/ unchanged (memory profile's decision agent)
├── session.rs, user_thread.rs, source_chunk.rs, schemas.rs unchanged roles
```
The generic tick is a **`tinyagents` `CompiledGraph`** (the same runtime the
orchestration wake path runs on — see phase 6), wrapped by scheduler concerns
in `engine.rs`, identical for every kind:
```
lock → timeout guard → config load → provider gate + rate-cap halt (per-instance signature)
→ run graph: START ─► observe ─┬─(quiet)──────────────────────► commit ─► END
└─► prepare_context ─► reflect ─► commit ─► END
· nodes delegate to the profile (the profile IS the injected runtime)
· SqliteCheckpointer → a killed tick resumes instead of losing its window
→ superseded-generation check
→ per-instance state/status update
```
Everything that is currently *good* about the engine — tick lock, generation
counter, `TICK_TIMEOUT`, rate-cap circuit breaker (TAURI-RUST-HXF), tool-capability
detection (TAURI-RUST-ADC), only-advance-on-success baselines, quiet-tick
short-circuit — moves into the generic runner **once** and every world gets it
for free.
## The factory
```rust
pub enum SubconsciousKind { Memory, TinyPlace }
pub fn make_subconscious(kind: SubconsciousKind, config: &Config) -> SubconsciousInstance {
let profile: Arc<dyn SubconsciousProfile> = match kind {
SubconsciousKind::Memory => Arc::new(profiles::memory::MemoryProfile::new(config)),
SubconsciousKind::TinyPlace => Arc::new(profiles::tinyplace::TinyPlaceProfile::new(config)),
};
SubconsciousInstance::new(profile, config)
}
```
`registry.rs` (today's `global.rs`) holds the instances, bootstraps the enabled
set after login (`Memory` when `heartbeat.enabled`; `TinyPlace` when
`orchestration.enabled`), and tears all of them down on user switch.
## Invariants that must survive the refactor
1. **Isolation** — the subconscious never contacts anyone. The tinyplace
profile stays a tool-free provider chat; the memory profile's agent toolset
keeps the no-channel/no-outbound test (`subconscious_agent_tool_surface_has_no_channel_or_effect_tools`).
2. **Taint** — any tick that reacted to external content runs
`SubconsciousTainted` (memory: diff-driven ticks; tinyplace: always).
3. **State only advances on success**; a superseded tick discards its result.
4. **Quiet ticks cost nothing** — no LLM call when `observe()` is empty.
5. **`subconscious.status` never touches the tick mutex** — reads from SQLite.
6. **Back-compat** — existing DB keys (`last_tick_at`, `baseline_checkpoint_id`)
migrate to the `memory:`-namespaced keys; the RPC keeps its legacy top-level
fields mirroring the memory instance.
## Phases
| Phase | File | Deliverable |
| --- | --- | --- |
| 1 | [phase-1-profile-and-engine.md](phase-1-profile-and-engine.md) | `SubconsciousProfile` trait, generic `SubconsciousInstance`, namespaced store |
| 2 | [phase-2-memory-profile.md](phase-2-memory-profile.md) | Extract the memory flow into `profiles/memory.rs`, behavior-identical |
| 3 | [phase-3-tinyplace-profile.md](phase-3-tinyplace-profile.md) | Wrap the orchestration review as `profiles/tinyplace.rs`; delete the inline call |
| 4 | [phase-4-factory-registry-rpc.md](phase-4-factory-registry-rpc.md) | Factory + registry lifecycle, heartbeat fan-out, per-instance RPC |
| 5 | [phase-5-tests-and-docs.md](phase-5-tests-and-docs.md) | Test matrix, migration tests, README/docs updates, rollout |
| 6 | [phase-6-tinyagents-reuse.md](phase-6-tinyagents-reuse.md) | TinyAgents graph reuse map + upstream PRs to `tinyhumansai/tinyagents` (deadline, cancel token, checkpoint GC) |
| 7 | [phase-7-ui.md](phase-7-ui.md) | UI: both kinds visible + triggerable — instance cards in the Subconscious tab, steering header on the orchestration tab's Subconscious window |
Each phase is a separately compilable, committable slice; phases 2 and 3 are
pure extractions (no behavior change), which keeps the diffs reviewable and
the coverage gate satisfiable per-slice.
@@ -0,0 +1,188 @@
# Phase 1 — `SubconsciousProfile` trait + generic instance runner
Goal: introduce the abstraction without moving any behavior yet. At the end of
this phase the existing `SubconsciousEngine` still works exactly as today; the
new types compile alongside it and are exercised only by unit tests.
## 1.1 `profile.rs` — the world contract
```rust
/// One "world" a subconscious can be instantiated over.
#[async_trait::async_trait]
pub trait SubconsciousProfile: Send + Sync {
/// Stable instance id — store-key namespace, log prefix, RPC name.
fn id(&self) -> &'static str; // "memory" | "tinyplace"
/// Tick cadence for this world (heartbeat multiplies its base interval).
fn cadence(&self, config: &Config) -> std::time::Duration;
/// Stage 1: what changed in this world since my baseline?
/// Errors and first-ever ticks surface as an empty observation
/// (`has_changes == false`) so the runner's quiet-path handles them.
async fn observe(&self, config: &Config) -> Observation;
/// Stage 2 (optional): grounding context for the reflection turn.
/// Default impl returns "" (the tinyplace profile skips this stage).
async fn prepare_context(&self, _config: &Config, _obs: &Observation) -> String {
String::new()
}
/// Stage 3: the reflection turn. Runs only when `obs.has_changes`.
async fn reflect(
&self,
config: &Config,
obs: &Observation,
prepared_context: &str,
) -> Result<Reflection, String>;
/// Advance this world's baseline/cursor. Called by the runner:
/// - after a quiet tick (refresh the baseline), and
/// - after a successful reflect (never after a failed or superseded one).
async fn commit(&self, config: &Config, obs: &Observation);
/// Turn-origin taint for this observation (memory: tainted iff the diff
/// carried external content; tinyplace: always tainted).
fn origin(&self, obs: &Observation) -> TrustedAutomationSource;
}
pub struct Observation {
/// Rendered world diff handed to `reflect` (empty when quiet).
pub rendered: String,
pub has_changes: bool,
/// Whether the change window contains third-party content (taint input).
pub has_external_content: bool,
/// Opaque token `commit` uses to advance exactly the observed window
/// (memory: none needed — it re-checkpoints; tinyplace: newest reviewed
/// compressed-row created_at for the review cursor).
pub commit_token: Option<String>,
}
pub enum Reflection {
/// The decision agent acted through tools (memory profile).
Acted { response_chars: usize },
/// A steering directive was emitted (tinyplace profile).
Steered { directive_id: i64 },
/// The model looked and correctly chose to do nothing.
Idle,
}
```
Design notes:
- `observe` is **infallible by signature**: today `tick_inner` already treats a
diff error as "quiet tick, refresh baseline"; encoding that in the type keeps
the runner branch-free. The profile logs its own errors.
- `reflect` returns `Err(String)` (matching today's `run_agent` error channel)
so the runner can keep the existing error classifiers — the string is fed to
`is_tool_capability_error` / `is_permanent_rate_cap_error` unchanged.
- `commit_token` exists because the tinyplace world advances a *cursor over
reviewed rows* (must not skip rows that arrived mid-tick), while the memory
world re-checkpoints the whole world. Both fit one optional string.
## 1.2 `engine.rs` — `SubconsciousInstance` (generic runner **as a tinyagents graph**)
Rename the struct conceptually: `SubconsciousEngine``SubconsciousInstance`
(keep a `pub type SubconsciousEngine = SubconsciousInstance;` alias until
phase 4 updates the callers). The instance owns exactly what the engine owns
today, minus every memory-specific line:
```rust
pub struct SubconsciousInstance {
profile: Arc<dyn SubconsciousProfile>,
graph: Arc<CompiledGraph<SubconsciousState>>, // built once at construction
workspace_dir: PathBuf,
mode: SubconsciousMode,
interval_minutes: u32,
enabled: bool,
state: Mutex<EngineState>, // unchanged fields
tick_generation: AtomicU64,
tick_lock: Mutex<()>,
}
```
The tick body is **not** hand-rolled control flow: it is one `tinyagents`
[`CompiledGraph`] built with `GraphBuilder`, exactly the pattern
`orchestration/graph/build.rs` established (nodes call an injected
`dyn` runtime; the graph owns routing/termination; a stub runtime makes the
mechanics unit-testable). Topology:
```text
START ─► observe ─┬─(quiet)────────────────────────────► commit ─► END
└─(changed)─► prepare_context ─► reflect ─► commit ─► END
```
- `SubconsciousState` (serde, like `OrchestrationState`): the `Observation`,
`prepared_context`, `Reflection` outcome, tick metadata (`tick_id`,
generation). Channels are plain `LastValue`s — no message reducers needed.
- The node handlers delegate 1:1 to the `SubconsciousProfile` methods — the
profile trait **is** the injected runtime (mirroring
`OrchestrationRuntime`); no second abstraction.
- Conditional edge out of `observe` on `has_changes` (command-routing, same
mechanism as the `frontend` router).
- Checkpointing: `SqliteCheckpointer<SubconsciousState>` at
`<workspace>/subconscious/graph_checkpoints.db`, thread id
`subconscious:<instance>:<tick_id>`. A tick killed mid-reflect resumes (or
is superseded cleanly) instead of losing the window — same durability story
the wake graph already has. Baseline advancement stays in `commit`, so a
resumed tick can never double-advance.
- Observability for free: graph stream events / status snapshots
(`tinyagents::graph::stream`/`status`) give per-node timing without bespoke
logging (keep the `[subconscious]` log lines anyway per repo logging rules).
What deliberately stays *outside* the graph, in `SubconsciousInstance`:
the cadence loop trigger (heartbeat), the tick lock + 5s acquisition skip,
the generation counter (supersede), `TICK_TIMEOUT`, provider gate + rate-cap
halt, and status. Rationale: these are scheduler/circuit-breaker concerns
identical to what `ops::invoke_with_runtime` keeps outside the wake graph —
see phase 6 for the tinyagents-side gaps that would let some of them move
into the runtime later.
The pieces that stay verbatim in the runner:
- 5s tick-lock acquisition timeout + skip log
- `TICK_TIMEOUT` wall clock (30 min) — consider making it a profile constant
later; not in this phase
- config-load failure path
- provider gate (`subconscious_provider_unavailable_reason`) and the rate-cap
halt (`should_skip_for_rate_cap_halt` / `arm_rate_cap_halt`) — the halt
signature gains the instance id prefix (`"memory|cloud"`) so one world's
halt never silences another
- tool-capability / rate-cap error classification on `reflect` failure
- superseded-generation discard
- `status()` — gains `instance: String` (the profile id)
## 1.3 `store.rs` — instance-namespaced KV + migration
Today: `subconscious_state` (REAL KV: `last_tick_at`) and
`subconscious_state_text` (TEXT KV: `baseline_checkpoint_id`), one row each.
Change the accessors to take the instance id and prefix the key
(`"memory:last_tick_at"`, `"tinyplace:last_tick_at"`, …). One-time migration in
the DDL block of `with_connection` (idempotent, like all our DDL):
```sql
UPDATE subconscious_state SET key = 'memory:' || key
WHERE key IN ('last_tick_at') AND NOT EXISTS (SELECT 1 FROM subconscious_state WHERE key = 'memory:last_tick_at');
UPDATE subconscious_state_text SET key = 'memory:' || key
WHERE key IN ('baseline_checkpoint_id') AND NOT EXISTS (SELECT 1 FROM subconscious_state_text WHERE key = 'memory:baseline_checkpoint_id');
```
(Exact guard shape to be settled in implementation — requirement is: running
old→new→old… never loses `last_tick_at`, and the migration is a no-op on a
fresh DB. The tinyplace profile's *review cursor* stays where it lives today,
in the orchestration store — the profile owns it via `commit`.)
## 1.4 Tests (this phase)
- A `FakeProfile` (scripted observations/reflections, call recorder) driving
`SubconsciousInstance` directly:
- quiet observation → no `reflect`, `commit` called, `last_tick_at` advanced
- failing `reflect` → no `commit`, `consecutive_failures` bumped, baseline untouched
- rate-cap error string → halt armed under `"<id>|<provider-sig>"`; second
tick skips; signature change resumes
- superseded generation → result discarded, no `commit`
- Store: namespaced get/set round-trip; legacy-key migration (seed old keys,
open, assert `memory:`-keys carry the values).
Existing `engine_tests.rs` keeps passing untouched (the old engine still
exists in this phase).
@@ -0,0 +1,58 @@
# Phase 2 — Extract the memory profile (behavior-identical)
Goal: move today's stages 13 out of `engine.rs` into
`profiles/memory.rs`, switch the live engine to
`SubconsciousInstance::new(MemoryProfile, config)`, and delete the old
monolithic `tick_inner`. **No behavior change** — this phase is a pure
extraction, verified by keeping `engine_tests.rs` green against the new
composition.
## 2.1 What moves where
| Today (`engine.rs`) | Destination |
| --- | --- |
| baseline load + `diff_since_checkpoint` + `world_diff_change_count` | `MemoryProfile::observe` |
| `render_world_diff` + `MAX_ITEMS_PER_SOURCE` | `profiles/memory.rs` (private) |
| `prepare_context` (context_scout + `with_root_parent`, TAURI-RUST-HMW) | `MemoryProfile::prepare_context` |
| `SUBCONSCIOUS_TOOL_CATALOG` | `profiles/memory.rs` |
| `run_agent` (slim agent, `hint:subconscious`, Full autonomy, mode → iteration caps, user-message contract) | `MemoryProfile::reflect` |
| `refresh_baseline` (`create_checkpoint` + persist id) | `MemoryProfile::commit` |
| `tick_origin_source` | `MemoryProfile::origin` (keep the free fn for its unit test) |
| **the inline `run_orchestration_review` call (stage 0)** | **stays temporarily** — moved in phase 3 |
Note on the stage-0 call: during phase 2 it lives in a small shim at the top
of the runner (`if profile.id() == "memory" { run_orchestration_review(...) }`)
or equivalently stays as a pre-hook closure passed by the bootstrap. Ugly on
purpose and clearly marked `// phase-3 removes this`; it keeps phase 2 purely
mechanical.
## 2.2 `MemoryProfile` specifics
- `id()``"memory"`; `cadence``mode.default_interval_minutes().max(5)`
minutes (today's value).
- `observe`: returns `has_changes == false` for first-tick/no-baseline, quiet
window, or diff error (each with today's log lines). Sets
`has_external_content = true` whenever there are changes (every change
originates from a source sync — today's comment carries over).
- `reflect`: returns `Reflection::Acted { response_chars }`; the SubconsciousMode
`max_tool_iterations` mapping (15 / 30 / Off→short-circuit) is unchanged.
- `commit`: re-checkpoint + persist under the namespaced key
(`memory:baseline_checkpoint_id`). Best-effort, warn on failure (unchanged).
- `origin`: `tick_origin_source(obs.has_external_content)`.
## 2.3 Public-surface continuity
`mod.rs` keeps exporting `SubconsciousEngine` (alias), `SubconsciousStatus`,
`TickResult`, `notify_user`, session/source_chunk items — nothing outside the
domain changes in this phase. `global.rs` swaps its construction call only.
## 2.4 Tests
- Port `engine_tests.rs` to construct `SubconsciousInstance` with
`MemoryProfile` — assertions unchanged (rate-cap halt lifecycle, provider
routing/signature, origin escalation, tool-capability detection).
- New: `MemoryProfile::observe` unit tests over a seeded memory_diff store
(quiet vs changed windows), reusing whatever fixture `memory_diff` tests use.
- The agent-toolset isolation test in `orchestration/ops.rs`
(`subconscious_agent_tool_surface_has_no_channel_or_effect_tools`) keeps
compiling — `agent/agent.toml` does not move.
@@ -0,0 +1,85 @@
# Phase 3 — The tiny.place profile (orchestration steering)
Goal: make the tiny.place/orchestration subconscious a first-class instance
and delete the stage-0 shim from the runner. After this phase the two worlds
tick independently: separate locks, cadences, circuit breakers, failure
counters, and status rows.
## 3.1 `profiles/tinyplace.rs`
Wraps the machinery that already exists in `orchestration/`:
- `id()``"tinyplace"`.
- `cadence`: its own knob (see 3.3); default = the heartbeat interval, so the
merged behavior matches today (review ran once per memory tick).
- `observe`: the load half of `run_orchestration_review`
`review_cursor``list_unreviewed_compressed(REVIEW_BATCH)` +
`list_recent_world_mutations(REVIEW_BATCH)` + `current_cycle_counter`.
`has_changes = !compressed.is_empty()` (today's idle gate).
`rendered = build_steering_prompt(&summaries, &mutations)`.
`commit_token = newest reviewed created_at`.
`has_external_content = true` always — harness DMs are third-party content.
- `prepare_context`: default no-op (steering is deliberately tool-free; no
scout).
- `reflect`: the synth half — `synthesize_steering` (tool-free
`create_chat_provider("subconscious")` chat, `SubconsciousTainted` origin,
one retry on contract violation) + persist
(`insert_steering_directive`, supersede prior, `record_subconscious_directive`
into the local Subconscious window + event publish). Returns
`Steered { directive_id }` or `Idle` (clean `NONE` / twice-failed —
today both still advance the cursor; preserve that by returning `Idle`,
not `Err`, for those cases).
- `commit`: `set_review_cursor(commit_token)`. This is where the phase pays
off: today the cursor advance is tangled into `run_orchestration_review`'s
three exit paths; splitting observe/reflect/commit lets the runner enforce
"advance only when the tick wasn't superseded" uniformly.
- `origin`: always `SubconsciousTainted`.
## 3.2 Refactor of `orchestration::ops`
`run_orchestration_review` is currently the public entry called by the
subconscious engine. Plan:
- Split it into store-facing pieces the profile calls
(`load_review_window(config) -> ReviewWindow`,
`synthesize_and_persist(config, window, tick_id) -> Option<i64>`), keeping
them in `orchestration::ops` — the orchestration domain still owns its
store shapes and the steering contract; the subconscious profile is just
the *scheduler + policy* around them.
- Keep a thin `run_orchestration_review` wrapper for the two existing tests
(or port those tests to drive the profile; implementor's choice — the
invariants asserted must survive: emit-then-inject-next-cycle, idempotent
re-tick, exactly-one-directive).
- Delete the stage-0 shim in the subconscious runner (`// phase-3 removes
this` marker from phase 2).
## 3.3 Config
- `orchestration.enabled` remains the master gate for the tinyplace instance
(profile `observe` returns quiet when disabled — same as today's early
`Ok(false)`).
- Add `orchestration.review_interval_minutes: Option<u32>` (None = heartbeat
interval) consumed by `cadence`. Schema change in
`config/schema/orchestration.rs` + env override in `load.rs` +
`.env.example` note.
## 3.4 Isolation invariants (unchanged, now testable per-instance)
- The tinyplace reflect path constructs **no Agent and no toolset** — it is a
provider chat. Add a compile-level guard mirroring the existing agent-toml
test: the profile module must not import `tinyplace::agent_tools` or any
`send_message`-family symbol (source-scan test like
`orchestration_logs_never_reference_message_bodies`).
- The steering directive remains an out-of-band writer: it is read by
`apply_cycle_steering` at the next wake, never edges into the graph.
## 3.5 Tests
- Profile-level: seeded orchestration store → observe has_changes; scripted
provider (existing `test_provider_override`) → `Steered`; cursor advanced
via runner commit; re-tick idle.
- Runner-level: tinyplace + memory instances ticking concurrently against one
workspace — no lock contention between them, independent `last_tick_at`
keys, one instance's rate-cap halt does not gate the other.
- Regression: the full-cycle orchestration graph test keeps passing (steering
injection path untouched).
@@ -0,0 +1,90 @@
# Phase 4 — Factory, registry lifecycle, heartbeat fan-out, RPC
Goal: the "make subconscious" surface — instantiate any set of worlds, drive
them from the heartbeat, expose per-instance status/trigger over JSON-RPC.
## 4.1 `factory.rs`
```rust
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SubconsciousKind { Memory, TinyPlace }
impl SubconsciousKind {
pub fn id(self) -> &'static str; // "memory" | "tinyplace"
pub fn parse(s: &str) -> Option<Self>;
/// Which kinds should run for this config (bootstrap set).
pub fn enabled_kinds(config: &Config) -> Vec<Self> {
// Memory ⇐ heartbeat.enabled && mode != Off (today's gate)
// TinyPlace ⇐ orchestration.enabled (today's gate)
}
}
pub fn make_subconscious(kind: SubconsciousKind, config: &Config) -> SubconsciousInstance;
```
`make_subconscious` is the *only* place profiles are constructed — tests and
the trigger RPC go through it too, so a new kind is: profile file + one match
arm + one `enabled_kinds` line.
## 4.2 `registry.rs` (grown from `global.rs`)
Replace the single `OnceLock<Arc<Mutex<Option<SubconsciousEngine>>>>` with a
keyed registry:
```rust
static REGISTRY: OnceLock<Mutex<HashMap<SubconsciousKind, Arc<SubconsciousInstance>>>>;
```
- `get_or_init_instance(kind)` — lazy per-kind construction (same
load-config-then-insert dance as today's `get_or_init_engine`).
- `bootstrap_after_login()` — unchanged guard (`BOOTSTRAPPED` swap), then
initializes every `enabled_kinds(config)` member, spawns the heartbeat, and
(unchanged) the opt-in trigger orchestrator.
- `stop_heartbeat_loop()` / `reset_engine_for_user_switch()` — abort the
heartbeat, shut down the trigger orchestrator, then clear the whole map so
a user switch rebuilds every instance against the new workspace.
- Keep `get_or_init_engine()` as a deprecated alias for
`get_or_init_instance(Memory)` until the RPC handlers are ported (below),
then delete it.
Instances stay `Arc` (not `Mutex<Option<..>>`): the instance's own
`tick_lock`/`state` mutexes already serialize what needs serializing, and the
status path must remain lock-free per invariant 5.
## 4.3 Heartbeat fan-out
`heartbeat/engine.rs` currently calls the single engine's `tick()` on its
interval. Change: on each heartbeat interval, iterate the registry and tick
every instance whose `cadence` has elapsed since its `last_tick_at`
(per-instance keys from phase 1 make this a pure read). Instances tick
**concurrently** (`tokio::spawn` per instance, joined with the existing
cancel/abort semantics) — a slow memory tick must not delay a tinyplace
review. The heartbeat's event-planner duties (meetings/reminders) are
untouched.
## 4.4 RPC surface (`schemas.rs`)
Backward-compatible extension of the `subconscious` namespace:
- `subconscious.status` → today's top-level fields stay, populated from the
**memory** instance (existing UI keeps working), plus
`instances: [SubconsciousStatus]` with one row per registered kind
(each row gains `instance: "memory" | "tinyplace"`).
- `subconscious.trigger` → optional `kind` param (`"memory"` default —
today's behavior; `"tinyplace"`; `"all"`). Still fire-and-forget
(spawned, returns immediately).
- Status reads stay SQLite-only: per-instance `last_tick_at` comes from the
namespaced KV; in-memory counters (failures, halt reason) come from the
instance's `status()` which takes only the small `state` mutex, never
`tick_lock` — same as today.
Frontend consumption of these fields is phase 7 (instance cards in the
Subconscious tab, steering header in the TinyPlace Orchestration tab). Not a
blocker for the Rust work — the additions are backward-compatible.
## 4.5 `about_app`
Update `src/openhuman/about_app/` copy: the subconscious is now described as
per-world instances (memory awareness + tiny.place orchestration steering),
per the repo rule that user-facing feature changes update about_app.
@@ -0,0 +1,65 @@
# Phase 5 — Test matrix, docs, rollout
## 5.1 Test matrix (cumulative — most land inside phases 14)
| Layer | Test | Phase |
| --- | --- | --- |
| runner | FakeProfile: quiet / fail / rate-cap-halt / superseded lifecycles | 1 |
| store | key namespacing + legacy→`memory:` migration round-trip | 1 |
| memory profile | observe over seeded diff store; origin escalation; mode→iteration caps | 2 |
| ported | all of today's `engine_tests.rs` against `SubconsciousInstance<MemoryProfile>` | 2 |
| tinyplace profile | observe/reflect/commit against scripted provider; idle-NONE advances cursor | 3 |
| cross-instance | two instances, one workspace: independent state keys, halts, no lock coupling | 3 |
| factory | `enabled_kinds` gating per config; `make_subconscious` per kind | 4 |
| registry | bootstrap set, user-switch reset clears map, deprecated alias | 4 |
| heartbeat | fan-out ticks only elapsed cadences; concurrent tick spawn | 4 |
| RPC | `status` legacy fields mirror memory; `instances` rows; `trigger kind=…` | 4 |
| json_rpc_e2e | extend `tests/json_rpc_e2e.rs`: `subconscious.status` shape incl. `instances` | 4 |
| isolation | tinyplace source-scan (no agent/toolset imports); agent.toml scan (exists) | 3 |
Coverage gate: each phase is its own PR-sized slice with ≥80% diff coverage;
the extraction phases (2, 3) inherit most coverage from ported tests.
## 5.2 Docs
- `src/openhuman/subconscious/README.md` — rewrite around the factory: the
generic tick skeleton, the profile table (memory / tinyplace), per-instance
persistence keys, RPC additions. Keep the gotchas list (post-login
bootstrap, status-never-locks, state-advances-on-success, quiet-tick
short-circuit, taint) — all still true, now per instance.
- `orchestration/mod.rs` module docs — stage 6 wording: the review is now
driven by the tinyplace subconscious instance, not inlined in the memory
tick.
- `gitbooks/developing/architecture/agent-harness.md` — if it names the
subconscious loop, reflect the split. Run `pnpm docs:generate` +
`pnpm docs:check` (Docs Drift lane) in the final slice.
- `about_app` copy (phase 4.5).
## 5.3 Rollout & risk
- **No data-shape risk**: the only migration is KV key renaming inside
`subconscious.db` (phase 1.3), designed to be old-version-tolerant. The
orchestration store is untouched.
- **Behavioral deltas to call out in the PR series** (all intentional):
1. The orchestration review no longer piggybacks on the memory tick — it
runs on its own cadence, so it also runs when the memory world is quiet
(today a memory-provider outage silently starves steering; after phase 3
it doesn't).
2. A rate-cap halt on one world no longer pauses the other.
3. `subconscious.status` gains fields (additive only).
- **Branch/PR sequencing**: one branch per phase off `upstream/main`
(`feat/subconscious-profile-core`, `feat/subconscious-memory-profile`, …),
stacked; each independently green on ci-lite. Phase 2+3 could merge as one
PR if review prefers seeing the shim appear and disappear together.
## 5.4 Explicit non-goals (this plan)
- No change to the orchestration wake graph, compression ratio, context-guard
thresholds, or steering contract — those already implement the spec.
- No new subconscious kinds beyond the two (the factory makes them cheap
later; e.g. a `team` world or a `channels` world).
- No frontend redesign beyond phase 7's scoped additions (instance cards +
steering header) — no new pages, routes, or Redux slices.
- The opt-in event-driven trigger pipeline (`subconscious_triggers` +
`LongLivedSession`) keeps its current shape; folding it into a profile is a
possible follow-up once the factory exists.
@@ -0,0 +1,83 @@
# Phase 6 — TinyAgents graph reuse & upstream changes
The subconscious runner (phase 1.2) is built on `tinyagents::graph` — the same
durable-workflow runtime the orchestration wake path uses. This phase tracks
(a) what we reuse as-is, and (b) the gaps that warrant changes **inside
`vendor/tinyagents`**, which is a git submodule of the separate repo
`tinyhumansai/tinyagents`: those changes go on their own branch there, raised
as their **own PR**, and the openhuman PR bumps the submodule pointer to the
merged commit (same workflow as `docs/plans/rlm-workflows/phase-2-tinyagents.md`).
## 6.1 Reused as-is (no upstream change)
| Need | tinyagents surface |
| --- | --- |
| tick topology + conditional quiet/changed routing | `GraphBuilder`, `Route`/command-routing, `START`/`END` |
| typed per-tick state | serde state + `LastValue` channels |
| crash-safe resume of an interrupted tick | `SqliteCheckpointer`, `CheckpointConfig`/`DurabilityMode` |
| injected behavior (profile = runtime) | the `dyn`-runtime node pattern from `orchestration/graph/build.rs` |
| stub-driven mechanics tests | `graph::testkit` |
| per-node timing / run snapshots | `graph::stream`, `graph::status` |
| bounded loops (none today, future kinds may loop) | `max_supersteps` backstop |
## 6.2 Candidate upstream changes (each = one small PR to `tinyhumansai/tinyagents`)
Confirm each gap against the vendored source before writing code; drop any
that turn out to already exist. Ordered by how much openhuman scaffolding
they delete:
1. **Wall-clock graph deadline.** Today `TICK_TIMEOUT` wraps the whole run in
`tokio::time::timeout` from the outside, which cannot leave a clean
checkpoint. Add an optional per-run deadline to `GraphExecution` (abort
between supersteps + best-effort in-flight cancellation, surfaced as a
typed `GraphTimeout` error with the last checkpoint intact). Benefits the
orchestration wake graph too.
2. **External cancellation/supersede token.** The generation-counter
supersede (`tick_generation`) currently only takes effect at the *end* of
a tick (result discarded after the LLM spend). A shared cancel flag
checked between supersteps — the graph twin of the REPL's `ReplCancelFlag`
from the rlm-workflows plan — lets a newer tick abort an in-flight one
before its reflect call completes. If the REPL flag work landed, reuse its
primitive rather than adding a second one.
3. **Checkpoint GC / retention.** Ticks are frequent (every N minutes,
forever), so `graph_checkpoints.db` grows unboundedly. Add a retention
policy to `SqliteCheckpointer` (keep last K threads / prune completed
threads older than T). The wake graph has the same latent issue.
4. **(Stretch) periodic-trigger helper.** A `graph::orchestration`-level
"run this compiled graph every interval with jitter + overlap policy"
would absorb our heartbeat fan-out (phase 4.3). Only worth it if the
upstream maintainers want it — the openhuman heartbeat already works, so
this is explicitly optional and last.
Items 13 are independent; none blocks phases 15. Phase 1 ships with the
outer `tokio::time::timeout` + end-of-tick supersede exactly as today, and
swaps to the upstream primitives when the submodule bump lands — each swap is
a small follow-up PR that deletes code.
## 6.3 Sequencing with the vendor submodule
1. Branch in `vendor/tinyagents` (e.g. `feat/graph-run-deadline`), implement
with unit tests in the tinyagents style (`types.rs`/`mod.rs`/`test.rs`),
PR against `tinyhumansai/tinyagents`.
2. Meanwhile openhuman phases 15 proceed against the current pin.
3. After the upstream merge: one openhuman PR per adopted primitive — bump
the submodule pointer, replace the scaffolding (external timeout →
deadline; end-of-tick supersede → cancel flag; add GC config), keep the
behavior tests green.
4. Never point the openhuman submodule at an unmerged tinyagents branch on
`main`-bound PRs.
## 6.4 Confirmation findings (checked against the current vendored pin)
Each candidate from §6.2 was checked against `vendor/tinyagents/src/graph/`:
| # | Candidate | Status against the current pin |
| --- | --- | --- |
| 1 | Wall-clock graph deadline | **Gap confirmed.** Only a *per-node* timeout exists (`ExecutorConfig::node_timeout``TinyAgentsError::Timeout` in `compiled/executor.rs`) and `total_timeout`/`item_timeout` on `parallel`. There is no whole-run deadline on `run_with_thread`. Still a valid upstream PR. Openhuman keeps the outer `tokio::time::timeout(TICK_TIMEOUT, …)` for now. |
| 2 | External cancel/supersede token | **Gap confirmed (primitive exists, not wired).** `harness::cancel::CancellationToken` exists and is honoured by `parallel` (`with_cancellation`), but the main `CompiledGraph` superstep loop (`compiled/executor.rs`) does not check it between supersteps. Upstream work is "thread the existing token into the graph executor", not a new primitive. Openhuman keeps the end-of-tick + `commit`-node generation check for now. |
| 3 | Checkpoint GC / retention | **Already exists — adopted now.** `Checkpointer` already exposes `prune(thread_id, keep_last)` and `delete_thread(thread_id)` (default trait methods + sqlite/file impls, `checkpoint/mod.rs`). No upstream PR needed. `SubconsciousInstance::run_graph` now calls `delete_thread` on the tick's unique thread after the run returns, so `graph_checkpoints.db` stays bounded (test: `completed_ticks_leave_no_checkpoint_threads`). |
Net: candidate #3 is done in-tree using an existing primitive; candidates #1
and #2 remain genuine, independent follow-up PRs against
`tinyhumansai/tinyagents` and do **not** block this plan — phase 1 already ships
the outer timeout + end-of-tick supersede they would later replace.
@@ -0,0 +1,95 @@
# Phase 7 — UI: see and interact with both subconscious kinds
Goal: the two instances become visible, distinguishable, and individually
triggerable in the app — in the **Subconscious tab** (both kinds' health and
controls) and in the **TinyPlace Orchestration tab** (the tinyplace kind in
its natural habitat, next to the steering it produces). Depends on the
phase-4 RPC additions (`instances[]` on status, `kind` on trigger); pure
`app/src` work otherwise. Existing building blocks are reused — notably the
orchestration tab already renders the pinned Subconscious chat window
(`chat.kind === 'subconscious'` in `TinyPlaceOrchestrationTab.tsx`), which is
where directives stream in today via `useOrchestrationChats`.
## 7.1 Types + clients (`app/src/utils/tauriCommands/subconscious.ts`)
- Extend `SubconsciousStatus` with the additive phase-4 fields:
`instances: SubconsciousInstanceStatus[]`, where each row is today's status
shape plus `instance: 'memory' | 'tinyplace'`. Legacy top-level fields keep
mirroring the memory instance, so nothing breaks while the UI migrates.
- `subconsciousTrigger(kind?: 'memory' | 'tinyplace' | 'all')` — optional
param passed through to `openhuman.subconscious_trigger`; no-arg call keeps
today's behavior (memory).
- All calls stay on `callCoreRpc` / `core_rpc_relay` per the frontend rules.
## 7.2 Hook (`app/src/hooks/useSubconscious.ts`)
- Surface `instances` alongside the existing fields; add
`triggerTick(kind?)` and per-kind `triggering` state (two buttons must not
share one spinner).
- Keep polling/refresh cadence as-is; one status call still feeds everything.
## 7.3 Subconscious tab (`IntelligenceSubconsciousTab.tsx`)
Rework the status area from "one engine" to **two instance cards** under the
existing mode/interval controls (which continue to govern the memory
instance — mode semantics don't apply to tinyplace):
- **Memory card** ("Your world" / connected sources): today's status row —
total ticks, last tick, consecutive failures, provider-unavailable banner
with the Settings deep-link — plus its own *Run now*.
- **TinyPlace card** ("Orchestration steering"): enabled state (mirrors
`orchestration.enabled`; show a disabled-with-hint state when off), last
review tick, failures/halt reason, *Run review now*
(`triggerTick('tinyplace')`), and a "View directives →" link that navigates
to the TinyPlace Orchestration tab with the Subconscious window selected.
- Shared `SubconsciousInstanceCard` component
(`app/src/components/intelligence/SubconsciousInstanceCard.tsx`) so a third
kind later is a data change, not new JSX.
## 7.4 TinyPlace Orchestration tab (`TinyPlaceOrchestrationTab.tsx`)
The pinned Subconscious chat window already shows emitted directives (built
in an earlier slice — reuse, don't rebuild). Add the *instance* dimension on
top of it:
- A compact **steering status header** above that window's transcript:
current directive (or "no active directive"), `expires_after_cycles`
countdown against the live cycle counter, last review time, and a
*Run review now* button — same `triggerTick('tinyplace')` path as 7.3.
Data comes from the phase-4 status row plus a small read RPC if needed
(`orchestration.status` already exists as the stage-7 read surface; extend
it with `current_directive` rather than inventing a new method).
- Badge the window in the chat list ("Subconscious · steering") so the two
meanings of "subconscious" in the product read as one system: the tab shows
the tinyplace instance *output*, the Subconscious tab shows both instances'
*health*.
- Cross-link back: the header links to the Subconscious tab for controls.
## 7.5 i18n
New keys (instance card titles/descriptions, run-review, steering header,
badge) go into `en.ts` **and real translations in all locale files**
(`ar bn de es fr hi id it ko pl pt ru zh-CN`). Gate: `pnpm i18n:check` +
`pnpm i18n:english:check`.
## 7.6 Tests
- Vitest, co-located:
- `subconscious.ts` client: trigger passes `kind`; status parses
`instances` and tolerates its absence (older core during rollout).
- `useSubconscious`: per-kind triggering state; instances plumbed through.
- `IntelligenceSubconsciousTab`: renders two cards from a stubbed status;
tinyplace card disabled state when orchestration is off; per-card Run now
dispatches the right kind.
- `TinyPlaceOrchestrationTab`: steering header renders directive/empty
states; run-review calls trigger with `tinyplace`.
- E2E (desktop spec, mock backend): Subconscious tab shows both cards;
triggering the tinyplace review surfaces a directive message in the
orchestration Subconscious window (mock `__admin/behavior` scripted).
## 7.7 Slicing
Two commits minimum: (a) types/client/hook + Subconscious tab cards,
(b) orchestration tab steering header + cross-links. i18n rides with each.
This phase can start as soon as phase 4's RPC shape is merged; it does not
depend on phases 56.
+2 -1
View File
@@ -2088,7 +2088,8 @@ async fn run_server_inner(
if !config.heartbeat.enabled {
log::info!("[subconscious] disabled by config (heartbeat.enabled = false)");
} else {
match crate::openhuman::subconscious::global::bootstrap_after_login().await
match crate::openhuman::subconscious::registry::bootstrap_after_login()
.await
{
Ok(()) => log::info!(
"[subconscious] bootstrapped on startup (existing session)"
+13 -9
View File
@@ -133,7 +133,7 @@ fn run_tick(args: &[String]) -> Result<()> {
// Check provider availability
if let Some(reason) =
crate::openhuman::subconscious::engine::subconscious_provider_unavailable_reason(
crate::openhuman::subconscious::provider::subconscious_provider_unavailable_reason(
&config,
)
{
@@ -143,7 +143,7 @@ fn run_tick(args: &[String]) -> Result<()> {
// Create engine and run tick. The engine pulls its own memory_diff /
// context state from the workspace — no memory client to pass in.
let engine = crate::openhuman::subconscious::SubconsciousEngine::new(&config);
let engine = crate::openhuman::subconscious::memory_instance(&config);
eprintln!("[subconscious] running tick...");
let result = engine
@@ -160,7 +160,11 @@ fn run_tick(args: &[String]) -> Result<()> {
// Print the world baseline the next tick will diff against.
let baseline = crate::openhuman::subconscious::store::with_connection(
&config.workspace_dir,
crate::openhuman::subconscious::store::get_baseline_checkpoint_id,
|conn| {
crate::openhuman::subconscious::store::get_baseline_checkpoint_id(
conn, "memory",
)
},
)
.unwrap_or(None);
match baseline {
@@ -189,18 +193,18 @@ fn run_status(args: &[String]) -> Result<()> {
let mode = config.heartbeat.effective_subconscious_mode();
let provider_reason = if mode.is_enabled() {
crate::openhuman::subconscious::engine::subconscious_provider_unavailable_reason(
crate::openhuman::subconscious::provider::subconscious_provider_unavailable_reason(
&config,
)
} else {
None
};
let last_tick = crate::openhuman::subconscious::store::with_connection(
&config.workspace_dir,
crate::openhuman::subconscious::store::get_last_tick_at,
)
.ok();
let last_tick =
crate::openhuman::subconscious::store::with_connection(&config.workspace_dir, |conn| {
crate::openhuman::subconscious::store::get_last_tick_at(conn, "memory")
})
.ok();
let status = serde_json::json!({
"mode": mode.as_str(),
+4 -2
View File
@@ -1798,8 +1798,10 @@ pub(super) const CAPABILITIES: &[Capability] = &[
category: CapabilityCategory::Intelligence,
description: "Coordinate wrapped Claude Code / Codex sessions over tiny.place: a \
split-brain wake graph (quick front end + reasoning core) replies to \
session DMs, and an offline subconscious reflects on the world diff to \
steer later cycles.",
session DMs, and a dedicated tiny.place subconscious instance reflects on \
the compressed history + world diff on its own cadence to steer later \
cycles — separate from the memory subconscious that watches your \
connected sources.",
how_to: "Intelligence > Orchestration (pair a wrapped session, then chat via the Master \
window).",
status: CapabilityStatus::Beta,
+2 -2
View File
@@ -529,7 +529,7 @@ async fn finish_revalidated_user_activation(
crate::openhuman::memory_conversations::register_conversation_persistence_subscriber(
target_config.workspace_dir.clone(),
);
if let Err(error) = crate::openhuman::subconscious::global::bootstrap_after_login().await {
if let Err(error) = crate::openhuman::subconscious::registry::bootstrap_after_login().await {
warn!("{LOG_PREFIX} subconscious bootstrap failed after pending session revalidation: {error}");
}
if let Some(source_config) = service_rebind_source {
@@ -672,7 +672,7 @@ async fn clear_deferred_session_after_backend_rejection(
Err(_) => {}
}
crate::openhuman::credentials::stop_login_gated_services(config).await;
crate::openhuman::subconscious::global::reset_engine_for_user_switch().await;
crate::openhuman::subconscious::registry::reset_engine_for_user_switch().await;
crate::openhuman::credentials::sentry_scope::clear();
clear_result
@@ -102,6 +102,20 @@ impl Config {
}
}
if let Some(raw) = env.get("OPENHUMAN_ORCH_REVIEW_INTERVAL_MINUTES") {
let trimmed = raw.trim();
if !trimmed.is_empty() {
match trimmed.parse::<u32>() {
Ok(mins) => self.orchestration.review_interval_minutes = Some(mins),
Err(_) => tracing::warn!(
env = "OPENHUMAN_ORCH_REVIEW_INTERVAL_MINUTES",
value = %raw,
"invalid tinyplace review interval ignored; expected an unsigned integer (minutes)"
),
}
}
}
if let Some(language) = env.get("OPENHUMAN_OUTPUT_LANGUAGE") {
let language = language.trim();
if !language.is_empty() {
@@ -66,6 +66,14 @@ pub struct OrchestrationConfig {
/// spawn per cycle. Default: `2`.
#[serde(default = "default_subagent_concurrency")]
pub subagent_concurrency: u32,
/// Cadence (minutes) of the `tinyplace` subconscious steering review — the
/// offline reflection that emits `STEERING_DIRECTIVE`s over the compressed
/// execution history. `None` (the default) means "use the heartbeat
/// interval", so the merged behaviour matches the pre-factory build where
/// the review ran once per memory tick. Env: `OPENHUMAN_ORCH_REVIEW_INTERVAL_MINUTES`.
#[serde(default)]
pub review_interval_minutes: Option<u32>,
}
impl OrchestrationConfig {
@@ -73,6 +81,15 @@ impl OrchestrationConfig {
pub fn effective_evict_threshold(&self) -> f32 {
self.context_evict_threshold.clamp(0.8, 0.9)
}
/// The steering-review cadence in minutes: the explicit
/// `review_interval_minutes` when set, else the supplied heartbeat interval
/// (floored at 1 so it can never be a busy-loop).
pub fn effective_review_interval_minutes(&self, heartbeat_interval: u32) -> u32 {
self.review_interval_minutes
.unwrap_or(heartbeat_interval)
.max(1)
}
}
impl Default for OrchestrationConfig {
@@ -84,6 +101,7 @@ impl Default for OrchestrationConfig {
message_window: default_message_window(),
context_evict_threshold: default_evict_threshold(),
subagent_concurrency: default_subagent_concurrency(),
review_interval_minutes: None,
}
}
}
+2 -2
View File
@@ -456,7 +456,7 @@ async fn store_session_inner(
// heartbeat loop. Idempotent — no-op on subsequent logins of the same
// process. Bootstrap failures are non-fatal: the session itself is
// already stored above, so we only warn.
if let Err(e) = crate::openhuman::subconscious::global::bootstrap_after_login().await {
if let Err(e) = crate::openhuman::subconscious::registry::bootstrap_after_login().await {
tracing::warn!(error = %e, "[subconscious] post-login bootstrap failed");
logs.push(format!("subconscious bootstrap warning: {e}"));
} else {
@@ -597,7 +597,7 @@ pub async fn clear_session(config: &Config) -> Result<RpcOutcome<serde_json::Val
// cached engine would keep pointing at the previous user's workspace_dir
// and the heartbeat task would leak, ticking against the wrong DB when a
// different user signs in to the same sidecar process.
crate::openhuman::subconscious::global::reset_engine_for_user_switch().await;
crate::openhuman::subconscious::registry::reset_engine_for_user_switch().await;
// Drop the Sentry scope user so events surfaced during/after teardown
// (and before the next login) are no longer attributed to the
@@ -696,7 +696,7 @@ pub fn log_context_window_exceeded(
/// [`crate::core::observability::is_provider_user_state_message`] (Sentry
/// demotion of the `domain=agent` re-report) and the subconscious tick loop's
/// permanent-rejection circuit breaker
/// (`crate::openhuman::subconscious::engine`).
/// (`crate::openhuman::subconscious::provider`).
pub fn is_provider_rate_cap_exceeded_message(body: &str) -> bool {
let lower = body.to_ascii_lowercase();
lower.contains("request too large")
+86 -39
View File
@@ -16,6 +16,7 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::openhuman::config::Config;
@@ -183,26 +184,69 @@ fn advance_cursor(config: &Config, agent_id: &str, session_id: &str, latest: i64
}
// ── Stage 6: subconscious orchestration review ──────────────────────────────
//
// The review is driven by the dedicated **`tinyplace` subconscious instance**
// (`subconscious::profiles::tinyplace`), which ticks on its own cadence via the
// heartbeat fan-out — it no longer piggybacks on the memory tick. That profile
// calls [`load_review_window`] (observe) + [`synthesize_and_persist`] (reflect)
// and advances the review cursor from its own `commit`. The all-in-one
// [`run_orchestration_review`] wrapper below is retained for its unit tests.
/// The subconscious tick's `orchestration_review` stage (stage 6): reflect over
/// the orchestration layer's unreviewed compressed history + cumulative
/// world-diff timeline and, if a macro-trend warrants it, emit **one** steering
/// directive that later reasoning cycles inject into their prompt.
/// Reflect over the orchestration layer's unreviewed compressed history +
/// cumulative world-diff timeline and, if a macro-trend warrants it, emit **one**
/// steering directive that later reasoning cycles inject into their prompt.
///
/// Fully offline: a single **tool-free** provider chat on the `subconscious`
/// route (structurally isolated — no channel/effect tools reachable). Self-gating
/// (no-op when orchestration is disabled or there is nothing new to review) and
/// idempotent (advances a review cursor only after a successful persist). Returns
/// `Ok(true)` when a directive was emitted.
/// idempotent (advances a review cursor after the persist). Returns `Ok(true)`
/// when a directive was emitted. The live tick path uses the split
/// `load_review_window` + `synthesize_and_persist` instead (see the stage note).
pub async fn run_orchestration_review(
config: &Config,
source_tick_id: &str,
) -> Result<bool, String> {
if !config.orchestration.enabled {
let Some(window) = load_review_window(config).await? else {
return Ok(false);
};
let emitted = synthesize_and_persist(config, &window, source_tick_id).await?;
// The all-in-one wrapper advances the cursor itself (idle or emitted) to
// preserve the pre-split behaviour; the tinyplace profile instead advances
// it from its `commit`, so a superseded tick can't skip rows.
if let Some(newest) = &window.newest_reviewed {
let _ = store::with_connection(&config.workspace_dir, |conn| {
store::set_review_cursor(conn, newest)
});
}
Ok(emitted.is_some())
}
/// The unreviewed slice of orchestration history a steering review reflects
/// over, plus the cursor token that pins exactly the window observed. Serde so
/// it can ride the subconscious tick graph's checkpointed state.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReviewWindow {
/// Compressed-history summaries new since the review cursor (oldest-first).
pub summaries: Vec<String>,
/// The cumulative world-diff mutation timeline (context, not the trigger).
pub mutations: Vec<String>,
/// Reasoning-cycle counter stamped on an emitted directive.
pub current_cycle: i64,
/// How many compressed rows this window folded (for `derived_from`).
pub compressed_count: usize,
/// Newest reviewed compressed-row `created_at` — the commit cursor. `None`
/// only on an empty window (which never produces a `Some(window)`).
pub newest_reviewed: Option<String>,
}
/// Stage 6 load half: the unreviewed compressed history + cumulative world-diff
/// timeline. Self-gating — returns `None` (a clean quiet tick) when orchestration
/// is disabled or there is nothing new since the review cursor.
pub async fn load_review_window(config: &Config) -> Result<Option<ReviewWindow>, String> {
if !config.orchestration.enabled {
return Ok(None);
}
// 1. Load unreviewed compressed history + the cumulative world-diff timeline.
let (compressed, mutations, current_cycle) =
store::with_connection(&config.workspace_dir, |conn| {
let cursor = store::review_cursor(conn)?;
@@ -215,54 +259,57 @@ pub async fn run_orchestration_review(
// Idempotence trigger: a review fires only on **new** compressed history
// since the cursor. Compressed rows are written every cycle alongside the
// world diff, so this makes a re-tick with no new data a clean no-op while
// still handing the model the full cumulative world timeline for context.
// world diff, so a re-tick with no new data is a clean no-op while still
// handing the model the full cumulative world timeline for context.
if compressed.is_empty() {
log::debug!(target: LOG, "[orchestration] review.idle — no new compressed history");
return Ok(false);
return Ok(None);
}
let newest_reviewed = compressed.iter().map(|(c, _)| c.clone()).max();
let summaries: Vec<String> = compressed.iter().map(|(_, t)| t.clone()).collect();
Ok(Some(ReviewWindow {
summaries: compressed.iter().map(|(_, t)| t.clone()).collect(),
compressed_count: compressed.len(),
mutations,
current_cycle,
newest_reviewed,
}))
}
// 2. Synthesize offline (tool-free chat, tainted origin). Retry once on a
// contract violation; a clean NONE is a valid idle response.
let prompt = build_steering_prompt(&summaries, &mutations);
let parsed = synthesize_steering(config, &prompt, source_tick_id).await;
let Some(parsed) = parsed else {
// No directive this window (idle, NONE, or twice-failed). Advance the
// cursor so we don't reflect on the same rows forever — a transient model
// failure simply yields no directive for this batch.
if let Some(newest) = newest_reviewed {
let _ = store::with_connection(&config.workspace_dir, |conn| {
store::set_review_cursor(conn, &newest)
});
}
return Ok(false);
/// Stage 6 synthesize half: reflect over `window` offline (tool-free chat,
/// tainted origin) and, when a macro-trend warrants it, persist **one** steering
/// directive (superseding the prior) + surface it in the local Subconscious
/// window. Returns the new directive id, or `None` on a clean NONE / twice-failed.
///
/// Deliberately does **not** advance the review cursor — the caller owns that so
/// a superseded tick cannot skip rows (the tinyplace profile advances it from
/// `commit`).
pub async fn synthesize_and_persist(
config: &Config,
window: &ReviewWindow,
source_tick_id: &str,
) -> Result<Option<i64>, String> {
let prompt = build_steering_prompt(&window.summaries, &window.mutations);
let Some(parsed) = synthesize_steering(config, &prompt, source_tick_id).await else {
return Ok(None);
};
// 3. Persist the directive (superseding the prior), advance the cursor, and
// surface it in the local Subconscious chat window.
let now = chrono::Utc::now().to_rfc3339();
let derived_from = format!(
"compressed_rows:{} world_mutations:{}",
compressed.len(),
mutations.len()
window.compressed_count,
window.mutations.len()
);
let directive_id = store::with_connection(&config.workspace_dir, |conn| {
let id = store::insert_steering_directive(
store::insert_steering_directive(
conn,
&parsed.text,
&now,
source_tick_id,
parsed.expires_after_cycles,
current_cycle,
window.current_cycle,
&derived_from,
)?;
if let Some(newest) = &newest_reviewed {
store::set_review_cursor(conn, newest)?;
}
Ok(id)
)
})
.map_err(|e| format!("review persist: {e}"))?;
@@ -272,7 +319,7 @@ pub async fn run_orchestration_review(
"[orchestration] review.directive_emitted id={directive_id} expires_after={} derived={derived_from}",
parsed.expires_after_cycles,
);
Ok(true)
Ok(Some(directive_id))
}
/// Run the offline steering synthesis: a single tool-free chat on the
+6 -6
View File
@@ -485,12 +485,12 @@ fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
.map_err(|e| format!("status: {e}"))?;
// Last subconscious tick (best-effort — subconscious store is separate).
let last_tick_at = crate::openhuman::subconscious::store::with_connection(
&config.workspace_dir,
crate::openhuman::subconscious::store::get_last_tick_at,
)
.ok()
.filter(|v| *v > 0.0);
let last_tick_at =
crate::openhuman::subconscious::store::with_connection(&config.workspace_dir, |conn| {
crate::openhuman::subconscious::store::get_last_tick_at(conn, "memory")
})
.ok()
.filter(|v| *v > 0.0);
to_json(OrchestrationStatus {
steering,
+93 -61
View File
@@ -1,82 +1,114 @@
# subconscious
The subconscious is OpenHuman's background-awareness layer: a periodic loop that, on each tick, runs a small **structured three-stage flow** and lets a slim agent decide what (if anything) to act on. The actual periodic schedule is owned by the `heartbeat` domain; this module owns the tick flow, the world baseline, and the proactive output surface.
The subconscious is OpenHuman's **Deep Reflection Layer**: an offline, cron-driven
loop that consumes a compressed view of how a *world* changed and emits short,
dense outputs that steer the rest of the system. It is a **factory** — one generic
reflection runner instantiated once per world:
## The tick (`engine.rs`)
- **`memory`** — the user's connected memory sources (Gmail / Slack / Notion /
folders). Observes a `memory_diff` against a baseline checkpoint; reflects with
the slim decision agent (to-dos, goals, `notify_user`, delegation).
- **`tinyplace`** — the tiny.place orchestration world. Observes the 20:1-compressed
execution history + cumulative world-state diff; reflects with a **tool-free**
steering synthesis that emits `STEERING_DIRECTIVE`s for the reasoning core.
1. **memory_diff (code)** — diff the user's connected memory sources against the **world baseline** captured at the end of the previous tick (`memory_diff::ops::diff_since_checkpoint`). Renders a compact "what changed" summary. A quiet window (no changes), the first-ever tick, or a diff error short-circuits the tick: it refreshes the baseline, advances `last_tick_at`, and returns **without** running the agent — so idle ticks cost nothing.
2. **prepare_context (code)** — run the read-only `context_scout` over the diff (`agent_prepare_context::run_context_scout_with_catalog`, driven from engine code with the subconscious tool catalogue) to gather grounding from memory, goals/profile, integrations, and the web.
3. **decide (agent)** — hand `diff + prepared context` to the slim `subconscious` agent. It records/advances actionable follow-ups on the user's **global to-do board** (`update_task`, `threadId: "user-tasks"`), evolves **long-term goals** (`goals_*`), surfaces time-sensitive items (`notify_user`), or delegates deeper work (`spawn_async_subagent`).
Adding a world is a new profile file + one factory arm — not another engine.
Continuity across ticks lives in those durable stores (global to-dos + goals), not a bespoke scratchpad. The world baseline is the only per-tick engine state, persisted as a `memory_diff` checkpoint id.
## The generic tick (`instance.rs`)
## Responsibilities
Each tick body is a [`tinyagents` `CompiledGraph`](../tinyagents) — the same durable
runtime the orchestration wake path runs on:
- Route the decision turn to a local Ollama/LM Studio model or the OpenHuman cloud (`workload_local_model("subconscious")` / `subconscious_provider`). `run_agent` sets `default_model = "hint:subconscious"` so the session builder resolves the `subconscious` workload role (not `chat`); on the managed backend that pins the lightweight `chat-v1` tier.
- Run the decision agent with **Full** autonomy (it must write internal continuity — to-dos/goals/notify), while escalating taint via the turn origin: any tick that reacted to source changes runs as `SubconsciousTainted` so the approval gate refuses external-effect tools.
- Persist `last_tick_at` (status/dedupe) and `baseline_checkpoint_id` (the world snapshot the next tick diffs against) across restarts.
- Provide an overlap guard (generation counter) so a newer tick supersedes an in-flight one and discards its results without advancing state.
- Expose `status` / `trigger` over JSON-RPC.
```text
START ─► observe ─┬─(quiet)──────────────────────────► commit ─► done ─► END
└─(changed)─► prepare ─► reflect ─┬─(ok)──► commit ─► done ─► END
└─(err)────────────► done ─► END
```
The node handlers delegate 1:1 to the world's [`SubconsciousProfile`](profile.rs) —
the profile **is** the injected runtime. Everything world-agnostic lives in the
runner **once**, so every world gets it for free:
- per-instance tick lock + 5s acquisition skip,
- generation/supersede counter (checked in the `commit` node **and** post-run, so a
superseded tick never advances state or commits),
- `TICK_TIMEOUT` (30 min) wall clock,
- provider gate + per-instance rate-cap halt (signature `"<id>|<provider-sig>"`, so
one world's 413/TPM halt never silences another — TAURI-RUST-HXF),
- tool-capability classifier (TAURI-RUST-ADC),
- advance-baseline-only-on-success, quiet-tick short-circuit,
- `SqliteCheckpointer` resume of an interrupted tick.
## Profiles (`profiles/`)
A profile implements `observe → prepare_context → reflect → commit` + `origin`:
| Method | `memory` | `tinyplace` |
| --- | --- | --- |
| `observe` | diff connected sources vs the baseline checkpoint | load the unreviewed compressed history + world-diff window |
| `prepare_context` | read-only `context_scout` over the diff | none (steering is deliberately tool-free) |
| `reflect` | slim decision agent (`hint:subconscious`, Full autonomy) → `Acted` | tool-free provider chat → `Steered`/`Idle` |
| `commit` | re-checkpoint the world baseline | advance the review cursor to the observed window |
| `origin` | tainted iff the diff carried external content | always `SubconsciousTainted` |
## Persistence
SQLite at `<workspace>/subconscious/subconscious.db` (per-user workspace). State
keys are **namespaced per instance** (`"<instance>:<key>"`):
- `subconscious_state` — REAL KV: `memory:last_tick_at`, `tinyplace:last_tick_at`.
- `subconscious_state_text` — TEXT KV: `memory:baseline_checkpoint_id`.
- The tinyplace **review cursor** lives in the orchestration store, not here — the
profile owns it via `commit`.
- Legacy single-engine keys (`last_tick_at`, `baseline_checkpoint_id`) migrate to
the `memory:`-namespace via an idempotent, old-version-tolerant UPDATE in the DDL
batch.
- Tick graph checkpoints: `<workspace>/subconscious/graph_checkpoints.db`, thread
`subconscious:<instance>:<tick_id>`.
Legacy task/log/escalation/reflection tables are retained for back-compat with
existing DBs but are no longer written or read.
## Key files
| File | Role |
| --- | --- |
| `mod.rs` | Export-focused; re-exports the engine, session, source_chunk, schemas, and core types. |
| `types.rs` | `SubconsciousStatus`, `TickResult`. |
| `engine.rs` | `SubconsciousEngine` — the three-stage tick (`tick`/`tick_inner`), `prepare_context`, `refresh_baseline`, `run_agent`, world-diff rendering, provider routing (`resolve_subconscious_route`, `subconscious_provider_unavailable_reason`), `tick_origin_source`, tool-capability-error detection. |
| `store.rs` | SQLite persistence + DDL; `with_connection` with busy-timeout + retry (TAURI-RUST-A). `get/set_last_tick_at`, `get/set_baseline_checkpoint_id`. (Legacy task/log/escalation/reflection tables are retained for back-compat but no longer written.) |
| `source_chunk.rs` | `SourceChunk` + `resolve_chunks` / `parse_ref` — used by the agent prompt builder to hydrate reflection `source_refs` into frozen previews (`PREVIEW_MAX_CHARS = 400`). Shared with `agent::prompts`, not subconscious-only. |
| `session.rs` | `LongLivedSession` — persistent agent for the opt-in event-driven trigger path (`subconscious_triggers`); builds the `subconscious` agent and resumes history from the reserved orchestrator thread. |
| `user_thread.rs` | `NotifyUserTool` / `notify_user` — proactive user handoff; publishes `DomainEvent::ProactiveMessageRequested` and the reserved `subconscious:user` thread. |
| `agent/` | The slim `subconscious` agent definition: `agent.toml` (toolset) + `prompt.md`. |
| `global.rs` | Engine singleton: `get_or_init_engine`, `bootstrap_after_login`, `stop_heartbeat_loop`. Spawns the `heartbeat` loop and the opt-in trigger orchestrator. |
| `heartbeat/` | Periodic scheduler + event planner (meeting/reminder/notification delivery) that drives `engine.tick()`. |
| `schemas.rs` | RPC controller schemas + handlers (`subconscious.status` / `subconscious.trigger`). |
| `decision_log.rs`, `executor.rs` | Legacy stubs retained for back-compat; not on the live tick path. |
## Public surface
From `mod.rs`: `SubconsciousEngine`, `LongLivedSession`/`ProcessOutcome`/`ORCHESTRATOR_THREAD_ID`, `SourceChunk`, `SubconsciousStatus`/`TickResult`, `notify_user`/`NotifyUserTool`/`USER_THREAD_ID`, `all_subconscious_controller_schemas` / `all_subconscious_registered_controllers`, and the `global::*` lifecycle functions.
| `mod.rs` | Export-focused; re-exports the factory, instance, profile types, session, source_chunk, schemas. |
| `profile.rs` | `SubconsciousProfile` trait + serde `Observation`/`Reflection`. |
| `instance.rs` | `SubconsciousInstance` — the generic tinyagents-graph runner + scheduler/circuit-breaker shell + `status()`/`is_due()`. |
| `profiles/memory.rs` | `MemoryProfile` + `memory_instance()` — the memory world (world-diff render, decision agent, baseline). |
| `profiles/tinyplace.rs` | `TinyPlaceProfile` + `tinyplace_instance()` — orchestration steering (wraps `orchestration::ops::load_review_window` / `synthesize_and_persist`). |
| `provider.rs` | Shared subconscious provider routing, rate-cap halt signature, and permanent-error classifiers (world-agnostic). |
| `factory.rs` | `SubconsciousKind` + `make_subconscious` + `enabled_kinds` — the bootstrap set. |
| `registry.rs` | Keyed `HashMap<Kind, Arc<SubconsciousInstance>>`; `get_or_init_instance`, `registered_instances`, `bootstrap_after_login`, user-switch reset. Spawns the heartbeat loop + opt-in trigger orchestrator. |
| `heartbeat/` | Periodic scheduler + event planner; each interval fans out, ticking every registered instance whose cadence has elapsed, concurrently. |
| `store.rs` | SQLite persistence + DDL; instance-namespaced `get/set_last_tick_at`, `get/set_baseline_checkpoint_id` + legacy-key migration. |
| `schemas.rs` | RPC controllers (`subconscious.status` / `subconscious.trigger`). |
| `session.rs`, `source_chunk.rs`, `user_thread.rs`, `agent/` | Unchanged roles (opt-in trigger session, chunk hydration, `notify_user`, the slim agent def). |
## RPC / controllers
Namespace `subconscious` (i.e. `openhuman.subconscious_<function>`):
Namespace `subconscious` (`openhuman.subconscious_<function>`):
| Function | Purpose |
| --- | --- |
| `status` | Engine status (read entirely from DB to avoid blocking on the tick mutex). |
| `trigger` | Manually fire a tick (spawned in the background; returns immediately). |
## Agent tools
This module owns `user_thread.rs` (`notify_user`). The tick's other tools come from elsewhere: `memory_diff` (`memory_diff` domain), `agent_prepare_context` / `spawn_async_subagent` (`agent_orchestration`), `update_task` (`todos`/agent tools), `goals_*` (`memory_goals`).
## Persistence
SQLite at `<workspace>/subconscious/subconscious.db` (per-user workspace):
- `subconscious_state` — REAL KV holding `last_tick_at` (restart-durable dedupe cutoff).
- `subconscious_state_text` — TEXT KV holding `baseline_checkpoint_id` (the `memory_diff` checkpoint the next tick diffs against).
- Legacy tables (`subconscious_tasks` / `_log` / `_escalations` / `_reflections` / `_hotness_snapshots`) are retained for back-compat with existing DBs and are no longer written or read.
The world snapshots/checkpoints themselves live in the `memory_diff` domain's own DB (`<workspace>/memory_diff/diff.db`), not here.
`with_connection` runs all DDL on every open, with a 5s busy timeout and 3-retry exponential backoff for transient `SQLITE_BUSY`/`SQLITE_LOCKED`.
## Dependencies
- `openhuman::config``Config`/`HeartbeatConfig`, provider routing, `workspace_dir`.
- `openhuman::memory_diff``ops::diff_since_checkpoint` / `ops::create_checkpoint` + diff types (stage 1 + baseline).
- `openhuman::agent_orchestration``run_context_scout_with_catalog` (stage 2).
- `openhuman::agent``Agent`, turn-origin taint plumbing (stage 3).
- `openhuman::heartbeat``HeartbeatEngine`; `global.rs` spawns the periodic loop that calls `tick`.
- `openhuman::credentials``AuthService`/`APP_SESSION_PROVIDER` for cloud-session provider availability.
- `openhuman::scheduler_gate``is_signed_out()` gate for the cloud provider.
| `status` | Legacy top-level fields mirror the **memory** instance; `instances[]` lists every registered world (each tagged with `instance`). Read entirely from SQLite / the small state mutex — never the tick lock. |
| `trigger` | Fire a tick for a world: optional `kind` (`"memory"` default, `"tinyplace"`, `"all"`). Spawned; returns immediately. |
## Notes / gotchas
- **Engine bootstraps post-login** (`global::bootstrap_after_login`) so state writes to the per-user workspace, not the pre-login global default.
- **`status` RPC never touches the engine mutex** — it reads straight from SQLite, since the engine lock is held for the full tick.
- **State only advances on success.** A failed decision turn leaves `last_tick_at` and the baseline in place, so the next tick re-diffs the same window instead of losing it. A superseded tick discards its result.
- **Quiet ticks short-circuit before the agent** — if the diff has no changes, no decision turn runs (and no cost is incurred); the baseline is still refreshed.
- **Taint:** any tick that reacted to source changes runs `SubconsciousTainted`; the decision agent's slim toolset is internal-only, and external effects (incl. inside delegated work) stay gated by the approval gate.
- **Instances bootstrap post-login** (`registry::bootstrap_after_login`) against the
per-user workspace. The heartbeat loop is the clock that drives every world's tick.
- **`status` never touches the tick mutex** — it reads SQLite + the small state
mutex, since the tick lock is held for a full tick.
- **State only advances on success.** A failed reflect leaves `last_tick_at` and the
baseline/cursor in place (re-observe the same window next tick). A superseded tick
discards its result and skips commit.
- **Quiet ticks short-circuit before reflect** — no LLM call when `observe` is empty;
the baseline is still refreshed via `commit`.
- **Taint:** the memory decision agent's toolset is internal-only, the tinyplace
reflect path is a tool-free provider chat (source-scan test enforces no
Agent/channel/send-message symbols); external effects stay gated by the approval
gate. A tick that reacted to external content runs `SubconsciousTainted`.
- **One world's rate-cap halt never silences another** — the halt signature is
prefixed with the instance id.
-865
View File
@@ -1,865 +0,0 @@
//! Subconscious engine — periodic, structured background loop.
//!
//! Each tick is a small, deterministic, three-stage flow:
//!
//! 1. **memory_diff (code)** — diff the agent's connected sources against the
//! world baseline captured at the end of the previous tick, to see how the
//! user's world changed (`memory_diff::ops::diff_since_checkpoint`).
//! 2. **prepare_context (code)** — run the read-only `context_scout`
//! (`agent_prepare_context`) over that diff to gather grounding context
//! from memory, goals/profile, integrations, and the web.
//! 3. **decide (agent)** — hand `diff + context` to the slim subconscious
//! agent, which decides what (if anything) to do: record follow-ups on the
//! user's global to-do board (`update_task`), evolve long-term goals
//! (`goals_*`), notify the user (`notify_user`), or delegate deeper work
//! (`spawn_async_subagent`).
//!
//! Continuity across ticks lives in those durable stores (global to-dos +
//! goals), not in a bespoke scratchpad — so quiet ticks (no diff) cost nothing
//! and the loop stays stateless beyond the world baseline.
//!
//! ## Concurrency & timeouts
//!
//! A per-engine `tick_lock` prevents overlapping ticks. Each tick has a hard
//! wall-clock timeout (`TICK_TIMEOUT`) so a stuck LLM call cannot block the loop
//! forever. Individual tool calls within the agent turn are bounded by the agent
//! harness's own iteration cap.
use super::store;
use super::types::{SubconsciousStatus, TickResult};
use crate::openhuman::agent_orchestration::parent_context::with_root_parent;
use crate::openhuman::config::schema::SubconsciousMode;
use crate::openhuman::config::Config;
use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER};
use crate::openhuman::memory_diff::types::CrossSourceDiff;
use anyhow::Result;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
/// Hard timeout for a single subconscious tick (agent run).
const TICK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60);
/// Per-tool-call timeout injected into the agent config.
const TOOL_CALL_TIMEOUT_SECS: u64 = 5 * 60;
/// Label stamped on the world-baseline checkpoint the tick re-creates each run.
const BASELINE_CHECKPOINT_LABEL: &str = "subconscious_tick";
/// Max changed items listed per source in the rendered world diff, to keep the
/// decision agent's prompt bounded when a source churns a lot.
const MAX_ITEMS_PER_SOURCE: usize = 10;
/// Tool catalogue handed to the `context_scout` so its `recommended_tool_calls`
/// stay grounded in tools the decision agent can actually call. Keep in sync
/// with `agent/agent.toml`'s `[tools].named` (actionable subset).
const SUBCONSCIOUS_TOOL_CATALOG: &str = "\
- notify_user: Send the user a proactive message about something important or time-sensitive.
- update_task: Add or update an actionable item on the user's global to-do board.
- goals_add: Record a new long-term goal that the changed world makes relevant.
- goals_edit: Revise an existing long-term goal.
- spawn_async_subagent: Delegate deeper research or multi-step work.
";
/// Actionable reason surfaced (via `SubconsciousStatus.provider_unavailable_reason`)
/// when a subconscious tick fails because the configured chat model has no
/// tool-use endpoint. The subconscious turn is inherently tool-bearing (it acts
/// through tools), so a tool-incapable model can never satisfy a tick — this
/// tells the user how to recover. See TAURI-RUST-ADC.
const TOOL_UNSUPPORTED_REASON: &str = "The selected chat model has no tool-use endpoint, so Subconscious can't run. Pick a tool-capable model in Settings > AI.";
/// Surfaced in [`SubconsciousStatus`] when the circuit breaker has halted ticks
/// because the configured Subconscious model keeps rejecting requests with a
/// permanent per-minute token cap (413/TPM). Actionable: the fix is the user's
/// to make (a bigger model/tier), so the message points there.
const RATE_CAP_HALT_REASON: &str = "Subconscious is paused: the selected model rejected the request because it exceeds your provider's per-minute token limit. Pick a higher-tier model or provider for Subconscious in Settings > AI > Advanced.";
/// Pick the `TrustedAutomationSource` variant for a subconscious tick.
///
/// Extracted from the engine's `run_agent` body so the origin-escalation
/// contract can be unit-tested without spinning up a real `Agent` + provider.
///
/// Contract: any tick that reacted to third-party sync changes (the memory_diff
/// surfaced added/modified/removed items, all of which originate from external
/// sources like Gmail / Slack / Notion / synced folders) must run with
/// `SubconsciousTainted` so the approval gate refuses external_effect tools.
/// A tick with no external changes keeps the legacy `Subconscious` origin.
pub(crate) fn tick_origin_source(
has_external_content: bool,
) -> crate::openhuman::agent::turn_origin::TrustedAutomationSource {
if has_external_content {
crate::openhuman::agent::turn_origin::TrustedAutomationSource::SubconsciousTainted
} else {
crate::openhuman::agent::turn_origin::TrustedAutomationSource::Subconscious
}
}
pub struct SubconsciousEngine {
workspace_dir: PathBuf,
mode: SubconsciousMode,
interval_minutes: u32,
context_budget_tokens: u32,
enabled: bool,
state: Mutex<EngineState>,
tick_generation: AtomicU64,
tick_lock: Mutex<()>,
}
struct EngineState {
last_tick_at: f64,
total_ticks: u64,
consecutive_failures: u64,
provider_unavailable_reason: Option<String>,
/// Signature of the subconscious provider routing (see
/// [`subconscious_provider_signature`]) that is permanently rejecting ticks
/// with a per-minute token-cap `413`/TPM. While set and still matching the
/// live config, ticks skip the agent run entirely instead of re-firing the
/// doomed request every interval (TAURI-RUST-HXF — a permanent provider
/// rejection re-reported per tick is the cron-billing-flood family, #3913).
/// Cleared automatically when the config's signature changes (the user
/// switched the Subconscious model/provider/tier). In-memory only: a restart
/// re-probes once, then re-halts on the first rejection — one event per
/// launch, not a flood.
rate_cap_halt_signature: Option<String>,
}
impl EngineState {
/// Pre-tick gate: consult the rate-cap halt against the live provider
/// signature. Returns `true` when the tick must skip the agent run because a
/// halt is active for the still-current config. A halt whose signature no
/// longer matches (the user switched Subconscious model/provider/tier) is
/// cleared here and the tick proceeds. Counts a skipped tick so status stays
/// accurate. TAURI-RUST-HXF.
fn should_skip_for_rate_cap_halt(&mut self, signature: &str) -> bool {
match evaluate_rate_cap_halt(self.rate_cap_halt_signature.as_deref(), signature) {
RateCapHaltDecision::Skip => {
info!(
"[subconscious] halted — the Subconscious provider keeps hitting a permanent \
per-minute token cap (413/TPM); skipping tick until the model/tier changes \
(TAURI-RUST-HXF)"
);
self.total_ticks += 1;
true
}
RateCapHaltDecision::Resume => {
info!(
"[subconscious] Subconscious provider config changed — clearing rate-cap halt \
and resuming ticks"
);
self.rate_cap_halt_signature = None;
if self.provider_unavailable_reason.as_deref() == Some(RATE_CAP_HALT_REASON) {
self.provider_unavailable_reason = None;
}
false
}
RateCapHaltDecision::Proceed => false,
}
}
/// Arm the rate-cap halt after a tick failed with a permanent per-minute
/// token-cap rejection, so subsequent ticks skip until the provider
/// signature changes. Surfaces an actionable reason in
/// [`SubconsciousStatus`]. TAURI-RUST-HXF.
fn arm_rate_cap_halt(&mut self, signature: &str) {
info!(
"[subconscious] provider rejected the tick with a permanent per-minute token cap \
(413/TPM) — halting until the Subconscious model/tier changes (TAURI-RUST-HXF)"
);
self.rate_cap_halt_signature = Some(signature.to_string());
self.provider_unavailable_reason = Some(RATE_CAP_HALT_REASON.to_string());
}
}
impl SubconsciousEngine {
pub fn new(config: &crate::openhuman::config::Config) -> Self {
Self::from_heartbeat_config(&config.heartbeat, config.workspace_dir.clone())
}
pub fn from_heartbeat_config(
heartbeat: &crate::openhuman::config::HeartbeatConfig,
workspace_dir: PathBuf,
) -> Self {
let last_tick_at = match store::with_connection(&workspace_dir, store::get_last_tick_at) {
Ok(v) => {
if v > 0.0 {
info!("[subconscious] resumed last_tick_at={v} from disk");
}
v
}
Err(e) => {
warn!("[subconscious] last_tick_at load failed, falling back to 0.0: {e}");
0.0
}
};
let mode = heartbeat.effective_subconscious_mode();
Self {
workspace_dir,
mode,
interval_minutes: mode.default_interval_minutes().max(5),
context_budget_tokens: heartbeat.context_budget_tokens,
enabled: mode.is_enabled(),
state: Mutex::new(EngineState {
last_tick_at,
total_ticks: 0,
consecutive_failures: 0,
provider_unavailable_reason: None,
rate_cap_halt_signature: None,
}),
tick_generation: AtomicU64::new(0),
tick_lock: Mutex::new(()),
}
}
pub async fn run(&self) -> Result<()> {
if !self.enabled {
info!("[subconscious] disabled, exiting");
return Ok(());
}
let interval_secs = u64::from(self.interval_minutes) * 60;
info!(
"[subconscious] started: every {} minutes, budget {} tokens",
self.interval_minutes, self.context_budget_tokens
);
loop {
tokio::time::sleep(std::time::Duration::from_secs(interval_secs)).await;
match self.tick().await {
Ok(result) => {
info!(
"[subconscious] tick: duration={}ms response_chars={}",
result.duration_ms, result.response_chars
);
}
Err(e) => {
warn!("[subconscious] tick error: {e}");
}
}
}
}
pub async fn tick(&self) -> Result<TickResult> {
let _tick_guard =
match tokio::time::timeout(std::time::Duration::from_secs(5), self.tick_lock.lock())
.await
{
Ok(guard) => guard,
Err(_) => {
warn!("[subconscious] tick skipped — another tick is still running");
return Ok(TickResult {
tick_at: now_secs(),
duration_ms: 0,
response_chars: 0,
});
}
};
match tokio::time::timeout(TICK_TIMEOUT, self.tick_inner()).await {
Ok(result) => result,
Err(_) => {
warn!(
"[subconscious] tick timed out after {}s",
TICK_TIMEOUT.as_secs()
);
let mut state = self.state.lock().await;
state.consecutive_failures += 1;
state.total_ticks += 1;
Ok(TickResult {
tick_at: now_secs(),
duration_ms: TICK_TIMEOUT.as_millis() as u64,
response_chars: 0,
})
}
}
}
async fn tick_inner(&self) -> Result<TickResult> {
let started = std::time::Instant::now();
let tick_at = now_secs();
let my_generation = self.tick_generation.fetch_add(1, Ordering::SeqCst) + 1;
let config = match Config::load_or_init().await {
Ok(c) => c,
Err(e) => {
warn!("[subconscious] config load failed: {e}");
let mut state = self.state.lock().await;
state.provider_unavailable_reason = Some(format!("Config unavailable: {e}"));
state.consecutive_failures += 1;
state.total_ticks += 1;
return Ok(TickResult {
tick_at,
duration_ms: started.elapsed().as_millis() as u64,
response_chars: 0,
});
}
};
let provider_signature = subconscious_provider_signature(&config);
if self
.state
.lock()
.await
.should_skip_for_rate_cap_halt(&provider_signature)
{
return Ok(TickResult {
tick_at,
duration_ms: started.elapsed().as_millis() as u64,
response_chars: 0,
});
}
if let Some(reason) = subconscious_provider_unavailable_reason(&config) {
info!("[subconscious] provider unavailable, skipping tick: {reason}");
let mut state = self.state.lock().await;
state.provider_unavailable_reason = Some(reason);
state.consecutive_failures += 1;
state.total_ticks += 1;
return Ok(TickResult {
tick_at,
duration_ms: started.elapsed().as_millis() as u64,
response_chars: 0,
});
}
{
let mut state = self.state.lock().await;
state.provider_unavailable_reason = None;
}
// ── Stage: orchestration_review — steer the reasoning core (stage 6) ──
// Offline reflection over the orchestration layer's compressed history +
// cumulative world diff, emitting a steering directive for later reasoning
// cycles. Runs before the memory_diff stage and independently of it (it is
// driven by orchestration activity, not source syncs). Self-gating: a
// clean no-op when orchestration is disabled or there is nothing new to
// review. Its only output is the directive (+ the local Subconscious
// window) — no channels, no external effects.
let review_tick_id = format!("subconscious:orchestration_review:{tick_at}");
match crate::openhuman::orchestration::ops::run_orchestration_review(
&config,
&review_tick_id,
)
.await
{
Ok(true) => info!("[subconscious] orchestration_review emitted a steering directive"),
Ok(false) => {}
Err(e) => warn!("[subconscious] orchestration_review failed: {e}"),
}
// ── Stage 1: memory_diff — how did the agent's world change? ──────────
let baseline =
store::with_connection(&self.workspace_dir, store::get_baseline_checkpoint_id)
.unwrap_or_else(|e| {
warn!("[subconscious] baseline load failed: {e}");
None
});
let diff: Option<CrossSourceDiff> = match &baseline {
Some(checkpoint_id) => match crate::openhuman::memory_diff::ops::diff_since_checkpoint(
checkpoint_id,
&config,
false,
)
.await
{
Ok(d) => Some(d),
Err(e) => {
warn!("[subconscious] memory_diff failed (baseline={checkpoint_id}): {e}");
None
}
},
None => {
debug!("[subconscious] no world baseline yet — first tick establishes one");
None
}
};
let has_changes = diff
.as_ref()
.map(|d| world_diff_change_count(d) > 0)
.unwrap_or(false);
if !has_changes {
// Quiet window, first tick, or a diff error: nothing to react to.
// Refresh the baseline and return without spending a decision turn.
info!("[subconscious] no world changes this tick — refreshing baseline, no agent run");
self.refresh_baseline(&config).await;
let mut state = self.state.lock().await;
state.total_ticks += 1;
if self.tick_generation.load(Ordering::SeqCst) == my_generation {
state.consecutive_failures = 0;
state.last_tick_at = tick_at;
persist_last_tick_at(&self.workspace_dir, tick_at);
}
return Ok(TickResult {
tick_at,
duration_ms: started.elapsed().as_millis() as u64,
response_chars: 0,
});
}
let diff = diff.expect("has_changes implies diff is Some");
let world_diff = render_world_diff(&diff);
// Every change originates from an external source sync, so the decision
// turn runs tainted: the approval gate refuses external_effect tools.
let has_external_content = true;
// ── Stage 2: prepare_context — ground the diff before deciding ───────
let prepared_context = self.prepare_context(&config, &world_diff).await;
// ── Stage 3: decide — slim agent acts on diff + prepared context ─────
let mut agent_prompt =
String::with_capacity(world_diff.len() + prepared_context.len() + 256);
agent_prompt.push_str("## What changed in your world since the last check\n\n");
agent_prompt.push_str(&world_diff);
agent_prompt.push_str("\n\n");
if !prepared_context.is_empty() {
agent_prompt.push_str("## Prepared context\n\n");
agent_prompt.push_str(&prepared_context);
agent_prompt.push_str("\n\n");
}
let agent_result = self
.run_agent(&config, &agent_prompt, has_external_content)
.await;
let agent_failed = agent_result.is_err();
let response_chars = *agent_result.as_ref().unwrap_or(&0);
// Check if superseded by a newer tick.
if self.tick_generation.load(Ordering::SeqCst) != my_generation {
info!("[subconscious] tick superseded by newer tick, discarding");
let mut state = self.state.lock().await;
state.total_ticks += 1;
return Ok(TickResult {
tick_at,
duration_ms: started.elapsed().as_millis() as u64,
response_chars: 0,
});
}
// Advance the world baseline only on a successful, current tick, so a
// failed tick re-diffs the same window next time instead of losing it.
if !agent_failed {
self.refresh_baseline(&config).await;
}
let mut state = self.state.lock().await;
state.total_ticks += 1;
if agent_failed {
state.consecutive_failures += 1;
// Surface an actionable reason when the failure is a permanent
// tool-capability error (TAURI-RUST-ADC): the subconscious turn is
// inherently tool-bearing, so a tool-incapable chat model can never
// satisfy it and the user must pick a tool-capable model.
if let Err(e) = &agent_result {
if is_tool_capability_error(e) {
info!(
"[subconscious] configured chat model has no tool-use endpoint — Subconscious can't run until the model changes (TAURI-RUST-ADC)"
);
state.provider_unavailable_reason = Some(TOOL_UNSUPPORTED_REASON.to_string());
} else if is_permanent_rate_cap_error(e) {
state.arm_rate_cap_halt(&provider_signature);
}
}
} else {
state.consecutive_failures = 0;
state.last_tick_at = tick_at;
persist_last_tick_at(&self.workspace_dir, tick_at);
}
Ok(TickResult {
tick_at,
duration_ms: started.elapsed().as_millis() as u64,
response_chars,
})
}
/// Stage 2: run the read-only `context_scout` over the world diff to gather
/// grounding context. Best-effort — on any error the decision agent simply
/// runs without a prepared-context section.
///
/// The tick is a controller-spawned background surface with **no enclosing
/// agent turn**, so the `context_scout` spawn has no ambient
/// `current_parent()`. We establish a root parent for the spawn via the
/// shared [`with_root_parent`] helper — the same construction the
/// workflow-run engine and agent-team runtime use. Without it every tick's
/// scout died with `NoParentContext` and the decision ran un-grounded
/// (Sentry TAURI-RUST-HMW; #4337). The build runs only on ticks that have
/// world changes (the sole caller is past the `has_changes` gate), so quiet
/// ticks pay nothing.
async fn prepare_context(&self, config: &Config, world_diff: &str) -> String {
let question = format!(
"Background awareness check. Here is what changed in the user's connected sources \
since the last check:\n\n{world_diff}\n\nSurface what the user should be aware of or \
act on, and the context that grounds a good decision.",
);
// Flatten: outer Err = root-parent build failure, inner = scout result.
let scout = with_root_parent(config, "subconscious", "subconscious", "subconscious", {
crate::openhuman::agent_orchestration::tools::run_context_scout_with_catalog(
&question,
None,
SUBCONSCIOUS_TOOL_CATALOG,
)
})
.await
.and_then(|inner| inner);
match scout {
Ok(result) if !result.is_error => {
debug!(
"[subconscious] prepared context bundle ({} chars)",
result.output().chars().count()
);
result.output().to_string()
}
Ok(result) => {
warn!(
"[subconscious] prepare_context returned an error result: {}",
result.output()
);
String::new()
}
Err(e) => {
warn!("[subconscious] prepare_context failed: {e}");
String::new()
}
}
}
/// Re-snapshot the world and persist the new checkpoint as the baseline the
/// next tick diffs against. Best-effort — a failure leaves the old baseline
/// in place (the next tick diffs against a slightly older window).
async fn refresh_baseline(&self, config: &Config) {
match crate::openhuman::memory_diff::ops::create_checkpoint(
BASELINE_CHECKPOINT_LABEL,
config,
)
.await
{
Ok(ckpt) => {
if let Err(e) = store::with_connection(&self.workspace_dir, |conn| {
store::set_baseline_checkpoint_id(conn, &ckpt.id)
}) {
warn!("[subconscious] failed to persist baseline checkpoint id: {e}");
} else {
debug!("[subconscious] world baseline advanced to {}", ckpt.id);
}
}
Err(e) => warn!("[subconscious] failed to create world baseline checkpoint: {e}"),
}
}
pub async fn status(&self) -> SubconsciousStatus {
let state = self.state.lock().await;
SubconsciousStatus {
enabled: self.enabled,
mode: self.mode.as_str().to_string(),
provider_available: state.provider_unavailable_reason.is_none(),
provider_unavailable_reason: state.provider_unavailable_reason.clone(),
interval_minutes: self.interval_minutes,
last_tick_at: if state.last_tick_at > 0.0 {
Some(state.last_tick_at)
} else {
None
},
total_ticks: state.total_ticks,
consecutive_failures: state.consecutive_failures,
}
}
/// Run the slim subconscious agent over `prompt_text` (diff + prepared
/// context). The agent decides and acts through its tools. Returns
/// `response_chars` on success, or `Err` on agent init/run failure.
async fn run_agent(
&self,
config: &Config,
prompt_text: &str,
has_external_content: bool,
) -> Result<usize, String> {
use crate::openhuman::agent::Agent;
let mut effective = config.clone();
effective.agent.agent_timeout_secs = TOOL_CALL_TIMEOUT_SECS;
// Route the tick build through the `subconscious` background workload so
// Settings → AI → Advanced "Subconscious" governs the cloud tick
// provider, instead of riding the `chat` role. The session builder maps
// `hint:subconscious` → the `subconscious` provider role; on the managed
// backend the model still resolves to `chat-v1` (no regression).
effective.default_model = Some("hint:subconscious".to_string());
debug!(
"[subconscious] tick provider routed via hint:subconscious (subconscious_provider={:?})",
effective.subconscious_provider
);
// The decision agent must write internal continuity (global to-dos,
// goals) and surface proactive messages — all app-internal writes, not
// external effects. So it runs with Full autonomy; genuinely external
// effects are still gated by the tainted origin + approval gate. Mode
// only scales how much delegation depth the tick gets.
effective.autonomy.level = crate::openhuman::security::AutonomyLevel::Full;
match self.mode {
SubconsciousMode::Simple => {
effective.agent.max_tool_iterations = 15;
}
SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => {
effective.agent.max_tool_iterations = 30;
}
SubconsciousMode::Off => return Ok(0),
}
let mut agent = Agent::from_config(&effective).map_err(|e| {
warn!("[subconscious] agent init failed: {e}");
format!("agent init: {e}")
})?;
agent.set_event_context(
format!("subconscious:tick:{}", now_secs() as u64),
"subconscious",
);
let mode_guidance = match self.mode {
SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => {
"\n\nYou may delegate deeper work with `spawn_async_subagent` (e.g. research \
or multi-step execution) when you spot something genuinely actionable."
}
_ => "",
};
let user_message = format!(
"{prompt_text}\
## Your job\n\n\
The diff above is how the user's world changed since the last check; the prepared \
context grounds it. Decide what (if anything) deserves action:\n\
- Record or update actionable follow-ups on the user's to-do board with `update_task` \
(pass `threadId: \"user-tasks\"`).\n\
- Evolve the user's long-term goals with `goals_add` / `goals_edit` when the world \
shifts what matters to them.\n\
- Surface anything time-sensitive or important with `notify_user`.\n\n\
If nothing meaningful changed, do nothing — staying silent is the right call most \
ticks. Do not invent busywork.{mode_guidance}",
);
debug!("[subconscious] spawning decision agent");
let source = tick_origin_source(has_external_content);
debug!(
"[subconscious] tick origin source={:?} has_external_content={has_external_content}",
source
);
let origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation {
job_id: format!("subconscious:tick:{}", now_secs() as u64),
source,
};
let response = crate::openhuman::agent::turn_origin::with_origin(
origin,
agent.run_single(&user_message),
)
.await
.map_err(|e| {
warn!("[subconscious] agent run failed: {e}");
format!("agent run: {e}")
})?;
let response_chars = response.chars().count();
info!(
"[subconscious] decision agent completed (response {} chars)",
response_chars
);
Ok(response_chars)
}
}
// ── World-diff rendering ─────────────────────────────────────────────────────
/// Total added + modified + removed across all sources in a cross-source diff.
fn world_diff_change_count(diff: &CrossSourceDiff) -> u32 {
diff.summary.added + diff.summary.modified + diff.summary.removed
}
/// Render a [`CrossSourceDiff`] into a compact markdown summary for the decision
/// agent's prompt. Per-source change lists are capped at [`MAX_ITEMS_PER_SOURCE`]
/// so a churny source can't blow out the context window.
fn render_world_diff(diff: &CrossSourceDiff) -> String {
let s = &diff.summary;
let total = s.added + s.modified + s.removed;
if total == 0 {
return "Nothing changed across your connected sources since the last check.".to_string();
}
let mut out = format!(
"{total} item(s) changed across your sources since the last check \
({} added, {} modified, {} removed).\n",
s.added, s.modified, s.removed
);
for source in &diff.per_source {
let ss = &source.summary;
if ss.added + ss.modified + ss.removed == 0 {
continue;
}
out.push_str(&format!(
"\n### {} ({})\n- {} added, {} modified, {} removed\n",
source.source_label, source.source_kind, ss.added, ss.modified, ss.removed
));
for change in source.changes.iter().take(MAX_ITEMS_PER_SOURCE) {
let verb = match change.kind {
crate::openhuman::memory_diff::types::ChangeKind::Added => "added",
crate::openhuman::memory_diff::types::ChangeKind::Removed => "removed",
crate::openhuman::memory_diff::types::ChangeKind::Modified => "modified",
};
let label = if change.title.trim().is_empty() {
change.item_id.as_str()
} else {
change.title.as_str()
};
out.push_str(&format!(" - [{verb}] {label}\n"));
}
if source.changes.len() > MAX_ITEMS_PER_SOURCE {
out.push_str(&format!(
" - …and {} more\n",
source.changes.len() - MAX_ITEMS_PER_SOURCE
));
}
}
out
}
// ── Provider routing ────────────────────────────────────────────────────────
#[derive(Clone, Debug, Eq, PartialEq)]
enum SubconsciousProviderRoute {
LocalOllama { model: String },
OpenHumanCloud,
Other(String),
}
pub(crate) fn subconscious_provider_unavailable_reason(config: &Config) -> Option<String> {
match resolve_subconscious_route(config) {
SubconsciousProviderRoute::LocalOllama { .. } => None,
SubconsciousProviderRoute::OpenHumanCloud => {
if crate::openhuman::scheduler_gate::is_signed_out() {
return Some(
"Sign in to use the OpenHuman cloud Subconscious provider.".to_string(),
);
}
let state_dir = config
.config_path
.parent()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| config.workspace_dir.clone());
let auth = AuthService::new(&state_dir, config.secrets.encrypt);
match auth.get_provider_bearer_token(APP_SESSION_PROVIDER, None) {
Ok(Some(token)) if !token.trim().is_empty() => None,
Ok(_) => Some(
"Sign in or configure a local Subconscious provider in Settings > AI."
.to_string(),
),
Err(e) => Some(format!("Unable to read the OpenHuman session: {e}")),
}
}
SubconsciousProviderRoute::Other(_) => None,
}
}
fn resolve_subconscious_route(config: &Config) -> SubconsciousProviderRoute {
if let Some(model) = config.workload_local_model("subconscious") {
return SubconsciousProviderRoute::LocalOllama { model };
}
let raw = config
.subconscious_provider
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("cloud");
let is_openhuman_cloud = raw.eq_ignore_ascii_case("cloud")
|| raw.eq_ignore_ascii_case("openhuman")
|| raw.to_ascii_lowercase().starts_with("openhuman:");
if is_openhuman_cloud {
SubconsciousProviderRoute::OpenHumanCloud
} else {
SubconsciousProviderRoute::Other(raw.to_string())
}
}
/// Stable identity of the Subconscious provider routing — the exact knobs a
/// user changes in Settings > AI > Advanced to switch the tick model/provider.
/// The rate-cap circuit breaker keys its halt on this so a permanent per-minute
/// token-cap rejection stops re-firing while the SAME config is set, and
/// auto-clears the moment the user picks a different model/provider/tier.
fn subconscious_provider_signature(config: &Config) -> String {
match resolve_subconscious_route(config) {
SubconsciousProviderRoute::LocalOllama { model } => format!("local:{model}"),
SubconsciousProviderRoute::OpenHumanCloud => "cloud".to_string(),
SubconsciousProviderRoute::Other(raw) => format!("other:{raw}"),
}
}
/// Outcome of comparing an active rate-cap halt against the live provider
/// signature at the start of a tick. Pure so it is unit-testable without
/// spinning an engine/agent.
#[derive(Debug, PartialEq, Eq)]
enum RateCapHaltDecision {
/// A halt is set for the same signature still in config — skip the run.
Skip,
/// A halt is set but the signature changed — clear it and resume ticking.
Resume,
/// No halt in effect — run the tick normally.
Proceed,
}
/// Decide whether a tick should skip, resume, or proceed given the stored
/// rate-cap halt signature (if any) and the live provider signature.
fn evaluate_rate_cap_halt(halt_signature: Option<&str>, current: &str) -> RateCapHaltDecision {
match halt_signature {
Some(sig) if sig == current => RateCapHaltDecision::Skip,
Some(_) => RateCapHaltDecision::Resume,
None => RateCapHaltDecision::Proceed,
}
}
/// True when an agent-run error is a permanent per-minute token-cap rejection
/// (413/TPM) — the request is larger than the provider account's per-minute
/// budget, so retrying the same tick can never succeed. Delegates to the shared
/// provider matcher (single source of truth with the Sentry classifier in
/// `core::observability`) so the wording can't drift. TAURI-RUST-HXF.
fn is_permanent_rate_cap_error(msg: &str) -> bool {
crate::openhuman::inference::provider::is_provider_rate_cap_exceeded_message(msg)
}
/// True when an agent-run error means the configured chat model can't do tool
/// calls at all — a permanent, user-actionable condition (pick a tool-capable
/// model). Matches both the direct-provider body (`<model> does not support
/// tools`) and OpenRouter's router-level phrasing (`No endpoints found that
/// support tool use`, TAURI-RUST-ADC). Kept narrow to tool capability so an
/// unrelated provider error (auth, billing, rate-limit) is not misread as one.
fn is_tool_capability_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("no endpoints found that support tool use")
|| lower.contains("does not support tools")
}
fn persist_last_tick_at(workspace_dir: &std::path::Path, tick_at: f64) {
if let Err(e) =
store::with_connection(workspace_dir, |conn| store::set_last_tick_at(conn, tick_at))
{
warn!("[subconscious] failed to persist last_tick_at={tick_at}: {e}");
}
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
#[cfg(test)]
#[path = "engine_tests.rs"]
mod tests;
-243
View File
@@ -1,243 +0,0 @@
use super::*;
// ── Tick origin upgrade (#approval-origin) ──────────────────────────────
#[test]
fn tick_origin_untainted_keeps_subconscious_source() {
use crate::openhuman::agent::turn_origin::TrustedAutomationSource;
let source = tick_origin_source(false);
assert!(matches!(source, TrustedAutomationSource::Subconscious));
}
#[test]
fn tick_origin_with_external_sync_chunk_uses_tainted_source() {
use crate::openhuman::agent::turn_origin::TrustedAutomationSource;
let source = tick_origin_source(true);
assert!(matches!(
source,
TrustedAutomationSource::SubconsciousTainted
));
}
// ── Tool-capability error detection (TAURI-RUST-ADC) ────────────────────
#[test]
fn tool_capability_error_matches_openrouter_and_direct_bodies() {
// OpenRouter router-level 404 (the reported ADC body).
assert!(is_tool_capability_error(
r#"agent run: openrouter API error (404 Not Found): {"error":{"message":"No endpoints found that support tool use. Try disabling \"spawn_async_subagent\"."}}"#
));
// Direct-provider "does not support tools" phrasing (TAURI-RUST-35 family).
assert!(is_tool_capability_error(
r#"agent run: cloud API error: {"error":{"message":"qwen2:0.5b does not support tools"}}"#
));
// Case-insensitive.
assert!(is_tool_capability_error(
"NO ENDPOINTS FOUND THAT SUPPORT TOOL USE"
));
}
#[test]
fn tool_capability_error_ignores_unrelated_failures() {
// A different 404, an auth wall, and a generic timeout must NOT match.
assert!(!is_tool_capability_error(
r#"agent run: openrouter API error (404 Not Found): {"error":{"message":"model 'llama3.3' not found"}}"#
));
assert!(!is_tool_capability_error(
"agent run: Backend returned 401 Unauthorized: Invalid token"
));
assert!(!is_tool_capability_error("agent run: request timed out"));
}
// ── World-diff rendering (Stage 1) ──────────────────────────────────────
use crate::openhuman::memory_diff::types::{
ChangeKind, CrossSourceDiff, DiffResult, DiffSummary, ItemChange,
};
fn change(item_id: &str, title: &str, kind: ChangeKind) -> ItemChange {
ItemChange {
item_id: item_id.to_string(),
title: title.to_string(),
kind,
old_content_hash: None,
new_content_hash: None,
text_diff: None,
}
}
#[test]
fn empty_cross_source_diff_has_zero_change_count() {
let diff = CrossSourceDiff {
checkpoint_id: Some("ckpt_1".into()),
computed_at_ms: 0,
summary: DiffSummary::default(),
per_source: Vec::new(),
};
assert_eq!(world_diff_change_count(&diff), 0);
// The "no changes" render is the quiet-tick sentinel; the tick short-circuits
// before it ever reaches the agent, but the renderer stays well-defined.
assert!(render_world_diff(&diff).contains("Nothing changed"));
}
#[test]
fn render_world_diff_summarises_changes_per_source() {
let diff = CrossSourceDiff {
checkpoint_id: Some("ckpt_1".into()),
computed_at_ms: 0,
summary: DiffSummary {
added: 2,
modified: 1,
removed: 0,
unchanged: 5,
},
per_source: vec![DiffResult {
source_id: "src_gmail".into(),
source_kind: "composio".into(),
source_label: "Gmail".into(),
from_snapshot_id: Some("snap_a".into()),
to_snapshot_id: "snap_b".into(),
summary: DiffSummary {
added: 2,
modified: 1,
removed: 0,
unchanged: 5,
},
changes: vec![
change("m1", "Invoice from Acme", ChangeKind::Added),
change("m2", "Re: launch plan", ChangeKind::Added),
change("m3", "Standup notes", ChangeKind::Modified),
],
}],
};
assert_eq!(world_diff_change_count(&diff), 3);
let rendered = render_world_diff(&diff);
assert!(rendered.contains("3 item(s) changed"));
assert!(rendered.contains("Gmail (composio)"));
assert!(rendered.contains("[added] Invoice from Acme"));
assert!(rendered.contains("[modified] Standup notes"));
}
#[test]
fn render_world_diff_caps_items_and_falls_back_to_item_id() {
let mut changes = Vec::new();
for i in 0..(MAX_ITEMS_PER_SOURCE + 3) {
// Empty title forces the item_id fallback.
changes.push(change(&format!("item_{i}"), "", ChangeKind::Added));
}
let n = changes.len() as u32;
let diff = CrossSourceDiff {
checkpoint_id: None,
computed_at_ms: 0,
summary: DiffSummary {
added: n,
..DiffSummary::default()
},
per_source: vec![DiffResult {
source_id: "src_folder".into(),
source_kind: "folder".into(),
source_label: "Notes".into(),
from_snapshot_id: None,
to_snapshot_id: "snap_x".into(),
summary: DiffSummary {
added: n,
..DiffSummary::default()
},
changes,
}],
};
let rendered = render_world_diff(&diff);
assert!(rendered.contains("[added] item_0"), "uses item_id fallback");
assert!(rendered.contains("…and 3 more"), "caps the per-source list");
}
// ── Rate-cap circuit breaker (TAURI-RUST-HXF) ───────────────────────────
#[test]
fn evaluate_rate_cap_halt_skip_resume_proceed() {
// No halt in effect → run normally.
assert_eq!(
evaluate_rate_cap_halt(None, "other:groq"),
RateCapHaltDecision::Proceed
);
// Halt set for the same signature still in config → skip the doomed run.
assert_eq!(
evaluate_rate_cap_halt(Some("other:groq"), "other:groq"),
RateCapHaltDecision::Skip
);
// Halt set but the user switched provider/model → clear it and resume.
assert_eq!(
evaluate_rate_cap_halt(Some("other:groq"), "cloud"),
RateCapHaltDecision::Resume
);
}
#[test]
fn permanent_rate_cap_error_matches_wrapped_groq_agent_error_only() {
// The verbatim wrapped agent-run error the tick surfaces (413/TPM) →
// permanent, so the breaker halts.
assert!(is_permanent_rate_cap_error(
r#"agent run: groq API error (413 Payload Too Large): {"error":{"message":"Request too large for model `openai/gpt-oss-120b` in organization `org_x` service tier `on_demand` on tokens per minute (TPM): Limit 8000, Requested 42084."}}"#
));
// A transient 429 burst ("try again in Ns") must NOT halt — it stays
// retryable, so the two permanent-error arms never overlap.
assert!(!is_permanent_rate_cap_error(
"agent run: groq API error (429 Too Many Requests): Rate limit reached. Please try again in 2.5s."
));
// A tool-capability error is a different permanent condition handled by its
// own arm, not the rate-cap breaker.
assert!(!is_permanent_rate_cap_error(
"agent run: No endpoints found that support tool use"
));
}
#[test]
fn subconscious_provider_signature_tracks_config_changes() {
// Default config routes to OpenHuman cloud.
let mut cfg = Config::default();
assert_eq!(subconscious_provider_signature(&cfg), "cloud");
// A BYO provider override yields a distinct, stable signature.
cfg.subconscious_provider = Some("groq".to_string());
let groq_sig = subconscious_provider_signature(&cfg);
assert_eq!(groq_sig, "other:groq");
// Switching the provider changes the signature — the breaker's cue to
// clear a halt and resume ticking.
cfg.subconscious_provider = Some("openai".to_string());
assert_ne!(subconscious_provider_signature(&cfg), groq_sig);
}
#[test]
fn rate_cap_halt_state_transitions() {
let mut state = EngineState {
last_tick_at: 0.0,
total_ticks: 0,
consecutive_failures: 0,
provider_unavailable_reason: None,
rate_cap_halt_signature: None,
};
// No halt armed → the tick proceeds (does not skip).
assert!(!state.should_skip_for_rate_cap_halt("other:groq"));
// A permanent rate-cap failure arms the halt + actionable reason.
state.arm_rate_cap_halt("other:groq");
assert_eq!(state.rate_cap_halt_signature.as_deref(), Some("other:groq"));
assert_eq!(
state.provider_unavailable_reason.as_deref(),
Some(RATE_CAP_HALT_REASON)
);
// Same config still set → skip the doomed run, and count the skipped tick.
let before = state.total_ticks;
assert!(state.should_skip_for_rate_cap_halt("other:groq"));
assert_eq!(state.total_ticks, before + 1);
// User switched provider (signature changed) → clear halt + reason, resume.
assert!(!state.should_skip_for_rate_cap_halt("cloud"));
assert!(state.rate_cap_halt_signature.is_none());
assert!(state.provider_unavailable_reason.is_none());
}
+69
View File
@@ -0,0 +1,69 @@
//! The "make a subconscious" surface: the [`SubconsciousKind`] enum + the single
//! [`make_subconscious`] constructor every caller (registry, trigger RPC, tests)
//! goes through. Adding a world is a profile file + one match arm here + one
//! `enabled_kinds` line — never another engine.
use serde::{Deserialize, Serialize};
use super::instance::SubconsciousInstance;
use super::profiles::{memory::memory_instance, tinyplace::tinyplace_instance};
use crate::openhuman::config::Config;
/// One instantiable subconscious world.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SubconsciousKind {
/// The user's connected memory sources (Gmail / Slack / Notion / folders).
Memory,
/// The tiny.place orchestration steering world.
TinyPlace,
}
impl SubconsciousKind {
/// Every kind, in a stable order (memory first — it owns the legacy status).
pub const ALL: [SubconsciousKind; 2] = [SubconsciousKind::Memory, SubconsciousKind::TinyPlace];
/// Stable id — store-key namespace, log prefix, RPC name.
pub fn id(self) -> &'static str {
match self {
SubconsciousKind::Memory => "memory",
SubconsciousKind::TinyPlace => "tinyplace",
}
}
/// Parse a kind id (`"memory"` | `"tinyplace"`); `None` on anything else.
pub fn parse(s: &str) -> Option<Self> {
match s {
"memory" => Some(SubconsciousKind::Memory),
"tinyplace" => Some(SubconsciousKind::TinyPlace),
_ => None,
}
}
/// Which kinds should run for this config — the bootstrap set.
///
/// - `Memory` ⇐ `heartbeat.enabled && mode != Off` (the pre-factory gate).
/// - `TinyPlace` ⇐ `orchestration.enabled` (the pre-factory review gate).
pub fn enabled_kinds(config: &Config) -> Vec<Self> {
let mut kinds = Vec::new();
if config.heartbeat.enabled && config.heartbeat.effective_subconscious_mode().is_enabled() {
kinds.push(SubconsciousKind::Memory);
}
if config.orchestration.enabled {
kinds.push(SubconsciousKind::TinyPlace);
}
kinds
}
}
/// The only place profiles are constructed into a runner.
pub fn make_subconscious(kind: SubconsciousKind, config: &Config) -> SubconsciousInstance {
match kind {
SubconsciousKind::Memory => memory_instance(config),
SubconsciousKind::TinyPlace => tinyplace_instance(config),
}
}
#[cfg(test)]
#[path = "factory_tests.rs"]
mod tests;
@@ -0,0 +1,57 @@
use super::*;
use crate::openhuman::config::schema::SubconsciousMode;
use crate::openhuman::config::Config;
#[test]
fn kind_id_and_parse_round_trip() {
for kind in SubconsciousKind::ALL {
assert_eq!(SubconsciousKind::parse(kind.id()), Some(kind));
}
assert_eq!(SubconsciousKind::parse("nope"), None);
}
#[test]
fn enabled_kinds_gates_memory_on_heartbeat_and_mode() {
let mut cfg = Config::default();
// Heartbeat enabled + an enabled mode + orchestration on (default) → both.
cfg.heartbeat.enabled = true;
cfg.heartbeat.subconscious_mode = SubconsciousMode::Simple;
cfg.orchestration.enabled = true;
assert_eq!(
SubconsciousKind::enabled_kinds(&cfg),
vec![SubconsciousKind::Memory, SubconsciousKind::TinyPlace]
);
// Mode Off drops memory but tinyplace still runs on orchestration.enabled.
cfg.heartbeat.subconscious_mode = SubconsciousMode::Off;
assert_eq!(
SubconsciousKind::enabled_kinds(&cfg),
vec![SubconsciousKind::TinyPlace]
);
// Heartbeat disabled drops memory regardless of mode.
cfg.heartbeat.enabled = false;
cfg.heartbeat.subconscious_mode = SubconsciousMode::Aggressive;
assert_eq!(
SubconsciousKind::enabled_kinds(&cfg),
vec![SubconsciousKind::TinyPlace]
);
// Orchestration off too → nothing runs.
cfg.orchestration.enabled = false;
assert!(SubconsciousKind::enabled_kinds(&cfg).is_empty());
}
#[test]
fn make_subconscious_builds_each_kind_with_matching_id() {
let cfg = Config::default();
assert_eq!(
make_subconscious(SubconsciousKind::Memory, &cfg).id(),
"memory"
);
assert_eq!(
make_subconscious(SubconsciousKind::TinyPlace, &cfg).id(),
"tinyplace"
);
}
+30 -26
View File
@@ -1,10 +1,17 @@
use crate::openhuman::config::{Config, HeartbeatConfig};
use crate::openhuman::subconscious::global::get_or_init_engine;
use crate::openhuman::subconscious::registry::registered_instances;
use anyhow::Result;
use std::path::Path;
use tokio::time::{self, Duration};
use tracing::{info, warn};
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
/// Heartbeat engine — periodic scheduler that delegates to planner collectors
/// and optional subconscious model inference.
pub struct HeartbeatEngine {
@@ -101,32 +108,29 @@ impl HeartbeatEngine {
}
if current.inference_enabled {
// Get the shared global engine (same instance as RPC handlers)
let lock = match get_or_init_engine().await {
Ok(l) => l,
Err(e) => {
warn!("[heartbeat] failed to get engine: {e}");
continue;
// Fan out: tick every registered instance whose own cadence has
// elapsed, concurrently — a slow memory tick must not delay a
// tinyplace review. `is_due` is a lock-free read of each
// instance's `last_tick_at`, so this poll never blocks.
let now = now_secs();
let mut handles = Vec::new();
for inst in registered_instances().await {
if inst.is_due(now).await {
handles.push(tokio::spawn(async move {
let id = inst.id();
match inst.tick().await {
Ok(result) => info!(
"[heartbeat] {id} tick: duration={}ms response_chars={}",
result.duration_ms, result.response_chars
),
Err(e) => warn!("[heartbeat] {id} tick error: {e}"),
}
}));
}
};
let guard = lock.lock().await;
let engine = match guard.as_ref() {
Some(e) => e,
None => {
warn!("[heartbeat] engine not initialized");
continue;
}
};
match engine.tick().await {
Ok(result) => {
info!(
"[heartbeat] tick: duration={}ms response_chars={}",
result.duration_ms, result.response_chars
);
}
Err(e) => {
warn!("[heartbeat] subconscious tick error: {e}");
}
for handle in handles {
if let Err(e) = handle.await {
warn!("[heartbeat] subconscious tick task join failed: {e}");
}
}
} else {
+2 -2
View File
@@ -141,11 +141,11 @@ pub async fn settings_set(
|| patch.triggers_enabled.is_some()
|| patch.max_promotions_per_hour.is_some()
{
crate::openhuman::subconscious::global::stop_heartbeat_loop().await;
crate::openhuman::subconscious::registry::stop_heartbeat_loop().await;
if config.heartbeat.effective_subconscious_mode().is_enabled() {
debug!("[heartbeat][rpc] settings_set: (re)starting for mode change");
if let Err(error) =
crate::openhuman::subconscious::global::bootstrap_after_login().await
crate::openhuman::subconscious::registry::bootstrap_after_login().await
{
warn!("[heartbeat][rpc] settings_set: heartbeat bootstrap failed: {error}");
return Err(format!(
+600
View File
@@ -0,0 +1,600 @@
//! `SubconsciousInstance` — the generic, world-agnostic subconscious runner.
//!
//! One instance ticks one [`SubconsciousProfile`] (`memory`, `tinyplace`, …).
//! The tick body is a [`tinyagents` `CompiledGraph`](tinyagents::graph) — the
//! same durable-workflow runtime the orchestration wake path runs on:
//!
//! ```text
//! START ─► observe ─┬─(quiet)────────────────────────────► commit ─► done ─► END
//! └─(changed)─► prepare ─► reflect ─┬─(ok)──► commit ─► done ─► END
//! └─(err)────────────► done ─► END
//! ```
//!
//! The node handlers delegate 1:1 to the profile — the profile trait **is** the
//! injected runtime (mirroring `OrchestrationRuntime`). Everything world-agnostic
//! lives here, **once**, so every world gets it for free: the tick lock + 5s
//! acquisition skip, the generation/supersede counter, the `TICK_TIMEOUT` wall
//! clock, the provider gate + per-instance rate-cap halt (TAURI-RUST-HXF), the
//! tool-capability classifier (TAURI-RUST-ADC), only-advance-baselines-on-success,
//! and the quiet-tick short-circuit.
//!
//! Deviation from the plan sketch: the graph is rebuilt per tick (capturing the
//! freshly-loaded `Config` + the profile in the node closures) rather than once
//! at construction, so each tick reflects the live provider config. This is the
//! established `orchestration::graph::build::run_orchestration_graph` pattern and
//! does not affect checkpoint resume, which is keyed by thread id on disk.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tinyagents::graph::{
Checkpointer, ClosureStateReducer, Command, CompiledGraph, GraphBuilder, NodeContext,
NodeResult, SqliteCheckpointer,
};
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
use super::profile::{Observation, Reflection, SubconsciousProfile};
use super::provider::{
evaluate_rate_cap_halt, is_permanent_rate_cap_error, is_tool_capability_error,
subconscious_provider_signature, subconscious_provider_unavailable_reason, RateCapHaltDecision,
RATE_CAP_HALT_REASON, TOOL_UNSUPPORTED_REASON,
};
use super::types::{SubconsciousStatus, TickResult};
use crate::openhuman::config::Config;
use crate::openhuman::tinyagents::observability::GraphTracingSink;
/// Hard timeout for a single subconscious tick.
const TICK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60);
/// The state threaded through one tick graph. Serde so a killed tick can resume
/// from the last checkpoint boundary rather than losing its window.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct SubconsciousState {
/// Stable per-tick id (`<unix_secs>`), scoping the checkpoint thread.
tick_id: String,
/// The generation this tick was born with — compared against the shared
/// counter in the `commit` node so a superseded tick never advances state.
generation: u64,
observation: Observation,
prepared_context: String,
reflection: Option<Reflection>,
reflect_error: Option<String>,
committed: bool,
}
/// Reducer update emitted by a subconscious node (one per node result).
enum SubconsciousUpdate {
Observed(Observation),
Prepared(String),
Reflected(Reflection),
ReflectFailed(String),
Committed,
Noop,
}
/// The instance-local, in-memory counters and circuit-breaker state. Guarded by
/// a small mutex the `status()` path can take without ever touching `tick_lock`
/// (invariant: status never blocks on a running tick).
struct InstanceState {
last_tick_at: f64,
total_ticks: u64,
consecutive_failures: u64,
provider_unavailable_reason: Option<String>,
/// Signature (`"<id>|<provider-sig>"`) of a permanently-rejecting provider
/// (413/TPM). While set and still matching the live config, ticks skip the
/// reflect run. Cleared when the config's signature changes. In-memory only.
/// TAURI-RUST-HXF.
rate_cap_halt_signature: Option<String>,
}
impl InstanceState {
/// Pre-tick gate: consult the rate-cap halt against the live signature.
/// Returns `true` when the tick must skip because a halt is active for the
/// still-current config. A halt whose signature no longer matches is cleared
/// here and the tick proceeds. Counts a skipped tick. TAURI-RUST-HXF.
fn should_skip_for_rate_cap_halt(&mut self, signature: &str, log_prefix: &str) -> bool {
match evaluate_rate_cap_halt(self.rate_cap_halt_signature.as_deref(), signature) {
RateCapHaltDecision::Skip => {
info!(
"{log_prefix} halted — provider keeps hitting a permanent per-minute token cap \
(413/TPM); skipping tick until the model/tier changes (TAURI-RUST-HXF)"
);
self.total_ticks += 1;
true
}
RateCapHaltDecision::Resume => {
info!("{log_prefix} provider config changed — clearing rate-cap halt, resuming");
self.rate_cap_halt_signature = None;
if self.provider_unavailable_reason.as_deref() == Some(RATE_CAP_HALT_REASON) {
self.provider_unavailable_reason = None;
}
false
}
RateCapHaltDecision::Proceed => false,
}
}
/// Arm the rate-cap halt after a tick failed with a permanent per-minute
/// token-cap rejection. TAURI-RUST-HXF.
fn arm_rate_cap_halt(&mut self, signature: &str, log_prefix: &str) {
info!(
"{log_prefix} provider rejected the tick with a permanent per-minute token cap \
(413/TPM) — halting until the model/tier changes (TAURI-RUST-HXF)"
);
self.rate_cap_halt_signature = Some(signature.to_string());
self.provider_unavailable_reason = Some(RATE_CAP_HALT_REASON.to_string());
}
}
pub struct SubconsciousInstance {
profile: Arc<dyn SubconsciousProfile>,
workspace_dir: PathBuf,
enabled: bool,
interval_minutes: u32,
/// Display label for `status().mode` — the memory instance maps its
/// `SubconsciousMode`; worlds with no mode concept pass a fixed label.
mode_label: String,
state: Mutex<InstanceState>,
/// Shared with the graph's `commit` node so a superseded tick self-skips its
/// commit between supersteps, not just at the end.
tick_generation: Arc<AtomicU64>,
tick_lock: Mutex<()>,
}
impl SubconsciousInstance {
/// Construct an instance around `profile`, resuming its persisted
/// `last_tick_at` from disk. `enabled`, `interval_minutes` and `mode_label`
/// are supplied by the caller (factory / bootstrap) since they derive from
/// world-specific config the profile already read.
pub fn new(
profile: Arc<dyn SubconsciousProfile>,
workspace_dir: PathBuf,
enabled: bool,
interval_minutes: u32,
mode_label: impl Into<String>,
) -> Self {
let instance_id = profile.id();
let last_tick_at = match super::store::with_connection(&workspace_dir, |conn| {
super::store::get_last_tick_at(conn, instance_id)
}) {
Ok(v) => {
if v > 0.0 {
info!("[subconscious:{instance_id}] resumed last_tick_at={v} from disk");
}
v
}
Err(e) => {
warn!("[subconscious:{instance_id}] last_tick_at load failed, using 0.0: {e}");
0.0
}
};
Self {
profile,
workspace_dir,
enabled,
interval_minutes,
mode_label: mode_label.into(),
state: Mutex::new(InstanceState {
last_tick_at,
total_ticks: 0,
consecutive_failures: 0,
provider_unavailable_reason: None,
rate_cap_halt_signature: None,
}),
tick_generation: Arc::new(AtomicU64::new(0)),
tick_lock: Mutex::new(()),
}
}
fn log_prefix(&self) -> String {
format!("[subconscious:{}]", self.profile.id())
}
/// Run one tick: acquire the per-instance tick lock (skip if another tick
/// holds it after 5s), then run the tick under the hard `TICK_TIMEOUT`.
pub async fn tick(&self) -> Result<TickResult> {
let prefix = self.log_prefix();
let _tick_guard =
match tokio::time::timeout(std::time::Duration::from_secs(5), self.tick_lock.lock())
.await
{
Ok(guard) => guard,
Err(_) => {
warn!("{prefix} tick skipped — another tick is still running");
return Ok(TickResult {
tick_at: now_secs(),
duration_ms: 0,
response_chars: 0,
});
}
};
match tokio::time::timeout(TICK_TIMEOUT, self.tick_inner()).await {
Ok(result) => result,
Err(_) => {
warn!("{prefix} tick timed out after {}s", TICK_TIMEOUT.as_secs());
let mut state = self.state.lock().await;
state.consecutive_failures += 1;
state.total_ticks += 1;
Ok(TickResult {
tick_at: now_secs(),
duration_ms: TICK_TIMEOUT.as_millis() as u64,
response_chars: 0,
})
}
}
}
async fn tick_inner(&self) -> Result<TickResult> {
let config = match Config::load_or_init().await {
Ok(c) => c,
Err(e) => {
warn!("{} config load failed: {e}", self.log_prefix());
let mut state = self.state.lock().await;
state.provider_unavailable_reason = Some(format!("Config unavailable: {e}"));
state.consecutive_failures += 1;
state.total_ticks += 1;
return Ok(TickResult {
tick_at: now_secs(),
duration_ms: 0,
response_chars: 0,
});
}
};
self.run_tick(config).await
}
/// The tick body given a loaded config. Split out so tests can drive it with
/// a crafted `Config` without hitting disk (`Config::load_or_init`).
async fn run_tick(&self, config: Config) -> Result<TickResult> {
let prefix = self.log_prefix();
let started = std::time::Instant::now();
let tick_at = now_secs();
let my_generation = self.tick_generation.fetch_add(1, Ordering::SeqCst) + 1;
// ── Provider gate + rate-cap halt (per-instance signature) ───────────
let provider_signature = format!(
"{}|{}",
self.profile.id(),
subconscious_provider_signature(&config)
);
if self
.state
.lock()
.await
.should_skip_for_rate_cap_halt(&provider_signature, &prefix)
{
return Ok(self.quiet_result(tick_at, started));
}
if let Some(reason) = subconscious_provider_unavailable_reason(&config) {
info!("{prefix} provider unavailable, skipping tick: {reason}");
let mut state = self.state.lock().await;
state.provider_unavailable_reason = Some(reason);
state.consecutive_failures += 1;
state.total_ticks += 1;
return Ok(self.quiet_result(tick_at, started));
}
{
let mut state = self.state.lock().await;
state.provider_unavailable_reason = None;
}
// ── Run the tick graph (observe → [prepare → reflect] → commit) ──────
let seed = SubconsciousState {
tick_id: format!("{}", tick_at as u64),
generation: my_generation,
..Default::default()
};
let final_state = match self.run_graph(&config, seed).await {
Ok(s) => s,
Err(e) => {
warn!("{prefix} tick graph run failed: {e}");
let mut state = self.state.lock().await;
state.consecutive_failures += 1;
state.total_ticks += 1;
return Ok(self.quiet_result(tick_at, started));
}
};
// ── Superseded-generation discard ────────────────────────────────────
if self.tick_generation.load(Ordering::SeqCst) != my_generation {
info!("{prefix} tick superseded by newer tick, discarding");
let mut state = self.state.lock().await;
state.total_ticks += 1;
return Ok(self.quiet_result(tick_at, started));
}
// ── Per-instance state/status update ─────────────────────────────────
let response_chars = match &final_state.reflection {
Some(Reflection::Acted { response_chars }) => *response_chars,
_ => 0,
};
let mut state = self.state.lock().await;
state.total_ticks += 1;
if let Some(err) = &final_state.reflect_error {
state.consecutive_failures += 1;
if is_tool_capability_error(err) {
info!("{prefix} configured chat model has no tool-use endpoint (TAURI-RUST-ADC)");
state.provider_unavailable_reason = Some(TOOL_UNSUPPORTED_REASON.to_string());
} else if is_permanent_rate_cap_error(err) {
state.arm_rate_cap_halt(&provider_signature, &prefix);
}
} else {
// Quiet tick or successful reflect — both advance.
state.consecutive_failures = 0;
state.last_tick_at = tick_at;
persist_last_tick_at(&self.workspace_dir, self.profile.id(), tick_at);
}
Ok(TickResult {
tick_at,
duration_ms: started.elapsed().as_millis() as u64,
response_chars,
})
}
fn quiet_result(&self, tick_at: f64, started: std::time::Instant) -> TickResult {
TickResult {
tick_at,
duration_ms: started.elapsed().as_millis() as u64,
response_chars: 0,
}
}
/// Build and run the per-tick graph, checkpointing at every super-step
/// boundary under thread `subconscious:<instance>:<tick_id>`.
async fn run_graph(
&self,
config: &Config,
seed: SubconsciousState,
) -> anyhow::Result<SubconsciousState> {
let graph = self.build_graph(config)?;
let thread_id = format!("subconscious:{}:{}", self.profile.id(), seed.tick_id);
let checkpoint_db = self
.workspace_dir
.join("subconscious")
.join("graph_checkpoints.db");
let checkpointer = Arc::new(
SqliteCheckpointer::<SubconsciousState>::open(&checkpoint_db)
.map_err(|e| anyhow::anyhow!("open subconscious checkpoint store: {e}"))?,
);
// Keep a handle for post-run GC. Each tick uses a unique thread id, so a
// *completed* tick's checkpoints are dead weight — ticks run every N
// minutes forever, so without pruning `graph_checkpoints.db` grows
// unboundedly (phase 6: adopt the checkpointer's existing retention
// primitive rather than adding one upstream).
let gc = Arc::clone(&checkpointer);
let graph = graph
.with_checkpointer(checkpointer)
.with_event_sink(Arc::new(GraphTracingSink::new(thread_id.clone())));
let exec = graph
.run_with_thread(thread_id.clone(), seed)
.await
.map_err(|e| anyhow::anyhow!("subconscious graph run failed: {e}"))?;
// The run returned; resume value is spent. Drop this tick's thread so the
// checkpoint db stays bounded. Best-effort — a GC failure is not a tick
// failure.
if let Err(e) = gc.delete_thread(&thread_id).await {
debug!(
"{} checkpoint GC failed for {thread_id}: {e}",
self.log_prefix()
);
}
Ok(exec.state)
}
fn build_graph(
&self,
config: &Config,
) -> anyhow::Result<CompiledGraph<SubconsciousState, SubconsciousUpdate>> {
let profile = self.profile.clone();
let config = Arc::new(config.clone());
let generation = self.tick_generation.clone();
let log_prefix = self.log_prefix();
let mut builder = GraphBuilder::<SubconsciousState, SubconsciousUpdate>::new().set_reducer(
ClosureStateReducer::new(|mut s: SubconsciousState, u: SubconsciousUpdate| {
match u {
SubconsciousUpdate::Observed(obs) => s.observation = obs,
SubconsciousUpdate::Prepared(ctx) => s.prepared_context = ctx,
SubconsciousUpdate::Reflected(r) => s.reflection = Some(r),
SubconsciousUpdate::ReflectFailed(e) => s.reflect_error = Some(e),
SubconsciousUpdate::Committed => s.committed = true,
SubconsciousUpdate::Noop => {}
}
Ok(s)
}),
);
// `observe`: what changed in this world? Routes quiet → commit,
// changed → prepare.
{
let profile = profile.clone();
let config = config.clone();
let prefix = log_prefix.clone();
builder = builder.add_node("observe", move |_s: SubconsciousState, _c: NodeContext| {
let profile = profile.clone();
let config = config.clone();
let prefix = prefix.clone();
async move {
let obs = profile.observe(&config).await;
let route = if obs.has_changes { "prepare" } else { "commit" };
debug!(
"{prefix} node.observe has_changes={} external={} route={route}",
obs.has_changes, obs.has_external_content,
);
Ok(NodeResult::Command(
Command::default()
.with_update(SubconsciousUpdate::Observed(obs))
.with_goto([route]),
))
}
});
}
// `prepare`: grounding context for the reflection turn (changed only).
{
let profile = profile.clone();
let config = config.clone();
builder = builder.add_node("prepare", move |s: SubconsciousState, _c: NodeContext| {
let profile = profile.clone();
let config = config.clone();
async move {
let ctx = profile.prepare_context(&config, &s.observation).await;
Ok(NodeResult::Update(SubconsciousUpdate::Prepared(ctx)))
}
});
}
// `reflect`: the reflection turn. Routes ok → commit, err → done (a
// failed reflect must not advance the baseline).
{
let profile = profile.clone();
let config = config.clone();
let prefix = log_prefix.clone();
builder = builder.add_node("reflect", move |s: SubconsciousState, _c: NodeContext| {
let profile = profile.clone();
let config = config.clone();
let prefix = prefix.clone();
async move {
let origin = profile.origin(&s.observation);
debug!("{prefix} node.reflect origin={origin:?}");
match profile
.reflect(&config, &s.observation, &s.prepared_context)
.await
{
Ok(reflection) => Ok(NodeResult::Command(
Command::default()
.with_update(SubconsciousUpdate::Reflected(reflection))
.with_goto(["commit"]),
)),
Err(e) => {
warn!("{prefix} node.reflect failed: {e}");
Ok(NodeResult::Command(
Command::default()
.with_update(SubconsciousUpdate::ReflectFailed(e))
.with_goto(["done"]),
))
}
}
}
});
}
// `commit`: advance the world baseline/cursor — but only if this tick
// has not been superseded by a newer one (checked against the shared
// generation counter, the graph twin of an external cancel token).
{
let profile = profile.clone();
let config = config.clone();
let prefix = log_prefix.clone();
builder = builder.add_node("commit", move |s: SubconsciousState, _c: NodeContext| {
let profile = profile.clone();
let config = config.clone();
let generation = generation.clone();
let prefix = prefix.clone();
async move {
if generation.load(Ordering::SeqCst) != s.generation {
debug!("{prefix} node.commit skipped — tick superseded");
return Ok(NodeResult::Update(SubconsciousUpdate::Noop));
}
profile.commit(&config, &s.observation).await;
debug!("{prefix} node.commit advanced baseline");
Ok(NodeResult::Update(SubconsciousUpdate::Committed))
}
});
}
let graph = builder
.add_node(
"done",
|_s: SubconsciousState, _c: NodeContext| async move {
Ok(NodeResult::Update(SubconsciousUpdate::Noop))
},
)
.add_edge("prepare", "reflect")
.add_edge("commit", "done")
.set_entry("observe")
.mark_command_routing("observe")
.mark_command_routing("reflect")
.set_finish("done")
.compile()
.map_err(|e| anyhow::anyhow!("subconscious graph compile failed: {e}"))?;
Ok(graph)
}
/// Stable world id (`"memory"` | `"tinyplace"`) — for logging + fan-out.
pub fn id(&self) -> &'static str {
self.profile.id()
}
/// Whether this instance's cadence has elapsed since its last successful
/// tick (a never-ticked instance is always due). Pure read of the small
/// `state` mutex — never touches `tick_lock`, so the heartbeat fan-out can
/// poll it without ever blocking on a running tick.
pub async fn is_due(&self, now: f64) -> bool {
let last = self.state.lock().await.last_tick_at;
if last <= 0.0 {
return true;
}
now - last >= f64::from(self.interval_minutes) * 60.0
}
pub async fn status(&self) -> SubconsciousStatus {
let state = self.state.lock().await;
SubconsciousStatus {
instance: self.profile.id().to_string(),
enabled: self.enabled,
mode: self.mode_label.clone(),
provider_available: state.provider_unavailable_reason.is_none(),
provider_unavailable_reason: state.provider_unavailable_reason.clone(),
interval_minutes: self.interval_minutes,
last_tick_at: (state.last_tick_at > 0.0).then_some(state.last_tick_at),
total_ticks: state.total_ticks,
consecutive_failures: state.consecutive_failures,
}
}
}
#[cfg(test)]
impl SubconsciousInstance {
/// Drive one tick with a caller-supplied `Config`, skipping the tick lock,
/// timeout, and `Config::load_or_init` disk read. Test seam only.
pub(crate) async fn run_tick_for_test(&self, config: Config) -> Result<TickResult> {
self.run_tick(config).await
}
/// A clone of the shared generation counter so a test profile can simulate a
/// newer tick superseding the one in flight.
pub(crate) fn generation_handle(&self) -> Arc<AtomicU64> {
self.tick_generation.clone()
}
pub(crate) async fn snapshot_failures(&self) -> u64 {
self.state.lock().await.consecutive_failures
}
}
fn persist_last_tick_at(workspace_dir: &std::path::Path, instance: &str, tick_at: f64) {
if let Err(e) = super::store::with_connection(workspace_dir, |conn| {
super::store::set_last_tick_at(conn, instance, tick_at)
}) {
warn!("[subconscious:{instance}] failed to persist last_tick_at={tick_at}: {e}");
}
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
#[cfg(test)]
#[path = "instance_tests.rs"]
mod tests;
@@ -0,0 +1,343 @@
//! Runner mechanics driven by a scripted [`FakeProfile`] — the world-agnostic
//! tick behaviour (quiet short-circuit, baseline-on-success, rate-cap halt
//! lifecycle, supersede discard) verified without any real provider/agent IO.
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use super::*;
use crate::openhuman::agent::turn_origin::TrustedAutomationSource;
use crate::openhuman::config::Config;
use crate::openhuman::subconscious::profile::{Observation, Reflection, SubconsciousProfile};
/// A scripted profile: records how often each stage ran and returns canned
/// observations / reflections so the runner mechanics can be asserted in
/// isolation.
struct FakeProfile {
observations: Mutex<Vec<Observation>>,
reflect_result: Mutex<Result<Reflection, String>>,
observe_calls: AtomicUsize,
prepare_calls: AtomicUsize,
reflect_calls: AtomicUsize,
commit_calls: AtomicUsize,
/// When set, `reflect` bumps this generation counter once to simulate a
/// newer tick superseding the in-flight one.
supersede: Mutex<Option<Arc<AtomicU64>>>,
supersede_pending: AtomicUsize,
}
impl FakeProfile {
fn new(obs: Observation, reflect: Result<Reflection, String>) -> Self {
Self {
observations: Mutex::new(vec![obs]),
reflect_result: Mutex::new(reflect),
observe_calls: AtomicUsize::new(0),
prepare_calls: AtomicUsize::new(0),
reflect_calls: AtomicUsize::new(0),
commit_calls: AtomicUsize::new(0),
supersede: Mutex::new(None),
supersede_pending: AtomicUsize::new(0),
}
}
fn changed() -> Observation {
Observation {
rendered: "something changed".into(),
has_changes: true,
has_external_content: true,
commit_token: None,
}
}
fn quiet() -> Observation {
Observation {
rendered: String::new(),
has_changes: false,
has_external_content: false,
commit_token: None,
}
}
}
#[async_trait::async_trait]
impl SubconsciousProfile for FakeProfile {
fn id(&self) -> &'static str {
"memory"
}
fn cadence(&self, _config: &Config) -> std::time::Duration {
std::time::Duration::from_secs(300)
}
async fn observe(&self, _config: &Config) -> Observation {
self.observe_calls.fetch_add(1, Ordering::SeqCst);
let mut obs = self.observations.lock().unwrap();
if obs.len() > 1 {
obs.remove(0)
} else {
obs[0].clone()
}
}
async fn prepare_context(&self, _config: &Config, _obs: &Observation) -> String {
self.prepare_calls.fetch_add(1, Ordering::SeqCst);
"prepared".into()
}
async fn reflect(
&self,
_config: &Config,
_obs: &Observation,
_prepared: &str,
) -> Result<Reflection, String> {
self.reflect_calls.fetch_add(1, Ordering::SeqCst);
if self.supersede_pending.swap(0, Ordering::SeqCst) > 0 {
if let Some(gen) = self.supersede.lock().unwrap().as_ref() {
gen.fetch_add(1, Ordering::SeqCst);
}
}
self.reflect_result.lock().unwrap().clone()
}
async fn commit(&self, _config: &Config, _obs: &Observation) {
self.commit_calls.fetch_add(1, Ordering::SeqCst);
}
fn origin(&self, obs: &Observation) -> TrustedAutomationSource {
if obs.has_external_content {
TrustedAutomationSource::SubconsciousTainted
} else {
TrustedAutomationSource::Subconscious
}
}
}
/// A config that routes the subconscious provider to an available BYO provider
/// with a stable, non-cloud signature (`other:groq`) so the provider gate never
/// short-circuits and the rate-cap signature is deterministic.
fn test_config(workspace: &std::path::Path) -> Config {
let mut cfg = Config::default();
cfg.workspace_dir = workspace.to_path_buf();
cfg.subconscious_provider = Some("groq".to_string());
cfg
}
fn build(profile: Arc<FakeProfile>, workspace: &std::path::Path) -> SubconsciousInstance {
SubconsciousInstance::new(
profile as Arc<dyn SubconsciousProfile>,
workspace.to_path_buf(),
true,
5,
"simple",
)
}
// ── Rate-cap circuit breaker state machine (TAURI-RUST-HXF) ─────────────
#[test]
fn instance_state_rate_cap_transitions() {
let mut state = InstanceState {
last_tick_at: 0.0,
total_ticks: 0,
consecutive_failures: 0,
provider_unavailable_reason: None,
rate_cap_halt_signature: None,
};
let prefix = "[subconscious:memory]";
// No halt armed → the tick proceeds (does not skip).
assert!(!state.should_skip_for_rate_cap_halt("memory|other:groq", prefix));
// A permanent rate-cap failure arms the halt + actionable reason.
state.arm_rate_cap_halt("memory|other:groq", prefix);
assert_eq!(
state.rate_cap_halt_signature.as_deref(),
Some("memory|other:groq")
);
assert_eq!(
state.provider_unavailable_reason.as_deref(),
Some(RATE_CAP_HALT_REASON)
);
// Same config still set → skip the doomed run, and count the skipped tick.
let before = state.total_ticks;
assert!(state.should_skip_for_rate_cap_halt("memory|other:groq", prefix));
assert_eq!(state.total_ticks, before + 1);
// User switched provider (signature changed) → clear halt + reason, resume.
assert!(!state.should_skip_for_rate_cap_halt("memory|cloud", prefix));
assert!(state.rate_cap_halt_signature.is_none());
assert!(state.provider_unavailable_reason.is_none());
}
#[tokio::test]
async fn quiet_observation_commits_and_advances_without_reflecting() {
let dir = tempfile::tempdir().unwrap();
let profile = Arc::new(FakeProfile::new(FakeProfile::quiet(), Ok(Reflection::Idle)));
let instance = build(profile.clone(), dir.path());
let result = instance
.run_tick_for_test(test_config(dir.path()))
.await
.unwrap();
assert_eq!(profile.observe_calls.load(Ordering::SeqCst), 1);
assert_eq!(
profile.prepare_calls.load(Ordering::SeqCst),
0,
"no prepare"
);
assert_eq!(
profile.reflect_calls.load(Ordering::SeqCst),
0,
"no reflect"
);
assert_eq!(profile.commit_calls.load(Ordering::SeqCst), 1, "committed");
// last_tick_at advanced to this tick.
let status = instance.status().await;
assert!(status.last_tick_at.is_some());
assert_eq!(status.consecutive_failures, 0);
assert_eq!(status.total_ticks, 1);
assert_eq!(result.response_chars, 0);
}
#[tokio::test]
async fn changed_observation_runs_full_pipeline_and_commits_on_success() {
let dir = tempfile::tempdir().unwrap();
let profile = Arc::new(FakeProfile::new(
FakeProfile::changed(),
Ok(Reflection::Acted { response_chars: 42 }),
));
let instance = build(profile.clone(), dir.path());
let result = instance
.run_tick_for_test(test_config(dir.path()))
.await
.unwrap();
assert_eq!(profile.observe_calls.load(Ordering::SeqCst), 1);
assert_eq!(profile.prepare_calls.load(Ordering::SeqCst), 1);
assert_eq!(profile.reflect_calls.load(Ordering::SeqCst), 1);
assert_eq!(profile.commit_calls.load(Ordering::SeqCst), 1);
assert_eq!(result.response_chars, 42);
assert_eq!(instance.status().await.consecutive_failures, 0);
}
#[tokio::test]
async fn failing_reflect_holds_baseline_and_bumps_failures() {
let dir = tempfile::tempdir().unwrap();
let profile = Arc::new(FakeProfile::new(
FakeProfile::changed(),
Err("agent run: transient boom".into()),
));
let instance = build(profile.clone(), dir.path());
instance
.run_tick_for_test(test_config(dir.path()))
.await
.unwrap();
assert_eq!(profile.reflect_calls.load(Ordering::SeqCst), 1);
assert_eq!(profile.commit_calls.load(Ordering::SeqCst), 0, "no commit");
let status = instance.status().await;
assert_eq!(status.consecutive_failures, 1);
// A failed reflect must not advance last_tick_at (re-diff the window next).
assert!(status.last_tick_at.is_none());
}
#[tokio::test]
async fn permanent_rate_cap_error_arms_halt_then_config_change_resumes() {
let dir = tempfile::tempdir().unwrap();
let rate_cap = r#"agent run: groq API error (413 Payload Too Large): {"error":{"message":"Request too large for model in organization on tokens per minute (TPM): Limit 8000, Requested 42084."}}"#;
let profile = Arc::new(FakeProfile::new(
FakeProfile::changed(),
Err(rate_cap.to_string()),
));
let instance = build(profile.clone(), dir.path());
// Tick 1: reflect fails with a permanent 413 → halt armed under `memory|other:groq`.
instance
.run_tick_for_test(test_config(dir.path()))
.await
.unwrap();
assert_eq!(profile.observe_calls.load(Ordering::SeqCst), 1);
let status = instance.status().await;
assert!(!status.provider_available, "halt surfaced as unavailable");
// Tick 2: same config → the gate skips before observe even runs.
instance
.run_tick_for_test(test_config(dir.path()))
.await
.unwrap();
assert_eq!(
profile.observe_calls.load(Ordering::SeqCst),
1,
"halted tick must not observe"
);
// Tick 3: user switched provider (signature changes) → halt clears, resumes.
let mut resumed = test_config(dir.path());
resumed.subconscious_provider = Some("openai".to_string());
instance.run_tick_for_test(resumed).await.unwrap();
assert_eq!(
profile.observe_calls.load(Ordering::SeqCst),
2,
"resumed tick observes again"
);
}
#[tokio::test]
async fn completed_ticks_leave_no_checkpoint_threads() {
// Phase 6: each tick uses a unique checkpoint thread; a completed tick GCs
// it, so the checkpoint db stays bounded no matter how many ticks run.
use tinyagents::graph::{Checkpointer, SqliteCheckpointer};
let dir = tempfile::tempdir().unwrap();
let profile = Arc::new(FakeProfile::new(
FakeProfile::changed(),
Ok(Reflection::Acted { response_chars: 1 }),
));
let instance = build(profile.clone(), dir.path());
for _ in 0..3 {
instance
.run_tick_for_test(test_config(dir.path()))
.await
.unwrap();
}
let db = dir.path().join("subconscious").join("graph_checkpoints.db");
let cp = SqliteCheckpointer::<serde_json::Value>::open(&db).unwrap();
let threads = cp.list_threads().await.unwrap();
assert!(
threads.is_empty(),
"completed ticks pruned their threads, got {threads:?}"
);
}
#[tokio::test]
async fn superseded_tick_discards_result_and_skips_commit() {
let dir = tempfile::tempdir().unwrap();
let profile = Arc::new(FakeProfile::new(
FakeProfile::changed(),
Ok(Reflection::Acted { response_chars: 99 }),
));
let instance = build(profile.clone(), dir.path());
// Wire the profile to bump the instance's generation during reflect.
*profile.supersede.lock().unwrap() = Some(instance.generation_handle());
profile.supersede_pending.store(1, Ordering::SeqCst);
let result = instance
.run_tick_for_test(test_config(dir.path()))
.await
.unwrap();
assert_eq!(profile.reflect_calls.load(Ordering::SeqCst), 1);
assert_eq!(
profile.commit_calls.load(Ordering::SeqCst),
0,
"superseded tick must not commit"
);
assert_eq!(result.response_chars, 0, "result discarded");
let status = instance.status().await;
assert!(status.last_tick_at.is_none(), "no baseline advance");
assert_eq!(
instance.snapshot_failures().await,
0,
"not counted a failure"
);
}
+16 -3
View File
@@ -1,7 +1,11 @@
pub mod agent;
pub mod engine;
pub mod global;
pub mod factory;
pub mod heartbeat;
pub mod instance;
pub mod profile;
pub mod profiles;
pub mod provider;
pub mod registry;
mod schemas;
pub mod session;
pub mod source_chunk;
@@ -9,7 +13,16 @@ pub mod store;
pub mod types;
pub mod user_thread;
pub use engine::SubconsciousEngine;
pub use factory::{make_subconscious, SubconsciousKind};
pub use instance::SubconsciousInstance;
pub use profile::{Observation, Reflection, SubconsciousProfile};
pub use profiles::memory::memory_instance;
pub use profiles::tinyplace::tinyplace_instance;
/// Back-compat alias for the old single-engine type. The live `memory` world is
/// now a [`SubconsciousInstance`] built via [`memory_instance`]; callers that
/// held a `SubconsciousEngine` keep the same runtime type.
pub type SubconsciousEngine = SubconsciousInstance;
pub use schemas::{
all_controller_schemas as all_subconscious_controller_schemas,
all_registered_controllers as all_subconscious_registered_controllers,
+98
View File
@@ -0,0 +1,98 @@
//! The `SubconsciousProfile` contract — one "world" a subconscious can be
//! instantiated over.
//!
//! A profile is the injected runtime for the generic [`super::instance`] tick
//! graph: its methods are the graph's nodes (observe → prepare_context →
//! reflect → commit). The runner owns everything world-agnostic (tick lock,
//! generation/supersede, timeout, provider gate + rate-cap halt, status); the
//! profile owns only what differs between worlds. Adding a new world is a new
//! profile file, not another engine.
//!
//! Two profiles ship today (phases 23):
//! - `memory` — the user's connected sources; observes a memory_diff, reflects
//! with the slim decision agent (to-dos, goals, `notify_user`, delegation).
//! - `tinyplace` — the tiny.place orchestration world; observes the compressed
//! execution history + world-diff, reflects with a tool-free steering
//! synthesis that emits `STEERING_DIRECTIVE`s.
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::openhuman::agent::turn_origin::TrustedAutomationSource;
use crate::openhuman::config::Config;
/// What a profile's `observe` stage found changed in its world since the last
/// baseline. Serde so it can ride in the tick graph's checkpointed state.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Observation {
/// Rendered world diff handed to `reflect` (empty when quiet).
pub rendered: String,
/// Whether this window is worth a reflection turn. `false` short-circuits
/// the runner's quiet path (no LLM call — quiet ticks cost nothing).
pub has_changes: bool,
/// Whether the change window contains third-party content — the taint input
/// for [`SubconsciousProfile::origin`].
pub has_external_content: bool,
/// Opaque token `commit` uses to advance exactly the observed window
/// (memory: none — it re-checkpoints the whole world; tinyplace: the newest
/// reviewed compressed-row `created_at` for the review cursor).
pub commit_token: Option<String>,
}
/// The outcome of a profile's `reflect` stage. Serde so it can ride in the tick
/// graph's checkpointed state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Reflection {
/// The decision agent acted through tools (memory profile).
Acted { response_chars: usize },
/// A steering directive was emitted (tinyplace profile).
Steered { directive_id: i64 },
/// The model looked and correctly chose to do nothing.
Idle,
}
/// One "world" a subconscious can be instantiated over. See the module docs.
#[async_trait::async_trait]
pub trait SubconsciousProfile: Send + Sync {
/// Stable instance id — store-key namespace, log prefix, RPC name.
/// (`"memory"` | `"tinyplace"`.)
fn id(&self) -> &'static str;
/// Tick cadence for this world (the heartbeat fans out on this).
fn cadence(&self, config: &Config) -> Duration;
/// Stage 1: what changed in this world since my baseline?
///
/// Infallible by signature: errors and first-ever ticks surface as an empty
/// observation (`has_changes == false`) so the runner's branch-free quiet
/// path handles them uniformly. The profile logs its own errors.
async fn observe(&self, config: &Config) -> Observation;
/// Stage 2 (optional): grounding context for the reflection turn. The
/// default returns `""` (the tinyplace profile skips this — steering is
/// deliberately tool-free). Runs only when `obs.has_changes`.
async fn prepare_context(&self, _config: &Config, _obs: &Observation) -> String {
String::new()
}
/// Stage 3: the reflection turn. Runs only when `obs.has_changes`.
///
/// Returns `Err(String)` on a genuine failure (agent/provider error) so the
/// runner can classify it (tool-capability, permanent rate cap) and hold the
/// baseline; a "looked and did nothing" result is `Ok(Reflection::Idle)`.
async fn reflect(
&self,
config: &Config,
obs: &Observation,
prepared_context: &str,
) -> Result<Reflection, String>;
/// Advance this world's baseline/cursor. Called by the runner after a quiet
/// tick (refresh the baseline) and after a successful reflect — never after
/// a failed or superseded one.
async fn commit(&self, config: &Config, obs: &Observation);
/// Turn-origin taint for this observation (memory: tainted iff the diff
/// carried external content; tinyplace: always tainted).
fn origin(&self, obs: &Observation) -> TrustedAutomationSource;
}
@@ -0,0 +1,429 @@
//! The `memory` subconscious world — OpenHuman's internal high-level world of
//! the user's connected memory sources (Gmail / Slack / Notion / folders).
//!
//! Extracted verbatim from the old monolithic engine (stages 13):
//! 1. **observe** — diff the connected sources against the world baseline
//! (`memory_diff::diff_since_checkpoint`) to see how the user's world changed.
//! 2. **prepare_context** — run the read-only `context_scout` over that diff to
//! gather grounding context.
//! 3. **reflect** — hand `diff + context` to the slim decision agent, which
//! records to-dos (`update_task`), evolves goals (`goals_*`), notifies the
//! user (`notify_user`), or delegates (`spawn_async_subagent`).
//!
//! The generic [`SubconsciousInstance`] runner owns the scheduler + circuit
//! breaker; this profile owns only what is memory-specific.
use std::sync::Arc;
use async_trait::async_trait;
use tracing::{debug, info, warn};
use super::super::instance::SubconsciousInstance;
use super::super::profile::{Observation, Reflection, SubconsciousProfile};
use super::super::store;
use crate::openhuman::agent::turn_origin::TrustedAutomationSource;
use crate::openhuman::agent_orchestration::parent_context::with_root_parent;
use crate::openhuman::config::schema::SubconsciousMode;
use crate::openhuman::config::Config;
use crate::openhuman::memory_diff::types::CrossSourceDiff;
/// Per-tool-call timeout injected into the decision agent config.
const TOOL_CALL_TIMEOUT_SECS: u64 = 5 * 60;
/// Label stamped on the world-baseline checkpoint `commit` re-creates each tick.
const BASELINE_CHECKPOINT_LABEL: &str = "subconscious_tick";
/// Max changed items listed per source in the rendered world diff, to keep the
/// decision agent's prompt bounded when a source churns a lot.
const MAX_ITEMS_PER_SOURCE: usize = 10;
/// Tool catalogue handed to the `context_scout` so its `recommended_tool_calls`
/// stay grounded in tools the decision agent can actually call. Keep in sync
/// with `agent/agent.toml`'s `[tools].named` (actionable subset).
const SUBCONSCIOUS_TOOL_CATALOG: &str = "\
- notify_user: Send the user a proactive message about something important or time-sensitive.
- update_task: Add or update an actionable item on the user's global to-do board.
- goals_add: Record a new long-term goal that the changed world makes relevant.
- goals_edit: Revise an existing long-term goal.
- spawn_async_subagent: Delegate deeper research or multi-step work.
";
/// Construct the live `memory` instance from config (used by the registry /
/// bootstrap). The only place `MemoryProfile` is wired into a runner.
pub fn memory_instance(config: &Config) -> SubconsciousInstance {
let mode = config.heartbeat.effective_subconscious_mode();
SubconsciousInstance::new(
Arc::new(MemoryProfile::new(config)),
config.workspace_dir.clone(),
mode.is_enabled(),
mode.default_interval_minutes().max(5),
mode.as_str(),
)
}
/// The `memory` world profile. Holds only the subconscious mode (drives cadence
/// and the decision agent's delegation depth); everything else it reads live
/// from the per-tick `Config`.
pub struct MemoryProfile {
mode: SubconsciousMode,
}
impl MemoryProfile {
pub fn new(config: &Config) -> Self {
Self {
mode: config.heartbeat.effective_subconscious_mode(),
}
}
/// Stage 2: run the read-only `context_scout` over the world diff to gather
/// grounding context. Best-effort — on any error the decision agent simply
/// runs without a prepared-context section.
///
/// The tick is a controller-spawned background surface with **no enclosing
/// agent turn**, so the scout spawn has no ambient `current_parent()`. We
/// establish a root parent via [`with_root_parent`] — without it every
/// tick's scout died with `NoParentContext` (Sentry TAURI-RUST-HMW; #4337).
async fn run_scout(&self, config: &Config, world_diff: &str) -> String {
let question = format!(
"Background awareness check. Here is what changed in the user's connected sources \
since the last check:\n\n{world_diff}\n\nSurface what the user should be aware of or \
act on, and the context that grounds a good decision.",
);
// Flatten: outer Err = root-parent build failure, inner = scout result.
let scout = with_root_parent(config, "subconscious", "subconscious", "subconscious", {
crate::openhuman::agent_orchestration::tools::run_context_scout_with_catalog(
&question,
None,
SUBCONSCIOUS_TOOL_CATALOG,
)
})
.await
.and_then(|inner| inner);
match scout {
Ok(result) if !result.is_error => {
debug!(
"[subconscious:memory] prepared context bundle ({} chars)",
result.output().chars().count()
);
result.output().to_string()
}
Ok(result) => {
warn!(
"[subconscious:memory] prepare_context returned an error result: {}",
result.output()
);
String::new()
}
Err(e) => {
warn!("[subconscious:memory] prepare_context failed: {e}");
String::new()
}
}
}
/// Run the slim subconscious agent over `prompt_text` (diff + prepared
/// context). The agent decides and acts through its tools. Returns
/// `response_chars` on success, or `Err` on agent init/run failure.
async fn run_agent(
&self,
config: &Config,
prompt_text: &str,
has_external_content: bool,
) -> Result<usize, String> {
use crate::openhuman::agent::Agent;
let mut effective = config.clone();
effective.agent.agent_timeout_secs = TOOL_CALL_TIMEOUT_SECS;
// Route the tick build through the `subconscious` background workload so
// Settings → AI → Advanced "Subconscious" governs the cloud tick
// provider, instead of riding the `chat` role.
effective.default_model = Some("hint:subconscious".to_string());
debug!(
"[subconscious:memory] tick provider routed via hint:subconscious (subconscious_provider={:?})",
effective.subconscious_provider
);
// The decision agent must write internal continuity (global to-dos,
// goals) and surface proactive messages — all app-internal writes, not
// external effects. So it runs with Full autonomy; genuinely external
// effects are still gated by the tainted origin + approval gate. Mode
// only scales how much delegation depth the tick gets.
effective.autonomy.level = crate::openhuman::security::AutonomyLevel::Full;
match self.mode {
SubconsciousMode::Simple => {
effective.agent.max_tool_iterations = 15;
}
SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => {
effective.agent.max_tool_iterations = 30;
}
SubconsciousMode::Off => return Ok(0),
}
let mut agent = Agent::from_config(&effective).map_err(|e| {
warn!("[subconscious:memory] agent init failed: {e}");
format!("agent init: {e}")
})?;
agent.set_event_context(
format!("subconscious:tick:{}", now_secs() as u64),
"subconscious",
);
let mode_guidance = match self.mode {
SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => {
"\n\nYou may delegate deeper work with `spawn_async_subagent` (e.g. research \
or multi-step execution) when you spot something genuinely actionable."
}
_ => "",
};
let user_message = format!(
"{prompt_text}\
## Your job\n\n\
The diff above is how the user's world changed since the last check; the prepared \
context grounds it. Decide what (if anything) deserves action:\n\
- Record or update actionable follow-ups on the user's to-do board with `update_task` \
(pass `threadId: \"user-tasks\"`).\n\
- Evolve the user's long-term goals with `goals_add` / `goals_edit` when the world \
shifts what matters to them.\n\
- Surface anything time-sensitive or important with `notify_user`.\n\n\
If nothing meaningful changed, do nothing — staying silent is the right call most \
ticks. Do not invent busywork.{mode_guidance}",
);
debug!("[subconscious:memory] spawning decision agent");
let source = tick_origin_source(has_external_content);
let origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation {
job_id: format!("subconscious:tick:{}", now_secs() as u64),
source,
};
let response = crate::openhuman::agent::turn_origin::with_origin(
origin,
agent.run_single(&user_message),
)
.await
.map_err(|e| {
warn!("[subconscious:memory] agent run failed: {e}");
format!("agent run: {e}")
})?;
let response_chars = response.chars().count();
info!(
"[subconscious:memory] decision agent completed (response {} chars)",
response_chars
);
Ok(response_chars)
}
}
#[async_trait]
impl SubconsciousProfile for MemoryProfile {
fn id(&self) -> &'static str {
"memory"
}
fn cadence(&self, _config: &Config) -> std::time::Duration {
std::time::Duration::from_secs(u64::from(self.mode.default_interval_minutes().max(5)) * 60)
}
async fn observe(&self, config: &Config) -> Observation {
// ── Stage 1: memory_diff — how did the agent's world change? ──────────
// (The tiny.place orchestration review is now its own `tinyplace`
// instance, ticked independently by the heartbeat fan-out — it no longer
// piggybacks here.)
let baseline = store::with_connection(&config.workspace_dir, |conn| {
store::get_baseline_checkpoint_id(conn, "memory")
})
.unwrap_or_else(|e| {
warn!("[subconscious:memory] baseline load failed: {e}");
None
});
let diff: Option<CrossSourceDiff> = match &baseline {
Some(checkpoint_id) => match crate::openhuman::memory_diff::ops::diff_since_checkpoint(
checkpoint_id,
config,
false,
)
.await
{
Ok(d) => Some(d),
Err(e) => {
warn!(
"[subconscious:memory] memory_diff failed (baseline={checkpoint_id}): {e}"
);
None
}
},
None => {
debug!("[subconscious:memory] no world baseline yet — first tick establishes one");
None
}
};
let has_changes = diff
.as_ref()
.map(|d| world_diff_change_count(d) > 0)
.unwrap_or(false);
if !has_changes {
// Quiet window, first tick, or a diff error: nothing to react to.
// The runner routes straight to commit, which refreshes the baseline.
info!("[subconscious:memory] no world changes this tick — refreshing baseline, no agent run");
return Observation::default();
}
let diff = diff.expect("has_changes implies diff is Some");
Observation {
rendered: render_world_diff(&diff),
has_changes: true,
// Every change originates from an external source sync, so the
// decision turn runs tainted: the approval gate refuses
// external_effect tools.
has_external_content: true,
commit_token: None,
}
}
async fn prepare_context(&self, config: &Config, obs: &Observation) -> String {
self.run_scout(config, &obs.rendered).await
}
async fn reflect(
&self,
config: &Config,
obs: &Observation,
prepared_context: &str,
) -> Result<Reflection, String> {
let mut agent_prompt =
String::with_capacity(obs.rendered.len() + prepared_context.len() + 256);
agent_prompt.push_str("## What changed in your world since the last check\n\n");
agent_prompt.push_str(&obs.rendered);
agent_prompt.push_str("\n\n");
if !prepared_context.is_empty() {
agent_prompt.push_str("## Prepared context\n\n");
agent_prompt.push_str(prepared_context);
agent_prompt.push_str("\n\n");
}
let response_chars = self
.run_agent(config, &agent_prompt, obs.has_external_content)
.await?;
Ok(Reflection::Acted { response_chars })
}
async fn commit(&self, config: &Config, _obs: &Observation) {
// Re-snapshot the world and persist the new checkpoint as the baseline
// the next tick diffs against. Best-effort — a failure leaves the old
// baseline in place (the next tick diffs against a slightly older window).
match crate::openhuman::memory_diff::ops::create_checkpoint(
BASELINE_CHECKPOINT_LABEL,
config,
)
.await
{
Ok(ckpt) => {
if let Err(e) = store::with_connection(&config.workspace_dir, |conn| {
store::set_baseline_checkpoint_id(conn, "memory", &ckpt.id)
}) {
warn!("[subconscious:memory] failed to persist baseline checkpoint id: {e}");
} else {
debug!(
"[subconscious:memory] world baseline advanced to {}",
ckpt.id
);
}
}
Err(e) => {
warn!("[subconscious:memory] failed to create world baseline checkpoint: {e}")
}
}
}
fn origin(&self, obs: &Observation) -> TrustedAutomationSource {
tick_origin_source(obs.has_external_content)
}
}
// ── World-diff rendering ─────────────────────────────────────────────────────
/// Pick the `TrustedAutomationSource` variant for a memory tick.
///
/// Contract: any tick that reacted to third-party sync changes (added/modified/
/// removed items, all originating from external sources like Gmail / Slack /
/// Notion / synced folders) must run with `SubconsciousTainted` so the approval
/// gate refuses external_effect tools. A tick with no external changes keeps the
/// legacy `Subconscious` origin.
pub(crate) fn tick_origin_source(has_external_content: bool) -> TrustedAutomationSource {
if has_external_content {
TrustedAutomationSource::SubconsciousTainted
} else {
TrustedAutomationSource::Subconscious
}
}
/// Total added + modified + removed across all sources in a cross-source diff.
pub(crate) fn world_diff_change_count(diff: &CrossSourceDiff) -> u32 {
diff.summary.added + diff.summary.modified + diff.summary.removed
}
/// Render a [`CrossSourceDiff`] into a compact markdown summary for the decision
/// agent's prompt. Per-source change lists are capped at [`MAX_ITEMS_PER_SOURCE`]
/// so a churny source can't blow out the context window.
pub(crate) fn render_world_diff(diff: &CrossSourceDiff) -> String {
let s = &diff.summary;
let total = s.added + s.modified + s.removed;
if total == 0 {
return "Nothing changed across your connected sources since the last check.".to_string();
}
let mut out = format!(
"{total} item(s) changed across your sources since the last check \
({} added, {} modified, {} removed).\n",
s.added, s.modified, s.removed
);
for source in &diff.per_source {
let ss = &source.summary;
if ss.added + ss.modified + ss.removed == 0 {
continue;
}
out.push_str(&format!(
"\n### {} ({})\n- {} added, {} modified, {} removed\n",
source.source_label, source.source_kind, ss.added, ss.modified, ss.removed
));
for change in source.changes.iter().take(MAX_ITEMS_PER_SOURCE) {
let verb = match change.kind {
crate::openhuman::memory_diff::types::ChangeKind::Added => "added",
crate::openhuman::memory_diff::types::ChangeKind::Removed => "removed",
crate::openhuman::memory_diff::types::ChangeKind::Modified => "modified",
};
let label = if change.title.trim().is_empty() {
change.item_id.as_str()
} else {
change.title.as_str()
};
out.push_str(&format!(" - [{verb}] {label}\n"));
}
if source.changes.len() > MAX_ITEMS_PER_SOURCE {
out.push_str(&format!(
" - …and {} more\n",
source.changes.len() - MAX_ITEMS_PER_SOURCE
));
}
}
out
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
#[cfg(test)]
#[path = "memory_tests.rs"]
mod tests;
@@ -0,0 +1,124 @@
use super::*;
// ── Tick origin upgrade (#approval-origin) ──────────────────────────────
#[test]
fn tick_origin_untainted_keeps_subconscious_source() {
use crate::openhuman::agent::turn_origin::TrustedAutomationSource;
let source = tick_origin_source(false);
assert!(matches!(source, TrustedAutomationSource::Subconscious));
}
#[test]
fn tick_origin_with_external_sync_chunk_uses_tainted_source() {
use crate::openhuman::agent::turn_origin::TrustedAutomationSource;
let source = tick_origin_source(true);
assert!(matches!(
source,
TrustedAutomationSource::SubconsciousTainted
));
}
// ── World-diff rendering (Stage 1) ──────────────────────────────────────
use crate::openhuman::memory_diff::types::{
ChangeKind, CrossSourceDiff, DiffResult, DiffSummary, ItemChange,
};
fn change(item_id: &str, title: &str, kind: ChangeKind) -> ItemChange {
ItemChange {
item_id: item_id.to_string(),
title: title.to_string(),
kind,
old_content_hash: None,
new_content_hash: None,
text_diff: None,
}
}
#[test]
fn empty_cross_source_diff_has_zero_change_count() {
let diff = CrossSourceDiff {
checkpoint_id: Some("ckpt_1".into()),
computed_at_ms: 0,
summary: DiffSummary::default(),
per_source: Vec::new(),
};
assert_eq!(world_diff_change_count(&diff), 0);
// The "no changes" render is the quiet-tick sentinel; the tick short-circuits
// before it ever reaches the agent, but the renderer stays well-defined.
assert!(render_world_diff(&diff).contains("Nothing changed"));
}
#[test]
fn render_world_diff_summarises_changes_per_source() {
let diff = CrossSourceDiff {
checkpoint_id: Some("ckpt_1".into()),
computed_at_ms: 0,
summary: DiffSummary {
added: 2,
modified: 1,
removed: 0,
unchanged: 5,
},
per_source: vec![DiffResult {
source_id: "src_gmail".into(),
source_kind: "composio".into(),
source_label: "Gmail".into(),
from_snapshot_id: Some("snap_a".into()),
to_snapshot_id: "snap_b".into(),
summary: DiffSummary {
added: 2,
modified: 1,
removed: 0,
unchanged: 5,
},
changes: vec![
change("m1", "Invoice from Acme", ChangeKind::Added),
change("m2", "Re: launch plan", ChangeKind::Added),
change("m3", "Standup notes", ChangeKind::Modified),
],
}],
};
assert_eq!(world_diff_change_count(&diff), 3);
let rendered = render_world_diff(&diff);
assert!(rendered.contains("3 item(s) changed"));
assert!(rendered.contains("Gmail (composio)"));
assert!(rendered.contains("[added] Invoice from Acme"));
assert!(rendered.contains("[modified] Standup notes"));
}
#[test]
fn render_world_diff_caps_items_and_falls_back_to_item_id() {
let mut changes = Vec::new();
for i in 0..(MAX_ITEMS_PER_SOURCE + 3) {
// Empty title forces the item_id fallback.
changes.push(change(&format!("item_{i}"), "", ChangeKind::Added));
}
let n = changes.len() as u32;
let diff = CrossSourceDiff {
checkpoint_id: None,
computed_at_ms: 0,
summary: DiffSummary {
added: n,
..DiffSummary::default()
},
per_source: vec![DiffResult {
source_id: "src_folder".into(),
source_kind: "folder".into(),
source_label: "Notes".into(),
from_snapshot_id: None,
to_snapshot_id: "snap_x".into(),
summary: DiffSummary {
added: n,
..DiffSummary::default()
},
changes,
}],
};
let rendered = render_world_diff(&diff);
assert!(rendered.contains("[added] item_0"), "uses item_id fallback");
assert!(rendered.contains("…and 3 more"), "caps the per-source list");
}
@@ -0,0 +1,7 @@
//! Concrete subconscious worlds. Each profile file implements
//! [`SubconsciousProfile`](super::profile::SubconsciousProfile) for one world;
//! the generic [`SubconsciousInstance`](super::instance::SubconsciousInstance)
//! runner ticks it. Adding a world is a new file here, not a new engine.
pub mod memory;
pub mod tinyplace;
@@ -0,0 +1,140 @@
//! The `tinyplace` subconscious world — tiny.place orchestration steering.
//!
//! Reflects offline over the orchestration layer's 20:1-compressed execution
//! history + cumulative world-state diff and, when a macro-trend warrants it,
//! emits **one** `STEERING_DIRECTIVE` that later reasoning cycles inject into
//! their prompt. The reflect turn is a **tool-free provider chat** — it
//! constructs no Agent and no toolset, so it can never contact anyone (the
//! isolation invariant, enforced by a source scan below).
//!
//! This profile is pure scheduler + policy: the orchestration domain still owns
//! its store shapes and the steering contract via
//! [`orchestration::ops::load_review_window`] / [`synthesize_and_persist`].
use std::sync::Arc;
use async_trait::async_trait;
use tracing::warn;
use super::super::instance::SubconsciousInstance;
use super::super::profile::{Observation, Reflection, SubconsciousProfile};
use crate::openhuman::agent::turn_origin::TrustedAutomationSource;
use crate::openhuman::config::Config;
use crate::openhuman::orchestration::ops::{load_review_window, synthesize_and_persist};
use crate::openhuman::orchestration::store as orch_store;
/// Construct the live `tinyplace` instance from config (used by the registry /
/// bootstrap). The only place `TinyPlaceProfile` is wired into a runner.
pub fn tinyplace_instance(config: &Config) -> SubconsciousInstance {
let interval = config
.orchestration
.effective_review_interval_minutes(config.heartbeat.interval_minutes);
SubconsciousInstance::new(
Arc::new(TinyPlaceProfile),
config.workspace_dir.clone(),
config.orchestration.enabled,
interval,
// Steering has no mode concept; a fixed label keeps the status shape.
"steering",
)
}
/// The `tinyplace` world profile. Stateless — every tick reads its window live
/// from the orchestration store through the shared ops seam.
pub struct TinyPlaceProfile;
#[async_trait]
impl SubconsciousProfile for TinyPlaceProfile {
fn id(&self) -> &'static str {
"tinyplace"
}
fn cadence(&self, config: &Config) -> std::time::Duration {
let mins = config
.orchestration
.effective_review_interval_minutes(config.heartbeat.interval_minutes);
std::time::Duration::from_secs(u64::from(mins) * 60)
}
async fn observe(&self, config: &Config) -> Observation {
// Self-gating: `None` when orchestration is disabled or nothing new to
// review (same idle gate as the pre-factory `Ok(false)` path).
match load_review_window(config).await {
Ok(Some(window)) => {
let commit_token = window.newest_reviewed.clone();
// Serialize the whole window through the tick graph's state so
// reflect sees exactly the rows observe pinned (the checkpoint
// schema stays a single string, per the generic Observation).
let rendered = match serde_json::to_string(&window) {
Ok(json) => json,
Err(e) => {
warn!("[subconscious:tinyplace] review window encode failed: {e}");
return Observation::default();
}
};
Observation {
rendered,
has_changes: true,
// Harness DMs are third-party content — always tainted.
has_external_content: true,
commit_token,
}
}
Ok(None) => Observation::default(),
Err(e) => {
warn!("[subconscious:tinyplace] review load failed: {e}");
Observation::default()
}
}
}
// prepare_context: default no-op — steering is deliberately tool-free.
async fn reflect(
&self,
config: &Config,
obs: &Observation,
_prepared_context: &str,
) -> Result<Reflection, String> {
let window = serde_json::from_str(&obs.rendered)
.map_err(|e| format!("review window decode: {e}"))?;
let tick_id = format!("subconscious:tinyplace:{}", now_secs() as u64);
match synthesize_and_persist(config, &window, &tick_id).await? {
Some(directive_id) => Ok(Reflection::Steered { directive_id }),
// A clean NONE or twice-failed synthesis is an idle result, not an
// error — the cursor still advances (via commit) so we don't reflect
// the same rows forever.
None => Ok(Reflection::Idle),
}
}
async fn commit(&self, config: &Config, obs: &Observation) {
// Advance the review cursor to exactly the window observed. Only the
// runner's non-superseded, non-failed path reaches here, so this is the
// uniform "advance only when the tick stuck" point. A quiet tick carries
// no token → no-op.
if let Some(token) = &obs.commit_token {
if let Err(e) = orch_store::with_connection(&config.workspace_dir, |conn| {
orch_store::set_review_cursor(conn, token)
}) {
warn!("[subconscious:tinyplace] review cursor advance failed: {e}");
}
}
}
fn origin(&self, _obs: &Observation) -> TrustedAutomationSource {
// Steering always reacts to third-party harness content.
TrustedAutomationSource::SubconsciousTainted
}
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
#[cfg(test)]
#[path = "tinyplace_tests.rs"]
mod tests;
@@ -0,0 +1,202 @@
use super::*;
use crate::openhuman::orchestration::store;
use std::sync::Arc;
/// The factory test override is process-global, so tests that install a scripted
/// provider must not run concurrently. Poison-tolerant serialization lock.
static PROVIDER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// A scripted provider so `create_chat_provider` returns a canned steering
/// synthesis without any network (the factory test override).
struct ScriptedProvider {
reply: String,
}
#[async_trait]
impl crate::openhuman::inference::provider::Provider for ScriptedProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok(self.reply.clone())
}
}
fn test_config(dir: &std::path::Path) -> Config {
let mut cfg = Config::default();
cfg.workspace_dir = dir.to_path_buf();
cfg.orchestration.enabled = true;
// A BYO provider signature so any downstream provider gate is deterministic.
cfg.subconscious_provider = Some("groq".to_string());
cfg
}
/// Seed one compressed-history row + one world-diff entry so a review has data.
fn seed_activity(config: &Config, tag: &str) {
store::with_connection(&config.workspace_dir, |conn| {
store::insert_compressed(
conn,
&format!("h1#{tag}"),
"h1",
"@me",
400,
20,
&format!("did work {tag}"),
&format!("2026-07-02T00:0{tag}:00Z"),
)?;
store::append_world_diff(
conn,
&format!("h1#{tag}"),
"h1",
"@me",
"sig",
&format!("world moved {tag}"),
"delta",
&format!("2026-07-02T00:0{tag}:00Z"),
)?;
Ok(())
})
.unwrap();
}
#[test]
fn tinyplace_profile_metadata() {
let profile = TinyPlaceProfile;
assert_eq!(profile.id(), "tinyplace");
// Always tainted — steering reacts to third-party harness content.
let tainted = profile.origin(&Observation::default());
assert!(matches!(
tainted,
TrustedAutomationSource::SubconsciousTainted
));
}
#[test]
fn cadence_defaults_to_heartbeat_interval_then_honours_override() {
let mut cfg = Config::default();
cfg.heartbeat.interval_minutes = 7;
// No explicit review interval → follows the heartbeat.
assert_eq!(
TinyPlaceProfile.cadence(&cfg),
std::time::Duration::from_secs(7 * 60)
);
// Explicit override wins.
cfg.orchestration.review_interval_minutes = Some(3);
assert_eq!(
TinyPlaceProfile.cadence(&cfg),
std::time::Duration::from_secs(3 * 60)
);
}
#[tokio::test]
async fn observe_is_quiet_when_orchestration_disabled_or_empty() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_config(dir.path());
// Disabled → quiet regardless of data.
cfg.orchestration.enabled = false;
let obs = TinyPlaceProfile.observe(&cfg).await;
assert!(!obs.has_changes);
// Enabled but empty store → quiet.
cfg.orchestration.enabled = true;
let obs = TinyPlaceProfile.observe(&cfg).await;
assert!(!obs.has_changes, "no unreviewed history → quiet");
}
#[tokio::test]
async fn observe_reflect_commit_emits_directive_then_advances_cursor() {
let _serial = PROVIDER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
seed_activity(&config, "1");
let _guard = crate::openhuman::inference::provider::factory::test_provider_override::install(
Arc::new(ScriptedProvider {
reply: "STEERING_DIRECTIVE: prioritize the billing migration\n\
expires_after_cycles: 12"
.to_string(),
}),
);
let profile = TinyPlaceProfile;
// observe → changed window, tainted, with a commit cursor token.
let obs = profile.observe(&config).await;
assert!(obs.has_changes);
assert!(obs.has_external_content);
assert!(
obs.commit_token.is_some(),
"carries the review cursor token"
);
// reflect → a directive is synthesized + persisted.
let reflection = profile.reflect(&config, &obs, "").await.unwrap();
let directive_id = match reflection {
Reflection::Steered { directive_id } => directive_id,
other => panic!("expected Steered, got {other:?}"),
};
assert!(directive_id > 0);
store::with_connection(&config.workspace_dir, |conn| {
let cur = store::current_steering_directive(conn, 0)?.expect("current directive");
assert_eq!(cur.text, "prioritize the billing migration");
Ok(())
})
.unwrap();
// Before commit, the cursor is still empty (the split moved advance here).
let before = store::with_connection(&config.workspace_dir, store::review_cursor).unwrap();
assert!(before.is_empty(), "cursor not advanced until commit");
// commit → cursor advances to the observed window.
profile.commit(&config, &obs).await;
let after = store::with_connection(&config.workspace_dir, store::review_cursor).unwrap();
assert!(!after.is_empty(), "commit advanced the review cursor");
// A re-observe over the same (now-reviewed) data is quiet — idempotent.
let requiet = profile.observe(&config).await;
assert!(!requiet.has_changes, "re-tick with no new data is quiet");
}
#[tokio::test]
async fn clean_none_is_idle_not_error() {
let _serial = PROVIDER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
seed_activity(&config, "1");
let _guard = crate::openhuman::inference::provider::factory::test_provider_override::install(
Arc::new(ScriptedProvider {
reply: "NONE".to_string(),
}),
);
let profile = TinyPlaceProfile;
let obs = profile.observe(&config).await;
assert!(obs.has_changes);
// A clean NONE is an idle result — not an Err (so the cursor still advances).
let reflection = profile.reflect(&config, &obs, "").await.unwrap();
assert!(matches!(reflection, Reflection::Idle));
}
#[test]
fn steering_reflect_path_imports_no_agent_or_channel_symbols() {
// Isolation invariant (stage 6): the tinyplace reflect path is a tool-free
// provider chat — it must construct no Agent and reference no outbound
// channel/send-message symbols. Source-scan the profile module itself.
const SRC: &str = include_str!("tinyplace.rs");
for forbidden in [
"Agent::from_config",
"agent_tools",
"send_message",
"run_single",
"spawn_async_subagent",
] {
assert!(
!SRC.contains(forbidden),
"tinyplace profile must not reference `{forbidden}` (isolation invariant)"
);
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Shared Subconscious provider routing + failure classification.
//!
//! Both subconscious worlds (the `memory` decision agent and the `tinyplace`
//! steering synthesis) run on the same **`subconscious`** provider route —
//! Settings → AI → Advanced "Subconscious" governs the cloud/local tick model.
//! The route resolution, the rate-cap circuit-breaker signature, and the two
//! permanent-error classifiers (tool-capability, per-minute token cap) are
//! therefore world-agnostic and live here, shared by the generic
//! [`super::instance`] runner and every [`super::profiles`] world.
use crate::openhuman::config::Config;
use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER};
/// Actionable reason surfaced (via `SubconsciousStatus.provider_unavailable_reason`)
/// when a subconscious tick fails because the configured chat model has no
/// tool-use endpoint. The memory decision turn is inherently tool-bearing (it
/// acts through tools), so a tool-incapable model can never satisfy such a tick
/// — this tells the user how to recover. See TAURI-RUST-ADC.
pub(crate) const TOOL_UNSUPPORTED_REASON: &str = "The selected chat model has no tool-use endpoint, so Subconscious can't run. Pick a tool-capable model in Settings > AI.";
/// Surfaced in `SubconsciousStatus` when the circuit breaker has halted ticks
/// because the configured Subconscious model keeps rejecting requests with a
/// permanent per-minute token cap (413/TPM). Actionable: the fix is the user's
/// to make (a bigger model/tier), so the message points there.
pub(crate) const RATE_CAP_HALT_REASON: &str = "Subconscious is paused: the selected model rejected the request because it exceeds your provider's per-minute token limit. Pick a higher-tier model or provider for Subconscious in Settings > AI > Advanced.";
#[derive(Clone, Debug, Eq, PartialEq)]
enum SubconsciousProviderRoute {
LocalOllama { model: String },
OpenHumanCloud,
Other(String),
}
/// Actionable reason the configured Subconscious provider can't run right now
/// (e.g. signed out of the OpenHuman cloud), or `None` when it is available.
/// Route resolution is shared by both worlds.
pub(crate) fn subconscious_provider_unavailable_reason(config: &Config) -> Option<String> {
match resolve_subconscious_route(config) {
SubconsciousProviderRoute::LocalOllama { .. } => None,
SubconsciousProviderRoute::OpenHumanCloud => {
if crate::openhuman::scheduler_gate::is_signed_out() {
return Some(
"Sign in to use the OpenHuman cloud Subconscious provider.".to_string(),
);
}
let state_dir = config
.config_path
.parent()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| config.workspace_dir.clone());
let auth = AuthService::new(&state_dir, config.secrets.encrypt);
match auth.get_provider_bearer_token(APP_SESSION_PROVIDER, None) {
Ok(Some(token)) if !token.trim().is_empty() => None,
Ok(_) => Some(
"Sign in or configure a local Subconscious provider in Settings > AI."
.to_string(),
),
Err(e) => Some(format!("Unable to read the OpenHuman session: {e}")),
}
}
SubconsciousProviderRoute::Other(_) => None,
}
}
fn resolve_subconscious_route(config: &Config) -> SubconsciousProviderRoute {
if let Some(model) = config.workload_local_model("subconscious") {
return SubconsciousProviderRoute::LocalOllama { model };
}
let raw = config
.subconscious_provider
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("cloud");
let is_openhuman_cloud = raw.eq_ignore_ascii_case("cloud")
|| raw.eq_ignore_ascii_case("openhuman")
|| raw.to_ascii_lowercase().starts_with("openhuman:");
if is_openhuman_cloud {
SubconsciousProviderRoute::OpenHumanCloud
} else {
SubconsciousProviderRoute::Other(raw.to_string())
}
}
/// Stable identity of the Subconscious provider routing — the exact knobs a
/// user changes in Settings > AI > Advanced to switch the tick model/provider.
/// The rate-cap circuit breaker keys its halt on this so a permanent per-minute
/// token-cap rejection stops re-firing while the SAME config is set, and
/// auto-clears the moment the user picks a different model/provider/tier.
///
/// The generic runner prefixes this with the instance id (`"memory|cloud"`) so
/// one world's halt never silences another.
pub(crate) fn subconscious_provider_signature(config: &Config) -> String {
match resolve_subconscious_route(config) {
SubconsciousProviderRoute::LocalOllama { model } => format!("local:{model}"),
SubconsciousProviderRoute::OpenHumanCloud => "cloud".to_string(),
SubconsciousProviderRoute::Other(raw) => format!("other:{raw}"),
}
}
/// Outcome of comparing an active rate-cap halt against the live provider
/// signature at the start of a tick. Pure so it is unit-testable without
/// spinning an engine/agent.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RateCapHaltDecision {
/// A halt is set for the same signature still in config — skip the run.
Skip,
/// A halt is set but the signature changed — clear it and resume ticking.
Resume,
/// No halt in effect — run the tick normally.
Proceed,
}
/// Decide whether a tick should skip, resume, or proceed given the stored
/// rate-cap halt signature (if any) and the live provider signature.
pub(crate) fn evaluate_rate_cap_halt(
halt_signature: Option<&str>,
current: &str,
) -> RateCapHaltDecision {
match halt_signature {
Some(sig) if sig == current => RateCapHaltDecision::Skip,
Some(_) => RateCapHaltDecision::Resume,
None => RateCapHaltDecision::Proceed,
}
}
/// True when an agent-run error is a permanent per-minute token-cap rejection
/// (413/TPM) — the request is larger than the provider account's per-minute
/// budget, so retrying the same tick can never succeed. Delegates to the shared
/// provider matcher (single source of truth with the Sentry classifier in
/// `core::observability`) so the wording can't drift. TAURI-RUST-HXF.
pub(crate) fn is_permanent_rate_cap_error(msg: &str) -> bool {
crate::openhuman::inference::provider::is_provider_rate_cap_exceeded_message(msg)
}
/// True when an agent-run error means the configured chat model can't do tool
/// calls at all — a permanent, user-actionable condition (pick a tool-capable
/// model). Matches both the direct-provider body (`<model> does not support
/// tools`) and OpenRouter's router-level phrasing (`No endpoints found that
/// support tool use`, TAURI-RUST-ADC). Kept narrow to tool capability so an
/// unrelated provider error (auth, billing, rate-limit) is not misread as one.
pub(crate) fn is_tool_capability_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("no endpoints found that support tool use")
|| lower.contains("does not support tools")
}
#[cfg(test)]
#[path = "provider_tests.rs"]
mod tests;
@@ -0,0 +1,89 @@
use super::*;
use crate::openhuman::config::Config;
// ── Tool-capability error detection (TAURI-RUST-ADC) ────────────────────
#[test]
fn tool_capability_error_matches_openrouter_and_direct_bodies() {
// OpenRouter router-level 404 (the reported ADC body).
assert!(is_tool_capability_error(
r#"agent run: openrouter API error (404 Not Found): {"error":{"message":"No endpoints found that support tool use. Try disabling \"spawn_async_subagent\"."}}"#
));
// Direct-provider "does not support tools" phrasing (TAURI-RUST-35 family).
assert!(is_tool_capability_error(
r#"agent run: cloud API error: {"error":{"message":"qwen2:0.5b does not support tools"}}"#
));
// Case-insensitive.
assert!(is_tool_capability_error(
"NO ENDPOINTS FOUND THAT SUPPORT TOOL USE"
));
}
#[test]
fn tool_capability_error_ignores_unrelated_failures() {
// A different 404, an auth wall, and a generic timeout must NOT match.
assert!(!is_tool_capability_error(
r#"agent run: openrouter API error (404 Not Found): {"error":{"message":"model 'llama3.3' not found"}}"#
));
assert!(!is_tool_capability_error(
"agent run: Backend returned 401 Unauthorized: Invalid token"
));
assert!(!is_tool_capability_error("agent run: request timed out"));
}
// ── Rate-cap circuit breaker (TAURI-RUST-HXF) ───────────────────────────
#[test]
fn evaluate_rate_cap_halt_skip_resume_proceed() {
// No halt in effect → run normally.
assert_eq!(
evaluate_rate_cap_halt(None, "other:groq"),
RateCapHaltDecision::Proceed
);
// Halt set for the same signature still in config → skip the doomed run.
assert_eq!(
evaluate_rate_cap_halt(Some("other:groq"), "other:groq"),
RateCapHaltDecision::Skip
);
// Halt set but the user switched provider/model → clear it and resume.
assert_eq!(
evaluate_rate_cap_halt(Some("other:groq"), "cloud"),
RateCapHaltDecision::Resume
);
}
#[test]
fn permanent_rate_cap_error_matches_wrapped_groq_agent_error_only() {
// The verbatim wrapped agent-run error the tick surfaces (413/TPM) →
// permanent, so the breaker halts.
assert!(is_permanent_rate_cap_error(
r#"agent run: groq API error (413 Payload Too Large): {"error":{"message":"Request too large for model `openai/gpt-oss-120b` in organization `org_x` service tier `on_demand` on tokens per minute (TPM): Limit 8000, Requested 42084."}}"#
));
// A transient 429 burst ("try again in Ns") must NOT halt — it stays
// retryable, so the two permanent-error arms never overlap.
assert!(!is_permanent_rate_cap_error(
"agent run: groq API error (429 Too Many Requests): Rate limit reached. Please try again in 2.5s."
));
// A tool-capability error is a different permanent condition handled by its
// own arm, not the rate-cap breaker.
assert!(!is_permanent_rate_cap_error(
"agent run: No endpoints found that support tool use"
));
}
#[test]
fn subconscious_provider_signature_tracks_config_changes() {
// Default config routes to OpenHuman cloud.
let mut cfg = Config::default();
assert_eq!(subconscious_provider_signature(&cfg), "cloud");
// A BYO provider override yields a distinct, stable signature.
cfg.subconscious_provider = Some("groq".to_string());
let groq_sig = subconscious_provider_signature(&cfg);
assert_eq!(groq_sig, "other:groq");
// Switching the provider changes the signature — the breaker's cue to
// clear a halt and resume ticking.
cfg.subconscious_provider = Some("openai".to_string());
assert_ne!(subconscious_provider_signature(&cfg), groq_sig);
}
@@ -1,47 +1,61 @@
//! Global singleton for the SubconsciousEngine.
//! Registry of live subconscious instances — one per enabled [`SubconsciousKind`].
//!
//! Shared between the heartbeat background loop and RPC handlers
//! so both see the same state and counters.
//! Shared between the heartbeat fan-out and the RPC handlers so both see the
//! same instances and counters. Instances are held as plain `Arc` (not
//! `Mutex<Option<..>>`): each instance's own `tick_lock`/`state` mutexes
//! serialize what needs serializing, and the status path must stay lock-free
//! (it never takes `tick_lock`).
use super::engine::SubconsciousEngine;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
static ENGINE: OnceLock<Arc<Mutex<Option<SubconsciousEngine>>>> = OnceLock::new();
use super::factory::{make_subconscious, SubconsciousKind};
use super::instance::SubconsciousInstance;
static REGISTRY: OnceLock<Mutex<HashMap<SubconsciousKind, Arc<SubconsciousInstance>>>> =
OnceLock::new();
static BOOTSTRAPPED: AtomicBool = AtomicBool::new(false);
static HEARTBEAT_HANDLE: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
fn engine_lock() -> &'static Arc<Mutex<Option<SubconsciousEngine>>> {
ENGINE.get_or_init(|| Arc::new(Mutex::new(None)))
fn registry() -> &'static Mutex<HashMap<SubconsciousKind, Arc<SubconsciousInstance>>> {
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
fn heartbeat_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
HEARTBEAT_HANDLE.get_or_init(|| Mutex::new(None))
}
pub async fn get_or_init_engine() -> Result<Arc<Mutex<Option<SubconsciousEngine>>>, String> {
let lock = engine_lock();
/// Lazily construct (or fetch) the instance for `kind`, keyed in the registry.
pub async fn get_or_init_instance(
kind: SubconsciousKind,
) -> Result<Arc<SubconsciousInstance>, String> {
{
let guard = lock.lock().await;
if guard.is_some() {
return Ok(Arc::clone(lock));
let map = registry().lock().await;
if let Some(inst) = map.get(&kind) {
return Ok(Arc::clone(inst));
}
}
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| format!("load config: {e}"))?;
let inst = Arc::new(make_subconscious(kind, &config));
let engine = SubconsciousEngine::new(&config);
let mut map = registry().lock().await;
Ok(Arc::clone(map.entry(kind).or_insert(inst)))
}
let mut guard = lock.lock().await;
if guard.is_none() {
*guard = Some(engine);
}
Ok(Arc::clone(lock))
/// Every currently-registered instance (the bootstrap set), in a stable order
/// (memory first). Used by the heartbeat fan-out and `subconscious.status`.
pub async fn registered_instances() -> Vec<Arc<SubconsciousInstance>> {
let map = registry().lock().await;
SubconsciousKind::ALL
.iter()
.filter_map(|k| map.get(k).map(Arc::clone))
.collect()
}
pub async fn bootstrap_after_login() -> Result<(), String> {
@@ -57,18 +71,26 @@ pub async fn bootstrap_after_login() -> Result<(), String> {
format!("load config: {e}")
})?;
// The heartbeat loop is the clock that drives every instance's tick (plus
// the event-planner duties). If it is disabled, no world ticks — same gate
// as the pre-factory build.
if !config.heartbeat.enabled {
tracing::info!("[subconscious] heartbeat disabled in config — bootstrap skipped");
BOOTSTRAPPED.store(false, Ordering::SeqCst);
return Ok(());
}
get_or_init_engine().await.inspect_err(|_e| {
BOOTSTRAPPED.store(false, Ordering::SeqCst);
})?;
// Initialize every enabled world against the per-user workspace.
let kinds = SubconsciousKind::enabled_kinds(&config);
for kind in &kinds {
get_or_init_instance(*kind).await.inspect_err(|_e| {
BOOTSTRAPPED.store(false, Ordering::SeqCst);
})?;
}
tracing::info!(
workspace = %config.workspace_dir.display(),
"[subconscious] engine initialized against per-user workspace"
kinds = ?kinds.iter().map(|k| k.id()).collect::<Vec<_>>(),
"[subconscious] instances initialized against per-user workspace"
);
let heartbeat = crate::openhuman::heartbeat::engine::HeartbeatEngine::new(
@@ -156,12 +178,11 @@ pub async fn stop_heartbeat_loop() {
BOOTSTRAPPED.store(false, Ordering::SeqCst);
}
/// Reset for a user switch: stop the heartbeat + triggers, then clear the whole
/// registry so a subsequent bootstrap rebuilds every instance against the new
/// workspace.
pub async fn reset_engine_for_user_switch() {
stop_heartbeat_loop().await;
let lock = engine_lock();
let mut guard = lock.lock().await;
*guard = None;
tracing::info!("[subconscious] engine reset for user switch");
registry().lock().await.clear();
tracing::info!("[subconscious] registry reset for user switch");
}
+97 -36
View File
@@ -2,8 +2,10 @@
use serde_json::{Map, Value};
use super::global::get_or_init_engine;
use super::factory::SubconsciousKind;
use super::registry::{get_or_init_instance, registered_instances};
use super::store;
use super::types::SubconsciousStatus;
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::rpc::RpcOutcome;
@@ -30,15 +32,20 @@ pub fn schemas(function: &str) -> ControllerSchema {
"status" => ControllerSchema {
namespace: "subconscious",
function: "status",
description: "Get the current subconscious engine status.",
description: "Get subconscious status. Legacy top-level fields mirror the memory \
instance; `instances` lists every registered world.",
inputs: vec![],
outputs: vec![field("result", TypeSchema::Json, "Engine status.")],
},
"trigger" => ControllerSchema {
namespace: "subconscious",
function: "trigger",
description: "Manually trigger a subconscious tick.",
inputs: vec![],
description: "Manually trigger a subconscious tick for a world.",
inputs: vec![optional_field(
"kind",
TypeSchema::String,
"Which world to tick: \"memory\" (default), \"tinyplace\", or \"all\".",
)],
outputs: vec![field("result", TypeSchema::Json, "Tick result.")],
},
_other => ControllerSchema {
@@ -51,33 +58,56 @@ pub fn schemas(function: &str) -> ControllerSchema {
}
}
/// The `subconscious.status` response: today's flat status (mirroring the memory
/// instance for backward compatibility) plus one row per registered world.
#[derive(serde::Serialize)]
struct SubconsciousStatusResponse {
#[serde(flatten)]
legacy: SubconsciousStatus,
instances: Vec<SubconsciousStatus>,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let engine_arc = get_or_init_engine().await.ok();
if let Some(arc) = engine_arc {
let guard = arc.lock().await;
if let Some(engine) = guard.as_ref() {
let status = engine.status().await;
return to_json(RpcOutcome::single_log(status, "subconscious status"));
let instances = registered_instances().await;
if !instances.is_empty() {
let mut rows = Vec::with_capacity(instances.len());
for inst in &instances {
rows.push(inst.status().await);
}
// Legacy top-level = the memory row (or the first registered world).
let legacy = rows
.iter()
.find(|r| r.instance == "memory")
.or_else(|| rows.first())
.cloned()
.expect("non-empty instances");
let response = SubconsciousStatusResponse {
legacy,
instances: rows,
};
return to_json(RpcOutcome::single_log(response, "subconscious status"));
}
// Not bootstrapped yet — derive the memory row from config (as before).
let config = load_config().await?;
let hb = &config.heartbeat;
let last_tick_at =
store::with_connection(&config.workspace_dir, |conn| store::get_last_tick_at(conn))
.ok();
let last_tick_at = store::with_connection(&config.workspace_dir, |conn| {
store::get_last_tick_at(conn, "memory")
})
.ok();
let provider_unavailable_reason = if hb.enabled && hb.inference_enabled {
super::engine::subconscious_provider_unavailable_reason(&config)
super::provider::subconscious_provider_unavailable_reason(&config)
} else {
None
};
let mode = hb.effective_subconscious_mode();
let status = super::types::SubconsciousStatus {
let status = SubconsciousStatus {
instance: "memory".to_string(),
enabled: mode.is_enabled(),
mode: mode.as_str().to_string(),
provider_available: provider_unavailable_reason.is_none(),
@@ -87,36 +117,58 @@ fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
total_ticks: 0,
consecutive_failures: 0,
};
to_json(RpcOutcome::single_log(status, "subconscious status"))
let response = SubconsciousStatusResponse {
legacy: status.clone(),
instances: vec![status],
};
to_json(RpcOutcome::single_log(response, "subconscious status"))
})
}
fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
fn handle_trigger(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let lock = get_or_init_engine().await?;
// `kind`: "memory" (default), "tinyplace", or "all".
let raw = params
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("memory")
.to_string();
let lock_clone = std::sync::Arc::clone(&lock);
tokio::spawn(async move {
let guard = lock_clone.lock().await;
if let Some(engine) = guard.as_ref() {
match engine.tick().await {
Ok(result) => {
tracing::info!(
"[subconscious] manual tick: duration={}ms response_chars={}",
result.duration_ms,
result.response_chars,
);
}
Err(e) => {
tracing::warn!("[subconscious] manual tick error: {e}");
}
let kinds: Vec<SubconsciousKind> = if raw == "all" {
SubconsciousKind::ALL.to_vec()
} else {
match SubconsciousKind::parse(&raw) {
Some(k) => vec![k],
None => {
return Err(format!(
"unknown subconscious kind '{raw}' (expected memory|tinyplace|all)"
))
}
}
});
};
// Fire-and-forget: spawn each world's tick and return immediately.
for kind in kinds {
tokio::spawn(async move {
match get_or_init_instance(kind).await {
Ok(inst) => match inst.tick().await {
Ok(result) => tracing::info!(
"[subconscious] manual {} tick: duration={}ms response_chars={}",
kind.id(),
result.duration_ms,
result.response_chars,
),
Err(e) => {
tracing::warn!("[subconscious] manual {} tick error: {e}", kind.id())
}
},
Err(e) => tracing::warn!("[subconscious] manual {} init error: {e}", kind.id()),
}
});
}
to_json(RpcOutcome::single_log(
serde_json::json!({"triggered": true}),
serde_json::json!({"triggered": true, "kind": raw}),
"subconscious tick triggered",
))
})
@@ -137,6 +189,15 @@ fn field(name: &'static str, ty: TypeSchema, comment: &'static str) -> FieldSche
}
}
fn optional_field(name: &'static str, ty: TypeSchema, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty,
comment,
required: false,
}
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
+1 -1
View File
@@ -29,7 +29,7 @@ use crate::openhuman::config::Config;
use crate::openhuman::memory_conversations::ConversationMessage;
use crate::openhuman::security::AutonomyLevel;
use super::engine::tick_origin_source;
use super::profiles::memory::tick_origin_source;
/// Reserved conversation thread backing the background orchestrator's
/// internal reasoning. Distinct from the user-facing thread (slice 6).
+44 -9
View File
@@ -246,6 +246,27 @@ const SCHEMA_DDL: &str = "
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- One-time key namespacing migration (subconscious-factory phase 1): the
-- single-engine keys become `memory:`-namespaced so a second world
-- (tinyplace, ) can keep its own `last_tick_at`/baseline without colliding.
-- Idempotent and old-version tolerant: guarded by NOT EXISTS so running
-- oldnewold never loses the value, and a no-op on a fresh DB (no legacy
-- rows). The tinyplace review cursor lives in the orchestration store, not
-- here the profile owns it via `commit`.
UPDATE subconscious_state
SET key = 'memory:last_tick_at'
WHERE key = 'last_tick_at'
AND NOT EXISTS (
SELECT 1 FROM subconscious_state WHERE key = 'memory:last_tick_at'
);
UPDATE subconscious_state_text
SET key = 'memory:baseline_checkpoint_id'
WHERE key = 'baseline_checkpoint_id'
AND NOT EXISTS (
SELECT 1 FROM subconscious_state_text
WHERE key = 'memory:baseline_checkpoint_id'
);
";
#[cfg(test)]
@@ -256,43 +277,57 @@ pub(crate) const SCHEMA_DDL_FOR_TESTS: &str = SCHEMA_DDL;
const STATE_KEY_LAST_TICK_AT: &str = "last_tick_at";
const STATE_KEY_BASELINE_CHECKPOINT_ID: &str = "baseline_checkpoint_id";
pub fn get_last_tick_at(conn: &Connection) -> Result<f64> {
/// Instance-namespaced state key: `"<instance>:<key>"` (e.g. `memory:last_tick_at`).
/// One subconscious.db is shared across all worlds, so each instance prefixes
/// its keys with its stable id to keep independent `last_tick_at`/baseline rows.
fn namespaced(instance: &str, key: &str) -> String {
format!("{instance}:{key}")
}
pub fn get_last_tick_at(conn: &Connection, instance: &str) -> Result<f64> {
let value: Option<f64> = conn
.query_row(
"SELECT value FROM subconscious_state WHERE key = ?1",
[STATE_KEY_LAST_TICK_AT],
[namespaced(instance, STATE_KEY_LAST_TICK_AT)],
|row| row.get(0),
)
.optional()?;
Ok(value.unwrap_or(0.0))
}
pub fn set_last_tick_at(conn: &Connection, value: f64) -> Result<()> {
pub fn set_last_tick_at(conn: &Connection, instance: &str, value: f64) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO subconscious_state (key, value) VALUES (?1, ?2)",
rusqlite::params![STATE_KEY_LAST_TICK_AT, value],
rusqlite::params![namespaced(instance, STATE_KEY_LAST_TICK_AT), value],
)?;
Ok(())
}
/// The memory_diff checkpoint the structured tick diffs against — i.e. the
/// snapshot of "the agent's world" captured at the end of the previous tick.
/// `None` until the first tick establishes a baseline.
pub fn get_baseline_checkpoint_id(conn: &Connection) -> Result<Option<String>> {
/// `None` until the first tick establishes a baseline. Namespaced per instance.
pub fn get_baseline_checkpoint_id(conn: &Connection, instance: &str) -> Result<Option<String>> {
let value: Option<String> = conn
.query_row(
"SELECT value FROM subconscious_state_text WHERE key = ?1",
[STATE_KEY_BASELINE_CHECKPOINT_ID],
[namespaced(instance, STATE_KEY_BASELINE_CHECKPOINT_ID)],
|row| row.get(0),
)
.optional()?;
Ok(value)
}
pub fn set_baseline_checkpoint_id(conn: &Connection, checkpoint_id: &str) -> Result<()> {
pub fn set_baseline_checkpoint_id(
conn: &Connection,
instance: &str,
checkpoint_id: &str,
) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO subconscious_state_text (key, value) VALUES (?1, ?2)",
rusqlite::params![STATE_KEY_BASELINE_CHECKPOINT_ID, checkpoint_id],
rusqlite::params![
namespaced(instance, STATE_KEY_BASELINE_CHECKPOINT_ID),
checkpoint_id
],
)?;
Ok(())
}
+94 -16
View File
@@ -9,33 +9,111 @@ fn test_conn() -> Connection {
#[test]
fn last_tick_at_round_trip() {
let conn = test_conn();
assert_eq!(get_last_tick_at(&conn).unwrap(), 0.0);
set_last_tick_at(&conn, 12345.678).unwrap();
assert_eq!(get_last_tick_at(&conn).unwrap(), 12345.678);
assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 0.0);
set_last_tick_at(&conn, "memory", 12345.678).unwrap();
assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 12345.678);
}
#[test]
fn last_tick_at_upsert() {
let conn = test_conn();
set_last_tick_at(&conn, 1.0).unwrap();
set_last_tick_at(&conn, 2.0).unwrap();
assert_eq!(get_last_tick_at(&conn).unwrap(), 2.0);
set_last_tick_at(&conn, "memory", 1.0).unwrap();
set_last_tick_at(&conn, "memory", 2.0).unwrap();
assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 2.0);
}
#[test]
fn state_keys_are_namespaced_per_instance() {
// Two worlds share one subconscious.db; their `last_tick_at`/baseline rows
// must not collide (subconscious-factory invariant: independent state keys).
let conn = test_conn();
set_last_tick_at(&conn, "memory", 10.0).unwrap();
set_last_tick_at(&conn, "tinyplace", 20.0).unwrap();
assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 10.0);
assert_eq!(get_last_tick_at(&conn, "tinyplace").unwrap(), 20.0);
set_baseline_checkpoint_id(&conn, "memory", "ckpt_mem").unwrap();
set_baseline_checkpoint_id(&conn, "tinyplace", "ckpt_tp").unwrap();
assert_eq!(
get_baseline_checkpoint_id(&conn, "memory").unwrap(),
Some("ckpt_mem".to_string())
);
assert_eq!(
get_baseline_checkpoint_id(&conn, "tinyplace").unwrap(),
Some("ckpt_tp".to_string())
);
}
#[test]
fn legacy_keys_migrate_to_memory_namespace() {
// Seed the pre-factory single-engine keys, then re-run the DDL (as every
// `open_and_initialize` does) and assert the values now live under the
// `memory:`-namespaced keys the memory instance reads.
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(SCHEMA_DDL).unwrap();
conn.execute(
"INSERT INTO subconscious_state (key, value) VALUES ('last_tick_at', 555.5)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO subconscious_state_text (key, value) VALUES ('baseline_checkpoint_id', 'ckpt_legacy')",
[],
)
.unwrap();
// The migration is part of the idempotent DDL batch — re-running it renames.
conn.execute_batch(SCHEMA_DDL).unwrap();
assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 555.5);
assert_eq!(
get_baseline_checkpoint_id(&conn, "memory").unwrap(),
Some("ckpt_legacy".to_string())
);
// The bare legacy keys are gone (renamed, not duplicated).
let legacy_count: i32 = conn
.query_row(
"SELECT COUNT(*) FROM subconscious_state WHERE key = 'last_tick_at'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(legacy_count, 0);
}
#[test]
fn migration_never_clobbers_existing_namespaced_value() {
// Old→new→old tolerance: if a `memory:`-namespaced value already exists, a
// stale bare legacy key must NOT overwrite it (NOT EXISTS guard).
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(SCHEMA_DDL).unwrap();
set_last_tick_at(&conn, "memory", 999.0).unwrap();
conn.execute(
"INSERT INTO subconscious_state (key, value) VALUES ('last_tick_at', 1.0)",
[],
)
.unwrap();
conn.execute_batch(SCHEMA_DDL).unwrap();
// The namespaced value wins; the legacy row is left in place, not merged.
assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 999.0);
}
#[test]
fn baseline_checkpoint_id_round_trip() {
let conn = test_conn();
// Unset until the first tick establishes a baseline.
assert_eq!(get_baseline_checkpoint_id(&conn).unwrap(), None);
set_baseline_checkpoint_id(&conn, "ckpt_abc").unwrap();
assert_eq!(get_baseline_checkpoint_id(&conn, "memory").unwrap(), None);
set_baseline_checkpoint_id(&conn, "memory", "ckpt_abc").unwrap();
assert_eq!(
get_baseline_checkpoint_id(&conn).unwrap(),
get_baseline_checkpoint_id(&conn, "memory").unwrap(),
Some("ckpt_abc".to_string())
);
// Advancing the baseline replaces the previous id.
set_baseline_checkpoint_id(&conn, "ckpt_def").unwrap();
set_baseline_checkpoint_id(&conn, "memory", "ckpt_def").unwrap();
assert_eq!(
get_baseline_checkpoint_id(&conn).unwrap(),
get_baseline_checkpoint_id(&conn, "memory").unwrap(),
Some("ckpt_def".to_string())
);
}
@@ -72,8 +150,8 @@ fn open_and_initialize_creates_usable_db_on_real_fs() {
let db_path = dir.path().join("subconscious.db");
let conn = open_and_initialize(&db_path).unwrap();
set_last_tick_at(&conn, 99.5).unwrap();
assert_eq!(get_last_tick_at(&conn).unwrap(), 99.5);
set_last_tick_at(&conn, "memory", 99.5).unwrap();
assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 99.5);
// On a normal local filesystem WAL succeeds; assert we landed on a valid
// persistent journal mode (wal when supported, otherwise the truncate
@@ -93,8 +171,8 @@ fn with_connection_creates_parent_dir_and_db() {
// workspace and initialize a working DB end-to-end.
let workspace = tempfile::tempdir().unwrap();
let tick = with_connection(workspace.path(), |conn| {
set_last_tick_at(conn, 7.0)?;
get_last_tick_at(conn)
set_last_tick_at(conn, "memory", 7.0)?;
get_last_tick_at(conn, "memory")
})
.unwrap();
assert_eq!(tick, 7.0);
@@ -113,5 +191,5 @@ fn apply_journal_mode_falls_back_without_panicking() {
let conn = Connection::open_in_memory().unwrap();
apply_journal_mode(&conn);
conn.execute_batch(SCHEMA_DDL).unwrap();
assert_eq!(get_last_tick_at(&conn).unwrap(), 0.0);
assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 0.0);
}
+9 -1
View File
@@ -2,9 +2,13 @@
use serde::{Deserialize, Serialize};
/// Summary of the subconscious engine status.
/// Summary of a subconscious instance's status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubconsciousStatus {
/// Which world this row describes (`"memory"` | `"tinyplace"`). Defaulted to
/// `"memory"` when absent so older callers/serialized rows keep parsing.
#[serde(default = "default_instance")]
pub instance: String,
pub enabled: bool,
pub mode: String,
pub provider_available: bool,
@@ -15,6 +19,10 @@ pub struct SubconsciousStatus {
pub consecutive_failures: u64,
}
fn default_instance() -> String {
"memory".to_string()
}
/// Result of a single subconscious tick.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TickResult {
+97
View File
@@ -5555,6 +5555,103 @@ async fn json_rpc_screen_intelligence_capture_test_returns_stable_shape() {
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_subconscious_status_exposes_instances_and_trigger_takes_kind() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// ── subconscious.status: legacy top-level fields + instances[] ──────────
let status = post_json_rpc(&rpc_base, 1101, "openhuman.subconscious_status", json!({})).await;
let result = assert_no_jsonrpc_error(&status, "subconscious_status");
let status_result = result.get("result").unwrap_or(result);
// Legacy flattened fields (backward compat) mirror the memory instance.
assert_eq!(
status_result.get("instance").and_then(Value::as_str),
Some("memory"),
"legacy top-level mirrors the memory instance: {status_result}"
);
assert!(
status_result.get("mode").and_then(Value::as_str).is_some(),
"expected top-level mode string: {status_result}"
);
assert!(
status_result
.get("enabled")
.and_then(Value::as_bool)
.is_some(),
"expected top-level enabled bool: {status_result}"
);
// Additive: instances[] with at least the memory row, each tagged.
let instances = status_result
.get("instances")
.and_then(Value::as_array)
.expect("expected instances array");
assert!(
!instances.is_empty(),
"instances not empty: {status_result}"
);
assert!(
instances
.iter()
.any(|row| row.get("instance").and_then(Value::as_str) == Some("memory")),
"instances includes the memory row: {status_result}"
);
// ── subconscious.trigger: optional kind echoes back ─────────────────────
let trig = post_json_rpc(
&rpc_base,
1102,
"openhuman.subconscious_trigger",
json!({ "kind": "tinyplace" }),
)
.await;
let trig_result = assert_no_jsonrpc_error(&trig, "subconscious_trigger");
let trig_body = trig_result.get("result").unwrap_or(trig_result);
assert_eq!(
trig_body.get("triggered").and_then(Value::as_bool),
Some(true),
"trigger fire-and-forget acks: {trig_body}"
);
assert_eq!(
trig_body.get("kind").and_then(Value::as_str),
Some("tinyplace"),
"trigger echoes the requested kind: {trig_body}"
);
// An unknown kind is a clean JSON-RPC error, not a panic.
let bad = post_json_rpc(
&rpc_base,
1103,
"openhuman.subconscious_trigger",
json!({ "kind": "nope" }),
)
.await;
assert!(
bad.get("error").is_some(),
"unknown kind returns a JSON-RPC error: {bad}"
);
rpc_join.abort();
mock_join.abort();
}
#[tokio::test]
async fn json_rpc_screen_intelligence_status_returns_stable_shape() {
let _env_lock = json_rpc_e2e_env_lock();