From be530f085dbee4dae8a714c8586d7c804b53fe4d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 8 Apr 2026 08:45:08 +0100 Subject: [PATCH] refactor(plugin-sdk): share tool payload extraction --- docs/plugins/sdk-migration.md | 1 + docs/plugins/sdk-overview.md | 1 + extensions/qa-lab/src/extract-tool-payload.ts | 32 +------------ package.json | 4 ++ scripts/lib/plugin-sdk-entrypoints.json | 1 + src/infra/outbound/tool-payload.test.ts | 39 ++-------------- src/infra/outbound/tool-payload.ts | 26 +---------- src/plugin-sdk/tool-payload.test.ts | 45 +++++++++++++++++++ src/plugin-sdk/tool-payload.ts | 43 ++++++++++++++++++ .../contracts/plugin-sdk-subpaths.test.ts | 4 ++ 10 files changed, 104 insertions(+), 92 deletions(-) create mode 100644 src/plugin-sdk/tool-payload.test.ts create mode 100644 src/plugin-sdk/tool-payload.ts diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 08bd7ed39ee..af55574274e 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -262,6 +262,7 @@ Current bundled provider examples: | `plugin-sdk/request-url` | Request URL helpers | Extract string URLs from request-like inputs | | `plugin-sdk/run-command` | Timed command helpers | Timed command runner with normalized stdout/stderr | | `plugin-sdk/param-readers` | Param readers | Common tool/CLI param readers | + | `plugin-sdk/tool-payload` | Tool payload extraction | Extract normalized payloads from tool result objects | | `plugin-sdk/tool-send` | Tool send extraction | Extract canonical send target fields from tool args | | `plugin-sdk/temp-path` | Temp path helpers | Shared temp-download path helpers | | `plugin-sdk/logging-core` | Logging helpers | Subsystem logger and redaction helpers | diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 96de78bf89f..d0a7e6ccb0a 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -201,6 +201,7 @@ explicitly promotes one as public. | `plugin-sdk/request-url` | Extract string URLs from fetch/request-like inputs | | `plugin-sdk/run-command` | Timed command runner with normalized stdout/stderr results | | `plugin-sdk/param-readers` | Common tool/CLI param readers | + | `plugin-sdk/tool-payload` | Extract normalized payloads from tool result objects | | `plugin-sdk/tool-send` | Extract canonical send target fields from tool args | | `plugin-sdk/temp-path` | Shared temp-download path helpers | | `plugin-sdk/logging-core` | Subsystem logger and redaction helpers | diff --git a/extensions/qa-lab/src/extract-tool-payload.ts b/extensions/qa-lab/src/extract-tool-payload.ts index 29ceee6d9dc..916303c6322 100644 --- a/extensions/qa-lab/src/extract-tool-payload.ts +++ b/extensions/qa-lab/src/extract-tool-payload.ts @@ -1,31 +1 @@ -type ResultWithDetails = { - details?: unknown; - content?: unknown; -}; - -export function extractQaToolPayload(result: ResultWithDetails | null | undefined): unknown { - if (!result) { - return undefined; - } - if (result.details !== undefined) { - return result.details; - } - const textBlock = Array.isArray(result.content) - ? result.content.find( - (block) => - block && - typeof block === "object" && - (block as { type?: unknown }).type === "text" && - typeof (block as { text?: unknown }).text === "string", - ) - : undefined; - const text = (textBlock as { text?: string } | undefined)?.text; - if (!text) { - return result.content ?? result; - } - try { - return JSON.parse(text); - } catch { - return text; - } -} +export { extractToolPayload as extractQaToolPayload } from "openclaw/plugin-sdk/tool-payload"; diff --git a/package.json b/package.json index f24cc1fb56e..9a952adcc0d 100644 --- a/package.json +++ b/package.json @@ -1005,6 +1005,10 @@ "types": "./dist/plugin-sdk/tlon.d.ts", "default": "./dist/plugin-sdk/tlon.js" }, + "./plugin-sdk/tool-payload": { + "types": "./dist/plugin-sdk/tool-payload.d.ts", + "default": "./dist/plugin-sdk/tool-payload.js" + }, "./plugin-sdk/tool-send": { "types": "./dist/plugin-sdk/tool-send.d.ts", "default": "./dist/plugin-sdk/tool-send.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index c0e42bece79..2c28d6ef876 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -240,6 +240,7 @@ "telegram-command-config", "thread-ownership", "tlon", + "tool-payload", "tool-send", "twitch", "webhook-ingress", diff --git a/src/infra/outbound/tool-payload.test.ts b/src/infra/outbound/tool-payload.test.ts index 08629089618..507a1b30229 100644 --- a/src/infra/outbound/tool-payload.test.ts +++ b/src/infra/outbound/tool-payload.test.ts @@ -1,42 +1,9 @@ import { describe, expect, it } from "vitest"; +import { extractToolPayload as extractSharedToolPayload } from "../../plugin-sdk/tool-payload.js"; import { extractToolPayload } from "./tool-payload.js"; describe("extractToolPayload", () => { - it("prefers explicit details payloads", () => { - expect( - extractToolPayload({ - details: { ok: true }, - content: [{ type: "text", text: '{"ignored":true}' }], - } as never), - ).toEqual({ ok: true }); - }); - - it("parses JSON text blocks from tool content", () => { - expect( - extractToolPayload({ - content: [ - { type: "image", url: "https://example.com/a.png" }, - { type: "text", text: '{"ok":true,"count":2}' }, - ], - } as never), - ).toEqual({ ok: true, count: 2 }); - }); - - it("falls back to raw text, then content, then the whole result", () => { - expect( - extractToolPayload({ - content: [{ type: "text", text: "not json" }], - } as never), - ).toBe("not json"); - - const content = [{ type: "image", url: "https://example.com/a.png" }]; - expect( - extractToolPayload({ - content, - } as never), - ).toBe(content); - - const result = { status: "ok" }; - expect(extractToolPayload(result as never)).toBe(result); + it("re-exports the shared plugin-sdk helper", () => { + expect(extractToolPayload).toBe(extractSharedToolPayload); }); }); diff --git a/src/infra/outbound/tool-payload.ts b/src/infra/outbound/tool-payload.ts index 33a8d1fd6a4..7c2ac4d9721 100644 --- a/src/infra/outbound/tool-payload.ts +++ b/src/infra/outbound/tool-payload.ts @@ -1,25 +1 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; - -export function extractToolPayload(result: AgentToolResult): unknown { - if (result.details !== undefined) { - return result.details; - } - const textBlock = Array.isArray(result.content) - ? result.content.find( - (block) => - block && - typeof block === "object" && - (block as { type?: unknown }).type === "text" && - typeof (block as { text?: unknown }).text === "string", - ) - : undefined; - const text = (textBlock as { text?: string } | undefined)?.text; - if (text) { - try { - return JSON.parse(text); - } catch { - return text; - } - } - return result.content ?? result; -} +export { extractToolPayload } from "../../plugin-sdk/tool-payload.js"; diff --git a/src/plugin-sdk/tool-payload.test.ts b/src/plugin-sdk/tool-payload.test.ts new file mode 100644 index 00000000000..2ad73fef1ff --- /dev/null +++ b/src/plugin-sdk/tool-payload.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { extractToolPayload } from "./tool-payload.js"; + +describe("extractToolPayload", () => { + it("returns undefined for missing results", () => { + expect(extractToolPayload(undefined)).toBeUndefined(); + expect(extractToolPayload(null)).toBeUndefined(); + }); + + it("prefers explicit details payloads", () => { + expect( + extractToolPayload({ + details: { ok: true }, + content: [{ type: "text", text: '{"ignored":true}' }], + }), + ).toEqual({ ok: true }); + }); + + it("parses JSON text blocks and falls back to raw text, content, or the whole result", () => { + expect( + extractToolPayload({ + content: [ + { type: "image", url: "https://example.com/a.png" }, + { type: "text", text: '{"ok":true,"count":2}' }, + ], + }), + ).toEqual({ ok: true, count: 2 }); + + expect( + extractToolPayload({ + content: [{ type: "text", text: "not json" }], + }), + ).toBe("not json"); + + const content = [{ type: "image", url: "https://example.com/a.png" }]; + expect( + extractToolPayload({ + content, + }), + ).toBe(content); + + const result = { status: "ok" }; + expect(extractToolPayload(result)).toBe(result); + }); +}); diff --git a/src/plugin-sdk/tool-payload.ts b/src/plugin-sdk/tool-payload.ts new file mode 100644 index 00000000000..3aebcd48fe2 --- /dev/null +++ b/src/plugin-sdk/tool-payload.ts @@ -0,0 +1,43 @@ +type ToolPayloadTextBlock = { + type: "text"; + text: string; +}; + +export type ToolPayloadCarrier = { + details?: unknown; + content?: unknown; +}; + +function isToolPayloadTextBlock(block: unknown): block is ToolPayloadTextBlock { + return ( + !!block && + typeof block === "object" && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ); +} + +/** + * Extract the most useful payload from tool result-like objects shared across + * outbound core flows and bundled plugin helpers. + */ +export function extractToolPayload(result: ToolPayloadCarrier | null | undefined): unknown { + if (!result) { + return undefined; + } + if (result.details !== undefined) { + return result.details; + } + const textBlock = Array.isArray(result.content) + ? result.content.find(isToolPayloadTextBlock) + : undefined; + const text = textBlock?.text; + if (!text) { + return result.content ?? result; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 17ed31154b4..be8c3420948 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -394,6 +394,10 @@ describe("plugin-sdk subpath exports", () => { "wrapExternalContent", ], }); + expectSourceContract("tool-payload", { + mentions: ["extractToolPayload", "ToolPayloadCarrier"], + omits: ["createAnthropicToolPayloadCompatibilityWrapper", "extractToolSend"], + }); expectSourceMentions("compat", [ "createPluginRuntimeStore", "createScopedChannelConfigAdapter",