diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b575b45c4..f7e373d10c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Fixes + +- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19. + ## 2026.4.2-beta.1 ### Breaking diff --git a/src/agents/pi-tools.params.test.ts b/src/agents/pi-tools.params.test.ts index 0951de355b6..c6737feeda3 100644 --- a/src/agents/pi-tools.params.test.ts +++ b/src/agents/pi-tools.params.test.ts @@ -47,6 +47,40 @@ describe("assertRequiredParams", () => { ).toThrow(/\(received: file_path\)[^,]/); }); + it("shows empty-string values for present params that still fail validation", () => { + expect(() => + assertRequiredParams( + { path: "/tmp/a.txt", content: " " }, + [ + { keys: ["path", "file_path"], label: "path alias" }, + { keys: ["content"], label: "content" }, + ], + "write", + ), + ).toThrow(/\(received: path, content=\)/); + }); + + it("shows wrong-type values for present params that still fail validation", async () => { + const tool = wrapToolParamNormalization( + { + name: "write", + label: "write", + description: "write a file", + parameters: {}, + execute: vi.fn(), + }, + CLAUDE_PARAM_GROUPS.write, + ); + await expect( + tool.execute( + "id", + { file_path: "test.txt", content: { unexpected: true } }, + new AbortController().signal, + vi.fn(), + ), + ).rejects.toThrow(/\(received: (?:path, content=|content=, path)\)/); + }); + it("includes multiple received keys when several params are present", () => { expect(() => assertRequiredParams( diff --git a/src/agents/pi-tools.params.ts b/src/agents/pi-tools.params.ts index cc319c8b87e..e0976573113 100644 --- a/src/agents/pi-tools.params.ts +++ b/src/agents/pi-tools.params.ts @@ -13,6 +13,39 @@ function parameterValidationError(message: string): Error { return new Error(`${message}.${RETRY_GUIDANCE_SUFFIX}`); } +function describeReceivedParamValue(value: unknown, allowEmpty = false): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value === "string") { + if (allowEmpty || value.trim().length > 0) { + return undefined; + } + return ""; + } + if (Array.isArray(value)) { + return ""; + } + return `<${typeof value}>`; +} + +function formatReceivedParamHint( + record: Record, + groups: readonly RequiredParamGroup[], +): string { + const allowEmptyKeys = new Set( + groups.filter((group) => group.allowEmpty).flatMap((group) => group.keys), + ); + const received = Object.keys(record).flatMap((key) => { + const detail = describeReceivedParamValue(record[key], allowEmptyKeys.has(key)); + if (record[key] === undefined || record[key] === null) { + return []; + } + return [detail ? `${key}=${detail}` : key]; + }); + return received.length > 0 ? ` (received: ${received.join(", ")})` : ""; +} + export const CLAUDE_PARAM_GROUPS = { read: [{ keys: ["path", "file_path", "filePath", "file"], label: "path alias" }], write: [ @@ -275,10 +308,7 @@ export function assertRequiredParams( if (missingLabels.length > 0) { const joined = missingLabels.join(", "); const noun = missingLabels.length === 1 ? "parameter" : "parameters"; - const receivedKeys = Object.keys(record).filter( - (k) => record[k] !== undefined && record[k] !== null, - ); - const receivedHint = receivedKeys.length > 0 ? ` (received: ${receivedKeys.join(", ")})` : ""; + const receivedHint = formatReceivedParamHint(record, groups); throw parameterValidationError(`Missing required ${noun}: ${joined}${receivedHint}`); } }