using System.Diagnostics;using System.Text.Json;using Agent.Core.ToolCalling;namespace Agent.Core.Ollama;public class OllamaClient(Logger logger, ChatHistoryStore? historyStore = null){ private const string StatusBlue = "#AAAAFF"; private const string StatusGray = "#AAAAAA"; public string BaseUrl { get; } = "http://localhost:11434"; public string ChatApiUrl => $"{BaseUrl}/api/chat"; public string ShowApiUrl => $"{BaseUrl}/api/show"; public string EmbedApiUrl => $"{BaseUrl}/api/embeddings"; public HttpClient Http { get; } = new() { Timeout = TimeSpan.FromMinutes(20) }; public string Model { get; set; } = "gemma4"; public string EmbeddingModel { get; set; } = "nomic-embed-text"; private int NumCtx { get; set; } = 131072; public int ContextMax => NumCtx; public string SystemPrompt { get; private set; } = string.Empty; public ChatHistory History { get; } = []; public ChatHistoryStore HistoryStore { get; } = historyStore ?? new ChatHistoryStore(); public Logger Logger { get; } = logger; public string AgentName { get; set; } = "Lilith"; public Memory.MemoryManager? Memory { get; private set; } public void InitMemory(string memoryFilePath) { Memory = new Memory.MemoryManager(memoryFilePath, this); if (ToolRegistry is not null) { ToolRegistry.Memory = Memory; } } public virtual async Task<float[]> GetEmbeddingAsync(string prompt, string? modelOverride = null) { string embedModel = modelOverride ?? EmbeddingModel; var request = new { model = embedModel, prompt = prompt }; using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(EmbedApiUrl, request); using HttpResponseMessage httpResp = await Http.SendAsync(httpReq); if (!httpResp.IsSuccessStatusCode) { if (embedModel != "nomic-embed-text") { PrintStatus($"Ollama model '{embedModel}' embedding failed or not found. Pulling 'nomic-embed-text' as fallback...", LogColors.Yellow); await PullModelAsync(new OllamaClient(Logger) { Model = "nomic-embed-text" }); return await GetEmbeddingAsync(prompt, "nomic-embed-text"); } httpResp.EnsureSuccessStatusCode(); } string json = await httpResp.Content.ReadAsStringAsync(); using var doc = System.Text.Json.JsonDocument.Parse(json); if (doc.RootElement.TryGetProperty("embedding", out var embeddingProp)) { var list = new List<float>(); foreach (var val in embeddingProp.EnumerateArray()) { list.Add(val.GetSingle()); } return list.ToArray(); } throw new InvalidOperationException("Embedding response did not contain 'embedding' array."); } private ToolRegistry? _toolRegistry; /// <summary>When set, Ollama may invoke these tools during <see cref="StreamReply"/>.</summary> public ToolRegistry? ToolRegistry { get => _toolRegistry; set { _toolRegistry = value; if (_toolRegistry is not null) { _toolRegistry.Memory = Memory; _toolRegistry.OnNameChanged += newName => { string oldPrompt = SystemPrompt; SystemPrompt = oldPrompt.Replace(AgentName, newName, StringComparison.OrdinalIgnoreCase); if (History.Count > 0 && History[0].Role.Equals("system", StringComparison.OrdinalIgnoreCase)) { History[0] = History[0] with { Content = History[0].Content.Replace(AgentName, newName, StringComparison.OrdinalIgnoreCase) }; } AgentName = newName; }; } } } public int ContextUsagePercent { get; private set; } public int CurrentContextTokens { get; private set; } public int ContextWarningThreshold { get; set; } = 60; public event Action<int>? OnContextHighUsage; public void ClearHistory() { History.Clear(); CurrentContextTokens = 0; if (!string.IsNullOrEmpty(SystemPrompt)) { History.Add("system", SystemPrompt); } } public void StartNewSession() => HistoryStore.StartNewSession(); public void SaveCurrentConversation() { try { if (History.Any(m => !m.Role.Equals("system", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(m.Content))) { HistoryStore.SaveSession(Model, History); } } catch (Exception ex) { Logger.WriteLine("History", $"Could not save chat history: {ex.Message}", LogColors.Orange); } } public IReadOnlyList<ChatSessionSummary> ListSavedConversations() => HistoryStore.ListSessions(); public bool LoadSavedConversation(int displayIndex) { var sessions = HistoryStore.ListSessions(); if (displayIndex < 1 || displayIndex > sessions.Count) { return false; } return LoadSavedConversation(sessions[displayIndex - 1].Id); } public bool LoadSavedConversation(string id) { try { var session = HistoryStore.LoadSession(id); if (session is null) { return false; } History.Clear(); foreach (var message in session.Messages) { History.Add(new ChatMessage(message.Role, message.Content, message.Images, Tokens: message.Tokens)); } if (History.Count == 0 || !History[0].Role.Equals("system", StringComparison.OrdinalIgnoreCase)) { History.Insert(0, new ChatMessage("system", SystemPrompt)); } CurrentContextTokens = 0; ContextUsagePercent = 0; return true; } catch (Exception ex) { Logger.WriteLine("History", $"Could not load chat history: {ex.Message}", LogColors.Orange); return false; } } public async Task<string> StreamReply(string userInput, List<string>? images = null) { History.Add(new ChatMessage("user", userInput, Images: images)); const int maxToolRounds = 8; for (int round = 0; round < maxToolRounds; round++) { var toolDefs = ToolRegistry is { } registry && registry.ToOllamaDefinitions().Count > 0 ? registry.ToOllamaDefinitions().ToList() : null; var request = new ChatRequest( Model, History, Stream: false, Options: new ChatOptions(NumCtx), Tools: toolDefs); using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ChatApiUrl, request); // Start background spinner using var spinnerCts = new CancellationTokenSource(); var spinnerTask = Task.Run(async () => { var spinner = new[] { "|", "/", "-", "\\" }; int spinIdx = 0; while (!spinnerCts.Token.IsCancellationRequested) { var spinChar = spinner[spinIdx % spinner.Length]; spinIdx++; Logger.WriteProgress(AgentName, $"{AgentName}: [Thinking {spinChar}]"); await Task.Delay(150, spinnerCts.Token); } }); HttpResponseMessage httpResp; try { httpResp = await Http.SendAsync(httpReq); httpResp.EnsureSuccessStatusCode(); } finally { spinnerCts.Cancel(); try { await spinnerTask; } catch (OperationCanceledException) { } catch { } } string json = await httpResp.Content.ReadAsStringAsync(); ChatChunk? chunk = json.Deserialize<ChatChunk>(); ChatMessage? message = chunk?.Message; if (message?.ToolCalls is { Count: > 0 } toolCalls && ToolRegistry is { } tools) { Logger.WriteProgress(AgentName, ""); // clear thinking line // Ollama requires null content (not empty string) on assistant tool-call messages History.Add(new ChatMessage("assistant", message.Content ?? string.Empty, ToolCalls: toolCalls)); foreach (ToolCall call in toolCalls) { string toolName = call.Function.Name; string toolArgs = call.Function.Arguments ?? "{}"; tools.TryInvoke(toolName, toolArgs, out string toolResult); string toolLabel = ResolveToolLogLabel(toolName, toolArgs, toolResult); Logger.WriteLine("Tool", $"[TOOL] {toolLabel} \u2192 {toolResult}", LogColors.Cyan); History.Add(new ChatMessage("tool", toolResult, Name: toolName)); } continue; } string reply = message?.Content ?? string.Empty; Logger.WriteProgress(AgentName, ""); // clear thinking line Logger.Write(AgentName, $"\r{AgentName}: " + reply, LogColors.Yellow); History[^1] = History[^1] with { Tokens = chunk?.PromptTokens }; History.Add("assistant", reply, chunk?.CompletionTokens); ApplyContextUsage(chunk?.PromptTokens); SaveCurrentConversation(); return reply; } throw new InvalidOperationException("Tool-calling exceeded maximum rounds without a final reply."); } private void ApplyContextUsage(int? promptTokens) { if (promptTokens is { } pt && NumCtx > 0) { CurrentContextTokens = pt; ContextUsagePercent = (int)((long)pt * 100 / NumCtx); if (ContextUsagePercent >= ContextWarningThreshold) { OnContextHighUsage?.Invoke(ContextUsagePercent); } } } public static async Task<OllamaClient> CreateClient( string model, string embeddingModel, string systemPrompt, Logger logger, bool startOllamaIfNotRunning, string agentName = "Lilith") { var client = new OllamaClient(logger) { Model = model, EmbeddingModel = embeddingModel, SystemPrompt = systemPrompt, AgentName = agentName }; client.History.Add("system", systemPrompt); client.PrintStatus($"Initializing client for model '{model}'...", StatusBlue); if (!IsOllamaInstalled()) { client.PrintStatus("Ollama not found on PATH — installing via official PowerShell installer...", LogColors.Yellow); if (!await InstallOllamaAsync(client)) { client.PrintStatus("Ollama installation failed. Please install manually from https://ollama.com.", LogColors.Red); Environment.Exit(1); } client.PrintStatus("Ollama installed.", LogColors.Green); } client.PrintStatus("Checking Ollama connection...", StatusBlue); if (await client.IsRunningAsync()) { client.PrintStatus("Ollama is running.", LogColors.Green); client.PrintStatus($"Running model '{model}' to ensure it is ready...", StatusBlue); await PullModelAsync(client); client.PrintStatus("Model ready.", LogColors.Green); if (!string.Equals(embeddingModel, model, StringComparison.OrdinalIgnoreCase)) { client.PrintStatus($"Running embedding model '{embeddingModel}' to ensure it is ready...", StatusBlue); await PullModelAsync(new OllamaClient(logger) { Model = embeddingModel, AgentName = agentName }); client.PrintStatus("Embedding model ready.", LogColors.Green); } client.PrintStatus("Querying model specs...", StatusBlue); client.NumCtx = await client.GetModelContextSizeAsync(); client.PrintStatus($"Context window: {client.NumCtx:N0} tokens.", LogColors.Green); return client; } if (startOllamaIfNotRunning) { client.PrintStatus($"Ollama not detected — starting with model '{model}'...", LogColors.Yellow); client.LaunchOllamaRun(model); } else { client.PrintStatus("Ollama not detected — waiting for it to start. Press Q to quit.", LogColors.Yellow); } while (!await client.IsRunningAsync()) { if (Console.KeyAvailable) { ConsoleKey key = Console.ReadKey(intercept: true).Key; if (key == ConsoleKey.Q) { client.PrintStatus("Exiting — Ollama was not found.", LogColors.Red); Environment.Exit(0); } } await Task.Delay(1000); } client.PrintStatus("Ollama is ready.", LogColors.Green); client.PrintStatus($"Running model '{model}' to ensure it is ready...", StatusBlue); await PullModelAsync(client); client.PrintStatus("Model ready.", LogColors.Green); if (!string.Equals(embeddingModel, model, StringComparison.OrdinalIgnoreCase)) { client.PrintStatus($"Running embedding model '{embeddingModel}' to ensure it is ready...", StatusBlue); await PullModelAsync(new OllamaClient(logger) { Model = embeddingModel, AgentName = agentName }); client.PrintStatus("Embedding model ready.", LogColors.Green); } client.PrintStatus("Querying model specs...", StatusBlue); client.NumCtx = await client.GetModelContextSizeAsync(); client.PrintStatus($"Context window: {client.NumCtx:N0} tokens.", LogColors.Green); return client; } public static Task<OllamaClient> CreateClient( string model, string systemPrompt, Logger logger, bool startOllamaIfNotRunning, string agentName = "Lilith") => CreateClient(model, "nomic-embed-text", systemPrompt, logger, startOllamaIfNotRunning, agentName); private async Task<int> GetModelContextSizeAsync() { try { var request = new ShowModelRequest(Model); using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ShowApiUrl, request); using HttpResponseMessage httpResp = await Http.SendAsync(httpReq); if (!httpResp.IsSuccessStatusCode) { PrintStatus("Could not retrieve model specs — using default context (131,072 tokens).", LogColors.Orange); return 131072; } string json = await httpResp.Content.ReadAsStringAsync(); ShowModelResponse? response = json.Deserialize<ShowModelResponse>(); if (response?.ModelInfo is { } info) { foreach (var kv in info) { if (kv.Key.EndsWith(".context_length") && kv.Value.TryGetInt32(out int ctx)) { return ctx; } } } } catch (Exception ex) { PrintStatus($"Model spec query failed ({ex.Message}) — using default context (131,072 tokens).", LogColors.Orange); } return 131072; } private async Task<bool> IsRunningAsync() => await Http.IsReachableAsync($"{BaseUrl}/"); private static bool IsOllamaInstalled() { try { var psi = new ProcessStartInfo("ollama", "--version") { CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, }; using var proc = Process.Start(psi); if (proc == null) { return false; } if (!proc.WaitForExit(5000)) { try { proc.Kill(); } catch { // ignore } return false; } return proc.ExitCode == 0; } catch { return false; } } private static async Task<bool> PullModelAsync(OllamaClient client) { try { client.PrintStatus($"Pulling model '{client.Model}'…", StatusBlue); var psi = new ProcessStartInfo { FileName = "ollama", CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, }; psi.ArgumentList.Add("pull"); psi.ArgumentList.Add(client.Model); using var proc = Process.Start(psi); if (proc is null) { client.PrintStatus("Could not start ollama pull process.", LogColors.Red); return false; } var tracker = new OllamaCliProgressTracker(client.Model); Task stdoutTask = OllamaCliStreamReader.PumpAsync(proc.StandardOutput, tracker); Task stderrTask = OllamaCliStreamReader.PumpAsync(proc.StandardError, tracker); DateTime started = DateTime.UtcNow; DateTime deadline = started.AddMinutes(30); while (!proc.HasExited && DateTime.UtcNow < deadline) { TimeSpan elapsed = DateTime.UtcNow - started; TimeSpan remaining = deadline - DateTime.UtcNow; client.PrintProgress(tracker.GetDisplayMessage(elapsed, remaining), StatusGray); await Task.Delay(250); } await proc.WaitForExitAsync(); await Task.WhenAll(stdoutTask, stderrTask); if (proc.ExitCode != 0 && !await IsModelAvailableAsync(client.Model)) { client.PrintStatus($"ollama pull failed (exit {proc.ExitCode}).", LogColors.Red); return false; } if (await IsModelAvailableAsync(client.Model)) { client.PrintStatus( $"Model '{client.Model}' ready ({(DateTime.UtcNow - started):mm\\:ss}).", LogColors.Green); return true; } client.PrintStatus($"Timed out waiting for model '{client.Model}' to become available.", LogColors.Orange); if (!proc.HasExited) { proc.Kill(entireProcessTree: true); } return false; } catch (Exception ex) { client.PrintStatus($"Model pull error: {ex.Message}", LogColors.Red); return false; } } private static async Task<bool> IsModelAvailableAsync(string model) { try { var psi = new ProcessStartInfo { FileName = "ollama", CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, }; psi.ArgumentList.Add("show"); psi.ArgumentList.Add(model); using var proc = Process.Start(psi); if (proc == null) { return false; } Task exited = proc.WaitForExitAsync(); Task timeout = Task.Delay(TimeSpan.FromSeconds(10)); if (await Task.WhenAny(exited, timeout) != exited) { try { proc.Kill(entireProcessTree: true); } catch { // ignore } return false; } return proc.ExitCode == 0; } catch { return false; } } private static async Task<bool> InstallOllamaAsync(OllamaClient client) { try { var psi = new ProcessStartInfo { FileName = "powershell", Arguments = "-NoProfile -ExecutionPolicy Bypass -Command " + "\"irm https://ollama.com/install.ps1 | iex\"", CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, }; using var proc = Process.Start(psi); if (proc == null) { return false; } proc.OutputDataReceived += (_, e) => { if (!string.IsNullOrEmpty(e.Data)) { client.PrintStatus(e.Data, StatusGray); } }; proc.ErrorDataReceived += (_, e) => { if (!string.IsNullOrEmpty(e.Data)) { client.PrintStatus(e.Data, LogColors.Orange); } }; proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); await proc.WaitForExitAsync(); return proc.ExitCode == 0 && IsOllamaInstalled(); } catch (Exception ex) { client.PrintStatus($"Installer error: {ex.Message}", LogColors.Red); return false; } } private void LaunchOllamaRun(string model) => TryCatchExtensions.TryCatchCustomMessage(OllamaRunLog(model), () => OllamaRun(model)); private CatchLog OllamaRunLog(string model) => new("Ollama", $"Could not start Ollama with model '{model}'.", Logger); private static Process? OllamaRun(string model) => Process.Start(new ProcessStartInfo("ollama", $"run {model}") { CreateNoWindow = true, UseShellExecute = false }); private void PrintStatus(string message, string color) => Logger.WriteLine("Ollama", message, color); private void PrintProgress(string message, string color) => Logger.WriteProgress("Ollama", message, color); private static string ResolveToolLogLabel(string toolName, string toolArgs, string toolResult) { if (toolName.Equals("execute_tool", StringComparison.OrdinalIgnoreCase)) { try { using var doc = JsonDocument.Parse(toolArgs); if (doc.RootElement.TryGetProperty("tool_name", out var nameProp)) { string innerName = nameProp.GetString() ?? toolName; string innerArgs = "{}"; if (doc.RootElement.TryGetProperty("tool_arguments", out var argsProp) && argsProp.ValueKind != JsonValueKind.Null && argsProp.ValueKind != JsonValueKind.Undefined) innerArgs = argsProp.GetRawText(); return ResolveToolLogLabel(innerName, innerArgs, toolResult); } } catch { } } if (toolName.Equals("write_workspace_file", StringComparison.OrdinalIgnoreCase) && toolResult.Contains("Stored memory under path", StringComparison.OrdinalIgnoreCase)) { return "store_memory"; } if (toolName.Equals("read_workspace_file", StringComparison.OrdinalIgnoreCase) && toolArgs.Contains("user/", StringComparison.OrdinalIgnoreCase) && !toolResult.StartsWith("Error", StringComparison.OrdinalIgnoreCase)) { return toolResult.Contains("No memory found at path", StringComparison.OrdinalIgnoreCase) ? "retrieve_memory" : "get_memory_at_path"; } return toolName; }}
Documentation
OllamaClient
Ollama chat client addon.
OllamaClient.cs
OllamaClient— chat streaming, context tracking, model install/wait, and history integration.