SPB Git

spb/poche Public

Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.

Swift 100%
8.4 KB · 226 lines swift
Raw Blame History
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