diff --git a/src/agents/agent-tools-parameter-schema.ts b/src/agents/agent-tools-parameter-schema.ts index 5d5bb77112f..04af207633d 100644 --- a/src/agents/agent-tools-parameter-schema.ts +++ b/src/agents/agent-tools-parameter-schema.ts @@ -16,6 +16,42 @@ export type ToolParameterSchemaOptions = { modelCompat?: ModelCompatConfig; }; +const MAX_TOOL_PARAMETER_SCHEMA_CACHE_ENTRIES_PER_SCHEMA = 8; +const toolParameterSchemaCache = new WeakMap>(); + +function resolveToolParameterSchemaCacheKey( + options: ToolParameterSchemaOptions | undefined, +): string { + const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider); + const normalizedModelId = normalizeLowercaseStringOrEmpty(options?.modelId); + const unsupportedKeywords = [ + ...resolveUnsupportedToolSchemaKeywords(options?.modelCompat), + ].sort(); + const omitEmptyArrayItems = shouldOmitEmptyArrayItems(options?.modelCompat); + return JSON.stringify([ + normalizedProvider, + normalizedModelId, + unsupportedKeywords, + omitEmptyArrayItems, + ]); +} + +function getCachedToolParameterSchema(schema: object, key: string): TSchema | undefined { + return toolParameterSchemaCache.get(schema)?.find((entry) => entry.key === key)?.value; +} + +function rememberCachedToolParameterSchema(schema: object, key: string, value: TSchema): TSchema { + const entries = toolParameterSchemaCache.get(schema) ?? []; + toolParameterSchemaCache.set( + schema, + [{ key, value }, ...entries.filter((entry) => entry.key !== key)].slice( + 0, + MAX_TOOL_PARAMETER_SCHEMA_CACHE_ENTRIES_PER_SCHEMA, + ), + ); + return value; +} + function extractEnumValues(schema: unknown): unknown[] | undefined { if (!schema || typeof schema !== "object") { return undefined; @@ -705,9 +741,9 @@ function normalizeOpenApiSchemaKeywords(schema: unknown): unknown { return changed || nullable ? normalized : schema; } -export function normalizeToolParameterSchema( +function normalizeToolParameterSchemaUncached( schema: unknown, - options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig }, + options?: ToolParameterSchemaOptions, ): TSchema { const inlinedSchema = normalizeOpenApiSchemaKeywords(inlineLocalToolSchemaRefs(schema)); const schemaRecord = @@ -844,3 +880,22 @@ export function normalizeToolParameterSchema( // Merging properties preserves useful enums like `action` while keeping schemas portable. return applyProviderCleaning(flattenedSchema); } + +export function normalizeToolParameterSchema( + schema: unknown, + options?: ToolParameterSchemaOptions, +): TSchema { + if (!schema || typeof schema !== "object") { + return normalizeToolParameterSchemaUncached(schema, options); + } + const cacheKey = resolveToolParameterSchemaCacheKey(options); + const cached = getCachedToolParameterSchema(schema, cacheKey); + if (cached) { + return cached; + } + return rememberCachedToolParameterSchema( + schema, + cacheKey, + normalizeToolParameterSchemaUncached(schema, options), + ); +} diff --git a/src/agents/agent-tools.schema.test.ts b/src/agents/agent-tools.schema.test.ts index 48011a85a05..ad262c4723d 100644 --- a/src/agents/agent-tools.schema.test.ts +++ b/src/agents/agent-tools.schema.test.ts @@ -20,6 +20,23 @@ const TEST_USAGE = { }; describe("normalizeToolParameterSchema", () => { + it("reuses normalized schemas for the same schema object and provider options", () => { + const schema = { + type: "object", + properties: { + names: { type: "array" }, + }, + }; + + const first = normalizeToolParameterSchema(schema); + const second = normalizeToolParameterSchema(schema); + const providerSpecific = normalizeToolParameterSchema(schema, { modelProvider: "gemini" }); + + expect(second).toBe(first); + expect(providerSpecific).not.toBe(first); + expect(providerSpecific).toEqual(first); + }); + it("normalizes truly empty schemas to type:object with properties:{}", () => { expect(normalizeToolParameterSchema({})).toEqual({ type: "object", diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index e6e3277ecb2..1afbcaf0946 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -602,11 +602,12 @@ function readCandidatePackageManifest(params: { } const canUseProcessCache = params.origin === "bundled" || !params.rejectHardlinks; const stat = readPackageManifestStat(params.dir); - const processCached = - canUseProcessCache && stat ? packageManifestProcessCache.get(cacheKey) : undefined; - if (processCached && processCached.mtimeMs === stat.mtimeMs && processCached.size === stat.size) { - params.packageManifestCache?.set(cacheKey, processCached.manifest); - return processCached.manifest; + if (canUseProcessCache && stat) { + const processCached = packageManifestProcessCache.get(cacheKey); + if (processCached?.mtimeMs === stat.mtimeMs && processCached.size === stat.size) { + params.packageManifestCache?.set(cacheKey, processCached.manifest); + return processCached.manifest; + } } const manifest = params.origin === "bundled" @@ -1358,13 +1359,13 @@ export function discoverOpenClawPlugins(params: { const workspaceDir = normalizeOptionalString(params.workspaceDir); const workspaceRoot = workspaceDir ? resolveUserPath(workspaceDir, env) : undefined; const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env }); + const realpathCache = new Map(); + const packageManifestCache = new Map(); const scopedResult = tracePluginLifecyclePhase( "discovery scan", () => { const result = createDiscoveryResult(); const seen = new Set(); - const realpathCache = new Map(); - const packageManifestCache = new Map(); const extra = params.extraPaths ?? []; for (const extraPath of extra) { if (typeof extraPath !== "string") { @@ -1430,8 +1431,6 @@ export function discoverOpenClawPlugins(params: { () => { const result = createDiscoveryResult(); const seen = new Set(); - const realpathCache = new Map(); - const packageManifestCache = new Map(); for (const sourceOverlayDir of listBundledSourceOverlayDirs({ bundledRoot: roots.stock, env, diff --git a/src/plugins/plugin-module-loader-cache.test.ts b/src/plugins/plugin-module-loader-cache.test.ts index 3a32a6e351b..10552423a8a 100644 --- a/src/plugins/plugin-module-loader-cache.test.ts +++ b/src/plugins/plugin-module-loader-cache.test.ts @@ -456,6 +456,53 @@ describe("getCachedPluginModuleLoader", () => { }); }); + it("lets native require handle compiled plugin SDK aliases before source-transform fallback", async () => { + const fromSourceTransformer = vi.fn(); + const createJiti = vi.fn(() => fromSourceTransformer); + const nativeStub = vi.fn((target: string) => ({ + ok: true as const, + moduleExport: { loadedFrom: target }, + })); + vi.doMock("./native-module-require.js", () => ({ + isJavaScriptModulePath: (p: string) => + p.endsWith(".js") || p.endsWith(".mjs") || p.endsWith(".cjs"), + tryNativeRequireJavaScriptModule: nativeStub, + })); + const { getCachedPluginModuleLoader, getPluginModuleLoaderStats } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-plugin-sdk-alias"); + + const cache = new Map(); + const loader = getCachedPluginModuleLoader({ + cache, + modulePath: "/repo/dist/extensions/demo/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", + aliasMap: { + "openclaw/plugin-sdk": "/repo/dist/plugin-sdk/root-alias.cjs", + "openclaw/plugin-sdk/core": "/repo/dist/plugin-sdk/core.js", + }, + createLoader: asPluginModuleLoaderFactory(createJiti), + }); + + const result = loader("/repo/dist/extensions/demo/api.js") as { loadedFrom: string }; + expect(result.loadedFrom).toBe("/repo/dist/extensions/demo/api.js"); + expect(createJiti).not.toHaveBeenCalled(); + expect(fromSourceTransformer).not.toHaveBeenCalled(); + expectNativeOptions(nativeStub, "/repo/dist/extensions/demo/api.js"); + const options = callArg(nativeStub, 0, 1, "native options") as { + aliasMap?: Record; + }; + expect(options.aliasMap?.["openclaw/plugin-sdk/core"]).toBe("/repo/dist/plugin-sdk/core.js"); + expectStats(getPluginModuleLoaderStats(), { + calls: 1, + nativeHits: 1, + nativeMisses: 0, + sourceTransformFallbacks: 0, + sourceTransformForced: 0, + }); + }); + it("does not source-transform fallback after native loading reaches a missing dependency", async () => { const fromSourceTransformer = vi.fn(); const createJiti = vi.fn(() => fromSourceTransformer); diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index 4ec504828da..37a16bad783 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -49,8 +48,6 @@ export type PluginModuleLoaderStatsSnapshot = { const DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES = 128; const MAX_TRACKED_SOURCE_TRANSFORM_TARGETS = 24; -const PLUGIN_SDK_IMPORT_SPECIFIER_PATTERN = - /(?:\bfrom\s*["']|\bimport\s*\(\s*["']|\brequire\s*\(\s*["'])(?:openclaw|@openclaw)\/plugin-sdk(?:\/[^"']*)?["']/u; const requireForJiti = createRequire(import.meta.url); let createJitiLoaderFactory: PluginModuleLoaderFactory | undefined; const pluginModuleLoaderStats = { @@ -216,29 +213,6 @@ function createLazySourceTransformLoader(params: { }; } -function shouldForceSourceTransformForPluginSdkAlias(params: { - target: string; - aliasMap: Record; -}): boolean { - if ( - !params.aliasMap["openclaw/plugin-sdk"] && - !params.aliasMap["@openclaw/plugin-sdk"] && - !Object.keys(params.aliasMap).some( - (key) => key.startsWith("openclaw/plugin-sdk/") || key.startsWith("@openclaw/plugin-sdk/"), - ) - ) { - return false; - } - if (!/\.[cm]?js$/iu.test(params.target)) { - return false; - } - try { - return PLUGIN_SDK_IMPORT_SPECIFIER_PATTERN.test(fs.readFileSync(params.target, "utf-8")); - } catch { - return false; - } -} - function createPluginModuleLoader(params: { loaderFilename: string; aliasMap: Record; @@ -271,20 +245,8 @@ function createPluginModuleLoader(params: { // for TS / TSX sources and for the small set of require(esm) / // async-module fallbacks `tryNativeRequireJavaScriptModule` declines to // handle. - const getLoadWithAliasTransform = createLazySourceTransformLoader({ - ...params, - sourceTransformTryNative: false, - }); return ((target: string, ...rest: unknown[]) => { pluginModuleLoaderStats.calls += 1; - if (shouldForceSourceTransformForPluginSdkAlias({ target, aliasMap: params.aliasMap })) { - pluginModuleLoaderStats.sourceTransformForced += 1; - recordSourceTransformTarget(target); - return (getLoadWithAliasTransform() as (t: string, ...a: unknown[]) => unknown)( - target, - ...rest, - ); - } const native = tryNativeRequireJavaScriptModule(target, { allowWindows: true, aliasMap: params.aliasMap, diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index 976e26878a6..071abf0a8d1 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -95,6 +95,30 @@ function escapeRegExp(value: string): string { type PreservedSpan = { start: number; end: number; trim: boolean }; +const NORMALIZED_SESSION_KEY_CACHE_MAX_ENTRIES = 2048; +const NORMALIZED_SESSION_KEY_CACHE_MAX_LENGTH = 4096; +const normalizedSessionKeyCache = new Map(); + +function readNormalizedSessionKeyCache(raw: string): string | undefined { + return raw.length <= NORMALIZED_SESSION_KEY_CACHE_MAX_LENGTH + ? normalizedSessionKeyCache.get(raw) + : undefined; +} + +function writeNormalizedSessionKeyCache(raw: string, normalized: string): void { + if (raw.length > NORMALIZED_SESSION_KEY_CACHE_MAX_LENGTH) { + return; + } + normalizedSessionKeyCache.set(raw, normalized); + while (normalizedSessionKeyCache.size > NORMALIZED_SESSION_KEY_CACHE_MAX_ENTRIES) { + const oldest = normalizedSessionKeyCache.keys().next().value; + if (oldest === undefined) { + return; + } + normalizedSessionKeyCache.delete(oldest); + } +} + function mayContainCasePreservingPeer(raw: string): boolean { const folded = raw.toLowerCase(); return CASE_PRESERVING_PEERS.some((descriptor) => folded.includes(`${descriptor.channel}:`)); @@ -169,8 +193,14 @@ export function normalizeSessionKeyPreservingOpaquePeerIds( if (!raw) { return ""; } + const cached = readNormalizedSessionKeyCache(raw); + if (cached !== undefined) { + return cached; + } if (!mayContainCasePreservingPeer(raw)) { - return raw.toLowerCase(); + const normalized = raw.toLowerCase(); + writeNormalizedSessionKeyCache(raw, normalized); + return normalized; } const spans = collectCasePreservedSpans(raw) .filter((span) => span.end > span.start) @@ -189,6 +219,7 @@ export function normalizeSessionKeyPreservingOpaquePeerIds( cursor = span.end; } normalized += normalizeLowercaseStringOrEmpty(raw.slice(cursor)); + writeNormalizedSessionKeyCache(raw, normalized); return normalized; }