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:
digua committed 2026-08-24 22:35:47 +08:00
1 parent 85bf46b168
commit 77c4dec9e6
6 files changed
+202 -16

No files matched your search

+86 -1
View File
@@ -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])
})
+5 -1
View File
@@ -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
+18 -10
View File
@@ -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
}
+2
View File
@@ -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