feat(memory-v2): auto-accept candidate memories in active and canary modes

Let Personal Memory candidates pass policy gates without per-user manual review, while keeping shadow mode for explicit human approval.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-13 14:16:57 +08:00
parent 7f7aced751
commit b33c943b69
4 changed files with 147 additions and 13 deletions
+43 -5
View File
@@ -70,13 +70,34 @@ function classify(text) {
return rules.find((rule) => rule.pattern.test(text)) ?? null;
}
const CANDIDATE_MODES = new Set(['off', 'shadow', 'canary', 'active']);
function normalizeCandidateMode(value, fallback = 'shadow') {
const normalized = normalizeText(value, 20).toLowerCase();
return CANDIDATE_MODES.has(normalized) ? normalized : fallback;
}
export function shouldAutoAcceptCandidate(candidate, config) {
if (!candidate || !config?.enabled) return false;
const mode = normalizeCandidateMode(config.requestedMode, 'shadow');
if (mode === 'shadow' || mode === 'off') return false;
if (mode === 'active') return true;
if (candidate.policyReason === 'explicit_memory_request') return true;
return Number(candidate.confidence) >= Math.max(Number(config.minConfidence) || 0, 0.9);
}
export function resolvePersonalShadowConfig(env = process.env) {
const candidateEnabled = readFlag(env, 'MEMORY_CANDIDATE_ENABLED', false);
const requestedMode = normalizeText(env?.MEMORY_CANDIDATE_MODE || (candidateEnabled ? 'shadow' : 'off'), 20).toLowerCase();
const requestedMode = normalizeCandidateMode(
env?.MEMORY_CANDIDATE_MODE || (candidateEnabled ? 'active' : 'off'),
candidateEnabled ? 'active' : 'off',
);
const enabled = candidateEnabled && requestedMode !== 'off';
return {
enabled: candidateEnabled && requestedMode !== 'off',
enabled,
requestedMode,
effectiveMode: candidateEnabled && requestedMode !== 'off' ? 'shadow' : 'off',
effectiveMode: enabled ? requestedMode : 'off',
autoReviewEnabled: enabled && requestedMode !== 'shadow',
minImportance: readNumber(env, 'MEMORY_CANDIDATE_MIN_IMPORTANCE', 0.7, { min: 0, max: 1 }),
minConfidence: readNumber(env, 'MEMORY_CANDIDATE_MIN_CONFIDENCE', 0.8, { min: 0, max: 1 }),
maxPending: Math.round(readNumber(env, 'MEMORY_CANDIDATE_MAX_PENDING', 500, { min: 1, max: 5000 })),
@@ -96,6 +117,8 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
runs: 0,
observedMessages: 0,
accepted: 0,
autoReviewed: 0,
pendingReview: 0,
rejected: 0,
deduped: 0,
errors: 0,
@@ -178,10 +201,21 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
metrics.observedMessages += 1;
const result = evaluate({ userId, sessionId, message, index });
if (result.accepted && store?.saveCandidate) {
const persisted = await store.saveCandidate(result.candidate);
const autoAccept = shouldAutoAcceptCandidate(result.candidate, config);
const persisted = await store.saveCandidate(result.candidate, { autoAccept });
if (persisted?.inserted === false) {
metrics.deduped += 1;
} else if (autoAccept) {
result.candidate.status = 'accepted';
metrics.autoReviewed += 1;
} else {
metrics.pendingReview += 1;
}
} else if (result.accepted) {
const autoAccept = shouldAutoAcceptCandidate(result.candidate, config);
result.candidate.status = autoAccept ? 'accepted' : 'candidate';
if (autoAccept) metrics.autoReviewed += 1;
else metrics.pendingReview += 1;
}
results.push(result);
}
@@ -189,8 +223,11 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
enabled: true,
skipped: false,
mode: config.effectiveMode,
autoReviewEnabled: config.autoReviewEnabled,
observed: results.length,
accepted: results.filter((result) => result.accepted).length,
autoReviewed: results.filter((result) => result.accepted && result.candidate?.status === 'accepted').length,
pendingReview: results.filter((result) => result.accepted && result.candidate?.status === 'candidate').length,
rejected: results.filter((result) => !result.accepted).length,
};
} catch (err) {
@@ -205,7 +242,8 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
enabled: config.enabled,
requestedMode: config.requestedMode,
effectiveMode: config.effectiveMode,
phase: 'shadow-candidate-v1',
phase: config.autoReviewEnabled ? 'auto-review-v1' : 'shadow-candidate-v1',
autoReviewEnabled: config.autoReviewEnabled,
injectionEnabled: false,
persistence: store?.saveCandidate ? 'mysql' : 'bounded-memory',
pendingCandidates: candidates.length,
+93 -3
View File
@@ -1,16 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPersonalMemoryShadowPipeline, resolvePersonalShadowConfig } from './memory-v2-personal-shadow.mjs';
import {
createPersonalMemoryShadowPipeline,
resolvePersonalShadowConfig,
shouldAutoAcceptCandidate,
} from './memory-v2-personal-shadow.mjs';
import { createMemoryV2 } from './memory-v2.mjs';
test('personal memory shadow config never activates response injection', () => {
test('personal memory shadow config maps active mode to auto review', () => {
const config = resolvePersonalShadowConfig({
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'active',
});
assert.equal(config.enabled, true);
assert.equal(config.requestedMode, 'active');
assert.equal(config.effectiveMode, 'active');
assert.equal(config.autoReviewEnabled, true);
});
test('personal memory shadow config keeps shadow mode for manual review', () => {
const config = resolvePersonalShadowConfig({
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'shadow',
});
assert.equal(config.effectiveMode, 'shadow');
assert.equal(config.autoReviewEnabled, false);
});
test('shouldAutoAcceptCandidate auto accepts explicit requests in canary mode', () => {
const config = resolvePersonalShadowConfig({
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'canary',
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
});
assert.equal(shouldAutoAcceptCandidate({
policyReason: 'explicit_memory_request',
confidence: 0.98,
}, config), true);
assert.equal(shouldAutoAcceptCandidate({
policyReason: 'stable_fact_signal',
confidence: 0.82,
}, config), false);
assert.equal(shouldAutoAcceptCandidate({
policyReason: 'decision_signal',
confidence: 0.9,
}, config), true);
});
test('shadow pipeline auto accepts durable candidates in active mode', async () => {
const saved = [];
const pipeline = createPersonalMemoryShadowPipeline({
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'active',
MEMORY_POLICY_ENABLED: '1',
},
store: {
async saveCandidate(candidate, options = {}) {
saved.push({ candidate, options });
return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' };
},
},
});
const result = await pipeline.observeWrite({
userId: 'u1',
sessionId: 's1',
messages: [{ role: 'user', text: '我决定所有生产发布必须从完整 main 分支打包。' }],
});
assert.equal(result.autoReviewed, 1);
assert.equal(result.pendingReview, 0);
assert.equal(saved.length, 1);
assert.equal(saved[0].options.autoAccept, true);
assert.equal(pipeline.getStatus().autoReviewEnabled, true);
assert.equal(pipeline.getStatus().phase, 'auto-review-v1');
});
test('shadow pipeline keeps low-confidence canary candidates pending review', async () => {
const saved = [];
const pipeline = createPersonalMemoryShadowPipeline({
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'canary',
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
},
store: {
async saveCandidate(candidate, options = {}) {
saved.push({ candidate, options });
return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' };
},
},
});
await pipeline.observeWrite({
userId: 'u1',
sessionId: 's1',
messages: [{ role: 'user', text: '项目使用 pgvector 作为向量存储。' }],
});
assert.equal(saved.length, 1);
assert.equal(saved[0].options.autoAccept, false);
assert.equal(pipeline.getStatus().health.pendingReview, 1);
});
test('shadow pipeline extracts durable candidates with evidence and deduplicates', async () => {
@@ -24,7 +111,10 @@ test('shadow pipeline extracts durable candidates with evidence and deduplicates
MEMORY_CANDIDATE_MIN_IMPORTANCE: '0.7',
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
},
now: () => clock += 1,
now: () => {
clock += 1;
return clock;
},
});
const input = {
userId: 'u1',
+10 -4
View File
@@ -60,13 +60,16 @@ export function createPersonalMemoryCandidateStore(pool, { table = TABLE, now =
const quoted = `\`${table}\``;
return {
async saveCandidate(candidate) {
async saveCandidate(candidate, { autoAccept = false, reviewedBy = 'system:auto-review' } = {}) {
const updatedAt = now();
const status = autoAccept ? 'accepted' : 'candidate';
const reviewedAt = autoAccept ? updatedAt : null;
const reviewer = autoAccept ? String(reviewedBy || 'system:auto-review') : null;
const [result] = await pool.query(
`INSERT IGNORE INTO ${quoted}
(id, user_id, session_id, memory_type, content, importance, confidence, status,
policy_reason, evidence_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'candidate', ?, ?, ?, ?)`,
policy_reason, evidence_json, reviewed_by, reviewed_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
candidate.id,
candidate.userId,
@@ -75,13 +78,16 @@ export function createPersonalMemoryCandidateStore(pool, { table = TABLE, now =
candidate.content,
candidate.importance,
candidate.confidence,
status,
candidate.policyReason,
JSON.stringify(candidate.evidence ?? {}),
reviewer,
reviewedAt,
candidate.createdAt,
updatedAt,
],
);
return { inserted: Number(result?.affectedRows ?? 0) > 0 };
return { inserted: Number(result?.affectedRows ?? 0) > 0, status };
},
async listCandidates({ status = 'candidate', userId = null, limit = 50, offset = 0 } = {}) {
+1 -1
View File
@@ -37,7 +37,7 @@ test('candidate store saves, lists, counts, and reviews with parameterized SQL',
id: 'pmc_1', userId: 'u1', sessionId: 's1', memoryType: 'episodic',
content: '决定使用 main 发布', importance: 0.9, confidence: 0.9,
policyReason: 'decision_signal', evidence: { sourceId: 's1:0' }, createdAt: 1,
}), { inserted: true });
}, { autoAccept: true }), { inserted: true, status: 'accepted' });
const rows = await store.listCandidates({ limit: 20 });
assert.equal(rows[0].evidence.sourceId, 's1:0');
assert.deepEqual(await store.countByStatus(), { candidate: 1 });