mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
76c063b3d9
commit
35099685be
9 changed files with 259 additions and 5 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [] })
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue