CookbookTool loop in C#

Tool loop in C#

The model can call two functions, find_book and availability, as many times as it needs; the loop ends when it answers in text. It uses plain HTTP, so there is nothing to install beyond the standard library unless noted. It targets .NET 8 and uses System.Text.Json.Nodes.

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
 
var library = new Dictionary<string, (string Title, int Copies, int OnLoan, string? DueBack)>
{
    ["978-0141439518"] = ("Pride and Prejudice", 3, 3, "2026-10-19"),
    ["978-0007119318"] = ("The Hobbit", 2, 1, null),
};
 
JsonNode RunTool(string name, string rawArgs)
{
    try
    {
        var args = JsonNode.Parse(rawArgs)!;
        switch (name)
        {
            case "find_book":
                var q = args["query"]!.GetValue<string>().ToLowerInvariant();
                var hits = new JsonArray();
                foreach (var (isbn, b) in library)
                    if (b.Title.ToLowerInvariant().Contains(q)) hits.Add(new JsonObject { ["isbn"] = isbn, ["title"] = b.Title });
                return new JsonObject { ["results"] = hits };
            case "availability":
                var key = args["isbn"]!.GetValue<string>();
                if (!library.TryGetValue(key, out var book)) return new JsonObject { ["error"] = $"no book with ISBN {key}" };
                var free = book.Copies - book.OnLoan;
                return new JsonObject { ["available"] = free, ["next_due_back"] = free == 0 ? book.DueBack : null };
            default:
                return new JsonObject { ["error"] = $"unknown tool {name}" };
        }
    }
    catch (Exception e) { return new JsonObject { ["error"] = e.Message }; }
}
 
var tools = JsonNode.Parse("""
    [
      {"type": "function", "function": {"name": "find_book", "description": "Search the catalogue by title words. Returns ISBNs.",
        "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
      {"type": "function", "function": {"name": "availability", "description": "Copies available now for an ISBN, and when the next one is due back.",
        "parameters": {"type": "object", "properties": {"isbn": {"type": "string"}}, "required": ["isbn"]}}}
    ]
    """)!;
var messages = new JsonArray
{
    new JsonObject { ["role"] = "system", ["content"] = "You help members of a town library. Use the tools; never guess availability." },
    new JsonObject { ["role"] = "user", ["content"] = "Can I borrow Pride and Prejudice today? If not, when?" },
};
 
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("SYNTIC_API_KEY"));
 
for (var round = 0; round < 8; round++)
{
    var body = new JsonObject { ["model"] = "Amara S1.2", ["stream"] = false, ["max_tokens"] = 800, ["tools"] = tools.DeepClone(), ["messages"] = messages.DeepClone() };
    var res = await http.PostAsync("https://app.syntic.ai/v1/chat/completions", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
    var json = JsonNode.Parse(await res.Content.ReadAsStringAsync())!;
    if (!res.IsSuccessStatusCode) throw new Exception(json["error"]!.ToJsonString());
 
    var msg = json["choices"]![0]!["message"]!;
    var calls = msg["tool_calls"]?.AsArray();
    if (calls is null || calls.Count == 0) { Console.WriteLine(msg["content"]); return; }
 
    messages.Add(msg.DeepClone());
    foreach (var call in calls)
    {
        var result = RunTool(call!["function"]!["name"]!.GetValue<string>(), call["function"]!["arguments"]?.GetValue<string>() ?? "{}");
        messages.Add(new JsonObject { ["role"] = "tool", ["tool_call_id"] = call["id"]!.GetValue<string>(), ["content"] = result.ToJsonString() });
    }
}
Console.WriteLine("Sorry, I couldn't finish that request.");

The three rules

  1. Append the assistant message with its tool_calls exactly as received.
  2. Add one tool message per call, with the matching tool_call_id.
  3. Send the same tools list on every round.

Errors from your functions are returned to the model as results so it can recover, and the round limit stops a runaway loop.

Was this page helpful?