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; }
}
Top comments (0)