Skip to content

Commit 891c7d9

Browse files
authored
fix(active-memory): align recall timeout with hook runner
Fixes #72606.
1 parent f256eeb commit 891c7d9

12 files changed

Lines changed: 347 additions & 120 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
2020
- fix(security): block npm_execpath injection from workspace .env [AI-assisted]. (#73262) Thanks @pgondhi987.
2121
- Tools/web_fetch: decode response bodies from raw bytes using declared HTTP, XML, or HTML meta charsets before extraction, so Shift_JIS and other legacy-charset pages no longer return mojibake. Fixes #72916. Thanks @amknight.
2222
- Active Memory: skip payload-less `memory_search` transcript tool results when building debug telemetry, so newer empty entries no longer hide the latest useful debug payload. (#68773) Thanks @SimbaKingjoe.
23+
- Active Memory: keep recall setup time from consuming the configured model timeout while giving the hook runner an explicit bounded budget for the plugin, so slow embedded-run setup no longer causes immediate recall timeouts. Fixes #72606. (#72620) Thanks @hyspacex.
2324
- Channels/Discord: bound message read/search REST calls, route those actions through Gateway execution, and fall back to `CommandTargetSessionKey` for inbound hook session keys so Discord reads do not hang and hooks still fire when `SessionKey` is empty. Fixes #73431. (#73521) Thanks @amknight.
2425
- Plugins/media: auto-enable provider plugins referenced by `agents.defaults.imageGenerationModel`, `videoGenerationModel`, and `musicGenerationModel` primary/fallback refs, so configured Google and MiniMax media providers do not stay disabled behind a restrictive plugin allowlist. Thanks @vincentkoc.
2526
- Memory-core/dreaming: retry managed dreaming cron registration after startup when the cron service is not reachable yet, so the scheduled Memory Dreaming Promotion sweep recovers without waiting for heartbeat traffic. Fixes #72841. Thanks @amknight.

extensions/active-memory/index.test.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
3838

3939
describe("active-memory plugin", () => {
4040
const hooks: Record<string, Function> = {};
41+
const hookOptions: Record<string, Record<string, unknown> | undefined> = {};
4142
const registeredCommands: Record<string, any> = {};
4243
const runEmbeddedPiAgent = vi.fn();
4344
let stateDir = "";
@@ -105,8 +106,9 @@ describe("active-memory plugin", () => {
105106
registerCommand: vi.fn((command) => {
106107
registeredCommands[command.name] = command;
107108
}),
108-
on: vi.fn((hookName: string, handler: Function) => {
109+
on: vi.fn((hookName: string, handler: Function, opts?: Record<string, unknown>) => {
109110
hooks[hookName] = handler;
111+
hookOptions[hookName] = opts;
110112
}),
111113
};
112114
const getActiveMemoryLines = (sessionKey: string): string[] => {
@@ -159,6 +161,9 @@ describe("active-memory plugin", () => {
159161
for (const key of Object.keys(hooks)) {
160162
delete hooks[key];
161163
}
164+
for (const key of Object.keys(hookOptions)) {
165+
delete hookOptions[key];
166+
}
162167
for (const key of Object.keys(registeredCommands)) {
163168
delete registeredCommands[key];
164169
}
@@ -179,7 +184,10 @@ describe("active-memory plugin", () => {
179184
});
180185

181186
it("registers a before_prompt_build hook", () => {
182-
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
187+
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function), {
188+
timeoutMs: 150_000,
189+
});
190+
expect(hookOptions.before_prompt_build?.timeoutMs).toBe(150_000);
183191
});
184192

185193
it("runs recall without recording shared auth-profile failures", async () => {
@@ -567,7 +575,7 @@ describe("active-memory plugin", () => {
567575
agents: ["main"],
568576
allowedChatTypes: ["explicit"],
569577
};
570-
await plugin.register(api as unknown as OpenClawPluginApi);
578+
plugin.register(api as unknown as OpenClawPluginApi);
571579

572580
const result = await hooks.before_prompt_build(
573581
{ prompt: "what should i work on next?", messages: [] },
@@ -591,7 +599,7 @@ describe("active-memory plugin", () => {
591599
agents: ["main"],
592600
allowedChatTypes: ["explicit"],
593601
};
594-
await plugin.register(api as unknown as OpenClawPluginApi);
602+
plugin.register(api as unknown as OpenClawPluginApi);
595603

596604
const result = await hooks.before_prompt_build(
597605
{ prompt: "what should i work on next?", messages: [] },
@@ -2012,6 +2020,7 @@ describe("active-memory plugin", () => {
20122020

20132021
it("does not cache timeout results", async () => {
20142022
__testing.setMinimumTimeoutMsForTests(1);
2023+
__testing.setSetupGraceTimeoutMsForTests(0);
20152024
api.pluginConfig = {
20162025
agents: ["main"],
20172026
timeoutMs: 1,
@@ -2096,6 +2105,7 @@ describe("active-memory plugin", () => {
20962105

20972106
it("ignores late subagent payloads once the active-memory timeout signal has fired", async () => {
20982107
__testing.setMinimumTimeoutMsForTests(1);
2108+
__testing.setSetupGraceTimeoutMsForTests(0);
20992109
api.pluginConfig = {
21002110
agents: ["main"],
21012111
timeoutMs: 1,
@@ -2134,10 +2144,44 @@ describe("active-memory plugin", () => {
21342144
).toBe(true);
21352145
});
21362146

2147+
it("does not spend the model timeout budget on active-memory subagent setup", async () => {
2148+
const CONFIGURED_TIMEOUT_MS = 10;
2149+
__testing.setMinimumTimeoutMsForTests(1);
2150+
__testing.setSetupGraceTimeoutMsForTests(100);
2151+
api.pluginConfig = {
2152+
agents: ["main"],
2153+
timeoutMs: CONFIGURED_TIMEOUT_MS,
2154+
logging: true,
2155+
};
2156+
plugin.register(api as unknown as OpenClawPluginApi);
2157+
runEmbeddedPiAgent.mockImplementationOnce(async () => {
2158+
await new Promise((resolve) => setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 30));
2159+
return { payloads: [{ text: "remember the ramen place" }] };
2160+
});
2161+
2162+
const result = await hooks.before_prompt_build(
2163+
{ prompt: "what wings should i order? setup grace", messages: [] },
2164+
{
2165+
agentId: "main",
2166+
trigger: "user",
2167+
sessionKey: "agent:main:setup-grace",
2168+
messageProvider: "webchat",
2169+
},
2170+
);
2171+
2172+
expect(result?.prependContext).toContain("remember the ramen place");
2173+
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.timeoutMs).toBe(CONFIGURED_TIMEOUT_MS);
2174+
const infoLines = vi
2175+
.mocked(api.logger.info)
2176+
.mock.calls.map((call: unknown[]) => String(call[0]));
2177+
expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(false);
2178+
});
2179+
21372180
it("returns timeout within a hard deadline even when the subagent never checks the abort signal", async () => {
21382181
const CONFIGURED_TIMEOUT_MS = 200;
21392182
const MARGIN_MS = 500;
21402183
__testing.setMinimumTimeoutMsForTests(1);
2184+
__testing.setSetupGraceTimeoutMsForTests(0);
21412185
api.pluginConfig = {
21422186
agents: ["main"],
21432187
timeoutMs: CONFIGURED_TIMEOUT_MS,

extensions/active-memory/index.ts

Lines changed: 109 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const DEFAULT_CACHE_TTL_MS = 15_000;
3535
const DEFAULT_MAX_CACHE_ENTRIES = 1000;
3636
const CACHE_SWEEP_INTERVAL_MS = 1000;
3737
const DEFAULT_MIN_TIMEOUT_MS = 250;
38+
const DEFAULT_SETUP_GRACE_TIMEOUT_MS = 30_000;
3839
const DEFAULT_QUERY_MODE = "recent" as const;
3940
const DEFAULT_QMD_SEARCH_MODE = "search" as const;
4041
const DEFAULT_TRANSCRIPT_DIR = "active-memory";
@@ -216,6 +217,7 @@ type AsyncLock = <T>(task: () => Promise<T>) => Promise<T>;
216217
const toggleStoreLocks = new Map<string, AsyncLock>();
217218
let lastActiveRecallCacheSweepAt = 0;
218219
let minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
220+
let setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
219221

220222
function createAsyncLock(): AsyncLock {
221223
let lock: Promise<void> = Promise.resolve();
@@ -2188,9 +2190,10 @@ async function maybeResolveActiveRecall(params: {
21882190
const controller = new AbortController();
21892191
const TIMEOUT_SENTINEL = Symbol("timeout");
21902192
let sessionFile: string | undefined;
2193+
const watchdogTimeoutMs = params.config.timeoutMs + setupGraceTimeoutMs;
21912194
const timeoutId = setTimeout(() => {
2192-
controller.abort(new Error(`active-memory timeout after ${params.config.timeoutMs}ms`));
2193-
}, params.config.timeoutMs);
2195+
controller.abort(new Error(`active-memory timeout after ${watchdogTimeoutMs}ms`));
2196+
}, watchdogTimeoutMs);
21942197
timeoutId.unref?.();
21952198

21962199
const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
@@ -2428,109 +2431,114 @@ export default definePluginEntry({
24282431
},
24292432
});
24302433

2431-
api.on("before_prompt_build", async (event, ctx) => {
2432-
try {
2433-
refreshLiveConfigFromRuntime();
2434-
const resolvedAgentId = resolveStatusUpdateAgentId(ctx);
2435-
const resolvedSessionKey =
2436-
ctx.sessionKey?.trim() ||
2437-
(resolvedAgentId
2438-
? resolveCanonicalSessionKeyFromSessionId({
2439-
api,
2440-
agentId: resolvedAgentId,
2441-
sessionId: ctx.sessionId,
2442-
})
2443-
: undefined);
2444-
const effectiveAgentId =
2445-
resolvedAgentId || resolveStatusUpdateAgentId({ sessionKey: resolvedSessionKey });
2446-
if (await isSessionActiveMemoryDisabled({ api, sessionKey: resolvedSessionKey })) {
2447-
await persistPluginStatusLines({
2448-
api,
2449-
agentId: effectiveAgentId,
2450-
sessionKey: resolvedSessionKey,
2451-
});
2452-
return undefined;
2453-
}
2454-
if (!isEnabledForAgent(config, effectiveAgentId)) {
2455-
await persistPluginStatusLines({
2456-
api,
2457-
agentId: effectiveAgentId,
2458-
sessionKey: resolvedSessionKey,
2459-
});
2460-
return undefined;
2461-
}
2462-
if (!isEligibleInteractiveSession(ctx)) {
2463-
await persistPluginStatusLines({
2464-
api,
2465-
agentId: effectiveAgentId,
2466-
sessionKey: resolvedSessionKey,
2434+
const beforePromptBuildTimeoutMs = 120_000 + setupGraceTimeoutMs;
2435+
api.on(
2436+
"before_prompt_build",
2437+
async (event, ctx) => {
2438+
try {
2439+
refreshLiveConfigFromRuntime();
2440+
const resolvedAgentId = resolveStatusUpdateAgentId(ctx);
2441+
const resolvedSessionKey =
2442+
ctx.sessionKey?.trim() ||
2443+
(resolvedAgentId
2444+
? resolveCanonicalSessionKeyFromSessionId({
2445+
api,
2446+
agentId: resolvedAgentId,
2447+
sessionId: ctx.sessionId,
2448+
})
2449+
: undefined);
2450+
const effectiveAgentId =
2451+
resolvedAgentId || resolveStatusUpdateAgentId({ sessionKey: resolvedSessionKey });
2452+
if (await isSessionActiveMemoryDisabled({ api, sessionKey: resolvedSessionKey })) {
2453+
await persistPluginStatusLines({
2454+
api,
2455+
agentId: effectiveAgentId,
2456+
sessionKey: resolvedSessionKey,
2457+
});
2458+
return undefined;
2459+
}
2460+
if (!isEnabledForAgent(config, effectiveAgentId)) {
2461+
await persistPluginStatusLines({
2462+
api,
2463+
agentId: effectiveAgentId,
2464+
sessionKey: resolvedSessionKey,
2465+
});
2466+
return undefined;
2467+
}
2468+
if (!isEligibleInteractiveSession(ctx)) {
2469+
await persistPluginStatusLines({
2470+
api,
2471+
agentId: effectiveAgentId,
2472+
sessionKey: resolvedSessionKey,
2473+
});
2474+
return undefined;
2475+
}
2476+
if (
2477+
!isAllowedChatType(config, {
2478+
...ctx,
2479+
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
2480+
mainKey: api.config.session?.mainKey,
2481+
})
2482+
) {
2483+
await persistPluginStatusLines({
2484+
api,
2485+
agentId: effectiveAgentId,
2486+
sessionKey: resolvedSessionKey,
2487+
});
2488+
return undefined;
2489+
}
2490+
if (
2491+
!isAllowedChatId(config, {
2492+
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
2493+
messageProvider: ctx.messageProvider,
2494+
})
2495+
) {
2496+
await persistPluginStatusLines({
2497+
api,
2498+
agentId: effectiveAgentId,
2499+
sessionKey: resolvedSessionKey,
2500+
});
2501+
return undefined;
2502+
}
2503+
const query = buildQuery({
2504+
latestUserMessage: event.prompt,
2505+
recentTurns: extractRecentTurns(event.messages),
2506+
config,
24672507
});
2468-
return undefined;
2469-
}
2470-
if (
2471-
!isAllowedChatType(config, {
2472-
...ctx,
2473-
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
2474-
mainKey: api.config.session?.mainKey,
2475-
})
2476-
) {
2477-
await persistPluginStatusLines({
2508+
const result = await maybeResolveActiveRecall({
24782509
api,
2510+
config,
24792511
agentId: effectiveAgentId,
24802512
sessionKey: resolvedSessionKey,
2481-
});
2482-
return undefined;
2483-
}
2484-
if (
2485-
!isAllowedChatId(config, {
2486-
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
2513+
sessionId: ctx.sessionId,
24872514
messageProvider: ctx.messageProvider,
2488-
})
2489-
) {
2490-
await persistPluginStatusLines({
2491-
api,
2492-
agentId: effectiveAgentId,
2493-
sessionKey: resolvedSessionKey,
2515+
channelId: ctx.channelId,
2516+
query,
2517+
currentModelProviderId: ctx.modelProviderId,
2518+
currentModelId: ctx.modelId,
24942519
});
2520+
if (!result.summary) {
2521+
return undefined;
2522+
}
2523+
const promptPrefix = buildPromptPrefix(result.summary);
2524+
if (!promptPrefix) {
2525+
return undefined;
2526+
}
2527+
return {
2528+
prependContext: promptPrefix,
2529+
};
2530+
} catch (error) {
2531+
const message = toSingleLineLogValue(
2532+
error instanceof Error ? error.message : String(error),
2533+
);
2534+
api.logger.warn?.(
2535+
`active-memory: before_prompt_build failed, skipping memory lookup: ${message}`,
2536+
);
24952537
return undefined;
24962538
}
2497-
const query = buildQuery({
2498-
latestUserMessage: event.prompt,
2499-
recentTurns: extractRecentTurns(event.messages),
2500-
config,
2501-
});
2502-
const result = await maybeResolveActiveRecall({
2503-
api,
2504-
config,
2505-
agentId: effectiveAgentId,
2506-
sessionKey: resolvedSessionKey,
2507-
sessionId: ctx.sessionId,
2508-
messageProvider: ctx.messageProvider,
2509-
channelId: ctx.channelId,
2510-
query,
2511-
currentModelProviderId: ctx.modelProviderId,
2512-
currentModelId: ctx.modelId,
2513-
});
2514-
if (!result.summary) {
2515-
return undefined;
2516-
}
2517-
const promptPrefix = buildPromptPrefix(result.summary);
2518-
if (!promptPrefix) {
2519-
return undefined;
2520-
}
2521-
return {
2522-
prependContext: promptPrefix,
2523-
};
2524-
} catch (error) {
2525-
const message = toSingleLineLogValue(
2526-
error instanceof Error ? error.message : String(error),
2527-
);
2528-
api.logger.warn?.(
2529-
`active-memory: before_prompt_build failed, skipping memory lookup: ${message}`,
2530-
);
2531-
return undefined;
2532-
}
2533-
});
2539+
},
2540+
{ timeoutMs: beforePromptBuildTimeoutMs },
2541+
);
25342542
},
25352543
});
25362544

@@ -2548,9 +2556,13 @@ export const __testing = {
25482556
activeRecallCache.clear();
25492557
lastActiveRecallCacheSweepAt = 0;
25502558
minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
2559+
setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
25512560
},
25522561
setMinimumTimeoutMsForTests(value: number) {
25532562
minimumTimeoutMs = value;
25542563
},
2564+
setSetupGraceTimeoutMsForTests(value: number) {
2565+
setupGraceTimeoutMs = Math.max(0, Math.floor(value));
2566+
},
25552567
setCachedResult,
25562568
};

src/plugins/hook-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,5 +938,6 @@ export type PluginHookRegistration<K extends PluginHookName = PluginHookName> =
938938
hookName: K;
939939
handler: PluginHookHandlerMap[K];
940940
priority?: number;
941+
timeoutMs?: number;
941942
source: string;
942943
};

0 commit comments

Comments
 (0)