mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(agents): preserve unreadable tool schemas through wrapping
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
39
src/agents/agent-tools.clone.ts
Normal file
39
src/agents/agent-tools.clone.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user