Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src/app/settings/memory/field-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function FieldControl({
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItem disabled={option.disabled} key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ function honchoSchema(): MemoryProviderConfig {
}

beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
getMemoryProviderConfig.mockResolvedValue(honchoSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
Expand Down Expand Up @@ -186,6 +187,32 @@ describe('ProviderConfigPanel', () => {
expect(screen.getByRole('button', { name: /Full config/ })).toBeTruthy()
})

it('shows a stored select value that Desktop cannot choose', async () => {
const config = honchoSchema()
const environment = config.fields.find(field => field.key === 'environment')

if (!environment) {
throw new Error('environment field missing from fixture')
}

environment.value = 'local_embedded'
environment.options.push({
value: 'local_embedded',
label: 'Local Embedded',
description: 'This stored value is not selectable in Desktop.',
disabled: true
})

getMemoryProviderConfig.mockResolvedValue(config)

await renderPanel()

expect(await screen.findByText('Local Embedded')).toBeTruthy()
fireEvent.click(screen.getByRole('combobox'))
const option = await screen.findByRole('option', { name: 'Local Embedded' })
expect(option.getAttribute('aria-disabled')).toBe('true')
})

it('shows an inline error with retry when the load fails, then recovers', async () => {
getMemoryProviderConfig.mockRejectedValueOnce(new Error('Timed out connecting to Hermes backend'))

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export type MemoryProviderFieldKind = 'bool' | 'json' | 'number' | 'secret' | 's

export interface MemoryProviderFieldOption {
description: string
disabled?: boolean
label: string
value: string
}
Expand Down
Empty file.
1 change: 1 addition & 0 deletions contributors/emails/edrayoca+agent@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Blut-Agent
42 changes: 41 additions & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4981,6 +4981,24 @@ def _provider_field_entry(field: ProviderField) -> Dict[str, Any]:
}


def _stored_select_option(value: str) -> Dict[str, Any]:
"""Represent a stored value that this surface deliberately cannot select.

Older versions, another Hermes surface, or a hand-edited provider config
can contain a valid runtime value outside the Desktop declaration. Showing
that value honestly is read-only: the declared options still remain the
write allow-list enforced by ``_coerce_field_value``.
"""

label = value.replace("_", " ").replace("-", " ").title()
return {
"value": value,
"label": label,
"description": "This stored value is not selectable in Desktop.",
"disabled": True,
}


# Sentinel: remove this key so it falls back to the host or built-in default.
_UNSET: Any = object()

Expand Down Expand Up @@ -5127,6 +5145,16 @@ def _honcho_read_sources() -> tuple[Dict[str, Any], str, Dict[str, Any]]:
return raw, host, host_block_of(raw, host)


def _read_field_dep(when_pred: Dict[str, str], sources: tuple, env: Dict[str, str]) -> str | None:
"""Return the stored value for the dependency key used in a ``when`` predicate."""

dep_key = next(iter(when_pred))
for source in sources:
if dep_key in source and source[dep_key] is not None:
return str(source[dep_key])
return None


def _declared_provider_payload(provider: ProviderConfigSchema) -> Dict[str, Any]:
fields: List[Dict[str, Any]] = []
env = load_env()
Expand All @@ -5145,6 +5173,15 @@ def sources_for(field: ProviderField) -> tuple:
return (data,)

for field in provider.fields:
# Skip fields whose conditional visibility predicate is not met.
if field.when:
dep_value = _read_field_dep(field.when, sources_for(field), env)
if dep_value is None:
continue
dep_key = next(iter(field.when))
if str(dep_value) != str(field.when.get(dep_key)):
continue

entry = _provider_field_entry(field)
sources = sources_for(field)

Expand All @@ -5161,7 +5198,10 @@ def sources_for(field: ProviderField) -> tuple:

value = _serialize_field_value(field, native)
if field.kind == "select" and value not in field.allowed_values():
value = field.default
if native is None:
value = field.default
else:
entry["options"].append(_stored_select_option(value))
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)
Expand Down
3 changes: 3 additions & 0 deletions plugins/memory/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ class ProviderField:
info: str = ""
# Host-block placement: "host" (per-profile) or "root"; flat-json ignores it.
scope: str = "host"
# Conditional visibility: field is only shown when all keys match their expected values
# in the stored provider config (e.g. ``when={"mode": "local_embedded"}``).
when: dict[str, str] | None = None

@property
def is_secret(self) -> bool:
Expand Down
62 changes: 58 additions & 4 deletions plugins/memory/hindsight/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
name="hindsight",
label="Hindsight",
fields=(
# — Connection —
ProviderField(
key="mode",
label="Mode",
Expand All @@ -32,32 +33,84 @@
),
),
inline=True,
group="Connection",
),
ProviderField(
key="api_key",
label="API key",
label="Hindsight API key",
kind=KIND_SECRET,
env_key="HINDSIGHT_API_KEY",
description="Used to authenticate with the Hindsight API.",
description="Cloud or Local External authentication. Not used by Local Embedded mode.",
placeholder="Enter Hindsight API key",
inline=True,
group="Connection",
),
ProviderField(
key="api_url",
label="API URL",
label="Hindsight API URL",
kind=KIND_TEXT,
default="https://api.hindsight.vectorize.io",
aliases=("apiUrl",),
env_fallbacks=("HINDSIGHT_API_URL",),
description="Cloud or Local External endpoint. Not used by Local Embedded mode.",
inline=True,
group="Connection",
),
# — Local Embedded LLM —
ProviderField(
key="llm_provider",
label="LLM provider",
kind=KIND_SELECT,
default="openai",
description="LLM backend used by Local Embedded mode.",
options=(
ProviderFieldOption("openai", "OpenAI"),
ProviderFieldOption("anthropic", "Anthropic"),
ProviderFieldOption("gemini", "Gemini"),
ProviderFieldOption("groq", "Groq"),
ProviderFieldOption("openrouter", "OpenRouter"),
ProviderFieldOption("minimax", "MiniMax"),
ProviderFieldOption("ollama", "Ollama"),
ProviderFieldOption("lmstudio", "LM Studio"),
ProviderFieldOption("openai_compatible", "OpenAI compatible"),
),
group="Local Embedded LLM",
),
ProviderField(
key="llm_base_url",
label="LLM base URL",
kind=KIND_TEXT,
description="Custom LLM endpoint used by Local Embedded mode.",
placeholder="https://…/v1",
group="Local Embedded LLM",
),
ProviderField(
key="llm_api_key",
label="LLM API key",
kind=KIND_SECRET,
env_key="HINDSIGHT_LLM_API_KEY",
description="LLM credential used by Local Embedded mode. Optional for some local servers.",
placeholder="Enter LLM API key",
group="Local Embedded LLM",
),
ProviderField(
key="llm_model",
label="LLM model",
kind=KIND_TEXT,
default="gpt-4o-mini",
description="Model used by Local Embedded mode.",
placeholder="gpt-4o-mini",
group="Local Embedded LLM",
),
# — Memory —
ProviderField(
key="bank_id",
label="Bank ID",
kind=KIND_TEXT,
default="hermes",
aliases=("bankId",),
inline=True,
group="Memory",
),
ProviderField(
key="recall_budget",
Expand All @@ -71,6 +124,7 @@
ProviderFieldOption("high", "high"),
),
inline=True,
group="Memory",
),
),
)
)
68 changes: 58 additions & 10 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,35 +701,83 @@ def test_openviking_dashboard_rejects_blocked_endpoint_before_saving(self):
memory_config = load_config().get("memory", {})
assert "openviking" not in memory_config

