fix(plugins): stop firing pre_tool_call hook twice per tool execution#17611
Merged
Conversation
The skip_pre_tool_call_hook flag was added to prevent double-firing of
pre_tool_call when run_agent._invoke_tool pre-checks for a block
directive and then dispatches via handle_function_call. But the
implementation added an else: branch that fired invoke_hook again for
'observers', without noticing that get_pre_tool_call_block_message() in
hermes_cli.plugins already fires invoke_hook('pre_tool_call', ...) as
part of its block-directive poll.
Result: every tool call ran through the run_agent loop fired the hook
twice — reported by community users whose observer / audit plugins
logged each tool invocation twice with identical timestamps.
Fix: delete the else: branch. The single-fire contract is now:
- skip=False (direct handle_function_call): hook fires once inside
get_pre_tool_call_block_message().
- skip=True (run_agent._invoke_tool path): caller fires the hook
once via get_pre_tool_call_block_message(); handle_function_call
must not fire it again.
Tightened the existing skip-flag test (renamed to
test_skip_flag_prevents_double_fire) to assert pre_tool_call fires
zero times when skip=True, and added
test_run_agent_pattern_fires_pre_tool_call_exactly_once to lock in
end-to-end that the full block-check + dispatch sequence fires the
hook exactly once.
sunJose
pushed a commit
to sunJose/hermes-agent
that referenced
this pull request
Apr 30, 2026
…NousResearch#17611) The skip_pre_tool_call_hook flag was added to prevent double-firing of pre_tool_call when run_agent._invoke_tool pre-checks for a block directive and then dispatches via handle_function_call. But the implementation added an else: branch that fired invoke_hook again for 'observers', without noticing that get_pre_tool_call_block_message() in hermes_cli.plugins already fires invoke_hook('pre_tool_call', ...) as part of its block-directive poll. Result: every tool call ran through the run_agent loop fired the hook twice — reported by community users whose observer / audit plugins logged each tool invocation twice with identical timestamps. Fix: delete the else: branch. The single-fire contract is now: - skip=False (direct handle_function_call): hook fires once inside get_pre_tool_call_block_message(). - skip=True (run_agent._invoke_tool path): caller fires the hook once via get_pre_tool_call_block_message(); handle_function_call must not fire it again. Tightened the existing skip-flag test (renamed to test_skip_flag_prevents_double_fire) to assert pre_tool_call fires zero times when skip=True, and added test_run_agent_pattern_fires_pre_tool_call_exactly_once to lock in end-to-end that the full block-check + dispatch sequence fires the hook exactly once.
donald131
pushed a commit
to donald131/hermes-agent
that referenced
this pull request
May 2, 2026
…NousResearch#17611) The skip_pre_tool_call_hook flag was added to prevent double-firing of pre_tool_call when run_agent._invoke_tool pre-checks for a block directive and then dispatches via handle_function_call. But the implementation added an else: branch that fired invoke_hook again for 'observers', without noticing that get_pre_tool_call_block_message() in hermes_cli.plugins already fires invoke_hook('pre_tool_call', ...) as part of its block-directive poll. Result: every tool call ran through the run_agent loop fired the hook twice — reported by community users whose observer / audit plugins logged each tool invocation twice with identical timestamps. Fix: delete the else: branch. The single-fire contract is now: - skip=False (direct handle_function_call): hook fires once inside get_pre_tool_call_block_message(). - skip=True (run_agent._invoke_tool path): caller fires the hook once via get_pre_tool_call_block_message(); handle_function_call must not fire it again. Tightened the existing skip-flag test (renamed to test_skip_flag_prevents_double_fire) to assert pre_tool_call fires zero times when skip=True, and added test_run_agent_pattern_fires_pre_tool_call_exactly_once to lock in end-to-end that the full block-check + dispatch sequence fires the hook exactly once.
nickdlkk
pushed a commit
to nickdlkk/hermes-agent
that referenced
this pull request
May 11, 2026
…NousResearch#17611) The skip_pre_tool_call_hook flag was added to prevent double-firing of pre_tool_call when run_agent._invoke_tool pre-checks for a block directive and then dispatches via handle_function_call. But the implementation added an else: branch that fired invoke_hook again for 'observers', without noticing that get_pre_tool_call_block_message() in hermes_cli.plugins already fires invoke_hook('pre_tool_call', ...) as part of its block-directive poll. Result: every tool call ran through the run_agent loop fired the hook twice — reported by community users whose observer / audit plugins logged each tool invocation twice with identical timestamps. Fix: delete the else: branch. The single-fire contract is now: - skip=False (direct handle_function_call): hook fires once inside get_pre_tool_call_block_message(). - skip=True (run_agent._invoke_tool path): caller fires the hook once via get_pre_tool_call_block_message(); handle_function_call must not fire it again. Tightened the existing skip-flag test (renamed to test_skip_flag_prevents_double_fire) to assert pre_tool_call fires zero times when skip=True, and added test_run_agent_pattern_fires_pre_tool_call_exactly_once to lock in end-to-end that the full block-check + dispatch sequence fires the hook exactly once.
02356abc
pushed a commit
to 02356abc/hermes-agent
that referenced
this pull request
May 14, 2026
…NousResearch#17611) The skip_pre_tool_call_hook flag was added to prevent double-firing of pre_tool_call when run_agent._invoke_tool pre-checks for a block directive and then dispatches via handle_function_call. But the implementation added an else: branch that fired invoke_hook again for 'observers', without noticing that get_pre_tool_call_block_message() in hermes_cli.plugins already fires invoke_hook('pre_tool_call', ...) as part of its block-directive poll. Result: every tool call ran through the run_agent loop fired the hook twice — reported by community users whose observer / audit plugins logged each tool invocation twice with identical timestamps. Fix: delete the else: branch. The single-fire contract is now: - skip=False (direct handle_function_call): hook fires once inside get_pre_tool_call_block_message(). - skip=True (run_agent._invoke_tool path): caller fires the hook once via get_pre_tool_call_block_message(); handle_function_call must not fire it again. Tightened the existing skip-flag test (renamed to test_skip_flag_prevents_double_fire) to assert pre_tool_call fires zero times when skip=True, and added test_run_agent_pattern_fires_pre_tool_call_exactly_once to lock in end-to-end that the full block-check + dispatch sequence fires the hook exactly once.
jsboige
pushed a commit
to jsboige/hermes-agent
that referenced
this pull request
May 14, 2026
…NousResearch#17611) The skip_pre_tool_call_hook flag was added to prevent double-firing of pre_tool_call when run_agent._invoke_tool pre-checks for a block directive and then dispatches via handle_function_call. But the implementation added an else: branch that fired invoke_hook again for 'observers', without noticing that get_pre_tool_call_block_message() in hermes_cli.plugins already fires invoke_hook('pre_tool_call', ...) as part of its block-directive poll. Result: every tool call ran through the run_agent loop fired the hook twice — reported by community users whose observer / audit plugins logged each tool invocation twice with identical timestamps. Fix: delete the else: branch. The single-fire contract is now: - skip=False (direct handle_function_call): hook fires once inside get_pre_tool_call_block_message(). - skip=True (run_agent._invoke_tool path): caller fires the hook once via get_pre_tool_call_block_message(); handle_function_call must not fire it again. Tightened the existing skip-flag test (renamed to test_skip_flag_prevents_double_fire) to assert pre_tool_call fires zero times when skip=True, and added test_run_agent_pattern_fires_pre_tool_call_exactly_once to lock in end-to-end that the full block-check + dispatch sequence fires the hook exactly once.
dannyJ848
pushed a commit
to dannyJ848/hermes-agent
that referenced
this pull request
May 17, 2026
…NousResearch#17611) The skip_pre_tool_call_hook flag was added to prevent double-firing of pre_tool_call when run_agent._invoke_tool pre-checks for a block directive and then dispatches via handle_function_call. But the implementation added an else: branch that fired invoke_hook again for 'observers', without noticing that get_pre_tool_call_block_message() in hermes_cli.plugins already fires invoke_hook('pre_tool_call', ...) as part of its block-directive poll. Result: every tool call ran through the run_agent loop fired the hook twice — reported by community users whose observer / audit plugins logged each tool invocation twice with identical timestamps. Fix: delete the else: branch. The single-fire contract is now: - skip=False (direct handle_function_call): hook fires once inside get_pre_tool_call_block_message(). - skip=True (run_agent._invoke_tool path): caller fires the hook once via get_pre_tool_call_block_message(); handle_function_call must not fire it again. Tightened the existing skip-flag test (renamed to test_skip_flag_prevents_double_fire) to assert pre_tool_call fires zero times when skip=True, and added test_run_agent_pattern_fires_pre_tool_call_exactly_once to lock in end-to-end that the full block-check + dispatch sequence fires the hook exactly once.
gweeteve
pushed a commit
to gweeteve/hermes-agent
that referenced
this pull request
Jun 2, 2026
…NousResearch#17611) The skip_pre_tool_call_hook flag was added to prevent double-firing of pre_tool_call when run_agent._invoke_tool pre-checks for a block directive and then dispatches via handle_function_call. But the implementation added an else: branch that fired invoke_hook again for 'observers', without noticing that get_pre_tool_call_block_message() in hermes_cli.plugins already fires invoke_hook('pre_tool_call', ...) as part of its block-directive poll. Result: every tool call ran through the run_agent loop fired the hook twice — reported by community users whose observer / audit plugins logged each tool invocation twice with identical timestamps. Fix: delete the else: branch. The single-fire contract is now: - skip=False (direct handle_function_call): hook fires once inside get_pre_tool_call_block_message(). - skip=True (run_agent._invoke_tool path): caller fires the hook once via get_pre_tool_call_block_message(); handle_function_call must not fire it again. Tightened the existing skip-flag test (renamed to test_skip_flag_prevents_double_fire) to assert pre_tool_call fires zero times when skip=True, and added test_run_agent_pattern_fires_pre_tool_call_exactly_once to lock in end-to-end that the full block-check + dispatch sequence fires the hook exactly once.
Egavasyug
pushed a commit
to Egavasyug/hermes-agent
that referenced
this pull request
Jun 10, 2026
…NousResearch#17611) The skip_pre_tool_call_hook flag was added to prevent double-firing of pre_tool_call when run_agent._invoke_tool pre-checks for a block directive and then dispatches via handle_function_call. But the implementation added an else: branch that fired invoke_hook again for 'observers', without noticing that get_pre_tool_call_block_message() in hermes_cli.plugins already fires invoke_hook('pre_tool_call', ...) as part of its block-directive poll. Result: every tool call ran through the run_agent loop fired the hook twice — reported by community users whose observer / audit plugins logged each tool invocation twice with identical timestamps. Fix: delete the else: branch. The single-fire contract is now: - skip=False (direct handle_function_call): hook fires once inside get_pre_tool_call_block_message(). - skip=True (run_agent._invoke_tool path): caller fires the hook once via get_pre_tool_call_block_message(); handle_function_call must not fire it again. Tightened the existing skip-flag test (renamed to test_skip_flag_prevents_double_fire) to assert pre_tool_call fires zero times when skip=True, and added test_run_agent_pattern_fires_pre_tool_call_exactly_once to lock in end-to-end that the full block-check + dispatch sequence fires the hook exactly once.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
pre_tool_call now fires exactly once per tool execution.
Previously, every tool call routed through the main agent loop fired pre_tool_call twice. Community plugin authors with observer/audit hooks saw each tool invocation logged twice with identical timestamps.
Root cause
run_agent._invoke_toolpre-checks for a block directive viaget_pre_tool_call_block_message(), then dispatches withhandle_function_call(skip_pre_tool_call_hook=True)specifically to avoid double-firing. Buthandle_function_callhad anelse:branch (added in eabc0a2) that firedinvoke_hook("pre_tool_call", ...)again "for observers" — without noticing thatget_pre_tool_call_block_message()itself callsinvoke_hook("pre_tool_call", ...)as part of its poll. So the "observer" pass was redundant and produced the double-fire.Changes
model_tools.py: delete theelse:branch that re-fired the hook when skip=True.tests/test_model_tools.py: renamedtest_skip_flag_prevents_double_block_check→test_skip_flag_prevents_double_fire, now assertshook_calls.count("pre_tool_call") == 0in the skip=True path (was merely checking presence).tests/test_model_tools.py: newtest_run_agent_pattern_fires_pre_tool_call_exactly_once— end-to-end regression that mirrors the run_agent call sequence and asserts the hook fires exactly once across block-check + dispatch.Single-fire contract
handle_function_call(skip=False)run_agent._invoke_tool→handle_function_call(skip=True)Validation
tests/test_model_tools.py— 24/24 pass (including the 2 new/tightened ones).tests/hermes_cli/test_plugins.py+tests/test_transform_tool_result_hook.py+tests/agent/test_shell_hooks.py+tests/plugins/test_langfuse_plugin.py+tests/hermes_cli/test_hooks_cli.py— 137/137 pass.Reported on Discord by Norbert, surfaced by Gille [NOUS].