// // AgentSession.swift // Poche // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // import Foundation import FoundationModels import Observation /// Owns the LanguageModelSession lifecycle: preflight, streaming, /// condensation, invisible recycling, refusal handling. /// /// Rules enforced here (CLAUDE.md §5, §9): /// - never more than one request in flight (Neural Engine is serialized); /// - condense at 70% of the window, keep the last turns intact; /// - recycling is invisible — the user never sees a "new conversation"; /// - `exceededContextWindowSize` recycles and replays, never surfaces; /// - a guardrail refusal is a normal outcome, never an error screen. @MainActor @Observable final class AgentSession { enum Outcome: Sendable { case completed(String) case refused(String) case failed(String) } private(set) var isResponding = false private(set) var usageRatio: Double = 0 /// Fired after each condensation so the summary can be persisted as /// long-term memory (CLAUDE.md §5). var onSummary: ((String) -> Void)? private let budget = ContextBudget() private let condenser = Condenser() private let thermal = ThermalMonitor() private let tools: [any Tool] private var session: LanguageModelSession /// Private mirror of the transcript, used for token accounting, /// condensation and recycling. Display state lives in ChatViewModel. private var log: [AgentExchange] = [] private var carriedSummary: String? /// App events (confirmed/cancelled actions) injected into the next turn /// so the model knows what actually happened. private var pendingNotes: [String] = [] /// Instructions + tool schemas: paid on every single call. private let fixedCost: Int /// Repeated failures mean the system inference layer is down (e.g. the /// simulator without model assets) — after two in a row, say so honestly /// instead of an eternal "try again". private var consecutiveFailures = 0 /// How many recent exchanges survive a recycle verbatim /// (3 user/assistant turns ≈ 6 entries; CLAUDE.md §5). private static let keptVerbatim = 6 init(tools: [any Tool]) { self.tools = tools self.fixedCost = TokenEstimator.tokens(in: SystemInstructions.current) + tools.count * TokenEstimator.perToolOverhead self.session = Self.makeSession(tools: tools, summary: nil, recent: []) session.prewarm() } func respond( to userText: String, onPartial: @escaping @MainActor (String) -> Void ) async -> Outcome { guard !isResponding, !session.isResponding else { return .failed("Je réponds déjà — un instant.") } if thermal.shouldPauseInference { return .failed("Ton iPhone chauffe beaucoup. Je fais une courte pause, réessaie dans un moment.") } isResponding = true defer { isResponding = false } let prompt = composePrompt(with: userText) // Preflight (CLAUDE.md §2): condense before the window overflows, // and always keep room for the response. let projected = estimatedTokens + TokenEstimator.tokens(in: prompt) if budget.needsCondensation(estimatedTokens: projected) || !budget.canSend(estimatedTokens: projected) { await recycle() } return await performTurn(prompt: prompt, userText: userText, onPartial: onPartial, isRetry: false) } /// Records an app-side event (confirmation, cancellation) to surface to /// the model on its next turn instead of letting it trust its proposal. func noteEvent(_ text: String) { pendingNotes.append(text) log.append(AgentExchange(role: .event, text: text)) refreshUsage() } // MARK: - Turn execution private func performTurn( prompt: String, userText: String, onPartial: @escaping @MainActor (String) -> Void, isRetry: Bool ) async -> Outcome { do { var latest = "" let stream = session.streamResponse(to: prompt) for try await snapshot in stream { latest = snapshot.content onPartial(latest) } pendingNotes.removeAll() consecutiveFailures = 0 log.append(AgentExchange(role: .user, text: userText)) log.append(AgentExchange(role: .assistant, text: latest)) refreshUsage() return .completed(latest) } catch let error as LanguageModelSession.GenerationError { #if DEBUG print("AgentSession generation error:", error) #endif switch error { case .guardrailViolation, .refusal: // Normal interface state: neutral message, thread intact. return .refused("Je ne peux pas répondre à ça tel quel. Reformule autrement et on continue.") case .exceededContextWindowSize: // Safety net (CLAUDE.md §5): recycle, replay, never surface. guard !isRetry else { return .failed("Je n’arrive pas à reprendre le fil. Reformule ton dernier message.") } await recycle() return await performTurn(prompt: prompt, userText: userText, onPartial: onPartial, isRetry: true) case .assetsUnavailable: return .failed("Le modèle n’est pas prêt sur cet appareil. Vérifie qu’Apple Intelligence est activé, puis réessaie dans un moment.") default: return .failed(genericFailureMessage()) } } catch { #if DEBUG print("AgentSession unexpected error:", error) #endif return .failed(genericFailureMessage()) } } private func genericFailureMessage() -> String { consecutiveFailures += 1 if consecutiveFailures >= 2 { return "Le modèle de cet appareil ne répond pas. Dans le simulateur, Apple Intelligence est souvent indisponible — sur un iPhone compatible (15 Pro ou plus récent), tout fonctionne." } return "Un pépin de mon côté. Réessaie dans un instant." } private func composePrompt(with userText: String) -> String { guard !pendingNotes.isEmpty else { return userText } let notes = pendingNotes.map { "[\($0)]" }.joined(separator: "\n") return notes + "\n" + userText } // MARK: - Condensation & recycling (invisible to the user) private func recycle() async { let recent = Array(log.suffix(Self.keptVerbatim)) let old = Array(log.dropLast(Self.keptVerbatim)) var summary = carriedSummary ?? "" if !old.isEmpty { do { summary = try await condenser.condense(old) } catch { summary = condenser.fallbackSummary(for: old) } } carriedSummary = summary.isEmpty ? nil : summary if let carriedSummary { onSummary?(carriedSummary) } log = recent session = Self.makeSession(tools: tools, summary: carriedSummary, recent: recent) session.prewarm() refreshUsage() } private static func makeSession( tools: [any Tool], summary: String?, recent: [AgentExchange] ) -> LanguageModelSession { var instructions = SystemInstructions.current if let summary, !summary.isEmpty { instructions += "\n\nRésumé fidèle de la conversation jusqu'ici :\n\(summary)" } if !recent.isEmpty { let rendered = recent .map { "\($0.role == .user ? "Utilisateur" : $0.role == .assistant ? "Assistant" : "Événement") : \(String($0.text.prefix(280)))" } .joined(separator: "\n") instructions += "\n\nDerniers échanges :\n\(rendered)" } return LanguageModelSession(model: .default, tools: tools, instructions: instructions) } // MARK: - Accounting private var estimatedTokens: Int { let logCost = log.reduce(0) { $0 + TokenEstimator.tokens(in: $1.text) } let summaryCost = carriedSummary.map { TokenEstimator.tokens(in: $0) } ?? 0 let notesCost = pendingNotes.reduce(0) { $0 + TokenEstimator.tokens(in: $1) } return fixedCost + logCost + summaryCost + notesCost } private func refreshUsage() { usageRatio = budget.usageRatio(estimatedTokens: estimatedTokens) } }