fix(gateway): bound Lobster Ajv schema compilation

This commit is contained in:
Vincent Koc
2026-04-26 17:57:59 -07:00
parent 3c95327b34
commit 67d00826b2
4 changed files with 227 additions and 0 deletions

View File

@@ -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.

View File

@@ -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<string, CompileCacheEntry>;
};
type AjvPrototypePatch = {
[installedSymbol]?: boolean;
compile: (schema: AnySchema) => ValidateFunction;
removeSchema: (schemaKeyRef?: Parameters<AjvInstance["removeSchema"]>[0]) => AjvInstance;
};
type JsonLike = null | boolean | number | string | JsonLike[] | { [key: string]: JsonLike };
function stableJsonStringify(value: unknown, seen = new WeakSet<object>()): 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<string, unknown>;
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<string, CompileCacheEntry> {
let cache = instance[cacheSymbol];
if (!cache) {
cache = new Map<string, CompileCacheEntry>();
Object.defineProperty(instance, cacheSymbol, {
value: cache,
configurable: true,
});
}
return cache;
}
function rememberCompiledValidator(params: {
cache: Map<string, CompileCacheEntry>;
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<JsonLike> {
const key = compileCacheKey(schema);
if (!key) {
return originalCompile.call(this, schema) as ValidateFunction<JsonLike>;
}
const cache = readCompileCache(this);
const cached = cache.get(key);
if (cached) {
return cached.validate as ValidateFunction<JsonLike>;
}
const validate = originalCompile.call(this, schema) as ValidateFunction<JsonLike>;
rememberCompiledValidator({
cache,
instance: this,
key,
removeSchema: originalRemoveSchema,
schema,
validate,
});
return validate;
};
proto.removeSchema = function removeSchemaAndClearContentCache(
this: AjvWithCompileCache,
schemaKeyRef?: Parameters<AjvInstance["removeSchema"]>[0],
) {
this[cacheSymbol]?.clear();
return originalRemoveSchema.call(this, schemaKeyRef);
};
}

View File

@@ -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");

View File

@@ -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<T>(
export async function loadEmbeddedToolRuntimeFromPackage(
options: LoadEmbeddedToolRuntimeFromPackageOptions = {},
): Promise<EmbeddedToolRuntime> {
installLobsterAjvCompileCache();
const importModule =
options.importModule ??
(async (specifier: string) => (await import(specifier)) as Partial<EmbeddedToolRuntime>);