Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions LilAgents/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
}
}

Expand All @@ -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 ""
}
}

Expand All @@ -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: [
Expand All @@ -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
}

Expand All @@ -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."
}
}

Expand All @@ -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()
}
}
}
}
Expand Down
148 changes: 148 additions & 0 deletions LilAgents/FoundationModelsSession.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

// 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
}
}
15 changes: 15 additions & 0 deletions LilAgents/WalkerCharacter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
4 changes: 4 additions & 0 deletions lil-agents.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -51,6 +52,7 @@
A10000020000000000000035 /* OpenCodeSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeSession.swift; sourceTree = "<group>"; };
A10000020000000000000036 /* DockVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockVisibility.swift; sourceTree = "<group>"; };
A10000020000000000000037 /* OpenClawSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenClawSession.swift; sourceTree = "<group>"; };
A10000020000000000000038 /* FoundationModelsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelsSession.swift; sourceTree = "<group>"; };
A10000030000000000000001 /* lil agents.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "lil agents.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down