hermes - ✅(Solved) Fix [Bug]: post_tool_call hook is not invoked for built-in tools (memory, todo, session_search, clarify, delegate_task) [1 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

try: from hermes_cli.plugins import invoke_hook invoke_hook("post_tool_call", tool_name=..., args=..., result=..., ...) except Exception: pass

Root Cause

Two dispatch paths in run_agent.py bypass handle_function_call():

Concurrent pathRunAgent._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.

Fix Action

Fix / Workaround

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.

  1. Expected: [post_tool_call] memory args=... printed for each call.
  2. Actual: Nothing printed for any of the above. The hook fires only for tools dispatched through the registry (web_search, terminal, read_file, etc.).

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:

PR fix notes

PR #12928: fix(hooks): fire post_tool_call hook for built-in tools (closes #12922)

Description (problem / solution / changelog)

Summary

Fix for #12922 — post_tool_call plugin hook was silently bypassed for built-in tools that short-circuit handle_function_call: todo, memory, session_search, clarify, delegate_task, context-engine helpers, and memory-provider tools.

This broke external memory backends (and any plugin auditing tool calls) that subscribe to post_tool_call to mirror agent state — memory operations in particular went completely unobserved.

Changes

model_tools.py

  • Add skip_post_tool_call_hook=False parameter to handle_function_call, mirroring the existing skip_pre_tool_call_hook escape hatch. Lets the agent loop tell the dispatcher "I will fire the hook myself".

run_agent.py

  • Introduce private helper _fire_post_tool_call_hook(...) that swallows hook exceptions (consistent with model_tools behaviour).
  • Invoke it from every built-in branch of _invoke_tool (concurrent path).
  • Invoke it in _execute_tool_calls_sequential for every short-circuited built-in branch:
    • todo, session_search, memory (agent-loop), clarify — fired right after function_result is set
    • delegate_task — fired in finally so failures still emit the hook
    • Memory-provider tools — fired in the existing finally block
  • Registry-routed tools (the else branch / handle_function_call path) keep firing the hook exactly once via the dispatcher — no double-fire.

Tests

5 new tests in tests/run_agent/test_run_agent.py:

  • Parametrised: _invoke_tool fires post_tool_call exactly once for todo, memory, clarify with correct tool_name/args/result/tool_call_id payload.
  • session_search with no DB still fires the hook on its error path.
  • web_search (registry-routed) fires the hook exactly once — guards against double-fire regression.

Full regression run: 296/296 passing in tests/run_agent/ + tests/test_model_tools.py.

Observed Impact

In our setup (custom OpenHippo memory plugin syncing Hermes' built-in memory tool calls into a SQLite-backed long-term store), this bug caused 0 of 22 real memory entries to sync. After the fix lands, plugins receive the hook for every memory mutation as documented.

Notes

  • No public API removed; the new skip_post_tool_call_hook parameter defaults to False, preserving existing callers.
  • Helper kept private (_fire_post_tool_call_hook) — internal-only refactor.
  • Closes #12922.

Changed files

  • model_tools.py (modified, +11/-9)
  • run_agent.py (modified, +58/-13)
  • tests/run_agent/test_run_agent.py (modified, +61/-0)

Code Example

# ~/.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)

---

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

---

try:
    from hermes_cli.plugins import invoke_hook
    invoke_hook("post_tool_call", tool_name=..., args=..., result=..., ...)
except Exception:
    pass
RAW_BUFFERClick to expand / collapse

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

  1. 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)
  1. Start Hermes (gateway) and trigger:

    • a memory action (add/replace/remove)
    • a todo write
    • a session_search
    • a clarify
    • a delegate_task
  2. Expected: [post_tool_call] memory args=... printed for each call.

  3. 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 pathRunAgent._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 @ 8a6aa588 (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.

extent analysis

TL;DR

To fix the issue, modify the run_agent.py to invoke the post_tool_call hook for built-in tools by adding a private _fire_post_tool_call_hook() helper and calling it from each bypass branch in both dispatch paths.

Guidance

  • Identify the dispatch paths in run_agent.py that bypass handle_function_call(), specifically the concurrent path in RunAgent._invoke_tool() and the sequential path in _execute_tool_calls().
  • Create a private _fire_post_tool_call_hook() helper on the agent to invoke the post_tool_call hook.
  • Call the _fire_post_tool_call_hook() helper from each bypass branch in both dispatch paths to ensure the post_tool_call hook is fired for all tools.
  • Consider extending the existing skip_pre_tool_call_hook pattern with a symmetric skip_post_tool_call_hook parameter on handle_function_call() to maintain consistency.

Example

def _fire_post_tool_call_hook(self, tool_name, args, result):
    try:
        from hermes_cli.plugins import invoke_hook
        invoke_hook("post_tool_call", tool_name=tool_name, args=args, result=result)
    except Exception:
        pass

# In RunAgent._invoke_tool():
if function_name == "todo":
    result = _todo_tool(...)
    self._fire_post_tool_call_hook(function_name, ..., result)
    return result

Notes

The proposed fix aims to maintain symmetry with the pre_tool_call hook and ensure that the post_tool_call hook is fired for all tools, including built-in ones. However, the exact implementation details may vary depending on the specific requirements and constraints of the Hermes project.

Recommendation

Apply the proposed fix by modifying the run_agent.py to invoke the post_tool_call hook for built-in tools, as this will ensure that plugins can reliably observe all tool calls and maintain consistency with the pre_tool_call hook.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - ✅(Solved) Fix [Bug]: post_tool_call hook is not invoked for built-in tools (memory, todo, session_search, clarify, delegate_task) [1 pull requests]