Skip to content

Commit d55c7ea

Browse files
committed
fix(plugins): bound prompt memory recall latency
1 parent 5de284c commit d55c7ea

4 files changed

Lines changed: 213 additions & 10 deletions

File tree

extensions/memory-lancedb/index.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,102 @@ describe("memory plugin e2e", () => {
602602
}
603603
});
604604

605+
test("bounds auto-recall latency during prompt build", async () => {
606+
vi.useFakeTimers();
607+
const post = vi.fn(() => new Promise(() => undefined));
608+
const ensureGlobalUndiciEnvProxyDispatcher = vi.fn();
609+
const loadLanceDbModule = vi.fn(async () => ({
610+
connect: vi.fn(async () => ({
611+
tableNames: vi.fn(async () => ["memories"]),
612+
openTable: vi.fn(async () => ({
613+
vectorSearch: vi.fn(() => ({ limit: vi.fn(() => ({ toArray: vi.fn(async () => []) })) })),
614+
countRows: vi.fn(async () => 0),
615+
add: vi.fn(async () => undefined),
616+
delete: vi.fn(async () => undefined),
617+
})),
618+
})),
619+
}));
620+
621+
vi.resetModules();
622+
vi.doMock("openclaw/plugin-sdk/runtime-env", () => ({
623+
ensureGlobalUndiciEnvProxyDispatcher,
624+
}));
625+
vi.doMock("openai", () => ({
626+
default: class MockOpenAI {
627+
post = post;
628+
},
629+
}));
630+
vi.doMock("./lancedb-runtime.js", () => ({
631+
loadLanceDbModule,
632+
}));
633+
634+
try {
635+
const { default: dynamicMemoryPlugin } = await import("./index.js");
636+
const on = vi.fn();
637+
const logger = {
638+
info: vi.fn(),
639+
warn: vi.fn(),
640+
error: vi.fn(),
641+
debug: vi.fn(),
642+
};
643+
const mockApi = {
644+
id: "memory-lancedb",
645+
name: "Memory (LanceDB)",
646+
source: "test",
647+
config: {},
648+
pluginConfig: {
649+
embedding: {
650+
apiKey: OPENAI_API_KEY,
651+
model: "text-embedding-3-small",
652+
},
653+
dbPath: getDbPath(),
654+
autoCapture: false,
655+
autoRecall: true,
656+
},
657+
runtime: {},
658+
logger,
659+
registerTool: vi.fn(),
660+
registerCli: vi.fn(),
661+
registerService: vi.fn(),
662+
on,
663+
resolvePath: (p: string) => p,
664+
};
665+
666+
dynamicMemoryPlugin.register(mockApi as any);
667+
668+
const beforePromptBuild = on.mock.calls.find(
669+
([hookName]) => hookName === "before_prompt_build",
670+
)?.[1];
671+
expect(beforePromptBuild).toBeTypeOf("function");
672+
673+
const resultPromise = beforePromptBuild?.(
674+
{ prompt: "what editor should i use?", messages: [] },
675+
{},
676+
);
677+
await vi.advanceTimersByTimeAsync(15_000);
678+
679+
await expect(resultPromise).resolves.toBeUndefined();
680+
expect(ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce();
681+
expect(post).toHaveBeenCalledWith(
682+
"/embeddings",
683+
expect.objectContaining({
684+
maxRetries: 0,
685+
timeout: 15_000,
686+
}),
687+
);
688+
expect(loadLanceDbModule).not.toHaveBeenCalled();
689+
expect(logger.warn).toHaveBeenCalledWith(
690+
"memory-lancedb: auto-recall timed out after 15000ms; skipping memory injection to avoid stalling agent startup",
691+
);
692+
} finally {
693+
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
694+
vi.doUnmock("openai");
695+
vi.doUnmock("./lancedb-runtime.js");
696+
vi.resetModules();
697+
vi.useRealTimers();
698+
}
699+
});
700+
605701
test("uses live runtime config to enable auto-recall after startup disable", async () => {
606702
const embeddingsCreate = vi.fn(async () => ({
607703
data: [{ embedding: [0.1, 0.2, 0.3] }],

extensions/memory-lancedb/index.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ function resolveAutoCaptureStartIndex(
149149
// ============================================================================
150150

151151
const TABLE_NAME = "memories";
152+
const DEFAULT_AUTO_RECALL_TIMEOUT_MS = 15_000;
152153

153154
class MemoryDB {
154155
private db: LanceDB.Connection | null = null;
@@ -262,7 +263,7 @@ class MemoryDB {
262263
// ============================================================================
263264

264265
type Embeddings = {
265-
embed(text: string): Promise<number[]>;
266+
embed(text: string, options?: { timeoutMs?: number }): Promise<number[]>;
266267
};
267268

268269
class OpenAiCompatibleEmbeddings implements Embeddings {
@@ -277,7 +278,7 @@ class OpenAiCompatibleEmbeddings implements Embeddings {
277278
this.client = new OpenAI({ apiKey, baseURL: baseUrl });
278279
}
279280

280-
async embed(text: string): Promise<number[]> {
281+
async embed(text: string, options?: { timeoutMs?: number }): Promise<number[]> {
281282
const params: OpenAI.EmbeddingCreateParams = {
282283
model: this.model,
283284
input: text,
@@ -292,6 +293,7 @@ class OpenAiCompatibleEmbeddings implements Embeddings {
292293
// transport and normalize the response ourselves.
293294
const response = await this.client.post<EmbeddingCreateResponse>("/embeddings", {
294295
body: params,
296+
...(options?.timeoutMs ? { timeout: options.timeoutMs, maxRetries: 0 } : {}),
295297
});
296298
return normalizeEmbeddingVector(response.data?.[0]?.embedding);
297299
}
@@ -353,6 +355,32 @@ class ProviderAdapterEmbeddings implements Embeddings {
353355
}
354356
}
355357

358+
async function runWithTimeout<T>(params: {
359+
timeoutMs: number;
360+
task: () => Promise<T>;
361+
}): Promise<{ status: "ok"; value: T } | { status: "timeout" }> {
362+
let timeout: ReturnType<typeof setTimeout> | undefined;
363+
const TIMEOUT = Symbol("timeout");
364+
const timeoutPromise = new Promise<typeof TIMEOUT>((resolve) => {
365+
timeout = setTimeout(() => resolve(TIMEOUT), params.timeoutMs);
366+
timeout.unref?.();
367+
});
368+
const taskPromise = params.task();
369+
taskPromise.catch(() => undefined);
370+
371+
try {
372+
const result = await Promise.race([taskPromise, timeoutPromise]);
373+
if (result === TIMEOUT) {
374+
return { status: "timeout" };
375+
}
376+
return { status: "ok", value: result };
377+
} finally {
378+
if (timeout) {
379+
clearTimeout(timeout);
380+
}
381+
}
382+
}
383+
356384
function createEmbeddings(api: OpenClawPluginApi, cfg: MemoryConfig): Embeddings {
357385
const { provider, model, dimensions, apiKey, baseUrl } = cfg.embedding;
358386
if (provider === "openai" && apiKey) {
@@ -818,8 +846,22 @@ export default definePluginEntry({
818846
event.prompt,
819847
currentCfg.recallMaxChars,
820848
);
821-
const vector = await embeddings.embed(recallQuery);
822-
const results = await db.search(vector, 3, 0.3);
849+
const recall = await runWithTimeout({
850+
timeoutMs: DEFAULT_AUTO_RECALL_TIMEOUT_MS,
851+
task: async () => {
852+
const vector = await embeddings.embed(recallQuery, {
853+
timeoutMs: DEFAULT_AUTO_RECALL_TIMEOUT_MS,
854+
});
855+
return await db.search(vector, 3, 0.3);
856+
},
857+
});
858+
if (recall.status === "timeout") {
859+
api.logger.warn?.(
860+
`memory-lancedb: auto-recall timed out after ${DEFAULT_AUTO_RECALL_TIMEOUT_MS}ms; skipping memory injection to avoid stalling agent startup`,
861+
);
862+
return undefined;
863+
}
864+
const results = recall.value;
823865

824866
if (results.length === 0) {
825867
return undefined;

src/plugins/hooks.model-override-wiring.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,44 @@ describe("model override pipeline wiring", () => {
226226
expectedPrependContext,
227227
});
228228
});
229+
230+
it("skips timed-out handlers and continues", async () => {
231+
vi.useFakeTimers();
232+
try {
233+
addBeforePromptBuildHook(
234+
registry,
235+
"slow-plugin",
236+
() => new Promise<PluginHookBeforePromptBuildResult>(() => undefined),
237+
10,
238+
);
239+
addBeforePromptBuildHook(registry, "fast-plugin", () => ({ prependContext: "fast" }), 1);
240+
const logger = {
241+
error: vi.fn(),
242+
warn: vi.fn(),
243+
info: vi.fn(),
244+
debug: vi.fn(),
245+
};
246+
const runner = createHookRunner(registry, {
247+
logger,
248+
modifyingHookTimeoutMsByHook: { before_prompt_build: 5 },
249+
});
250+
251+
const resultPromise = runner.runBeforePromptBuild(
252+
{ prompt: "test", messages: [] },
253+
stubCtx,
254+
);
255+
await vi.advanceTimersByTimeAsync(5);
256+
257+
await expect(resultPromise).resolves.toEqual({ prependContext: "fast" });
258+
expect(logger.error).toHaveBeenCalledWith(
259+
expect.stringContaining(
260+
"[hooks] before_prompt_build handler from slow-plugin failed: timed out after 5ms",
261+
),
262+
);
263+
} finally {
264+
vi.useRealTimers();
265+
}
266+
});
229267
});
230268

231269
describe("graceful degradation + hook detection", () => {

src/plugins/hooks.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,19 @@ export type HookRunnerOptions = {
160160
* the runner continues, but the plugin's underlying work is not cancelled.
161161
*/
162162
voidHookTimeoutMsByHook?: Partial<Record<PluginHookName, number>>;
163+
/**
164+
* Optional timeout for modifying hooks. A timed-out hook is logged and skipped,
165+
* but the plugin's underlying work is not cancelled.
166+
*/
167+
modifyingHookTimeoutMsByHook?: Partial<Record<PluginHookName, number>>;
163168
};
164169

165170
const DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, number>> = {
166171
agent_end: 30_000,
167172
};
173+
const DEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, number>> = {
174+
before_prompt_build: 15_000,
175+
};
168176

169177
type ModifyingHookPolicy<K extends PluginHookName, TResult> = {
170178
mergeResults?: (
@@ -236,6 +244,10 @@ export function createHookRunner(
236244
...DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK,
237245
...options.voidHookTimeoutMsByHook,
238246
};
247+
const modifyingHookTimeoutMsByHook = {
248+
...DEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK,
249+
...options.modifyingHookTimeoutMsByHook,
250+
};
239251

240252
const shouldCatchHookErrors = (hookName: PluginHookName): boolean =>
241253
catchErrors && (failurePolicyByHook[hookName] ?? "fail-open") === "fail-open";
@@ -385,13 +397,27 @@ export function createHookRunner(
385397
return Math.floor(timeoutMs);
386398
};
387399

388-
const withVoidHookTimeout = async <T>(promise: Promise<T>, timeoutMs: number): Promise<T> => {
400+
const getModifyingHookTimeoutMs = (hookName: PluginHookName): number | undefined => {
401+
const timeoutMs = modifyingHookTimeoutMsByHook[hookName];
402+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
403+
return undefined;
404+
}
405+
return Math.floor(timeoutMs);
406+
};
407+
408+
const withHookTimeout = async <T>(
409+
promise: Promise<T>,
410+
timeoutMs: number,
411+
options: { unref?: boolean } = {},
412+
): Promise<T> => {
389413
let timer: ReturnType<typeof setTimeout> | undefined;
390414
const timeout = new Promise<never>((_, reject) => {
391415
timer = setTimeout(() => {
392416
reject(new Error(`timed out after ${timeoutMs}ms`));
393417
}, timeoutMs);
394-
timer.unref?.();
418+
if (options.unref) {
419+
timer.unref?.();
420+
}
395421
});
396422

397423
try {
@@ -435,7 +461,7 @@ export function createHookRunner(
435461
);
436462
const timeoutMs = getVoidHookTimeoutMs(hookName);
437463
if (timeoutMs) {
438-
await withVoidHookTimeout(promise, timeoutMs);
464+
await withHookTimeout(promise, timeoutMs, { unref: true });
439465
} else {
440466
await promise;
441467
}
@@ -468,9 +494,10 @@ export function createHookRunner(
468494

469495
for (const hook of hooks) {
470496
try {
471-
const handlerResult = await (
472-
hook.handler as (event: unknown, ctx: unknown) => Promise<TResult>
473-
)(event, ctx);
497+
const handler = hook.handler as (event: unknown, ctx: unknown) => Promise<TResult>;
498+
const promise = Promise.resolve(handler(event, ctx));
499+
const timeoutMs = getModifyingHookTimeoutMs(hookName);
500+
const handlerResult = timeoutMs ? await withHookTimeout(promise, timeoutMs) : await promise;
474501

475502
if (handlerResult !== undefined && handlerResult !== null) {
476503
if (policy.mergeResults) {

0 commit comments

Comments
 (0)