mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(subconscious): factory — one reflection engine, many worlds (memory + tinyplace) (#4550)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 l’orchestration',
|
||||
'subconscious.instance.tinyplace.subtitle': 'Revue des sessions tiny.place',
|
||||
'subconscious.instance.tinyplace.disabledHint':
|
||||
'Activez l’Orchestration 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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 dell’orchestrazione',
|
||||
'subconscious.instance.tinyplace.subtitle': 'Revisione delle sessioni tiny.place',
|
||||
'subconscious.instance.tinyplace.disabledHint':
|
||||
'Abilita l’Orchestrazione 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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (80–90%) | `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 1–3 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 1–3, 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 1–3 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 1–4)
|
||||
|
||||
| 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 1–3 are independent; none blocks phases 1–5. 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 1–5 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 5–6.
|
||||
+2
-1
@@ -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)"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 2–3):
|
||||
//! - `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 1–3):
|
||||
//! 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
-- old→new→old 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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user