spb/poche Public
Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.
Swift 100%
1//2// AgentSession.swift3// Poche4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//89import Foundation10import FoundationModels11import Observation1213/// Owns the LanguageModelSession lifecycle: preflight, streaming,14/// condensation, invisible recycling, refusal handling.15///16/// Rules enforced here (CLAUDE.md §5, §9):17/// - never more than one request in flight (Neural Engine is serialized);18/// - condense at 70% of the window, keep the last turns intact;19/// - recycling is invisible — the user never sees a "new conversation";20/// - `exceededContextWindowSize` recycles and replays, never surfaces;21/// - a guardrail refusal is a normal outcome, never an error screen.22@MainActor23@Observable24final class AgentSession {25 enum Outcome: Sendable {26 case completed(String)27 case refused(String)28 case failed(String)29 }3031 private(set) var isResponding = false32 private(set) var usageRatio: Double = 03334 /// Fired after each condensation so the summary can be persisted as35 /// long-term memory (CLAUDE.md §5).36 var onSummary: ((String) -> Void)?3738 private let budget = ContextBudget()39 private let condenser = Condenser()40 private let thermal = ThermalMonitor()41 private let tools: [any Tool]4243 private var session: LanguageModelSession44 /// Private mirror of the transcript, used for token accounting,45 /// condensation and recycling. Display state lives in ChatViewModel.46 private var log: [AgentExchange] = []47 private var carriedSummary: String?48 /// App events (confirmed/cancelled actions) injected into the next turn49 /// so the model knows what actually happened.50 private var pendingNotes: [String] = []5152 /// Instructions + tool schemas: paid on every single call.53 private let fixedCost: Int5455 /// Repeated failures mean the system inference layer is down (e.g. the56 /// simulator without model assets) — after two in a row, say so honestly57 /// instead of an eternal "try again".58 private var consecutiveFailures = 05960 /// How many recent exchanges survive a recycle verbatim61 /// (3 user/assistant turns ≈ 6 entries; CLAUDE.md §5).62 private static let keptVerbatim = 66364 init(tools: [any Tool]) {65 self.tools = tools66 self.fixedCost = TokenEstimator.tokens(in: SystemInstructions.current)67 + tools.count * TokenEstimator.perToolOverhead68 self.session = Self.makeSession(tools: tools, summary: nil, recent: [])69 session.prewarm()70 }7172 func respond(73 to userText: String,74 onPartial: @escaping @MainActor (String) -> Void75 ) async -> Outcome {76 guard !isResponding, !session.isResponding else {77 return .failed("Je réponds déjà — un instant.")78 }79 if thermal.shouldPauseInference {80 return .failed("Ton iPhone chauffe beaucoup. Je fais une courte pause, réessaie dans un moment.")81 }8283 isResponding = true84 defer { isResponding = false }8586 let prompt = composePrompt(with: userText)8788 // Preflight (CLAUDE.md §2): condense before the window overflows,89 // and always keep room for the response.90 let projected = estimatedTokens + TokenEstimator.tokens(in: prompt)91 if budget.needsCondensation(estimatedTokens: projected) || !budget.canSend(estimatedTokens: projected) {92 await recycle()93 }9495 return await performTurn(prompt: prompt, userText: userText, onPartial: onPartial, isRetry: false)96 }9798 /// Records an app-side event (confirmation, cancellation) to surface to99 /// the model on its next turn instead of letting it trust its proposal.100 func noteEvent(_ text: String) {101 pendingNotes.append(text)102 log.append(AgentExchange(role: .event, text: text))103 refreshUsage()104 }105106 // MARK: - Turn execution107108 private func performTurn(109 prompt: String,110 userText: String,111 onPartial: @escaping @MainActor (String) -> Void,112 isRetry: Bool113 ) async -> Outcome {114 do {115 var latest = ""116 let stream = session.streamResponse(to: prompt)117 for try await snapshot in stream {118 latest = snapshot.content119 onPartial(latest)120 }121122 pendingNotes.removeAll()123 consecutiveFailures = 0124 log.append(AgentExchange(role: .user, text: userText))125 log.append(AgentExchange(role: .assistant, text: latest))126 refreshUsage()127 return .completed(latest)128 } catch let error as LanguageModelSession.GenerationError {129 #if DEBUG130 print("AgentSession generation error:", error)131 #endif132 switch error {133 case .guardrailViolation, .refusal:134 // Normal interface state: neutral message, thread intact.135 return .refused("Je ne peux pas répondre à ça tel quel. Reformule autrement et on continue.")136 case .exceededContextWindowSize:137 // Safety net (CLAUDE.md §5): recycle, replay, never surface.138 guard !isRetry else {139 return .failed("Je n’arrive pas à reprendre le fil. Reformule ton dernier message.")140 }141 await recycle()142 return await performTurn(prompt: prompt, userText: userText, onPartial: onPartial, isRetry: true)143 case .assetsUnavailable:144 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.")145 default:146 return .failed(genericFailureMessage())147 }148 } catch {149 #if DEBUG150 print("AgentSession unexpected error:", error)151 #endif152 return .failed(genericFailureMessage())153 }154 }155156 private func genericFailureMessage() -> String {157 consecutiveFailures += 1158 if consecutiveFailures >= 2 {159 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."160 }161 return "Un pépin de mon côté. Réessaie dans un instant."162 }163164 private func composePrompt(with userText: String) -> String {165 guard !pendingNotes.isEmpty else { return userText }166 let notes = pendingNotes.map { "[\($0)]" }.joined(separator: "\n")167 return notes + "\n" + userText168 }169170 // MARK: - Condensation & recycling (invisible to the user)171172 private func recycle() async {173 let recent = Array(log.suffix(Self.keptVerbatim))174 let old = Array(log.dropLast(Self.keptVerbatim))175176 var summary = carriedSummary ?? ""177 if !old.isEmpty {178 do {179 summary = try await condenser.condense(old)180 } catch {181 summary = condenser.fallbackSummary(for: old)182 }183 }184185 carriedSummary = summary.isEmpty ? nil : summary186 if let carriedSummary {187 onSummary?(carriedSummary)188 }189 log = recent190 session = Self.makeSession(tools: tools, summary: carriedSummary, recent: recent)191 session.prewarm()192 refreshUsage()193 }194195 private static func makeSession(196 tools: [any Tool],197 summary: String?,198 recent: [AgentExchange]199 ) -> LanguageModelSession {200 var instructions = SystemInstructions.current201 if let summary, !summary.isEmpty {202 instructions += "\n\nRésumé fidèle de la conversation jusqu'ici :\n\(summary)"203 }204 if !recent.isEmpty {205 let rendered = recent206 .map { "\($0.role == .user ? "Utilisateur" : $0.role == .assistant ? "Assistant" : "Événement") : \(String($0.text.prefix(280)))" }207 .joined(separator: "\n")208 instructions += "\n\nDerniers échanges :\n\(rendered)"209 }210 return LanguageModelSession(model: .default, tools: tools, instructions: instructions)211 }212213 // MARK: - Accounting214215 private var estimatedTokens: Int {216 let logCost = log.reduce(0) { $0 + TokenEstimator.tokens(in: $1.text) }217 let summaryCost = carriedSummary.map { TokenEstimator.tokens(in: $0) } ?? 0218 let notesCost = pendingNotes.reduce(0) { $0 + TokenEstimator.tokens(in: $1) }219 return fixedCost + logCost + summaryCost + notesCost220 }221222 private func refreshUsage() {223 usageRatio = budget.usageRatio(estimatedTokens: estimatedTokens)224 }225}226