Files
Termind/FrameworkTests/HermesMemoryTests.swift

1133 lines
56 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import SQLite3
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import Testing
@Suite @MainActor
struct HermesMemoryTests {
private func directory() -> URL { FileManager.default.temporaryDirectory.appending(path: "memory-test-\(UUID())") }
private func messageIDs(in result: AgentJSONValue) throws -> [UUID] {
let rows = try #require(result.objectValue?["messages"]?.arrayValue)
return try rows.map { row in
let text = try #require(row.objectValue?["message_id"]?.stringValue)
return try #require(UUID(uuidString: text))
}
}
private func memoryContents(in result: AgentJSONValue) throws -> [String] {
let entries = try #require(result.objectValue?["entries"]?.arrayValue)
return try entries.map { try #require($0.objectValue?["content"]?.stringValue) }
}
@Test func disabledMemoryDoesNotCreateFiles() async throws {
let url = directory()
let model = MemoryModel(storage: GRDBMemoryStore(directory: url))
await model.refresh()
#expect(await model.session(UUID()) == nil)
#expect(!FileManager.default.fileExists(atPath: url.path))
}
@Test func memorySurvivesReopeningWithoutFrozenSnapshot() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
_ = try await store.applyMemory([.init(action: .add, target: .user, content: "Likes concise replies")])
_ = try await store.applyMemory([
.init(action: .replace, target: .user, content: "Likes detailed replies", oldText: "concise")
])
try await store.close()
let reopened = GRDBMemoryStore(directory: url)
#expect(try await reopened.memoryState().text(.user) == "Likes detailed replies")
#expect(try await reopened.searchMemories(.init(query: "detailed")).encodedString.contains("detailed"))
#expect(try await !reopened.searchMemories(.init(query: "concise")).encodedString.contains("concise"))
try await reopened.close()
}
@Test func v1SnapshotMigrationKeepsEntriesAndDropsFrozenCopies() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
let path = url.appending(path: "memory.sqlite").path
var raw: OpaquePointer?
#expect(sqlite3_open(path, &raw) == SQLITE_OK)
let legacy = AgentMemoryState(entries: [.init(target: .user, content: "Keep this preference")])
let hex = try JSONEncoder().encode(legacy).map { String(format: "%02x", $0) }.joined()
let sql = """
CREATE TABLE grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY);
INSERT INTO grdb_migrations VALUES ('hermes-v1');
CREATE TABLE memory_state (id INTEGER PRIMARY KEY CHECK(id=1), data BLOB NOT NULL);
INSERT INTO memory_state VALUES (1,X'\(hex)');
CREATE TABLE snapshots (conversation_id TEXT PRIMARY KEY, content TEXT NOT NULL);
INSERT INTO snapshots VALUES ('legacy','Frozen private copy');
CREATE TABLE reviews (conversation_id TEXT PRIMARY KEY, message_id TEXT NOT NULL);
CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT NOT NULL, modified REAL NOT NULL);
CREATE TABLE messages (id TEXT NOT NULL, session_id TEXT NOT NULL, position INTEGER NOT NULL,
role TEXT NOT NULL, content TEXT NOT NULL, created REAL NOT NULL,
PRIMARY KEY(session_id, id));
CREATE VIRTUAL TABLE message_fts USING fts5(content, content='messages', content_rowid='rowid', tokenize='trigram');
CREATE INDEX messages_session_position ON messages(session_id, position);
"""
#expect(sqlite3_exec(raw, sql, nil, nil, nil) == SQLITE_OK)
#expect(sqlite3_close(raw) == SQLITE_OK)
let store = GRDBMemoryStore(directory: url)
#expect(try await store.memoryState().text(.user) == "Keep this preference")
try await store.close()
#expect(sqlite3_open(path, &raw) == SQLITE_OK)
var statement: OpaquePointer?
#expect(
sqlite3_prepare_v2(
raw, "SELECT count(*) FROM sqlite_master WHERE name='snapshots'", -1,
&statement, nil) == SQLITE_OK)
#expect(sqlite3_step(statement) == SQLITE_ROW)
#expect(sqlite3_column_int(statement, 0) == 0)
sqlite3_finalize(statement)
#expect(sqlite3_close(raw) == SQLITE_OK)
}
@Test func editingPreservesSeparateMemoryEntries() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let state = try await store.replaceTarget(.memory, text: "First fact\n§\nSecond fact")
#expect(state.entries.count == 2)
let updated = try await store.applyMemory([.init(action: .remove, target: .memory, oldText: "First")])
#expect(updated.text(.memory) == "Second fact")
try await store.close()
}
@Test func dateCommentsUseUTCAndNeverBecomeMemoryContent() throws {
let created = Date(timeIntervalSince1970: 1_779_923_400)
let referenced = Date(timeIntervalSince1970: 1_779_930_600)
let entry = AgentMemoryEntry(target: .memory, content: "Keep this fact", createdAt: created)
let state = AgentMemoryState(entries: [entry])
let text = MemoryDateAnnotations.text(
for: .memory, in: state, references: [entry.id: referenced])
#expect(text == "Keep this fact <!-- created=2026-05-27, last=2026-05-28 -->")
#expect(try MemoryDateAnnotations.content(from: text) == "Keep this fact")
#expect(try MemoryDateAnnotations.content(from: "Keep this fact") == "Keep this fact")
#expect(
try MemoryDateAnnotations.content(from: "Remember <!-- ordinary comment -->")
== "Remember <!-- ordinary comment -->")
#expect(MemoryDateAnnotations.draftUsage(text).characters == "Keep this fact".count)
#expect(MemoryDateAnnotations.document(state, references: [entry.id: referenced]).contains(text))
#expect(throws: (any Error).self) {
try MemoryDateAnnotations.content(from: "Keep this fact <!-- creatd=2026-05-27, last=2026-05-28 -->")
}
#expect(throws: (any Error).self) {
try MemoryDateAnnotations.content(from: "Keep this fact <!-- created=2026-05-27, last=2026-05-28")
}
}
@Test func searchTouchesOnlyReturnedEntriesWithoutInvalidatingReview() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let original = try await store.applyMemory([
.init(action: .add, target: .memory, content: "Project Atlas uses pnpm"),
.init(action: .add, target: .memory, content: "Project Borealis uses npm"),
])
let atlas = try #require(original.entries.first { $0.content.contains("Atlas") })
let borealis = try #require(original.entries.first { $0.content.contains("Borealis") })
let model = MemoryModel(storage: store)
await model.setEnabled(true)
let session = try #require(await model.session(UUID()))
let misses = try await session.access.searchMemories(.init(query: "nothing matches"))
#expect(try memoryContents(in: misses).isEmpty)
#expect(try await store.memoryReferenceDates().isEmpty)
let result = try await session.access.searchMemories(.init(query: "Atlas"))
#expect(try memoryContents(in: result) == ["Project Atlas uses pnpm"])
#expect(model.referenceDates[atlas.id] != nil)
#expect(model.referenceDates[borealis.id] == nil)
let storedDates = try await store.memoryReferenceDates()
#expect(storedDates.count == 1)
#expect(storedDates[atlas.id] != nil)
let reviewed = try await store.applyReview(
[.init(action: .add, target: .user, content: "Prefers short answers")],
expected: original, messageID: UUID(), conversationID: UUID(), reviewedActivityIDs: [])
#expect(reviewed != nil)
let reviewedDates = try await store.memoryReferenceDates()
#expect(reviewedDates[atlas.id] != nil)
try await store.close()
let reopened = GRDBMemoryStore(directory: url)
let reopenedDates = try await reopened.memoryReferenceDates()
#expect(reopenedDates[atlas.id] != nil)
#expect(reopenedDates[borealis.id] == nil)
try await reopened.close()
}
@Test func editorIgnoresDateChangesAndPreservesMatchingEntryIdentities() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let original = try await store.applyMemory([
.init(action: .add, target: .memory, content: "First fact"),
.init(action: .add, target: .memory, content: "Second fact"),
])
let first = original.entries[0]
let second = original.entries[1]
let reference = Date(timeIntervalSince1970: ceil(first.createdAt.timeIntervalSince1970) + 10)
try await store.recordMemoryReferences([first.id], at: reference)
let initialDates = try await store.memoryReferenceDates()
#expect(initialDates.count == 1)
#expect(initialDates[first.id] != nil)
let edited = try await store.replaceTarget(
.memory,
text: "First fact revised\n§\nSecond fact <!-- created=1900-01-01, last=1900-01-01 -->")
#expect(edited.entries.map(\.content) == ["First fact revised", "Second fact"])
#expect(edited.entries.map(\.id) == [first.id, second.id])
#expect(edited.entries[0].createdAt == first.createdAt)
#expect(edited.entries[1].createdAt == second.createdAt)
let editedDates = try await store.memoryReferenceDates()
#expect(editedDates[first.id] == reference)
await #expect(throws: (any Error).self) {
try await store.replaceTarget(.memory, text: "First fact revised <!-- created=oops")
}
#expect(try await store.memoryState() == edited)
let withoutComment = try await store.replaceTarget(.memory, text: "First fact revised")
#expect(withoutComment.entries.map(\.id) == [first.id])
let remainingDates = try await store.memoryReferenceDates()
#expect(remainingDates[second.id] == nil)
try await store.close()
}
@Test func ambiguousEditDoesNotAssignAnOldDateToANewEntry() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let original = try await store.applyMemory([
.init(action: .add, target: .memory, content: "First fact"),
.init(action: .add, target: .memory, content: "Second fact"),
])
try await store.recordMemoryReferences([original.entries[0].id], at: .now)
let updated = try await store.replaceTarget(.memory, text: "New fact")
#expect(updated.entries.count == 1)
#expect(!original.entries.map(\.id).contains(updated.entries[0].id))
#expect(try await store.memoryReferenceDates().isEmpty)
try await store.close()
}
@Test func staleBackgroundReviewCannotOverwriteForegroundChanges() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let state = try await store.memoryState()
_ = try await store.applyMemory([.init(action: .add, target: .user, content: "New preference")])
let id = UUID()
let messageID = UUID()
let result = try await store.applyReview(
[.init(action: .add, target: .user, content: "Old assumption")], expected: state,
messageID: messageID, conversationID: id, reviewedActivityIDs: [])
#expect(result == nil)
#expect(try await store.lastReviewedMessage(id) == nil)
try await store.close()
}
@Test func activityThresholdsReviewOneShortWindowAndReplayIsDeduplicated() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let memory = MemoryModel(storage: store)
await memory.setEnabled(true)
let conversationID = UUID()
var messages: [AgentTranscriptMessage] = []
for index in 0..<10 {
messages.append(.init(role: .user, text: "Request \(index)"))
messages.append(.init(role: .assistant, text: "Done \(index)"))
}
let tools = (0..<14).map { _ in
MemoryLearningEvent(id: UUID(), sourceMessageID: messages[1].id)
}
let reviewer = CountingMemoryReviewModel()
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: messages.prefix(18).filter { $0.role == .user }.map(\.id),
toolInvocations: tools, messages: Array(messages.prefix(18)), model: reviewer)
#expect(await reviewer.count == 0)
#expect(try await store.learningProgress(conversationID).turns == 9)
#expect(try await store.learningProgress(conversationID).tools == 14)
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: [messages[18].id], toolInvocations: [],
messages: messages, model: reviewer)
try await waitUntil {
let count = await reviewer.count
let turns = try? await store.learningProgress(conversationID).turns
return count == 1 && turns == 0
}
#expect(await reviewer.count == 1)
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: [messages[18].id], toolInvocations: tools,
messages: messages, model: reviewer)
#expect(try await store.learningProgress(conversationID).turns == 0)
#expect(try await store.learningProgress(conversationID).tools == 0)
#expect(await reviewer.count == 1)
let moreTools = (0..<15).map { _ in
MemoryLearningEvent(id: UUID(), sourceMessageID: messages[19].id)
}
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: [], toolInvocations: Array(moreTools.prefix(14)),
messages: messages, model: reviewer)
#expect(await reviewer.count == 1)
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: [], toolInvocations: [moreTools[14]],
messages: messages, model: reviewer)
try await waitUntil {
let count = await reviewer.count
let tools = try? await store.learningProgress(conversationID).tools
return count == 2 && tools == 0
}
#expect(await reviewer.count == 2)
}
@Test func reviewPreservesActivityAddedWhileModelIsWorking() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let memory = MemoryModel(storage: store)
await memory.setEnabled(true)
let conversationID = UUID()
let first = (0..<10).map { AgentTranscriptMessage(role: .user, text: "Question \($0)") }
let reviewer = BlockingMemoryReviewModel()
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: first.map(\.id),
toolInvocations: [], messages: first, model: reviewer)
await reviewer.waitUntilEntered()
let later = AgentTranscriptMessage(role: .user, text: "Later request")
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: [later.id],
toolInvocations: [], messages: first + [later], model: reviewer)
await reviewer.release()
try await waitUntil { (try? await store.learningProgress(conversationID).turns) == 1 }
#expect(try await store.learningProgress(conversationID).turns == 1)
}
@Test func correctionCandidatesIncludeChineseAndEnglishCommandsButExcludeAcknowledgments() {
#expect(MemoryModel.looksLikeCorrection("No, use yarn instead"))
#expect(MemoryModel.looksLikeCorrection("不要 npm,用 yarn"))
#expect(!MemoryModel.looksLikeCorrection("Actually looks great"))
#expect(!MemoryModel.looksLikeCorrection("没关系,不是问题"))
}
@Test func consecutiveCorrectionsReplaceScopedMemoryAndDeduplicateMessages() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
_ = try await store.applyMemory([
.init(action: .add, target: .memory, content: "For project A, use npm")
])
let memory = MemoryModel(storage: store)
await memory.setEnabled(true)
let conversationID = UUID()
let context = AgentTranscriptMessage(role: .user, text: "For project A, use npm")
let first = AgentTranscriptMessage(
role: .user, text: "No, use yarn instead for project A",
authoredText: "No, use yarn instead for project A")
let second = AgentTranscriptMessage(
role: .user, text: "不要 yarn,用 pnpm,在项目 A",
authoredText: "不要 yarn,用 pnpm,在项目 A")
let reviewer = SequencedMemoryReviewModel(replies: [
#"{"operations":[{"action":"replace","target":"memory","old_text":"npm","content":"For project A, use yarn"}]}"#,
#"{"operations":[{"action":"replace","target":"memory","old_text":"yarn","content":"For project A, use pnpm"}]}"#,
])
memory.considerCorrection(
conversationID: conversationID, message: first,
context: [context, first], model: reviewer)
memory.considerCorrection(
conversationID: conversationID, message: second,
context: [context, first, second], model: reviewer)
try await waitUntil { (try? await store.memoryState().text(.memory)) == "For project A, use pnpm" }
#expect(try await store.correctionWasReviewed(first.id, conversationID: conversationID))
#expect(try await store.correctionWasReviewed(second.id, conversationID: conversationID))
memory.considerCorrection(
conversationID: conversationID, message: first,
context: [context, first], model: reviewer)
try await Task.sleep(for: .milliseconds(30))
#expect(await reviewer.count == 2)
}
@Test func failedOlderCorrectionCannotRetryAfterNewerCorrectionSucceeds() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
_ = try await store.applyMemory([
.init(action: .add, target: .memory, content: "For project A, use npm")
])
let memory = MemoryModel(storage: store)
await memory.setEnabled(true)
let conversationID = UUID()
let first = AgentTranscriptMessage(
role: .user, text: "No, use yarn instead", authoredText: "No, use yarn instead")
let second = AgentTranscriptMessage(
role: .user, text: "No, use pnpm instead", authoredText: "No, use pnpm instead")
let reviewer = FailingFirstMemoryReviewModel()
memory.considerCorrection(conversationID: conversationID, message: first, context: [first], model: reviewer)
memory.considerCorrection(conversationID: conversationID, message: second, context: [second], model: reviewer)
try await waitUntil { (try? await store.memoryState().text(.memory)) == "For project A, use pnpm" }
#expect(await reviewer.count == 2)
memory.considerCorrection(conversationID: conversationID, message: first, context: [first], model: reviewer)
try await Task.sleep(for: .milliseconds(30))
#expect(await reviewer.count == 2)
#expect(try await store.memoryState().text(.memory) == "For project A, use pnpm")
}
@Test func oldLearningTokenCannotWriteAfterDisableAndReenable() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let memory = MemoryModel(storage: store)
await memory.setEnabled(true)
let old = try #require(await memory.session(UUID()))
let token = try #require(memory.learningToken(for: old.context))
await memory.setEnabled(false)
await memory.setEnabled(true)
let message = AgentTranscriptMessage(role: .user, text: "Old run")
let reviewer = CountingMemoryReviewModel()
let conversationID = UUID()
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: [message.id],
toolInvocations: [], messages: [message], model: reviewer,
sessionToken: token)
#expect(try await store.learningProgress(conversationID).turns == 0)
#expect(await reviewer.count == 0)
#expect(memory.learningToken(for: old.context) == nil)
}
@Test func editingHistoryRevokesAnInFlightCorrection() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let memory = MemoryModel(storage: store)
await memory.setEnabled(true)
let history = InMemoryAgentRunRepository()
let repository = MemoryIndexedAgentRepository(base: history, memory: memory)
let conversationID = UUID()
let message = AgentTranscriptMessage(
role: .user, text: "No, use yarn instead",
authoredText: "No, use yarn instead")
try await repository.createConversation(
.init(
id: conversationID, messages: [message], lastRunState: .completed))
let reviewer = BlockingMemoryReviewModel()
memory.considerCorrection(
conversationID: conversationID, message: message,
context: [message], model: reviewer)
await reviewer.waitUntilEntered()
let replacement = AgentTranscriptMessage(role: .user, text: "Use npm for this one task")
try await repository.truncateConversation(
conversationID, fromUserMessageID: message.id,
replacingWith: replacement)
await reviewer.release()
try await Task.sleep(for: .milliseconds(50))
#expect(try await store.memoryState().entries.isEmpty)
#expect(try await !store.correctionWasReviewed(message.id, conversationID: conversationID))
let epoch = memory.learningEpoch(for: conversationID)
memory.invalidateLearning(for: conversationID)
#expect(memory.learningEpoch(for: conversationID) != epoch)
let session = try #require(await memory.session(conversationID))
let token = try #require(memory.learningToken(for: session.context))
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: [message.id],
toolInvocations: [], messages: [message], model: reviewer,
sessionToken: token, historyEpoch: epoch)
#expect(try await store.learningProgress(conversationID).turns == 0)
}
@Test func historyEditWhileLoadingProgressDoesNotRecordOrReviewOldMessages() async throws {
let store = MemoryLifecycleStore(blocksLearningProgress: true)
let memory = MemoryModel(storage: store)
await memory.setEnabled(true)
let conversationID = UUID()
let messages = (0..<10).map { AgentTranscriptMessage(role: .user, text: "Question \($0)") }
let reviewer = CountingMemoryReviewModel()
let task = Task {
await memory.recordActivity(
conversationID: conversationID, userMessageIDs: messages.map(\.id),
toolInvocations: [], messages: messages, model: reviewer)
}
await store.waitUntilLearningProgressEntered()
memory.invalidateLearning(for: conversationID)
await store.releaseLearningProgress()
await task.value
#expect(await store.learningProgress(conversationID).turns == 0)
#expect(await reviewer.count == 0)
}
@Test func completedToolBatchIsCountedBeforeRunFinishes() {
let conversation = ConversationModel()
let runID = UUID()
var counts: [Int] = []
conversation.memoryToolBatchFinished = { tools, _ in counts.append(tools.count) }
conversation.handle(.runStarted(.init(id: runID, state: .running, startedAt: .now)))
for index in 0..<15 {
let call = AgentToolCall(id: "call-\(index)", name: "example", arguments: .object([:]))
let invocation = AgentToolInvocation(runID: runID, call: call)
conversation.handle(.toolStarted(invocation))
conversation.handle(.toolFinished(invocation, AgentToolResult(callID: call.id, content: "done")))
}
conversation.handle(.turnStarted(index: 1))
#expect(counts == [15])
}
@Test func memoryToolsDoNotCountTowardLearningThreshold() {
let conversation = ConversationModel()
let runID = UUID()
var batches: [[MemoryLearningEvent]] = []
conversation.memoryToolBatchFinished = { tools, _ in batches.append(tools) }
conversation.handle(.runStarted(.init(id: runID, state: .running, startedAt: .now)))
for name in ["memory", "memory_search", "session_search", "example"] {
let call = AgentToolCall(id: "call-\(name)", name: name, arguments: .object([:]))
let invocation = AgentToolInvocation(runID: runID, call: call)
conversation.handle(.toolStarted(invocation))
conversation.handle(.toolFinished(invocation, AgentToolResult(callID: call.id, content: "done")))
}
conversation.handle(.turnStarted(index: 1))
#expect(batches.count == 1)
#expect(batches.first?.count == 1)
}
@Test func searchSupportsChineseAndRemovesDeletedHistory() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let conversation = StoredAgentConversation(
id: UUID(),
messages: [
.init(role: .user, text: "部署服务器需要测试"), .init(role: .assistant, text: "Done"),
], lastRunState: .completed)
try await store.replaceHistory([conversation])
#expect(try await store.searchSessions(.init(query: "服务器")).encodedString.contains("部署"))
#expect(try await store.searchSessions(.init(query: "部署")).encodedString.contains("服务器"))
#expect(
try await store.searchSessions(.init(conversationID: conversation.id, offset: 1)).encodedString.contains(
"Done"))
try await store.replaceHistory([])
#expect(try await !store.searchSessions(.init(query: "服务器")).encodedString.contains("部署"))
try await store.close()
}
@Test func sessionSearchFallsBackToPartialEnglishTermsAndIgnoresPunctuation() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let deploy = AgentTranscriptMessage(role: .user, text: "Deploy the staging server")
let backup = AgentTranscriptMessage(role: .user, text: "Backup the database")
try await store.replaceHistory([
.init(id: UUID(), messages: [deploy], lastRunState: .completed),
.init(id: UUID(), messages: [backup], lastRunState: .completed),
])
let plain = try await store.searchSessions(.init(query: "deploy backup"))
let punctuated = try await store.searchSessions(.init(query: "deploy, backup?"))
#expect(try Set(messageIDs(in: plain)) == [deploy.id, backup.id])
#expect(try Set(messageIDs(in: punctuated)) == [deploy.id, backup.id])
#expect(try await messageIDs(in: store.searchSessions(.init(query: "quantum octopus"))).isEmpty)
try await store.close()
}
@Test func sessionSearchFindsChineseQuestionsAndShortTermsWithinScope() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let matchingID = UUID()
let unrelatedID = UUID()
let matching = AgentTranscriptMessage(role: .user, text: "服务器部署失败,请检查端口")
let unrelated = AgentTranscriptMessage(role: .user, text: "服务器监控正常")
try await store.replaceHistory([
.init(id: matchingID, messages: [matching], lastRunState: .completed),
.init(id: unrelatedID, messages: [unrelated], lastRunState: .completed),
])
#expect(try await messageIDs(in: store.searchSessions(.init(query: "服务器部署失败怎么办"))) == [matching.id])
#expect(try await messageIDs(in: store.searchSessions(.init(query: "部署"))) == [matching.id])
#expect(try await messageIDs(in: store.searchSessions(.init(query: "服务器怎么部署?"))) == [matching.id])
#expect(try await messageIDs(in: store.searchSessions(.init(query: "部署", conversationID: unrelatedID))).isEmpty)
#expect(
try await messageIDs(in: store.searchSessions(.init(query: "部署", conversationID: matchingID))) == [
matching.id
])
#expect(try await messageIDs(in: store.searchSessions(.init(conversationID: matchingID))) == [matching.id])
try await store.close()
}
@Test func sessionSearchKeepsStrictPagesAndRanksMoreRelevantShortTermMatches() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let old = Date(timeIntervalSince1970: 100)
let recent = Date(timeIntervalSince1970: 200)
let strict = AgentTranscriptMessage(role: .user, text: "deploy backup")
let partial = AgentTranscriptMessage(role: .user, text: "deploy only")
let relevantChinese = AgentTranscriptMessage(role: .user, text: "服务器部署记录")
let recentChinese = AgentTranscriptMessage(role: .user, text: "服务器状态正常")
try await store.replaceHistory([
.init(id: UUID(), messages: [strict], lastRunState: .completed, metadata: .init(updatedAt: old)),
.init(id: UUID(), messages: [partial], lastRunState: .completed, metadata: .init(updatedAt: recent)),
.init(id: UUID(), messages: [relevantChinese], lastRunState: .completed, metadata: .init(updatedAt: old)),
.init(id: UUID(), messages: [recentChinese], lastRunState: .completed, metadata: .init(updatedAt: recent)),
])
let first = try await store.searchSessions(.init(query: "deploy backup", limit: 1))
#expect(try messageIDs(in: first) == [strict.id])
#expect(first.objectValue?["next_offset"] == .null)
#expect(
try await messageIDs(in: store.searchSessions(.init(query: "deploy backup", offset: 1, limit: 1))).isEmpty)
let chineseQuery = "服务器 故障 部署"
let chineseFirst = try await store.searchSessions(.init(query: chineseQuery, limit: 1))
let chineseSecond = try await store.searchSessions(.init(query: chineseQuery, offset: 1, limit: 1))
#expect(try messageIDs(in: chineseFirst) == [relevantChinese.id])
#expect(try messageIDs(in: chineseSecond) == [recentChinese.id])
#expect(chineseFirst.objectValue?["next_offset"]?.integerValue == 1)
#expect(
try await messageIDs(in: store.searchSessions(.init(query: chineseQuery, limit: 1))) == [relevantChinese.id]
)
try await store.close()
}
@Test func sessionSearchUsesBM25BeforeConversationRecencyAndPreservesAccents() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let relevant = AgentTranscriptMessage(role: .user, text: "deploy backup deploy backup deploy backup")
let recent = AgentTranscriptMessage(
role: .user, text: "deploy backup with many unrelated words about weather music images and travel")
let accented = AgentTranscriptMessage(role: .user, text: "Café deployment notes")
try await store.replaceHistory([
.init(
id: UUID(), messages: [relevant], lastRunState: .completed,
metadata: .init(updatedAt: Date(timeIntervalSince1970: 100))),
.init(
id: UUID(), messages: [recent], lastRunState: .completed,
metadata: .init(updatedAt: Date(timeIntervalSince1970: 200))),
.init(id: UUID(), messages: [accented], lastRunState: .completed),
])
#expect(try await messageIDs(in: store.searchSessions(.init(query: "deploy backup"))).first == relevant.id)
#expect(try await messageIDs(in: store.searchSessions(.init(query: "Café"))) == [accented.id])
#expect(try await messageIDs(in: store.searchSessions(.init(query: "cafe"))).isEmpty)
try await store.close()
}
@Test func sessionSearchMatchesUppercaseAccentsWithShortTermsThroughTool() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let matching = AgentTranscriptMessage(role: .user, text: "CAFÉ go live checklist")
let plain = AgentTranscriptMessage(role: .user, text: "CAFE go live checklist")
try await store.replaceHistory([
.init(id: UUID(), messages: [matching], lastRunState: .completed),
.init(id: UUID(), messages: [plain], lastRunState: .completed),
])
let tool = SessionSearchTool(store: store)
let invocation = AgentToolInvocation(
runID: UUID(),
call: .init(
id: "accent-search", name: "session_search",
arguments: .object(["query": .string("café go")])))
let context = AgentToolExecutionContext(
runID: invocation.runID, userIntent: "Find the café checklist", secretBroker: SecretBroker(),
userInteraction: UnavailableAgentUserInteraction(), preflightMetadata: [:], authorize: { _ in .allow })
let result = try await tool.execute(invocation, context: context)
#expect(result.callID == invocation.call.id)
#expect(result.content.contains(matching.id.uuidString))
#expect(!result.content.contains(plain.id.uuidString))
try await store.close()
}
@Test func defaultMemorySearchFindsStoredQuestionAndPartialMatches() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
_ = try await store.applyMemory([
.init(action: .add, target: .memory, content: "服务器部署失败时检查防火墙"),
.init(action: .add, target: .memory, content: "Project Atlas uses the staging server"),
.init(action: .add, target: .user, content: "Backup jobs run every night"),
])
let chinese = try await store.searchMemories(.init(query: "服务器部署失败怎么办"))
let punctuated = try await store.searchMemories(.init(query: "Atlas, staging?"))
let broad = try await store.searchMemories(.init(query: "Atlas backup"))
#expect(try memoryContents(in: chinese).contains("服务器部署失败时检查防火墙"))
#expect(try memoryContents(in: punctuated).contains("Project Atlas uses the staging server"))
#expect(
try Set(memoryContents(in: broad)) == [
"Project Atlas uses the staging server", "Backup jobs run every night",
])
let tool = MemorySearchTool(store: store)
let invocation = AgentToolInvocation(
runID: UUID(),
call: .init(
id: "memory-search", name: "memory_search",
arguments: .object(["query": .string("Atlas backup"), "target": .string("memory")])))
let context = AgentToolExecutionContext(
runID: invocation.runID, userIntent: "Find saved project memory", secretBroker: SecretBroker(),
userInteraction: UnavailableAgentUserInteraction(), preflightMetadata: [:], authorize: { _ in .allow })
let result = try await tool.execute(invocation, context: context)
#expect(result.callID == invocation.call.id)
#expect(result.content.contains("Project Atlas uses the staging server"))
#expect(!result.content.contains("Backup jobs run every night"))
try await store.close()
}
@Test func destroyRevokesActiveAccessAndReenableCanBackfillOldHistory() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let store = GRDBMemoryStore(directory: url)
let model = MemoryModel(storage: store)
let history = InMemoryAgentRunRepository()
let conversationID = UUID()
await history.save(
.init(
conversationID: conversationID, state: .completed, permissionMode: .askForApproval,
messages: [.init(role: .user, text: "Historical zebra fact")]))
model.attachHistory(history)
await model.setEnabled(true)
let session = try #require(await model.session(conversationID))
_ = try await session.access.applyMemory([.init(action: .add, target: .memory, content: "Temporary memory")])
await model.destroy()
#expect(!model.isEnabled)
#expect(!FileManager.default.fileExists(atPath: url.path))
await #expect(throws: (any Error).self) {
try await session.access.applyMemory([.init(action: .add, target: .memory, content: "Late write")])
}
#expect(session.context.transform(.init(systemPrompt: "Base", messages: [], tools: [])).systemPrompt == "Base")
await model.destroy()
await model.setEnabled(true)
let fresh = try #require(await model.session(conversationID))
#expect(try await fresh.access.memoryState().entries.isEmpty)
#expect(try await fresh.access.searchSessions(.init(query: "zebra")).encodedString.contains("Historical"))
await model.setEnabled(false)
}
@Test func suspendedMemoryDefersTerminalHistoryIndexUntilResume() async throws {
let store = MemoryLifecycleStore()
let model = MemoryModel(storage: store)
let history = InMemoryAgentRunRepository()
let repository = MemoryIndexedAgentRepository(base: history, memory: model)
await model.setEnabled(true)
await store.waitForHistoryReplacementCount(1)
await waitForIndexingToFinish(model)
model.suspend()
let conversationID = UUID()
try await repository.save(
.init(
conversationID: conversationID, state: .completed, permissionMode: .askForApproval,
messages: [.init(role: .user, text: "Saved after suspension")]))
#expect(!model.isIndexing)
#expect(await store.historyReplacementCount == 1)
model.resume()
await store.waitForHistoryReplacementCount(2)
await waitForIndexingToFinish(model)
#expect(await store.lastHistory?.contains(where: { $0.id == conversationID }) == true)
}
@Test func suspendInvalidatesExistingMemoryAccessAcrossResume() async throws {
let store = MemoryLifecycleStore()
let model = MemoryModel(storage: store)
await model.setEnabled(true)
let conversationID = UUID()
let original = try #require(await model.session(conversationID))
model.suspend()
#expect(await model.session(conversationID) == nil)
model.resume()
await #expect(throws: CancellationError.self) { try await original.access.memoryState() }
let resumed = try #require(await model.session(conversationID))
_ = try await resumed.access.memoryState()
}
@Test func resumeReconcilesACommittedWriteFromTheSuspendedGeneration() async throws {
let store = MemoryLifecycleStore(blocksApply: true)
let model = MemoryModel(storage: store)
await model.setEnabled(true)
let original = try #require(await model.session(UUID()))
let write = Task {
try await original.access.applyMemory([
.init(action: .add, target: .memory, content: "Committed during suspension")
])
}
await store.waitUntilApplyEntered()
model.suspend()
model.resume()
try await waitUntil { await store.memoryStateCallCount >= 2 }
await store.releaseApply()
await #expect(throws: CancellationError.self) { try await write.value }
try await waitUntil {
await store.memoryStateCallCount >= 3
&& model.state.text(.memory) == "Committed during suspension"
}
await #expect(throws: CancellationError.self) { try await original.access.memoryState() }
}
@Test func suspendPreventsStaleReviewFromApplyingAfterResume() async throws {
let store = MemoryLifecycleStore()
let model = MemoryModel(storage: store)
await model.setEnabled(true)
let reviewModel = BlockingMemoryReviewModel()
let review = Task {
await model.review(
conversationID: UUID(), messages: [.init(role: .user, text: "Remember concise replies")],
model: reviewModel, wait: true)
}
await reviewModel.waitUntilEntered()
model.suspend()
model.resume()
await reviewModel.release()
await review.value
#expect(await store.reviewApplyCount == 0)
#expect(model.state.entries.isEmpty)
}
@Test func skillPackagesPersistWithoutChangingLegacySkillRows() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let persistence = SkillPersistence(context: container.mainContext)
let skill = AgentSkill(
name: "example", summary: "Example", body: "Example",
package: .init(frontmatter: "metadata:\n owner: team", files: ["assets/raw": Data([0, 255])]))
try persistence.save([skill])
#expect(try persistence.load() == [skill])
try persistence.save([])
#expect(try container.mainContext.fetchCount(FetchDescriptor<PersistentSkillPackage>()) == 0)
}
@Test func olderSyncPayloadCannotDiscardSupportingFiles() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let defaults = UserDefaults(suiteName: "skill-sync-\(UUID())")!
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
let skill = AgentSkill(
name: "example", summary: "Example", body: "Original",
package: .init(files: ["references/guide": Data("Keep".utf8)]))
try store.saveSkills([skill])
var legacy = skill
legacy.package = nil
legacy.body = "Edited on an old client"
let payload = SettingsRecordPayload.skill(legacy, sortIndex: 0)
let result = try store.applyRemote([
SettingsEnvelope(
key: payload.key,
version: .init(modifiedAt: .now.addingTimeInterval(60), counter: 1, writerID: UUID()), payload: payload)
])
#expect(result.snapshot.skills.first?.body == legacy.body)
#expect(result.snapshot.skills.first?.package == skill.package)
#expect(result.needsUpload.contains(payload.key))
}
@Test func skillImporterRejectsSymbolicLinks() throws {
let url = directory()
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: url) }
let file = url.appending(path: "original.md")
try Data("---\nname: example\ndescription: Example\n---\nBody".utf8).write(to: file)
try FileManager.default.createSymbolicLink(at: url.appending(path: "SKILL.md"), withDestinationURL: file)
#expect(throws: (any Error).self) { try SkillImporter.read(url) }
}
@Test func failedDestroyStaysDisabledAndStartupRetriesIt() async throws {
let url = directory()
defer { try? FileManager.default.removeItem(at: url) }
let suite = "destroy-test-\(UUID())"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let base = GRDBMemoryStore(directory: url)
let failing = FailingDestroyStore(base: base)
let model = MemoryModel(storage: failing, defaults: defaults)
await model.setEnabled(true)
await model.destroy()
#expect(!model.isEnabled)
#expect(model.error != nil)
#expect(defaults.bool(forKey: "hermesMemoryDestroyPending"))
await model.setEnabled(true)
#expect(!model.isEnabled)
let restarted = MemoryModel(storage: base, defaults: defaults)
for _ in 0..<200 where defaults.bool(forKey: "hermesMemoryDestroyPending") {
try await Task.sleep(for: .milliseconds(5))
}
#expect(!restarted.isEnabled)
#expect(!defaults.bool(forKey: "hermesMemoryDestroyPending"))
#expect(!FileManager.default.fileExists(atPath: url.path))
}
}
private actor MemoryLifecycleStore: MemoryPersisting {
func prepareSession(_ conversationID: UUID) {}
private var memory = AgentMemoryState()
private var historyReplacements: [[StoredAgentConversation]] = []
private let blocksApply: Bool
private let blocksLearningProgress: Bool
private var applyEntered = false
private var applyReleased = false
private var historyReplacementWaiters: [Int: [CheckedContinuation<Void, Never>]] = [:]
private var applyEntryWaiters: [CheckedContinuation<Void, Never>] = []
private var applyReleaseWaiters: [CheckedContinuation<Void, Never>] = []
private var learningProgressEntered = false
private var learningProgressReleased = false
private var learningProgressEntryWaiters: [CheckedContinuation<Void, Never>] = []
private var learningProgressReleaseWaiters: [CheckedContinuation<Void, Never>] = []
private(set) var reviewApplyCount = 0
private(set) var memoryStateCallCount = 0
private var progress = MemoryLearningProgress()
var historyReplacementCount: Int { historyReplacements.count }
var lastHistory: [StoredAgentConversation]? { historyReplacements.last }
init(blocksApply: Bool = false, blocksLearningProgress: Bool = false) {
self.blocksApply = blocksApply
self.blocksLearningProgress = blocksLearningProgress
}
func waitUntilLearningProgressEntered() async {
if learningProgressEntered { return }
await withCheckedContinuation { learningProgressEntryWaiters.append($0) }
}
func releaseLearningProgress() {
learningProgressReleased = true
let waiters = learningProgressReleaseWaiters
learningProgressReleaseWaiters.removeAll()
for waiter in waiters { waiter.resume() }
}
func waitForHistoryReplacementCount(_ count: Int) async {
guard historyReplacements.count < count else { return }
await withCheckedContinuation { historyReplacementWaiters[count, default: []].append($0) }
}
func waitUntilApplyEntered() async {
if applyEntered { return }
await withCheckedContinuation { applyEntryWaiters.append($0) }
}
func releaseApply() {
applyReleased = true
let waiters = applyReleaseWaiters
applyReleaseWaiters.removeAll()
for waiter in waiters { waiter.resume() }
}
func rebuildHistoryIndex() {}
func memoryReferenceDates() -> [UUID: Date] { [:] }
func recordMemoryReferences(_ ids: [UUID], at date: Date) {}
func memoryState() -> AgentMemoryState {
memoryStateCallCount += 1
return memory
}
func applyMemory(_ operations: [AgentMemoryOperation]) async throws -> AgentMemoryState {
if blocksApply {
applyEntered = true
let waiters = applyEntryWaiters
applyEntryWaiters.removeAll()
for waiter in waiters { waiter.resume() }
if !applyReleased {
await withCheckedContinuation { applyReleaseWaiters.append($0) }
}
}
memory = try memory.applying(operations)
return memory
}
func replaceHistory(_ conversations: [StoredAgentConversation]) {
historyReplacements.append(conversations)
let readyCounts = historyReplacementWaiters.keys.filter { $0 <= historyReplacements.count }
let waiters = readyCounts.flatMap { historyReplacementWaiters.removeValue(forKey: $0) ?? [] }
for waiter in waiters { waiter.resume() }
}
func searchSessions(_ request: AgentSessionSearchRequest) -> AgentJSONValue { .array([]) }
func recordActivity(
_ id: UUID, sourceMessageID: UUID, kind: MemoryActivityKind,
conversationID: UUID
) -> MemoryLearningProgress {
progress.events.append(.init(id: id, sourceMessageID: sourceMessageID))
if kind == .turn { progress.turns += 1 } else { progress.tools += 1 }
return progress
}
func learningProgress(_ id: UUID) async -> MemoryLearningProgress {
if blocksLearningProgress {
learningProgressEntered = true
let waiters = learningProgressEntryWaiters
learningProgressEntryWaiters.removeAll()
for waiter in waiters { waiter.resume() }
if !learningProgressReleased {
await withCheckedContinuation { learningProgressReleaseWaiters.append($0) }
}
}
return progress
}
func correctionWasReviewed(_ messageID: UUID, conversationID: UUID) -> Bool { false }
func applyCorrection(
_ operations: [AgentMemoryOperation], expected: AgentMemoryState,
messageID: UUID, conversationID: UUID
) throws -> AgentMemoryState? {
guard memory == expected else { return nil }
if !operations.isEmpty { memory = try memory.applying(operations) }
return memory
}
func applyReview(
_ operations: [AgentMemoryOperation], expected: AgentMemoryState, messageID: UUID,
conversationID: UUID, reviewedActivityIDs: [UUID]
) throws -> AgentMemoryState? {
reviewApplyCount += 1
guard memory == expected else { return nil }
if !operations.isEmpty { memory = try memory.applying(operations) }
return memory
}
func replaceTarget(_ target: AgentMemoryTarget, text: String) -> AgentMemoryState { memory }
func close() {}
func destroy() { memory = .init() }
}
private actor BlockingMemoryReviewModel: AgentModelCompleting {
private var entered = false
private var released = false
private var entryWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
func waitUntilEntered() async {
if entered { return }
await withCheckedContinuation { entryWaiters.append($0) }
}
func release() {
released = true
let waiters = releaseWaiters
releaseWaiters.removeAll()
for waiter in waiters { waiter.resume() }
}
func complete(_ request: AgentModelRequest) async throws -> [AgentModelStreamEvent] {
entered = true
let waiters = entryWaiters
entryWaiters.removeAll()
for waiter in waiters { waiter.resume() }
if !released {
await withCheckedContinuation { releaseWaiters.append($0) }
}
return [
.textDelta(#"{"operations":[{"action":"add","target":"user","content":"Likes concise replies"}]}"#),
.finished(.completed),
]
}
}
private actor CountingMemoryReviewModel: AgentModelCompleting {
private(set) var count = 0
func complete(_ request: AgentModelRequest) async throws -> [AgentModelStreamEvent] {
count += 1
return [.textDelta(#"{"operations":[]}"#), .finished(.completed)]
}
}
private actor SequencedMemoryReviewModel: AgentModelCompleting {
private var replies: [String]
private(set) var count = 0
init(replies: [String]) { self.replies = replies }
func complete(_ request: AgentModelRequest) async throws -> [AgentModelStreamEvent] {
count += 1
guard !replies.isEmpty else { throw CancellationError() }
return [.textDelta(replies.removeFirst()), .finished(.completed)]
}
}
private actor FailingFirstMemoryReviewModel: AgentModelCompleting {
private(set) var count = 0
func complete(_ request: AgentModelRequest) async throws -> [AgentModelStreamEvent] {
count += 1
if count == 1 { throw MemoryReviewTestFailure() }
let result =
count == 2
? #"{"operations":[{"action":"replace","target":"memory","old_text":"npm","content":"For project A, use pnpm"}]}"#
: #"{"operations":[{"action":"replace","target":"memory","old_text":"pnpm","content":"For project A, use yarn"}]}"#
return [.textDelta(result), .finished(.completed)]
}
}
private struct MemoryReviewTestFailure: Error {}
@MainActor
private func waitUntil(
timeout: Duration = .seconds(10), _ condition: @escaping @MainActor () async -> Bool
) async throws {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while !(await condition()) {
guard clock.now < deadline else { throw MemoryLifecycleTestTimeout() }
try await Task.sleep(for: .milliseconds(1))
}
}
@MainActor
private func waitForIndexingToFinish(_ model: MemoryModel) async {
while model.isIndexing { await Task.yield() }
}
private struct MemoryLifecycleTestTimeout: Error {}
private struct FailingDestroyStore: MemoryPersisting {
let base: GRDBMemoryStore
func prepareSession(_ conversationID: UUID) async throws { try await base.prepareSession(conversationID) }
func rebuildHistoryIndex() async throws { try await base.rebuildHistoryIndex() }
func memoryState() async throws -> AgentMemoryState { try await base.memoryState() }
func memoryReferenceDates() async throws -> [UUID: Date] { try await base.memoryReferenceDates() }
func recordMemoryReferences(_ ids: [UUID], at date: Date) async throws {
try await base.recordMemoryReferences(ids, at: date)
}
func applyMemory(_ operations: [AgentMemoryOperation]) async throws -> AgentMemoryState {
try await base.applyMemory(operations)
}
func replaceHistory(_ conversations: [StoredAgentConversation]) async throws {
try await base.replaceHistory(conversations)
}
func searchSessions(_ request: AgentSessionSearchRequest) async throws -> AgentJSONValue {
try await base.searchSessions(request)
}
func recordActivity(
_ id: UUID, sourceMessageID: UUID, kind: MemoryActivityKind,
conversationID: UUID
) async throws -> MemoryLearningProgress {
try await base.recordActivity(
id, sourceMessageID: sourceMessageID, kind: kind,
conversationID: conversationID)
}
func learningProgress(_ id: UUID) async throws -> MemoryLearningProgress { try await base.learningProgress(id) }
func correctionWasReviewed(_ messageID: UUID, conversationID: UUID) async throws -> Bool {
try await base.correctionWasReviewed(messageID, conversationID: conversationID)
}
func applyCorrection(
_ operations: [AgentMemoryOperation], expected: AgentMemoryState,
messageID: UUID, conversationID: UUID
) async throws -> AgentMemoryState? {
try await base.applyCorrection(
operations, expected: expected, messageID: messageID,
conversationID: conversationID)
}
func applyReview(
_ operations: [AgentMemoryOperation], expected: AgentMemoryState, messageID: UUID,
conversationID: UUID, reviewedActivityIDs: [UUID]
) async throws -> AgentMemoryState? {
try await base.applyReview(
operations, expected: expected, messageID: messageID,
conversationID: conversationID, reviewedActivityIDs: reviewedActivityIDs)
}
func replaceTarget(_ target: AgentMemoryTarget, text: String) async throws -> AgentMemoryState {
try await base.replaceTarget(target, text: text)
}
func close() async throws { try await base.close() }
func destroy() async throws { throw CocoaError(.fileWriteNoPermission) }
}