DEV Community

Cover image for Native AOT-Compatible C# Local AI Agent with Ollama Tool Calling
artydev
artydev

Posted on

Native AOT-Compatible C# Local AI Agent with Ollama Tool Calling

Nice tool for personal usage:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

// ─────────────────────────────────────────────────────────────
// AOT SERIALIZATION CONTEXT
// ─────────────────────────────────────────────────────────────
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(OllamaRequest))]
[JsonSerializable(typeof(OllamaResponse))]
[JsonSerializable(typeof(OllamaOptions))]
internal partial class OllamaJsonContext : JsonSerializerContext { }


// ─────────────────────────────────────────────────────────────
// TOOL REGISTRY
// ─────────────────────────────────────────────────────────────

public record ToolParam(string Name, string Type, string Description);

public record ToolDefinition(
    string Name,
    string Description,
    IReadOnlyList<ToolParam> Params,
    Func<Dictionary<string, string>, string> Handler
);

public static class ToolRegistry
{
    public static readonly IReadOnlyList<ToolDefinition> Tools = new List<ToolDefinition>
    {
        // ── greet_user ────────────────────────────────────────
        new(
            Name: "greet_user",
            Description: "MUST be called whenever the user introduces themselves or provides their name " +
                         "(e.g. 'I am John', 'my name is Alice', 'hi I'm Marco', 'call me Sam'). " +
                         "Extract the name and call this tool immediately — do NOT reply in plain text.",
            Params: new[] { new ToolParam("name", "string", "The user's first name as stated") },
            Handler: args => args.TryGetValue("name", out var name) ? $"Bonjour {name}!" : "Error: 'name' parameter not found."
        ),

        // ── add_numbers ───────────────────────────────────────
        new(
            Name: "add_numbers",
            Description: "Add two numbers together and return the result.",
            Params: new[]
            {
                new ToolParam("a", "number", "First operand"),
                new ToolParam("b", "number", "Second operand")
            },
            Handler: args =>
            {
                if (args.TryGetValue("a", out var strA) && args.TryGetValue("b", out var strB) &&
                    double.TryParse(strA, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double a) &&
                    double.TryParse(strB, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double b))
                {
                    return (a + b).ToString(System.Globalization.CultureInfo.InvariantCulture);
                }
                return "Error: Could not parse 'a' or 'b' as valid numbers.";
            }
        )
    };

    private static readonly Dictionary<string, ToolDefinition> _index =
        Tools.ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase);

    public static bool TryGet(string name, out ToolDefinition? tool) => _index.TryGetValue(name, out tool);
}


// ─────────────────────────────────────────────────────────────
// PROMPT BUILDER
// ─────────────────────────────────────────────────────────────
public static class PromptBuilder
{
private const string BasePrompt = """
        You are a precise, helpful assistant with access to a dynamic set of tools.
        Your primary job is to have natural, useful conversations. Tools exist only to augment
        your responses when genuinely needed — never as a reflex.

        ══════════════════════════════════════════
        TOOL CALL FORMAT
        ══════════════════════════════════════════
        All tools are called using self-closing XML tags:
          <call:TOOL_NAME param1="value1" param2="value2" />

        Rules for values:
          • Strings  → quote as-is:          name="Alice"
          • Numbers  → raw value, no units:  a="12"  b="7.5"
          • Booleans → lowercase string:     enabled="true"
          • Lists    → comma-separated:      items="a,b,c"

        ══════════════════════════════════════════
        AVAILABLE TOOLS
        ══════════════════════════════════════════
        {TOOLS_BLOCK}

        ══════════════════════════════════════════
        DECISION FRAMEWORK
        ══════════════════════════════════════════
        STEP 1 — THINK silently before every response:
          a) What is the user actually asking for?
          b) Does any listed tool directly serve that need?
          c) Would the tool's output be the core of my answer?

        STEP 2 — Use a tool ONLY when ALL of these are true:
          ✔ A listed tool exists that matches the user's intent
          ✔ The tool's output is the primary value of the response
          ✔ The required parameters can be extracted unambiguously from the input

        STEP 3 — Do NOT use a tool when:
          ✘ The question is answerable directly from your internal general knowledge
          ✘ A tool exists but its output is peripheral to the answer
          ✘ Parameters are missing or ambiguous — ask the user to clarify instead (CASE D)
          ✘ No listed tool matches — IMMEDIATELY drop all tool logic and answer naturally

        ══════════════════════════════════════════
        OUTPUT FORMAT — FOUR STRICT CASES
        ══════════════════════════════════════════
        CASE A — No tool matches / General Knowledge → Respond naturally using your own brain. 
          *CRITICAL:* Do not mention tools, do not apologize about missing tools, and do not explain what you can or cannot do. Just give the answer directly.
          User:  "Who invented the telephone?"
          You:   "Alexander Graham Bell is credited with inventing the telephone in 1876."
          User:  "Who was Albert Einstein?"
          You:   "Albert Einstein was a theoretical physicist who developed the theory of relativity..."

        ──────────────────────────────────────────
        CASE B — Tool matches → output ONLY the XML tag. Zero preamble. Zero trailing text.
          User:  "Add 15 and 27"
          You:   <call:add_numbers a="15" b="27" />

          User:  "Hey, I'm Sophie!"
          You:   <call:greet_user name="Sophie" />

        ──────────────────────────────────────────
        CASE C — After a tool result is returned → incorporate it naturally. Be concise and friendly.
          Result: "[Result for add_numbers]: 42"
          You:    "15 + 27 equals 42."

        ──────────────────────────────────────────
        CASE D — Parameters missing or ambiguous → ask ONE targeted clarifying question. No tool call yet.
          User:  "Add the numbers"
          You:   "I'd be happy to add them — which two numbers should I use?"

        ══════════════════════════════════════════
        CHAINING & MULTIPLE TOOLS
        ══════════════════════════════════════════
        If a task requires multiple tools:
          • You MAY output multiple <call:...> tags in a single response if independent actions are needed.
          • Wait for the results, then synthesize all outputs into one coherent answer.

        ══════════════════════════════════════════
        ABSOLUTE PROHIBITIONS
        ══════════════════════════════════════════
          ✘ NEVER use the words "tool", "XML", "tag", "parameter", or "framework" in your conversation with the user.
          ✘ Never invent tool names not listed in AVAILABLE TOOLS above.
          ✘ Never output partial, nested, or malformed XML.
          ✘ Never mix tool XML with surrounding prose in the same response.
          ✘ Never guess parameter values — if uncertain, trigger CASE D.
          ✘ Never say "I don't have a tool for that" — if no tool applies, just act like a normal AI assistant.
          ✘ Never use a tool for rhetorical, emotional, or illustrative purposes.
        """;

