fix(wechat): guard ambiguous memory recall and scheduled task execution
Skip memory injection for short filler messages, demote page-workflow memory pollution, keep scheduled automation off page.generate, and fail scheduled tasks that only return clarification instead of deliverables. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -208,6 +208,40 @@ export function isMemoryRecallQuestion(text) {
|
|||||||
return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized));
|
return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_WORKFLOW_QUERY_PATTERNS = [
|
||||||
|
/(?:生成|做成|整理|排版|导出|发布).{0,8}(?:页面|网页|html|专题|报告页|推文)/iu,
|
||||||
|
/(?:mindspace|MindSpace)/u,
|
||||||
|
/每日新闻/u,
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAGE_WORKFLOW_MEMORY_PATTERNS = [
|
||||||
|
/(?:做成|生成|整理|排版).{0,8}(?:页面|网页|html|专题|报告)/iu,
|
||||||
|
/每日新闻/u,
|
||||||
|
/(?:mindspace|MindSpace)/u,
|
||||||
|
/页面.{0,8}(?:偏好|习惯|模板)/u,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function queryImpliesPageWorkflow(text) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return PAGE_WORKFLOW_QUERY_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPageWorkflowMemory(text) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return PAGE_WORKFLOW_MEMORY_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSkipAmbiguousMemoryQuery(text) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
if (!normalized) return true;
|
||||||
|
if (isMemoryRecallQuestion(normalized)) return false;
|
||||||
|
if (pgvectorMemoryBackendInternals.extractKeywordTerms(normalized).length > 0) return false;
|
||||||
|
// Short acknowledgements/fillers without a topic anchor (e.g. 「可以」「先不聊了」).
|
||||||
|
return normalized.length <= 4;
|
||||||
|
}
|
||||||
|
|
||||||
function memoryItemText(item) {
|
function memoryItemText(item) {
|
||||||
return String(item?.text ?? item?.memory_text ?? item?.content ?? '').trim();
|
return String(item?.text ?? item?.memory_text ?? item?.content ?? '').trim();
|
||||||
}
|
}
|
||||||
@@ -229,6 +263,13 @@ export function scoreAgentMemoryCandidate(item, query) {
|
|||||||
}
|
}
|
||||||
let score = (lexical * 100) + keywordBoost;
|
let score = (lexical * 100) + keywordBoost;
|
||||||
if (isEpisodicMemoryItem(item) && score > 0) score += 5;
|
if (isEpisodicMemoryItem(item) && score > 0) score += 5;
|
||||||
|
if (
|
||||||
|
isPageWorkflowMemory(text)
|
||||||
|
&& !queryImpliesPageWorkflow(query)
|
||||||
|
&& !isMemoryRecallQuestion(query)
|
||||||
|
) {
|
||||||
|
score -= 100;
|
||||||
|
}
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1366,6 +1407,11 @@ export function createChatIntentRouter(options = {}) {
|
|||||||
agentMemoryMetrics.lastReason = 'intervention_skip';
|
agentMemoryMetrics.lastReason = 'intervention_skip';
|
||||||
return { ...base, reason: 'intervention_skip' };
|
return { ...base, reason: 'intervention_skip' };
|
||||||
}
|
}
|
||||||
|
if (shouldSkipAmbiguousMemoryQuery(text)) {
|
||||||
|
agentMemoryMetrics.skipped += 1;
|
||||||
|
agentMemoryMetrics.lastReason = 'ambiguous_query_skip';
|
||||||
|
return { ...base, reason: 'ambiguous_query_skip' };
|
||||||
|
}
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
let personalFailed = false;
|
let personalFailed = false;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
resolveChatIntentRouterPolicy,
|
resolveChatIntentRouterPolicy,
|
||||||
mergeAgentMemoryCandidates,
|
mergeAgentMemoryCandidates,
|
||||||
scoreAgentMemoryCandidate,
|
scoreAgentMemoryCandidate,
|
||||||
|
shouldSkipAmbiguousMemoryQuery,
|
||||||
} from './chat-intent-router.mjs';
|
} from './chat-intent-router.mjs';
|
||||||
|
|
||||||
/** Ambiguous user text that should miss FAQ/rules and exercise LLM router paths in tests. */
|
/** Ambiguous user text that should miss FAQ/rules and exercise LLM router paths in tests. */
|
||||||
@@ -1398,6 +1399,66 @@ test('scoreAgentMemoryCandidate boosts episodic matches when topic overlap exist
|
|||||||
assert.ok(episodicScore > personalScore);
|
assert.ok(episodicScore > personalScore);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shouldSkipAmbiguousMemoryQuery skips filler without topic anchor', () => {
|
||||||
|
assert.equal(shouldSkipAmbiguousMemoryQuery('可以'), true);
|
||||||
|
assert.equal(shouldSkipAmbiguousMemoryQuery('我们继续聊聊德川家康'), false);
|
||||||
|
assert.equal(shouldSkipAmbiguousMemoryQuery('9岁小孩教育'), false);
|
||||||
|
assert.equal(shouldSkipAmbiguousMemoryQuery('你记得我们聊过什么吗'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent memory skips resolve for ambiguous short filler', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const router = createChatIntentRouter({
|
||||||
|
env: {
|
||||||
|
MEMORY_AGENT_RESOLVE_ENABLED: '1',
|
||||||
|
MEMORY_AGENT_INJECTION_MODE: 'active',
|
||||||
|
},
|
||||||
|
memoryV2: {
|
||||||
|
async resolve(input) {
|
||||||
|
calls.push(input);
|
||||||
|
return { memories: [{ label: 'preference', text: '每日新闻页面偏好' }] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await router.resolveAgentMemoryContext({
|
||||||
|
userId: 'user-tang',
|
||||||
|
text: '可以',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.skipped, true);
|
||||||
|
assert.equal(result.reason, 'ambiguous_query_skip');
|
||||||
|
assert.equal(result.memories.length, 0);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent memory merge demotes page workflow memories when query is general chat', () => {
|
||||||
|
const merged = mergeAgentMemoryCandidates({
|
||||||
|
query: '9岁小孩教育,孩子玩手机超时怎么办',
|
||||||
|
limit: 2,
|
||||||
|
personalMemories: [
|
||||||
|
{ id: 'personal:news', label: 'preference', text: '用户希望整理每日新闻并做成页面发布' },
|
||||||
|
{ id: 'personal:kid', label: 'experience', text: '用户关心9岁孩子教育与手机使用边界' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(merged[0].id, 'personal:kid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scoreAgentMemoryCandidate penalizes page workflow memories on general chat', () => {
|
||||||
|
const pageScore = scoreAgentMemoryCandidate({
|
||||||
|
id: 'personal:news',
|
||||||
|
label: 'preference',
|
||||||
|
text: '用户希望整理每日新闻并做成页面发布',
|
||||||
|
}, '可以,我们继续聊孩子教育');
|
||||||
|
const topicScore = scoreAgentMemoryCandidate({
|
||||||
|
id: 'personal:kid',
|
||||||
|
label: 'experience',
|
||||||
|
text: '用户关心9岁孩子教育与手机使用边界',
|
||||||
|
}, '可以,我们继续聊孩子教育');
|
||||||
|
assert.ok(topicScore > pageScore);
|
||||||
|
});
|
||||||
|
|
||||||
test('active agent memory context is hidden from displayText but available to orchestration envelope', () => {
|
test('active agent memory context is hidden from displayText but available to orchestration envelope', () => {
|
||||||
const enriched = applyAgentOrchestrationToUserMessage(
|
const enriched = applyAgentOrchestrationToUserMessage(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export function buildScheduledTaskExecutionPrompt(task, {
|
|||||||
`任务要求:${task.taskSpec}`,
|
`任务要求:${task.taskSpec}`,
|
||||||
'执行约束:',
|
'执行约束:',
|
||||||
'- 这是系统自动触发的定时任务,请直接完成可交付结果,不要反问用户。',
|
'- 这是系统自动触发的定时任务,请直接完成可交付结果,不要反问用户。',
|
||||||
|
'- 禁止向用户追问时间、频率或任务内容;taskSpec 已是完整执行说明。',
|
||||||
|
'- 禁止调用 scheduled_task_create / scheduled_task_list / scheduled_task_cancel;只执行 taskSpec。',
|
||||||
'- 若需要生成页面,必须先 load_skill → static-page-publish,再 write_file 到 public/*.html,并给出正式可访问 URL。',
|
'- 若需要生成页面,必须先 load_skill → static-page-publish,再 write_file 到 public/*.html,并给出正式可访问 URL。',
|
||||||
'- 若只需摘要/文本,给出完整中文结果摘要。',
|
'- 若只需摘要/文本,给出完整中文结果摘要。',
|
||||||
'- 完成后在回复中明确写出交付结果(链接或摘要)。',
|
'- 完成后在回复中明确写出交付结果(链接或摘要)。',
|
||||||
@@ -69,6 +71,31 @@ export function formatScheduledTaskDeliveryMessage(task, deliveryText) {
|
|||||||
return `${header}\n\n${body}`.trim();
|
return `${header}\n\n${body}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [
|
||||||
|
/需确认/u,
|
||||||
|
/请确认/u,
|
||||||
|
/请问/u,
|
||||||
|
/未指定/u,
|
||||||
|
/需要澄清/u,
|
||||||
|
/在创建前需要确认/u,
|
||||||
|
/信息不完整/u,
|
||||||
|
/具体几点/u,
|
||||||
|
/缺(?:少|失)/u,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function looksLikeScheduledTaskNonDelivery(text) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
if (!normalized) return true;
|
||||||
|
if (SCHEDULED_TASK_CLARIFICATION_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/https?:\/\//i.test(normalized)) return false;
|
||||||
|
if (/public\/[^\s]+\.html/i.test(normalized)) return false;
|
||||||
|
if (/页面链接/u.test(normalized)) return false;
|
||||||
|
if (/(?:已生成|已完成|交付).{0,24}(?:页面|链接|结果)/u.test(normalized)) return false;
|
||||||
|
return normalized.length < 40;
|
||||||
|
}
|
||||||
|
|
||||||
export async function finalizeScheduledTaskPageDelivery({
|
export async function finalizeScheduledTaskPageDelivery({
|
||||||
pool,
|
pool,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildScheduledTaskExecutionPrompt,
|
buildScheduledTaskExecutionPrompt,
|
||||||
extractScheduledTaskDeliveryText,
|
extractScheduledTaskDeliveryText,
|
||||||
formatScheduledTaskDeliveryMessage,
|
formatScheduledTaskDeliveryMessage,
|
||||||
|
looksLikeScheduledTaskNonDelivery,
|
||||||
} from './scheduled-task-executor.mjs';
|
} from './scheduled-task-executor.mjs';
|
||||||
|
|
||||||
test('buildScheduledTaskExecutionPrompt includes task spec and automation marker', () => {
|
test('buildScheduledTaskExecutionPrompt includes task spec and automation marker', () => {
|
||||||
@@ -19,6 +20,18 @@ test('buildScheduledTaskExecutionPrompt includes task spec and automation marker
|
|||||||
assert.match(text, /每日新闻页/);
|
assert.match(text, /每日新闻页/);
|
||||||
assert.match(text, /搜索今日新闻并生成 HTML 页面/);
|
assert.match(text, /搜索今日新闻并生成 HTML 页面/);
|
||||||
assert.match(text, /Scheduled Automation/);
|
assert.match(text, /Scheduled Automation/);
|
||||||
|
assert.match(text, /禁止调用 scheduled_task_create/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('looksLikeScheduledTaskNonDelivery detects clarification replies', () => {
|
||||||
|
assert.equal(
|
||||||
|
looksLikeScheduledTaskNonDelivery('请问 7月31日具体几点执行这个任务?'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
looksLikeScheduledTaskNonDelivery('页面已生成:https://example.com/news.html'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extractScheduledTaskDeliveryText reads last assistant message', () => {
|
test('extractScheduledTaskDeliveryText reads last assistant message', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
executeScheduledTask,
|
executeScheduledTask,
|
||||||
formatScheduledTaskDeliveryMessage,
|
formatScheduledTaskDeliveryMessage,
|
||||||
|
looksLikeScheduledTaskNonDelivery,
|
||||||
} from './scheduled-task-executor.mjs';
|
} from './scheduled-task-executor.mjs';
|
||||||
|
|
||||||
export function startScheduledTaskWorker({
|
export function startScheduledTaskWorker({
|
||||||
@@ -77,6 +78,11 @@ export function startScheduledTaskWorker({
|
|||||||
timeoutMs: executionTimeoutMs,
|
timeoutMs: executionTimeoutMs,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
if (looksLikeScheduledTaskNonDelivery(result.deliveryText)) {
|
||||||
|
const err = new Error('定时任务未产出可交付结果');
|
||||||
|
err.code = 'SCHEDULED_TASK_NON_DELIVERY';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
await deliverTaskResult(task, result.deliveryText);
|
await deliverTaskResult(task, result.deliveryText);
|
||||||
await scheduledTaskService.markTaskSucceeded(task, {
|
await scheduledTaskService.markTaskSucceeded(task, {
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
@@ -123,3 +123,55 @@ test('scheduled task worker marks failure after execution error', async () => {
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['failed:3:agent timeout', 'notify:scheduled_task_failed']);
|
assert.deepEqual(calls, ['failed:3:agent timeout', 'notify:scheduled_task_failed']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('scheduled task worker marks failure when agent only asks for clarification', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const task = {
|
||||||
|
id: 'task-3',
|
||||||
|
userId: 'user-3',
|
||||||
|
title: '每日诗意清晨',
|
||||||
|
recurrence: 'daily',
|
||||||
|
notifyChannel: 'web',
|
||||||
|
attempts: 1,
|
||||||
|
};
|
||||||
|
const worker = startScheduledTaskWorker({
|
||||||
|
intervalMs: 60_000,
|
||||||
|
userAuth: { id: 'user-auth' },
|
||||||
|
tkmindProxy: { id: 'proxy' },
|
||||||
|
scheduledTaskService: {
|
||||||
|
async listDueTasks() {
|
||||||
|
return [task];
|
||||||
|
},
|
||||||
|
async lockTask() {
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
async markTaskRunning(input) {
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
async markTaskSucceeded() {
|
||||||
|
calls.push('success');
|
||||||
|
},
|
||||||
|
async markTaskFailed(input, err) {
|
||||||
|
calls.push(`failed:${err.code}`);
|
||||||
|
return { ...input, status: 'failed', lastError: err.message };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scheduleService: {
|
||||||
|
async createUserNotification(input) {
|
||||||
|
calls.push(`notify:${input.notificationType}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
executeTask: async () => ({
|
||||||
|
sessionId: 'session-3',
|
||||||
|
requestId: 'req-3',
|
||||||
|
deliveryText: '请问每天早上几点执行?',
|
||||||
|
}),
|
||||||
|
logger: { warn() {} },
|
||||||
|
runOnStart: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await worker.runOnce();
|
||||||
|
worker.stop();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['failed:SCHEDULED_TASK_NON_DELIVERY', 'notify:scheduled_task_failed']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3525,6 +3525,12 @@ export function createWechatMpService({
|
|||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
logger,
|
logger,
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.warn?.(
|
||||||
|
'WeChat MP schedule intent handling failed open:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (scheduleReply) {
|
if (scheduleReply) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
isWechatPageEditText,
|
isWechatPageEditText,
|
||||||
isWechatPageRetryText,
|
isWechatPageRetryText,
|
||||||
} from './page-continuation.mjs';
|
} from './page-continuation.mjs';
|
||||||
|
import { shouldUseScheduledTaskAutomation } from '../../scheduled-task-intent.mjs';
|
||||||
|
|
||||||
export const PAGE_GENERATE_PATTERN =
|
export const PAGE_GENERATE_PATTERN =
|
||||||
/(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/iu;
|
/(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/iu;
|
||||||
@@ -29,6 +30,7 @@ export const CONNECTIVITY_TEST_PATTERN = /^(测试\s*\d*|test\s*\d*)[!!。.\s]
|
|||||||
export function isPageGenerateText(text) {
|
export function isPageGenerateText(text) {
|
||||||
const normalized = String(text ?? '').trim();
|
const normalized = String(text ?? '').trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
|
if (shouldUseScheduledTaskAutomation(normalized)) return false;
|
||||||
if (PAGE_GENERATE_NEGATION_PATTERN.test(normalized)) return false;
|
if (PAGE_GENERATE_NEGATION_PATTERN.test(normalized)) return false;
|
||||||
return (
|
return (
|
||||||
PAGE_GENERATE_PATTERN.test(normalized)
|
PAGE_GENERATE_PATTERN.test(normalized)
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ test('classifyWechatIntent detects page.generate', () => {
|
|||||||
assert.equal(isPageGenerateIntent({ msgType: 'text', agentText: intent.topic }), true);
|
assert.equal(isPageGenerateIntent({ msgType: 'text', agentText: intent.topic }), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('classifyWechatIntent keeps scheduled automation on chat.general', () => {
|
||||||
|
const intent = classifyWechatIntent({
|
||||||
|
msgType: 'text',
|
||||||
|
agentText: '我想创建一个定时执行任务,今晚 21:45 分执行做新闻页面',
|
||||||
|
});
|
||||||
|
assert.equal(intent.kind, 'chat.general');
|
||||||
|
assert.equal(isPageGenerateIntent({ msgType: 'text', agentText: intent.text }), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('classifyWechatIntent detects session.reset', () => {
|
test('classifyWechatIntent detects session.reset', () => {
|
||||||
assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换话题' }).kind, 'session.reset');
|
assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换话题' }).kind, 'session.reset');
|
||||||
assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换新会话' }).kind, 'session.reset');
|
assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换新会话' }).kind, 'session.reset');
|
||||||
|
|||||||
Reference in New Issue
Block a user