fix(agents): preserve unreadable tool schemas through wrapping

This commit is contained in:
Vincent Koc
2026-06-04 09:23:38 +02:00
parent 25eb63885d
commit f3bc98af97
5 changed files with 128 additions and 11 deletions

View File

@@ -1,6 +1,7 @@
import { copyPluginToolMeta } from "../plugins/tools.js";
import { bindAbortRelay } from "../utils/fetch-timeout.js";
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
import { cloneToolWithExecute } from "./agent-tools.clone.js";
import type { AnyAgentTool } from "./agent-tools.types.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
@@ -57,16 +58,16 @@ export function wrapToolWithAbortSignal(
if (!execute) {
return tool;
}
const wrappedTool: AnyAgentTool = {
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
const wrappedTool = cloneToolWithExecute(
tool,
async (toolCallId, params, signal, onUpdate) => {
const combined = combineAbortSignals(signal, abortSignal);
if (combined?.aborted) {
throwAbortError();
}
return await execute(toolCallId, params, combined, onUpdate);
},
};
);
copyPluginToolMeta(tool, wrappedTool);
copyChannelAgentToolMeta(tool as never, wrappedTool as never);
copyBeforeToolCallHookMarker(tool, wrappedTool);

View File

@@ -48,6 +48,7 @@ import { resolveSkillWorkshopToolApproval } from "../skills/workshop/policy.js";
import { isPlainObject } from "../utils.js";
import { adjustedParamsByToolCallId } from "./agent-tools.before-tool-call.state.js";
import { copyChannelAgentToolMeta, getChannelAgentToolMeta } from "./channel-tools.js";
import { cloneToolWithExecute } from "./agent-tools.clone.js";
import {
getCodeModeExecBeforeHookMetadata,
getCodeModeExecBeforeHookMetadataForToolKind,
@@ -1103,9 +1104,9 @@ export function wrapToolWithBeforeToolCallHook(
...(options.approvalMode ? { approvalMode: options.approvalMode } : {}),
emitDiagnostics: options.emitDiagnostics !== false,
};
const wrappedTool: AnyAgentTool = {
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
const wrappedTool = cloneToolWithExecute(
tool,
async (toolCallId, params, signal, onUpdate) => {
const prepare = (tool as BeforeToolCallPreparingTool).prepareBeforeToolCallParams;
const preparedParams = prepare
? await prepare(params, {
@@ -1250,7 +1251,7 @@ export function wrapToolWithBeforeToolCallHook(
throw err;
}
},
};
);
copyPluginToolMeta(tool, wrappedTool);
copyChannelAgentToolMeta(tool as never, wrappedTool as never);
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, {

View File

@@ -0,0 +1,39 @@
import type { AnyAgentTool } from "./agent-tools.types.js";
export function cloneToolWithExecute(
tool: AnyAgentTool,
execute: AnyAgentTool["execute"],
): AnyAgentTool {
const descriptorTarget = Object.create(Object.getPrototypeOf(tool)) as AnyAgentTool;
const descriptors: PropertyDescriptorMap = { ...Object.getOwnPropertyDescriptors(tool) };
delete descriptors.execute;
Object.defineProperties(descriptorTarget, descriptors);
Object.defineProperty(descriptorTarget, "execute", {
value: execute,
enumerable: true,
configurable: true,
writable: true,
});
const localProperties = new Set<PropertyKey>(["execute"]);
// Keep own descriptors on the wrapper for enumeration and marker writes, but
// read source properties with the original receiver so class/proxy accessors keep their state.
return new Proxy(descriptorTarget, {
defineProperty(target, property, descriptor) {
localProperties.add(property);
return Reflect.defineProperty(target, property, descriptor);
},
get(target, property, receiver) {
if (localProperties.has(property)) {
return Reflect.get(target, property, receiver);
}
return Reflect.get(tool, property, tool);
},
set(target, property, value, receiver) {
if (localProperties.has(property)) {
return Reflect.set(target, property, value, receiver);
}
return Reflect.set(tool, property, value, tool);
},
});
}

View File

@@ -2,7 +2,11 @@ import { runAgentLoop, type AgentEvent, type StreamFn } from "openclaw/plugin-sd
import { createAssistantMessageEventStream, validateToolArguments } from "openclaw/plugin-sdk/llm";
import { Type, type TSchema } from "typebox";
import { describe, expect, it, vi } from "vitest";
import { wrapToolWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js";
import {
isToolWrappedWithBeforeToolCallHook,
wrapToolWithBeforeToolCallHook,
} from "./agent-tools.before-tool-call.js";
import { wrapToolWithAbortSignal } from "./agent-tools.abort.js";
import {
cleanToolSchemaForGemini,
normalizeToolParameterSchema,
@@ -650,6 +654,64 @@ function makeTool(parameters: TSchema): AnyAgentTool {
}
describe("normalizeToolParameters", () => {
it("leaves unreadable parameter descriptors for runtime projection diagnostics", () => {
const tool: AnyAgentTool = {
name: "fuzzplugin_unreadable",
label: "fuzzplugin_unreadable",
description: "Bad plugin tool",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
};
Object.defineProperty(tool, "parameters", {
enumerable: true,
get() {
throw new Error("fuzzplugin parameters getter exploded");
},
});
Object.defineProperty(tool, "execute", {
value: vi.fn(),
enumerable: true,
configurable: false,
writable: false,
});
const normalized = normalizeToolParameters(tool);
const hooked = wrapToolWithBeforeToolCallHook(normalized);
const wrapped = wrapToolWithAbortSignal(hooked, new AbortController().signal);
const parametersDescriptor = Object.getOwnPropertyDescriptor(wrapped, "parameters");
const readParameters = parametersDescriptor?.get?.bind(wrapped);
expect(isToolWrappedWithBeforeToolCallHook(wrapped)).toBe(true);
expect(readParameters).toBeTypeOf("function");
expect(() => readParameters?.()).toThrow("fuzzplugin parameters getter exploded");
});
it("preserves class-backed tool accessors through runtime wrappers", () => {
class ClassBackedTool {
readonly name = "class_tool";
readonly label = "Class tool";
readonly description = "Class-backed plugin tool";
readonly #parameters = {
type: "object",
properties: {
q: { type: "string" },
},
};
readonly execute = vi.fn();
get parameters(): unknown {
return this.#parameters;
}
}
const tool = new ClassBackedTool() as AnyAgentTool;
const hooked = wrapToolWithBeforeToolCallHook(tool);
const wrapped = wrapToolWithAbortSignal(hooked, new AbortController().signal);
expect(wrapped.name).toBe("class_tool");
expect(wrapped.parameters).toEqual(tool.parameters);
});
it("normalizes truly empty schemas to type:object with properties:{} (MCP parameter-free tools)", () => {
const tool: AnyAgentTool = {
name: "get_flux_instance",

View File

@@ -8,6 +8,8 @@ import { copyChannelAgentToolMeta } from "./channel-tools.js";
export { normalizeToolParameterSchema };
type ToolParametersRead = { readable: true; value: unknown } | { readable: false };
function isObjectSchemaWithNoRequiredParams(schema: unknown): boolean {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
return false;
@@ -59,6 +61,14 @@ function addEmptyObjectArgumentPreparation(tool: AnyAgentTool, parameters: unkno
};
}
function readToolParameters(tool: AnyAgentTool): ToolParametersRead {
try {
return { readable: true, value: tool.parameters };
} catch {
return { readable: false };
}
}
export function normalizeToolParameters(
tool: AnyAgentTool,
options?: ToolParameterSchemaOptions,
@@ -68,9 +78,13 @@ export function normalizeToolParameters(
copyChannelAgentToolMeta(tool as never, target as never);
return target;
}
const parametersRead = readToolParameters(tool);
if (!parametersRead.readable) {
return tool;
}
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
parametersRead.value && typeof parametersRead.value === "object"
? (parametersRead.value as Record<string, unknown>)
: undefined;
if (!schema) {
return tool;