    public static string Build(IReadOnlyList<ToolDefinition> tools)
    {
        var block = new StringBuilder();
        foreach (var tool in tools)
        {
            var paramStr = string.Join(" ", tool.Params.Select(p => $"{p.Name}=\"{{{p.Type}}}\""));
            block.AppendLine($"• {tool.Name}: {tool.Description}");
            block.AppendLine($"  Signature: <call:{tool.Name} {paramStr} />");
            block.AppendLine($"  Parameters:");
            foreach (var p in tool.Params)
                block.AppendLine($"    - {p.Name} ({p.Type}): {p.Description}");
            block.AppendLine();
        }
        return BasePrompt.Replace("{TOOLS_BLOCK}", block.ToString().TrimEnd());
    }
}


// ─────────────────────────────────────────────────────────────
// TOOL EXECUTOR — 100% Native AOT Source-Generated Parser
// ─────────────────────────────────────────────────────────────
public static partial class ToolExecutor
{
    [GeneratedRegex(@"<call:\s*([a-zA-Z0-9_]+)\s*(.*?)/?>", RegexOptions.Singleline)]
    private static partial Regex ToolTagRx();

    [GeneratedRegex(@"([a-zA-Z0-9_]+)\s*=\s*(?:""([^""]*)""|'([^']*)'|([^\s/>]+))")]
    private static partial Regex AttrRx();

    public static List<string> ExecuteAll(string raw)
    {
        var results = new List<string>();
        var matches = ToolTagRx().Matches(raw);

        foreach (Match match in matches)
        {
            string toolName = match.Groups[1].Value;
            string attrsString = match.Groups[2].Value;

            if (!ToolRegistry.TryGet(toolName, out var tool))
            {
                results.Add($"Error: Unknown tool '{toolName}'.");
                continue;
            }

            var args = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
            foreach (Match attrMatch in AttrRx().Matches(attrsString))
            {
                string key = attrMatch.Groups[1].Value;
                string value = attrMatch.Groups[2].Success ? attrMatch.Groups[2].Value
                             : attrMatch.Groups[3].Success ? attrMatch.Groups[3].Value
                             : attrMatch.Groups[4].Value;

                args[key] = value;
            }

            try
            {
                string output = tool!.Handler(args);
                results.Add($"[Result for {toolName}]: {output}");
            }
            catch (Exception ex)
            {
                results.Add($"[Error in {toolName}]: {ex.Message}");
            }
        }

        return results;
    }
}


// ─────────────────────────────────────────────────────────────
// MAIN CHAT LOOP
// ─────────────────────────────────────────────────────────────
public class OllamaChat
{
    const string OllamaUrl = "http://localhost:11434/api/chat";
    const string ModelName = "qwen2.5:3b";

    static readonly HttpClient Http = new();

