Skip to content

[Bug]: before_tool_call hook fires 2–3 times per tool invocation #15502

Description

@sreuter

before_tool_call hook fires 2–3 times per tool invocation

Summary

A single tool invocation by the LLM triggers the before_tool_call hook 2–3 times instead of once. This is because the hook is called from multiple independent execution paths that all run for the same tool call.

This makes it impossible to build blocking hooks (e.g. approval workflows, rate limiters, audit logging) without client-side deduplication hacks.

Reproduction

Register a before_tool_call hook that logs each invocation:

api.on("before_tool_call", async (event, ctx) => {
  console.log(`[hook] ${event.toolName} toolCallId=${event.toolCallId} t=${Date.now()}`);
}, { priority: 1000 });

Ask the agent to make a single tool call (e.g. web_fetch). Observe 2–3 log lines for the same toolCallId.

Root Cause

In dist/extensionAPI.js, the hook is invoked from three separate code paths that all execute for a single tool call:

Path 1 — toToolDefinitions() (line ~64184)
The execute wrapper calls runBeforeToolCallHook() directly:

execute: async (...args) => {
  const hookOutcome = await runBeforeToolCallHook({ toolName: name, params, toolCallId });
  // ...
  const result = await tool.execute(toolCallId, adjustedParams, signal, onUpdate);

Path 2 — wrapToolWithBeforeToolCallHook() (line ~61595)
But tool.execute above is already wrapped by wrapToolWithBeforeToolCallHook (applied at line ~62515), which fires the hook again:

execute: async (toolCallId, params, signal, onUpdate) => {
  const outcome = await runBeforeToolCallHook({ toolName, params, toolCallId, ctx });
  // ...
  return await execute(toolCallId, outcome.params, signal, onUpdate);

So toToolDefinitions fires the hook, then calls tool.execute() which is the wrapped function that fires it a second time.

Path 3 — handleToolExecutionStart stream handler (line ~65763)
Additionally, the stream event handler fires the hook a third time:

if (hookRunner?.hasHooks?.("before_tool_call")) {
  const hookEvent = { toolName, params: args };
  await hookRunner.runBeforeToolCall(hookEvent, { toolName });
}

Expected Behavior

before_tool_call should fire exactly once per tool invocation.

Impact

  • Blocking hooks (approval workflows, security gates) create duplicate requests that users must approve individually
  • If one duplicate arrives after the user already approved, the tool call hangs waiting for approval on a request the user doesn't know about
  • Audit/logging hooks record phantom entries
  • Rate limiters count 2–3x the actual usage

Suggested Fix

Either:

  1. Remove the hook call from toToolDefinitions since the wrapped tool.execute() already fires it, or
  2. Remove the wrapping from wrapToolWithBeforeToolCallHook and rely solely on toToolDefinitions, or
  3. Add a per-toolCallId guard so the hook only fires once regardless of path

Environment

  • OpenClaw v2026.2.12 (npm global install)
  • macOS, Node.js v22.17.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions