Skip to content

Commit 68e6f03

Browse files
committed
perf: reduce gateway runtime discovery overhead
1 parent 7b5f0c2 commit 68e6f03

6 files changed

Lines changed: 161 additions & 50 deletions

File tree

src/agents/agent-tools-parameter-schema.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,42 @@ export type ToolParameterSchemaOptions = {
1616
modelCompat?: ModelCompatConfig;
1717
};
1818

19+
const MAX_TOOL_PARAMETER_SCHEMA_CACHE_ENTRIES_PER_SCHEMA = 8;
20+
const toolParameterSchemaCache = new WeakMap<object, Array<{ key: string; value: TSchema }>>();
21+
22+
function resolveToolParameterSchemaCacheKey(
23+
options: ToolParameterSchemaOptions | undefined,
24+
): string {
25+
const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider);
26+
const normalizedModelId = normalizeLowercaseStringOrEmpty(options?.modelId);
27+
const unsupportedKeywords = [
28+
...resolveUnsupportedToolSchemaKeywords(options?.modelCompat),
29+
].sort();
30+
const omitEmptyArrayItems = shouldOmitEmptyArrayItems(options?.modelCompat);
31+
return JSON.stringify([
32+
normalizedProvider,
33+
normalizedModelId,
34+
unsupportedKeywords,
35+
omitEmptyArrayItems,
36+
]);
37+
}
38+
39+
function getCachedToolParameterSchema(schema: object, key: string): TSchema | undefined {
40+
return toolParameterSchemaCache.get(schema)?.find((entry) => entry.key === key)?.value;
41+
}
42+
43+
function rememberCachedToolParameterSchema(schema: object, key: string, value: TSchema): TSchema {
44+
const entries = toolParameterSchemaCache.get(schema) ?? [];
45+
toolParameterSchemaCache.set(
46+
schema,
47+
[{ key, value }, ...entries.filter((entry) => entry.key !== key)].slice(
48+
0,
49+
MAX_TOOL_PARAMETER_SCHEMA_CACHE_ENTRIES_PER_SCHEMA,
50+
),
51+
);
52+
return value;
53+
}
54+
1955
function extractEnumValues(schema: unknown): unknown[] | undefined {
2056
if (!schema || typeof schema !== "object") {
2157
return undefined;
@@ -705,9 +741,9 @@ function normalizeOpenApiSchemaKeywords(schema: unknown): unknown {
705741
return changed || nullable ? normalized : schema;
706742
}
707743

708-
export function normalizeToolParameterSchema(
744+
function normalizeToolParameterSchemaUncached(
709745
schema: unknown,
710-
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
746+
options?: ToolParameterSchemaOptions,
711747
): TSchema {
712748
const inlinedSchema = normalizeOpenApiSchemaKeywords(inlineLocalToolSchemaRefs(schema));
713749
const schemaRecord =
@@ -844,3 +880,22 @@ export function normalizeToolParameterSchema(
844880
// Merging properties preserves useful enums like `action` while keeping schemas portable.
845881
return applyProviderCleaning(flattenedSchema);
846882
}
883+
884+
export function normalizeToolParameterSchema(
885+
schema: unknown,
886+
options?: ToolParameterSchemaOptions,
887+
): TSchema {
888+
if (!schema || typeof schema !== "object") {
889+
return normalizeToolParameterSchemaUncached(schema, options);
890+
}
891+
const cacheKey = resolveToolParameterSchemaCacheKey(options);
892+
const cached = getCachedToolParameterSchema(schema, cacheKey);
893+
if (cached) {
894+
return cached;
895+
}
896+
return rememberCachedToolParameterSchema(
897+
schema,
898+
cacheKey,
899+
normalizeToolParameterSchemaUncached(schema, options),
900+
);
901+
}

src/agents/agent-tools.schema.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ const TEST_USAGE = {
2020
};
2121

2222
describe("normalizeToolParameterSchema", () => {
23+
it("reuses normalized schemas for the same schema object and provider options", () => {
24+
const schema = {
25+
type: "object",
26+
properties: {
27+
names: { type: "array" },
28+
},
29+
};
30+
31+
const first = normalizeToolParameterSchema(schema);
32+
const second = normalizeToolParameterSchema(schema);
33+
const providerSpecific = normalizeToolParameterSchema(schema, { modelProvider: "gemini" });
34+
35+
expect(second).toBe(first);
36+
expect(providerSpecific).not.toBe(first);
37+
expect(providerSpecific).toEqual(first);
38+
});
39+
2340
it("normalizes truly empty schemas to type:object with properties:{}", () => {
2441
expect(normalizeToolParameterSchema({})).toEqual({
2542
type: "object",

src/plugins/discovery.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -602,11 +602,12 @@ function readCandidatePackageManifest(params: {
602602
}
603603
const canUseProcessCache = params.origin === "bundled" || !params.rejectHardlinks;
604604
const stat = readPackageManifestStat(params.dir);
605-
const processCached =
606-
canUseProcessCache && stat ? packageManifestProcessCache.get(cacheKey) : undefined;
607-
if (processCached && processCached.mtimeMs === stat.mtimeMs && processCached.size === stat.size) {
608-
params.packageManifestCache?.set(cacheKey, processCached.manifest);
609-
return processCached.manifest;
605+
if (canUseProcessCache && stat) {
606+
const processCached = packageManifestProcessCache.get(cacheKey);
607+
if (processCached?.mtimeMs === stat.mtimeMs && processCached.size === stat.size) {
608+
params.packageManifestCache?.set(cacheKey, processCached.manifest);
609+
return processCached.manifest;
610+
}
610611
}
611612
const manifest =
612613
params.origin === "bundled"
@@ -1358,13 +1359,13 @@ export function discoverOpenClawPlugins(params: {
13581359
const workspaceDir = normalizeOptionalString(params.workspaceDir);
13591360
const workspaceRoot = workspaceDir ? resolveUserPath(workspaceDir, env) : undefined;
13601361
const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env });
1362+
const realpathCache = new Map<string, string>();
1363+
const packageManifestCache = new Map<string, PackageManifest | null>();
13611364
const scopedResult = tracePluginLifecyclePhase(
13621365
"discovery scan",
13631366
() => {
13641367
const result = createDiscoveryResult();
13651368
const seen = new Set<string>();
1366-
const realpathCache = new Map<string, string>();
1367-
const packageManifestCache = new Map<string, PackageManifest | null>();
13681369
const extra = params.extraPaths ?? [];
13691370
for (const extraPath of extra) {
13701371
if (typeof extraPath !== "string") {
@@ -1430,8 +1431,6 @@ export function discoverOpenClawPlugins(params: {
14301431
() => {
14311432
const result = createDiscoveryResult();
14321433
const seen = new Set<string>();
1433-
const realpathCache = new Map<string, string>();
1434-
const packageManifestCache = new Map<string, PackageManifest | null>();
14351434
for (const sourceOverlayDir of listBundledSourceOverlayDirs({
14361435
bundledRoot: roots.stock,
14371436
env,

src/plugins/plugin-module-loader-cache.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,53 @@ describe("getCachedPluginModuleLoader", () => {
456456
});
457457
});
458458

459+
it("lets native require handle compiled plugin SDK aliases before source-transform fallback", async () => {
460+
const fromSourceTransformer = vi.fn();
461+
const createJiti = vi.fn(() => fromSourceTransformer);
462+
const nativeStub = vi.fn((target: string) => ({
463+
ok: true as const,
464+
moduleExport: { loadedFrom: target },
465+
}));
466+
vi.doMock("./native-module-require.js", () => ({
467+
isJavaScriptModulePath: (p: string) =>
468+
p.endsWith(".js") || p.endsWith(".mjs") || p.endsWith(".cjs"),
469+
tryNativeRequireJavaScriptModule: nativeStub,
470+
}));
471+
const { getCachedPluginModuleLoader, getPluginModuleLoaderStats } = await importFreshModule<
472+
typeof import("./plugin-module-loader-cache.js")
473+
>(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-plugin-sdk-alias");
474+
475+
const cache = new Map();
476+
const loader = getCachedPluginModuleLoader({
477+
cache,
478+
modulePath: "/repo/dist/extensions/demo/api.js",
479+
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
480+
loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts",
481+
aliasMap: {
482+
"openclaw/plugin-sdk": "/repo/dist/plugin-sdk/root-alias.cjs",
483+
"openclaw/plugin-sdk/core": "/repo/dist/plugin-sdk/core.js",
484+
},
485+
createLoader: asPluginModuleLoaderFactory(createJiti),
486+
});
487+
488+
const result = loader("/repo/dist/extensions/demo/api.js") as { loadedFrom: string };
489+
expect(result.loadedFrom).toBe("/repo/dist/extensions/demo/api.js");
490+
expect(createJiti).not.toHaveBeenCalled();
491+
expect(fromSourceTransformer).not.toHaveBeenCalled();
492+
expectNativeOptions(nativeStub, "/repo/dist/extensions/demo/api.js");
493+
const options = callArg(nativeStub, 0, 1, "native options") as {
494+
aliasMap?: Record<string, string>;
495+
};
496+
expect(options.aliasMap?.["openclaw/plugin-sdk/core"]).toBe("/repo/dist/plugin-sdk/core.js");
497+
expectStats(getPluginModuleLoaderStats(), {
498+
calls: 1,
499+
nativeHits: 1,
500+
nativeMisses: 0,
501+
sourceTransformFallbacks: 0,
502+
sourceTransformForced: 0,
503+
});
504+
});
505+
459506
it("does not source-transform fallback after native loading reaches a missing dependency", async () => {
460507
const fromSourceTransformer = vi.fn();
461508
const createJiti = vi.fn(() => fromSourceTransformer);

src/plugins/plugin-module-loader-cache.ts

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import fs from "node:fs";
21
import { createRequire } from "node:module";
32
import path from "node:path";
43
import { pathToFileURL } from "node:url";
@@ -49,8 +48,6 @@ export type PluginModuleLoaderStatsSnapshot = {
4948

5049
const DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES = 128;
5150
const MAX_TRACKED_SOURCE_TRANSFORM_TARGETS = 24;
52-
const PLUGIN_SDK_IMPORT_SPECIFIER_PATTERN =
53-
/(?:\bfrom\s*["']|\bimport\s*\(\s*["']|\brequire\s*\(\s*["'])(?:openclaw|@openclaw)\/plugin-sdk(?:\/[^"']*)?["']/u;
5451
const requireForJiti = createRequire(import.meta.url);
5552
let createJitiLoaderFactory: PluginModuleLoaderFactory | undefined;
5653
const pluginModuleLoaderStats = {
@@ -216,29 +213,6 @@ function createLazySourceTransformLoader(params: {
216213
};
217214
}
218215

219-
function shouldForceSourceTransformForPluginSdkAlias(params: {
220-
target: string;
221-
aliasMap: Record<string, string>;
222-
}): boolean {
223-
if (
224-
!params.aliasMap["openclaw/plugin-sdk"] &&
225-
!params.aliasMap["@openclaw/plugin-sdk"] &&
226-
!Object.keys(params.aliasMap).some(
227-
(key) => key.startsWith("openclaw/plugin-sdk/") || key.startsWith("@openclaw/plugin-sdk/"),
228-
)
229-
) {
230-
return false;
231-
}
232-
if (!/\.[cm]?js$/iu.test(params.target)) {
233-
return false;
234-
}
235-
try {
236-
return PLUGIN_SDK_IMPORT_SPECIFIER_PATTERN.test(fs.readFileSync(params.target, "utf-8"));
237-
} catch {
238-
return false;
239-
}
240-
}
241-
242216
function createPluginModuleLoader(params: {
243217
loaderFilename: string;
244218
aliasMap: Record<string, string>;
@@ -271,20 +245,8 @@ function createPluginModuleLoader(params: {
271245
// for TS / TSX sources and for the small set of require(esm) /
272246
// async-module fallbacks `tryNativeRequireJavaScriptModule` declines to
273247
// handle.
274-
const getLoadWithAliasTransform = createLazySourceTransformLoader({
275-
...params,
276-
sourceTransformTryNative: false,
277-
});
278248
return ((target: string, ...rest: unknown[]) => {
279249
pluginModuleLoaderStats.calls += 1;
280-
if (shouldForceSourceTransformForPluginSdkAlias({ target, aliasMap: params.aliasMap })) {
281-
pluginModuleLoaderStats.sourceTransformForced += 1;
282-
recordSourceTransformTarget(target);
283-
return (getLoadWithAliasTransform() as (t: string, ...a: unknown[]) => unknown)(
284-
target,
285-
...rest,
286-
);
287-
}
288250
const native = tryNativeRequireJavaScriptModule(target, {
289251
allowWindows: true,
290252
aliasMap: params.aliasMap,

src/sessions/session-key-utils.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,30 @@ function escapeRegExp(value: string): string {
9595

9696
type PreservedSpan = { start: number; end: number; trim: boolean };
9797

98+
const NORMALIZED_SESSION_KEY_CACHE_MAX_ENTRIES = 2048;
99+
const NORMALIZED_SESSION_KEY_CACHE_MAX_LENGTH = 4096;
100+
const normalizedSessionKeyCache = new Map<string, string>();
101+
102+
function readNormalizedSessionKeyCache(raw: string): string | undefined {
103+
return raw.length <= NORMALIZED_SESSION_KEY_CACHE_MAX_LENGTH
104+
? normalizedSessionKeyCache.get(raw)
105+
: undefined;
106+
}
107+
108+
function writeNormalizedSessionKeyCache(raw: string, normalized: string): void {
109+
if (raw.length > NORMALIZED_SESSION_KEY_CACHE_MAX_LENGTH) {
110+
return;
111+
}
112+
normalizedSessionKeyCache.set(raw, normalized);
113+
while (normalizedSessionKeyCache.size > NORMALIZED_SESSION_KEY_CACHE_MAX_ENTRIES) {
114+
const oldest = normalizedSessionKeyCache.keys().next().value;
115+
if (oldest === undefined) {
116+
return;
117+
}
118+
normalizedSessionKeyCache.delete(oldest);
119+
}
120+
}
121+
98122
function mayContainCasePreservingPeer(raw: string): boolean {
99123
const folded = raw.toLowerCase();
100124
return CASE_PRESERVING_PEERS.some((descriptor) => folded.includes(`${descriptor.channel}:`));
@@ -169,8 +193,14 @@ export function normalizeSessionKeyPreservingOpaquePeerIds(
169193
if (!raw) {
170194
return "";
171195
}
196+
const cached = readNormalizedSessionKeyCache(raw);
197+
if (cached !== undefined) {
198+
return cached;
199+
}
172200
if (!mayContainCasePreservingPeer(raw)) {
173-
return raw.toLowerCase();
201+
const normalized = raw.toLowerCase();
202+
writeNormalizedSessionKeyCache(raw, normalized);
203+
return normalized;
174204
}
175205
const spans = collectCasePreservedSpans(raw)
176206
.filter((span) => span.end > span.start)
@@ -189,6 +219,7 @@ export function normalizeSessionKeyPreservingOpaquePeerIds(
189219
cursor = span.end;
190220
}
191221
normalized += normalizeLowercaseStringOrEmpty(raw.slice(cursor));
222+
writeNormalizedSessionKeyCache(raw, normalized);
192223
return normalized;
193224
}
194225

0 commit comments

Comments
 (0)