    static async Task Main()
    {
        string modelName = args.Length > 0 ? args[0] : "qwen2.5:3b";

        string systemPrompt = PromptBuilder.Build(ToolRegistry.Tools);
        var history = new List<OllamaMessage> { new() { Role = "system", Content = systemPrompt } };

        Console.WriteLine($"=== Engine Ready ({ModelName}) — {ToolRegistry.Tools.Count} tool(s) loaded ===");
        foreach (var t in ToolRegistry.Tools) Console.WriteLine($"  • {t.Name}");
        Console.WriteLine();

        while (true)
        {
            Console.Write("You: ");
            string? input = Console.ReadLine();
            if (string.IsNullOrEmpty(input) || input == "exit") break;

            history.Add(new OllamaMessage { Role = "user", Content = input });

            // Step 1: Manage context windows prior to execution
            ManageContextWindow(history);

            Console.Write("AI: ");
            string reply;

            try
            {
                // Step 2 & 3: Stream and intercept with an associated 30s timeout execution window
                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
                reply = await StreamAndCapture(history, cts.Token);
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("\n⚠️ [Error: Ollama took too long to respond. Request aborted.]\n");
                history.RemoveAt(history.Count - 1); 
                continue;
            }

            List<string> toolResults = ToolExecutor.ExecuteAll(reply);

            if (toolResults.Any())
            {
                string combinedResults = string.Join("\n", toolResults);
                Console.WriteLine("⚙️  [Executing System Tools...]");

                history.Add(new OllamaMessage { Role = "assistant", Content = reply });
                history.Add(new OllamaMessage { Role = "user",      Content = combinedResults });

                // Step 1: Manage window space again in case of massive tool outputs
                ManageContextWindow(history);

                Console.Write("AI: ");
                try
                {
                    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
                    string finalReply = await StreamAndCapture(history, cts.Token);
                    Console.WriteLine();
                    history.Add(new OllamaMessage { Role = "assistant", Content = finalReply });
                }
                catch (OperationCanceledException)
                {
                    Console.WriteLine("\n⚠️ [Error: Ollama timed out while processing tool results.]\n");
                    history.RemoveRange(history.Count - 2, 2); 
                }
            }
            else
            {
                // Defensive Edge-Case: If the stream was muted because it started with '<' 
                // but no valid tools were processed, print the raw output so information isn't lost.
                if (reply.StartsWith("<"))
                {
                    Console.Write(reply);
                }
                Console.WriteLine();
                history.Add(new OllamaMessage { Role = "assistant", Content = reply });
            }
        }
    }

    // Step 3: Implements In-Stream Interception & Muting
    static async Task<string> StreamAndCapture(List<OllamaMessage> history, CancellationToken cancellationToken)
    {
        var sb = new StringBuilder();
        var req = new OllamaRequest
        {
            Model   = ModelName,
            Stream  = true,
            Messages = history,
            Options = new OllamaOptions { Temperature = 0.0f }
        };

        var json = JsonSerializer.Serialize(req, OllamaJsonContext.Default.OllamaRequest);
        using var resp = await Http.PostAsync(
            OllamaUrl, new StringContent(json, Encoding.UTF8, "application/json"), cancellationToken);

        using var stream = await resp.Content.ReadAsStreamAsync(cancellationToken);
        using var reader = new StreamReader(stream);

        bool isToolCall = false;
        bool checkedFirstChar = false;

        while (await reader.ReadLineAsync(cancellationToken) is { } line)
        {
            var chunk = JsonSerializer.Deserialize(line, OllamaJsonContext.Default.OllamaResponse);
            if (chunk?.Message?.Content != null)
            {
                string content = chunk.Message.Content;
                sb.Append(content);

                // Instantly evaluate the first incoming byte chunk
                if (!checkedFirstChar && sb.Length > 0)
                {
                    if (sb[0] == '<')
                    {
                        isToolCall = true; // Mute console pipeline
                    }
                    checkedFirstChar = true;
                }

                if (!isToolCall)
                {
                    Console.Write(content);
                }
            }
            if (chunk?.Done == true) break;
        }
        return sb.ToString();
    }

    // Step 1: Implements Context Window Management
    static void ManageContextWindow(List<OllamaMessage> history)
    {
        const int MaxContextChars = 12000; // Safe allocation headroom threshold (~3000 tokens)
        while (history.Count > 1 && history.Sum(m => m.Content?.Length ?? 0) > MaxContextChars)
        {
            history.RemoveAt(1); // Safely drop index 1 (oldest turn), keeping system prompt intact at index 0
        }
    }
}


// ─────────────────────────────────────────────────────────────
// OLLAMA DATA MODELS
// ─────────────────────────────────────────────────────────────
public class OllamaOptions
{
    [JsonPropertyName("temperature")] public float Temperature { get; set; }
}

public class OllamaRequest
{
    [JsonPropertyName("model")]    public string             Model    { get; set; } = "";
    [JsonPropertyName("stream")]   public bool               Stream   { get; set; }
    [JsonPropertyName("messages")] public List<OllamaMessage> Messages { get; set; } = new();
    [JsonPropertyName("options")]  public OllamaOptions?     Options  { get; set; }
}

public class OllamaMessage
{
    [JsonPropertyName("role")]    public string Role    { get; set; } = "";
    [JsonPropertyName("content")] public string Content { get; set; } = "";
}

public class OllamaResponse
{
    [JsonPropertyName("message")] public OllamaMessage? Message { get; set; }
    [JsonPropertyName("done")]    public bool           Done    { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)