mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 03:11:10 +00:00
fix: unify plugin tool thread defaults via delivery context
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user