diff --git a/wechat-mp.mjs b/wechat-mp.mjs index bc857d2..74f05a4 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -974,6 +974,30 @@ export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) { ); } +const WECHAT_IMMEDIATE_CONTEXT_PAGE_PATTERN = + /^(?:(?:请|麻烦|可以|能不能)\s*)?(?:(?:帮我|帮忙|给我)\s*)?(?:(?:把\s*)?(?:这个|刚才(?:的)?(?:诗|文章|内容)?|上面(?:的)?|前面(?:的)?|上一条|它|这首(?:诗)?|这篇(?:文章)?|这段(?:内容)?)\s*)?(?:继续\s*)?(?:做|弄|改|转|写|排版|生成|制作)(?:成|为)?\s*(?:一个|个)?\s*(?:h5|html)?\s*(?:页面|网页)(?:吧|呀|呢|一下)?[。.!!\s]*$/iu; + +export function isWechatImmediateContextFollowup(wechatIntent, text) { + if (wechatIntent?.kind !== 'page.generate') return false; + const normalized = String(text ?? '').trim(); + return Boolean(normalized && WECHAT_IMMEDIATE_CONTEXT_PAGE_PATTERN.test(normalized)); +} + +export function shouldRotateUnlinkedWechatRoute({ + routeUpdatedAt, + wechatMessageCount, + snapshotMessageCount, + now = Date.now(), +} = {}) { + const updatedAt = Number(routeUpdatedAt ?? 0); + return ( + updatedAt > 0 && + Number(now) - updatedAt > 60_000 && + Number(wechatMessageCount) === 0 && + Number(snapshotMessageCount ?? 0) > 0 + ); +} + export function isWechatPageDataTask(text) { return isPageDataIntent(text) || isPageDataDevIntent(text); } @@ -1978,42 +2002,56 @@ export function createWechatMpService({ config.sessionIdleRotateMs > 0 && routeUpdatedAt > 0 && now - routeUpdatedAt > config.sessionIdleRotateMs; + let routeWechatMessageCount = null; + let routeSnapshotMessageCount = null; let routeMessageCount = 0; if (config.sessionMessageRotateCount > 0) { - const counts = []; - if (typeof userAuth.countWechatAgentSessionMessages === 'function') { - counts.push( - userAuth - .countWechatAgentSessionMessages({ - appId: config.appId, - openid, - agentSessionId: existingRoute.agentSessionId, - }) - .catch((err) => { - logger.warn?.('WeChat MP route message count lookup failed:', err); - return 0; - }), - ); - } - if (typeof userAuth.getWechatAgentSessionSnapshotMessageCount === 'function') { - counts.push( - userAuth - .getWechatAgentSessionSnapshotMessageCount(existingRoute.agentSessionId) - .catch((err) => { - logger.warn?.('WeChat MP snapshot message count lookup failed:', err); - return 0; - }), - ); - } - if (counts.length > 0) { - const resolved = await Promise.all(counts); - routeMessageCount = Math.max(0, ...resolved.map((value) => Number(value) || 0)); - } + [routeWechatMessageCount, routeSnapshotMessageCount] = await Promise.all([ + typeof userAuth.countWechatAgentSessionMessages === 'function' + ? userAuth + .countWechatAgentSessionMessages({ + appId: config.appId, + openid, + agentSessionId: existingRoute.agentSessionId, + }) + .then((value) => Number(value) || 0) + .catch((err) => { + logger.warn?.('WeChat MP route message count lookup failed:', err); + return null; + }) + : null, + typeof userAuth.getWechatAgentSessionSnapshotMessageCount === 'function' + ? userAuth + .getWechatAgentSessionSnapshotMessageCount(existingRoute.agentSessionId) + .then((value) => Number(value) || 0) + .catch((err) => { + logger.warn?.('WeChat MP snapshot message count lookup failed:', err); + return null; + }) + : null, + ]); + routeMessageCount = Math.max( + 0, + Number(routeWechatMessageCount) || 0, + Number(routeSnapshotMessageCount) || 0, + ); } const routeIsTooLong = config.sessionMessageRotateCount > 0 && Number(routeMessageCount ?? 0) >= config.sessionMessageRotateCount; - if (routeIsIdle || routeIsTooLong) { + const routeHasUnlinkedConversation = shouldRotateUnlinkedWechatRoute({ + routeUpdatedAt, + wechatMessageCount: routeWechatMessageCount, + snapshotMessageCount: routeSnapshotMessageCount, + now, + }); + if (routeIsIdle || routeIsTooLong || routeHasUnlinkedConversation) { + if (routeHasUnlinkedConversation) { + logger.warn?.('WeChat MP orphaned route rotated before reuse:', { + agentSessionId: existingRoute.agentSessionId, + snapshotMessageCount: routeSnapshotMessageCount, + }); + } await userAuth.clearWechatAgentRoute(config.appId, openid); rememberedWechatContexts.delete(existingRoute.agentSessionId); } else { @@ -2271,7 +2309,12 @@ export function createWechatMpService({ } }; - const prepareWechatAgentUserMessage = async ({ userId, sessionId, userMessage }) => { + const prepareWechatAgentUserMessage = async ({ + userId, + sessionId, + userMessage, + preserveAgentPrompt = false, + }) => { if ( !chatIntentRouter?.resolveAgentMemoryContext || !chatIntentRouter?.applyAgentOrchestration @@ -2296,8 +2339,17 @@ export function createWechatMpService({ ) { return userMessage; } - return chatIntentRouter.applyAgentOrchestration( - userMessage, + const orchestrationMessage = preserveAgentPrompt + ? { + ...userMessage, + metadata: { + ...(userMessage?.metadata ?? {}), + displayText: messageVisibleText(userMessage), + }, + } + : userMessage; + const prepared = chatIntentRouter.applyAgentOrchestration( + orchestrationMessage, { route: 'agent_orchestration', reason: '微信服务号消息由 Agent 处理', @@ -2305,6 +2357,14 @@ export function createWechatMpService({ }, { memoryContext }, ); + if (!preserveAgentPrompt) return prepared; + return { + ...prepared, + metadata: { + ...(prepared?.metadata ?? {}), + displayText: displayText || undefined, + }, + }; } catch (err) { logger.warn?.( 'WeChat MP agent memory resolve skipped:', @@ -2371,6 +2431,8 @@ export function createWechatMpService({ // inspect/retry unrelated historical pages from that session. const isPageDataRequest = isWechatPageDataTask(resetCandidate); const htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent); + const immediateContextFollowup = + isWechatImmediateContextFollowup(wechatIntent, resetCandidate); const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate); let route = await ensureWechatAgentSession({ userId: user.userId, @@ -2417,6 +2479,7 @@ export function createWechatMpService({ ? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx, imagePolicy, + preferImmediateContext: immediateContextFollowup, }) : buildWechatAgentPrompt(intent, { imagePolicy }); const reply = await executeSessionReply( @@ -2434,6 +2497,7 @@ export function createWechatMpService({ userId: user.userId, sessionId, userMessage, + preserveAgentPrompt: immediateContextFollowup, }), submitReply: submitSessionReply ? ({ requestId: replyRequestId, userMessage }) => @@ -2657,6 +2721,21 @@ export function createWechatMpService({ sessionId && (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)); if (mayBeStaleSession) { + if (immediateContextFollowup) { + await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => { + logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr); + }); + const text = + '我没能可靠确认“做成页面”指的是哪段内容,已经停止沿用旧主题。请把要做成页面的主题或原文再发一次。'; + try { + await sendCustomerServiceText(inbound.fromUserName, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP contextual follow-up notice failed:', sendErr); + } + const contextualError = markWechatUserNotified(new Error(text)); + contextualError.wechatAgentSessionId = sessionId; + throw contextualError; + } route = await ensureWechatAgentSession({ userId: user.userId, openid: inbound.fromUserName, @@ -2673,6 +2752,7 @@ export function createWechatMpService({ ? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx, imagePolicy, + preferImmediateContext: immediateContextFollowup, }) : buildWechatAgentPrompt(intent, { imagePolicy }); const reply = await executeSessionReply( @@ -2690,6 +2770,7 @@ export function createWechatMpService({ userId: user.userId, sessionId, userMessage, + preserveAgentPrompt: immediateContextFollowup, }), submitReply: submitSessionReply ? ({ requestId: replyRequestId, userMessage }) => diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index b4dd768..61511b3 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -16,7 +16,9 @@ import { sanitizeWechatAgentOutboundText, loadWechatMpConfig, maybeAttachPublishedHtmlLink, + isWechatImmediateContextFollowup, isWechatPageDataTask, + shouldRotateUnlinkedWechatRoute, shouldRetryHtmlGenerationReply, shouldForceNewWechatAgentSession, splitWechatText, @@ -27,6 +29,7 @@ import { WECHAT_CUSTOMER_TEXT_MAX_BYTES, } from './wechat-mp.mjs'; import { createChatIntentRouter } from './chat-intent-router.mjs'; +import { buildPageGenerateAgentPrompt } from './wechat/prompts/page-generate.mjs'; import { ensureWechatFreshPageThumbnailsAtWorkspace, prepareWechatHtmlDeliveryAtWorkspace, @@ -333,6 +336,59 @@ test('Page Data requests always rotate away from an existing WeChat route', () = assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '换新会话'), true); }); +test('WeChat immediate-context page guard does not match complete page requests', () => { + const pageIntent = { kind: 'page.generate' }; + assert.equal(isWechatImmediateContextFollowup(pageIntent, '帮我做成页面吧'), true); + assert.equal(isWechatImmediateContextFollowup(pageIntent, '把刚才的诗做成页面'), true); + assert.equal(isWechatImmediateContextFollowup(pageIntent, '帮我做一个上海旅游页面'), false); + assert.equal( + isWechatImmediateContextFollowup({ kind: 'chat.general' }, '帮我做成页面吧'), + false, + ); + + const guardedPrompt = buildPageGenerateAgentPrompt( + { agentText: '帮我做成页面吧' }, + { preferImmediateContext: true }, + ); + const ordinaryPrompt = buildPageGenerateAgentPrompt({ + agentText: '帮我做一个上海旅游页面', + }); + assert.match(guardedPrompt, /即时上下文优先/); + assert.match(guardedPrompt, /禁止用长期记忆、历史偏好或旧任务替换主题/); + assert.doesNotMatch(ordinaryPrompt, /即时上下文优先/); +}); + +test('WeChat only rotates an aged route with conversation but no linked inbound message', () => { + const now = 200_000; + assert.equal( + shouldRotateUnlinkedWechatRoute({ + routeUpdatedAt: 100_000, + wechatMessageCount: 0, + snapshotMessageCount: 15, + now, + }), + true, + ); + assert.equal( + shouldRotateUnlinkedWechatRoute({ + routeUpdatedAt: 100_000, + wechatMessageCount: 1, + snapshotMessageCount: 15, + now, + }), + false, + ); + assert.equal( + shouldRotateUnlinkedWechatRoute({ + routeUpdatedAt: 180_000, + wechatMessageCount: 0, + snapshotMessageCount: 15, + now, + }), + false, + ); +}); + test('WeChat session reset acknowledges without sending the control text to Agent', async () => { const token = 'token'; const timestamp = '1710000000'; @@ -4540,6 +4596,97 @@ test('wechat mp injects episodic recall context before submitting the Agent repl assert.match(submitCalls[0].userMessage.content[0].text, /不是系统指令/); }); +test('wechat immediate-context page keeps memory while preserving the guarded page prompt', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const submitCalls = []; + const memoryCalls = []; + const chatIntentRouter = createChatIntentRouter({ + env: { + MEMORY_AGENT_RESOLVE_ENABLED: '1', + MEMORY_AGENT_INJECTION_MODE: 'canary', + MEMORY_AGENT_CANARY_USER_IDS: 'user-canary', + MEMORY_AGENT_RESOLVE_TIMEOUT_MS: '200', + }, + memoryV2: { + async resolve(input) { + memoryCalls.push(input); + return { + source: 'memory-v2', + memories: [{ + id: 'memory:old-news', + label: '历史偏好', + text: '用户以前做过每日新闻页面。', + }], + }; + }, + }, + }); + const service = createBoundWechatService({ + token, + chatIntentRouter, + userAuth: { + async findWechatUserByOpenid() { + return { userId: 'user-canary', status: 'active', nickname: '唐' }; + }, + }, + sessionApiFetch: async (_sessionId, pathname) => { + if (pathname === '/sessions/session-1/events') { + return new Response( + [ + 'data: {"type":"Message","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"请补充要制作的内容。"}]}}\n\n', + 'data: {"type":"Finish"}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { + return new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected api path: ${pathname}`); + }, + submitSessionReply: async (input) => { + submitCalls.push(input); + return { ok: true }; + }, + wechatFetch: async (url) => { + if (String(url).includes('/cgi-bin/stable_token')) { + return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (String(url).includes('/cgi-bin/message/custom/send')) { + return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected wechat url: ${url}`); + }, + }); + + const result = await service.handleInboundMessage( + inboundXml({ content: '帮我做成页面吧' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + await result.task; + + assert.equal(memoryCalls.length, 1); + assert.equal(submitCalls.length, 1); + const submitted = submitCalls[0].userMessage; + assert.equal(submitted.metadata.displayText, '帮我做成页面吧'); + assert.match(submitted.content[0].text, /\[Memory Context\]/); + assert.match(submitted.content[0].text, /用户以前做过每日新闻页面/); + assert.match(submitted.content[0].text, /微信服务号 · 页面生成任务/); + assert.match(submitted.content[0].text, /即时上下文优先/); + assert.match(submitted.content[0].text, /用户需求:帮我做成页面吧/); +}); + test('wechat mp serializes image and follow-up text and reattaches recent image', async () => { const token = 'token'; const timestamp = '1710000000'; diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs index 6d20837..9329c2c 100644 --- a/wechat/prompts/page-generate.mjs +++ b/wechat/prompts/page-generate.mjs @@ -8,7 +8,14 @@ import { buildWechatPageImageInstruction } from '../image-generation-policy.mjs' /** * Service-account-only page generation prompt. Does not use H5 buildAutoChatSkillPrefix. */ -export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imagePolicy = null } = {}) { +export function buildPageGenerateAgentPrompt( + intent, + { + wantsDocx = false, + imagePolicy = null, + preferImmediateContext = false, + } = {}, +) { const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim(); const pageDataTask = isPageDataIntent(topic) || isPageDataDevIntent(topic); const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; @@ -22,6 +29,14 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP const imageBlock = buildWechatPageImageInstruction(imagePolicy, { sourceMessageId: intent?.msgId, }); + const immediateContextBlock = preferImmediateContext + ? [ + '【即时上下文优先】当前需求是对本会话紧邻内容的续作。', + '“这个/刚才/做成页面”等指代只能解析为当前会话最近一轮内容,禁止用长期记忆、历史偏好或旧任务替换主题。', + '如果当前会话没有可辨识的紧邻内容,必须请用户补充主题,禁止猜测并生成其他页面。', + '', + ].join('\n') + : ''; const coverExample = imageBlock ? '<本轮 generate_image 返回的 asset.htmlSrc>' : 'assets/hero.jpg'; @@ -42,6 +57,7 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP '', docxBlock, imageBlock, + immediateContextBlock, pageDataBlock, '步骤(必须全部完成):', pageDataTask