From 8a97803474fb4589e377e51dd724c48e4ea36403 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:11:27 +0100 Subject: [PATCH] fix(agents): normalize malformed tool results in adapter (#27007) --- CHANGELOG.md | 1 + src/agents/pi-tool-definition-adapter.test.ts | 51 +++++++++++++++++ src/agents/pi-tool-definition-adapter.ts | 56 ++++++++++++++++++- 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 717451a2e72..8f6b560f460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel. - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. - Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. +- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007) - Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. - Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. - Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. diff --git a/src/agents/pi-tool-definition-adapter.test.ts b/src/agents/pi-tool-definition-adapter.test.ts index 1b11bbf49be..6def07167cb 100644 --- a/src/agents/pi-tool-definition-adapter.test.ts +++ b/src/agents/pi-tool-definition-adapter.test.ts @@ -25,6 +25,15 @@ async function executeThrowingTool(name: string, callId: string) { return await def.execute(callId, {}, undefined, undefined, extensionContext); } +async function executeTool(tool: AgentTool, callId: string) { + const defs = toToolDefinitions([tool]); + const def = defs[0]; + if (!def) { + throw new Error("missing tool definition"); + } + return await def.execute(callId, {}, undefined, undefined, extensionContext); +} + describe("pi tool definition adapter", () => { it("wraps tool errors into a tool result", async () => { const result = await executeThrowingTool("boom", "call1"); @@ -46,4 +55,46 @@ describe("pi tool definition adapter", () => { error: "nope", }); }); + + it("coerces details-only tool results to include content", async () => { + const tool = { + name: "memory_query", + label: "Memory Query", + description: "returns details only", + parameters: Type.Object({}), + execute: (async () => ({ + details: { + hits: [{ id: "a1", score: 0.9 }], + }, + })) as unknown as AgentTool["execute"], + } satisfies AgentTool; + + const result = await executeTool(tool, "call3"); + expect(result.details).toEqual({ + hits: [{ id: "a1", score: 0.9 }], + }); + expect(result.content[0]).toMatchObject({ type: "text" }); + expect((result.content[0] as { text?: string }).text).toContain('"hits"'); + }); + + it("coerces non-standard object results to include content", async () => { + const tool = { + name: "memory_query_raw", + label: "Memory Query Raw", + description: "returns plain object", + parameters: Type.Object({}), + execute: (async () => ({ + count: 2, + ids: ["m1", "m2"], + })) as unknown as AgentTool["execute"], + } satisfies AgentTool; + + const result = await executeTool(tool, "call4"); + expect(result.details).toEqual({ + count: 2, + ids: ["m1", "m2"], + }); + expect(result.content[0]).toMatchObject({ type: "text" }); + expect((result.content[0] as { text?: string }).text).toContain('"count"'); + }); }); diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index f3963600c80..a6221586242 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -62,6 +62,56 @@ function describeToolExecutionError(err: unknown): { return { message: String(err) }; } +function stringifyToolPayload(payload: unknown): string { + if (typeof payload === "string") { + return payload; + } + try { + const encoded = JSON.stringify(payload, null, 2); + if (typeof encoded === "string") { + return encoded; + } + } catch { + // Fall through to String(payload) for non-serializable values. + } + return String(payload); +} + +function normalizeToolExecutionResult(params: { + toolName: string; + result: unknown; +}): AgentToolResult { + const { toolName, result } = params; + if (result && typeof result === "object") { + const record = result as Record; + if (Array.isArray(record.content)) { + return result as AgentToolResult; + } + 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 { + content: [ + { + type: "text", + text: stringifyToolPayload(safeDetails), + }, + ], + details: safeDetails, + }; + } + const safeDetails = result ?? { status: "ok", tool: toolName }; + return { + content: [ + { + type: "text", + text: stringifyToolPayload(safeDetails), + }, + ], + details: safeDetails, + }; +} + function splitToolExecuteArgs(args: ToolExecuteArgsAny): { toolCallId: string; params: unknown; @@ -111,7 +161,11 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] { } executeParams = hookOutcome.params; } - const result = await tool.execute(toolCallId, executeParams, signal, onUpdate); + const rawResult = await tool.execute(toolCallId, executeParams, signal, onUpdate); + const result = normalizeToolExecutionResult({ + toolName: normalizedName, + result: rawResult, + }); const afterParams = beforeHookWrapped ? (consumeAdjustedParamsForToolCall(toolCallId) ?? executeParams) : executeParams;