mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
fix(memory): profile-scope the provider config endpoints
The settings page follows the desktop's active-profile switcher, but the provider config calls didn't: no profileScoped() on the client and no profile param on the backend, so a multi-profile desktop edited the serving process's config while every surrounding card showed the selected profile's. Accept ?profile= on both endpoints and resolve inside _profile_scope (the skills/toolsets contract), spread profileScoped() into the two client calls, and key the schema cache on the resolved config_schema.py path instead of the provider name — user-installed plugins are per-profile, so one profile's lookup must never answer for another's.
This commit is contained in:
parent
703305751d
commit
76c063b3d9
6 changed files with 113 additions and 30 deletions
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -369,12 +369,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
|
|||
|
||||
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
|
||||
return window.hermesDesktop.api<MemoryProviderConfig>({
|
||||
...profileScoped(),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
|
||||
})
|
||||
}
|
||||
|
||||
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`,
|
||||
method: 'PUT',
|
||||
body: { values }
|
||||
|
|
|
|||
|
|
@ -4177,13 +4177,17 @@ def _write_provider_honcho(provider: ProviderConfigSchema, values: Dict[str, str
|
|||
|
||||
|
||||
@app.get("/api/memory/providers/{name}/config")
|
||||
async def get_memory_provider_config(name: str):
|
||||
provider = await asyncio.to_thread(get_provider_config_schema, 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, "docs_url": "", "fields": []}
|
||||
return await asyncio.to_thread(_memory_provider_payload, provider)
|
||||
async def get_memory_provider_config(name: str, profile: Optional[str] = None):
|
||||
def _run():
|
||||
with _profile_scope(profile):
|
||||
provider = get_provider_config_schema(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, "docs_url": "", "fields": []}
|
||||
return _memory_provider_payload(provider)
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
|
||||
def _update_memory_provider_config(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
|
||||
|
|
@ -4203,13 +4207,16 @@ def _update_memory_provider_config(provider: ProviderConfigSchema, values: Dict[
|
|||
|
||||
|
||||
@app.put("/api/memory/providers/{name}/config")
|
||||
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate):
|
||||
provider = await asyncio.to_thread(get_provider_config_schema, name)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
|
||||
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, profile: Optional[str] = None):
|
||||
def _run():
|
||||
with _profile_scope(profile):
|
||||
provider = get_provider_config_schema(name)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
|
||||
_update_memory_provider_config(provider, body.values or {})
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_update_memory_provider_config, provider, body.values or {})
|
||||
await asyncio.to_thread(_run)
|
||||
return {"ok": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -104,34 +104,39 @@ class ProviderConfigSchema:
|
|||
return tuple(f for f in self.fields if f.inline)
|
||||
|
||||
|
||||
_SCHEMA_CACHE: dict[str, ProviderConfigSchema | None] = {}
|
||||
_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.
|
||||
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.
|
||||
"""
|
||||
|
||||
if name in _SCHEMA_CACHE:
|
||||
return _SCHEMA_CACHE[name]
|
||||
|
||||
from plugins.memory import find_provider_dir
|
||||
|
||||
schema: ProviderConfigSchema | None = None
|
||||
provider_dir = find_provider_dir(name)
|
||||
path = provider_dir / "config_schema.py" if provider_dir else None
|
||||
if path is not None and path.is_file():
|
||||
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 path is None or not path.is_file():
|
||||
return None
|
||||
|
||||
_SCHEMA_CACHE[name] = schema
|
||||
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
|
||||
|
|
|
|||
|
|
@ -771,6 +771,38 @@ class TestWebServerEndpoints:
|
|||
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))
|
||||
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?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?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))
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,30 @@ 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"
|
||||
|
|
@ -30,7 +54,7 @@ def test_broken_schema_is_not_cached(monkeypatch, tmp_path):
|
|||
monkeypatch.setattr(memory, "find_provider_dir", lambda name: broken_dir)
|
||||
|
||||
assert get_provider_config_schema("broken") is None
|
||||
assert "broken" not in config_schema._SCHEMA_CACHE
|
||||
assert not config_schema._SCHEMA_CACHE
|
||||
|
||||
schema_file.write_text(
|
||||
"from plugins.memory.config_schema import ProviderConfigSchema\n"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue