mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(ai): enforce current-turn memory scopes
Share entities resolved during one Agent run so natural-language subject changes can safely use memory. Validate entity memory scope before permanent deletion to prevent stale IDs from deleting another subject's memory.
This commit is contained in:
1 parent
85bf46b168
commit
77c4dec9e6
6 files changed
+202
-16
No files matched your search
@@ -1,11 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import type {
|
||||
AIMemoryEntry,
|
||||
AIMemoryScope,
|
||||
AIEntityRef,
|
||||
CrossChatEntityResolution,
|
||||
CrossChatGlobalActivitySummaryRequest,
|
||||
CrossChatGlobalActivitySummaryResult,
|
||||
} from '@openchatlab/shared-types'
|
||||
import { createCrossChatAgentToolAdapters } from './agent-adapter'
|
||||
import type { CrossChatAnalysisToolService } from './types'
|
||||
import type { AIMemoryToolService, CrossChatAnalysisToolService } from './types'
|
||||
|
||||
function globalActivitySummary(): CrossChatGlobalActivitySummaryResult {
|
||||
return {
|
||||
@@ -78,3 +82,84 @@ test('the shared Desktop and CLI Web adapter exposes and executes all thirteen c
|
||||
assert.equal((result.details as CrossChatGlobalActivitySummaryResult).dataState, 'fresh')
|
||||
assert.equal(JSON.parse(result.content[0]?.text ?? '{}').metrics.sentMessageCount, 100)
|
||||
})
|
||||
|
||||
test('cross-chat tool adapters share entities resolved during the current Agent run', async () => {
|
||||
const analysisService = {
|
||||
resolveEntities: async (refs: AIEntityRef[]): Promise<CrossChatEntityResolution> => ({
|
||||
contacts: refs
|
||||
.filter((ref): ref is Extract<AIEntityRef, { type: 'contact' }> => ref.type === 'contact')
|
||||
.map((ref) => ({
|
||||
ref,
|
||||
status: 'resolved' as const,
|
||||
cacheStatus: 'fresh' as const,
|
||||
sessions: [],
|
||||
unresolvedSessionIds: [],
|
||||
failedSessionIds: [],
|
||||
})),
|
||||
sessions: [],
|
||||
unresolved: [],
|
||||
coverage: {
|
||||
requestedEntities: refs.length,
|
||||
resolvedEntities: refs.length,
|
||||
candidateSessions: 0,
|
||||
resolvedSessions: 0,
|
||||
failedSessions: 0,
|
||||
},
|
||||
}),
|
||||
} as unknown as CrossChatAnalysisToolService
|
||||
const memory: AIMemoryEntry = {
|
||||
id: 'memory-contact-2',
|
||||
scopeType: 'contact',
|
||||
scopeId: 'contact-2',
|
||||
content: 'Newly resolved contact memory',
|
||||
sourceType: 'user',
|
||||
sourceAIChatId: null,
|
||||
sourceMessageId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}
|
||||
const memoryService = {
|
||||
list: (scope?: AIMemoryScope) =>
|
||||
scope?.scopeType === memory.scopeType && scope.scopeId === memory.scopeId ? [memory] : [],
|
||||
} as unknown as AIMemoryToolService
|
||||
const tools = createCrossChatAgentToolAdapters({
|
||||
locale: 'zh-CN',
|
||||
entityRefs: [{ type: 'contact', contactKey: 'contact-1', displayName: 'Previous selection' }],
|
||||
analysisService,
|
||||
memoryService,
|
||||
aiChatId: 'global-chat-test',
|
||||
allowProactiveMemory: true,
|
||||
maxToolResultTokens: 16_000,
|
||||
preprocessMessagesBySession: async (_sessionId, messages) => messages,
|
||||
preprocessSummariesBySession: async (_sessionId, summaries) => summaries,
|
||||
preprocessModelLabel: (value) => value,
|
||||
})
|
||||
const resolveTool = tools.find((tool) => tool.name === 'resolve_chat_entities')
|
||||
const memoryReadTool = tools.find((tool) => tool.name === 'memory_read')
|
||||
assert.ok(resolveTool)
|
||||
assert.ok(memoryReadTool)
|
||||
|
||||
const signal = new AbortController().signal
|
||||
const beforeResolve = await memoryReadTool.execute(
|
||||
'memory-before-resolve',
|
||||
{ scope_type: 'contact', scope_id: 'contact-2' },
|
||||
signal
|
||||
)
|
||||
assert.equal(beforeResolve.isError, true)
|
||||
|
||||
await resolveTool.execute(
|
||||
'resolve-contact-2',
|
||||
{
|
||||
entities: [{ type: 'contact', contactKey: 'contact-2', displayName: 'New contact' }],
|
||||
},
|
||||
signal
|
||||
)
|
||||
const afterResolve = await memoryReadTool.execute(
|
||||
'memory-after-resolve',
|
||||
{ scope_type: 'contact', scope_id: 'contact-2' },
|
||||
signal
|
||||
)
|
||||
|
||||
assert.equal(afterResolve.isError, undefined)
|
||||
assert.deepEqual((afterResolve.details as { entries: AIMemoryEntry[] }).entries, [memory])
|
||||
})
|
||||
@@ -70,6 +70,10 @@ export async function executeToolForAgent<TContext>(
|
||||
export function createCrossChatAgentToolAdapters(
|
||||
context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'>
|
||||
) {
|
||||
const runContext = {
|
||||
...context,
|
||||
resolvedEntityRefs: context.resolvedEntityRefs ?? [],
|
||||
}
|
||||
return CROSS_CHAT_AGENT_TOOL_REGISTRY.map((tool) => ({
|
||||
name: tool.name,
|
||||
label: tool.name,
|
||||
@@ -83,7 +87,7 @@ export function createCrossChatAgentToolAdapters(
|
||||
onUpdate?: (update: { content: []; details: { progress: ToolProgress } }) => void
|
||||
) =>
|
||||
executeToolForAgent(tool, params, {
|
||||
...context,
|
||||
...runContext,
|
||||
abortSignal: signal,
|
||||
reportProgress: (progress) => onUpdate?.({ content: [], details: { progress } }),
|
||||
}),
|
||||
|
||||
@@ -260,6 +260,7 @@ async function resolveHandler(
|
||||
const resolution = await context.analysisService.resolveEntities(dedupeEntityRefs(refs), {
|
||||
signal: context.abortSignal,
|
||||
})
|
||||
trackResolvedEntityRefs(context.resolvedEntityRefs, resolution)
|
||||
const data = { ...resolution, contactLookups }
|
||||
const modelData = limitEntityResolutionToBudget(
|
||||
resolution,
|
||||
@@ -803,12 +804,35 @@ function parseEntityInputs(value: unknown): ParsedEntityInput[] {
|
||||
function dedupeEntityRefs(refs: AIEntityRef[]): AIEntityRef[] {
|
||||
const byKey = new Map<string, AIEntityRef>()
|
||||
for (const ref of refs) {
|
||||
const key = ref.type === 'contact' ? `contact:${ref.contactKey}` : `session:${ref.sessionId}`
|
||||
byKey.set(key, ref)
|
||||
byKey.set(entityRefKey(ref), ref)
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
|
||||
function trackResolvedEntityRefs(
|
||||
currentRunRefs: AIEntityRef[] | undefined,
|
||||
resolution: CrossChatEntityResolution
|
||||
): void {
|
||||
if (!currentRunRefs) return
|
||||
const resolvedRefs: AIEntityRef[] = [
|
||||
...resolution.contacts
|
||||
.filter((item) => item.status === 'resolved' || item.status === 'partial')
|
||||
.map((item) => item.ref),
|
||||
...resolution.sessions.filter((item) => item.status === 'resolved').map((item) => item.ref),
|
||||
]
|
||||
const existingKeys = new Set(currentRunRefs.map(entityRefKey))
|
||||
for (const ref of resolvedRefs) {
|
||||
const key = entityRefKey(ref)
|
||||
if (existingKeys.has(key)) continue
|
||||
currentRunRefs.push(ref)
|
||||
existingKeys.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
function entityRefKey(ref: AIEntityRef): string {
|
||||
return ref.type === 'contact' ? `contact:${ref.contactKey}` : `session:${ref.sessionId}`
|
||||
}
|
||||
|
||||
function parseScopes(value: unknown): CrossChatSearchScope[] {
|
||||
if (!Array.isArray(value)) throw new Error('scopes must be an array')
|
||||
return value.map((item) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AIMemoryEntry, AIMemoryScope, AIEntityRef, CrossChatEntityResoluti
|
||||
import { ChatType } from '@openchatlab/shared-types'
|
||||
import { AGENT_TOOL_REGISTRY, CROSS_CHAT_AGENT_TOOL_REGISTRY, MCP_TOOL_REGISTRY } from '../registry'
|
||||
import type { AIMemoryToolService, CrossChatAnalysisToolService, CrossChatToolExecutionContext } from '../types'
|
||||
import { resolveChatEntitiesTool } from './cross-chat-tools'
|
||||
import { memoryForgetTool, memoryReadTool, memoryWriteTool } from './memory-tools'
|
||||
|
||||
class FakeMemoryService implements AIMemoryToolService {
|
||||
@@ -54,7 +55,10 @@ function entityResolution(refs: AIEntityRef[]): CrossChatEntityResolution {
|
||||
.filter((ref): ref is Extract<AIEntityRef, { type: 'contact' }> => ref.type === 'contact')
|
||||
.map((ref) => ({
|
||||
ref,
|
||||
status: ref.contactKey === 'contact-1' ? ('resolved' as const) : ('unresolved' as const),
|
||||
status:
|
||||
ref.contactKey === 'contact-1' || ref.contactKey === 'contact-2'
|
||||
? ('resolved' as const)
|
||||
: ('unresolved' as const),
|
||||
cacheStatus: 'fresh' as const,
|
||||
sessions: [],
|
||||
unresolvedSessionIds: [],
|
||||
@@ -97,7 +101,7 @@ function entityResolution(refs: AIEntityRef[]): CrossChatEntityResolution {
|
||||
}
|
||||
|
||||
function createContext(
|
||||
options: { allowProactiveMemory?: boolean; entityRefs?: AIEntityRef[] } = {}
|
||||
options: { allowProactiveMemory?: boolean; entityRefs?: AIEntityRef[]; resolvedEntityRefs?: AIEntityRef[] } = {}
|
||||
): CrossChatToolExecutionContext {
|
||||
const analysisService = {
|
||||
resolveEntities: async (refs: AIEntityRef[]) => entityResolution(refs),
|
||||
@@ -105,6 +109,7 @@ function createContext(
|
||||
return {
|
||||
locale: 'zh-CN',
|
||||
entityRefs: options.entityRefs,
|
||||
resolvedEntityRefs: options.resolvedEntityRefs,
|
||||
analysisService,
|
||||
memoryService: new FakeMemoryService(),
|
||||
aiChatId: 'global-chat-1',
|
||||
@@ -263,6 +268,64 @@ describe('global memory tools', () => {
|
||||
assert.deepEqual((current.data as { entries: AIMemoryEntry[] }).entries, [])
|
||||
})
|
||||
|
||||
it('allows a newly resolved entity in the same turn without reusing the stale selection', async () => {
|
||||
const resolvedEntityRefs: AIEntityRef[] = []
|
||||
const context = createContext({
|
||||
entityRefs: [{ type: 'contact', contactKey: 'contact-1', displayName: 'Previous selection' }],
|
||||
resolvedEntityRefs,
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
async () => memoryReadTool.handler({ scope_type: 'contact', scope_id: 'contact-2' }, context),
|
||||
/current turn/i
|
||||
)
|
||||
|
||||
await resolveChatEntitiesTool.handler(
|
||||
{
|
||||
entities: [{ type: 'contact', contactKey: 'contact-2', displayName: 'New contact' }],
|
||||
},
|
||||
context
|
||||
)
|
||||
|
||||
assert.deepEqual(resolvedEntityRefs, [{ type: 'contact', contactKey: 'contact-2', displayName: 'New contact' }])
|
||||
const written = await memoryWriteTool.handler(
|
||||
{
|
||||
scope_type: 'contact',
|
||||
scope_id: 'contact-2',
|
||||
content: '本轮新解析出的联系人记忆',
|
||||
source_type: 'user',
|
||||
},
|
||||
context
|
||||
)
|
||||
assert.equal((written.data as AIMemoryEntry).scopeId, 'contact-2')
|
||||
})
|
||||
|
||||
it('refuses to forget entity memory outside the current turn scope', async () => {
|
||||
const context = createContext({
|
||||
entityRefs: [{ type: 'contact', contactKey: 'contact-2', displayName: 'Current contact' }],
|
||||
})
|
||||
const memoryService = context.memoryService as FakeMemoryService
|
||||
const stale = memoryService.create({
|
||||
scopeType: 'contact',
|
||||
scopeId: 'contact-1',
|
||||
content: 'Stale contact memory',
|
||||
sourceType: 'user',
|
||||
})
|
||||
const current = memoryService.create({
|
||||
scopeType: 'contact',
|
||||
scopeId: 'contact-2',
|
||||
content: 'Current contact memory',
|
||||
sourceType: 'user',
|
||||
})
|
||||
|
||||
await assert.rejects(async () => memoryForgetTool.handler({ id: stale.id }, context), /current turn/i)
|
||||
assert.ok(memoryService.get(stale.id))
|
||||
|
||||
const forgotten = await memoryForgetTool.handler({ id: current.id }, context)
|
||||
assert.deepEqual(forgotten.data, { id: current.id, deleted: true })
|
||||
assert.equal(memoryService.get(current.id), null)
|
||||
})
|
||||
|
||||
it('returns bounded reads with AI verification guidance and forgets by stable id', async () => {
|
||||
const context = createContext()
|
||||
const memoryService = context.memoryService as FakeMemoryService
|
||||
|
||||
@@ -131,6 +131,12 @@ async function memoryForgetHandler(
|
||||
context: CrossChatToolExecutionContext
|
||||
): Promise<ToolResult> {
|
||||
const id = requireString(params.id, 'id')
|
||||
const current = context.memoryService.get(id)
|
||||
if (!current) {
|
||||
const data = { id, deleted: false }
|
||||
return { content: JSON.stringify(data), data }
|
||||
}
|
||||
await assertResolvableEntityScope(current, context, true)
|
||||
const data = { id, deleted: context.memoryService.forget(id) }
|
||||
return { content: JSON.stringify(data), data }
|
||||
}
|
||||
@@ -175,19 +181,15 @@ function optionalString(value: unknown): string | null {
|
||||
|
||||
async function assertResolvableEntityScope(
|
||||
scope: AIMemoryScope,
|
||||
context: CrossChatToolExecutionContext
|
||||
context: CrossChatToolExecutionContext,
|
||||
requireCurrentTurnEntity = false
|
||||
): Promise<void> {
|
||||
if (scope.scopeType === 'global' || scope.scopeType === 'self') return
|
||||
|
||||
if (context.entityRefs?.length) {
|
||||
const matchesCurrentSelection = context.entityRefs.some((ref) =>
|
||||
scope.scopeType === 'contact'
|
||||
? ref.type === 'contact' && ref.contactKey === scope.scopeId
|
||||
: ref.type === 'session' && ref.sessionType === ChatType.GROUP && ref.sessionId === scope.scopeId
|
||||
)
|
||||
if (!matchesCurrentSelection) {
|
||||
throw new Error('scope_id must match an entity explicitly selected for the current turn')
|
||||
}
|
||||
const currentTurnRefs = [...(context.entityRefs ?? []), ...(context.resolvedEntityRefs ?? [])]
|
||||
const matchesCurrentTurn = currentTurnRefs.some((ref) => matchesMemoryScope(ref, scope))
|
||||
if ((requireCurrentTurnEntity || currentTurnRefs.length > 0) && !matchesCurrentTurn) {
|
||||
throw new Error('scope_id must match an entity selected or resolved for the current turn')
|
||||
}
|
||||
|
||||
const ref: AIEntityRef =
|
||||
@@ -212,3 +214,9 @@ async function assertResolvableEntityScope(
|
||||
)
|
||||
if (!resolved) throw new Error('scope_id must be a resolvable stable group ID')
|
||||
}
|
||||
|
||||
function matchesMemoryScope(ref: AIEntityRef, scope: AIMemoryScope): boolean {
|
||||
return scope.scopeType === 'contact'
|
||||
? ref.type === 'contact' && ref.contactKey === scope.scopeId
|
||||
: ref.type === 'session' && ref.sessionType === ChatType.GROUP && ref.sessionId === scope.scopeId
|
||||
}
|
||||
@@ -423,6 +423,8 @@ export interface CrossChatToolExecutionContext {
|
||||
locale?: string
|
||||
/** Stable entities explicitly selected for the current user turn. */
|
||||
entityRefs?: AIEntityRef[]
|
||||
/** Stable entities successfully resolved by tools during the current Agent run. */
|
||||
resolvedEntityRefs?: AIEntityRef[]
|
||||
abortSignal?: AbortSignal
|
||||
reportProgress?: (progress: ToolProgress) => void
|
||||
maxToolResultTokens?: number
|
||||
|
||||
Reference in new issue
Block a user