Files
openclaw/src/agents/pi-tool-definition-adapter.ts
2026-03-31 15:50:14 +01:00

307 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { redactToolDetail } from "../logging/redact.js";
import { isPlainObject } from "../utils.js";
import { sanitizeForConsole } from "./console-sanitize.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, payloadTextResult } 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;
const TOOL_ERROR_PARAM_PREVIEW_MAX_CHARS = 600;
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 serializeToolParams(value: unknown): string {
if (value === undefined) {
return "<undefined>";
}
if (typeof value === "string") {
return value;
}
if (
value === null ||
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
if (typeof serialized === "string") {
return serialized;
}
} catch {
// Fall through to String(value).
}
if (typeof value === "function") {
return value.name ? `[Function ${value.name}]` : "[Function anonymous]";
}
if (typeof value === "symbol") {
return value.description ? `Symbol(${value.description})` : "Symbol()";
}
return Object.prototype.toString.call(value);
}
function formatToolParamPreview(label: string, value: unknown): string {
const serialized = serializeToolParams(value);
const redacted = redactToolDetail(serialized);
const preview = sanitizeForConsole(redacted, TOOL_ERROR_PARAM_PREVIEW_MAX_CHARS) ?? "<empty>";
return `${label}=${preview}`;
}
function describeToolFailureInputs(params: {
rawParams: unknown;
effectiveParams: unknown;
}): string {
const parts = [formatToolParamPreview("raw_params", params.rawParams)];
const rawSerialized = serializeToolParams(params.rawParams);
const effectiveSerialized = serializeToolParams(params.effectiveParams);
if (effectiveSerialized !== rawSerialized) {
parts.push(formatToolParamPreview("effective_params", params.effectiveParams));
}
return parts.join(" ");
}
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 payloadTextResult(safeDetails);
}
const safeDetails = result ?? { status: "ok", tool: toolName };
return payloadTextResult(safeDetails);
}
function buildToolExecutionErrorResult(params: {
toolName: string;
message: string;
}): AgentToolResult<unknown> {
return jsonResult({
status: "error",
tool: params.toolName,
error: params.message,
});
}
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}`);
}
const inputPreview = describeToolFailureInputs({
rawParams: params,
effectiveParams: executeParams,
});
logError(`[tools] ${normalizedName} failed: ${described.message} ${inputPreview}`);
return buildToolExecutionErrorResult({
toolName: normalizedName,
message: described.message,
});
}
},
} satisfies ToolDefinition;
});
}
/**
* Coerce tool-call params into a plain object.
*
* Some providers (e.g. Gemini) stream tool-call arguments as incremental
* string deltas. By the time the framework invokes the tool's `execute`
* callback the accumulated value may still be a JSON **string** rather than
* a parsed object. `isPlainObject()` returns `false` for strings, which
* caused the params to be silently replaced with `{}`.
*
* This helper tries `JSON.parse` when the value is a string and falls back
* to an empty object only when parsing genuinely fails.
*/
function coerceParamsRecord(value: unknown): Record<string, unknown> {
if (isPlainObject(value)) {
return value;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
try {
const parsed: unknown = JSON.parse(trimmed);
if (isPlainObject(parsed)) {
return parsed;
}
} catch {
// not valid JSON fall through to empty object
}
}
}
return {};
}
// 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 = coerceParamsRecord(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;
});
}