mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(ai): enforce cross-chat execution budgets
Count complete tool payloads with the runtime tokenizer so Chinese evidence respects the configured model context budget.\n\nResolve contact sources incrementally with cancellation checks and targeted member lookups to keep long cross-chat investigations responsive.
This commit is contained in:
1 parent
5d5355d4f1
commit
dc3bd82c90
11 files changed
+162
-21
No files matched your search
@@ -4,7 +4,7 @@ import type {
|
|||||||
PreprocessConfig,
|
PreprocessConfig,
|
||||||
SessionRuntimeAdapter,
|
SessionRuntimeAdapter,
|
||||||
} from '@openchatlab/node-runtime'
|
} from '@openchatlab/node-runtime'
|
||||||
import { preprocessCrossChatMessages } from '@openchatlab/node-runtime'
|
import { countTokens, preprocessCrossChatMessages } from '@openchatlab/node-runtime'
|
||||||
import {
|
import {
|
||||||
CROSS_CHAT_AGENT_TOOL_REGISTRY,
|
CROSS_CHAT_AGENT_TOOL_REGISTRY,
|
||||||
executeToolForAgent,
|
executeToolForAgent,
|
||||||
@@ -23,6 +23,7 @@ export function createCliCrossChatTools(options: {
|
|||||||
locale: options.locale,
|
locale: options.locale,
|
||||||
analysisService: options.analysisService,
|
analysisService: options.analysisService,
|
||||||
maxToolResultTokens: options.maxToolResultTokens,
|
maxToolResultTokens: options.maxToolResultTokens,
|
||||||
|
countTokens,
|
||||||
preprocessMessagesBySession: (sessionId, messages) =>
|
preprocessMessagesBySession: (sessionId, messages) =>
|
||||||
preprocessCrossChatMessages(
|
preprocessCrossChatMessages(
|
||||||
options.sessionAdapter,
|
options.sessionAdapter,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
PreprocessConfig,
|
PreprocessConfig,
|
||||||
SessionRuntimeAdapter,
|
SessionRuntimeAdapter,
|
||||||
} from '@openchatlab/node-runtime'
|
} from '@openchatlab/node-runtime'
|
||||||
import { preprocessCrossChatMessages } from '@openchatlab/node-runtime'
|
import { countTokens, preprocessCrossChatMessages } from '@openchatlab/node-runtime'
|
||||||
import {
|
import {
|
||||||
CROSS_CHAT_AGENT_TOOL_REGISTRY,
|
CROSS_CHAT_AGENT_TOOL_REGISTRY,
|
||||||
executeToolForAgent,
|
executeToolForAgent,
|
||||||
@@ -23,6 +23,7 @@ export function createElectronCrossChatTools(options: {
|
|||||||
locale: options.locale,
|
locale: options.locale,
|
||||||
analysisService: options.analysisService,
|
analysisService: options.analysisService,
|
||||||
maxToolResultTokens: options.maxToolResultTokens,
|
maxToolResultTokens: options.maxToolResultTokens,
|
||||||
|
countTokens,
|
||||||
preprocessMessagesBySession: (sessionId, messages) =>
|
preprocessMessagesBySession: (sessionId, messages) =>
|
||||||
preprocessCrossChatMessages(
|
preprocessCrossChatMessages(
|
||||||
options.sessionAdapter,
|
options.sessionAdapter,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export {
|
|||||||
getNonSystemMembersForContacts,
|
getNonSystemMembersForContacts,
|
||||||
getPrivateContactFacts,
|
getPrivateContactFacts,
|
||||||
isValidContactPlatformId,
|
isValidContactPlatformId,
|
||||||
|
resolveContactMember,
|
||||||
resolveOwnerMember,
|
resolveOwnerMember,
|
||||||
buildContactKey,
|
buildContactKey,
|
||||||
shouldScopeContactToSession,
|
shouldScopeContactToSession,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getGroupRelationshipGraphFacts,
|
getGroupRelationshipGraphFacts,
|
||||||
getNonSystemMembersForContacts,
|
getNonSystemMembersForContacts,
|
||||||
getPrivateContactFacts,
|
getPrivateContactFacts,
|
||||||
|
resolveContactMember,
|
||||||
resolveOwnerMember,
|
resolveOwnerMember,
|
||||||
} from '../contact-queries'
|
} from '../contact-queries'
|
||||||
import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces'
|
import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces'
|
||||||
@@ -121,6 +122,17 @@ describe('contact query helpers', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves one contact directly by stable platform id', () => {
|
||||||
|
assert.deepEqual(resolveContactMember(db, 'alice-pid'), {
|
||||||
|
id: 2,
|
||||||
|
platformId: 'alice-pid',
|
||||||
|
name: 'Alice G',
|
||||||
|
aliases: ['Ally', '小爱'],
|
||||||
|
avatar: 'alice.png',
|
||||||
|
})
|
||||||
|
assert.equal(resolveContactMember(db, 'missing-pid'), null)
|
||||||
|
})
|
||||||
|
|
||||||
it('returns saved member aliases for contact candidates', () => {
|
it('returns saved member aliases for contact candidates', () => {
|
||||||
const alice = getNonSystemMembersForContacts(db).find((member) => member.platformId === 'alice-pid')
|
const alice = getNonSystemMembersForContacts(db).find((member) => member.platformId === 'alice-pid')
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ export function isValidContactPlatformId(platformId: string | null | undefined):
|
|||||||
export function resolveOwnerMember(db: DatabaseAdapter): ContactMemberRef | null {
|
export function resolveOwnerMember(db: DatabaseAdapter): ContactMemberRef | null {
|
||||||
const meta = db.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string | null } | undefined
|
const meta = db.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string | null } | undefined
|
||||||
if (!isValidContactPlatformId(meta?.owner_id)) return null
|
if (!isValidContactPlatformId(meta?.owner_id)) return null
|
||||||
|
|
||||||
|
return resolveContactMember(db, meta.owner_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveContactMember(db: DatabaseAdapter, platformId: string): ContactMemberRef | null {
|
||||||
|
if (!isValidContactPlatformId(platformId)) return null
|
||||||
const aliasesSelect = hasColumn(db, 'member', 'aliases') ? 'aliases' : 'NULL as aliases'
|
const aliasesSelect = hasColumn(db, 'member', 'aliases') ? 'aliases' : 'NULL as aliases'
|
||||||
|
|
||||||
const row = db
|
const row = db
|
||||||
@@ -84,7 +90,7 @@ export function resolveOwnerMember(db: DatabaseAdapter): ContactMemberRef | null
|
|||||||
WHERE platform_id = ? AND ${nonSystemContactMemberCondition(db, 'm')}
|
WHERE platform_id = ? AND ${nonSystemContactMemberCondition(db, 'm')}
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
)
|
)
|
||||||
.get(meta.owner_id) as ContactMemberRow | undefined
|
.get(platformId) as ContactMemberRow | undefined
|
||||||
|
|
||||||
return row ? mapContactMemberRow(row) : null
|
return row ? mapContactMemberRow(row) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export {
|
|||||||
getNonSystemMembersForContacts,
|
getNonSystemMembersForContacts,
|
||||||
getPrivateContactFacts,
|
getPrivateContactFacts,
|
||||||
isValidContactPlatformId,
|
isValidContactPlatformId,
|
||||||
|
resolveContactMember,
|
||||||
resolveOwnerMember,
|
resolveOwnerMember,
|
||||||
} from './contact-queries'
|
} from './contact-queries'
|
||||||
|
|
||||||
|
|||||||
@@ -312,11 +312,11 @@ test('contact lookup uses the all-history snapshot for typed names and candidate
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('entity resolution uses contact keys and resolves per-session member ids without merging display names', () => {
|
test('entity resolution uses contact keys and resolves per-session member ids without merging display names', async () => {
|
||||||
const { env, contactsService } = createFixture()
|
const { env, contactsService } = createFixture()
|
||||||
try {
|
try {
|
||||||
const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService })
|
const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService })
|
||||||
const result = service.resolveEntities([
|
const result = await service.resolveEntities([
|
||||||
{ type: 'contact', contactKey: 'test:alice', displayName: 'Alice' },
|
{ type: 'contact', contactKey: 'test:alice', displayName: 'Alice' },
|
||||||
{ type: 'contact', contactKey: 'test:group-other:alice-other', displayName: 'Alice' },
|
{ type: 'contact', contactKey: 'test:group-other:alice-other', displayName: 'Alice' },
|
||||||
])
|
])
|
||||||
@@ -344,7 +344,7 @@ test('entity resolution uses contact keys and resolves per-session member ids wi
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('entity resolution uses the all-history contact snapshot so older source sessions remain searchable', () => {
|
test('entity resolution uses the all-history contact snapshot so older source sessions remain searchable', async () => {
|
||||||
const { env, contactsService: fixtureContactsService } = createFixture()
|
const { env, contactsService: fixtureContactsService } = createFixture()
|
||||||
try {
|
try {
|
||||||
const contactsService: Pick<ContactsService, 'getContactDetail' | 'getContactsPage'> = {
|
const contactsService: Pick<ContactsService, 'getContactDetail' | 'getContactsPage'> = {
|
||||||
@@ -367,7 +367,7 @@ test('entity resolution uses the all-history contact snapshot so older source se
|
|||||||
}
|
}
|
||||||
const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService })
|
const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService })
|
||||||
|
|
||||||
const result = service.resolveEntities([{ type: 'contact', contactKey: 'test:alice', displayName: 'Alice' }])
|
const result = await service.resolveEntities([{ type: 'contact', contactKey: 'test:alice', displayName: 'Alice' }])
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
result.contacts[0]?.sessions.map((session) => session.sessionId),
|
result.contacts[0]?.sessions.map((session) => session.sessionId),
|
||||||
@@ -380,6 +380,33 @@ test('entity resolution uses the all-history contact snapshot so older source se
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('entity resolution honors interruption between contact source sessions', async () => {
|
||||||
|
const { env, contactsService } = createFixture()
|
||||||
|
try {
|
||||||
|
const openedSessions: string[] = []
|
||||||
|
const adapter: SessionRuntimeAdapter = {
|
||||||
|
...env.adapter,
|
||||||
|
openReadonly: (sessionId) => {
|
||||||
|
openedSessions.push(sessionId)
|
||||||
|
return env.adapter.openReadonly(sessionId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const controller = new AbortController()
|
||||||
|
const service = createCrossChatAnalysisService({ adapter, contactsService })
|
||||||
|
const resolution = service.resolveEntities([{ type: 'contact', contactKey: 'test:alice', displayName: 'Alice' }], {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
queueMicrotask(() => controller.abort())
|
||||||
|
|
||||||
|
await assert.rejects(resolution, { name: 'AbortError' })
|
||||||
|
assert.ok(openedSessions.length > 0)
|
||||||
|
assert.ok(openedSessions.length < 2)
|
||||||
|
} finally {
|
||||||
|
env.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('scoped search filters by resolved member ids and keeps compound evidence identity', async () => {
|
test('scoped search filters by resolved member ids and keeps compound evidence identity', async () => {
|
||||||
const { env, contactsService } = createFixture()
|
const { env, contactsService } = createFixture()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getSearchMessageContext as getCoreSearchMessageContext,
|
getSearchMessageContext as getCoreSearchMessageContext,
|
||||||
getSessionMeta,
|
getSessionMeta,
|
||||||
getSessionOverview,
|
getSessionOverview,
|
||||||
|
resolveContactMember,
|
||||||
resolveOwnerMember,
|
resolveOwnerMember,
|
||||||
searchMessagesByKeywords,
|
searchMessagesByKeywords,
|
||||||
type DatabaseAdapter,
|
type DatabaseAdapter,
|
||||||
@@ -53,7 +54,7 @@ export interface CrossChatAnalysisServiceDeps {
|
|||||||
|
|
||||||
export interface CrossChatAnalysisService {
|
export interface CrossChatAnalysisService {
|
||||||
lookupContact(query: string): CrossChatContactLookupResult
|
lookupContact(query: string): CrossChatContactLookupResult
|
||||||
resolveEntities(refs: AIEntityRef[]): CrossChatEntityResolution
|
resolveEntities(refs: AIEntityRef[], options?: CrossChatOperationOptions): Promise<CrossChatEntityResolution>
|
||||||
searchMessages(request: CrossChatSearchRequest, options?: CrossChatOperationOptions): Promise<CrossChatSearchResult>
|
searchMessages(request: CrossChatSearchRequest, options?: CrossChatOperationOptions): Promise<CrossChatSearchResult>
|
||||||
getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult
|
getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult
|
||||||
getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise<CrossChatOverviewResult>
|
getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise<CrossChatOverviewResult>
|
||||||
@@ -116,7 +117,11 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveEntities(refs: AIEntityRef[]): CrossChatEntityResolution {
|
async resolveEntities(
|
||||||
|
refs: AIEntityRef[],
|
||||||
|
options: CrossChatOperationOptions = {}
|
||||||
|
): Promise<CrossChatEntityResolution> {
|
||||||
|
throwIfAborted(options.signal)
|
||||||
const contacts: CrossChatResolvedContact[] = []
|
const contacts: CrossChatResolvedContact[] = []
|
||||||
const sessions: CrossChatResolvedSession[] = []
|
const sessions: CrossChatResolvedSession[] = []
|
||||||
const unresolved: CrossChatUnresolvedEntity[] = []
|
const unresolved: CrossChatUnresolvedEntity[] = []
|
||||||
@@ -125,6 +130,7 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
|||||||
const failedSessionIds = new Set<string>()
|
const failedSessionIds = new Set<string>()
|
||||||
|
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
|
throwIfAborted(options.signal)
|
||||||
if (ref.type === 'session') {
|
if (ref.type === 'session') {
|
||||||
candidateSessionIds.add(ref.sessionId)
|
candidateSessionIds.add(ref.sessionId)
|
||||||
const descriptor = this.tryGetSessionDescriptor(ref.sessionId)
|
const descriptor = this.tryGetSessionDescriptor(ref.sessionId)
|
||||||
@@ -136,6 +142,7 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
|||||||
sessions.push({ ref, status: 'unresolved' })
|
sessions.push({ ref, status: 'unresolved' })
|
||||||
unresolved.push({ ref, reason: 'session_not_found' })
|
unresolved.push({ ref, reason: 'session_not_found' })
|
||||||
}
|
}
|
||||||
|
await yieldToEventLoop()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +164,7 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
|||||||
ref,
|
ref,
|
||||||
reason: detail.cache.status === 'missing' ? 'contact_snapshot_missing' : 'contact_not_found',
|
reason: detail.cache.status === 'missing' ? 'contact_snapshot_missing' : 'contact_not_found',
|
||||||
})
|
})
|
||||||
|
await yieldToEventLoop()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +175,9 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
|||||||
const unresolvedContactSessions: string[] = []
|
const unresolvedContactSessions: string[] = []
|
||||||
const failedContactSessions: string[] = []
|
const failedContactSessions: string[] = []
|
||||||
|
|
||||||
|
// 实体解析必须保留联系人全部精确来源;逐库让出事件循环,使长列表仍能及时响应取消。
|
||||||
for (const source of sourceSessions) {
|
for (const source of sourceSessions) {
|
||||||
|
throwIfAborted(options.signal)
|
||||||
candidateSessionIds.add(source.id)
|
candidateSessionIds.add(source.id)
|
||||||
try {
|
try {
|
||||||
const db = this.deps.adapter.openReadonly(source.id)
|
const db = this.deps.adapter.openReadonly(source.id)
|
||||||
@@ -177,7 +187,7 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const descriptor = getSessionDescriptor(source.id, db)
|
const descriptor = getSessionDescriptor(source.id, db)
|
||||||
const member = getMembers(db).find((item) => item.platformId === contact.platformId)
|
const member = resolveContactMember(db, contact.platformId)
|
||||||
if (!descriptor || !member) {
|
if (!descriptor || !member) {
|
||||||
unresolvedContactSessions.push(source.id)
|
unresolvedContactSessions.push(source.id)
|
||||||
continue
|
continue
|
||||||
@@ -193,6 +203,8 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
|||||||
failedContactSessions.push(source.id)
|
failedContactSessions.push(source.id)
|
||||||
failedSessionIds.add(source.id)
|
failedSessionIds.add(source.id)
|
||||||
appLogger.warn('cross-chat-analysis', `failed to resolve contact in source session: ${source.id}`, error)
|
appLogger.warn('cross-chat-analysis', `failed to resolve contact in source session: ${source.id}`, error)
|
||||||
|
} finally {
|
||||||
|
await yieldToEventLoop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +223,11 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
|||||||
failedSessionIds: failedContactSessions,
|
failedSessionIds: failedContactSessions,
|
||||||
})
|
})
|
||||||
if (status === 'unresolved') unresolved.push({ ref, reason: 'member_not_found' })
|
if (status === 'unresolved') unresolved.push({ ref, reason: 'member_not_found' })
|
||||||
|
if (sourceSessions.length === 0) await yieldToEventLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throwIfAborted(options.signal)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contacts,
|
contacts,
|
||||||
sessions,
|
sessions,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function createContext(overrides: Partial<CrossChatAnalysisToolService> = {}): C
|
|||||||
totalCandidates: 0,
|
totalCandidates: 0,
|
||||||
candidates: [],
|
candidates: [],
|
||||||
}),
|
}),
|
||||||
resolveEntities: (_refs: AIEntityRef[]): CrossChatEntityResolution => ({
|
resolveEntities: async (_refs: AIEntityRef[]): Promise<CrossChatEntityResolution> => ({
|
||||||
contacts: [],
|
contacts: [],
|
||||||
sessions: [],
|
sessions: [],
|
||||||
unresolved: [],
|
unresolved: [],
|
||||||
@@ -158,8 +158,9 @@ describe('cross-chat agent registry', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
resolveEntities: (refs) => {
|
resolveEntities: async (refs, options) => {
|
||||||
resolvedRefs = refs
|
resolvedRefs = refs
|
||||||
|
assert.equal(options?.signal, context.abortSignal)
|
||||||
return {
|
return {
|
||||||
contacts: [],
|
contacts: [],
|
||||||
sessions: [],
|
sessions: [],
|
||||||
@@ -174,6 +175,8 @@ describe('cross-chat agent registry', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
} as Partial<CrossChatAnalysisToolService>)
|
} as Partial<CrossChatAnalysisToolService>)
|
||||||
|
const controller = new AbortController()
|
||||||
|
context.abortSignal = controller.signal
|
||||||
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'resolve_chat_entities')
|
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'resolve_chat_entities')
|
||||||
assert.ok(tool)
|
assert.ok(tool)
|
||||||
|
|
||||||
@@ -207,7 +210,7 @@ describe('cross-chat agent registry', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
resolveEntities: (refs) => {
|
resolveEntities: async (refs) => {
|
||||||
resolvedRefs = refs
|
resolvedRefs = refs
|
||||||
return {
|
return {
|
||||||
contacts: [],
|
contacts: [],
|
||||||
@@ -322,6 +325,46 @@ describe('cross-chat agent registry', () => {
|
|||||||
assert.equal(details.crossChatEvidence.sources[0]?.snippet, 'x'.repeat(500))
|
assert.equal(details.crossChatEvidence.sources[0]?.snippet, 'x'.repeat(500))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the injected tokenizer when budgeting Chinese search payloads', async () => {
|
||||||
|
const context = createContext({
|
||||||
|
searchMessages: async () => ({
|
||||||
|
messages: [messageSource('a', 1, 1, '中文消息'.repeat(125))],
|
||||||
|
totalMatches: 1,
|
||||||
|
appliedFilters: { startTs: null, endTs: null, recentDays: null, sender: 'all' },
|
||||||
|
coverage: {
|
||||||
|
candidateSessions: 1,
|
||||||
|
scannedSessions: 1,
|
||||||
|
matchedSessions: 1,
|
||||||
|
failedSessions: 0,
|
||||||
|
truncated: false,
|
||||||
|
truncatedReasons: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
context.maxToolResultTokens = 300
|
||||||
|
const countTokens = (text: string) => {
|
||||||
|
let tokens = 0
|
||||||
|
for (const char of text) tokens += /[\u3400-\u9fff]/u.test(char) ? 1 : 0.25
|
||||||
|
return Math.ceil(tokens)
|
||||||
|
}
|
||||||
|
context.countTokens = countTokens
|
||||||
|
context.preprocessMessagesBySession = (_sessionId, messages) => messages
|
||||||
|
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'search_messages_globally')
|
||||||
|
assert.ok(tool)
|
||||||
|
|
||||||
|
const result = await tool.handler({ keywords: ['中文'] }, context)
|
||||||
|
const details = result.data as {
|
||||||
|
returned: number
|
||||||
|
coverage: { truncatedReasons: string[] }
|
||||||
|
crossChatEvidence: { sources: unknown[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(countTokens(result.content) <= 300)
|
||||||
|
assert.equal(details.returned, 0)
|
||||||
|
assert.deepEqual(details.crossChatEvidence.sources, [])
|
||||||
|
assert.ok(details.coverage.truncatedReasons.includes('evidence_budget'))
|
||||||
|
})
|
||||||
|
|
||||||
it('allows recent-message sampling only when explicit scopes are present', async () => {
|
it('allows recent-message sampling only when explicit scopes are present', async () => {
|
||||||
let captured: unknown
|
let captured: unknown
|
||||||
const context = createContext({
|
const context = createContext({
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ const overviewSchema: JsonSchema = {
|
|||||||
required: ['scopes'],
|
required: ['scopes'],
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveHandler(params: Record<string, unknown>, context: CrossChatToolExecutionContext): ToolResult {
|
async function resolveHandler(
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
context: CrossChatToolExecutionContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
const inputs = parseEntityInputs(params.entities)
|
const inputs = parseEntityInputs(params.entities)
|
||||||
const refs: AIEntityRef[] = []
|
const refs: AIEntityRef[] = []
|
||||||
const contactLookups: CrossChatContactLookupResult[] = []
|
const contactLookups: CrossChatContactLookupResult[] = []
|
||||||
@@ -119,7 +122,9 @@ function resolveHandler(params: Record<string, unknown>, context: CrossChatToolE
|
|||||||
}
|
}
|
||||||
refs.push(input as AIEntityRef)
|
refs.push(input as AIEntityRef)
|
||||||
}
|
}
|
||||||
const resolution = context.analysisService.resolveEntities(dedupeEntityRefs(refs))
|
const resolution = await context.analysisService.resolveEntities(dedupeEntityRefs(refs), {
|
||||||
|
signal: context.abortSignal,
|
||||||
|
})
|
||||||
const data = { ...resolution, contactLookups }
|
const data = { ...resolution, contactLookups }
|
||||||
return { content: JSON.stringify(data), data }
|
return { content: JSON.stringify(data), data }
|
||||||
}
|
}
|
||||||
@@ -169,7 +174,9 @@ async function searchHandler(
|
|||||||
coverage: buildCoverage(budgetTruncated),
|
coverage: buildCoverage(budgetTruncated),
|
||||||
messages: messages.map(toModelMessage),
|
messages: messages.map(toModelMessage),
|
||||||
})
|
})
|
||||||
const limited = limitMessagesToBudget(safeMessages, context.maxToolResultTokens, buildModelData)
|
const limited = limitMessagesToBudget(safeMessages, context.maxToolResultTokens, buildModelData, {
|
||||||
|
countTokens: context.countTokens,
|
||||||
|
})
|
||||||
const modelData = buildModelData(limited.messages, limited.truncated)
|
const modelData = buildModelData(limited.messages, limited.truncated)
|
||||||
const evidence: CrossChatEvidencePayload = {
|
const evidence: CrossChatEvidencePayload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -204,6 +211,7 @@ async function contextHandler(
|
|||||||
const limited = limitMessagesToBudget(safeMessages, context.maxToolResultTokens, buildModelData, {
|
const limited = limitMessagesToBudget(safeMessages, context.maxToolResultTokens, buildModelData, {
|
||||||
priorityIndexes: buildContextPriority(safeMessages, messageId),
|
priorityIndexes: buildContextPriority(safeMessages, messageId),
|
||||||
continueAfterOverflow: true,
|
continueAfterOverflow: true,
|
||||||
|
countTokens: context.countTokens,
|
||||||
})
|
})
|
||||||
const data = buildModelData(limited.messages, limited.truncated)
|
const data = buildModelData(limited.messages, limited.truncated)
|
||||||
return { content: JSON.stringify(data), data }
|
return { content: JSON.stringify(data), data }
|
||||||
@@ -354,7 +362,11 @@ function limitMessagesToBudget(
|
|||||||
messages: CrossChatMessageSource[],
|
messages: CrossChatMessageSource[],
|
||||||
maxToolResultTokens: number | undefined,
|
maxToolResultTokens: number | undefined,
|
||||||
buildPayload: (messages: CrossChatMessageSource[], truncated: boolean) => unknown,
|
buildPayload: (messages: CrossChatMessageSource[], truncated: boolean) => unknown,
|
||||||
options: { priorityIndexes?: number[]; continueAfterOverflow?: boolean } = {}
|
options: {
|
||||||
|
priorityIndexes?: number[]
|
||||||
|
continueAfterOverflow?: boolean
|
||||||
|
countTokens?: (text: string) => number
|
||||||
|
} = {}
|
||||||
): { messages: CrossChatMessageSource[]; truncated: boolean } {
|
): { messages: CrossChatMessageSource[]; truncated: boolean } {
|
||||||
const prepared = messages.map((message) => ({
|
const prepared = messages.map((message) => ({
|
||||||
...message,
|
...message,
|
||||||
@@ -364,7 +376,7 @@ function limitMessagesToBudget(
|
|||||||
return { messages: prepared, truncated: false }
|
return { messages: prepared, truncated: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxChars = maxToolResultTokens * 4
|
const countTokens = options.countTokens ?? estimatePayloadTokens
|
||||||
const priorityIndexes = options.priorityIndexes ?? prepared.map((_, index) => index)
|
const priorityIndexes = options.priorityIndexes ?? prepared.map((_, index) => index)
|
||||||
const selectedIndexes = new Set<number>()
|
const selectedIndexes = new Set<number>()
|
||||||
for (const index of priorityIndexes) {
|
for (const index of priorityIndexes) {
|
||||||
@@ -372,8 +384,8 @@ function limitMessagesToBudget(
|
|||||||
const candidateIndexes = [...selectedIndexes, index].sort((left, right) => left - right)
|
const candidateIndexes = [...selectedIndexes, index].sort((left, right) => left - right)
|
||||||
const candidateMessages = candidateIndexes.map((candidateIndex) => prepared[candidateIndex])
|
const candidateMessages = candidateIndexes.map((candidateIndex) => prepared[candidateIndex])
|
||||||
const truncated = candidateMessages.length < prepared.length
|
const truncated = candidateMessages.length < prepared.length
|
||||||
const estimatedChars = JSON.stringify(buildPayload(candidateMessages, truncated)).length
|
const serializedCandidate = JSON.stringify(buildPayload(candidateMessages, truncated))
|
||||||
if (estimatedChars > maxChars) {
|
if (countTokens(serializedCandidate) > maxToolResultTokens) {
|
||||||
if (options.continueAfterOverflow) continue
|
if (options.continueAfterOverflow) continue
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -383,6 +395,26 @@ function limitMessagesToBudget(
|
|||||||
return { messages: limited, truncated: limited.length < prepared.length }
|
return { messages: limited, truncated: limited.length < prepared.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function estimatePayloadTokens(text: string): number {
|
||||||
|
// Browser/MCP 等未注入 Node tokenizer 的调用方仍需避免按 ASCII 密度低估中文结果。
|
||||||
|
let cjkChars = 0
|
||||||
|
let otherChars = 0
|
||||||
|
for (const char of text) {
|
||||||
|
const codePoint = char.codePointAt(0) ?? 0
|
||||||
|
if (
|
||||||
|
(codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
|
||||||
|
(codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
|
||||||
|
(codePoint >= 0x3040 && codePoint <= 0x30ff) ||
|
||||||
|
(codePoint >= 0xac00 && codePoint <= 0xd7af)
|
||||||
|
) {
|
||||||
|
cjkChars++
|
||||||
|
} else {
|
||||||
|
otherChars++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.ceil(cjkChars / 1.6 + otherChars / 4)
|
||||||
|
}
|
||||||
|
|
||||||
function buildContextPriority(messages: CrossChatMessageSource[], anchorMessageId: number): number[] {
|
function buildContextPriority(messages: CrossChatMessageSource[], anchorMessageId: number): number[] {
|
||||||
const anchorIndex = messages.findIndex((message) => message.messageId === anchorMessageId)
|
const anchorIndex = messages.findIndex((message) => message.messageId === anchorMessageId)
|
||||||
if (anchorIndex < 0) return messages.map((_, index) => index)
|
if (anchorIndex < 0) return messages.map((_, index) => index)
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ export interface ToolExecutionContext {
|
|||||||
|
|
||||||
export interface CrossChatAnalysisToolService {
|
export interface CrossChatAnalysisToolService {
|
||||||
lookupContact(query: string): CrossChatContactLookupResult
|
lookupContact(query: string): CrossChatContactLookupResult
|
||||||
resolveEntities(refs: AIEntityRef[]): CrossChatEntityResolution
|
resolveEntities(refs: AIEntityRef[], options?: CrossChatOperationOptions): Promise<CrossChatEntityResolution>
|
||||||
searchMessages(request: CrossChatSearchRequest, options?: CrossChatOperationOptions): Promise<CrossChatSearchResult>
|
searchMessages(request: CrossChatSearchRequest, options?: CrossChatOperationOptions): Promise<CrossChatSearchResult>
|
||||||
getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult
|
getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult
|
||||||
getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise<CrossChatOverviewResult>
|
getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise<CrossChatOverviewResult>
|
||||||
@@ -368,6 +368,8 @@ export interface CrossChatToolExecutionContext {
|
|||||||
abortSignal?: AbortSignal
|
abortSignal?: AbortSignal
|
||||||
reportProgress?: (progress: ToolProgress) => void
|
reportProgress?: (progress: ToolProgress) => void
|
||||||
maxToolResultTokens?: number
|
maxToolResultTokens?: number
|
||||||
|
/** 平台注入的统一 tokenizer;未注入时 handler 使用 CJK-aware 轻量估算。 */
|
||||||
|
countTokens?: (text: string) => number
|
||||||
analysisService: CrossChatAnalysisToolService
|
analysisService: CrossChatAnalysisToolService
|
||||||
preprocessMessagesBySession: (
|
preprocessMessagesBySession: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
Reference in new issue
Block a user