openclaw/src/agents/pi-tools.hooks.ts
dp-web4 dbbca8e040 feat: wire before_tool_call / after_tool_call plugin hooks into tool pipeline
The typed plugin hooks `before_tool_call` and `after_tool_call` were fully
implemented in the hook runner but never invoked during agent tool execution.

This adds a tool wrapper (`pi-tools.hooks.ts`) that intercepts every tool's
`execute()` to fire these hooks, following the same pattern as the existing
`wrapToolWithAbortSignal` wrapper:

- `before_tool_call` runs sequentially before execution and can modify params
  or block the call entirely.
- `after_tool_call` runs fire-and-forget after execution with result, error,
  and duration.

Includes 12 unit tests covering the full lifecycle: pass-through, blocking,
param modification, error propagation, and hook isolation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:42:51 -08:00

89 lines
2.7 KiB
TypeScript

/**
* Tool hook wrapper — wires plugin before_tool_call / after_tool_call hooks
* into the tool execution pipeline.
*
* Follows the same wrapping pattern as pi-tools.abort.ts.
*/
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
export type ToolHookContext = {
agentId?: string;
sessionKey?: string;
};
/**
* Wrap a tool so that plugin before_tool_call and after_tool_call hooks fire
* around every invocation.
*
* - before_tool_call runs sequentially and may modify params or block the call.
* - after_tool_call runs in parallel (fire-and-forget) with result/error/duration.
*/
export function wrapToolWithHooks(tool: AnyAgentTool, ctx: ToolHookContext): AnyAgentTool {
const originalExecute = tool.execute;
if (!originalExecute) return tool;
return {
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
const hookRunner = getGlobalHookRunner();
const toolName = tool.name;
// --- before_tool_call ---
if (hookRunner?.hasHooks("before_tool_call")) {
const beforeResult = await hookRunner.runBeforeToolCall(
{ toolName, params: (params ?? {}) as Record<string, unknown> },
{ agentId: ctx.agentId, sessionKey: ctx.sessionKey, toolName },
);
if (beforeResult?.block) {
const reason = beforeResult.blockReason ?? "Blocked by plugin hook";
return {
content: [{ type: "text" as const, text: `[blocked] ${reason}` }],
details: undefined,
};
}
// Allow hooks to modify params
if (beforeResult?.params) {
params = beforeResult.params;
}
}
// --- execute ---
const start = Date.now();
let result: Awaited<ReturnType<NonNullable<AnyAgentTool["execute"]>>>;
let error: string | undefined;
try {
result = await originalExecute(toolCallId, params, signal, onUpdate);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
throw err;
} finally {
const durationMs = Date.now() - start;
// --- after_tool_call (fire-and-forget) ---
if (hookRunner?.hasHooks("after_tool_call")) {
hookRunner
.runAfterToolCall(
{
toolName,
params: (params ?? {}) as Record<string, unknown>,
result: error ? undefined : result!,
error,
durationMs,
},
{ agentId: ctx.agentId, sessionKey: ctx.sessionKey, toolName },
)
.catch(() => {
// swallow — after_tool_call is fire-and-forget
});
}
}
return result;
},
};
}