feat: harden cortex bridge workflows

This commit is contained in:
Junebugg1214 2026-03-14 18:17:55 -04:00
parent b9fc0b94ca
commit 9d9dd2d77b
9 changed files with 337 additions and 57 deletions

View File

@ -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",

View File

@ -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([]);

View File

@ -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<AgentCortexPromptContextResult> {
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<ResolvedAgentTurnCortexContext | null> {
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<AgentCortexConflictNotice | null> {
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<AgentCortexMemoryCaptureResult> {
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;

View File

@ -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) {

View File

@ -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<ReplyP
text: "Cortex prompt bridge is disabled for this agent. Enable it in config or with `openclaw memory cortex enable`.",
};
}
const preview = await previewCortexContext({
workspaceDir: params.workspaceDir,
graphPath: state.cortex.graphPath,
policy: state.mode,
maxChars: state.cortex.maxChars,
});
const previewBody = preview.context || "No Cortex context is currently being injected.";
let previewBody = "No Cortex context is currently being injected.";
let previewGraphPath = state.cortex.graphPath ?? ".cortex/context.json";
let previewError: string | null = null;
try {
const preview = await previewCortexContext({
workspaceDir: params.workspaceDir,
graphPath: state.cortex.graphPath,
policy: state.mode,
maxChars: state.cortex.maxChars,
});
previewGraphPath = preview.graphPath;
previewBody = preview.context || previewBody;
} catch (error) {
previewError = error instanceof Error ? error.message : String(error);
}
const captureStatus = await getAgentCortexMemoryCaptureStatusWithHistory({
agentId: state.agentId,
sessionId: state.sessionId,
@ -213,7 +221,7 @@ async function buildCortexWhyReply(params: HandleCommandsParams): Promise<ReplyP
"",
`Mode: ${state.mode}`,
`Source: ${state.source}`,
`Graph: ${preview.graphPath}`,
`Graph: ${previewGraphPath}`,
state.sessionId ? `Session: ${state.sessionId}` : null,
state.channelId ? `Channel: ${state.channelId}` : null,
captureStatus
@ -223,6 +231,7 @@ async function buildCortexWhyReply(params: HandleCommandsParams): Promise<ReplyP
captureStatus?.syncedCodingContext
? `Coding sync: updated (${(captureStatus.syncPlatforms ?? []).join(", ")})`
: null,
previewError ? `Preview error: ${previewError}` : null,
"",
"Injected Cortex context:",
previewBody,

View File

@ -785,6 +785,45 @@ describe("/cortex command", () => {
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 },

View File

@ -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 () => {

View File

@ -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<void> {
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<Record<string, unknown> | null> {
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
@ -471,6 +500,9 @@ function updateAgentCortexConfig(params: {
async function runCortexEnable(opts: CortexEnableCommandOptions): Promise<void> {
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<void>
}),
});
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 <id>", "Agent id (default: default agent)")
.option("--graph <path>", "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")

View File

@ -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<boolean> {
try {
await fs.access(pathname);