From cbde3c1945fb18b5f7f97ec99ade63d4713bb4b8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 08:16:20 +0800 Subject: [PATCH] perf(memory): defer session backfill runtime (#119372) Punchcard-Session: coral-workshop-workshop-3f --- extensions/memory-core/index.ts | 2 +- .../src/session-backfill-gateway.lazy.test.ts | 81 +++++++++++++++++++ .../src/session-backfill-gateway.runtime.ts | 3 + .../src/session-backfill-gateway.ts | 28 ++++--- .../src/session-backfill-lifecycle.ts | 41 ---------- .../src/session-backfill-selection.ts | 41 ++++++++++ .../memory-core/src/session-backfill.ts | 7 +- .../memory-core-host-runtime-core.ts | 6 +- 8 files changed, 149 insertions(+), 60 deletions(-) create mode 100644 extensions/memory-core/src/session-backfill-gateway.lazy.test.ts create mode 100644 extensions/memory-core/src/session-backfill-gateway.runtime.ts create mode 100644 extensions/memory-core/src/session-backfill-selection.ts diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index b0407fdebc2c..1b009e295a2c 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,8 +1,8 @@ -import { listAgentIds } from "openclaw/plugin-sdk/agent-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; // Memory Core plugin entrypoint registers its OpenClaw integration. import { jsonResult, + listAgentIds, resolveMemorySearchConfig, resolveSessionAgentIds, type MemoryPluginRuntime, diff --git a/extensions/memory-core/src/session-backfill-gateway.lazy.test.ts b/extensions/memory-core/src/session-backfill-gateway.lazy.test.ts new file mode 100644 index 000000000000..6e2851fc5da8 --- /dev/null +++ b/extensions/memory-core/src/session-backfill-gateway.lazy.test.ts @@ -0,0 +1,81 @@ +import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-runtime"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { expect, it, vi } from "vitest"; +import { registerSessionBackfillGatewayMethods } from "./session-backfill-gateway.js"; + +const backfillModule = vi.hoisted(() => ({ + loadCount: 0, + executeSessionBackfill: vi.fn(), + executeSessionBackfillBatch: vi.fn(), +})); + +vi.mock("./session-backfill.js", () => { + backfillModule.loadCount += 1; + return { + executeSessionBackfill: backfillModule.executeSessionBackfill, + executeSessionBackfillBatch: backfillModule.executeSessionBackfillBatch, + }; +}); + +it("loads session backfill execution only for the first valid request", async () => { + const methods = new Map Promise>(); + const api = { + runtime: { + config: { + current: () => ({ + agents: { + entries: { main: { default: true, workspace: "/tmp/main-workspace" } }, + }, + }), + }, + agent: { + resolveAgentWorkspaceDir: () => "/tmp/main-workspace", + }, + }, + registerGatewayMethod( + method: string, + handler: (options: GatewayRequestHandlerOptions) => Promise, + ) { + methods.set(method, handler); + }, + } as unknown as OpenClawPluginApi; + + registerSessionBackfillGatewayMethods(api); + expect(backfillModule.loadCount).toBe(0); + + const preview = methods.get("memory.sessionBackfill.preview"); + expect(preview).toBeDefined(); + const invalidRespond = vi.fn(); + await preview!({ + params: { agentId: "main", from: "invalid" }, + respond: invalidRespond, + } as unknown as GatewayRequestHandlerOptions); + expect(backfillModule.loadCount).toBe(0); + expect(invalidRespond.mock.calls[0]?.[2]).toMatchObject({ code: "INVALID_REQUEST" }); + + backfillModule.executeSessionBackfillBatch.mockResolvedValue({ + result: { + agentId: "main", + workspaceDir: "/tmp/main-workspace", + applied: false, + rem: false, + days: [], + candidateCount: 0, + stagedEntries: 0, + writtenDiaryEntries: 0, + replacedDiaryEntries: 0, + }, + continuation: { advanced: false, hasMore: false }, + }); + await preview!({ + params: { agentId: "main" }, + respond: vi.fn(), + } as unknown as GatewayRequestHandlerOptions); + await preview!({ + params: { agentId: "main" }, + respond: vi.fn(), + } as unknown as GatewayRequestHandlerOptions); + + expect(backfillModule.loadCount).toBe(1); + expect(backfillModule.executeSessionBackfillBatch).toHaveBeenCalledTimes(2); +}); diff --git a/extensions/memory-core/src/session-backfill-gateway.runtime.ts b/extensions/memory-core/src/session-backfill-gateway.runtime.ts new file mode 100644 index 000000000000..e8ae09827052 --- /dev/null +++ b/extensions/memory-core/src/session-backfill-gateway.runtime.ts @@ -0,0 +1,3 @@ +// Keep the gateway's dynamic boundary separate from the CLI runtime, which +// also owns a static session-backfill import for command execution. +export { executeSessionBackfill, executeSessionBackfillBatch } from "./session-backfill.js"; diff --git a/extensions/memory-core/src/session-backfill-gateway.ts b/extensions/memory-core/src/session-backfill-gateway.ts index 943940b76289..ab3c645cff9b 100644 --- a/extensions/memory-core/src/session-backfill-gateway.ts +++ b/extensions/memory-core/src/session-backfill-gateway.ts @@ -1,4 +1,3 @@ -import { listAgentIds } from "openclaw/plugin-sdk/agent-runtime"; import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/channel-actions"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { @@ -6,17 +5,14 @@ import { errorShape, type GatewayRequestHandlerOptions, } from "openclaw/plugin-sdk/gateway-runtime"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { listAgentIds } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import { resolveMemoryRemDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status"; import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; -import { - executeSessionBackfill, - executeSessionBackfillBatch, - normalizeSessionBackfillSelection, - type RunSessionBackfillParams, - type SessionBackfillResult, -} from "./session-backfill.js"; +import type { SessionBackfillResult } from "./session-backfill-contract.js"; +import { normalizeSessionBackfillSelection } from "./session-backfill-selection.js"; const SESSION_BACKFILL_GATEWAY_METHODS = { preview: "memory.sessionBackfill.preview", @@ -24,10 +20,12 @@ const SESSION_BACKFILL_GATEWAY_METHODS = { rollback: "memory.sessionBackfill.rollback", } as const; -type SessionBackfillGatewayParams = Pick< - RunSessionBackfillParams, - "agentId" | "from" | "to" | "limitDays" ->; +type SessionBackfillGatewayParams = { + agentId: string; + from?: string; + to?: string; + limitDays: number; +}; type SessionBackfillGatewayResult = { days: number; @@ -44,6 +42,10 @@ type SessionBackfillGatewayResult = { class InvalidSessionBackfillRequestError extends Error {} +const loadSessionBackfillGatewayRuntime = createLazyRuntimeModule( + () => import("./session-backfill-gateway.runtime.js"), +); + function paramsRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("params must be an object."); @@ -165,6 +167,7 @@ export function registerSessionBackfillGatewayMethods(api: OpenClawPluginApi): v } try { const context = resolveExecutionContext(api, request.agentId); + const { executeSessionBackfillBatch } = await loadSessionBackfillGatewayRuntime(); const execution = await executeSessionBackfillBatch({ ...request, ...context, @@ -203,6 +206,7 @@ export function registerSessionBackfillGatewayMethods(api: OpenClawPluginApi): v } try { const context = resolveExecutionContext(api, request.agentId); + const { executeSessionBackfill } = await loadSessionBackfillGatewayRuntime(); const result = await executeSessionBackfill({ ...request, ...context, rollback: true }); respond(true, { removedDiaryEntries: result.rollback?.removedDiaryEntries ?? 0, diff --git a/extensions/memory-core/src/session-backfill-lifecycle.ts b/extensions/memory-core/src/session-backfill-lifecycle.ts index 46d2444a1e2a..67e884c8f6f5 100644 --- a/extensions/memory-core/src/session-backfill-lifecycle.ts +++ b/extensions/memory-core/src/session-backfill-lifecycle.ts @@ -11,8 +11,6 @@ import type { } from "./session-backfill-contract.js"; import { readSessionIngestionState, writeSessionIngestionState } from "./session-ingestion.js"; -const DEFAULT_SESSION_BACKFILL_LIMIT_DAYS = 92; -const MEMORY_DAY_RE = /^\d{4}-\d{2}-\d{2}$/; // Batch keys are SHA-256 hex digests, so this colon-delimited marker cannot collide. const SESSION_BACKFILL_BASELINE_KEY_PREFIX = "complete-baseline:"; @@ -34,45 +32,6 @@ type SessionBackfillBaseline = { agentId: string; }; -function normalizeMemoryDay(value: string | undefined, flag: string): string | undefined { - if (value === undefined) { - return undefined; - } - const day = value.trim(); - if (!MEMORY_DAY_RE.test(day)) { - throw new Error(`${flag} must use YYYY-MM-DD.`); - } - const parsed = new Date(`${day}T00:00:00.000Z`); - if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== day) { - throw new Error(`${flag} must be a valid calendar day.`); - } - return day; -} - -export function normalizeSessionBackfillSelection( - params: { from?: string; to?: string; limitDays?: number }, - labels: { from: string; to: string; limitDays: string } = { - from: "--from", - to: "--to", - limitDays: "--limit-days", - }, -): { from?: string; to?: string; limitDays: number } { - const from = normalizeMemoryDay(params.from, labels.from); - const to = normalizeMemoryDay(params.to, labels.to); - if (from !== undefined && to !== undefined && from > to) { - throw new Error(`${labels.from} must not be after ${labels.to}.`); - } - const limitDays = params.limitDays ?? DEFAULT_SESSION_BACKFILL_LIMIT_DAYS; - if (!Number.isInteger(limitDays) || limitDays <= 0) { - throw new Error(`${labels.limitDays} must be a positive integer.`); - } - return { - ...(from !== undefined ? { from } : {}), - ...(to !== undefined ? { to } : {}), - limitDays, - }; -} - export async function recordSessionBackfillRewindBatch(params: { workspaceDir: string; candidates: SessionBackfillRewindCandidate[]; diff --git a/extensions/memory-core/src/session-backfill-selection.ts b/extensions/memory-core/src/session-backfill-selection.ts new file mode 100644 index 000000000000..d6514b275672 --- /dev/null +++ b/extensions/memory-core/src/session-backfill-selection.ts @@ -0,0 +1,41 @@ +const DEFAULT_SESSION_BACKFILL_LIMIT_DAYS = 92; +const MEMORY_DAY_RE = /^\d{4}-\d{2}-\d{2}$/; + +function normalizeMemoryDay(value: string | undefined, flag: string): string | undefined { + if (value === undefined) { + return undefined; + } + const day = value.trim(); + if (!MEMORY_DAY_RE.test(day)) { + throw new Error(`${flag} must use YYYY-MM-DD.`); + } + const parsed = new Date(`${day}T00:00:00.000Z`); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== day) { + throw new Error(`${flag} must be a valid calendar day.`); + } + return day; +} + +export function normalizeSessionBackfillSelection( + params: { from?: string; to?: string; limitDays?: number }, + labels: { from: string; to: string; limitDays: string } = { + from: "--from", + to: "--to", + limitDays: "--limit-days", + }, +): { from?: string; to?: string; limitDays: number } { + const from = normalizeMemoryDay(params.from, labels.from); + const to = normalizeMemoryDay(params.to, labels.to); + if (from !== undefined && to !== undefined && from > to) { + throw new Error(`${labels.from} must not be after ${labels.to}.`); + } + const limitDays = params.limitDays ?? DEFAULT_SESSION_BACKFILL_LIMIT_DAYS; + if (!Number.isInteger(limitDays) || limitDays <= 0) { + throw new Error(`${labels.limitDays} must be a positive integer.`); + } + return { + ...(from !== undefined ? { from } : {}), + ...(to !== undefined ? { to } : {}), + limitDays, + }; +} diff --git a/extensions/memory-core/src/session-backfill.ts b/extensions/memory-core/src/session-backfill.ts index 4d7a43f7753d..cccdd0d2ac5e 100644 --- a/extensions/memory-core/src/session-backfill.ts +++ b/extensions/memory-core/src/session-backfill.ts @@ -14,11 +14,11 @@ import type { import { drainSessionBackfill, markSessionBackfillRewindBaseline, - normalizeSessionBackfillSelection, recordSessionBackfillRewindBatch, resetSessionBackfillIngestionState, rewindSessionBackfillIngestionState, } from "./session-backfill-lifecycle.js"; +import { normalizeSessionBackfillSelection } from "./session-backfill-selection.js"; import { SESSION_INGESTION_MAX_MESSAGES_PER_FILE, SESSION_INGESTION_MAX_MESSAGES_PER_SWEEP, @@ -46,9 +46,6 @@ const SESSION_BACKFILL_QUERY_PREFIX = "__dreaming_session_backfill__"; const TOP_CANDIDATE_LIMIT = 5; const MAX_SESSION_BACKFILL_APPLY_BATCHES = 10_000; -export { normalizeSessionBackfillSelection } from "./session-backfill-lifecycle.js"; -export type { SessionBackfillResult } from "./session-backfill-contract.js"; - export type MemorySessionBackfillOptions = { agent?: string; from?: string; @@ -74,7 +71,7 @@ type SessionBackfillScan = { stateKey: string; }; -export type RunSessionBackfillParams = { +type RunSessionBackfillParams = { agentId: string; workspaceDir: string; from?: string; diff --git a/src/plugin-sdk/memory-core-host-runtime-core.ts b/src/plugin-sdk/memory-core-host-runtime-core.ts index 1fd8df39fe64..ece288003e8f 100644 --- a/src/plugin-sdk/memory-core-host-runtime-core.ts +++ b/src/plugin-sdk/memory-core-host-runtime-core.ts @@ -11,7 +11,11 @@ export { } from "../agents/tools/common.js"; export type { AnyAgentTool } from "../agents/tools/common.js"; export { resolveCronStyleNow } from "../agents/current-time.js"; -export { resolveDefaultAgentId, resolveSessionAgentIds } from "../agents/agent-scope.js"; +export { + listAgentIds, + resolveDefaultAgentId, + resolveSessionAgentIds, +} from "../agents/agent-scope.js"; export { resolveMemorySearchConfig } from "../agents/memory-search.js"; export { resolveMemoryDreamingPluginConfig } from "../memory-host-sdk/dreaming.js"; export { parseNonNegativeByteSize } from "../config/byte-size.js";