compaction

This commit is contained in:
Kaede Akino committed 2026-08-30 16:14:30 +08:00
1 parent 0dc80b76e0
commit 56073444b9
11 files changed
+710 -74

No files matched your search

+19 -1
View File
@@ -507,7 +507,12 @@ final class AppModel {
let summary = try await agentCompaction.summarize(
span.summarize, instructions: instructions, model: model
)
conversation.applyCompaction(summary: summary, cut: span.cut)
guard let record = conversation.prepareCompaction(
summary: summary, cut: span.cut
) else {
throw AgentCompactionError.nothingToCompact
}
try await persistAndApplyCompaction(record, to: conversation, cut: span.cut)
conversation.endCompaction()
return true
} catch {
@@ -516,6 +521,17 @@ final class AppModel {
}
}
/// The atomicity boundary for compaction: persistence happens first, and
/// the observable model changes only after that succeeds.
func persistAndApplyCompaction(
_ record: AgentCompactionRecord, to conversation: ConversationModel, cut: Int
) async throws {
try await agentRunRepository.persistCompaction(
record, conversationID: conversation.id
)
conversation.applyCompaction(record, cut: cut)
}
/// Compacts before a run when the conversation is close to the window.
///
/// Threshold only. Recovering a turn that has *already* overflowed means
@@ -807,6 +823,8 @@ final class AppModel {
else { return }
let stored = StoredAgentConversation(
id: fork.id, messages: fork.transcript,
modelTranscript: fork.transcript,
compactions: fork.compactionRecords,
messageRunIDs: fork.messageRunIDs,
runs: Array(fork.runSummaries.values),
toolCards: fork.toolCards.map {
@@ -20,6 +20,9 @@ nonisolated enum AgentConversationPersistenceError: LocalizedError, Sendable {
nonisolated protocol AgentRunPersisting: Sendable {
func save(_ snapshot: AgentRunSnapshot) async throws
func record(_ event: AgentEvent, runID: UUID) async throws
func persistCompaction(
_ record: AgentCompactionRecord, conversationID: UUID
) async throws
func loadConversations() async throws -> [StoredAgentConversation]
func deleteConversation(_ id: UUID) async throws
/// Records the generated summary. Separate from `save` because it arrives
@@ -46,6 +49,10 @@ nonisolated protocol AgentRunPersisting: Sendable {
nonisolated struct StoredAgentConversation: Sendable {
var id: UUID
var messages: [AgentTranscriptMessage]
/// The independently restored model-facing projection. `nil` preserves the
/// old memberwise-initializer behavior for callers that have no compaction.
var modelTranscript: [AgentTranscriptMessage]? = nil
var compactions: [AgentCompactionRecord] = []
/// UI-only ownership restored from message rows. Keeping it outside the
/// transcript prevents run identifiers leaking into provider requests.
var messageRunIDs: [UUID: UUID] = [:]
@@ -57,6 +64,80 @@ nonisolated struct StoredAgentConversation: Sendable {
var metadata = StoredAgentConversationMetadata()
}
nonisolated enum AgentCompactionProjection {
struct Restored: Sendable {
var messages: [AgentTranscriptMessage]
var modelTranscript: [AgentTranscriptMessage]
var records: [AgentCompactionRecord]
}
/// Separates legacy inline markers from raw display history, merges them
/// with dedicated records, and applies the newest valid boundary to the
/// model-facing transcript.
static func restore(
messages: [AgentTranscriptMessage], records stored: [AgentCompactionRecord]
) -> Restored {
let raw = messages.filter { !$0.isCompaction }
let inline = messages.filter(\.isCompaction)
var legacy: [AgentCompactionRecord] = []
// Dedicated records are always newer than inline markers from the old
// format. Negative ordinals preserve that relationship without
// trusting wall-clock time across launches.
var nextSequence = -inline.count
for message in inline {
// Legacy markers may have been assigned to the next run, so their
// array position is not a trustworthy boundary. Their timestamp was
// deliberately copied from the last summarized message.
let boundaryIndex = raw.lastIndex { $0.createdAt <= message.createdAt }
let summarized = boundaryIndex.map { raw[...$0] } ?? raw[...]
let summarizedTokens = summarized.reduce(0) {
$0 + AgentContextEstimator.tokens(
inCharacters: AgentContextEstimator.characterUnits(in: $1.text)
)
}
legacy.append(AgentCompactionRecord(
summaryMessageID: message.id,
compactedThroughMessageID: boundaryIndex.map { raw[$0].id },
sequence: nextSequence,
summary: message.text,
summarizedTokens: summarizedTokens,
keptTokens: AgentContextEstimator.tokens(
inCharacters: AgentContextEstimator.characterUnits(in: message.text)
),
displayAnchor: message.createdAt,
compactedAt: message.createdAt
))
nextSequence += 1
}
let records = (stored + legacy).sorted {
$0.sequence == $1.sequence
? $0.compactedAt < $1.compactedAt : $0.sequence < $1.sequence
}
return Restored(
messages: raw,
modelTranscript: modelTranscript(messages: raw, records: records),
records: records
)
}
static func modelTranscript(
messages: [AgentTranscriptMessage], records: [AgentCompactionRecord]
) -> [AgentTranscriptMessage] {
for record in records.reversed() {
guard let boundary = record.compactedThroughMessageID else {
return [record.summaryMessage] + messages
}
guard let index = messages.firstIndex(where: { $0.id == boundary }) else {
continue
}
return [record.summaryMessage] + messages[messages.index(after: index)...]
}
return messages
}
}
nonisolated struct StoredAgentConversationMetadata: Hashable, Sendable {
var customTitle = ""
var isPinned = false
@@ -88,16 +169,38 @@ actor InMemoryAgentRunRepository: AgentRunPersisting {
private var storedToolCards: [UUID: [StoredAgentToolCard]] = [:]
private var deletedConversationIDs = Set<UUID>()
private var metadata: [UUID: StoredAgentConversationMetadata] = [:]
private var compactions: [UUID: [AgentCompactionRecord]] = [:]
func save(_ snapshot: AgentRunSnapshot) {
guard !deletedConversationIDs.contains(snapshot.conversationID) else { return }
snapshots[snapshot.id] = snapshot
var stored = snapshot
stored.messages.removeAll(where: \.isCompaction)
snapshots[stored.id] = stored
}
func record(_ event: AgentEvent, runID: UUID) {}
func persistCompaction(
_ record: AgentCompactionRecord, conversationID: UUID
) throws {
guard !deletedConversationIDs.contains(conversationID),
snapshots.values.contains(where: { $0.conversationID == conversationID })
else { throw AgentConversationPersistenceError.conversationNotFound }
if let boundary = record.compactedThroughMessageID {
guard snapshots.values.contains(where: { snapshot in
snapshot.conversationID == conversationID
&& snapshot.messages.contains(where: { $0.id == boundary })
}) else { throw AgentConversationPersistenceError.messageNotFound }
}
guard compactions[conversationID]?.contains(where: { $0.id == record.id }) != true
else { return }
compactions[conversationID, default: []].append(record)
}
func loadConversations() -> [StoredAgentConversation] {
Dictionary(grouping: snapshots.values, by: \.conversationID).map { id, runs in
let ordered = runs.sorted { $0.startedAt < $1.startedAt }
var seen = Set<UUID>()
let messages = ordered.flatMap(\.messages).filter { seen.insert($0.id).inserted }
let storedMessages = ordered.flatMap(\.messages).filter { seen.insert($0.id).inserted }
let projection = AgentCompactionProjection.restore(
messages: storedMessages, records: compactions[id] ?? []
)
var messageRunIDs: [UUID: UUID] = [:]
for run in ordered {
for message in run.messages where messageRunIDs[message.id] == nil {
@@ -105,7 +208,10 @@ actor InMemoryAgentRunRepository: AgentRunPersisting {
}
}
return StoredAgentConversation(
id: id, messages: messages, messageRunIDs: messageRunIDs,
id: id, messages: projection.messages,
modelTranscript: projection.modelTranscript,
compactions: projection.records,
messageRunIDs: messageRunIDs,
runs: ordered.map {
AgentRunSummary(
id: $0.id, state: $0.state, startedAt: $0.startedAt,
@@ -158,9 +264,14 @@ actor InMemoryAgentRunRepository: AgentRunPersisting {
}) else { throw AgentConversationPersistenceError.messageNotFound }
let targetStart = target.startedAt
let removedRunIDs = Set(ordered.filter { $0.startedAt >= targetStart }.map(\.id))
let removedMessageIDs = Set(ordered.filter { removedRunIDs.contains($0.id) }
.flatMap(\.messages).map(\.id))
snapshots = snapshots.filter {
$0.value.conversationID != id || $0.value.startedAt < targetStart
}
compactions[id]?.removeAll {
$0.compactedThroughMessageID.map(removedMessageIDs.contains) == true
}
storedToolCards[id]?.removeAll { removedRunIDs.contains($0.invocation.runID) }
let replacement = AgentRunSnapshot(
conversationID: id, state: .completed,
@@ -186,12 +297,14 @@ actor InMemoryAgentRunRepository: AgentRunPersisting {
id: run.id, conversationID: conversation.id, state: run.state,
permissionMode: .askForApproval,
messages: conversation.messages.filter {
(conversation.messageRunIDs[$0.id] ?? fallbackRunID) == run.id
!$0.isCompaction
&& (conversation.messageRunIDs[$0.id] ?? fallbackRunID) == run.id
},
startedAt: run.startedAt, finishedAt: run.finishedAt
)
}
storedToolCards[conversation.id] = conversation.toolCards
compactions[conversation.id] = conversation.compactions
titles[conversation.id] = conversation.title
metadata[conversation.id] = conversation.metadata
}
@@ -199,6 +312,7 @@ actor InMemoryAgentRunRepository: AgentRunPersisting {
deletedConversationIDs.insert(id)
titles.removeValue(forKey: id)
metadata.removeValue(forKey: id)
compactions.removeValue(forKey: id)
storedToolCards.removeValue(forKey: id)
snapshots = snapshots.filter { $0.value.conversationID != id }
}
@@ -71,7 +71,8 @@ final class AssistantConversationStore {
let conversation = ConversationModel(
id: value.id,
messages: value.messages.compactMap { message in
switch message.role {
guard !message.isCompaction else { return nil }
return switch message.role {
case .user:
ChatMessage(
id: message.id, role: .user,
@@ -94,10 +95,11 @@ final class AssistantConversationStore {
},
// Tool rows are dropped from the visible list because they are
// drawn as cards, but the model still needs them next turn.
transcript: value.messages,
transcript: value.modelTranscript ?? value.messages,
runState: value.lastRunState,
messageRunIDs: value.messageRunIDs,
runs: value.runs,
compactionRecords: value.compactions,
createdAt: value.metadata.createdAt,
updatedAt: value.metadata.updatedAt,
sortIndex: value.metadata.sortIndex
+25
View File
@@ -605,6 +605,31 @@ nonisolated struct AgentTranscriptMessage: Identifiable, Hashable, Sendable, Cod
var createdAt: Date = .now
}
/// A durable replacement for the model-facing prefix of one conversation.
///
/// The original messages stay in run storage for the reader. This record says
/// which raw message the summary replaces through, so persistence can rebuild a
/// compact model transcript without deleting the display history or assigning
/// the synthetic summary to a run that never produced it.
nonisolated struct AgentCompactionRecord: Identifiable, Hashable, Sendable, Codable {
var id: UUID = UUID()
var summaryMessageID: UUID = UUID()
var compactedThroughMessageID: UUID?
var sequence: Int
var summary: String
var summarizedTokens: Int
var keptTokens: Int
var displayAnchor: Date
var compactedAt: Date = .now
var summaryMessage: AgentTranscriptMessage {
AgentTranscriptMessage(
id: summaryMessageID, role: .user, text: summary,
isCompaction: true, createdAt: displayAnchor
)
}
}
nonisolated struct AgentRunSnapshot: Identifiable, Hashable, Sendable, Codable {
var id: UUID = UUID()
var conversationID: UUID
+72 -29
View File
@@ -490,6 +490,23 @@ nonisolated struct AgentCompactionMark: Identifiable, Hashable, Sendable {
var summarizedTokens: Int
var keptTokens: Int
var createdAt: Date = .now
init(
id: UUID = UUID(), summarizedTokens: Int, keptTokens: Int,
createdAt: Date = .now
) {
self.id = id
self.summarizedTokens = summarizedTokens
self.keptTokens = keptTokens
self.createdAt = createdAt
}
init(_ record: AgentCompactionRecord) {
self.init(
id: record.id, summarizedTokens: record.summarizedTokens,
keptTokens: record.keptTokens, createdAt: record.displayAnchor
)
}
}
/// A presented plan and what became of it.
@@ -667,10 +684,11 @@ final class ConversationModel: Identifiable {
/// Plans presented in this conversation, oldest first.
private(set) var plans: [AgentPlanCard] = []
/// Where history stopped being sent, oldest first. Display only — the
/// authoritative record of a compaction is the `isCompaction` turn in
/// `transcript`, which is what survives a relaunch.
/// Where history stopped being sent, oldest first. Display only — durable
/// `compactionRecords` are authoritative across relaunches; their synthetic
/// transcript turns exist only in the model-facing projection.
private(set) var compactions: [AgentCompactionMark] = []
private(set) var compactionRecords: [AgentCompactionRecord] = []
/// Ephemeral UI state. Successful compactions become boundary marks; a
/// failed attempt stays readable for this process without entering the
@@ -803,6 +821,7 @@ final class ConversationModel: Identifiable {
runState: AgentRunState = .idle,
messageRunIDs: [UUID: UUID] = [:],
runs: [AgentRunSummary] = [],
compactionRecords: [AgentCompactionRecord] = [],
createdAt: Date = .now, updatedAt: Date = .now,
sortIndex: Double? = nil,
contextEstimatePublishInterval: Duration = .milliseconds(250)
@@ -821,6 +840,8 @@ final class ConversationModel: Identifiable {
self.messages = messages
self.transcript = transcript
self.messageRunIDs = messageRunIDs
self.compactionRecords = compactionRecords
compactions = compactionRecords.map(AgentCompactionMark.init)
runSummaries = Dictionary(uniqueKeysWithValues: runs.map { ($0.id, $0) })
self.runState = runState
runtimeState = runState
@@ -1028,9 +1049,20 @@ final class ConversationModel: Identifiable {
run.id = newID
return run
}
let clonedCompactions = compactionRecords.compactMap { record -> AgentCompactionRecord? in
guard let summaryMessageID = messageIDMap[record.summaryMessageID] else { return nil }
var copy = record
copy.id = UUID()
copy.summaryMessageID = summaryMessageID
// The fork stores only its compact model history, so its summary is
// the base context rather than a boundary into raw source rows.
copy.compactedThroughMessageID = nil
return copy
}
let fork = ConversationModel(
messages: visibleMessages, transcript: cloned, runState: .completed,
messageRunIDs: clonedMessageRunIDs, runs: clonedRuns,
compactionRecords: clonedCompactions,
createdAt: .now, updatedAt: .now
)
let resolvedTitle = forkTitle ?? Self.numberedForkTitle(base: derivedTitle, index: 1)
@@ -1333,15 +1365,11 @@ final class ConversationModel: Identifiable {
}
}
/// Replaces everything before `cut` with one synthetic turn carrying the
/// summary.
///
/// `transcript` — what the model is sent — is rewritten. `messages` — what
/// the reader sees — is not: a marker is added instead, so the conversation
/// stays scrollable and nothing the user wrote disappears. That is the same
/// rule `AgentContextPipeline` states for every other transform.
func applyCompaction(summary: String, cut: Int) {
guard cut > 0, cut <= transcript.count else { return }
/// Builds the durable record without changing the conversation. AppModel
/// persists this value before applying it, so a failed save cannot leave
/// memory and disk describing different contexts.
func prepareCompaction(summary: String, cut: Int) -> AgentCompactionRecord? {
guard cut > 0, cut <= transcript.count else { return nil }
let summarized = transcript[..<cut]
let summarizedTokens = summarized.reduce(0) {
$0
@@ -1349,28 +1377,35 @@ final class ConversationModel: Identifiable {
inCharacters: AgentContextEstimator.characterUnits(in: $1.text)
)
}
// Anchored to the *last summarized* message rather than the first kept
// one. `appendUserTurn` writes the visible `ChatMessage` a moment before
// the `AgentTranscriptMessage` beside it, so anchoring forward and
// subtracting an epsilon is a coin flip between the two lists whenever
// turns land close together. Anchoring backward is exact: everything
// summarized was stamped at or before this, and everything kept after.
let stamp = transcript[cut - 1].createdAt
let replacement = AgentTranscriptMessage(
role: .user, text: summary, isCompaction: true, createdAt: stamp
let boundary = summarized.reversed().first(where: { !$0.isCompaction })?.id
?? compactionRecords.last?.compactedThroughMessageID
return AgentCompactionRecord(
compactedThroughMessageID: boundary,
sequence: (compactionRecords.map(\.sequence).max() ?? -1) + 1,
summary: summary,
summarizedTokens: summarizedTokens,
keptTokens: AgentContextEstimator.tokens(
inCharacters: AgentContextEstimator.characterUnits(in: summary)
),
displayAnchor: transcript[cut - 1].createdAt
)
}
/// Replaces everything before `cut` with the already-persisted summary.
///
/// `transcript` — what the model is sent — is rewritten. `messages` — what
/// the reader sees — is not: a marker is added instead, so the conversation
/// stays scrollable and nothing the user wrote disappears. That is the same
/// rule `AgentContextPipeline` states for every other transform.
func applyCompaction(_ record: AgentCompactionRecord, cut: Int) {
precondition(cut > 0 && cut <= transcript.count)
let replacement = record.summaryMessage
let removedIDs = transcript[..<cut].map(\.id)
transcript.replaceSubrange(..<cut, with: [replacement])
contextEstimateTracker.remove(removedIDs)
contextEstimateTracker.upsert(replacement)
compactions.append(
AgentCompactionMark(
summarizedTokens: summarizedTokens,
keptTokens: AgentContextEstimator.tokens(
inCharacters: AgentContextEstimator.characterUnits(in: summary)
),
createdAt: stamp
))
compactionRecords.append(record)
compactions.append(AgentCompactionMark(record))
// The gauge is measuring a context that no longer exists. Dropping it
// rather than scaling it means the next turn reports a real number and
// until then the estimate is shown as an estimate.
@@ -1379,6 +1414,14 @@ final class ConversationModel: Identifiable {
publishContextEstimateImmediately()
}
/// Convenience for focused model tests that do not exercise persistence.
@discardableResult
func applyCompaction(summary: String, cut: Int) -> AgentCompactionRecord? {
guard let record = prepareCompaction(summary: summary, cut: cut) else { return nil }
applyCompaction(record, cut: cut)
return record
}
/// Records what the user decided about a plan.
///
/// Implementing turns plan mode off, because that is what the decision
@@ -113,6 +113,46 @@ final class PersistentAgentMessage {
}
}
/// A compaction is conversation state, not output produced by an agent run.
/// Keeping it in its own table preserves the complete run/message graph for the
/// transcript reader while giving restore an exact model-context boundary.
@Model
final class PersistentAgentCompaction {
@Attribute(.unique) var id: UUID = UUID()
var conversationID: UUID = UUID()
var summaryMessageID: UUID = UUID()
var compactedThroughMessageID: UUID?
var sequence = 0
var summary = ""
var summarizedTokens = 0
var keptTokens = 0
var displayAnchor: Date = Date()
var compactedAt: Date = Date()
init(_ record: AgentCompactionRecord, conversationID: UUID) {
id = record.id
self.conversationID = conversationID
summaryMessageID = record.summaryMessageID
compactedThroughMessageID = record.compactedThroughMessageID
sequence = record.sequence
summary = AgentPersistenceRedactor.visibleText(record.summary)
summarizedTokens = record.summarizedTokens
keptTokens = record.keptTokens
displayAnchor = record.displayAnchor
compactedAt = record.compactedAt
}
var value: AgentCompactionRecord {
AgentCompactionRecord(
id: id, summaryMessageID: summaryMessageID,
compactedThroughMessageID: compactedThroughMessageID,
sequence: sequence, summary: summary,
summarizedTokens: summarizedTokens, keptTokens: keptTokens,
displayAnchor: displayAnchor, compactedAt: compactedAt
)
}
}
/// V3 storage for original context snapshots. A separate model keeps the V2
/// message schema frozen and makes the migration an additive lightweight one.
@Model
@@ -258,13 +298,14 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
// 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 ids = Set(snapshot.messages.map(\.id))
let durableMessages = snapshot.messages.filter { !$0.isCompaction }
let ids = Set(durableMessages.map(\.id))
let stored = try context.fetch(FetchDescriptor<PersistentAgentMessage>(
predicate: #Predicate { ids.contains($0.id) }
))
let storedIDs = Set(stored.map(\.id))
var inserted = false
for (sequence, message) in snapshot.messages.enumerated()
for (sequence, message) in durableMessages.enumerated()
where !storedIDs.contains(message.id)
{
context.insert(PersistentAgentMessage(
@@ -277,6 +318,43 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
try context.save()
}
@MainActor
func persistCompaction(
_ record: AgentCompactionRecord, conversationID: UUID
) async throws {
guard !deletedConversationIDs.contains(conversationID) else {
throw AgentConversationPersistenceError.conversationNotFound
}
let conversations = try context.fetch(FetchDescriptor<PersistentAgentConversation>(
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<PersistentAgentRun>())
.filter { $0.conversationID == conversationID }.map(\.id))
let rows = try context.fetch(FetchDescriptor<PersistentAgentMessage>(
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<PersistentAgentCompaction>(
predicate: #Predicate { $0.id == recordID }
))
guard existing.isEmpty else { return }
context.insert(PersistentAgentCompaction(record, conversationID: conversationID))
do {
try context.save()
} catch {
context.rollback()
throw error
}
}
@MainActor
func record(_ event: AgentEvent, runID: UUID) async throws {
guard !deletedRunIDs.contains(runID) else { return }
@@ -321,6 +399,7 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
)
let runs = try context.fetch(FetchDescriptor<PersistentAgentRun>())
let messages = try context.fetch(FetchDescriptor<PersistentAgentMessage>())
let compactionRows = try context.fetch(FetchDescriptor<PersistentAgentCompaction>())
let attachmentRows = try context.fetch(
FetchDescriptor<PersistentAgentContextAttachments>()
)
@@ -392,13 +471,20 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
let cards = invocations.filter { runIDs.contains($0.runID) }
.sorted { $0.createdAt < $1.createdAt }
.compactMap { storedToolCard($0, results: results, reviews: reviews) }
return StoredAgentConversation(
id: conversation.id,
messages: Self.compacted(Self.recovering(
let projection = AgentCompactionProjection.restore(
messages: Self.recovering(
transcript,
results: results.filter { runIDs.contains($0.runID) },
invocations: invocations.filter { runIDs.contains($0.runID) }
)),
),
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 }
@@ -519,6 +605,7 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
let removedRuns = Array(runs[targetIndex...])
let removedRunIDs = Set(removedRuns.map(\.id))
let removedMessageIDs = Set(allMessages.filter { removedRunIDs.contains($0.runID) }.map(\.id))
deletedRunIDs.formUnion(removedRunIDs)
let earlierRunIDs = Set(runs[..<targetIndex].map(\.id))
let removesOpeningTurn = !allMessages.contains {
@@ -527,6 +614,12 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
}
do {
try deleteGraph(runIDs: removedRunIDs, runs: removedRuns)
for row in try context.fetch(FetchDescriptor<PersistentAgentCompaction>())
where row.conversationID == id
&& row.compactedThroughMessageID.map(removedMessageIDs.contains) == true
{
context.delete(row)
}
let replacementRun = AgentRunSnapshot(
conversationID: id, state: .completed,
@@ -599,6 +692,7 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
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 {
@@ -608,6 +702,9 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
))
insertContextAttachments(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) {
@@ -644,6 +741,8 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
for row in conversations where row.id == id { context.delete(row) }
let orderRows = try context.fetch(FetchDescriptor<PersistentAgentConversationOrder>())
for row in orderRows where row.conversationID == id { context.delete(row) }
for row in try context.fetch(FetchDescriptor<PersistentAgentCompaction>())
where row.conversationID == id { context.delete(row) }
try context.save()
}
@@ -684,23 +783,6 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
/// 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.
/// Re-applies a compaction that was recorded before the process ended.
///
/// History is rebuilt from every stored message of every run, so a
/// conversation that was compacted would come back whole and be sent whole —
/// the compaction silently undone at exactly the point the window was
/// already tight. The synthetic summary turn carries `isCompaction`, and
/// everything before the newest one is dropped.
///
/// The **last** marker, not the first: a conversation compacted twice has
/// two, and the older summary was itself folded into the newer one.
static func compacted(
_ messages: [AgentTranscriptMessage]
) -> [AgentTranscriptMessage] {
guard let cut = messages.lastIndex(where: \.isCompaction) else { return messages }
return Array(messages[cut...])
}
static func recovering(
_ transcript: [AgentTranscriptMessage],
results: [PersistentAgentToolResult],
@@ -550,12 +550,20 @@ enum TermindSchemaV8: VersionedSchema {
}
}
enum TermindSchemaV9: VersionedSchema {
static let versionIdentifier = Schema.Version(9, 0, 0)
static var models: [any PersistentModel.Type] {
TermindSchemaV8.models + [PersistentAgentCompaction.self]
}
}
enum TermindMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[
TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self,
TermindSchemaV4.self, TermindSchemaV5.self, TermindSchemaV6.self,
TermindSchemaV7.self, TermindSchemaV8.self,
TermindSchemaV7.self, TermindSchemaV8.self, TermindSchemaV9.self,
]
}
@@ -591,13 +599,16 @@ enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(
fromVersion: TermindSchemaV7.self, toVersion: TermindSchemaV8.self
),
MigrationStage.lightweight(
fromVersion: TermindSchemaV8.self, toVersion: TermindSchemaV9.self
),
]
}
}
@MainActor
final class HostPersistence {
static let schema = Schema(versionedSchema: TermindSchemaV8.self)
static let schema = Schema(versionedSchema: TermindSchemaV9.self)
let context: ModelContext
+81 -12
View File
@@ -289,6 +289,44 @@ struct AgentCompactionTests {
#expect(conversation.compactions.count == 1)
}
@Test
func preparingACompactionDoesNotMutateTheConversation() throws {
let conversation = ConversationModel()
for index in 0..<4 {
conversation.appendUserTurn("turn \(index) " + filler(tokens: 300))
}
let span = try #require(conversation.compactionSpan(keepRecentTokens: 300))
let transcript = conversation.transcript
let estimate = conversation.contextEstimate
let record = try #require(conversation.prepareCompaction(
summary: "summary", cut: span.cut
))
#expect(conversation.transcript == transcript)
#expect(conversation.contextEstimate == estimate)
#expect(conversation.compactions.isEmpty)
#expect(record.compactedThroughMessageID == transcript[span.cut - 1].id)
}
@Test
func aRepeatedCompactionInheritsTheRawBoundaryWhenItOnlyFoldsTheOldSummary() throws {
let conversation = ConversationModel()
conversation.appendUserTurn("old " + filler(tokens: 600))
conversation.appendUserTurn("recent " + filler(tokens: 20))
let firstSpan = try #require(conversation.compactionSpan(keepRecentTokens: 20))
let first = try #require(conversation.applyCompaction(
summary: "first summary", cut: firstSpan.cut
))
let second = try #require(conversation.prepareCompaction(
summary: "second summary", cut: 1
))
#expect(second.compactedThroughMessageID == first.compactedThroughMessageID)
#expect(second.sequence == first.sequence + 1)
}
@Test
func compactionActivityIsEphemeralAndNeverBecomesAMessage() throws {
let conversation = ConversationModel()
@@ -358,27 +396,58 @@ struct AgentCompactionTests {
/// it whole — undoing the compaction at the point it was most needed.
@Test
func restoreDoesNotResurrectCompactedHistory() {
let restored = SwiftDataAgentRunRepository.compacted([
user("very old"),
assistant("older reply"),
AgentTranscriptMessage(role: .user, text: "## Goal\nfirst", isCompaction: true),
user("recent"),
AgentTranscriptMessage(role: .user, text: "## Goal\nsecond", isCompaction: true),
user("newest"),
])
let at: (TimeInterval) -> Date = { Date(timeIntervalSinceReferenceDate: $0) }
let restored = AgentCompactionProjection.restore(messages: [
AgentTranscriptMessage(role: .user, text: "very old", createdAt: at(1)),
AgentTranscriptMessage(role: .assistant, text: "older reply", createdAt: at(2)),
AgentTranscriptMessage(
role: .user, text: "## Goal\nfirst", isCompaction: true, createdAt: at(2)
),
AgentTranscriptMessage(role: .user, text: "recent", createdAt: at(3)),
AgentTranscriptMessage(
role: .user, text: "## Goal\nsecond", isCompaction: true, createdAt: at(3)
),
AgentTranscriptMessage(role: .user, text: "newest", createdAt: at(4)),
], records: [])
// The last marker, not the first: a conversation compacted twice has
// two, and the older summary was folded into the newer one.
#expect(restored.count == 2)
#expect(restored.first?.text.contains("second") == true)
#expect(restored.last?.text == "newest")
#expect(restored.modelTranscript.count == 2)
#expect(restored.modelTranscript.first?.text.contains("second") == true)
#expect(restored.modelTranscript.last?.text == "newest")
#expect(restored.messages.count == 4)
}
@Test
func restoreLeavesAnUncompactedConversationAlone() {
let messages = [user("a"), assistant("b")]
#expect(SwiftDataAgentRunRepository.compacted(messages).count == 2)
let restored = AgentCompactionProjection.restore(messages: messages, records: [])
#expect(restored.modelTranscript == messages)
}
@Test
func legacyInlineMarkerIsHiddenFromDisplayAndKeptForTheModel() {
let old = AgentTranscriptMessage(
role: .user, text: "old", createdAt: Date(timeIntervalSinceReferenceDate: 1)
)
let marker = AgentTranscriptMessage(
role: .user, text: "legacy summary", isCompaction: true,
createdAt: Date(timeIntervalSinceReferenceDate: 1)
)
let recent = AgentTranscriptMessage(
role: .assistant, text: "recent",
createdAt: Date(timeIntervalSinceReferenceDate: 2)
)
let restored = AgentCompactionProjection.restore(
messages: [old, marker, recent], records: []
)
#expect(restored.messages.map(\.id) == [old.id, recent.id])
#expect(restored.modelTranscript.map(\.id) == [marker.id, recent.id])
#expect(restored.records.count == 1)
#expect(restored.records.first?.summaryMessageID == marker.id)
}
// MARK: - The summarizer's prompt
+235 -1
View File
@@ -173,6 +173,48 @@ struct AgentPersistenceTests {
).isEmpty)
}
@Test
func schemaV8MigratesToV9WithEmptyCompactionBacking() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("TermindAgentV9Migration-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true
)
defer { try? FileManager.default.removeItem(at: directory) }
let storeURL = directory.appendingPathComponent("Termind.store")
let legacySchema = Schema(versionedSchema: TermindSchemaV8.self)
let conversationID = UUID()
var legacyContainer: ModelContainer? = try ModelContainer(
for: legacySchema,
configurations: [
localModelConfiguration("Migration", schema: legacySchema, url: storeURL)
]
)
legacyContainer?.mainContext.insert(PersistentAgentConversation(
id: conversationID, title: "Existing"
))
try legacyContainer?.mainContext.save()
legacyContainer = nil
let migrated = try ModelContainer(
for: HostPersistence.schema,
migrationPlan: TermindMigrationPlan.self,
configurations: [
localModelConfiguration(
"Migration", schema: HostPersistence.schema, url: storeURL
)
]
)
#expect(try migrated.mainContext.fetch(
FetchDescriptor<PersistentAgentConversation>()
).map(\.id) == [conversationID])
#expect(try migrated.mainContext.fetch(
FetchDescriptor<PersistentAgentCompaction>()
).isEmpty)
}
@Test
func displayReasoningRoundTripsRedactedWithoutProviderState() async throws {
let configuration = localModelConfiguration(
@@ -786,6 +828,10 @@ struct AgentPersistenceTests {
let forkID = UUID()
let originalMessageID = UUID()
let forkMessageID = UUID()
let forkCompaction = AgentCompactionRecord(
compactedThroughMessageID: nil, sequence: 0, summary: "Fork base",
summarizedTokens: 10, keptTokens: 2, displayAnchor: .now
)
try await repository.save(AgentRunSnapshot(
conversationID: originalID, state: .completed,
permissionMode: .askForApproval,
@@ -797,16 +843,204 @@ struct AgentPersistenceTests {
id: forkID,
messages: [AgentTranscriptMessage(
id: forkMessageID, role: .user, text: "Original", authoredText: "Original"
)], lastRunState: .completed,
)], compactions: [forkCompaction], lastRunState: .completed,
metadata: StoredAgentConversationMetadata(customTitle: "Original (1)")
))
let restored = try await repository.loadConversations()
#expect(Set(restored.map(\.id)) == Set([originalID, forkID]))
#expect(restored.first { $0.id == forkID }?.messages.first?.id == forkMessageID)
#expect(restored.first { $0.id == forkID }?.modelTranscript?.first?.id
== forkCompaction.summaryMessageID)
#expect(restored.first { $0.id == forkID }?.metadata.customTitle == "Original (1)")
try await repository.deleteConversation(originalID)
#expect(try await repository.loadConversations().map(\.id) == [forkID])
try await repository.deleteConversation(forkID)
#expect(try container.mainContext.fetch(
FetchDescriptor<PersistentAgentCompaction>()
).isEmpty)
}
@Test
func compactionPersistsImmediatelyWithoutReplacingDisplayHistory() async throws {
let configuration = localModelConfiguration(
"AgentCompactionPersistenceTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
let container = try ModelContainer(
for: HostPersistence.schema, configurations: [configuration]
)
let conversationID = UUID()
let old = AgentTranscriptMessage(
role: .user, text: "Old question " + String(repeating: "x", count: 4_000)
)
let answer = AgentTranscriptMessage(
role: .assistant, text: "Old answer " + String(repeating: "y", count: 4_000)
)
let recent = AgentTranscriptMessage(role: .user, text: "Recent question")
let repository = SwiftDataAgentRunRepository(context: container.mainContext)
try await repository.save(AgentRunSnapshot(
conversationID: conversationID, state: .completed,
permissionMode: .askForApproval, messages: [old, answer, recent]
))
let record = AgentCompactionRecord(
compactedThroughMessageID: answer.id, sequence: 0,
summary: "## Goal\nKeep the recent work",
summarizedTokens: 20, keptTokens: 8,
displayAnchor: answer.createdAt
)
try await repository.persistCompaction(record, conversationID: conversationID)
let reopened = SwiftDataAgentRunRepository(context: container.mainContext)
let restored = try #require(try await reopened.loadConversations().first)
#expect(restored.messages.map(\.id) == [old.id, answer.id, recent.id])
#expect(restored.modelTranscript?.map(\.id) == [record.summaryMessageID, recent.id])
#expect(restored.modelTranscript?.first?.isCompaction == true)
#expect(restored.compactions == [record])
let store = AssistantConversationStore()
store.restore([restored])
let live = try #require(store.conversation(id: conversationID))
#expect(live.messages.map(\.id) == [old.id, answer.id, recent.id])
#expect(live.transcript.map(\.id) == [record.summaryMessageID, recent.id])
#expect(live.compactions.map(\.id) == [record.id])
let fullContext = ConversationModel(transcript: restored.messages).contextEstimate.total
#expect(live.contextEstimate.total < fullContext)
#expect(try container.mainContext.fetch(
FetchDescriptor<PersistentAgentCompaction>()
).count == 1)
}
@Test
func aRunAfterCompactionDoesNotPersistTheSyntheticSummaryAsAMessage() async throws {
let configuration = localModelConfiguration(
"AgentCompactionNextRunTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
let container = try ModelContainer(
for: HostPersistence.schema, configurations: [configuration]
)
let conversationID = UUID()
let old = AgentTranscriptMessage(role: .user, text: "Old")
let recent = AgentTranscriptMessage(role: .assistant, text: "Recent")
let repository = SwiftDataAgentRunRepository(context: container.mainContext)
try await repository.save(AgentRunSnapshot(
conversationID: conversationID, state: .completed,
permissionMode: .askForApproval, messages: [old, recent]
))
let record = AgentCompactionRecord(
compactedThroughMessageID: old.id, sequence: 0, summary: "Summary",
summarizedTokens: 10, keptTokens: 2, displayAnchor: old.createdAt
)
try await repository.persistCompaction(record, conversationID: conversationID)
let followUp = AgentTranscriptMessage(role: .user, text: "Continue")
try await repository.save(AgentRunSnapshot(
conversationID: conversationID, state: .completed,
permissionMode: .askForApproval,
messages: [record.summaryMessage, recent, followUp]
))
let messageRows = try container.mainContext.fetch(
FetchDescriptor<PersistentAgentMessage>()
)
let compactionMessageCount = messageRows.filter { $0.isCompaction }.count
#expect(compactionMessageCount == 0)
#expect(!messageRows.contains { $0.id == record.summaryMessageID })
let restored = try #require(try await repository.loadConversations().first)
#expect(restored.messages.map(\.id) == [old.id, recent.id, followUp.id])
#expect(restored.modelTranscript?.map(\.id) == [
record.summaryMessageID, recent.id, followUp.id,
])
}
@Test
func newestOfTwoCompactionsWinsAfterRestore() async throws {
let configuration = localModelConfiguration(
"AgentRepeatedCompactionTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
let container = try ModelContainer(
for: HostPersistence.schema, configurations: [configuration]
)
let conversationID = UUID()
let messages = [
AgentTranscriptMessage(role: .user, text: "One"),
AgentTranscriptMessage(role: .assistant, text: "Two"),
AgentTranscriptMessage(role: .user, text: "Three"),
AgentTranscriptMessage(role: .assistant, text: "Four"),
]
let repository = SwiftDataAgentRunRepository(context: container.mainContext)
try await repository.save(AgentRunSnapshot(
conversationID: conversationID, state: .completed,
permissionMode: .askForApproval, messages: messages
))
let first = AgentCompactionRecord(
compactedThroughMessageID: messages[1].id, sequence: 0, summary: "First",
summarizedTokens: 10, keptTokens: 2, displayAnchor: messages[1].createdAt,
compactedAt: Date(timeIntervalSinceReferenceDate: 1)
)
let second = AgentCompactionRecord(
compactedThroughMessageID: messages[2].id, sequence: 1, summary: "Second",
summarizedTokens: 12, keptTokens: 2, displayAnchor: messages[2].createdAt,
compactedAt: Date(timeIntervalSinceReferenceDate: 2)
)
try await repository.persistCompaction(first, conversationID: conversationID)
try await repository.persistCompaction(second, conversationID: conversationID)
let restored = try #require(try await repository.loadConversations().first)
#expect(restored.messages.map(\.id) == messages.map(\.id))
#expect(restored.modelTranscript?.map(\.id) == [second.summaryMessageID, messages[3].id])
#expect(restored.compactions == [first, second])
}
@Test
func truncationDeletesOnlyCompactionsWhoseBoundaryWasRemoved() async throws {
let configuration = localModelConfiguration(
"AgentCompactionTruncationTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
let container = try ModelContainer(
for: HostPersistence.schema, configurations: [configuration]
)
let conversationID = UUID()
let firstUser = AgentTranscriptMessage(role: .user, text: "One")
let firstAnswer = AgentTranscriptMessage(role: .assistant, text: "Answer one")
let secondUser = AgentTranscriptMessage(role: .user, text: "Two")
var firstRun = AgentRunSnapshot(
conversationID: conversationID, state: .completed,
permissionMode: .askForApproval, messages: [firstUser, firstAnswer],
startedAt: Date(timeIntervalSinceReferenceDate: 1)
)
firstRun.finishedAt = Date(timeIntervalSinceReferenceDate: 2)
var secondRun = AgentRunSnapshot(
conversationID: conversationID, state: .completed,
permissionMode: .askForApproval,
messages: [firstUser, firstAnswer, secondUser],
startedAt: Date(timeIntervalSinceReferenceDate: 3)
)
secondRun.finishedAt = Date(timeIntervalSinceReferenceDate: 4)
let repository = SwiftDataAgentRunRepository(context: container.mainContext)
try await repository.save(firstRun)
try await repository.save(secondRun)
let kept = AgentCompactionRecord(
compactedThroughMessageID: firstAnswer.id, sequence: 0, summary: "Kept",
summarizedTokens: 8, keptTokens: 2, displayAnchor: firstAnswer.createdAt
)
let removed = AgentCompactionRecord(
compactedThroughMessageID: secondUser.id, sequence: 1, summary: "Removed",
summarizedTokens: 10, keptTokens: 2, displayAnchor: secondUser.createdAt
)
try await repository.persistCompaction(kept, conversationID: conversationID)
try await repository.persistCompaction(removed, conversationID: conversationID)
try await repository.truncateConversation(
conversationID, fromUserMessageID: secondUser.id,
replacingWith: AgentTranscriptMessage(role: .user, text: "Edited")
)
let restored = try #require(try await repository.loadConversations().first)
#expect(restored.compactions == [kept])
#expect(restored.modelTranscript?.first?.id == kept.summaryMessageID)
}
}
+3
View File
@@ -921,6 +921,9 @@ private actor RecordingRunRepository: AgentRunPersisting {
var recorded: [AgentEvent] = []
func save(_ snapshot: AgentRunSnapshot) {}
func record(_ event: AgentEvent, runID: UUID) { recorded.append(event) }
func persistCompaction(
_ record: AgentCompactionRecord, conversationID: UUID
) {}
func loadConversations() -> [StoredAgentConversation] { [] }
func deleteConversation(_ id: UUID) {}
func setTitle(_ title: String, conversationID: UUID) {}
+35
View File
@@ -277,6 +277,36 @@ struct WindowStateTests {
#expect(window.assistantOperationError == "Storage unavailable")
}
@Test
func failedCompactionSaveDoesNotChangeTheInMemoryContext() async throws {
let repository = FailingConversationOperationRepository()
let hosts = HostLibraryModel()
let app = AppModel(
hosts: hosts,
dependencies: AppDependencies(
hostRepository: InMemoryHostRepository(), outboxRunner: nil,
agentRunRepository: repository
)
)
let conversation = ConversationModel()
conversation.appendUserTurn("old " + String(repeating: "x", count: 4_000))
conversation.appendUserTurn("recent")
let span = try #require(conversation.compactionSpan(keepRecentTokens: 2))
let record = try #require(conversation.prepareCompaction(
summary: "summary", cut: span.cut
))
let transcript = conversation.transcript
let estimate = conversation.contextEstimate
await #expect(throws: (any Error).self) {
try await app.persistAndApplyCompaction(record, to: conversation, cut: span.cut)
}
#expect(conversation.transcript == transcript)
#expect(conversation.contextEstimate == estimate)
#expect(conversation.compactions.isEmpty)
}
/// Each window used to build its own `ConversationModel` for the same
/// conversation id, so a reply streaming in one window was invisible in the
/// other and both could start a run against the same stored history.
@@ -1165,6 +1195,11 @@ private actor FailingConversationOperationRepository: AgentRunPersisting {
func save(_ snapshot: AgentRunSnapshot) {}
func record(_ event: AgentEvent, runID: UUID) {}
func persistCompaction(
_ record: AgentCompactionRecord, conversationID: UUID
) throws {
throw StorageFailure()
}
func loadConversations() -> [StoredAgentConversation] { [] }
func deleteConversation(_ id: UUID) {}
func setTitle(_ title: String, conversationID: UUID) {}