Files
openclaw/src/agents/pi-tool-definition-adapter.ts
Vincent Koc 44183c6eb1 fix(hooks): consolidate after_tool_call context + single-fire behavior (#32201)
* fix(hooks): deduplicate after_tool_call hook in embedded runs

(cherry picked from commit c129a1a74b)

* fix(hooks): propagate sessionKey in after_tool_call context

The after_tool_call hook in handleToolExecutionEnd was passing
`sessionKey: undefined` in the ToolContext, even though the value is
available on ctx.params. This broke plugins that need session context
in after_tool_call handlers (e.g., for per-session audit trails or
security logging).

- Add `sessionKey` to the `ToolHandlerParams` Pick type
- Pass `ctx.params.sessionKey` through to the hook context
- Add test assertion to prevent regression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit b7117384fc)

* fix(hooks): thread agentId through to after_tool_call hook context

Follow-up to #30511 — the after_tool_call hook context was passing
`agentId: undefined` because SubscribeEmbeddedPiSessionParams did not
carry the agent identity. This threads sessionAgentId (resolved in
attempt.ts) through the session params into the tool handler context,
giving plugins accurate agent-scoped context for both before_tool_call
and after_tool_call hooks.

Changes:
- Add `agentId?: string` to SubscribeEmbeddedPiSessionParams
- Add "agentId" to ToolHandlerParams Pick type
- Pass `agentId: sessionAgentId` at the subscribeEmbeddedPiSession()
  call site in attempt.ts
- Wire ctx.params.agentId into the after_tool_call hook context
- Update tests to assert agentId propagation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit aad01edd3e)

* changelog: credit after_tool_call hook contributors

* Update CHANGELOG.md

* agents: preserve adjusted params until tool end

* agents: emit after_tool_call with adjusted args

* tests: cover adjusted after_tool_call params

* tests: align adapter after_tool_call expectation

---------

Co-authored-by: jbeno <jim@jimbeno.net>
Co-authored-by: scoootscooob <zhentongfan@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:33:37 -08:00

237 lines
7.1 KiB
TypeScript

