ales27pm's picture
Add autonomous on-device iOS assistant
7b2dfc5 verified
Raw
History Blame Contribute Delete
47.9 kB
import Foundation
import Observation
enum AssistantSessionError: LocalizedError, Sendable {
case persistenceUnavailable
case sessionUnavailable
case memoryLimitReached
case knowledgeLimitReached
case handoffLimitReached
case handoffNotFound
case handoffNotActive
case taskLimitReached
case taskNotFound
case taskAlreadyOpen
case unreceiptableToolResult
var errorDescription: String? {
switch self {
case .persistenceUnavailable:
"Local assistant storage is unavailable."
case .sessionUnavailable:
"The assistant session is no longer available."
case .memoryLimitReached:
"The local memory limit has been reached. Delete an item before saving another."
case .knowledgeLimitReached:
"The local knowledge limit has been reached. Delete an item before capturing another."
case .handoffLimitReached:
"The local handoff limit has been reached. Delete a handoff before creating another."
case .handoffNotFound:
"The requested session handoff no longer exists."
case .handoffNotActive:
"The requested session handoff is not the active checkpoint."
case .taskLimitReached:
"The local task limit has been reached. Delete a task before adding another."
case .taskNotFound:
"The requested task no longer exists."
case .taskAlreadyOpen:
"The requested task is already open."
case .unreceiptableToolResult:
"The local action result could not be recorded safely."
}
}
}
@MainActor
@Observable
final class AssistantSession {
var workspace: AssistantWorkspace = .empty
var draft = ""
private(set) var isWorkspaceReady = false
private(set) var isRunning = false
private(set) var isModelLoaded = false
private(set) var isModelLoading = false
private(set) var modelStatus = "Loading local data…"
private(set) var pendingApproval: ToolApprovalRequest?
private(set) var errorMessage: String?
private(set) var calendarAuthorization: SystemCalendarAuthorization = .notDetermined
private(set) var isCalendarAccessRequesting = false
private(set) var resourceSnapshot: AssistantResourceSnapshot?
private(set) var resourceWarning: String?
@ObservationIgnored private let calendarReader: EventKitCalendarReader
@ObservationIgnored private let toolRegistry: AgentToolRegistry
@ObservationIgnored private let resourceProvider = SystemResourceSnapshotProvider()
@ObservationIgnored private var persistence: AssistantPersistence?
@ObservationIgnored private var runtime: DolphinModelRuntime?
@ObservationIgnored private var agentLoop: AgentLoop?
@ObservationIgnored private var loadWorkspaceTask: Task<Void, Never>?
@ObservationIgnored private var loadModelTask: Task<Void, Never>?
@ObservationIgnored private var runTask: Task<Void, Never>?
@ObservationIgnored private var runTimeoutTask: Task<Void, Never>?
@ObservationIgnored private var persistenceTask: Task<Void, Never>?
@ObservationIgnored private var approvalExpiryTask: Task<Void, Never>?
@ObservationIgnored private var approvalContinuation:
CheckedContinuation<Bool, Never>?
@ObservationIgnored private var activeRunID: UUID?
@ObservationIgnored private var applicationIsActive = false
var canSubmitDraft: Bool {
guard
isWorkspaceReady,
!isRunning,
!isModelLoading,
!submittedDraftText.isEmpty
else { return false }
return isModelLoaded
|| AgentRequestRouter.plan(for: submittedDraftText).mode == .deterministic
}
private var submittedDraftText: String {
String(
draft.trimmingCharacters(in: .whitespacesAndNewlines).prefix(8_000)
)
}
private var idleModelStatus: String {
guard isWorkspaceReady else { return "Local storage unavailable" }
return isModelLoaded ? "Model ready" : "Model not loaded"
}
init() {
let calendarReader = EventKitCalendarReader()
self.calendarReader = calendarReader
toolRegistry = AgentToolRegistry(calendarReader: calendarReader)
do {
let persistence = try AssistantPersistence()
self.persistence = persistence
loadWorkspaceTask = Task { [weak self, persistence] in
do {
let savedWorkspace = try await persistence.load()
try Task.checkCancellation()
guard let self else { return }
let recoveredWorkspace = self.sanitized(savedWorkspace)
let recoveredInterruptedRuns = recoveredWorkspace != savedWorkspace
self.workspace = recoveredWorkspace
self.isWorkspaceReady = true
self.modelStatus = "Model not loaded"
Task { await self.refreshCalendarAuthorization() }
if recoveredInterruptedRuns {
self.persistSoon()
}
} catch is CancellationError {
return
} catch {
guard let self else { return }
// Preserve the unreadable file and fail closed. Apparently durable
// chat or settings must never proceed against an unavailable store.
self.persistence = nil
self.isWorkspaceReady = false
self.modelStatus = "Local storage unavailable"
self.errorMessage =
"Saved data could not be read and was left untouched: \(error.localizedDescription)"
}
self?.loadWorkspaceTask = nil
}
} catch {
persistence = nil
isWorkspaceReady = false
modelStatus = "Local storage unavailable"
errorMessage = error.localizedDescription
}
}
func loadModel() {
guard
isWorkspaceReady,
!isModelLoaded,
!isModelLoading,
loadModelTask == nil
else {
return
}
errorMessage = nil
refreshResourceSnapshot()
if let resourceSnapshot {
switch ResourceBudgetPolicy.modelLoadDecision(for: resourceSnapshot) {
case .allow:
resourceWarning = nil
case .warn(let warning):
resourceWarning = warning
recordStandalone(
kind: .model,
status: .information,
title: "Resource advisory",
detail: warning
)
case .deny(let reason):
resourceWarning = nil
modelStatus = "Model load paused"
errorMessage = reason
recordStandalone(
kind: .model,
status: .failed,
title: "Model load blocked",
detail: reason
)
return
}
}
isModelLoading = true
modelStatus = "Loading 1.81 GB model…"
recordStandalone(
kind: .model,
status: .running,
title: "Loading model",
detail: DolphinModelRuntime.modelIdentifier
)
loadModelTask = Task { [weak self] in
defer {
self?.isModelLoading = false
self?.loadModelTask = nil
}
do {
let runtime = try await DolphinModelRuntime()
let information = await runtime.information()
try Task.checkCancellation()
guard let self else { return }
guard self.isWorkspaceReady, self.persistence != nil else {
self.modelStatus = "Local storage unavailable"
return
}
self.runtime = runtime
self.agentLoop = AgentLoop(runtime: runtime)
self.isModelLoaded = true
self.modelStatus = "Model ready"
self.recordStandalone(
kind: .model,
status: .succeeded,
title: "Model ready",
detail:
"Loaded in \(Self.format(information.loadSeconds)) s · \(information.maxContextLength)-token state · \(information.maxQueryLength)-token chunks"
)
} catch is CancellationError {
guard let self else { return }
self.modelStatus = self.isWorkspaceReady
? "Model load cancelled"
: "Local storage unavailable"
self.recordStandalone(
kind: .model,
status: .cancelled,
title: "Model load cancelled",
detail: ""
)
} catch {
guard let self else { return }
self.modelStatus = "Model load failed"
self.errorMessage = error.localizedDescription
self.recordStandalone(
kind: .model,
status: .failed,
title: "Model load failed",
detail: error.localizedDescription
)
}
}
}
func cancelModelLoad() {
guard isModelLoading, let loadModelTask else { return }
modelStatus = "Cancelling model load…"
loadModelTask.cancel()
}
func releaseModel() {
guard !isRunning, !isModelLoading else { return }
agentLoop = nil
runtime = nil
isModelLoaded = false
modelStatus = "Model not loaded"
recordStandalone(
kind: .model,
status: .information,
title: "Model released",
detail: "Core ML model memory can now be reclaimed by iOS."
)
}
func send() {
let message = submittedDraftText
guard
!message.isEmpty,
isWorkspaceReady,
!isRunning,
!isModelLoading,
let persistence
else { return }
let requestPlan = AgentRequestRouter.plan(for: message)
let selectedAgentLoop: AgentLoop
let usesLoadedModel: Bool
if isModelLoaded, let agentLoop {
selectedAgentLoop = agentLoop
usesLoadedModel = true
} else if requestPlan.mode == .deterministic {
selectedAgentLoop = AgentLoop(runtime: ToolOnlyDolphinRuntime())
usesLoadedModel = false
} else {
return
}
errorMessage = nil
draft = ""
let settings = sanitized(workspace.settings)
workspace.settings = settings
let userMessage = AssistantMessage(
role: .user,
content: message
)
workspace.messages.append(userMessage)
trimWorkspaceHistory()
let runID = UUID()
let conversation = workspace.messages
workspace.runs.append(
AgentRunRecord(
id: runID,
requestMessageID: userMessage.id,
requestText: userMessage.content,
contextMessageIDs: conversation.map(\.id),
settingsSnapshot: settings
)
)
trimRunHistory()
activeRunID = runID
isRunning = true
modelStatus = usesLoadedModel ? "Thinking…" : "Running verified plan…"
let activeHandoff = workspace.activeHandoffID.flatMap { activeID in
workspace.handoffs.first { $0.id == activeID }
}
var memoryContext = KnowledgeEngine().promptContext(
knowledge: workspace.knowledgeItems,
query: userMessage.content,
limit: 4
)
if let activeHandoff {
memoryContext.insert(
MemoryItem(
id: activeHandoff.id,
content: "[active handoff] \(activeHandoff.title): \(activeHandoff.summary)",
createdAt: activeHandoff.restoredAt ?? activeHandoff.createdAt
),
at: 0
)
}
if memoryContext.count < 5 {
let existingIDs = Set(memoryContext.map(\.id))
memoryContext.append(
contentsOf: workspace.memories.reversed().filter {
!existingIDs.contains($0.id)
}.prefix(5 - memoryContext.count)
)
}
let registry = toolRegistry
let context = toolExecutionContext(runID: runID)
runTimeoutTask?.cancel()
runTimeoutTask = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(settings.maxRunSeconds))
} catch {
return
}
guard let self, self.activeRunID == runID else { return }
self.markCancellationRequested(runID: runID, reason: .deadline)
self.modelStatus = "Stopping at the run deadline…"
self.resolveApproval(approved: false)
self.runTask?.cancel()
}
runTask = Task {
[weak self, selectedAgentLoop, registry, context, persistence] in
guard let self else { return }
do {
try await self.persistImmediately(using: persistence)
try Task.checkCancellation()
} catch is CancellationError {
// Continue into the normal cancellation path so the persisted run is
// terminalized only after cancellation is actually observed.
} catch {
self.markRunTerminal(
runID: runID,
status: .failed,
stopReason: .storageFailure,
finalMessageID: nil,
errorSummary: error.localizedDescription
)
self.markPersistenceUnavailable(error)
self.finishRunState(runID: runID)
return
}
let callbacks = AgentLoopCallbacks(
normalizeTool: { call in
try registry.normalize(call)
},
executeTool: { call in
await registry.execute(call, context: context)
},
requestApproval: { [weak self] request in
guard let self else { return false }
return await self.waitForApproval(request)
},
emit: { [weak self] event in
self?.record(event)
},
modelProgress: { [weak self] progress in
self?.modelStatus = "Thinking · \(progress.generatedTokens) tokens"
},
checkpoint: { [weak self] update in
guard let self else {
throw AssistantSessionError.sessionUnavailable
}
try await self.applyCheckpoint(update, runID: runID)
}
)
do {
let result = try await selectedAgentLoop.run(
runID: runID,
conversation: conversation,
memoryContext: memoryContext,
settings: settings,
toolDefinitions: registry.definitions,
callbacks: callbacks
)
try Task.checkCancellation()
guard self.activeRunID == runID else { return }
let finalMessage = AssistantMessage(
role: .assistant,
content: result.finalText
)
self.workspace.messages.append(finalMessage)
self.trimWorkspaceHistory()
self.markRunTerminal(
runID: runID,
status: result.outcome == .succeeded ? .succeeded : .failed,
stopReason: nil,
finalMessageID: finalMessage.id,
errorSummary: result.outcome == .failed
? "The run stopped without completing every requested action."
: nil,
toolCallsUsed: result.toolCalls,
modelTurns: result.modelTurns
)
self.modelStatus = self.idleModelStatus
do {
try await self.persistImmediately(using: persistence)
} catch {
self.markTerminalPersistenceFailure(runID: runID, error: error)
self.markPersistenceUnavailable(error)
}
} catch is CancellationError {
await self.terminalizeStoppedRun(
runID: runID,
reason: self.runStopReason(runID: runID),
persistence: persistence
)
} catch let error as AgentLoopError where error == .timeLimitReached {
await self.terminalizeStoppedRun(
runID: runID,
reason: .deadline,
persistence: persistence
)
} catch {
guard self.activeRunID == runID else { return }
self.errorMessage = error.localizedDescription
let finalMessage = AssistantMessage(
role: .assistant,
content:
"I couldn’t finish this run safely. \(error.localizedDescription)"
+ self.terminalReceiptAppendix(for: runID)
)
self.workspace.messages.append(finalMessage)
self.record(
AgentEvent(
runID: runID,
sequence: self.nextSequence(for: runID),
kind: .run,
status: .failed,
title: "Run failed",
detail: error.localizedDescription
)
)
self.markRunTerminal(
runID: runID,
status: .failed,
stopReason: self.runStopReason(runID: runID),
finalMessageID: finalMessage.id,
errorSummary: error.localizedDescription
)
self.modelStatus = self.idleModelStatus
do {
try await self.persistImmediately(using: persistence)
} catch {
self.markTerminalPersistenceFailure(runID: runID, error: error)
self.markPersistenceUnavailable(error)
}
}
self.finishRunState(runID: runID)
}
}
func stop() {
guard isRunning else { return }
if let activeRunID {
markCancellationRequested(runID: activeRunID, reason: .user)
}
modelStatus = "Stopping after the current operation…"
runTimeoutTask?.cancel()
runTimeoutTask = nil
resolveApproval(approved: false)
runTask?.cancel()
}
func enteredBackground() {
applicationIsActive = false
guard isRunning else { return }
if let activeRunID {
markCancellationRequested(runID: activeRunID, reason: .background)
}
modelStatus = "Stopping because Dolphin left the foreground…"
runTimeoutTask?.cancel()
runTimeoutTask = nil
resolveApproval(approved: false)
runTask?.cancel()
}
func enteredForeground() {
applicationIsActive = true
refreshResourceSnapshot()
Task { await refreshCalendarAuthorization() }
}
func refreshResourceSnapshot() {
let snapshot = resourceProvider.snapshot()
resourceSnapshot = snapshot
switch ResourceBudgetPolicy.modelLoadDecision(for: snapshot) {
case .allow:
resourceWarning = nil
case .warn(let warning), .deny(let warning):
resourceWarning = warning
}
}
func refreshCalendarAuthorization() async {
calendarAuthorization = await calendarReader.authorizationStatus()
}
/// This is the sole permission-prompt path. A model/tool call cannot reach
/// it; the user must tap the Calendar control in Settings while foregrounded.
@discardableResult
func requestAndEnableCalendarAccess() async -> Bool {
guard
isWorkspaceReady,
!isRunning,
applicationIsActive,
!isCalendarAccessRequesting
else { return false }
isCalendarAccessRequesting = true
errorMessage = nil
defer { isCalendarAccessRequesting = false }
do {
let authorization = try await calendarReader.requestFullAccess()
calendarAuthorization = authorization
guard authorization == .fullAccess else {
errorMessage = SystemCalendarReaderError
.fullAccessRequired(authorization)
.localizedDescription
return false
}
guard applicationIsActive else {
errorMessage =
"Calendar access was granted by iOS, but Dolphin stayed disabled because the app left the foreground. Return to Settings and tap Enable Calendar Context."
return false
}
workspace.settings.enabledSystemCapabilities.insert(.calendarRead)
persistSoon()
recordStandalone(
kind: .persistence,
status: .information,
title: "Calendar context enabled",
detail: "Dolphin may request bounded event-title and time reads. Every read still requires one-time approval."
)
return true
} catch {
calendarAuthorization = await calendarReader.authorizationStatus()
errorMessage = "Calendar access was not enabled: \(error.localizedDescription)"
return false
}
}
@discardableResult
func enableCalendarContext() -> Bool {
guard
isWorkspaceReady,
!isRunning,
applicationIsActive,
calendarAuthorization == .fullAccess
else { return false }
workspace.settings.enabledSystemCapabilities.insert(.calendarRead)
persistSoon()
recordStandalone(
kind: .persistence,
status: .information,
title: "Calendar context enabled",
detail: "Calendar reads remain bounded and require approval once per exact request."
)
return true
}
func disableCalendarContext() {
guard isWorkspaceReady, !isRunning else { return }
guard workspace.settings.enabledSystemCapabilities.remove(.calendarRead) != nil else {
return
}
persistSoon()
recordStandalone(
kind: .persistence,
status: .information,
title: "Calendar context disabled",
detail: "Dolphin will deny Calendar tool requests. You can separately revoke OS access in iOS Settings."
)
}
func approvePending() {
guard let request = pendingApproval else { return }
resolveApproval(approved: !request.isExpired(), matching: request.id)
}
func denyPending() {
guard let request = pendingApproval else { return }
resolveApproval(approved: false, matching: request.id)
}
func clearConversation() {
guard isWorkspaceReady, !isRunning else { return }
workspace.messages.removeAll()
workspace.runs.removeAll()
workspace.events.removeAll { $0.runID != nil }
scrubConversationCopiesFromHandoffs()
persistSoon()
}
func deleteMemory(id: UUID) {
guard isWorkspaceReady, !isRunning else { return }
guard let memory = workspace.memories.first(where: { $0.id == id }) else {
return
}
workspace.memories.removeAll { $0.id == id }
let canonicalMirror = KnowledgeEngine.canonicalCaptureFields(
title: "Saved memory",
content: memory.content,
tags: ["memory"],
relatedItemIDs: []
)
let mirrorIDs = Set(workspace.knowledgeItems.compactMap { item -> UUID? in
guard
item.id == memory.id
|| (
item.title == canonicalMirror.title
&& item.content == canonicalMirror.content
&& item.tags == canonicalMirror.tags
&& item.createdAt == memory.createdAt
)
else { return nil }
return item.id
})
removeKnowledgeAndDerivedState(ids: mirrorIDs)
persistSoon()
}
@discardableResult
func captureKnowledge(
kind: KnowledgeKind,
title: String,
content: String,
tags: [String] = []
) -> Bool {
guard
isWorkspaceReady,
!isRunning,
workspace.knowledgeItems.count < KnowledgeEngine.Limits.maxCorpusItems
else { return false }
let item = KnowledgeItem(
kind: kind,
title: title,
content: content,
tags: tags,
source: .user
)
workspace.knowledgeItems.append(item)
appendAutomaticKnowledgeDigestIfNeeded(trigger: "manual capture")
persistSoon()
recordStandalone(
kind: .persistence,
status: .succeeded,
title: "Knowledge captured",
detail: "\(item.kind.rawValue): \(item.title) · revision \(item.revision)"
)
return true
}
func deleteKnowledge(id: UUID) {
guard isWorkspaceReady, !isRunning else { return }
guard workspace.knowledgeItems.contains(where: { $0.id == id }) else {
return
}
workspace.memories.removeAll { $0.id == id }
removeKnowledgeAndDerivedState(ids: [id])
persistSoon()
recordStandalone(
kind: .persistence,
status: .information,
title: "Knowledge deleted",
detail: id.uuidString
)
}
@discardableResult
func createHandoff(title: String, focus: String) -> Bool {
guard isWorkspaceReady, !isRunning else { return false }
let handoff = KnowledgeEngine().makeHandoff(
title: title,
focus: focus,
knowledge: workspace.knowledgeItems,
tasks: workspace.tasks,
recentMessages: workspace.messages.suffix(6).map(\.content)
)
if let index = workspace.handoffs.firstIndex(where: { $0.id == handoff.id }) {
workspace.handoffs[index] = handoff
} else {
guard workspace.handoffs.count < 100 else { return false }
workspace.handoffs.append(handoff)
}
workspace.activeHandoffID = handoff.id
persistSoon()
recordStandalone(
kind: .persistence,
status: .succeeded,
title: "Session handoff created",
detail: "\(handoff.title) [\(handoff.id.uuidString)]"
)
return true
}
@discardableResult
func restoreHandoff(id: UUID) -> Bool {
guard
isWorkspaceReady,
!isRunning,
let index = workspace.handoffs.firstIndex(where: { $0.id == id })
else { return false }
workspace.handoffs[index].restoredAt = Date()
workspace.activeHandoffID = id
persistSoon()
recordStandalone(
kind: .persistence,
status: .succeeded,
title: "Session handoff restored",
detail: "\(workspace.handoffs[index].title) [\(id.uuidString)]. No prior action was replayed."
)
return true
}
func deleteHandoff(id: UUID) {
guard isWorkspaceReady, !isRunning else { return }
let previousCount = workspace.handoffs.count
workspace.handoffs.removeAll { $0.id == id }
guard workspace.handoffs.count != previousCount else { return }
if workspace.activeHandoffID == id { workspace.activeHandoffID = nil }
// Digests can contain the handoff focus but schema-v5 digests do not carry
// a source-handoff ID, so conservatively remove derived snapshots.
workspace.knowledgeDigests.removeAll()
persistSoon()
recordStandalone(
kind: .persistence,
status: .information,
title: "Session handoff deleted",
detail: id.uuidString
)
}
func deleteTask(id: UUID) {
guard isWorkspaceReady, !isRunning else { return }
guard workspace.tasks.contains(where: { $0.id == id }) else { return }
workspace.tasks.removeAll { $0.id == id }
let removedHandoffIDs = Set(
workspace.handoffs.filter { $0.taskIDs.contains(id) }.map(\.id)
)
workspace.handoffs.removeAll { removedHandoffIDs.contains($0.id) }
if let activeID = workspace.activeHandoffID,
removedHandoffIDs.contains(activeID)
{
workspace.activeHandoffID = nil
}
// Digests can copy task titles but do not yet carry task evidence IDs.
workspace.knowledgeDigests.removeAll()
persistSoon()
}
@discardableResult
func deactivateHandoff() -> Bool {
guard
isWorkspaceReady,
!isRunning,
let activeID = workspace.activeHandoffID
else { return false }
workspace.activeHandoffID = nil
persistSoon()
recordStandalone(
kind: .persistence,
status: .information,
title: "Session handoff deactivated",
detail: "\(activeID.uuidString). The checkpoint remains saved."
)
return true
}
@discardableResult
func setTaskCompletion(id: UUID, isCompleted: Bool) -> Bool {
guard
isWorkspaceReady,
!isRunning,
let index = workspace.tasks.firstIndex(where: { $0.id == id })
else { return false }
guard workspace.tasks[index].isCompleted != isCompleted else {
return false
}
workspace.tasks[index].isCompleted = isCompleted
workspace.tasks[index].completedAt = isCompleted ? Date() : nil
persistSoon()
return true
}
@discardableResult
func saveSettings(_ settings: AgentSettings) -> AgentSettings? {
guard isWorkspaceReady, !isRunning else { return nil }
let appliedSettings = sanitized(settings)
workspace.settings = appliedSettings
persistSoon()
recordStandalone(
kind: .persistence,
status: .information,
title: "Settings updated",
detail: "The local save was queued. Any storage failure will appear here."
)
return appliedSettings
}
private func waitForApproval(_ request: ToolApprovalRequest) async -> Bool {
guard activeRunID == request.runID, pendingApproval == nil else {
return false
}
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard !Task.isCancelled else {
continuation.resume(returning: false)
return
}
pendingApproval = request
approvalContinuation = continuation
let interval = max(0, request.expiresAt.timeIntervalSinceNow)
approvalExpiryTask = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(interval))
} catch {
return
}
guard let self else { return }
self.resolveApproval(approved: false, matching: request.id)
}
}
} onCancel: { [weak self] in
Task { @MainActor in
self?.resolveApproval(approved: false, matching: request.id)
}
}
}
private func resolveApproval(
approved: Bool,
matching requestID: UUID? = nil
) {
if let requestID, pendingApproval?.id != requestID { return }
approvalExpiryTask?.cancel()
approvalExpiryTask = nil
pendingApproval = nil
let continuation = approvalContinuation
approvalContinuation = nil
continuation?.resume(returning: approved)
}
private func applyCheckpoint(
_ update: AgentRunCheckpointUpdate,
runID: UUID
) async throws {
guard
isWorkspaceReady,
let persistence,
let index = workspace.runs.firstIndex(where: { $0.id == runID }),
!workspace.runs[index].status.isTerminal
else {
throw AssistantSessionError.persistenceUnavailable
}
switch update {
case .phase(let phase):
workspace.runs[index].checkpoint.phase = phase
workspace.runs[index].checkpoint.activeCall = nil
case .activeCall(let call, let phase):
workspace.runs[index].checkpoint.phase = phase
workspace.runs[index].checkpoint.activeCall = call
case .counters(let toolCallsUsed, let modelTurns):
workspace.runs[index].checkpoint.toolCallsUsed = toolCallsUsed
workspace.runs[index].checkpoint.modelTurns = modelTurns
case .completedReceipt(let receipt, let toolCallsUsed, let modelTurns):
if !workspace.runs[index].checkpoint.completedReceipts.contains(
where: { $0.call.id == receipt.call.id }
) {
workspace.runs[index].checkpoint.completedReceipts.append(receipt)
workspace.runs[index].checkpoint.completedReceipts = Array(
workspace.runs[index].checkpoint.completedReceipts.suffix(
AgentSettings.maxToolStepsRange.upperBound
)
)
}
workspace.runs[index].checkpoint.activeCall = nil
workspace.runs[index].checkpoint.toolCallsUsed = toolCallsUsed
workspace.runs[index].checkpoint.modelTurns = modelTurns
}
workspace.runs[index].checkpoint.updatedAt = Date()
try await persistImmediately(using: persistence)
}
private func persistImmediately(
using persistence: AssistantPersistence
) async throws {
if let persistenceTask {
await persistenceTask.value
}
guard
isWorkspaceReady,
let currentPersistence = self.persistence,
currentPersistence === persistence
else {
throw AssistantSessionError.persistenceUnavailable
}
try await persistence.save(workspace)
}
private func markCancellationRequested(
runID: UUID,
reason: AgentRunStopReason
) {
guard
let index = workspace.runs.firstIndex(where: { $0.id == runID }),
!workspace.runs[index].status.isTerminal
else { return }
workspace.runs[index].status = .cancellationRequested
workspace.runs[index].stopReason = workspace.runs[index].stopReason ?? reason
workspace.runs[index].checkpoint.updatedAt = Date()
persistSoon()
}
private func markRunTerminal(
runID: UUID,
status: AgentRunStatus,
stopReason: AgentRunStopReason?,
finalMessageID: UUID?,
errorSummary: String?,
toolCallsUsed: Int? = nil,
modelTurns: Int? = nil
) {
guard
status.isTerminal,
let index = workspace.runs.firstIndex(where: { $0.id == runID }),
!workspace.runs[index].status.isTerminal
else { return }
workspace.runs[index].status = status
workspace.runs[index].stopReason = stopReason
?? workspace.runs[index].stopReason
workspace.runs[index].checkpoint.phase = .finalizing
workspace.runs[index].checkpoint.activeCall = nil
if let toolCallsUsed {
workspace.runs[index].checkpoint.toolCallsUsed = toolCallsUsed
}
if let modelTurns {
workspace.runs[index].checkpoint.modelTurns = modelTurns
}
workspace.runs[index].checkpoint.updatedAt = Date()
workspace.runs[index].completedAt = Date()
workspace.runs[index].finalMessageID = finalMessageID
workspace.runs[index].errorSummary = errorSummary.map {
String($0.prefix(500))
}
trimRunHistory()
}
private func markTerminalPersistenceFailure(runID: UUID, error: Error) {
guard let index = workspace.runs.firstIndex(where: { $0.id == runID }) else {
return
}
workspace.runs[index].status = .failed
workspace.runs[index].stopReason = .storageFailure
workspace.runs[index].checkpoint.phase = .finalizing
workspace.runs[index].checkpoint.activeCall = nil
workspace.runs[index].checkpoint.updatedAt = Date()
workspace.runs[index].completedAt = workspace.runs[index].completedAt
?? Date()
workspace.runs[index].errorSummary = String(
"The terminal result could not be confirmed durable: \(error.localizedDescription)"
.prefix(500)
)
}
private func runStopReason(runID: UUID) -> AgentRunStopReason? {
workspace.runs.first(where: { $0.id == runID })?.stopReason
}
private func terminalizeStoppedRun(
runID: UUID,
reason: AgentRunStopReason?,
persistence: AssistantPersistence
) async {
guard activeRunID == runID else { return }
let resolution = AgentRunTerminalClassifier.cancellation(for: reason)
let finalMessage = AssistantMessage(
role: .assistant,
content: resolution.message + terminalReceiptAppendix(for: runID),
wasStopped: true
)
workspace.messages.append(finalMessage)
record(
AgentEvent(
runID: runID,
sequence: nextSequence(for: runID),
kind: .run,
status: resolution.eventStatus,
title: resolution.title,
detail: resolution.detail
)
)
markRunTerminal(
runID: runID,
status: resolution.status,
stopReason: resolution.stopReason,
finalMessageID: finalMessage.id,
errorSummary: resolution.errorSummary
)
modelStatus = idleModelStatus
do {
try await persistImmediately(using: persistence)
} catch {
markTerminalPersistenceFailure(runID: runID, error: error)
markPersistenceUnavailable(error)
}
}
private func finishRunState(runID: UUID) {
guard activeRunID == runID else { return }
runTimeoutTask?.cancel()
runTimeoutTask = nil
resolveApproval(approved: false)
activeRunID = nil
isRunning = false
runTask = nil
}
private func trimRunHistory() {
let removable = workspace.runs.filter { $0.status.isTerminal }
let overflow = max(0, removable.count - 100)
guard overflow > 0 else { return }
let removedIDs = Set(removable.prefix(overflow).map(\.id))
workspace.runs.removeAll { removedIDs.contains($0.id) }
}
private func toolExecutionContext(runID: UUID) -> AgentToolExecutionContext {
AgentToolExecutionContext(
snapshot: { [weak self] in
guard let self else { return .empty }
return await self.workspaceSnapshot()
},
isApplicationActive: { [weak self] in
guard let self else { return false }
return await self.applicationIsActive
},
commitLocalWrite: {
[weak self] mutation, call, definition, resultBuilder in
guard let self else {
throw AssistantSessionError.sessionUnavailable
}
return try await self.applyWorkspaceMutation(
mutation,
runID: runID,
call: call,
definition: definition,
resultBuilder: resultBuilder
)
}
)
}
private func workspaceSnapshot() -> AssistantWorkspace {
workspace
}
private func applyWorkspaceMutation(
_ mutation: AssistantWorkspaceMutation,
runID: UUID,
call: AgentToolCall,
definition: AgentToolDefinition,
resultBuilder: AssistantWorkspaceResultBuilder
) async throws -> AgentToolResult {
guard isWorkspaceReady, let persistence else {
throw AssistantSessionError.persistenceUnavailable
}
// Finish any older snapshot before beginning this transaction. Nothing in
// the live workspace is mutated while this potentially long wait occurs.
if let persistenceTask {
await persistenceTask.value
}
try Task.checkCancellation()
guard
isWorkspaceReady,
let currentPersistence = self.persistence,
currentPersistence === persistence,
activeRunID == runID
else {
throw AssistantSessionError.persistenceUnavailable
}
let previousWorkspace = workspace
let result: AssistantWorkspaceMutationResult
switch mutation {
case .saveMemory(let item):
guard workspace.memories.count < 500 else {
throw AssistantSessionError.memoryLimitReached
}
workspace.memories.append(item)
if workspace.knowledgeItems.count < KnowledgeEngine.Limits.maxCorpusItems {
workspace.knowledgeItems.append(
KnowledgeItem(
id: item.id,
kind: .fact,
title: "Saved memory",
content: item.content,
tags: ["memory"],
source: .user,
createdAt: item.createdAt
)
)
appendAutomaticKnowledgeDigestIfNeeded(trigger: "memory capture")
}
result = .memory(item)
case .captureKnowledge(let item):
guard workspace.knowledgeItems.count < KnowledgeEngine.Limits.maxCorpusItems else {
throw AssistantSessionError.knowledgeLimitReached
}
workspace.knowledgeItems.append(item)
appendAutomaticKnowledgeDigestIfNeeded(trigger: "fifth capture")
result = .knowledge(item)
case .createHandoff(let handoff):
if let index = workspace.handoffs.firstIndex(where: { $0.id == handoff.id }) {
workspace.handoffs[index] = handoff
} else {
guard workspace.handoffs.count < 100 else {
throw AssistantSessionError.handoffLimitReached
}
workspace.handoffs.append(handoff)
}
workspace.activeHandoffID = handoff.id
result = .handoff(handoff)
case .restoreHandoff(let id, let restoredAt):
guard let index = workspace.handoffs.firstIndex(where: { $0.id == id }) else {
throw AssistantSessionError.handoffNotFound
}
workspace.handoffs[index].restoredAt = restoredAt
workspace.activeHandoffID = id
result = .handoff(workspace.handoffs[index])
case .deactivateHandoff(let id):
guard workspace.activeHandoffID == id else {
throw AssistantSessionError.handoffNotActive
}
guard let handoff = workspace.handoffs.first(where: { $0.id == id }) else {
throw AssistantSessionError.handoffNotFound
}
workspace.activeHandoffID = nil
result = .handoff(handoff)
case .addTask(let item):
guard workspace.tasks.count < 500 else {
throw AssistantSessionError.taskLimitReached
}
workspace.tasks.append(item)
result = .task(item)
case .completeTask(let id, let completedAt):
guard let index = workspace.tasks.firstIndex(where: { $0.id == id }) else {
throw AssistantSessionError.taskNotFound
}
workspace.tasks[index].isCompleted = true
workspace.tasks[index].completedAt = completedAt
result = .task(workspace.tasks[index])
case .reopenTask(let id):
guard let index = workspace.tasks.firstIndex(where: { $0.id == id }) else {
throw AssistantSessionError.taskNotFound
}
guard workspace.tasks[index].isCompleted else {
throw AssistantSessionError.taskAlreadyOpen
}
workspace.tasks[index].isCompleted = false
workspace.tasks[index].completedAt = nil
result = .task(workspace.tasks[index])
}
let toolResult: AgentToolResult
do {
toolResult = try resultBuilder(result).bounded(
toMaximumCharacters: definition.maxOutputCharacters
)
} catch {
workspace = previousWorkspace
throw error
}
guard
toolResult.succeeded,
let receipt = AgentToolReceipt(
runID: runID,
call: call,
definition: definition,
result: toolResult
)
else {
workspace = previousWorkspace
throw AssistantSessionError.unreceiptableToolResult
}
workspace.events.append(
AgentEvent(
runID: runID,
sequence: nextSequence(for: runID),
kind: .tool,
status: .succeeded,
title: "Tool completed",
detail: receipt.evidenceJSON,
receipt: receipt
)
)
if let runIndex = workspace.runs.firstIndex(where: { $0.id == runID }),
!workspace.runs[runIndex].checkpoint.completedReceipts.contains(
where: { $0.call.id == receipt.call.id }
)
{
workspace.runs[runIndex].checkpoint.completedReceipts.append(receipt)
workspace.runs[runIndex].checkpoint.completedReceipts = Array(
workspace.runs[runIndex].checkpoint.completedReceipts.suffix(
AgentSettings.maxToolStepsRange.upperBound
)
)
workspace.runs[runIndex].checkpoint.activeCall = nil
workspace.runs[runIndex].checkpoint.updatedAt = Date()
}
if workspace.events.count > 500 {
workspace.events.removeFirst(workspace.events.count - 500)
}
do {
// Once the in-memory transaction exists, finish the atomic save even if
// cancellation arrives. This keeps the local write and its receipt in the
// same durable snapshot.
try await persistence.save(workspace)
return toolResult
} catch {
workspace = previousWorkspace
markPersistenceUnavailable(error)
throw error
}
}
private func record(_ event: AgentEvent) {
if let receipt = event.receipt,
workspace.events.contains(where: {
$0.receipt?.runID == receipt.runID
&& $0.receipt?.call.id == receipt.call.id
})
{
return
}
workspace.events.append(event)
if workspace.events.count > 500 {
workspace.events.removeFirst(workspace.events.count - 500)
}
persistSoon()
}
private func terminalReceiptAppendix(for runID: UUID) -> String {
var seen: Set<UUID> = []
let receipts = workspace.events.compactMap(\.receipt).filter { receipt in
receipt.runID == runID && seen.insert(receipt.call.id).inserted
}
guard !receipts.isEmpty else {
return ""
}
let hasLocalWrites = receipts.contains { $0.risk == .localWrite }
return hasLocalWrites
? "\n\nSome local changes completed before the stop. Review Activity before retrying."
: "\n\nSome read-only steps completed before the stop. Details are in Activity."
}
private func recordStandalone(
kind: AgentEventKind,
status: AgentEventStatus,
title: String,
detail: String
) {
record(
AgentEvent(
sequence: nextSequence(for: nil),
kind: kind,
status: status,
title: title,
detail: detail
)
)
}
private func nextSequence(for runID: UUID?) -> Int {
(workspace.events
.filter { $0.runID == runID }
.map(\.sequence)
.max() ?? 0) + 1
}
private func persistSoon() {
guard isWorkspaceReady, let persistence else { return }
let snapshot = workspace
let previousTask = persistenceTask
persistenceTask = Task { [weak self, persistence] in
if let previousTask {
await previousTask.value
}
do {
try await persistence.save(snapshot)
} catch {
self?.markPersistenceUnavailable(error)
}
}
}
private func markPersistenceUnavailable(_ error: Error) {
if let activeRunID,
let index = workspace.runs.firstIndex(where: { $0.id == activeRunID }),
!workspace.runs[index].status.isTerminal
{
workspace.runs[index].status = .cancellationRequested
workspace.runs[index].stopReason = .storageFailure
workspace.runs[index].checkpoint.updatedAt = Date()
}
persistence = nil
isWorkspaceReady = false
errorMessage =
"Local storage became unavailable. Unsaved changes may be visible but are not confirmed durable: \(error.localizedDescription)"
modelStatus = "Local storage unavailable"
resolveApproval(approved: false)
runTimeoutTask?.cancel()
runTimeoutTask = nil
loadModelTask?.cancel()
runTask?.cancel()
}
private func trimWorkspaceHistory() {
if workspace.messages.count > 200 {
workspace.messages.removeFirst(workspace.messages.count - 200)
}
while workspace.messages.first?.role == .assistant {
workspace.messages.removeFirst()
}
}
private func sanitized(_ workspace: AssistantWorkspace) -> AssistantWorkspace {
var value = workspace
value.messages = Array(value.messages.suffix(200)).compactMap { message in
guard message.role == .assistant else { return message }
var cleaned = message
cleaned.content = AssistantChatText.cleaned(message.content)
return cleaned.content.isEmpty ? nil : cleaned
}
while value.messages.first?.role == .assistant {
value.messages.removeFirst()
}
value.events = Array(value.events.suffix(500))
value.memories = Array(value.memories.suffix(500))
value.knowledgeItems = Array(
value.knowledgeItems.suffix(KnowledgeEngine.Limits.maxCorpusItems)
)
value.knowledgeDigests = Array(value.knowledgeDigests.suffix(50))
value.handoffs = Array(value.handoffs.suffix(100))
if let activeHandoffID = value.activeHandoffID,
!value.handoffs.contains(where: { $0.id == activeHandoffID })
{
value.activeHandoffID = nil
}
value.tasks = Array(value.tasks.suffix(500))
value.recoverInterruptedRuns()
value.events = Array(value.events.suffix(500))
let removableRuns = value.runs.filter { $0.status.isTerminal }
let overflow = max(0, removableRuns.count - 100)
if overflow > 0 {
let removedIDs = Set(removableRuns.prefix(overflow).map(\.id))
value.runs.removeAll { removedIDs.contains($0.id) }
}
value.settings = sanitized(value.settings)
return value
}
private func sanitized(_ settings: AgentSettings) -> AgentSettings {
settings.sanitized()
}
private func appendAutomaticKnowledgeDigestIfNeeded(trigger: String) {
guard
!workspace.knowledgeItems.isEmpty,
workspace.knowledgeItems.count.isMultiple(of: 5)
else { return }
let activeHandoff = workspace.activeHandoffID.flatMap { activeID in
workspace.handoffs.first { $0.id == activeID }
}
let digest = KnowledgeEngine().synthesizeContext(
knowledge: workspace.knowledgeItems,
tasks: workspace.tasks,
handoff: activeHandoff,
trigger: trigger
)
if let index = workspace.knowledgeDigests.firstIndex(where: { $0.id == digest.id }) {
workspace.knowledgeDigests[index] = digest
} else {
workspace.knowledgeDigests.append(digest)
}
workspace.knowledgeDigests = Array(workspace.knowledgeDigests.suffix(50))
}
private func removeKnowledgeAndDerivedState(ids: Set<UUID>) {
guard !ids.isEmpty else { return }
let cascade = KnowledgeEngine().deletionCascade(
deletingKnowledgeIDs: ids,
handoffs: workspace.handoffs,
digests: workspace.knowledgeDigests
)
workspace.knowledgeItems.removeAll { ids.contains($0.id) }
for index in workspace.knowledgeItems.indices {
workspace.knowledgeItems[index].relatedItemIDs.removeAll {
ids.contains($0)
}
}
workspace.handoffs.removeAll { cascade.handoffIDs.contains($0.id) }
workspace.knowledgeDigests.removeAll { cascade.digestIDs.contains($0.id) }
if let activeID = workspace.activeHandoffID,
cascade.handoffIDs.contains(activeID)
{
workspace.activeHandoffID = nil
}
}
private func scrubConversationCopiesFromHandoffs() {
workspace.handoffs = workspace.handoffs.map {
$0.removingRecentConversationContext()
}
}
private static func format(_ value: Double) -> String {
String(format: "%.2f", value)
}
}