Skip to content

Agent tool call hooks now fire during tool execution#2340

Closed
AhmedTheGeek wants to merge 1 commit intoopenclaw:mainfrom
AhmedTheGeek:feat/wire-tool-call-hooks
Closed

Agent tool call hooks now fire during tool execution#2340
AhmedTheGeek wants to merge 1 commit intoopenclaw:mainfrom
AhmedTheGeek:feat/wire-tool-call-hooks

Conversation

@AhmedTheGeek
Copy link

@AhmedTheGeek AhmedTheGeek commented Jan 26, 2026

Summary

Wire up the existing before_tool_call and after_tool_call plugin hooks so they actually fire during the agent execution loop. These hooks were defined in src/plugins/hooks.ts but never invoked.

What changed

New file: src/agents/pi-tools.hooks.ts

A tool wrapper module (following the same pattern as pi-tools.abort.ts) that wraps each tool's execute method to fire plugin hooks:

  • wrapToolWithHooks(tool, hookRunner, ctx) — wraps a single tool
  • wrapToolsWithHooks(tools, hookRunner, ctx) — wraps an array; returns the original array unchanged if no hooks are registered (zero overhead)

Modified: src/agents/pi-embedded-runner/run/attempt.ts

  • Moved hookRunner initialization earlier (before splitSdkTools) so it's available for tool wrapping
  • Both builtInTools and customTools are wrapped before being passed to createAgentSession
  • Extracted hookAgentId to avoid repeated sessionKey.split(":") calls

How it works

before_tool_call

Fires before each tool executes. Plugin handlers receive { toolName, params } and can:

  • Block the call: return { block: true, blockReason: "..." } → tool is skipped, a synthetic [Tool call blocked] ... result is returned to the model
  • Modify params: return { params: modifiedParams } → the modified params are used instead

Handlers run sequentially in priority order. If any handler blocks, the tool is not executed.

after_tool_call

Fires after each tool returns (or throws). Plugin handlers receive { toolName, params, result, error, durationMs }.

This is fire-and-forget (void hook) — handlers run in parallel and failures are caught and logged.

Plugin author usage

// In a plugin's hooks registration:
{
  hookName: "before_tool_call",
  handler: async (event, ctx) => {
    // Log all tool calls
    console.log(`Tool ${event.toolName} called with`, event.params);

    // Block dangerous tools
    if (event.toolName === "exec" && isSuspicious(event.params)) {
      return { block: true, blockReason: "Blocked by security policy" };
    }

    // Or modify params
    return { params: sanitize(event.params) };
  },
}

{
  hookName: "after_tool_call",
  handler: async (event, ctx) => {
    // Post-execution logging / telemetry
    metrics.recordToolCall({
      tool: event.toolName,
      durationMs: event.durationMs,
      success: !event.error,
    });
  },
}

Safety

  • Hook errors never break tool execution (caught and logged)
  • Tools are only wrapped when hooks are registered (no overhead otherwise)
  • Follows existing patterns: same wrapper style as wrapToolWithAbortSignal, same hook runner access as session-tool-result-guard-wrapper.ts
  • No refactoring of existing code — purely additive wiring

Greptile Overview

Greptile Summary

This PR wires before_tool_call and after_tool_call plugin hooks into the embedded agent execution loop by adding a wrapToolsWithHooks wrapper that decorates each tool’s execute method. runEmbeddedAttempt now initializes the global hookRunner earlier and passes wrapped builtInTools and customTools into createAgentSession, and it also avoids repeated sessionKey.split(":") by extracting hookAgentId.

The change fits into the existing plugin-hook architecture (src/plugins/hooks.ts) similarly to other tool wrappers (e.g. abort-signal wrapping) and enables plugins to observe/block/modify tool calls and record post-execution telemetry.

Confidence Score: 3/5

  • This PR is likely safe to merge, but the blocked-tool-call behavior may cause tool result pairing issues on strict providers.
  • Core wiring is straightforward and follows existing wrapper patterns, but the new wrapper introduces subtle contract questions: returning a raw string on blocked calls and not emitting a toolResult tied to the toolCallId can break strict tool-call/tool-result pairing or downstream result parsing depending on provider/tooling expectations.
  • src/agents/pi-tools.hooks.ts

(3/5) Reply to the agent's comments like "Can you suggest a fix for this @greptileai?" or ask follow-up questions!

… loop

- Add pi-tools.hooks.ts: wraps tool execute methods to fire plugin hooks
- before_tool_call runs before each tool, supports blocking and param modification
- after_tool_call fires as fire-and-forget after tool returns with timing data
- Hook errors are caught and logged, never breaking tool execution
- Tools are only wrapped when hooks are actually registered (zero overhead otherwise)
- Move hookRunner initialization earlier in attempt.ts for tool wrapping access
- Extract hookAgentId to avoid repeated string splitting
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 5 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +29 to +34
execute: async (
toolCallId: string,
params: unknown,
signal?: AbortSignal,
onUpdate?: (data: unknown) => void,
) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

