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:
@@ -70,13 +70,34 @@ function classify(text) {
|
|||||||
return rules.find((rule) => rule.pattern.test(text)) ?? null;
|
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) {
|
export function resolvePersonalShadowConfig(env = process.env) {
|
||||||
const candidateEnabled = readFlag(env, 'MEMORY_CANDIDATE_ENABLED', false);
|
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 {
|
return {
|
||||||
enabled: candidateEnabled && requestedMode !== 'off',
|
enabled,
|
||||||
requestedMode,
|
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 }),
|
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 }),
|
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 })),
|
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,
|
runs: 0,
|
||||||
observedMessages: 0,
|
observedMessages: 0,
|
||||||
accepted: 0,
|
accepted: 0,
|
||||||
|
autoReviewed: 0,
|
||||||
|
pendingReview: 0,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
deduped: 0,
|
deduped: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
@@ -178,10 +201,21 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
|
|||||||
metrics.observedMessages += 1;
|
metrics.observedMessages += 1;
|
||||||
const result = evaluate({ userId, sessionId, message, index });
|
const result = evaluate({ userId, sessionId, message, index });
|
||||||
if (result.accepted && store?.saveCandidate) {
|
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) {
|
if (persisted?.inserted === false) {
|
||||||
metrics.deduped += 1;
|
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);
|
results.push(result);
|
||||||
}
|
}
|
||||||
@@ -189,8 +223,11 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
skipped: false,
|
skipped: false,
|
||||||
mode: config.effectiveMode,
|
mode: config.effectiveMode,
|
||||||
|
autoReviewEnabled: config.autoReviewEnabled,
|
||||||
observed: results.length,
|
observed: results.length,
|
||||||
accepted: results.filter((result) => result.accepted).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,
|
rejected: results.filter((result) => !result.accepted).length,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -205,7 +242,8 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
|
|||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
requestedMode: config.requestedMode,
|
requestedMode: config.requestedMode,
|
||||||
effectiveMode: config.effectiveMode,
|
effectiveMode: config.effectiveMode,
|
||||||
phase: 'shadow-candidate-v1',
|
phase: config.autoReviewEnabled ? 'auto-review-v1' : 'shadow-candidate-v1',
|
||||||
|
autoReviewEnabled: config.autoReviewEnabled,
|
||||||
injectionEnabled: false,
|
injectionEnabled: false,
|
||||||
persistence: store?.saveCandidate ? 'mysql' : 'bounded-memory',
|
persistence: store?.saveCandidate ? 'mysql' : 'bounded-memory',
|
||||||
pendingCandidates: candidates.length,
|
pendingCandidates: candidates.length,
|
||||||
|
|||||||
@@ -1,16 +1,103 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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';
|
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({
|
const config = resolvePersonalShadowConfig({
|
||||||
MEMORY_CANDIDATE_ENABLED: '1',
|
MEMORY_CANDIDATE_ENABLED: '1',
|
||||||
MEMORY_CANDIDATE_MODE: 'active',
|
MEMORY_CANDIDATE_MODE: 'active',
|
||||||
});
|
});
|
||||||
assert.equal(config.enabled, true);
|
assert.equal(config.enabled, true);
|
||||||
assert.equal(config.requestedMode, 'active');
|
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.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 () => {
|
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_IMPORTANCE: '0.7',
|
||||||
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
|
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
|
||||||
},
|
},
|
||||||
now: () => clock += 1,
|
now: () => {
|
||||||
|
clock += 1;
|
||||||
|
return clock;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const input = {
|
const input = {
|
||||||
userId: 'u1',
|
userId: 'u1',
|
||||||
|
|||||||
@@ -60,13 +60,16 @@ export function createPersonalMemoryCandidateStore(pool, { table = TABLE, now =
|
|||||||
const quoted = `\`${table}\``;
|
const quoted = `\`${table}\``;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async saveCandidate(candidate) {
|
async saveCandidate(candidate, { autoAccept = false, reviewedBy = 'system:auto-review' } = {}) {
|
||||||
const updatedAt = now();
|
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(
|
const [result] = await pool.query(
|
||||||
`INSERT IGNORE INTO ${quoted}
|
`INSERT IGNORE INTO ${quoted}
|
||||||
(id, user_id, session_id, memory_type, content, importance, confidence, status,
|
(id, user_id, session_id, memory_type, content, importance, confidence, status,
|
||||||
policy_reason, evidence_json, created_at, updated_at)
|
policy_reason, evidence_json, reviewed_by, reviewed_at, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'candidate', ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
candidate.id,
|
candidate.id,
|
||||||
candidate.userId,
|
candidate.userId,
|
||||||
@@ -75,13 +78,16 @@ export function createPersonalMemoryCandidateStore(pool, { table = TABLE, now =
|
|||||||
candidate.content,
|
candidate.content,
|
||||||
candidate.importance,
|
candidate.importance,
|
||||||
candidate.confidence,
|
candidate.confidence,
|
||||||
|
status,
|
||||||
candidate.policyReason,
|
candidate.policyReason,
|
||||||
JSON.stringify(candidate.evidence ?? {}),
|
JSON.stringify(candidate.evidence ?? {}),
|
||||||
|
reviewer,
|
||||||
|
reviewedAt,
|
||||||
candidate.createdAt,
|
candidate.createdAt,
|
||||||
updatedAt,
|
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 } = {}) {
|
async listCandidates({ status = 'candidate', userId = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ test('candidate store saves, lists, counts, and reviews with parameterized SQL',
|
|||||||
id: 'pmc_1', userId: 'u1', sessionId: 's1', memoryType: 'episodic',
|
id: 'pmc_1', userId: 'u1', sessionId: 's1', memoryType: 'episodic',
|
||||||
content: '决定使用 main 发布', importance: 0.9, confidence: 0.9,
|
content: '决定使用 main 发布', importance: 0.9, confidence: 0.9,
|
||||||
policyReason: 'decision_signal', evidence: { sourceId: 's1:0' }, createdAt: 1,
|
policyReason: 'decision_signal', evidence: { sourceId: 's1:0' }, createdAt: 1,
|
||||||
}), { inserted: true });
|
}, { autoAccept: true }), { inserted: true, status: 'accepted' });
|
||||||
const rows = await store.listCandidates({ limit: 20 });
|
const rows = await store.listCandidates({ limit: 20 });
|
||||||
assert.equal(rows[0].evidence.sourceId, 's1:0');
|
assert.equal(rows[0].evidence.sourceId, 's1:0');
|
||||||
assert.deepEqual(await store.countByStatus(), { candidate: 1 });
|
assert.deepEqual(await store.countByStatus(), { candidate: 1 });
|
||||||
|
|||||||
Reference in New Issue
Block a user