refactor: cache provider tool runtimes

This commit is contained in:
Peter Steinberger
2026-04-18 18:57:29 +01:00
parent c39314c14a
commit a7e029fde9
12 changed files with 179 additions and 18 deletions

View File

@@ -6,6 +6,16 @@ import type {
import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract"; import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract";
const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey"; const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey";
type BraveWebSearchRuntime = typeof import("./brave-web-search-provider.runtime.js");
let braveWebSearchRuntimePromise: Promise<BraveWebSearchRuntime> | undefined;
function loadBraveWebSearchRuntime(): Promise<BraveWebSearchRuntime> {
braveWebSearchRuntimePromise ??= import("./brave-web-search-provider.runtime.js");
return braveWebSearchRuntimePromise;
}
const BraveSearchSchema = { const BraveSearchSchema = {
type: "object", type: "object",
properties: { properties: {
@@ -111,7 +121,7 @@ function createBraveToolDefinition(
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.", : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: BraveSearchSchema, parameters: BraveSearchSchema,
execute: async (args) => { execute: async (args) => {
const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js"); const { executeBraveSearch } = await loadBraveWebSearchRuntime();
return await executeBraveSearch(args, searchConfig); return await executeBraveSearch(args, searchConfig);
}, },
}; };

View File

@@ -1,8 +1,18 @@
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
import { import {
createWebSearchProviderContractFields, createWebSearchProviderContractFields,
type WebSearchProviderPlugin, type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract"; } from "openclaw/plugin-sdk/provider-web-search-contract";
type DuckDuckGoClientModule = typeof import("./ddg-client.js");
let duckDuckGoClientModulePromise: Promise<DuckDuckGoClientModule> | undefined;
function loadDuckDuckGoClientModule(): Promise<DuckDuckGoClientModule> {
duckDuckGoClientModulePromise ??= import("./ddg-client.js");
return duckDuckGoClientModulePromise;
}
const DuckDuckGoSearchSchema = { const DuckDuckGoSearchSchema = {
type: "object", type: "object",
properties: { properties: {
@@ -47,10 +57,7 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
"Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.", "Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.",
parameters: DuckDuckGoSearchSchema, parameters: DuckDuckGoSearchSchema,
execute: async (args) => { execute: async (args) => {
const [{ runDuckDuckGoSearch }, { readNumberParam, readStringParam }] = await Promise.all([ const { runDuckDuckGoSearch } = await loadDuckDuckGoClientModule();
import("./ddg-client.js"),
import("openclaw/plugin-sdk/provider-web-search"),
]);
return await runDuckDuckGoSearch({ return await runDuckDuckGoSearch({
config: ctx.config, config: ctx.config,
query: readStringParam(args, "query", { required: true }), query: readStringParam(args, "query", { required: true }),

View File

@@ -8,6 +8,15 @@ const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "i
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const; const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100; const EXA_MAX_SEARCH_COUNT = 100;
type ExaWebSearchRuntime = typeof import("./exa-web-search-provider.runtime.js");
let exaWebSearchRuntimePromise: Promise<ExaWebSearchRuntime> | undefined;
function loadExaWebSearchRuntime(): Promise<ExaWebSearchRuntime> {
exaWebSearchRuntimePromise ??= import("./exa-web-search-provider.runtime.js");
return exaWebSearchRuntimePromise;
}
const ExaSearchSchema = { const ExaSearchSchema = {
type: "object", type: "object",
properties: { properties: {
@@ -81,8 +90,7 @@ export function createExaWebSearchProvider(): WebSearchProviderPlugin {
"Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.", "Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.",
parameters: ExaSearchSchema, parameters: ExaSearchSchema,
execute: async (args) => { execute: async (args) => {
const { executeExaWebSearchProviderTool } = const { executeExaWebSearchProviderTool } = await loadExaWebSearchRuntime();
await import("./exa-web-search-provider.runtime.js");
return await executeExaWebSearchProviderTool(ctx, args); return await executeExaWebSearchProviderTool(ctx, args);
}, },
}), }),

View File

@@ -4,6 +4,16 @@ import {
} from "openclaw/plugin-sdk/provider-web-search-contract"; } from "openclaw/plugin-sdk/provider-web-search-contract";
const FIRECRAWL_CREDENTIAL_PATH = "plugins.entries.firecrawl.config.webSearch.apiKey"; const FIRECRAWL_CREDENTIAL_PATH = "plugins.entries.firecrawl.config.webSearch.apiKey";
type FirecrawlClientModule = typeof import("./firecrawl-client.js");
let firecrawlClientModulePromise: Promise<FirecrawlClientModule> | undefined;
function loadFirecrawlClientModule(): Promise<FirecrawlClientModule> {
firecrawlClientModulePromise ??= import("./firecrawl-client.js");
return firecrawlClientModulePromise;
}
const GenericFirecrawlSearchSchema = { const GenericFirecrawlSearchSchema = {
type: "object", type: "object",
properties: { properties: {
@@ -42,7 +52,7 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
"Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.", "Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.",
parameters: GenericFirecrawlSearchSchema, parameters: GenericFirecrawlSearchSchema,
execute: async (args) => { execute: async (args) => {
const { runFirecrawlSearch } = await import("./firecrawl-client.js"); const { runFirecrawlSearch } = await loadFirecrawlClientModule();
return await runFirecrawlSearch({ return await runFirecrawlSearch({
cfg: ctx.config, cfg: ctx.config,
query: typeof args.query === "string" ? args.query : "", query: typeof args.query === "string" ? args.query : "",

View File

@@ -8,6 +8,16 @@ import {
import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js"; import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js";
const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey"; const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey";
type GeminiWebSearchRuntime = typeof import("./gemini-web-search-provider.runtime.js");
let geminiWebSearchRuntimePromise: Promise<GeminiWebSearchRuntime> | undefined;
function loadGeminiWebSearchRuntime(): Promise<GeminiWebSearchRuntime> {
geminiWebSearchRuntimePromise ??= import("./gemini-web-search-provider.runtime.js");
return geminiWebSearchRuntimePromise;
}
const GEMINI_TOOL_PARAMETERS = { const GEMINI_TOOL_PARAMETERS = {
type: "object", type: "object",
properties: { properties: {
@@ -35,7 +45,7 @@ function createGeminiToolDefinition(
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.", "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
parameters: GEMINI_TOOL_PARAMETERS, parameters: GEMINI_TOOL_PARAMETERS,
execute: async (args) => { execute: async (args) => {
const { executeGeminiSearch } = await import("./gemini-web-search-provider.runtime.js"); const { executeGeminiSearch } = await loadGeminiWebSearchRuntime();
return await executeGeminiSearch(args, searchConfig); return await executeGeminiSearch(args, searchConfig);
}, },
}; };

View File

@@ -6,6 +6,15 @@ import {
const MINIMAX_CREDENTIAL_PATH = "plugins.entries.minimax.config.webSearch.apiKey"; const MINIMAX_CREDENTIAL_PATH = "plugins.entries.minimax.config.webSearch.apiKey";
const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const; const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const;
type MiniMaxWebSearchRuntime = typeof import("./minimax-web-search-provider.runtime.js");
let miniMaxWebSearchRuntimePromise: Promise<MiniMaxWebSearchRuntime> | undefined;
function loadMiniMaxWebSearchRuntime(): Promise<MiniMaxWebSearchRuntime> {
miniMaxWebSearchRuntimePromise ??= import("./minimax-web-search-provider.runtime.js");
return miniMaxWebSearchRuntimePromise;
}
const MiniMaxSearchSchema = { const MiniMaxSearchSchema = {
type: "object", type: "object",
properties: { properties: {
@@ -41,8 +50,7 @@ export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin {
"Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.", "Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.",
parameters: MiniMaxSearchSchema, parameters: MiniMaxSearchSchema,
execute: async (args) => { execute: async (args) => {
const { executeMiniMaxWebSearchProviderTool } = const { executeMiniMaxWebSearchProviderTool } = await loadMiniMaxWebSearchRuntime();
await import("./minimax-web-search-provider.runtime.js");
return await executeMiniMaxWebSearchProviderTool(ctx, args); return await executeMiniMaxWebSearchProviderTool(ctx, args);
}, },
}), }),

View File

@@ -9,6 +9,15 @@ import { resolvePerplexityRuntimeTransport } from "./perplexity-web-search-provi
const PERPLEXITY_CREDENTIAL_PATH = "plugins.entries.perplexity.config.webSearch.apiKey"; const PERPLEXITY_CREDENTIAL_PATH = "plugins.entries.perplexity.config.webSearch.apiKey";
type PerplexityWebSearchRuntime = typeof import("./perplexity-web-search-provider.runtime.js");
let perplexityWebSearchRuntimePromise: Promise<PerplexityWebSearchRuntime> | undefined;
function loadPerplexityWebSearchRuntime(): Promise<PerplexityWebSearchRuntime> {
perplexityWebSearchRuntimePromise ??= import("./perplexity-web-search-provider.runtime.js");
return perplexityWebSearchRuntimePromise;
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
@@ -95,8 +104,7 @@ function createPerplexityToolDefinition(
: "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.", : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.",
parameters: createPerplexityParameters(schemaTransport), parameters: createPerplexityParameters(schemaTransport),
execute: async (args) => { execute: async (args) => {
const { executePerplexitySearch } = const { executePerplexitySearch } = await loadPerplexityWebSearchRuntime();
await import("./perplexity-web-search-provider.runtime.js");
return await executePerplexitySearch(args, searchConfig); return await executePerplexitySearch(args, searchConfig);
}, },
}; };

View File

@@ -6,6 +6,15 @@ import {
const SEARXNG_CREDENTIAL_PATH = "plugins.entries.searxng.config.webSearch.baseUrl"; const SEARXNG_CREDENTIAL_PATH = "plugins.entries.searxng.config.webSearch.baseUrl";
type SearxngClientModule = typeof import("./searxng-client.js");
let searxngClientModulePromise: Promise<SearxngClientModule> | undefined;
function loadSearxngClientModule(): Promise<SearxngClientModule> {
searxngClientModulePromise ??= import("./searxng-client.js");
return searxngClientModulePromise;
}
const SearxngSearchSchema = { const SearxngSearchSchema = {
type: "object", type: "object",
properties: { properties: {
@@ -52,7 +61,7 @@ export function createSearxngWebSearchProvider(): WebSearchProviderPlugin {
"Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.", "Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.",
parameters: SearxngSearchSchema, parameters: SearxngSearchSchema,
execute: async (args) => { execute: async (args) => {
const { runSearxngSearch } = await import("./searxng-client.js"); const { runSearxngSearch } = await loadSearxngClientModule();
return await runSearxngSearch({ return await runSearxngSearch({
config: ctx.config, config: ctx.config,
query: readStringParam(args, "query", { required: true }), query: readStringParam(args, "query", { required: true }),

View File

@@ -4,6 +4,16 @@ import {
} from "openclaw/plugin-sdk/provider-web-search-contract"; } from "openclaw/plugin-sdk/provider-web-search-contract";
const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey"; const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey";
type TavilyClientModule = typeof import("./tavily-client.js");
let tavilyClientModulePromise: Promise<TavilyClientModule> | undefined;
function loadTavilyClientModule(): Promise<TavilyClientModule> {
tavilyClientModulePromise ??= import("./tavily-client.js");
return tavilyClientModulePromise;
}
const GenericTavilySearchSchema = { const GenericTavilySearchSchema = {
type: "object", type: "object",
properties: { properties: {
@@ -42,7 +52,7 @@ export function createTavilyWebSearchProvider(): WebSearchProviderPlugin {
"Search the web using Tavily. Returns structured results with snippets. Use tavily_search for Tavily-specific options like search depth, topic filtering, or AI answers.", "Search the web using Tavily. Returns structured results with snippets. Use tavily_search for Tavily-specific options like search depth, topic filtering, or AI answers.",
parameters: GenericTavilySearchSchema, parameters: GenericTavilySearchSchema,
execute: async (args) => { execute: async (args) => {
const { runTavilySearch } = await import("./tavily-client.js"); const { runTavilySearch } = await loadTavilyClientModule();
return await runTavilySearch({ return await runTavilySearch({
cfg: ctx.config, cfg: ctx.config,
query: typeof args.query === "string" ? args.query : "", query: typeof args.query === "string" ? args.query : "",

View File

@@ -24,6 +24,15 @@ import {
} from "./x-search-tool-shared.js"; } from "./x-search-tool-shared.js";
const PROVIDER_ID = "xai"; const PROVIDER_ID = "xai";
type CodeExecutionModule = typeof import("./code-execution.js");
let codeExecutionModulePromise: Promise<CodeExecutionModule> | undefined;
function loadCodeExecutionModule(): Promise<CodeExecutionModule> {
codeExecutionModulePromise ??= import("./code-execution.js");
return codeExecutionModulePromise;
}
function hasResolvableXaiApiKey(config: unknown): boolean { function hasResolvableXaiApiKey(config: unknown): boolean {
return Boolean( return Boolean(
resolveFallbackXaiAuth(config as never)?.apiKey || readProviderEnvValue(["XAI_API_KEY"]), resolveFallbackXaiAuth(config as never)?.apiKey || readProviderEnvValue(["XAI_API_KEY"]),
@@ -89,7 +98,7 @@ function createLazyCodeExecutionTool(ctx: {
}), }),
}), }),
execute: async (toolCallId: string, args: Record<string, unknown>) => { execute: async (toolCallId: string, args: Record<string, unknown>) => {
const { createCodeExecutionTool } = await import("./code-execution.js"); const { createCodeExecutionTool } = await loadCodeExecutionModule();
const tool = createCodeExecutionTool({ const tool = createCodeExecutionTool({
config: ctx.config as never, config: ctx.config as never,
runtimeConfig: (ctx.runtimeConfig as never) ?? null, runtimeConfig: (ctx.runtimeConfig as never) ?? null,

View File

@@ -40,6 +40,26 @@ function isTypeOnlyImportDeclaration(node) {
); );
} }
function readDeclarationName(node) {
if (
(ts.isFunctionDeclaration(node) ||
ts.isMethodDeclaration(node) ||
ts.isVariableDeclaration(node)) &&
node.name &&
ts.isIdentifier(node.name)
) {
return node.name.text;
}
if (ts.isPropertyAssignment(node)) {
if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) {
return node.name.text;
}
}
return null;
}
function isIgnoredTestHelperContent(content) { function isIgnoredTestHelperContent(content) {
return /\bfrom\s+["']vitest["']/.test(content) || /\bfrom\s+["']@vitest\//.test(content); return /\bfrom\s+["']vitest["']/.test(content) || /\bfrom\s+["']@vitest\//.test(content);
} }
@@ -61,6 +81,8 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const staticRuntimeImports = new Map(); const staticRuntimeImports = new Map();
const dynamicImports = new Map(); const dynamicImports = new Map();
const directExecuteImports = [];
const declarationStack = [];
const addLine = (map, specifier, line) => { const addLine = (map, specifier, line) => {
const lines = map.get(specifier) ?? []; const lines = map.get(specifier) ?? [];
@@ -69,6 +91,11 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
}; };
const visit = (node) => { const visit = (node) => {
const declarationName = readDeclarationName(node);
if (declarationName) {
declarationStack.push(declarationName);
}
if ( if (
ts.isImportDeclaration(node) && ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) && ts.isStringLiteral(node.moduleSpecifier) &&
@@ -84,16 +111,26 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
) { ) {
const specifier = readStringLiteral(node.arguments[0]); const specifier = readStringLiteral(node.arguments[0]);
if (specifier) { if (specifier) {
addLine(dynamicImports, specifier, toLine(sourceFile, node)); const line = toLine(sourceFile, node);
addLine(dynamicImports, specifier, line);
if (declarationStack.includes("execute")) {
directExecuteImports.push({
line,
reason: `direct dynamic import of "${specifier}" inside execute path; move it behind a cached loader`,
});
}
} }
} }
ts.forEachChild(node, visit); ts.forEachChild(node, visit);
if (declarationName) {
declarationStack.pop();
}
}; };
visit(sourceFile); visit(sourceFile);
const advisories = []; const advisories = [...directExecuteImports];
for (const [specifier, dynamicLines] of dynamicImports) { for (const [specifier, dynamicLines] of dynamicImports) {
const staticLines = staticRuntimeImports.get(specifier); const staticLines = staticRuntimeImports.get(specifier);
if (staticLines?.length) { if (staticLines?.length) {

View File

@@ -54,4 +54,39 @@ describe("check-dynamic-import-warts", () => {
`; `;
expect(findDynamicImportAdvisories(source)).toEqual([]); expect(findDynamicImportAdvisories(source)).toEqual([]);
}); });
it("flags direct dynamic imports inside execute paths", () => {
const source = `
export function createTool() {
return {
execute: async () => {
return await import("./runtime.js");
},
};
}
`;
expect(findDynamicImportAdvisories(source)).toEqual([
{
line: 5,
reason:
'direct dynamic import of "./runtime.js" inside execute path; move it behind a cached loader',
},
]);
});
it("allows execute paths that call cached loaders", () => {
const source = `
let runtimePromise: Promise<typeof import("./runtime.js")> | undefined;
function loadRuntime() {
runtimePromise ??= import("./runtime.js");
return runtimePromise;
}
export function createTool() {
return {
execute: async () => await loadRuntime(),
};
}
`;
expect(findDynamicImportAdvisories(source)).toEqual([]);
});
}); });