From 67d00826b2bba1806e18626de69c412a6ed32110 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 17:57:59 -0700 Subject: [PATCH] fix(gateway): bound Lobster Ajv schema compilation --- CHANGELOG.md | 1 + extensions/lobster/src/lobster-ajv-cache.ts | 142 ++++++++++++++++++ extensions/lobster/src/lobster-runner.test.ts | 81 ++++++++++ extensions/lobster/src/lobster-runner.ts | 3 + 4 files changed, 227 insertions(+) create mode 100644 extensions/lobster/src/lobster-ajv-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 795bf46c3f6..d9a381c7add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Exec/node: skip approval-plan preparation for full-trust `host=node` runs so interpreter and script commands no longer fail with `SYSTEM_RUN_DENIED: approval cannot safely bind` when effective policy is `security=full` and `ask=off`. Fixes #48457 and duplicate #69251. Thanks @ajtran303, @jaserNo1, @Blakeshannon, @lesliefag, and @AvIsBeastMC. - Exec/node: synthesize a local approval plan when a paired node advertises `system.run` without `system.run.prepare`, unblocking approval-required `host=node` exec on current macOS companion nodes while preserving remote prepare for node hosts that support it. Fixes #37591 and duplicate #66839; carries forward #69725. Thanks @soloclz. - Memory/QMD: prefer QMD's `--mask` collection pattern flag so root memory indexing stays scoped to `MEMORY.md` instead of widening to every markdown file in the workspace. Thanks @codex. +- Lobster/Gateway: memoize repeated Ajv schema compilation before loading the embedded Lobster runtime so scheduled workflows and `llm.invoke` loops stop growing gateway heap on content-identical schemas. Fixes #71148. Thanks @cmi525 and @vsolaz. - Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq. - Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han. - Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67. diff --git a/extensions/lobster/src/lobster-ajv-cache.ts b/extensions/lobster/src/lobster-ajv-cache.ts new file mode 100644 index 00000000000..4121710c1b7 --- /dev/null +++ b/extensions/lobster/src/lobster-ajv-cache.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import AjvPkg, { type AnySchema, type ValidateFunction } from "ajv"; + +const installedSymbol = Symbol.for("openclaw.lobster.ajv-compile-cache.installed"); +const cacheSymbol = Symbol.for("openclaw.lobster.ajv-compile-cache.entries"); +const maxEntries = 512; + +type AjvInstance = import("ajv").default; + +type CompileCacheEntry = { + schema: AnySchema; + validate: ValidateFunction; +}; + +const AjvCtor = AjvPkg as unknown as { + new (opts?: object): AjvInstance; + prototype: AjvInstance; +}; + +type AjvWithCompileCache = AjvInstance & { + [cacheSymbol]?: Map; +}; + +type AjvPrototypePatch = { + [installedSymbol]?: boolean; + compile: (schema: AnySchema) => ValidateFunction; + removeSchema: (schemaKeyRef?: Parameters[0]) => AjvInstance; +}; + +type JsonLike = null | boolean | number | string | JsonLike[] | { [key: string]: JsonLike }; + +function stableJsonStringify(value: unknown, seen = new WeakSet()): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (seen.has(value)) { + throw new TypeError("Cannot cache cyclic JSON schema"); + } + seen.add(value); + if (Array.isArray(value)) { + const items = value.map((entry) => stableJsonStringify(entry, seen)); + seen.delete(value); + return `[${items.join(",")}]`; + } + const record = value as Record; + const keys = Object.keys(record).toSorted(); + const properties = keys + .filter((key) => record[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stableJsonStringify(record[key], seen)}`); + seen.delete(value); + return `{${properties.join(",")}}`; +} + +function compileCacheKey(schema: unknown): string | null { + try { + return createHash("sha256").update(stableJsonStringify(schema)).digest("hex"); + } catch { + return null; + } +} + +function readCompileCache(instance: AjvWithCompileCache): Map { + let cache = instance[cacheSymbol]; + if (!cache) { + cache = new Map(); + Object.defineProperty(instance, cacheSymbol, { + value: cache, + configurable: true, + }); + } + return cache; +} + +function rememberCompiledValidator(params: { + cache: Map; + instance: AjvWithCompileCache; + key: string; + removeSchema: AjvPrototypePatch["removeSchema"]; + schema: AnySchema; + validate: ValidateFunction; +}) { + const { cache, instance, key, removeSchema, schema, validate } = params; + if (!cache.has(key) && cache.size >= maxEntries) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) { + const evicted = cache.get(oldest); + cache.delete(oldest); + if (evicted) { + removeSchema.call(instance, evicted.schema); + } + } + } + cache.set(key, { schema, validate }); +} + +export function installLobsterAjvCompileCache() { + const proto = AjvCtor.prototype as unknown as AjvPrototypePatch; + if (proto[installedSymbol]) { + return; + } + + const originalCompile = proto.compile; + const originalRemoveSchema = proto.removeSchema; + + Object.defineProperty(proto, installedSymbol, { + value: true, + configurable: true, + }); + + proto.compile = function compileWithContentCache( + this: AjvWithCompileCache, + schema: AnySchema, + ): ValidateFunction { + const key = compileCacheKey(schema); + if (!key) { + return originalCompile.call(this, schema) as ValidateFunction; + } + const cache = readCompileCache(this); + const cached = cache.get(key); + if (cached) { + return cached.validate as ValidateFunction; + } + const validate = originalCompile.call(this, schema) as ValidateFunction; + rememberCompiledValidator({ + cache, + instance: this, + key, + removeSchema: originalRemoveSchema, + schema, + validate, + }); + return validate; + }; + + proto.removeSchema = function removeSchemaAndClearContentCache( + this: AjvWithCompileCache, + schemaKeyRef?: Parameters[0], + ) { + this[cacheSymbol]?.clear(); + return originalRemoveSchema.call(this, schemaKeyRef); + }; +} diff --git a/extensions/lobster/src/lobster-runner.test.ts b/extensions/lobster/src/lobster-runner.test.ts index ff0cf6927ee..7dfd764c1cb 100644 --- a/extensions/lobster/src/lobster-runner.test.ts +++ b/extensions/lobster/src/lobster-runner.test.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmbeddedLobsterRunner, @@ -8,6 +10,38 @@ import { resolveLobsterCwd, } from "./lobster-runner.js"; +const requireForTest = createRequire(import.meta.url); + +type AjvCacheOwner = { + _cache?: { size: number }; +}; + +function readAjvInternalCacheSize(ajv: unknown): number { + return (ajv as AjvCacheOwner)._cache?.size ?? 0; +} + +function createRepeatedResponseSchema() { + return { + type: "object", + properties: { + answer: { type: "string" }, + }, + required: ["answer"], + additionalProperties: false, + }; +} + +function createUniqueResponseSchema(index: number) { + return { + type: "object", + properties: { + [`answer${index}`]: { type: "string" }, + }, + required: [`answer${index}`], + additionalProperties: false, + }; +} + describe("resolveLobsterCwd", () => { it("defaults to the current working directory", () => { expect(resolveLobsterCwd(undefined)).toBe(process.cwd()); @@ -356,6 +390,53 @@ describe("createEmbeddedLobsterRunner", () => { expect(loadRuntime).toHaveBeenCalledTimes(1); }); + it("installs an Ajv content cache before loading the embedded runtime", async () => { + const AjvModule = await import("ajv"); + const AjvCtor = AjvModule.default as unknown as new (opts?: object) => import("ajv").default; + const ajv = new AjvCtor({ allErrors: true, strict: false, addUsedSchema: false }); + const before = readAjvInternalCacheSize(ajv); + + await loadEmbeddedToolRuntimeFromPackage({ + importModule: async () => ({ + runToolRequest: vi.fn(), + resumeToolRequest: vi.fn(), + }), + }); + + const first = ajv.compile(createRepeatedResponseSchema()); + const second = ajv.compile(createRepeatedResponseSchema()); + const afterRepeated = readAjvInternalCacheSize(ajv); + + expect(second).toBe(first); + expect(afterRepeated - before).toBe(1); + + for (let index = 0; index < 520; index += 1) { + ajv.compile(createUniqueResponseSchema(index)); + } + + expect(readAjvInternalCacheSize(ajv)).toBeLessThanOrEqual(before + 512); + }); + + it("deduplicates content-identical schema compilation in the installed Lobster runtime", async () => { + await loadEmbeddedToolRuntimeFromPackage(); + + const corePath = requireForTest.resolve("@clawdbot/lobster/core"); + const validationPath = corePath.replace(/\/core\/index\.js$/, "/validation.js"); + const validationModule = (await import(pathToFileURL(validationPath).href)) as { + sharedAjv: import("ajv").default; + }; + const before = readAjvInternalCacheSize(validationModule.sharedAjv); + + const first = validationModule.sharedAjv.compile(createRepeatedResponseSchema()); + for (let index = 0; index < 1000; index += 1) { + validationModule.sharedAjv.compile(createRepeatedResponseSchema()); + } + const second = validationModule.sharedAjv.compile(createRepeatedResponseSchema()); + + expect(second).toBe(first); + expect(readAjvInternalCacheSize(validationModule.sharedAjv) - before).toBe(1); + }); + it("falls back to the installed package core file when the core export is unavailable", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-package-")); const packageRoot = path.join(tempDir, "node_modules", "@clawdbot", "lobster"); diff --git a/extensions/lobster/src/lobster-runner.ts b/extensions/lobster/src/lobster-runner.ts index 7209b719d60..bccae0453a7 100644 --- a/extensions/lobster/src/lobster-runner.ts +++ b/extensions/lobster/src/lobster-runner.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import path from "node:path"; import { Readable, Writable } from "node:stream"; import { pathToFileURL } from "node:url"; +import { installLobsterAjvCompileCache } from "./lobster-ajv-cache.js"; export type LobsterEnvelope = | { @@ -296,6 +297,8 @@ async function withTimeout( export async function loadEmbeddedToolRuntimeFromPackage( options: LoadEmbeddedToolRuntimeFromPackageOptions = {}, ): Promise { + installLobsterAjvCompileCache(); + const importModule = options.importModule ?? (async (specifier: string) => (await import(specifier)) as Partial);