Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2632,20 +2632,22 @@ const memoryLanceDBProPlugin = {
// Auto-capture: analyze and store important information after agent ends
if (config.autoCapture !== false) {
type AgentEndAutoCaptureHook = {
(event: any, ctx: any): void;
(event: any, ctx: any): Promise<void> | void;
__lastRun?: Promise<void>;
};

const agentEndAutoCaptureHook: AgentEndAutoCaptureHook = (event, ctx) => {
const agentEndAutoCaptureHook: AgentEndAutoCaptureHook = async (event, ctx) => {
if (!event.success || !event.messages || event.messages.length === 0) {
return;
}

// Fire-and-forget: run capture work in the background so the hook
// returns immediately and does not hold the session lock. Blocking
// here causes downstream channel deliveries (e.g. Telegram) to be
// silently dropped when the session store lock times out.
// See: https://github.com/CortexReach/memory-lancedb-pro/issues/260
// Await capture with a 15 s safety timeout so that memory writes
// are not silently dropped when the host agent exits immediately
// after this hook fires. The timeout prevents the hook from holding
// the session lock indefinitely (which would stall downstream channel
// deliveries — see #260). If the timeout fires first, the capture
// promise is still running but the hook returns, matching the old
// fire-and-forget behaviour as a fallback.
const backgroundRun = (async () => {
try {
// Feature 7: Check extraction rate limit before any work
Expand Down Expand Up @@ -2968,7 +2970,19 @@ const memoryLanceDBProPlugin = {
}
})();
agentEndAutoCaptureHook.__lastRun = backgroundRun;
void backgroundRun;
let safetyTimer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
backgroundRun,
new Promise<void>(resolve => {
safetyTimer = setTimeout(resolve, 15_000);
}),
]);
} catch {
// swallow – best-effort capture must never break the host
} finally {
if (safetyTimer !== undefined) clearTimeout(safetyTimer);
}
};

api.on("agent_end", agentEndAutoCaptureHook);
Expand Down
83 changes: 83 additions & 0 deletions test/agent-end-async-capture.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(__dirname, "..", "index.ts"), "utf8");

// ---------------------------------------------------------------------------
// 1. AgentEndAutoCaptureHook type allows Promise<void>
// ---------------------------------------------------------------------------
describe("agent_end hook – async type signature", () => {
it("AgentEndAutoCaptureHook return type includes Promise<void>", () => {
const typeRe =
/type\s+AgentEndAutoCaptureHook\s*=\s*\([^)]*\)\s*=>\s*Promise<void>\s*\|\s*void/;
assert.match(src, typeRe, "Return type should be Promise<void> | void");
});

it("hook is declared async", () => {
assert.ok(
src.includes("= async (event, ctx) => {"),
"agentEndAutoCaptureHook should be an async arrow function",
);
});
});

// ---------------------------------------------------------------------------
// 2. backgroundRun is awaited with a safety timeout, not fire-and-forget
// ---------------------------------------------------------------------------
describe("agent_end hook – await with safety timeout", () => {
it("uses Promise.race with a timeout", () => {
assert.ok(
src.includes("await Promise.race(["),
"backgroundRun should be awaited via Promise.race",
);
assert.ok(
src.includes("setTimeout(resolve, 15_000)"),
"safety timeout should be 15 000 ms",
);
});

it("does NOT use fire-and-forget void", () => {
const lines = src.split("\n");
const fireAndForget = lines.some(
(l) => l.trim() === "void backgroundRun;" || l.trim() === "void backgroundRun",
);
assert.ok(!fireAndForget, "fire-and-forget 'void backgroundRun' must be removed");
});

it("swallows errors so the host agent is never broken", () => {
const idx = src.indexOf("await Promise.race([");
assert.ok(idx > -1, "Promise.race must exist");
const after = src.slice(idx, idx + 600);
assert.ok(
after.includes("catch"),
"there must be a catch block around the await",
);
});

it("cleans up the safety timer", () => {
assert.ok(
src.includes("clearTimeout(safetyTimer)"),
"safetyTimer must be cleared in a finally block",
);
});
});

// ---------------------------------------------------------------------------
// 3. Early-return guard for empty output
// ---------------------------------------------------------------------------
describe("agent_end hook – early-return guard", () => {
it("returns early when event output is empty", () => {
const hookStart = src.indexOf("= async (event, ctx) => {");
assert.ok(hookStart > -1, "async hook must exist");
const slice = src.slice(hookStart, hookStart + 1500);
const hasGuard =
slice.includes("!output") ||
slice.includes("output.length === 0") ||
slice.includes("output?.length");
assert.ok(hasGuard, "hook should guard against empty output");
});
});
Loading