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? @ObservationIgnored private var loadModelTask: Task? @ObservationIgnored private var runTask: Task? @ObservationIgnored private var runTimeoutTask: Task? @ObservationIgnored private var persistenceTask: Task? @ObservationIgnored private var approvalExpiryTask: Task? @ObservationIgnored private var approvalContinuation: CheckedContinuation? @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 = [] 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) { 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) } }