From 75eff70e23025883fb6ed02520610d7f5e880605 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 23 Aug 2026 20:58:05 +0800 Subject: [PATCH] feat(ai): improve cross-chat evidence retrieval --- apps/cli/src/ai/agent-stream-runner.ts | 3 +- apps/desktop/main/ai/agent-stream-runner.ts | 4 +- .../src/ai/__tests__/cross-chat-agent.test.ts | 35 +++- .../node-runtime/src/ai/cross-chat-agent.ts | 34 +++- packages/node-runtime/src/ai/index.ts | 7 +- packages/node-runtime/src/index.ts | 9 +- .../src/services/cross-chat-analysis/index.ts | 2 + .../cross-chat-analysis/service.test.ts | 139 ++++++++++++- .../services/cross-chat-analysis/service.ts | 186 +++++++++++++++++- .../src/services/cross-chat-analysis/types.ts | 2 + packages/node-runtime/src/services/index.ts | 2 + packages/shared-types/index.ts | 23 +++ .../src/definitions/cross-chat-tools.test.ts | 171 +++++++++++++++- .../tools/src/definitions/cross-chat-tools.ts | 120 +++++++++-- packages/tools/src/index.ts | 1 + packages/tools/src/registry.ts | 2 + packages/tools/src/types.ts | 2 + 17 files changed, 689 insertions(+), 53 deletions(-) diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 7204a34a..122cbdb5 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -25,6 +25,7 @@ import { getChartCapabilitySkill, getSkillConfigWithBuiltinChart, resolveChartRuntimeForRequest, + resolveCrossChatToolResultTokenBudget, runCrossChatAgent, } from '@openchatlab/node-runtime' import { getChatOverview, normalizeBuiltinToolNames } from '@openchatlab/core' @@ -153,7 +154,7 @@ export function createCliRunAgentStream( sessionAdapter: options.sessionAdapter, locale, preprocessConfig: params.preprocessConfig, - maxToolResultTokens, + maxToolResultTokens: resolveCrossChatToolResultTokenBudget(contextWindow), }) await runCrossChatAgent({ userMessage, diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index 92ceb88b..572cfbce 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -28,6 +28,7 @@ import { getChartCapabilitySkill, initTokenizer, resolveChartRuntimeForRequest, + resolveCrossChatToolResultTokenBudget, buildSemanticSearchGuidance, runCrossChatAgent, } from '@openchatlab/node-runtime' @@ -143,13 +144,12 @@ export function createElectronRunAgentStream( } const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '') const resolvedContextWindow = modelDef?.contextWindow || DEFAULT_CONTEXT_WINDOW - const maxToolResultPercent = DEFAULT_CONTEXT_COMPRESSION_CONFIG.maxToolResultPercent ?? 50 const tools = createElectronCrossChatTools({ analysisService: crossChatOptions.analysisService, sessionAdapter: crossChatOptions.sessionAdapter, locale, preprocessConfig: params.preprocessConfig, - maxToolResultTokens: Math.floor(resolvedContextWindow * (maxToolResultPercent / 100)), + maxToolResultTokens: resolveCrossChatToolResultTokenBudget(resolvedContextWindow), }) await runCrossChatAgent({ userMessage, 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 a910b834..d768e6c1 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 @@ -3,13 +3,25 @@ import { describe, it } from 'node:test' import type { Api as PiApi, Model as PiModel } from '@earendil-works/pi-ai' import type { AIChatManager } from '../chats' import type { AgentStreamChunk } from '../agent/event-handler' -import { buildCrossChatSystemPrompt, runCrossChatAgent } from '../cross-chat-agent' +import { + buildCrossChatSystemPrompt, + resolveCrossChatToolResultTokenBudget, + runCrossChatAgent, +} from '../cross-chat-agent' + +describe('cross-chat tool result budget', () => { + it('keeps the proportional budget for ordinary models and caps very large contexts', () => { + assert.equal(resolveCrossChatToolResultTokenBudget(128_000), 64_000) + assert.equal(resolveCrossChatToolResultTokenBudget(1_000_000), 128_000) + }) +}) describe('cross-chat agent prompt', () => { it('locks the agent to dedicated tools and makes scope semantic rather than persistent', () => { const prompt = buildCrossChatSystemPrompt('zh-CN', new Date('2026-08-23T12:00:00Z')) for (const tool of [ 'resolve_chat_entities', + 'read_recent_session', 'inspect_contact_sessions', 'inspect_shared_interactions', 'search_messages_globally', @@ -29,22 +41,31 @@ describe('cross-chat agent prompt', () => { assert.match(prompt, /唯一候选自动继续/) assert.match(prompt, /多个候选必须停下来请用户确认/) assert.match(prompt, /限定 scopes 时,可以不提供关键词/) - assert.match(prompt, /最近.*90 天/) + assert.match(prompt, /最近.*30 天/) assert.match(prompt, /recent_days/) assert.match(prompt, /当前日期是.*2026.*8.*23/) assert.match(prompt, /真实当前时间为基准/) assert.match(prompt, /禁止根据数据库截止时间.*手工计算/) assert.match(prompt, /sender.*owner/) assert.match(prompt, /本人发言.*检索种子/) + assert.match(prompt, /“我和某人最近聊了什么”/) + assert.match(prompt, /只使用.*私聊/) + assert.match(prompt, /直接调用 read_recent_session/) + assert.match(prompt, /不要先用 search_messages_globally.*填满.*证据预算/) + assert.match(prompt, /不要仅因为.*hasEarlierMessages=true.*自动扩大搜索/) + assert.match(prompt, /最近一条已导入私聊.*具体时间/) + assert.match(prompt, /工具自行控制证据量/) + assert.match(prompt, /不要传入消息数量、会话数量或执行时长预算/) assert.match(prompt, /coverage/) assert.doesNotMatch(prompt, /四个工具/) assert.doesNotMatch(prompt, /可以使用.*execute_sql/) }) - it('keeps the English prompt on the same six-tool investigation contract', () => { + it('keeps the English prompt on the same seven-tool investigation contract', () => { const prompt = buildCrossChatSystemPrompt('en-US', new Date('2026-08-23T12:00:00Z')) for (const tool of [ 'resolve_chat_entities', + 'read_recent_session', 'inspect_contact_sessions', 'inspect_shared_interactions', 'search_messages_globally', @@ -59,6 +80,14 @@ describe('cross-chat agent prompt', () => { assert.match(prompt, /current date is.*August 23, 2026/i) assert.match(prompt, /real current time/) assert.match(prompt, /Never calculate start_time from a dataset cutoff/) + assert.match(prompt, /last 30 days/) + assert.match(prompt, /what have this person and I been talking about recently/i) + assert.match(prompt, /use only the resolved direct private session/i) + assert.match(prompt, /call read_recent_session instead of filling the cross-chat search evidence budget/i) + assert.match(prompt, /Do not expand merely because selection.hasEarlierMessages is true/i) + assert.match(prompt, /latest imported private-chat timestamp/i) + assert.match(prompt, /tool controls evidence volume/i) + assert.match(prompt, /Do not pass message-count, session-count, or execution-time budgets/) assert.doesNotMatch(prompt, /four tools/) }) }) diff --git a/packages/node-runtime/src/ai/cross-chat-agent.ts b/packages/node-runtime/src/ai/cross-chat-agent.ts index 23ac667f..faa412d8 100644 --- a/packages/node-runtime/src/ai/cross-chat-agent.ts +++ b/packages/node-runtime/src/ai/cross-chat-agent.ts @@ -36,6 +36,14 @@ export interface RunCrossChatAgentOptions { logger?: CrossChatAgentLogger } +export const CROSS_CHAT_MAX_TOOL_RESULT_TOKENS = 128_000 + +export function resolveCrossChatToolResultTokenBudget(contextWindow: number): number { + const maxToolResultPercent = DEFAULT_CONTEXT_COMPRESSION_CONFIG.maxToolResultPercent ?? 50 + const proportionalBudget = Math.floor(contextWindow * (maxToolResultPercent / 100)) + return Math.min(proportionalBudget, CROSS_CHAT_MAX_TOOL_RESULT_TOKENS) +} + export async function runCrossChatAgent(options: RunCrossChatAgentOptions): Promise { const { userMessage, @@ -211,24 +219,27 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date()): - 是界面选择器写入的稳定实体引用,涉及这些实体时直接调用 resolve_chat_entities。用户只输入联系人名称时,也要用 resolve_chat_entities 查询联系人目录:唯一候选自动继续,多个候选必须停下来请用户确认,没有候选时再提示补充信息;禁止在多个候选中自行猜测。 - 对话历史中的实体引用只帮助理解上下文,不构成永久锁定范围。每一轮根据用户语义决定继续原对象、切换对象或执行全局发现。 - 联系人默认覆盖其实际参与的私聊和群聊。多个对象既可能是交集、并集,也可能需要分别检索比较;以用户语义为准,不要机械套用一种集合规则。 +- 当用户问“我和某人最近聊了什么”或明确要求回顾两人的私聊内容时,只使用 resolve_chat_entities 返回的对应私聊 session,不要混入该联系人所在的群聊。用户没有给出明确日期或天数时,这里的“最近”指已导入私聊中最新一段可用内容:解析到唯一私聊后直接调用 read_recent_session,不要先用 search_messages_globally 填满跨对话证据预算。只有明确说“最近 N 天”、给出日期范围、要求更早内容或深度调查时才改用 search_messages_globally。没有对应私聊时先明确说明,再询问是否改查共同群。 - 用户询问某个联系人出现在哪些已导入会话、分别说了多少或最近在哪里活跃时,先用 inspect_contact_sessions 盘点来源。不要仅因为消息中出现了 @联系人就机械调用;普通事实题仍按问题使用搜索与上下文工具。 - 用户询问 2—5 人的关系、相识背景、共同圈子或互动变化时,先用 inspect_shared_interactions 找全员共同会话、逐人活跃、直接回复、相邻发言和消息锚点;不要为了多人问题机械地先对每个人调用 inspect_contact_sessions。 - 三人及以上的 inspect_shared_interactions 结果是“所有参与者都出现”的严格交集;如果用户要比较多组两两关系,应按成员对分别调查,不要把只含部分人的会话混进全员共同来源。 - 只有用户明确表达“忘了和谁聊过”“在所有聊天里找”等全局发现意图时,才允许不带 scopes 调用 search_messages_globally。全局发现必须提供至少一个关键词;已经限定 scopes 时,可以不提供关键词来抽取近期消息样本。 -- 用户使用“最近”“近期”等相对时间但没有给出具体范围时,第一次支持时间范围的搜索或结构调查工具调用必须传 recent_days=90,并在回答中说明按最近 90 天统计;禁止根据数据库截止时间或 lastMessageTs 手工计算 start_time,也禁止先扫描全部历史再事后主观截取。只有 90 天内没有结果时,才能询问用户是否扩大范围。 -- 用户把自己作为事件主体(例如“我最近跟多少人聊过我买房”)时,第一次搜索必须传 sender=owner,把本人发言作为检索种子;不要先搜索所有人的同类话题。命中后再用 get_cross_chat_message_context 展开周边消息,识别真正参与对话的人;上下文不限制为本人发言。 +- 除上述双人私聊内容回顾外,用户使用“最近”“近期”等相对时间但没有给出具体范围时,第一次支持时间范围的搜索或结构调查工具调用必须传 recent_days=30,并在回答中说明按最近 30 天统计;禁止根据数据库截止时间或 lastMessageTs 手工计算 start_time,也禁止先扫描全部历史再事后主观截取。只有 30 天内没有结果时,才能询问用户是否扩大范围。 +- 用户把自己作为事件主体(例如“我最近跟多少人聊过我买房”)时,第一次搜索必须传 sender=owner,把本人发言作为检索种子;不要先搜索所有人的同类话题。搜索工具会自动附带周边消息以识别真正参与对话的人;只有需要更深原文时才继续使用 get_cross_chat_message_context,上下文不限制为本人发言。 工具与结论规则: -- 你只有 resolve_chat_entities、inspect_contact_sessions、inspect_shared_interactions、search_messages_globally、get_cross_chat_message_context、get_cross_chat_overview 六个工具。不要声称可以使用单会话 SQL、语义索引、技能、图表或热力图。 +- 你只有 resolve_chat_entities、read_recent_session、inspect_contact_sessions、inspect_shared_interactions、search_messages_globally、get_cross_chat_message_context、get_cross_chat_overview 七个工具。不要声称可以使用单会话 SQL、语义索引、技能、图表或热力图。 +- read_recent_session 是单个已解析会话的轻量近期回顾:它内部读取最新一小段原文和最多 5 个已有段落摘要,模型不能指定消息预算。不要仅因为 selection.hasEarlierMessages=true 就自动扩大搜索;先回答用户当前的普通近期回顾,只有用户要求具体时间、关键词、更早内容或深挖时再使用 search_messages_globally。 - inspect_contact_sessions 返回的是已导入会话中的本人发言和成员表结构事实,不返回聊天正文,也不能证明现实世界中的全部群聊。roster_only 只说明导入成员表记录过此人;存在 nextCursor 或 coverage 不完整时不能表述成全量结果。 - inspect_shared_interactions 的群名称、成员结构、共同活跃和相邻发言只是调查导航信号,不能直接证明同事、同学、亲属、朋友或亲密关系。优先对 direct_reply 和 proximity 锚点调用 get_cross_chat_message_context 读取原文;回复目标可能由 relatedMessageId 指向较远消息,必要时分别展开两个消息 ID。 - 没有直接回复或原文证据时,只能确认共同来源或同场活跃,并明确具体关系仍不确定。只有 coverage 完整且时间范围明确,才允许给出“没有发生过”或“没有互动”的强负面判断;partial/skipped_budget 不是零。 -- search_messages_globally 提供关键词时是字面 LIKE 检索,不是语义搜索。用户问“聊过什么”等开放问题时,先限定联系人或群聊 scopes,再无关键词抽取近期消息并按需展开上下文;不要对全部会话做无关键词扫描。 +- search_messages_globally 提供关键词时是字面 LIKE 检索,不是语义搜索。用户问“聊过什么”等开放问题时,先限定联系人或群聊 scopes,再无关键词抽取指定时间范围内的消息;工具自行控制证据量,优先返回私聊证据,剩余容量按目标人物在群聊中的匹配活跃度分配,并自动附带命中消息周围的上下文。不要传入消息数量、会话数量或执行时长预算,也不要对全部会话做无关键词扫描。 - 比较联系人或群聊时,优先分别给出概览并检索有来源的证据。需要理解命中消息时,用 session_id + message_id 获取上下文。 - 工具返回 coverage 和 truncated。覆盖不完整或被截断时必须明确说明抽样范围,禁止把样本表述成全量结论。 +- 涉及单个联系人的限时搜索返回 0 条时,不要只说“什么都没有”。必须从 resolve_chat_entities 返回的私聊 session 元数据中说明最近一条已导入私聊的具体时间,以及它是否落在查询窗口之外;没有私聊 session 或没有可用时间时也要明确说明。之后再询问用户是否扩大范围。 - sender=owner 时还要检查 coverage.ownerResolution;存在未设置或无法解析“我”的会话时,必须说明对应覆盖缺口,禁止通过昵称猜测本人。 - 引用证据时说明来源会话;不要泄露工具未返回的信息,也不要编造联系人、群聊或消息。 -- 如果问题不需要聊天数据,直接回答;如果现有六个工具不足,坦诚说明当前能力边界。 +- 如果问题不需要聊天数据,直接回答;如果现有七个工具不足,坦诚说明当前能力边界。 你可以使用工具。如果需要你没有的信息,请调用提供的函数。` } @@ -241,24 +252,27 @@ Data and scope rules: - 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. - 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. - When the user asks which imported sessions contain one contact, how much that person spoke in each session, or where they were active, use inspect_contact_sessions first. Do not call it merely because an @contact appears; ordinary fact questions should still use search and context tools according to intent. - For relationship, shared-background, shared-circle, or interaction-change questions about two to five people, call inspect_shared_interactions first to find sessions containing everyone, per-person activity, replies, proximity signals, and message anchors. Do not mechanically call inspect_contact_sessions once per person first. - For three or more people, inspect_shared_interactions returns the strict intersection containing every participant. If the user wants several pairwise comparisons, inspect each pair separately rather than mixing sessions that contain only part of the cohort into the all-participant result. - Call search_messages_globally without scopes only for explicit global discovery such as "I forgot who I discussed this with". Global discovery always requires at least one keyword; scoped searches may omit keywords to sample recent messages. -- When the user says "recent" or "recently" without a specific range, the first time-ranged search or structural-inspection call must pass recent_days=90 and the answer must state that it covers the last 90 days. Never calculate start_time from a dataset cutoff or lastMessageTs, and never scan all history first and apply a subjective cutoff afterward. Ask before widening only when the 90-day search finds nothing. -- When the user is the subject of the event, such as "who did I discuss my home purchase with", the first search must pass sender=owner and treat the owner's messages as discovery seeds. Then use get_cross_chat_message_context to identify actual participants; surrounding context is not owner-only. +- Except for the dyadic private-chat recap above, when the user says "recent" or "recently" without a specific range, the first time-ranged search or structural-inspection call must pass recent_days=30 and the answer must state that it covers the last 30 days. Never calculate start_time from a dataset cutoff or lastMessageTs, and never scan all history first and apply a subjective cutoff afterward. Ask before widening only when the 30-day search finds nothing. +- When the user is the subject of the event, such as "who did I discuss my home purchase with", the first search must pass sender=owner and treat the owner's messages as discovery seeds. Search automatically includes surrounding messages to identify actual participants; call get_cross_chat_message_context only when deeper source text is needed. Surrounding context is not owner-only. Tool and conclusion rules: -- You only have resolve_chat_entities, inspect_contact_sessions, inspect_shared_interactions, search_messages_globally, get_cross_chat_message_context, and get_cross_chat_overview. Do not claim access to session SQL, semantic search, skills, charts, or heatmaps. +- You only have resolve_chat_entities, read_recent_session, inspect_contact_sessions, inspect_shared_interactions, search_messages_globally, get_cross_chat_message_context, and get_cross_chat_overview. Do not claim access to session SQL, semantic search, skills, charts, or heatmaps. +- read_recent_session is the lightweight recent-recap path for one resolved session. It internally reads a small newest message slice and up to five existing segment summaries; the model cannot set message budgets. Do not expand merely because selection.hasEarlierMessages is true. Answer the ordinary recent recap first, then use search_messages_globally only for explicit dates, keywords, earlier content, or deeper investigation. - inspect_contact_sessions returns the contact's own activity and imported member-roster facts, not message text or every real-world chat. roster_only only means that the imported member table records the person. Never describe a paged or incomplete result as exhaustive. - Group names, member structure, co-active days, and proximity from inspect_shared_interactions are investigation signals, not proof of colleague, classmate, family, friend, or intimate relationships. Expand direct_reply and proximity anchors with get_cross_chat_message_context; a distant reply target may require separately opening relatedMessageId. - Without direct replies or source text, state only shared-source or co-activity facts and keep the relationship uncertain. Strong negative claims require complete coverage and an explicit time range. partial or skipped_budget never means zero. -- Search with keywords is literal LIKE matching, not semantic retrieval. For open-ended questions such as "what did we discuss", first resolve contact or session scopes, then sample recent messages without keywords and expand relevant context. Never scan every session without keywords. +- Search with keywords is literal LIKE matching, not semantic retrieval. For open-ended questions such as "what did we discuss", first resolve contact or session scopes, then sample messages inside the requested time range without keywords. The tool controls evidence volume, prioritizes private-chat evidence, allocates remaining capacity to groups by the target person's matching activity, and automatically includes surrounding context. Do not pass message-count, session-count, or execution-time budgets, and never scan every session without keywords. - For comparisons, prefer separate overviews and source-backed evidence. Expand a hit with session_id plus message_id when context is needed. - Respect coverage and truncated fields. State incomplete coverage or sampling explicitly and never present a sample as exhaustive. +- When a time-bounded search about one contact returns zero messages, do not merely say that nothing was found. From the direct private session metadata returned by resolve_chat_entities, state the latest imported private-chat timestamp as an exact date and whether it falls outside the requested window. Explicitly say when no private session or timestamp is available, then ask before widening the range. - For sender=owner, inspect coverage.ownerResolution and disclose sessions where the owner is missing or unresolved. Never guess the owner from display names. - Name the source session when citing evidence. Never invent people, sessions, or messages. -- Answer directly when no chat data is needed. If the six tools are insufficient, explain the current limitation honestly. +- Answer directly when no chat data is needed. If the seven tools are insufficient, explain the current limitation honestly. You have access to tools. If you need information you don't have, use the provided functions.` } diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index 89980cce..48475a54 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -139,7 +139,12 @@ export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, export { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './agent' export { createDataSnapshotFromOverview } from './agent' export { buildSemanticSearchGuidance } from './agent' -export { buildCrossChatSystemPrompt, runCrossChatAgent } from './cross-chat-agent' +export { + CROSS_CHAT_MAX_TOOL_RESULT_TOKENS, + buildCrossChatSystemPrompt, + resolveCrossChatToolResultTokenBudget, + runCrossChatAgent, +} from './cross-chat-agent' export type { CrossChatAgentLogger, RunCrossChatAgentOptions } from './cross-chat-agent' export type { AnalysisPlanIntent, diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 93aebc69..ba03c7aa 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -310,7 +310,12 @@ export { DEFAULT_MAX_TOOL_ROUNDS, createLlmRouteDecider, decideRequestRoute, run export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './ai' export { buildPlanGuidance, createAnalysisPlanner, createDataSnapshotFromOverview, createPlanContentBlock } from './ai' export { buildSemanticSearchGuidance } from './ai' -export { buildCrossChatSystemPrompt, runCrossChatAgent } from './ai' +export { + CROSS_CHAT_MAX_TOOL_RESULT_TOKENS, + buildCrossChatSystemPrompt, + resolveCrossChatToolResultTokenBudget, + runCrossChatAgent, +} from './ai' export type { CrossChatAgentLogger, RunCrossChatAgentOptions } from './ai' export type { AnalysisPlanIntent, @@ -541,6 +546,8 @@ export type { CrossChatOverviewItem, CrossChatOverviewRequest, CrossChatOverviewResult, + CrossChatRecentSessionResult, + CrossChatRecentSessionSummary, CrossChatResolvedContact, CrossChatResolvedContactSession, CrossChatResolvedSession, diff --git a/packages/node-runtime/src/services/cross-chat-analysis/index.ts b/packages/node-runtime/src/services/cross-chat-analysis/index.ts index 87db8cf2..fd600b67 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/index.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/index.ts @@ -10,6 +10,8 @@ export type { CrossChatOverviewItem, CrossChatOverviewRequest, CrossChatOverviewResult, + CrossChatRecentSessionResult, + CrossChatRecentSessionSummary, CrossChatResolvedContact, CrossChatResolvedContactSession, CrossChatResolvedSession, diff --git a/packages/node-runtime/src/services/cross-chat-analysis/service.test.ts b/packages/node-runtime/src/services/cross-chat-analysis/service.test.ts index 3d8761de..7b0ba990 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/service.test.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/service.test.ts @@ -1193,10 +1193,13 @@ test('scoped search filters by resolved member ids and keeps compound evidence i }) assert.deepEqual( - result.messages.map((message) => [message.sessionId, message.messageId, message.senderId]), + result.messages.map((message) => [message.sessionId, message.messageId, message.senderId, message.evidenceRole]), [ - ['group-work', 1, 20], - ['private-alice', 1, 10], + ['group-work', 3, 20, 'context'], + ['group-work', 2, 21, 'context'], + ['group-work', 1, 20, 'match'], + ['private-alice', 2, 1, 'context'], + ['private-alice', 1, 10, 'match'], ] ) assert.equal(result.coverage.truncated, false) @@ -1248,6 +1251,128 @@ test('message context stays inside the indexed segment around the matched messag } }) +test('recent session recap returns a bounded latest slice plus existing summaries', () => { + const { env, contactsService } = createFixture() + try { + env.seed({ + id: 'recent-recap', + name: 'Recent recap', + type: 'private', + members: [ + { id: 1, platformId: 'owner', name: 'Me' }, + { id: 2, platformId: 'friend', name: 'Friend' }, + ], + messages: Array.from({ length: 220 }, (_, index) => ({ + id: index + 1, + senderId: index % 2 === 0 ? 1 : 2, + ts: 1_000 + index * 100, + content: `message-${index + 1}`, + })), + }) + const db = env.adapter.ensureWritable('recent-recap') + generateSessionIndex(db, 10) + db.exec("UPDATE segment SET summary = 'summary-' || id, summary_message_count = message_count") + db.close() + const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService }) + + const result = service.readRecentSession('recent-recap') + + assert.deepEqual( + result.messages.map((message) => message.messageId), + Array.from({ length: 200 }, (_, index) => 220 - index) + ) + assert.deepEqual( + result.summaries.map((summary) => summary.summary), + ['summary-220', 'summary-219', 'summary-218', 'summary-217', 'summary-216'] + ) + assert.deepEqual(result.coverage, { + totalMessages: 220, + returnedMessages: 200, + returnedSummaries: 5, + hasEarlierMessages: true, + }) + } finally { + env.cleanup() + } +}) + +test('scoped search spends its internal evidence budget on private-chat context before groups', async () => { + const { env, contactsService } = createFixture() + try { + const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService }) + const result = await service.searchMessages({ + keywords: ['project alpha'], + scopes: [ + { sessionId: 'group-work', memberIds: [20] }, + { sessionId: 'private-alice', memberIds: [10] }, + ], + maxEvidence: 2, + }) + + assert.deepEqual( + result.messages.map((message) => [message.sessionId, message.messageId, message.evidenceRole]), + [ + ['private-alice', 2, 'context'], + ['private-alice', 1, 'match'], + ] + ) + assert.ok(result.coverage.truncatedReasons.includes('evidence_budget')) + } finally { + env.cleanup() + } +}) + +test('scoped group search spends remaining evidence on the most active matching group', async () => { + const { env, contactsService } = createFixture() + try { + const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService }) + const result = await service.searchMessages({ + keywords: [], + scopes: [ + { sessionId: 'group-other', memberIds: [30] }, + { sessionId: 'group-work', memberIds: [20] }, + ], + maxEvidence: 2, + }) + + assert.deepEqual([...new Set(result.messages.map((message) => message.sessionId))], ['group-work']) + } finally { + env.cleanup() + } +}) + +test('search defaults to at most one thousand context-inclusive evidence messages', async () => { + const { env, contactsService } = createFixture() + try { + env.seed({ + id: 'large-private', + name: 'Large private', + type: 'private', + members: [ + { id: 1, platformId: 'owner', name: 'Me' }, + { id: 2, platformId: 'target', name: 'Target' }, + ], + messages: Array.from({ length: 1_100 }, (_, index) => ({ + id: index + 1, + senderId: 2, + ts: index + 1, + content: `activity ${index + 1}`, + })), + }) + const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService }) + const result = await service.searchMessages({ + keywords: [], + scopes: [{ sessionId: 'large-private', memberIds: [2] }], + }) + + assert.equal(result.totalMatches, 1_100) + assert.equal(result.messages.length, 1_000) + assert.ok(result.coverage.truncatedReasons.includes('evidence_budget')) + } finally { + env.cleanup() + } +}) + test('global search scans recent sessions first and reports budget truncation', async () => { const { env, contactsService } = createFixture() try { @@ -1261,7 +1386,7 @@ test('global search scans recent sessions first and reports budget truncation', assert.deepEqual(progress, ['group-work']) assert.deepEqual( result.messages.map((message) => message.sessionId), - ['group-work', 'group-work'] + ['group-work', 'group-work', 'group-work'] ) assert.equal(result.coverage.candidateSessions, 3) assert.equal(result.coverage.scannedSessions, 1) @@ -1313,7 +1438,10 @@ test('global search applies the relative time range and resolves the owner indep assert.deepEqual( result.messages.map((message) => [message.sessionId, message.messageId, message.senderId]), - [['recent-owner-session', 1, 40]] + [ + ['recent-owner-session', 2, 41], + ['recent-owner-session', 1, 40], + ] ) assert.deepEqual(result.appliedFilters, { startTs: nowSeconds - 90 * 86400, @@ -1345,6 +1473,7 @@ test('search allows empty keywords for scoped sampling but rejects unscoped scan scoped.messages.map((message) => [message.sessionId, message.messageId, message.senderId]), [ ['group-work', 3, 20], + ['group-work', 2, 21], ['group-work', 1, 20], ] ) diff --git a/packages/node-runtime/src/services/cross-chat-analysis/service.ts b/packages/node-runtime/src/services/cross-chat-analysis/service.ts index 994055ff..b899e452 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/service.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/service.ts @@ -5,6 +5,7 @@ import { getParticipantSetInteractionFacts, getMembers, getRecentMessages, + getSegmentSummaries, getSearchMessageContext as getCoreSearchMessageContext, getSessionMeta, getSessionOverview, @@ -31,6 +32,7 @@ import { type CrossChatOverviewRequest, type CrossChatOverviewResult, type CrossChatParticipantRef, + type CrossChatRecentSessionResult, type CrossChatResolvedContact, type CrossChatResolvedSession, type CrossChatSearchRequest, @@ -48,12 +50,16 @@ import type { ContactsService } from '../contacts' const DEFAULT_MAX_SESSIONS = 24 const MAX_MAX_SESSIONS = 100 -const DEFAULT_MAX_EVIDENCE = 80 -const MAX_MAX_EVIDENCE = 200 +const DEFAULT_MAX_EVIDENCE = 1_000 +const MAX_MAX_EVIDENCE = 1_000 const DEFAULT_MAX_WALL_TIME_MS = 8_000 const MAX_MAX_WALL_TIME_MS = 30_000 const DEFAULT_CONTEXT_SIZE = 10 const MAX_CONTEXT_SIZE = 50 +const RECENT_SESSION_MESSAGE_LIMIT = 200 +const RECENT_SESSION_SUMMARY_LIMIT = 5 +const SEARCH_CONTEXT_BEFORE = 2 +const SEARCH_CONTEXT_AFTER = 2 const SECONDS_PER_DAY = 86400 const MAX_RECENT_DAYS = 3650 const CONTACT_SESSIONS_ALGORITHM_VERSION = 'contact-sessions-v1' @@ -83,6 +89,7 @@ export interface CrossChatAnalysisService { request: CrossChatSharedInteractionsRequest, options?: CrossChatOperationOptions ): Promise + readRecentSession(sessionId: string): CrossChatRecentSessionResult searchMessages(request: CrossChatSearchRequest, options?: CrossChatOperationOptions): Promise getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise @@ -659,7 +666,7 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { if (eligibleCandidateCount > selected.length) truncatedReasons.add('session_budget') if (timedOut) truncatedReasons.add('time_budget') - const messages: CrossChatMessageSource[] = [] + const sessionResults: SessionSearchResult[] = [] let totalMatches = 0 let scannedSessions = 0 let matchedSessions = 0 @@ -697,7 +704,15 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { totalMatches += result.total ?? result.messages.length if (result.messages.length > 0) matchedSessions++ if ((result.total ?? result.messages.length) > result.messages.length) truncatedReasons.add('evidence_budget') - messages.push(...result.messages.map((message) => toCrossChatMessage(candidate.descriptor, message))) + sessionResults.push({ + candidate, + db, + totalMatches: result.total ?? result.messages.length, + matches: result.messages.map((message) => ({ + ...toCrossChatMessage(candidate.descriptor, message), + evidenceRole: 'match' as const, + })), + }) } catch (error) { failedSessionIds.add(candidate.descriptor.sessionId) appLogger.warn('cross-chat-analysis', `failed to search session: ${candidate.descriptor.sessionId}`, error) @@ -707,6 +722,17 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { await yieldToEventLoop() } + sessionResults.sort(compareSessionSearchResults) + const evidence = collectSearchEvidence(sessionResults, maxEvidence, startTs, endTs, { + deadline: startedAt + maxWallTimeMs, + now: () => this.now(), + signal: options.signal, + }) + const messages = evidence.messages + if (evidence.timedOut) truncatedReasons.add('time_budget') + const returnedMatchCount = messages.filter((message) => message.evidenceRole === 'match').length + if (returnedMatchCount < totalMatches) truncatedReasons.add('evidence_budget') + const sortMultiplier = request.sort === 'asc' ? 1 : -1 messages.sort( (left, right) => @@ -714,10 +740,6 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { left.sessionId.localeCompare(right.sessionId) || left.messageId - right.messageId) * sortMultiplier ) - if (messages.length > maxEvidence) { - messages.length = maxEvidence - truncatedReasons.add('evidence_budget') - } options.onProgress?.({ processedSessions, totalSessions: selected.length }) return { @@ -741,6 +763,41 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { } } + readRecentSession(sessionId: string): CrossChatRecentSessionResult { + const db = this.deps.adapter.ensureReadonly(sessionId) + const descriptor = getSessionDescriptor(sessionId, db) + if (!descriptor) throw createNotFoundError(`Session not found: ${sessionId}`) + + const recent = searchMessagesByKeywords(db, [], { + sort: 'desc', + limit: RECENT_SESSION_MESSAGE_LIMIT, + }) + const summaries = getSegmentSummaries(db, { limit: RECENT_SESSION_SUMMARY_LIMIT }) + .filter((segment): segment is typeof segment & { summary: string } => typeof segment.summary === 'string') + .map((segment) => ({ + segmentId: segment.id, + startTs: segment.startTs, + endTs: segment.endTs, + messageCount: segment.messageCount, + participants: segment.participants, + summary: segment.summary, + })) + const messages = recent.messages.map((message) => toCrossChatMessage(descriptor, message)) + const totalMessages = recent.total ?? messages.length + + return { + source: descriptor, + messages, + summaries, + coverage: { + totalMessages, + returnedMessages: messages.length, + returnedSummaries: summaries.length, + hasEarlierMessages: totalMessages > messages.length, + }, + } + } + getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult { const db = this.deps.adapter.ensureReadonly(request.sessionId) const descriptor = getSessionDescriptor(request.sessionId, db) @@ -929,6 +986,119 @@ interface SearchCandidate { memberIds?: number[] } +interface SessionSearchResult { + candidate: SearchCandidate + db: DatabaseAdapter + totalMatches: number + matches: CrossChatMessageSource[] +} + +function compareSessionSearchResults(left: SessionSearchResult, right: SessionSearchResult): number { + const leftPrivate = left.candidate.descriptor.sessionType === ChatType.PRIVATE + const rightPrivate = right.candidate.descriptor.sessionType === ChatType.PRIVATE + if (leftPrivate !== rightPrivate) return leftPrivate ? -1 : 1 + return ( + right.totalMatches - left.totalMatches || + (right.candidate.descriptor.lastMessageTs ?? Number.NEGATIVE_INFINITY) - + (left.candidate.descriptor.lastMessageTs ?? Number.NEGATIVE_INFINITY) + ) +} + +function collectSearchEvidence( + results: SessionSearchResult[], + maxMessages: number, + startTs?: number, + endTs?: number, + options: { deadline: number; now: () => number; signal?: AbortSignal } = { + deadline: Number.POSITIVE_INFINITY, + now: Date.now, + } +): { messages: CrossChatMessageSource[]; timedOut: boolean } { + const selected = new Map() + let timedOut = false + + for (const result of results) { + let matchIndex = 0 + while (matchIndex < result.matches.length && selected.size < maxMessages) { + throwIfAborted(options.signal) + if (options.now() >= options.deadline) { + timedOut = true + break + } + const remaining = maxMessages - selected.size + const expectedBlockSize = SEARCH_CONTEXT_BEFORE + SEARCH_CONTEXT_AFTER + 1 + const batchSize = Math.min( + result.matches.length - matchIndex, + remaining < expectedBlockSize ? 1 : Math.max(1, Math.floor(remaining / expectedBlockSize)) + ) + const batch = result.matches.slice(matchIndex, matchIndex + batchSize) + const matchIds = new Set(batch.map((message) => message.messageId)) + const contextMessages = getCoreSearchMessageContext( + result.db, + [...matchIds], + SEARCH_CONTEXT_BEFORE, + SEARCH_CONTEXT_AFTER + ) + .map((message) => ({ + ...toCrossChatMessage(result.candidate.descriptor, message), + evidenceRole: matchIds.has(message.id) ? ('match' as const) : ('context' as const), + })) + .filter((message) => isWithinSearchRange(message.timestamp, startTs, endTs)) + + for (const matchId of matchIds) { + const key = crossChatMessageKey(result.candidate.descriptor.sessionId, matchId) + const existing = selected.get(key) + if (existing?.evidenceRole === 'context') selected.set(key, { ...existing, evidenceRole: 'match' }) + } + + const unseen = contextMessages.filter( + (message) => !selected.has(crossChatMessageKey(message.sessionId, message.messageId)) + ) + const accepted = + unseen.length <= remaining ? unseen : prioritizeContextMessages(unseen, matchIds).slice(0, remaining) + for (const message of accepted) { + const key = crossChatMessageKey(message.sessionId, message.messageId) + const existing = selected.get(key) + if (!existing || (existing.evidenceRole === 'context' && message.evidenceRole === 'match')) { + selected.set(key, message) + } + } + matchIndex += batchSize + } + if (timedOut || selected.size >= maxMessages) break + } + + return { messages: [...selected.values()], timedOut } +} + +function prioritizeContextMessages( + messages: CrossChatMessageSource[], + matchIds: Set +): CrossChatMessageSource[] { + return [...messages].sort((left, right) => { + const leftMatch = matchIds.has(left.messageId) + const rightMatch = matchIds.has(right.messageId) + if (leftMatch !== rightMatch) return leftMatch ? -1 : 1 + const leftDistance = nearestMessageIdDistance(left.messageId, matchIds) + const rightDistance = nearestMessageIdDistance(right.messageId, matchIds) + return leftDistance - rightDistance || left.messageId - right.messageId + }) +} + +function nearestMessageIdDistance(messageId: number, matchIds: Set): number { + let distance = Number.POSITIVE_INFINITY + for (const matchId of matchIds) distance = Math.min(distance, Math.abs(messageId - matchId)) + return distance +} + +function crossChatMessageKey(sessionId: string, messageId: number): string { + return `${sessionId}:${messageId}` +} + +function isWithinSearchRange(timestamp: number, startTs: number | undefined, endTs: number | undefined): boolean { + return (startTs === undefined || timestamp >= startTs) && (endTs === undefined || timestamp <= endTs) +} + function addBoundedSearchCandidate( candidates: SearchCandidate[], candidate: SearchCandidate, diff --git a/packages/node-runtime/src/services/cross-chat-analysis/types.ts b/packages/node-runtime/src/services/cross-chat-analysis/types.ts index 630a2e6e..dd7ec451 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/types.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/types.ts @@ -7,6 +7,8 @@ export type { CrossChatOverviewItem, CrossChatOverviewRequest, CrossChatOverviewResult, + CrossChatRecentSessionResult, + CrossChatRecentSessionSummary, CrossChatResolvedContact, CrossChatResolvedContactSession, CrossChatResolvedSession, diff --git a/packages/node-runtime/src/services/index.ts b/packages/node-runtime/src/services/index.ts index 9e0c6e3e..f2e64d45 100644 --- a/packages/node-runtime/src/services/index.ts +++ b/packages/node-runtime/src/services/index.ts @@ -66,6 +66,8 @@ export type { CrossChatOverviewItem, CrossChatOverviewRequest, CrossChatOverviewResult, + CrossChatRecentSessionResult, + CrossChatRecentSessionSummary, CrossChatResolvedContact, CrossChatResolvedContactSession, CrossChatResolvedSession, diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index d1bce702..4cb29829 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -618,6 +618,8 @@ export interface CrossChatMessageSource extends CrossChatSessionDescriptor { content: string timestamp: number messageType: number + /** Whether this row directly matched the search or was loaded as surrounding context. */ + evidenceRole?: 'match' | 'context' } export type CrossChatTruncationReason = 'session_budget' | 'evidence_budget' | 'time_budget' @@ -646,6 +648,27 @@ export interface CrossChatSearchResult { } } +export interface CrossChatRecentSessionSummary { + segmentId: number + startTs: number + endTs: number + messageCount: number + participants: string[] + summary: string +} + +export interface CrossChatRecentSessionResult { + source: CrossChatSessionDescriptor + messages: CrossChatMessageSource[] + summaries: CrossChatRecentSessionSummary[] + coverage: { + totalMessages: number + returnedMessages: number + returnedSummaries: number + hasEarlierMessages: boolean + } +} + export interface CrossChatMessageContextRequest { sessionId: string messageId: number diff --git a/packages/tools/src/definitions/cross-chat-tools.test.ts b/packages/tools/src/definitions/cross-chat-tools.test.ts index aa952d90..6ad4eb19 100644 --- a/packages/tools/src/definitions/cross-chat-tools.test.ts +++ b/packages/tools/src/definitions/cross-chat-tools.test.ts @@ -8,6 +8,7 @@ import type { CrossChatMessageContextResult, CrossChatMessageSource, CrossChatOverviewResult, + CrossChatRecentSessionResult, CrossChatSearchResult, CrossChatSharedInteractionsResult, } from '@openchatlab/shared-types' @@ -98,6 +99,35 @@ function contactSessionsResult(sessionCount: number, sessionName = 'Work group') } } +function recentSessionResult(): CrossChatRecentSessionResult { + return { + source: { + sessionId: 'session-a', + sessionName: 'Session A', + sessionType: ChatType.PRIVATE, + platform: 'test', + lastMessageTs: 10, + }, + messages: [messageSource('session-a', 7, 10, 'private raw content')], + summaries: [ + { + segmentId: 3, + startTs: 8, + endTs: 10, + messageCount: 2, + participants: ['Alice'], + summary: 'Recent topic summary', + }, + ], + coverage: { + totalMessages: 100, + returnedMessages: 1, + returnedSummaries: 1, + hasEarlierMessages: true, + }, + } +} + function sharedInteractionsResult(anchorsTruncated: boolean): CrossChatSharedInteractionsResult { return { algorithmVersion: 'test', @@ -255,6 +285,7 @@ function createContext(overrides: Partial = {}): C identityCollisionSessions: 0, }, }), + readRecentSession: (): CrossChatRecentSessionResult => recentSessionResult(), searchMessages: async (): Promise => ({ messages: [ { @@ -319,10 +350,11 @@ function createContext(overrides: Partial = {}): C } describe('cross-chat agent registry', () => { - it('contains only the six dedicated tools and is isolated from session and MCP registries', () => { + it('contains only the seven dedicated tools and is isolated from session and MCP registries', () => { const names = CROSS_CHAT_AGENT_TOOL_REGISTRY.map((tool) => tool.name) assert.deepEqual(names, [ 'resolve_chat_entities', + 'read_recent_session', 'search_messages_globally', 'get_cross_chat_message_context', 'get_cross_chat_overview', @@ -341,6 +373,37 @@ describe('cross-chat agent registry', () => { } }) + it('reads one resolved session through the bounded recap path and preserves evidence', async () => { + let capturedSessionId: string | undefined + const context = createContext({ + readRecentSession: (sessionId) => { + capturedSessionId = sessionId + return recentSessionResult() + }, + }) + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'read_recent_session') + assert.ok(tool) + assert.deepEqual(Object.keys(tool.inputSchema.properties ?? {}), ['session_id']) + + const result = await tool.handler({ session_id: 'session-a' }, context) + const content = JSON.parse(result.content) + + assert.equal(capturedSessionId, 'session-a') + assert.equal(content.selection.strategy, 'latest_session_slice') + assert.equal(content.selection.hasEarlierMessages, true) + assert.equal(content.messages[0].content, '[redacted]') + assert.equal(content.summaries[0].summary, 'Recent topic summary') + assert.equal(result.data?.crossChatEvidence.sources[0].snippet, '[redacted]') + }) + + it('keeps latest private-chat recaps outside the default thirty-day window', () => { + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'search_messages_globally') + assert.ok(tool) + assert.match(tool.description, /latest available private-chat recap/i) + assert.match(tool.description, /omit recent_days/i) + assert.match(tool.inputSchema.properties?.recent_days?.description ?? '', /latest available private-chat recap/i) + }) + it('forwards exact shared-interaction participants without accepting display names', async () => { let captured: Record | undefined const context = createContext({ @@ -816,6 +879,15 @@ describe('cross-chat agent registry', () => { const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'search_messages_globally') assert.ok(tool) const result = await tool.handler({ keywords: ['private'] }, createContext()) + const modelData = JSON.parse(result.content) as { + returned: number + selection: { + matchedMessages: number + contextMessages: number + privateMessages: number + groupMessages: number + } + } const data = result.data as { crossChatEvidence: { sources: Array<{ sessionId: string; messageId: number; snippet: string }> } } @@ -823,6 +895,13 @@ describe('cross-chat agent registry', () => { assert.equal(result.content.includes('private raw content'), false) assert.equal(result.content.includes('[redacted]'), true) assert.equal(result.content.includes('crossChatEvidence'), false) + assert.equal(modelData.returned, 1) + assert.deepEqual(modelData.selection, { + matchedMessages: 1, + contextMessages: 0, + privateMessages: 1, + groupMessages: 0, + }) assert.deepEqual(data.crossChatEvidence.sources, [ { sessionId: 'session-a', @@ -943,6 +1022,44 @@ describe('cross-chat agent registry', () => { assert.ok(details.coverage.truncatedReasons.includes('evidence_budget')) }) + it('budgets a thousand search messages with logarithmic tokenizer calls', async () => { + const messages = Array.from({ length: 1_000 }, (_, index) => + messageSource('a', index + 1, index + 1, `第 ${index + 1} 条消息:${'聊天内容'.repeat(20)}`) + ) + const context = createContext({ + searchMessages: async () => ({ + messages, + totalMatches: messages.length, + appliedFilters: { startTs: null, endTs: null, recentDays: null, sender: 'all' }, + coverage: { + candidateSessions: 1, + scannedSessions: 1, + matchedSessions: 1, + failedSessions: 0, + truncated: false, + truncatedReasons: [], + }, + }), + }) + let tokenCountCalls = 0 + context.maxToolResultTokens = 20_000 + context.countTokens = (text) => { + tokenCountCalls++ + return Math.ceil(text.length / 4) + } + context.preprocessMessagesBySession = (_sessionId, sessionMessages) => sessionMessages + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'search_messages_globally') + assert.ok(tool) + + const result = await tool.handler({ keywords: ['聊天'] }, context) + const modelData = JSON.parse(result.content) as { returned: number; coverage: { truncated: boolean } } + + assert.ok(modelData.returned > 0) + assert.ok(modelData.returned < messages.length) + assert.equal(modelData.coverage.truncated, true) + assert.ok(tokenCountCalls <= 11, `expected at most 11 tokenizer calls, got ${tokenCountCalls}`) + }) + it('allows recent-message sampling only when explicit scopes are present', async () => { let captured: unknown const context = createContext({ @@ -980,12 +1097,49 @@ describe('cross-chat agent registry', () => { sender: 'all', matchMode: 'any', sort: 'desc', - maxSessions: undefined, - maxEvidence: undefined, - maxWallTimeMs: undefined, }) }) + it('keeps evidence and execution budgets out of the model-visible search contract', async () => { + let captured: unknown + const context = createContext({ + searchMessages: async (request) => { + captured = request + return { + messages: [], + totalMatches: 0, + appliedFilters: { startTs: null, endTs: null, recentDays: null, sender: 'all' }, + coverage: { + candidateSessions: 1, + scannedSessions: 1, + matchedSessions: 0, + failedSessions: 0, + truncated: false, + truncatedReasons: [], + }, + } + }, + }) + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'search_messages_globally') + assert.ok(tool) + + assert.equal(tool.inputSchema.properties?.max_evidence, undefined) + assert.equal(tool.inputSchema.properties?.max_sessions, undefined) + assert.equal(tool.inputSchema.properties?.max_wall_time_ms, undefined) + await tool.handler( + { + keywords: ['activity'], + max_evidence: 1, + max_sessions: 1, + max_wall_time_ms: 1, + }, + context + ) + assert.equal('maxEvidence' in (captured as Record), false) + assert.equal('maxSessions' in (captured as Record), false) + assert.equal('maxWallTimeMs' in (captured as Record), false) + }) + it('forwards relative time and owner-only filters for global discovery', async () => { let captured: unknown const context = createContext({ @@ -997,7 +1151,7 @@ describe('cross-chat agent registry', () => { appliedFilters: { startTs: 100, endTs: null, - recentDays: 90, + recentDays: 30, sender: 'owner', }, coverage: { @@ -1018,20 +1172,17 @@ describe('cross-chat agent registry', () => { }) const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'search_messages_globally') assert.ok(tool) - await tool.handler({ keywords: ['买房'], recent_days: 90, sender: 'owner' }, context) + await tool.handler({ keywords: ['买房'], recent_days: 30, sender: 'owner' }, context) assert.deepEqual(captured, { keywords: ['买房'], scopes: undefined, startTs: undefined, endTs: undefined, - recentDays: 90, + recentDays: 30, sender: 'owner', matchMode: 'any', sort: 'desc', - maxSessions: undefined, - maxEvidence: undefined, - maxWallTimeMs: undefined, }) }) diff --git a/packages/tools/src/definitions/cross-chat-tools.ts b/packages/tools/src/definitions/cross-chat-tools.ts index 38a1fbf7..852d9e6f 100644 --- a/packages/tools/src/definitions/cross-chat-tools.ts +++ b/packages/tools/src/definitions/cross-chat-tools.ts @@ -29,7 +29,7 @@ const recentDaysProperty = { type: 'number' as const, minimum: 1, description: - 'Relative time window ending at the real current time. Use 90 when the user says recent/recently without a more specific range.', + 'Relative time window ending at the real current time. Use 30 for unspecified recent activity, but omit it for a latest available private-chat recap without an explicit date or duration.', } const resolveSchema: JsonSchema = { @@ -78,13 +78,21 @@ const searchSchema: JsonSchema = { "Message sender filter. Use owner when the user's wording makes them the subject, such as 'I discussed buying a home'.", }, sort: { type: 'string', enum: ['asc', 'desc'], description: 'Timestamp order; defaults to newest first' }, - max_sessions: { type: 'number', description: 'Maximum sessions to scan' }, - max_evidence: { type: 'number', description: 'Maximum evidence messages returned' }, - max_wall_time_ms: { type: 'number', description: 'Maximum wall time for this scan' }, ...timeParamProperties, }, } +const recentSessionSchema: JsonSchema = { + type: 'object', + properties: { + session_id: { + type: 'string', + description: 'One exact session ID returned by resolve_chat_entities', + }, + }, + required: ['session_id'], +} + const contextSchema: JsonSchema = { type: 'object', properties: { @@ -227,9 +235,6 @@ async function searchHandler( sender: params.sender === 'owner' ? 'owner' : 'all', matchMode: params.match_mode === 'all' ? 'all' : 'any', sort: params.sort === 'asc' ? 'asc' : 'desc', - maxSessions: parseOptionalNumber(params.max_sessions), - maxEvidence: parseOptionalNumber(params.max_evidence), - maxWallTimeMs: parseOptionalNumber(params.max_wall_time_ms), }, { signal: context.abortSignal, @@ -252,6 +257,7 @@ async function searchHandler( const buildModelData = (messages: CrossChatMessageSource[], budgetTruncated: boolean) => ({ totalMatches: result.totalMatches, returned: messages.length, + selection: summarizeEvidenceSelection(messages), appliedFilters: result.appliedFilters, coverage: buildCoverage(budgetTruncated), messages: messages.map(toModelMessage), @@ -275,6 +281,51 @@ async function searchHandler( return { content: JSON.stringify(modelData), data } } +async function readRecentSessionHandler( + params: Record, + context: CrossChatToolExecutionContext +): Promise { + const result = context.analysisService.readRecentSession(requireString(params.session_id, 'session_id')) + const safeMessages = await preprocessBySession(context, result.messages) + const buildModelData = (messages: CrossChatMessageSource[], budgetTruncated: boolean) => ({ + source: result.source, + selection: { + strategy: 'latest_session_slice', + totalMessages: result.coverage.totalMessages, + returnedMessages: messages.length, + returnedSummaries: result.summaries.length, + hasEarlierMessages: result.coverage.hasEarlierMessages, + toolResultTruncated: budgetTruncated, + }, + summaries: result.summaries, + messages: messages.map(toModelMessage), + }) + const limited = limitMessagesToBudget(safeMessages, context.maxToolResultTokens, buildModelData, { + countTokens: context.countTokens, + }) + const modelData = buildModelData(limited.messages, limited.truncated) + const evidence: CrossChatEvidencePayload = { + version: 1, + query: 'recent session recap', + sources: limited.messages.map(toEvidenceSource), + coverage: { + candidateSessions: 1, + scannedSessions: 1, + matchedSessions: limited.messages.length > 0 ? 1 : 0, + failedSessions: 0, + truncated: limited.truncated, + truncatedReasons: limited.truncated ? ['evidence_budget'] : [], + }, + } + return { + content: JSON.stringify(modelData), + data: { + ...modelData, + crossChatEvidence: evidence, + }, + } +} + async function contextHandler( params: Record, context: CrossChatToolExecutionContext @@ -424,10 +475,20 @@ export const resolveChatEntitiesTool: ToolDefinition = { + name: 'read_recent_session', + description: + 'Read one exactly resolved session for a concise latest-available recap. The tool internally returns a bounded newest message slice plus up to five existing segment summaries, with source evidence and preprocessing. Use it after resolve_chat_entities when the user asks what happened recently in one private chat or one explicitly selected session without a calendar range. Do not pass message budgets. For explicit dates, durations, keywords, multi-session work, or deeper history, use search_messages_globally instead.', + inputSchema: recentSessionSchema, + handler: readRecentSessionHandler, + category: 'core', + truncationStrategy: 'keep_first', +} + export const searchMessagesGloballyTool: ToolDefinition = { name: 'search_messages_globally', description: - "Search exact keywords across resolved contacts or sessions. Use recent_days=90 when 'recent' has no explicit duration, and sender=owner when the user is the subject of the requested action. Owner hits are discovery seeds; expand their context to identify other participants. With explicit scopes, omit keywords to sample recent messages. Unscoped global discovery always requires keywords. Results include applied filters, source identity, coverage, and truncation status.", + 'Search exact keywords across resolved contacts or sessions. Use recent_days=30 for unspecified recent activity, but for a latest available private-chat recap without an explicit date or duration, scope only the direct private session and omit recent_days. Use sender=owner when the user is the subject of the requested action. The tool controls evidence volume internally, prioritizes private-chat evidence, ranks remaining group sources by matching activity, and automatically includes surrounding context. With explicit scopes, omit keywords to sample recent messages. Unscoped global discovery always requires keywords. Results include applied filters, source identity, coverage, and truncation status.', inputSchema: searchSchema, handler: searchHandler, category: 'core', @@ -456,7 +517,7 @@ export const getCrossChatOverviewTool: ToolDefinition = { name: 'inspect_contact_sessions', description: - "Inspect one resolved contact across imported private and group sessions. Returns the contact's own message counts, first/last speech, active days, roster-only presence, dataset cutoff, and coverage. Use recent_days=90 for recent activity without an explicit duration; never derive start_time from the dataset cutoff. Use only for person source/activity questions after exact identity resolution; this tool does not infer relationship labels or return message text.", + "Inspect one resolved contact across imported private and group sessions. Returns the contact's own message counts, first/last speech, active days, roster-only presence, dataset cutoff, and coverage. Use recent_days=30 for recent activity without an explicit duration; never derive start_time from the dataset cutoff. Use only for person source/activity questions after exact identity resolution; this tool does not infer relationship labels or return message text.", inputSchema: inspectContactSessionsSchema, handler: inspectContactSessionsHandler, category: 'core', @@ -465,7 +526,7 @@ export const inspectContactSessionsTool: ToolDefinition = { name: 'inspect_shared_interactions', description: - 'Inspect common imported sessions for two to five exactly resolved people, including owner when requested. Returns per-person activity, directional replies, proximity signals, co-active days, message anchors, dataset cutoff, and coverage. Use recent_days=90 for recent interaction questions without an explicit duration; never derive start_time from the dataset cutoff. Common sessions contain every requested participant. These structural signals guide evidence reading and must never be treated as relationship labels or replace message context.', + 'Inspect common imported sessions for two to five exactly resolved people, including owner when requested. Returns per-person activity, directional replies, proximity signals, co-active days, message anchors, dataset cutoff, and coverage. Use recent_days=30 for recent interaction questions without an explicit duration; never derive start_time from the dataset cutoff. Common sessions contain every requested participant. These structural signals guide evidence reading and must never be treated as relationship labels or replace message context.', inputSchema: inspectSharedInteractionsSchema, handler: inspectSharedInteractionsHandler, category: 'core', @@ -818,10 +879,30 @@ function limitMessagesToBudget( } const countTokens = options.countTokens ?? estimatePayloadTokens - const priorityIndexes = options.priorityIndexes ?? prepared.map((_, index) => index) + const priorityIndexes = [...new Set(options.priorityIndexes ?? prepared.map((_, index) => index))].filter( + (index) => index >= 0 && index < prepared.length + ) + + if (!options.continueAfterOverflow) { + let low = 0 + let high = priorityIndexes.length + // 普通搜索按优先级取连续前缀,使用二分将大结果集的完整序列化和 tokenizer 调用降到对数级。 + while (low < high) { + const middle = Math.ceil((low + high) / 2) + const candidateIndexes = priorityIndexes.slice(0, middle).sort((left, right) => left - right) + const candidateMessages = candidateIndexes.map((candidateIndex) => prepared[candidateIndex]) + const truncated = candidateMessages.length < prepared.length + const serializedCandidate = JSON.stringify(buildPayload(candidateMessages, truncated)) + if (countTokens(serializedCandidate) <= maxToolResultTokens) low = middle + else high = middle - 1 + } + const limitedIndexes = priorityIndexes.slice(0, low).sort((left, right) => left - right) + const limited = limitedIndexes.map((index) => prepared[index]) + return { messages: limited, truncated: limited.length < prepared.length } + } + const selectedIndexes = new Set() for (const index of priorityIndexes) { - if (index < 0 || index >= prepared.length || selectedIndexes.has(index)) continue const candidateIndexes = [...selectedIndexes, index].sort((left, right) => left - right) const candidateMessages = candidateIndexes.map((candidateIndex) => prepared[candidateIndex]) const truncated = candidateMessages.length < prepared.length @@ -1063,9 +1144,24 @@ function toModelMessage(message: CrossChatMessageSource): Record { + let matchedMessages = 0 + let contextMessages = 0 + let privateMessages = 0 + let groupMessages = 0 + for (const message of messages) { + if (message.evidenceRole === 'context') contextMessages++ + else matchedMessages++ + if (message.sessionType === 'private') privateMessages++ + else groupMessages++ + } + return { matchedMessages, contextMessages, privateMessages, groupMessages } +} + function toEvidenceSource(message: CrossChatMessageSource): CrossChatEvidencePayload['sources'][number] { return { sessionId: message.sessionId, diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 3c88e7cc..28de7837 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -45,6 +45,7 @@ export { retrieveChatEvidenceTool } from './definitions/retrieve-chat-evidence' export { getCrossChatMessageContextTool, getCrossChatOverviewTool, + readRecentSessionTool, resolveChatEntitiesTool, searchMessagesGloballyTool, } from './definitions/cross-chat-tools' diff --git a/packages/tools/src/registry.ts b/packages/tools/src/registry.ts index 7a023827..951cd6a2 100644 --- a/packages/tools/src/registry.ts +++ b/packages/tools/src/registry.ts @@ -35,6 +35,7 @@ import { getCrossChatOverviewTool, inspectContactSessionsTool, inspectSharedInteractionsTool, + readRecentSessionTool, resolveChatEntitiesTool, searchMessagesGloballyTool, } from './definitions/cross-chat-tools' @@ -86,6 +87,7 @@ export const AGENT_TOOL_REGISTRY: ToolDefinition[] = [ /** Dedicated global-analysis tools. Never merge this registry into session Agent or MCP registries. */ export const CROSS_CHAT_AGENT_TOOL_REGISTRY = [ resolveChatEntitiesTool, + readRecentSessionTool, searchMessagesGloballyTool, getCrossChatMessageContextTool, getCrossChatOverviewTool, diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts index 3902a470..e597483b 100644 --- a/packages/tools/src/types.ts +++ b/packages/tools/src/types.ts @@ -17,6 +17,7 @@ import type { CrossChatOperationOptions, CrossChatOverviewRequest, CrossChatOverviewResult, + CrossChatRecentSessionResult, CrossChatSearchRequest, CrossChatSearchResult, CrossChatSharedInteractionsRequest, @@ -370,6 +371,7 @@ export interface CrossChatAnalysisToolService { request: CrossChatSharedInteractionsRequest, options?: CrossChatOperationOptions ): Promise + readRecentSession(sessionId: string): CrossChatRecentSessionResult searchMessages(request: CrossChatSearchRequest, options?: CrossChatOperationOptions): Promise getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise