Skip to content

Commit 40f2bf3

Browse files
committed
fix: cache plugin tool factories by context
1 parent 335f870 commit 40f2bf3

4 files changed

Lines changed: 319 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ Docs: https://docs.openclaw.ai
131131
- 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.
132132
- 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.
133133
- Plugins/ClawHub: install package runtime dependencies for archive-backed plugin installs, so ClawHub packages such as WhatsApp load declared dependencies after download. Thanks @vincentkoc.
134+
- 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.
134135
- 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.
135136
- 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.
136137
- 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.

docs/tools/plugin.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,12 @@ including plugin id, declared tool names, result shape, and whether the tool is
368368
optional. Slow lines are promoted to warnings when a single factory takes at
369369
least 1s or total plugin tool factory prep takes at least 5s.
370370

371+
OpenClaw caches successful plugin tool factory results for repeated resolutions
372+
with the same effective request context. The cache key includes the effective
373+
runtime config, workspace, agent/session ids, sandbox policy, browser settings,
374+
delivery context, requester identity, and ownership state, so factories that
375+
depend on those trusted fields are re-run when the context changes.
376+
371377
If one plugin dominates the timing, inspect its runtime registrations:
372378

373379
```bash

src/plugins/tools.optional.test.ts

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
2626

2727
let resolvePluginTools: typeof import("./tools.js").resolvePluginTools;
2828
let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey;
29+
let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache;
2930
let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry;
3031
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
3132
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
@@ -58,14 +59,15 @@ function createContext() {
5859
}
5960

6061
function createResolveToolsParams(params?: {
62+
context?: ReturnType<typeof createContext> & Record<string, unknown>;
6163
toolAllowlist?: readonly string[];
6264
existingToolNames?: Set<string>;
6365
env?: NodeJS.ProcessEnv;
6466
suppressNameConflicts?: boolean;
6567
allowGatewaySubagentBinding?: boolean;
6668
}) {
6769
return {
68-
context: createContext() as never,
70+
context: (params?.context ?? createContext()) as never,
6971
...(params?.toolAllowlist ? { toolAllowlist: [...params.toolAllowlist] } : {}),
7072
...(params?.existingToolNames ? { existingToolNames: params.existingToolNames } : {}),
7173
...(params?.env ? { env: params.env } : {}),
@@ -360,7 +362,8 @@ function expectConflictingCoreNameResolution(params: {
360362

361363
describe("resolvePluginTools optional tools", () => {
362364
beforeAll(async () => {
363-
({ buildPluginToolMetadataKey, resolvePluginTools } = await import("./tools.js"));
365+
({ buildPluginToolMetadataKey, resetPluginToolFactoryCache, resolvePluginTools } =
366+
await import("./tools.js"));
364367
({ pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } =
365368
await import("./runtime.js"));
366369
({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } =
@@ -380,11 +383,13 @@ describe("resolvePluginTools optional tools", () => {
380383
}));
381384
resetPluginRuntimeStateForTest?.();
382385
clearCurrentPluginMetadataSnapshot?.();
386+
resetPluginToolFactoryCache?.();
383387
});
384388

385389
afterEach(() => {
386390
resetPluginRuntimeStateForTest?.();
387391
clearCurrentPluginMetadataSnapshot?.();
392+
resetPluginToolFactoryCache?.();
388393
setLoggerOverride(null);
389394
loggingState.rawConsole = null;
390395
resetLogger();
@@ -812,6 +817,163 @@ describe("resolvePluginTools optional tools", () => {
812817
expect(warnSpy).not.toHaveBeenCalled();
813818
});
814819

820+
it("caches plugin tool factory results for equivalent request context", () => {
821+
const factory = vi.fn(() => makeTool("cached_tool"));
822+
setRegistry([
823+
{
824+
pluginId: "cache-test",
825+
optional: false,
826+
source: "/tmp/cache-test.js",
827+
names: ["cached_tool"],
828+
factory,
829+
},
830+
]);
831+
832+
const first = resolvePluginTools(createResolveToolsParams({ context: createContext() }));
833+
const second = resolvePluginTools(createResolveToolsParams({ context: createContext() }));
834+
835+
expectResolvedToolNames(first, ["cached_tool"]);
836+
expectResolvedToolNames(second, ["cached_tool"]);
837+
expect(factory).toHaveBeenCalledTimes(1);
838+
expect(second[0]).toBe(first[0]);
839+
});
840+
841+
it("does not reuse plugin tool factory results across sandbox context changes", () => {
842+
const factory = vi.fn((rawCtx: unknown) => {
843+
const ctx = rawCtx as { sandboxed?: boolean };
844+
return ctx.sandboxed ? null : makeTool("sandbox_sensitive_tool");
845+
});
846+
setRegistry([
847+
{
848+
pluginId: "sandbox-sensitive",
849+
optional: false,
850+
source: "/tmp/sandbox-sensitive.js",
851+
names: ["sandbox_sensitive_tool"],
852+
factory,
853+
},
854+
]);
855+
856+
const hostTools = resolvePluginTools(
857+
createResolveToolsParams({
858+
context: { ...createContext(), sandboxed: false },
859+
}),
860+
);
861+
const sandboxedTools = resolvePluginTools(
862+
createResolveToolsParams({
863+
context: { ...createContext(), sandboxed: true },
864+
}),
865+
);
866+
867+
expectResolvedToolNames(hostTools, ["sandbox_sensitive_tool"]);
868+
expect(sandboxedTools).toEqual([]);
869+
expect(factory).toHaveBeenCalledTimes(2);
870+
});
871+
872+
it("does not reuse plugin tool factory results across runtime config changes", () => {
873+
const firstRuntimeConfig = {
874+
...createContext().config,
875+
plugins: { ...createContext().config.plugins, allow: ["runtime_sensitive_tool"] },
876+
};
877+
const secondRuntimeConfig = {
878+
...createContext().config,
879+
plugins: { ...createContext().config.plugins, allow: ["runtime_sensitive_next_tool"] },
880+
};
881+
const factory = vi.fn((rawCtx: unknown) => {
882+
const ctx = rawCtx as { runtimeConfig?: { plugins?: { allow?: string[] } } };
883+
return makeTool(ctx.runtimeConfig?.plugins?.allow?.[0] ?? "runtime_missing_tool");
884+
});
885+
setRegistry([
886+
{
887+
pluginId: "runtime-sensitive",
888+
optional: false,
889+
source: "/tmp/runtime-sensitive.js",
890+
names: ["runtime_sensitive_tool", "runtime_sensitive_next_tool"],
891+
factory,
892+
},
893+
]);
894+
895+
const first = resolvePluginTools(
896+
createResolveToolsParams({
897+
context: { ...createContext(), runtimeConfig: firstRuntimeConfig as never },
898+
}),
899+
);
900+
const second = resolvePluginTools(
901+
createResolveToolsParams({
902+
context: { ...createContext(), runtimeConfig: secondRuntimeConfig as never },
903+
}),
904+
);
905+
906+
expectResolvedToolNames(first, ["runtime_sensitive_tool"]);
907+
expectResolvedToolNames(second, ["runtime_sensitive_next_tool"]);
908+
expect(factory).toHaveBeenCalledTimes(2);
909+
});
910+
911+
it("reuses plugin tool factory results when only runtime config getter identity changes", () => {
912+
const runtimeConfig = {
913+
...createContext().config,
914+
plugins: { ...createContext().config.plugins, allow: ["getter_sensitive_tool"] },
915+
};
916+
const factory = vi.fn((rawCtx: unknown) => {
917+
const ctx = rawCtx as { getRuntimeConfig?: () => { plugins?: { allow?: string[] } } };
918+
return makeTool(ctx.getRuntimeConfig?.()?.plugins?.allow?.[0] ?? "getter_missing_tool");
919+
});
920+
setRegistry([
921+
{
922+
pluginId: "getter-sensitive",
923+
optional: false,
924+
source: "/tmp/getter-sensitive.js",
925+
names: ["getter_sensitive_tool"],
926+
factory,
927+
},
928+
]);
929+
930+
const context = createContext();
931+
const first = resolvePluginTools(
932+
createResolveToolsParams({
933+
context: { ...context, getRuntimeConfig: () => runtimeConfig as never },
934+
}),
935+
);
936+
const second = resolvePluginTools(
937+
createResolveToolsParams({
938+
context: { ...context, getRuntimeConfig: () => runtimeConfig as never },
939+
}),
940+
);
941+
942+
expectResolvedToolNames(first, ["getter_sensitive_tool"]);
943+
expectResolvedToolNames(second, ["getter_sensitive_tool"]);
944+
expect(factory).toHaveBeenCalledTimes(1);
945+
});
946+
947+
it("reads live runtime config once per plugin tool resolution for cache keys", () => {
948+
const runtimeConfig = createContext().config;
949+
const getRuntimeConfig = vi.fn(() => runtimeConfig);
950+
setRegistry([
951+
{
952+
pluginId: "getter-a",
953+
optional: false,
954+
source: "/tmp/getter-a.js",
955+
names: ["getter_a_tool"],
956+
factory: () => makeTool("getter_a_tool"),
957+
},
958+
{
959+
pluginId: "getter-b",
960+
optional: false,
961+
source: "/tmp/getter-b.js",
962+
names: ["getter_b_tool"],
963+
factory: () => makeTool("getter_b_tool"),
964+
},
965+
]);
966+
967+
const tools = resolvePluginTools(
968+
createResolveToolsParams({
969+
context: { ...createContext(), getRuntimeConfig: getRuntimeConfig as never },
970+
}),
971+
);
972+
973+
expectResolvedToolNames(tools, ["getter_a_tool", "getter_b_tool"]);
974+
expect(getRuntimeConfig).toHaveBeenCalledTimes(1);
975+
});
976+
815977
it("skips factory-returned tools outside the manifest tool contract", () => {
816978
const registry = setRegistry([
817979
{

0 commit comments

Comments
 (0)