Plugins: add tool_result_received hook for output interception#11071
Plugins: add tool_result_received hook for output interception#11071ThomasLWang wants to merge 10 commits intoopenclaw:mainfrom
Conversation
| // Execute the tool | ||
| const result = await execute(toolCallId, beforeOutcome.params, signal, onUpdate); | ||
| // Pure tool execution time (excludes hook overhead) |
There was a problem hiding this comment.
Hook skips tool errors
tool_result_received is declared with error?: string (PluginHookToolResultReceivedEvent), but this wrapper only calls the hook after a successful await execute(...). If the tool throws, the hook never runs, and even on success error is always undefined because it’s never passed. This makes it impossible for plugins to filter/block error outputs via this hook. Consider either (a) running the hook in a try/catch/finally path and populating error on failure, or (b) removing error from the event contract if errors are intentionally out of scope.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.before-tool-call.ts
Line: 142:144
Comment:
**Hook skips tool errors**
`tool_result_received` is declared with `error?: string` (`PluginHookToolResultReceivedEvent`), but this wrapper only calls the hook after a successful `await execute(...)`. If the tool throws, the hook never runs, and even on success `error` is always `undefined` because it’s never passed. This makes it impossible for plugins to filter/block error outputs via this hook. Consider either (a) running the hook in a `try/catch/finally` path and populating `error` on failure, or (b) removing `error` from the event contract if errors are intentionally out of scope.
How can I resolve this? If you propose a fix, please make it concise.|
Re: Hook skips tool errors — Good catch. This hook is designed to intercept successful tool outputs only (the security use case is filtering content like emails/web pages for prompt injection, not error messages). Removed the unused |
…law#10328) Add a new plugin hook that runs after tool execution but before results reach the agent, enabling plugins to modify or block tool outputs for security guardrails and content filtering. Fixes from review: - Pass original params (unknown) instead of normalizing to {} - Fix runModifyingHook type cast to match handler signatures - Clarify durationMs measures pure tool execution time Co-Authored-By: Thomas <ThomasLWang@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Hello, can this pr be merged? |
|
@greptileai can you help review this? |
| execute: async (toolCallId, params, signal, onUpdate) => { | ||
| const outcome = await runBeforeToolCallHook({ | ||
| const startTime = Date.now(); | ||
|
|
||
| // Before hook - can modify params or block the call | ||
| const beforeOutcome = await runBeforeToolCallHook({ | ||
| toolName, | ||
| params, | ||
| toolCallId, | ||
| ctx, | ||
| }); | ||
| if (outcome.blocked) { | ||
| throw new Error(outcome.reason); | ||
| if (beforeOutcome.blocked) { | ||
| throw new Error(beforeOutcome.reason); | ||
| } | ||
|
|
||
| // Execute the tool | ||
| const result = await execute(toolCallId, beforeOutcome.params, signal, onUpdate); | ||
| // Pure tool execution time (excludes hook overhead) | ||
| const durationMs = Date.now() - startTime; | ||
|
|
||
| // After hook - can modify result or block it | ||
| const afterOutcome = await runToolResultReceivedHook({ | ||
| toolName, | ||
| params: beforeOutcome.params, | ||
| result, | ||
| toolCallId, | ||
| ctx, | ||
| durationMs, | ||
| }); |
There was a problem hiding this comment.
Incorrect durationMs semantics
durationMs is described as “Pure tool execution time (excludes hook overhead)”, but it’s measured from startTime taken before runBeforeToolCallHook(...), so it includes before-hook time (and any other overhead prior to execute(...)). This makes the tool_result_received event’s durationMs inaccurate for plugins that rely on it (e.g., latency guardrails). Consider starting the timer immediately before execute(...) (or explicitly documenting that durationMs includes before-hook time).
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.before-tool-call.ts
Line: 128:155
Comment:
**Incorrect `durationMs` semantics**
`durationMs` is described as “Pure tool execution time (excludes hook overhead)”, but it’s measured from `startTime` taken *before* `runBeforeToolCallHook(...)`, so it includes before-hook time (and any other overhead prior to `execute(...)`). This makes the `tool_result_received` event’s `durationMs` inaccurate for plugins that rely on it (e.g., latency guardrails). Consider starting the timer immediately before `execute(...)` (or explicitly documenting that `durationMs` includes before-hook time).
How can I resolve this? If you propose a fix, please make it concise.Move startTime capture to immediately before tool execution (after before-hook completes) to ensure durationMs accurately represents pure tool execution time, excluding before-hook overhead. Addresses review feedback from greptile-apps on PR openclaw#11071.
|
Fixed the The Changes:
✅ All tests pass (239 tests) The fix is now on main and addresses the review feedback from @greptile-apps. |
|
Perfect! The timing fix looks correct. Moving The implementation now matches the documented semantics:
✅ This addresses the correctness issue from the review. |
- Accept upstream README changes - Regenerate contributors list with update-clawtributors script (485 entries) - Includes timing fix for tool_result_received hook
|
Also resolved the README merge conflict: ✅ Merged upstream main branch The fork is now up to date with upstream and includes all fixes. |
|
@greptileai can you help review this ? |
| execute: async (toolCallId, params, signal, onUpdate) => { | ||
| const outcome = await runBeforeToolCallHook({ | ||
| // Before hook - can modify params or block the call | ||
| const beforeOutcome = await runBeforeToolCallHook({ | ||
| toolName, |
There was a problem hiding this comment.
Double-wrap causes hooks twice
wrapToolWithBeforeToolCallHook always wraps tool.execute with hook logic and returns a new object. If this wrapper is applied more than once to the same tool instance (e.g., tools are composed/registered in multiple places), hooks will run multiple times per call and may block/modify results unexpectedly. Consider marking wrapped tools (e.g., via a Symbol/flag) and returning early when already wrapped to make wrapping idempotent.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.before-tool-call.ts
Line: 125:128
Comment:
**Double-wrap causes hooks twice**
`wrapToolWithBeforeToolCallHook` always wraps `tool.execute` with hook logic and returns a new object. If this wrapper is applied more than once to the same tool instance (e.g., tools are composed/registered in multiple places), hooks will run multiple times per call and may block/modify results unexpectedly. Consider marking wrapped tools (e.g., via a Symbol/flag) and returning early when already wrapped to make wrapping idempotent.
How can I resolve this? If you propose a fix, please make it concise.Add Symbol-based marker to prevent double-wrapping of tools with hooks. Multiple calls to wrapToolWithBeforeToolCallHook now return early if the tool is already wrapped, preventing hooks from running multiple times. Addresses review feedback from greptile-apps on PR openclaw#11071.
|
Also fixed the double-wrap issue: Issue: Solution: Added Symbol-based marker (
All three review issues are now resolved: Commits: |
|
@greptileai code updated, please help to review it. |
| for (const hook of hooks) { | ||
| try { | ||
| const handlerResult = await ( | ||
| hook.handler as (event: unknown, ctx: unknown) => Promise<TResult> | ||
| hook.handler as (event: unknown, ctx: unknown) => Promise<TResult | void> | ||
| )(event, ctx); |
There was a problem hiding this comment.
Dropping sync hook returns
runModifyingHook casts handlers as async-only ((event, ctx) => Promise<TResult | void>), so any hook handler that returns a plain object synchronously (which the PluginHookHandlerMap allows for modifying hooks like before_tool_call and tool_result_received) will be ignored because await on a non-Promise yields the value but the cast prevents non-Promise call sites in TS and can mask incorrect handler invocation types. This should call handlers as possibly-sync (Promise<TResult|void> | TResult | void) like the map declares, otherwise synchronous plugin hooks won’t be able to modify/block anything.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/hooks.ts
Line: 152:156
Comment:
**Dropping sync hook returns**
`runModifyingHook` casts handlers as async-only (`(event, ctx) => Promise<TResult | void>`), so any hook handler that returns a plain object synchronously (which the `PluginHookHandlerMap` allows for modifying hooks like `before_tool_call` and `tool_result_received`) will be ignored because `await` on a non-Promise yields the value but the cast prevents non-Promise call sites in TS and can mask incorrect handler invocation types. This should call handlers as possibly-sync (`Promise<TResult|void> | TResult | void`) like the map declares, otherwise synchronous plugin hooks won’t be able to modify/block anything.
How can I resolve this? If you propose a fix, please make it concise.| // After hook - can modify result or block it | ||
| const afterOutcome = await runToolResultReceivedHook({ | ||
| toolName, | ||
| params: beforeOutcome.params, | ||
| result, | ||
| toolCallId, | ||
| ctx, | ||
| durationMs, | ||
| }); | ||
| if (afterOutcome.blocked) { | ||
| throw new Error(afterOutcome.reason); | ||
| } | ||
|
|
||
| // oxlint-disable-next-line typescript/no-explicit-any | ||
| return afterOutcome.result as any; |
There was a problem hiding this comment.
Null result cannot be set
In runToolResultReceivedHook, the wrapper only applies a modified result when hookResult?.result !== undefined. That makes it impossible for a plugin to intentionally replace a tool result with null (or explicitly clear it) because null will be treated the same as “no override” and the original result will pass through. If null is a valid/desired override value for redaction, you’ll need a different sentinel (e.g., check for property existence rather than value).
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.before-tool-call.ts
Line: 150:164
Comment:
**Null result cannot be set**
In `runToolResultReceivedHook`, the wrapper only applies a modified result when `hookResult?.result !== undefined`. That makes it impossible for a plugin to intentionally replace a tool result with `null` (or explicitly clear it) because `null` will be treated the same as “no override” and the original result will pass through. If `null` is a valid/desired override value for redaction, you’ll need a different sentinel (e.g., check for property existence rather than value).
How can I resolve this? If you propose a fix, please make it concise.Two fixes based on greptile-apps review:
1. Allow synchronous hook handlers: Changed runModifyingHook type cast to
support both sync and async returns (Promise<T> | T | void), fixing
before_tool_call and tool_result_received handlers that return plain
objects synchronously.
2. Allow null as valid result override: Changed result check from
'result \!== undefined' to property existence check ('result' in obj),
enabling plugins to intentionally set null results for redaction.
Addresses review feedback from greptile-apps on PR openclaw#11071.
|
Fixed two additional issues from latest Greptile review: 1. Synchronous hook handlers now supported ✅Issue: Solution: Updated type cast to Impact: Plugins can now return plain objects synchronously from 2. Null results now allowed ✅Issue: Check Solution: Changed to property existence check: Impact: Plugins can now intentionally set All review issues resolved:
Commit: d162fc5 |
|
@greptileai Please help review it. |
| event, | ||
| ctx, | ||
| (acc, next) => ({ | ||
| result: next.result ?? acc?.result, | ||
| block: next.block ?? acc?.block, | ||
| blockReason: next.blockReason ?? acc?.blockReason, | ||
| }), |
There was a problem hiding this comment.
Null tool result lost
In runToolResultReceived, the merge uses result: next.result ?? acc?.result, which drops an explicit null override (and any other intentional falsy-but-not-undefined values) from a hook and falls back to the previous result. Since wrapToolWithBeforeToolCallHook now explicitly supports null overrides by checking property existence, this merge step reintroduces the inability for plugins to redact by setting result: null.
A fix is to merge based on key presence (e.g. ("result" in next) ? next.result : acc?.result) rather than nullish coalescing.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/hooks.ts
Line: 331:337
Comment:
**Null tool result lost**
In `runToolResultReceived`, the merge uses `result: next.result ?? acc?.result`, which drops an explicit `null` override (and any other intentional falsy-but-not-undefined values) from a hook and falls back to the previous result. Since `wrapToolWithBeforeToolCallHook` now explicitly supports `null` overrides by checking property existence, this merge step reintroduces the inability for plugins to redact by setting `result: null`.
A fix is to merge based on key presence (e.g. `("result" in next) ? next.result : acc?.result`) rather than nullish coalescing.
How can I resolve this? If you propose a fix, please make it concise.Changed merge logic from nullish coalescing (??) to property existence
check ('result' in next) to preserve explicit null overrides from hooks.
Without this fix, hooks that intentionally set result: null for redaction
would have their null values dropped and replaced with the accumulated
result, defeating the purpose of the null override.
Addresses review feedback from greptile-apps on PR openclaw#11071.
|
Fixed the merge logic issue: Null result preservation in merge ✅Issue: The merge function in result: next.result ?? acc?.result, // ❌ Drops nullThis caused explicit Solution: Changed to property existence check: result: "result" in next ? next.result : acc?.result, // ✅ Preserves nullImpact: Plugins can now successfully redact tool results by setting This completes the null-handling fix across all layers:
Commit: af1349a |
|
@greptileai please review |
|
@greptileai can you merge this pr? |
|
I don't have the ability to merge pull requests. Only users with write access to the repository can merge PRs. However, I can confirm that this PR appears ready to merge based on:
If you have merge permissions, you can merge it yourself through the GitHub interface. If you need someone else to merge it, you'll need to ask a maintainer or someone with write access to the |
|
@greptileai Which maintainer do you think is suitable for merge this PR? |
|
Based on the repository's PR workflow and recent commit activity, here are the most suitable maintainers for this PR: Primary recommendation: @quotentiroler - Most active maintainer in the recent commits with extensive work on plugin infrastructure, CI/CD improvements, and codebase refactoring. Has demonstrated deep knowledge of the agent runtime and plugin system. Alternative recommendations:
This PR adds plugin lifecycle hooks (specifically |
bfc1ccb to
f92900f
Compare
|
This pull request has been automatically marked as stale due to inactivity. |
Summary
tool_result_receivedplugin hook that runs after tool execution but before results reach the agent, enabling plugins to modify or block tool outputs for security guardrails and content filtering.after_tool_callhook.unknown), fixrunModifyingHooktype cast, clarifydurationMssemantics.Based on #10328 by @ThomasLWang with review feedback from greptile-apps addressed.
Test plan
pnpm buildpassespnpm check(tsgo + oxlint + oxfmt) passespnpm test— 219 tests pass🤖 Generated with Claude Code
Co-Authored-By: Thomas thomas@openguardrails.com
Greptile Overview
Greptile Summary
This PR adds a new plugin lifecycle hook,
tool_result_received, which runs after a tool executes but before its output is delivered back into the agent loop. Tool execution is now wrapped so plugins can (a) modify tool params pre-execution viabefore_tool_calland (b) modify/block tool results post-execution via the new sequential hook, whileafter_tool_callremains parallel fire-and-forget. The hook runner (src/plugins/hooks.ts) gains a sequential “modifying hook” runner that supports both sync and async handlers and merges results in priority order, including preserving explicitnulloverrides for tool results. Documentation and changelog are updated to describe the new interception point in the agent loop.Confidence Score: 5/5