From a7e029fde9be58835d35f67c2b0de8313ba5f306 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 18:57:29 +0100 Subject: [PATCH] refactor: cache provider tool runtimes --- .../brave/src/brave-web-search-provider.ts | 12 +++++- .../duckduckgo/src/ddg-search-provider.ts | 15 +++++-- extensions/exa/src/exa-web-search-provider.ts | 12 +++++- .../src/firecrawl-search-provider.ts | 12 +++++- .../google/src/gemini-web-search-provider.ts | 12 +++++- .../src/minimax-web-search-provider.ts | 12 +++++- .../src/perplexity-web-search-provider.ts | 12 +++++- .../searxng/src/searxng-search-provider.ts | 11 ++++- .../tavily/src/tavily-search-provider.ts | 12 +++++- extensions/xai/index.ts | 11 ++++- scripts/check-dynamic-import-warts.mjs | 41 ++++++++++++++++++- .../check-dynamic-import-warts.test.ts | 35 ++++++++++++++++ 12 files changed, 179 insertions(+), 18 deletions(-) diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 9fd8140a868..e4f6864cdb5 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -6,6 +6,16 @@ import type { import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract"; const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey"; + +type BraveWebSearchRuntime = typeof import("./brave-web-search-provider.runtime.js"); + +let braveWebSearchRuntimePromise: Promise | undefined; + +function loadBraveWebSearchRuntime(): Promise { + braveWebSearchRuntimePromise ??= import("./brave-web-search-provider.runtime.js"); + return braveWebSearchRuntimePromise; +} + const BraveSearchSchema = { type: "object", 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.", parameters: BraveSearchSchema, execute: async (args) => { - const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js"); + const { executeBraveSearch } = await loadBraveWebSearchRuntime(); return await executeBraveSearch(args, searchConfig); }, }; diff --git a/extensions/duckduckgo/src/ddg-search-provider.ts b/extensions/duckduckgo/src/ddg-search-provider.ts index f6ebd025c51..abb38f0da6d 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.ts @@ -1,8 +1,18 @@ +import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers"; import { createWebSearchProviderContractFields, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search-contract"; +type DuckDuckGoClientModule = typeof import("./ddg-client.js"); + +let duckDuckGoClientModulePromise: Promise | undefined; + +function loadDuckDuckGoClientModule(): Promise { + duckDuckGoClientModulePromise ??= import("./ddg-client.js"); + return duckDuckGoClientModulePromise; +} + const DuckDuckGoSearchSchema = { type: "object", properties: { @@ -47,10 +57,7 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin { "Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.", parameters: DuckDuckGoSearchSchema, execute: async (args) => { - const [{ runDuckDuckGoSearch }, { readNumberParam, readStringParam }] = await Promise.all([ - import("./ddg-client.js"), - import("openclaw/plugin-sdk/provider-web-search"), - ]); + const { runDuckDuckGoSearch } = await loadDuckDuckGoClientModule(); return await runDuckDuckGoSearch({ config: ctx.config, query: readStringParam(args, "query", { required: true }), diff --git a/extensions/exa/src/exa-web-search-provider.ts b/extensions/exa/src/exa-web-search-provider.ts index df32eb605f9..a59d8a3d742 100644 --- a/extensions/exa/src/exa-web-search-provider.ts +++ b/extensions/exa/src/exa-web-search-provider.ts @@ -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_MAX_SEARCH_COUNT = 100; +type ExaWebSearchRuntime = typeof import("./exa-web-search-provider.runtime.js"); + +let exaWebSearchRuntimePromise: Promise | undefined; + +function loadExaWebSearchRuntime(): Promise { + exaWebSearchRuntimePromise ??= import("./exa-web-search-provider.runtime.js"); + return exaWebSearchRuntimePromise; +} + const ExaSearchSchema = { type: "object", 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.", parameters: ExaSearchSchema, execute: async (args) => { - const { executeExaWebSearchProviderTool } = - await import("./exa-web-search-provider.runtime.js"); + const { executeExaWebSearchProviderTool } = await loadExaWebSearchRuntime(); return await executeExaWebSearchProviderTool(ctx, args); }, }), diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts index 3f93fa2c738..973d8074f82 100644 --- a/extensions/firecrawl/src/firecrawl-search-provider.ts +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -4,6 +4,16 @@ import { } from "openclaw/plugin-sdk/provider-web-search-contract"; const FIRECRAWL_CREDENTIAL_PATH = "plugins.entries.firecrawl.config.webSearch.apiKey"; + +type FirecrawlClientModule = typeof import("./firecrawl-client.js"); + +let firecrawlClientModulePromise: Promise | undefined; + +function loadFirecrawlClientModule(): Promise { + firecrawlClientModulePromise ??= import("./firecrawl-client.js"); + return firecrawlClientModulePromise; +} + const GenericFirecrawlSearchSchema = { type: "object", 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.", parameters: GenericFirecrawlSearchSchema, execute: async (args) => { - const { runFirecrawlSearch } = await import("./firecrawl-client.js"); + const { runFirecrawlSearch } = await loadFirecrawlClientModule(); return await runFirecrawlSearch({ cfg: ctx.config, query: typeof args.query === "string" ? args.query : "", diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index 37ca0966d8e..98be12866a5 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -8,6 +8,16 @@ import { import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js"; const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey"; + +type GeminiWebSearchRuntime = typeof import("./gemini-web-search-provider.runtime.js"); + +let geminiWebSearchRuntimePromise: Promise | undefined; + +function loadGeminiWebSearchRuntime(): Promise { + geminiWebSearchRuntimePromise ??= import("./gemini-web-search-provider.runtime.js"); + return geminiWebSearchRuntimePromise; +} + const GEMINI_TOOL_PARAMETERS = { type: "object", 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.", parameters: GEMINI_TOOL_PARAMETERS, execute: async (args) => { - const { executeGeminiSearch } = await import("./gemini-web-search-provider.runtime.js"); + const { executeGeminiSearch } = await loadGeminiWebSearchRuntime(); return await executeGeminiSearch(args, searchConfig); }, }; diff --git a/extensions/minimax/src/minimax-web-search-provider.ts b/extensions/minimax/src/minimax-web-search-provider.ts index 367ef7f1948..c3f0100e5e0 100644 --- a/extensions/minimax/src/minimax-web-search-provider.ts +++ b/extensions/minimax/src/minimax-web-search-provider.ts @@ -6,6 +6,15 @@ import { 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; +type MiniMaxWebSearchRuntime = typeof import("./minimax-web-search-provider.runtime.js"); + +let miniMaxWebSearchRuntimePromise: Promise | undefined; + +function loadMiniMaxWebSearchRuntime(): Promise { + miniMaxWebSearchRuntimePromise ??= import("./minimax-web-search-provider.runtime.js"); + return miniMaxWebSearchRuntimePromise; +} + const MiniMaxSearchSchema = { type: "object", properties: { @@ -41,8 +50,7 @@ export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin { "Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.", parameters: MiniMaxSearchSchema, execute: async (args) => { - const { executeMiniMaxWebSearchProviderTool } = - await import("./minimax-web-search-provider.runtime.js"); + const { executeMiniMaxWebSearchProviderTool } = await loadMiniMaxWebSearchRuntime(); return await executeMiniMaxWebSearchProviderTool(ctx, args); }, }), diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index 718ca1a14af..390c65d608f 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -9,6 +9,15 @@ import { resolvePerplexityRuntimeTransport } from "./perplexity-web-search-provi const PERPLEXITY_CREDENTIAL_PATH = "plugins.entries.perplexity.config.webSearch.apiKey"; +type PerplexityWebSearchRuntime = typeof import("./perplexity-web-search-provider.runtime.js"); + +let perplexityWebSearchRuntimePromise: Promise | undefined; + +function loadPerplexityWebSearchRuntime(): Promise { + perplexityWebSearchRuntimePromise ??= import("./perplexity-web-search-provider.runtime.js"); + return perplexityWebSearchRuntimePromise; +} + function isRecord(value: unknown): value is Record { 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.", parameters: createPerplexityParameters(schemaTransport), execute: async (args) => { - const { executePerplexitySearch } = - await import("./perplexity-web-search-provider.runtime.js"); + const { executePerplexitySearch } = await loadPerplexityWebSearchRuntime(); return await executePerplexitySearch(args, searchConfig); }, }; diff --git a/extensions/searxng/src/searxng-search-provider.ts b/extensions/searxng/src/searxng-search-provider.ts index 708e55ad93f..b68aec488ee 100644 --- a/extensions/searxng/src/searxng-search-provider.ts +++ b/extensions/searxng/src/searxng-search-provider.ts @@ -6,6 +6,15 @@ import { const SEARXNG_CREDENTIAL_PATH = "plugins.entries.searxng.config.webSearch.baseUrl"; +type SearxngClientModule = typeof import("./searxng-client.js"); + +let searxngClientModulePromise: Promise | undefined; + +function loadSearxngClientModule(): Promise { + searxngClientModulePromise ??= import("./searxng-client.js"); + return searxngClientModulePromise; +} + const SearxngSearchSchema = { type: "object", properties: { @@ -52,7 +61,7 @@ export function createSearxngWebSearchProvider(): WebSearchProviderPlugin { "Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.", parameters: SearxngSearchSchema, execute: async (args) => { - const { runSearxngSearch } = await import("./searxng-client.js"); + const { runSearxngSearch } = await loadSearxngClientModule(); return await runSearxngSearch({ config: ctx.config, query: readStringParam(args, "query", { required: true }), diff --git a/extensions/tavily/src/tavily-search-provider.ts b/extensions/tavily/src/tavily-search-provider.ts index c05981e54a1..bb7b18e308e 100644 --- a/extensions/tavily/src/tavily-search-provider.ts +++ b/extensions/tavily/src/tavily-search-provider.ts @@ -4,6 +4,16 @@ import { } from "openclaw/plugin-sdk/provider-web-search-contract"; const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey"; + +type TavilyClientModule = typeof import("./tavily-client.js"); + +let tavilyClientModulePromise: Promise | undefined; + +function loadTavilyClientModule(): Promise { + tavilyClientModulePromise ??= import("./tavily-client.js"); + return tavilyClientModulePromise; +} + const GenericTavilySearchSchema = { type: "object", 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.", parameters: GenericTavilySearchSchema, execute: async (args) => { - const { runTavilySearch } = await import("./tavily-client.js"); + const { runTavilySearch } = await loadTavilyClientModule(); return await runTavilySearch({ cfg: ctx.config, query: typeof args.query === "string" ? args.query : "", diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 2f4e9423374..ce35dd0e56b 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -24,6 +24,15 @@ import { } from "./x-search-tool-shared.js"; const PROVIDER_ID = "xai"; +type CodeExecutionModule = typeof import("./code-execution.js"); + +let codeExecutionModulePromise: Promise | undefined; + +function loadCodeExecutionModule(): Promise { + codeExecutionModulePromise ??= import("./code-execution.js"); + return codeExecutionModulePromise; +} + function hasResolvableXaiApiKey(config: unknown): boolean { return Boolean( resolveFallbackXaiAuth(config as never)?.apiKey || readProviderEnvValue(["XAI_API_KEY"]), @@ -89,7 +98,7 @@ function createLazyCodeExecutionTool(ctx: { }), }), execute: async (toolCallId: string, args: Record) => { - const { createCodeExecutionTool } = await import("./code-execution.js"); + const { createCodeExecutionTool } = await loadCodeExecutionModule(); const tool = createCodeExecutionTool({ config: ctx.config as never, runtimeConfig: (ctx.runtimeConfig as never) ?? null, diff --git a/scripts/check-dynamic-import-warts.mjs b/scripts/check-dynamic-import-warts.mjs index 9744d6d554a..92e0ece932a 100644 --- a/scripts/check-dynamic-import-warts.mjs +++ b/scripts/check-dynamic-import-warts.mjs @@ -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) { 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 staticRuntimeImports = new Map(); const dynamicImports = new Map(); + const directExecuteImports = []; + const declarationStack = []; const addLine = (map, specifier, line) => { const lines = map.get(specifier) ?? []; @@ -69,6 +91,11 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") { }; const visit = (node) => { + const declarationName = readDeclarationName(node); + if (declarationName) { + declarationStack.push(declarationName); + } + if ( ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && @@ -84,16 +111,26 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") { ) { const specifier = readStringLiteral(node.arguments[0]); 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); + if (declarationName) { + declarationStack.pop(); + } }; visit(sourceFile); - const advisories = []; + const advisories = [...directExecuteImports]; for (const [specifier, dynamicLines] of dynamicImports) { const staticLines = staticRuntimeImports.get(specifier); if (staticLines?.length) { diff --git a/test/scripts/check-dynamic-import-warts.test.ts b/test/scripts/check-dynamic-import-warts.test.ts index b5937eed3bf..62bb8e86e39 100644 --- a/test/scripts/check-dynamic-import-warts.test.ts +++ b/test/scripts/check-dynamic-import-warts.test.ts @@ -54,4 +54,39 @@ describe("check-dynamic-import-warts", () => { `; 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 | undefined; + function loadRuntime() { + runtimePromise ??= import("./runtime.js"); + return runtimePromise; + } + export function createTool() { + return { + execute: async () => await loadRuntime(), + }; + } + `; + expect(findDynamicImportAdvisories(source)).toEqual([]); + }); });