mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user