From 44defeb71b31b8f73ee2a3003e380f6466c7d3dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 23:48:52 +0000 Subject: [PATCH] fix: unify plugin tool thread defaults via delivery context --- .../openclaw-tools.plugin-context.test.ts | 152 ++++++++++++++++++ src/agents/openclaw-tools.ts | 149 ++++++++++++++++- src/plugins/types.ts | 3 + 3 files changed, 302 insertions(+), 2 deletions(-) diff --git a/src/agents/openclaw-tools.plugin-context.test.ts b/src/agents/openclaw-tools.plugin-context.test.ts index 6b07f169dbe..2db402badd2 100644 --- a/src/agents/openclaw-tools.plugin-context.test.ts +++ b/src/agents/openclaw-tools.plugin-context.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AnyAgentTool } from "./tools/common.js"; const { resolvePluginToolsMock } = vi.hoisted(() => ({ resolvePluginToolsMock: vi.fn((params?: unknown) => { @@ -102,4 +103,155 @@ describe("createOpenClawTools plugin context", () => { }), ); }); + + it("forwards ambient deliveryContext to plugin tool context", () => { + createOpenClawTools({ + config: {} as never, + agentChannel: "slack", + agentTo: "channel:C123", + agentAccountId: "work", + agentThreadId: "1710000000.000100", + }); + + expect(resolvePluginToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + deliveryContext: { + channel: "slack", + to: "channel:C123", + accountId: "work", + threadId: "1710000000.000100", + }, + }), + }), + ); + }); + + it("injects ambient thread defaults without mutating shared plugin tool instances", async () => { + const executeMock = vi.fn(async () => ({ + content: [{ type: "text" as const, text: "ok" }], + details: {}, + })); + const sharedTool: AnyAgentTool = { + name: "plugin-thread-default", + label: "plugin-thread-default", + description: "test", + parameters: { + type: "object", + properties: { + threadId: { type: "string" }, + }, + }, + execute: executeMock, + }; + resolvePluginToolsMock.mockImplementation(() => [sharedTool] as never); + + const first = createOpenClawTools({ + config: {} as never, + agentThreadId: "111.222", + }).find((tool) => tool.name === "plugin-thread-default"); + const second = createOpenClawTools({ + config: {} as never, + agentThreadId: "333.444", + }).find((tool) => tool.name === "plugin-thread-default"); + + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(first).not.toBe(sharedTool); + expect(second).not.toBe(sharedTool); + expect(first).not.toBe(second); + + await first?.execute("call-1", {}); + await second?.execute("call-2", {}); + + expect(executeMock).toHaveBeenNthCalledWith(1, "call-1", { threadId: "111.222" }); + expect(executeMock).toHaveBeenNthCalledWith(2, "call-2", { threadId: "333.444" }); + }); + + it("injects messageThreadId defaults for missing params objects", async () => { + const executeMock = vi.fn(async () => ({ + content: [{ type: "text" as const, text: "ok" }], + details: {}, + })); + const tool: AnyAgentTool = { + name: "plugin-message-thread-default", + label: "plugin-message-thread-default", + description: "test", + parameters: { + type: "object", + properties: { + messageThreadId: { type: "number" }, + }, + }, + execute: executeMock, + }; + resolvePluginToolsMock.mockReturnValue([tool] as never); + + const wrapped = createOpenClawTools({ + config: {} as never, + agentThreadId: "77", + }).find((candidate) => candidate.name === tool.name); + + await wrapped?.execute("call-1", undefined); + + expect(executeMock).toHaveBeenCalledWith("call-1", { messageThreadId: 77 }); + }); + + it("preserves string thread ids for tools that declare string thread parameters", async () => { + const executeMock = vi.fn(async () => ({ + content: [{ type: "text" as const, text: "ok" }], + details: {}, + })); + const tool: AnyAgentTool = { + name: "plugin-string-thread-default", + label: "plugin-string-thread-default", + description: "test", + parameters: { + type: "object", + properties: { + threadId: { type: "string" }, + }, + }, + execute: executeMock, + }; + resolvePluginToolsMock.mockReturnValue([tool] as never); + + const wrapped = createOpenClawTools({ + config: {} as never, + agentThreadId: "77", + }).find((candidate) => candidate.name === tool.name); + + await wrapped?.execute("call-1", {}); + + expect(executeMock).toHaveBeenCalledWith("call-1", { threadId: "77" }); + }); + + it("does not override explicit thread params when ambient defaults exist", async () => { + const executeMock = vi.fn(async () => ({ + content: [{ type: "text" as const, text: "ok" }], + details: {}, + })); + const tool: AnyAgentTool = { + name: "plugin-thread-override", + label: "plugin-thread-override", + description: "test", + parameters: { + type: "object", + properties: { + threadId: { type: "string" }, + }, + }, + execute: executeMock, + }; + resolvePluginToolsMock.mockReturnValue([tool] as never); + + const wrapped = createOpenClawTools({ + config: {} as never, + agentThreadId: "111.222", + }).find((candidate) => candidate.name === tool.name); + + await wrapped?.execute("call-1", { threadId: "explicit" }); + + expect(executeMock).toHaveBeenCalledWith("call-1", { threadId: "explicit" }); + }); }); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index a3ba6618a87..2c51a0cddca 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -1,7 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; import { callGateway } from "../gateway/call.js"; -import { resolvePluginTools } from "../plugins/tools.js"; +import { readSnakeCaseParamRaw } from "../param-key.js"; +import { copyPluginToolMeta, resolvePluginTools } from "../plugins/tools.js"; import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js"; +import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; import { resolveSessionAgentId } from "./agent-scope.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; @@ -39,6 +41,131 @@ const defaultOpenClawToolsDeps: OpenClawToolsDeps = { let openClawToolsDeps: OpenClawToolsDeps = defaultOpenClawToolsDeps; +type ThreadInjectionKey = "threadId" | "messageThreadId"; + +function coerceAmbientThreadIdForSchema(params: { + value: unknown; + expectedType?: "string" | "number"; +}): string | number | undefined { + const { value, expectedType } = params; + if (value === undefined || value === null) { + return undefined; + } + if (expectedType === "string") { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return undefined; + } + if (expectedType === "number") { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + return undefined; + } + if (/^-?\d+$/.test(trimmed) && !Number.isSafeInteger(parsed)) { + return undefined; + } + return parsed; + } + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + return undefined; +} + +function resolveThreadInjectionTarget(tool: AnyAgentTool): { + key: ThreadInjectionKey; + expectedType?: "string" | "number"; +} | null { + const schema = + tool.parameters && typeof tool.parameters === "object" + ? (tool.parameters as Record) + : null; + const properties = + schema?.properties && typeof schema.properties === "object" + ? (schema.properties as Record) + : null; + if (!properties) { + return null; + } + for (const key of ["threadId", "messageThreadId"] as const) { + const property = + properties[key] && typeof properties[key] === "object" + ? (properties[key] as Record) + : null; + if (!property) { + continue; + } + const type = property.type; + const expectedType = + type === "string" ? "string" : type === "number" || type === "integer" ? "number" : undefined; + return { key, expectedType }; + } + return null; +} + +function wrapPluginToolWithAmbientThreadDefaults(params: { + tool: AnyAgentTool; + ambientThreadId: string | number; +}): AnyAgentTool { + const target = resolveThreadInjectionTarget(params.tool); + if (!params.tool.execute || !target) { + return params.tool; + } + const defaultThreadId = coerceAmbientThreadIdForSchema({ + value: params.ambientThreadId, + expectedType: target.expectedType, + }); + if (defaultThreadId === undefined) { + return params.tool; + } + const originalExecute = params.tool.execute.bind(params.tool); + const wrappedTool: AnyAgentTool = { + ...params.tool, + execute: async (...args: unknown[]) => { + const existingParams = args[1]; + const paramsRecord = + existingParams == null + ? {} + : existingParams && typeof existingParams === "object" && !Array.isArray(existingParams) + ? (existingParams as Record) + : null; + if (!paramsRecord) { + return await originalExecute(...(args as Parameters)); + } + if ( + readSnakeCaseParamRaw(paramsRecord, "threadId") !== undefined || + readSnakeCaseParamRaw(paramsRecord, "messageThreadId") !== undefined + ) { + return await originalExecute(...(args as Parameters)); + } + const nextArgs = [...args]; + nextArgs[1] = { ...paramsRecord, [target.key]: defaultThreadId }; + return await originalExecute(...(nextArgs as Parameters)); + }, + }; + copyPluginToolMeta(params.tool, wrappedTool); + return wrappedTool; +} + export function createOpenClawTools( options?: { sandboxBrowserBridgeUrl?: string; @@ -101,6 +228,12 @@ export function createOpenClawTools( const spawnWorkspaceDir = resolveWorkspaceRoot( options?.spawnWorkspaceDir ?? options?.workspaceDir, ); + const deliveryContext = normalizeDeliveryContext({ + channel: options?.agentChannel, + to: options?.agentTo, + accountId: options?.agentAccountId, + threadId: options?.agentThreadId, + }); const runtimeWebTools = getActiveRuntimeWebToolsMetadata(); const sandbox = options?.sandboxRoot && options?.sandboxFsBridge @@ -255,6 +388,7 @@ export function createOpenClawTools( }, messageChannel: options?.agentChannel, agentAccountId: options?.agentAccountId, + deliveryContext, requesterSenderId: options?.requesterSenderId ?? undefined, senderIsOwner: options?.senderIsOwner ?? undefined, sandboxed: options?.sandboxed, @@ -264,7 +398,18 @@ export function createOpenClawTools( allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding, }); - return [...tools, ...pluginTools]; + const ambientThreadId = deliveryContext?.threadId; + const wrappedPluginTools = + ambientThreadId == null + ? pluginTools + : pluginTools.map((tool) => + wrapPluginToolWithAmbientThreadDefaults({ + tool, + ambientThreadId, + }), + ); + + return [...tools, ...wrappedPluginTools]; } export const __testing = { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index a8d2e8e433d..638fb564336 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -51,6 +51,7 @@ import type { SpeechTelephonySynthesisResult, SpeechVoiceOption, } from "../tts/provider-types.js"; +import type { DeliveryContext } from "../utils/delivery-context.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { SecretInputMode } from "./provider-auth-types.js"; import type { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; @@ -125,6 +126,8 @@ export type OpenClawPluginToolContext = { }; messageChannel?: string; agentAccountId?: string; + /** Trusted ambient delivery route for the active agent/session. */ + deliveryContext?: DeliveryContext; /** Trusted sender id from inbound context (runtime-provided, not tool args). */ requesterSenderId?: string; /** Whether the trusted sender is an owner. */