mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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.
This commit is contained in:
1 parent
f26c6f6eba
commit
10e0825578
4 files changed
+323
-6
No files matched your search
@@ -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<string | undefined> = []
|
||||
try {
|
||||
const contactsService: Pick<ContactsService, 'getContactDetail' | 'getContactsPage'> = {
|
||||
...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 {
|
||||
|
||||
@@ -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<CrossChatContactSessionsResult['coverage']['truncatedReasons'][number]>()
|
||||
|
||||
@@ -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<CrossChatAnalysisToolService> = {}): 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({
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
context: CrossChatToolExecutionContext
|
||||
@@ -279,6 +286,8 @@ async function inspectContactSessionsHandler(
|
||||
context: CrossChatToolExecutionContext
|
||||
): Promise<ToolResult> {
|
||||
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<CrossChatToolExecutionContext> = {
|
||||
@@ -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[]
|
||||
|
||||
Reference in new issue
Block a user