Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
76 changes: 72 additions & 4 deletions integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Comment on lines +208 to +220

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing tool_call_id collisions can silently drop observations.

When call.get("id", "") is empty/missing, multiple tool calls in the same or different assistant messages will all key into pending under "", so an earlier unmatched call gets silently overwritten before it can be paired with its result.

🐛 Proposed fix to avoid collisions on missing ids
                 call_id = call.get("id", "")
+                if not call_id:
+                    call_id = f"__noid_{len(pending)}_{name}"
                 try:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
}
call_id = call.get("id", "")
if not call_id:
call_id = f"__noid_{len(pending)}_{name}"
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,
}
🧰 Tools
🪛 ast-grep (0.44.1)

[info] 211-211: use jsonify instead of json.dumps for JSON output
Context: json.dumps(args_str)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integrations/hermes/__init__.py` around lines 208 - 218, The pending
tool-call tracking in the tool-call parsing block can collide when
call.get("id", "") is empty, causing multiple calls to overwrite the same entry
and drop earlier observations. Update the logic around the call_id assignment
and pending[...] insertion to ensure every tool call gets a unique key even when
the Hermes payload omits an id, and keep the existing tool_name/tool_input
mapping intact while preventing empty-string collisions.

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
Expand Down Expand Up @@ -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,
},
})

Expand Down
33 changes: 33 additions & 0 deletions test/hermes-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});