Compare commits

...

2 Commits

Author SHA1 Message Date
Tak Hoffman
949c09f408 fix: satisfy check formatting and lint for plugin tool-call hooks 2026-02-12 18:19:38 -06:00
Patrick Barletta
59f9da02b4 feat: dispatch before_tool_call and after_tool_call hooks from both tool execution paths
The typed plugin hook system (registry.typedHooks) supports before_tool_call
and after_tool_call hooks, but they were only partially wired up:

- pi-tool-definition-adapter: had before_tool_call via runBeforeToolCallHook
  but was missing after_tool_call entirely
- pi-embedded-subscribe: had neither hook dispatched

This adds full before_tool_call and after_tool_call dispatch to both code
paths, enabling plugins registered via api.on() to observe all tool
executions. The after_tool_call hook fires on both success and error paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:35:31 -08:00
7 changed files with 110 additions and 6 deletions

View File

@@ -454,6 +454,9 @@ export async function runEmbeddedAttempt(
model: params.model,
});
// Get hook runner early so it's available when creating tools
const hookRunner = getGlobalHookRunner();
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: !!sandbox?.enabled,
@@ -631,6 +634,7 @@ export async function runEmbeddedAttempt(
const subscription = subscribeEmbeddedPiSession({
session: activeSession,
runId: params.runId,
hookRunner: getGlobalHookRunner() ?? undefined,
verboseLevel: params.verboseLevel,
reasoningMode: params.reasoningLevel ?? "off",
toolResultFormat: params.toolResultFormat,
@@ -714,8 +718,7 @@ export async function runEmbeddedAttempt(
}
}
// Get hook runner once for both before_agent_start and agent_end hooks
const hookRunner = getGlobalHookRunner();
// Hook runner was already obtained earlier before tool creation
const hookAgentId =
typeof params.agentId === "string" && params.agentId.trim()
? normalizeAgentId(params.agentId)

View File

@@ -1,4 +1,9 @@
import type { AgentEvent } from "@mariozechner/pi-agent-core";
import type { HookRunner } from "../plugins/hooks.js";
import type {
PluginHookAfterToolCallEvent,
PluginHookBeforeToolCallEvent,
} from "../plugins/types.js";
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
@@ -13,6 +18,10 @@ import {
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
import { normalizeToolName } from "./tool-policy.js";
type OpenClawGlobal = typeof globalThis & {
__openclawHookRunner?: HookRunner;
};
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
const normalized = toolName.trim().toLowerCase();
if (normalized !== "exec" && normalized !== "bash") {
@@ -51,6 +60,20 @@ export async function handleToolExecutionStart(
const toolCallId = String(evt.toolCallId);
const args = evt.args;
// Call before_tool_call hook
const hookRunner = ctx.hookRunner ?? (globalThis as OpenClawGlobal).__openclawHookRunner;
if (hookRunner?.hasHooks?.("before_tool_call")) {
try {
const hookEvent: PluginHookBeforeToolCallEvent = {
toolName,
params: args && typeof args === "object" ? (args as Record<string, unknown>) : {},
};
await hookRunner.runBeforeToolCall(hookEvent, { toolName });
} catch (err) {
ctx.log.debug(`before_tool_call hook failed: tool=${toolName} error=${String(err)}`);
}
}
if (toolName === "read") {
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
const filePath = typeof record.path === "string" ? record.path.trim() : "";
@@ -145,7 +168,7 @@ export function handleToolExecutionUpdate(
});
}
export function handleToolExecutionEnd(
export async function handleToolExecutionEnd(
ctx: EmbeddedPiSubscribeContext,
evt: AgentEvent & {
toolName: string;
@@ -220,6 +243,22 @@ export function handleToolExecutionEnd(
`embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
);
// Call after_tool_call hook
const hookRunnerAfter = ctx.hookRunner ?? (globalThis as OpenClawGlobal).__openclawHookRunner;
if (hookRunnerAfter?.hasHooks?.("after_tool_call")) {
try {
const hookEvent: PluginHookAfterToolCallEvent = {
toolName,
params: {}, // Input params not available in end handler; hook context has toolName
result: sanitizedResult,
error: isToolError ? extractToolErrorMessage(sanitizedResult) : undefined,
};
await hookRunnerAfter.runAfterToolCall(hookEvent, { toolName });
} catch (err) {
ctx.log.debug(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
}
}
if (ctx.params.onToolResult && ctx.shouldEmitToolOutput()) {
const outputText = extractToolResultText(sanitizedResult);
if (outputText) {

View File

@@ -42,7 +42,10 @@ export function createEmbeddedPiSessionEventHandler(ctx: EmbeddedPiSubscribeCont
handleToolExecutionUpdate(ctx, evt as never);
return;
case "tool_execution_end":
handleToolExecutionEnd(ctx, evt as never);
// Async handler - best-effort, non-blocking
handleToolExecutionEnd(ctx, evt as never).catch((err) => {
ctx.log.debug(`tool_execution_end handler failed: ${String(err)}`);
});
return;
case "agent_start":
handleAgentStart(ctx);

View File

@@ -2,6 +2,7 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js";
import type { ReasoningLevel } from "../auto-reply/thinking.js";
import type { InlineCodeState } from "../markdown/code-spans.js";
import type { HookRunner } from "../plugins/hooks.js";
import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
import type { MessagingToolSend } from "./pi-embedded-messaging.js";
import type {
@@ -69,6 +70,7 @@ export type EmbeddedPiSubscribeContext = {
log: EmbeddedSubscribeLogger;
blockChunking?: BlockReplyChunking;
blockChunker: EmbeddedBlockChunker | null;
hookRunner?: HookRunner;
shouldEmitToolResult: () => boolean;
shouldEmitToolOutput: () => boolean;

View File

@@ -575,6 +575,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
log,
blockChunking,
blockChunker,
hookRunner: params.hookRunner,
shouldEmitToolResult,
shouldEmitToolOutput,
emitToolSummary,

View File

@@ -1,5 +1,6 @@
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { HookRunner } from "../plugins/hooks.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
export type ToolResultFormat = "markdown" | "plain";
@@ -7,6 +8,7 @@ export type ToolResultFormat = "markdown" | "plain";
export type SubscribeEmbeddedPiSessionParams = {
session: AgentSession;
runId: string;
hookRunner?: HookRunner;
verboseLevel?: VerboseLevel;
reasoningMode?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;

View File

@@ -6,6 +6,7 @@ import type {
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
import { logDebug, logError } from "../logger.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { isPlainObject } from "../utils.js";
import { runBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
import { normalizeToolName } from "./tool-policy.js";
@@ -90,7 +91,38 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
execute: async (...args: ToolExecuteArgs): Promise<AgentToolResult<unknown>> => {
const { toolCallId, params, onUpdate, signal } = splitToolExecuteArgs(args);
try {
return await tool.execute(toolCallId, params, signal, onUpdate);
// Call before_tool_call hook
const hookOutcome = await runBeforeToolCallHook({
toolName: name,
params,
toolCallId,
});
if (hookOutcome.blocked) {
throw new Error(hookOutcome.reason);
}
const adjustedParams = hookOutcome.params;
const result = await tool.execute(toolCallId, adjustedParams, signal, onUpdate);
// Call after_tool_call hook
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("after_tool_call")) {
try {
await hookRunner.runAfterToolCall(
{
toolName: name,
params: isPlainObject(adjustedParams) ? adjustedParams : {},
result,
},
{ toolName: name },
);
} catch (hookErr) {
logDebug(
`after_tool_call hook failed: tool=${normalizedName} error=${String(hookErr)}`,
);
}
}
return result;
} catch (err) {
if (signal?.aborted) {
throw err;
@@ -107,11 +139,33 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
logDebug(`tools: ${normalizedName} failed stack:\n${described.stack}`);
}
logError(`[tools] ${normalizedName} failed: ${described.message}`);
return jsonResult({
const errorResult = jsonResult({
status: "error",
tool: normalizedName,
error: described.message,
});
// Call after_tool_call hook for errors too
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("after_tool_call")) {
try {
await hookRunner.runAfterToolCall(
{
toolName: normalizedName,
params: isPlainObject(params) ? params : {},
error: described.message,
},
{ toolName: normalizedName },
);
} catch (hookErr) {
logDebug(
`after_tool_call hook failed: tool=${normalizedName} error=${String(hookErr)}`,
);
}
}
return errorResult;
}
},
} satisfies ToolDefinition;