From a6557608c6df372e25a130f64f3a28f8e396b1f3 Mon Sep 17 00:00:00 2001 From: digua Date: Sat, 22 Aug 2026 20:50:48 +0800 Subject: [PATCH] fix(ai): harden shared interaction analysis Keep shared-interaction evidence, pagination, and token truncation accurate while bounding anchor inputs, proximity scans, contact discovery, and reply aggregation. Resolve participants across all imported history and isolate damaged database candidates without changing the final analysis tree. --- .../relationship-graph-queries.test.ts | 112 ++++++++++ packages/core/src/query/advanced/social.ts | 71 +++++- packages/core/src/query/contact-queries.ts | 120 +++++++--- .../src/services/contacts/compute.ts | 2 +- .../src/services/contacts/service.test.ts | 54 +++++ .../src/services/contacts/service.ts | 3 +- .../src/services/contacts/signature.ts | 3 +- .../cross-chat-analysis/service.test.ts | 177 +++++++++++++++ .../services/cross-chat-analysis/service.ts | 14 +- packages/shared-types/index.ts | 1 + .../src/definitions/cross-chat-tools.test.ts | 210 +++++++++++++++++- .../tools/src/definitions/cross-chat-tools.ts | 135 +++++++++-- 12 files changed, 838 insertions(+), 64 deletions(-) diff --git a/packages/core/src/query/__tests__/relationship-graph-queries.test.ts b/packages/core/src/query/__tests__/relationship-graph-queries.test.ts index 1f7ac159..3bd868bd 100644 --- a/packages/core/src/query/__tests__/relationship-graph-queries.test.ts +++ b/packages/core/src/query/__tests__/relationship-graph-queries.test.ts @@ -94,6 +94,10 @@ describe('relationship graph query helpers', () => { platform_message_id TEXT, reply_to_message_id TEXT ); + CREATE INDEX idx_message_sender ON message(sender_id); + CREATE INDEX idx_message_sender_ts ON message(sender_id, ts); + CREATE INDEX idx_message_reply_to ON message(reply_to_message_id); + CREATE INDEX idx_message_platform_id ON message(platform_message_id); INSERT INTO meta (name, platform, type, imported_at, owner_id) VALUES ('Group', 'wechat', 'group', 1700000000, 'owner-pid'); INSERT INTO member (id, platform_id, account_name, group_nickname, aliases, avatar) VALUES @@ -126,6 +130,43 @@ describe('relationship graph query helpers', () => { ) }) + it('keeps two-speaker look-ahead scans linear for long conversations', () => { + const messageCount = 20_000 + const source = Array.from({ length: messageCount }, (_, index) => ({ + messageId: index + 1, + senderId: (index % 2) + 1, + ts: index, + })) + const maxMessageReads = messageCount * 8 + let messageReads = 0 + const messages = new Proxy(source, { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + messageReads++ + if (messageReads > maxMessageReads) throw new Error('message look-ahead scan exceeded its linear bound') + } + return Reflect.get(target, property, receiver) + }, + }) + + const pairs = accumulateSelectedCoOccurrencePairs(messages, [[1, 2]]) + + assert.equal(pairs[0]?.coOccurrenceCount, messageCount - 1) + assert.ok(messageReads <= maxMessageReads) + }) + + it('does not treat messages from separate conversation windows as proximity evidence', () => { + const pairs = accumulateSelectedCoOccurrencePairs( + [ + { messageId: 1, senderId: 1, ts: 0 }, + { messageId: 2, senderId: 2, ts: 2 * 86400 }, + ], + [[1, 2]] + ) + + assert.deepEqual(pairs, []) + }) + afterEach(() => { raw.close() }) @@ -233,6 +274,77 @@ describe('relationship graph query helpers', () => { ) }) + it('keeps direct reply aggregation bounded for a dense reply history', { timeout: 2_000 }, () => { + const messageCount = 5_000 + raw + .prepare( + `WITH RECURSIVE sequence(id) AS ( + SELECT 1 + UNION ALL + SELECT id + 1 FROM sequence WHERE id < ? + ) + INSERT INTO message + (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id) + SELECT + id, + CASE WHEN id % 2 = 0 THEN 2 ELSE 3 END, + 1700000000 + id, + 0, + 'message ' || id, + 'dense-' || id, + CASE WHEN id = 1 THEN NULL ELSE 'dense-' || (id - 1) END + FROM sequence` + ) + .run(messageCount) + + const facts = getParticipantSetInteractionFacts(db, [2, 3], { + maxAnchorsPerPair: 2, + maxProximityMessages: 0, + }) + const pair = facts.pairs[0] + + assert.equal(pair.directReplyCount, messageCount - 1) + assert.equal(pair.repliesFromSourceToTarget, messageCount / 2) + assert.equal(pair.repliesFromTargetToSource, messageCount / 2 - 1) + assert.equal(pair.lastDirectReplyTs, 1700000000 + messageCount) + assert.deepEqual( + pair.anchors.map((anchor) => [anchor.messageId, anchor.relatedMessageId]), + [ + [messageCount, messageCount - 1], + [messageCount - 1, messageCount - 2], + ] + ) + assert.equal(pair.anchorsTruncated, false) + + const limited = getParticipantSetInteractionFacts(db, [2, 3], { + maxAnchorsPerPair: 1, + maxProximityMessages: 0, + }).pairs[0] + assert.deepEqual( + limited.anchors.map((anchor) => anchor.messageId), + [messageCount] + ) + assert.equal(limited.anchorsTruncated, true) + }) + + it('reports anchor truncation only when eligible evidence was omitted', () => { + const insert = raw.prepare( + `INSERT INTO message + (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + insert.run(1, 2, 1704067200, 0, 'alice', 'alice-1', null) + insert.run(2, 3, 1704067260, 0, 'bob', 'bob-1', null) + + const complete = getParticipantSetInteractionFacts(db, [2, 3], { maxAnchorsPerPair: 1 }) + assert.equal(complete.pairs[0].anchors.length, 1) + assert.equal(complete.pairs[0].anchorsTruncated, false) + + const truncated = getParticipantSetInteractionFacts(db, [2, 3], { maxAnchorsPerPair: 0 }) + assert.equal(truncated.pairs[0].anchors.length, 0) + assert.equal(truncated.pairs[0].anchorsTruncated, true) + }) + it('filters interaction events by reply time while allowing an older referenced message', () => { const insert = raw.prepare( `INSERT INTO message diff --git a/packages/core/src/query/advanced/social.ts b/packages/core/src/query/advanced/social.ts index 2d83f8cd..beee41fe 100644 --- a/packages/core/src/query/advanced/social.ts +++ b/packages/core/src/query/advanced/social.ts @@ -309,6 +309,7 @@ export interface SelectedCoOccurrencePairStats extends CoOccurrencePairStats { } const DEFAULT_CLUSTER_OPTIONS = { lookAhead: 3, decaySeconds: 120, topEdges: 100 } +const DEFAULT_SELECTED_PROXIMITY_MAX_GAP_SECONDS = 1800 function roundNum(value: number, digits = 4): number { const factor = 10 ** digits @@ -371,11 +372,14 @@ export function accumulateCoOccurrencePairs( export function accumulateSelectedCoOccurrencePairs( messages: IdentifiedCoOccurrenceMessage[], selectedPairs: Array, - options?: ClusterGraphOptions & { maxAnchorsPerPair?: number } + options?: ClusterGraphOptions & { maxAnchorsPerPair?: number; maxGapSeconds?: number } ): SelectedCoOccurrencePairStats[] { const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options } + const lookAhead = Math.max(0, Math.ceil(opts.lookAhead)) const maxAnchorsPerPair = Math.max(0, Math.floor(options?.maxAnchorsPerPair ?? 2)) + const maxGapSeconds = Math.max(0, options?.maxGapSeconds ?? DEFAULT_SELECTED_PROXIMITY_MAX_GAP_SECONDS) const selectedKeys = new Set(selectedPairs.map(([left, right]) => clusterPairKey(left, right))) + const candidateIndexes = buildDistinctSpeakerLookAhead(messages, lookAhead) const stats = new Map< string, { @@ -390,18 +394,16 @@ export function accumulateSelectedCoOccurrencePairs( for (let i = 0; i < messages.length - 1; i++) { const anchor = messages[i] - const seenPartners = new Set() - let partnersFound = 0 - for (let j = i + 1; j < messages.length && partnersFound < opts.lookAhead; j++) { - const candidate = messages[j] - if (candidate.senderId === anchor.senderId || seenPartners.has(candidate.senderId)) continue - seenPartners.add(candidate.senderId) - partnersFound++ + for (let partnerIndex = 0; partnerIndex < lookAhead; partnerIndex++) { + const candidateIndex = candidateIndexes[i * lookAhead + partnerIndex] + if (candidateIndex < 0) break + const candidate = messages[candidateIndex] + const deltaSeconds = candidate.ts - anchor.ts + if (deltaSeconds > maxGapSeconds) break const key = clusterPairKey(anchor.senderId, candidate.senderId) if (!selectedKeys.has(key)) continue - const deltaSeconds = candidate.ts - anchor.ts const decayWeight = Math.exp(-deltaSeconds / opts.decaySeconds) - const positionWeight = 1 - (partnersFound - 1) * 0.2 + const positionWeight = 1 - partnerIndex * 0.2 const weight = decayWeight * positionWeight const sourceId = Math.min(anchor.senderId, candidate.senderId) const targetId = Math.max(anchor.senderId, candidate.senderId) @@ -444,6 +446,55 @@ export function accumulateSelectedCoOccurrencePairs( })) } +/** + * 为每条消息预计算其后最先出现的若干位不同发言人。 + * + * 反向扫描时,链表中只保留每位发言人在当前位置之后的首次出现,且按消息位置升序排列。 + * 因而每条消息只需读取链表头部的 lookAhead 个节点,避免双人长对话反复扫描到数组末尾。 + */ +function buildDistinctSpeakerLookAhead(messages: IdentifiedCoOccurrenceMessage[], lookAhead: number): Int32Array { + const candidateIndexes = new Int32Array(messages.length * lookAhead) + candidateIndexes.fill(-1) + if (lookAhead === 0 || messages.length === 0) return candidateIndexes + + const previousIndexes = new Int32Array(messages.length) + const nextIndexes = new Int32Array(messages.length) + previousIndexes.fill(-1) + nextIndexes.fill(-1) + const activeIndexBySender = new Map() + let headIndex = -1 + + for (let index = messages.length - 1; index >= 0; index--) { + const anchor = messages[index] + let candidateIndex = headIndex + let found = 0 + while (candidateIndex >= 0 && found < lookAhead) { + if (messages[candidateIndex].senderId !== anchor.senderId) { + candidateIndexes[index * lookAhead + found] = candidateIndex + found++ + } + candidateIndex = nextIndexes[candidateIndex] + } + + const previousOccurrence = activeIndexBySender.get(anchor.senderId) + if (previousOccurrence !== undefined) { + const previousIndex = previousIndexes[previousOccurrence] + const nextIndex = nextIndexes[previousOccurrence] + if (previousIndex >= 0) nextIndexes[previousIndex] = nextIndex + else headIndex = nextIndex + if (nextIndex >= 0) previousIndexes[nextIndex] = previousIndex + } + + previousIndexes[index] = -1 + nextIndexes[index] = headIndex + if (headIndex >= 0) previousIndexes[headIndex] = index + headIndex = index + activeIndexBySender.set(anchor.senderId, index) + } + + return candidateIndexes +} + function retainBestSelectedAnchor( anchors: SelectedCoOccurrenceAnchor[], candidate: SelectedCoOccurrenceAnchor, diff --git a/packages/core/src/query/contact-queries.ts b/packages/core/src/query/contact-queries.ts index 96a9eb2b..c5b4afdb 100644 --- a/packages/core/src/query/contact-queries.ts +++ b/packages/core/src/query/contact-queries.ts @@ -69,6 +69,7 @@ export interface ParticipantInteractionPairFacts { lastProximityTs: number | null coActiveDays: number anchors: ParticipantInteractionAnchor[] + anchorsTruncated: boolean } export interface ParticipantSetInteractionFacts { @@ -323,12 +324,56 @@ export function getParticipantSetInteractionFacts( activeDaysByMemberId.get(targetMemberId) ?? new Set() ), anchors: [], + anchorsTruncated: false, }) } } - const replyRows = db + // 回复历史可能远大于最终结果;计数留在 SQLite 聚合,避免把每条回复物化到 Node.js。 + // target.sender_id 过滤会诱导 SQLite 走 sender 索引;一元加号只排除该索引候选,让引用连接使用平台消息 ID。 + const replyStatsRows = db .prepare( + `SELECT + msg.sender_id as replySenderId, + target.sender_id as targetSenderId, + COUNT(*) as directReplyCount, + MAX(msg.ts) as lastDirectReplyTs + FROM message msg + JOIN message target ON msg.reply_to_message_id = target.platform_message_id + JOIN member sender ON msg.sender_id = sender.id + JOIN member targetMember ON target.sender_id = targetMember.id + WHERE msg.reply_to_message_id IS NOT NULL + AND ${nonSystemMessageCondition(db, 'msg', 'sender')} + AND ${nonSystemMessageCondition(db, 'target', 'targetMember')} + AND msg.sender_id IN (${placeholders}) + AND +target.sender_id IN (${placeholders}) + AND msg.sender_id <> target.sender_id${timeFilter.sql} + GROUP BY msg.sender_id, target.sender_id` + ) + .all(...memberIds, ...memberIds, ...timeFilter.params) as Array<{ + replySenderId: number + targetSenderId: number + directReplyCount: number + lastDirectReplyTs: number | null + }> + const maxAnchorsPerPair = Math.max(0, Math.floor(options.maxAnchorsPerPair ?? 4)) + const replyDirectionCountsByPair = new Map() + for (const row of replyStatsRows) { + const pair = pairMap.get(contactPairKey(row.replySenderId, row.targetSenderId)) + if (!pair) continue + pair.directReplyCount += row.directReplyCount + if (row.replySenderId === pair.sourceMemberId) pair.repliesFromSourceToTarget += row.directReplyCount + else pair.repliesFromTargetToSource += row.directReplyCount + if (row.lastDirectReplyTs !== null) { + pair.lastDirectReplyTs = Math.max(pair.lastDirectReplyTs ?? 0, row.lastDirectReplyTs) + } + const pairKey = contactPairKey(row.replySenderId, row.targetSenderId) + replyDirectionCountsByPair.set(pairKey, (replyDirectionCountsByPair.get(pairKey) ?? 0) + 1) + } + + // 每个有回复的方向只读取最近一条锚点,最多 5 人时查询次数仍固定在 20 次以内。 + if (maxAnchorsPerPair > 0 && replyStatsRows.length > 0) { + const replyAnchorStatement = db.prepare( `SELECT msg.id as messageId, target.id as relatedMessageId, @@ -342,39 +387,46 @@ export function getParticipantSetInteractionFacts( WHERE msg.reply_to_message_id IS NOT NULL AND ${nonSystemMessageCondition(db, 'msg', 'sender')} AND ${nonSystemMessageCondition(db, 'target', 'targetMember')} - AND msg.sender_id IN (${placeholders}) - AND target.sender_id IN (${placeholders})${timeFilter.sql} - ORDER BY msg.ts DESC, msg.id DESC` + AND msg.sender_id = ? + AND +target.sender_id = ?${timeFilter.sql} + ORDER BY msg.ts DESC, msg.id DESC + LIMIT 1` ) - .all(...memberIds, ...memberIds, ...timeFilter.params) as Array<{ - messageId: number - relatedMessageId: number - replySenderId: number - targetSenderId: number - replyTs: number - }> - const maxAnchorsPerPair = Math.max(0, Math.floor(options.maxAnchorsPerPair ?? 4)) - const replyDirectionsWithAnchor = new Set() - for (const row of replyRows) { - if (row.replySenderId === row.targetSenderId) continue - const pair = pairMap.get(contactPairKey(row.replySenderId, row.targetSenderId)) - if (!pair) continue - pair.directReplyCount++ - if (row.replySenderId === pair.sourceMemberId) pair.repliesFromSourceToTarget++ - else pair.repliesFromTargetToSource++ - pair.lastDirectReplyTs = Math.max(pair.lastDirectReplyTs ?? 0, row.replyTs) - const directionKey = `${row.replySenderId}:${row.targetSenderId}` - if (pair.anchors.length < maxAnchorsPerPair && !replyDirectionsWithAnchor.has(directionKey)) { - replyDirectionsWithAnchor.add(directionKey) - pair.anchors.push({ - messageId: row.messageId, - relatedMessageId: row.relatedMessageId, - timestamp: row.replyTs, + const anchorsByPair = new Map() + for (const row of replyStatsRows) { + const anchor = replyAnchorStatement.get(row.replySenderId, row.targetSenderId, ...timeFilter.params) as + | { + messageId: number + relatedMessageId: number + replySenderId: number + targetSenderId: number + replyTs: number + } + | undefined + if (!anchor) continue + const pairKey = contactPairKey(anchor.replySenderId, anchor.targetSenderId) + const pairAnchors = anchorsByPair.get(pairKey) ?? [] + pairAnchors.push({ + messageId: anchor.messageId, + relatedMessageId: anchor.relatedMessageId, + timestamp: anchor.replyTs, signal: 'direct_reply', - fromMemberId: row.replySenderId, - toMemberId: row.targetSenderId, + fromMemberId: anchor.replySenderId, + toMemberId: anchor.targetSenderId, }) + anchorsByPair.set(pairKey, pairAnchors) } + for (const [pairKey, anchors] of anchorsByPair) { + const pair = pairMap.get(pairKey) + if (!pair) continue + anchors.sort((left, right) => right.timestamp - left.timestamp || right.messageId - left.messageId) + pair.anchors.push(...anchors.slice(0, maxAnchorsPerPair)) + } + } + + for (const [pairKey, directionCount] of replyDirectionCountsByPair) { + const pair = pairMap.get(pairKey) + if (pair && directionCount > maxAnchorsPerPair) pair.anchorsTruncated = true } const maxProximityMessages = Math.max(0, Math.floor(options.maxProximityMessages ?? 200_000)) @@ -407,7 +459,8 @@ export function getParticipantSetInteractionFacts( const selectedPairFacts = accumulateSelectedCoOccurrencePairs( proximityRows, [...pairMap.values()].map((pair) => [pair.sourceMemberId, pair.targetMemberId] as const), - { maxAnchorsPerPair } + // 多保留一个候选,用于区分“命中上限”和“确实省略了证据”。 + { maxAnchorsPerPair: maxAnchorsPerPair + 1 } ) for (const selected of selectedPairFacts) { const pair = pairMap.get(contactPairKey(selected.sourceId, selected.targetId)) @@ -416,7 +469,10 @@ export function getParticipantSetInteractionFacts( pair.coOccurrenceRawScore = selected.rawScore pair.lastProximityTs = selected.lastOccurrenceTs for (const anchor of selected.anchors) { - if (pair.anchors.length >= maxAnchorsPerPair) break + if (pair.anchors.length >= maxAnchorsPerPair) { + pair.anchorsTruncated = true + break + } pair.anchors.push({ messageId: anchor.messageId, relatedMessageId: anchor.relatedMessageId, diff --git a/packages/node-runtime/src/services/contacts/compute.ts b/packages/node-runtime/src/services/contacts/compute.ts index c25a8178..5589c3e5 100644 --- a/packages/node-runtime/src/services/contacts/compute.ts +++ b/packages/node-runtime/src/services/contacts/compute.ts @@ -110,7 +110,7 @@ interface ContactsFactsCacheContext { export function computeContactsSnapshot(options: ComputeContactsSnapshotOptions): ContactsSnapshot { const startedAt = options.now?.() ?? Date.now() - const sessionIds = options.adapter.listSessionIds() + const sessionIds = options.adapter.listSessionCandidateIds?.() ?? options.adapter.listSessionIds() const factsCache = options.factsCacheDir ? createFactsCacheContext(options.factsCacheDir) : null const timeRange = resolveContactsTimeRange( options.timeRangePreset, diff --git a/packages/node-runtime/src/services/contacts/service.test.ts b/packages/node-runtime/src/services/contacts/service.test.ts index 4fd323b3..538b13da 100644 --- a/packages/node-runtime/src/services/contacts/service.test.ts +++ b/packages/node-runtime/src/services/contacts/service.test.ts @@ -69,6 +69,7 @@ class TestEnv { this.adapter = { listSessionIds: () => [...this.dbPaths.keys()], + listSessionCandidateIds: () => [...this.dbPaths.keys()], openReadonly: (id) => open(id, true), openWritable: (id) => open(id, false), closeSession: () => {}, @@ -163,6 +164,59 @@ class TestEnv { } } +test('contact detail and snapshot computation isolate damaged database candidates', async (t) => { + const env = new TestEnv() + const damagedDbPath = path.join(env.dir, 'damaged.db') + fs.writeFileSync(damagedDbPath, 'not a sqlite database') + + env.seed({ + id: 'private-a', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + ], + messages: privateMessages(5, 1, 1704103200), + }) + + const candidateIds = [...env.adapter.listSessionCandidateIds!(), 'damaged'] + const adapter: SessionRuntimeAdapter = { + ...env.adapter, + listSessionIds: () => { + throw new Error('eager database validation should not be used') + }, + listSessionCandidateIds: () => candidateIds, + getDbPath: (sessionId) => (sessionId === 'damaged' ? damagedDbPath : env.adapter.getDbPath(sessionId)), + openReadonly: (sessionId) => { + if (sessionId === 'damaged') throw new Error('damaged database') + return env.adapter.openReadonly(sessionId) + }, + } + const service = createContactsService({ + adapter, + systemDir: env.dir, + runner: () => new Promise(() => {}), + }) + t.after(async () => { + await service.close() + env.cleanup() + }) + + const detail = service.getContactDetail('weixin:alice', { acceptStale: true, timeRangePreset: 'all' }) + assert.equal(detail.contact, null) + assert.equal(detail.task?.status, 'running') + assert.equal(detail.task?.totalSessions, 2) + + const snapshot = computeContactsSnapshot({ adapter, signature: 'sig-1', timeRangePreset: 'all' }) + assert.equal( + snapshot.contacts.some((contact) => contact.key === 'weixin:alice'), + true + ) + assert.equal(snapshot.diagnostics.skippedFailedSessions, 1) +}) + function privateMessages(count: number, startId: number, startTs: number): SeedMessage[] { return Array.from({ length: count }, (_, index) => ({ id: startId + index, diff --git a/packages/node-runtime/src/services/contacts/service.ts b/packages/node-runtime/src/services/contacts/service.ts index d352b86f..7e0aa182 100644 --- a/packages/node-runtime/src/services/contacts/service.ts +++ b/packages/node-runtime/src/services/contacts/service.ts @@ -204,6 +204,7 @@ class DefaultContactsService implements ContactsService { private ensureTaskStarted(signature: string, timeRangePreset: ContactsTimeRangePreset): void { if (this.inFlight) return + const sessionIds = this.deps.adapter.listSessionCandidateIds?.() ?? this.deps.adapter.listSessionIds() const taskId = `contacts_${this.now()}_${Math.random().toString(36).slice(2)}` this.task = { id: taskId, @@ -211,7 +212,7 @@ class DefaultContactsService implements ContactsService { startedAt: this.now(), finishedAt: null, processedSessions: 0, - totalSessions: this.deps.adapter.listSessionIds().length, + totalSessions: sessionIds.length, timeRangePreset, } diff --git a/packages/node-runtime/src/services/contacts/signature.ts b/packages/node-runtime/src/services/contacts/signature.ts index 1f4a8d0b..1284f758 100644 --- a/packages/node-runtime/src/services/contacts/signature.ts +++ b/packages/node-runtime/src/services/contacts/signature.ts @@ -12,7 +12,8 @@ export function buildContactsSignature( `algorithm:${CONTACTS_ALGORITHM_VERSION}`, `range:${normalizeContactsTimeRangePreset(timeRangePreset)}`, ] - for (const sessionId of [...adapter.listSessionIds()].sort()) { + const sessionIds = adapter.listSessionCandidateIds?.() ?? adapter.listSessionIds() + for (const sessionId of [...sessionIds].sort()) { const dbPath = adapter.getDbPath(sessionId) parts.push(`${sessionId}:${getDbFileVersion(dbPath)}`) } 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 0f054ca6..1a41155c 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 @@ -771,6 +771,183 @@ test('shared interaction inspection finds two non-owner contacts and preserves r } }) +test('shared interaction inspection validates raw candidates within failure and budget boundaries', async () => { + const { env, contactsService } = createFixture() + const participants = [ + { type: 'contact' as const, contactKey: 'test:alice' }, + { type: 'contact' as const, contactKey: 'test:bob' }, + ] + 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, + }).inspectSharedInteractions({ participants }) + + assert.equal(eagerEnumerationCalled, false) + assert.deepEqual( + failureResult.sessions.map((session) => session.sessionId), + ['group-work'] + ) + assert.deepEqual(failureResult.coverage.failedSessionIds, ['damaged']) + + let identityNow = 0 + const identityOpenedSessions: string[] = [] + const slowContactsService: Pick = { + ...contactsService, + getContactDetail: (key, options) => { + const result = contactsService.getContactDetail(key, options) + identityNow = 9_000 + return result + }, + } + const identityTimedAdapter: SessionRuntimeAdapter = { + ...env.adapter, + openReadonly: (sessionId) => { + identityOpenedSessions.push(sessionId) + return env.adapter.openReadonly(sessionId) + }, + } + const identityTimedResult = await createCrossChatAnalysisService({ + adapter: identityTimedAdapter, + contactsService: slowContactsService, + now: () => identityNow, + }).inspectSharedInteractions({ participants, maxWallTimeMs: 8_000 }) + + assert.deepEqual(identityOpenedSessions, []) + assert.equal(identityTimedResult.coverage.scannedSessions, 0) + assert.ok(identityTimedResult.coverage.truncatedReasons.includes('time_budget')) + + let now = 0 + const openedSessions: string[] = [] + const timedAdapter: SessionRuntimeAdapter = { + ...env.adapter, + listSessionCandidateIds: () => { + now = 9_000 + return env.adapter.listSessionCandidateIds!() + }, + openReadonly: (sessionId) => { + openedSessions.push(sessionId) + return env.adapter.openReadonly(sessionId) + }, + } + const timedResult = await createCrossChatAnalysisService({ + adapter: timedAdapter, + contactsService, + now: () => now, + }).inspectSharedInteractions({ participants, maxWallTimeMs: 8_000 }) + + assert.deepEqual(openedSessions, []) + assert.equal(timedResult.coverage.scannedSessions, 0) + assert.ok(timedResult.coverage.truncatedReasons.includes('time_budget')) + assert.ok(timedResult.coverage.nextCursor) + + const controller = new AbortController() + const cancelledAdapter: SessionRuntimeAdapter = { + ...env.adapter, + listSessionCandidateIds: () => { + controller.abort() + return env.adapter.listSessionCandidateIds!() + }, + } + await assert.rejects( + () => + createCrossChatAnalysisService({ adapter: cancelledAdapter, contactsService }).inspectSharedInteractions( + { participants }, + { signal: controller.signal } + ), + { name: 'AbortError' } + ) + } finally { + env.cleanup() + } +}) + +test('shared interaction inspection excludes cross-window messages from proximity signals', async () => { + const { env, contactsService } = createFixture() + try { + env.seed({ + id: 'group-distant', + name: 'Distant group', + type: 'group', + members: [ + { id: 10, platformId: 'alice', name: 'Alice' }, + { id: 20, platformId: 'bob', name: 'Bob' }, + ], + messages: [ + { id: 1, senderId: 10, ts: 500, content: 'Alice' }, + { id: 2, senderId: 20, ts: 500 + 2 * 86400, content: 'Bob much later' }, + ], + }) + const result = await createCrossChatAnalysisService({ + adapter: env.adapter, + contactsService, + }).inspectSharedInteractions({ + participants: [ + { type: 'contact', contactKey: 'test:alice' }, + { type: 'contact', contactKey: 'test:bob' }, + ], + }) + + const distant = result.sessions.find((session) => session.sessionId === 'group-distant') + assert.ok(distant) + assert.equal(distant.pairs[0].coOccurrenceCount, 0) + assert.equal(distant.pairs[0].coOccurrenceRawScore, 0) + assert.equal(distant.pairs[0].lastProximityTs, null) + assert.equal( + distant.pairs[0].anchors.some((anchor) => anchor.signal === 'proximity'), + false + ) + assert.equal(distant.priorityReasons.includes('has_proximity'), false) + assert.equal(result.summary.sessionsWithProximitySignals, 1) + } finally { + env.cleanup() + } +}) + +test('shared interaction inspection resolves participants from 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.inspectSharedInteractions({ + participants: [ + { type: 'contact', contactKey: 'test:alice' }, + { type: 'contact', contactKey: 'test:bob' }, + ], + }) + + assert.deepEqual( + result.sessions.map((session) => session.sessionId), + ['group-work'] + ) + assert.deepEqual(detailPresets, ['all', 'all']) + } finally { + env.cleanup() + } +}) + test('shared interaction inspection resumes common sessions without skipping a page', 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 5360ef77..c3c3fd18 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/service.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/service.ts @@ -60,7 +60,7 @@ const CONTACT_SESSIONS_ALGORITHM_VERSION = 'contact-sessions-v1' const DEFAULT_INSPECTION_PAGE_SIZE = 50 const MAX_INSPECTION_PAGE_SIZE = 100 const SHARED_INTERACTIONS_ALGORITHM_VERSION = 'shared-interactions-v1' -const PROXIMITY_ALGORITHM_VERSION = 'lookahead-3-decay-120-v1' +const PROXIMITY_ALGORITHM_VERSION = 'lookahead-3-decay-120-gap-1800-v2' const DEFAULT_SHARED_INTERACTIONS_PAGE_SIZE = 20 const MAX_SHARED_INTERACTIONS_PAGE_SIZE = 50 const DEFAULT_MAX_ANCHORS_PER_PAIR = 4 @@ -434,6 +434,7 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { MAX_MAX_ANCHORS_PER_PAIR ) const maxWallTimeMs = clampInteger(request.maxWallTimeMs, DEFAULT_MAX_WALL_TIME_MS, 1, MAX_MAX_WALL_TIME_MS) + const startedAt = this.now() const resolvedParticipants = participantRefs.map((ref, index) => { if (ref.type === 'owner') { return { @@ -446,7 +447,10 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { contact: undefined, } } - const detail = this.deps.contactsService.getContactDetail(ref.contactKey, { acceptStale: true }) + const detail = this.deps.contactsService.getContactDetail(ref.contactKey, { + acceptStale: true, + timeRangePreset: 'all', + }) return { index, ref, @@ -465,6 +469,7 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { return emptySharedInteractionsResult(publicParticipants, unresolvedParticipantIndexes, range) } + throwIfAborted(options.signal) const sessionScopedIds = resolvedParticipants .flatMap((participant) => participant.ref.type === 'contact' && participant.contact?.sessionScoped && participant.contact.sessionId @@ -477,8 +482,9 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { ? [] : sessionScopedIds.length === 1 ? sessionScopedIds - : [...new Set(this.deps.adapter.listSessionIds())] + : [...new Set(this.deps.adapter.listSessionCandidateIds?.() ?? this.deps.adapter.listSessionIds())] ).sort((left, right) => left.localeCompare(right)) + throwIfAborted(options.signal) const cursorFingerprint = createInspectionFingerprint({ candidateSessionIds, participantRefs, @@ -487,7 +493,6 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { maxAnchorsPerPair, }) const cursor = parseInspectionCursor(request.cursor, cursorFingerprint, candidateSessionIds) - const startedAt = this.now() const sessions: CrossChatSharedInteractionsResult['sessions'] = [] const failedSessionIds: string[] = [] const truncatedReasons = new Set() @@ -1160,6 +1165,7 @@ function mapSharedInteractionSession( fromParticipantIndex: participantIndexByMemberId.get(anchor.fromMemberId) ?? 0, toParticipantIndex: participantIndexByMemberId.get(anchor.toMemberId) ?? 0, })), + anchorsTruncated: pair.anchorsTruncated, })) const priorityReasons: CrossChatSharedInteractionsResult['sessions'][number]['priorityReasons'] = [] if (pairs.some((pair) => pair.directReplyCount > 0)) priorityReasons.push('has_direct_reply') diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 114c6797..dec4b701 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -815,6 +815,7 @@ export interface CrossChatParticipantPairFacts { lastProximityTs: number | null coActiveDays: number anchors: CrossChatMessageAnchor[] + anchorsTruncated: boolean } export interface CrossChatSharedInteractionSessionItem extends CrossChatSessionDescriptor { diff --git a/packages/tools/src/definitions/cross-chat-tools.test.ts b/packages/tools/src/definitions/cross-chat-tools.test.ts index b36471d1..47cf7531 100644 --- a/packages/tools/src/definitions/cross-chat-tools.test.ts +++ b/packages/tools/src/definitions/cross-chat-tools.test.ts @@ -98,6 +98,73 @@ function contactSessionsResult(sessionCount: number, sessionName = 'Work group') } } +function sharedInteractionsResult(anchorsTruncated: boolean): CrossChatSharedInteractionsResult { + return { + algorithmVersion: 'test', + proximityAlgorithmVersion: 'test', + participants: [], + appliedRange: { + startTs: null, + endTs: null, + dataEarliestMessageTs: 100, + dataLatestMessageTs: 200, + }, + summary: { + scope: 'complete_result', + commonSessions: 1, + commonPrivateSessions: 0, + commonGroupSessions: 1, + sessionsWithDirectReplies: 0, + sessionsWithProximitySignals: 1, + }, + sessions: [ + { + sessionId: 'group-shared', + sessionName: 'Shared group', + sessionType: ChatType.GROUP, + platform: 'test', + lastMessageTs: 200, + memberCount: 5, + participants: [], + overlapRange: { startTs: 100, endTs: 200 }, + allParticipantsCoActiveDays: 1, + pairs: [ + { + sourceParticipantIndex: 0, + targetParticipantIndex: 1, + directReplyCount: 0, + repliesFromSourceToTarget: 0, + repliesFromTargetToSource: 0, + lastDirectReplyTs: null, + coOccurrenceCount: 1, + coOccurrenceRawScore: 1, + lastProximityTs: 200, + coActiveDays: 1, + anchors: [], + anchorsTruncated, + }, + ], + priorityReasons: ['has_proximity'], + proximityStatus: 'complete', + }, + ], + coverage: { + candidateSessions: 1, + scannedSessions: 1, + matchedSessions: 1, + returnedSessions: 1, + failedSessions: 0, + failedSessionIds: [], + complete: true, + nextCursor: null, + truncated: false, + truncatedReasons: [], + unresolvedParticipantIndexes: [], + identityCollisionSessions: 0, + }, + } +} + function createContext(overrides: Partial = {}): CrossChatToolExecutionContext { const service: CrossChatAnalysisToolService = { lookupContact: (query: string): CrossChatContactLookupResult => ({ @@ -308,6 +375,41 @@ describe('cross-chat agent registry', () => { ) }) + it('clamps shared anchor limits before budget estimation', async () => { + const requests: Array> = [] + const context = createContext({ + inspectSharedInteractions: async (request) => { + requests.push(request as unknown as Record) + return createContext().analysisService.inspectSharedInteractions({ participants: [] }) + }, + }) + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'inspect_shared_interactions') + assert.ok(tool) + assert.equal(tool.inputSchema.properties.max_anchors_per_pair?.minimum, 0) + assert.equal(tool.inputSchema.properties.max_anchors_per_pair?.maximum, 8) + + await tool.handler( + { + participants: [{ type: 'owner' }, { type: 'contact', contact_key: 'test:alice' }], + max_anchors_per_pair: 1e100, + }, + context + ) + context.maxToolResultTokens = 1_000_000 + await tool.handler( + { + participants: [{ type: 'owner' }, { type: 'contact', contact_key: 'test:alice' }], + max_anchors_per_pair: 9, + }, + context + ) + + assert.deepEqual( + requests.map((request) => request.maxAnchorsPerPair), + [8, 8] + ) + }) + it('requires a stable contact key and forwards contact-session inspection options', async () => { let captured: Record | undefined const context = createContext({ @@ -372,7 +474,7 @@ describe('cross-chat agent registry', () => { assert.ok(tokenCountCalls > 0) }) - it('reduces shared-interaction pages to the tool token budget without hiding truncation', async () => { + it('does not report shared-interaction truncation when a reduced budget still returns all data', async () => { let sharedRequest: Record | undefined const sharedContext = createContext({ inspectSharedInteractions: async (request) => { @@ -400,7 +502,111 @@ describe('cross-chat agent registry', () => { ) assert.equal(sharedRequest?.pageSize, 1) assert.equal(sharedRequest?.maxAnchorsPerPair, 2) - assert.deepEqual((sharedResult.data as CrossChatSharedInteractionsResult).coverage.truncatedReasons, [ + assert.equal((sharedResult.data as CrossChatSharedInteractionsResult).coverage.truncated, false) + assert.deepEqual((sharedResult.data as CrossChatSharedInteractionsResult).coverage.truncatedReasons, []) + }) + + it('keeps shared interactions within the model token budget without dropping full tool details', async () => { + const longSessionName = '超长共同群聊名称'.repeat(500) + let tokenCountCalls = 0 + const countTokens = (text: string) => { + tokenCountCalls++ + return Math.ceil(Array.from(text).length / 4) + } + const sharedContext = createContext({ + inspectSharedInteractions: async () => { + const result = sharedInteractionsResult(false) + result.sessions[0].sessionName = longSessionName + return result + }, + }) + sharedContext.maxToolResultTokens = 1_000 + sharedContext.countTokens = countTokens + const sharedTool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'inspect_shared_interactions') + assert.ok(sharedTool) + + const result = await sharedTool.handler( + { + participants: [{ type: 'owner' }, { type: 'contact', contact_key: 'test:a' }], + }, + sharedContext + ) + const data = result.data as CrossChatSharedInteractionsResult + const modelData = JSON.parse(result.content) as { sessions?: Array<{ sessionName?: string }> } + + 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) <= sharedContext.maxToolResultTokens) + assert.ok(tokenCountCalls > 0) + }) + + it('reports the tool budget when reduced anchor capacity actually omits evidence', async () => { + const sharedContext = createContext({ + inspectSharedInteractions: async () => sharedInteractionsResult(true), + }) + sharedContext.maxToolResultTokens = 2_500 + const sharedTool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'inspect_shared_interactions') + assert.ok(sharedTool) + + const result = await sharedTool.handler( + { + participants: [ + { type: 'owner' }, + { type: 'contact', contact_key: 'test:a' }, + { type: 'contact', contact_key: 'test:b' }, + { type: 'contact', contact_key: 'test:c' }, + { type: 'contact', contact_key: 'test:d' }, + ], + page_size: 20, + max_anchors_per_pair: 4, + }, + sharedContext + ) + + assert.deepEqual((result.data as CrossChatSharedInteractionsResult).coverage.truncatedReasons, [ + 'tool_result_budget', + ]) + }) + + it('reports the tool budget when a reduced shared-interaction page leaves continuation', async () => { + const sharedContext = createContext({ + inspectSharedInteractions: async () => { + const result = sharedInteractionsResult(false) + return { + ...result, + summary: { ...result.summary, scope: 'current_batch' }, + coverage: { + ...result.coverage, + complete: false, + nextCursor: 'next-page', + truncated: true, + truncatedReasons: ['page_size'], + }, + } + }, + }) + sharedContext.maxToolResultTokens = 2_500 + const sharedTool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'inspect_shared_interactions') + assert.ok(sharedTool) + + const result = await sharedTool.handler( + { + participants: [ + { type: 'owner' }, + { type: 'contact', contact_key: 'test:a' }, + { type: 'contact', contact_key: 'test:b' }, + { type: 'contact', contact_key: 'test:c' }, + { type: 'contact', contact_key: 'test:d' }, + ], + page_size: 20, + max_anchors_per_pair: 4, + }, + sharedContext + ) + + assert.deepEqual((result.data as CrossChatSharedInteractionsResult).coverage.truncatedReasons, [ + 'page_size', 'tool_result_budget', ]) }) diff --git a/packages/tools/src/definitions/cross-chat-tools.ts b/packages/tools/src/definitions/cross-chat-tools.ts index ada11fe4..0b66188a 100644 --- a/packages/tools/src/definitions/cross-chat-tools.ts +++ b/packages/tools/src/definitions/cross-chat-tools.ts @@ -126,6 +126,8 @@ const inspectContactSessionsSchema: JsonSchema = { required: ['contact_key'], } +const MAX_SHARED_ANCHORS_PER_PAIR = 8 + const inspectSharedInteractionsSchema: JsonSchema = { type: 'object', properties: { @@ -147,6 +149,8 @@ const inspectSharedInteractionsSchema: JsonSchema = { max_anchors_per_pair: { type: 'number', description: 'Maximum direct-reply and proximity message anchors returned for each participant pair', + minimum: 0, + maximum: MAX_SHARED_ANCHORS_PER_PAIR, }, max_wall_time_ms: { type: 'number', description: 'Maximum wall time for this inspection batch' }, ...timeParamProperties, @@ -156,7 +160,7 @@ const inspectSharedInteractionsSchema: JsonSchema = { const DEFAULT_CONTACT_INSPECTION_PAGE_SIZE = 50 const MAX_CONTACT_INSPECTION_PAGE_SIZE = 100 -const CONTACT_MODEL_LABEL_MAX_LENGTH = 80 +const MODEL_LABEL_MAX_LENGTH = 80 const DEFAULT_SHARED_INSPECTION_PAGE_SIZE = 20 const DEFAULT_SHARED_ANCHORS_PER_PAIR = 4 const TOOL_RESULT_CHARS_PER_TOKEN = 4 @@ -363,7 +367,7 @@ async function inspectSharedInteractionsHandler( const participants = parseParticipants(params.participants) const requestedPageSize = parseOptionalNumber(params.page_size) const requestedAnchorsPerPair = parseOptionalNumber(params.max_anchors_per_pair) - const budget = limitSharedResultToBudget( + const budget = limitSharedRequestToBudget( participants.length, requestedPageSize, requestedAnchorsPerPair, @@ -389,8 +393,19 @@ async function inspectSharedInteractionsHandler( }), } ) - const data: CrossChatSharedInteractionsResult = budget.limited ? withToolResultBudgetReason(result) : result - return { content: JSON.stringify(data), data } + const pageBudgetTruncated = + budget.pageLimited && result.coverage.nextCursor !== null && result.coverage.truncatedReasons.includes('page_size') + const anchorBudgetTruncated = + budget.anchorsLimited && result.sessions.some((session) => session.pairs.some((pair) => pair.anchorsTruncated)) + const countTokens = context.countTokens ?? estimatePayloadTokens + const payloadExceedsBudget = + !!context.maxToolResultTokens && + context.maxToolResultTokens > 0 && + countTokens(JSON.stringify(result)) > context.maxToolResultTokens + const data: CrossChatSharedInteractionsResult = + pageBudgetTruncated || anchorBudgetTruncated || payloadExceedsBudget ? withToolResultBudgetReason(result) : result + const modelData = limitSharedModelResultToBudget(data, context.maxToolResultTokens, countTokens) + return { content: JSON.stringify(modelData), data } } export const resolveChatEntitiesTool: ToolDefinition = { @@ -530,23 +545,33 @@ function limitContactPageSizeToBudget( } } -function limitSharedResultToBudget( +function limitSharedRequestToBudget( participantCount: number, requestedPageSize: number | undefined, requestedAnchorsPerPair: number | undefined, maxToolResultTokens: number | undefined -): { pageSize: number | undefined; maxAnchorsPerPair: number | undefined; limited: boolean } { +): { + pageSize: number | undefined + maxAnchorsPerPair: number | undefined + pageLimited: boolean + anchorsLimited: boolean +} { + const normalizedAnchorsPerPair = + requestedAnchorsPerPair === undefined + ? undefined + : Math.min(MAX_SHARED_ANCHORS_PER_PAIR, Math.max(0, Math.floor(requestedAnchorsPerPair))) if (!maxToolResultTokens || maxToolResultTokens <= 0) { return { pageSize: requestedPageSize, - maxAnchorsPerPair: requestedAnchorsPerPair, - limited: false, + maxAnchorsPerPair: normalizedAnchorsPerPair, + pageLimited: false, + anchorsLimited: false, } } const pairCount = (participantCount * (participantCount - 1)) / 2 const requestedPage = requestedPageSize ?? DEFAULT_SHARED_INSPECTION_PAGE_SIZE - const requestedAnchors = requestedAnchorsPerPair ?? DEFAULT_SHARED_ANCHORS_PER_PAIR + const requestedAnchors = normalizedAnchorsPerPair ?? DEFAULT_SHARED_ANCHORS_PER_PAIR const maxChars = maxToolResultTokens * TOOL_RESULT_CHARS_PER_TOKEN let maxAnchorsPerPair = requestedAnchors @@ -565,7 +590,8 @@ function limitSharedResultToBudget( return { pageSize, maxAnchorsPerPair, - limited: pageSize < requestedPage || maxAnchorsPerPair < requestedAnchors, + pageLimited: pageSize < requestedPage, + anchorsLimited: maxAnchorsPerPair < requestedAnchors, } } @@ -649,11 +675,94 @@ function limitContactResultToBudget( ) } +function limitSharedModelResultToBudget( + result: CrossChatSharedInteractionsResult, + maxToolResultTokens: number | undefined, + countTokens: (text: string) => number +): unknown { + if (!maxToolResultTokens || maxToolResultTokens <= 0) return result + if (countTokens(JSON.stringify(result)) <= maxToolResultTokens) return result + + // details 保留完整调查结果;模型可见文本逐级压缩名称和锚点,并优先保留已被 cursor 消费的 session 身份。 + const compactLabels = { + ...result, + participants: result.participants.map((participant) => ({ + ...participant, + displayName: truncateModelLabel(participant.displayName), + })), + sessions: result.sessions.map((session) => ({ + ...session, + sessionName: truncateModelLabel(session.sessionName), + participants: session.participants.map((participant) => ({ + ...participant, + memberName: truncateModelLabel(participant.memberName), + })), + })), + } + const withAnchorLimit = (maxAnchorsPerPair: number) => ({ + ...compactLabels, + sessions: compactLabels.sessions.map((session) => ({ + ...session, + pairs: session.pairs.map((pair) => ({ + ...pair, + anchors: pair.anchors.slice(0, maxAnchorsPerPair).map(({ sessionId: _sessionId, ...anchor }) => anchor), + anchorsTruncated: pair.anchorsTruncated || pair.anchors.length > maxAnchorsPerPair, + })), + })), + }) + const identityFacts = { + algorithmVersion: result.algorithmVersion, + proximityAlgorithmVersion: result.proximityAlgorithmVersion, + participants: compactLabels.participants.map((participant) => ({ + index: participant.index, + ref: participant.ref, + status: participant.status, + displayName: participant.displayName, + platform: participant.platform, + })), + appliedRange: result.appliedRange, + summary: result.summary, + sessions: compactLabels.sessions.map((session) => ({ + sessionId: session.sessionId, + sessionName: session.sessionName, + sessionType: session.sessionType, + platform: session.platform, + lastMessageTs: session.lastMessageTs, + memberCount: session.memberCount, + priorityReasons: session.priorityReasons, + proximityStatus: session.proximityStatus, + })), + coverage: result.coverage, + } + const coverageOnly = { + summary: result.summary, + coverage: result.coverage, + } + const continuationOnly = { + coverage: { + complete: result.coverage.complete, + nextCursor: result.coverage.nextCursor, + truncated: true, + truncatedReasons: result.coverage.truncatedReasons, + }, + } + const candidates: unknown[] = [ + compactLabels, + withAnchorLimit(2), + withAnchorLimit(1), + withAnchorLimit(0), + identityFacts, + coverageOnly, + continuationOnly, + { truncated: true, truncatedReasons: ['tool_result_budget'] }, + {}, + ] + return candidates.find((candidate) => countTokens(JSON.stringify(candidate)) <= maxToolResultTokens) ?? {} +} + 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 + return chars.length > MODEL_LABEL_MAX_LENGTH ? `${chars.slice(0, MODEL_LABEL_MAX_LENGTH).join('')}…` : value } async function preprocessBySession(