// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import AgentKit import Foundation import SwiftData import TermindApplication @MainActor public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { private let context: ModelContext private var deletedConversationIDs = Set() private var deletedRunIDs = Set() public init(context: ModelContext) { self.context = context interruptUnfinishedRuns() } @MainActor public func save(_ snapshot: AgentRunSnapshot) async throws { guard !deletedConversationIDs.contains(snapshot.conversationID) else { deletedRunIDs.insert(snapshot.id) return } guard !deletedRunIDs.contains(snapshot.id) else { return } let previousDeletedRunIDs = deletedRunIDs do { let runID = snapshot.id let runDescriptor = FetchDescriptor(predicate: #Predicate { $0.id == runID }) let runRow: PersistentAgentRun if let row = try context.fetch(runDescriptor).first { let persistedStartedAt = row.startedAt row.update(snapshot) if row.startedAt < persistedStartedAt { row.startedAt = persistedStartedAt } if let finishedAt = row.finishedAt, finishedAt < row.startedAt { row.finishedAt = row.startedAt } runRow = row } else { let row = PersistentAgentRun(snapshot) context.insert(row) runRow = row } let conversationID = snapshot.conversationID let conversationDescriptor = FetchDescriptor( predicate: #Predicate { $0.id == conversationID } ) // No title seeding here. `title` means "the generated summary", and // empty means there is not one yet — which is what lets the UI decide to // fall back to the opening line rather than having that line frozen into // storage as though it were a name someone chose. if let conversation = try context.fetch(conversationDescriptor).first { conversation.updatedAt = .now } else { context.insert( PersistentAgentConversation( id: conversationID, title: "", createdAt: snapshot.startedAt )) } // Matched on message id, not on this run's id. A conversation's earlier // turns are replayed into every later run, so filtering by `runID` made // each save re-insert the whole history — colliding with the unique // constraint and rewriting rows that were already correct. let durableMessages = snapshot.messages.filter { !$0.isCompaction } let ids = Set(durableMessages.map(\.id)) let stored = try context.fetch( FetchDescriptor( predicate: #Predicate { ids.contains($0.id) } )) var storedByID = Dictionary(uniqueKeysWithValues: stored.map { ($0.id, $0) }) if let prompt = durableMessages.last(where: { $0.role == .user }), let stagedMessage = storedByID[prompt.id], stagedMessage.runID == prompt.id, prompt.id != runID, try isStagingReplacementRun(prompt.id, message: stagedMessage, conversationID: conversationID) { stagedMessage.runID = runID stagedMessage.sequence = durableMessages.firstIndex(where: { $0.id == prompt.id }) ?? 0 let stagingID = prompt.id let stagingDescriptor = FetchDescriptor( predicate: #Predicate { $0.id == stagingID } ) if let stagingRun = try context.fetch(stagingDescriptor).first { if runRow.startedAt < stagingRun.startedAt { runRow.startedAt = stagingRun.startedAt } if let finishedAt = runRow.finishedAt, finishedAt < runRow.startedAt { runRow.finishedAt = runRow.startedAt } context.delete(stagingRun) } deletedRunIDs.insert(stagingID) } let storedIDs = Set(storedByID.keys) var inserted = false for (sequence, message) in durableMessages.enumerated() where !storedIDs.contains(message.id) { let row = PersistentAgentMessage(message, runID: runID, sequence: sequence) context.insert(row) storedByID[message.id] = row insertContextAttachments(for: message) inserted = true try insertOrigin(for: message) } // A user row may already exist (editing or interrupted preparation) before // the run freezes its context. Add its backing once without rewriting history. let replayIDs = Set( try context.fetch(FetchDescriptor()).map(\.messageID)) for message in durableMessages where !replayIDs.contains(message.id) { insertReplay(for: message) } guard inserted || context.hasChanges else { return } try context.save() } catch { context.rollback() deletedRunIDs = previousDeletedRunIDs throw error } } @MainActor public func persistCompaction( _ record: AgentCompactionRecord, conversationID: UUID ) async throws { guard !deletedConversationIDs.contains(conversationID) else { throw AgentConversationPersistenceError.conversationNotFound } let conversations = try context.fetch( FetchDescriptor( predicate: #Predicate { $0.id == conversationID } )) guard conversations.first != nil else { throw AgentConversationPersistenceError.conversationNotFound } if let boundary = record.compactedThroughMessageID { let runIDs = Set( try context.fetch(FetchDescriptor()) .filter { $0.conversationID == conversationID }.map(\.id)) let rows = try context.fetch( FetchDescriptor( predicate: #Predicate { $0.id == boundary } )) guard rows.contains(where: { runIDs.contains($0.runID) }) else { throw AgentConversationPersistenceError.messageNotFound } } let recordID = record.id let existing = try context.fetch( FetchDescriptor( predicate: #Predicate { $0.id == recordID } )) guard existing.isEmpty else { return } context.insert(PersistentAgentCompaction(record, conversationID: conversationID)) try context.savePendingChanges() } @MainActor public func record(_ event: AgentEvent, runID: UUID) async throws { guard !deletedRunIDs.contains(runID) else { return } switch event { case .toolProposed(let invocation, _): try upsertInvocation(invocation, state: "proposed") case .toolStarted(let invocation): try upsertInvocation(invocation, state: "started") case .toolFinished(let invocation, let result): try upsertInvocation(invocation, state: "finished") context.insert(PersistentAgentToolResult(result, invocation: invocation)) case .reviewStarted(let request): context.insert( PersistentAgentReviewRecord( runID: runID, invocationID: request.invocation.id, decision: nil )) case .reviewFinished(let request, let decision): context.insert( PersistentAgentReviewRecord( runID: runID, invocationID: request.invocation.id, decision: decision )) case .reviewFailed(let request, let reason, let timedOut): context.insert( PersistentAgentReviewRecord( runID: runID, invocationID: request.invocation.id, failure: reason, timedOut: timedOut )) default: // Nothing to write, so nothing to save. `AgentRuntime` already // filters on `isDurable`; this is the second half of the same // guarantee, for any caller that does not. return } try context.save() } @MainActor public func loadConversations() async throws -> [StoredAgentConversation] { let conversations = try context.fetch( FetchDescriptor( sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] )) let orderRows = try context.fetch(FetchDescriptor()) let sortIndexByConversation = Dictionary( uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0.sortIndex) } ) let runs = try context.fetch(FetchDescriptor()) let messages = try context.fetch(FetchDescriptor()) let replayByMessage = Dictionary( uniqueKeysWithValues: try context.fetch(FetchDescriptor()).map { ($0.messageID, $0) }) let originByMessage = Dictionary( uniqueKeysWithValues: try context.fetch(FetchDescriptor()).map { ($0.messageID, $0.value) }) let compactionRows = try context.fetch(FetchDescriptor()) let attachmentRows = try context.fetch( FetchDescriptor() ) let attachmentsByMessage = Dictionary( uniqueKeysWithValues: attachmentRows.map { ($0.messageID, $0.attachments) } ) let invocations = try context.fetch(FetchDescriptor()) let results = try context.fetch(FetchDescriptor()) let reviews = try context.fetch(FetchDescriptor()) return conversations.map { conversation in let matchingRuns = runs.filter { $0.conversationID == conversation.id } .sorted(by: Self.runPrecedes) let runIDs = Set(matchingRuns.map(\.id)) // Ordered by run first, then by time within it. Sorting the whole // conversation on `createdAt` alone let two runs interleave whenever // their message timestamps were close, which reorders a transcript // the model is about to be shown. let runOrder = Dictionary( uniqueKeysWithValues: matchingRuns.enumerated().map { ($0.element.id, $0.offset) } ) let transcript = messages.filter { runIDs.contains($0.runID) } .sorted { lhs, rhs in Self.messagePrecedes(lhs, rhs, runOrder: runOrder) } .compactMap { row -> AgentTranscriptMessage? in guard let role = AgentTranscriptRole(rawValue: row.role) else { return nil } let calls = (try? JSONDecoder().decode( [AgentToolCall].self, from: row.toolCallsData )) ?? [] let images = (try? JSONDecoder().decode( [AgentImageAttachment].self, from: row.imagesData )) ?? [] let contextAttachments = attachmentsByMessage[row.id] ?? [] let reasoning = (try? JSONDecoder().decode( [AgentReasoningBlock].self, from: row.reasoningData )) ?? [] var text = row.text var modelText = replayByMessage[row.id]?.modelText if role == .tool { let result = results.last { $0.runID == row.runID && $0.callID == row.toolCallID }? .restoredResult modelText = modelText.map { AgentPersistenceRedactor.restoredToolResultText($0, fallbacks: [result?.modelContent]) } ?? result?.modelContent text = AgentPersistenceRedactor.restoredToolResultText( text, fallbacks: [result?.content, modelText]) } return AgentTranscriptMessage( id: row.id, role: role, text: text, authoredText: row.authoredText, images: images, contextAttachments: contextAttachments, reasoning: reasoning, toolCalls: calls, toolCallID: row.toolCallID, toolName: row.toolName, isError: row.isError, isCompaction: row.isCompaction, contextSnapshot: replayByMessage[row.id]?.contextSnapshot, modelText: modelText, origin: originByMessage[row.id], createdAt: row.createdAt ) } var messageRunIDs = Dictionary( uniqueKeysWithValues: messages.filter { runIDs.contains($0.runID) } .map { ($0.id, $0.runID) } ) let state = matchingRuns.last.flatMap { AgentRunState(rawValue: $0.state) } ?? .completed var sourceMessageIDsByRun: [UUID: [String: UUID]] = [:] for message in transcript where message.role == .assistant { guard let runID = messageRunIDs[message.id] else { continue } for call in message.toolCalls { sourceMessageIDsByRun[runID, default: [:]][call.id] = message.id } } let cards = invocations.filter { runIDs.contains($0.runID) } .sorted { $0.createdAt < $1.createdAt } .compactMap { row -> StoredAgentToolCard? in guard var card = storedToolCard(row, results: results, reviews: reviews) else { return nil } // Older invocation rows predate source-message ownership. The // raw transcript still has the exact run-local call grammar, // so project that identity on restore instead of guessing by time. if card.invocation.sourceMessageID == nil { card.invocation.sourceMessageID = sourceMessageIDsByRun[row.runID]?[row.callID] } return card } let matchingResults = results.filter { runIDs.contains($0.runID) } let recoveredTranscript = Self.recovering( transcript, results: matchingResults, invocations: invocations.filter { runIDs.contains($0.runID) }) let recoveredMessageIDs = Set(recoveredTranscript.map(\.id)) for result in matchingResults { let messageID = AgentTranscriptMessage.toolResultID( runID: result.runID, callID: result.callID) if recoveredMessageIDs.contains(messageID) { messageRunIDs[messageID] = result.runID } } let projection = AgentCompactionProjection.restore( messages: recoveredTranscript, records: compactionRows.filter { $0.conversationID == conversation.id } .map(\.value) ) return StoredAgentConversation( id: conversation.id, messages: projection.messages, modelTranscript: projection.modelTranscript, compactions: projection.records, messageRunIDs: messageRunIDs, runs: matchingRuns.compactMap { run in guard let state = AgentRunState(rawValue: run.state) else { return nil } return AgentRunSummary( id: run.id, state: state, startedAt: run.startedAt, finishedAt: run.finishedAt, failure: run.failure ) }, toolCards: cards, lastRunState: state, title: conversation.title, metadata: StoredAgentConversationMetadata( customTitle: conversation.customTitle, isPinned: conversation.isPinned, sortIndex: sortIndexByConversation[conversation.id], archivedAt: conversation.archivedAt, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt ) ) } } @MainActor public func setTitle(_ title: String, conversationID: UUID) async throws { guard !deletedConversationIDs.contains(conversationID) else { return } let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == conversationID } ) // A title is model output about remote-influenced text, so it goes // through the same redaction every other stored string does. guard let row = try context.fetch(descriptor).first else { return } row.title = AgentPersistenceRedactor.visibleText(String(title.prefix(80))) row.updatedAt = .now try context.save() } @MainActor public func setConversationMetadata( _ metadata: StoredAgentConversationMetadata, conversationID: UUID ) async throws { guard !deletedConversationIDs.contains(conversationID) else { throw AgentConversationPersistenceError.conversationNotFound } let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == conversationID } ) guard let row = try context.fetch(descriptor).first else { throw AgentConversationPersistenceError.conversationNotFound } row.customTitle = AgentPersistenceRedactor.visibleText( String(metadata.customTitle.prefix(80)) ) row.isPinned = metadata.isPinned row.archivedAt = metadata.archivedAt row.updatedAt = metadata.updatedAt try context.savePendingChanges() } @MainActor public func reorderConversations(_ updates: [StoredAgentConversationOrder]) async throws { let ids = Set(updates.map(\.id)) guard updates.count == ids.count else { throw AgentConversationPersistenceError.conversationNotFound } let rows = try context.fetch( FetchDescriptor( predicate: #Predicate { ids.contains($0.id) } )) guard rows.count == ids.count else { throw AgentConversationPersistenceError.conversationNotFound } let updatesByID = Dictionary(uniqueKeysWithValues: updates.map { ($0.id, $0) }) let orderRows = try context.fetch( FetchDescriptor( predicate: #Predicate { ids.contains($0.conversationID) } )) var orderByID = Dictionary( uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0) } ) for row in rows { guard let update = updatesByID[row.id] else { continue } row.isPinned = update.isPinned if let order = orderByID[row.id] { order.sortIndex = update.sortIndex } else { let order = PersistentAgentConversationOrder( conversationID: row.id, sortIndex: update.sortIndex ) context.insert(order) orderByID[row.id] = order } } try context.savePendingChanges() } @MainActor public func truncateConversation( _ id: UUID, fromUserMessageID messageID: UUID, replacingWith replacement: AgentTranscriptMessage ) async throws { try Task.checkCancellation() guard replacement.role == .user, !replacement.isCompaction, replacement.toolCalls.isEmpty, replacement.toolCallID == nil, replacement.toolName == nil, !replacement.isError, replacement.id != messageID else { throw AgentConversationPersistenceError.messageNotFound } let allRuns = try context.fetch(FetchDescriptor()) let runs = allRuns.filter { $0.conversationID == id } .sorted(by: Self.runPrecedes) let allMessages = try context.fetch(FetchDescriptor()) let runIDs = Set(runs.map(\.id)) let runOrder = Dictionary( uniqueKeysWithValues: runs.enumerated().map { ($0.element.id, $0.offset) }) let conversationMessages = allMessages.filter { runIDs.contains($0.runID) } .sorted { Self.messagePrecedes($0, $1, runOrder: runOrder) } guard !allMessages.contains(where: { $0.id == replacement.id }), !allRuns.contains(where: { $0.id == replacement.id }), let boundary = conversationMessages.firstIndex(where: { $0.id == messageID }), conversationMessages[boundary].role == AgentTranscriptRole.user.rawValue, !conversationMessages[boundary].isCompaction, let targetRunIndex = runs.firstIndex(where: { $0.id == conversationMessages[boundary].runID }) else { throw AgentConversationPersistenceError.messageNotFound } let target = conversationMessages[boundary] let keptMessages = Array(conversationMessages[..()) guard !originRows.contains(where: { $0.messageID == target.id }) else { throw AgentConversationPersistenceError.messageNotFound } let removesTargetRun = keptTargetMessages.isEmpty let runsToDelete = removesTargetRun ? [runs[targetRunIndex]] + laterRuns : laterRuns let runIDsToDelete = Set(runsToDelete.map(\.id)) let retiredRunIDs = laterRunIDs.union([target.runID]) let previousDeletedRunIDs = deletedRunIDs deletedRunIDs.formUnion(retiredRunIDs) let removesOpeningTurn = !keptMessages.contains { $0.role == AgentTranscriptRole.user.rawValue && !$0.isCompaction } do { try deleteGraph(runIDs: runIDsToDelete, runs: runsToDelete) if !removesTargetRun, let lastKept = keptTargetMessages.last(where: { $0.role != AgentTranscriptRole.tool.rawValue }) ?? keptTargetMessages.last { try deleteTargetRunTools( runID: target.runID, keptMessages: keptTargetMessages, removedMessages: removedMessages.filter { $0.runID == target.runID }, boundary: target.createdAt) try deleteMessageBacking(removedMessages.filter { $0.runID == target.runID }) let targetRun = runs[targetRunIndex] targetRun.state = AgentRunState.completed.rawValue // Tool results are omitted from the visible transcript after // restore. End the neutral prefix summary at the last retained // non-tool message so live editing and reopening agree. targetRun.finishedAt = lastKept.createdAt targetRun.failure = nil } for row in try context.fetch(FetchDescriptor()) where row.conversationID == id && row.compactedThroughMessageID.map(removedMessageIDs.contains) == true { context.delete(row) } let lastKeptRun = removesTargetRun ? runs[..( predicate: #Predicate { $0.id == id } ) if let conversation = try context.fetch(descriptor).first { if removesOpeningTurn { conversation.title = "" } conversation.updatedAt = .now } try context.save() } catch { context.rollback() deletedRunIDs = previousDeletedRunIDs throw error } } @MainActor public func createConversation(_ value: StoredAgentConversation) async throws { guard !deletedConversationIDs.contains(value.id) else { throw AgentConversationPersistenceError.conversationAlreadyExists } let conversationID = value.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == conversationID } ) guard try context.fetch(descriptor).isEmpty else { throw AgentConversationPersistenceError.conversationAlreadyExists } let metadata = value.metadata let conversation = PersistentAgentConversation( id: value.id, title: value.title, createdAt: metadata.createdAt, updatedAt: metadata.updatedAt ) conversation.customTitle = metadata.customTitle conversation.isPinned = metadata.isPinned conversation.archivedAt = metadata.archivedAt try context.commit { context.insert(conversation) if let sortIndex = metadata.sortIndex { context.insert( PersistentAgentConversationOrder( conversationID: value.id, sortIndex: sortIndex )) } let summaries = value.runs.isEmpty ? [ AgentRunSummary( id: UUID(), state: value.lastRunState, startedAt: metadata.createdAt, finishedAt: metadata.updatedAt ) ] : value.runs.sorted { $0.startedAt < $1.startedAt } for summary in summaries { context.insert( PersistentAgentRun( AgentRunSnapshot( id: summary.id, conversationID: value.id, state: summary.state, permissionMode: .askForApproval, messages: [], startedAt: summary.startedAt, finishedAt: summary.finishedAt, failure: summary.failure ))) } let validRunIDs = Set(summaries.map(\.id)) let fallbackRunID = summaries[0].id for (sequence, message) in value.messages.enumerated() { guard !message.isCompaction else { continue } context.insert( PersistentAgentMessage( message, runID: value.messageRunIDs[message.id].flatMap { validRunIDs.contains($0) ? $0 : nil } ?? fallbackRunID, sequence: sequence )) insertContextAttachments(for: message) insertReplay(for: message) try insertOrigin(for: message) } for record in value.compactions { context.insert(PersistentAgentCompaction(record, conversationID: value.id)) } for card in value.toolCards { var invocation = card.invocation if !validRunIDs.contains(invocation.runID) { invocation.runID = fallbackRunID } context.insert(PersistentAgentToolInvocation(invocation, state: card.state)) if let result = card.result { context.insert(PersistentAgentToolResult(result, invocation: invocation)) } if let review = card.review { context.insert( PersistentAgentReviewRecord( runID: invocation.runID, invocationID: invocation.id, decision: review )) } } } } @MainActor public func deleteConversation(_ id: UUID) async throws { deletedConversationIDs.insert(id) let runs = try context.fetch(FetchDescriptor()) .filter { $0.conversationID == id } let runIDs = Set(runs.map(\.id)) deletedRunIDs.formUnion(runIDs) try deleteGraph(runIDs: runIDs, runs: runs) let conversations = try context.fetch(FetchDescriptor()) for row in conversations where row.id == id { context.delete(row) } let orderRows = try context.fetch(FetchDescriptor()) for row in orderRows where row.conversationID == id { context.delete(row) } let selectionRows = try context.fetch(FetchDescriptor()) for row in selectionRows where row.conversationID == id { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where row.conversationID == id { context.delete(row) } try context.save() } private func deleteGraph(runIDs: Set, runs: [PersistentAgentRun]) throws { for row in try context.fetch(FetchDescriptor()) where runIDs.contains(row.runID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where runIDs.contains(row.runID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where runIDs.contains(row.runID) { context.delete(row) } let messages = try context.fetch(FetchDescriptor()) .filter { runIDs.contains($0.runID) } let messageIDs = Set(messages.map(\.id)) for row in try context.fetch(FetchDescriptor()) where messageIDs.contains(row.messageID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where messageIDs.contains(row.messageID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where messageIDs.contains(row.messageID) { context.delete(row) } for row in messages { context.delete(row) } for row in runs { context.delete(row) } } private func deleteMessageBacking(_ messages: [PersistentAgentMessage]) throws { let messageIDs = Set(messages.map(\.id)) for row in try context.fetch(FetchDescriptor()) where messageIDs.contains(row.messageID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where messageIDs.contains(row.messageID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where messageIDs.contains(row.messageID) { context.delete(row) } for row in messages { context.delete(row) } } private func deleteTargetRunTools( runID: UUID, keptMessages: [PersistentAgentMessage], removedMessages: [PersistentAgentMessage], boundary: Date ) throws { let removedMessageIDs = Set(removedMessages.map(\.id)) let keptMessageIDs = Set(keptMessages.map(\.id)) let keptCallIDs = Set(keptMessages.flatMap(Self.toolCallIDs)) let removedCallIDs = Set(removedMessages.flatMap(Self.toolCallIDs)) let invocations = try context.fetch(FetchDescriptor()) let removedInvocations = invocations.filter { invocation in guard invocation.runID == runID else { return false } if let source = invocation.sourceMessageID { if removedMessageIDs.contains(source) { return true } if keptMessageIDs.contains(source) { return false } } if removedCallIDs.contains(invocation.callID) { return true } if keptCallIDs.contains(invocation.callID) { return false } // Legacy rows with no recoverable source or call ownership belong // to the edited suffix at the boundary as well as after it. return invocation.createdAt >= boundary } let invocationIDs = Set(removedInvocations.map(\.id)) for row in try context.fetch(FetchDescriptor()) where row.runID == runID && invocationIDs.contains(row.invocationID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where row.runID == runID && invocationIDs.contains(row.invocationID) { context.delete(row) } for row in removedInvocations { context.delete(row) } } private func isStagingReplacementRun( _ id: UUID, message: PersistentAgentMessage, conversationID: UUID ) throws -> Bool { let runDescriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) guard let run = try context.fetch(runDescriptor).first, run.conversationID == conversationID, run.state == AgentRunState.completed.rawValue, run.failure == nil, message.id == id, message.runID == id, message.role == AgentTranscriptRole.user.rawValue, !message.isCompaction else { return false } let messages = try context.fetch(FetchDescriptor()) guard messages.filter({ $0.runID == id }).count == 1 else { return false } let invocations = try context.fetch(FetchDescriptor()) let results = try context.fetch(FetchDescriptor()) let reviews = try context.fetch(FetchDescriptor()) return !invocations.contains { $0.runID == id } && !results.contains { $0.runID == id } && !reviews.contains { $0.runID == id } } private static func runPrecedes(_ lhs: PersistentAgentRun, _ rhs: PersistentAgentRun) -> Bool { if lhs.startedAt != rhs.startedAt { return lhs.startedAt < rhs.startedAt } return lhs.id.uuidString < rhs.id.uuidString } private static func messagePrecedes( _ lhs: PersistentAgentMessage, _ rhs: PersistentAgentMessage, runOrder: [UUID: Int] ) -> Bool { let left = runOrder[lhs.runID] ?? 0 let right = runOrder[rhs.runID] ?? 0 if left != right { return left < right } if lhs.sequence != rhs.sequence { return lhs.sequence < rhs.sequence } if lhs.createdAt != rhs.createdAt { return lhs.createdAt < rhs.createdAt } let rank: (String) -> Int = { role in switch AgentTranscriptRole(rawValue: role) { case .user: 0 case .assistant: 1 case .tool: 2 case nil: 3 } } if rank(lhs.role) != rank(rhs.role) { return rank(lhs.role) < rank(rhs.role) } return lhs.id.uuidString < rhs.id.uuidString } private static func toolCallIDs(_ message: PersistentAgentMessage) -> [String] { ((try? JSONDecoder().decode([AgentToolCall].self, from: message.toolCallsData)) ?? []).map(\.id) } private func insertReplay(for message: AgentTranscriptMessage) { guard message.contextSnapshot != nil || message.modelText != nil else { return } context.insert(PersistentAgentMessageReplay(message)) } private func insertContextAttachments(for message: AgentTranscriptMessage) { guard !message.contextAttachments.isEmpty else { return } context.insert( PersistentAgentContextAttachments( messageID: message.id, attachments: message.contextAttachments )) } private func insertOrigin(for message: AgentTranscriptMessage) throws { guard let origin = message.origin else { return } context.insert(PersistentAgentMessageOrigin(messageID: message.id, origin: origin)) let messageID = message.id let receipts = try context.fetch( FetchDescriptor( predicate: #Predicate { $0.messageID == messageID })) for receipt in receipts where receipt.state == AgentMessageDeliveryState.queued.rawValue { receipt.state = AgentMessageDeliveryState.delivered.rawValue receipt.failure = nil receipt.updatedAt = .now } } /// Puts back tool results that were recorded but never made it into the /// transcript. /// /// The two are written on different schedules: a result row lands as its /// tool finishes, while its transcript row waits for the run's next /// snapshot save. A process killed in between — force quit, crash, the OS /// reclaiming the app — leaves the call answered in storage and unanswered /// in history, and an assistant turn with an unanswered call is rejected /// outright on the next request. /// /// `AgentUnansweredToolCallRepair` would invent a placeholder for the same /// gap, and does for anything still missing after this. Doing it here first /// is what gives the model the output the tool actually produced instead of /// being told the outcome is unknown when it is sitting in the database. public static func recovering( _ transcript: [AgentTranscriptMessage], results: [PersistentAgentToolResult], invocations: [PersistentAgentToolInvocation] ) -> [AgentTranscriptMessage] { guard transcript.contains(where: { $0.role == .assistant && !$0.toolCalls.isEmpty }) else { return transcript } let names = Dictionary( invocations.map { ($0.callID, $0.toolName) }, uniquingKeysWith: { first, _ in first } ) var recovered: [AgentTranscriptMessage] = [] recovered.reserveCapacity(transcript.count) var index = transcript.startIndex while index < transcript.endIndex { let message = transcript[index] recovered.append(message) index += 1 guard message.role == .assistant, !message.toolCalls.isEmpty else { continue } var answered: Set = [] while index < transcript.endIndex, transcript[index].role == .tool { if let id = transcript[index].toolCallID { answered.insert(id) } recovered.append(transcript[index]) index += 1 } for call in message.toolCalls where !answered.contains(call.id) { // The last recorded one: a call id is unique to its invocation, // so more than one row can only mean a rewritten result. guard let stored = results.last(where: { $0.callID == call.id }) else { continue } let result = stored.restoredResult recovered.append( AgentTranscriptMessage.toolResult( result, runID: stored.runID, toolName: names[call.id] ?? call.name )) } } return recovered } private func upsertInvocation( _ invocation: AgentToolInvocation, state: String ) throws { let id = invocation.id let fetch = FetchDescriptor( predicate: #Predicate { $0.id == id } ) if let row = try context.fetch(fetch).first { let rank = ["proposed": 0, "started": 1, "finished": 2] if rank[state, default: 0] > rank[row.state, default: 0] { row.state = state } if row.sourceMessageID == nil { row.sourceMessageID = invocation.sourceMessageID } } else { context.insert(PersistentAgentToolInvocation(invocation, state: state)) } } private func storedToolCard( _ row: PersistentAgentToolInvocation, results: [PersistentAgentToolResult], reviews: [PersistentAgentReviewRecord] ) -> StoredAgentToolCard? { guard let arguments = try? AgentJSONValue.decode(row.argumentsData) else { return nil } let invocation = AgentToolInvocation( id: row.id, runID: row.runID, call: AgentToolCall(id: row.callID, name: row.toolName, arguments: arguments), sourceMessageID: row.sourceMessageID, targetHostID: row.targetHostID, createdAt: row.createdAt ) let result = results.last(where: { $0.invocationID == row.id })?.restoredResult let descriptor = AgentToolDescriptor( name: row.toolName, summary: row.toolName, inputSchema: .object(["type": .string("object")]), target: row.targetHostID == nil ? .local : .host, approvalPolicy: .ask, presentation: result?.toolPresentation ) let review = reviews.filter { $0.invocationID == row.id } .sorted { $0.createdAt < $1.createdAt } .last.flatMap { stored -> GuardianDecision? in guard let verdict = stored.verdict.flatMap(GuardianVerdict.init), let risk = stored.risk.flatMap(GuardianRisk.init), let authorization = stored.userAuthorization.flatMap({ raw in GuardianAuthorization(rawValue: raw) ?? (raw == "sufficient" ? .high : raw == "insufficient" ? .low : nil) }) else { return nil } return GuardianDecision( verdict: verdict, risk: risk, userAuthorization: authorization, reason: stored.reason ?? "" ) } return StoredAgentToolCard( invocation: invocation, descriptor: descriptor, state: row.state, result: result, review: review ) } private func interruptUnfinishedRuns() { do { let rows = try context.fetch(FetchDescriptor()) var changed = false for row in rows { guard let state = AgentRunState(rawValue: row.state), !state.isTerminal else { continue } row.state = AgentRunState.interrupted.rawValue row.finishedAt = .now changed = true } if changed { try context.save() } } catch { context.rollback() } } }