mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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?)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user