From 9d9dd2d77b8784a47dc96d8c0a999bb1a45994ae Mon Sep 17 00:00:00 2001 From: Junebugg1214 <82672745+Junebugg1214@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:17:55 -0400 Subject: [PATCH] feat: harden cortex bridge workflows --- package.json | 4 + src/agents/cortex.test.ts | 79 +++++++++++++++++ src/agents/cortex.ts | 108 ++++++++++++++++-------- src/auto-reply/reply/agent-runner.ts | 30 ++++--- src/auto-reply/reply/commands-cortex.ts | 27 ++++-- src/auto-reply/reply/commands.test.ts | 39 +++++++++ src/cli/memory-cli.test.ts | 33 ++++++++ src/cli/memory-cli.ts | 53 +++++++++++- src/memory/cortex.ts | 21 +++++ 9 files changed, 337 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index 54d897eb66f..ca80ed0150a 100644 --- a/package.json +++ b/package.json @@ -304,6 +304,10 @@ "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", "test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts", "test:channels": "vitest run --config vitest.channels.config.ts", + "test:ci:cortex": "pnpm exec vitest run src/cli/memory-cli.test.ts src/auto-reply/reply/commands.test.ts src/agents/cortex.test.ts", + "test:ci:daemon-windows": "pnpm exec vitest run src/daemon/schtasks.stop.test.ts src/daemon/schtasks.startup-fallback.test.ts", + "test:ci:path-windows": "pnpm exec vitest run src/infra/update-global.test.ts src/infra/home-dir.test.ts src/infra/executable-path.test.ts src/infra/exec-approvals-store.test.ts src/infra/pairing-files.test.ts src/infra/stable-node-path.test.ts src/infra/hardlink-guards.test.ts src/infra/exec-allowlist-pattern.test.ts src/security/temp-path-guard.test.ts src/infra/run-node.test.ts", + "test:ci:ui-parse": "pnpm exec vitest run ui/src/ui/views/agents-utils.test.ts", "test:coverage": "vitest run --config vitest.unit.config.ts --coverage", "test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", diff --git a/src/agents/cortex.test.ts b/src/agents/cortex.test.ts index 01db3e8a390..16508871e51 100644 --- a/src/agents/cortex.test.ts +++ b/src/agents/cortex.test.ts @@ -37,6 +37,7 @@ import { resolveAgentCortexConfig, resolveAgentCortexModeStatus, resolveAgentCortexPromptContext, + resolveAgentTurnCortexContext, resolveCortexChannelTarget, } from "./cortex.js"; @@ -258,6 +259,44 @@ describe("resolveAgentCortexPromptContext", () => { expect(result.error).toContain("Cortex graph not found"); }); + + it("reuses resolved turn status when provided", async () => { + getCortexModeOverride.mockResolvedValueOnce(null); + previewCortexContext.mockResolvedValueOnce({ + workspaceDir: "/tmp/openclaw-workspace", + graphPath: "/tmp/openclaw-workspace/.cortex/context.json", + policy: "technical", + maxChars: 1500, + context: "## Cortex Context\n- Shipping", + }); + + const cfg: OpenClawConfig = { + agents: { + defaults: { + cortex: { + enabled: true, + }, + }, + list: [{ id: "main" }], + }, + }; + + const resolved = await resolveAgentTurnCortexContext({ + cfg, + agentId: "main", + workspaceDir: "/tmp/openclaw-workspace", + }); + const result = await resolveAgentCortexPromptContext({ + cfg, + agentId: "main", + workspaceDir: "/tmp/openclaw-workspace", + promptMode: "full", + resolved, + }); + + expect(result.context).toContain("Shipping"); + expect(getCortexStatus).toHaveBeenCalledTimes(1); + }); }); describe("resolveAgentCortexConflictNotice", () => { @@ -309,6 +348,46 @@ describe("resolveAgentCortexConflictNotice", () => { expect(second).toBeNull(); }); + it("reuses resolved turn status when checking conflicts", async () => { + listCortexMemoryConflicts.mockResolvedValueOnce([ + { + id: "conf_1", + type: "temporal_flip", + severity: 0.91, + summary: "Hiring status changed from active to paused", + }, + ]); + + const cfg: OpenClawConfig = { + agents: { + defaults: { + cortex: { + enabled: true, + }, + }, + list: [{ id: "main" }], + }, + }; + + const resolved = await resolveAgentTurnCortexContext({ + cfg, + agentId: "main", + workspaceDir: "/tmp/openclaw-workspace", + sessionId: "session-1", + channelId: "channel-1", + }); + await resolveAgentCortexConflictNotice({ + cfg, + agentId: "main", + workspaceDir: "/tmp/openclaw-workspace", + sessionId: "session-1", + channelId: "channel-1", + resolved, + }); + + expect(getCortexStatus).toHaveBeenCalledTimes(1); + }); + it("applies cooldown even when no Cortex conflicts are found", async () => { listCortexMemoryConflicts.mockResolvedValueOnce([]); diff --git a/src/agents/cortex.ts b/src/agents/cortex.ts index 7427954cb14..d84e81a4dfc 100644 --- a/src/agents/cortex.ts +++ b/src/agents/cortex.ts @@ -8,6 +8,7 @@ import { previewCortexContext, syncCortexCodingContext, type CortexPolicy, + type CortexStatus, } from "../memory/cortex.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { @@ -35,6 +36,11 @@ export type ResolvedAgentCortexModeStatus = { maxChars: number; }; +export type ResolvedAgentTurnCortexContext = { + config: ResolvedAgentCortexModeStatus; + status: CortexStatus; +}; + export type AgentCortexConflictNotice = { text: string; conflictId: string; @@ -245,30 +251,30 @@ export async function resolveAgentCortexPromptContext(params: { promptMode: "full" | "minimal"; sessionId?: string; channelId?: string; + resolved?: ResolvedAgentTurnCortexContext | null; }): Promise { if (!params.cfg || params.promptMode !== "full") { return {}; } - const cortex = await resolveAgentCortexModeStatus({ - cfg: params.cfg, - agentId: params.agentId, - sessionId: params.sessionId, - channelId: params.channelId, - }); - if (!cortex) { + const resolved = + params.resolved ?? + (await resolveAgentTurnCortexContext({ + cfg: params.cfg, + agentId: params.agentId, + workspaceDir: params.workspaceDir, + sessionId: params.sessionId, + channelId: params.channelId, + })); + if (!resolved) { return {}; } try { - const status = await getCortexStatus({ - workspaceDir: params.workspaceDir, - graphPath: cortex.graphPath, - }); const preview = await previewCortexContext({ workspaceDir: params.workspaceDir, - graphPath: cortex.graphPath, - policy: cortex.mode, - maxChars: cortex.maxChars, - status, + graphPath: resolved.config.graphPath, + policy: resolved.config.mode, + maxChars: resolved.config.maxChars, + status: resolved.status, }); return preview.context ? { context: preview.context } : {}; } catch (error) { @@ -278,6 +284,32 @@ export async function resolveAgentCortexPromptContext(params: { } } +export async function resolveAgentTurnCortexContext(params: { + cfg?: OpenClawConfig; + agentId: string; + workspaceDir: string; + sessionId?: string; + channelId?: string; +}): Promise { + if (!params.cfg) { + return null; + } + const config = await resolveAgentCortexModeStatus({ + cfg: params.cfg, + agentId: params.agentId, + sessionId: params.sessionId, + channelId: params.channelId, + }); + if (!config) { + return null; + } + const status = await getCortexStatus({ + workspaceDir: params.workspaceDir, + graphPath: config.graphPath, + }); + return { config, status }; +} + export function resetAgentCortexConflictNoticeStateForTests(): void { cortexConflictNoticeCooldowns.clear(); cortexMemoryCaptureStatuses.clear(); @@ -378,12 +410,21 @@ export async function resolveAgentCortexConflictNotice(params: { minSeverity?: number; now?: number; cooldownMs?: number; + resolved?: ResolvedAgentTurnCortexContext | null; }): Promise { if (!params.cfg) { return null; } - const cortex = resolveAgentCortexConfig(params.cfg, params.agentId); - if (!cortex) { + const resolved = + params.resolved ?? + (await resolveAgentTurnCortexContext({ + cfg: params.cfg, + agentId: params.agentId, + workspaceDir: params.workspaceDir, + sessionId: params.sessionId, + channelId: params.channelId, + })); + if (!resolved) { return null; } const targetKey = buildAgentCortexConversationKey({ @@ -398,15 +439,11 @@ export async function resolveAgentCortexConflictNotice(params: { return null; } try { - const status = await getCortexStatus({ - workspaceDir: params.workspaceDir, - graphPath: cortex.graphPath, - }); const conflicts = await listCortexMemoryConflicts({ workspaceDir: params.workspaceDir, - graphPath: cortex.graphPath, + graphPath: resolved.config.graphPath, minSeverity: params.minSeverity ?? DEFAULT_CORTEX_CONFLICT_SEVERITY, - status, + status: resolved.status, }); const topConflict = conflicts .filter((entry) => entry.id && entry.summary) @@ -438,6 +475,7 @@ export async function ingestAgentCortexMemoryCandidate(params: { sessionId?: string; channelId?: string; provider?: string; + resolved?: ResolvedAgentTurnCortexContext | null; }): Promise { const conversationKey = buildAgentCortexConversationKey({ agentId: params.agentId, @@ -449,8 +487,16 @@ export async function ingestAgentCortexMemoryCandidate(params: { cortexMemoryCaptureStatuses.set(conversationKey, { ...result, updatedAt: Date.now() }); return result; } - const cortex = resolveAgentCortexConfig(params.cfg, params.agentId); - if (!cortex) { + const resolved = + params.resolved ?? + (await resolveAgentTurnCortexContext({ + cfg: params.cfg, + agentId: params.agentId, + workspaceDir: params.workspaceDir, + sessionId: params.sessionId, + channelId: params.channelId, + })); + if (!resolved) { const result = { captured: false, score: 0, reason: "cortex disabled" }; cortexMemoryCaptureStatuses.set(conversationKey, { ...result, updatedAt: Date.now() }); return result; @@ -461,13 +507,9 @@ export async function ingestAgentCortexMemoryCandidate(params: { return decision; } try { - const status = await getCortexStatus({ - workspaceDir: params.workspaceDir, - graphPath: cortex.graphPath, - }); await ingestCortexMemoryFromText({ workspaceDir: params.workspaceDir, - graphPath: cortex.graphPath, + graphPath: resolved.config.graphPath, event: { actor: "user", text: params.commandBody, @@ -476,7 +518,7 @@ export async function ingestAgentCortexMemoryCandidate(params: { channelId: params.channelId, provider: params.provider, }, - status, + status: resolved.status, }); let syncedCodingContext = false; let syncPlatforms: string[] | undefined; @@ -491,10 +533,10 @@ export async function ingestAgentCortexMemoryCandidate(params: { try { const syncResult = await syncCortexCodingContext({ workspaceDir: params.workspaceDir, - graphPath: cortex.graphPath, + graphPath: resolved.config.graphPath, policy: syncPolicy.policy, platforms: syncPolicy.platforms, - status, + status: resolved.status, }); syncedCodingContext = true; syncPlatforms = syncResult.platforms; diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index d1dd8ca2e6f..dc2d454c780 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -3,7 +3,7 @@ import { lookupContextTokens } from "../../agents/context.js"; import { ingestAgentCortexMemoryCandidate, resolveAgentCortexConflictNotice, - resolveAgentCortexModeStatus, + resolveAgentTurnCortexContext, resolveCortexChannelTarget, } from "../../agents/cortex.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; @@ -705,15 +705,15 @@ export async function runReplyAgent(params: { to: sessionCtx.To, from: sessionCtx.From, }); - const cortexModeStatus = - verboseEnabled && cfg - ? await resolveAgentCortexModeStatus({ - cfg, - agentId: cortexAgentId, - sessionId: followupRun.run.sessionId, - channelId: cortexChannelId, - }) - : null; + const resolvedTurnCortex = cfg + ? await resolveAgentTurnCortexContext({ + cfg, + agentId: cortexAgentId, + workspaceDir: followupRun.run.workspaceDir, + sessionId: followupRun.run.sessionId, + channelId: cortexChannelId, + }) + : null; const cortexMemoryCapture = cfg ? await ingestAgentCortexMemoryCandidate({ cfg, @@ -723,6 +723,7 @@ export async function runReplyAgent(params: { sessionId: followupRun.run.sessionId, channelId: cortexChannelId, provider: followupRun.run.messageProvider, + resolved: resolvedTurnCortex, }) : null; const cortexConflictNotice = cfg @@ -732,17 +733,18 @@ export async function runReplyAgent(params: { workspaceDir: followupRun.run.workspaceDir, sessionId: followupRun.run.sessionId, channelId: cortexChannelId, + resolved: resolvedTurnCortex, }) : null; - if (verboseEnabled && cortexModeStatus) { + if (verboseEnabled && resolvedTurnCortex) { const sourceLabel = - cortexModeStatus.source === "session-override" + resolvedTurnCortex.config.source === "session-override" ? "session override" - : cortexModeStatus.source === "channel-override" + : resolvedTurnCortex.config.source === "channel-override" ? "channel override" : "agent config"; verboseNotices.push({ - text: `🧠 Cortex: ${cortexModeStatus.mode} (${sourceLabel})`, + text: `🧠 Cortex: ${resolvedTurnCortex.config.mode} (${sourceLabel})`, }); } if (verboseEnabled && cortexMemoryCapture?.captured) { diff --git a/src/auto-reply/reply/commands-cortex.ts b/src/auto-reply/reply/commands-cortex.ts index 705effe802f..e378e22aa39 100644 --- a/src/auto-reply/reply/commands-cortex.ts +++ b/src/auto-reply/reply/commands-cortex.ts @@ -53,7 +53,7 @@ function parseResolveAction(value?: string): CortexMemoryResolveAction | null { } function resolveActiveSessionId(params: HandleCommandsParams): string | undefined { - return params.sessionEntry?.sessionId; + return params.sessionEntry?.sessionId ?? params.ctx.SessionId; } function resolveActiveChannelId(params: HandleCommandsParams): string { @@ -195,13 +195,21 @@ async function buildCortexWhyReply(params: HandleCommandsParams): Promise { expect(result.reply?.text).toContain("Injected Cortex context:"); }); + it("keeps /cortex why useful when preview fails", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + agents: { + defaults: { + cortex: { + enabled: true, + mode: "technical", + maxChars: 1500, + graphPath: ".cortex/context.json", + }, + }, + }, + } as OpenClawConfig; + resolveAgentCortexModeStatusMock.mockResolvedValueOnce({ + enabled: true, + mode: "professional", + source: "agent-config", + maxChars: 1500, + graphPath: path.join(testWorkspaceDir, ".cortex", "context.json"), + }); + previewCortexContextMock.mockRejectedValueOnce(new Error("Cortex graph not found")); + + const params = buildParams("/cortex why", cfg, { + SessionId: "session-1", + NativeChannelId: "C123", + }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Why I answered this way"); + expect(result.reply?.text).toContain("Mode: professional"); + expect(result.reply?.text).toContain("Preview error: Cortex graph not found"); + expect(result.reply?.text).toContain("Injected Cortex context:"); + expect(result.reply?.text).toContain("No Cortex context is currently being injected."); + }); + it("shows continuity details for the active conversation", async () => { const cfg = { commands: { text: true }, diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index b65fa3f5ca7..d1cad685010 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const getMemorySearchManager = vi.fn(); const getCortexStatus = vi.fn(); const previewCortexContext = vi.fn(); +const ensureCortexGraphInitialized = vi.fn(); const getCortexModeOverride = vi.fn(); const setCortexModeOverride = vi.fn(); const clearCortexModeOverride = vi.fn(); @@ -25,6 +26,7 @@ vi.mock("../memory/index.js", () => ({ })); vi.mock("../memory/cortex.js", () => ({ + ensureCortexGraphInitialized, getCortexStatus, previewCortexContext, })); @@ -66,6 +68,7 @@ afterEach(() => { getMemorySearchManager.mockClear(); getCortexStatus.mockClear(); previewCortexContext.mockClear(); + ensureCortexGraphInitialized.mockClear(); getCortexModeOverride.mockClear(); setCortexModeOverride.mockClear(); clearCortexModeOverride.mockClear(); @@ -686,6 +689,10 @@ describe("memory cli", () => { it("enables Cortex prompt bridge in agent defaults", async () => { mockWritableConfigSnapshot({}); + ensureCortexGraphInitialized.mockResolvedValueOnce({ + graphPath: "/tmp/openclaw-workspace/.cortex/context.json", + created: true, + }); const log = spyRuntimeLogs(); await runMemoryCli(["cortex", "enable", "--mode", "professional", "--max-chars", "2200"]); @@ -704,6 +711,32 @@ describe("memory cli", () => { expect(log).toHaveBeenCalledWith( "Enabled Cortex prompt bridge for agent defaults (professional, 2200 chars).", ); + expect(log).toHaveBeenCalledWith( + "Initialized Cortex graph: /tmp/openclaw-workspace/.cortex/context.json", + ); + expect(ensureCortexGraphInitialized).toHaveBeenCalledWith({ + workspaceDir: "/tmp/openclaw-workspace", + graphPath: undefined, + }); + }); + + it("initializes a Cortex graph without changing config", async () => { + ensureCortexGraphInitialized.mockResolvedValueOnce({ + graphPath: "/tmp/openclaw-workspace/.cortex/context.json", + created: false, + }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["cortex", "init"]); + + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(ensureCortexGraphInitialized).toHaveBeenCalledWith({ + workspaceDir: "/tmp/openclaw-workspace", + graphPath: undefined, + }); + expect(log).toHaveBeenCalledWith( + "Cortex graph already present: /tmp/openclaw-workspace/.cortex/context.json", + ); }); it("disables Cortex prompt bridge for a specific agent", async () => { diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index e6be9cedfe2..a7dcfa9aa10 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -14,7 +14,12 @@ import { setCortexModeOverride, type CortexModeScope, } from "../memory/cortex-mode-overrides.js"; -import { getCortexStatus, previewCortexContext, type CortexPolicy } from "../memory/cortex.js"; +import { + ensureCortexGraphInitialized, + getCortexStatus, + previewCortexContext, + type CortexPolicy, +} from "../memory/cortex.js"; import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js"; import { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js"; import { defaultRuntime } from "../runtime.js"; @@ -395,6 +400,30 @@ async function runCortexPreview( } } +async function runCortexInit(opts: CortexCommandOptions): Promise { + const cfg = loadConfig(); + const agentId = resolveAgent(cfg, opts.agent); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + try { + const result = await ensureCortexGraphInitialized({ + workspaceDir, + graphPath: opts.graph, + }); + if (opts.json) { + defaultRuntime.log(JSON.stringify({ agentId, workspaceDir, ...result }, null, 2)); + return; + } + defaultRuntime.log( + result.created + ? `Initialized Cortex graph: ${shortenHomePath(result.graphPath)}` + : `Cortex graph already present: ${shortenHomePath(result.graphPath)}`, + ); + } catch (err) { + defaultRuntime.error(formatErrorMessage(err)); + process.exitCode = 1; + } +} + async function loadWritableMemoryConfig(): Promise | null> { const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid) { @@ -471,6 +500,9 @@ function updateAgentCortexConfig(params: { async function runCortexEnable(opts: CortexEnableCommandOptions): Promise { try { + const cfg = loadConfig(); + const agentId = resolveAgent(cfg, opts.agent); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const next = await loadWritableMemoryConfig(); if (!next) { return; @@ -487,11 +519,20 @@ async function runCortexEnable(opts: CortexEnableCommandOptions): Promise }), }); await writeConfigFile(next); + const initResult = await ensureCortexGraphInitialized({ + workspaceDir, + graphPath: opts.graph, + }); const scope = opts.agent?.trim() ? `agent ${opts.agent.trim()}` : "agent defaults"; defaultRuntime.log( `Enabled Cortex prompt bridge for ${scope} (${parseCortexMode(opts.mode)}, ${normalizeCortexMaxChars(opts.maxChars)} chars).`, ); + defaultRuntime.log( + initResult.created + ? `Initialized Cortex graph: ${shortenHomePath(initResult.graphPath)}` + : `Cortex graph ready: ${shortenHomePath(initResult.graphPath)}`, + ); } catch (err) { defaultRuntime.error(formatErrorMessage(err)); process.exitCode = 1; @@ -1176,6 +1217,16 @@ export function registerMemoryCli(program: Command) { }, ); + cortex + .command("init") + .description("Create the default Cortex graph if it does not exist") + .option("--agent ", "Agent id (default: default agent)") + .option("--graph ", "Override Cortex graph path") + .option("--json", "Print JSON") + .action(async (opts: CortexCommandOptions) => { + await runCortexInit(opts); + }); + cortex .command("enable") .description("Enable Cortex prompt context injection in config") diff --git a/src/memory/cortex.ts b/src/memory/cortex.ts index e23dc9a31fd..e6e6588e1a3 100644 --- a/src/memory/cortex.ts +++ b/src/memory/cortex.ts @@ -70,6 +70,14 @@ const DEFAULT_GRAPH_RELATIVE_PATH = path.join(".cortex", "context.json"); const DEFAULT_POLICY: CortexPolicy = "technical"; const DEFAULT_MAX_CHARS = 1_500; export const DEFAULT_CORTEX_CODING_PLATFORMS = ["claude-code", "cursor", "copilot"] as const; +const EMPTY_CORTEX_GRAPH = { + schema_version: "5.0", + graph: { + nodes: [], + edges: [], + }, + meta: {}, +} as const; type CortexStatusParams = { workspaceDir: string; @@ -96,6 +104,19 @@ export function resolveCortexGraphPath(workspaceDir: string, graphPath?: string) return path.normalize(path.resolve(workspaceDir, trimmed)); } +export async function ensureCortexGraphInitialized(params: { + workspaceDir: string; + graphPath?: string; +}): Promise<{ graphPath: string; created: boolean }> { + const graphPath = resolveCortexGraphPath(params.workspaceDir, params.graphPath); + if (await pathExists(graphPath)) { + return { graphPath, created: false }; + } + await fs.mkdir(path.dirname(graphPath), { recursive: true }); + await fs.writeFile(graphPath, `${JSON.stringify(EMPTY_CORTEX_GRAPH, null, 2)}\n`, "utf8"); + return { graphPath, created: true }; +} + async function pathExists(pathname: string): Promise { try { await fs.access(pathname);