import type {
AgentTool,
AgentToolResult,
AgentToolUpdateCallback,
} from "@mariozechner/pi-agent-core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { logDebug, logError } from "../logger.js";
import { isPlainObject } from "../utils.js";
import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
import type { HookContext } from "./pi-tools.before-tool-call.js";
import {
isToolWrappedWithBeforeToolCallHook,
runBeforeToolCallHook,
} from "./pi-tools.before-tool-call.js";
import { normalizeToolName } from "./tool-policy.js";
import { jsonResult } from "./tools/common.js";
type AnyAgentTool = AgentTool;
type ToolExecuteArgsCurrent = [
string,
unknown,
AbortSignal | undefined,
AgentToolUpdateCallback<unknown> | undefined,
unknown,
];
type ToolExecuteArgsLegacy = [
string,
unknown,
AgentToolUpdateCallback<unknown> | undefined,
unknown,
AbortSignal | undefined,
];
type ToolExecuteArgs = ToolDefinition["execute"] extends (...args: infer P) => unknown
? P
: ToolExecuteArgsCurrent;
type ToolExecuteArgsAny = ToolExecuteArgs | ToolExecuteArgsLegacy | ToolExecuteArgsCurrent;
function isAbortSignal(value: unknown): value is AbortSignal {
return typeof value === "object" && value !== null && "aborted" in value;
}
function isLegacyToolExecuteArgs(args: ToolExecuteArgsAny): args is ToolExecuteArgsLegacy {
const third = args[2];
const fifth = args[4];
if (typeof third === "function") {
return true;
}
return isAbortSignal(fifth);
}
function describeToolExecutionError(err: unknown): {
message: string;
stack?: string;
} {
if (err instanceof Error) {
const message = err.message?.trim() ? err.message : String(err);
return { message, stack: err.stack };
}
return { message: String(err) };
}
function stringifyToolPayload(payload: unknown): string {
if (typeof payload === "string") {
return payload;
}
try {
const encoded = JSON.stringify(payload, null, 2);
if (typeof encoded === "string") {
return encoded;
}
} catch {
// Fall through to String(payload) for non-serializable values.
}
return String(payload);
}
function normalizeToolExecutionResult(params: {
toolName: string;
result: unknown;
}): AgentToolResult<unknown> {
const { toolName, result } = params;
if (result && typeof result === "object") {
const record = result as Record<string, unknown>;
if (Array.isArray(record.content)) {
return result as AgentToolResult<unknown>;
}
logDebug(`tools: ${toolName} returned non-standard result (missing content[]); coercing`);
const details = "details" in record ? record.details : record;
const safeDetails = details ?? { status: "ok", tool: toolName };
return {
content: [
{
type: "text",
text: stringifyToolPayload(safeDetails),
},
],
details: safeDetails,
};
}
const safeDetails = result ?? { status: "ok", tool: toolName };
return {
content: [
{
type: "text",
text: stringifyToolPayload(safeDetails),
},
],
details: safeDetails,
};
}
function splitToolExecuteArgs(args: ToolExecuteArgsAny): {
toolCallId: string;
params: unknown;
onUpdate: AgentToolUpdateCallback<unknown> | undefined;
signal: AbortSignal | undefined;
} {
if (isLegacyToolExecuteArgs(args)) {
const [toolCallId, params, onUpdate, _ctx, signal] = args;
return {
toolCallId,
params,
onUpdate,
signal,
};
}
const [toolCallId, params, signal, onUpdate] = args;
return {
toolCallId,
params,
onUpdate,
signal,
};
}
export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
return tools.map((tool) => {
const name = tool.name || "tool";
const normalizedName = normalizeToolName(name);
const beforeHookWrapped = isToolWrappedWithBeforeToolCallHook(tool);
return {
name,
label: tool.label ?? name,
description: tool.description ?? "",
parameters: tool.parameters,
execute: async (...args: ToolExecuteArgs): Promise<AgentToolResult<unknown>> => {
const { toolCallId, params, onUpdate, signal } = splitToolExecuteArgs(args);
let executeParams = params;
try {
if (!beforeHookWrapped) {
const hookOutcome = await runBeforeToolCallHook({
toolName: name,
params,
toolCallId,
});
if (hookOutcome.blocked) {
throw new Error(hookOutcome.reason);
}
executeParams = hookOutcome.params;
}
const rawResult = await tool.execute(toolCallId, executeParams, signal, onUpdate);
const result = normalizeToolExecutionResult({
toolName: normalizedName,
result: rawResult,
});
return result;
} catch (err) {
if (signal?.aborted) {
throw err;
}
const name =
err && typeof err === "object" && "name" in err
? String((err as { name?: unknown }).name)
: "";
if (name === "AbortError") {
throw err;
}
const described = describeToolExecutionError(err);
if (described.stack && described.stack !== described.message) {
logDebug(`tools: ${normalizedName} failed stack:\n${described.stack}`);
}
logError(`[tools] ${normalizedName} failed: ${described.message}`);
return jsonResult({
status: "error",
tool: normalizedName,
error: described.message,
});
}
},
} satisfies ToolDefinition;
});
}
// Convert client tools (OpenResponses hosted tools) to ToolDefinition format
// These tools are intercepted to return a "pending" result instead of executing
export function toClientToolDefinitions(
tools: ClientToolDefinition[],
onClientToolCall?: (toolName: string, params: Record<string, unknown>) => void,
hookContext?: HookContext,
): ToolDefinition[] {
return tools.map((tool) => {
const func = tool.function;
return {
name: func.name,
label: func.name,
description: func.description ?? "",
parameters: func.parameters as ToolDefinition["parameters"],
execute: async (...args: ToolExecuteArgs): Promise<AgentToolResult<unknown>> => {
const { toolCallId, params } = splitToolExecuteArgs(args);
const outcome = await runBeforeToolCallHook({
toolName: func.name,
params,
toolCallId,
ctx: hookContext,
});
if (outcome.blocked) {
throw new Error(outcome.reason);
}
const adjustedParams = outcome.params;
const paramsRecord = isPlainObject(adjustedParams) ? adjustedParams : {};
// Notify handler that a client tool was called
if (onClientToolCall) {
onClientToolCall(func.name, paramsRecord);
}
// Return a pending result - the client will execute this tool
return jsonResult({
status: "pending",
tool: func.name,
message: "Tool execution delegated to client",
});
},
} satisfies ToolDefinition;
});
}