mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(desktop): restore curated declared schema for the provider panel
The desktop provider panel previously rendered the curated declarations from hermes_cli/memory_providers.py: five hindsight fields, and no panel at all for undeclared providers like honcho (OAuth connect only). The dashboard provider-switching rework re-pointed the shared config route at raw plugin schemas, so the desktop began dumping every internal field (35 for hindsight) and grew a bespoke honcho panel. Serve both surfaces from the same route: ?surface=declared returns the curated schema (empty for undeclared providers) with the original config-file + env-store write semantics; the dashboard keeps the raw plugin schema unchanged. The desktop client opts into declared.
This commit is contained in:
parent
861d69c7bb
commit
c7e09f2571
3 changed files with 193 additions and 4 deletions
|
|
@ -413,15 +413,16 @@ 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>({
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
|
||||
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 }>({
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`,
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
|
||||
method: 'PUT',
|
||||
body: { values }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ 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:
|
||||
|
|
@ -5169,9 +5174,129 @@ 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):
|
||||
async def get_memory_provider_config(name: str, surface: 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)
|
||||
|
||||
provider = _load_memory_provider(name)
|
||||
if provider is None:
|
||||
# Undeclared providers (e.g. builtin) have no config surface. Return an
|
||||
|
|
@ -5202,8 +5327,22 @@ 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):
|
||||
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, surface: 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}")
|
||||
|
|
|
|||
|
|
@ -491,6 +491,55 @@ class TestWebServerEndpoints:
|
|||
assert fields["api_key"]["url"] == "https://app.honcho.dev"
|
||||
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")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
fields = self._provider_field_map(data)
|
||||
assert set(fields) == {"mode", "api_key", "api_url", "bank_id", "recall_budget"}
|
||||
assert fields["mode"]["kind"] == "select"
|
||||
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")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["fields"] == []
|
||||
|
||||
def test_declared_surface_put_writes_config_and_secret(self):
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import load_env
|
||||
|
||||
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",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
assert load_env()["HINDSIGHT_API_KEY"] == "hs-declared-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
|
||||
|
||||
def test_declared_surface_put_rejects_undeclared_provider(self):
|
||||
resp = self.client.put(
|
||||
"/api/memory/providers/honcho/config?surface=declared",
|
||||
json={"values": {"api_key": "x"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_all_listed_memory_provider_configs_fetch(self):
|
||||
resp = self.client.get("/api/memory")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue