Skip to content
Open
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
32 changes: 28 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ function shouldSkipReflectionMessage(role: string, text: string): boolean {

const AUTO_CAPTURE_MAP_MAX_ENTRIES = 2000;
const AUTO_CAPTURE_EXPLICIT_REMEMBER_RE =
/^(?:请|請)?(?:记住|記住|记一下|記一下|别忘了|別忘了)[。.!??!]*$/u;
/^(?:请|請)?(?:remember(?:\s+this)?|merk(?:e?\s+dir|\s+es\s+dir)|vergiss\s+(?:das\s+)?nicht|nicht\s+vergessen|记住|記住|记一下|記一下|别忘了|別忘了)[。.!??!]*$/iu;

/**
* Prune a Map to stay within the given maximum number of entries.
Expand Down Expand Up @@ -1264,6 +1264,12 @@ const MEMORY_TRIGGERS = [
/老是|講不聽|總是|总是|從不|从不|一直|每次都/,
/重要|關鍵|关键|注意|千萬別|千万别/,
/幫我|筆記|存檔|存起來|存一下|重點|原則|底線/,
// German triggers
/merk(?:e?\s+dir|\s+es\s+dir)|erinner(?:e)?\s+dich|vergiss\s+(?:das\s+)?nicht|nicht\s+vergessen/i,
/ich bevorzuge|ich mag|ich hasse|ich will|ich brauche/i,
/wir haben entschieden|wir nutzen|ab jetzt|ab sofort|in zukunft/i,
/mein\s+\w+\s+(?:ist|heißt)|ich\s+(?:wohne|arbeite)\b/i,
/\b(immer|niemals|wichtig)\b/i,
];

const CAPTURE_EXCLUDE_PATTERNS = [
Expand Down Expand Up @@ -2556,11 +2562,11 @@ 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;
}
Expand Down Expand Up @@ -2892,7 +2898,25 @@ const memoryLanceDBProPlugin = {
}
})();
agentEndAutoCaptureHook.__lastRun = backgroundRun;
void backgroundRun;

// Await backgroundRun with timeout to prevent process-exit race
// condition in CLI one-shot mode. OpenClaw core calls runAgentEnd()
// fire-and-forget (.catch() only), so this await does NOT block
// session locks or channel deliveries (see Issue #260).
// The 15s timeout is a safety net for hung API calls.
let safetyTimer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
backgroundRun,
new Promise<void>(resolve => {
safetyTimer = setTimeout(resolve, 15_000);
}),
]);
} catch {
// Errors already logged inside backgroundRun
} finally {
if (safetyTimer !== undefined) clearTimeout(safetyTimer);
}
};

api.on("agent_end", agentEndAutoCaptureHook);
Expand Down
3 changes: 3 additions & 0 deletions src/adaptive-retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const FORCE_RETRIEVE_PATTERNS = [
/\b(my (name|email|phone|address|birthday|preference))\b/i,
/\b(what did (i|we)|did i (tell|say|mention))\b/i,
/(你记得|[你妳]記得|之前|上次|以前|还记得|還記得|提到过|提到過|说过|說過)/i,
// German retrieval triggers
/\b(erinnerst du dich|weißt du noch|was (war|ist) mein|letzte[sn]?\s+mal|vorher|vorhin|gestern|neulich)\b/i,
/\b(merk dir|merke dir|habe ich dir gesagt|habe ich erw[äa]hnt)\b/i,
];

/**
Expand Down
186 changes: 186 additions & 0 deletions test/agent-end-async-capture.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import Module from "node:module";
import jitiFactory from "jiti";

// Ensure openclaw module resolution works in CI environments
process.env.NODE_PATH = [
process.env.NODE_PATH,
"/opt/homebrew/lib/node_modules/openclaw/node_modules",
"/opt/homebrew/lib/node_modules",
].filter(Boolean).join(":");
Module._initPaths();

const jiti = jitiFactory(import.meta.url, { interopDefault: true });
const plugin = jiti("../index.ts");

function createMockApi(pluginConfig) {
return {
pluginConfig,
hooks: {},
toolFactories: {},
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
resolvePath(value) { return value; },
registerTool(toolOrFactory, meta) {
this.toolFactories[meta.name] =
typeof toolOrFactory === "function" ? toolOrFactory : () => toolOrFactory;
},
registerCli() {},
registerService() {},
on(name, handler) { this.hooks[name] = handler; },
registerHook(name, handler) { this.hooks[name] = handler; },
};
}

const workDir = mkdtempSync(path.join(tmpdir(), "agent-end-async-test-"));

try {
describe("agent_end hook — async capture behavior", () => {

// -----------------------------------------------------------------------
// Test 1: hook returns a Promise (is async)
// -----------------------------------------------------------------------
it("agent_end hook returns a Promise (async function)", () => {
const api = createMockApi({
dbPath: path.join(workDir, "db-async-check"),
autoRecall: false,
embedding: {
provider: "openai-compatible",
apiKey: "dummy",
model: "text-embedding-3-small",
baseURL: "http://127.0.0.1:9/v1",
dimensions: 1536,
},
});
plugin.register(api);

const hook = api.hooks.agent_end;
assert.ok(hook, "agent_end hook should be registered");

// Call with a non-successful event to hit early return path
const result = hook({ success: false, messages: [] }, {});
// Even early return from an async function yields a Promise
assert.ok(
result === undefined || result instanceof Promise,
"hook should return a Promise or undefined (async function signature)",
);
});

// -----------------------------------------------------------------------
// Test 2: hook does NOT throw when backgroundRun rejects
// -----------------------------------------------------------------------
it("hook swallows errors from backgroundRun gracefully", async () => {
const api = createMockApi({
dbPath: path.join(workDir, "db-error-swallow"),
autoRecall: false,
embedding: {
provider: "openai-compatible",
apiKey: "dummy",
model: "text-embedding-3-small",
// Point at unreachable endpoint to force rejection
baseURL: "http://127.0.0.1:9/v1",
dimensions: 1536,
},
});
plugin.register(api);

const hook = api.hooks.agent_end;
assert.ok(hook, "agent_end hook should be registered");

// This triggers a real backgroundRun that will fail because the
// embedding endpoint is unreachable. The hook should NOT throw.
await assert.doesNotReject(
Promise.resolve(hook(
{
success: true,
messages: [
{ role: "user", content: "Merke dir: Testdaten sind wichtig" },
{ role: "assistant", content: "Alles klar, ich merke mir das." },
],
},
{ agentId: "test-agent", sessionKey: "test:session:1" },
)),
"hook should not throw even when backgroundRun rejects",
);
});

// -----------------------------------------------------------------------
// Test 3: hook stores __lastRun as a Promise
// -----------------------------------------------------------------------
it("stores __lastRun as a Promise for downstream consumers", () => {
const api = createMockApi({
dbPath: path.join(workDir, "db-lastrun"),
autoRecall: false,
embedding: {
provider: "openai-compatible",
apiKey: "dummy",
model: "text-embedding-3-small",
baseURL: "http://127.0.0.1:9/v1",
dimensions: 1536,
},
});
plugin.register(api);

const hook = api.hooks.agent_end;
// Trigger hook with valid event
hook(
{
success: true,
messages: [
{ role: "user", content: "Mein Lieblingseditor ist Neovim" },
{ role: "assistant", content: "Notiert." },
],
},
{ agentId: "test-agent", sessionKey: "test:session:2" },
);

assert.ok(
hook.__lastRun instanceof Promise,
"__lastRun should be set to a Promise so callers can await it",
);
});

// -----------------------------------------------------------------------
// Test 4: hook does NOT hang on early-return (no messages)
// -----------------------------------------------------------------------
it("returns immediately for empty message events (no timer leak)", async () => {
const api = createMockApi({
dbPath: path.join(workDir, "db-early-return"),
autoRecall: false,
embedding: {
provider: "openai-compatible",
apiKey: "dummy",
model: "text-embedding-3-small",
baseURL: "http://127.0.0.1:9/v1",
dimensions: 1536,
},
});
plugin.register(api);

const hook = api.hooks.agent_end;

const start = Date.now();
await Promise.resolve(hook({ success: true, messages: [] }, {}));
const elapsed = Date.now() - start;

// Early return should be near-instant, not wait for 15s timeout
assert.ok(
elapsed < 1000,
`Early return should complete in <1s, took ${elapsed}ms`,
);
});
});
} finally {
rmSync(workDir, { recursive: true, force: true });
}

console.log("OK: agent-end-async-capture test passed");
Loading