From 2959230e1e5d5475b2bf5b9619c0061c89ea6206 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 9 May 2026 11:47:22 +0100 Subject: [PATCH] fix(hermes): serialize handle_tool_call returns to JSON string (closes #254) Hermes stores handle_tool_call's return value as the tool result `content` field in the session history. Anthropic-protocol LLM providers reject non-string content on the next request with: Failed to deserialize the JSON body into the target type: messages[N]: content should be a string or a list at line 1 column XXXXX Once triggered, every subsequent request in that session 400s until the session JSON is manually cleaned. The plugin's handle_tool_call returned raw Python dicts in all 4 paths (memory_recall, memory_save, memory_search, unknown-tool fallback). Wrap each return in json.dumps so the result is always a string. This matches the contract that agentmemory's own MCP server already honors in src/mcp/standalone.ts: { type: "text", text: JSON.stringify(payload, null, ...) } Tightens the return-type annotation on both the abstract base and the concrete class from Any -> str so mypy / type checkers catch any future regression. Reported by @KyoMio with full Anthropic-protocol repro. --- integrations/hermes/__init__.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py index 584c5e40..fdffc17b 100644 --- a/integrations/hermes/__init__.py +++ b/integrations/hermes/__init__.py @@ -34,7 +34,7 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: ... @abstractmethod def get_tool_schemas(self) -> list[dict]: ... @abstractmethod - def handle_tool_call(self, name: str, args: dict) -> Any: ... + def handle_tool_call(self, name: str, args: dict) -> str: ... def get_config_schema(self) -> list[dict]: return [] def save_config(self, values: dict, hermes_home: str) -> None: pass def system_prompt_block(self) -> str: return "" @@ -242,14 +242,19 @@ def get_tool_schemas(self) -> list[dict]: }, ] - def handle_tool_call(self, name: str, args: dict) -> Any: + def handle_tool_call(self, name: str, args: dict) -> str: + # Hermes stores the return value as the tool result `content` in the + # session history. Anthropic-protocol providers reject non-string + # content with a 400 on the next request, so always serialize to a + # JSON string here — matches what agentmemory's main MCP server does + # in src/mcp/standalone.ts (`{ type: "text", text: JSON.stringify(...) }`). if name == "memory_recall": result = _api(self._base, "search", { "query": args["query"], "limit": args.get("limit", 10), }) if not result: - return {"results": []} + return json.dumps({"results": []}) items = [] for r in result.get("results", []): obs = r.get("observation", r) @@ -260,14 +265,14 @@ def handle_tool_call(self, name: str, args: dict) -> Any: "importance": obs.get("importance", 0), "timestamp": obs.get("timestamp", ""), }) - return {"results": items} + return json.dumps({"results": items}) if name == "memory_save": result = _api(self._base, "remember", { "content": args["content"], "type": args.get("type", "fact"), }) - return result or {"success": False} + return json.dumps(result or {"success": False}) if name == "memory_search": result = _api(self._base, "smart-search", { @@ -275,7 +280,7 @@ def handle_tool_call(self, name: str, args: dict) -> Any: "limit": args.get("limit", 5), }) if not result: - return {"results": []} + return json.dumps({"results": []}) items = [] for r in result.get("results", []): obs = r.get("observation", r) @@ -284,9 +289,9 @@ def handle_tool_call(self, name: str, args: dict) -> Any: "narrative": obs.get("narrative", "")[:300], "score": r.get("combinedScore", r.get("score", 0)), }) - return {"results": items} + return json.dumps({"results": items}) - return {"error": f"Unknown tool: {name}"} + return json.dumps({"error": f"Unknown tool: {name}"}) def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None: _api_bg(self._base, "observe", {