Bug Description
The post_tool_call plugin hook is not invoked for several built-in tools (memory, todo, session_search, clarify, delegate_task) and for memory-provider tools, because run_agent.py dispatches these directly without going through model_tools.handle_function_call(), which is where invoke_hook("post_tool_call", ...) lives.
Result: any plugin that registers a post_tool_call hook to observe these tools (e.g. an external memory bridge that wants to be notified when the user updates their MEMORY.md) silently never receives callbacks, even though pre_tool_call works fine for the same tools.
Steps to Reproduce
- Create a plugin that registers a
post_tool_call hook:
# ~/.hermes/plugins/test-observer/__init__.py
def _on_post(tool_name, args, result, **kwargs):
print(f"[post_tool_call] {tool_name} args={args}")
def register(ctx):
ctx.register_hook("post_tool_call", _on_post)
-
Start Hermes (gateway) and trigger:
- a
memory action (add/replace/remove)
- a
todo write
- a
session_search
- a
clarify
- a
delegate_task
-
Expected: [post_tool_call] memory args=... printed for each call.
-
Actual: Nothing printed for any of the above. The hook fires only for tools dispatched through the registry (web_search, terminal, read_file, etc.).
Verified with grep — pre_tool_call is invoked in _invoke_tool (concurrent path) via get_pre_tool_call_block_message, but no corresponding post_tool_call invocation exists for the elif branches.
Expected Behavior
post_tool_call should fire for every tool the agent successfully executes, regardless of whether it's dispatched through handle_function_call() (registry) or short-circuited inline by run_agent (built-in tools). The plugin contract documented in website/docs/guides/build-a-hermes-plugin.md says:
This hook fires for ALL tool calls, not just ours
That is currently false for ~5 tool names.
Actual Behavior
post_tool_call only fires for tools routed through model_tools.handle_function_call() at model_tools.py:516. The shortcuts in run_agent.py skip this path entirely.
Root Cause
Two dispatch paths in run_agent.py bypass handle_function_call():
Concurrent path — RunAgent._invoke_tool() (run_agent.py ~line 8008):
if function_name == "todo":
return _todo_tool(...) # ← bypasses post_tool_call
elif function_name == "session_search":
return _session_search(...) # ← bypasses
elif function_name == "memory":
result = _memory_tool(...)
...
return result # ← bypasses
elif self._memory_manager and self._memory_manager.has_tool(...):
return self._memory_manager.handle_tool_call(...) # ← bypasses
elif function_name == "clarify":
return _clarify_tool(...) # ← bypasses
elif function_name == "delegate_task":
return _delegate_task(...) # ← bypasses
else:
return handle_function_call(..., skip_pre_tool_call_hook=True) # ✅ fires post hook
Sequential path — _execute_tool_calls() has the same structural pattern with the same 5 elif branches that skip the registry.
The hook is only fired by model_tools.py:514-526:
try:
from hermes_cli.plugins import invoke_hook
invoke_hook("post_tool_call", tool_name=..., args=..., result=..., ...)
except Exception:
pass
So tools that never enter handle_function_call() never trigger the hook.
Impact
- Plugins documented to observe all tools silently miss critical events.
- External memory bridges (e.g. plugins that mirror Hermes MEMORY.md to a vector DB) cannot reliably stay in sync with built-in
memory writes.
- Audit/observability plugins under-report by ~5 tool names.
- Asymmetric with
pre_tool_call, which is invoked correctly for all the same built-in tools (block-message check at _invoke_tool line 8019 and the equivalent sequential check). Plugins reasonably expect symmetry.
Environment
- OS: Ubuntu 24.04 (x86_64)
- Python: 3.12
- Hermes: main @ 8a6aa58 (post v0.x)
Proposed Fix
PR incoming. Approach: extend the existing skip_pre_tool_call_hook pattern with a symmetric skip_post_tool_call_hook parameter on handle_function_call(), add a private _fire_post_tool_call_hook() helper on the agent, and call it from each bypass branch in both dispatch paths. Minimal surface area, mirrors existing code style.
Bug Description
The
post_tool_callplugin hook is not invoked for several built-in tools (memory,todo,session_search,clarify,delegate_task) and for memory-provider tools, becauserun_agent.pydispatches these directly without going throughmodel_tools.handle_function_call(), which is whereinvoke_hook("post_tool_call", ...)lives.Result: any plugin that registers a
post_tool_callhook to observe these tools (e.g. an external memory bridge that wants to be notified when the user updates their MEMORY.md) silently never receives callbacks, even thoughpre_tool_callworks fine for the same tools.Steps to Reproduce
post_tool_callhook:Start Hermes (gateway) and trigger:
memoryaction (add/replace/remove)todowritesession_searchclarifydelegate_taskExpected:
[post_tool_call] memory args=...printed for each call.Actual: Nothing printed for any of the above. The hook fires only for tools dispatched through the registry (
web_search,terminal,read_file, etc.).Verified with grep —
pre_tool_callis invoked in_invoke_tool(concurrent path) viaget_pre_tool_call_block_message, but no correspondingpost_tool_callinvocation exists for the elif branches.Expected Behavior
post_tool_callshould fire for every tool the agent successfully executes, regardless of whether it's dispatched throughhandle_function_call()(registry) or short-circuited inline byrun_agent(built-in tools). The plugin contract documented inwebsite/docs/guides/build-a-hermes-plugin.mdsays:That is currently false for ~5 tool names.
Actual Behavior
post_tool_callonly fires for tools routed throughmodel_tools.handle_function_call()atmodel_tools.py:516. The shortcuts inrun_agent.pyskip this path entirely.Root Cause
Two dispatch paths in
run_agent.pybypasshandle_function_call():Concurrent path —
RunAgent._invoke_tool()(run_agent.py ~line 8008):Sequential path —
_execute_tool_calls()has the same structural pattern with the same 5 elif branches that skip the registry.The hook is only fired by
model_tools.py:514-526:So tools that never enter
handle_function_call()never trigger the hook.Impact
memorywrites.pre_tool_call, which is invoked correctly for all the same built-in tools (block-message check at_invoke_toolline 8019 and the equivalent sequential check). Plugins reasonably expect symmetry.Environment
Proposed Fix
PR incoming. Approach: extend the existing
skip_pre_tool_call_hookpattern with a symmetricskip_post_tool_call_hookparameter onhandle_function_call(), add a private_fire_post_tool_call_hook()helper on the agent, and call it from each bypass branch in both dispatch paths. Minimal surface area, mirrors existing code style.