feat: integrate Cortex local memory into OpenClaw

This commit is contained in:
Marc J Saint-jour 2026-03-12 18:41:05 -04:00
parent a55c5e7c7a
commit 0a161b96fe

View File

@ -21,14 +21,13 @@ type AgentRunParams = {
onAssistantMessageStart?: () => Promise<void> | void;
onReasoningStream?: (payload: { text?: string }) => Promise<void> | void;
onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
};
type EmbeddedRunParams = {
prompt?: string;
extraSystemPrompt?: string;
memoryFlushWritePath?: string;
bootstrapPromptWarningSignaturesSeen?: string[];
bootstrapPromptWarningSignature?: string;
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
@ -37,6 +36,8 @@ type EmbeddedRunParams = {
const state = vi.hoisted(() => ({
runEmbeddedPiAgentMock: vi.fn(),
runCliAgentMock: vi.fn(),
resolveAgentCortexModeStatusMock: vi.fn(),
resolveCortexChannelTargetMock: vi.fn(),
}));
let modelFallbackModule: typeof import("../../agents/model-fallback.js");
@ -79,6 +80,17 @@ vi.mock("../../agents/cli-runner.js", () => ({
runCliAgent: (params: unknown) => state.runCliAgentMock(params),
}));
vi.mock("../../agents/cortex.js", async () => {
const actual =
await vi.importActual<typeof import("../../agents/cortex.js")>("../../agents/cortex.js");
return {
...actual,
resolveAgentCortexModeStatus: (params: unknown) =>
state.resolveAgentCortexModeStatusMock(params),
resolveCortexChannelTarget: (params: unknown) => state.resolveCortexChannelTargetMock(params),
};
});
vi.mock("./queue.js", () => ({
enqueueFollowupRun: vi.fn(),
scheduleFollowupDrain: vi.fn(),
@ -94,6 +106,13 @@ beforeAll(async () => {
beforeEach(() => {
state.runEmbeddedPiAgentMock.mockClear();
state.runCliAgentMock.mockClear();
state.resolveAgentCortexModeStatusMock.mockReset();
state.resolveCortexChannelTargetMock.mockReset();
state.resolveAgentCortexModeStatusMock.mockResolvedValue(null);
state.resolveCortexChannelTargetMock.mockImplementation(
(params: { originatingTo?: string; channel?: string }) =>
params.originatingTo ?? params.channel ?? "unknown",
);
vi.mocked(enqueueFollowupRun).mockClear();
vi.mocked(scheduleFollowupDrain).mockClear();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
@ -595,40 +614,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
});
it("preserves channelData on forwarded tool results", async () => {
const onToolResult = vi.fn();
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
await params.onToolResult?.({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
return { payloads: [{ text: "final" }], meta: {} };
});
const { run } = createMinimalRun({
typingMode: "message",
opts: { onToolResult },
});
await run();
expect(onToolResult).toHaveBeenCalledWith({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
});
it("retries transient HTTP failures once with timer-driven backoff", async () => {
vi.useFakeTimers();
let calls = 0;
@ -737,6 +722,40 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
});
it("announces active Cortex mode only when verbose mode is enabled", async () => {
const cases = [
{ name: "verbose on", verbose: "on" as const, expectNotice: true },
{ name: "verbose off", verbose: "off" as const, expectNotice: false },
] as const;
for (const testCase of cases) {
state.resolveAgentCortexModeStatusMock.mockResolvedValueOnce({
enabled: true,
mode: "minimal",
source: "session-override",
maxChars: 1500,
});
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "final" }],
meta: {},
});
const { run } = createMinimalRun({
resolvedVerboseLevel: testCase.verbose,
});
const res = await run();
const payload = Array.isArray(res)
? (res[0] as { text?: string })
: (res as { text?: string });
if (testCase.expectNotice) {
expect(payload.text, testCase.name).toContain("Cortex: minimal (session override)");
continue;
}
expect(payload.text, testCase.name).not.toContain("Cortex:");
}
});
it("announces model fallback only when verbose mode is enabled", async () => {
const cases = [
{ name: "verbose on", verbose: "on" as const, expectNotice: true },
@ -1255,79 +1274,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
});
it("clears stale runtime model fields when resetSession retries after compaction failure", async () => {
await withTempStateDir(async (stateDir) => {
const sessionId = "session-stale-model";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile: transcriptPath,
modelProvider: "qwencode",
model: "qwen3.5-plus-2026-02-15",
contextTokens: 123456,
systemPromptReport: {
source: "run",
generatedAt: Date.now(),
sessionId,
sessionKey: "main",
provider: "qwencode",
model: "qwen3.5-plus-2026-02-15",
workspaceDir: stateDir,
bootstrapMaxChars: 1000,
bootstrapTotalMaxChars: 2000,
systemPrompt: {
chars: 10,
projectContextChars: 5,
nonProjectContextChars: 5,
},
injectedWorkspaceFiles: [],
skills: {
promptChars: 0,
entries: [],
},
tools: {
listChars: 0,
schemaChars: 0,
entries: [],
},
},
};
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8");
state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
throw new Error(
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
);
});
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
await run();
expect(sessionStore.main.modelProvider).toBeUndefined();
expect(sessionStore.main.model).toBeUndefined();
expect(sessionStore.main.contextTokens).toBeUndefined();
expect(sessionStore.main.systemPromptReport).toBeUndefined();
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.modelProvider).toBeUndefined();
expect(persisted.main.model).toBeUndefined();
expect(persisted.main.contextTokens).toBeUndefined();
expect(persisted.main.systemPromptReport).toBeUndefined();
});
});
it("surfaces overflow fallback when embedded run returns empty payloads", async () => {
state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
payloads: [],
@ -1685,14 +1631,9 @@ describe("runReplyAgent memory flush", () => {
const flushCall = calls[0];
expect(flushCall?.prompt).toContain("Write notes.");
expect(flushCall?.prompt).toContain("NO_REPLY");
expect(flushCall?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
expect(flushCall?.prompt).toContain("MEMORY.md");
expect(flushCall?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/);
expect(flushCall?.extraSystemPrompt).toContain("extra system");
expect(flushCall?.extraSystemPrompt).toContain("Flush memory now.");
expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY");
expect(flushCall?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md");
expect(flushCall?.extraSystemPrompt).toContain("MEMORY.md");
expect(calls[1]?.prompt).toBe("hello");
});
});
@ -1780,17 +1721,9 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
const calls: Array<{
prompt?: string;
extraSystemPrompt?: string;
memoryFlushWritePath?: string;
}> = [];
const calls: Array<{ prompt?: string }> = [];
state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
calls.push({
prompt: params.prompt,
extraSystemPrompt: params.extraSystemPrompt,
memoryFlushWritePath: params.memoryFlushWritePath,
});
calls.push({ prompt: params.prompt });
if (params.prompt?.includes("Pre-compaction memory flush.")) {
return { payloads: [], meta: {} };
}
@ -1817,10 +1750,6 @@ describe("runReplyAgent memory flush", () => {
expect(calls[0]?.prompt).toContain("Pre-compaction memory flush.");
expect(calls[0]?.prompt).toContain("Current time:");
expect(calls[0]?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
expect(calls[0]?.prompt).toContain("MEMORY.md");
expect(calls[0]?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/);
expect(calls[0]?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md");
expect(calls[0]?.extraSystemPrompt).toContain("MEMORY.md");
expect(calls[1]?.prompt).toBe("hello");
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
@ -2077,4 +2006,3 @@ describe("runReplyAgent memory flush", () => {
});
});
});
import type { ReplyPayload } from "../types.js";