wrapToolWithHooks hard-codes the execute signature ((toolCallId, params, signal?, onUpdate?)). If any tool implementation expects additional args (or uses a different arity), this wrapper will silently drop them, which can break tool behavior at runtime.

Consider forwarding ...args instead of enumerating parameters, so the wrapper is signature/arity-safe.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.hooks.ts
Line: 29:34

Comment:
`wrapToolWithHooks` hard-codes the `execute` signature (`(toolCallId, params, signal?, onUpdate?)`). If any tool implementation expects additional args (or uses a different arity), this wrapper will silently drop them, which can break tool behavior at runtime.

Consider forwarding `...args` instead of enumerating parameters, so the wrapper is signature/arity-safe.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +45 to +48
const beforeResult = await hookRunner.runBeforeToolCall(
{
toolName,
params: (params ?? {}) as Record<string, unknown>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Casting params to Record<string, unknown> (and defaulting to {}) can be surprising for tools that legitimately take non-object params (string/array/etc.). Plugins will see a different shape than the tool actually received.

If the hook contract requires an object, it may be worth either (a) documenting/enforcing that tools must take object params, or (b) passing through unknown and letting plugins narrow.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.hooks.ts
Line: 45:48

Comment:
Casting `params` to `Record<string, unknown>` (and defaulting to `{}`) can be surprising for tools that legitimately take non-object params (string/array/etc.). Plugins will see a different shape than the tool actually received.

If the hook contract requires an object, it may be worth either (a) documenting/enforcing that tools must take object params, or (b) passing through `unknown` and letting plugins narrow.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +52 to +55
if (beforeResult?.block) {
const reason = beforeResult.blockReason ?? "Blocked by plugin hook";
log.debug(`before_tool_call: blocked ${toolName} — ${reason}`);
return `[Tool call blocked] ${reason}`;
Copy link
Contributor

Choose a reason for hiding this comment

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

When before_tool_call blocks, the wrapper returns a string ("[Tool call blocked] ..."). Some tools / model adapters may expect structured tool results (e.g., JSON) or a consistent result type; returning a plain string could break downstream parsing or transcript persistence depending on tool-result format.

If there’s an existing "synthetic tool result" shape used elsewhere, consider returning that instead for blocked calls.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.hooks.ts
Line: 52:55

Comment:
When `before_tool_call` blocks, the wrapper returns a string (`"[Tool call blocked] ..."`). Some tools / model adapters may expect structured tool results (e.g., JSON) or a consistent result type; returning a plain string could break downstream parsing or transcript persistence depending on tool-result format.

If there’s an existing "synthetic tool result" shape used elsewhere, consider returning that instead for blocked calls.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +52 to +56
if (beforeResult?.block) {
const reason = beforeResult.blockReason ?? "Blocked by plugin hook";
log.debug(`before_tool_call: blocked ${toolName} — ${reason}`);
return `[Tool call blocked] ${reason}`;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Blocking a tool call here returns early without producing an actual toolResult message associated with toolCallId. On providers that require strict tool-call/tool-result pairing, this can leave a dangling tool call and rely on later synthetic repair (or break if synthetic tool results are disabled).

It may be safer to synthesize a proper toolResult tied to toolCallId when blocked, rather than returning a raw string.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.hooks.ts
Line: 52:56

Comment:
Blocking a tool call here returns early without producing an actual `toolResult` message associated with `toolCallId`. On providers that require strict tool-call/tool-result pairing, this can leave a dangling tool call and rely on later synthetic repair (or break if synthetic tool results are disabled).

It may be safer to synthesize a proper toolResult tied to `toolCallId` when blocked, rather than returning a raw string.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +432 to +435
// Get hook runner once for tool-call hooks, before_agent_start, and agent_end
const hookRunner = getGlobalHookRunner();
const hookAgentId = params.sessionKey?.split(":")[0] ?? "main";

Copy link
Contributor

Choose a reason for hiding this comment

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

hookAgentId falls back to "main", but this file already computes sessionAgentId via resolveSessionAgentIds earlier. If those can diverge, hooks may receive a different agentId than the session uses.

If possible, reusing sessionAgentId for hook context would keep hook attribution consistent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 432:435

Comment:
`hookAgentId` falls back to `"main"`, but this file already computes `sessionAgentId` via `resolveSessionAgentIds` earlier. If those can diverge, hooks may receive a different `agentId` than the session uses.

If possible, reusing `sessionAgentId` for hook context would keep hook attribution consistent.

How can I resolve this? If you propose a fix, please make it concise.

@Takhoffman
Copy link
Contributor

Closing with Tak approval: superseded by #15012

@Takhoffman Takhoffman closed this Feb 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants