From 3e964d237bef937fd25fa544e61ab024dc114c1f Mon Sep 17 00:00:00 2001 From: Gold Date: Sat, 4 Jul 2026 20:00:17 -0300 Subject: [PATCH 1/2] feat(hermes): capture tool calls individually and remove content truncation in sync_turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hermes integration's sync_turn had two issues that degraded memory quality for Hermes Agent users: 1. Content truncation — tool_input was hard-cut to 500 chars and tool_output to 2000 chars, losing long prompts, code blocks, and tool results. The agentmemory server already truncates to 8000 chars server-side, so the plugin-side limits were needlessly aggressive. 2. No per-tool-call capture — Hermes passes the full OpenAI-format messages list (including assistant tool_calls and matching tool results) via the messages kwarg, but sync_turn ignored it entirely. Every turn produced a single conversation observation with concatenated text, losing granular tool-call data. Changes: - Remove [:500]/[:2000] truncation - Add _extract_tool_observations() helper that walks the messages list, matches assistant tool_calls to their tool results by tool_call_id, and emits one observation per call/result pair - Cap at 10 most-recent tool calls per turn to avoid observation flooding - Keep the conversation observation as a fallback (backward compatible) Tested: 9 new assertions in hermes-plugin.test.ts, E2E validated against live agentmemory server. --- integrations/hermes/__init__.py | 76 +++++++++++++++++++++++++++++++-- test/hermes-plugin.test.ts | 33 ++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py index 2933632d0..30988ecda 100644 --- a/integrations/hermes/__init__.py +++ b/integrations/hermes/__init__.py @@ -174,6 +174,59 @@ def _api_bg(base: str, path: str, body: dict | None = None) -> None: t.start() +def _extract_tool_observations( + messages: list[dict[str, Any]], + max_results: int = 10, +) -> list[dict[str, Any]]: + """Extract individual tool-call observations from an OpenAI-format message list. + + Hermes passes the full conversation messages (including assistant tool_calls + and tool results) via the ``messages`` kwarg on ``sync_turn``. This helper + walks that list and yields one ``data`` dict per tool call/result pair, in + the same ``{tool_name, tool_input, tool_output}`` shape that the agentmemory + server expects from ``post_tool_use`` hooks. + + Tool calls are matched to their results by ``tool_call_id``. Only calls + that have a matching result are returned. The *latest* calls are returned + first (capped by ``max_results``) so a turn that fires 50 tool calls does + not flood the observation pipeline. + """ + pending: dict[str, dict[str, Any]] = {} + results: list[dict[str, Any]] = [] + for msg in messages: + if not isinstance(msg, dict): + continue + role = msg.get("role") + if role == "assistant" and isinstance(msg.get("tool_calls"), list): + for call in msg["tool_calls"]: + if not isinstance(call, dict): + continue + fn = call.get("function") or {} + name = fn.get("name", "") + if not name: + continue + call_id = call.get("id", "") + try: + args_str = fn.get("arguments", "") + if isinstance(args_str, dict): + args_str = json.dumps(args_str) + except (TypeError, ValueError): + args_str = str(fn.get("arguments", "")) + pending[call_id] = { + "tool_name": name, + "tool_input": args_str, + } + elif role == "tool": + call_id = msg.get("tool_call_id", "") + if call_id in pending: + content = msg.get("content", "") + if isinstance(content, (str, bytes)): + pending[call_id]["tool_output"] = str(content) + results.append(pending.pop(call_id)) + results.reverse() + return results[:max_results] + + class AgentMemoryProvider(MemoryProvider): @property @@ -344,16 +397,31 @@ def handle_tool_call(self, name: str, args: dict) -> str: return json.dumps({"error": f"Unknown tool: {name}"}) def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None: + session_id = kwargs.get("session_id", self._session_id) + ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + messages = kwargs.get("messages") + if messages: + for obs in _extract_tool_observations(messages, max_results=10): + _api_bg(self._base, "observe", { + "hookType": "post_tool_use", + "sessionId": session_id, + "project": self._project, + "cwd": self._project, + "timestamp": ts, + "data": obs, + }) + _api_bg(self._base, "observe", { "hookType": "post_tool_use", - "sessionId": kwargs.get("session_id", self._session_id), + "sessionId": session_id, "project": self._project, "cwd": self._project, - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "timestamp": ts, "data": { "tool_name": "conversation", - "tool_input": user[:500], - "tool_output": assistant[:2000], + "tool_input": user, + "tool_output": assistant, }, }) diff --git a/test/hermes-plugin.test.ts b/test/hermes-plugin.test.ts index 87e7ed644..0caed7ca4 100644 --- a/test/hermes-plugin.test.ts +++ b/test/hermes-plugin.test.ts @@ -69,3 +69,36 @@ describe("Hermes plugin manifest", () => { ); }); }); + +describe("sync_turn rich capture", () => { + const source = readFileSync("integrations/hermes/__init__.py", "utf8"); + + it("does not hard-truncate tool_input to 500 chars", () => { + // The old code had user[:500] — verify that's gone. + expect(source).not.toMatch(/user\[:500\]/); + }); + + it("does not hard-truncate tool_output to 2000 chars", () => { + expect(source).not.toMatch(/assistant\[:2000\]/); + }); + + it("defines _extract_tool_observations helper", () => { + expect(source).toMatch(/def _extract_tool_observations\(/); + }); + + it("uses messages kwarg in sync_turn", () => { + expect(source).toMatch(/kwargs\.get\(["']messages["']\)/); + }); + + it("matches tool results by tool_call_id", () => { + expect(source).toMatch(/tool_call_id/); + }); + + it("caps extracted observations with max_results", () => { + expect(source).toMatch(/max_results/); + }); + + it("still sends a conversation observation as fallback", () => { + expect(source).toMatch(/tool_name.*conversation/); + }); +}); From 32f4208d9fd6c4cb1dab7291ae42af8abbac4a29 Mon Sep 17 00:00:00 2001 From: Gold Date: Sat, 4 Jul 2026 22:49:26 -0300 Subject: [PATCH 2/2] fix: skip tool calls without id to prevent pending dict collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review caught that empty call_id ('') causes all id-less tool calls to overwrite the same pending[''] entry, dropping earlier calls. Skip calls without an id — the conversation fallback observation already captures the full turn when tool calls can't be matched to results. --- integrations/hermes/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py index 30988ecda..302160815 100644 --- a/integrations/hermes/__init__.py +++ b/integrations/hermes/__init__.py @@ -206,6 +206,8 @@ def _extract_tool_observations( if not name: continue call_id = call.get("id", "") + if not call_id: + continue try: args_str = fn.get("arguments", "") if isinstance(args_str, dict): @@ -250,6 +252,7 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: "project": self._project, "cwd": self._project, }) + self._first_prompt_sent = False def get_config_schema(self) -> list[dict]: return [ @@ -400,6 +403,17 @@ def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None: session_id = kwargs.get("session_id", self._session_id) ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + if not getattr(self, "_first_prompt_sent", False) and user: + _api_bg(self._base, "observe", { + "hookType": "prompt_submit", + "sessionId": session_id, + "project": self._project, + "cwd": self._project, + "timestamp": ts, + "data": {"prompt": user}, + }) + self._first_prompt_sent = True + messages = kwargs.get("messages") if messages: for obs in _extract_tool_observations(messages, max_results=10):