Agent tool call hooks now fire during tool execution#2340
Agent tool call hooks now fire during tool execution#2340AhmedTheGeek wants to merge 1 commit intoopenclaw:mainfrom
Conversation
… 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
| execute: async ( | ||
| toolCallId: string, | ||
| params: unknown, | ||
| signal?: AbortSignal, | ||
| onUpdate?: (data: unknown) => void, | ||
| ) => { |
There was a problem hiding this 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.
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.| const beforeResult = await hookRunner.runBeforeToolCall( | ||
| { | ||
| toolName, | ||
| params: (params ?? {}) as Record<string, unknown>, |
There was a problem hiding this 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.
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.| if (beforeResult?.block) { | ||
| const reason = beforeResult.blockReason ?? "Blocked by plugin hook"; | ||
| log.debug(`before_tool_call: blocked ${toolName} — ${reason}`); | ||
| return `[Tool call blocked] ${reason}`; |
There was a problem hiding this 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.
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.| if (beforeResult?.block) { | ||
| const reason = beforeResult.blockReason ?? "Blocked by plugin hook"; | ||
| log.debug(`before_tool_call: blocked ${toolName} — ${reason}`); | ||
| return `[Tool call blocked] ${reason}`; | ||
| } |
There was a problem hiding this 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.
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.| // Get hook runner once for tool-call hooks, before_agent_start, and agent_end | ||
| const hookRunner = getGlobalHookRunner(); | ||
| const hookAgentId = params.sessionKey?.split(":")[0] ?? "main"; | ||
|
|
There was a problem hiding this 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.
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.|
Closing with Tak approval: superseded by #15012 |
Summary
Wire up the existing
before_tool_callandafter_tool_callplugin hooks so they actually fire during the agent execution loop. These hooks were defined insrc/plugins/hooks.tsbut never invoked.What changed
New file:
src/agents/pi-tools.hooks.tsA tool wrapper module (following the same pattern as
pi-tools.abort.ts) that wraps each tool'sexecutemethod to fire plugin hooks:wrapToolWithHooks(tool, hookRunner, ctx)— wraps a single toolwrapToolsWithHooks(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.tshookRunnerinitialization earlier (beforesplitSdkTools) so it's available for tool wrappingbuiltInToolsandcustomToolsare wrapped before being passed tocreateAgentSessionhookAgentIdto avoid repeatedsessionKey.split(":")callsHow it works
before_tool_callFires before each tool executes. Plugin handlers receive
{ toolName, params }and can:{ block: true, blockReason: "..." }→ tool is skipped, a synthetic[Tool call blocked] ...result is returned to the model{ params: modifiedParams }→ the modified params are used insteadHandlers run sequentially in priority order. If any handler blocks, the tool is not executed.
after_tool_callFires 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
Safety
wrapToolWithAbortSignal, same hook runner access assession-tool-result-guard-wrapper.tsGreptile Overview
Greptile Summary
This PR wires
before_tool_callandafter_tool_callplugin hooks into the embedded agent execution loop by adding awrapToolsWithHookswrapper that decorates each tool’sexecutemethod.runEmbeddedAttemptnow initializes the globalhookRunnerearlier and passes wrappedbuiltInToolsandcustomToolsintocreateAgentSession, and it also avoids repeatedsessionKey.split(":")by extractinghookAgentId.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
(3/5) Reply to the agent's comments like "Can you suggest a fix for this @greptileai?" or ask follow-up questions!