From 10e0825578a876347f3ea0443421fdff18cbbc9c Mon Sep 17 00:00:00 2001 From: digua Date: Sat, 22 Aug 2026 20:18:33 +0800 Subject: [PATCH] fix(ai): harden contact session inspection Use the all-history contact snapshot and validate raw session candidates inside the cancellable scan loop. Bound model-visible structural results to the configured token budget while preserving full tool details and continuation metadata. --- .../cross-chat-analysis/service.test.ts | 97 +++++++++++++ .../services/cross-chat-analysis/service.ts | 11 +- .../src/definitions/cross-chat-tools.test.ts | 93 +++++++++++++ .../tools/src/definitions/cross-chat-tools.ts | 128 +++++++++++++++++- 4 files changed, 323 insertions(+), 6 deletions(-) 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 e04b8474..aa826787 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 @@ -466,6 +466,103 @@ test('contact session inspection scans imported sessions and separates own messa } }) +test('contact session inspection uses the all-history contact snapshot', async () => { + const { env, contactsService: fixtureContactsService } = createFixture() + const detailPresets: Array = [] + try { + const contactsService: Pick = { + ...fixtureContactsService, + getContactDetail: (key, options) => { + detailPresets.push(options?.timeRangePreset) + return options?.timeRangePreset === 'all' ? fixtureContactsService.getContactDetail(key, options) : detail(null) + }, + } + const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService }) + + const result = await service.inspectContactSessions({ contactKey: 'test:alice' }) + + assert.equal(result.contact?.contactKey, 'test:alice') + assert.deepEqual( + result.sessions.map((session) => session.sessionId), + ['group-work', 'private-alice'] + ) + assert.deepEqual(detailPresets, ['all']) + } finally { + env.cleanup() + } +}) + +test('contact session inspection validates raw candidates inside failure, time, and cancellation boundaries', async () => { + const { env, contactsService } = createFixture() + try { + const candidateIds = [...env.adapter.listSessionCandidateIds!(), 'damaged'] + let eagerEnumerationCalled = false + const failureAdapter: SessionRuntimeAdapter = { + ...env.adapter, + listSessionIds: () => { + eagerEnumerationCalled = true + throw new Error('eager enumeration should not be used') + }, + listSessionCandidateIds: () => candidateIds, + openReadonly: (sessionId) => { + if (sessionId === 'damaged') throw new Error('fixture failure') + return env.adapter.openReadonly(sessionId) + }, + } + const failureResult = await createCrossChatAnalysisService({ + adapter: failureAdapter, + contactsService, + }).inspectContactSessions({ contactKey: 'test:alice' }) + + assert.equal(eagerEnumerationCalled, false) + assert.deepEqual( + failureResult.sessions.map((session) => session.sessionId), + ['group-work', 'private-alice'] + ) + assert.deepEqual(failureResult.coverage.failedSessionIds, ['damaged']) + + let now = 0 + const openedSessions: string[] = [] + const timedAdapter: SessionRuntimeAdapter = { + ...env.adapter, + openReadonly: (sessionId) => { + openedSessions.push(sessionId) + const db = env.adapter.openReadonly(sessionId) + now += 9_000 + return db + }, + } + const timedResult = await createCrossChatAnalysisService({ + adapter: timedAdapter, + contactsService, + now: () => now, + }).inspectContactSessions({ contactKey: 'test:alice', maxWallTimeMs: 8_000 }) + + assert.equal(openedSessions.length, 1) + assert.equal(timedResult.coverage.scannedSessions, 1) + assert.ok(timedResult.coverage.truncatedReasons.includes('time_budget')) + assert.ok(timedResult.coverage.nextCursor) + + const controller = new AbortController() + const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService }) + await assert.rejects( + () => + service.inspectContactSessions( + { contactKey: 'test:alice' }, + { + signal: controller.signal, + onProgress: (progress) => { + if (progress.currentSessionId === 'group-work') controller.abort() + }, + } + ), + { name: 'AbortError' } + ) + } finally { + env.cleanup() + } +}) + test('contact session inspection honors time ranges, session-scoped identity, and continuation cursors', async () => { const { env, contactsService } = createFixture() try { 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 a64105e3..b4fe0591 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/service.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/service.ts @@ -267,17 +267,23 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { const includeRosterOnly = request.includeRosterOnly !== false const pageSize = clampInteger(request.pageSize, DEFAULT_INSPECTION_PAGE_SIZE, 1, MAX_INSPECTION_PAGE_SIZE) const maxWallTimeMs = clampInteger(request.maxWallTimeMs, DEFAULT_MAX_WALL_TIME_MS, 1, MAX_MAX_WALL_TIME_MS) - const detail = this.deps.contactsService.getContactDetail(contactKey, { acceptStale: true }) + const detail = this.deps.contactsService.getContactDetail(contactKey, { + acceptStale: true, + timeRangePreset: 'all', + }) const contact = detail.contact if (!contact) { return emptyContactSessionsResult(detail.cache.status, range) } + const startedAt = this.now() const candidateSessionIds = contact.sessionScoped ? contact.sessionId ? [contact.sessionId] : [] - : [...new Set(this.deps.adapter.listSessionIds())].sort((left, right) => left.localeCompare(right)) + : [...new Set(this.deps.adapter.listSessionCandidateIds?.() ?? this.deps.adapter.listSessionIds())].sort( + (left, right) => left.localeCompare(right) + ) const cursorFingerprint = createInspectionFingerprint({ candidateSessionIds, contactKey, @@ -286,7 +292,6 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { includeRosterOnly, }) const cursor = parseInspectionCursor(request.cursor, cursorFingerprint, candidateSessionIds) - const startedAt = this.now() const sessions: CrossChatContactSessionsResult['sessions'] = [] const failedSessionIds: string[] = [] const truncatedReasons = new Set() diff --git a/packages/tools/src/definitions/cross-chat-tools.test.ts b/packages/tools/src/definitions/cross-chat-tools.test.ts index 655a83e8..faf01493 100644 --- a/packages/tools/src/definitions/cross-chat-tools.test.ts +++ b/packages/tools/src/definitions/cross-chat-tools.test.ts @@ -36,6 +36,67 @@ function messageSource( } } +function contactSessionsResult(sessionCount: number, sessionName = 'Work group'): CrossChatContactSessionsResult { + return { + algorithmVersion: 'test', + contact: { + contactKey: 'test:alice', + displayName: 'Alice', + platform: 'test', + sessionScoped: false, + }, + appliedRange: { + startTs: null, + endTs: null, + dataEarliestMessageTs: 100, + dataLatestMessageTs: 200, + }, + summary: { + scope: 'current_batch', + matchedSessions: sessionCount, + privateSessions: 0, + groupSessions: sessionCount, + spokeSessions: sessionCount, + rosterOnlySessions: 0, + ownMessageCount: sessionCount * 10, + firstOwnMessageTs: 100, + lastOwnMessageTs: 200, + }, + sessions: Array.from({ length: sessionCount }, (_, index) => ({ + sessionId: `group-${index}`, + sessionName, + sessionType: ChatType.GROUP, + platform: 'test', + lastMessageTs: 200, + memberId: index + 1, + memberName: 'Alice', + presence: 'spoke', + presenceObservedInRange: true, + ownMessageCount: 10, + sessionMessageCount: 100, + messageShare: 0.1, + firstOwnMessageTs: 100, + lastOwnMessageTs: 200, + activeDays: 2, + memberCount: 20, + sessionFirstMessageTs: 90, + })), + coverage: { + candidateSessions: 20, + scannedSessions: sessionCount, + matchedSessions: sessionCount, + returnedSessions: sessionCount, + failedSessions: 0, + failedSessionIds: [], + complete: false, + nextCursor: 'next-page', + truncated: true, + truncatedReasons: ['page_size'], + contactCacheStatus: 'fresh', + }, + } +} + function createContext(overrides: Partial = {}): CrossChatToolExecutionContext { const service: CrossChatAnalysisToolService = { lookupContact: (query: string): CrossChatContactLookupResult => ({ @@ -209,6 +270,38 @@ describe('cross-chat agent registry', () => { await assert.rejects(async () => tool.handler({ contact_key: '' }, context), /contact_key is required/) }) + it('keeps contact inspection within the model token budget without dropping full tool details', async () => { + let capturedPageSize: number | undefined + const longSessionName = '超长群聊名称'.repeat(500) + let tokenCountCalls = 0 + const countTokens = (text: string) => { + tokenCountCalls++ + return Math.ceil(Array.from(text).length / 4) + } + const context = createContext({ + inspectContactSessions: async (request) => { + capturedPageSize = request.pageSize + return contactSessionsResult(request.pageSize ?? 12, longSessionName) + }, + }) + context.maxToolResultTokens = 1_000 + context.countTokens = countTokens + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'inspect_contact_sessions') + assert.ok(tool) + + const result = await tool.handler({ contact_key: 'test:alice', page_size: 12 }, context) + const data = result.data as CrossChatContactSessionsResult + const modelData = JSON.parse(result.content) as { sessions?: Array<{ sessionName?: string }> } + + assert.ok(capturedPageSize && capturedPageSize < 12) + assert.equal(data.sessions.length, capturedPageSize) + assert.equal(data.sessions[0]?.sessionName, longSessionName) + assert.notEqual(modelData.sessions?.[0]?.sessionName, longSessionName) + assert.ok(data.coverage.truncatedReasons.includes('tool_result_budget')) + assert.ok(countTokens(result.content) <= context.maxToolResultTokens) + assert.ok(tokenCountCalls > 0) + }) + it('resolves a unique contact name before continuing with stable scopes', async () => { let resolvedRefs: AIEntityRef[] = [] const context = createContext({ diff --git a/packages/tools/src/definitions/cross-chat-tools.ts b/packages/tools/src/definitions/cross-chat-tools.ts index ed5f1f4e..c7363a4f 100644 --- a/packages/tools/src/definitions/cross-chat-tools.ts +++ b/packages/tools/src/definitions/cross-chat-tools.ts @@ -124,6 +124,13 @@ const inspectContactSessionsSchema: JsonSchema = { required: ['contact_key'], } +const DEFAULT_CONTACT_INSPECTION_PAGE_SIZE = 50 +const MAX_CONTACT_INSPECTION_PAGE_SIZE = 100 +const CONTACT_MODEL_LABEL_MAX_LENGTH = 80 +const TOOL_RESULT_CHARS_PER_TOKEN = 4 +const TOOL_RESULT_BASE_CHARS = 1_500 +const CONTACT_SESSION_ESTIMATED_CHARS = 650 + async function resolveHandler( params: Record, context: CrossChatToolExecutionContext @@ -279,6 +286,8 @@ async function inspectContactSessionsHandler( context: CrossChatToolExecutionContext ): Promise { const timeFilter = parseExtendedTimeParams(params) + const requestedPageSize = parseOptionalNumber(params.page_size) + const budgetedPageSize = limitContactPageSizeToBudget(requestedPageSize, context.maxToolResultTokens) const result = await context.analysisService.inspectContactSessions( { contactKey: requireString(params.contact_key, 'contact_key'), @@ -286,7 +295,7 @@ async function inspectContactSessionsHandler( endTs: timeFilter?.endTs, includeRosterOnly: params.include_roster_only === undefined ? true : requireBoolean(params.include_roster_only), cursor: typeof params.cursor === 'string' && params.cursor.trim() ? params.cursor.trim() : undefined, - pageSize: parseOptionalNumber(params.page_size), + pageSize: budgetedPageSize.value, maxWallTimeMs: parseOptionalNumber(params.max_wall_time_ms), }, { @@ -299,8 +308,15 @@ async function inspectContactSessionsHandler( }), } ) - const data: CrossChatContactSessionsResult = result - return { content: JSON.stringify(data), data } + const countTokens = context.countTokens ?? estimatePayloadTokens + const pageBudgetTruncated = budgetedPageSize.limited && result.coverage.truncatedReasons.includes('page_size') + const payloadExceedsBudget = + !!context.maxToolResultTokens && + context.maxToolResultTokens > 0 && + countTokens(JSON.stringify(result)) > context.maxToolResultTokens + const data = pageBudgetTruncated || payloadExceedsBudget ? withContactToolResultBudgetReason(result) : result + const modelData = limitContactResultToBudget(data, context.maxToolResultTokens, countTokens) + return { content: JSON.stringify(modelData), data } } export const resolveChatEntitiesTool: ToolDefinition = { @@ -399,6 +415,112 @@ function parseScopes(value: unknown): CrossChatSearchScope[] { }) } +function limitContactPageSizeToBudget( + requestedPageSize: number | undefined, + maxToolResultTokens: number | undefined +): { value: number | undefined; limited: boolean } { + if (!maxToolResultTokens || maxToolResultTokens <= 0) { + return { value: requestedPageSize, limited: false } + } + + const requested = Math.min( + MAX_CONTACT_INSPECTION_PAGE_SIZE, + Math.max(1, Math.floor(requestedPageSize ?? DEFAULT_CONTACT_INSPECTION_PAGE_SIZE)) + ) + // service 调用前先收缩分页,保证 nextCursor 指向模型实际看过的最后一批结果,而不是截断后的尾部。 + const maxChars = maxToolResultTokens * TOOL_RESULT_CHARS_PER_TOKEN + const budgeted = Math.max(1, Math.floor((maxChars - TOOL_RESULT_BASE_CHARS) / CONTACT_SESSION_ESTIMATED_CHARS)) + return { + value: budgeted < requested ? budgeted : requestedPageSize, + limited: budgeted < requested, + } +} + +function withContactToolResultBudgetReason(result: CrossChatContactSessionsResult): CrossChatContactSessionsResult { + return { + ...result, + coverage: { + ...result.coverage, + truncated: true, + truncatedReasons: [...new Set([...result.coverage.truncatedReasons, 'tool_result_budget' as const])], + }, + } +} + +function limitContactResultToBudget( + result: CrossChatContactSessionsResult, + maxToolResultTokens: number | undefined, + countTokens: (text: string) => number +): unknown { + if (!maxToolResultTokens || maxToolResultTokens <= 0) return result + if (countTokens(JSON.stringify(result)) <= maxToolResultTokens) return result + + // details 保留完整结构供 UI 和日志追溯;这里只逐级压缩模型可见文本,并始终保留 coverage/continuation。 + const compactLabels = { + ...result, + contact: result.contact ? { ...result.contact, displayName: truncateModelLabel(result.contact.displayName) } : null, + sessions: result.sessions.map((session) => ({ + ...session, + sessionName: truncateModelLabel(session.sessionName), + memberName: truncateModelLabel(session.memberName), + })), + } + const coreFacts = { + algorithmVersion: result.algorithmVersion, + contact: compactLabels.contact, + appliedRange: result.appliedRange, + summary: result.summary, + sessions: result.sessions.map((session) => ({ + sessionId: session.sessionId, + sessionName: truncateModelLabel(session.sessionName), + sessionType: session.sessionType, + platform: session.platform, + presence: session.presence, + ownMessageCount: session.ownMessageCount, + sessionMessageCount: session.sessionMessageCount, + firstOwnMessageTs: session.firstOwnMessageTs, + lastOwnMessageTs: session.lastOwnMessageTs, + activeDays: session.activeDays, + memberCount: session.memberCount, + sessionFirstMessageTs: session.sessionFirstMessageTs, + lastMessageTs: session.lastMessageTs, + })), + coverage: result.coverage, + } + const identityFacts = { + contact: result.contact + ? { + contactKey: result.contact.contactKey, + displayName: truncateModelLabel(result.contact.displayName), + platform: result.contact.platform, + } + : null, + sessions: result.sessions.map((session) => ({ + sessionId: session.sessionId, + sessionType: session.sessionType, + presence: session.presence, + ownMessageCount: session.ownMessageCount, + lastOwnMessageTs: session.lastOwnMessageTs, + })), + coverage: result.coverage, + } + const coverageOnly = { coverage: result.coverage } + const candidates: unknown[] = [compactLabels, coreFacts, identityFacts, coverageOnly] + return ( + candidates.find((candidate) => countTokens(JSON.stringify(candidate)) <= maxToolResultTokens) ?? { + truncated: true, + truncatedReasons: ['tool_result_budget'], + } + ) +} + +function truncateModelLabel(value: string): string { + const chars = Array.from(value) + return chars.length > CONTACT_MODEL_LABEL_MAX_LENGTH + ? `${chars.slice(0, CONTACT_MODEL_LABEL_MAX_LENGTH).join('')}…` + : value +} + async function preprocessBySession( context: CrossChatToolExecutionContext, messages: CrossChatMessageSource[]