def test_declared_surface_preserves_local_embedded_values(self):
from hermes_constants import get_hermes_home
from hermes_cli.config import save_env_value

config_path = get_hermes_home() / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
json.dumps(
{
"mode": "local_embedded",
"llm_provider": "openai_compatible",
"llm_base_url": "https://llm.example/v1",
"llm_model": "example-model",
}
),
encoding="utf-8",
)
save_env_value("HINDSIGHT_LLM_API_KEY", "local-secret")

resp = self.client.get("/api/memory/providers/hindsight/config?surface=declared")



assert resp.status_code == 200
data = resp.json()
fields = self._provider_field_map(data)
assert fields["mode"]["value"] == "local_embedded"
local_option = next(
option
for option in fields["mode"]["options"]
if option["value"] == "local_embedded"
)
assert local_option["disabled"] is True
assert fields["llm_provider"]["value"] == "openai_compatible"
assert fields["llm_base_url"]["value"] == "https://llm.example/v1"
assert fields["llm_model"]["value"] == "example-model"
assert fields["llm_api_key"]["is_set"] is True
assert fields["llm_api_key"]["value"] == ""
assert "local-secret" not in json.dumps(data)
def test_declared_surface_put_writes_config_and_secret(self):
from hermes_constants import get_hermes_home
from hermes_cli.config import load_env

config_path = get_hermes_home() / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
json.dumps({"mode": "local_embedded"}), encoding="utf-8"
)

resp = self.client.put(
"/api/memory/providers/hindsight/config?surface=declared",
json={
"values": {
"mode": "local_external",
"api_url": "http://localhost:8888",
"api_key": "hs-declared-key",
"llm_provider": "openai_compatible",
"llm_base_url": "https://llm.example/v1",
"llm_model": "example-model",
"llm_api_key": "hs-llm-key",
}
},
)

assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert load_env()["HINDSIGHT_API_KEY"] == "hs-declared-key"
assert load_env()["HINDSIGHT_LLM_API_KEY"] == "hs-llm-key"

config_path = get_hermes_home() / "hindsight" / "config.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config["mode"] == "local_external"
assert provider_config["api_url"] == "http://localhost:8888"
assert "api_key" not in provider_config
assert provider_config["mode"] == "local_embedded"
assert provider_config["llm_provider"] == "openai_compatible"
assert provider_config["llm_base_url"] == "https://llm.example/v1"
assert provider_config["llm_model"] == "example-model"
assert "llm_api_key" not in provider_config

def test_declared_surface_put_keeps_local_embedded_setup_cli_owned(self):
resp = self.client.put(
"/api/memory/providers/hindsight/config?surface=declared",
json={"values": {"mode": "local_embedded"}},
)

assert resp.status_code == 400



Expand Down
Loading
Loading