Ollama/OllamaClient/OllamaClient.cscsharp

Documentation

OllamaClient

Ollama chat client addon.

OllamaClient.cs

  • OllamaClient — chat streaming, context tracking, model install/wait, and history integration.
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 HttpClient Http { get; } = new()    {        Timeout = TimeSpan.FromMinutes(20)    };    public string Model { get; set; } = "gemma4";    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";    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.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 systemPrompt,        Logger logger,        bool startOllamaIfNotRunning,        string agentName = "Lilith")    {        var client = new OllamaClient(logger)        {            Model = model,            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);            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);        client.PrintStatus("Querying model specs...", StatusBlue);        client.NumCtx = await client.GetModelContextSizeAsync();        client.PrintStatus($"Context window: {client.NumCtx:N0} tokens.", LogColors.Green);        return client;    }    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 { }        }        return toolName;    }}