diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 6113fd6b..9852303e 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -156,6 +156,7 @@ export function createCliRunAgentStream( analysisService: options.crossChatAnalysisService, sessionAdapter: options.sessionAdapter, locale, + entityRefs, preprocessConfig: params.preprocessConfig, maxToolResultTokens: resolveCrossChatToolResultTokenBudget(contextWindow), memoryService: options.memoryService, diff --git a/apps/cli/src/ai/cross-chat-tool-adapter.ts b/apps/cli/src/ai/cross-chat-tool-adapter.ts index d02ebc71..37a3e591 100644 --- a/apps/cli/src/ai/cross-chat-tool-adapter.ts +++ b/apps/cli/src/ai/cross-chat-tool-adapter.ts @@ -5,6 +5,7 @@ import type { PreprocessConfig, SessionRuntimeAdapter, } from '@openchatlab/node-runtime' +import type { AIEntityRef } from '@openchatlab/shared-types' import { countTokens, preprocessCrossChatLabel, @@ -17,6 +18,7 @@ export function createCliCrossChatTools(options: { analysisService: CrossChatAnalysisService sessionAdapter: SessionRuntimeAdapter locale?: string + entityRefs?: AIEntityRef[] preprocessConfig?: Record maxToolResultTokens: number memoryService: AIMemoryService @@ -25,6 +27,7 @@ export function createCliCrossChatTools(options: { }): AgentTool[] { const context: Omit = { locale: options.locale, + entityRefs: options.entityRefs, analysisService: options.analysisService, memoryService: options.memoryService, aiChatId: options.aiChatId, diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index f6f5b56a..4e48a481 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -151,6 +151,7 @@ export function createElectronRunAgentStream( analysisService: crossChatOptions.analysisService, sessionAdapter: crossChatOptions.sessionAdapter, locale, + entityRefs, preprocessConfig: params.preprocessConfig, maxToolResultTokens: resolveCrossChatToolResultTokenBudget(resolvedContextWindow), memoryService: crossChatOptions.memoryService, diff --git a/apps/desktop/main/ai/cross-chat-tool-adapter.ts b/apps/desktop/main/ai/cross-chat-tool-adapter.ts index 4b4a79b5..3bc7a82e 100644 --- a/apps/desktop/main/ai/cross-chat-tool-adapter.ts +++ b/apps/desktop/main/ai/cross-chat-tool-adapter.ts @@ -5,6 +5,7 @@ import type { PreprocessConfig, SessionRuntimeAdapter, } from '@openchatlab/node-runtime' +import type { AIEntityRef } from '@openchatlab/shared-types' import { countTokens, preprocessCrossChatLabel, @@ -17,6 +18,7 @@ export function createElectronCrossChatTools(options: { analysisService: CrossChatAnalysisService sessionAdapter: SessionRuntimeAdapter locale?: string + entityRefs?: AIEntityRef[] preprocessConfig?: Record maxToolResultTokens: number memoryService: AIMemoryService @@ -25,6 +27,7 @@ export function createElectronCrossChatTools(options: { }): AgentTool[] { const context: Omit = { locale: options.locale, + entityRefs: options.entityRefs, analysisService: options.analysisService, memoryService: options.memoryService, aiChatId: options.aiChatId, diff --git a/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts b/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts index a18dce16..943b176a 100644 --- a/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts +++ b/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts @@ -39,6 +39,9 @@ describe('cross-chat agent prompt', () => { assert.match(prompt, new RegExp(tool)) } assert.match(prompt, /不构成永久锁定范围/) + assert.match(prompt, /本轮最新选择/) + assert.match(prompt, /不得复用历史消息中的旧实体/) + assert.match(prompt, /都必须按本轮稳定 ID 重新调用 memory_read/) assert.match(prompt, /交集、并集/) assert.match(prompt, /不要仅因为消息中出现了 @联系人就机械调用/) assert.match(prompt, /roster_only/) @@ -99,6 +102,9 @@ describe('cross-chat agent prompt', () => { assert.match(prompt, new RegExp(tool)) } assert.match(prompt, /strict intersection containing every participant/) + assert.match(prompt, /latest selection for this turn/) + assert.match(prompt, /never stale entities from earlier messages/) + assert.match(prompt, /must call memory_read again with the current stable IDs/) assert.match(prompt, /private-chat frequency ranking/) assert.match(prompt, /never substitute keyword hits/i) assert.match(prompt, /coverage\.complete=true/) diff --git a/packages/node-runtime/src/ai/cross-chat-agent.ts b/packages/node-runtime/src/ai/cross-chat-agent.ts index dee81928..cc961778 100644 --- a/packages/node-runtime/src/ai/cross-chat-agent.ts +++ b/packages/node-runtime/src/ai/cross-chat-agent.ts @@ -221,6 +221,7 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date(), g - 当前日期是 ${currentDate}(时区:${timeZone})。用户的相对时间表达以真实当前时间为基准;数据库截止时间只说明已导入数据的覆盖范围。 - 用户已授权你查询全部本地聊天数据,但只能为回答当前问题按需调用工具,禁止无目的遍历。 - 是界面选择器写入的稳定实体引用,涉及这些实体时直接调用 resolve_chat_entities。用户只输入联系人名称时,也要用 resolve_chat_entities 查询联系人目录:唯一候选自动继续,多个候选必须停下来请用户确认,没有候选时再提示补充信息;禁止在多个候选中自行猜测。 +- 当前用户消息中的 是本轮最新选择。用户说“当前对象”“所选实体”等指代时,只使用本轮引用,不得复用历史消息中的旧实体;本轮同时选择多个实体时可以按问题使用其中任意一个。 - 对话历史中的实体引用只帮助理解上下文,不构成永久锁定范围。每一轮根据用户语义决定继续原对象、切换对象或执行全局发现。 - 联系人默认覆盖其实际参与的私聊和群聊。多个对象既可能是交集、并集,也可能需要分别检索比较;以用户语义为准,不要机械套用一种集合规则。 - 当用户问“我和某人最近聊了什么”或明确要求回顾两人的私聊内容时,只使用 resolve_chat_entities 返回的对应私聊 session,不要混入该联系人所在的群聊。用户没有给出明确日期或天数时,这里的“最近”指已导入私聊中最新一段可用内容:解析到唯一私聊后直接调用 read_recent_session,不要先用 search_messages_globally 填满跨对话证据预算。只有明确说“最近 N 天”、给出日期范围、要求更早内容或深度调查时才改用 search_messages_globally。没有对应私聊时先明确说明,再询问是否改查共同群。 @@ -253,6 +254,7 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date(), g 长期记忆规则: - 只保存跨 AI 对话仍有长期价值的用户偏好、身份背景、明确纠正,或已有聊天证据支持且会影响后续调查方向的稳定结论。不要保存本轮临时要求、普通统计、聊天原文、工具过程、普通回答或未经证据支持的猜测。 +- 本轮存在 时,任何针对“当前对象/所选实体”的记忆读取都必须按本轮稳定 ID 重新调用 memory_read;即使历史中回答过相同问题,也不得直接复用旧实体的记忆或旧答案。 - 联系人和群聊记忆必须先取得稳定 contactKey 或 group sessionId。对具体实体展开实质分析前先调用 memory_read;写入前也先读取同一作用域,已有相同含义时不要重复创建。 - 用户明确提供、要求记住或纠正的内容使用 source_type=user;你根据聊天数据形成的结论使用 source_type=ai,并写清必要的观察时间和不确定性。 - source_type=ai 的记忆只是调查线索,后续使用时必须重新查询当前聊天数据和原始聊天证据,不能把旧结论当作当前事实。 @@ -268,6 +270,7 @@ Data and scope rules: - The current date is ${currentDate} (time zone: ${timeZone}). Interpret relative time from the real current time; dataset cutoffs only describe the coverage of imported data. - The user authorizes access to all local chat data, but you must query only what is needed for the current question and never crawl without purpose. - contains stable references selected in the UI and should be passed directly to resolve_chat_entities. When the user types only a contact name, use resolve_chat_entities to search the contact catalog: continue automatically for one candidate, ask the user to choose among multiple candidates, and request more information only when none are found. Never guess among ambiguous candidates. +- in the current user message is the latest selection for this turn. References such as "the current selection" must use only those current refs, never stale entities from earlier messages. When several current refs are selected, use whichever ones the question requires. - Entity references in history provide conversational context, not a permanently locked scope. Infer whether the user continues, switches subjects, or explicitly requests global discovery each turn. - A contact normally covers the private and group sessions they actually participate in. Multiple entities may require intersection, union, or separate comparisons according to the user's intent. - When the user asks “what have this person and I been talking about recently” or clearly requests a dyadic private-chat recap, use only the resolved direct private session and do not mix in groups containing that contact. Without an explicit calendar range or duration, “recent” means the latest available imported private conversation: after resolving one direct private session, call read_recent_session instead of filling the cross-chat search evidence budget. Use search_messages_globally only when the user gives dates or a duration, asks for earlier content, or requests deeper investigation. If no direct private session exists, say so before asking whether to search shared groups. @@ -300,6 +303,7 @@ Tool and conclusion rules: Long-term memory rules: - Save only preferences, identity/background facts, explicit corrections, or evidence-backed stable conclusions that remain useful across AI conversations. Never save temporary instructions, ordinary statistics, transcripts, tool traces, ordinary answers, or unsupported guesses. +- When the current turn includes , every memory question about "the current selection" must call memory_read again with the current stable IDs. Never reuse an earlier entity's memory or answer merely because the same question appeared in history. - Obtain a stable contactKey or group sessionId before reading or writing entity memory. Call memory_read before substantive entity analysis and before writing that scope; do not create a duplicate with the same meaning. - Use source_type=user only for facts the user explicitly provides, asks to remember, or corrects. Use source_type=ai for conclusions derived from chat data, including the observation period and uncertainty. - Treat source_type=ai memory only as an investigation lead. Re-query current chat data and original chat evidence before using it as a fact. diff --git a/packages/tools/src/definitions/memory-tools.test.ts b/packages/tools/src/definitions/memory-tools.test.ts index e7699064..b30bde3e 100644 --- a/packages/tools/src/definitions/memory-tools.test.ts +++ b/packages/tools/src/definitions/memory-tools.test.ts @@ -96,12 +96,15 @@ function entityResolution(refs: AIEntityRef[]): CrossChatEntityResolution { } } -function createContext(options: { allowProactiveMemory?: boolean } = {}): CrossChatToolExecutionContext { +function createContext( + options: { allowProactiveMemory?: boolean; entityRefs?: AIEntityRef[] } = {} +): CrossChatToolExecutionContext { const analysisService = { resolveEntities: async (refs: AIEntityRef[]) => entityResolution(refs), } as unknown as CrossChatAnalysisToolService return { locale: 'zh-CN', + entityRefs: options.entityRefs, analysisService, memoryService: new FakeMemoryService(), aiChatId: 'global-chat-1', @@ -208,6 +211,28 @@ describe('global memory tools', () => { ) }) + it('does not reuse a stale entity scope when the current turn selects another entity', async () => { + const context = createContext({ + entityRefs: [{ type: 'contact', contactKey: 'contact-1', displayName: 'Current contact' }], + }) + + await assert.rejects( + async () => memoryReadTool.handler({ scope_type: 'contact', scope_id: 'contact-2' }, context), + /current turn/i + ) + await assert.rejects( + async () => + memoryWriteTool.handler( + { scope_type: 'group', scope_id: 'group-1', content: 'stale group', source_type: 'user' }, + context + ), + /current turn/i + ) + + const current = await memoryReadTool.handler({ scope_type: 'contact', scope_id: 'contact-1' }, context) + assert.deepEqual((current.data as { entries: AIMemoryEntry[] }).entries, []) + }) + it('returns bounded reads with AI verification guidance and forgets by stable id', async () => { const context = createContext() const memoryService = context.memoryService as FakeMemoryService diff --git a/packages/tools/src/definitions/memory-tools.ts b/packages/tools/src/definitions/memory-tools.ts index 3e1185c4..5416af63 100644 --- a/packages/tools/src/definitions/memory-tools.ts +++ b/packages/tools/src/definitions/memory-tools.ts @@ -179,6 +179,17 @@ async function assertResolvableEntityScope( ): Promise { if (scope.scopeType === 'global') return + if (context.entityRefs?.length) { + const matchesCurrentSelection = context.entityRefs.some((ref) => + scope.scopeType === 'contact' + ? ref.type === 'contact' && ref.contactKey === scope.scopeId + : ref.type === 'session' && ref.sessionType === ChatType.GROUP && ref.sessionId === scope.scopeId + ) + if (!matchesCurrentSelection) { + throw new Error('scope_id must match an entity explicitly selected for the current turn') + } + } + const ref: AIEntityRef = scope.scopeType === 'contact' ? { type: 'contact', contactKey: scope.scopeId!, displayName: scope.scopeId! } diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts index 271d863e..7a259a6f 100644 --- a/packages/tools/src/types.ts +++ b/packages/tools/src/types.ts @@ -421,6 +421,8 @@ export interface AIMemoryToolService { export interface CrossChatToolExecutionContext { locale?: string + /** Stable entities explicitly selected for the current user turn. */ + entityRefs?: AIEntityRef[] abortSignal?: AbortSignal reportProgress?: (progress: ToolProgress) => void maxToolResultTokens?: number