diff --git a/LilAgents/AgentSession.swift b/LilAgents/AgentSession.swift index 8eee8f4..e9731a4 100644 --- a/LilAgents/AgentSession.swift +++ b/LilAgents/AgentSession.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - Provider enum AgentProvider: String, CaseIterable { - case claude, codex, copilot, gemini, opencode, openclaw + case claude, codex, copilot, gemini, opencode, openclaw, foundationModels private static let defaultsKey = "selectedProvider" @@ -19,12 +19,13 @@ enum AgentProvider: String, CaseIterable { var displayName: String { switch self { - case .claude: return "Claude" - case .codex: return "Codex" - case .copilot: return "Copilot" - case .gemini: return "Gemini" - case .opencode: return "OpenCode" - case .openclaw: return "OpenClaw" + case .claude: return "Claude" + case .codex: return "Codex" + case .copilot: return "Copilot" + case .gemini: return "Gemini" + case .opencode: return "OpenCode" + case .openclaw: return "OpenClaw" + case .foundationModels: return "On-Device" } } @@ -43,12 +44,13 @@ enum AgentProvider: String, CaseIterable { var binaryName: String { switch self { - case .claude: return "claude" - case .codex: return "codex" - case .copilot: return "copilot" - case .gemini: return "gemini" - case .opencode: return "opencode" - case .openclaw: return "openclaw" + case .claude: return "claude" + case .codex: return "codex" + case .copilot: return "copilot" + case .gemini: return "gemini" + case .opencode: return "opencode" + case .openclaw: return "openclaw" + case .foundationModels: return "" } } @@ -65,6 +67,15 @@ enum AgentProvider: String, CaseIterable { availability[provider] = OpenClawConfig.load().authToken.isEmpty == false continue } + // FoundationModels is framework-based, not a local binary + if provider == .foundationModels { + if #available(macOS 26.0, *) { + availability[provider] = FoundationModelsSession.isAvailable() + } else { + availability[provider] = false + } + continue + } group.enter() let home = FileManager.default.homeDirectoryForCurrentUser.path ShellEnvironment.findBinary(name: provider.binaryName, fallbackPaths: [ @@ -83,6 +94,12 @@ enum AgentProvider: String, CaseIterable { var isAvailable: Bool { if self == .openclaw { return OpenClawConfig.load().authToken.isEmpty == false } + if self == .foundationModels { + if #available(macOS 26.0, *) { + return FoundationModelsSession.isAvailable() + } + return false + } return AgentProvider.availability[self] ?? false } @@ -105,6 +122,8 @@ enum AgentProvider: String, CaseIterable { return "To install, run this in Terminal:\n curl -fsSL https://opencode.ai/install | bash" case .openclaw: return "OpenClaw is a self-hosted AI gateway.\n\nInstall: npm install -g openclaw\nStart: openclaw gateway run\n\nDocs: https://docs.openclaw.ai" + case .foundationModels: + return "Apple Intelligence requires macOS 26+ on Apple Silicon\nwith Apple Intelligence enabled in System Settings." } } @@ -116,6 +135,13 @@ enum AgentProvider: String, CaseIterable { case .gemini: return GeminiSession() case .opencode: return OpenCodeSession() case .openclaw: return OpenClawSession() + case .foundationModels: + if #available(macOS 26.0, *) { + return FoundationModelsSession() + } else { + // Fallback — should never reach here if isAvailable is checked first + return ClaudeSession() + } } } } diff --git a/LilAgents/FoundationModelsSession.swift b/LilAgents/FoundationModelsSession.swift new file mode 100644 index 0000000..dc1c59e --- /dev/null +++ b/LilAgents/FoundationModelsSession.swift @@ -0,0 +1,148 @@ +import Foundation +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// On-device LLM session using Apple FoundationModels (macOS 26+). +/// Adapts the FoundationModels streaming API to the lil-agents +/// callback-based AgentSession protocol. +@available(macOS 26.0, *) +class FoundationModelsSession: AgentSession { + private(set) var isRunning = false + private(set) var isBusy = false + var history: [AgentMessage] = [] + + var onText: ((String) -> Void)? + var onError: ((String) -> Void)? + var onToolUse: ((String, [String: Any]) -> Void)? + var onToolResult: ((String, Bool) -> Void)? + var onSessionReady: (() -> Void)? + var onTurnComplete: (() -> Void)? + var onProcessExit: (() -> Void)? + + #if canImport(FoundationModels) + private var session: LanguageModelSession? + #endif + + private var currentTask: Task? + + // MARK: - Availability + + static func isAvailable() -> Bool { + #if canImport(FoundationModels) + switch SystemLanguageModel.default.availability { + case .available: return true + default: return false + } + #else + return false + #endif + } + + // MARK: - AgentSession + + func start() { + guard !isRunning else { return } + + #if canImport(FoundationModels) + switch SystemLanguageModel.default.availability { + case .available: + let s = LanguageModelSession() + s.prewarm() + session = s + isRunning = true + onSessionReady?() + default: + let msg = AgentProvider.foundationModels.installInstructions + onError?(msg) + history.append(AgentMessage(role: .error, text: msg)) + } + #else + let msg = "FoundationModels framework not available on this system." + onError?(msg) + history.append(AgentMessage(role: .error, text: msg)) + #endif + } + + func send(message: String) { + guard isRunning else { return } + guard !isBusy else { return } + + isBusy = true + history.append(AgentMessage(role: .user, text: message)) + + #if canImport(FoundationModels) + guard let session = session else { + isBusy = false + onError?("On-device session is not available.") + return + } + + currentTask = Task { [weak self] in + await self?.streamResponse(session: session, prompt: message) + } + #else + isBusy = false + onError?("FoundationModels framework not available.") + #endif + } + + func terminate() { + currentTask?.cancel() + currentTask = nil + #if canImport(FoundationModels) + session = nil + #endif + isRunning = false + isBusy = false + onProcessExit?() + } + + // MARK: - Streaming + + #if canImport(FoundationModels) + @MainActor + private func streamResponse(session: LanguageModelSession, prompt: String) async { + var accumulated = "" + do { + let stream = session.streamResponse(to: prompt) + for try await snapshot in stream { + guard !Task.isCancelled else { + isBusy = false + onTurnComplete?() + return + } + let full = snapshot.content + let delta = String(full.dropFirst(accumulated.count)) + if !delta.isEmpty { + onText?(delta) + } + accumulated = full + } + // Stream completed successfully + if !accumulated.isEmpty { + history.append(AgentMessage(role: .assistant, text: accumulated)) + } + isBusy = false + onTurnComplete?() + } catch { + isBusy = false + onError?(error.localizedDescription) + } + } + #endif + + // MARK: - Context Reset + + /// Recreate the inner LanguageModelSession for /clear support. + /// The outer session object (and its callback wiring) stays alive. + func resetContext() { + currentTask?.cancel() + currentTask = nil + #if canImport(FoundationModels) + let s = LanguageModelSession() + s.prewarm() + session = s + #endif + } +} diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index fa30824..fb168d4 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -506,6 +506,21 @@ class WalkerCharacter { } func resetSession() { + // For FoundationModels, reset context without full teardown + if #available(macOS 26.0, *), + let fmSession = session as? FoundationModelsSession { + fmSession.resetContext() + currentStreamingText = "" + showingCompletion = false + currentPhrase = "" + completionBubbleExpiry = 0 + hideBubble() + terminalView?.resetState() + terminalView?.showSessionMessage() + fmSession.history = [] + return + } + session?.terminate() session = nil currentStreamingText = "" diff --git a/lil-agents.xcodeproj/project.pbxproj b/lil-agents.xcodeproj/project.pbxproj index d45a7c5..611425a 100644 --- a/lil-agents.xcodeproj/project.pbxproj +++ b/lil-agents.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ A10000010000000000000036 /* OpenCodeSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000035 /* OpenCodeSession.swift */; }; A10000010000000000000037 /* DockVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000036 /* DockVisibility.swift */; }; A10000010000000000000038 /* OpenClawSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000037 /* OpenClawSession.swift */; }; + A10000010000000000000039 /* FoundationModelsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000038 /* FoundationModelsSession.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -51,6 +52,7 @@ A10000020000000000000035 /* OpenCodeSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeSession.swift; sourceTree = ""; }; A10000020000000000000036 /* DockVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockVisibility.swift; sourceTree = ""; }; A10000020000000000000037 /* OpenClawSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenClawSession.swift; sourceTree = ""; }; + A10000020000000000000038 /* FoundationModelsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelsSession.swift; sourceTree = ""; }; A10000030000000000000001 /* lil agents.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "lil agents.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -88,6 +90,7 @@ A10000020000000000000034 /* GeminiSession.swift */, A10000020000000000000035 /* OpenCodeSession.swift */, A10000020000000000000037 /* OpenClawSession.swift */, + A10000020000000000000038 /* FoundationModelsSession.swift */, A10000020000000000000036 /* DockVisibility.swift */, A10000020000000000000023 /* TerminalView.swift */, A10000020000000000000024 /* WalkerCharacter.swift */, @@ -200,6 +203,7 @@ A10000010000000000000036 /* OpenCodeSession.swift in Sources */, A10000010000000000000037 /* DockVisibility.swift in Sources */, A10000010000000000000038 /* OpenClawSession.swift in Sources */, + A10000010000000000000039 /* FoundationModelsSession.swift in Sources */, A10000010000000000000023 /* TerminalView.swift in Sources */, A10000010000000000000024 /* WalkerCharacter.swift in Sources */, A10000010000000000000025 /* LilAgentsController.swift in Sources */,