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));
|
||||
}
|
||||
|
||||
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) {
|
||||
return String(item?.text ?? item?.memory_text ?? item?.content ?? '').trim();
|
||||
}
|
||||
@@ -229,6 +263,13 @@ export function scoreAgentMemoryCandidate(item, query) {
|
||||
}
|
||||
let score = (lexical * 100) + keywordBoost;
|
||||
if (isEpisodicMemoryItem(item) && score > 0) score += 5;
|
||||
if (
|
||||
isPageWorkflowMemory(text)
|
||||
&& !queryImpliesPageWorkflow(query)
|
||||
&& !isMemoryRecallQuestion(query)
|
||||
) {
|
||||
score -= 100;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
@@ -1366,6 +1407,11 @@ export function createChatIntentRouter(options = {}) {
|
||||
agentMemoryMetrics.lastReason = '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();
|
||||
try {
|
||||
let personalFailed = false;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
resolveChatIntentRouterPolicy,
|
||||
mergeAgentMemoryCandidates,
|
||||
scoreAgentMemoryCandidate,
|
||||
shouldSkipAmbiguousMemoryQuery,
|
||||
} from './chat-intent-router.mjs';
|
||||
|
||||
/** 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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const enriched = applyAgentOrchestrationToUserMessage(
|
||||
{
|
||||
|
||||
@@ -30,6 +30,8 @@ export function buildScheduledTaskExecutionPrompt(task, {
|
||||
`任务要求:${task.taskSpec}`,
|
||||
'执行约束:',
|
||||
'- 这是系统自动触发的定时任务,请直接完成可交付结果,不要反问用户。',
|
||||
'- 禁止向用户追问时间、频率或任务内容;taskSpec 已是完整执行说明。',
|
||||
'- 禁止调用 scheduled_task_create / scheduled_task_list / scheduled_task_cancel;只执行 taskSpec。',
|
||||
'- 若需要生成页面,必须先 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();
|
||||
}
|
||||
|
||||
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({
|
||||
pool,
|
||||
userId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildScheduledTaskExecutionPrompt,
|
||||
extractScheduledTaskDeliveryText,
|
||||
formatScheduledTaskDeliveryMessage,
|
||||
looksLikeScheduledTaskNonDelivery,
|
||||
} from './scheduled-task-executor.mjs';
|
||||
|
||||
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, /搜索今日新闻并生成 HTML 页面/);
|
||||
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', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
executeScheduledTask,
|
||||
formatScheduledTaskDeliveryMessage,
|
||||
looksLikeScheduledTaskNonDelivery,
|
||||
} from './scheduled-task-executor.mjs';
|
||||
|
||||
export function startScheduledTaskWorker({
|
||||
@@ -77,6 +78,11 @@ export function startScheduledTaskWorker({
|
||||
timeoutMs: executionTimeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (looksLikeScheduledTaskNonDelivery(result.deliveryText)) {
|
||||
const err = new Error('定时任务未产出可交付结果');
|
||||
err.code = 'SCHEDULED_TASK_NON_DELIVERY';
|
||||
throw err;
|
||||
}
|
||||
await deliverTaskResult(task, result.deliveryText);
|
||||
await scheduledTaskService.markTaskSucceeded(task, {
|
||||
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']);
|
||||
});
|
||||
|
||||
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,
|
||||
llmProviderService,
|
||||
logger,
|
||||
}).catch((err) => {
|
||||
logger.warn?.(
|
||||
'WeChat MP schedule intent handling failed open:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
if (scheduleReply) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isWechatPageEditText,
|
||||
isWechatPageRetryText,
|
||||
} from './page-continuation.mjs';
|
||||
import { shouldUseScheduledTaskAutomation } from '../../scheduled-task-intent.mjs';
|
||||
|
||||
export const PAGE_GENERATE_PATTERN =
|
||||
/(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/iu;
|
||||
@@ -29,6 +30,7 @@ export const CONNECTIVITY_TEST_PATTERN = /^(测试\s*\d*|test\s*\d*)[!!。.\s]
|
||||
export function isPageGenerateText(text) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
if (shouldUseScheduledTaskAutomation(normalized)) return false;
|
||||
if (PAGE_GENERATE_NEGATION_PATTERN.test(normalized)) return false;
|
||||
return (
|
||||
PAGE_GENERATE_PATTERN.test(normalized)
|
||||
|
||||
@@ -33,6 +33,15 @@ test('classifyWechatIntent detects page.generate', () => {
|
||||
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', () => {
|
||||
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