diff --git a/src/hal0/agents/hermes_provision.py b/src/hal0/agents/hermes_provision.py index 015f922..72b3781 100644 --- a/src/hal0/agents/hermes_provision.py +++ b/src/hal0/agents/hermes_provision.py @@ -1100,27 +1100,77 @@ def _mcp_memory_call( params: dict[str, Any], *, agent_id: str, - base_url: str = "http://127.0.0.1:8080/mcp/memory", + base_url: str = "http://127.0.0.1:8080", timeout: float = 5.0, private: bool = False, ) -> dict[str, Any]: - """JSON-RPC POST to the hal0-memory MCP server. Stdlib transport.""" - from urllib.error import URLError + """Call the hal0-memory surface. Returns ``{ok, result?, error?}``. + + **Was** a one-shot JSON-RPC POST to ``/mcp/memory`` — broken per + #302 because real FastMCP requires the initialize handshake at + ``/mcp/memory/mcp`` with session-tagged subsequent calls. That made + every call here silently fail with HTTP 405 + the failure-tolerant + path in :func:`_phase_namespace_register` swallowed the error, + meaning identity cards were never being written. + + **Now** translates the MCP ``tools/call`` shape to the REST shims + at ``/api/memory/{add,search,delete}`` (added in #302). The method/ + params shape is preserved so existing call sites don't change. + + Supported method/tool combinations: + - ``method="tools/call"``, ``params.name="memory_search"`` → POST /api/memory/search + - ``method="tools/call"``, ``params.name="memory_add"`` → POST /api/memory/add + - ``method="tools/call"``, ``params.name="memory_delete"`` → POST /api/memory/delete + + Anything else returns ``{"ok": False, "error": "unsupported method"}`` + — proper MCP tool calls still need an MCP SDK client. That's tracked + as a v0.4 cleanup (see #302 comment). + """ + from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen - body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode( - "utf-8" - ) + base_url = base_url.rstrip("/") + + # Translate MCP envelope → REST endpoint. + if method == "tools/call" and isinstance(params, dict): + tool = params.get("name") + arguments = params.get("arguments") or {} + route_map = { + "memory_search": "/api/memory/search", + "memory_add": "/api/memory/add", + "memory_delete": "/api/memory/delete", + } + path = route_map.get(tool) + if path is None: + return {"ok": False, "error": f"unsupported tool {tool!r}"} + body_bytes = json.dumps(arguments).encode("utf-8") + url = f"{base_url}{path}" + else: + return {"ok": False, "error": f"unsupported method {method!r}"} + headers = {"Content-Type": "application/json", "X-hal0-Agent": agent_id} if private: headers["X-hal0-Private"] = "1" - req = Request(base_url, data=body, headers=headers, method="POST") + req = Request(url, data=body_bytes, headers=headers, method="POST") try: with urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8")) + except HTTPError as exc: + # Surface the body if it's a hal0 error envelope so the warning + # message in the caller is operator-actionable. + try: + err_body = json.loads(exc.read().decode("utf-8")) + err_msg = (err_body.get("error") or {}).get("message") or str(exc) + except Exception: + err_msg = str(exc) + return {"ok": False, "error": err_msg} except (URLError, OSError, json.JSONDecodeError, TimeoutError) as exc: return {"ok": False, "error": str(exc)} - return {"ok": True, "result": data.get("result")} + # REST shims return the wrapper's dict directly (e.g. + # {"items": [...]} for search, {"id": ..., "timestamp": ...} for + # add). Preserve the old ``result`` envelope key for call-site + # compat — every reader does ``call["result"].get("items")`` etc. + return {"ok": True, "result": data} def _phase_namespace_register(state: BootstrapState) -> PhaseResult: diff --git a/src/hal0/api/routes/memory.py b/src/hal0/api/routes/memory.py index d3b3e59..a902be9 100644 --- a/src/hal0/api/routes/memory.py +++ b/src/hal0/api/routes/memory.py @@ -171,6 +171,115 @@ async def update_graph_config(request: Request) -> dict[str, Any]: return out +# ── REST shims for /api/memory/{add,search,list,delete} (#302) ───────────── +# +# Plain-HTTP veneer over CogneeWrapper for callers that don't speak the +# MCP protocol (Hermes bootstrap CLI, dashboard Agents > Peers tab, +# in-process scripts). The MCP transport at /mcp/memory/mcp stays +# available for proper MCP clients; these routes are a parallel path +# for the much-larger HTTP-only audience. +# +# Why: #302 surfaced that the bootstrap + CLI + dashboard were all +# POSTing to /mcp/memory as if it were one-shot JSON-RPC. Real FastMCP +# transport needs initialize + session-tagged subsequent calls — that's +# work for a future MCP-SDK-client refactor. Until then, REST shims are +# the cheapest unblock so identity cards actually get written. + + +@router.post("/add") +async def memory_add(request: Request) -> dict[str, Any]: + """Add a memory item. Body: ``{text, dataset?, tags?, source?, metadata?}``. + + Returns ``{id, timestamp}`` from :meth:`CogneeWrapper.add`. The + ``dataset`` defaults to ``"shared"``; tags + metadata are + free-form lists / dict respectively. + """ + body = await _read_json_body(request) + text = body.get("text") + if not isinstance(text, str) or not text: + raise Hal0Error( + "memory_add requires 'text' (non-empty string)", + details={"path": "/api/memory/add"}, + ) + wrapper = _wrapper(request) + return await wrapper.add( + text=text, + dataset=body.get("dataset", "shared"), + tags=body.get("tags") or [], + source=body.get("source"), + metadata=body.get("metadata") or {}, + ) + + +@router.post("/search") +async def memory_search(request: Request) -> dict[str, Any]: + """Search memory. Body: ``{query, limit?, dataset?, tags?, before?, after?}``. + + Returns ``{items: [MemoryRecord, ...]}`` — wrapped in an envelope so + we can add ``next_cursor`` / counters later without breaking clients. + """ + body = await _read_json_body(request) + query = body.get("query") + if not isinstance(query, str) or not query: + raise Hal0Error( + "memory_search requires 'query' (non-empty string)", + details={"path": "/api/memory/search"}, + ) + wrapper = _wrapper(request) + items = await wrapper.search( + query=query, + limit=int(body.get("limit", 10)), + dataset=body.get("dataset", "shared"), + tags=body.get("tags") or [], + before=body.get("before"), + after=body.get("after"), + ) + return {"items": items} + + +@router.get("/list") +async def memory_list( + request: Request, + dataset: str = "shared", + cursor: str | None = None, + limit: int = 50, +) -> dict[str, Any]: + """Paginated list. Returns ``{items: [...], next_cursor: str | null}``.""" + wrapper = _wrapper(request) + return await wrapper.list_items(dataset=dataset, cursor=cursor, limit=limit) + + +@router.post("/delete") +async def memory_delete(request: Request) -> dict[str, int]: + """Delete by id. Body: ``{ids: [...]}``. Returns ``{deleted: int}``.""" + body = await _read_json_body(request) + ids = body.get("ids") + if not isinstance(ids, list) or not ids: + raise Hal0Error( + "memory_delete requires 'ids' (non-empty list)", + details={"path": "/api/memory/delete"}, + ) + wrapper = _wrapper(request) + return await wrapper.delete(ids=ids) + + +async def _read_json_body(request: Request) -> dict[str, Any]: + """Tolerant JSON body parser (mirrors v1.py:_read_json_body).""" + try: + body = await request.json() + except Exception as exc: + raise Hal0Error( + "request body must be valid JSON", + details={"error": str(exc)}, + ) from exc + if not isinstance(body, dict): + raise Hal0Error("request body must be a JSON object") + return body + + +# ── Helper exports for tests ──────────────────────────────────────────────── + + __all__ = [ "GraphUpstreamConfig", "MemoryGraphConfig", diff --git a/src/hal0/cli/agent_commands.py b/src/hal0/cli/agent_commands.py index 047e157..db4fa7f 100644 --- a/src/hal0/cli/agent_commands.py +++ b/src/hal0/cli/agent_commands.py @@ -124,36 +124,31 @@ def _uninstall_hermes_memory() -> None: import urllib.error import urllib.request + # #302: REST shims at /api/memory/{search,delete} instead of the + # broken /mcp/memory JSON-RPC POST. Same idempotent uninstall + # semantics: failure is tolerated (memory unreachable shouldn't + # strand the operator with a half-uninstalled agent). url = _api_base() search_body = _json.dumps( { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "memory_search", - "arguments": { - "query": "hermes-agent", - "tags": ["agent-identity"], - "dataset": "agents", - "limit": 50, - }, - }, + "query": "hermes-agent", + "tags": ["agent-identity"], + "dataset": "agents", + "limit": 50, } ).encode("utf-8") headers = {"Content-Type": "application/json", "X-hal0-Agent": "hermes-agent"} req = urllib.request.Request( - f"{url}/mcp/memory", data=search_body, headers=headers, method="POST" + f"{url}/api/memory/search", data=search_body, headers=headers, method="POST" ) try: with urllib.request.urlopen(req, timeout=5.0) as resp: data = _json.loads(resp.read().decode("utf-8")) except (urllib.error.URLError, OSError, _json.JSONDecodeError): return - result = data.get("result") if isinstance(data, dict) else None - if not isinstance(result, dict): + if not isinstance(data, dict): return - items = result.get("items") or result.get("results") or [] + items = data.get("items") or [] ids: list[str] = [] for it in items if isinstance(items, list) else []: if not isinstance(it, dict): @@ -163,16 +158,9 @@ def _uninstall_hermes_memory() -> None: ids.append(it["id"]) if not ids: return - del_body = _json.dumps( - { - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": {"name": "memory_delete", "arguments": {"ids": ids}}, - } - ).encode("utf-8") + del_body = _json.dumps({"ids": ids}).encode("utf-8") req2 = urllib.request.Request( - f"{url}/mcp/memory", data=del_body, headers=headers, method="POST" + f"{url}/api/memory/delete", data=del_body, headers=headers, method="POST" ) try: with urllib.request.urlopen(req2, timeout=5.0): @@ -359,24 +347,20 @@ def agent_peers() -> None: if _api_unreachable(url): raise typer.Exit(1) + # #302: switched from the broken /mcp/memory JSON-RPC POST to the + # REST shim at /api/memory/search. The MCP server at /mcp/memory + # requires the FastMCP initialize handshake + session-tagged calls; + # one-shot POST returns 405. The shim is plain HTTP. body = _json.dumps( { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "memory_search", - "arguments": { - "query": "agent identity", - "tags": ["agent-identity"], - "dataset": "agents", - "limit": 50, - }, - }, + "query": "agent identity", + "tags": ["agent-identity"], + "dataset": "agents", + "limit": 50, } ).encode("utf-8") req = urllib.request.Request( - f"{url}/mcp/memory", + f"{url}/api/memory/search", data=body, headers={ "Content-Type": "application/json", @@ -388,13 +372,10 @@ def agent_peers() -> None: with urllib.request.urlopen(req, timeout=5.0) as resp: data = _json.loads(resp.read().decode("utf-8")) except (urllib.error.URLError, OSError, _json.JSONDecodeError) as exc: - die(f"memory MCP unreachable: {exc}") + die(f"memory API unreachable: {exc}") return - items = [] - result = data.get("result") if isinstance(data, dict) else None - if isinstance(result, dict): - items = result.get("items") or [] + items = data.get("items") or [] if isinstance(data, dict) else [] if not items: console.print("[dim]No agent identity cards published yet.[/dim]") return diff --git a/ui/src/dash/extras.jsx b/ui/src/dash/extras.jsx index 9205e12..77403a1 100644 --- a/ui/src/dash/extras.jsx +++ b/ui/src/dash/extras.jsx @@ -867,27 +867,22 @@ function AgentPeers() { let cancelled = false; (async () => { try { - const resp = await fetch("/mcp/memory", { + // #302: REST shim at /api/memory/search instead of /mcp/memory. + // The streamable-HTTP MCP transport at /mcp/memory/mcp requires + // the initialize handshake — not doable from a fetch() oneshot. + const resp = await fetch("/api/memory/search", { method: "POST", headers: { "Content-Type": "application/json", "X-hal0-Agent": "hal0-dashboard" }, body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "memory_search", - arguments: { - query: "agent identity", - tags: ["agent-identity"], - dataset: "agents", - limit: 50, - }, - }, + query: "agent identity", + tags: ["agent-identity"], + dataset: "agents", + limit: 50, }), }); const data = await resp.json(); if (cancelled) return; - const items = (data && data.result && data.result.items) || []; + const items = (data && data.items) || []; setCards(items); setLoading(false); } catch (e) {