Files
Termind/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift
T
Kaede Akino 3a59d89c52 feat(memory): retrieve on demand and learn from activity
Review every 10 turns or 15 non-memory tool calls and save explicit corrections promptly. Invalidate outdated learning after lifecycle and history changes, and prevent older correction retries from overwriting newer corrections.

Replace capacity bars with entry and character counts, retain per-entry and model-input budgets, and migrate away from frozen snapshots. Upgrade the AgentKit package and submodule to signed release 0.15.0.
2026-09-25 17:38:28 +08:00

611 lines
28 KiB
Swift

import AgentKit
import Foundation
@Observable @MainActor
public final class MemoryModel {
public private(set) var isEnabled: Bool
public private(set) var isBusy = false
public private(set) var isIndexing = false
public private(set) var state = AgentMemoryState()
public private(set) var error: String?
public var reviewEnabled: Bool {
didSet {
defaults?.set(reviewEnabled, forKey: Self.reviewKey)
if !reviewEnabled {
for task in reviews.values { task.cancel() }
for task in corrections.values { task.cancel() }
for task in correctionRetries.values { task.cancel() }
pendingReviews.removeAll()
pendingCorrections.removeAll()
queuedCorrectionIDs.removeAll()
}
}
}
public var reviewModel: AIModelRef? {
didSet { defaults?.set(try? JSONEncoder().encode(reviewModel), forKey: Self.modelKey) }
}
private static let enabledKey = "hermesMemoryEnabled"
private static let destroyingKey = "hermesMemoryDestroyPending"
private static let reviewKey = "hermesMemoryReviewEnabled"
private static let modelKey = "hermesMemoryReviewModel"
private let defaults: UserDefaults?
private let storage: (any MemoryPersisting)?
private var generation = UUID()
private var contexts: [UUID: AgentMemoryContext] = [:]
private var history: (any AgentRunPersisting)?
private var indexTask: Task<Void, Never>?
private var indexDirty = false
private var reviews: [UUID: Task<Void, Never>] = [:]
private var pendingReviews: [UUID: (@Sendable () async -> Void)] = [:]
private var corrections: [UUID: Task<Void, Never>] = [:]
private var pendingCorrections:
[UUID: [(AgentTranscriptMessage, [AgentTranscriptMessage], any AgentModelCompleting)]] = [:]
private var queuedCorrectionIDs: Set<UUID> = []
private var correctionRetries: [UUID: Task<Void, Never>] = [:]
private var correctionRetryConversations: [UUID: UUID] = [:]
private var correctionFailures: [UUID: Int] = [:]
private var correctionOrders: [UUID: [UUID: Int]] = [:]
private var nextCorrectionOrder: [UUID: Int] = [:]
private var latestSuccessfulCorrectionOrder: [UUID: Int] = [:]
private var retryAfter: [UUID: Date] = [:]
private var learningEpochs: [UUID: UUID] = [:]
private var isSuspended = false
public func learningEpoch(for conversationID: UUID) -> UUID {
if let existing = learningEpochs[conversationID] { return existing }
let fresh = UUID()
learningEpochs[conversationID] = fresh
return fresh
}
public func invalidateLearning(for conversationID: UUID) {
learningEpochs[conversationID] = UUID()
reviews[conversationID]?.cancel()
corrections[conversationID]?.cancel()
pendingReviews.removeValue(forKey: conversationID)
pendingCorrections.removeValue(forKey: conversationID)
correctionOrders.removeValue(forKey: conversationID)
nextCorrectionOrder.removeValue(forKey: conversationID)
latestSuccessfulCorrectionOrder.removeValue(forKey: conversationID)
retryAfter.removeValue(forKey: conversationID)
for id in correctionRetryConversations.filter({ $0.value == conversationID }).keys {
correctionRetries.removeValue(forKey: id)?.cancel()
correctionRetryConversations.removeValue(forKey: id)
}
}
public func learningToken(for context: AgentMemoryContext) -> UUID? {
guard isEnabled, !isSuspended, !isBusy,
contexts.values.contains(where: { $0 === context })
else { return nil }
return generation
}
public init(storage: (any MemoryPersisting)? = nil, defaults: UserDefaults? = nil) {
self.storage = storage
self.defaults = defaults
let pending = defaults?.bool(forKey: Self.destroyingKey) == true
isEnabled = !pending && storage != nil && defaults?.bool(forKey: Self.enabledKey) == true
reviewEnabled = defaults?.object(forKey: Self.reviewKey) as? Bool ?? true
reviewModel = defaults?.data(forKey: Self.modelKey).flatMap {
try? JSONDecoder().decode(AIModelRef.self, from: $0)
}
if pending { Task { await destroy() } }
}
public func attachHistory(_ history: any AgentRunPersisting) {
self.history = history
historyChanged()
}
private func invalidate() {
generation = UUID()
for context in contexts.values { context.invalidate() }
contexts.removeAll()
indexTask?.cancel()
for task in reviews.values { task.cancel() }
for task in corrections.values { task.cancel() }
for task in correctionRetries.values { task.cancel() }
pendingReviews.removeAll()
pendingCorrections.removeAll()
queuedCorrectionIDs.removeAll()
correctionRetries.removeAll()
correctionRetryConversations.removeAll()
correctionFailures.removeAll()
correctionOrders.removeAll()
nextCorrectionOrder.removeAll()
latestSuccessfulCorrectionOrder.removeAll()
retryAfter.removeAll()
learningEpochs.removeAll()
}
public func setEnabled(_ enabled: Bool) async {
guard !isBusy, let storage, defaults?.bool(forKey: Self.destroyingKey) != true else { return }
isBusy = true
defer { isBusy = false }
invalidate()
isEnabled = enabled
defaults?.set(enabled, forKey: Self.enabledKey)
error = nil
do {
if enabled {
state = try await storage.memoryState()
historyChanged()
} else {
try await storage.close()
}
} catch { self.error = error.localizedDescription }
}
public func refresh() async {
guard isEnabled, !isSuspended, let storage else { return }
let token = generation
do {
let value = try await storage.memoryState()
if token == generation {
state = value
error = nil
}
} catch { if token == generation { self.error = error.localizedDescription } }
}
public func edit(_ target: AgentMemoryTarget, text: String) async {
guard isEnabled, !isSuspended, let storage else { return }
let token = generation
do {
let updated = try await storage.replaceTarget(target, text: text)
try publishCommittedState(updated, token: token)
error = nil
} catch { if token == generation { self.error = error.localizedDescription } }
}
public func session(_ id: UUID) async -> (context: AgentMemoryContext, access: any AgentMemoryAccessing)? {
guard isEnabled, !isSuspended, let storage else { return nil }
let token = generation
do {
// Open and migrate the store before exposing tools to the run.
try await storage.prepareSession(id)
guard token == generation, isEnabled else { return nil }
let context = contexts[id] ?? AgentMemoryContext()
contexts[id] = context
return (context, MemoryAccess(model: self, token: token))
} catch {
if token == generation { self.error = error.localizedDescription }
return nil
}
}
private func require(_ token: UUID) throws -> any MemoryPersisting {
try Task.checkCancellation()
guard isEnabled, !isSuspended, token == generation, !isBusy, let storage else { throw CancellationError() }
return storage
}
private func publishCommittedState(_ updated: AgentMemoryState, token: UUID) throws {
do {
_ = try require(token)
} catch {
reloadStateForCurrentGeneration()
throw error
}
state = updated
}
private func reloadStateForCurrentGeneration() {
guard isEnabled, !isSuspended, !isBusy, let storage else { return }
let token = generation
Task { [weak self] in
guard let self else { return }
do {
let value = try await storage.memoryState()
guard token == generation, isEnabled, !isSuspended, !isBusy else { return }
state = value
error = nil
} catch {
guard token == generation, isEnabled, !isSuspended, !isBusy else { return }
self.error = error.localizedDescription
}
}
}
fileprivate func read(token: UUID) async throws -> AgentMemoryState {
let result = try await require(token).memoryState()
_ = try require(token)
return result
}
@discardableResult
fileprivate func apply(_ operations: [AgentMemoryOperation], token: UUID) async throws -> AgentMemoryState {
let result = try await require(token).applyMemory(operations)
try publishCommittedState(result, token: token)
return result
}
fileprivate func search(_ request: AgentSessionSearchRequest, token: UUID) async throws -> AgentJSONValue {
_ = try require(token)
historyChanged()
await indexTask?.value
let result = try await require(token).searchSessions(request)
_ = try require(token)
return result
}
fileprivate func searchMemory(_ request: AgentMemorySearchRequest, token: UUID) async throws -> AgentJSONValue {
let result = try await require(token).searchMemories(request)
_ = try require(token)
return result
}
public func rebuildHistoryIndex() async {
guard isEnabled, !isSuspended else { return }
let token = generation
historyChanged()
await indexTask?.value
do {
try await require(token).rebuildHistoryIndex()
if token == generation { error = nil }
} catch { if token == generation { self.error = error.localizedDescription } }
}
public func historyChanged() {
guard isEnabled, !isSuspended, let storage, let history else { return }
indexDirty = true
guard indexTask == nil else { return }
let token = generation
isIndexing = true
indexTask = Task { [weak self] in
guard let self else { return }
defer {
indexTask = nil
isIndexing = false
if indexDirty, isEnabled, !isSuspended { historyChanged() }
}
do {
while indexDirty, token == generation, isEnabled, !Task.isCancelled {
indexDirty = false
let conversations = try await history.loadConversations()
_ = try require(token)
try await storage.replaceHistory(conversations)
}
} catch is CancellationError {
} catch {
if token == generation {
self.error = error.localizedDescription
indexDirty = false
}
}
}
}
public func recordActivity(
conversationID: UUID, userMessageIDs: [UUID], toolInvocations: [MemoryLearningEvent],
messages: [AgentTranscriptMessage], model: (any AgentModelCompleting)?,
sessionToken: UUID? = nil, historyEpoch: UUID? = nil
) async {
guard isEnabled, !isSuspended, reviewEnabled, let storage,
sessionToken == nil || sessionToken == generation,
historyEpoch == nil || historyEpoch == learningEpoch(for: conversationID)
else { return }
let token = generation
let epoch = learningEpoch(for: conversationID)
do {
var progress = try await storage.learningProgress(conversationID)
guard epoch == learningEpoch(for: conversationID) else { return }
for id in userMessageIDs {
_ = try require(token)
guard epoch == learningEpoch(for: conversationID) else { return }
progress = try await storage.recordActivity(
id, sourceMessageID: id, kind: .turn, conversationID: conversationID)
guard epoch == learningEpoch(for: conversationID) else { return }
}
for event in toolInvocations {
_ = try require(token)
guard epoch == learningEpoch(for: conversationID) else { return }
progress = try await storage.recordActivity(
event.id, sourceMessageID: event.sourceMessageID, kind: .tool,
conversationID: conversationID)
guard epoch == learningEpoch(for: conversationID) else { return }
}
guard let model, progress.turns >= 10 || progress.tools >= 15 else { return }
guard retryAfter[conversationID].map({ $0 <= .now }) ?? true else { return }
await review(
conversationID: conversationID, messages: messages, model: model,
force: false, expectedEpoch: epoch)
} catch is CancellationError {
} catch { if token == generation { self.error = error.localizedDescription } }
}
public nonisolated static func looksLikeCorrection(_ text: String) -> Bool {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !value.isEmpty, value.count <= 4_000 else { return false }
if ["没关系", "不是问题", "no problem", "no worries", "actually looks great"].contains(where: value.contains) {
return false
}
let strong = [
"纠正", "更正", "不对", "别再", "以后请", "以后不要", "记住", "我说错了",
"correction", "i meant", "not what i", "from now on", "remember that",
]
if strong.contains(where: value.contains) { return true }
if value.hasPrefix("no,") || value.hasPrefix("no ") || value.hasPrefix("不,") { return true }
if value.contains("不要") && (value.contains("用") || value.contains("改")) { return true }
if value.contains("不是") && (value.contains("而是") || value.contains("应该")) { return true }
if value.contains("actually") && (value.contains("instead") || value.contains("use ")) { return true }
return false
}
public func considerCorrection(
conversationID: UUID, message: AgentTranscriptMessage, context: [AgentTranscriptMessage],
model: any AgentModelCompleting, sessionToken: UUID? = nil, historyEpoch: UUID? = nil
) {
let authored = message.authoredText ?? ContextChip.visiblePrompt(in: message.text)
guard isEnabled, !isSuspended, reviewEnabled,
sessionToken == nil || sessionToken == generation,
historyEpoch == nil || historyEpoch == learningEpoch(for: conversationID),
Self.looksLikeCorrection(authored), !queuedCorrectionIDs.contains(message.id),
(correctionOrders[conversationID]?[message.id] ?? .max)
> (latestSuccessfulCorrectionOrder[conversationID] ?? 0)
else { return }
if correctionOrders[conversationID]?[message.id] == nil {
let order = nextCorrectionOrder[conversationID, default: 0] + 1
nextCorrectionOrder[conversationID] = order
correctionOrders[conversationID, default: [:]][message.id] = order
}
queuedCorrectionIDs.insert(message.id)
var authoredMessage = message
authoredMessage.authoredText = authored
pendingCorrections[conversationID, default: []].append((authoredMessage, context, model))
reviews[conversationID]?.cancel()
startNextCorrection(conversationID)
}
private func startNextCorrection(_ conversationID: UUID) {
guard corrections[conversationID] == nil,
let next = pendingCorrections[conversationID]?.first
else { return }
pendingCorrections[conversationID]?.removeFirst()
let token = generation
let epoch = learningEpoch(for: conversationID)
let order = correctionOrders[conversationID]?[next.0.id] ?? 0
let task = Task { [weak self] in
guard let self else { return }
defer {
queuedCorrectionIDs.remove(next.0.id)
corrections.removeValue(forKey: conversationID)
startNextCorrection(conversationID)
if pendingCorrections[conversationID]?.isEmpty != false,
let pending = pendingReviews.removeValue(forKey: conversationID)
{
Task { await pending() }
}
}
do {
guard order > (latestSuccessfulCorrectionOrder[conversationID] ?? 0) else { return }
let storage = try require(token)
guard try await !storage.correctionWasReviewed(next.0.id, conversationID: conversationID) else {
return
}
if let running = reviews[conversationID] { await running.value }
_ = try require(token)
guard epoch == learningEpoch(for: conversationID) else { return }
for _ in 0..<2 {
let current = try await read(token: token)
let operations = try await timedOperations {
try await AgentMemoryLearningService().correction(
message: next.0, context: next.1, state: current, model: next.2)
}
_ = try require(token)
guard epoch == learningEpoch(for: conversationID) else { return }
if let updated = try await storage.applyCorrection(
operations, expected: current, messageID: next.0.id, conversationID: conversationID)
{
try publishCommittedState(updated, token: token)
latestSuccessfulCorrectionOrder[conversationID] = order
for id in correctionRetryConversations.filter({ $0.value == conversationID }).keys {
if let retryOrder = correctionOrders[conversationID]?[id], retryOrder < order {
correctionRetries.removeValue(forKey: id)?.cancel()
correctionRetryConversations.removeValue(forKey: id)
}
}
correctionFailures.removeValue(forKey: next.0.id)
return
}
}
scheduleCorrectionRetry(next, conversationID: conversationID, token: token, epoch: epoch)
} catch is CancellationError {
} catch {
if token == generation {
self.error = error.localizedDescription
scheduleCorrectionRetry(next, conversationID: conversationID, token: token, epoch: epoch)
}
}
}
corrections[conversationID] = task
}
private func scheduleCorrectionRetry(
_ correction: (AgentTranscriptMessage, [AgentTranscriptMessage], any AgentModelCompleting),
conversationID: UUID, token: UUID, epoch: UUID
) {
guard epoch == learningEpoch(for: conversationID),
let order = correctionOrders[conversationID]?[correction.0.id],
order > (latestSuccessfulCorrectionOrder[conversationID] ?? 0)
else { return }
let failures = correctionFailures[correction.0.id, default: 0] + 1
correctionFailures[correction.0.id] = failures
let delay = min(300, 15 * (1 << min(failures, 5)))
correctionRetries[correction.0.id]?.cancel()
correctionRetryConversations[correction.0.id] = conversationID
correctionRetries[correction.0.id] = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard let self, !Task.isCancelled, token == generation,
epoch == learningEpoch(for: conversationID),
order > (latestSuccessfulCorrectionOrder[conversationID] ?? 0)
else { return }
correctionRetries.removeValue(forKey: correction.0.id)
correctionRetryConversations.removeValue(forKey: correction.0.id)
considerCorrection(
conversationID: conversationID, message: correction.0,
context: correction.1, model: correction.2, historyEpoch: epoch)
}
}
private func timedOperations(
_ work: @escaping @Sendable () async throws -> [AgentMemoryOperation]
) async throws -> [AgentMemoryOperation] {
try await withThrowingTaskGroup(of: [AgentMemoryOperation].self) { group in
group.addTask { try await work() }
group.addTask {
try await Task.sleep(for: .seconds(60))
throw MemoryReviewTimeout()
}
defer { group.cancelAll() }
return try await group.next() ?? []
}
}
public func review(
conversationID: UUID, messages: [AgentTranscriptMessage], model: any AgentModelCompleting,
wait: Bool = false, force: Bool = true, expectedEpoch: UUID? = nil
) async {
guard isEnabled, !isSuspended, reviewEnabled, let storage, !messages.isEmpty else { return }
let token = generation
let epoch = learningEpoch(for: conversationID)
guard expectedEpoch == nil || expectedEpoch == epoch else { return }
if let correction = corrections[conversationID] {
if wait {
await correction.value
} else {
pendingReviews[conversationID] = { [weak self] in
await self?.review(
conversationID: conversationID, messages: messages, model: model,
force: force, expectedEpoch: epoch)
}
return
}
}
if let running = reviews[conversationID] {
if wait {
await running.value
} else {
pendingReviews[conversationID] = { [weak self] in
await self?.review(
conversationID: conversationID, messages: messages, model: model,
force: force, expectedEpoch: epoch)
}
return
}
}
guard token == generation, epoch == learningEpoch(for: conversationID), isEnabled else { return }
let task = Task { [weak self] in
guard let self else { return }
defer {
reviews.removeValue(forKey: conversationID)
if corrections[conversationID] == nil,
let next = pendingReviews.removeValue(forKey: conversationID)
{
Task { await next() }
}
}
do {
var progress = try await storage.learningProgress(conversationID)
guard epoch == learningEpoch(for: conversationID) else { return }
guard force || progress.turns >= 10 || progress.tools >= 15 else { return }
guard force || (retryAfter[conversationID].map({ $0 <= .now }) ?? true) else { return }
while true {
let cursorIndex = progress.lastReviewedMessage.flatMap { cursor in
messages.firstIndex(where: { $0.id == cursor })
}
let pendingIndex = progress.events.compactMap { event in
messages.firstIndex(where: { $0.id == event.sourceMessageID })
}.min()
let start = min(cursorIndex.map { $0 + 1 } ?? 0, pendingIndex ?? messages.count)
guard start < messages.count else { return }
var end = start
while end < messages.count, end - start < 100 {
let candidate = end + 1
let serialized = AgentCompaction.serialized(
Array(messages[start..<candidate]), toolResultLimit: 300)
if serialized.count > 28_000, end > start { break }
end = candidate
}
let messageIDs = Set(messages[start..<end].map(\.id))
let reviewedIDs = progress.events.filter { messageIDs.contains($0.sourceMessageID) }.map(\.id)
var contextStart = max(0, start - 2)
while contextStart < start {
let withContext = AgentCompaction.serialized(
Array(messages[contextStart..<end]), toolResultLimit: 300)
if withContext.count <= 28_000 { break }
contextStart += 1
}
let delta = Array(messages[contextStart..<end])
let current = try await read(token: token)
let operations = try await timedOperations {
try await AgentMemoryLearningService().review(messages: delta, state: current, model: model)
}
_ = try require(token)
guard epoch == learningEpoch(for: conversationID) else { return }
guard
let updated = try await storage.applyReview(
operations, expected: current, messageID: messages[end - 1].id,
conversationID: conversationID, reviewedActivityIDs: reviewedIDs)
else {
retryAfter[conversationID] = .now.addingTimeInterval(30)
return
}
try publishCommittedState(updated, token: token)
retryAfter.removeValue(forKey: conversationID)
guard end < messages.count else { return }
progress = try await storage.learningProgress(conversationID)
}
} catch is CancellationError {
} catch {
if token == generation {
self.error = error.localizedDescription
retryAfter[conversationID] = .now.addingTimeInterval(30)
}
}
}
reviews[conversationID] = task
if wait { await task.value }
}
public func destroy() async {
guard !isBusy, let storage else { return }
isBusy = true
isEnabled = false
defaults?.set(false, forKey: Self.enabledKey)
defaults?.set(true, forKey: Self.destroyingKey)
invalidate()
indexDirty = false
let pending = Array(reviews.values)
let correctionPending = Array(corrections.values)
let delayed = Array(correctionRetries.values)
await indexTask?.value
for task in pending { await task.value }
for task in correctionPending { await task.value }
for task in delayed { await task.value }
do {
try await storage.destroy()
state = .init()
error = nil
defaults?.removeObject(forKey: Self.destroyingKey)
} catch { self.error = error.localizedDescription }
isBusy = false
}
public func suspend() {
guard !isSuspended else { return }
isSuspended = true
invalidate()
}
public func resume() {
guard isSuspended else { return }
isSuspended = false
reloadStateForCurrentGeneration()
historyChanged()
}
}
private struct MemoryReviewTimeout: Error {}
private struct MemoryAccess: AgentMemoryAccessing {
weak var model: MemoryModel?
let token: UUID
func memoryState() async throws -> AgentMemoryState {
guard let model else { throw CancellationError() }
return try await model.read(token: token)
}
func applyMemory(_ operations: [AgentMemoryOperation]) async throws -> AgentMemoryState {
guard let model else { throw CancellationError() }
return try await model.apply(operations, token: token)
}
func searchSessions(_ request: AgentSessionSearchRequest) async throws -> AgentJSONValue {
guard let model else { throw CancellationError() }
return try await model.search(request, token: token)
}
func searchMemories(_ request: AgentMemorySearchRequest) async throws -> AgentJSONValue {
guard let model else { throw CancellationError() }
return try await model.searchMemory(request, token: token)
}
}