Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 58 additions & 8 deletions src/hal0/agents/hermes_provision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
109 changes: 109 additions & 0 deletions src/hal0/api/routes/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 24 additions & 43 deletions src/hal0/cli/agent_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
23 changes: 9 additions & 14 deletions ui/src/dash/extras.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading