mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
fix(gateway): bound Lobster Ajv schema compilation
This commit is contained in:
@@ -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.
|
||||
|
||||
142
extensions/lobster/src/lobster-ajv-cache.ts
Normal file
142
extensions/lobster/src/lobster-ajv-cache.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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>);
|
||||
|
||||
Reference in New Issue
Block a user