From ac1363d435dea50363fe2ef08424a2d6f05be89e Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sun, 3 May 2026 21:50:18 -0700 Subject: [PATCH] feat(memory): user-facing long-term memory window preset (#1137) (#1161) --- .../components/MemoryWindowControl.tsx | 174 ++++++++++ .../__tests__/MemoryWindowControl.test.tsx | 133 ++++++++ .../settings/panels/MemoryDataPanel.tsx | 24 +- app/src/utils/tauriCommands/config.ts | 16 + docs/MEMORY_CONTEXT_WINDOW.md | 74 +++++ .../agent/harness/session/builder.rs | 8 +- src/openhuman/agent/harness/session/turn.rs | 32 +- .../agent/harness/session/turn_tests.rs | 2 +- src/openhuman/agent/prompts/types.rs | 11 + src/openhuman/config/ops.rs | 18 ++ src/openhuman/config/ops_tests.rs | 40 +++ src/openhuman/config/schema/agent.rs | 301 +++++++++++++++++- src/openhuman/config/schema/mod.rs | 2 +- src/openhuman/config/schemas.rs | 7 + 14 files changed, 821 insertions(+), 21 deletions(-) create mode 100644 app/src/components/settings/components/MemoryWindowControl.tsx create mode 100644 app/src/components/settings/components/__tests__/MemoryWindowControl.test.tsx create mode 100644 docs/MEMORY_CONTEXT_WINDOW.md diff --git a/app/src/components/settings/components/MemoryWindowControl.tsx b/app/src/components/settings/components/MemoryWindowControl.tsx new file mode 100644 index 000000000..8460c6584 --- /dev/null +++ b/app/src/components/settings/components/MemoryWindowControl.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from 'react'; + +import { + isTauri, + MEMORY_CONTEXT_WINDOWS, + type MemoryContextWindow, + openhumanGetConfig, + openhumanUpdateMemorySettings, +} from '../../../utils/tauriCommands'; + +interface PresetMeta { + label: string; + badge: string; + hint: string; +} + +/** + * Plain-language framing for each preset. The actual character budgets + * live in the Rust core (`MemoryContextWindow::limits` in + * `src/openhuman/config/schema/agent.rs`) — these strings only describe + * the UX tradeoff so users can pick without doing math. + */ +export const MEMORY_WINDOW_PRESET_META: Record = { + minimal: { + label: 'Minimal', + badge: 'Cheapest', + hint: 'Smallest memory window. Cheapest, fastest, least continuity between runs.', + }, + balanced: { + label: 'Balanced', + badge: 'Recommended', + hint: 'Sensible default — good continuity without burning extra tokens on every run.', + }, + extended: { + label: 'Extended', + badge: 'More context', + hint: 'More long-term memory injected into each run. Higher token cost per turn.', + }, + maximum: { + label: 'Maximum', + badge: 'Highest cost', + hint: 'The largest safe window. Best continuity, meaningfully higher token bill on every run.', + }, +}; + +const isMemoryContextWindow = (value: unknown): value is MemoryContextWindow => + typeof value === 'string' && (MEMORY_CONTEXT_WINDOWS as readonly string[]).includes(value); + +const extractCurrentWindow = (snapshot: unknown): MemoryContextWindow => { + if (!snapshot || typeof snapshot !== 'object') return 'balanced'; + const root = snapshot as Record; + const config = (root.config as Record | undefined) ?? root; + const agent = config.agent as Record | undefined; + const candidate = agent?.memory_window; + return isMemoryContextWindow(candidate) ? candidate : 'balanced'; +}; + +interface Props { + onError?: (message: string) => void; + onSaved?: (window: MemoryContextWindow) => void; +} + +/** + * Stepped memory-context window selector. + * + * - Reads the persisted preference from the core via `openhuman.get_config`. + * - Writes it back via `openhuman.update_memory_settings` (the core + * owns the actual char-budget mapping). + * - Renders four options with plain-language hints so users understand + * the cost / continuity tradeoff. + */ +const MemoryWindowControl = ({ onError, onSaved }: Props) => { + const [current, setCurrent] = useState('balanced'); + const [pending, setPending] = useState(null); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(null); + + useEffect(() => { + if (!isTauri()) { + setLoaded(true); + return; + } + let cancelled = false; + const load = async () => { + try { + const response = await openhumanGetConfig(); + if (cancelled) return; + setCurrent(extractCurrentWindow(response.result)); + } catch (err) { + if (cancelled) return; + onError?.(err instanceof Error ? err.message : 'Failed to load memory settings'); + } finally { + if (!cancelled) setLoaded(true); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, [onError]); + + const select = async (next: MemoryContextWindow) => { + if (next === current || saving) return; + setPending(next); + setSaving(next); + try { + if (isTauri()) { + await openhumanUpdateMemorySettings({ memory_window: next }); + } + setCurrent(next); + onSaved?.(next); + } catch (err) { + onError?.(err instanceof Error ? err.message : 'Failed to save memory window'); + } finally { + setSaving(null); + setPending(null); + } + }; + + const activeForUi = pending ?? current; + const meta = MEMORY_WINDOW_PRESET_META[activeForUi]; + + return ( +
+
+
+

Long-term memory window

+

+ How much remembered context OpenHuman injects into every new agent run. Larger windows + feel more aware of past conversations but use more tokens — and cost more — on every + run. +

+
+
+
+ {MEMORY_CONTEXT_WINDOWS.map(option => { + const optionMeta = MEMORY_WINDOW_PRESET_META[option]; + const isActive = activeForUi === option; + const isSaving = saving === option; + return ( + + ); + })} +
+

+ {meta.hint} +

+
+ ); +}; + +export default MemoryWindowControl; diff --git a/app/src/components/settings/components/__tests__/MemoryWindowControl.test.tsx b/app/src/components/settings/components/__tests__/MemoryWindowControl.test.tsx new file mode 100644 index 000000000..a1dd18948 --- /dev/null +++ b/app/src/components/settings/components/__tests__/MemoryWindowControl.test.tsx @@ -0,0 +1,133 @@ +/** + * Tests for the user-facing memory-context window selector. + * + * Covers the wording the user sees (so the cost/continuity tradeoff is + * surfaced explicitly), the persisted-preference roundtrip, and the + * core RPC contract — the panel must call `update_memory_settings` + * with the canonical lowercase preset label so the core stays the + * source of truth for actual char budgets. + */ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; +import MemoryWindowControl from '../MemoryWindowControl'; + +const hoisted = vi.hoisted(() => ({ + mockGetConfig: vi.fn(), + mockUpdateMemorySettings: vi.fn(), + mockIsTauri: vi.fn(() => true), +})); + +vi.mock('../../../../utils/tauriCommands', () => ({ + isTauri: hoisted.mockIsTauri, + openhumanGetConfig: hoisted.mockGetConfig, + openhumanUpdateMemorySettings: hoisted.mockUpdateMemorySettings, + MEMORY_CONTEXT_WINDOWS: ['minimal', 'balanced', 'extended', 'maximum'], +})); + +beforeEach(() => { + hoisted.mockGetConfig.mockReset(); + hoisted.mockUpdateMemorySettings.mockReset(); + hoisted.mockIsTauri.mockReturnValue(true); +}); + +const respondWithWindow = (memory_window: string | undefined) => { + hoisted.mockGetConfig.mockResolvedValue({ + result: { + config: { agent: memory_window === undefined ? {} : { memory_window } }, + workspace_dir: '/tmp/ws', + config_path: '/tmp/cfg.toml', + }, + }); +}; + +describe('MemoryWindowControl', () => { + it('explains the cost/continuity tradeoff in plain language', async () => { + respondWithWindow('balanced'); + renderWithProviders(); + + // Header copy makes the tradeoff explicit — increasing the window + // costs more on every run. + expect(screen.getByText(/larger windows feel more aware/i)).toHaveTextContent( + /use more tokens/i + ); + expect(screen.getByText(/cost more/i)).toBeInTheDocument(); + + // All four presets are offered. + for (const label of ['Minimal', 'Balanced', 'Extended', 'Maximum']) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + + await waitFor(() => { + expect(screen.getByTestId('memory-window-option-balanced')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + }); + + it('reflects the persisted preference returned by the core', async () => { + respondWithWindow('extended'); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId('memory-window-option-extended')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + expect(screen.getByTestId('memory-window-hint')).toHaveTextContent(/more long-term memory/i); + }); + + it('falls back to balanced when the snapshot omits the field', async () => { + respondWithWindow(undefined); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId('memory-window-option-balanced')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + }); + + it('persists the chosen preset via update_memory_settings using the canonical label', async () => { + respondWithWindow('balanced'); + hoisted.mockUpdateMemorySettings.mockResolvedValue({ + result: { config: { agent: { memory_window: 'maximum' } } }, + }); + const onSaved = vi.fn(); + renderWithProviders(); + + await waitFor(() => expect(hoisted.mockGetConfig).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId('memory-window-option-maximum')); + + await waitFor(() => { + expect(hoisted.mockUpdateMemorySettings).toHaveBeenCalledWith({ memory_window: 'maximum' }); + }); + await waitFor(() => expect(onSaved).toHaveBeenCalledWith('maximum')); + await waitFor(() => { + expect(screen.getByTestId('memory-window-option-maximum')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + }); + + it('surfaces save failures to the parent without rolling forward', async () => { + respondWithWindow('balanced'); + hoisted.mockUpdateMemorySettings.mockRejectedValue(new Error('disk full')); + const onError = vi.fn(); + renderWithProviders(); + + await waitFor(() => expect(hoisted.mockGetConfig).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId('memory-window-option-extended')); + + await waitFor(() => expect(onError).toHaveBeenCalledWith('disk full')); + expect(screen.getByTestId('memory-window-option-balanced')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); +}); diff --git a/app/src/components/settings/panels/MemoryDataPanel.tsx b/app/src/components/settings/panels/MemoryDataPanel.tsx index b1e3532ba..18ee9208a 100644 --- a/app/src/components/settings/panels/MemoryDataPanel.tsx +++ b/app/src/components/settings/panels/MemoryDataPanel.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import type { ToastNotification } from '../../../types/intelligence'; import { MemoryWorkspace } from '../../intelligence/MemoryWorkspace'; import { ToastContainer } from '../../intelligence/Toast'; +import MemoryWindowControl from '../components/MemoryWindowControl'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; @@ -10,15 +11,29 @@ const MemoryDataPanel = () => { const { navigateBack, breadcrumbs } = useSettingsNavigation(); const [toasts, setToasts] = useState([]); - const addToast = (toast: Omit) => { + const addToast = useCallback((toast: Omit) => { const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` }; setToasts(prev => [...prev, newToast]); - }; + }, []); const removeToast = (id: string) => { setToasts(prev => prev.filter(t => t.id !== id)); }; + const handleWindowError = useCallback( + (message: string) => { + addToast({ type: 'error', title: 'Memory window', message }); + }, + [addToast] + ); + + const handleWindowSaved = useCallback( + (window: string) => { + addToast({ type: 'success', title: 'Memory window updated', message: `Set to ${window}.` }); + }, + [addToast] + ); + return (
{ onBack={navigateBack} breadcrumbs={breadcrumbs} /> -
+
+
diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 228908506..5d6fa391f 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -17,12 +17,28 @@ export interface ModelSettingsUpdate { default_temperature?: number | null; } +/** + * Stepped user-facing memory-context window preset. Mirrors the core + * `MemoryContextWindow` enum (`src/openhuman/config/schema/agent.rs`) + * — the actual char budgets are owned by the core, this is the label. + */ +export type MemoryContextWindow = 'minimal' | 'balanced' | 'extended' | 'maximum'; + +export const MEMORY_CONTEXT_WINDOWS: MemoryContextWindow[] = [ + 'minimal', + 'balanced', + 'extended', + 'maximum', +]; + export interface MemorySettingsUpdate { backend?: string | null; auto_save?: boolean | null; embedding_provider?: string | null; embedding_model?: string | null; embedding_dimensions?: number | null; + /** One of `MEMORY_CONTEXT_WINDOWS`. */ + memory_window?: MemoryContextWindow | null; } export interface RuntimeSettingsUpdate { diff --git a/docs/MEMORY_CONTEXT_WINDOW.md b/docs/MEMORY_CONTEXT_WINDOW.md new file mode 100644 index 000000000..d2616d93a --- /dev/null +++ b/docs/MEMORY_CONTEXT_WINDOW.md @@ -0,0 +1,74 @@ +# Long-term memory window + +User-facing setting that controls how much long-term memory OpenHuman injects +into every new agent / orchestrator session. + +## What it changes + +Two distinct injection paths share one preset so the user only has to make one +choice: + +1. **Recalled memory + working memory** — the `[Memory context]` and + `[User working memory]` blocks built by + [`DefaultMemoryLoader::load_context`](../src/openhuman/agent/memory_loader.rs) + on every turn. +2. **Tree-summarizer root summaries** — the per-namespace root summaries + pulled into the system prompt on the first turn of a session by + [`fetch_learned_context`](../src/openhuman/agent/harness/session/turn.rs). + +Both call sites read the active limits from +[`AgentConfig::resolved_memory_limits`](../src/openhuman/config/schema/agent.rs). + +## Presets + +| Preset | Recall cap (chars) | Per-namespace tree cap | Total tree cap | +|---|---:|---:|---:| +| `minimal` | 800 | 2 000 | 8 000 | +| `balanced` *(default)* | 2 000 | 8 000 | 32 000 | +| `extended` | 4 000 | 16 000 | 64 000 | +| `maximum` | 8 000 | 32 000 | 128 000 | + +`balanced` matches the historical hard-coded behaviour. `maximum` is bounded so +prompts cannot grow beyond ~32k tokens of injected long-term memory regardless +of how many namespaces a workspace accumulates. + +## Where the setting lives + +- **Storage**: `agent.memory_window` in the persisted config TOML. +- **Read**: `openhuman.get_config` → `config.agent.memory_window`. +- **Write**: `openhuman.update_memory_settings` with + `{ "memory_window": "minimal" | "balanced" | "extended" | "maximum" }`. +- **UI**: `Settings → Memory Data → Long-term memory window` + (`app/src/components/settings/components/MemoryWindowControl.tsx`). + +## Design rules + +- **Core owns the budgets.** The frontend stores a label only; mapping + label → char caps lives in + [`MemoryContextWindow::limits`](../src/openhuman/config/schema/agent.rs). + A buggy or future client cannot pick "infinite memory" by accident. +- **Stepped, not freeform.** The presets are deliberately discrete so the UX + copy (`Minimal` / `Balanced` / `Extended` / `Maximum`) and the actual + budgets line up. There is no raw "memory budget" slider in the UI. +- **Backward-compat raw override (unmigrated configs only).** A config from + before this setting existed deserializes with `memory_window = None`. While + unmigrated, the resolver falls back to the legacy + `agent.max_memory_context_chars` for the recall cap (clamped to the + `Maximum` preset's ceiling). The first time a preset is written — by the UI + or by any client — `memory_window` becomes `Some(...)` and the preset is + authoritative; the legacy raw field is then ignored entirely. This means + picking `Minimal` in the UI on a config that previously had a wider raw + value really does shrink injection size. +- **Safety bound.** The `maximum` preset is the absolute ceiling. No code path + in the harness reads memory caps from anywhere other than + `resolved_memory_limits`, so this ceiling is the single fact to audit. + +## Adding a new preset + +1. Extend `MemoryContextWindow` in `src/openhuman/config/schema/agent.rs` and + add its limits in `MemoryContextWindow::limits`. +2. Update `as_str` / `from_str_opt` so the RPC + config TOML round-trip works. +3. Add the label to `MEMORY_CONTEXT_WINDOWS` and the meta map in + `app/src/components/settings/components/MemoryWindowControl.tsx`. +4. Add unit tests in both `memory_window_tests` (Rust) and + `MemoryWindowControl.test.tsx` (Vitest). diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index 695b43054..7cfb37bfd 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -1024,8 +1024,12 @@ impl Agent { .memory(memory) .tool_dispatcher(tool_dispatcher) .memory_loader(Box::new( - DefaultMemoryLoader::new(5, config.memory.min_relevance_score) - .with_max_chars(config.agent.max_memory_context_chars), + DefaultMemoryLoader::new(5, config.memory.min_relevance_score).with_max_chars( + config + .agent + .resolved_memory_limits() + .max_memory_context_chars, + ), )) .prompt_builder(prompt_builder) .config(config.agent.clone()) diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 15d9116fd..c9a5b964f 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -1205,7 +1205,16 @@ impl Agent { // long-term context. Done synchronously here because the calls // are filesystem reads, not provider/network round-trips, and // happen exactly once per session (only on the first turn). - let tree_root_summaries = collect_tree_root_summaries(&self.workspace_dir); + // + // Per-namespace + total caps come from the user-facing memory + // window preset on `AgentConfig` so changing the slider in the + // UI takes effect on the very next session-start. + let limits = self.config.resolved_memory_limits(); + let tree_root_summaries = collect_tree_root_summaries( + &self.workspace_dir, + limits.per_namespace_max_chars, + limits.total_tree_max_chars, + ); LearnedContextData { observations: obs_entries @@ -1483,18 +1492,19 @@ impl Agent { /// Wrapper around /// [`crate::openhuman::tree_summarizer::store::collect_root_summaries_with_caps`] -/// that pins the per-namespace and total caps to the constants exposed -/// from `context::prompt`. The store helper does the actual work — this -/// indirection just keeps the call site readable and the caps in one -/// place where the prompt section is defined. -fn collect_tree_root_summaries(workspace_dir: &std::path::Path) -> Vec<(String, String)> { - use crate::openhuman::context::prompt::{ - USER_MEMORY_PER_NAMESPACE_MAX_CHARS, USER_MEMORY_TOTAL_MAX_CHARS, - }; +/// that takes user-resolved per-namespace and total caps. The actual +/// limits are derived from the active +/// [`crate::openhuman::config::schema::agent::MemoryContextWindow`] +/// preset by [`crate::openhuman::config::schema::agent::AgentConfig::resolved_memory_limits`]. +fn collect_tree_root_summaries( + workspace_dir: &std::path::Path, + per_namespace_cap: usize, + total_cap: usize, +) -> Vec<(String, String)> { crate::openhuman::tree_summarizer::store::collect_root_summaries_with_caps( workspace_dir, - USER_MEMORY_PER_NAMESPACE_MAX_CHARS, - USER_MEMORY_TOTAL_MAX_CHARS, + per_namespace_cap, + total_cap, ) } diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index f50e6569d..ad4c17ec7 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -276,7 +276,7 @@ fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() { ); let long = "x".repeat(500); assert_eq!(sanitize_learned_entry(&long).chars().count(), 200); - assert!(collect_tree_root_summaries(agent.workspace_dir()).is_empty()); + assert!(collect_tree_root_summaries(agent.workspace_dir(), 8_000, 32_000).is_empty()); } #[tokio::test] diff --git a/src/openhuman/agent/prompts/types.rs b/src/openhuman/agent/prompts/types.rs index a74085cdc..d39652c73 100644 --- a/src/openhuman/agent/prompts/types.rs +++ b/src/openhuman/agent/prompts/types.rs @@ -30,10 +30,21 @@ pub(crate) const USER_FILE_MAX_CHARS: usize = 2_000; /// asked for ("at least 2000 tokens of user memory") for a single /// namespace, and matches what the tree summarizer's `Day` level /// already enforces upstream. +/// +/// **Note**: this constant matches the `Balanced` preset of +/// [`crate::openhuman::config::schema::agent::MemoryContextWindow`] — +/// the live agent harness now resolves the per-namespace cap from that +/// preset (see `AgentConfig::resolved_memory_limits`). The constant is +/// kept as the documented baseline for prompt-section authors. +#[allow(dead_code)] pub(crate) const USER_MEMORY_PER_NAMESPACE_MAX_CHARS: usize = 8_000; /// Hard ceiling across all namespaces, so a workspace with 30 namespaces /// doesn't burn the entire context window. ~32 000 chars ≈ 8 000 tokens. +/// +/// **Note**: same Balanced-preset baseline relationship as +/// `USER_MEMORY_PER_NAMESPACE_MAX_CHARS` — see its rustdoc. +#[allow(dead_code)] pub(crate) const USER_MEMORY_TOTAL_MAX_CHARS: usize = 32_000; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 1086422a8..cf6c53186 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -163,6 +163,12 @@ pub struct MemorySettingsPatch { pub embedding_provider: Option, pub embedding_model: Option, pub embedding_dimensions: Option, + /// Stepped user-facing memory-context window preset (see + /// [`crate::openhuman::config::schema::agent::MemoryContextWindow`]). + /// Accepts `"minimal" | "balanced" | "extended" | "maximum"`. + /// Unknown values are silently ignored so old clients can keep + /// posting partial patches. + pub memory_window: Option, } #[derive(Debug, Clone, Default)] @@ -274,6 +280,18 @@ pub async fn apply_memory_settings( if let Some(dimensions) = update.embedding_dimensions { config.memory.embedding_dimensions = dimensions; } + if let Some(window_label) = update.memory_window.as_deref() { + if let Some(window) = + crate::openhuman::config::schema::MemoryContextWindow::from_str_opt(window_label) + { + config.agent.memory_window = Some(window); + } else { + tracing::warn!( + requested = window_label, + "[config] unknown memory_window preset — leaving existing setting unchanged" + ); + } + } config.save().await.map_err(|e| e.to_string())?; let snapshot = snapshot_config_json(config)?; Ok(RpcOutcome::new( diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index b507a7d95..40cfac010 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -260,6 +260,7 @@ async fn apply_memory_settings_updates_all_provided_fields() { embedding_provider: Some("ollama".into()), embedding_model: Some("nomic".into()), embedding_dimensions: Some(768), + memory_window: Some("extended".into()), }; let _ = apply_memory_settings(&mut cfg, patch).await.expect("apply"); assert_eq!(cfg.memory.backend, "sqlite"); @@ -267,6 +268,45 @@ async fn apply_memory_settings_updates_all_provided_fields() { assert_eq!(cfg.memory.embedding_provider, "ollama"); assert_eq!(cfg.memory.embedding_model, "nomic"); assert_eq!(cfg.memory.embedding_dimensions, 768); + assert_eq!( + cfg.agent.memory_window, + Some(crate::openhuman::config::schema::MemoryContextWindow::Extended) + ); +} + +#[tokio::test] +async fn apply_memory_settings_ignores_unknown_memory_window_label() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.agent.memory_window = Some(crate::openhuman::config::schema::MemoryContextWindow::Balanced); + let original = cfg.agent.memory_window; + let patch = MemorySettingsPatch { + memory_window: Some("ginormous".into()), + ..MemorySettingsPatch::default() + }; + let _ = apply_memory_settings(&mut cfg, patch).await.expect("apply"); + assert_eq!(cfg.agent.memory_window, original); +} + +#[tokio::test] +async fn apply_memory_settings_round_trips_all_window_labels() { + use crate::openhuman::config::schema::MemoryContextWindow; + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let windows: [MemoryContextWindow; 4] = [ + MemoryContextWindow::Minimal, + MemoryContextWindow::Balanced, + MemoryContextWindow::Extended, + MemoryContextWindow::Maximum, + ]; + for window in windows { + let patch = MemorySettingsPatch { + memory_window: Some(window.as_str().to_string()), + ..MemorySettingsPatch::default() + }; + apply_memory_settings(&mut cfg, patch).await.expect("apply"); + assert_eq!(cfg.agent.memory_window, Some(window)); + } } #[tokio::test] diff --git a/src/openhuman/config/schema/agent.rs b/src/openhuman/config/schema/agent.rs index 82009bb08..a04f64f24 100644 --- a/src/openhuman/config/schema/agent.rs +++ b/src/openhuman/config/schema/agent.rs @@ -3,6 +3,98 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// User-facing memory-context window preset. +/// +/// Each preset maps deterministically (via [`MemoryContextWindow::limits`]) +/// to the actual character budgets used by the agent harness when +/// injecting recalled memory and the long-term memory summary tree into +/// new agent / orchestrator sessions. The mapping is the single source +/// of truth — the frontend never decides budgets directly. Presets are +/// bounded (`Maximum` ≈ 8 000 chars of recall + ≈ 128 000 chars of root +/// summary, ≈ 32k tokens) so users cannot accidentally blow up prompts. +/// +/// See `docs/MEMORY_CONTEXT_WINDOW.md` for the user-facing tradeoff +/// guidance and the per-preset numbers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum MemoryContextWindow { + /// Cheapest, lightest. Tight recall + tree-summary budget. + Minimal, + /// Sensible default — current behaviour. + #[default] + Balanced, + /// More continuity at the cost of more tokens per run. + Extended, + /// Maximum allowed continuity — meaningfully larger token bill. + Maximum, +} + +/// Concrete character budgets resolved from a [`MemoryContextWindow`] +/// preset. All three caps are bounded to keep prompt growth safe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MemoryWindowLimits { + /// Cap for `[Memory context]` + `[User working memory]` injection + /// produced by `DefaultMemoryLoader`. + pub max_memory_context_chars: usize, + /// Per-namespace cap when collecting tree-summarizer root summaries + /// for the system prompt (first turn only). + pub per_namespace_max_chars: usize, + /// Hard ceiling across all namespaces for the tree-summary block. + pub total_tree_max_chars: usize, +} + +impl MemoryContextWindow { + /// Return the canonical budgets for this preset. The mapping is + /// intentionally stepped (no continuous slider) so the UI and core + /// stay aligned and impact is predictable. + pub fn limits(self) -> MemoryWindowLimits { + match self { + MemoryContextWindow::Minimal => MemoryWindowLimits { + max_memory_context_chars: 800, + per_namespace_max_chars: 2_000, + total_tree_max_chars: 8_000, + }, + MemoryContextWindow::Balanced => MemoryWindowLimits { + max_memory_context_chars: 2_000, + per_namespace_max_chars: 8_000, + total_tree_max_chars: 32_000, + }, + MemoryContextWindow::Extended => MemoryWindowLimits { + max_memory_context_chars: 4_000, + per_namespace_max_chars: 16_000, + total_tree_max_chars: 64_000, + }, + MemoryContextWindow::Maximum => MemoryWindowLimits { + max_memory_context_chars: 8_000, + per_namespace_max_chars: 32_000, + total_tree_max_chars: 128_000, + }, + } + } + + /// Stable lowercase label for serialization across CLI / RPC / UI. + pub fn as_str(self) -> &'static str { + match self { + MemoryContextWindow::Minimal => "minimal", + MemoryContextWindow::Balanced => "balanced", + MemoryContextWindow::Extended => "extended", + MemoryContextWindow::Maximum => "maximum", + } + } + + /// Parse from the lowercase label produced by [`Self::as_str`]. + /// Returns `None` for unknown inputs so callers can fall back. + pub fn from_str_opt(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "minimal" => Some(Self::Minimal), + "balanced" => Some(Self::Balanced), + "extended" => Some(Self::Extended), + "maximum" => Some(Self::Maximum), + _ => None, + } + } +} + /// Configuration for a delegate sub-agent used by the `delegate` tool. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct DelegateAgentConfig { @@ -39,10 +131,25 @@ pub struct AgentConfig { pub max_parallel_tools: usize, #[serde(default = "default_agent_tool_dispatcher")] pub tool_dispatcher: String, - /// Maximum characters of memory context to inject per turn. - /// Higher values provide richer context but consume more of the context window. + /// **Legacy** — maximum characters of memory context to inject per + /// turn. Prefer [`AgentConfig::memory_window`]; this field is only + /// honoured for unmigrated configs (those that have never set the + /// preset). Once a preset is explicitly chosen, the preset is + /// authoritative and this value is ignored. #[serde(default = "default_max_memory_context_chars")] pub max_memory_context_chars: usize, + /// Stepped user-facing preset that maps to the actual memory + /// injection budgets. See [`MemoryContextWindow`]. + /// + /// `None` means "no preset has been chosen yet" (e.g. a config + /// upgraded from a build that predates this setting). In that + /// case [`AgentConfig::resolved_memory_limits`] honours the legacy + /// raw `max_memory_context_chars` field for backward compatibility. + /// Once the user picks a preset (or any caller writes one) it + /// becomes authoritative — the raw field is then ignored, so the + /// UI control is the single source of truth from that point on. + #[serde(default)] + pub memory_window: Option, /// Per-channel maximum permission level for tool execution. /// Keys are channel names (e.g., "telegram", "discord", "web", "cli"). /// Values are permission levels: "none", "readonly", "write", "execute", "dangerous". @@ -83,6 +190,39 @@ fn default_max_memory_context_chars() -> usize { 2000 } +impl AgentConfig { + /// Resolve the active memory-context budgets for this agent config. + /// + /// Two cases: + /// + /// 1. **Preset chosen** (`memory_window = Some(_)`) — the preset is + /// authoritative. The legacy raw `max_memory_context_chars` + /// field is ignored entirely. This is the steady-state path: the + /// UI control is the single source of truth. + /// + /// 2. **Unmigrated config** (`memory_window = None`) — fall back to + /// the legacy raw `max_memory_context_chars` for the recall cap + /// so a config upgraded from an older build keeps its previous + /// recall behaviour. The raw value is still bounded by the + /// `Maximum` preset's recall cap so safety limits are preserved. + /// Tree-summary caps come from the `Balanced` baseline because + /// older builds had no notion of a per-namespace tree cap on + /// this code path. + pub fn resolved_memory_limits(&self) -> MemoryWindowLimits { + match self.memory_window { + Some(window) => window.limits(), + None => { + let mut limits = MemoryContextWindow::Balanced.limits(); + let hard_cap = MemoryContextWindow::Maximum + .limits() + .max_memory_context_chars; + limits.max_memory_context_chars = self.max_memory_context_chars.min(hard_cap); + limits + } + } + } +} + impl Default for AgentConfig { fn default() -> Self { Self { @@ -93,8 +233,165 @@ impl Default for AgentConfig { max_parallel_tools: default_max_parallel_tools(), tool_dispatcher: default_agent_tool_dispatcher(), max_memory_context_chars: default_max_memory_context_chars(), + memory_window: None, channel_permissions: std::collections::HashMap::new(), tool_result_budget_bytes: default_tool_result_budget_bytes(), } } } + +#[cfg(test)] +mod memory_window_tests { + use super::*; + + #[test] + fn presets_are_strictly_ordered_and_bounded() { + let m = MemoryContextWindow::Minimal.limits(); + let b = MemoryContextWindow::Balanced.limits(); + let e = MemoryContextWindow::Extended.limits(); + let max = MemoryContextWindow::Maximum.limits(); + + // Recall cap grows monotonically with preset size. + assert!(m.max_memory_context_chars < b.max_memory_context_chars); + assert!(b.max_memory_context_chars < e.max_memory_context_chars); + assert!(e.max_memory_context_chars < max.max_memory_context_chars); + + // Tree summary caps grow monotonically too. + assert!(m.per_namespace_max_chars < b.per_namespace_max_chars); + assert!(b.per_namespace_max_chars < e.per_namespace_max_chars); + assert!(e.per_namespace_max_chars < max.per_namespace_max_chars); + assert!(m.total_tree_max_chars < max.total_tree_max_chars); + + // Hard ceiling is bounded — Maximum still leaves headroom in a + // typical 200k-token context window. + assert!(max.total_tree_max_chars <= 128_000); + } + + #[test] + fn balanced_matches_legacy_defaults() { + // Balanced preset must keep historical behaviour: 2 000 char + // recall budget and 32 000 char total tree-summary cap (used to + // be hard-coded constants in `agent/prompts/types.rs`). + let b = MemoryContextWindow::Balanced.limits(); + assert_eq!(b.max_memory_context_chars, 2_000); + assert_eq!(b.per_namespace_max_chars, 8_000); + assert_eq!(b.total_tree_max_chars, 32_000); + } + + #[test] + fn default_agent_config_is_unmigrated_and_resolves_to_balanced_caps() { + // Default = `memory_window: None` (unmigrated). The recall cap + // falls back to the legacy `max_memory_context_chars` default + // (2 000), which matches Balanced — so the resolved limits are + // byte-identical to the historical behaviour. + let cfg = AgentConfig::default(); + assert_eq!(cfg.memory_window, None); + assert_eq!( + cfg.resolved_memory_limits(), + MemoryContextWindow::Balanced.limits() + ); + } + + #[test] + fn explicit_preset_is_authoritative_and_ignores_legacy_raw_field() { + // Once Minimal is chosen, the preset's recall cap (800) is what + // the harness sees — even if the legacy raw field still holds a + // wider value from before the user picked a preset. Without + // this, switching to `Minimal` in the UI would silently fail to + // shrink the recall budget. + let cfg = AgentConfig { + memory_window: Some(MemoryContextWindow::Minimal), + max_memory_context_chars: 4_000, + ..AgentConfig::default() + }; + assert_eq!( + cfg.resolved_memory_limits(), + MemoryContextWindow::Minimal.limits(), + "explicit preset must override legacy raw field" + ); + } + + #[test] + fn unmigrated_config_honours_legacy_raw_field_within_safety_ceiling() { + // Unmigrated power-user config with a legacy override of 4 000 + // keeps that recall cap on upgrade so behaviour doesn't shrink + // silently. Tree caps come from the Balanced baseline because + // older builds had no per-namespace cap on this code path. + let cfg = AgentConfig { + memory_window: None, + max_memory_context_chars: 4_000, + ..AgentConfig::default() + }; + let limits = cfg.resolved_memory_limits(); + assert_eq!(limits.max_memory_context_chars, 4_000); + assert_eq!( + limits.per_namespace_max_chars, + MemoryContextWindow::Balanced + .limits() + .per_namespace_max_chars + ); + + // An unbounded legacy value is clamped to the Maximum preset's + // recall cap so on-disk overrides can't blow up prompts. + let runaway = AgentConfig { + memory_window: None, + max_memory_context_chars: 1_000_000, + ..AgentConfig::default() + }; + assert_eq!( + runaway.resolved_memory_limits().max_memory_context_chars, + MemoryContextWindow::Maximum + .limits() + .max_memory_context_chars + ); + } + + #[test] + fn switching_preset_can_shrink_recall_below_legacy_value() { + // Regression for the CodeRabbit concern: an unmigrated config + // with a wide legacy override that then explicitly picks + // `Minimal` in the UI must end up with the Minimal recall cap, + // not the legacy value. + let mut cfg = AgentConfig { + memory_window: None, + max_memory_context_chars: 4_000, + ..AgentConfig::default() + }; + assert_eq!(cfg.resolved_memory_limits().max_memory_context_chars, 4_000); + cfg.memory_window = Some(MemoryContextWindow::Minimal); + assert_eq!( + cfg.resolved_memory_limits().max_memory_context_chars, + MemoryContextWindow::Minimal + .limits() + .max_memory_context_chars + ); + } + + #[test] + fn from_str_opt_round_trips() { + for window in [ + MemoryContextWindow::Minimal, + MemoryContextWindow::Balanced, + MemoryContextWindow::Extended, + MemoryContextWindow::Maximum, + ] { + assert_eq!( + MemoryContextWindow::from_str_opt(window.as_str()), + Some(window) + ); + } + assert_eq!( + MemoryContextWindow::from_str_opt("MAXIMUM"), + Some(MemoryContextWindow::Maximum) + ); + assert_eq!(MemoryContextWindow::from_str_opt("nonsense"), None); + } + + #[test] + fn enum_serializes_as_lowercase_string() { + let json = serde_json::to_string(&MemoryContextWindow::Extended).unwrap(); + assert_eq!(json, "\"extended\""); + let back: MemoryContextWindow = serde_json::from_str("\"minimal\"").unwrap(); + assert_eq!(back, MemoryContextWindow::Minimal); + } +} diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index b1afc91d8..83977def6 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -30,7 +30,7 @@ mod tools; mod update; pub use accessibility::ScreenIntelligenceConfig; -pub use agent::{AgentConfig, DelegateAgentConfig}; +pub use agent::{AgentConfig, DelegateAgentConfig, MemoryContextWindow, MemoryWindowLimits}; pub use autocomplete::AutocompleteConfig; pub use autonomy::AutonomyConfig; pub use channels::{ diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index dd8bee50a..e5d412288 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -23,6 +23,8 @@ struct MemorySettingsUpdate { embedding_provider: Option, embedding_model: Option, embedding_dimensions: Option, + /// One of `"minimal" | "balanced" | "extended" | "maximum"`. + memory_window: Option, } #[derive(Debug, Deserialize)] @@ -293,6 +295,10 @@ pub fn schemas(function: &str) -> ControllerSchema { comment: "Embedding dimensions.", required: false, }, + optional_string( + "memory_window", + "Stepped long-term memory window preset: minimal | balanced | extended | maximum.", + ), ], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }, @@ -622,6 +628,7 @@ fn handle_update_memory_settings(params: Map) -> ControllerFuture embedding_provider: update.embedding_provider, embedding_model: update.embedding_model, embedding_dimensions: update.embedding_dimensions, + memory_window: update.memory_window, }; to_json(config_rpc::load_and_apply_memory_settings(patch).await?) })