diff --git a/src/agents/agent-tools.abort.ts b/src/agents/agent-tools.abort.ts index 7f29222455e4..31587f4876bf 100644 --- a/src/agents/agent-tools.abort.ts +++ b/src/agents/agent-tools.abort.ts @@ -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); diff --git a/src/agents/agent-tools.before-tool-call.ts b/src/agents/agent-tools.before-tool-call.ts index 73590f8ba023..3758961d577a 100644 --- a/src/agents/agent-tools.before-tool-call.ts +++ b/src/agents/agent-tools.before-tool-call.ts @@ -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, { diff --git a/src/agents/agent-tools.clone.ts b/src/agents/agent-tools.clone.ts new file mode 100644 index 000000000000..4b154444448a --- /dev/null +++ b/src/agents/agent-tools.clone.ts @@ -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(["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); + }, + }); +} diff --git a/src/agents/agent-tools.schema.test.ts b/src/agents/agent-tools.schema.test.ts index 92edcf84e568..8778091980d5 100644 --- a/src/agents/agent-tools.schema.test.ts +++ b/src/agents/agent-tools.schema.test.ts @@ -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", diff --git a/src/agents/agent-tools.schema.ts b/src/agents/agent-tools.schema.ts index 0acfcaa48f97..c2fc822797a4 100644 --- a/src/agents/agent-tools.schema.ts +++ b/src/agents/agent-tools.schema.ts @@ -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) + parametersRead.value && typeof parametersRead.value === "object" + ? (parametersRead.value as Record) : undefined; if (!schema) { return tool;