fix(hooks): propagate run/tool IDs for tool hook correlation (#32360)

* Plugin SDK: add run and tool call fields to tool hooks

* Agents: propagate runId and toolCallId in before_tool_call

* Agents: thread runId through tool wrapper context

* Runner: pass runId into tool hook context

* Compaction: pass runId into tool hook context

* Agents: scope after_tool_call start data by run

* Tests: cover run and tool IDs in before_tool_call hooks

* Tests: add run-scoped after_tool_call collision coverage

* Hooks: scope adjusted tool params by run

* Tests: cover run-scoped adjusted param collisions

* Hooks: preserve active tool start metadata until end

* Changelog: add tool-hook correlation note
This commit is contained in:
Vincent Koc
2026-03-02 17:23:08 -08:00
committed by GitHub
parent 61adcea68e
commit 747902a26a
9 changed files with 200 additions and 19 deletions

View File

@@ -371,6 +371,7 @@ export async function compactEmbeddedPiSessionDirect(
agentAccountId: params.agentAccountId,
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,

View File

@@ -645,6 +645,7 @@ export async function runEmbeddedAttempt(
senderIsOwner: params.senderIsOwner,
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
@@ -920,6 +921,7 @@ export async function runEmbeddedAttempt(
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
loopDetection: clientToolLoopDetection,
},
)

View File

@@ -22,8 +22,17 @@ import { consumeAdjustedParamsForToolCall } from "./pi-tools.before-tool-call.js
import { buildToolMutationState, isSameToolMutationAction } from "./tool-mutation.js";
import { normalizeToolName } from "./tool-policy.js";
/** Track tool execution start times and args for after_tool_call hook */
const toolStartData = new Map<string, { startTime: number; args: unknown }>();
type ToolStartRecord = {
startTime: number;
args: unknown;
};
/** Track tool execution start data for after_tool_call hook. */
const toolStartData = new Map<string, ToolStartRecord>();
function buildToolStartKey(runId: string, toolCallId: string): string {
return `${runId}:${toolCallId}`;
}
function isCronAddAction(args: unknown): boolean {
if (!args || typeof args !== "object") {
@@ -182,9 +191,10 @@ export async function handleToolExecutionStart(
const toolName = normalizeToolName(rawToolName);
const toolCallId = String(evt.toolCallId);
const args = evt.args;
const runId = ctx.params.runId;
// Track start time and args for after_tool_call hook
toolStartData.set(toolCallId, { startTime: Date.now(), args });
toolStartData.set(buildToolStartKey(runId, toolCallId), { startTime: Date.now(), args });
if (toolName === "read") {
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
@@ -302,12 +312,14 @@ export async function handleToolExecutionEnd(
) {
const toolName = normalizeToolName(String(evt.toolName));
const toolCallId = String(evt.toolCallId);
const runId = ctx.params.runId;
const isError = Boolean(evt.isError);
const result = evt.result;
const isToolError = isError || isToolResultError(result);
const sanitizedResult = sanitizeToolResult(result);
const startData = toolStartData.get(toolCallId);
toolStartData.delete(toolCallId);
const toolStartKey = buildToolStartKey(runId, toolCallId);
const startData = toolStartData.get(toolStartKey);
toolStartData.delete(toolStartKey);
const callSummary = ctx.state.toolMetaById.get(toolCallId);
const meta = callSummary?.meta;
ctx.state.toolMetas.push({ toolName, meta });
@@ -364,7 +376,7 @@ export async function handleToolExecutionEnd(
startData?.args && typeof startData.args === "object"
? (startData.args as Record<string, unknown>)
: {};
const adjustedArgs = consumeAdjustedParamsForToolCall(toolCallId);
const adjustedArgs = consumeAdjustedParamsForToolCall(toolCallId, runId);
const afterToolCallArgs =
adjustedArgs && typeof adjustedArgs === "object"
? (adjustedArgs as Record<string, unknown>)
@@ -424,6 +436,8 @@ export async function handleToolExecutionEnd(
const hookEvent: PluginHookAfterToolCallEvent = {
toolName,
params: afterToolCallArgs,
runId,
toolCallId,
result: sanitizedResult,
error: isToolError ? extractToolErrorMessage(sanitizedResult) : undefined,
durationMs,
@@ -434,6 +448,8 @@ export async function handleToolExecutionEnd(
agentId: ctx.params.agentId,
sessionKey: ctx.params.sessionKey,
sessionId: ctx.params.sessionId,
runId,
toolCallId,
})
.catch((err) => {
ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);

View File

@@ -3,7 +3,11 @@ import { resetDiagnosticSessionStateForTest } from "../logging/diagnostic-sessio
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js";
import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
import {
__testing as beforeToolCallTesting,
consumeAdjustedParamsForToolCall,
wrapToolWithBeforeToolCallHook,
} from "./pi-tools.before-tool-call.js";
vi.mock("../plugins/hook-runner-global.js");
@@ -37,6 +41,7 @@ describe("before_tool_call hook integration", () => {
beforeEach(() => {
resetDiagnosticSessionStateForTest();
beforeToolCallTesting.adjustedParamsByToolCallId.clear();
hookRunner = installMockHookRunner();
});
@@ -123,6 +128,7 @@ describe("before_tool_call hook integration", () => {
agentId: "main",
sessionKey: "main",
sessionId: "ephemeral-main",
runId: "run-main",
});
const extensionContext = {} as Parameters<typeof tool.execute>[3];
@@ -132,15 +138,51 @@ describe("before_tool_call hook integration", () => {
{
toolName: "read",
params: {},
runId: "run-main",
toolCallId: "call-5",
},
{
toolName: "read",
agentId: "main",
sessionKey: "main",
sessionId: "ephemeral-main",
runId: "run-main",
toolCallId: "call-5",
},
);
});
it("keeps adjusted params isolated per run when toolCallId collides", async () => {
hookRunner.hasHooks.mockReturnValue(true);
hookRunner.runBeforeToolCall
.mockResolvedValueOnce({ params: { marker: "A" } })
.mockResolvedValueOnce({ params: { marker: "B" } });
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any
const toolA = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
runId: "run-a",
});
// oxlint-disable-next-line typescript/no-explicit-any
const toolB = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
runId: "run-b",
});
const extensionContextA = {} as Parameters<typeof toolA.execute>[3];
const extensionContextB = {} as Parameters<typeof toolB.execute>[3];
const sharedToolCallId = "shared-call";
await toolA.execute(sharedToolCallId, { path: "/tmp/a.txt" }, undefined, extensionContextA);
await toolB.execute(sharedToolCallId, { path: "/tmp/b.txt" }, undefined, extensionContextB);
expect(consumeAdjustedParamsForToolCall(sharedToolCallId, "run-a")).toEqual({
path: "/tmp/a.txt",
marker: "A",
});
expect(consumeAdjustedParamsForToolCall(sharedToolCallId, "run-b")).toEqual({
path: "/tmp/b.txt",
marker: "B",
});
expect(consumeAdjustedParamsForToolCall(sharedToolCallId, "run-a")).toBeUndefined();
});
});
describe("before_tool_call hook deduplication (#15502)", () => {

View File

@@ -11,6 +11,7 @@ export type HookContext = {
sessionKey?: string;
/** Ephemeral session UUID — regenerated on /new and /reset. */
sessionId?: string;
runId?: string;
loopDetection?: ToolLoopDetectionConfig;
};
@@ -23,6 +24,13 @@ const MAX_TRACKED_ADJUSTED_PARAMS = 1024;
const LOOP_WARNING_BUCKET_SIZE = 10;
const MAX_LOOP_WARNING_KEYS = 256;
function buildAdjustedParamsKey(params: { runId?: string; toolCallId: string }): string {
if (params.runId && params.runId.trim()) {
return `${params.runId}:${params.toolCallId}`;
}
return params.toolCallId;
}
function shouldEmitLoopWarning(state: SessionState, warningKey: string, count: number): boolean {
if (!state.toolLoopWarningBuckets) {
state.toolLoopWarningBuckets = new Map();
@@ -141,17 +149,22 @@ export async function runBeforeToolCallHook(args: {
try {
const normalizedParams = isPlainObject(params) ? params : {};
const toolContext = {
toolName,
...(args.ctx?.agentId ? { agentId: args.ctx.agentId } : {}),
...(args.ctx?.sessionKey ? { sessionKey: args.ctx.sessionKey } : {}),
...(args.ctx?.sessionId ? { sessionId: args.ctx.sessionId } : {}),
...(args.ctx?.runId ? { runId: args.ctx.runId } : {}),
...(args.toolCallId ? { toolCallId: args.toolCallId } : {}),
};
const hookResult = await hookRunner.runBeforeToolCall(
{
toolName,
params: normalizedParams,
...(args.ctx?.runId ? { runId: args.ctx.runId } : {}),
...(args.toolCallId ? { toolCallId: args.toolCallId } : {}),
},
{
toolName,
agentId: args.ctx?.agentId,
sessionKey: args.ctx?.sessionKey,
sessionId: args.ctx?.sessionId,
},
toolContext,
);
if (hookResult?.block) {
@@ -197,7 +210,8 @@ export function wrapToolWithBeforeToolCallHook(
throw new Error(outcome.reason);
}
if (toolCallId) {
adjustedParamsByToolCallId.set(toolCallId, outcome.params);
const adjustedParamsKey = buildAdjustedParamsKey({ runId: ctx?.runId, toolCallId });
adjustedParamsByToolCallId.set(adjustedParamsKey, outcome.params);
if (adjustedParamsByToolCallId.size > MAX_TRACKED_ADJUSTED_PARAMS) {
const oldest = adjustedParamsByToolCallId.keys().next().value;
if (oldest) {
@@ -240,14 +254,16 @@ export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
}
export function consumeAdjustedParamsForToolCall(toolCallId: string): unknown {
const params = adjustedParamsByToolCallId.get(toolCallId);
adjustedParamsByToolCallId.delete(toolCallId);
export function consumeAdjustedParamsForToolCall(toolCallId: string, runId?: string): unknown {
const adjustedParamsKey = buildAdjustedParamsKey({ runId, toolCallId });
const params = adjustedParamsByToolCallId.get(adjustedParamsKey);
adjustedParamsByToolCallId.delete(adjustedParamsKey);
return params;
}
export const __testing = {
BEFORE_TOOL_CALL_WRAPPED,
buildAdjustedParamsKey,
adjustedParamsByToolCallId,
runBeforeToolCallHook,
isPlainObject,

View File

@@ -190,6 +190,8 @@ export function createOpenClawCodingTools(options?: {
sessionKey?: string;
/** Ephemeral session UUID — regenerated on /new and /reset. */
sessionId?: string;
/** Stable run identifier for this agent invocation. */
runId?: string;
agentDir?: string;
workspaceDir?: string;
config?: OpenClawConfig;
@@ -537,6 +539,7 @@ export function createOpenClawCodingTools(options?: {
agentId,
sessionKey: options?.sessionKey,
sessionId: options?.sessionId,
runId: options?.runId,
loopDetection: resolveToolLoopDetectionConfig({ cfg: options?.config, agentId }),
}),
);