mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(subconscious): add scratchpad deep research loop (#3439)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -2074,6 +2074,8 @@ const messages: TranslationMap = {
|
||||
'subconscious.runNow': 'تشغيل الآن',
|
||||
'subconscious.providerUnavailableTitle': 'تم إيقاف اللاوعي مؤقتًا',
|
||||
'subconscious.providerSettings': 'إعدادات الذكاء الاصطناعي',
|
||||
'subconscious.scratchpadInfo':
|
||||
'يحتفظ اللاوعي بدفتر ملاحظات مستمر للملاحظات عبر الدورات. تحقق من الإعدادات → وصول الوكيل لتكوين الوضع والتكرار.',
|
||||
'subconscious.approvalNeeded': 'يلزم الموافقة',
|
||||
'subconscious.requiresApproval': 'يتطلب الموافقة',
|
||||
'subconscious.fixInConnections': 'إصلاح في الاتصالات',
|
||||
|
||||
@@ -2116,6 +2116,8 @@ const messages: TranslationMap = {
|
||||
'subconscious.runNow': 'এখনই চালান',
|
||||
'subconscious.providerUnavailableTitle': 'Subconscious বিরত আছে',
|
||||
'subconscious.providerSettings': 'AI সেটিংস',
|
||||
'subconscious.scratchpadInfo':
|
||||
'সাবকনশাস টিক জুড়ে পর্যবেক্ষণের একটি স্থায়ী স্ক্র্যাচপ্যাড বজায় রাখে। মোড এবং ফ্রিকোয়েন্সি কনফিগার করতে সেটিংস → এজেন্ট অ্যাক্সেস দেখুন।',
|
||||
'subconscious.approvalNeeded': 'অনুমোদন প্রয়োজন',
|
||||
'subconscious.requiresApproval': 'অনুমোদন প্রয়োজন',
|
||||
'subconscious.fixInConnections': 'সংযোগে ঠিক করুন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2115,6 +2115,8 @@ const messages: TranslationMap = {
|
||||
'subconscious.runNow': 'अभी चलाएं',
|
||||
'subconscious.providerUnavailableTitle': 'Subconscious रुका हुआ है',
|
||||
'subconscious.providerSettings': 'AI सेटिंग्स',
|
||||
'subconscious.scratchpadInfo':
|
||||
'सबकॉन्शस टिक्स में अवलोकनों का एक स्थायी स्क्रैचपैड बनाए रखता है। मोड और आवृत्ति कॉन्फ़िगर करने के लिए सेटिंग्स → एजेंट एक्सेस देखें।',
|
||||
'subconscious.approvalNeeded': 'अनुमति चाहिए',
|
||||
'subconscious.requiresApproval': 'अनुमति ज़रूरी है',
|
||||
'subconscious.fixInConnections': 'Connections में ठीक करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2094,6 +2094,8 @@ const messages: TranslationMap = {
|
||||
'subconscious.runNow': '지금 실행',
|
||||
'subconscious.providerUnavailableTitle': 'Subconscious 일시 중지됨',
|
||||
'subconscious.providerSettings': 'AI 설정',
|
||||
'subconscious.scratchpadInfo':
|
||||
'잠재의식은 틱 전반에 걸쳐 관찰 사항의 영구 메모장을 유지합니다. 모드와 빈도를 구성하려면 설정 → 에이전트 접근을 확인하세요.',
|
||||
'subconscious.approvalNeeded': '승인 필요',
|
||||
'subconscious.requiresApproval': '승인이 필요함',
|
||||
'subconscious.fixInConnections': '연결에서 수정',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2133,6 +2133,8 @@ const messages: TranslationMap = {
|
||||
'subconscious.runNow': 'Запустить сейчас',
|
||||
'subconscious.providerUnavailableTitle': 'Подсознание приостановлено',
|
||||
'subconscious.providerSettings': 'Настройки ИИ',
|
||||
'subconscious.scratchpadInfo':
|
||||
'Подсознание ведёт постоянный блокнот наблюдений между циклами. Проверьте Настройки → Доступ агента для настройки режима и частоты.',
|
||||
'subconscious.approvalNeeded': 'Требуется подтверждение',
|
||||
'subconscious.requiresApproval': 'Требует подтверждения',
|
||||
'subconscious.fixInConnections': 'Исправить в подключениях',
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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={:?}",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
+4
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---");
|
||||
|
||||
Reference in New Issue
Block a user