feat(memory): user-facing long-term memory window preset (#1137) (#1161)

This commit is contained in:
Steven Enamakel
2026-05-03 21:50:18 -07:00
committed by GitHub
parent 67931013a1
commit ac1363d435
14 changed files with 821 additions and 21 deletions
@@ -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<MemoryContextWindow, PresetMeta> = {
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<string, unknown>;
const config = (root.config as Record<string, unknown> | undefined) ?? root;
const agent = config.agent as Record<string, unknown> | 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<MemoryContextWindow>('balanced');
const [pending, setPending] = useState<MemoryContextWindow | null>(null);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState<MemoryContextWindow | null>(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 (
<div
className="border border-border rounded-lg p-4 space-y-3 bg-background"
data-testid="memory-window-control">
<div className="flex items-baseline justify-between">
<div>
<h3 className="text-base font-semibold">Long-term memory window</h3>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
</div>
<div
role="radiogroup"
aria-label="Long-term memory window"
className="grid grid-cols-1 sm:grid-cols-4 gap-2">
{MEMORY_CONTEXT_WINDOWS.map(option => {
const optionMeta = MEMORY_WINDOW_PRESET_META[option];
const isActive = activeForUi === option;
const isSaving = saving === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={isActive}
data-testid={`memory-window-option-${option}`}
disabled={!loaded || (saving !== null && !isSaving)}
onClick={() => void select(option)}
className={`text-left rounded-md border px-3 py-2 transition-colors ${
isActive ? 'border-primary bg-primary/10' : 'border-border hover:bg-accent/40'
} disabled:opacity-60 disabled:cursor-not-allowed`}>
<div className="flex items-center justify-between">
<span className="font-medium">{optionMeta.label}</span>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
{optionMeta.badge}
</span>
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground" data-testid="memory-window-hint">
{meta.hint}
</p>
</div>
);
};
export default MemoryWindowControl;
@@ -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(<MemoryWindowControl />);
// 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(<MemoryWindowControl />);
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(<MemoryWindowControl />);
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(<MemoryWindowControl onSaved={onSaved} />);
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(<MemoryWindowControl onError={onError} />);
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'
);
});
});
@@ -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<ToastNotification[]>([]);
const addToast = (toast: Omit<ToastNotification, 'id'>) => {
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
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 (
<div className="z-10 relative">
<SettingsHeader
@@ -27,7 +42,8 @@ const MemoryDataPanel = () => {
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4">
<div className="p-4 space-y-4">
<MemoryWindowControl onError={handleWindowError} onSaved={handleWindowSaved} />
<MemoryWorkspace onToast={addToast} />
</div>
<ToastContainer notifications={toasts} onRemove={removeToast} />
+16
View File
@@ -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 {
+74
View File
@@ -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).
@@ -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())
+21 -11
View File
@@ -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,
)
}
@@ -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]
+11
View File
@@ -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;
// ─────────────────────────────────────────────────────────────────────────────
+18
View File
@@ -163,6 +163,12 @@ pub struct MemorySettingsPatch {
pub embedding_provider: Option<String>,
pub embedding_model: Option<String>,
pub embedding_dimensions: Option<usize>,
/// 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<String>,
}
#[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(
+40
View File
@@ -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]
+299 -2
View File
@@ -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<Self> {
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<MemoryContextWindow>,
/// 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);
}
}
+1 -1
View File
@@ -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::{
+7
View File
@@ -23,6 +23,8 @@ struct MemorySettingsUpdate {
embedding_provider: Option<String>,
embedding_model: Option<String>,
embedding_dimensions: Option<usize>,
/// One of `"minimal" | "balanced" | "extended" | "maximum"`.
memory_window: Option<String>,
}
#[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<String, Value>) -> 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?)
})