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)