fix: unify plugin tool thread defaults via delivery context

This commit is contained in:
Peter Steinberger 2026-03-27 23:48:52 +00:00
parent 1c412b1ac6
commit 44defeb71b
No known key found for this signature in database
3 changed files with 302 additions and 2 deletions

View File

@ -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" });
});
});

View File

@ -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<string, unknown>)
: null;
const properties =
schema?.properties && typeof schema.properties === "object"
? (schema.properties as Record<string, unknown>)
: 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<string, unknown>)
: 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<string, unknown>)
: null;
if (!paramsRecord) {
return await originalExecute(...(args as Parameters<typeof originalExecute>));
}
if (
readSnakeCaseParamRaw(paramsRecord, "threadId") !== undefined ||
readSnakeCaseParamRaw(paramsRecord, "messageThreadId") !== undefined
) {
return await originalExecute(...(args as Parameters<typeof originalExecute>));
}
const nextArgs = [...args];
nextArgs[1] = { ...paramsRecord, [target.key]: defaultThreadId };
return await originalExecute(...(nextArgs as Parameters<typeof originalExecute>));
},
};
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 = {

View File

@ -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. */