diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3c7b3bd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,127 @@ +# Contributing to lil-agents + +Thanks for interest in contributing! Here's how to get started. + +## Local LLM Support + +This project supports multiple local LLM backends for privacy-first AI interactions. + +### Supported Local LLMs + +#### Ollama (Recommended for beginners) + +Easiest to use with pre-quantized models: + +```bash +brew install ollama +ollama serve +``` + +In another terminal: +```bash +ollama run llama2 +``` + +#### vLLM (High-performance inference) + +For faster inference with HuggingFace models: + +```bash +python3 -m venv ~/.venv +source ~/.venv/bin/activate +pip install vllm +python -m vllm.entrypoints.openai.api_server +``` + +#### Llama.cpp (Lightweight) + +Minimal resource footprint, great for older machines: + +```bash +brew install llama.cpp +llama-cli -m -i +``` + +### Adding a New Local LLM Provider + +1. Add a new case to `AgentProvider` enum in `LilAgents/AgentSession.swift`: + ```swift + case localMyLLM + ``` + +2. Update provider properties: + ```swift + var displayName: String { + switch self { + case .localMyLLM: return "Local • MyLLM" + // ... + } + } + + var binaryName: String { + switch self { + case .localMyLLM: return "myllm" + // ... + } + } + ``` + +3. Add installation instructions: + ```swift + var installInstructions: String { + switch self { + case .localMyLLM: + return "Installation instructions for MyLLM..." + // ... + } + } + ``` + +4. Update factory method: + ```swift + func createSession() -> any AgentSession { + switch self { + case .localMyLLM: return LocalLLMSession(provider: .localMyLLM) + // ... + } + } + ``` + +5. Add binary search paths in `detectAvailableProviders()` if needed + +6. Test with your LLM installed locally + +## Code Style + +- Follow Swift naming conventions (camelCase for variables/functions, PascalCase for types) +- Use `private` for internal properties +- Add `// MARK: -` comments to organize code sections +- Document public functions with comments +- Use descriptive variable names +- Prefer `guard let` for optional unwrapping + +## Testing + +1. Install a local LLM (Ollama recommended) +2. Build the app in Xcode: `open lil-agents.xcodeproj` +3. Run and select the LLM from Provider menu +4. Test interactive chat + +## Building + +```bash +open lil-agents.xcodeproj +``` + +Build with Xcode or via command line: +```bash +xcodebuild -scheme LilAgents -configuration Release +``` + +## Submitting Changes + +1. Fork the repo +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Commit with clear messages +4. Push and open a PR to `ryanstephen/lil-agents` +5. Reference any related issues diff --git a/LilAgents/AgentSession.swift b/LilAgents/AgentSession.swift index 8eee8f4..fa1e315 100644 --- a/LilAgents/AgentSession.swift +++ b/LilAgents/AgentSession.swift @@ -4,6 +4,7 @@ import Foundation enum AgentProvider: String, CaseIterable { case claude, codex, copilot, gemini, opencode, openclaw + case localLlama, localOllama, localVllm private static let defaultsKey = "selectedProvider" @@ -19,12 +20,15 @@ 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 .localLlama: return "Local • Llama" + case .localOllama: return "Local • Ollama" + case .localVllm: return "Local • vLLM" } } @@ -43,12 +47,15 @@ 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 .localLlama: return "llama" + case .localOllama: return "ollama" + case .localVllm: return "vllm" } } @@ -59,23 +66,40 @@ enum AgentProvider: String, CaseIterable { static func detectAvailableProviders(completion: @escaping () -> Void) { let all = AgentProvider.allCases let group = DispatchGroup() + for provider in all { // OpenClaw is network-based, not a local binary if provider == .openclaw { availability[provider] = OpenClawConfig.load().authToken.isEmpty == false continue } + group.enter() let home = FileManager.default.homeDirectoryForCurrentUser.path - ShellEnvironment.findBinary(name: provider.binaryName, fallbackPaths: [ + var fallbackPaths = [ "\(home)/.local/bin/\(provider.binaryName)", "/usr/local/bin/\(provider.binaryName)", "/opt/homebrew/bin/\(provider.binaryName)" - ]) { path in + ] + + // Add extra paths for local LLMs + switch provider { + case .localOllama: + fallbackPaths.append("/opt/ollama/bin/ollama") + fallbackPaths.append("\(home)/.ollama/bin/ollama") + case .localVllm: + fallbackPaths.append("\(home)/.venv/bin/vllm") + fallbackPaths.append("/usr/local/bin/vllm") + default: + break + } + + ShellEnvironment.findBinary(name: provider.binaryName, fallbackPaths: fallbackPaths) { path in availability[provider] = path != nil group.leave() } } + group.notify(queue: .main) { completion() } @@ -105,17 +129,82 @@ 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 .localLlama: + return "To install Llama locally:\n\n1. Download from: https://github.com/ggerganov/llama.cpp\n2. Build: make\n3. Place binary in /usr/local/bin or ~/.local/bin\n\nOr use Homebrew:\n brew install llama.cpp" + case .localOllama: + return "To install Ollama:\n\n1. Download from: https://ollama.ai\n2. Install the app\n3. Run: ollama serve (in background)\n4. Test: ollama run llama2\n\nOr with Homebrew:\n brew install ollama" + case .localVllm: + return "To install vLLM:\n\n1. Create virtual env: python3 -m venv ~/.venv\n2. Activate: source ~/.venv/bin/activate\n3. Install: pip install vllm\n4. Start server: python -m vllm.entrypoints.openai.api_server" } } func createSession() -> any AgentSession { switch self { - case .claude: return ClaudeSession() - case .codex: return CodexSession() - case .copilot: return CopilotSession() - case .gemini: return GeminiSession() - case .opencode: return OpenCodeSession() - case .openclaw: return OpenClawSession() + case .claude: return ClaudeSession() + case .codex: return CodexSession() + case .copilot: return CopilotSession() + case .gemini: return GeminiSession() + case .opencode: return OpenCodeSession() + case .openclaw: return OpenClawSession() + case .localLlama: return LocalLLMSession(provider: .localLlama) + case .localOllama: return LocalLLMSession(provider: .localOllama) + case .localVllm: return LocalLLMSession(provider: .localVllm) + } + } +} + +// MARK: - Local LLM Configuration + +/// Stores and manages per-provider configuration for local LLMs. +struct LocalLLMConfig { + let provider: AgentProvider + var endpoint: String + var modelName: String + var apiKey: String + var temperature: Float + var maxTokens: Int + + private static let defaults = UserDefaults.standard + + /// Load configuration from UserDefaults, with sensible defaults. + static func load(for provider: AgentProvider) -> LocalLLMConfig { + let prefix = provider.rawValue + return LocalLLMConfig( + provider: provider, + endpoint: defaults.string(forKey: "\(prefix)_endpoint") ?? defaultEndpoint(for: provider), + modelName: defaults.string(forKey: "\(prefix)_model") ?? defaultModel(for: provider), + apiKey: defaults.string(forKey: "\(prefix)_apikey") ?? "", + temperature: Float(defaults.double(forKey: "\(prefix)_temperature")) == 0 ? 0.7 : Float(defaults.double(forKey: "\(prefix)_temperature")), + maxTokens: defaults.integer(forKey: "\(prefix)_maxTokens") == 0 ? 2048 : defaults.integer(forKey: "\(prefix)_maxTokens") + ) + } + + /// Persist configuration to UserDefaults. + mutating func save() { + let prefix = provider.rawValue + let defaults = UserDefaults.standard + defaults.set(endpoint, forKey: "\(prefix)_endpoint") + defaults.set(modelName, forKey: "\(prefix)_model") + defaults.set(apiKey, forKey: "\(prefix)_apikey") + defaults.set(Double(temperature), forKey: "\(prefix)_temperature") + defaults.set(maxTokens, forKey: "\(prefix)_maxTokens") + } + + private static func defaultEndpoint(for provider: AgentProvider) -> String { + switch provider { + case .localOllama: return "http://localhost:11434" + case .localVllm: return "http://localhost:8000" + case .localLlama: return "http://localhost:8080" + default: return "http://localhost:8000" + } + } + + private static func defaultModel(for provider: AgentProvider) -> String { + switch provider { + case .localOllama: return "llama2" + case .localVllm: return "meta-llama/Llama-2-7b-hf" + case .localLlama: return "default" + default: return "default" } } } diff --git a/LilAgents/LocalLLMSession.swift b/LilAgents/LocalLLMSession.swift new file mode 100644 index 0000000..ad9f85c --- /dev/null +++ b/LilAgents/LocalLLMSession.swift @@ -0,0 +1,264 @@ +import Foundation + +// MARK: - Local LLM Session + +/// Generic session handler for local LLMs (Llama, Ollama, vLLM). +/// Conforms to `AgentSession` to integrate with the provider system. +class LocalLLMSession: NSObject, AgentSession { + // MARK: - Properties + + 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)? + + private var config: LocalLLMConfig + private var process: Process? + private var inputPipe: Pipe? + private var outputPipe: Pipe? + private var errorPipe: Pipe? + private var readThread: Thread? + private let outputQueue = DispatchQueue(label: "com.lil-agents.localllm.output") + + // MARK: - Init + + init(provider: AgentProvider) { + super.init() + self.config = LocalLLMConfig.load(for: provider) + } + + // MARK: - Session Lifecycle + + func start() { + guard !isRunning else { return } + isRunning = true + + switch config.provider { + case .localOllama: + startOllama() + case .localVllm: + startVLLM() + case .localLlama: + startLlama() + default: + onError?("Unknown local LLM provider") + isRunning = false + } + } + + func send(message: String) { + guard let proc = process, proc.isRunning, let stdin = inputPipe?.fileHandleForWriting else { + onError?("LLM process not running") + return + } + + isBusy = true + history.append(AgentMessage(role: .user, text: message)) + + if let data = (message + "\n").data(using: .utf8) { + stdin.write(data) + } + } + + func terminate() { + process?.terminate() + try? inputPipe?.fileHandleForWriting.close() + isRunning = false + isBusy = false + onProcessExit?() + } + + // MARK: - Provider-Specific Launchers + + private func startOllama() { + let home = FileManager.default.homeDirectoryForCurrentUser.path + ShellEnvironment.findBinary(name: "ollama", fallbackPaths: [ + "\(home)/.local/bin/ollama", + "/usr/local/bin/ollama", + "/opt/homebrew/bin/ollama", + "/opt/ollama/bin/ollama" + ]) { [weak self] path in + guard let self = self else { return } + guard let binaryPath = path else { + self.onError?("Ollama not found. \(AgentProvider.localOllama.installInstructions)") + self.isRunning = false + return + } + self.launchOllamaProcess(binaryPath: binaryPath) + } + } + + private func startVLLM() { + let home = FileManager.default.homeDirectoryForCurrentUser.path + ShellEnvironment.findBinary(name: "vllm", fallbackPaths: [ + "\(home)/.venv/bin/vllm", + "/usr/local/bin/vllm", + "\(home)/.local/bin/vllm" + ]) { [weak self] path in + guard let self = self else { return } + guard let binaryPath = path else { + self.onError?("vLLM not found. \(AgentProvider.localVllm.installInstructions)") + self.isRunning = false + return + } + self.launchVLLMProcess(binaryPath: binaryPath) + } + } + + private func startLlama() { + let home = FileManager.default.homeDirectoryForCurrentUser.path + ShellEnvironment.findBinary(name: "llama", fallbackPaths: [ + "\(home)/.local/bin/llama", + "/usr/local/bin/llama", + "/opt/homebrew/bin/llama" + ]) { [weak self] path in + guard let self = self else { return } + guard let binaryPath = path else { + self.onError?("Llama not found. \(AgentProvider.localLlama.installInstructions)") + self.isRunning = false + return + } + self.launchLlamaProcess(binaryPath: binaryPath) + } + } + + // MARK: - Process Launchers + + private func launchOllamaProcess(binaryPath: String) { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: binaryPath) + proc.arguments = [ + "run", + config.modelName, + "--verbose" + ] + proc.environment = ShellEnvironment.processEnvironment() + + inputPipe = Pipe() + outputPipe = Pipe() + errorPipe = Pipe() + + proc.standardInput = inputPipe + proc.standardOutput = outputPipe + proc.standardError = errorPipe + + proc.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { + self?.isRunning = false + self?.isBusy = false + self?.onProcessExit?() + } + } + + do { + try proc.run() + self.process = proc + onSessionReady?() + readOutputAsync() + } catch { + onError?("Failed to start Ollama: \(error.localizedDescription)") + isRunning = false + } + } + + private func launchVLLMProcess(binaryPath: String) { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: binaryPath) + proc.arguments = [ + "--model", config.modelName, + "--port", "8000", + "--tensor-parallel-size", "1" + ] + proc.environment = ShellEnvironment.processEnvironment() + + outputPipe = Pipe() + errorPipe = Pipe() + proc.standardOutput = outputPipe + proc.standardError = errorPipe + + proc.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { + self?.isRunning = false + self?.isBusy = false + self?.onProcessExit?() + } + } + + do { + try proc.run() + self.process = proc + // Give vLLM time to start the server + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in + self?.onSessionReady?() + } + readOutputAsync() + } catch { + onError?("Failed to start vLLM: \(error.localizedDescription)") + isRunning = false + } + } + + private func launchLlamaProcess(binaryPath: String) { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: binaryPath) + proc.arguments = ["-i", "-c", "2048"] + proc.environment = ShellEnvironment.processEnvironment() + + inputPipe = Pipe() + outputPipe = Pipe() + errorPipe = Pipe() + + proc.standardInput = inputPipe + proc.standardOutput = outputPipe + proc.standardError = errorPipe + + proc.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { + self?.isRunning = false + self?.isBusy = false + self?.onProcessExit?() + } + } + + do { + try proc.run() + self.process = proc + onSessionReady?() + readOutputAsync() + } catch { + onError?("Failed to start Llama: \(error.localizedDescription)") + isRunning = false + } + } + + // MARK: - Output Reading + + private func readOutputAsync() { + readThread = Thread { [weak self] in + guard let self = self, let pipe = self.outputPipe else { return } + let handle = pipe.fileHandleForReading + + while self.isRunning { + let data = handle.availableData + if data.isEmpty { + usleep(100_000) // 100ms + continue + } + + if let output = String(data: data, encoding: .utf8), !output.isEmpty { + DispatchQueue.main.async { + self.onText?(output) + } + } + } + } + readThread?.start() + } +}