Skip to content

Commit 4203f28

Browse files
committed
fix(active-memory): align recall timeout with hook runner
1 parent dd643c8 commit 4203f28

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
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
1919
- fix(security): block npm_execpath injection from workspace .env [AI-assisted]. (#73262) Thanks @pgondhi987.
2020
- 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.
2121
- 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.
22+
- 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.
2223
- 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.
2324
- 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.
2425
- 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: [] },
@@ -2007,6 +2015,7 @@ describe("active-memory plugin", () => {
20072015

20082016
it("does not cache timeout results", async () => {
20092017
__testing.setMinimumTimeoutMsForTests(1);
2018+
__testing.setSetupGraceTimeoutMsForTests(0);
20102019
api.pluginConfig = {
20112020
agents: ["main"],
20122021
timeoutMs: 1,
@@ -2091,6 +2100,7 @@ describe("active-memory plugin", () => {
20912100

20922101
it("ignores late subagent payloads once the active-memory timeout signal has fired", async () => {
20932102
__testing.setMinimumTimeoutMsForTests(1);
2103+
__testing.setSetupGraceTimeoutMsForTests(0);
20942104
api.pluginConfig = {
20952105
agents: ["main"],
20962106
timeoutMs: 1,
@@ -2129,10 +2139,44 @@ describe("active-memory plugin", () => {
21292139
).toBe(true);
21302140
});
21312141

2142+
it("does not spend the model timeout budget on active-memory subagent setup", async () => {
2143+
const CONFIGURED_TIMEOUT_MS = 10;
2144+
__testing.setMinimumTimeoutMsForTests(1);
2145+
__testing.setSetupGraceTimeoutMsForTests(100);
2146+
api.pluginConfig = {
2147+
agents: ["main"],
2148+
timeoutMs: CONFIGURED_TIMEOUT_MS,
2149+
logging: true,
2150+
};
2151+
plugin.register(api as unknown as OpenClawPluginApi);
2152+
runEmbeddedPiAgent.mockImplementationOnce(async () => {
2153+
await new Promise((resolve) => setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 30));
2154+
return { payloads: [{ text: "remember the ramen place" }] };
2155+
});
2156+
2157+
const result = await hooks.before_prompt_build(
2158+
{ prompt: "what wings should i order? setup grace", messages: [] },
2159+
{
2160+
agentId: "main",
2161+
trigger: "user",
2162+
sessionKey: "agent:main:setup-grace",
2163+
messageProvider: "webchat",
2164+
},
2165+
);
2166+
2167+
expect(result?.prependContext).toContain("remember the ramen place");
2168+
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.timeoutMs).toBe(CONFIGURED_TIMEOUT_MS);
2169+
const infoLines = vi
2170+
.mocked(api.logger.info)
2171+
.mock.calls.map((call: unknown[]) => String(call[0]));
2172+
expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(false);
2173+
});
2174+
21322175
it("returns timeout within a hard deadline even when the subagent never checks the abort signal", async () => {
21332176
const CONFIGURED_TIMEOUT_MS = 200;
21342177
const MARGIN_MS = 500;
21352178
__testing.setMinimumTimeoutMsForTests(1);
2179+
__testing.setSetupGraceTimeoutMsForTests(0);
21362180
api.pluginConfig = {
21372181
agents: ["main"],
21382182
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();
@@ -2182,9 +2184,10 @@ async function maybeResolveActiveRecall(params: {
21822184
const controller = new AbortController();
21832185
const TIMEOUT_SENTINEL = Symbol("timeout");
21842186
let sessionFile: string | undefined;
2187+
const watchdogTimeoutMs = params.config.timeoutMs + setupGraceTimeoutMs;
21852188
const timeoutId = setTimeout(() => {
2186-
controller.abort(new Error(`active-memory timeout after ${params.config.timeoutMs}ms`));
2187-
}, params.config.timeoutMs);
2189+
controller.abort(new Error(`active-memory timeout after ${watchdogTimeoutMs}ms`));
2190+
}, watchdogTimeoutMs);
21882191
timeoutId.unref?.();
21892192

21902193
const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
@@ -2422,109 +2425,114 @@ export default definePluginEntry({
24222425
},
24232426
});
24242427

2425-
api.on("before_prompt_build", async (event, ctx) => {
2426-
try {
2427-
refreshLiveConfigFromRuntime();
2428-
const resolvedAgentId = resolveStatusUpdateAgentId(ctx);
2429-
const resolvedSessionKey =
2430-
ctx.sessionKey?.trim() ||
2431-
(resolvedAgentId
2432-
? resolveCanonicalSessionKeyFromSessionId({
2433-
api,
2434-
agentId: resolvedAgentId,
2435-
sessionId: ctx.sessionId,
2436-
})
2437-
: undefined);
2438-
const effectiveAgentId =
2439-
resolvedAgentId || resolveStatusUpdateAgentId({ sessionKey: resolvedSessionKey });
2440-
if (await isSessionActiveMemoryDisabled({ api, sessionKey: resolvedSessionKey })) {
2441-
await persistPluginStatusLines({
2442-
api,
2443-
agentId: effectiveAgentId,
2444-
sessionKey: resolvedSessionKey,
2445-
});
2446-
return undefined;
2447-
}
2448-
if (!isEnabledForAgent(config, effectiveAgentId)) {
2449-
await persistPluginStatusLines({
2450-
api,
2451-
agentId: effectiveAgentId,
2452-
sessionKey: resolvedSessionKey,
2453-
});
2454-
return undefined;
2455-
}
2456-
if (!isEligibleInteractiveSession(ctx)) {
2457-
await persistPluginStatusLines({
2458-
api,
2459-
agentId: effectiveAgentId,
2460-
sessionKey: resolvedSessionKey,
2428+
const beforePromptBuildTimeoutMs = 120_000 + setupGraceTimeoutMs;
2429+
api.on(
2430+
"before_prompt_build",
2431+
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,
2467+
});
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({
2478+
api,
2479+
agentId: effectiveAgentId,
2480+
sessionKey: resolvedSessionKey,
2481+
});
2482+
return undefined;
2483+
}
2484+
if (
2485+
!isAllowedChatId(config, {
2486+
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
2487+
messageProvider: ctx.messageProvider,
2488+
})
2489+
) {
2490+
await persistPluginStatusLines({
2491+
api,
2492+
agentId: effectiveAgentId,
2493+
sessionKey: resolvedSessionKey,
2494+
});
2495+
return undefined;
2496+
}
2497+
const query = buildQuery({
2498+
latestUserMessage: event.prompt,
2499+
recentTurns: extractRecentTurns(event.messages),
2500+
config,
24612501
});
2462-
return undefined;
2463-
}
2464-
if (
2465-
!isAllowedChatType(config, {
2466-
...ctx,
2467-
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
2468-
mainKey: api.config.session?.mainKey,
2469-
})
2470-
) {
2471-
await persistPluginStatusLines({
2502+
const result = await maybeResolveActiveRecall({
24722503
api,
2504+
config,
24732505
agentId: effectiveAgentId,
24742506
sessionKey: resolvedSessionKey,
2475-
});
2476-
return undefined;
2477-
}
2478-
if (
2479-
!isAllowedChatId(config, {
2480-
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
2507+
sessionId: ctx.sessionId,
24812508
messageProvider: ctx.messageProvider,
2482-
})
2483-
) {
2484-
await persistPluginStatusLines({
2485-
api,
2486-
agentId: effectiveAgentId,
2487-
sessionKey: resolvedSessionKey,
2509+
channelId: ctx.channelId,
2510+
query,
2511+
currentModelProviderId: ctx.modelProviderId,
2512+
currentModelId: ctx.modelId,
24882513
});
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+
);
24892531
return undefined;
24902532
}
2491-
const query = buildQuery({
2492-
latestUserMessage: event.prompt,
2493-
recentTurns: extractRecentTurns(event.messages),
2494-
config,
2495-
});
2496-
const result = await maybeResolveActiveRecall({
2497-
api,
2498-
config,
2499-
agentId: effectiveAgentId,
2500-
sessionKey: resolvedSessionKey,
2501-
sessionId: ctx.sessionId,
2502-
messageProvider: ctx.messageProvider,
2503-
channelId: ctx.channelId,
2504-
query,
2505-
currentModelProviderId: ctx.modelProviderId,
2506-
currentModelId: ctx.modelId,
2507-
});
2508-
if (!result.summary) {
2509-
return undefined;
2510-
}
2511-
const promptPrefix = buildPromptPrefix(result.summary);
2512-
if (!promptPrefix) {
2513-
return undefined;
2514-
}
2515-
return {
2516-
prependContext: promptPrefix,
2517-
};
2518-
} catch (error) {
2519-
const message = toSingleLineLogValue(
2520-
error instanceof Error ? error.message : String(error),
2521-
);
2522-
api.logger.warn?.(
2523-
`active-memory: before_prompt_build failed, skipping memory lookup: ${message}`,
2524-
);
2525-
return undefined;
2526-
}
2527-
});
2533+
},
2534+
{ timeoutMs: beforePromptBuildTimeoutMs },
2535+
);
25282536
},
25292537
});
25302538

@@ -2542,9 +2550,13 @@ export const __testing = {
25422550
activeRecallCache.clear();
25432551
lastActiveRecallCacheSweepAt = 0;
25442552
minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
2553+
setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
25452554
},
25462555
setMinimumTimeoutMsForTests(value: number) {
25472556
minimumTimeoutMs = value;
25482557
},
2558+
setSetupGraceTimeoutMsForTests(value: number) {
2559+
setupGraceTimeoutMs = Math.max(0, Math.floor(value));
2560+
},
25492561
setCachedResult,
25502562
};

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)