fix(ai): budget cross-chat entity results

Keep full entity resolution details available to the application while bounding the model-visible payload with the runtime tokenizer. Preserve coverage and truncation metadata, prioritize usable scopes fairly, and cap ambiguity hints to avoid duplicate unbounded source lists.
This commit is contained in:
digua committed 2026-08-22 17:09:23 +08:00
1 parent dc3bd82c90
commit fbcc0f299f
2 files changed
+297 -2

No files matched your search

@@ -199,7 +199,11 @@ describe('cross-chat agent registry', () => {
displayName: '小红',
platform: 'test',
aliases: ['小红 A'],
sourceSessions: [{ id: 'private-1', name: '小红 A', type: ChatType.PRIVATE }],
sourceSessions: Array.from({ length: 5 }, (_, index) => ({
id: `group-${index}`,
name: `小红所在群 ${index}`,
type: ChatType.GROUP,
})),
},
{
contactKey: 'test:xiaohong-2',
@@ -226,12 +230,111 @@ describe('cross-chat agent registry', () => {
}
},
} as Partial<CrossChatAnalysisToolService>)
context.maxToolResultTokens = 2_000
context.countTokens = (text) => Math.ceil(text.length / 4)
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'resolve_chat_entities')
assert.ok(tool)
const result = await tool.handler({ entities: [{ type: 'contact', displayName: '小红' }] }, context)
assert.deepEqual(resolvedRefs, [])
assert.equal((result.data as { contactLookups: Array<{ status: string }> }).contactLookups[0]?.status, 'ambiguous')
const modelData = JSON.parse(result.content) as {
contactLookups: Array<{
candidates: Array<{
sourceSessionCount: number
sourceSessionHints: unknown[]
sourceSessionHintsTruncated: boolean
}>
}>
}
assert.equal(modelData.contactLookups[0]?.candidates[0]?.sourceSessionCount, 5)
assert.equal(modelData.contactLookups[0]?.candidates[0]?.sourceSessionHints.length, 3)
assert.equal(modelData.contactLookups[0]?.candidates[0]?.sourceSessionHintsTruncated, true)
})
it('budgets model-visible entity scopes without dropping full tool details', async () => {
const resolvedSessions = Array.from({ length: 100 }, (_, index) => ({
sessionId: `group-${index}`,
sessionName: `工作交流群 ${index}`,
sessionType: ChatType.GROUP,
platform: 'test' as const,
lastMessageTs: 1000 - index,
memberId: index + 1,
memberPlatformId: 'xiaohong',
memberName: '小红',
}))
const sourceSessions = resolvedSessions.map((session) => ({
id: session.sessionId,
name: session.sessionName,
type: session.sessionType,
}))
const context = createContext({
lookupContact: () => ({
query: '小红',
status: 'resolved',
cacheStatus: 'fresh',
totalCandidates: 1,
candidates: [
{
contactKey: 'test:xiaohong',
displayName: '小红',
platform: 'test',
aliases: [],
sourceSessions,
},
],
}),
resolveEntities: async () => ({
contacts: [
{
ref: { type: 'contact', contactKey: 'test:xiaohong', displayName: '小红' },
status: 'resolved',
cacheStatus: 'fresh',
sessions: resolvedSessions,
unresolvedSessionIds: [],
failedSessionIds: [],
},
],
sessions: [],
unresolved: [],
coverage: {
requestedEntities: 1,
resolvedEntities: 1,
candidateSessions: resolvedSessions.length,
resolvedSessions: resolvedSessions.length,
failedSessions: 0,
},
}),
})
const countTokens = (text: string) => Math.ceil(text.length / 4)
context.maxToolResultTokens = 800
context.countTokens = countTokens
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'resolve_chat_entities')
assert.ok(tool)
const result = await tool.handler({ entities: [{ type: 'contact', displayName: '小红' }] }, context)
const modelData = JSON.parse(result.content) as {
contacts: Array<{ sessionCount: number; returnedSessions: number; sessions: unknown[] }>
coverage: { returnedSourceScopes: number; truncated: boolean; truncatedReasons: string[] }
contactLookups: Array<{
candidates: Array<{ sourceSessionCount: number; sourceSessions?: unknown[] }>
}>
}
const details = result.data as {
contacts: Array<{ sessions: unknown[] }>
contactLookups: Array<{ candidates: Array<{ sourceSessions: unknown[] }> }>
}
assert.ok(countTokens(result.content) <= 800)
assert.equal(modelData.coverage.truncated, true)
assert.ok(modelData.coverage.truncatedReasons.includes('tool_result_budget'))
assert.ok(modelData.coverage.returnedSourceScopes < resolvedSessions.length)
assert.equal(modelData.contacts[0]?.sessionCount, resolvedSessions.length)
assert.equal(modelData.contacts[0]?.returnedSessions, modelData.contacts[0]?.sessions.length)
assert.equal(modelData.contactLookups[0]?.candidates[0]?.sourceSessionCount, sourceSessions.length)
assert.equal(modelData.contactLookups[0]?.candidates[0]?.sourceSessions, undefined)
assert.equal(details.contacts[0]?.sessions.length, resolvedSessions.length)
assert.equal(details.contactLookups[0]?.candidates[0]?.sourceSessions.length, sourceSessions.length)
})
it('sanitizes each session before returning global search evidence', async () => {
@@ -1,8 +1,11 @@
import type {
AIEntityRef,
CrossChatContactLookupResult,
CrossChatEntityResolution,
CrossChatEvidencePayload,
CrossChatMessageSource,
CrossChatResolvedContactSession,
CrossChatResolvedSession,
CrossChatSearchScope,
} from '@openchatlab/shared-types'
import type { CrossChatToolExecutionContext, JsonSchema, ToolDefinition, ToolResult } from '../types'
@@ -126,7 +129,13 @@ async function resolveHandler(
signal: context.abortSignal,
})
const data = { ...resolution, contactLookups }
return { content: JSON.stringify(data), data }
const modelData = limitEntityResolutionToBudget(
resolution,
contactLookups,
context.maxToolResultTokens,
context.countTokens
)
return { content: JSON.stringify(modelData), data }
}
async function searchHandler(
@@ -395,6 +404,189 @@ function limitMessagesToBudget(
return { messages: limited, truncated: limited.length < prepared.length }
}
type EntityResolutionScopeItem =
| { kind: 'contact'; contactIndex: number; session: CrossChatResolvedContactSession }
| { kind: 'session'; sessionIndex: number; session: CrossChatResolvedSession }
function limitEntityResolutionToBudget(
resolution: CrossChatEntityResolution,
contactLookups: CrossChatContactLookupResult[],
maxToolResultTokens: number | undefined,
injectedCountTokens?: (text: string) => number
): unknown {
const fullData = { ...resolution, contactLookups }
if (!maxToolResultTokens || maxToolResultTokens <= 0) return fullData
const countTokens = injectedCountTokens ?? estimatePayloadTokens
const scopeItems = buildEntityResolutionScopePriority(resolution)
let includeLookupHints = true
let emptyPayload = buildEntityResolutionPayload(resolution, contactLookups, scopeItems, 0, includeLookupHints)
// 先舍弃只用于姓名消歧的来源提示,把预算优先留给后续工具真正需要的精确 scope。
if (countTokens(JSON.stringify(emptyPayload)) > maxToolResultTokens) {
includeLookupHints = false
emptyPayload = buildEntityResolutionPayload(resolution, contactLookups, scopeItems, 0, includeLookupHints)
}
if (countTokens(JSON.stringify(emptyPayload)) > maxToolResultTokens) {
return buildMinimalEntityResolutionPayload(resolution, contactLookups, maxToolResultTokens, countTokens)
}
let low = 0
let high = scopeItems.length
// 最终 JSON 每次都用平台 tokenizer 复核;二分前缀避免大量来源时反复编码完整增长数组。
while (low < high) {
const middle = Math.ceil((low + high) / 2)
const candidate = buildEntityResolutionPayload(resolution, contactLookups, scopeItems, middle, includeLookupHints)
if (countTokens(JSON.stringify(candidate)) <= maxToolResultTokens) low = middle
else high = middle - 1
}
return buildEntityResolutionPayload(resolution, contactLookups, scopeItems, low, includeLookupHints)
}
function buildEntityResolutionScopePriority(resolution: CrossChatEntityResolution): EntityResolutionScopeItem[] {
const items: EntityResolutionScopeItem[] = []
// 用户显式选择的会话优先;联系人来源按最近时间轮询,避免第一个联系人耗尽全部预算。
for (const [sessionIndex, session] of resolution.sessions.entries()) {
if (session.status === 'resolved' && session.session) items.push({ kind: 'session', sessionIndex, session })
}
const contactSessions = resolution.contacts.map((contact) =>
[...contact.sessions].sort(
(left, right) =>
(right.lastMessageTs ?? Number.NEGATIVE_INFINITY) - (left.lastMessageTs ?? Number.NEGATIVE_INFINITY)
)
)
for (let depth = 0; ; depth++) {
let added = false
for (const [contactIndex, sessions] of contactSessions.entries()) {
const session = sessions[depth]
if (!session) continue
items.push({ kind: 'contact', contactIndex, session })
added = true
}
if (!added) break
}
return items
}
function buildEntityResolutionPayload(
resolution: CrossChatEntityResolution,
contactLookups: CrossChatContactLookupResult[],
scopeItems: EntityResolutionScopeItem[],
selectedScopeCount: number,
includeLookupHints: boolean
): Record<string, unknown> {
const selectedContactSessions = new Map<number, CrossChatResolvedContactSession[]>()
const selectedSessionIndexes = new Set<number>()
for (const item of scopeItems.slice(0, selectedScopeCount)) {
if (item.kind === 'session') {
selectedSessionIndexes.add(item.sessionIndex)
continue
}
const sessions = selectedContactSessions.get(item.contactIndex) ?? []
sessions.push(item.session)
selectedContactSessions.set(item.contactIndex, sessions)
}
const truncated = selectedScopeCount < scopeItems.length
const contacts = resolution.contacts.map((contact, index) => {
const sessions = selectedContactSessions.get(index) ?? []
return {
ref: contact.ref,
status: contact.status,
cacheStatus: contact.cacheStatus,
sessionCount: contact.sessions.length,
returnedSessions: sessions.length,
sessions,
unresolvedSessionCount: contact.unresolvedSessionIds.length,
failedSessionCount: contact.failedSessionIds.length,
}
})
const sessions = resolution.sessions.filter(
(session, index) => session.status === 'unresolved' || selectedSessionIndexes.has(index)
)
return {
contacts,
sessions,
unresolved: resolution.unresolved,
coverage: {
...resolution.coverage,
returnedContactEntities: contacts.length,
returnedSessionEntities: sessions.length,
returnedSourceScopes: selectedScopeCount,
truncated,
truncatedReasons: truncated ? ['tool_result_budget'] : [],
},
contactLookups: buildModelContactLookups(contactLookups, includeLookupHints),
}
}
function buildModelContactLookups(
contactLookups: CrossChatContactLookupResult[],
includeHints: boolean
): Array<Record<string, unknown>> {
return contactLookups.map((lookup) => ({
query: lookup.query,
status: lookup.status,
cacheStatus: lookup.cacheStatus,
totalCandidates: lookup.totalCandidates,
candidates: lookup.candidates.map((candidate) => {
const hints = includeHints && lookup.status === 'ambiguous' ? candidate.sourceSessions.slice(0, 3) : []
return {
contactKey: candidate.contactKey,
displayName: candidate.displayName,
platform: candidate.platform,
aliases: candidate.aliases.slice(0, 8),
aliasCount: candidate.aliases.length,
sourceSessionCount: candidate.sourceSessions.length,
sourceSessionHintsReturned: hints.length,
sourceSessionHintsTruncated: lookup.status === 'ambiguous' && hints.length < candidate.sourceSessions.length,
...(hints.length > 0
? {
sourceSessionHints: hints,
}
: {}),
}
}),
}))
}
function buildMinimalEntityResolutionPayload(
resolution: CrossChatEntityResolution,
contactLookups: CrossChatContactLookupResult[],
maxToolResultTokens: number,
countTokens: (text: string) => number
): Record<string, unknown> {
const coverage = {
...resolution.coverage,
returnedContactEntities: 0,
returnedSessionEntities: 0,
returnedSourceScopes: 0,
truncated: true,
truncatedReasons: ['tool_result_budget'],
}
const candidates: Array<Record<string, unknown>> = [
{
contacts: [],
sessions: [],
unresolved: [],
coverage,
contactLookups: contactLookups.map((lookup) => ({
query: lookup.query,
status: lookup.status,
totalCandidates: lookup.totalCandidates,
})),
},
{ coverage },
{ truncated: true, truncatedReasons: ['tool_result_budget'] },
]
return (
candidates.find((candidate) => countTokens(JSON.stringify(candidate)) <= maxToolResultTokens) ?? {
truncated: true,
}
)
}
function estimatePayloadTokens(text: string): number {
// Browser/MCP 等未注入 Node tokenizer 的调用方仍需避免按 ASCII 密度低估中文结果。
let cjkChars = 0