feat: integrate Cortex local memory into OpenClaw
This commit is contained in:
parent
a55c5e7c7a
commit
0a161b96fe
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user