mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): schema-driven memory-provider config surface
Make the desktop memory settings dynamic instead of hardcoded per
provider. The dropdown is now populated from discover_memory_providers()
(bundled + user-installed + pip) rather than a static enum, and each
provider's config panel is derived from its own get_config_schema() —
the same declaration `hermes memory setup` uses — so adding or porting a
provider is pure declaration with no bespoke UI, conditional, or
endpoint.
- memory_providers.py: reworked from a hand-written Hindsight registry
into a pure adapter (describe_provider + coerce_value) that normalizes
a provider's raw schema into typed fields — secret(+env), select,
boolean, typed text — carrying `when` conditionals, url, and required.
- MemoryProvider ABC: add optional read_current_config() (default {}),
the read-back mirror of save_config(). Ported mem0, hindsight, honcho,
holographic; the rest fall back to schema defaults safely.
- web_server GET/PUT /api/memory/providers/{name}/config now load the
live provider, derive its schema, write non-secrets via the provider's
own save_config() (each keeps its native storage), persist secrets to
the env store, and `when`-gate validation so hidden fields aren't
required or written. Secrets stay write-only (is_set only).
- ProviderConfigPanel: `when`-conditional visibility (handles Hindsight's
mode-gated duplicate keys), boolean toggle, and credential url links.
Dropdown driven by getMemoryStatus(); hardcoded enum removed.
Tests assert the mapping contract and endpoint behavior (schema
derivation, save-via-save_config, secret-never-returned, when-gating,
select rejection) against real bundled providers rather than a snapshot
of a hardcoded list.
This commit is contained in:
parent
03d9a95a74
commit
e48d18c8e2
16 changed files with 770 additions and 291 deletions
|
|
@ -11,6 +11,7 @@ import {
|
|||
getHermesConfigDefaults,
|
||||
getHermesConfigRecord,
|
||||
getHermesConfigSchema,
|
||||
getMemoryStatus,
|
||||
saveHermesConfig
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -198,6 +199,8 @@ export function ConfigSettings({
|
|||
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
|
||||
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
|
||||
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
|
||||
const [memoryProviderOptions, setMemoryProviderOptions] = useState<string[] | null>(null)
|
||||
const [memoryProviderLabels, setMemoryProviderLabels] = useState<Record<string, string>>({})
|
||||
const saveVersionRef = useRef(0)
|
||||
const [saveVersion, setSaveVersion] = useState(0)
|
||||
|
||||
|
|
@ -240,6 +243,37 @@ export function ConfigSettings({
|
|||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
// Memory provider dropdown is driven by backend discovery (bundled +
|
||||
// user-installed + pip plugins), not a hardcoded enum — every discovered
|
||||
// provider shows up automatically. '' is the built-in (MEMORY.md/USER.md).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getMemoryStatus()
|
||||
.then(status => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const names = status.providers.map(p => p.name)
|
||||
setMemoryProviderOptions(['', ...names])
|
||||
setMemoryProviderLabels({
|
||||
'': 'Built-in (MEMORY.md / USER.md)',
|
||||
...Object.fromEntries(
|
||||
status.providers.map(p => [p.name, p.description ? `${p.name} — ${p.description}` : p.name])
|
||||
)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setMemoryProviderOptions(null)
|
||||
setMemoryProviderLabels({})
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || saveVersion === 0) {
|
||||
return
|
||||
|
|
@ -361,10 +395,18 @@ export function ConfigSettings({
|
|||
enumOptions={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
||||
: enumOptionsFor(key, getNested(config, key), config)
|
||||
: key === 'memory.provider'
|
||||
? enumOptionsFor(key, getNested(config, key), config, memoryProviderOptions ?? [''])
|
||||
: enumOptionsFor(key, getNested(config, key), config)
|
||||
}
|
||||
onChange={value => updateConfig(setNested(config, key, value))}
|
||||
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
|
||||
optionLabels={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? elevenLabsVoiceLabels
|
||||
: key === 'memory.provider'
|
||||
? memoryProviderLabels
|
||||
: undefined
|
||||
}
|
||||
schema={field}
|
||||
schemaKey={key}
|
||||
value={getNested(config, key)}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,10 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
|||
'code_execution.mode': ['project', 'strict'],
|
||||
'context.engine': ['compressor', 'default', 'custom'],
|
||||
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
|
||||
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
|
||||
// memory.provider is intentionally absent: the dropdown is populated at
|
||||
// runtime from backend discovery (discover_memory_providers) in
|
||||
// config-settings.tsx, so bundled + user-installed + pip providers all show
|
||||
// up without hand-editing this list.
|
||||
// Terminal execution backends — kept in sync with the dispatch ladder in
|
||||
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
|
||||
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).
|
||||
|
|
|
|||
|
|
@ -6,10 +6,18 @@ import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from
|
|||
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('lists Hindsight as a built-in desktop memory provider option', () => {
|
||||
const options = enumOptionsFor('memory.provider', '', {})
|
||||
it('has no hardcoded memory.provider enum (driven by backend discovery)', () => {
|
||||
// The dropdown is populated at runtime from discover_memory_providers();
|
||||
// there must be no static enum here or new providers won't appear.
|
||||
expect(enumOptionsFor('memory.provider', '', {})).toBeUndefined()
|
||||
})
|
||||
|
||||
expect(options).toContain('hindsight')
|
||||
it('keeps the current memory provider selectable while discovery loads', () => {
|
||||
// config-settings passes [''] as the fallback before discovery resolves;
|
||||
// the active provider is appended so it never vanishes from the dropdown.
|
||||
const options = enumOptionsFor('memory.provider', 'hindsight', {}, [''])
|
||||
|
||||
expect(options).toEqual(['', 'hindsight'])
|
||||
})
|
||||
|
||||
describe('defineFieldCopy', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MemoryProviderConfig } from '@/types/hermes'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
const getMemoryProviderConfig = vi.fn()
|
||||
const saveMemoryProviderConfig = vi.fn()
|
||||
|
|
@ -16,62 +16,65 @@ vi.mock('@/store/notifications', () => ({
|
|||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function hindsightSchema(overrides: Partial<MemoryProviderConfig['fields'][number]>[] = []): MemoryProviderConfig {
|
||||
const fields: MemoryProviderConfig['fields'] = [
|
||||
{
|
||||
function field(partial: Partial<MemoryProviderField> & Pick<MemoryProviderField, 'key'>): MemoryProviderField {
|
||||
return {
|
||||
default: '',
|
||||
description: '',
|
||||
is_set: false,
|
||||
kind: 'text',
|
||||
label: partial.key,
|
||||
options: [],
|
||||
placeholder: '',
|
||||
required: false,
|
||||
url: '',
|
||||
value: '',
|
||||
value_type: 'str',
|
||||
when: [],
|
||||
...partial
|
||||
}
|
||||
}
|
||||
|
||||
function hindsightSchema(overrides: Partial<MemoryProviderField>[] = []): MemoryProviderConfig {
|
||||
const fields: MemoryProviderField[] = [
|
||||
field({
|
||||
key: 'mode',
|
||||
label: 'Mode',
|
||||
kind: 'select',
|
||||
value: 'cloud',
|
||||
description: 'How Hermes connects to Hindsight.',
|
||||
placeholder: '',
|
||||
is_set: true,
|
||||
description: 'How Hermes connects to Hindsight.',
|
||||
options: [
|
||||
{ value: 'cloud', label: 'Cloud', description: 'Hindsight Cloud API (lightweight, just needs an API key)' },
|
||||
{ value: 'local_external', label: 'Local External', description: 'Connect to an existing Hindsight instance' }
|
||||
]
|
||||
},
|
||||
{
|
||||
}),
|
||||
field({
|
||||
key: 'api_key',
|
||||
label: 'API key',
|
||||
kind: 'secret',
|
||||
value: '',
|
||||
description: 'Used to authenticate with the Hindsight API.',
|
||||
placeholder: 'Enter Hindsight API key',
|
||||
is_set: false,
|
||||
options: []
|
||||
},
|
||||
{
|
||||
key: 'api_url',
|
||||
label: 'API URL',
|
||||
kind: 'text',
|
||||
value: 'https://api.hindsight.vectorize.io',
|
||||
description: '',
|
||||
placeholder: '',
|
||||
is_set: true,
|
||||
options: []
|
||||
},
|
||||
{ key: 'bank_id', label: 'Bank ID', kind: 'text', value: 'hermes', description: '', placeholder: '', is_set: true, options: [] },
|
||||
{
|
||||
placeholder: 'Enter Hindsight API key'
|
||||
}),
|
||||
field({ key: 'api_url', label: 'API URL', value: 'https://api.hindsight.vectorize.io', is_set: true }),
|
||||
field({ key: 'bank_id', label: 'Bank ID', value: 'hermes', is_set: true }),
|
||||
field({
|
||||
key: 'recall_budget',
|
||||
label: 'Recall budget',
|
||||
kind: 'select',
|
||||
value: 'mid',
|
||||
description: '',
|
||||
placeholder: '',
|
||||
is_set: true,
|
||||
options: [
|
||||
{ value: 'low', label: 'low', description: '' },
|
||||
{ value: 'mid', label: 'mid', description: '' },
|
||||
{ value: 'high', label: 'high', description: '' }
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
return {
|
||||
name: 'hindsight',
|
||||
label: 'Hindsight',
|
||||
fields: fields.map((field, index) => ({ ...field, ...overrides[index] }))
|
||||
fields: fields.map((f, index) => ({ ...f, ...overrides[index] }))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,4 +142,55 @@ describe('ProviderConfigPanel', () => {
|
|||
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
|
||||
expect(container.querySelector('section')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows and hides fields based on their when-clause as the mode changes', async () => {
|
||||
getMemoryProviderConfig.mockResolvedValue({
|
||||
name: 'hindsight',
|
||||
label: 'Hindsight',
|
||||
fields: [
|
||||
field({
|
||||
key: 'mode',
|
||||
label: 'Mode',
|
||||
kind: 'select',
|
||||
value: 'cloud',
|
||||
options: [
|
||||
{ value: 'cloud', label: 'Cloud', description: '' },
|
||||
{ value: 'local_embedded', label: 'Local Embedded', description: '' }
|
||||
]
|
||||
}),
|
||||
field({ key: 'api_url', label: 'API URL', value: 'https://api', when: [{ key: 'mode', value: 'cloud' }] }),
|
||||
field({ key: 'llm_model', label: 'LLM model', value: 'gpt-4o-mini', when: [{ key: 'mode', value: 'local_embedded' }] })
|
||||
]
|
||||
})
|
||||
|
||||
await renderPanel()
|
||||
|
||||
// Cloud is selected: the cloud-gated field shows, the embedded one doesn't.
|
||||
expect(await screen.findByLabelText('API URL')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('LLM model')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Local Embedded' }))
|
||||
|
||||
// Switching to local_embedded flips which gated field is visible.
|
||||
expect(await screen.findByLabelText('LLM model')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('API URL')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a boolean field as a toggle and saves it as a string', async () => {
|
||||
getMemoryProviderConfig.mockResolvedValue({
|
||||
name: 'hindsight',
|
||||
label: 'Hindsight',
|
||||
fields: [field({ key: 'auto_recall', label: 'Auto recall', kind: 'boolean', value: 'true', value_type: 'bool', is_set: true })]
|
||||
})
|
||||
|
||||
await renderPanel()
|
||||
|
||||
const toggle = await screen.findByRole('switch')
|
||||
expect(toggle).toBeTruthy()
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', { auto_recall: 'false' }))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||
import { Check, Loader2, Save } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -12,12 +13,28 @@ import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
|||
import { CONTROL_TEXT } from './constants'
|
||||
import { LoadingState, Pill } from './primitives'
|
||||
|
||||
/** Seed editable values from the schema: non-secret fields keep their current
|
||||
* value, secret fields start blank (their value is never returned). */
|
||||
/** A field is active only when every clause in its `when` matches the current
|
||||
* values (e.g. Hindsight's `api_url` shows only `when` `mode === 'cloud'`). */
|
||||
function whenMatches(field: MemoryProviderField, values: Record<string, string>): boolean {
|
||||
return field.when.every(clause => String(values[clause.key] ?? '') === clause.value)
|
||||
}
|
||||
|
||||
/** Seed editable values from the schema. Secrets always start blank (their
|
||||
* value is never returned). Conditional fields are seeded against the
|
||||
* unconditional ones first so duplicate keys (same key, different `when`)
|
||||
* resolve to the variant that matches the current selection. */
|
||||
function seedValues(config: MemoryProviderConfig): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value])
|
||||
)
|
||||
const values: Record<string, string> = {}
|
||||
const seed = (field: MemoryProviderField) => {
|
||||
values[field.key] = field.kind === 'secret' ? '' : field.value
|
||||
}
|
||||
|
||||
config.fields.filter(f => f.when.length === 0).forEach(seed)
|
||||
config.fields.filter(f => f.when.length > 0 && whenMatches(f, values)).forEach(seed)
|
||||
// Backfill any key not yet seeded (hidden variants) so saves never send undefined.
|
||||
config.fields.filter(f => !(f.key in values)).forEach(seed)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
|
|
@ -53,6 +70,15 @@ function FieldControl({
|
|||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'boolean') {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={value === 'true'} onCheckedChange={checked => onChange(checked ? 'true' : 'false')} />
|
||||
<span className="text-xs text-muted-foreground">{value === 'true' ? 'On' : 'Off'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'secret') {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -133,6 +159,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
}
|
||||
|
||||
const secretFields = config.fields.filter(field => field.kind === 'secret')
|
||||
const visibleFields = config.fields.filter(field => whenMatches(field, values))
|
||||
|
||||
return (
|
||||
<section className="py-3">
|
||||
|
|
@ -155,9 +182,22 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
|
||||
{expanded && (
|
||||
<div className="mt-3 grid gap-4 rounded-xl bg-background/60 p-4">
|
||||
{config.fields.map(field => (
|
||||
<label className="grid gap-1.5" key={field.key}>
|
||||
<span className="text-xs font-medium text-muted-foreground">{field.label}</span>
|
||||
{visibleFields.map((field, index) => (
|
||||
<label className="grid gap-1.5" key={`${field.key}-${index}`}>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive">*</span>}
|
||||
{field.url && (
|
||||
<a
|
||||
className="font-normal text-primary underline-offset-2 hover:underline"
|
||||
href={field.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Get key
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type {
|
|||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
MemoryProviderConfig,
|
||||
MemoryStatus,
|
||||
MessagingPlatformsResponse,
|
||||
MessagingPlatformTestResponse,
|
||||
MessagingPlatformUpdate,
|
||||
|
|
@ -341,6 +342,13 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
|
|||
})
|
||||
}
|
||||
|
||||
export function getMemoryStatus(): Promise<MemoryStatus> {
|
||||
return window.hermesDesktop.api<MemoryStatus>({
|
||||
...profileScoped(),
|
||||
path: '/api/memory'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
|
||||
return window.hermesDesktop.api<MemoryProviderConfig>({
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export interface EnvVarInfo {
|
|||
url: null | string
|
||||
}
|
||||
|
||||
export type MemoryProviderFieldKind = 'secret' | 'select' | 'text'
|
||||
export type MemoryProviderFieldKind = 'boolean' | 'secret' | 'select' | 'text'
|
||||
|
||||
export interface MemoryProviderFieldOption {
|
||||
description: string
|
||||
|
|
@ -121,7 +121,15 @@ export interface MemoryProviderFieldOption {
|
|||
value: string
|
||||
}
|
||||
|
||||
/** A conditional-visibility clause: the field is shown only when the field
|
||||
* named `key` currently equals `value`. */
|
||||
export interface MemoryProviderFieldWhen {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MemoryProviderField {
|
||||
default: string
|
||||
description: string
|
||||
is_set: boolean
|
||||
key: string
|
||||
|
|
@ -129,7 +137,11 @@ export interface MemoryProviderField {
|
|||
label: string
|
||||
options: MemoryProviderFieldOption[]
|
||||
placeholder: string
|
||||
required: boolean
|
||||
url: string
|
||||
value: string
|
||||
value_type: string
|
||||
when: MemoryProviderFieldWhen[]
|
||||
}
|
||||
|
||||
export interface MemoryProviderConfig {
|
||||
|
|
@ -138,6 +150,18 @@ export interface MemoryProviderConfig {
|
|||
name: string
|
||||
}
|
||||
|
||||
export interface MemoryProviderInfo {
|
||||
configured: boolean
|
||||
description: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface MemoryStatus {
|
||||
active: string
|
||||
builtin_files: Record<string, number>
|
||||
providers: MemoryProviderInfo[]
|
||||
}
|
||||
|
||||
export interface MessagingEnvVarInfo {
|
||||
advanced: boolean
|
||||
description: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue