feat(subconscious): add scratchpad deep research loop (#3439)

This commit is contained in:
Steven Enamakel
2026-06-06 14:29:44 -04:00
committed by GitHub
parent 638e5caad5
commit 0ac6e6a5bc
76 changed files with 1632 additions and 2441 deletions
@@ -1,12 +1,9 @@
import { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useT } from '../../lib/i18n/I18nContext';
import { setSelectedThread } from '../../store/threadSlice';
import type { SubconsciousMode } from '../../utils/tauriCommands/heartbeat';
import type { SubconsciousStatus } from '../../utils/tauriCommands/subconscious';
import SubconsciousReflectionCards from './SubconsciousReflectionCards';
interface ModeOption {
id: SubconsciousMode;
@@ -70,7 +67,6 @@ export default function IntelligenceSubconsciousTab({
}: IntelligenceSubconsciousTabProps) {
const { t } = useT();
const navigate = useNavigate();
const dispatch = useDispatch();
const providerUnavailable = status?.provider_available === false;
const providerUnavailableReason = providerUnavailable
? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle'))
@@ -96,11 +92,6 @@ export default function IntelligenceSubconsciousTab({
}
}, [localSlider, intervalMinutes, setIntervalMinutes]);
const handleNavigateToThread = (threadId: string) => {
dispatch(setSelectedThread(threadId));
navigate('/chat');
};
const handleRunTick = async () => {
try {
await triggerTick();
@@ -256,10 +247,11 @@ export default function IntelligenceSubconsciousTab({
)}
{isEnabled && (
<SubconsciousReflectionCards
onNavigateToThread={handleNavigateToThread}
pollIntervalMs={15_000}
/>
<div className="rounded-lg border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 p-4">
<p className="text-sm text-zinc-500 dark:text-zinc-400">
{t('subconscious.scratchpadInfo')}
</p>
</div>
)}
</div>
);
@@ -1,291 +0,0 @@
/**
* Reflection card list for the Intelligence tab (#623).
*
* Self-contained component that polls `subconscious_reflections_list`,
* renders a card per reflection with kind chip, action button (only when
* `proposed_action` is non-null), and dismiss button. Optimistic dismiss
* hides the card immediately on tap so the UI feels responsive.
*
* Acting on a reflection drives `actOnReflection`, which **spawns a fresh
* conversation thread** seeded with body + proposed_action and returns
* the new thread id. The component navigates the user (via the
* `onNavigateToThread` callback) into the new conversation. Reflections
* never write into existing threads — every act gets its own thread so
* the user's main chat surface stays uncluttered.
*/
import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import {
actOnReflection,
dismissReflection,
listReflections,
type Reflection,
type ReflectionKind,
} from '../../utils/tauriCommands/subconscious';
interface SubconsciousReflectionCardsProps {
/**
* Called after a successful "Act" with the freshly-spawned thread id.
* Caller is responsible for routing the user into the new conversation
* (e.g. setting active thread + navigating to the chat surface).
*/
onNavigateToThread?: (threadId: string) => void;
/**
* Polling interval (ms). 0 disables polling — the component will
* fetch once on mount.
*/
pollIntervalMs?: number;
/**
* Test-only seed used by Vitest to bypass the Tauri RPC layer. When
* provided, the component renders these reflections without polling.
*/
initialReflections?: Reflection[];
}
const KIND_LABEL: Partial<Record<ReflectionKind, string>> = {
hotness_spike: 'Hotness spike',
cross_source_pattern: 'Cross-source pattern',
daily_digest: 'Daily digest',
due_item: 'Due item',
risk: 'Risk',
opportunity: 'Opportunity',
};
function kindLabel(kind: ReflectionKind, _t: (key: string) => string): string {
return KIND_LABEL[kind] ?? kind;
}
/**
* Render a `created_at` (epoch seconds, as Rust serializes `f64` from
* `subconscious_reflections.created_at`) into a short relative-time
* label like "Just now", "5m ago", "3h ago", "2d ago". Anything older
* than ~7 days falls back to a fixed `MMM D` so cards aren't ambiguous
* when the user scrolls into older reflections.
*/
function formatRelativeTime(epochSeconds: number, t: (key: string) => string): string {
const nowMs = Date.now();
const tsMs = epochSeconds * 1000;
const diffSec = Math.max(0, Math.floor((nowMs - tsMs) / 1000));
if (diffSec < 45) return t('notifications.justNow');
if (diffSec < 3600)
return t('notifications.minAgo').replace('{n}', String(Math.floor(diffSec / 60)));
if (diffSec < 86_400)
return t('notifications.hrAgo').replace('{n}', String(Math.floor(diffSec / 3600)));
if (diffSec < 604_800)
return t('notifications.dayAgo').replace('{n}', String(Math.floor(diffSec / 86_400)));
return new Date(tsMs).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
/** Full ISO-ish datetime for the title-attribute tooltip. */
function formatAbsoluteTime(epochSeconds: number): string {
return new Date(epochSeconds * 1000).toLocaleString();
}
export default function SubconsciousReflectionCards({
onNavigateToThread,
pollIntervalMs = 0,
initialReflections,
}: SubconsciousReflectionCardsProps) {
const { t } = useT();
const [reflections, setReflections] = useState<Reflection[]>(initialReflections ?? []);
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(initialReflections === undefined);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (initialReflections !== undefined) return; // test mode
try {
const resp = await listReflections(50);
const data = resp.result ?? [];
console.debug('[subconscious-ui] reflections list:ok', { count: data.length });
setReflections(data);
setError(null);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.debug('[subconscious-ui] reflections list:error', { error: msg });
setError(msg);
} finally {
setLoading(false);
}
}, [initialReflections]);
useEffect(() => {
// Fire the initial fetch through a microtask so `setState` calls
// inside `refresh` don't run during effect-commit (which trips the
// `react-hooks/set-state-in-effect` lint).
let cancelled = false;
const tick = () => {
if (cancelled) return;
void refresh();
};
Promise.resolve().then(tick);
if (pollIntervalMs > 0 && initialReflections === undefined) {
const handle = setInterval(tick, pollIntervalMs);
return () => {
cancelled = true;
clearInterval(handle);
};
}
return () => {
cancelled = true;
};
}, [refresh, pollIntervalMs, initialReflections]);
const handleDismiss = async (id: string) => {
console.debug('[subconscious-ui] reflection dismiss:start', { id });
setHiddenIds(prev => new Set(prev).add(id)); // optimistic
try {
await dismissReflection(id);
console.debug('[subconscious-ui] reflection dismiss:ok', { id });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.debug('[subconscious-ui] reflection dismiss:error', { id, error: msg });
// Rollback optimistic hide on failure.
setHiddenIds(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
};
const handleAct = async (reflection: Reflection) => {
console.debug('[subconscious-ui] reflection act:start', { id: reflection.id });
try {
const resp = await actOnReflection(reflection.id);
console.debug('[subconscious-ui] reflection act:ok', {
id: reflection.id,
thread: resp.result.thread_id,
});
setHiddenIds(prev => new Set(prev).add(reflection.id));
onNavigateToThread?.(resp.result.thread_id);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.debug('[subconscious-ui] reflection act:error', { id: reflection.id, error: msg });
setError(msg);
}
};
const visible = reflections.filter(
r => !hiddenIds.has(r.id) && r.dismissed_at === null && r.acted_on_at === null
);
if (loading) {
return (
<div
data-testid="reflection-cards-loading"
className="text-xs text-stone-400 dark:text-neutral-500 py-2">
{t('reflections.loading')}
</div>
);
}
if (visible.length === 0 && !error) {
return (
<div
data-testid="reflection-cards-empty"
className="text-xs text-stone-400 dark:text-neutral-500 py-3">
{t('reflections.empty')}
</div>
);
}
// Nested-scroll layout: header is pinned at the top of the cards section,
// the card list below scrolls independently inside `flex-1 overflow-y-auto`.
// `min-h-0` is the Tailwind escape hatch for the flex-overflow gotcha —
// without it, `flex-1` children with overflow won't actually shrink to
// the parent's height and the inner scrollbar never engages.
return (
<div data-testid="reflection-cards" className="flex flex-col h-full min-h-0 overflow-hidden">
<div className="shrink-0 pb-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-primary-400" />
{t('reflections.title')}
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300">
{visible.length}
</span>
</h3>
{error && (
<div
data-testid="reflection-cards-error"
className="text-xs text-coral-600 dark:text-coral-300 mt-2">
{error}
</div>
)}
</div>
{/*
Card list. Two height knobs working together:
* `flex-1 min-h-0` — when an ancestor has a constrained height
(e.g. a panel with `h-full`), the inner scroll area fills the
remaining space and `min-h-0` is the flex-overflow escape
hatch that lets it actually shrink + scroll instead of
blowing the parent's bounds.
* `max-h-[70vh]` — when the cards live inside a flow-sized
container (the current Intelligence tab uses `space-y-6` with
no `h-full`, so the panel just grows with content), this
caps the list at roughly the viewport's upper half. On a
typical laptop the cap is ~720px, which fits ~8 cards
comfortably; on a 720p display it shrinks to ~500px.
Either way the inner list scrolls independently of the rest
of the Subconscious tab once the cap is hit.
*/}
<div className="flex-1 min-h-0 max-h-[70vh] overflow-y-auto space-y-2 pr-1">
{visible.map(r => (
<div
key={r.id}
data-testid={`reflection-card-${r.id}`}
className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300">
{kindLabel(r.kind, t)}
</span>
<span
data-testid={`reflection-timestamp-${r.id}`}
className="text-[10px] text-stone-400 dark:text-neutral-500"
title={formatAbsoluteTime(r.created_at)}>
{formatRelativeTime(r.created_at, t)}
</span>
</div>
<p className="text-sm text-stone-900 dark:text-neutral-100 whitespace-pre-line break-words">
{r.body}
</p>
{r.proposed_action && (
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-2">
<em>{t('reflections.proposedAction')}:</em> {r.proposed_action}
</p>
)}
</div>
<div className="flex flex-col gap-2 flex-shrink-0">
{r.thread_id && (
<button
data-testid={`reflection-view-${r.id}`}
onClick={() => onNavigateToThread?.(r.thread_id!)}
className="px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 border border-stone-200 dark:border-neutral-700 text-stone-600 dark:text-neutral-300 rounded-lg transition-colors">
{t('reflections.viewConversation')}
</button>
)}
{r.proposed_action && (
<button
data-testid={`reflection-act-${r.id}`}
onClick={() => void handleAct(r)}
className="px-3 py-1.5 text-xs bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors">
{t('reflections.act')}
</button>
)}
<button
data-testid={`reflection-dismiss-${r.id}`}
onClick={() => void handleDismiss(r.id)}
className="px-3 py-1.5 text-xs bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 text-stone-600 dark:text-neutral-300 rounded-lg transition-colors">
{t('reflections.dismiss')}
</button>
</div>
</div>
</div>
))}
</div>
</div>
);
}
@@ -5,27 +5,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setSelectedThread } from '../../../store/threadSlice';
import IntelligenceSubconsciousTab from '../IntelligenceSubconsciousTab';
const mockDispatch = vi.fn();
const mockNavigate = vi.fn();
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch, useSelector: () => 'en' }));
vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate }));
vi.mock('../SubconsciousReflectionCards', () => ({
default: ({ onNavigateToThread }: { onNavigateToThread?: (id: string) => void }) => (
<button
type="button"
data-testid="cards-stub-trigger"
onClick={() => onNavigateToThread?.('spawned-thread-42')}>
trigger
</button>
),
}));
function baseProps(): ComponentProps<typeof IntelligenceSubconsciousTab> {
return {
status: null,
@@ -62,27 +47,18 @@ describe('IntelligenceSubconsciousTab', () => {
expect(setMode).toHaveBeenCalledWith('simple');
});
it('hides Run Now and reflections when mode is off', () => {
it('hides Run Now when mode is off', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} mode="off" />);
expect(screen.queryByText('Run Now')).not.toBeInTheDocument();
expect(screen.queryByTestId('cards-stub-trigger')).not.toBeInTheDocument();
});
it('shows Run Now and reflections when mode is simple', () => {
it('shows Run Now when mode is simple', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} mode="simple" />);
expect(screen.getByText('Run Now')).toBeInTheDocument();
expect(screen.getByTestId('cards-stub-trigger')).toBeInTheDocument();
});
it('shows aggressive warning when mode is aggressive', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} mode="aggressive" />);
expect(screen.getByText(/full tool access including writes/)).toBeInTheDocument();
});
it('on Act → dispatches setSelectedThread + navigates to /chat', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} mode="simple" />);
fireEvent.click(screen.getByTestId('cards-stub-trigger'));
expect(mockDispatch).toHaveBeenCalledWith(setSelectedThread('spawned-thread-42'));
expect(mockNavigate).toHaveBeenCalledWith('/chat');
});
});
@@ -1,183 +0,0 @@
/**
* Vitest for SubconsciousReflectionCards (#623).
*
* Covers: empty state, card rendering with/without proposed_action,
* action button visibility, dismiss optimistic hide, the act → spawn-
* thread RPC wiring, and the onNavigateToThread callback.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import {
actOnReflection,
dismissReflection,
listReflections,
type Reflection,
} from '../../../utils/tauriCommands/subconscious';
import SubconsciousReflectionCards from '../SubconsciousReflectionCards';
// Mock just the subconscious tauriCommand surface — leaves the rest of
// the module untouched so the component's static imports don't blow up.
vi.mock('../../../utils/tauriCommands/subconscious', async () => {
const actual = await vi.importActual<typeof import('../../../utils/tauriCommands/subconscious')>(
'../../../utils/tauriCommands/subconscious'
);
return {
...actual,
listReflections: vi.fn(),
actOnReflection: vi.fn(),
dismissReflection: vi.fn(),
};
});
const mockedListReflections = vi.mocked(listReflections);
const mockedActOnReflection = vi.mocked(actOnReflection);
const mockedDismissReflection = vi.mocked(dismissReflection);
function refl(overrides: Partial<Reflection> = {}): Reflection {
return {
id: 'r-1',
kind: 'hotness_spike',
body: 'Phoenix surge',
proposed_action: 'Pull mentions',
source_refs: ['entity:phoenix'],
created_at: 1,
acted_on_at: null,
dismissed_at: null,
thread_id: null,
...overrides,
};
}
describe('SubconsciousReflectionCards', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders empty state when no reflections', () => {
renderWithProviders(<SubconsciousReflectionCards initialReflections={[]} />);
expect(screen.getByTestId('reflection-cards-empty')).toBeTruthy();
});
it('renders reflections with Act + Dismiss buttons when proposed_action is present', () => {
renderWithProviders(<SubconsciousReflectionCards initialReflections={[refl()]} />);
expect(screen.getByText('Phoenix surge')).toBeTruthy();
expect(screen.getByText('Hotness spike')).toBeTruthy();
expect(screen.getByTestId('reflection-act-r-1')).toBeTruthy();
expect(screen.getByTestId('reflection-dismiss-r-1')).toBeTruthy();
});
it('renders reflections without Act button when proposed_action is null', () => {
renderWithProviders(
<SubconsciousReflectionCards
initialReflections={[refl({ id: 'obs-1', proposed_action: null })]}
/>
);
expect(screen.queryByTestId('reflection-act-obs-1')).toBeNull();
expect(screen.getByTestId('reflection-dismiss-obs-1')).toBeTruthy();
});
it('hides card optimistically on dismiss tap', async () => {
mockedDismissReflection.mockResolvedValueOnce({ result: { dismissed: 'r-1' }, logs: [] });
renderWithProviders(<SubconsciousReflectionCards initialReflections={[refl()]} />);
fireEvent.click(screen.getByTestId('reflection-dismiss-r-1'));
await waitFor(() => {
expect(screen.queryByTestId('reflection-card-r-1')).toBeNull();
});
expect(mockedDismissReflection).toHaveBeenCalledWith('r-1');
});
it('act fires actOnReflection RPC, hides card, and calls onNavigateToThread with the new thread id', async () => {
mockedActOnReflection.mockResolvedValueOnce({
result: { reflection_id: 'r-1', thread_id: 'spawned-thread-1' },
logs: [],
});
const onNavigateToThread = vi.fn();
renderWithProviders(
<SubconsciousReflectionCards
initialReflections={[refl()]}
onNavigateToThread={onNavigateToThread}
/>
);
fireEvent.click(screen.getByTestId('reflection-act-r-1'));
await waitFor(() => {
expect(mockedActOnReflection).toHaveBeenCalledWith('r-1');
});
await waitFor(() => {
expect(screen.queryByTestId('reflection-card-r-1')).toBeNull();
});
expect(onNavigateToThread).toHaveBeenCalledWith('spawned-thread-1');
});
it('hides reflections that already have dismissed_at or acted_on_at', () => {
renderWithProviders(
<SubconsciousReflectionCards
initialReflections={[
refl({ id: 'visible' }),
refl({ id: 'gone-acted', acted_on_at: 100 }),
refl({ id: 'gone-dismissed', dismissed_at: 100 }),
]}
/>
);
expect(screen.getByTestId('reflection-card-visible')).toBeTruthy();
expect(screen.queryByTestId('reflection-card-gone-acted')).toBeNull();
expect(screen.queryByTestId('reflection-card-gone-dismissed')).toBeNull();
});
it('fetches reflections on mount via listReflections (when no initial seed)', async () => {
mockedListReflections.mockResolvedValueOnce({ result: [refl({ id: 'fetched' })], logs: [] });
renderWithProviders(<SubconsciousReflectionCards />);
await waitFor(() => {
expect(mockedListReflections).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByTestId('reflection-card-fetched')).toBeTruthy();
});
});
it('shows the error banner when listReflections rejects', async () => {
mockedListReflections.mockRejectedValueOnce(new Error('boom: rpc unreachable'));
renderWithProviders(<SubconsciousReflectionCards />);
await waitFor(() => {
expect(screen.getByTestId('reflection-cards-error')).toBeTruthy();
});
expect(screen.getByTestId('reflection-cards-error').textContent).toContain(
'boom: rpc unreachable'
);
});
it('rolls the optimistic dismiss back when dismissReflection rejects', async () => {
mockedDismissReflection.mockRejectedValueOnce(new Error('rpc denied'));
renderWithProviders(<SubconsciousReflectionCards initialReflections={[refl()]} />);
fireEvent.click(screen.getByTestId('reflection-dismiss-r-1'));
// First the card disappears (optimistic), then it comes back when the
// rejection lands in the catch handler — the rollback path is what
// bumps coverage on the otherwise-untested catch branch.
await waitFor(() => {
expect(screen.queryByTestId('reflection-card-r-1')).toBeNull();
});
await waitFor(() => {
expect(screen.getByTestId('reflection-card-r-1')).toBeTruthy();
});
});
it('surfaces the error banner when actOnReflection rejects', async () => {
mockedActOnReflection.mockRejectedValueOnce(new Error('act failed'));
const onNavigateToThread = vi.fn();
renderWithProviders(
<SubconsciousReflectionCards
initialReflections={[refl()]}
onNavigateToThread={onNavigateToThread}
/>
);
fireEvent.click(screen.getByTestId('reflection-act-r-1'));
await waitFor(() => {
expect(screen.getByTestId('reflection-cards-error')).toBeTruthy();
});
// Card stays visible (act failed → no optimistic hide finalises) and
// the navigate callback is *not* fired.
expect(screen.getByTestId('reflection-card-r-1')).toBeTruthy();
expect(onNavigateToThread).not.toHaveBeenCalled();
});
});
+2
View File
@@ -2074,6 +2074,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'تشغيل الآن',
'subconscious.providerUnavailableTitle': 'تم إيقاف اللاوعي مؤقتًا',
'subconscious.providerSettings': 'إعدادات الذكاء الاصطناعي',
'subconscious.scratchpadInfo':
'يحتفظ اللاوعي بدفتر ملاحظات مستمر للملاحظات عبر الدورات. تحقق من الإعدادات → وصول الوكيل لتكوين الوضع والتكرار.',
'subconscious.approvalNeeded': 'يلزم الموافقة',
'subconscious.requiresApproval': 'يتطلب الموافقة',
'subconscious.fixInConnections': 'إصلاح في الاتصالات',
+2
View File
@@ -2116,6 +2116,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'এখনই চালান',
'subconscious.providerUnavailableTitle': 'Subconscious বিরত আছে',
'subconscious.providerSettings': 'AI সেটিংস',
'subconscious.scratchpadInfo':
'সাবকনশাস টিক জুড়ে পর্যবেক্ষণের একটি স্থায়ী স্ক্র্যাচপ্যাড বজায় রাখে। মোড এবং ফ্রিকোয়েন্সি কনফিগার করতে সেটিংস → এজেন্ট অ্যাক্সেস দেখুন।',
'subconscious.approvalNeeded': 'অনুমোদন প্রয়োজন',
'subconscious.requiresApproval': 'অনুমোদন প্রয়োজন',
'subconscious.fixInConnections': 'সংযোগে ঠিক করুন',
+2
View File
@@ -2168,6 +2168,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'Jetzt ausführen',
'subconscious.providerUnavailableTitle': 'Unterbewusstsein ist pausiert',
'subconscious.providerSettings': 'KI-Einstellungen',
'subconscious.scratchpadInfo':
'Das Unterbewusstsein führt ein dauerhaftes Notizbuch mit Beobachtungen über Ticks hinweg. Überprüfen Sie Einstellungen → Agentenzugriff, um Modus und Häufigkeit zu konfigurieren.',
'subconscious.approvalNeeded': 'Genehmigung erforderlich',
'subconscious.requiresApproval': 'Erfordert eine Genehmigung',
'subconscious.fixInConnections': 'Fix in Verbindungen',
+2
View File
@@ -2579,6 +2579,8 @@ const en: TranslationMap = {
'subconscious.runNow': 'Run Now',
'subconscious.providerUnavailableTitle': 'Subconscious is paused',
'subconscious.providerSettings': 'AI settings',
'subconscious.scratchpadInfo':
'The subconscious maintains a persistent scratchpad of observations across ticks. Check Settings → Agent access to configure mode and frequency.',
'subconscious.approvalNeeded': 'Approval Needed',
'subconscious.requiresApproval': 'Requires approval',
'subconscious.fixInConnections': 'Fix in Connections',
+2
View File
@@ -2160,6 +2160,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'Ejecutar ahora',
'subconscious.providerUnavailableTitle': 'Subconsciente en pausa',
'subconscious.providerSettings': 'Ajustes de IA',
'subconscious.scratchpadInfo':
'El subconsciente mantiene un bloc de notas persistente de observaciones a través de los ciclos. Consulta Configuración → Acceso del agente para configurar el modo y la frecuencia.',
'subconscious.approvalNeeded': 'Se necesita aprobación',
'subconscious.requiresApproval': 'Requiere aprobación',
'subconscious.fixInConnections': 'Corregir en Conexiones',
+2
View File
@@ -2166,6 +2166,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'Exécuter maintenant',
'subconscious.providerUnavailableTitle': 'Subconscient en pause',
'subconscious.providerSettings': 'Paramètres IA',
'subconscious.scratchpadInfo':
"Le subconscient maintient un bloc-notes persistant d'observations à travers les cycles. Consultez Paramètres → Accès agent pour configurer le mode et la fréquence.",
'subconscious.approvalNeeded': 'Approbation requise',
'subconscious.requiresApproval': 'Nécessite une approbation',
'subconscious.fixInConnections': 'Corriger dans Connexions',
+2
View File
@@ -2115,6 +2115,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'अभी चलाएं',
'subconscious.providerUnavailableTitle': 'Subconscious रुका हुआ है',
'subconscious.providerSettings': 'AI सेटिंग्स',
'subconscious.scratchpadInfo':
'सबकॉन्शस टिक्स में अवलोकनों का एक स्थायी स्क्रैचपैड बनाए रखता है। मोड और आवृत्ति कॉन्फ़िगर करने के लिए सेटिंग्स → एजेंट एक्सेस देखें।',
'subconscious.approvalNeeded': 'अनुमति चाहिए',
'subconscious.requiresApproval': 'अनुमति ज़रूरी है',
'subconscious.fixInConnections': 'Connections में ठीक करें',
+2
View File
@@ -2118,6 +2118,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'Jalankan Sekarang',
'subconscious.providerUnavailableTitle': 'Subconscious dijeda',
'subconscious.providerSettings': 'Pengaturan AI',
'subconscious.scratchpadInfo':
'Alam bawah sadar memelihara catatan pengamatan yang persisten di seluruh siklus. Periksa Pengaturan → Akses agen untuk mengonfigurasi mode dan frekuensi.',
'subconscious.approvalNeeded': 'Persetujuan Diperlukan',
'subconscious.requiresApproval': 'Memerlukan persetujuan',
'subconscious.fixInConnections': 'Perbaiki di Koneksi',
+2
View File
@@ -2152,6 +2152,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'Esegui ora',
'subconscious.providerUnavailableTitle': 'Subconscio in pausa',
'subconscious.providerSettings': 'Impostazioni IA',
'subconscious.scratchpadInfo':
'Il subconscio mantiene un blocco appunti persistente di osservazioni attraverso i cicli. Controlla Impostazioni → Accesso agente per configurare modalità e frequenza.',
'subconscious.approvalNeeded': 'Approvazione necessaria',
'subconscious.requiresApproval': 'Richiede approvazione',
'subconscious.fixInConnections': 'Correggi in Connessioni',
+2
View File
@@ -2094,6 +2094,8 @@ const messages: TranslationMap = {
'subconscious.runNow': '지금 실행',
'subconscious.providerUnavailableTitle': 'Subconscious 일시 중지됨',
'subconscious.providerSettings': 'AI 설정',
'subconscious.scratchpadInfo':
'잠재의식은 틱 전반에 걸쳐 관찰 사항의 영구 메모장을 유지합니다. 모드와 빈도를 구성하려면 설정 → 에이전트 접근을 확인하세요.',
'subconscious.approvalNeeded': '승인 필요',
'subconscious.requiresApproval': '승인이 필요함',
'subconscious.fixInConnections': '연결에서 수정',
+2
View File
@@ -2139,6 +2139,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'Uruchom teraz',
'subconscious.providerUnavailableTitle': 'Podświadomość wstrzymana',
'subconscious.providerSettings': 'Ustawienia AI',
'subconscious.scratchpadInfo':
'Podświadomość utrzymuje trwały notatnik obserwacji między cyklami. Sprawdź Ustawienia → Dostęp agenta, aby skonfigurować tryb i częstotliwość.',
'subconscious.approvalNeeded': 'Wymagana zgoda',
'subconscious.requiresApproval': 'Wymaga zgody',
'subconscious.fixInConnections': 'Napraw w Połączeniach',
+2
View File
@@ -2156,6 +2156,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'Executar Agora',
'subconscious.providerUnavailableTitle': 'Subconsciente pausado',
'subconscious.providerSettings': 'Configurações de IA',
'subconscious.scratchpadInfo':
'O subconsciente mantém um bloco de notas persistente de observações entre ciclos. Verifique Configurações → Acesso do agente para configurar o modo e a frequência.',
'subconscious.approvalNeeded': 'Aprovação Necessária',
'subconscious.requiresApproval': 'Requer aprovação',
'subconscious.fixInConnections': 'Corrigir em Conexões',
+2
View File
@@ -2133,6 +2133,8 @@ const messages: TranslationMap = {
'subconscious.runNow': 'Запустить сейчас',
'subconscious.providerUnavailableTitle': 'Подсознание приостановлено',
'subconscious.providerSettings': 'Настройки ИИ',
'subconscious.scratchpadInfo':
'Подсознание ведёт постоянный блокнот наблюдений между циклами. Проверьте Настройки → Доступ агента для настройки режима и частоты.',
'subconscious.approvalNeeded': 'Требуется подтверждение',
'subconscious.requiresApproval': 'Требует подтверждения',
'subconscious.fixInConnections': 'Исправить в подключениях',
+2
View File
@@ -2011,6 +2011,8 @@ const messages: TranslationMap = {
'subconscious.runNow': '立即运行',
'subconscious.providerUnavailableTitle': '潜意识已暂停',
'subconscious.providerSettings': 'AI 设置',
'subconscious.scratchpadInfo':
'潜意识在每次循环中维护一个持久的观察记事本。请查看设置 → 代理访问来配置模式和频率。',
'subconscious.approvalNeeded': '需要审批',
'subconscious.requiresApproval': '需要审批',
'subconscious.fixInConnections': '在连接中修复',
@@ -1,101 +0,0 @@
/**
* Vitest for the subconscious tauriCommands surface (#623).
*
* Covers the three RPC wrappers — `listReflections`, `actOnReflection`,
* `dismissReflection` — plus their `isTauri()` guard. Mirrors the
* mocking pattern used by `config.test.ts` and `core.test.ts` so the
* wrappers are validated against the live `callCoreRpc` contract
* without spinning up a real Tauri runtime.
*/
import { isTauri } from '@tauri-apps/api/core';
import { afterEach, beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
describe('tauriCommands/subconscious', () => {
const mockIsTauri = isTauri as Mock;
const mockCallCoreRpc = callCoreRpc as Mock;
let listReflections: typeof import('./subconscious').listReflections;
let actOnReflection: typeof import('./subconscious').actOnReflection;
let dismissReflection: typeof import('./subconscious').dismissReflection;
beforeEach(async () => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
const actual = await vi.importActual<typeof import('./subconscious')>('./subconscious');
listReflections = actual.listReflections;
actOnReflection = actual.actOnReflection;
dismissReflection = actual.dismissReflection;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('listReflections', () => {
test('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(listReflections()).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('forwards default limit and omits since_ts when absent', async () => {
mockCallCoreRpc.mockResolvedValue({ result: [], logs: [] });
await listReflections();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.subconscious_reflections_list',
params: { limit: 50 },
});
});
test('passes through explicit limit + since_ts when supplied', async () => {
mockCallCoreRpc.mockResolvedValue({ result: [], logs: [] });
await listReflections(20, 1700000000);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.subconscious_reflections_list',
params: { limit: 20, since_ts: 1700000000 },
});
});
});
describe('actOnReflection', () => {
test('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(actOnReflection('r-1')).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('dispatches openhuman.subconscious_reflections_act with reflection_id', async () => {
mockCallCoreRpc.mockResolvedValue({
result: { reflection_id: 'r-1', thread_id: 'thr-9' },
logs: [],
});
const resp = await actOnReflection('r-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.subconscious_reflections_act',
params: { reflection_id: 'r-1' },
});
expect(resp.result.thread_id).toBe('thr-9');
});
});
describe('dismissReflection', () => {
test('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(dismissReflection('r-1')).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('dispatches openhuman.subconscious_reflections_dismiss with reflection_id', async () => {
mockCallCoreRpc.mockResolvedValue({ result: { dismissed: 'r-1' }, logs: [] });
await dismissReflection('r-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.subconscious_reflections_dismiss',
params: { reflection_id: 'r-1' },
});
});
});
});
+5 -66
View File
@@ -1,5 +1,8 @@
/**
* Subconscious engine commands — thoughts (reflections) and engine control.
* Subconscious engine commands — engine control and scratchpad.
*
* Reflection/thoughts RPCs have been removed — the subconscious now
* maintains only a scratchpad (via agent tools) and run logs.
*/
import { callCoreRpc } from '../../services/coreRpcClient';
import { type CommandResponse, isTauri } from './common';
@@ -19,9 +22,8 @@ export interface SubconsciousStatus {
export interface TickResult {
tick_at: number;
thoughts_count: number;
thread_id: string | null;
duration_ms: number;
response_chars?: number;
}
// ── Status & Trigger ─────────────────────────────────────────────────────────
@@ -39,66 +41,3 @@ export async function subconsciousTrigger(): Promise<CommandResponse<TickResult>
method: 'openhuman.subconscious_trigger',
});
}
// ── Thoughts (Reflections) ──────────────────────────────────────────────────
export type ReflectionKind =
| 'hotness_spike'
| 'cross_source_pattern'
| 'daily_digest'
| 'due_item'
| 'risk'
| 'opportunity';
export interface SourceChunk {
ref_id: string;
kind: string;
content: string;
metadata?: unknown;
}
export interface Reflection {
id: string;
kind: ReflectionKind;
body: string;
proposed_action: string | null;
source_refs: string[];
source_chunks?: SourceChunk[];
created_at: number;
acted_on_at: number | null;
dismissed_at: number | null;
thread_id: string | null;
}
export async function listReflections(
limit = 50,
sinceTs?: number
): Promise<CommandResponse<Reflection[]>> {
if (!isTauri()) throw new Error('Not running in Tauri');
const params: Record<string, unknown> = { limit };
if (sinceTs !== undefined) params.since_ts = sinceTs;
return await callCoreRpc<CommandResponse<Reflection[]>>({
method: 'openhuman.subconscious_reflections_list',
params,
});
}
export async function actOnReflection(
reflectionId: string
): Promise<CommandResponse<{ reflection_id: string; thread_id: string }>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<{ reflection_id: string; thread_id: string }>>({
method: 'openhuman.subconscious_reflections_act',
params: { reflection_id: reflectionId },
});
}
export async function dismissReflection(
reflectionId: string
): Promise<CommandResponse<{ dismissed: string }>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<{ dismissed: string }>>({
method: 'openhuman.subconscious_reflections_dismiss',
params: { reflection_id: reflectionId },
});
}
+3
View File
@@ -73,6 +73,9 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
)
}
"memory" => crate::core::memory_cli::run_memory_command(&args[1..]),
"subconscious" | "sub" => {
crate::core::subconscious_cli::run_subconscious_command(&args[1..])
}
"agent" => {
log::debug!(
"[cli] dispatching to agent subcommand, args={:?}",
+1
View File
@@ -23,6 +23,7 @@ pub mod rpc_log;
pub mod runtime;
pub mod shutdown;
pub mod socketio;
pub mod subconscious_cli;
pub mod types;
/// Canonical function contract for domain controllers.
+286
View File
@@ -0,0 +1,286 @@
//! `openhuman subconscious` — CLI for testing and debugging the subconscious loop.
//!
//! Usage:
//! openhuman subconscious tick [--workspace <path>] [--mode simple|aggressive] [--verbose]
//! openhuman subconscious status [--workspace <path>]
//! openhuman subconscious scratchpad [--workspace <path>]
use anyhow::{anyhow, Result};
use std::path::PathBuf;
pub fn run_subconscious_command(args: &[String]) -> Result<()> {
if args.is_empty() || is_help(&args[0]) {
print_help();
return Ok(());
}
match args[0].as_str() {
"tick" => run_tick(&args[1..]),
"status" => run_status(&args[1..]),
"scratchpad" | "pad" => run_scratchpad(&args[1..]),
other => Err(anyhow!(
"unknown subconscious subcommand '{other}'. Run `openhuman subconscious --help`."
)),
}
}
// ── tick ────────────────────────────────────────────────────────────────────
struct TickFlags {
workspace: Option<PathBuf>,
mode: Option<String>,
verbose: bool,
}
fn parse_tick_flags(args: &[String]) -> Result<TickFlags> {
let mut workspace: Option<PathBuf> = None;
let mut mode: Option<String> = None;
let mut verbose = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--workspace" | "-w" => {
workspace = Some(PathBuf::from(
args.get(i + 1)
.ok_or_else(|| anyhow!("missing value for --workspace"))?,
));
i += 2;
}
"--mode" | "-m" => {
mode = Some(
args.get(i + 1)
.ok_or_else(|| anyhow!("missing value for --mode"))?
.clone(),
);
i += 2;
}
"--verbose" | "-v" => {
verbose = true;
i += 1;
}
other => return Err(anyhow!("unknown flag '{other}'")),
}
}
Ok(TickFlags {
workspace,
mode,
verbose,
})
}
fn run_tick(args: &[String]) -> Result<()> {
let flags = parse_tick_flags(args)?;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let mut config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| anyhow!("config load failed: {e}"))?;
if let Some(ws) = &flags.workspace {
config.workspace_dir = ws.clone();
}
if let Some(mode_str) = &flags.mode {
config.heartbeat.subconscious_mode = match mode_str.as_str() {
"simple" => crate::openhuman::config::schema::SubconsciousMode::Simple,
"aggressive" => crate::openhuman::config::schema::SubconsciousMode::Aggressive,
other => {
return Err(anyhow!(
"unknown mode '{other}', expected simple|aggressive"
))
}
};
config.heartbeat.enabled = true;
config.heartbeat.inference_enabled = true;
}
// Ensure subconscious is enabled
if !config.heartbeat.enabled || !config.heartbeat.inference_enabled {
config.heartbeat.enabled = true;
config.heartbeat.inference_enabled = true;
if !config.heartbeat.subconscious_mode.is_enabled() {
config.heartbeat.subconscious_mode =
crate::openhuman::config::schema::SubconsciousMode::Simple;
}
}
let mode = config.heartbeat.effective_subconscious_mode();
eprintln!(
"[subconscious] mode={} workspace={}",
mode.as_str(),
config.workspace_dir.display()
);
// Init memory client
let _ = crate::openhuman::memory::global::init(config.workspace_dir.clone());
// Init scheduler gate so is_signed_out() works
crate::openhuman::scheduler_gate::init_global(&config);
// Seed signed_out from session token
match crate::api::jwt::get_session_token(&config) {
Ok(Some(_)) => {
crate::openhuman::scheduler_gate::set_signed_out(false);
eprintln!("[subconscious] session token found — provider available");
}
Ok(None) => {
eprintln!("[subconscious] WARNING: no session token — cloud provider will fail");
eprintln!(" hint: run `openhuman call auth store_session --token <JWT>` first");
}
Err(e) => {
eprintln!("[subconscious] WARNING: session token read failed: {e}");
}
}
// Check provider availability
if let Some(reason) =
crate::openhuman::subconscious::engine::subconscious_provider_unavailable_reason(
&config,
)
{
eprintln!("[subconscious] provider unavailable: {reason}");
return Err(anyhow!("provider unavailable: {reason}"));
}
// Create engine and run tick
let memory = crate::openhuman::memory::global::client_if_ready();
let engine = crate::openhuman::subconscious::SubconsciousEngine::new(&config, memory);
eprintln!("[subconscious] running tick...");
let result = engine
.tick()
.await
.map_err(|e| anyhow!("tick failed: {e}"))?;
eprintln!(
"[subconscious] tick complete: duration={}ms response_chars={}",
result.duration_ms, result.response_chars,
);
if flags.verbose {
// Print scratchpad state after tick
let entries = crate::openhuman::subconscious::scratchpad::load(&config.workspace_dir)
.unwrap_or_default();
if !entries.is_empty() {
eprintln!("\n[subconscious] scratchpad after tick:");
println!("{}", serde_json::to_string_pretty(&entries)?);
}
}
Ok(())
})
}
// ── status ─────────────────────────────────────────────────────────────────
fn run_status(args: &[String]) -> Result<()> {
let workspace = parse_workspace_flag(args)?;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let mut config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| anyhow!("config load failed: {e}"))?;
if let Some(ws) = workspace {
config.workspace_dir = ws;
}
let mode = config.heartbeat.effective_subconscious_mode();
let provider_reason = if mode.is_enabled() {
crate::openhuman::subconscious::engine::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 status = serde_json::json!({
"mode": mode.as_str(),
"enabled": mode.is_enabled(),
"provider_available": provider_reason.is_none(),
"provider_unavailable_reason": provider_reason,
"last_tick_at": last_tick.filter(|v| *v > 0.0),
"interval_minutes": mode.default_interval_minutes().max(5),
});
println!("{}", serde_json::to_string_pretty(&status)?);
Ok(())
})
}
// ── scratchpad ─────────────────────────────────────────────────────────────
fn run_scratchpad(args: &[String]) -> Result<()> {
let workspace = parse_workspace_flag(args)?;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let mut config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| anyhow!("config load failed: {e}"))?;
if let Some(ws) = workspace {
config.workspace_dir = ws;
}
let entries = crate::openhuman::subconscious::scratchpad::load(&config.workspace_dir)
.map_err(|e| anyhow!("failed to read scratchpad: {e}"))?;
if entries.is_empty() {
eprintln!("(scratchpad empty)");
} else {
println!("{}", serde_json::to_string_pretty(&entries)?);
}
Ok(())
})
}
// ── helpers ────────────────────────────────────────────────────────────────
fn parse_workspace_flag(args: &[String]) -> Result<Option<PathBuf>> {
let mut workspace: Option<PathBuf> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--workspace" | "-w" => {
workspace = Some(PathBuf::from(
args.get(i + 1)
.ok_or_else(|| anyhow!("missing --workspace"))?,
));
i += 2;
}
other => return Err(anyhow!("unknown flag '{other}'")),
}
}
Ok(workspace)
}
fn is_help(s: &str) -> bool {
matches!(s, "--help" | "-h" | "help")
}
fn print_help() {
eprintln!(
"Usage: openhuman subconscious <command> [options]
Commands:
tick Run a single subconscious tick (synchronous, waits for completion)
status Show current subconscious engine status
scratchpad Dump the persistent scratchpad
Tick options:
--mode <simple|aggressive> Override the subconscious mode
--workspace <path> Override workspace directory
--verbose, -v Print scratchpad after tick
Common options:
--workspace <path> Override workspace directory
"
);
}
+8 -5
View File
@@ -225,8 +225,10 @@ pub struct AgentDefinition {
/// hands off to `Reasoning` or `Worker`, never to itself.
/// * `Reasoning` MUST NOT list another `Reasoning` agent in
/// `subagents`. Reasoning composes downward into `Worker`s.
/// * `Worker` MUST NOT list any subagents. Workers execute; they
/// do not orchestrate.
/// * `Worker` MUST NOT list open-ended subagents. Workers execute;
/// they do not orchestrate. The hidden `call_memory_agent` tool may
/// still use `agent_memory` in this policy so memory retrieval is
/// gated without adding visible delegation tools.
/// * `{ skills = "*" }` entries expand to the generic
/// `integrations_agent` (a `Worker`) so they are always allowed.
///
@@ -259,9 +261,10 @@ pub struct AgentDefinition {
/// ```
///
/// `Chat` and `Reasoning` are forbidden from spawning their own tier;
/// `Worker` is forbidden from spawning anything. Total depth is capped
/// at three hops by the harness regardless of tier (defence in depth
/// against custom TOMLs that drop the tier annotation).
/// `Worker` is forbidden from spawning anything except the hidden
/// `agent_memory` retrieval specialist. Total depth is capped at three
/// hops by the harness regardless of tier (defence in depth against
/// custom TOMLs that drop the tier annotation).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AgentTier {
@@ -1,6 +1,6 @@
//! Tests for the builder module — dedup_visible_tool_specs and related logic.
use super::dedup_visible_tool_specs;
use super::{dedup_visible_tool_specs, should_synthesize_delegation_tools};
use crate::openhuman::tools::ToolSpec;
use serde_json::json;
@@ -71,3 +71,25 @@ fn preserves_full_spec_content_for_kept_entries() {
json!({"type": "object", "required": ["x"]})
);
}
#[test]
fn memory_only_subagent_policy_does_not_synthesize_delegate_tools() {
let defs = crate::openhuman::agent_registry::agents::load_builtins().unwrap();
let help = defs
.iter()
.find(|def| def.id == "help")
.expect("help agent is built in");
let orchestrator = defs
.iter()
.find(|def| def.id == "orchestrator")
.expect("orchestrator is built in");
assert!(
!should_synthesize_delegation_tools(help),
"memory-only subagent policy should gate call_memory_agent without adding delegate tools"
);
assert!(
should_synthesize_delegation_tools(orchestrator),
"orchestrator still needs synthesized delegate tools"
);
}
@@ -2,6 +2,7 @@
//! `build_session_agent_inner` constructor.
use super::helpers::prefetch_tool_memory_rules_blocking;
use super::should_synthesize_delegation_tools;
use crate::openhuman::agent::dispatcher::{
NativeToolDispatcher, PFormatToolDispatcher, XmlToolDispatcher,
};
@@ -740,11 +741,15 @@ impl Agent {
crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global(),
) {
(Some(def), Some(reg)) => {
let synthed = tools::orchestrator_tools::collect_orchestrator_tools(
def,
reg,
prewarmed_integrations_slice,
);
let synthed = if should_synthesize_delegation_tools(def) {
tools::orchestrator_tools::collect_orchestrator_tools(
def,
reg,
prewarmed_integrations_slice,
)
} else {
Vec::new()
};
let filter: Option<std::collections::HashSet<String>> = match &def.tools {
ToolScope::Named(names) => {
let mut set: std::collections::HashSet<String> =
@@ -13,6 +13,7 @@ mod setters;
#[cfg(test)]
mod builder_tests;
use crate::openhuman::agent::harness::definition::{AgentDefinition, ToolScope};
use crate::openhuman::agent_tool_policy::ToolPolicySession;
use crate::openhuman::tools::ToolSpec;
@@ -64,3 +65,18 @@ pub(super) fn visible_tool_specs_for_policy(
.cloned()
.collect()
}
pub(super) fn should_synthesize_delegation_tools(def: &AgentDefinition) -> bool {
match &def.tools {
ToolScope::Wildcard => !def.subagents.is_empty(),
ToolScope::Named(names) => names.iter().any(|name| {
matches!(
name.as_str(),
"spawn_subagent"
| "spawn_async_subagent"
| "spawn_parallel_agents"
| "spawn_worker_thread"
)
}),
}
}
+14
View File
@@ -130,6 +130,20 @@ impl Tool for CallMemoryAgentTool {
)
})?;
let parent = parent.expect("checked above");
if !parent.allowed_subagent_ids.contains(AGENT_ID) {
log::warn!(
"[call_memory_agent] blocked memory subagent outside parent allowlist parent_agent={} requested_agent={} allowed={:?}",
parent.agent_definition_id,
AGENT_ID,
parent.allowed_subagent_ids
);
return Ok(ToolResult::error(format!(
"call_memory_agent: agent '{AGENT_ID}' is not in parent agent '{}' subagents.allowlist",
parent.agent_definition_id
)));
}
let mut prompt = format!(
"Search the user's memory tree and return relevant context for this query:\n\n{query}"
);
@@ -18,6 +18,9 @@ omit_skills_catalog = true
[model]
hint = "agentic"
[subagents]
allowlist = ["agent_memory"]
[tools]
# Narrow allowlist. Wallet + web3 + market primitives only — no shell, no
# file_write, no broad HTTP, no integration delegation. Wallet names line up
@@ -20,5 +20,8 @@ omit_memory_md = false
[model]
hint = "agentic"
[subagents]
allowlist = ["agent_memory"]
[tools]
named = ["gitbooks_search", "gitbooks_get_page", "call_memory_agent"]
+54 -9
View File
@@ -193,6 +193,11 @@ pub const BUILTINS: &[BuiltinAgent] = &[
toml: include_str!("../../agent_memory/agent/agent.toml"),
prompt_fn: crate::openhuman::agent_memory::agent::prompt::build,
},
BuiltinAgent {
id: "subconscious",
toml: include_str!("../../subconscious/agent/agent.toml"),
prompt_fn: crate::openhuman::subconscious::agent::prompt::build,
},
];
/// Parse every entry in [`BUILTINS`] into an [`AgentDefinition`].
@@ -245,12 +250,16 @@ pub fn validate_tier_hierarchy(defs: &[AgentDefinition]) -> Result<()> {
SubagentEntry::Skills(_) => continue,
};
// Worker leaves: no spawn surface at all.
if def.agent_tier == AgentTier::Worker {
// Worker leaves: no open-ended spawn surface. A worker may still
// name `agent_memory` so the hidden `call_memory_agent` tool can
// be policy-gated by the same parent-context allowlist without
// synthesising visible delegate tools.
if def.agent_tier == AgentTier::Worker && child_id != "agent_memory" {
anyhow::bail!(
"agent `{parent}` is a `worker` tier and must not list `{child}` (or any \
agent) in its subagents — workers are leaf executors. Either remove the \
entry or re-tier `{parent}` as `chat` / `reasoning`.",
"agent `{parent}` is a `worker` tier and must not list `{child}` in its \
subagents — workers are leaf executors except for the hidden `agent_memory` \
retrieval policy. Either remove the entry or re-tier `{parent}` as `chat` / \
`reasoning`.",
parent = def.id,
child = child_id,
);
@@ -317,7 +326,9 @@ fn parse_builtin(b: &BuiltinAgent) -> Result<AgentDefinition> {
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::agent::harness::definition::{ModelSpec, SandboxMode, ToolScope};
use crate::openhuman::agent::harness::definition::{
ModelSpec, SandboxMode, SubagentEntry, ToolScope,
};
#[test]
fn all_builtins_parse() {
@@ -325,6 +336,27 @@ mod tests {
assert_eq!(defs.len(), BUILTINS.len());
}
#[test]
fn call_memory_agent_users_allow_agent_memory_subagent() {
for def in load_builtins().expect("built-in TOML must parse") {
let uses_call_memory_agent = match &def.tools {
ToolScope::Named(tools) => tools.iter().any(|tool| tool == "call_memory_agent"),
ToolScope::Wildcard => false,
};
if !uses_call_memory_agent {
continue;
}
assert!(
def.subagents.iter().any(|entry| {
matches!(entry, SubagentEntry::AgentId(id) if id == "agent_memory")
}),
"{} exposes call_memory_agent but does not allow agent_memory in subagents",
def.id
);
}
}
#[test]
fn trigger_reactor_has_agentic_hint_and_narrow_tools() {
let def = find("trigger_reactor");
@@ -1056,6 +1088,8 @@ mod tests {
#[test]
fn control_specialists_have_named_tools_and_are_worker_leaves() {
use crate::openhuman::agent::harness::definition::SubagentEntry;
for expected in [
"task_manager_agent",
"settings_agent",
@@ -1065,7 +1099,18 @@ mod tests {
] {
let def = find(expected);
assert_eq!(def.agent_tier, AgentTier::Worker);
assert!(def.subagents.is_empty(), "{expected} must be a worker leaf");
let visible_subagents: Vec<&str> = def
.subagents
.iter()
.filter_map(|entry| match entry {
SubagentEntry::AgentId(id) if id != "agent_memory" => Some(id.as_str()),
_ => None,
})
.collect();
assert!(
visible_subagents.is_empty(),
"{expected} must be a worker leaf except for hidden agent_memory lookup"
);
match def.tools {
ToolScope::Named(tools) => {
assert!(
@@ -1103,13 +1148,13 @@ mod tests {
#[test]
fn other_builtins_default_to_worker_tier() {
for def in load_builtins().unwrap() {
if def.id == "orchestrator" || def.id == "planner" {
if def.id == "orchestrator" || def.id == "planner" || def.id == "subconscious" {
continue;
}
assert_eq!(
def.agent_tier,
AgentTier::Worker,
"{} should default to worker tier (only orchestrator/planner are non-worker today)",
"{} should default to worker tier (only orchestrator/planner/subconscious are non-worker today)",
def.id
);
}
@@ -18,6 +18,9 @@ omit_skills_catalog = true
[model]
hint = "agentic"
[subagents]
allowlist = ["agent_memory"]
[tools]
# Narrow allowlist. Prediction-market venues only — no shell, no
# file_write, no broad HTTP, no integration delegation, no wallet
@@ -60,6 +60,10 @@ allowlist = [
# "forget", profile/persona edits, learned facets, and people alias
# management here.
"profile_memory_agent",
# Hidden memory retrieval specialist reached through `call_memory_agent`.
# Keep it in the allowlist so direct memory lookups are subject to the
# same parent-context gate as explicit subagent delegation.
"agent_memory",
# Account/admin specialist. Route billing, team membership/invites/roles,
# OAuth/credential/session, and referral requests here.
"account_admin_agent",
@@ -21,6 +21,9 @@ agent_tier = "reasoning"
[model]
hint = "reasoning"
[subagents]
allowlist = ["agent_memory"]
[tools]
# Read + research only. The planner produces plans — it never mutates
# the workspace, memory, or state. Any writes the plan requires get
@@ -15,6 +15,9 @@ omit_memory_md = true
[model]
hint = "agentic"
[subagents]
allowlist = ["agent_memory"]
[tools]
named = [
"generate_presentation",
@@ -16,6 +16,9 @@ omit_memory_md = false
[model]
hint = "agentic"
[subagents]
allowlist = ["agent_memory"]
[tools]
named = [
"call_memory_agent",
@@ -15,6 +15,9 @@ omit_skills_catalog = true
[model]
hint = "agentic"
[subagents]
allowlist = ["agent_memory"]
[tools]
named = [
"http_request",
@@ -29,6 +29,9 @@ omit_memory_md = false
# via the normal `RouterProvider` path.
hint = "agentic"
[subagents]
allowlist = ["agent_memory"]
[tools]
# Small, deliberately narrow tool surface:
# - call_memory_agent / memory_store: look up and persist context
@@ -129,34 +129,12 @@ pub(super) fn build_session_agent(
}
fn load_reflection_chunks_for_thread(
workspace_dir: &std::path::Path,
thread_id: &str,
_workspace_dir: &std::path::Path,
_thread_id: &str,
) -> Option<Vec<crate::openhuman::subconscious::SourceChunk>> {
let messages = crate::openhuman::memory_conversations::get_messages(
workspace_dir.to_path_buf(),
thread_id,
)
.ok()?;
let first = messages.first()?;
let origin = first
.extra_metadata
.get("origin")
.and_then(|v| v.as_str())?;
if origin != "subconscious_reflection" {
return None;
}
let reflection_id = first
.extra_metadata
.get("reflection_id")
.and_then(|v| v.as_str())?
.to_string();
let reflection =
crate::openhuman::subconscious::store::with_connection(workspace_dir, |conn| {
crate::openhuman::subconscious::reflection_store::get_reflection(conn, &reflection_id)
})
.ok()
.flatten()?;
Some(reflection.source_chunks)
// Reflection store has been removed. Existing threads spawned from
// reflections no longer receive memory-context injection.
None
}
pub(crate) fn locale_reply_directive(locale: &str) -> Option<String> {
+8 -13
View File
@@ -1,15 +1,10 @@
//! Heartbeat loop — periodic scheduler that delegates to the subconscious
//! engine for task-driven evaluation via local model inference.
//!
//! HEARTBEAT.md in the workspace defines the task checklist.
//! The subconscious engine evaluates tasks against workspace state
//! (memory, graph, skills) using the local Ollama model.
//! Heartbeat — re-exports from `subconscious::heartbeat` after module
//! consolidation. Kept as a thin shim so external `crate::openhuman::heartbeat::*`
//! paths continue to compile without a crate-wide rename.
pub mod engine;
pub mod planner;
pub mod rpc;
mod schemas;
pub use schemas::{
all_controller_schemas as all_heartbeat_controller_schemas,
all_registered_controllers as all_heartbeat_registered_controllers,
pub use crate::openhuman::subconscious::heartbeat::engine;
pub use crate::openhuman::subconscious::heartbeat::planner;
pub use crate::openhuman::subconscious::heartbeat::rpc;
pub use crate::openhuman::subconscious::heartbeat::{
all_heartbeat_controller_schemas, all_heartbeat_registered_controllers,
};
+6
View File
@@ -211,6 +211,12 @@ const RESOURCE_CATALOG: &[PromptResource] = &[
description: "Dedicated memory retrieval subagent using smart-walk strategies.",
content: include_str!("../agent_memory/agent/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/subconscious",
name: "subconscious",
description: "Background reasoning agent that maintains subconscious scratchpad context.",
content: include_str!("../subconscious/agent/prompt.md"),
},
];
/// Returns the `resources/list` result payload listing every catalog entry.
@@ -0,0 +1,32 @@
id = "subconscious"
display_name = "Subconscious Agent"
when_to_use = "Background awareness loop — periodically wakes up, reviews the user's situation report and context, maintains a persistent scratchpad of observations, and delegates deeper research when in aggressive mode."
temperature = 0.4
max_iterations = 30
sandbox_mode = "read_only"
# Background coordinator: simple mode only edits scratchpad, but aggressive
# mode may delegate bounded deep work through the explicit subagent policy.
agent_tier = "reasoning"
omit_identity = false
omit_memory_context = false
omit_safety_preamble = true
omit_skills_catalog = true
omit_profile = false
omit_memory_md = false
[model]
hint = "chat"
[subagents]
allowlist = [
"orchestrator",
"researcher",
]
[tools]
named = [
"scratchpad_add",
"scratchpad_edit",
"scratchpad_remove",
"spawn_subagent",
]
+1
View File
@@ -0,0 +1 @@
pub mod prompt;
@@ -0,0 +1,68 @@
# Subconscious Agent
You are the user's background awareness layer — a deep reasoning loop
that wakes up periodically, reviews the user's situation report, and
maintains a persistent scratchpad of observations and follow-ups.
Your situation report and any pre-loaded memory context are provided
in the user message. Use this information to maintain your scratchpad.
## Scratchpad Maintenance
Your scratchpad IS your continuity mechanism across ticks. Maintain it
actively — it persists between ticks and is the primary output of your work.
**Tools:**
1. **`scratchpad_add`** — Save a thought, hypothesis, or follow-up item.
Use `priority` (0-10) to mark importance.
Example: `{"body": "User has a meeting with Alice on Friday — check
if prep is done", "priority": 5}`
2. **`scratchpad_edit`** — Update an existing entry with new information
or revised thinking. Pass the `id` shown in brackets.
3. **`scratchpad_remove`** — Remove an entry that's no longer relevant
or has been fully addressed.
**Scratchpad discipline:**
- Add new observations as you discover them from the situation report
- Edit stale entries with fresh data
- Remove resolved items — don't let the pad grow stale
- High-priority items (p7+) should be actionable, not vague
## Deep Research (Aggressive mode only)
When operating in aggressive mode, you have access to `spawn_subagent`
for deeper investigation:
- **`spawn_subagent`** with `agent_id: "orchestrator"` — Delegate
complex multi-step tasks. The orchestrator can plan, execute code,
search the web, and coordinate across tools. Use this when you
identify something the user should act on and you have the autonomy
to help.
- Pass `model: "<reasoning-model>"` for deep reasoning tasks
- Example: `{"agent_id": "orchestrator", "prompt": "Research and
draft a summary of...", "model": "reasoning-v1"}`
- **`spawn_subagent`** with `agent_id: "researcher"` — Delegate web
searches, artifact fetching, or external research that goes beyond
what your context provides.
**When to use aggressive delegation:**
- A deadline is approaching and the user hasn't started prep
- A pattern across sources suggests an emerging issue
- The scratchpad has a high-priority item that needs external data
## Observation Guidelines
Based on your situation report, identify:
- **Patterns** across sources (email + calendar + chat converging on same topic)
- **Deadlines** approaching or overdue
- **Risks** — concentration of negative signals, unresolved blockers
- **Opportunities** — connections the user might not see
- **Activity spikes** — topics getting unusually hot
**Self vs. others**: the *Your Identifiers* section (if present) lists
the user's handles, emails, and user_ids. Never attribute someone else's
activity to the user.
@@ -0,0 +1,38 @@
//! System prompt builder for the `subconscious` built-in agent.
use crate::openhuman::context::prompt::{
render_safety, render_tools, render_user_files, render_workspace, PromptContext,
};
use anyhow::Result;
const ARCHETYPE: &str = include_str!("prompt.md");
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::with_capacity(8192);
out.push_str(ARCHETYPE.trim_end());
out.push_str("\n\n");
let user_files = render_user_files(ctx)?;
if !user_files.trim().is_empty() {
out.push_str(user_files.trim_end());
out.push_str("\n\n");
}
let tools = render_tools(ctx)?;
if !tools.trim().is_empty() {
out.push_str(tools.trim_end());
out.push_str("\n\n");
}
let safety = render_safety();
out.push_str(safety.trim_end());
out.push_str("\n\n");
let workspace = render_workspace(ctx)?;
if !workspace.trim().is_empty() {
out.push_str(workspace.trim_end());
out.push('\n');
}
Ok(out)
}
+281 -249
View File
@@ -1,13 +1,18 @@
//! Subconscious engine — periodic agent loop that produces thoughts.
//! Subconscious engine — periodic agent loop that maintains a scratchpad.
//!
//! On each tick: build situation report → run subconscious agent
//! parse thoughts from output → create thread → store reflections.
//! On each tick: load scratchpad → retrieve memory context (in code)
//! build situation report → run subconscious agent (with tool access +
//! timeout) → agent maintains scratchpad via tools → log the run.
//!
//! ## 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::prompt;
use super::reflection::{apply_cap, hydrate_draft, Reflection, ReflectionDraft};
use super::reflection_store;
use super::scratchpad;
use super::situation_report::build_situation_report;
use super::source_chunk::resolve_chunks;
use super::store;
use super::types::{SubconsciousStatus, TickResult};
use crate::openhuman::config::schema::SubconsciousMode;
@@ -17,10 +22,18 @@ use crate::openhuman::memory_store::MemoryClientRef;
use anyhow::Result;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
/// Max chunks to retrieve from memory before the LLM call.
const MEMORY_RETRIEVAL_MAX_CHUNKS: u32 = 30;
/// 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;
/// Pick the `TrustedAutomationSource` variant for a subconscious tick.
///
/// Extracted from the engine's `run_agent` body so the
@@ -51,6 +64,7 @@ pub struct SubconsciousEngine {
memory: Option<MemoryClientRef>,
state: Mutex<EngineState>,
tick_generation: AtomicU64,
tick_lock: Mutex<()>,
}
struct EngineState {
@@ -99,6 +113,7 @@ impl SubconsciousEngine {
provider_unavailable_reason: None,
}),
tick_generation: AtomicU64::new(0),
tick_lock: Mutex::new(()),
}
}
@@ -119,8 +134,8 @@ impl SubconsciousEngine {
match self.tick().await {
Ok(result) => {
info!(
"[subconscious] tick: thoughts={} thread={:?} duration={}ms",
result.thoughts_count, result.thread_id, result.duration_ms
"[subconscious] tick: duration={}ms response_chars={}",
result.duration_ms, result.response_chars
);
}
Err(e) => {
@@ -131,6 +146,41 @@ impl SubconsciousEngine {
}
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();
@@ -146,9 +196,8 @@ impl SubconsciousEngine {
state.total_ticks += 1;
return Ok(TickResult {
tick_at,
thoughts_count: 0,
thread_id: None,
duration_ms: started.elapsed().as_millis() as u64,
response_chars: 0,
});
}
};
@@ -161,9 +210,8 @@ impl SubconsciousEngine {
state.total_ticks += 1;
return Ok(TickResult {
tick_at,
thoughts_count: 0,
thread_id: None,
duration_ms: started.elapsed().as_millis() as u64,
response_chars: 0,
});
}
@@ -173,67 +221,63 @@ impl SubconsciousEngine {
drop(state);
// 1. Build situation report
let recent_reflections = store::with_connection(&self.workspace_dir, |conn| {
reflection_store::list_recent(conn, 8, None)
})
.unwrap_or_else(|e| {
warn!("[subconscious] recent reflections load failed: {e}");
Vec::new()
});
let report = build_situation_report(
&config,
&self.workspace_dir,
last_tick_at,
self.context_budget_tokens,
&recent_reflections,
)
.await;
let has_external_content = report.has_external_content;
// 2. Load identity context
let identity = prompt::load_identity_context(&self.workspace_dir);
// 2. Load scratchpad (persistent working memory)
let scratchpad_entries = scratchpad::load(&self.workspace_dir).unwrap_or_else(|e| {
warn!("[subconscious] scratchpad load failed: {e}");
Vec::new()
});
let scratchpad_section = scratchpad::render_for_prompt(&scratchpad_entries);
// 3. Run the subconscious agent
let agent_prompt = prompt::build_agent_prompt(&report.prompt_text, &identity);
// 3. Pre-LLM memory retrieval — query the memory tree using
// scratchpad entries as context so the recall is focused on
// what the subconscious is currently tracking.
let memory_section = retrieve_memory_context(&self.memory, &scratchpad_entries).await;
// 4. Load identity context
let identity = load_identity_context(&self.workspace_dir);
// 5. Build user message with dynamic context (system prompt comes from agent definition)
let agent_prompt = format!(
"{identity}\n\n\
## Situation Report (pre-loaded context)\n\n\
{situation}\n\n\
{memory}\n\n\
{scratchpad}",
situation = report.prompt_text,
memory = memory_section,
scratchpad = scratchpad_section,
);
let agent_result = self
.run_agent(&config, &agent_prompt, has_external_content)
.await;
let agent_failed = agent_result.is_err();
let drafts = agent_result.unwrap_or_default();
let response_chars = match &agent_result {
Ok(chars) => *chars,
Err(_) => 0,
};
// 4. Check if superseded
// 6. Check if superseded
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,
thoughts_count: 0,
thread_id: None,
duration_ms: started.elapsed().as_millis() as u64,
response_chars: 0,
});
}
// 5. Create thread and persist reflections
let thread_id = if !drafts.is_empty() {
let tid = self.create_tick_thread(&config, tick_at, &agent_prompt, &drafts);
Some(tid)
} else {
None
};
let reflections = persist_reflections(
&self.workspace_dir,
&config,
drafts,
tick_at,
thread_id.as_deref(),
)
.await;
let thoughts_count = reflections.len();
// 6. Update state — only advance last_tick_at and reset failures
// 7. Update state — only advance last_tick_at and reset failures
// when the agent actually ran. Errors keep consecutive_failures
// incrementing and leave last_tick_at unchanged so the next tick
// re-fetches the same window.
@@ -249,9 +293,8 @@ impl SubconsciousEngine {
Ok(TickResult {
tick_at,
thoughts_count,
thread_id,
duration_ms: started.elapsed().as_millis() as u64,
response_chars,
})
}
@@ -274,29 +317,29 @@ impl SubconsciousEngine {
}
}
/// Run the subconscious agent with mode-appropriate tool access and
/// parse thoughts from its final response. Returns `Err` on agent
/// init/run failure so the caller can track consecutive failures
/// separately from an empty-but-successful tick.
/// Run the subconscious agent with mode-appropriate tool access.
/// The agent maintains the scratchpad via tools during its turn.
/// 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<Vec<ReflectionDraft>, String> {
) -> Result<usize, String> {
use crate::openhuman::agent::Agent;
let mut effective = config.clone();
effective.agent.agent_timeout_secs = TOOL_CALL_TIMEOUT_SECS;
match self.mode {
SubconsciousMode::Simple => {
effective.autonomy.level = crate::openhuman::security::AutonomyLevel::ReadOnly;
effective.agent.max_tool_iterations = 4;
effective.agent.max_tool_iterations = 15;
}
SubconsciousMode::Aggressive => {
effective.autonomy.level = crate::openhuman::security::AutonomyLevel::Full;
effective.agent.max_tool_iterations = 8;
effective.agent.max_tool_iterations = 30;
}
SubconsciousMode::Off => return Ok(vec![]),
SubconsciousMode::Off => return Ok(0),
}
let mut agent = Agent::from_config(&effective).map_err(|e| {
@@ -309,26 +352,31 @@ impl SubconsciousEngine {
"subconscious",
);
let mode_guidance = match self.mode {
SubconsciousMode::Aggressive => {
"\n\n\
You are in AGGRESSIVE mode. You may use `spawn_subagent` to delegate \
complex tasks:\n\
- `agent_id: \"orchestrator\"` with `model: \"reasoning-v1\"` for deep \
reasoning and multi-step execution\n\
- `agent_id: \"researcher\"` for web research and external data\n\n\
Use this power when you identify actionable opportunities, approaching \
deadlines, or patterns that warrant proactive help."
}
_ => "",
};
let user_message = format!(
"{prompt_text}\n\n\
Use your tools to look up any relevant memory, recent activity, or \
context that would help you produce insightful observations. When \
you're done researching, end your final message with a JSON block \
of your thoughts:\n\n\
```json\n\
{{\"thoughts\": [...]}}\n\
```"
## Instructions\n\n\
Based on the situation report and your existing scratchpad, maintain your \
scratchpad — add new observations, edit stale entries, remove resolved items.\n\n\
Your scratchpad IS your continuity mechanism across ticks. Keep it focused \
and actionable.\
{mode_guidance}",
);
debug!("[subconscious] spawning agent with tool access");
// Subconscious ticks are trusted automation: the user enabled the
// background loop knowing it should think about their state.
// When the situation report carries content derived from third-
// party sync sources (Gmail / Slack / Notion / sealed source
// summaries), escalate the origin so the approval gate refuses
// external_effect tools for the rest of the tick — a hostile
// upstream message can otherwise nudge the LLM into a tool call
// the user would never have authorised.
let source = tick_origin_source(has_external_content);
debug!(
"[subconscious] tick origin source={:?} has_external_content={has_external_content}",
@@ -348,85 +396,12 @@ impl SubconsciousEngine {
format!("agent run: {e}")
})?;
let drafts = parse_thoughts(&response);
let response_chars = response.chars().count();
info!(
"[subconscious] agent produced {} thoughts (response {} chars)",
drafts.len(),
response.len()
"[subconscious] agent completed (response {} chars)",
response_chars
);
Ok(drafts)
}
/// Create a conversation thread for this tick so the user can view the
/// agent's reasoning by clicking on any thought.
fn create_tick_thread(
&self,
config: &Config,
tick_at: f64,
_agent_prompt: &str,
drafts: &[ReflectionDraft],
) -> String {
let thread_id = uuid::Uuid::new_v4().to_string();
let dt = chrono::DateTime::from_timestamp(tick_at as i64, 0)
.unwrap_or_else(|| chrono::Utc::now());
let thread_title = format!("Subconscious — {}", dt.format("%b %d, %H:%M"));
let now_iso = chrono::Utc::now().to_rfc3339();
if let Err(e) = crate::openhuman::memory_conversations::ensure_thread(
config.workspace_dir.clone(),
crate::openhuman::memory_conversations::CreateConversationThread {
id: thread_id.clone(),
title: thread_title,
created_at: now_iso.clone(),
parent_thread_id: None,
labels: Some(vec!["subconscious".to_string()]),
personality_id: None,
},
) {
warn!("[subconscious] failed to create tick thread: {e}");
return thread_id;
}
// Seed thread with a summary of the thoughts as the assistant message
let body = drafts
.iter()
.map(|d| {
let action = d
.proposed_action
.as_deref()
.map(|a| format!("\n\n_Proposed action_: {a}"))
.unwrap_or_default();
format!(
"**{}** — {}{}",
d.kind.as_str().replace('_', " "),
d.body,
action
)
})
.collect::<Vec<_>>()
.join("\n\n---\n\n");
let seed_message = crate::openhuman::memory_conversations::ConversationMessage {
id: uuid::Uuid::new_v4().to_string(),
content: body,
message_type: "text".to_string(),
extra_metadata: serde_json::json!({
"origin": "subconscious_tick",
"tick_at": tick_at,
"thoughts_count": drafts.len(),
}),
sender: "assistant".to_string(),
created_at: now_iso,
};
if let Err(e) = crate::openhuman::memory_conversations::append_message(
config.workspace_dir.clone(),
&thread_id,
seed_message,
) {
warn!("[subconscious] failed to seed tick thread: {e}");
}
thread_id
Ok(response_chars)
}
}
@@ -489,113 +464,104 @@ fn resolve_subconscious_route(config: &Config) -> SubconsciousProviderRoute {
}
}
// ── Thought parsing ─────────────────────────────────────────────────────────
// ── Pre-LLM memory retrieval ────────────────────────────────────────────────
/// Response envelope for the agent's JSON output.
#[derive(Debug, Clone, serde::Deserialize)]
struct ThoughtsResponse {
#[serde(default)]
thoughts: Vec<ReflectionDraft>,
// Backward compat: also accept "reflections" key
#[serde(default)]
reflections: Vec<ReflectionDraft>,
}
fn parse_thoughts(text: &str) -> Vec<ReflectionDraft> {
let json_text = extract_json(text);
// Try full envelope
if let Ok(response) = serde_json::from_str::<ThoughtsResponse>(json_text) {
let mut drafts = response.thoughts;
if drafts.is_empty() {
drafts = response.reflections;
/// Query the memory tree using scratchpad entries as context, returning
/// a rendered markdown section to inject into the user message. This
/// replaces the old `call_memory_agent` tool call — the retrieval now
/// happens in code before the LLM runs, saving a full agent turn.
async fn retrieve_memory_context(
memory: &Option<MemoryClientRef>,
scratchpad_entries: &[scratchpad::ScratchpadEntry],
) -> String {
let client = match memory {
Some(c) => c,
None => {
debug!("[subconscious] no memory client — skipping pre-LLM retrieval");
return String::new();
}
if !drafts.is_empty() {
return drafts;
}
}
// Try bare array
if let Ok(drafts) = serde_json::from_str::<Vec<ReflectionDraft>>(json_text) {
if !drafts.is_empty() {
return drafts;
}
}
warn!("[subconscious] could not parse agent output for thoughts");
vec![]
}
fn extract_json(text: &str) -> &str {
let trimmed = text.trim();
let obj_start = trimmed.find('{');
let arr_start = trimmed.find('[');
let start = match (obj_start, arr_start) {
(Some(o), Some(a)) => o.min(a),
(Some(o), None) => o,
(None, Some(a)) => a,
(None, None) => return trimmed,
};
let end = if trimmed.as_bytes().get(start) == Some(&b'[') {
trimmed.rfind(']').map(|i| i + 1)
} else {
trimmed.rfind('}').map(|i| i + 1)
};
let end = end.unwrap_or(trimmed.len());
if start < end {
&trimmed[start..end]
} else {
trimmed
// Build a query from high-priority scratchpad items (p5+) or fall back
// to a generic recent-activity query.
let query = build_memory_query(scratchpad_entries);
debug!(
"[subconscious] pre-LLM memory retrieval query_len={}",
query.len()
);
let started = std::time::Instant::now();
// Query conversation_memory namespace for relevant context
let conversation_ctx = client
.query_namespace("conversation_memory", &query, MEMORY_RETRIEVAL_MAX_CHUNKS)
.await
.unwrap_or_else(|e| {
warn!("[subconscious] conversation_memory query failed: {e}");
String::new()
});
// Also recall recent learning reflections (user patterns, preferences)
let reflections_ctx = client
.recall_namespace("learning_reflections", 10)
.await
.ok()
.flatten()
.unwrap_or_default();
let elapsed = started.elapsed();
info!(
"[subconscious] pre-LLM memory retrieval done in {:.1}s conv_chars={} refl_chars={}",
elapsed.as_secs_f64(),
conversation_ctx.len(),
reflections_ctx.len()
);
if conversation_ctx.is_empty() && reflections_ctx.is_empty() {
return String::new();
}
let mut section = String::from("## Memory Context (pre-loaded)\n\n");
if !conversation_ctx.is_empty() {
section.push_str("### Recent Conversations & Activity\n\n");
section.push_str(&conversation_ctx);
section.push_str("\n\n");
}
if !reflections_ctx.is_empty() {
section.push_str("### Learned User Patterns\n\n");
section.push_str(&reflections_ctx);
section.push_str("\n\n");
}
section
}
// ── Reflection persistence ──────────────────────────────────────────────────
/// Build a memory query from scratchpad entries. High-priority items
/// (p5+) get included verbatim; lower-priority items contribute keywords.
/// Falls back to a generic query when the scratchpad is empty.
fn build_memory_query(entries: &[scratchpad::ScratchpadEntry]) -> String {
if entries.is_empty() {
return "What has the user been working on recently? Any upcoming deadlines, \
unresolved threads, or notable activity across all sources?"
.to_string();
}
async fn persist_reflections(
workspace_dir: &std::path::Path,
config: &Config,
drafts: Vec<ReflectionDraft>,
now: f64,
thread_id: Option<&str>,
) -> Vec<Reflection> {
let (drafts, dropped) = apply_cap(drafts);
if dropped > 0 {
debug!(
"[subconscious] reflections cap dropped {} excess (kept {})",
dropped,
drafts.len()
let high_priority: Vec<&scratchpad::ScratchpadEntry> =
entries.iter().filter(|e| e.priority >= 5).collect();
if high_priority.is_empty() {
// Use all entries as a broad query
let bodies: Vec<&str> = entries.iter().map(|e| e.body.as_str()).collect();
return format!(
"Recent activity and updates related to: {}",
bodies.join("; ")
);
}
if drafts.is_empty() {
return vec![];
}
let reflections: Vec<Reflection> = drafts
.into_iter()
.map(|d| {
let chunks = resolve_chunks(config, &d.source_refs);
hydrate_draft(
d,
uuid::Uuid::new_v4().to_string(),
now,
chunks,
thread_id.map(String::from),
)
})
.collect();
if let Err(e) = store::with_connection(workspace_dir, |conn| {
for r in &reflections {
if let Err(e) = reflection_store::add_reflection(conn, r) {
warn!("[subconscious] reflection persist failed id={}: {e}", r.id);
}
}
Ok(())
}) {
warn!("[subconscious] reflection batch persist failed: {e}");
}
reflections
let bodies: Vec<&str> = high_priority.iter().map(|e| e.body.as_str()).collect();
format!(
"Updates and context for these tracked items: {}",
bodies.join("; ")
)
}
fn persist_last_tick_at(workspace_dir: &std::path::Path, tick_at: f64) {
@@ -613,6 +579,72 @@ fn now_secs() -> f64 {
.unwrap_or(0.0)
}
// ── Identity loading ───────────────────────────────────────────────────────
const IDENTITY_EXCERPT_CHARS: usize = 2000;
fn load_identity_context(workspace_dir: &std::path::Path) -> String {
let prompts_dir = resolve_prompts_dir(workspace_dir);
let mut ctx = String::new();
if let Some(ref dir) = prompts_dir {
if let Some(soul) = load_file_excerpt(dir, "SOUL.md") {
ctx.push_str(&soul);
ctx.push_str("\n\n");
}
}
if let Some(profile) = load_file_excerpt(workspace_dir, "PROFILE.md") {
ctx.push_str("## User Profile\n\n");
ctx.push_str(&profile);
ctx.push_str("\n\n");
}
if ctx.is_empty() {
"You are OpenHuman, an AI assistant for productivity and collaboration.".to_string()
} else {
ctx
}
}
fn resolve_prompts_dir(workspace_dir: &std::path::Path) -> Option<std::path::PathBuf> {
let workspace_ai = workspace_dir.join("ai");
if workspace_ai.is_dir() {
return Some(workspace_ai);
}
if let Some(dir) = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from) {
let candidate = dir
.join("src")
.join("openhuman")
.join("agent")
.join("prompts");
if candidate.is_dir() {
return Some(candidate);
}
}
if let Ok(cwd) = std::env::current_dir() {
return crate::openhuman::dev_paths::repo_ai_prompts_dir(&cwd);
}
None
}
fn load_file_excerpt(dir: &std::path::Path, filename: &str) -> Option<String> {
let content = std::fs::read_to_string(dir.join(filename)).ok()?;
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.chars().count() > IDENTITY_EXCERPT_CHARS {
let truncated: String = trimmed.chars().take(IDENTITY_EXCERPT_CHARS).collect();
Some(format!("{truncated}\n[... truncated]"))
} else {
Some(trimmed.to_string())
}
}
#[cfg(test)]
#[path = "engine_tests.rs"]
mod tests;
@@ -1,62 +1,4 @@
use super::*;
use crate::openhuman::subconscious::reflection::ReflectionKind;
#[test]
fn parse_thoughts_from_envelope() {
let json = r#"{"thoughts": [
{"kind": "hotness_spike", "body": "Phoenix surged", "source_refs": ["entity:phoenix"]},
{"kind": "risk", "body": "Deadline approaching"}
]}"#;
let drafts = parse_thoughts(json);
assert_eq!(drafts.len(), 2);
assert_eq!(drafts[0].kind, ReflectionKind::HotnessSpike);
assert_eq!(drafts[1].kind, ReflectionKind::Risk);
}
#[test]
fn parse_thoughts_from_reflections_key() {
let json = r#"{"reflections": [
{"kind": "opportunity", "body": "New connection available"}
]}"#;
let drafts = parse_thoughts(json);
assert_eq!(drafts.len(), 1);
}
#[test]
fn parse_thoughts_from_bare_array() {
let json = r#"[{"kind": "daily_digest", "body": "Summary of the day"}]"#;
let drafts = parse_thoughts(json);
assert_eq!(drafts.len(), 1);
}
#[test]
fn parse_thoughts_returns_empty_on_garbage() {
let drafts = parse_thoughts("not json at all");
assert!(drafts.is_empty());
}
#[test]
fn parse_thoughts_handles_markdown_wrapper() {
let json = "```json\n{\"thoughts\": [{\"kind\": \"risk\", \"body\": \"test\"}]}\n```";
let drafts = parse_thoughts(json);
assert_eq!(drafts.len(), 1);
}
#[test]
fn extract_json_finds_object() {
let text = "Here's the JSON: {\"a\": 1} done.";
let extracted = extract_json(text);
assert!(extracted.starts_with('{'));
assert!(extracted.ends_with('}'));
}
#[test]
fn extract_json_finds_array() {
let text = "Result: [1, 2, 3] end.";
let extracted = extract_json(text);
assert!(extracted.starts_with('['));
assert!(extracted.ends_with(']'));
}
// ── Tick origin upgrade (#approval-origin) ──────────────────────────────
@@ -108,8 +108,8 @@ impl HeartbeatEngine {
match engine.tick().await {
Ok(result) => {
info!(
"[heartbeat] tick: thoughts={} thread={:?} duration={}ms",
result.thoughts_count, result.thread_id, result.duration_ms
"[heartbeat] tick: duration={}ms response_chars={}",
result.duration_ms, result.response_chars
);
}
Err(e) => {
@@ -146,9 +146,11 @@ impl HeartbeatEngine {
return;
}
let summary =
crate::openhuman::heartbeat::planner::evaluate_and_dispatch(config, chrono::Utc::now())
.await;
let summary = crate::openhuman::subconscious::heartbeat::planner::evaluate_and_dispatch(
config,
chrono::Utc::now(),
)
.await;
tracing::debug!(
source_events = summary.source_events,
deliveries_attempted = summary.deliveries_attempted,
@@ -0,0 +1,15 @@
//! Heartbeat loop — periodic scheduler that delegates to the subconscious
//! engine for task-driven evaluation via local model inference.
//!
//! HEARTBEAT.md in the workspace defines the task checklist.
//! The subconscious engine evaluates tasks against workspace state
//! (memory, graph, skills) using the local Ollama model.
pub mod engine;
pub mod planner;
pub mod rpc;
mod schemas;
pub use schemas::{
all_controller_schemas as all_heartbeat_controller_schemas,
all_registered_controllers as all_heartbeat_registered_controllers,
};
@@ -123,7 +123,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
fn handle_settings_get(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
crate::openhuman::heartbeat::rpc::settings_get()
crate::openhuman::subconscious::heartbeat::rpc::settings_get()
.await?
.into_cli_compatible_json()
})
@@ -131,10 +131,10 @@ fn handle_settings_get(_params: Map<String, Value>) -> ControllerFuture {
fn handle_settings_set(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let patch: crate::openhuman::heartbeat::rpc::HeartbeatSettingsPatch =
let patch: crate::openhuman::subconscious::heartbeat::rpc::HeartbeatSettingsPatch =
serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid heartbeat settings_set params: {e}"))?;
crate::openhuman::heartbeat::rpc::settings_set(patch)
crate::openhuman::subconscious::heartbeat::rpc::settings_set(patch)
.await?
.into_cli_compatible_json()
})
@@ -142,7 +142,7 @@ fn handle_settings_set(params: Map<String, Value>) -> ControllerFuture {
fn handle_tick_now(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
crate::openhuman::heartbeat::rpc::tick_now()
crate::openhuman::subconscious::heartbeat::rpc::tick_now()
.await?
.into_cli_compatible_json()
})
@@ -1,90 +0,0 @@
#[cfg(test)]
mod tests {
use crate::openhuman::subconscious::reflection::{
hydrate_draft, ReflectionDraft, ReflectionKind,
};
use crate::openhuman::subconscious::reflection_store;
use crate::openhuman::subconscious::store;
#[test]
fn reflection_with_thread_id_persists() {
let dir = tempfile::tempdir().unwrap();
store::with_connection(dir.path(), |conn| {
let draft = ReflectionDraft {
kind: ReflectionKind::Opportunity,
body: "Test thought".into(),
proposed_action: Some("Do something".into()),
source_refs: vec!["entity:test".into()],
};
let reflection = hydrate_draft(
draft,
"r-1".into(),
1_700_000_000.0,
Vec::new(),
Some("thread-abc".into()),
);
reflection_store::add_reflection(conn, &reflection)?;
let got = reflection_store::get_reflection(conn, "r-1")?.unwrap();
assert_eq!(got.thread_id, Some("thread-abc".into()));
assert_eq!(got.body, "Test thought");
Ok(())
})
.unwrap();
}
#[test]
fn reflection_without_thread_id_persists() {
let dir = tempfile::tempdir().unwrap();
store::with_connection(dir.path(), |conn| {
let draft = ReflectionDraft {
kind: ReflectionKind::DailyDigest,
body: "No thread".into(),
proposed_action: None,
source_refs: vec![],
};
let reflection = hydrate_draft(draft, "r-2".into(), 1_700_000_000.0, Vec::new(), None);
reflection_store::add_reflection(conn, &reflection)?;
let got = reflection_store::get_reflection(conn, "r-2")?.unwrap();
assert!(got.thread_id.is_none());
Ok(())
})
.unwrap();
}
#[test]
fn list_recent_includes_thread_id() {
let dir = tempfile::tempdir().unwrap();
store::with_connection(dir.path(), |conn| {
for i in 0..3 {
let draft = ReflectionDraft {
kind: ReflectionKind::HotnessSpike,
body: format!("thought {i}"),
proposed_action: None,
source_refs: vec![],
};
let tid = if i == 1 {
Some("thread-xyz".into())
} else {
None
};
let reflection = hydrate_draft(
draft,
format!("r-{i}"),
1_700_000_000.0 + f64::from(i),
Vec::new(),
tid,
);
reflection_store::add_reflection(conn, &reflection)?;
}
let list = reflection_store::list_recent(conn, 10, None)?;
assert_eq!(list.len(), 3);
assert_eq!(list[1].thread_id, Some("thread-xyz".into()));
assert!(list[0].thread_id.is_none());
Ok(())
})
.unwrap();
}
}
+3 -7
View File
@@ -1,19 +1,15 @@
pub mod agent;
pub mod engine;
pub mod global;
pub mod prompt;
pub mod reflection;
pub mod reflection_store;
pub mod heartbeat;
mod schemas;
pub mod scratchpad;
pub mod situation_report;
pub mod source_chunk;
pub mod store;
pub mod types;
#[cfg(test)]
mod integration_tests;
pub use engine::SubconsciousEngine;
pub use reflection::{Reflection, ReflectionKind, MAX_REFLECTIONS_PER_TICK};
pub use schemas::{
all_controller_schemas as all_subconscious_controller_schemas,
all_registered_controllers as all_subconscious_registered_controllers,
-174
View File
@@ -1,174 +0,0 @@
//! Prompt builder for the subconscious agent.
//!
//! The subconscious agent is a periodic summarizer that reads the situation
//! report (memory-tree signals, recent activity, hotness deltas) and
//! produces structured thoughts (reflections) about the user's state.
use std::path::Path;
const IDENTITY_EXCERPT_CHARS: usize = 2000;
/// Build the system prompt for the subconscious agent tick. The agent
/// observes the user's world via the situation report and produces
/// structured reflections.
pub fn build_agent_prompt(situation_report: &str, identity_context: &str) -> String {
format!(
r#"{identity_context}
# Subconscious Agent
You are the user's background awareness layer. You wake up periodically,
review what's happening in their world, and surface useful thoughts.
## Situation Report (pre-loaded context)
{situation_report}
## Instructions
1. **Research**: Use your tools to look up relevant memory, recent activity,
conversations, or web context that would deepen your understanding.
Use `memory_recall` to query specific topics. Use `web_fetch` or search
tools if external context would help.
2. **Observe**: Based on both the situation report and your research,
identify patterns, deadlines, risks, opportunities, or interesting
cross-source connections.
3. **Promote to orchestrator**: If you find something that needs a deeper
investigation or multi-step action, use `spawn_subagent` or
`spawn_worker_thread` to delegate the work. The orchestrator can take
action; you observe and delegate.
4. **Surface thoughts**: Produce structured observations for the user.
Only surface genuinely useful insights — skip trivial observations.
**Self vs. others**: the *Your Identifiers* section (if present) lists
the user's handles, emails, and user_ids. Never attribute someone else's
activity to the user.
**Anti-double-emit**: the *Recent reflections* section shows what you
already surfaced. Re-emit only if the signal materially intensified.
Cap: at most **5 thoughts per tick**.
## Final output
After you've finished researching, end your final message with a JSON
block containing your thoughts:
```json
{{
"thoughts": [
{{
"kind": "hotness_spike | cross_source_pattern | daily_digest | due_item | risk | opportunity",
"body": "Short markdown observation.",
"proposed_action": "Optional one-tap action text (or null).",
"source_refs": ["entity:foo", "summary:bar"]
}}
]
}}
```
"#
)
}
/// Render a slice of recent reflections as a prompt block for the
/// situation report's "Recent reflections" section.
pub fn format_recent_reflections_for_prompt(
reflections: &[crate::openhuman::subconscious::reflection::Reflection],
) -> String {
crate::openhuman::subconscious::situation_report::reflections::build_section(reflections)
}
// ── Identity loading ─────────────────────────────────────────────────────────
pub fn load_identity_context(workspace_dir: &Path) -> String {
let prompts_dir = resolve_prompts_dir(workspace_dir);
let mut ctx = String::new();
if let Some(ref dir) = prompts_dir {
if let Some(soul) = load_file_excerpt(dir, "SOUL.md") {
ctx.push_str(&soul);
ctx.push_str("\n\n");
}
}
if let Some(profile) = load_file_excerpt(workspace_dir, "PROFILE.md") {
ctx.push_str("## User Profile\n\n");
ctx.push_str(&profile);
ctx.push_str("\n\n");
}
if ctx.is_empty() {
"You are OpenHuman, an AI assistant for productivity and collaboration.".to_string()
} else {
ctx
}
}
fn resolve_prompts_dir(workspace_dir: &Path) -> Option<std::path::PathBuf> {
let workspace_ai = workspace_dir.join("ai");
if workspace_ai.is_dir() {
return Some(workspace_ai);
}
if let Some(dir) = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from) {
let candidate = dir
.join("src")
.join("openhuman")
.join("agent")
.join("prompts");
if candidate.is_dir() {
return Some(candidate);
}
}
if let Ok(cwd) = std::env::current_dir() {
return crate::openhuman::dev_paths::repo_ai_prompts_dir(&cwd);
}
None
}
fn load_file_excerpt(dir: &Path, filename: &str) -> Option<String> {
let content = std::fs::read_to_string(dir.join(filename)).ok()?;
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.chars().count() > IDENTITY_EXCERPT_CHARS {
let truncated: String = trimmed.chars().take(IDENTITY_EXCERPT_CHARS).collect();
Some(format!("{truncated}\n[... truncated]"))
} else {
Some(trimmed.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_prompt_includes_report_and_identity() {
let prompt = build_agent_prompt("## State\nSome data.", "Identity here");
assert!(prompt.contains("Some data."));
assert!(prompt.contains("Identity here"));
assert!(prompt.contains("thoughts"));
}
#[test]
fn agent_prompt_includes_output_schema() {
let prompt = build_agent_prompt("", "");
assert!(prompt.contains("kind"));
assert!(prompt.contains("body"));
assert!(prompt.contains("proposed_action"));
assert!(prompt.contains("source_refs"));
}
#[test]
fn identity_context_loads_or_falls_back() {
let ctx = load_identity_context(std::path::Path::new("/nonexistent"));
assert!(ctx.contains("OpenHuman"));
}
}
-198
View File
@@ -1,198 +0,0 @@
//! Reflection primitive for the proactive subconscious layer (#623).
//!
//! Reflections are the **observation** counterpart to [`super::types::Escalation`]:
//! the LLM emits them at tick time when memory-tree signals warrant attention,
//! but unlike escalations they **never** carry an executable side effect, and
//! (unlike the original #623 design) they **never** auto-post into any
//! conversation thread. Reflections live exclusively on the Intelligence tab;
//! `proposed_action` is a free-text suggestion the user sees as a one-tap
//! button. Tapping it spawns a *new* conversation thread seeded with the
//! reflection's body + action — the existing chat thread is never bloated.
//!
//! The per-tick cap [`MAX_REFLECTIONS_PER_TICK`] guards against runaway
//! emission. Excess reflections are dropped at debug log level.
use serde::{Deserialize, Serialize};
use super::source_chunk::SourceChunk;
/// Hard cap on reflections persisted per subconscious tick. Excess are
/// dropped with a `debug!` log entry. Picked empirically: five is the
/// sweet spot between "useful proactive surface" and "notification spam".
pub const MAX_REFLECTIONS_PER_TICK: usize = 5;
/// One persisted observation about the user's state. Created by the
/// subconscious tick LLM, surfaced to the user only via the Intelligence
/// tab (no automatic conversation post).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Reflection {
/// Stable id (UUIDv4 string).
pub id: String,
/// What kind of signal triggered the reflection. See [`ReflectionKind`].
pub kind: ReflectionKind,
/// Human-readable observation body. Markdown-friendly.
pub body: String,
/// Optional one-tap action text. When present, the frontend renders an
/// action button that drives `reflections_act`, which spawns a fresh
/// conversation thread seeded with body + action.
pub proposed_action: Option<String>,
/// References to underlying signals (entity ids, summary ids, etc).
/// Free-form opaque strings — used for provenance, not parsed.
pub source_refs: Vec<String>,
/// Resolved snapshot of the chunks the LLM cited via `source_refs`,
/// captured at tick time. Powers (a) the Intelligence-tab "Sources"
/// disclosure for transparency and (b) the orchestrator's memory-
/// context injection into the system prompt for any chat turn in a
/// thread spawned from this reflection. Snapshot semantics — chunks
/// freeze at tick time even if the underlying entity/summary mutates
/// later. See `super::source_chunk` for the resolver.
#[serde(default)]
pub source_chunks: Vec<SourceChunk>,
/// Epoch seconds when persisted.
pub created_at: f64,
/// Epoch seconds when the user tapped the proposed action.
pub acted_on_at: Option<f64>,
/// Epoch seconds when the user dismissed the card.
pub dismissed_at: Option<f64>,
/// Thread ID of the agent conversation that produced this reflection.
/// Clicking the thought in the UI navigates to this thread.
#[serde(default)]
pub thread_id: Option<String>,
}
/// Categorisation of the underlying signal. Start narrow; we can grow
/// the enum if a clear new bucket emerges from real data.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReflectionKind {
/// Hotness score moved sharply for an entity since last tick.
HotnessSpike,
/// Multiple sources are converging on the same entity / topic.
CrossSourcePattern,
/// New global L0 daily digest worth highlighting.
DailyDigest,
/// A sealed summary references an item with a near-term deadline.
DueItem,
/// Pattern looks risky — concentration of negative signals, etc.
Risk,
/// Pattern looks like an opportunity worth user attention.
Opportunity,
}
impl ReflectionKind {
/// Stable lowercase string used for SQL persistence + UI chips.
pub fn as_str(self) -> &'static str {
match self {
Self::HotnessSpike => "hotness_spike",
Self::CrossSourcePattern => "cross_source_pattern",
Self::DailyDigest => "daily_digest",
Self::DueItem => "due_item",
Self::Risk => "risk",
Self::Opportunity => "opportunity",
}
}
/// Inverse of [`Self::as_str`]. Falls back to [`Self::DailyDigest`]
/// on unknown values — the most generic bucket.
pub fn from_str_lossy(s: &str) -> Self {
match s {
"hotness_spike" => Self::HotnessSpike,
"cross_source_pattern" => Self::CrossSourcePattern,
"due_item" => Self::DueItem,
"risk" => Self::Risk,
"opportunity" => Self::Opportunity,
_ => Self::DailyDigest,
}
}
}
/// Compact wire shape that the LLM emits per reflection. Differs from
/// [`Reflection`] in that the LLM does not yet know its persisted `id`,
/// `created_at`, or any of the lifecycle timestamps. We hydrate those
/// on the Rust side before persistence.
///
/// Note: prior versions of this struct had a `disposition` field controlling
/// whether to post into a conversation thread. That auto-post path is gone —
/// reflections are now observation-only. If the LLM emits a `disposition`
/// field anyway (forward/backward compat), serde silently ignores it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReflectionDraft {
pub kind: ReflectionKind,
pub body: String,
#[serde(default)]
pub proposed_action: Option<String>,
#[serde(default)]
pub source_refs: Vec<String>,
}
/// Hydrate one [`ReflectionDraft`] into a persistable [`Reflection`].
/// Pure: callers pass `id`, `now`, and the resolved `source_chunks`
/// explicitly so tests are deterministic and the resolver can be mocked.
/// Production callers: see `engine::persist_and_surface_reflections`,
/// which calls `source_chunk::resolve_chunks` against the live config
/// before invoking this.
pub fn hydrate_draft(
draft: ReflectionDraft,
id: String,
now: f64,
source_chunks: Vec<SourceChunk>,
thread_id: Option<String>,
) -> Reflection {
Reflection {
id,
kind: draft.kind,
body: draft.body,
proposed_action: draft.proposed_action,
source_refs: draft.source_refs,
source_chunks,
created_at: now,
acted_on_at: None,
dismissed_at: None,
thread_id,
}
}
/// Build a stable dedup key from the reflection's signal-identifying
/// fields. Two reflections with the same key and similar body should
/// not both persist within a tick — the second is the LLM repeating
/// itself rather than catching a meaningfully new signal.
///
/// The key is `kind + sorted source_refs + leading 80 chars of body`.
/// Body is included because `kind`+`source_refs` alone misses cases
/// where the same source is interpreted two different ways.
pub fn dedup_key(kind: ReflectionKind, source_refs: &[String], body: &str) -> String {
// Canonicalize the refs: trim, drop empties, dedupe, sort. The LLM
// sometimes echoes the same id twice in `source_refs` or sandwiches
// whitespace; without this normalization those near-identical
// reflections produce different keys and slip through the gate.
let mut refs: Vec<String> = source_refs
.iter()
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.collect();
refs.sort();
refs.dedup();
// Canonicalize the body: collapse runs of whitespace into single
// spaces and trim. Same rationale — a reflection with an extra
// newline or double space at the start would otherwise key
// differently from the original.
let canonical_body: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
let body_prefix: String = canonical_body.chars().take(80).collect();
format!("{}|{}|{}", kind.as_str(), refs.join(","), body_prefix)
}
/// Apply the per-tick cap to a list of drafts, dropping the tail. Returns
/// the kept slice along with the count dropped (so the caller can log
/// it at debug level).
pub fn apply_cap(drafts: Vec<ReflectionDraft>) -> (Vec<ReflectionDraft>, usize) {
if drafts.len() <= MAX_REFLECTIONS_PER_TICK {
return (drafts, 0);
}
let dropped = drafts.len() - MAX_REFLECTIONS_PER_TICK;
let kept = drafts.into_iter().take(MAX_REFLECTIONS_PER_TICK).collect();
(kept, dropped)
}
#[cfg(test)]
#[path = "reflection_tests.rs"]
mod tests;
@@ -1,286 +0,0 @@
//! SQLite persistence for the proactive subconscious reflection layer (#623).
//!
//! Two tables:
//! - `subconscious_reflections` — durable record of every reflection the
//! tick LLM emits. Indexed by `(created_at DESC)` so the Intelligence tab
//! and the prompt's "Recent reflections" section can both fetch in one go.
//! - `subconscious_hotness_snapshots` — per-entity copy of the previous
//! tick's hotness score, used by the situation report's
//! `hotness_deltas` section to compute meaningful movement.
//!
//! DDL is appended to `super::store::SCHEMA_DDL` so the schema migration
//! and `with_connection` lifecycle stay unified — no parallel DB handle.
//! See [`super::store::with_connection`] for the sole entry point.
//!
//! Migration note: prior versions of this schema carried `disposition` and
//! `surfaced_at` columns to support the now-removed auto-post-into-thread
//! flow. [`migrate_drop_legacy_columns`] handles existing DBs by dropping
//! those columns + their index; the DDL below describes the post-migration
//! shape so fresh installs come up clean.
use anyhow::{Context, Result};
use rusqlite::{params, Connection, OptionalExtension};
use super::reflection::{Reflection, ReflectionKind};
use super::source_chunk::SourceChunk;
/// DDL appended to the subconscious schema. Imported by `super::store`'s
/// `SCHEMA_DDL` constant so every connection runs the migration.
pub const REFLECTION_SCHEMA_DDL: &str = "
CREATE TABLE IF NOT EXISTS subconscious_reflections (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
body TEXT NOT NULL,
proposed_action TEXT,
source_refs TEXT NOT NULL DEFAULT '[]',
source_chunks TEXT NOT NULL DEFAULT '[]',
created_at REAL NOT NULL,
acted_on_at REAL,
dismissed_at REAL,
thread_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_reflections_created
ON subconscious_reflections(created_at DESC);
CREATE TABLE IF NOT EXISTS subconscious_hotness_snapshots (
entity_id TEXT PRIMARY KEY,
score REAL NOT NULL,
captured_at REAL NOT NULL
);
";
/// Best-effort migration: drop the legacy `disposition` / `surfaced_at`
/// columns and their index from `subconscious_reflections` if they still
/// exist on disk. Idempotent — repeated calls and clean installs are no-ops.
///
/// Each statement is run with errors swallowed because:
/// - On a fresh install the columns/index were never created → DROP errors.
/// - On a previously-migrated install the columns/index are already gone.
/// - SQLite ≥ 3.35 supports `ALTER TABLE ... DROP COLUMN`; older builds
/// would fail this whole block, but we ship sqlite≥3.35 via rusqlite's
/// bundled feature so this is fine in practice.
pub fn migrate_drop_legacy_columns(conn: &Connection) {
let _ = conn.execute(
"DROP INDEX IF EXISTS idx_reflections_disposition_created",
[],
);
let _ = conn.execute(
"ALTER TABLE subconscious_reflections DROP COLUMN disposition",
[],
);
let _ = conn.execute(
"ALTER TABLE subconscious_reflections DROP COLUMN surfaced_at",
[],
);
}
/// Idempotent additive migration: add the `source_chunks` JSON column to
/// previously-migrated DBs that pre-date the #623-followup memory-context
/// snapshot work. Errors swallowed because:
/// - Fresh installs already have the column from the CREATE TABLE above.
/// - Already-migrated installs have it too, so ADD COLUMN errors with
/// "duplicate column" — a no-op for our purposes.
pub fn migrate_add_source_chunks_column(conn: &Connection) {
let _ = conn.execute(
"ALTER TABLE subconscious_reflections ADD COLUMN source_chunks TEXT NOT NULL DEFAULT '[]'",
[],
);
}
pub fn migrate_add_thread_id_column(conn: &Connection) {
let _ = conn.execute(
"ALTER TABLE subconscious_reflections ADD COLUMN thread_id TEXT",
[],
);
}
// ── Reflection CRUD ──────────────────────────────────────────────────────────
/// Persist a fresh reflection. Idempotent on `id`: if a row with the same
/// id already exists the existing row is preserved (caller should be
/// generating UUIDs, so this is purely a safety net for double-writes).
pub fn add_reflection(conn: &Connection, reflection: &Reflection) -> Result<()> {
let source_refs_json = serde_json::to_string(&reflection.source_refs)
.context("serialize source_refs")
.unwrap_or_else(|_| "[]".to_string());
let source_chunks_json = serde_json::to_string(&reflection.source_chunks)
.context("serialize source_chunks")
.unwrap_or_else(|_| "[]".to_string());
conn.execute(
"INSERT OR IGNORE INTO subconscious_reflections (
id, kind, body, proposed_action, source_refs, source_chunks,
created_at, acted_on_at, dismissed_at, thread_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
reflection.id,
reflection.kind.as_str(),
reflection.body,
reflection.proposed_action,
source_refs_json,
source_chunks_json,
reflection.created_at,
reflection.acted_on_at,
reflection.dismissed_at,
reflection.thread_id,
],
)
.context("insert reflection")?;
log::debug!(
"[subconscious::reflection_store] added id={} kind={} chunks={} thread={:?}",
reflection.id,
reflection.kind.as_str(),
reflection.source_chunks.len(),
reflection.thread_id,
);
Ok(())
}
/// List reflections in reverse-chronological order, with optional cursor
/// `since_ts` (epoch seconds; rows with `created_at > since_ts`).
pub fn list_recent(
conn: &Connection,
limit: usize,
since_ts: Option<f64>,
) -> Result<Vec<Reflection>> {
let limit = limit.max(1) as i64;
let mut rows = Vec::new();
let mut stmt;
let mapped: Vec<Reflection> = if let Some(ts) = since_ts {
stmt = conn.prepare(
"SELECT id, kind, body, proposed_action, source_refs, source_chunks,
created_at, acted_on_at, dismissed_at, thread_id
FROM subconscious_reflections
WHERE created_at > ?1
ORDER BY created_at DESC LIMIT ?2",
)?;
let it = stmt.query_map(params![ts, limit], row_to_reflection)?;
for r in it {
rows.push(r?);
}
rows
} else {
stmt = conn.prepare(
"SELECT id, kind, body, proposed_action, source_refs, source_chunks,
created_at, acted_on_at, dismissed_at, thread_id
FROM subconscious_reflections
ORDER BY created_at DESC LIMIT ?1",
)?;
let it = stmt.query_map(params![limit], row_to_reflection)?;
for r in it {
rows.push(r?);
}
rows
};
Ok(mapped)
}
/// Fetch one reflection by id.
pub fn get_reflection(conn: &Connection, id: &str) -> Result<Option<Reflection>> {
let mut stmt = conn.prepare(
"SELECT id, kind, body, proposed_action, source_refs, source_chunks,
created_at, acted_on_at, dismissed_at, thread_id
FROM subconscious_reflections WHERE id = ?1",
)?;
let r = stmt
.query_row(params![id], row_to_reflection)
.optional()
.context("get reflection")?;
Ok(r)
}
/// Stamp `acted_on_at` when the user taps the proposed action.
pub fn mark_acted(conn: &Connection, id: &str, ts: f64) -> Result<()> {
conn.execute(
"UPDATE subconscious_reflections SET acted_on_at = ?1 WHERE id = ?2",
params![ts, id],
)?;
Ok(())
}
/// Stamp `dismissed_at` when the user dismisses the card.
pub fn mark_dismissed(conn: &Connection, id: &str, ts: f64) -> Result<()> {
conn.execute(
"UPDATE subconscious_reflections SET dismissed_at = ?1 WHERE id = ?2",
params![ts, id],
)?;
Ok(())
}
fn row_to_reflection(row: &rusqlite::Row) -> rusqlite::Result<Reflection> {
let id: String = row.get(0)?;
let kind_s: String = row.get(1)?;
let body: String = row.get(2)?;
let proposed_action: Option<String> = row.get(3)?;
let source_refs_json: String = row.get(4)?;
let source_chunks_json: String = row.get(5)?;
let created_at: f64 = row.get(6)?;
let acted_on_at: Option<f64> = row.get(7)?;
let dismissed_at: Option<f64> = row.get(8)?;
let thread_id: Option<String> = row.get(9)?;
let source_refs: Vec<String> =
serde_json::from_str(&source_refs_json).unwrap_or_else(|_| Vec::new());
let source_chunks: Vec<SourceChunk> =
serde_json::from_str(&source_chunks_json).unwrap_or_else(|_| Vec::new());
Ok(Reflection {
id,
kind: ReflectionKind::from_str_lossy(&kind_s),
body,
proposed_action,
source_refs,
source_chunks,
created_at,
acted_on_at,
dismissed_at,
thread_id,
})
}
// ── Hotness snapshot CRUD ────────────────────────────────────────────────────
/// Read all stored snapshots — keyed by `entity_id`. Returns `(entity_id,
/// score)` pairs. Order is unspecified.
pub fn load_hotness_snapshots(conn: &Connection) -> Result<Vec<(String, f64)>> {
let mut stmt = conn.prepare("SELECT entity_id, score FROM subconscious_hotness_snapshots")?;
let it = stmt.query_map([], |row| {
let id: String = row.get(0)?;
let score: f64 = row.get(1)?;
Ok((id, score))
})?;
let mut out = Vec::new();
for r in it {
out.push(r?);
}
Ok(out)
}
/// Replace the snapshot table with a fresh capture of current hotness.
/// Atomic — wrapped in a transaction so partial state never leaks.
pub fn replace_hotness_snapshots(
conn: &mut Connection,
snapshots: &[(String, f64)],
captured_at: f64,
) -> Result<()> {
let tx = conn.transaction()?;
tx.execute("DELETE FROM subconscious_hotness_snapshots", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO subconscious_hotness_snapshots (entity_id, score, captured_at)
VALUES (?1, ?2, ?3)",
)?;
for (id, score) in snapshots {
stmt.execute(params![id, score, captured_at])?;
}
}
tx.commit()?;
log::debug!(
"[subconscious::reflection_store] replaced hotness snapshots count={}",
snapshots.len()
);
Ok(())
}
#[cfg(test)]
#[path = "reflection_store_tests.rs"]
mod tests;
@@ -1,108 +0,0 @@
//! Lifecycle tests for `subconscious_reflections` + `subconscious_hotness_snapshots`.
//!
//! Builds an in-memory SQLite, runs the full subconscious DDL (so we
//! exercise the migration appended in `super::store::SCHEMA_DDL`), and
//! validates CRUD + idempotency + ordering.
use super::*;
use crate::openhuman::subconscious::reflection::{hydrate_draft, ReflectionDraft, ReflectionKind};
use rusqlite::Connection;
fn fresh_conn() -> Connection {
let conn = Connection::open_in_memory().expect("open in-mem");
// Run the same DDL that `with_connection` runs in production, so the
// migration path is exercised.
conn.execute_batch(crate::openhuman::subconscious::store::SCHEMA_DDL_FOR_TESTS)
.expect("apply DDL");
conn
}
fn sample_reflection(id: &str, created_at: f64) -> Reflection {
let draft = ReflectionDraft {
kind: ReflectionKind::HotnessSpike,
body: format!("body for {id}"),
proposed_action: Some("Take a look".into()),
source_refs: vec!["entity:foo".into()],
};
hydrate_draft(draft, id.into(), created_at, Vec::new(), None)
}
#[test]
fn add_and_get_round_trip() {
let conn = fresh_conn();
let r = sample_reflection("r1", 1.0);
add_reflection(&conn, &r).expect("add");
let got = get_reflection(&conn, "r1").expect("get").expect("present");
assert_eq!(got, r);
}
#[test]
fn add_is_idempotent_on_id() {
let conn = fresh_conn();
let r = sample_reflection("dup", 5.0);
add_reflection(&conn, &r).unwrap();
let mut bumped = r.clone();
bumped.body = "DIFFERENT — should not overwrite".into();
add_reflection(&conn, &bumped).unwrap();
let got = get_reflection(&conn, "dup").unwrap().unwrap();
assert_eq!(got.body, "body for dup");
}
#[test]
fn list_recent_orders_newest_first() {
let conn = fresh_conn();
add_reflection(&conn, &sample_reflection("a", 1.0)).unwrap();
add_reflection(&conn, &sample_reflection("b", 5.0)).unwrap();
add_reflection(&conn, &sample_reflection("c", 3.0)).unwrap();
let got = list_recent(&conn, 10, None).unwrap();
let ids: Vec<&str> = got.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["b", "c", "a"]);
}
#[test]
fn list_recent_respects_since_ts() {
let conn = fresh_conn();
add_reflection(&conn, &sample_reflection("a", 1.0)).unwrap();
add_reflection(&conn, &sample_reflection("b", 5.0)).unwrap();
let got = list_recent(&conn, 10, Some(2.0)).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].id, "b");
}
#[test]
fn mark_acted_and_dismissed_set_timestamps() {
let conn = fresh_conn();
add_reflection(&conn, &sample_reflection("act", 1.0)).unwrap();
add_reflection(&conn, &sample_reflection("dis", 1.0)).unwrap();
mark_acted(&conn, "act", 50.0).unwrap();
mark_dismissed(&conn, "dis", 60.0).unwrap();
assert_eq!(
get_reflection(&conn, "act").unwrap().unwrap().acted_on_at,
Some(50.0)
);
assert_eq!(
get_reflection(&conn, "dis").unwrap().unwrap().dismissed_at,
Some(60.0)
);
}
#[test]
fn hotness_snapshot_replace_clears_then_writes() {
let mut conn = fresh_conn();
replace_hotness_snapshots(&mut conn, &[("e1".into(), 0.5), ("e2".into(), 1.5)], 100.0).unwrap();
let v1 = load_hotness_snapshots(&conn).unwrap();
assert_eq!(v1.len(), 2);
replace_hotness_snapshots(&mut conn, &[("e1".into(), 0.9)], 200.0).unwrap();
let v2 = load_hotness_snapshots(&conn).unwrap();
assert_eq!(v2.len(), 1);
assert_eq!(v2[0], ("e1".to_string(), 0.9));
}
#[test]
fn hotness_snapshot_replace_with_empty_clears_table() {
let mut conn = fresh_conn();
replace_hotness_snapshots(&mut conn, &[("e1".into(), 0.1)], 1.0).unwrap();
replace_hotness_snapshots(&mut conn, &[], 2.0).unwrap();
assert!(load_hotness_snapshots(&conn).unwrap().is_empty());
}
@@ -1,124 +0,0 @@
//! Unit tests for `reflection.rs` — wire shape, hydration, dedup, cap.
use super::*;
#[test]
fn reflection_kind_round_trip() {
for k in [
ReflectionKind::HotnessSpike,
ReflectionKind::CrossSourcePattern,
ReflectionKind::DailyDigest,
ReflectionKind::DueItem,
ReflectionKind::Risk,
ReflectionKind::Opportunity,
] {
assert_eq!(ReflectionKind::from_str_lossy(k.as_str()), k);
}
// Unknown -> DailyDigest (most generic).
assert_eq!(
ReflectionKind::from_str_lossy("nope"),
ReflectionKind::DailyDigest
);
}
#[test]
fn parses_reflection_draft_from_llm_json() {
// The legacy `disposition` field is silently ignored by serde — kept
// in the fixture to verify forward/backward compat with LLM responses
// emitted before the field was dropped from the prompt contract.
let json = r#"{
"kind": "hotness_spike",
"body": "Phoenix has been mentioned 4x in the last hour.",
"disposition": "notify",
"proposed_action": "Pull recent Phoenix mentions",
"source_refs": ["entity:phoenix", "summary:abc"]
}"#;
let d: ReflectionDraft = serde_json::from_str(json).expect("parse");
assert_eq!(d.kind, ReflectionKind::HotnessSpike);
assert_eq!(
d.proposed_action.as_deref(),
Some("Pull recent Phoenix mentions")
);
assert_eq!(d.source_refs.len(), 2);
}
#[test]
fn parses_minimal_reflection_draft_without_optional_fields() {
let json = r#"{
"kind": "daily_digest",
"body": "New daily digest sealed."
}"#;
let d: ReflectionDraft = serde_json::from_str(json).expect("parse");
assert!(d.proposed_action.is_none());
assert!(d.source_refs.is_empty());
}
#[test]
fn hydrate_draft_fills_lifecycle_fields() {
let draft = ReflectionDraft {
kind: ReflectionKind::Opportunity,
body: "User mentioned founders dinner".into(),
proposed_action: Some("Draft an invite list".into()),
source_refs: vec!["entity:dinner".into()],
};
let r = hydrate_draft(draft, "abc-123".into(), 1_700_000_000.0, Vec::new(), None);
assert_eq!(r.id, "abc-123");
assert_eq!(r.created_at, 1_700_000_000.0);
assert!(r.acted_on_at.is_none());
assert!(r.dismissed_at.is_none());
}
#[test]
fn dedup_key_is_stable_across_source_ref_order() {
let body = "Same observation";
let k1 = dedup_key(
ReflectionKind::Risk,
&["a".into(), "b".into(), "c".into()],
body,
);
let k2 = dedup_key(
ReflectionKind::Risk,
&["c".into(), "a".into(), "b".into()],
body,
);
assert_eq!(k1, k2);
}
#[test]
fn dedup_key_changes_when_kind_changes() {
let refs = vec!["a".to_string()];
let r1 = dedup_key(ReflectionKind::Risk, &refs, "body");
let r2 = dedup_key(ReflectionKind::Opportunity, &refs, "body");
assert_ne!(r1, r2);
}
#[test]
fn apply_cap_keeps_within_limit() {
let drafts: Vec<ReflectionDraft> = (0..3)
.map(|i| ReflectionDraft {
kind: ReflectionKind::DailyDigest,
body: format!("body {i}"),
proposed_action: None,
source_refs: vec![],
})
.collect();
let (kept, dropped) = apply_cap(drafts);
assert_eq!(kept.len(), 3);
assert_eq!(dropped, 0);
}
#[test]
fn apply_cap_trims_excess() {
let drafts: Vec<ReflectionDraft> = (0..10)
.map(|i| ReflectionDraft {
kind: ReflectionKind::DailyDigest,
body: format!("body {i}"),
proposed_action: None,
source_refs: vec![],
})
.collect();
let (kept, dropped) = apply_cap(drafts);
assert_eq!(kept.len(), MAX_REFLECTIONS_PER_TICK);
assert_eq!(dropped, 10 - MAX_REFLECTIONS_PER_TICK);
assert_eq!(kept[0].body, "body 0"); // FIFO order preserved
}
+4 -230
View File
@@ -3,20 +3,13 @@
use serde_json::{Map, Value};
use super::global::get_or_init_engine;
use super::reflection_store;
use super::store;
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::rpc::RpcOutcome;
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("status"),
schemas("trigger"),
schemas("reflections_list"),
schemas("reflections_act"),
schemas("reflections_dismiss"),
]
vec![schemas("status"), schemas("trigger")]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
@@ -29,18 +22,6 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("trigger"),
handler: handle_trigger,
},
RegisteredController {
schema: schemas("reflections_list"),
handler: handle_reflections_list,
},
RegisteredController {
schema: schemas("reflections_act"),
handler: handle_reflections_act,
},
RegisteredController {
schema: schemas("reflections_dismiss"),
handler: handle_reflections_dismiss,
},
]
}
@@ -60,48 +41,6 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![],
outputs: vec![field("result", TypeSchema::Json, "Tick result.")],
},
"reflections_list" => ControllerSchema {
namespace: "subconscious",
function: "reflections_list",
description: "List recent subconscious thoughts. Newest first.",
inputs: vec![
field_opt("limit", TypeSchema::U64, "Max entries (default 50)."),
field_opt(
"since_ts",
TypeSchema::F64,
"Epoch seconds — only return thoughts newer than this.",
),
],
outputs: vec![field("reflections", TypeSchema::Json, "Thought records.")],
},
"reflections_act" => ControllerSchema {
namespace: "subconscious",
function: "reflections_act",
description: "Act on a thought — creates a fresh conversation thread \
and seeds it with the thought body as the first ASSISTANT \
message. Returns the new thread id.",
inputs: vec![field_req(
"reflection_id",
TypeSchema::String,
"Thought ID.",
)],
outputs: vec![field(
"result",
TypeSchema::Json,
"{reflection_id, thread_id}.",
)],
},
"reflections_dismiss" => ControllerSchema {
namespace: "subconscious",
function: "reflections_dismiss",
description: "Dismiss a thought card. Sets `dismissed_at`.",
inputs: vec![field_req(
"reflection_id",
TypeSchema::String,
"Thought ID.",
)],
outputs: vec![field("result", TypeSchema::Json, "Dismissal confirmation.")],
},
_other => ControllerSchema {
namespace: "subconscious",
function: "unknown",
@@ -116,10 +55,6 @@ pub fn schemas(function: &str) -> ControllerSchema {
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
// Prefer live engine status (includes in-memory tick counters).
// Fall back to a config-derived snapshot when the engine is not yet
// initialised — the counters will read 0, which is accurate at that
// point because no ticks have run yet.
let engine_arc = get_or_init_engine().await.ok();
if let Some(arc) = engine_arc {
let guard = arc.lock().await;
@@ -129,7 +64,6 @@ fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
}
}
// Engine not yet initialised — build a snapshot from config.
let config = load_config().await?;
let hb = &config.heartbeat;
@@ -143,8 +77,6 @@ fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
None
};
let mode = hb.effective_subconscious_mode();
// total_ticks and consecutive_failures are 0 here because the engine
// has not started; the engine Mutex cannot be held during RPC.
let status = super::types::SubconsciousStatus {
enabled: mode.is_enabled(),
mode: mode.as_str().to_string(),
@@ -171,10 +103,9 @@ fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
match engine.tick().await {
Ok(result) => {
tracing::info!(
"[subconscious] manual tick: thoughts={} thread={:?} duration={}ms",
result.thoughts_count,
result.thread_id,
result.duration_ms
"[subconscious] manual tick: duration={}ms response_chars={}",
result.duration_ms,
result.response_chars,
);
}
Err(e) => {
@@ -191,145 +122,6 @@ fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_reflections_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let since_ts = params.get("since_ts").and_then(|v| v.as_f64());
let config = load_config().await?;
let reflections = store::with_connection(&config.workspace_dir, |conn| {
reflection_store::list_recent(conn, limit, since_ts)
})
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(reflections, "reflections listed"))
})
}
fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let reflection_id = params
.get("reflection_id")
.and_then(|v| v.as_str())
.ok_or("reflection_id is required")?
.to_string();
let config = load_config().await?;
let reflection = store::with_connection(&config.workspace_dir, |conn| {
reflection_store::get_reflection(conn, &reflection_id)
})
.map_err(|e| format!("{e:#}"))?
.ok_or_else(|| format!("reflection not found: {reflection_id}"))?;
let thread_id = uuid::Uuid::new_v4().to_string();
let thread_title: String = {
let mut s: String = reflection
.body
.chars()
.filter(|c| !c.is_control())
.take(60)
.collect();
if reflection.body.chars().count() > 60 {
s.push('…');
}
if s.trim().is_empty() {
format!(
"Reflection: {kind}",
kind = reflection.kind.as_str().replace('_', " ")
)
} else {
s
}
};
let now_iso = chrono::Utc::now().to_rfc3339();
crate::openhuman::memory_conversations::ensure_thread(
config.workspace_dir.clone(),
crate::openhuman::memory_conversations::CreateConversationThread {
id: thread_id.clone(),
title: thread_title,
created_at: now_iso.clone(),
parent_thread_id: None,
labels: Some(vec!["subconscious".to_string()]),
personality_id: None,
},
)
.map_err(|e| format!("ensure_thread (reflection-spawned) failed: {e}"))?;
let body_md = match reflection.proposed_action.as_deref() {
Some(action) if !action.trim().is_empty() => format!(
"{body}\n\n_Proposed action_: {action}",
body = reflection.body.trim(),
action = action.trim()
),
_ => reflection.body.trim().to_string(),
};
let extra_metadata = serde_json::json!({
"reflection_id": reflection.id,
"kind": reflection.kind.as_str(),
"proposed_action": reflection.proposed_action,
"source_refs": reflection.source_refs,
"origin": "subconscious_reflection",
});
let seed_message = crate::openhuman::memory_conversations::ConversationMessage {
id: uuid::Uuid::new_v4().to_string(),
content: body_md,
message_type: "text".to_string(),
extra_metadata,
sender: "assistant".to_string(),
created_at: now_iso,
};
crate::openhuman::memory_conversations::append_message(
config.workspace_dir.clone(),
&thread_id,
seed_message,
)
.map_err(|e| format!("append seed reflection message failed: {e}"))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
if let Err(e) = store::with_connection(&config.workspace_dir, |conn| {
reflection_store::mark_acted(conn, &reflection_id, now)
}) {
log::warn!(
"[subconscious] failed to stamp acted_on_at reflection={} thread={}: {e}",
reflection_id,
thread_id
);
}
to_json(RpcOutcome::single_log(
serde_json::json!({
"reflection_id": reflection_id,
"thread_id": thread_id,
}),
"reflection acted",
))
})
}
fn handle_reflections_dismiss(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let reflection_id = params
.get("reflection_id")
.and_then(|v| v.as_str())
.ok_or("reflection_id is required")?
.to_string();
let config = load_config().await?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
store::with_connection(&config.workspace_dir, |conn| {
reflection_store::mark_dismissed(conn, &reflection_id, now)
})
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(
serde_json::json!({"dismissed": reflection_id}),
"reflection dismissed",
))
})
}
// ── Helpers ──────────────────────────────────────────────────────────────────
async fn load_config() -> Result<crate::openhuman::config::Config, String> {
@@ -345,24 +137,6 @@ fn field(name: &'static str, ty: TypeSchema, comment: &'static str) -> FieldSche
}
}
fn field_req(name: &'static str, ty: TypeSchema, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty,
comment,
required: true,
}
}
fn field_opt(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()
}
+8 -19
View File
@@ -1,24 +1,13 @@
use super::*;
#[test]
fn all_schemas_returns_five() {
assert_eq!(all_controller_schemas().len(), 5);
fn all_schemas_returns_two() {
assert_eq!(all_controller_schemas().len(), 2);
}
#[test]
fn all_controllers_returns_five() {
assert_eq!(all_registered_controllers().len(), 5);
}
#[test]
fn reflection_rpcs_are_registered() {
let names: Vec<&str> = all_controller_schemas()
.iter()
.map(|s| s.function)
.collect();
assert!(names.contains(&"reflections_list"));
assert!(names.contains(&"reflections_act"));
assert!(names.contains(&"reflections_dismiss"));
fn all_controllers_returns_two() {
assert_eq!(all_registered_controllers().len(), 2);
}
#[test]
@@ -32,12 +21,12 @@ fn status_and_trigger_are_registered() {
}
#[test]
fn task_endpoints_are_removed() {
fn reflection_rpcs_are_removed() {
let names: Vec<&str> = all_controller_schemas()
.iter()
.map(|s| s.function)
.collect();
assert!(!names.contains(&"tasks_list"));
assert!(!names.contains(&"tasks_add"));
assert!(!names.contains(&"escalations_list"));
assert!(!names.contains(&"reflections_list"));
assert!(!names.contains(&"reflections_act"));
assert!(!names.contains(&"reflections_dismiss"));
}
@@ -0,0 +1,422 @@
//! Subconscious scratchpad — persistent working memory across ticks.
//!
//! The scratchpad holds up to `MAX_ENTRIES` (default 100) short thoughts
//! that carry over between subconscious ticks, giving the agent a
//! consistent stream-of-consciousness.
//!
//! Stored as `{workspace_dir}/subconscious/SUBCONSCIOUS_SCRATCHPAD.md`
//! so it's trivially inspectable with any text editor.
//!
//! ## File format
//!
//! # Subconscious Scratchpad
//!
//! ```json
//! [
//! {
//! "id": "abc123",
//! "body": "The thought body goes here.",
//! "priority": 5,
//! "created_at": 1700000000.123456,
//! "updated_at": 1700000000.123456
//! }
//! ]
//! ```
//!
//! The agent manages its own scratchpad via three tools:
//! `scratchpad_add`, `scratchpad_edit`, `scratchpad_remove`.
pub mod tools;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const DEFAULT_MAX_ENTRIES: usize = 100;
const FILENAME: &str = "SUBCONSCIOUS_SCRATCHPAD.md";
/// One scratchpad entry.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ScratchpadEntry {
pub id: String,
pub body: String,
pub priority: u32,
pub created_at: f64,
pub updated_at: f64,
}
fn scratchpad_path(workspace_dir: &Path) -> PathBuf {
workspace_dir.join("subconscious").join(FILENAME)
}
pub fn load(workspace_dir: &Path) -> Result<Vec<ScratchpadEntry>> {
let path = scratchpad_path(workspace_dir);
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(e).context("read scratchpad"),
};
Ok(parse_entries(&content))
}
pub fn add(workspace_dir: &Path, body: &str, priority: u32, max_entries: usize) -> Result<String> {
let mut entries = load(workspace_dir)?;
let id = short_id();
let now = now_secs();
entries.push(ScratchpadEntry {
id: id.clone(),
body: body.to_string(),
priority,
created_at: now,
updated_at: now,
});
evict(&mut entries, max_entries);
save(workspace_dir, &entries)?;
Ok(id)
}
pub fn edit(workspace_dir: &Path, id: &str, body: &str, priority: Option<u32>) -> Result<bool> {
let mut entries = load(workspace_dir)?;
let Some(entry) = entries.iter_mut().find(|e| e.id == id) else {
return Ok(false);
};
entry.body = body.to_string();
if let Some(p) = priority {
entry.priority = p;
}
entry.updated_at = now_secs();
save(workspace_dir, &entries)?;
Ok(true)
}
pub fn remove(workspace_dir: &Path, id: &str) -> Result<bool> {
let mut entries = load(workspace_dir)?;
let before = entries.len();
entries.retain(|e| e.id != id);
if entries.len() == before {
return Ok(false);
}
save(workspace_dir, &entries)?;
Ok(true)
}
pub fn render_for_prompt(entries: &[ScratchpadEntry]) -> String {
if entries.is_empty() {
return String::new();
}
let mut out = String::from("## Scratchpad (your persistent working memory)\n\n");
out.push_str(
"These are your own notes from previous ticks. Update, remove, or \
add entries as your understanding evolves.\n\n",
);
for entry in entries {
let ts = chrono::DateTime::from_timestamp(entry.updated_at as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "unknown".to_string());
out.push_str(&format!(
"- **[{}]** (p{}) {}\n _updated: {}_\n",
entry.id, entry.priority, entry.body, ts
));
}
out
}
// ── File I/O ────────────────────────────────────────────────────────────────
fn save(workspace_dir: &Path, entries: &[ScratchpadEntry]) -> Result<()> {
let path = scratchpad_path(workspace_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).context("create scratchpad dir")?;
}
let content = render_file(entries);
std::fs::write(&path, content).context("write scratchpad")?;
Ok(())
}
fn render_file(entries: &[ScratchpadEntry]) -> String {
let mut out = String::from("# Subconscious Scratchpad\n\n");
if entries.is_empty() {
return out;
}
out.push_str("```json\n");
match serde_json::to_string_pretty(entries) {
Ok(json) => out.push_str(&json),
Err(e) => {
log::warn!("[subconscious] failed to render scratchpad JSON: {e}");
out.push_str("[]");
}
}
out.push_str("\n```\n");
out
}
fn parse_entries(content: &str) -> Vec<ScratchpadEntry> {
if let Some(entries) = parse_json_entries(content) {
return entries;
}
let mut entries = Vec::new();
for block in content.split("\n---\n") {
let block = block.trim();
if block.is_empty() || block.starts_with("# ") {
if let Some(rest) = block.strip_prefix("# Subconscious Scratchpad") {
let rest = rest.trim();
if rest.is_empty() {
continue;
}
if let Some(entry) = parse_single_block(rest) {
entries.push(entry);
}
continue;
}
continue;
}
if let Some(entry) = parse_single_block(block) {
entries.push(entry);
}
}
entries
}
fn parse_json_entries(content: &str) -> Option<Vec<ScratchpadEntry>> {
let marker = "```json";
let start = content.find(marker)? + marker.len();
let rest = &content[start..];
let end = rest.rfind("```")?;
let json = rest[..end].trim();
if json.is_empty() {
return Some(Vec::new());
}
match serde_json::from_str(json) {
Ok(entries) => Some(entries),
Err(e) => {
log::warn!("[subconscious] failed to parse scratchpad JSON: {e}");
None
}
}
}
fn parse_single_block(block: &str) -> Option<ScratchpadEntry> {
let meta_start = block.find("<!-- entry:")?;
let meta_end = block[meta_start..].find("-->")? + meta_start + 3;
let meta_line = &block[meta_start..meta_end];
let id = extract_meta(meta_line, "entry:")?;
let priority = extract_meta(meta_line, "p:")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
let created_at = extract_meta(meta_line, "created:")
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let updated_at = extract_meta(meta_line, "updated:")
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(created_at);
let body = block[meta_end..].trim().to_string();
if body.is_empty() {
return None;
}
Some(ScratchpadEntry {
id,
body,
priority,
created_at,
updated_at,
})
}
fn extract_meta(line: &str, key: &str) -> Option<String> {
let start = line.find(key)? + key.len();
let rest = &line[start..];
let end = rest
.find(|c: char| c.is_whitespace() || c == '-')
.unwrap_or(rest.len());
let val = rest[..end].trim().to_string();
if val.is_empty() {
None
} else {
Some(val)
}
}
fn evict(entries: &mut Vec<ScratchpadEntry>, max: usize) {
if entries.len() <= max {
return;
}
entries.sort_by(|a, b| {
b.priority.cmp(&a.priority).then(
b.updated_at
.partial_cmp(&a.updated_at)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
entries.truncate(max);
}
fn short_id() -> String {
let uuid = uuid::Uuid::new_v4().to_string();
uuid[..8].to_string()
}
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)]
mod tests {
use super::*;
use tempfile::TempDir;
fn temp_workspace() -> TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn add_and_load_round_trip() {
let ws = temp_workspace();
let id = add(ws.path(), "first thought", 1, 100).unwrap();
let entries = load(ws.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, id);
assert_eq!(entries[0].body, "first thought");
assert_eq!(entries[0].priority, 1);
}
#[test]
fn edit_updates_body_and_priority() {
let ws = temp_workspace();
let id = add(ws.path(), "old", 0, 100).unwrap();
let found = edit(ws.path(), &id, "new", Some(5)).unwrap();
assert!(found);
let entries = load(ws.path()).unwrap();
assert_eq!(entries[0].body, "new");
assert_eq!(entries[0].priority, 5);
}
#[test]
fn remove_deletes_entry() {
let ws = temp_workspace();
let id = add(ws.path(), "gone", 0, 100).unwrap();
assert!(remove(ws.path(), &id).unwrap());
assert!(load(ws.path()).unwrap().is_empty());
}
#[test]
fn remove_nonexistent_returns_false() {
let ws = temp_workspace();
add(ws.path(), "keep", 0, 100).unwrap();
assert!(!remove(ws.path(), "nope").unwrap());
}
#[test]
fn add_evicts_oldest_low_priority_beyond_cap() {
let ws = temp_workspace();
for i in 0..5 {
add(ws.path(), &format!("note {i}"), 0, 100).unwrap();
}
add(ws.path(), "high priority", 10, 100).unwrap();
add(ws.path(), "newest", 0, 3).unwrap();
let entries = load(ws.path()).unwrap();
assert!(entries.len() <= 3);
assert!(entries.iter().any(|e| e.body == "high priority"));
}
#[test]
fn render_for_prompt_formats_entries() {
let entries = vec![ScratchpadEntry {
id: "abc".to_string(),
body: "test thought".to_string(),
priority: 2,
created_at: 1700000000.0,
updated_at: 1700000000.0,
}];
let rendered = render_for_prompt(&entries);
assert!(rendered.contains("## Scratchpad"));
assert!(rendered.contains("[abc]"));
assert!(rendered.contains("test thought"));
assert!(rendered.contains("(p2)"));
}
#[test]
fn render_for_prompt_empty_returns_empty() {
assert!(render_for_prompt(&[]).is_empty());
}
#[test]
fn load_missing_file_returns_empty() {
let ws = temp_workspace();
assert!(load(ws.path()).unwrap().is_empty());
}
#[test]
fn file_is_readable_markdown() {
let ws = temp_workspace();
add(ws.path(), "thought one", 5, 100).unwrap();
add(ws.path(), "thought two", 0, 100).unwrap();
let content = std::fs::read_to_string(scratchpad_path(ws.path())).unwrap();
assert!(content.starts_with("# Subconscious Scratchpad"));
assert!(content.contains("```json"));
assert!(content.contains("thought one"));
assert!(content.contains("thought two"));
assert!(content.contains("\"created_at\""));
}
#[test]
fn parse_round_trip_preserves_data() {
let original = vec![
ScratchpadEntry {
id: "aaa".to_string(),
body: "first".to_string(),
priority: 3,
created_at: 1700000000.0,
updated_at: 1700000100.0,
},
ScratchpadEntry {
id: "bbb".to_string(),
body: "second\nwith newlines".to_string(),
priority: 0,
created_at: 1700000050.0,
updated_at: 1700000050.0,
},
];
let rendered = render_file(&original);
let parsed = parse_entries(&rendered);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].id, "aaa");
assert_eq!(parsed[0].body, "first");
assert_eq!(parsed[0].priority, 3);
assert_eq!(parsed[1].id, "bbb");
assert_eq!(parsed[1].body, "second\nwith newlines");
}
#[test]
fn parse_round_trip_preserves_markdown_separators_and_subsecond_timestamps() {
let original = vec![ScratchpadEntry {
id: "aaa".to_string(),
body: "first\n---\nsecond".to_string(),
priority: 3,
created_at: 1700000000.123456,
updated_at: 1700000100.654321,
}];
let rendered = render_file(&original);
let parsed = parse_entries(&rendered);
assert_eq!(parsed, original);
}
#[test]
fn parse_legacy_markdown_entries() {
let content = "# Subconscious Scratchpad\n\n<!-- entry:abc p:2 created:1700000000 updated:1700000001 -->\nlegacy thought";
let parsed = parse_entries(content);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].id, "abc");
assert_eq!(parsed[0].body, "legacy thought");
assert_eq!(parsed[0].priority, 2);
}
}
@@ -0,0 +1,230 @@
//! Agent tools for managing the subconscious scratchpad.
//!
//! Three tools: `scratchpad_add`, `scratchpad_edit`, `scratchpad_remove`.
//! Registered via `all_scratchpad_tools()` and wired into the tool
//! registry so the subconscious agent can manage its own working memory.
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult, ToolScope};
use async_trait::async_trait;
use serde_json::json;
async fn workspace_dir() -> anyhow::Result<std::path::PathBuf> {
let config = crate::openhuman::config::load_config_with_timeout()
.await
.map_err(|e| anyhow::anyhow!("config load: {e}"))?;
Ok(config.workspace_dir)
}
pub fn all_scratchpad_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(ScratchpadAddTool),
Box::new(ScratchpadEditTool),
Box::new(ScratchpadRemoveTool),
]
}
// ── scratchpad_add ──────────────────────────────────────────────────────────
pub struct ScratchpadAddTool;
#[async_trait]
impl Tool for ScratchpadAddTool {
fn name(&self) -> &str {
"scratchpad_add"
}
fn description(&self) -> &str {
"Add a thought to the subconscious scratchpad. Use this to persist \
observations, hypotheses, or follow-up items across ticks. Max 100 entries; \
oldest low-priority entries are evicted when the cap is reached."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["body"],
"properties": {
"body": {
"type": "string",
"description": "The thought or note to persist (keep under ~500 chars)."
},
"priority": {
"type": "integer",
"description": "Priority 0-10. Higher priority entries survive eviction longer. Default: 0.",
"minimum": 0,
"maximum": 10
}
}
})
}
fn category(&self) -> ToolCategory {
ToolCategory::System
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
fn scope(&self) -> ToolScope {
ToolScope::AgentOnly
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let body = args
.get("body")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("scratchpad_add: `body` is required"))?;
let priority = args
.get("priority")
.and_then(|v| v.as_u64())
.unwrap_or(0)
.min(10) as u32;
let ws = workspace_dir().await?;
let id = super::add(&ws, body, priority, super::DEFAULT_MAX_ENTRIES)?;
Ok(ToolResult::success(format!(
"Added scratchpad entry id={id} priority={priority}"
)))
}
}
// ── scratchpad_edit ─────────────────────────────────────────────────────────
pub struct ScratchpadEditTool;
#[async_trait]
impl Tool for ScratchpadEditTool {
fn name(&self) -> &str {
"scratchpad_edit"
}
fn description(&self) -> &str {
"Edit an existing scratchpad entry. Update the body text and/or priority."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["id", "body"],
"properties": {
"id": {
"type": "string",
"description": "The entry ID to edit (shown in the scratchpad section as [id])."
},
"body": {
"type": "string",
"description": "Updated thought text."
},
"priority": {
"type": "integer",
"description": "Updated priority 0-10 (omit to keep current).",
"minimum": 0,
"maximum": 10
}
}
})
}
fn category(&self) -> ToolCategory {
ToolCategory::System
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
fn scope(&self) -> ToolScope {
ToolScope::AgentOnly
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let id = args
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("scratchpad_edit: `id` is required"))?;
let body = args
.get("body")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("scratchpad_edit: `body` is required"))?;
let priority = args
.get("priority")
.and_then(|v| v.as_u64())
.map(|v| v.min(10) as u32);
let ws = workspace_dir().await?;
let found = super::edit(&ws, id, body, priority)?;
if found {
Ok(ToolResult::success(format!(
"Updated scratchpad entry id={id}"
)))
} else {
Ok(ToolResult::error(format!(
"Scratchpad entry id={id} not found"
)))
}
}
}
// ── scratchpad_remove ───────────────────────────────────────────────────────
pub struct ScratchpadRemoveTool;
#[async_trait]
impl Tool for ScratchpadRemoveTool {
fn name(&self) -> &str {
"scratchpad_remove"
}
fn description(&self) -> &str {
"Remove a scratchpad entry by ID. Use when a thought is no longer relevant \
or has been fully addressed."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string",
"description": "The entry ID to remove."
}
}
})
}
fn category(&self) -> ToolCategory {
ToolCategory::System
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
fn scope(&self) -> ToolScope {
ToolScope::AgentOnly
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let id = args
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("scratchpad_remove: `id` is required"))?;
let ws = workspace_dir().await?;
let found = super::remove(&ws, id)?;
if found {
Ok(ToolResult::success(format!(
"Removed scratchpad entry id={id}"
)))
} else {
Ok(ToolResult::error(format!(
"Scratchpad entry id={id} not found"
)))
}
}
}
@@ -6,24 +6,16 @@
//! 1. **Environment** (kept): host/OS/workspace/time anchor.
//! 2. **Your Identifiers** (#1365): the user's connected-account
//! identifiers (Slack/Gmail/Notion handles, emails, user_ids) so the
//! reflection LLM can disambiguate body-text mentions — "Cyrus said X"
//! LLM can disambiguate body-text mentions — "Cyrus said X"
//! is the user iff `Cyrus` (or the email/handle) appears in this list.
//! 3. **Pending Tasks** (kept): subconscious task list from SQLite.
//! 4. **Recently-sealed summaries** (new): rows from `mem_tree_summaries`
//! grouped by tree.
//! 5. **Source-tree recap window** (new): recent source summaries since
//! `last_tick_at`.
//! 6. **Recent reflections** (new): the last N reflections from the
//! subconscious store, used by the LLM as anti-double-emit context.
//!
//! The hotness-deltas and global-L0-digest sections were removed with the
//! topic/global trees (the entity-hotness signal was a topic-curator
//! byproduct, and there is no longer a global digest node).
//!
//! Sections are appended in priority order; truncation drops the tail
//! when `token_budget` is exceeded. The legacy unified-store sections
//! (`MemoryClient::list_documents`, `graph_query`) and the local-skills
//! placeholder are intentionally dropped.
//! when `token_budget` is exceeded.
//!
//! Each submodule is responsible for one section so churn stays local.
@@ -31,10 +23,7 @@ use std::path::Path;
use crate::openhuman::config::Config;
use super::reflection::Reflection;
mod query_window;
pub(crate) mod reflections;
mod summaries;
/// Rough chars-per-token estimate for budget enforcement.
@@ -59,15 +48,11 @@ pub struct SituationReport {
/// `last_tick_at` is 0.0 on cold start (include everything in the
/// configured windows). `token_budget` caps total output; sections
/// after the cap are truncated with a marker.
///
/// Reflections come from `recent_reflections` so the caller can choose
/// whatever cursor logic suits them (typically: last 8 by `created_at`).
pub async fn build_situation_report(
config: &Config,
workspace_dir: &Path,
last_tick_at: f64,
token_budget: u32,
recent_reflections: &[Reflection],
) -> SituationReport {
let char_budget = (token_budget as usize) * CHARS_PER_TOKEN;
let mut report = String::with_capacity(char_budget.min(64_000));
@@ -79,7 +64,7 @@ pub async fn build_situation_report(
append_section(&mut report, &mut remaining, &env_section);
// Section 2 (#1365): the user's connected-account identifiers, so
// the reflection LLM can disambiguate "Cyrus said X" from body text
// the LLM can disambiguate "Cyrus said X" from body text
// — that's the user iff the identifier list claims it.
let identifiers_section = build_identifiers_section();
append_section(&mut report, &mut remaining, &identifiers_section);
@@ -99,10 +84,6 @@ pub async fn build_situation_report(
append_section(&mut report, &mut remaining, &recap_section);
has_external_content |= recap_tainted;
// Section 6: previous reflections (anti-double-emit context).
let reflections_section = reflections::build_section(recent_reflections);
append_section(&mut report, &mut remaining, &reflections_section);
if report.trim().is_empty() {
report.push_str("No state changes detected since last tick.\n");
}
@@ -130,7 +111,7 @@ fn build_environment_section(workspace_dir: &Path) -> String {
}
/// Render the user's connected-account identifiers (#1365) so the
/// reflection LLM can correlate body-text mentions back to the user.
/// LLM can correlate body-text mentions back to the user.
/// Empty string when no providers are connected — the section just
/// disappears rather than rendering an empty header.
fn build_identifiers_section() -> String {
@@ -144,9 +125,6 @@ fn build_identifiers_section() -> String {
if body.trim().is_empty() {
return String::new();
}
// The shared renderer emits "## Connected Identities". Rename the
// heading for the situation-report context so the LLM knows this is
// *the user's* identity surface, not a list of contacts.
let renamed = body.replacen("## Connected Identities", "## Your Identifiers", 1);
let mut out = renamed;
if !out.ends_with('\n') {
@@ -165,14 +143,11 @@ fn build_tasks_section(_workspace_dir: &Path) -> String {
}
/// Append a section, truncating at a UTF-8 char boundary if it overflows
/// the remaining budget. Once `remaining` hits zero, subsequent sections
/// are silently dropped (not even truncated marker added — caller
/// already noted the cap).
/// the remaining budget.
fn append_section(report: &mut String, remaining: &mut usize, section: &str) {
if *remaining == 0 {
return;
}
// +1 for the trailing newline we append
let needed = section.len().saturating_add(1);
if needed <= *remaining {
report.push_str(section);
@@ -221,7 +196,6 @@ mod tests {
#[test]
fn append_section_truncates_at_char_boundary() {
let mut report = String::new();
// "日本語" is 9 bytes (3 chars × 3 bytes each).
let mut remaining = 5;
append_section(&mut report, &mut remaining, "日本語タスク");
assert!(report.starts_with(""));
@@ -1,99 +0,0 @@
//! Recent reflections section — anti-double-emit context for the LLM (#623).
//!
//! Renders the last N persisted reflections so the model can decide to
//! decay a stale observation, strengthen one that's intensifying, or
//! skip emitting a duplicate.
//!
//! The caller does the actual loading from `subconscious_reflections`
//! (see `engine.rs` tick logic) so this module stays a pure formatter
//! and trivial to unit-test.
use std::fmt::Write;
use crate::openhuman::subconscious::reflection::Reflection;
/// Default cap on rendered reflections — `engine.rs` still supplies the
/// vector, but if more are passed we trim here so the prompt section
/// can't blow up.
const RENDER_CAP: usize = 8;
pub fn build_section(reflections: &[Reflection]) -> String {
if reflections.is_empty() {
return "## Recent reflections\n\nNone yet — first tick.\n".to_string();
}
let mut section = String::from("## Recent reflections\n\n");
section.push_str(
"Previous tick observations. Decide whether each still holds, has \
intensified, or has decayed — emit a fresh reflection only on a \
materially new signal:\n\n",
);
for r in reflections.iter().take(RENDER_CAP) {
let _ = writeln!(
section,
"- [{id}] kind={kind} — {body}",
id = r.id,
kind = r.kind.as_str(),
body = trim_for_prompt(&r.body),
);
}
section
}
fn trim_for_prompt(text: &str) -> String {
let single_line = text.replace('\n', " ");
if single_line.chars().count() <= 200 {
return single_line;
}
let mut out: String = single_line.chars().take(200).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::subconscious::reflection::{
hydrate_draft, ReflectionDraft, ReflectionKind,
};
fn r(id: &str, body: &str) -> Reflection {
hydrate_draft(
ReflectionDraft {
kind: ReflectionKind::HotnessSpike,
body: body.into(),
proposed_action: None,
source_refs: vec![],
},
id.into(),
1.0,
Vec::new(),
None,
)
}
#[test]
fn empty_renders_first_tick_message() {
let s = build_section(&[]);
assert!(s.contains("None yet — first tick"));
}
#[test]
fn renders_each_reflection() {
let s = build_section(&[r("a", "Phoenix surge"), r("b", "Calendar conflict")]);
assert!(s.contains("[a]"));
assert!(s.contains("Phoenix surge"));
assert!(s.contains("[b]"));
assert!(s.contains("Calendar conflict"));
}
#[test]
fn caps_at_render_cap() {
let many: Vec<Reflection> = (0..20).map(|i| r(&format!("r{i}"), "body")).collect();
let s = build_section(&many);
assert!(s.contains("[r0]"));
assert!(s.contains(&format!("[r{}]", RENDER_CAP - 1)));
// Past the cap should NOT appear.
assert!(!s.contains(&format!("[r{}]", RENDER_CAP)));
}
}
-4
View File
@@ -96,10 +96,6 @@ fn open_and_initialize(db_path: &Path) -> Result<Connection> {
conn.execute_batch(SCHEMA_DDL)
.context("failed to run subconscious schema DDL")?;
super::reflection_store::migrate_drop_legacy_columns(&conn);
super::reflection_store::migrate_add_source_chunks_column(&conn);
super::reflection_store::migrate_add_thread_id_column(&conn);
Ok(conn)
}
+2 -2
View File
@@ -19,7 +19,7 @@ pub struct SubconsciousStatus {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TickResult {
pub tick_at: f64,
pub thoughts_count: usize,
pub thread_id: Option<String>,
pub duration_ms: u64,
#[serde(default)]
pub response_chars: usize,
}
+3
View File
@@ -459,6 +459,9 @@ pub fn all_tools_with_runtime(
Box::new(WorkspaceInitTool),
];
// Subconscious scratchpad tools — persistent working memory across ticks.
tools.extend(crate::openhuman::subconscious::scratchpad::tools::all_scratchpad_tools());
// Presentation generation (#2778). Native-Rust engine (ppt-rs
// backed) as of the #2780-follow-up rust-engine refactor — no
// managed Python venv, no first-call install latency. Always
+7 -13
View File
@@ -42,14 +42,13 @@ async fn ingest_doc(
result.document_id
}
/// Two-tick E2E test — verifies the agent-per-tick model produces
/// thoughts from ingested memory data.
/// Two-tick E2E test — verifies the agent-per-tick model can process
/// ingested memory data and persist tick state.
#[tokio::test]
#[ignore] // requires running Ollama
async fn two_tick_e2e_with_real_ollama() {
use openhuman_core::openhuman::embeddings::NoopEmbedding;
use openhuman_core::openhuman::memory::{MemoryClient, UnifiedMemory};
use openhuman_core::openhuman::subconscious::reflection_store;
use openhuman_core::openhuman::subconscious::store;
let tmp = tempfile::tempdir().expect("tempdir");
@@ -90,15 +89,11 @@ async fn two_tick_e2e_with_real_ollama() {
println!("\n=== TICK 1 ===");
let result1 = engine.tick().await.expect("tick 1 should succeed");
println!(" Duration: {}ms", result1.duration_ms);
println!(" Thoughts: {}", result1.thoughts_count);
println!(" Thread: {:?}", result1.thread_id);
println!(" Response chars: {}", result1.response_chars);
// Check reflections in DB
let reflections1 = store::with_connection(workspace, |conn| {
reflection_store::list_recent(conn, 50, None)
})
.expect("list reflections");
println!(" Reflections in DB: {}", reflections1.len());
let last_tick1 =
store::with_connection(workspace, store::get_last_tick_at).expect("read last tick");
assert!(last_tick1 >= result1.tick_at);
// Tick 2 with new data
println!("\n=== TICK 2 ===");
@@ -114,8 +109,7 @@ async fn two_tick_e2e_with_real_ollama() {
let result2 = engine.tick().await.expect("tick 2 should succeed");
println!(" Duration: {}ms", result2.duration_ms);
println!(" Thoughts: {}", result2.thoughts_count);
println!(" Thread: {:?}", result2.thread_id);
println!(" Response chars: {}", result2.response_chars);
let status = engine.status().await;
println!("\n--- Status ---");