feat(memory): provider actions extension point

Providers with behavior beyond fields (validate a server, start a local
instance, link a CLI profile) previously had no mount besides forking the
panel. Let the schema declare actions and one generic endpoint run them:
ProviderAction on ProviderConfigSchema, POST
/api/memory/providers/{name}/actions/{action} dispatching to
ACTION_HANDLERS in the plugin's config_actions.py (path-loaded and
import-light, like the schema), profile-scoped and off the event loop.
Handlers get the submitted values dict, return a JSON-able result, and
raise ValueError for user-facing 400s. The panel renders declared actions
as generic buttons beside Save. No bundled provider declares actions yet.
This commit is contained in:
Erosika 2026-07-07 11:00:25 -04:00
parent 76c063b3d9
commit 35099685be
9 changed files with 259 additions and 5 deletions

View file

@ -30,6 +30,7 @@ function field(overrides: Partial<MemoryProviderField> & Pick<MemoryProviderFiel
function schema(): MemoryProviderConfig {
return {
actions: [],
name: 'honcho',
label: 'Honcho',
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',

View file

@ -5,10 +5,13 @@ import type { MemoryProviderConfig } from '@/types/hermes'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
const runMemoryProviderAction = vi.fn()
vi.mock('@/hermes', () => ({
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: [] })

View file

@ -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<Record<string, string>>({})
const [expanded, setExpanded] = useState(true)
const [saving, setSaving] = useState(false)
const [runningAction, setRunningAction] = useState<null | string>(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 }) {
</div>
))}
<div className="flex items-center justify-end pt-3">
<Button disabled={saving} onClick={() => void save()} size="sm">
<div className="flex items-center justify-end gap-2 pt-3">
{config.actions.map(action => (
<Button
disabled={runningAction !== null || saving}
key={action.key}
onClick={() => void runAction(action.key, action.label)}
size="sm"
title={action.description || undefined}
type="button"
variant="secondary"
>
{runningAction === action.key && <Loader2 className="size-3.5 animate-spin" />}
{action.label}
</Button>
))}
<Button disabled={saving || runningAction !== null} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>

View file

@ -383,6 +383,19 @@ export function saveMemoryProviderConfig(provider: string, values: Record<string
})
}
export function runMemoryProviderAction(
provider: string,
action: string,
values: Record<string, string>
): Promise<{ ok: boolean; result: Record<string, unknown> }> {
return window.hermesDesktop.api<{ ok: boolean; result: Record<string, unknown> }>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/actions/${encodeURIComponent(action)}`,
method: 'POST',
body: { values }
})
}
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
...profileScoped(),

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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))

View file

@ -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