diff --git a/apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx b/apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx index 91eea2e1e7e2..bfa0a54c5711 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx @@ -30,6 +30,7 @@ function field(overrides: Partial & Pick ({ getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider), - saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values) + saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values), + runMemoryProviderAction: (provider: string, action: string, values: unknown) => + runMemoryProviderAction(provider, action, values) })) vi.mock('@/store/notifications', () => ({ @@ -18,6 +21,7 @@ vi.mock('@/store/notifications', () => ({ function honchoSchema(): MemoryProviderConfig { return { + actions: [], name: 'honcho', label: 'Honcho', docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes', @@ -95,6 +99,7 @@ function honchoSchema(): MemoryProviderConfig { beforeEach(() => { getMemoryProviderConfig.mockResolvedValue(honchoSchema()) saveMemoryProviderConfig.mockResolvedValue({ ok: true }) + runMemoryProviderAction.mockResolvedValue({ ok: true, result: { message: 'pong' } }) }) afterEach(() => { @@ -161,6 +166,20 @@ describe('ProviderConfigPanel', () => { expect(screen.getByRole('button', { name: /Full config/ })).toBeTruthy() }) + it('renders declared actions as buttons and posts the inline values', async () => { + const schema = honchoSchema() + schema.actions = [{ key: 'ping', label: 'Ping', description: 'Check the server' }] + getMemoryProviderConfig.mockResolvedValue(schema) + + await renderPanel() + + fireEvent.click(await screen.findByRole('button', { name: 'Ping' })) + + await waitFor(() => + expect(runMemoryProviderAction).toHaveBeenCalledWith('honcho', 'ping', expect.objectContaining({ workspace: 'myws' })) + ) + }) + it('renders nothing for a provider with no declared config surface', async () => { getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] }) diff --git a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx index db8b49b227b6..7603547d33df 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx @@ -2,7 +2,7 @@ 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 { getMemoryProviderConfig, runMemoryProviderAction, saveMemoryProviderConfig } from '@/hermes' import { Loader2, Save, SlidersHorizontal } from '@/lib/icons' import { notify, notifyError } from '@/store/notifications' import type { MemoryProviderConfig } from '@/types/hermes' @@ -23,6 +23,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) { const [values, setValues] = useState>({}) const [expanded, setExpanded] = useState(true) const [saving, setSaving] = useState(false) + const [runningAction, setRunningAction] = useState(null) const [showModal, setShowModal] = useState(false) const refresh = useCallback(async () => { @@ -59,6 +60,26 @@ export function ProviderConfigPanel({ provider }: { provider: string }) { } }, [config, provider, refresh, values]) + const runAction = useCallback( + async (key: string, label: string) => { + setRunningAction(key) + try { + const { result } = await runMemoryProviderAction(provider, key, values) + notify({ + kind: 'success', + title: label, + message: typeof result.message === 'string' ? result.message : 'Action completed.' + }) + await refresh() + } catch (err) { + notifyError(err, `${label} failed`) + } finally { + setRunningAction(null) + } + }, + [provider, refresh, values] + ) + // Providers without a declared config surface (e.g. builtin) render nothing. if (config && config.fields.length === 0) { return null @@ -115,8 +136,22 @@ export function ProviderConfigPanel({ provider }: { provider: string }) { ))} -
- + ))} + diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index cf0ef688a36c..2064b802c1ea 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -383,6 +383,19 @@ export function saveMemoryProviderConfig(provider: string, values: Record +): Promise<{ ok: boolean; result: Record }> { + return window.hermesDesktop.api<{ ok: boolean; result: Record }>({ + ...profileScoped(), + path: `/api/memory/providers/${encodeURIComponent(provider)}/actions/${encodeURIComponent(action)}`, + method: 'POST', + body: { values } + }) +} + export function getEnvVars(): Promise> { return window.hermesDesktop.api>({ ...profileScoped(), diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 55b9400078c7..90e34f934060 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -141,7 +141,14 @@ export interface MemoryProviderField { value: string } +export interface MemoryProviderAction { + description: string + key: string + label: string +} + export interface MemoryProviderConfig { + actions: MemoryProviderAction[] docs_url: string fields: MemoryProviderField[] label: string diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a2d788e3d886..0004f966ccbd 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -73,6 +73,7 @@ from plugins.memory.config_schema import ( ProviderConfigSchema, ProviderField, STORAGE_HONCHO_HOST_BLOCK, + get_provider_action_handler, get_provider_config_schema, ) from gateway.status import ( @@ -844,6 +845,10 @@ class MemoryProviderConfigUpdate(BaseModel): values: Dict[str, str] = {} +class MemoryProviderActionRequest(BaseModel): + values: Dict[str, str] = {} + + class MessagingPlatformUpdate(BaseModel): enabled: Optional[bool] = None env: Dict[str, str] = {} @@ -4082,7 +4087,16 @@ def _memory_provider_payload(provider: ProviderConfigSchema) -> Dict[str, Any]: 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} + return { + "name": provider.name, + "label": provider.label, + "docs_url": provider.docs_url, + "fields": fields, + "actions": [ + {"key": action.key, "label": action.label, "description": action.description} + for action in provider.actions + ], + } def _apply_field_values(provider: ProviderConfigSchema, values: Dict[str, str], target_for) -> None: @@ -4227,6 +4241,42 @@ async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpd raise HTTPException(status_code=500, detail="Internal server error") +@app.post("/api/memory/providers/{name}/actions/{action}") +async def run_memory_provider_action( + name: str, action: str, body: MemoryProviderActionRequest, profile: Optional[str] = None +): + """Run a provider-declared action (validate, start-local, …) generically. + + The schema declares actions; the plugin's config_actions.py implements + them. Undeclared actions 404, a declared action without a handler is a + plugin bug and 500s, and a handler raising ValueError maps to 400 so + plugins can surface user-facing validation messages. + """ + + def _run(): + with _profile_scope(profile): + provider = get_provider_config_schema(name) + if provider is None or provider.action(action) is None: + raise HTTPException(status_code=404, detail=f"Unknown action: {name}/{action}") + handler = get_provider_action_handler(name, action) + if handler is None: + raise HTTPException( + status_code=500, detail=f"No handler for declared action: {name}/{action}" + ) + return handler(dict(body.values or {})) + + try: + result = await asyncio.to_thread(_run) + return {"ok": True, "result": result if isinstance(result, dict) else {}} + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + _log.exception("POST /api/memory/providers/%s/actions/%s failed", name, action) + raise HTTPException(status_code=500, detail="Internal server error") + + @app.get("/api/config") async def get_config(profile: Optional[str] = None): with _profile_scope(profile): diff --git a/plugins/memory/config_schema.py b/plugins/memory/config_schema.py index bb22f6df1f24..c1cbe2177ba0 100644 --- a/plugins/memory/config_schema.py +++ b/plugins/memory/config_schema.py @@ -89,6 +89,22 @@ class ProviderField: return {opt.value for opt in self.options} +@dataclass(frozen=True) +class ProviderAction: + """A provider-specific operation exposed as a button on the config panel. + + Declared here, implemented in the plugin's ``config_actions.py`` as an + entry in ``ACTION_HANDLERS`` — ``{key: handler}`` where the handler takes + the submitted field values dict and returns a JSON-able result dict. + Like ``config_schema.py``, that file is loaded by path and must stay + import-light; heavy imports belong inside the handler bodies. + """ + + key: str + label: str + description: str = "" + + @dataclass(frozen=True) class ProviderConfigSchema: """A provider plugin's declared config surface.""" @@ -99,10 +115,14 @@ class ProviderConfigSchema: # 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) + actions: tuple[ProviderAction, ...] = () def inline_fields(self) -> tuple[ProviderField, ...]: return tuple(f for f in self.fields if f.inline) + def action(self, key: str) -> ProviderAction | None: + return next((a for a in self.actions if a.key == key), None) + _SCHEMA_CACHE: dict[str, ProviderConfigSchema] = {} @@ -140,3 +160,30 @@ def get_provider_config_schema(name: str) -> ProviderConfigSchema | None: if schema is not None: _SCHEMA_CACHE[key] = schema return schema + + +def get_provider_action_handler(name: str, action_key: str): + """Return the handler for a declared action, or ``None`` when missing. + + Handlers live in ``ACTION_HANDLERS`` in the plugin's ``config_actions.py``, + loaded by path like the schema. Not cached: actions are user-initiated and + rare, and a fixed handler file must not need a restart to be picked up. + """ + + from plugins.memory import find_provider_dir + + provider_dir = find_provider_dir(name) + path = provider_dir / "config_actions.py" if provider_dir else None + if path is None or not path.is_file(): + return None + + try: + spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_actions.{name}", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + except Exception: + _log.exception("failed to load config actions for memory provider %r", name) + return None + + handlers = getattr(module, "ACTION_HANDLERS", None) + return handlers.get(action_key) if isinstance(handlers, dict) else None diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index b031766e1596..c3e7fa0b20bb 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -803,6 +803,82 @@ class TestWebServerEndpoints: ) assert fields["peerName"]["value"] == "eri" + # ── Memory provider actions ───────────────────────────────────────── + + @staticmethod + def _install_action_provider(monkeypatch, tmp_path): + plugin_dir = tmp_path / "actionprov" + plugin_dir.mkdir() + (plugin_dir / "config_schema.py").write_text( + "from plugins.memory.config_schema import ProviderAction, ProviderConfigSchema, ProviderField\n" + "CONFIG_SCHEMA = ProviderConfigSchema(\n" + ' name="actionprov",\n' + ' label="ActionProv",\n' + ' fields=(ProviderField(key="url", label="URL", inline=True),),\n' + ' actions=(ProviderAction("ping", "Ping", "Check the server"),),\n' + ")\n", + encoding="utf-8", + ) + (plugin_dir / "config_actions.py").write_text( + "def _ping(values):\n" + ' if values.get("url") == "bad":\n' + ' raise ValueError("unreachable url")\n' + ' return {"message": "pong " + values.get("url", "")}\n' + "\n" + 'ACTION_HANDLERS = {"ping": _ping}\n', + encoding="utf-8", + ) + import plugins.memory as memory + + real = memory.find_provider_dir + monkeypatch.setattr( + memory, "find_provider_dir", lambda name: plugin_dir if name == "actionprov" else real(name) + ) + + def test_provider_action_runs_declared_handler(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + self._install_action_provider(monkeypatch, tmp_path) + + resp = self.client.post( + "/api/memory/providers/actionprov/actions/ping", + json={"values": {"url": "https://x.example"}}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "result": {"message": "pong https://x.example"}} + + def test_provider_action_payload_lists_declared_actions(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + self._install_action_provider(monkeypatch, tmp_path) + + data = self.client.get("/api/memory/providers/actionprov/config").json() + assert data["actions"] == [{"key": "ping", "label": "Ping", "description": "Check the server"}] + # Providers that declare no actions ship an empty list. + assert self.client.get("/api/memory/providers/honcho/config").json()["actions"] == [] + + def test_provider_action_undeclared_is_404(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + self._install_action_provider(monkeypatch, tmp_path) + + assert self.client.post( + "/api/memory/providers/actionprov/actions/nope", json={"values": {}} + ).status_code == 404 + assert self.client.post( + "/api/memory/providers/honcho/actions/ping", json={"values": {}} + ).status_code == 404 + + def test_provider_action_value_error_maps_to_400(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + self._install_action_provider(monkeypatch, tmp_path) + + resp = self.client.post( + "/api/memory/providers/actionprov/actions/ping", + json={"values": {"url": "bad"}}, + ) + + assert resp.status_code == 400 + assert resp.json()["detail"] == "unreachable url" + def test_put_honcho_rejects_malformed_number_and_json(self, monkeypatch, tmp_path): monkeypatch.setenv("HOME", str(tmp_path)) diff --git a/tests/plugins/memory/test_config_schema.py b/tests/plugins/memory/test_config_schema.py index db4d999710af..db1759191971 100644 --- a/tests/plugins/memory/test_config_schema.py +++ b/tests/plugins/memory/test_config_schema.py @@ -65,3 +65,9 @@ def test_broken_schema_is_not_cached(monkeypatch, tmp_path): recovered = get_provider_config_schema("broken") assert recovered is not None assert recovered.label == "Broken" + + +def test_provider_without_action_module_has_no_handlers(): + from plugins.memory.config_schema import get_provider_action_handler + + assert get_provider_action_handler("honcho", "anything") is None