revert(memory): drop the provider actions extension point

No bundled provider declares actions, and the motivating request
(openviking, #56309) needs dynamic select options rather than actions.
Removing the unconsumed POST dispatch surface keeps this PR focused on
the config panel; the extension point can return as its own PR with its
first real consumer.
This commit is contained in:
Erosika 2026-07-10 15:41:52 -04:00
parent b45f5bef16
commit f000fbe5c5
9 changed files with 5 additions and 264 deletions

View file

@ -35,7 +35,6 @@ 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,13 +5,10 @@ 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),
runMemoryProviderAction: (provider: string, action: string, values: unknown) =>
runMemoryProviderAction(provider, action, values)
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/profile', async () => {
@ -26,7 +23,6 @@ vi.mock('@/store/notifications', () => ({
function honchoSchema(): MemoryProviderConfig {
return {
actions: [],
name: 'honcho',
label: 'Honcho',
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
@ -104,7 +100,6 @@ function honchoSchema(): MemoryProviderConfig {
beforeEach(() => {
getMemoryProviderConfig.mockResolvedValue(honchoSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
runMemoryProviderAction.mockResolvedValue({ ok: true, result: { message: 'pong' } })
})
afterEach(() => {
@ -190,20 +185,6 @@ 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('shows an inline error with retry when the load fails, then recovers', async () => {
getMemoryProviderConfig.mockRejectedValueOnce(new Error('Timed out connecting to Hermes backend'))

View file

@ -2,9 +2,9 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { getMemoryProviderConfig, runMemoryProviderAction, saveMemoryProviderConfig } from '@/hermes'
import { Loader2, SlidersHorizontal } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { SlidersHorizontal } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { FieldControl, FieldTitle } from './field-control'
@ -24,7 +24,6 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
const [values, setValues] = useState<Record<string, string>>({})
const [saved, setSaved] = useState<Record<string, string>>({})
const [expanded, setExpanded] = useState(true)
const [runningAction, setRunningAction] = useState<null | string>(null)
const [showModal, setShowModal] = useState(false)
const refresh = useCallback(async () => {
@ -75,26 +74,6 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
[provider, saved]
)
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
@ -164,24 +143,6 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
</div>
))}
{config.actions.length > 0 && (
<div className="flex items-center justify-end gap-2 pt-3">
{config.actions.map(action => (
<Button
disabled={runningAction !== null}
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>
))}
</div>
)}
</div>
)}

View file

@ -429,19 +429,6 @@ 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

@ -142,14 +142,7 @@ 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

@ -78,7 +78,6 @@ from plugins.memory.config_schema import (
ProviderConfigSchema,
ProviderField,
STORAGE_HONCHO_HOST_BLOCK,
get_provider_action_handler,
get_provider_config_schema,
)
from gateway.status import (
@ -858,10 +857,6 @@ class MemoryProviderConfigUpdate(BaseModel):
values: Dict[str, Any] = {}
class MemoryProviderActionRequest(BaseModel):
values: Dict[str, Any] = {}
class MemoryProviderSetupRequest(BaseModel):
values: Dict[str, Any] = {}
@ -4377,16 +4372,7 @@ def _declared_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,
"actions": [
{"key": action.key, "label": action.label, "description": action.description}
for action in provider.actions
],
}
return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields}
def _apply_field_values(provider: ProviderConfigSchema, values: Dict[str, str], target_for) -> None:
@ -5366,43 +5352,6 @@ 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.
"""
_require_valid_memory_provider_name(name)
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

@ -91,22 +91,6 @@ 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."""
@ -117,14 +101,10 @@ 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] = {}
@ -162,30 +142,3 @@ 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

@ -1059,82 +1059,6 @@ 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,9 +65,3 @@ 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