mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
Merge pull request #67206 from NousResearch/lane/c3-memory-panel
feat(desktop): declarative memory provider panel + built-in fix (salvage #51020, fixes #49513)
This commit is contained in:
commit
833bae3203
28 changed files with 2395 additions and 691 deletions
|
|
@ -18,14 +18,21 @@ import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config
|
|||
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
||||
import { PanelEmpty } from '../overlays/panel'
|
||||
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS } from './constants'
|
||||
import { FallbackModelsField } from './fallback-models-field'
|
||||
import { fieldCopyForSchemaKey } from './field-copy'
|
||||
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
||||
import {
|
||||
enumOptionsFor,
|
||||
getNested,
|
||||
isExternalMemoryProvider,
|
||||
prettyName,
|
||||
sectionFieldEntries,
|
||||
setNested
|
||||
} from './helpers'
|
||||
import { MemoryConnect } from './memory/connect'
|
||||
import { ProviderConfigPanel } from './memory/provider-config-panel'
|
||||
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import { ProviderConfigPanel } from './provider-config-panel'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
// provider — otherwise every provider's options render at once (the "totally
|
||||
|
|
@ -134,7 +141,9 @@ function ConfigField({
|
|||
? (optionLabels?.[option] ?? prettyName(option))
|
||||
: schemaKey === 'display.personality'
|
||||
? c.none
|
||||
: c.noneParen}
|
||||
: schemaKey === 'memory.provider'
|
||||
? c.builtinOnly
|
||||
: c.noneParen}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -336,14 +345,12 @@ export function ConfigSettings({
|
|||
}
|
||||
|
||||
const sectionFields = useMemo(() => {
|
||||
if (!schema) {
|
||||
if (!schema || !config) {
|
||||
return new Map<string, [string, ConfigFieldSchema][]>()
|
||||
}
|
||||
|
||||
return new Map(
|
||||
SECTIONS.map(s => [s.id, s.keys.flatMap(k => (schema[k] ? [[k, schema[k]] as [string, ConfigFieldSchema]] : []))])
|
||||
)
|
||||
}, [schema])
|
||||
return sectionFieldEntries(schema, config)
|
||||
}, [schema, config])
|
||||
|
||||
const fields = sectionFields.get(activeSectionId) ?? []
|
||||
|
||||
|
|
@ -459,7 +466,7 @@ export function ConfigSettings({
|
|||
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
||||
<ConfigField
|
||||
descriptionExtra={
|
||||
key === 'memory.provider' && Boolean(getNested(config, key)) ? (
|
||||
key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? (
|
||||
<MemoryConnect provider={String(getNested(config, key))} />
|
||||
) : undefined
|
||||
}
|
||||
|
|
@ -474,8 +481,8 @@ export function ConfigSettings({
|
|||
schemaKey={key}
|
||||
value={getNested(config, key)}
|
||||
/>
|
||||
{key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? (
|
||||
<ProviderConfigPanel provider={String(getNested(config, key))} />
|
||||
{key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? (
|
||||
<ProviderConfigPanel key={String(getNested(config, key))} provider={String(getNested(config, key))} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -247,7 +247,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', 'max', 'ultra'],
|
||||
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
|
||||
// Built-in memory is not a provider plugin: the empty sentinel renders as
|
||||
// "Built-in only" and a legacy literal `builtin` value is only kept visible
|
||||
// via enumOptionsFor's current-value passthrough (#49513).
|
||||
'memory.provider': ['', 'honcho', 'hindsight'],
|
||||
// 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).
|
||||
|
|
|
|||
|
|
@ -3,13 +3,43 @@ import { describe, expect, it } from 'vitest'
|
|||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy'
|
||||
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
|
||||
import {
|
||||
enumOptionsFor,
|
||||
getNested,
|
||||
isExternalMemoryProvider,
|
||||
providerGroup,
|
||||
sectionFieldEntries,
|
||||
setNested,
|
||||
stripToolsetLabel,
|
||||
toolsetDisplayLabel
|
||||
} from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('lists Hindsight as a built-in desktop memory provider option', () => {
|
||||
it('lists the desktop memory provider options in their declared order', () => {
|
||||
const options = enumOptionsFor('memory.provider', '', {})
|
||||
|
||||
expect(options).toContain('hindsight')
|
||||
// Built-in memory is not a provider plugin; the empty sentinel is the
|
||||
// only built-in-shaped entry (#49513).
|
||||
expect(options).toEqual(['', 'honcho', 'hindsight'])
|
||||
})
|
||||
|
||||
it('keeps a legacy literal builtin value visible as the current selection', () => {
|
||||
const options = enumOptionsFor('memory.provider', 'builtin', {})
|
||||
|
||||
expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin'])
|
||||
})
|
||||
|
||||
describe('isExternalMemoryProvider', () => {
|
||||
it('treats only real plugin names as external providers', () => {
|
||||
expect(isExternalMemoryProvider('honcho')).toBe(true)
|
||||
expect(isExternalMemoryProvider('hindsight')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats built-in aliases and empty values as not external', () => {
|
||||
for (const value of ['', 'builtin', 'built-in', 'Builtin', 'none', ' ', undefined, null, 7]) {
|
||||
expect(isExternalMemoryProvider(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineFieldCopy', () => {
|
||||
|
|
@ -176,4 +206,39 @@ describe('settings helpers', () => {
|
|||
expect(opts).toContain('xai')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sectionFieldEntries', () => {
|
||||
it('renders memory.provider from config even when the backend schema omits it', () => {
|
||||
const schema = { 'memory.memory_enabled': { type: 'boolean' as const } }
|
||||
const config: HermesConfigRecord = { memory: { memory_enabled: true, provider: '' } }
|
||||
|
||||
const memoryKeys = (sectionFieldEntries(schema, config).get('memory') ?? []).map(([key]) => key)
|
||||
|
||||
expect(memoryKeys).toContain('memory.provider')
|
||||
})
|
||||
|
||||
it('infers the field type from the config value when the schema omits the key', () => {
|
||||
const config: HermesConfigRecord = { memory: { provider: '', memory_enabled: true, memory_char_limit: 2200 } }
|
||||
|
||||
const fields = new Map(sectionFieldEntries({}, config).get('memory') ?? [])
|
||||
|
||||
expect(fields.get('memory.provider')?.type).toBe('string')
|
||||
expect(fields.get('memory.memory_enabled')?.type).toBe('boolean')
|
||||
expect(fields.get('memory.memory_char_limit')?.type).toBe('number')
|
||||
})
|
||||
|
||||
it('prefers the backend schema entry over inference when both exist', () => {
|
||||
const schema = { 'memory.provider': { type: 'select' as const, options: ['honcho'] } }
|
||||
const config: HermesConfigRecord = { memory: { provider: 'honcho' } }
|
||||
|
||||
const field = new Map(sectionFieldEntries(schema, config).get('memory') ?? []).get('memory.provider')
|
||||
|
||||
expect(field?.type).toBe('select')
|
||||
expect(field?.options).toEqual(['honcho'])
|
||||
})
|
||||
|
||||
it('hides declared keys absent from both schema and config', () => {
|
||||
expect(sectionFieldEntries({}, {}).get('memory') ?? []).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { asText } from '@/lib/text'
|
||||
import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants'
|
||||
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS, SECTIONS } from './constants'
|
||||
|
||||
// Canonical implementations live in @/lib/text; re-exported here so the many
|
||||
// settings/capabilities call sites keep their import path.
|
||||
|
|
@ -97,6 +97,40 @@ export function getNested(obj: HermesConfigRecord, path: string): unknown {
|
|||
return cur
|
||||
}
|
||||
|
||||
export function inferFieldSchema(value: unknown): ConfigFieldSchema {
|
||||
if (typeof value === 'boolean') {
|
||||
return { type: 'boolean' }
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return { type: 'number' }
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return { type: 'list' }
|
||||
}
|
||||
|
||||
return { type: 'string' }
|
||||
}
|
||||
|
||||
// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal.
|
||||
export function sectionFieldEntries(
|
||||
schema: Record<string, ConfigFieldSchema>,
|
||||
config: HermesConfigRecord
|
||||
): Map<string, [string, ConfigFieldSchema][]> {
|
||||
return new Map(
|
||||
SECTIONS.map(s => [
|
||||
s.id,
|
||||
s.keys.flatMap(k => {
|
||||
const value = getNested(config, k)
|
||||
const field = schema[k] ?? (value === undefined ? undefined : inferFieldSchema(value))
|
||||
|
||||
return field ? [[k, field] as [string, ConfigFieldSchema]] : []
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord {
|
||||
const clone = structuredClone(obj)
|
||||
const parts = configPathParts(path)
|
||||
|
|
@ -148,3 +182,16 @@ export function enumOptionsFor(
|
|||
|
||||
return current && !opts.includes(current) ? [...opts, current] : opts
|
||||
}
|
||||
|
||||
// Built-in memory (MEMORY.md/USER.md) is controlled by memory_enabled, not
|
||||
// memory.provider — only a real external plugin name gets provider-shaped
|
||||
// affordances (config panel, OAuth connect). See #49513.
|
||||
export function isExternalMemoryProvider(value: unknown): value is string {
|
||||
if (typeof value !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase()
|
||||
|
||||
return normalized !== '' && normalized !== 'builtin' && normalized !== 'built-in' && normalized !== 'none'
|
||||
}
|
||||
|
|
|
|||
128
apps/desktop/src/app/settings/memory/field-control.tsx
Normal file
128
apps/desktop/src/app/settings/memory/field-control.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Check, Info } from '@/lib/icons'
|
||||
import type { MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from '../constants'
|
||||
|
||||
// Fade the placeholder well below set values so example text never reads as data.
|
||||
const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground/45`
|
||||
|
||||
// Field label with an optional info tooltip, shared by the panel and modal rows.
|
||||
export function FieldTitle({ field }: { field: MemoryProviderField }) {
|
||||
if (!field.info) {
|
||||
return <>{field.label}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{field.label}
|
||||
<Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}>
|
||||
<Info aria-label={`About ${field.label}`} className="size-3.5 text-muted-foreground/70" />
|
||||
</Tip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Values are edited as strings; the backend coerces them to native types.
|
||||
export function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
onCommit
|
||||
}: {
|
||||
field: MemoryProviderField
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
// Present on autosaving surfaces: discrete controls commit on change, text-like
|
||||
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
|
||||
onCommit?: (value: string) => void
|
||||
}) {
|
||||
const set = (next: string) => {
|
||||
onChange(next)
|
||||
onCommit?.(next)
|
||||
}
|
||||
const commitDraft = onCommit ? () => onCommit(value) : undefined
|
||||
|
||||
if (field.kind === 'bool') {
|
||||
return <Switch checked={value === 'true'} onCheckedChange={checked => set(checked ? 'true' : 'false')} />
|
||||
}
|
||||
|
||||
if (field.kind === 'number') {
|
||||
return (
|
||||
<Input
|
||||
className={FIELD_INPUT}
|
||||
inputMode="numeric"
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'json') {
|
||||
return (
|
||||
<Textarea
|
||||
className={FIELD_INPUT}
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'select') {
|
||||
return (
|
||||
<Select onValueChange={set} value={value}>
|
||||
<SelectTrigger className={CONTROL_TEXT}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'secret') {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
className={`w-full ${FIELD_INPUT}`}
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
|
||||
type="password"
|
||||
value={value}
|
||||
/>
|
||||
{field.is_set && (
|
||||
<span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)">
|
||||
<Check className="size-3 text-(--ui-accent-secondary)" />
|
||||
set
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className={FIELD_INPUT}
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
const saveMemoryProviderConfig = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/profile', async () => {
|
||||
const { atom } = await import('nanostores')
|
||||
return { $activeGatewayProfile: atom('default') }
|
||||
})
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function field(
|
||||
overrides: Partial<MemoryProviderField> & Pick<MemoryProviderField, 'key' | 'kind'>
|
||||
): MemoryProviderField {
|
||||
return {
|
||||
label: overrides.key,
|
||||
value: '',
|
||||
description: '',
|
||||
placeholder: '',
|
||||
is_set: false,
|
||||
inline: false,
|
||||
group: 'Other',
|
||||
options: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function schema(): MemoryProviderConfig {
|
||||
return {
|
||||
name: 'honcho',
|
||||
label: 'Honcho',
|
||||
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
|
||||
fields: [
|
||||
field({ key: 'workspace', kind: 'text', label: 'Workspace', value: 'myws', inline: true, group: 'Connection' }),
|
||||
field({ key: 'saveMessages', kind: 'bool', label: 'Save messages', value: 'true', group: 'Message writing' }),
|
||||
field({ key: 'dialecticMaxChars', kind: 'number', label: 'Max result chars', value: '1200', group: 'Dialectic' }),
|
||||
field({
|
||||
key: 'userPeerAliases',
|
||||
kind: 'json',
|
||||
label: 'User peer aliases',
|
||||
value: '{"t":"eri"}',
|
||||
group: 'Identity'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderModal(open = true) {
|
||||
const { ProviderConfigModal } = await import('./provider-config-modal')
|
||||
const onOpenChange = vi.fn()
|
||||
const onSaved = vi.fn().mockResolvedValue(undefined)
|
||||
const result = render(
|
||||
<ProviderConfigModal
|
||||
config={schema()}
|
||||
onOpenChange={onOpenChange}
|
||||
onSaved={onSaved}
|
||||
open={open}
|
||||
provider="honcho"
|
||||
/>
|
||||
)
|
||||
return { ...result, onOpenChange, onSaved }
|
||||
}
|
||||
|
||||
describe('ProviderConfigModal', () => {
|
||||
it('renders every field grouped, including inline ones, with kind-specific controls', async () => {
|
||||
await renderModal()
|
||||
|
||||
expect(await screen.findByText('Message writing')).toBeTruthy()
|
||||
expect(screen.getByText('Dialectic')).toBeTruthy()
|
||||
// bool -> switch, number -> spinbutton, json/text -> textbox
|
||||
expect(screen.getByRole('switch')).toBeTruthy()
|
||||
expect(screen.getByDisplayValue('1200')).toBeTruthy()
|
||||
expect(screen.getByDisplayValue('myws')).toBeTruthy()
|
||||
expect(screen.getByDisplayValue('{"t":"eri"}')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('saves only edited fields, serializing the toggled bool to "false"', async () => {
|
||||
const { onSaved, onOpenChange } = await renderModal()
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
|
||||
|
||||
// A save must never ratify rendered defaults the backend does not store.
|
||||
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { saveMessages: 'false' }))
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled())
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('renders nothing while closed', async () => {
|
||||
await renderModal(false)
|
||||
expect(screen.queryByText('Message writing')).toBeNull()
|
||||
})
|
||||
})
|
||||
155
apps/desktop/src/app/settings/memory/provider-config-modal.tsx
Normal file
155
apps/desktop/src/app/settings/memory/provider-config-modal.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { saveMemoryProviderConfig } from '@/hermes'
|
||||
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
|
||||
import { FieldControl, FieldTitle } from './field-control'
|
||||
import { ListRow } from '../primitives'
|
||||
|
||||
// Secrets seed blank: values are write-only and blank keeps the stored one.
|
||||
function seedAll(config: MemoryProviderConfig): Record<string, string> {
|
||||
return Object.fromEntries(config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]))
|
||||
}
|
||||
|
||||
// Group fields in declared order, preserving first-seen group sequence.
|
||||
function groupFields(fields: MemoryProviderField[]): [string, MemoryProviderField[]][] {
|
||||
const groups: [string, MemoryProviderField[]][] = []
|
||||
for (const field of fields) {
|
||||
const name = field.group || 'Other'
|
||||
const bucket = groups.find(([key]) => key === name)
|
||||
if (bucket) {
|
||||
bucket[1].push(field)
|
||||
} else {
|
||||
groups.push([name, [field]])
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function ProviderConfigModal({
|
||||
config,
|
||||
provider,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved
|
||||
}: {
|
||||
config: MemoryProviderConfig
|
||||
provider: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
const activeProfile = useStore($activeGatewayProfile)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [seeded, setSeeded] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reseed on open so edits never start from a stale prior-session snapshot.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const seed = seedAll(config)
|
||||
setSeeded(seed)
|
||||
setValues(seed)
|
||||
}
|
||||
}, [open, config])
|
||||
|
||||
const save = async () => {
|
||||
// Untouched keys stay unsubmitted; runtime defaults still own their values.
|
||||
const edited = Object.fromEntries(Object.entries(values).filter(([key, value]) => value !== seeded[key]))
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveMemoryProviderConfig(provider, edited)
|
||||
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
|
||||
await onSaved()
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${config.label} settings`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-2xl dt-portal-scrollbar">
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={SlidersHorizontal}>{config.label} — full configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Every {config.label} option for the <span className="font-medium">{activeProfile}</span> profile. Blank
|
||||
fields fall back to the resolved host or built-in default.
|
||||
</DialogDescription>
|
||||
{config.docs_url && (
|
||||
<a
|
||||
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline"
|
||||
href={config.docs_url}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal?.(config.docs_url)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{config.label} configuration reference
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-w-0">
|
||||
{groupFields(config.fields).map(([group, fields]) => (
|
||||
<section className="mt-6 first:mt-2" key={group}>
|
||||
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
|
||||
{group}
|
||||
</h3>
|
||||
<div className="pl-1">
|
||||
{fields.map(field => (
|
||||
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
|
||||
<ListRow
|
||||
action={
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
value={values[field.key] ?? ''}
|
||||
/>
|
||||
}
|
||||
description={field.description}
|
||||
title={<FieldTitle field={field} />}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size="sm" type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={saving} onClick={() => void save()} size="sm">
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
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'
|
||||
|
||||
const getMemoryProviderConfig = vi.fn()
|
||||
const saveMemoryProviderConfig = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
|
||||
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/profile', async () => {
|
||||
const { atom } = await import('nanostores')
|
||||
return { $activeGatewayProfile: atom('default') }
|
||||
})
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function honchoSchema(): MemoryProviderConfig {
|
||||
return {
|
||||
name: 'honcho',
|
||||
label: 'Honcho',
|
||||
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
|
||||
fields: [
|
||||
{
|
||||
key: 'apiKey',
|
||||
label: 'API key',
|
||||
kind: 'secret',
|
||||
value: '',
|
||||
description: 'Authenticate with Honcho Cloud.',
|
||||
placeholder: 'Enter Honcho API key',
|
||||
is_set: false,
|
||||
inline: true,
|
||||
group: 'Connection',
|
||||
options: []
|
||||
},
|
||||
{
|
||||
key: 'baseUrl',
|
||||
label: 'Base URL',
|
||||
kind: 'text',
|
||||
value: '',
|
||||
description: 'Self-hosted Honcho URL.',
|
||||
placeholder: 'https://… (self-hosted)',
|
||||
is_set: false,
|
||||
inline: true,
|
||||
group: 'Connection',
|
||||
options: []
|
||||
},
|
||||
{
|
||||
key: 'environment',
|
||||
label: 'Environment',
|
||||
kind: 'select',
|
||||
value: 'production',
|
||||
description: 'Honcho environment.',
|
||||
placeholder: '',
|
||||
is_set: true,
|
||||
inline: true,
|
||||
group: 'Connection',
|
||||
options: [
|
||||
{ value: 'production', label: 'Production', description: '' },
|
||||
{ value: 'demo', label: 'Demo', description: '' },
|
||||
{ value: 'local', label: 'Local', description: '' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
kind: 'text',
|
||||
value: 'myws',
|
||||
description: 'Honcho workspace ID.',
|
||||
placeholder: 'hermes',
|
||||
is_set: true,
|
||||
inline: true,
|
||||
group: 'Connection',
|
||||
options: []
|
||||
},
|
||||
// Non-inline field: must NOT render in the compact panel and must NOT be
|
||||
// submitted when the panel saves.
|
||||
{
|
||||
key: 'writeFrequency',
|
||||
label: 'Write frequency',
|
||||
kind: 'text',
|
||||
value: 'async',
|
||||
description: '',
|
||||
placeholder: '',
|
||||
is_set: true,
|
||||
inline: false,
|
||||
group: 'Message writing',
|
||||
options: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getMemoryProviderConfig.mockResolvedValue(honchoSchema())
|
||||
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderPanel(provider = 'honcho') {
|
||||
const { ProviderConfigPanel } = await import('./provider-config-panel')
|
||||
|
||||
return render(<ProviderConfigPanel provider={provider} />)
|
||||
}
|
||||
|
||||
describe('ProviderConfigPanel', () => {
|
||||
it('renders the declared inline fields generically', async () => {
|
||||
await renderPanel()
|
||||
|
||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
expect(screen.getByPlaceholderText('https://… (self-hosted)')).toBeTruthy()
|
||||
expect(screen.getByText('Production')).toBeTruthy()
|
||||
expect(screen.getByText('Self-hosted Honcho URL.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides fields that are not marked inline', async () => {
|
||||
await renderPanel()
|
||||
|
||||
await screen.findByDisplayValue('myws')
|
||||
expect(screen.queryByDisplayValue('async')).toBeNull()
|
||||
expect(screen.queryByText('Write frequency')).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses and expands the fields', async () => {
|
||||
await renderPanel()
|
||||
|
||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
|
||||
expect(screen.queryByDisplayValue('myws')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
|
||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('autosaves a text field on blur as a one-key partial save', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const baseUrl = await screen.findByPlaceholderText('https://… (self-hosted)')
|
||||
fireEvent.change(baseUrl, { target: { value: 'http://localhost:8000' } })
|
||||
fireEvent.blur(baseUrl)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { baseUrl: 'http://localhost:8000' })
|
||||
)
|
||||
expect(saveMemoryProviderConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not save on blur when nothing changed', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const workspace = await screen.findByDisplayValue('myws')
|
||||
fireEvent.blur(workspace)
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Save' })).toBeNull())
|
||||
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('autosaves a committed secret and clears the draft', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const apiKey = await screen.findByPlaceholderText('Enter Honcho API key')
|
||||
fireEvent.blur(apiKey)
|
||||
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(apiKey, { target: { value: 'hch-new-key' } })
|
||||
fireEvent.blur(apiKey)
|
||||
|
||||
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { apiKey: 'hch-new-key' }))
|
||||
await waitFor(() => expect((apiKey as HTMLInputElement).value).toBe(''))
|
||||
})
|
||||
|
||||
it('offers a full-config trigger when modal-only fields exist', async () => {
|
||||
await renderPanel()
|
||||
|
||||
await screen.findByDisplayValue('myws')
|
||||
expect(screen.getByRole('button', { name: /Full config/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows an inline error with retry when the load fails, then recovers', async () => {
|
||||
getMemoryProviderConfig.mockRejectedValueOnce(new Error('Timed out connecting to Hermes backend'))
|
||||
|
||||
await renderPanel()
|
||||
|
||||
expect(await screen.findByText(/Timed out connecting/)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
|
||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders nothing for a provider with no declared config surface', async () => {
|
||||
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] })
|
||||
|
||||
const { container } = await renderPanel('builtin')
|
||||
|
||||
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
|
||||
expect(container.querySelector('section')).toBeNull()
|
||||
})
|
||||
})
|
||||
159
apps/desktop/src/app/settings/memory/provider-config-panel.tsx
Normal file
159
apps/desktop/src/app/settings/memory/provider-config-panel.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||
import { SlidersHorizontal } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { FieldControl, FieldTitle } from './field-control'
|
||||
import { ListRow, LoadingState, Pill } from '../primitives'
|
||||
import { ProviderConfigModal } from './provider-config-modal'
|
||||
|
||||
// Inline fields only: the compact panel must never re-write modal-owned keys.
|
||||
function seedValues(config: MemoryProviderConfig): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
config.fields.filter(field => field.inline).map(field => [field.key, field.kind === 'secret' ? '' : field.value])
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderConfigPanel({ provider }: { provider: string }) {
|
||||
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
|
||||
const [loadError, setLoadError] = useState<null | string>(null)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [saved, setSaved] = useState<Record<string, string>>({})
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await getMemoryProviderConfig(provider)
|
||||
const seed = seedValues(next)
|
||||
setConfig(next)
|
||||
setValues(seed)
|
||||
setSaved(seed)
|
||||
setLoadError(null)
|
||||
} catch (err) {
|
||||
setConfig(null)
|
||||
setLoadError(err instanceof Error ? err.message : 'Memory provider settings failed to load')
|
||||
}
|
||||
}, [provider])
|
||||
|
||||
useEffect(() => {
|
||||
setConfig(null)
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
// Autosave, matching the settings page around the panel: one-key partial PUT
|
||||
// on commit, silent on success, no full refresh (it would reset sibling drafts).
|
||||
const commitField = useCallback(
|
||||
async (field: MemoryProviderField, value: string) => {
|
||||
if (value === (saved[field.key] ?? '') || (field.kind === 'secret' && !value.trim())) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await saveMemoryProviderConfig(provider, { [field.key]: value })
|
||||
if (field.kind === 'secret') {
|
||||
setValues(current => ({ ...current, [field.key]: '' }))
|
||||
setConfig(
|
||||
current =>
|
||||
current && {
|
||||
...current,
|
||||
fields: current.fields.map(f => (f.key === field.key ? { ...f, is_set: true } : f))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
setSaved(current => ({ ...current, [field.key]: value }))
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${field.label}`)
|
||||
}
|
||||
},
|
||||
[provider, saved]
|
||||
)
|
||||
|
||||
// Providers without a declared config surface (e.g. builtin) render nothing.
|
||||
if (config && config.fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
Memory provider settings failed to load: {loadError}
|
||||
</span>
|
||||
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <LoadingState label="Loading memory provider settings..." />
|
||||
}
|
||||
|
||||
const inlineFields = config.fields.filter(field => field.inline)
|
||||
const secretFields = config.fields.filter(field => field.kind === 'secret')
|
||||
const hasFullConfig = config.fields.some(field => !field.inline)
|
||||
|
||||
return (
|
||||
<section className="py-1">
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
onClick={() => setExpanded(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret open={expanded} />
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
||||
{config.label} settings
|
||||
</span>
|
||||
{secretFields.map(field => (
|
||||
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
|
||||
))}
|
||||
</button>
|
||||
{hasFullConfig && (
|
||||
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Full config…
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="ml-1.5 border-l-2 border-(--ui-accent-secondary)/25 pb-4 pl-4 pr-4">
|
||||
{inlineFields.map(field => (
|
||||
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
|
||||
<ListRow
|
||||
action={
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
onCommit={value => void commitField(field, value)}
|
||||
value={values[field.key] ?? ''}
|
||||
/>
|
||||
}
|
||||
description={field.description}
|
||||
title={<FieldTitle field={field} />}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFullConfig && (
|
||||
<ProviderConfigModal
|
||||
config={config}
|
||||
onOpenChange={setShowModal}
|
||||
onSaved={refresh}
|
||||
open={showModal}
|
||||
provider={provider}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MemoryProviderConfig } from '@/types/hermes'
|
||||
|
||||
const getMemoryProviderConfig = vi.fn()
|
||||
const saveMemoryProviderConfig = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
|
||||
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function hindsightSchema(overrides: Partial<MemoryProviderConfig['fields'][number]>[] = []): MemoryProviderConfig {
|
||||
const fields: MemoryProviderConfig['fields'] = [
|
||||
{
|
||||
key: 'mode',
|
||||
label: 'Mode',
|
||||
kind: 'select',
|
||||
value: 'cloud',
|
||||
description: 'How Hermes connects to Hindsight.',
|
||||
placeholder: '',
|
||||
is_set: true,
|
||||
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' }
|
||||
]
|
||||
},
|
||||
{
|
||||
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: []
|
||||
},
|
||||
{
|
||||
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] }))
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getMemoryProviderConfig.mockResolvedValue(hindsightSchema())
|
||||
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderPanel(provider = 'hindsight') {
|
||||
const { ProviderConfigPanel } = await import('./provider-config-panel')
|
||||
|
||||
let result: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
result = render(<ProviderConfigPanel provider={provider} />)
|
||||
})
|
||||
|
||||
return result!
|
||||
}
|
||||
|
||||
describe('ProviderConfigPanel', () => {
|
||||
it('renders the declared provider fields generically', async () => {
|
||||
await renderPanel()
|
||||
|
||||
expect(await screen.findByDisplayValue('https://api.hindsight.vectorize.io')).toBeTruthy()
|
||||
expect(screen.getByDisplayValue('hermes')).toBeTruthy()
|
||||
expect(screen.getByText('Cloud')).toBeTruthy()
|
||||
expect(screen.getAllByText('Hindsight Cloud API (lightweight, just needs an API key)').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('mid')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('collapses and expands the fields', async () => {
|
||||
await renderPanel()
|
||||
|
||||
expect(await screen.findByLabelText('API URL')).toBeTruthy()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
|
||||
})
|
||||
expect(screen.queryByLabelText('API URL')).toBeNull()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
|
||||
})
|
||||
expect(await screen.findByLabelText('API URL')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('saves edited values without requiring a secret replacement', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const apiUrl = await screen.findByLabelText('API URL')
|
||||
await act(async () => {
|
||||
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
|
||||
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {
|
||||
mode: 'cloud',
|
||||
api_key: '',
|
||||
api_url: 'http://localhost:8888',
|
||||
bank_id: 'ben-bank',
|
||||
recall_budget: 'mid'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('renders nothing for a provider with no declared config surface', async () => {
|
||||
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', fields: [] })
|
||||
|
||||
const { container } = await renderPanel('builtin')
|
||||
|
||||
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
|
||||
expect(container.querySelector('section')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
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 { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||
import { Check, Loader2, Save } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
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). */
|
||||
function seedValues(config: MemoryProviderConfig): Record<string, string> {
|
||||
return Object.fromEntries(config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]))
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
field: MemoryProviderField
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
if (field.kind === 'select') {
|
||||
const selected = field.options.find(option => option.value === value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select onValueChange={onChange} value={value}>
|
||||
<SelectTrigger className={CONTROL_TEXT}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(selected?.description || field.description) && (
|
||||
<span className="text-xs text-muted-foreground">{selected?.description || field.description}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'secret') {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="min-w-64 flex-1 font-mono"
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
|
||||
type="password"
|
||||
value={value}
|
||||
/>
|
||||
{field.is_set && (
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" />
|
||||
Set
|
||||
</Pill>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="font-mono"
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderConfigPanel({ provider }: { provider: string }) {
|
||||
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await getMemoryProviderConfig(provider)
|
||||
setConfig(next)
|
||||
setValues(seedValues(next))
|
||||
} catch (err) {
|
||||
notifyError(err, 'Memory provider settings failed to load')
|
||||
setConfig(null)
|
||||
}
|
||||
}, [provider])
|
||||
|
||||
useEffect(() => {
|
||||
setConfig(null)
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
await saveMemoryProviderConfig(provider, values)
|
||||
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${config.label} settings`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [config, provider, refresh, values])
|
||||
|
||||
// Providers without a declared config surface (e.g. builtin) render nothing.
|
||||
if (config && config.fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <LoadingState label="Loading memory provider settings..." />
|
||||
}
|
||||
|
||||
const secretFields = config.fields.filter(field => field.kind === 'secret')
|
||||
|
||||
return (
|
||||
<section className="py-3">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-lg bg-background/60 px-3 py-2 text-left hover:bg-accent/50"
|
||||
onClick={() => setExpanded(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<DisclosureCaret open={expanded} />
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
||||
{config.label} settings
|
||||
</span>
|
||||
{secretFields.map(field => (
|
||||
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{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>
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
value={values[field.key] ?? ''}
|
||||
/>
|
||||
{field.kind !== 'select' && field.description && (
|
||||
<span className="text-xs text-muted-foreground">{field.description}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={saving} onClick={() => void save()} size="sm">
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import {
|
||||
checkHermesUpdate,
|
||||
getActionStatus,
|
||||
getMemoryProviderConfig,
|
||||
getStatus,
|
||||
restartGateway,
|
||||
saveMemoryProviderConfig,
|
||||
setApiRequestProfile,
|
||||
updateHermes
|
||||
} from './hermes'
|
||||
|
|
@ -33,6 +35,17 @@ describe('backend action helpers are profile-scoped', () => {
|
|||
expect(lastProfile()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards the active profile to memory provider config calls', () => {
|
||||
setApiRequestProfile('coder')
|
||||
|
||||
void getMemoryProviderConfig('honcho')
|
||||
void saveMemoryProviderConfig('honcho', { workspace: 'w' })
|
||||
|
||||
for (const call of api.mock.calls) {
|
||||
expect(call[0].profile).toBe('coder')
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards the active profile to every backend action', () => {
|
||||
setApiRequestProfile('coder')
|
||||
|
||||
|
|
|
|||
|
|
@ -576,12 +576,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
|
|||
// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema.
|
||||
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
|
||||
return window.hermesDesktop.api<MemoryProviderConfig>({
|
||||
...profileScoped(),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
|
||||
})
|
||||
}
|
||||
|
||||
export function saveMemoryProviderConfig(provider: string, values: Record<string, string>): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
|
||||
method: 'PUT',
|
||||
body: { values }
|
||||
|
|
|
|||
|
|
@ -513,6 +513,7 @@ export const en: Translations = {
|
|||
config: {
|
||||
none: 'None',
|
||||
noneParen: '(none)',
|
||||
builtinOnly: 'Built-in only',
|
||||
notSet: 'Not set',
|
||||
commaSeparated: 'comma-separated values',
|
||||
loading: 'Loading Hermes configuration...',
|
||||
|
|
|
|||
|
|
@ -606,6 +606,7 @@ export const ja = defineLocale({
|
|||
config: {
|
||||
none: 'なし',
|
||||
noneParen: '(なし)',
|
||||
builtinOnly: '内蔵のみ',
|
||||
notSet: '未設定',
|
||||
commaSeparated: 'カンマ区切りの値',
|
||||
loading: 'Hermes の設定を読み込み中...',
|
||||
|
|
|
|||
|
|
@ -424,6 +424,7 @@ export interface Translations {
|
|||
config: {
|
||||
none: string
|
||||
noneParen: string
|
||||
builtinOnly: string
|
||||
notSet: string
|
||||
commaSeparated: string
|
||||
loading: string
|
||||
|
|
|
|||
|
|
@ -594,6 +594,7 @@ export const zhHant = defineLocale({
|
|||
config: {
|
||||
none: '無',
|
||||
noneParen: '(無)',
|
||||
builtinOnly: '僅內建',
|
||||
notSet: '未設定',
|
||||
commaSeparated: '逗號分隔的值',
|
||||
loading: '正在載入 Hermes 設定...',
|
||||
|
|
|
|||
|
|
@ -705,6 +705,7 @@ export const zh: Translations = {
|
|||
config: {
|
||||
none: '无',
|
||||
noneParen: '(无)',
|
||||
builtinOnly: '仅内置',
|
||||
notSet: '未设置',
|
||||
commaSeparated: '逗号分隔的值',
|
||||
loading: '正在加载 Hermes 配置...',
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export interface EnvVarInfo {
|
|||
url: null | string
|
||||
}
|
||||
|
||||
export type MemoryProviderFieldKind = 'secret' | 'select' | 'text'
|
||||
export type MemoryProviderFieldKind = 'bool' | 'json' | 'number' | 'secret' | 'select' | 'text'
|
||||
|
||||
export interface MemoryProviderFieldOption {
|
||||
description: string
|
||||
|
|
@ -130,6 +130,9 @@ export interface MemoryProviderFieldOption {
|
|||
|
||||
export interface MemoryProviderField {
|
||||
description: string
|
||||
group: string
|
||||
info?: string
|
||||
inline: boolean
|
||||
is_set: boolean
|
||||
key: string
|
||||
kind: MemoryProviderFieldKind
|
||||
|
|
@ -140,6 +143,7 @@ export interface MemoryProviderField {
|
|||
}
|
||||
|
||||
export interface MemoryProviderConfig {
|
||||
docs_url: string
|
||||
fields: MemoryProviderField[]
|
||||
label: string
|
||||
name: string
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
"""Declarative configuration schema for desktop memory providers.
|
||||
|
||||
Each memory provider *declares* its configurable surface here — the fields, their
|
||||
types, which values are secrets, and (for selects) the allowed options. A single
|
||||
generic renderer in the desktop UI and a single generic ``GET/PUT
|
||||
/api/memory/providers/{name}/config`` endpoint pair drive the whole experience,
|
||||
so adding a new provider (mem0, honcho, ...) is pure declaration with zero
|
||||
bespoke UI components or endpoints.
|
||||
|
||||
This module is intentionally pure data: it imports nothing from the config/env
|
||||
layer. ``web_server`` owns the generic read/write logic that interprets these
|
||||
declarations against config.yaml, the provider config file, and the env store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
|
||||
# Field kinds understood by the generic renderer.
|
||||
KIND_TEXT = "text"
|
||||
KIND_SELECT = "select"
|
||||
KIND_SECRET = "secret"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderFieldOption:
|
||||
"""A single choice for a ``select`` field."""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderField:
|
||||
"""One configurable field on a memory provider.
|
||||
|
||||
A field is stored in exactly one place, decided by ``kind``:
|
||||
|
||||
* ``text`` / ``select`` — persisted to the provider's JSON config file
|
||||
(``<hermes_home>/<provider>/config.json``) under ``key``.
|
||||
* ``secret`` — persisted to the env store under ``env_key`` and never read
|
||||
back out over the API (only an ``is_set`` flag is surfaced).
|
||||
|
||||
``aliases`` and ``env_fallbacks`` let a field read legacy values written by
|
||||
earlier CLI/env setup without re-introducing per-provider code.
|
||||
"""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
kind: str = KIND_TEXT
|
||||
default: str = ""
|
||||
description: str = ""
|
||||
placeholder: str = ""
|
||||
options: tuple[ProviderFieldOption, ...] = ()
|
||||
env_key: str | None = None
|
||||
aliases: tuple[str, ...] = ()
|
||||
env_fallbacks: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def is_secret(self) -> bool:
|
||||
return self.kind == KIND_SECRET
|
||||
|
||||
def allowed_values(self) -> set[str]:
|
||||
return {opt.value for opt in self.options}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryProvider:
|
||||
"""A declared memory provider and its configurable fields."""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple)
|
||||
|
||||
|
||||
HINDSIGHT = MemoryProvider(
|
||||
name="hindsight",
|
||||
label="Hindsight",
|
||||
fields=(
|
||||
ProviderField(
|
||||
key="mode",
|
||||
label="Mode",
|
||||
kind=KIND_SELECT,
|
||||
default="cloud",
|
||||
description="How Hermes connects to Hindsight.",
|
||||
options=(
|
||||
ProviderFieldOption(
|
||||
"cloud",
|
||||
"Cloud",
|
||||
"Hindsight Cloud API (lightweight, just needs an API key)",
|
||||
),
|
||||
ProviderFieldOption(
|
||||
"local_external",
|
||||
"Local External",
|
||||
"Connect to an existing Hindsight instance",
|
||||
),
|
||||
),
|
||||
),
|
||||
ProviderField(
|
||||
key="api_key",
|
||||
label="API key",
|
||||
kind=KIND_SECRET,
|
||||
env_key="HINDSIGHT_API_KEY",
|
||||
description="Used to authenticate with the Hindsight API.",
|
||||
placeholder="Enter Hindsight API key",
|
||||
),
|
||||
ProviderField(
|
||||
key="api_url",
|
||||
label="API URL",
|
||||
kind=KIND_TEXT,
|
||||
default="https://api.hindsight.vectorize.io",
|
||||
aliases=("apiUrl",),
|
||||
env_fallbacks=("HINDSIGHT_API_URL",),
|
||||
),
|
||||
ProviderField(
|
||||
key="bank_id",
|
||||
label="Bank ID",
|
||||
kind=KIND_TEXT,
|
||||
default="hermes",
|
||||
aliases=("bankId",),
|
||||
),
|
||||
ProviderField(
|
||||
key="recall_budget",
|
||||
label="Recall budget",
|
||||
kind=KIND_SELECT,
|
||||
default="mid",
|
||||
aliases=("budget",),
|
||||
options=(
|
||||
ProviderFieldOption("low", "low"),
|
||||
ProviderFieldOption("mid", "mid"),
|
||||
ProviderFieldOption("high", "high"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Registry of providers that expose a desktop config surface. Providers without
|
||||
# an entry here (e.g. ``builtin``) simply render no config panel.
|
||||
MEMORY_PROVIDERS: dict[str, MemoryProvider] = {
|
||||
HINDSIGHT.name: HINDSIGHT,
|
||||
}
|
||||
|
||||
|
||||
def get_memory_provider(name: str) -> MemoryProvider | None:
|
||||
"""Return the declared provider for ``name``, or ``None`` if undeclared."""
|
||||
|
||||
return MEMORY_PROVIDERS.get(name)
|
||||
|
|
@ -76,6 +76,12 @@ from hermes_cli.config import (
|
|||
write_platform_config_field,
|
||||
_deep_merge,
|
||||
)
|
||||
from plugins.memory.config_schema import (
|
||||
ProviderConfigSchema,
|
||||
ProviderField,
|
||||
STORAGE_HONCHO_HOST_BLOCK,
|
||||
get_provider_config_schema,
|
||||
)
|
||||
from gateway.status import (
|
||||
derive_gateway_busy,
|
||||
derive_gateway_drainable,
|
||||
|
|
@ -85,11 +91,6 @@ from gateway.status import (
|
|||
parse_active_agents,
|
||||
read_runtime_status,
|
||||
)
|
||||
from hermes_cli.memory_providers import (
|
||||
MemoryProvider as DeclaredMemoryProvider,
|
||||
ProviderField as DeclaredProviderField,
|
||||
get_memory_provider as get_declared_memory_provider,
|
||||
)
|
||||
from utils import env_var_enabled
|
||||
|
||||
try:
|
||||
|
|
@ -621,10 +622,13 @@ def _memory_provider_options() -> List[str]:
|
|||
"""Discovered memory providers for the ``memory.provider`` select.
|
||||
|
||||
Directory-scan only (no provider imports), so it's safe at module import
|
||||
time. ``""`` (built-in) is always first; discovery failures degrade to the
|
||||
bundled defaults rather than dropping the field.
|
||||
time. ``""`` (built-in only) is always first; discovery failures degrade to
|
||||
the bundled defaults rather than dropping the field. The literal
|
||||
``builtin`` alias is deliberately NOT offered — built-in memory is not a
|
||||
provider plugin, and ``_normalize_memory_provider_name`` already maps any
|
||||
legacy ``builtin``/``built-in``/``none`` value back to ``""`` (#49513).
|
||||
"""
|
||||
options = ["", "builtin"]
|
||||
options = [""]
|
||||
try:
|
||||
from plugins.memory import list_memory_provider_names
|
||||
|
||||
|
|
@ -4586,6 +4590,342 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
return config
|
||||
|
||||
|
||||
# ── Memory provider config: one generic GET/PUT pair, dispatching on storage ──
|
||||
|
||||
|
||||
def _provider_field_entry(field: ProviderField) -> Dict[str, Any]:
|
||||
"""Static, storage-independent shape of one field for the UI payload."""
|
||||
|
||||
return {
|
||||
"key": field.key,
|
||||
"label": field.label,
|
||||
"kind": field.kind,
|
||||
"description": field.description,
|
||||
"info": field.info,
|
||||
"placeholder": field.placeholder,
|
||||
"inline": field.inline,
|
||||
"group": field.group,
|
||||
"options": [
|
||||
{"value": opt.value, "label": opt.label, "description": opt.description}
|
||||
for opt in field.options
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# Sentinel: remove this key so it falls back to the host or built-in default.
|
||||
_UNSET: Any = object()
|
||||
|
||||
|
||||
def _coerce_field_value(field: ProviderField, raw: str) -> Any:
|
||||
"""Coerce a submitted non-secret value to its native JSON type.
|
||||
|
||||
Values arrive as strings over the API; this converts them to the type the
|
||||
Honcho resolver expects (bool/number/list/dict), so e.g. a boolean is stored
|
||||
as a JSON ``false`` rather than the string ``"false"`` (which would read as
|
||||
truthy). Returns ``_UNSET`` when the field should be removed. Raises
|
||||
``ValueError`` on malformed input.
|
||||
"""
|
||||
|
||||
value = (raw or "").strip()
|
||||
kind = field.kind
|
||||
|
||||
if kind == "select":
|
||||
if not value:
|
||||
value = field.default
|
||||
if value not in field.allowed_values():
|
||||
raise ValueError(f"Invalid value for '{field.key}'")
|
||||
return value
|
||||
|
||||
if kind == "bool":
|
||||
from utils import is_truthy_value
|
||||
|
||||
return is_truthy_value(value)
|
||||
|
||||
if kind == "number":
|
||||
if not value:
|
||||
return _UNSET
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid number for '{field.key}'") from exc
|
||||
return int(number) if number.is_integer() else number
|
||||
|
||||
if kind == "json":
|
||||
if not value:
|
||||
return _UNSET
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError(f"Invalid JSON for '{field.key}'") from exc
|
||||
if not isinstance(parsed, (dict, list)):
|
||||
raise ValueError(f"'{field.key}' must be a JSON object or array")
|
||||
return parsed
|
||||
|
||||
# text / secret — blank clears the key so it falls back to host/default.
|
||||
return value if value else _UNSET
|
||||
|
||||
|
||||
def _serialize_field_value(field: ProviderField, value: Any) -> str:
|
||||
"""Render a stored native value as the string the generic UI edits.
|
||||
|
||||
``None`` (key absent) yields the field's declared default. Bools become
|
||||
``"true"``/``"false"``, JSON objects/arrays are re-encoded, numbers are
|
||||
stringified — so the renderer's per-kind controls always get the shape they
|
||||
expect regardless of how the value sits on disk.
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return field.default
|
||||
if field.kind == "bool":
|
||||
from utils import is_truthy_value
|
||||
|
||||
return "true" if is_truthy_value(value) else "false"
|
||||
if field.kind == "json":
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
# — flat-json backend (default; reusable for simple providers) —
|
||||
|
||||
|
||||
def _flat_json_path(provider: ProviderConfigSchema) -> Path:
|
||||
return get_hermes_home() / provider.name / "config.json"
|
||||
|
||||
|
||||
def _read_flat_json(provider: ProviderConfigSchema) -> Dict[str, Any]:
|
||||
path = _flat_json_path(provider)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
_log.warning("Failed to read memory provider config from %s", path, exc_info=True)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _read_field(field: ProviderField, sources: tuple, env: Dict[str, str]) -> Any:
|
||||
"""Return the stored native value from the first source holding it, or ``None``.
|
||||
|
||||
Presence (``key in source``) decides, not truthiness, so a stored ``False``
|
||||
or ``0`` survives instead of being mistaken for "unset".
|
||||
"""
|
||||
|
||||
for source in sources:
|
||||
for source_key in (field.key, *field.aliases):
|
||||
if source_key in source and source[source_key] is not None:
|
||||
return source[source_key]
|
||||
for env_key in field.env_fallbacks:
|
||||
value = env.get(env_key)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _declared_field_is_set(field: ProviderField, sources: tuple, env: Dict[str, str]) -> bool:
|
||||
for env_key in (field.env_key, *field.env_fallbacks):
|
||||
if env_key and env.get(env_key):
|
||||
return True
|
||||
return any(source.get(k) for source in sources for k in (field.key, *field.aliases))
|
||||
|
||||
|
||||
# — honcho host-block backend —
|
||||
|
||||
|
||||
def _honcho_resolvers():
|
||||
"""Lazily import the Honcho plugin's resolvers (optional plugin)."""
|
||||
|
||||
from plugins.memory.honcho.client import _host_block, resolve_active_host, resolve_config_path
|
||||
|
||||
return resolve_active_host, resolve_config_path, _host_block
|
||||
|
||||
|
||||
def _honcho_read_sources() -> tuple[Dict[str, Any], str, Dict[str, Any]]:
|
||||
"""Return (root config, active host key, host block) for the current profile."""
|
||||
|
||||
resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers()
|
||||
host = resolve_active_host()
|
||||
path = resolve_config_path()
|
||||
raw: Dict[str, Any] = {}
|
||||
if path.exists():
|
||||
try:
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
raw = loaded if isinstance(loaded, dict) else {}
|
||||
except Exception:
|
||||
_log.warning("Failed to read Honcho config from %s", path, exc_info=True)
|
||||
return raw, host, host_block_of(raw, host)
|
||||
|
||||
|
||||
def _declared_provider_payload(provider: ProviderConfigSchema) -> Dict[str, Any]:
|
||||
fields: List[Dict[str, Any]] = []
|
||||
env = load_env()
|
||||
is_honcho = provider.storage == STORAGE_HONCHO_HOST_BLOCK
|
||||
|
||||
if is_honcho:
|
||||
raw, host, host_block = _honcho_read_sources()
|
||||
|
||||
def sources_for(field: ProviderField) -> tuple:
|
||||
return (host_block, raw) if field.scope == "host" else (raw,)
|
||||
else:
|
||||
host = ""
|
||||
data = _read_flat_json(provider)
|
||||
|
||||
def sources_for(field: ProviderField) -> tuple:
|
||||
return (data,)
|
||||
|
||||
for field in provider.fields:
|
||||
entry = _provider_field_entry(field)
|
||||
sources = sources_for(field)
|
||||
|
||||
if field.is_secret:
|
||||
entry["value"] = "" # secrets are write-only over the API
|
||||
entry["is_set"] = _declared_field_is_set(field, sources, env)
|
||||
fields.append(entry)
|
||||
continue
|
||||
|
||||
native = _read_field(field, sources, env)
|
||||
if is_honcho and not field.placeholder and field.key in {"workspace", "aiPeer"}:
|
||||
# Blank fields surface the resolved host Honcho will actually use.
|
||||
entry["placeholder"] = host
|
||||
|
||||
value = _serialize_field_value(field, native)
|
||||
if field.kind == "select" and value not in field.allowed_values():
|
||||
value = field.default
|
||||
entry["value"] = value
|
||||
# Presence, not truthiness — a stored False/0 is still "set".
|
||||
entry["is_set"] = native is not None if is_honcho else bool(value)
|
||||
fields.append(entry)
|
||||
|
||||
return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields}
|
||||
|
||||
|
||||
def _apply_field_values(provider: ProviderConfigSchema, values: Dict[str, str], target_for) -> None:
|
||||
"""Apply submitted non-secret fields to their backend dict, in place.
|
||||
|
||||
Only keys present in ``values`` are touched, so a partial save never
|
||||
clobbers fields owned by another surface. ``_UNSET`` clears the key (and
|
||||
its aliases) so it falls back to the host/default mapping.
|
||||
"""
|
||||
|
||||
for field in provider.fields:
|
||||
if field.is_secret or field.key not in values:
|
||||
continue
|
||||
target = target_for(field)
|
||||
coerced = _coerce_field_value(field, values[field.key])
|
||||
if coerced is _UNSET:
|
||||
target.pop(field.key, None)
|
||||
for alias in field.aliases:
|
||||
target.pop(alias, None)
|
||||
else:
|
||||
target[field.key] = coerced
|
||||
|
||||
|
||||
def _write_provider_flat(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
|
||||
from utils import atomic_json_write
|
||||
|
||||
existing = _read_flat_json(provider)
|
||||
|
||||
for field in provider.fields:
|
||||
if field.is_secret:
|
||||
submitted = (values.get(field.key) or "").strip()
|
||||
if submitted and field.env_key:
|
||||
save_env_value(field.env_key, submitted)
|
||||
|
||||
_apply_field_values(provider, values, lambda field: existing)
|
||||
|
||||
path = _flat_json_path(provider)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_json_write(path, existing, mode=0o600)
|
||||
|
||||
|
||||
def _write_provider_honcho(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
|
||||
"""Persist submitted fields to Honcho's real config for the active host.
|
||||
|
||||
Only keys present in ``values`` are touched, so a partial save (e.g. the
|
||||
inline panel) never clobbers fields owned by the full-config editor. Blank
|
||||
text clears a key so it falls back to the host/default mapping.
|
||||
"""
|
||||
|
||||
from plugins.memory.honcho.oauth import ACCESS_TOKEN_PREFIX, _config_refresh_lock
|
||||
from utils import atomic_json_write
|
||||
|
||||
resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers()
|
||||
host = resolve_active_host()
|
||||
# Write the file reads resolve, or a save shadows it with a sparse copy.
|
||||
path = resolve_config_path()
|
||||
|
||||
# OAuth rotation is single-use; an unlocked RMW here can revoke the grant.
|
||||
with _config_refresh_lock(path):
|
||||
cfg: Dict[str, Any] = {}
|
||||
if path.exists():
|
||||
try:
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
cfg = loaded if isinstance(loaded, dict) else {}
|
||||
except Exception:
|
||||
_log.warning("Failed to read Honcho config from %s", path, exc_info=True)
|
||||
|
||||
hosts = cfg.get("hosts")
|
||||
cfg["hosts"] = hosts = hosts if isinstance(hosts, dict) else {}
|
||||
# Update the block reads resolve (legacy dot-form included), never shadow it.
|
||||
existing = host_block_of(cfg, host)
|
||||
host_key = next((k for k, v in hosts.items() if v is existing), host) if existing else host
|
||||
host_block = hosts.setdefault(host_key, existing)
|
||||
|
||||
for field in provider.fields:
|
||||
if not field.is_secret:
|
||||
continue
|
||||
submitted = (values.get(field.key) or "").strip()
|
||||
if not submitted:
|
||||
continue
|
||||
if field.env_key:
|
||||
save_env_value(field.env_key, submitted)
|
||||
# Persist where the client reads first; an OAuth token owns that slot.
|
||||
stored = host_block.get(field.key)
|
||||
if not (isinstance(stored, str) and stored.startswith(ACCESS_TOKEN_PREFIX)):
|
||||
host_block[field.key] = submitted
|
||||
|
||||
_apply_field_values(provider, values, lambda field: host_block if field.scope == "host" else cfg)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_json_write(path, cfg, mode=0o600)
|
||||
|
||||
|
||||
def _stringify_submitted_values(values: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""The declared-schema path edits strings; the dashboard may send natives."""
|
||||
|
||||
out: Dict[str, str] = {}
|
||||
for key, value in values.items():
|
||||
if value is None:
|
||||
out[key] = ""
|
||||
elif isinstance(value, str):
|
||||
out[key] = value
|
||||
elif isinstance(value, bool):
|
||||
out[key] = "true" if value else "false"
|
||||
elif isinstance(value, (dict, list)):
|
||||
out[key] = json.dumps(value)
|
||||
else:
|
||||
out[key] = str(value)
|
||||
return out
|
||||
|
||||
|
||||
def _update_memory_provider_config(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
|
||||
if provider.storage == STORAGE_HONCHO_HOST_BLOCK:
|
||||
_write_provider_honcho(provider, values)
|
||||
else:
|
||||
_write_provider_flat(provider, values)
|
||||
|
||||
config = load_config()
|
||||
memory_config = config.get("memory")
|
||||
if not isinstance(memory_config, dict):
|
||||
memory_config = {}
|
||||
config["memory"] = memory_config
|
||||
if memory_config.get("provider") != provider.name:
|
||||
memory_config["provider"] = provider.name
|
||||
save_config(config)
|
||||
|
||||
|
||||
def _memory_provider_label(name: str) -> str:
|
||||
return name.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
|
@ -5350,136 +5690,28 @@ def _require_valid_memory_provider_name(name: str) -> None:
|
|||
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Declared surface — curated desktop schema from hermes_cli.memory_providers.
|
||||
# The desktop panel requests ?surface=declared; the dashboard keeps the raw
|
||||
# plugin schema. Providers without a declaration render no desktop panel.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _declared_provider_file_path(provider: DeclaredMemoryProvider) -> Path:
|
||||
return get_hermes_home() / provider.name / "config.json"
|
||||
|
||||
|
||||
def _read_declared_provider_file(provider: DeclaredMemoryProvider) -> Dict[str, Any]:
|
||||
return _read_json_file(_declared_provider_file_path(provider))
|
||||
|
||||
|
||||
def _declared_read_field_value(field: DeclaredProviderField, data: Dict[str, Any]) -> str:
|
||||
for source_key in (field.key, *field.aliases):
|
||||
value = data.get(source_key)
|
||||
if value:
|
||||
return str(value)
|
||||
|
||||
env_on_disk = load_env()
|
||||
for env_key in field.env_fallbacks:
|
||||
value = env_on_disk.get(env_key)
|
||||
if value:
|
||||
return str(value)
|
||||
|
||||
return field.default
|
||||
|
||||
|
||||
def _declared_field_is_set(field: DeclaredProviderField, data: Dict[str, Any]) -> bool:
|
||||
env_on_disk = load_env()
|
||||
for env_key in (field.env_key, *field.env_fallbacks):
|
||||
if env_key and env_on_disk.get(env_key):
|
||||
return True
|
||||
return any(data.get(source_key) for source_key in (field.key, *field.aliases))
|
||||
|
||||
|
||||
def _declared_provider_payload(provider: DeclaredMemoryProvider) -> Dict[str, Any]:
|
||||
data = _read_declared_provider_file(provider)
|
||||
fields: List[Dict[str, Any]] = []
|
||||
|
||||
for field in provider.fields:
|
||||
entry: Dict[str, Any] = {
|
||||
"key": field.key,
|
||||
"label": field.label,
|
||||
"kind": field.kind,
|
||||
"description": field.description,
|
||||
"placeholder": field.placeholder,
|
||||
"options": [
|
||||
{"value": opt.value, "label": opt.label, "description": opt.description}
|
||||
for opt in field.options
|
||||
],
|
||||
}
|
||||
|
||||
if field.is_secret:
|
||||
# Secrets are write-only over the API; only expose whether one is set.
|
||||
entry["value"] = ""
|
||||
entry["is_set"] = _declared_field_is_set(field, data)
|
||||
else:
|
||||
value = _declared_read_field_value(field, data)
|
||||
if field.kind == "select" and value not in field.allowed_values():
|
||||
value = field.default
|
||||
entry["value"] = value
|
||||
entry["is_set"] = bool(value)
|
||||
|
||||
fields.append(entry)
|
||||
|
||||
return {"name": provider.name, "label": provider.label, "fields": fields}
|
||||
|
||||
|
||||
def _coerce_declared_field_value(field: DeclaredProviderField, raw: str) -> str:
|
||||
value = (raw or "").strip()
|
||||
if field.kind == "select":
|
||||
if not value:
|
||||
value = field.default
|
||||
if value not in field.allowed_values():
|
||||
raise ValueError(f"Invalid value for '{field.key}'")
|
||||
return value
|
||||
return value or field.default
|
||||
|
||||
|
||||
def _update_declared_provider_config(provider: DeclaredMemoryProvider, values: Dict[str, Any]) -> None:
|
||||
existing = _read_declared_provider_file(provider)
|
||||
json_values: Dict[str, Any] = {}
|
||||
secrets: Dict[str, str] = {}
|
||||
|
||||
for field in provider.fields:
|
||||
if field.is_secret:
|
||||
submitted = str(values.get(field.key) or "").strip()
|
||||
if submitted and field.env_key:
|
||||
secrets[field.env_key] = submitted
|
||||
continue
|
||||
|
||||
raw = (
|
||||
values[field.key]
|
||||
if field.key in values
|
||||
else str(existing.get(field.key, field.default))
|
||||
)
|
||||
json_values[field.key] = _coerce_declared_field_value(field, str(raw))
|
||||
|
||||
path = _declared_provider_file_path(provider)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing.update(json_values)
|
||||
from utils import atomic_json_write
|
||||
|
||||
atomic_json_write(path, existing, mode=0o600)
|
||||
|
||||
for env_key, secret in secrets.items():
|
||||
save_env_value(env_key, secret)
|
||||
|
||||
|
||||
@app.get("/api/memory/providers/{name}/config")
|
||||
async def get_memory_provider_config(name: str, surface: Optional[str] = None):
|
||||
async def get_memory_provider_config(name: str, surface: Optional[str] = None, profile: Optional[str] = None):
|
||||
_require_valid_memory_provider_name(name)
|
||||
|
||||
if surface == "declared":
|
||||
declared = get_declared_memory_provider(name)
|
||||
if declared is None:
|
||||
# Undeclared providers (e.g. builtin, honcho) have no desktop
|
||||
# config surface; the generic panel renders nothing.
|
||||
return {"name": name, "label": name, "fields": []}
|
||||
return _declared_provider_payload(declared)
|
||||
def _run():
|
||||
with _profile_scope(profile):
|
||||
if surface == "declared":
|
||||
declared = get_provider_config_schema(name)
|
||||
if declared is None:
|
||||
# Undeclared providers (e.g. builtin) have no desktop
|
||||
# config surface; the generic panel renders nothing.
|
||||
return {"name": name, "label": name, "docs_url": "", "fields": []}
|
||||
return _declared_provider_payload(declared)
|
||||
|
||||
provider = _load_memory_provider(name)
|
||||
if provider is None:
|
||||
# Undeclared providers (e.g. builtin) have no config surface. Return an
|
||||
# empty schema so the generic panel simply renders nothing.
|
||||
return {"name": name, "label": name, "fields": [], "setup": _memory_provider_setup_info(name)}
|
||||
return _memory_provider_payload(name, provider)
|
||||
provider = _load_memory_provider(name)
|
||||
if provider is None:
|
||||
# Undeclared providers (e.g. builtin) have no config surface. Return an
|
||||
# empty schema so the generic panel simply renders nothing.
|
||||
return {"name": name, "label": name, "fields": [], "setup": _memory_provider_setup_info(name)}
|
||||
return _memory_provider_payload(name, provider)
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
@app.post("/api/memory/providers/{name}/setup")
|
||||
async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest):
|
||||
|
|
@ -5503,41 +5735,37 @@ async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest):
|
|||
|
||||
|
||||
@app.put("/api/memory/providers/{name}/config")
|
||||
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None):
|
||||
async def update_memory_provider_config(
|
||||
name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None, profile: Optional[str] = None
|
||||
):
|
||||
_require_valid_memory_provider_name(name)
|
||||
|
||||
if surface == "declared":
|
||||
declared = get_declared_memory_provider(name)
|
||||
if declared is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
|
||||
try:
|
||||
_update_declared_provider_config(declared, body.values or {})
|
||||
return {"ok": True}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
_log.exception("PUT /api/memory/providers/%s/config (declared) failed", name)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
provider = _load_memory_provider(name)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
|
||||
|
||||
values = body.values or {}
|
||||
|
||||
def _run():
|
||||
with _profile_scope(profile):
|
||||
if surface == "declared":
|
||||
declared = get_provider_config_schema(name)
|
||||
if declared is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
|
||||
_update_memory_provider_config(declared, _stringify_submitted_values(values))
|
||||
return {"ok": True}
|
||||
|
||||
provider = _load_memory_provider(name)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
|
||||
_write_memory_provider_config_values(name, provider, values)
|
||||
_require_memory_provider_ready(name)
|
||||
config = load_config()
|
||||
memory_config = config.get("memory")
|
||||
if not isinstance(memory_config, dict):
|
||||
memory_config = {}
|
||||
config["memory"] = memory_config
|
||||
memory_config["provider"] = name
|
||||
save_config(config)
|
||||
return {"ok": True, "active": name}
|
||||
|
||||
try:
|
||||
_write_memory_provider_config_values(name, provider, values)
|
||||
_require_memory_provider_ready(name)
|
||||
|
||||
config = load_config()
|
||||
memory_config = config.get("memory")
|
||||
if not isinstance(memory_config, dict):
|
||||
memory_config = {}
|
||||
config["memory"] = memory_config
|
||||
memory_config["provider"] = name
|
||||
save_config(config)
|
||||
|
||||
return {"ok": True, "active": name}
|
||||
return await asyncio.to_thread(_run)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
|
|
|
|||
144
plugins/memory/config_schema.py
Normal file
144
plugins/memory/config_schema.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""Declarative configuration schema for memory provider plugins.
|
||||
|
||||
Each memory provider plugin *declares* its configurable surface in a
|
||||
``config_schema.py`` next to its ``__init__.py`` — the fields, their types,
|
||||
which values are secrets, and (for selects) the allowed options. A single
|
||||
generic renderer in the desktop UI and a single generic ``GET/PUT
|
||||
/api/memory/providers/{name}/config`` endpoint pair drive the whole
|
||||
experience, so adding a provider config surface is pure declaration with no
|
||||
bespoke UI components.
|
||||
|
||||
Schema files are loaded by path (like the provider plugins themselves), never
|
||||
via package import: plugin ``__init__.py`` files pull in the agent runtime,
|
||||
which must not load into the web server. A ``config_schema.py`` may only
|
||||
import from this module.
|
||||
|
||||
This module is intentionally pure data: it imports nothing from the
|
||||
config/env layer. ``web_server`` owns the generic read/write logic that
|
||||
interprets these declarations, dispatching on ``ProviderConfigSchema.storage``
|
||||
to the matching backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Field kinds understood by the generic renderer.
|
||||
KIND_TEXT = "text"
|
||||
KIND_SELECT = "select"
|
||||
KIND_SECRET = "secret"
|
||||
KIND_BOOL = "bool"
|
||||
KIND_NUMBER = "number"
|
||||
KIND_JSON = "json"
|
||||
|
||||
# Storage backends understood by web_server (see its read/write dispatch).
|
||||
STORAGE_FLAT_JSON = "flat_json"
|
||||
STORAGE_HONCHO_HOST_BLOCK = "honcho_host_block"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderFieldOption:
|
||||
"""A single choice for a ``select`` field."""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderField:
|
||||
"""One configurable field on a memory provider.
|
||||
|
||||
A field is stored in exactly one place, decided by ``kind``:
|
||||
|
||||
* non-secret kinds — persisted to the provider's config via its storage
|
||||
backend under ``key``.
|
||||
* ``secret`` — persisted to the env store under ``env_key`` and never read
|
||||
back out over the API (only an ``is_set`` flag is surfaced).
|
||||
|
||||
``aliases`` and ``env_fallbacks`` let a field read legacy values written by
|
||||
earlier CLI/env setup without re-introducing per-provider code. ``inline``
|
||||
marks the curated subset shown in the compact panel; the rest surface only
|
||||
in the full-config modal. ``group`` buckets fields within that modal.
|
||||
"""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
kind: str = KIND_TEXT
|
||||
default: str = ""
|
||||
description: str = ""
|
||||
placeholder: str = ""
|
||||
options: tuple[ProviderFieldOption, ...] = ()
|
||||
env_key: str | None = None
|
||||
aliases: tuple[str, ...] = ()
|
||||
env_fallbacks: tuple[str, ...] = ()
|
||||
inline: bool = False
|
||||
group: str = ""
|
||||
# Longer help text surfaced as an info tooltip next to the field label.
|
||||
info: str = ""
|
||||
# Host-block placement: "host" (per-profile) or "root"; flat-json ignores it.
|
||||
scope: str = "host"
|
||||
|
||||
@property
|
||||
def is_secret(self) -> bool:
|
||||
return self.kind == KIND_SECRET
|
||||
|
||||
def allowed_values(self) -> set[str]:
|
||||
return {opt.value for opt in self.options}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConfigSchema:
|
||||
"""A provider plugin's declared config surface."""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
storage: str = STORAGE_FLAT_JSON
|
||||
# Optional link to the provider's config docs, shown in the full-config modal.
|
||||
docs_url: str = ""
|
||||
fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple)
|
||||
|
||||
def inline_fields(self) -> tuple[ProviderField, ...]:
|
||||
return tuple(f for f in self.fields if f.inline)
|
||||
|
||||
|
||||
_SCHEMA_CACHE: dict[str, ProviderConfigSchema] = {}
|
||||
|
||||
|
||||
def get_provider_config_schema(name: str) -> ProviderConfigSchema | None:
|
||||
"""Return the ``CONFIG_SCHEMA`` declared by the provider plugin ``name``.
|
||||
|
||||
Providers without a ``config_schema.py`` (e.g. ``builtin``) return ``None``
|
||||
and simply render no config panel. The cache keys on the resolved schema
|
||||
file, not the name: user-installed plugins are per-profile, so one
|
||||
profile's lookup must never answer for another's.
|
||||
"""
|
||||
|
||||
from plugins.memory import find_provider_dir
|
||||
|
||||
provider_dir = find_provider_dir(name)
|
||||
path = provider_dir / "config_schema.py" if provider_dir else None
|
||||
if path is None or not path.is_file():
|
||||
return None
|
||||
|
||||
key = str(path)
|
||||
if key in _SCHEMA_CACHE:
|
||||
return _SCHEMA_CACHE[key]
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_schema.{name}", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
schema = getattr(module, "CONFIG_SCHEMA", None)
|
||||
except Exception:
|
||||
# Never cache a failed load: it would pin an empty panel until restart.
|
||||
_log.exception("failed to load config schema for memory provider %r", name)
|
||||
return None
|
||||
|
||||
if schema is not None:
|
||||
_SCHEMA_CACHE[key] = schema
|
||||
return schema
|
||||
76
plugins/memory/hindsight/config_schema.py
Normal file
76
plugins/memory/hindsight/config_schema.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Hindsight's declared config surface — rendered by the generic desktop panel."""
|
||||
|
||||
from plugins.memory.config_schema import (
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
KIND_TEXT,
|
||||
ProviderConfigSchema,
|
||||
ProviderField,
|
||||
ProviderFieldOption,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = ProviderConfigSchema(
|
||||
name="hindsight",
|
||||
label="Hindsight",
|
||||
fields=(
|
||||
ProviderField(
|
||||
key="mode",
|
||||
label="Mode",
|
||||
kind=KIND_SELECT,
|
||||
default="cloud",
|
||||
description="How Hermes connects to Hindsight.",
|
||||
options=(
|
||||
ProviderFieldOption(
|
||||
"cloud",
|
||||
"Cloud",
|
||||
"Hindsight Cloud API (lightweight, just needs an API key)",
|
||||
),
|
||||
ProviderFieldOption(
|
||||
"local_external",
|
||||
"Local External",
|
||||
"Connect to an existing Hindsight instance",
|
||||
),
|
||||
),
|
||||
inline=True,
|
||||
),
|
||||
ProviderField(
|
||||
key="api_key",
|
||||
label="API key",
|
||||
kind=KIND_SECRET,
|
||||
env_key="HINDSIGHT_API_KEY",
|
||||
description="Used to authenticate with the Hindsight API.",
|
||||
placeholder="Enter Hindsight API key",
|
||||
inline=True,
|
||||
),
|
||||
ProviderField(
|
||||
key="api_url",
|
||||
label="API URL",
|
||||
kind=KIND_TEXT,
|
||||
default="https://api.hindsight.vectorize.io",
|
||||
aliases=("apiUrl",),
|
||||
env_fallbacks=("HINDSIGHT_API_URL",),
|
||||
inline=True,
|
||||
),
|
||||
ProviderField(
|
||||
key="bank_id",
|
||||
label="Bank ID",
|
||||
kind=KIND_TEXT,
|
||||
default="hermes",
|
||||
aliases=("bankId",),
|
||||
inline=True,
|
||||
),
|
||||
ProviderField(
|
||||
key="recall_budget",
|
||||
label="Recall budget",
|
||||
kind=KIND_SELECT,
|
||||
default="mid",
|
||||
aliases=("budget",),
|
||||
options=(
|
||||
ProviderFieldOption("low", "low"),
|
||||
ProviderFieldOption("mid", "mid"),
|
||||
ProviderFieldOption("high", "high"),
|
||||
),
|
||||
inline=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
324
plugins/memory/honcho/config_schema.py
Normal file
324
plugins/memory/honcho/config_schema.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""Honcho's declared config surface — rendered by the generic desktop panel."""
|
||||
|
||||
from plugins.memory.config_schema import (
|
||||
KIND_BOOL,
|
||||
KIND_JSON,
|
||||
KIND_NUMBER,
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
KIND_TEXT,
|
||||
STORAGE_HONCHO_HOST_BLOCK,
|
||||
ProviderConfigSchema,
|
||||
ProviderField,
|
||||
ProviderFieldOption,
|
||||
)
|
||||
|
||||
|
||||
# Reasoning effort levels shared by dialectic-related selects.
|
||||
_REASONING_LEVELS = (
|
||||
ProviderFieldOption("minimal", "Minimal"),
|
||||
ProviderFieldOption("low", "Low"),
|
||||
ProviderFieldOption("medium", "Medium"),
|
||||
ProviderFieldOption("high", "High"),
|
||||
ProviderFieldOption("max", "Max"),
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = ProviderConfigSchema(
|
||||
name="honcho",
|
||||
label="Honcho",
|
||||
storage=STORAGE_HONCHO_HOST_BLOCK,
|
||||
docs_url="https://docs.honcho.dev/v3/guides/integrations/hermes",
|
||||
fields=(
|
||||
# — Connection —
|
||||
ProviderField(
|
||||
key="apiKey",
|
||||
label="API key",
|
||||
kind=KIND_SECRET,
|
||||
env_key="HONCHO_API_KEY",
|
||||
description="Authenticate with Honcho Cloud. Not needed for a self-hosted base URL.",
|
||||
placeholder="Enter Honcho API key",
|
||||
inline=True,
|
||||
group="Connection",
|
||||
),
|
||||
ProviderField(
|
||||
key="baseUrl",
|
||||
label="Base URL",
|
||||
kind=KIND_TEXT,
|
||||
aliases=("base_url",),
|
||||
env_fallbacks=("HONCHO_BASE_URL",),
|
||||
description="Self-hosted Honcho URL. Overrides the environment when set.",
|
||||
placeholder="https://… (self-hosted)",
|
||||
inline=True,
|
||||
group="Connection",
|
||||
scope="root",
|
||||
),
|
||||
ProviderField(
|
||||
key="environment",
|
||||
label="Environment",
|
||||
kind=KIND_SELECT,
|
||||
default="production",
|
||||
env_fallbacks=("HONCHO_ENVIRONMENT",),
|
||||
description="Honcho environment. Ignored when a base URL is set.",
|
||||
options=(
|
||||
ProviderFieldOption("production", "Cloud"),
|
||||
ProviderFieldOption("local", "Local"),
|
||||
),
|
||||
inline=True,
|
||||
group="Connection",
|
||||
),
|
||||
ProviderField(
|
||||
key="workspace",
|
||||
label="Workspace",
|
||||
kind=KIND_TEXT,
|
||||
description="Honcho workspace ID. Defaults to the profile host.",
|
||||
inline=True,
|
||||
group="Connection",
|
||||
),
|
||||
# — Identity —
|
||||
ProviderField(
|
||||
key="peerName",
|
||||
label="Peer name",
|
||||
kind=KIND_TEXT,
|
||||
description="Your stable user peer. Unifies memory across platforms for single-user setups.",
|
||||
placeholder="e.g. eri",
|
||||
inline=True,
|
||||
group="Identity",
|
||||
),
|
||||
ProviderField(
|
||||
key="aiPeer",
|
||||
label="AI peer",
|
||||
kind=KIND_TEXT,
|
||||
description="The AI-side peer name. Defaults to the profile host.",
|
||||
inline=True,
|
||||
group="Identity",
|
||||
),
|
||||
# — Session —
|
||||
ProviderField(
|
||||
key="sessionStrategy",
|
||||
label="Session strategy",
|
||||
kind=KIND_SELECT,
|
||||
default="per-directory",
|
||||
description="How conversations map to Honcho sessions.",
|
||||
info=(
|
||||
"Per session: every conversation gets its own Honcho session. "
|
||||
"Per directory: conversations from the same working directory share one. "
|
||||
"Per repo: conversations from the same git repo share one. "
|
||||
"Global: everything shares a single session."
|
||||
),
|
||||
options=(
|
||||
ProviderFieldOption("per-session", "Per session"),
|
||||
ProviderFieldOption("per-directory", "Per directory"),
|
||||
ProviderFieldOption("per-repo", "Per repo"),
|
||||
ProviderFieldOption("global", "Global"),
|
||||
),
|
||||
inline=True,
|
||||
group="Session",
|
||||
),
|
||||
# —————— Full-config-only fields below (inline=False) ——————
|
||||
# — Connection —
|
||||
ProviderField(
|
||||
key="timeout",
|
||||
label="Request timeout",
|
||||
kind=KIND_NUMBER,
|
||||
aliases=("requestTimeout",),
|
||||
env_fallbacks=("HONCHO_TIMEOUT",),
|
||||
description="Request timeout in seconds for Honcho HTTP calls. Blank uses the default.",
|
||||
placeholder="30",
|
||||
group="Connection",
|
||||
scope="root",
|
||||
),
|
||||
# — Identity —
|
||||
ProviderField(
|
||||
key="pinUserPeer",
|
||||
label="Pin user peer",
|
||||
kind=KIND_BOOL,
|
||||
default="false",
|
||||
aliases=("pinPeerName",),
|
||||
description="Pin the user peer to the peer name, ignoring gateway runtime identity. Unifies memory for single-user setups.",
|
||||
group="Identity",
|
||||
),
|
||||
ProviderField(
|
||||
key="runtimePeerPrefix",
|
||||
label="Runtime peer prefix",
|
||||
kind=KIND_TEXT,
|
||||
description="Prefix applied to unknown gateway runtime user IDs.",
|
||||
placeholder="e.g. telegram_",
|
||||
group="Identity",
|
||||
),
|
||||
ProviderField(
|
||||
key="userPeerAliases",
|
||||
label="User peer aliases",
|
||||
kind=KIND_JSON,
|
||||
description="Map gateway runtime user IDs to stable Honcho peers.",
|
||||
placeholder='{"telegram_123": "eri"}',
|
||||
group="Identity",
|
||||
),
|
||||
# — Session —
|
||||
ProviderField(
|
||||
key="sessionPeerPrefix",
|
||||
label="Session peer prefix",
|
||||
kind=KIND_BOOL,
|
||||
default="false",
|
||||
description="Prefix session peer names with the host.",
|
||||
group="Session",
|
||||
),
|
||||
ProviderField(
|
||||
key="sessions",
|
||||
label="Session overrides",
|
||||
kind=KIND_JSON,
|
||||
description="Explicit session ID overrides keyed by resolver.",
|
||||
placeholder='{"key": "session-id"}',
|
||||
group="Session",
|
||||
scope="root",
|
||||
),
|
||||
# — Message writing —
|
||||
ProviderField(
|
||||
key="saveMessages",
|
||||
label="Save messages",
|
||||
kind=KIND_BOOL,
|
||||
default="true",
|
||||
description="Persist conversation messages to Honcho.",
|
||||
group="Message writing",
|
||||
),
|
||||
ProviderField(
|
||||
key="writeFrequency",
|
||||
label="Write frequency",
|
||||
kind=KIND_TEXT,
|
||||
default="async",
|
||||
description="When to flush messages: async, turn, session, or every N turns.",
|
||||
info=(
|
||||
"async: write in the background as messages arrive. "
|
||||
"turn: flush after each turn. session: flush when the session ends. "
|
||||
"A number N flushes every N turns."
|
||||
),
|
||||
placeholder="async | turn | session | N",
|
||||
group="Message writing",
|
||||
),
|
||||
# — Dialectic —
|
||||
ProviderField(
|
||||
key="dialecticReasoningLevel",
|
||||
label="Reasoning level",
|
||||
kind=KIND_SELECT,
|
||||
default="low",
|
||||
description="Reasoning effort for dialectic (peer.chat) calls.",
|
||||
options=_REASONING_LEVELS,
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticDynamic",
|
||||
label="Dynamic reasoning",
|
||||
kind=KIND_BOOL,
|
||||
default="true",
|
||||
description="Let the model override the reasoning level per call.",
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticMaxChars",
|
||||
label="Max result chars",
|
||||
kind=KIND_NUMBER,
|
||||
description="Max chars of dialectic result injected into the system prompt.",
|
||||
placeholder="1200",
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticDepth",
|
||||
label="Depth",
|
||||
kind=KIND_NUMBER,
|
||||
description="Dialectic passes per cycle (1–3).",
|
||||
placeholder="1",
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticDepthLevels",
|
||||
label="Per-pass levels",
|
||||
kind=KIND_JSON,
|
||||
description="Reasoning level per pass; array length matches depth.",
|
||||
placeholder='["low", "medium"]',
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticMaxInputChars",
|
||||
label="Max input chars",
|
||||
kind=KIND_NUMBER,
|
||||
description="Max chars of query input sent to peer.chat().",
|
||||
placeholder="10000",
|
||||
group="Dialectic",
|
||||
),
|
||||
# — Reasoning —
|
||||
ProviderField(
|
||||
key="reasoningHeuristic",
|
||||
label="Reasoning heuristic",
|
||||
kind=KIND_BOOL,
|
||||
default="true",
|
||||
description="Scale the reasoning level up on longer queries.",
|
||||
group="Reasoning",
|
||||
),
|
||||
ProviderField(
|
||||
key="reasoningLevelCap",
|
||||
label="Reasoning level cap",
|
||||
kind=KIND_SELECT,
|
||||
default="high",
|
||||
description="Ceiling for the heuristic-selected reasoning level.",
|
||||
options=_REASONING_LEVELS,
|
||||
group="Reasoning",
|
||||
),
|
||||
# — Recall —
|
||||
ProviderField(
|
||||
key="recallMode",
|
||||
label="Recall mode",
|
||||
kind=KIND_SELECT,
|
||||
default="hybrid",
|
||||
description="How memory retrieval works: hybrid, context-only, or tools-only.",
|
||||
info=(
|
||||
"Hybrid: auto-injected context plus on-demand memory tools. "
|
||||
"Context only: injection without tools. "
|
||||
"Tools only: the model queries memory explicitly, nothing is injected."
|
||||
),
|
||||
options=(
|
||||
ProviderFieldOption("hybrid", "Hybrid"),
|
||||
ProviderFieldOption("context", "Context only"),
|
||||
ProviderFieldOption("tools", "Tools only"),
|
||||
),
|
||||
group="Recall",
|
||||
),
|
||||
ProviderField(
|
||||
key="contextTokens",
|
||||
label="Context token cap",
|
||||
kind=KIND_NUMBER,
|
||||
description="Cap on auto-injected context tokens. Blank leaves it uncapped.",
|
||||
placeholder="(uncapped)",
|
||||
group="Recall",
|
||||
),
|
||||
ProviderField(
|
||||
key="initOnSessionStart",
|
||||
label="Eager init",
|
||||
kind=KIND_BOOL,
|
||||
default="false",
|
||||
description="Initialize the session eagerly in tools mode instead of on first tool call.",
|
||||
group="Recall",
|
||||
),
|
||||
# — Limits —
|
||||
ProviderField(
|
||||
key="messageMaxChars",
|
||||
label="Message max chars",
|
||||
kind=KIND_NUMBER,
|
||||
description="Max chars per message sent to Honcho.",
|
||||
placeholder="25000",
|
||||
group="Limits",
|
||||
),
|
||||
# — Observation —
|
||||
ProviderField(
|
||||
key="observationMode",
|
||||
label="Observation mode",
|
||||
kind=KIND_SELECT,
|
||||
default="directional",
|
||||
description="Per-peer observation preset. Directional observes all directions; unified shares one view.",
|
||||
options=(
|
||||
ProviderFieldOption("directional", "Directional"),
|
||||
ProviderFieldOption("unified", "Unified"),
|
||||
),
|
||||
group="Observation",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -491,6 +491,28 @@ class TestWebServerEndpoints:
|
|||
assert fields["api_key"]["url"] == "https://app.honcho.dev"
|
||||
assert fields["baseUrl"]["kind"] == "text"
|
||||
|
||||
def test_instance_schema_serves_providers_without_declared_schema(self, monkeypatch):
|
||||
# The default surface serves the plugin instance's get_config_schema().
|
||||
from hermes_cli import web_server
|
||||
|
||||
class _Stub:
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
{"key": "api_key", "description": "Stub API key", "secret": True, "url": "https://stub.example"},
|
||||
{"key": "baseUrl", "description": "Stub base URL"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(web_server, "_load_memory_provider", lambda name: _Stub())
|
||||
|
||||
resp = self.client.get("/api/memory/providers/mem0/config")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
fields = self._provider_field_map(data)
|
||||
assert fields["api_key"]["kind"] == "secret"
|
||||
assert fields["api_key"]["url"] == "https://stub.example"
|
||||
assert fields["baseUrl"]["kind"] == "text"
|
||||
|
||||
def test_declared_surface_serves_curated_hindsight_schema(self):
|
||||
resp = self.client.get("/api/memory/providers/hindsight/config?surface=declared")
|
||||
|
||||
|
|
@ -502,7 +524,7 @@ class TestWebServerEndpoints:
|
|||
assert fields["api_key"]["kind"] == "secret"
|
||||
|
||||
def test_declared_surface_hides_undeclared_providers(self):
|
||||
resp = self.client.get("/api/memory/providers/honcho/config?surface=declared")
|
||||
resp = self.client.get("/api/memory/providers/builtin/config?surface=declared")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["fields"] == []
|
||||
|
|
@ -534,7 +556,7 @@ class TestWebServerEndpoints:
|
|||
|
||||
def test_declared_surface_put_rejects_undeclared_provider(self):
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
"/api/memory/providers/builtin/config?surface=declared",
|
||||
json={"values": {"api_key": "x"}},
|
||||
)
|
||||
|
||||
|
|
@ -581,7 +603,9 @@ class TestWebServerEndpoints:
|
|||
assert config_resp.status_code == 200
|
||||
assert config_resp.json()["setup"]["external_dependencies"] == byterover_setup["external_dependencies"]
|
||||
|
||||
def test_memory_status_reports_honcho_needs_config_after_dependency_setup(self, monkeypatch):
|
||||
def test_memory_status_reports_honcho_needs_config_after_dependency_setup(self, monkeypatch, tmp_path):
|
||||
# Pin HOME so a developer's real ~/.honcho config can't flip the status.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
original_dependency_importable = web_server._dependency_importable
|
||||
|
|
@ -926,6 +950,333 @@ class TestWebServerEndpoints:
|
|||
fetched = self.client.get("/api/model/moa").json()
|
||||
assert fetched["presets"]["default"]["fanout"] == "user_turn"
|
||||
assert fetched["presets"]["default"]["reference_max_tokens"] == 600
|
||||
# ── Memory provider config (Honcho host-block backend) ──────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_honcho_config(self):
|
||||
# Honcho tests write the suite-wide HERMES_HOME honcho.json; snapshot and
|
||||
# restore it so provider status/config state never leaks across tests.
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
path = get_hermes_home() / "honcho.json"
|
||||
before = path.read_bytes() if path.exists() else None
|
||||
yield
|
||||
if before is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
path.write_bytes(before)
|
||||
|
||||
@staticmethod
|
||||
def _seed_local_honcho(cfg=None):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
path = get_hermes_home() / "honcho.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(cfg if cfg is not None else {}), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_get_honcho_config_returns_safe_defaults(self, monkeypatch, tmp_path):
|
||||
# HOME isn't isolated by the suite; pin it so ~/.honcho can't leak in.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
resp = self.client.get("/api/memory/providers/honcho/config?surface=declared")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "honcho"
|
||||
assert data["label"] == "Honcho"
|
||||
assert data["docs_url"] == "https://docs.honcho.dev/v3/guides/integrations/hermes"
|
||||
|
||||
fields = self._provider_field_map(data)
|
||||
assert fields["environment"]["kind"] == "select"
|
||||
assert fields["environment"]["value"] == "production"
|
||||
assert {opt["value"] for opt in fields["environment"]["options"]} == {
|
||||
"production",
|
||||
"local",
|
||||
}
|
||||
assert fields["sessionStrategy"]["value"] == "per-directory"
|
||||
# Blank workspace/aiPeer surface the resolved host as the placeholder.
|
||||
assert fields["workspace"]["value"] == ""
|
||||
assert fields["workspace"]["placeholder"] == "hermes"
|
||||
assert fields["aiPeer"]["placeholder"] == "hermes"
|
||||
assert fields["apiKey"]["kind"] == "secret"
|
||||
assert fields["apiKey"]["is_set"] is False
|
||||
|
||||
def test_put_honcho_writes_host_block_root_and_secret(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HONCHO_API_KEY", "guard")
|
||||
monkeypatch.delenv("HONCHO_API_KEY")
|
||||
self._seed_local_honcho()
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import load_config, load_env
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={
|
||||
"values": {
|
||||
"apiKey": "hch-test-key",
|
||||
"baseUrl": "https://honcho.example.dev",
|
||||
"environment": "local",
|
||||
"workspace": "myws",
|
||||
"peerName": "eri",
|
||||
"aiPeer": "hermes",
|
||||
"sessionStrategy": "per-repo",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
assert load_config()["memory"]["provider"] == "honcho"
|
||||
assert load_env()["HONCHO_API_KEY"] == "hch-test-key"
|
||||
|
||||
cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
|
||||
# baseUrl is root-scoped; the rest live in the active host block.
|
||||
assert cfg["baseUrl"] == "https://honcho.example.dev"
|
||||
assert cfg["hosts"]["hermes"]["workspace"] == "myws"
|
||||
assert cfg["hosts"]["hermes"]["peerName"] == "eri"
|
||||
assert cfg["hosts"]["hermes"]["environment"] == "local"
|
||||
assert cfg["hosts"]["hermes"]["sessionStrategy"] == "per-repo"
|
||||
# The key lands where the client reads first; GET keeps it write-only.
|
||||
assert cfg["hosts"]["hermes"]["apiKey"] == "hch-test-key"
|
||||
|
||||
def test_put_honcho_blank_text_clears_key(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
self._seed_local_honcho()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"workspace": "myws"}},
|
||||
)
|
||||
self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"workspace": ""}},
|
||||
)
|
||||
|
||||
cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
|
||||
assert "workspace" not in cfg.get("hosts", {}).get("hermes", {})
|
||||
|
||||
def test_put_honcho_partial_save_preserves_other_keys(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
self._seed_local_honcho()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"workspace": "myws"}},
|
||||
)
|
||||
self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"peerName": "eri"}},
|
||||
)
|
||||
|
||||
host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"]
|
||||
assert host["workspace"] == "myws"
|
||||
assert host["peerName"] == "eri"
|
||||
|
||||
def test_put_honcho_rejects_unsupported_select_value(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"environment": "bogus"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_honcho_config_does_not_return_secret(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HONCHO_API_KEY", "guard")
|
||||
monkeypatch.delenv("HONCHO_API_KEY")
|
||||
self._seed_local_honcho()
|
||||
|
||||
self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"apiKey": "secret-value"}},
|
||||
)
|
||||
|
||||
resp = self.client.get("/api/memory/providers/honcho/config?surface=declared")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
fields = self._provider_field_map(data)
|
||||
assert fields["apiKey"]["is_set"] is True
|
||||
assert fields["apiKey"]["value"] == ""
|
||||
assert "secret-value" not in json.dumps(data)
|
||||
|
||||
def test_put_honcho_bool_stored_natively_and_false_survives(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
self._seed_local_honcho()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"saveMessages": "false", "dialecticDynamic": "true"}},
|
||||
)
|
||||
|
||||
host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"]
|
||||
# Native JSON bools, not the strings "false"/"true" (which read truthy).
|
||||
assert host["saveMessages"] is False
|
||||
assert host["dialecticDynamic"] is True
|
||||
|
||||
fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json())
|
||||
assert fields["saveMessages"]["value"] == "false"
|
||||
assert fields["dialecticDynamic"]["value"] == "true"
|
||||
|
||||
def test_put_honcho_number_stored_as_native_number(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
self._seed_local_honcho()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"dialecticMaxChars": "1200", "timeout": "2.5"}},
|
||||
)
|
||||
|
||||
cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
|
||||
assert cfg["hosts"]["hermes"]["dialecticMaxChars"] == 1200
|
||||
assert isinstance(cfg["hosts"]["hermes"]["dialecticMaxChars"], int)
|
||||
# timeout is root-scoped and keeps its fractional part.
|
||||
assert cfg["timeout"] == 2.5
|
||||
|
||||
fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json())
|
||||
assert fields["dialecticMaxChars"]["value"] == "1200"
|
||||
|
||||
def test_put_honcho_json_round_trips_object(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
self._seed_local_honcho()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"userPeerAliases": '{"telegram_1": "eri"}'}},
|
||||
)
|
||||
|
||||
host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"]
|
||||
assert host["userPeerAliases"] == {"telegram_1": "eri"}
|
||||
|
||||
fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json())
|
||||
assert json.loads(fields["userPeerAliases"]["value"]) == {"telegram_1": "eri"}
|
||||
|
||||
def test_put_honcho_first_save_merges_into_resolved_config(self, monkeypatch, tmp_path):
|
||||
# With no profile-local file, a save merges into the resolved global config.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
global_path = tmp_path / ".honcho" / "config.json"
|
||||
global_path.parent.mkdir(parents=True)
|
||||
global_path.write_text(
|
||||
json.dumps({"baseUrl": "https://kept.example", "hosts": {"hermes": {"workspace": "kept"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"peerName": "eri"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert not (get_hermes_home() / "honcho.json").exists()
|
||||
cfg = json.loads(global_path.read_text(encoding="utf-8"))
|
||||
assert cfg["baseUrl"] == "https://kept.example"
|
||||
assert cfg["hosts"]["hermes"] == {"workspace": "kept", "peerName": "eri"}
|
||||
|
||||
def test_put_honcho_updates_legacy_dot_form_host_block(self, monkeypatch, tmp_path):
|
||||
# The legacy dot-form block reads resolve is updated in place, not shadowed.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HERMES_HONCHO_HOST", "hermes_work")
|
||||
|
||||
path = self._seed_local_honcho({"hosts": {"hermes.work": {"workspace": "w", "peerName": "eri"}}})
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"sessionStrategy": "per-repo"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
hosts = json.loads(path.read_text(encoding="utf-8"))["hosts"]
|
||||
assert set(hosts) == {"hermes.work"}
|
||||
assert hosts["hermes.work"] == {"workspace": "w", "peerName": "eri", "sessionStrategy": "per-repo"}
|
||||
|
||||
def test_put_honcho_api_key_never_overwrites_oauth_token(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HONCHO_API_KEY", "guard")
|
||||
monkeypatch.delenv("HONCHO_API_KEY")
|
||||
from hermes_cli.config import load_env
|
||||
|
||||
path = self._seed_local_honcho({"hosts": {"hermes": {"apiKey": "hch-at-oauth-token"}}})
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"apiKey": "manual-key"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
cfg = json.loads(path.read_text(encoding="utf-8"))
|
||||
# The OAuth grant owns the JSON slot; the manual key lands in the env store.
|
||||
assert cfg["hosts"]["hermes"]["apiKey"] == "hch-at-oauth-token"
|
||||
assert load_env()["HONCHO_API_KEY"] == "manual-key"
|
||||
|
||||
def test_put_honcho_tolerates_null_hosts(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
path = self._seed_local_honcho({"hosts": None})
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"workspace": "myws"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert json.loads(path.read_text(encoding="utf-8"))["hosts"]["hermes"]["workspace"] == "myws"
|
||||
|
||||
def test_memory_provider_config_honors_profile_param(self, monkeypatch, tmp_path):
|
||||
# A ?profile= save must land in that profile's config, not the serving
|
||||
# process's — same contract as the skills/toolsets endpoints.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
# The suite pins HERMES_HONCHO_HOST=hermes; this test exercises
|
||||
# profile-driven host resolution, so drop the override explicitly.
|
||||
monkeypatch.delenv("HERMES_HONCHO_HOST", raising=False)
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.profiles import get_profile_dir
|
||||
|
||||
self._seed_local_honcho()
|
||||
|
||||
worker_home = get_profile_dir("worker")
|
||||
worker_home.mkdir(parents=True, exist_ok=True)
|
||||
worker_cfg = worker_home / "honcho.json"
|
||||
worker_cfg.write_text(json.dumps({"hosts": {"hermes_worker": {"workspace": "kept"}}}), encoding="utf-8")
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared&profile=worker",
|
||||
json={"values": {"peerName": "eri"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
worker_hosts = json.loads(worker_cfg.read_text(encoding="utf-8"))["hosts"]
|
||||
host_block = next(iter(worker_hosts.values()))
|
||||
assert host_block["peerName"] == "eri"
|
||||
# The serving process's own config is untouched.
|
||||
own = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
|
||||
assert "peerName" not in json.dumps(own)
|
||||
|
||||
fields = self._provider_field_map(
|
||||
self.client.get("/api/memory/providers/honcho/config?surface=declared&profile=worker").json()
|
||||
)
|
||||
assert fields["peerName"]["value"] == "eri"
|
||||
|
||||
def test_put_honcho_rejects_malformed_number_and_json(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
assert self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"dialecticMaxChars": "lots"}},
|
||||
).status_code == 400
|
||||
assert self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"userPeerAliases": "{not json"}},
|
||||
).status_code == 400
|
||||
|
||||
# ── GET /api/media (remote image display) ───────────────────────────
|
||||
|
||||
|
|
@ -3843,10 +4194,12 @@ class TestBuildSchemaFromConfig:
|
|||
assert entry["type"] == "select"
|
||||
assert entry["category"] == "memory"
|
||||
options = entry["options"]
|
||||
# Built-in sentinel first, plus at least one discovered provider.
|
||||
# Built-in-only sentinel first, plus at least one discovered provider.
|
||||
# The literal "builtin" alias must NOT be offered — built-in memory is
|
||||
# not a provider plugin (#49513).
|
||||
assert options[0] == ""
|
||||
assert "builtin" in options
|
||||
assert len(options) >= 3
|
||||
assert "builtin" not in options
|
||||
assert len(options) >= 2
|
||||
|
||||
def test_memory_provider_options_cover_discovered_providers(self):
|
||||
"""Every provider the /api/memory endpoint can activate is selectable."""
|
||||
|
|
|
|||
67
tests/plugins/memory/test_config_schema.py
Normal file
67
tests/plugins/memory/test_config_schema.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Tests for config-schema loading from memory provider plugin dirs."""
|
||||
|
||||
import plugins.memory.config_schema as config_schema
|
||||
from plugins.memory.config_schema import get_provider_config_schema
|
||||
|
||||
|
||||
def test_unknown_provider_is_none():
|
||||
assert get_provider_config_schema("builtin") is None
|
||||
|
||||
|
||||
def test_plugin_without_schema_is_none():
|
||||
# mem0 is a real plugin dir that declares no config_schema.py.
|
||||
assert get_provider_config_schema("mem0") is None
|
||||
|
||||
|
||||
def test_schemas_are_cached_per_provider():
|
||||
assert get_provider_config_schema("honcho") is get_provider_config_schema("honcho")
|
||||
|
||||
|
||||
def test_cache_keys_on_schema_path_not_name(monkeypatch, tmp_path):
|
||||
# User-installed plugins are per-profile; two profiles' plugins sharing a
|
||||
# name must not answer for each other.
|
||||
import plugins.memory as memory
|
||||
|
||||
schemas = {}
|
||||
for label in ("A", "B"):
|
||||
plugin_dir = tmp_path / label / "custom"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "config_schema.py").write_text(
|
||||
"from plugins.memory.config_schema import ProviderConfigSchema\n"
|
||||
f'CONFIG_SCHEMA = ProviderConfigSchema(name="custom", label="{label}")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
schemas[label] = plugin_dir
|
||||
|
||||
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
|
||||
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["A"])
|
||||
assert get_provider_config_schema("custom").label == "A"
|
||||
|
||||
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["B"])
|
||||
assert get_provider_config_schema("custom").label == "B"
|
||||
|
||||
|
||||
def test_broken_schema_is_not_cached(monkeypatch, tmp_path):
|
||||
# A load failure must retry on the next request, not pin an empty panel.
|
||||
broken_dir = tmp_path / "broken"
|
||||
broken_dir.mkdir()
|
||||
schema_file = broken_dir / "config_schema.py"
|
||||
schema_file.write_text("this is not python(", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
|
||||
import plugins.memory as memory
|
||||
|
||||
monkeypatch.setattr(memory, "find_provider_dir", lambda name: broken_dir)
|
||||
|
||||
assert get_provider_config_schema("broken") is None
|
||||
assert not config_schema._SCHEMA_CACHE
|
||||
|
||||
schema_file.write_text(
|
||||
"from plugins.memory.config_schema import ProviderConfigSchema\n"
|
||||
'CONFIG_SCHEMA = ProviderConfigSchema(name="broken", label="Broken")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
recovered = get_provider_config_schema("broken")
|
||||
assert recovered is not None
|
||||
assert recovered.label == "Broken"
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
"""Tests for the declarative memory-provider registry."""
|
||||
"""Tests for Hindsight's declared config surface."""
|
||||
|
||||
from hermes_cli.memory_providers import (
|
||||
from plugins.memory.config_schema import (
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
get_memory_provider,
|
||||
get_provider_config_schema,
|
||||
)
|
||||
|
||||
|
||||
def test_hindsight_is_declared():
|
||||
provider = get_memory_provider("hindsight")
|
||||
provider = get_provider_config_schema("hindsight")
|
||||
|
||||
assert provider is not None
|
||||
assert provider.label == "Hindsight"
|
||||
|
|
@ -21,8 +21,17 @@ def test_hindsight_is_declared():
|
|||
}
|
||||
|
||||
|
||||
def test_hindsight_mode_gating_is_expressed_as_select_options():
|
||||
provider = get_memory_provider("hindsight")
|
||||
def test_fields_are_all_inline():
|
||||
provider = get_provider_config_schema("hindsight")
|
||||
assert provider is not None
|
||||
|
||||
# Hindsight is simple enough to render fully in the compact panel, so it
|
||||
# never grows a Full config… modal.
|
||||
assert all(field.inline for field in provider.fields)
|
||||
|
||||
|
||||
def test_mode_gating_is_expressed_as_select_options():
|
||||
provider = get_provider_config_schema("hindsight")
|
||||
assert provider is not None
|
||||
|
||||
mode = next(field for field in provider.fields if field.key == "mode")
|
||||
|
|
@ -33,14 +42,10 @@ def test_hindsight_mode_gating_is_expressed_as_select_options():
|
|||
|
||||
|
||||
def test_api_key_is_a_secret_bound_to_env():
|
||||
provider = get_memory_provider("hindsight")
|
||||
provider = get_provider_config_schema("hindsight")
|
||||
assert provider is not None
|
||||
|
||||
api_key = next(field for field in provider.fields if field.key == "api_key")
|
||||
assert api_key.kind == KIND_SECRET
|
||||
assert api_key.is_secret is True
|
||||
assert api_key.env_key == "HINDSIGHT_API_KEY"
|
||||
|
||||
|
||||
def test_unknown_provider_is_none():
|
||||
assert get_memory_provider("builtin") is None
|
||||
90
tests/plugins/memory/test_honcho_config_schema.py
Normal file
90
tests/plugins/memory/test_honcho_config_schema.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Tests for Honcho's declared config surface."""
|
||||
|
||||
from plugins.memory.config_schema import (
|
||||
KIND_BOOL,
|
||||
KIND_JSON,
|
||||
KIND_NUMBER,
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
STORAGE_HONCHO_HOST_BLOCK,
|
||||
get_provider_config_schema,
|
||||
)
|
||||
|
||||
# The curated set shown in the compact panel; everything else lives in the modal.
|
||||
INLINE_KEYS = {
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
"environment",
|
||||
"workspace",
|
||||
"peerName",
|
||||
"aiPeer",
|
||||
"sessionStrategy",
|
||||
}
|
||||
|
||||
|
||||
def test_honcho_is_declared():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
|
||||
assert provider is not None
|
||||
assert provider.label == "Honcho"
|
||||
assert provider.storage == STORAGE_HONCHO_HOST_BLOCK
|
||||
# Field keys are unique, and the curated inline set is present.
|
||||
keys = [field.key for field in provider.fields]
|
||||
assert len(keys) == len(set(keys))
|
||||
assert INLINE_KEYS <= set(keys)
|
||||
|
||||
|
||||
def test_inline_fields_are_the_curated_subset():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
assert {field.key for field in provider.inline_fields()} == INLINE_KEYS
|
||||
# The modal-only fields are a non-empty remainder.
|
||||
non_inline = {f.key for f in provider.fields} - INLINE_KEYS
|
||||
assert {"writeFrequency", "recallMode", "userPeerAliases"} <= non_inline
|
||||
|
||||
|
||||
def test_declares_the_new_field_kinds():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
by_key = {f.key: f for f in provider.fields}
|
||||
assert by_key["saveMessages"].kind == KIND_BOOL
|
||||
assert by_key["dialecticMaxChars"].kind == KIND_NUMBER
|
||||
assert by_key["userPeerAliases"].kind == KIND_JSON
|
||||
assert by_key["recallMode"].allowed_values() == {"hybrid", "context", "tools"}
|
||||
assert by_key["observationMode"].allowed_values() == {"directional", "unified"}
|
||||
|
||||
|
||||
def test_selects_constrain_their_values():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
environment = next(f for f in provider.fields if f.key == "environment")
|
||||
assert environment.kind == KIND_SELECT
|
||||
# Honcho SDK only accepts local/production; "demo" is not a valid environment.
|
||||
assert environment.allowed_values() == {"production", "local"}
|
||||
|
||||
strategy = next(f for f in provider.fields if f.key == "sessionStrategy")
|
||||
assert strategy.allowed_values() == {"per-directory", "per-repo", "per-session", "global"}
|
||||
|
||||
|
||||
def test_api_key_is_a_secret_bound_to_env():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
api_key = next(f for f in provider.fields if f.key == "apiKey")
|
||||
assert api_key.kind == KIND_SECRET
|
||||
assert api_key.is_secret is True
|
||||
assert api_key.env_key == "HONCHO_API_KEY"
|
||||
|
||||
|
||||
def test_root_scoped_fields_are_exactly_the_global_ones():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
scopes = {f.key: f.scope for f in provider.fields}
|
||||
root_keys = {k for k, scope in scopes.items() if scope == "root"}
|
||||
# baseUrl, timeout and sessions live at the config root in Honcho's schema;
|
||||
# everything else is per-profile host-scoped.
|
||||
assert root_keys == {"baseUrl", "timeout", "sessions"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue