mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix: cache plugin tool factories by context
This commit is contained in:
@@ -131,6 +131,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Web search/MiniMax: include MiniMax Search in the web-search setup flow and let `MINIMAX_API_KEY` participate in MiniMax Search auto-detection. Supersedes #65828. Thanks @Jah-yee.
|
||||
- Plugins/ClawHub: preserve official source-linked trust through archive installs, so OpenClaw can install trusted ClawHub plugin packages that trigger the built-in dangerous-pattern scanner. Thanks @vincentkoc.
|
||||
- Plugins/ClawHub: install package runtime dependencies for archive-backed plugin installs, so ClawHub packages such as WhatsApp load declared dependencies after download. Thanks @vincentkoc.
|
||||
- Plugins/tools: cache repeated plugin tool factory results only for matching request context, reducing per-turn tool prep without leaking sandbox, session, browser, delivery, or runtime config state. Fixes #75956. Thanks @Linux2010.
|
||||
- Providers/LM Studio: allow `models.providers.lmstudio.params.preload: false` to skip OpenClaw's native model-load call so LM Studio JIT loading, idle TTL, and auto-evict can own model lifecycle. Fixes #75921. Thanks @garyd9.
|
||||
- Agents/transcripts: keep chat history, restart recovery, fork token checks, and stale-token compaction checks on bounded async transcript reads or cached async indexes instead of reparsing large session files. Thanks @mariozechner.
|
||||
- Telegram: inherit the process DNS result order for Bot API transport and downgrade recovered sticky IPv4 fallback promotions to debug logs, while keeping pinned-IP escalation warnings visible. Fixes #75904. Thanks @highfly-hi and @neeravmakwana.
|
||||
|
||||
@@ -368,6 +368,12 @@ including plugin id, declared tool names, result shape, and whether the tool is
|
||||
optional. Slow lines are promoted to warnings when a single factory takes at
|
||||
least 1s or total plugin tool factory prep takes at least 5s.
|
||||
|
||||
OpenClaw caches successful plugin tool factory results for repeated resolutions
|
||||
with the same effective request context. The cache key includes the effective
|
||||
runtime config, workspace, agent/session ids, sandbox policy, browser settings,
|
||||
delivery context, requester identity, and ownership state, so factories that
|
||||
depend on those trusted fields are re-run when the context changes.
|
||||
|
||||
If one plugin dominates the timing, inspect its runtime registrations:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
|
||||
|
||||
let resolvePluginTools: typeof import("./tools.js").resolvePluginTools;
|
||||
let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey;
|
||||
let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache;
|
||||
let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry;
|
||||
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
|
||||
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
|
||||
@@ -58,6 +59,7 @@ function createContext() {
|
||||
}
|
||||
|
||||
function createResolveToolsParams(params?: {
|
||||
context?: ReturnType<typeof createContext> & Record<string, unknown>;
|
||||
toolAllowlist?: readonly string[];
|
||||
existingToolNames?: Set<string>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -65,7 +67,7 @@ function createResolveToolsParams(params?: {
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
}) {
|
||||
return {
|
||||
context: createContext() as never,
|
||||
context: (params?.context ?? createContext()) as never,
|
||||
...(params?.toolAllowlist ? { toolAllowlist: [...params.toolAllowlist] } : {}),
|
||||
...(params?.existingToolNames ? { existingToolNames: params.existingToolNames } : {}),
|
||||
...(params?.env ? { env: params.env } : {}),
|
||||
@@ -360,7 +362,8 @@ function expectConflictingCoreNameResolution(params: {
|
||||
|
||||
describe("resolvePluginTools optional tools", () => {
|
||||
beforeAll(async () => {
|
||||
({ buildPluginToolMetadataKey, resolvePluginTools } = await import("./tools.js"));
|
||||
({ buildPluginToolMetadataKey, resetPluginToolFactoryCache, resolvePluginTools } =
|
||||
await import("./tools.js"));
|
||||
({ pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } =
|
||||
await import("./runtime.js"));
|
||||
({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } =
|
||||
@@ -380,11 +383,13 @@ describe("resolvePluginTools optional tools", () => {
|
||||
}));
|
||||
resetPluginRuntimeStateForTest?.();
|
||||
clearCurrentPluginMetadataSnapshot?.();
|
||||
resetPluginToolFactoryCache?.();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest?.();
|
||||
clearCurrentPluginMetadataSnapshot?.();
|
||||
resetPluginToolFactoryCache?.();
|
||||
setLoggerOverride(null);
|
||||
loggingState.rawConsole = null;
|
||||
resetLogger();
|
||||
@@ -812,6 +817,163 @@ describe("resolvePluginTools optional tools", () => {
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches plugin tool factory results for equivalent request context", () => {
|
||||
const factory = vi.fn(() => makeTool("cached_tool"));
|
||||
setRegistry([
|
||||
{
|
||||
pluginId: "cache-test",
|
||||
optional: false,
|
||||
source: "/tmp/cache-test.js",
|
||||
names: ["cached_tool"],
|
||||
factory,
|
||||
},
|
||||
]);
|
||||
|
||||
const first = resolvePluginTools(createResolveToolsParams({ context: createContext() }));
|
||||
const second = resolvePluginTools(createResolveToolsParams({ context: createContext() }));
|
||||
|
||||
expectResolvedToolNames(first, ["cached_tool"]);
|
||||
expectResolvedToolNames(second, ["cached_tool"]);
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(second[0]).toBe(first[0]);
|
||||
});
|
||||
|
||||
it("does not reuse plugin tool factory results across sandbox context changes", () => {
|
||||
const factory = vi.fn((rawCtx: unknown) => {
|
||||
const ctx = rawCtx as { sandboxed?: boolean };
|
||||
return ctx.sandboxed ? null : makeTool("sandbox_sensitive_tool");
|
||||
});
|
||||
setRegistry([
|
||||
{
|
||||
pluginId: "sandbox-sensitive",
|
||||
optional: false,
|
||||
source: "/tmp/sandbox-sensitive.js",
|
||||
names: ["sandbox_sensitive_tool"],
|
||||
factory,
|
||||
},
|
||||
]);
|
||||
|
||||
const hostTools = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...createContext(), sandboxed: false },
|
||||
}),
|
||||
);
|
||||
const sandboxedTools = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...createContext(), sandboxed: true },
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(hostTools, ["sandbox_sensitive_tool"]);
|
||||
expect(sandboxedTools).toEqual([]);
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not reuse plugin tool factory results across runtime config changes", () => {
|
||||
const firstRuntimeConfig = {
|
||||
...createContext().config,
|
||||
plugins: { ...createContext().config.plugins, allow: ["runtime_sensitive_tool"] },
|
||||
};
|
||||
const secondRuntimeConfig = {
|
||||
...createContext().config,
|
||||
plugins: { ...createContext().config.plugins, allow: ["runtime_sensitive_next_tool"] },
|
||||
};
|
||||
const factory = vi.fn((rawCtx: unknown) => {
|
||||
const ctx = rawCtx as { runtimeConfig?: { plugins?: { allow?: string[] } } };
|
||||
return makeTool(ctx.runtimeConfig?.plugins?.allow?.[0] ?? "runtime_missing_tool");
|
||||
});
|
||||
setRegistry([
|
||||
{
|
||||
pluginId: "runtime-sensitive",
|
||||
optional: false,
|
||||
source: "/tmp/runtime-sensitive.js",
|
||||
names: ["runtime_sensitive_tool", "runtime_sensitive_next_tool"],
|
||||
factory,
|
||||
},
|
||||
]);
|
||||
|
||||
const first = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...createContext(), runtimeConfig: firstRuntimeConfig as never },
|
||||
}),
|
||||
);
|
||||
const second = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...createContext(), runtimeConfig: secondRuntimeConfig as never },
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(first, ["runtime_sensitive_tool"]);
|
||||
expectResolvedToolNames(second, ["runtime_sensitive_next_tool"]);
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reuses plugin tool factory results when only runtime config getter identity changes", () => {
|
||||
const runtimeConfig = {
|
||||
...createContext().config,
|
||||
plugins: { ...createContext().config.plugins, allow: ["getter_sensitive_tool"] },
|
||||
};
|
||||
const factory = vi.fn((rawCtx: unknown) => {
|
||||
const ctx = rawCtx as { getRuntimeConfig?: () => { plugins?: { allow?: string[] } } };
|
||||
return makeTool(ctx.getRuntimeConfig?.()?.plugins?.allow?.[0] ?? "getter_missing_tool");
|
||||
});
|
||||
setRegistry([
|
||||
{
|
||||
pluginId: "getter-sensitive",
|
||||
optional: false,
|
||||
source: "/tmp/getter-sensitive.js",
|
||||
names: ["getter_sensitive_tool"],
|
||||
factory,
|
||||
},
|
||||
]);
|
||||
|
||||
const context = createContext();
|
||||
const first = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...context, getRuntimeConfig: () => runtimeConfig as never },
|
||||
}),
|
||||
);
|
||||
const second = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...context, getRuntimeConfig: () => runtimeConfig as never },
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(first, ["getter_sensitive_tool"]);
|
||||
expectResolvedToolNames(second, ["getter_sensitive_tool"]);
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reads live runtime config once per plugin tool resolution for cache keys", () => {
|
||||
const runtimeConfig = createContext().config;
|
||||
const getRuntimeConfig = vi.fn(() => runtimeConfig);
|
||||
setRegistry([
|
||||
{
|
||||
pluginId: "getter-a",
|
||||
optional: false,
|
||||
source: "/tmp/getter-a.js",
|
||||
names: ["getter_a_tool"],
|
||||
factory: () => makeTool("getter_a_tool"),
|
||||
},
|
||||
{
|
||||
pluginId: "getter-b",
|
||||
optional: false,
|
||||
source: "/tmp/getter-b.js",
|
||||
names: ["getter_b_tool"],
|
||||
factory: () => makeTool("getter_b_tool"),
|
||||
},
|
||||
]);
|
||||
|
||||
const tools = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...createContext(), getRuntimeConfig: getRuntimeConfig as never },
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(tools, ["getter_a_tool", "getter_b_tool"]);
|
||||
expect(getRuntimeConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips factory-returned tools outside the manifest tool contract", () => {
|
||||
const registry = setRegistry([
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeToolName } from "../agents/tool-policy.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js";
|
||||
import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js";
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
resolvePluginRuntimeLoadContext,
|
||||
} from "./runtime/load-context.js";
|
||||
import { findUndeclaredPluginToolNames } from "./tool-contracts.js";
|
||||
import type { OpenClawPluginToolContext } from "./types.js";
|
||||
import type { OpenClawPluginToolContext, OpenClawPluginToolFactory } from "./types.js";
|
||||
|
||||
export type PluginToolMeta = {
|
||||
pluginId: string;
|
||||
@@ -42,9 +43,106 @@ const log = createSubsystemLogger("plugins/tools");
|
||||
const PLUGIN_TOOL_FACTORY_WARN_TOTAL_MS = 5_000;
|
||||
const PLUGIN_TOOL_FACTORY_WARN_FACTORY_MS = 1_000;
|
||||
const PLUGIN_TOOL_FACTORY_SUMMARY_LIMIT = 20;
|
||||
const PLUGIN_TOOL_FACTORY_CACHE_LIMIT_PER_FACTORY = 64;
|
||||
|
||||
type PluginToolFactoryResult = AnyAgentTool | AnyAgentTool[] | null | undefined;
|
||||
|
||||
let pluginToolFactoryCache = new WeakMap<
|
||||
OpenClawPluginToolFactory,
|
||||
Map<string, PluginToolFactoryResult>
|
||||
>();
|
||||
let pluginToolFactoryCacheObjectIds = new WeakMap<object, number>();
|
||||
let nextPluginToolFactoryCacheObjectId = 1;
|
||||
|
||||
const pluginToolMeta = new WeakMap<AnyAgentTool, PluginToolMeta>();
|
||||
|
||||
export function resetPluginToolFactoryCache(): void {
|
||||
pluginToolFactoryCache = new WeakMap();
|
||||
pluginToolFactoryCacheObjectIds = new WeakMap();
|
||||
nextPluginToolFactoryCacheObjectId = 1;
|
||||
}
|
||||
|
||||
function getPluginToolFactoryCacheObjectId(value: object | null | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const existing = pluginToolFactoryCacheObjectIds.get(value);
|
||||
if (existing !== undefined) {
|
||||
return existing;
|
||||
}
|
||||
const next = nextPluginToolFactoryCacheObjectId++;
|
||||
pluginToolFactoryCacheObjectIds.set(value, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getPluginToolFactoryConfigCacheKey(
|
||||
value: PluginLoadOptions["config"] | null | undefined,
|
||||
): string | number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return resolveRuntimeConfigCacheKey(value);
|
||||
} catch {
|
||||
return getPluginToolFactoryCacheObjectId(value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPluginToolFactoryCacheKey(params: {
|
||||
ctx: OpenClawPluginToolContext;
|
||||
currentRuntimeConfig?: PluginLoadOptions["config"] | null;
|
||||
}): string {
|
||||
const { ctx } = params;
|
||||
return JSON.stringify({
|
||||
config: getPluginToolFactoryConfigCacheKey(ctx.config),
|
||||
runtimeConfig: getPluginToolFactoryConfigCacheKey(ctx.runtimeConfig),
|
||||
currentRuntimeConfig: getPluginToolFactoryConfigCacheKey(params.currentRuntimeConfig),
|
||||
fsPolicy: ctx.fsPolicy ?? null,
|
||||
workspaceDir: ctx.workspaceDir ?? null,
|
||||
agentDir: ctx.agentDir ?? null,
|
||||
agentId: ctx.agentId ?? null,
|
||||
sessionKey: ctx.sessionKey ?? null,
|
||||
sessionId: ctx.sessionId ?? null,
|
||||
browser: ctx.browser ?? null,
|
||||
messageChannel: ctx.messageChannel ?? null,
|
||||
agentAccountId: ctx.agentAccountId ?? null,
|
||||
deliveryContext: ctx.deliveryContext ?? null,
|
||||
requesterSenderId: ctx.requesterSenderId ?? null,
|
||||
senderIsOwner: ctx.senderIsOwner ?? null,
|
||||
sandboxed: ctx.sandboxed ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function readCachedPluginToolFactoryResult(params: {
|
||||
factory: OpenClawPluginToolFactory;
|
||||
cacheKey: string;
|
||||
}): { hit: boolean; result: PluginToolFactoryResult } {
|
||||
const cache = pluginToolFactoryCache.get(params.factory);
|
||||
if (!cache || !cache.has(params.cacheKey)) {
|
||||
return { hit: false, result: undefined };
|
||||
}
|
||||
return { hit: true, result: cache.get(params.cacheKey) };
|
||||
}
|
||||
|
||||
function writeCachedPluginToolFactoryResult(params: {
|
||||
factory: OpenClawPluginToolFactory;
|
||||
cacheKey: string;
|
||||
result: PluginToolFactoryResult;
|
||||
}): void {
|
||||
let cache = pluginToolFactoryCache.get(params.factory);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
pluginToolFactoryCache.set(params.factory, cache);
|
||||
}
|
||||
if (!cache.has(params.cacheKey) && cache.size >= PLUGIN_TOOL_FACTORY_CACHE_LIMIT_PER_FACTORY) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
cache.set(params.cacheKey, params.result);
|
||||
}
|
||||
|
||||
export function setPluginToolMeta(tool: AnyAgentTool, meta: PluginToolMeta): void {
|
||||
pluginToolMeta.set(tool, meta);
|
||||
}
|
||||
@@ -394,6 +492,15 @@ export function resolvePluginTools(params: {
|
||||
const blockedPlugins = new Set<string>();
|
||||
const factoryTimingStartedAt = Date.now();
|
||||
const factoryTimings: PluginToolFactoryTiming[] = [];
|
||||
let currentRuntimeConfigForFactoryCache: PluginLoadOptions["config"] | null | undefined =
|
||||
params.context.runtimeConfig;
|
||||
if (currentRuntimeConfigForFactoryCache === undefined && params.context.getRuntimeConfig) {
|
||||
try {
|
||||
currentRuntimeConfigForFactoryCache = params.context.getRuntimeConfig();
|
||||
} catch {
|
||||
currentRuntimeConfigForFactoryCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of registry.tools) {
|
||||
if (!scopedPluginIds.has(entry.pluginId)) {
|
||||
@@ -428,26 +535,55 @@ export function resolvePluginTools(params: {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let resolved: AnyAgentTool | AnyAgentTool[] | null | undefined = null;
|
||||
let resolved: PluginToolFactoryResult = null;
|
||||
let factoryFailed = false;
|
||||
const factoryStartedAt = Date.now();
|
||||
try {
|
||||
resolved = entry.factory(params.context);
|
||||
} catch (err) {
|
||||
factoryFailed = true;
|
||||
context.logger.error(`plugin tool failed (${entry.pluginId}): ${String(err)}`);
|
||||
} finally {
|
||||
const factoryEndedAt = Date.now();
|
||||
const result = describePluginToolFactoryResult(resolved, factoryFailed);
|
||||
const factoryCacheKey = buildPluginToolFactoryCacheKey({
|
||||
ctx: params.context,
|
||||
currentRuntimeConfig: currentRuntimeConfigForFactoryCache,
|
||||
});
|
||||
const cached = readCachedPluginToolFactoryResult({
|
||||
factory: entry.factory,
|
||||
cacheKey: factoryCacheKey,
|
||||
});
|
||||
if (cached.hit) {
|
||||
resolved = cached.result;
|
||||
const result = describePluginToolFactoryResult(resolved, false);
|
||||
factoryTimings.push({
|
||||
pluginId: entry.pluginId,
|
||||
names: declaredNames,
|
||||
durationMs: toElapsedMs(factoryEndedAt - factoryStartedAt),
|
||||
elapsedMs: toElapsedMs(factoryEndedAt - factoryTimingStartedAt),
|
||||
durationMs: 0,
|
||||
elapsedMs: toElapsedMs(Date.now() - factoryTimingStartedAt),
|
||||
result: result.result,
|
||||
resultCount: result.resultCount,
|
||||
optional: entry.optional,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
resolved = entry.factory(params.context);
|
||||
} catch (err) {
|
||||
factoryFailed = true;
|
||||
context.logger.error(`plugin tool failed (${entry.pluginId}): ${String(err)}`);
|
||||
} finally {
|
||||
const factoryEndedAt = Date.now();
|
||||
const result = describePluginToolFactoryResult(resolved, factoryFailed);
|
||||
factoryTimings.push({
|
||||
pluginId: entry.pluginId,
|
||||
names: declaredNames,
|
||||
durationMs: toElapsedMs(factoryEndedAt - factoryStartedAt),
|
||||
elapsedMs: toElapsedMs(factoryEndedAt - factoryTimingStartedAt),
|
||||
result: result.result,
|
||||
resultCount: result.resultCount,
|
||||
optional: entry.optional,
|
||||
});
|
||||
if (!factoryFailed) {
|
||||
writeCachedPluginToolFactoryResult({
|
||||
factory: entry.factory,
|
||||
cacheKey: factoryCacheKey,
|
||||
result: resolved,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (factoryFailed) {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user