LlmClient.RunAsync
C# · package Toolnexus · SPEC §8 · LlmClient.cs
public Task<RunResult> RunAsync(string prompt, Toolkit toolkit, List<object?>? history = null, CancellationToken cancellationToken = default)One turn of the agent loop: send prompt (plus any history) to the model, execute every tool
call the model asks for, feed the results back, and repeat until the model stops calling tools or
Options.MaxTurns is hit. Returns a RunResult — the final text, the full message transcript, every
ToolCall made along the way, and a Status ("done", "pending", or "incomplete").
RunAsync picks the OpenAI or Anthropic wire format from Options.Style — the loop shape (system
prompt, tool schema, parallel tool execution, retries, Metrics()) is identical either way.
When to use it
Section titled “When to use it”You want a complete answer, not the intermediate tokens — a batch job, a Slack bot reply, a script
that just needs the final text and the tool calls that produced it. It’s also what
LlmClient.Conversation.SendAsync calls underneath.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call
Section titled “1. The smallest useful call”using System.Net;using System.Text;using Toolnexus;
using var stub = new Stub(ctx =>{ Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hello from the stub"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}} """);});
var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key",});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());var result = await client.RunAsync("say hello", tk);
if (result.Status != "done") throw new Exception($"status: {result.Status}");if (result.Text != "hello from the stub") throw new Exception(result.Text);
Console.WriteLine($"ok: {result.Text}");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}2. A tool call, then a final answer
Section titled “2. A tool call, then a final answer”using System.Net;using System.Text;using Toolnexus;
var calls = 0;using var stub = new Stub(ctx =>{ calls++; if (calls == 1) { // Turn 1: the model asks to call "add". Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"add","arguments":"{\"a\":2,\"b\":3}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} """); } else { // Turn 2: the model reads the tool result and answers. Stub.Json(ctx, 200, """ {"id":"c2","choices":[{"message":{"role":"assistant","content":"2 + 3 = 5"},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":6,"total_tokens":21}} """); }});
var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key",});
var add = NativeTool.Of( "add", "Add two integers", new Dictionary<string, object?> { ["type"] = "object", ["properties"] = new Dictionary<string, object?> { ["a"] = new Dictionary<string, object?> { ["type"] = "integer" }, ["b"] = new Dictionary<string, object?> { ["type"] = "integer" }, }, }, (IDictionary<string, object?> a) => (Convert.ToInt32(a["a"]) + Convert.ToInt32(a["b"])).ToString());
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List<ITool> { add } });var result = await client.RunAsync("what is 2 + 3?", tk);
if (result.Status != "done") throw new Exception($"status: {result.Status}");if (result.ToolCallCount != 1) throw new Exception($"expected 1 tool call, got {result.ToolCallCount}");if (result.ToolCalls[0].Name != "add" || result.ToolCalls[0].Output != "5") throw new Exception(result.ToolCalls[0].Output);if (result.Text != "2 + 3 = 5") throw new Exception(result.Text);
Console.WriteLine($"ok: {result.Text} (turns={result.Turns}, tools={result.ToolCallCount})");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}3. Full surface — history, usage, and the run’s metadata
Section titled “3. Full surface — history, usage, and the run’s metadata”using System.Net;using System.Text;using Toolnexus;
using var stub = new Stub(ctx =>{ Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"continuing the thread"},"finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":4,"total_tokens":24}} """);});
var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", SystemPrompt = "You are terse.", MaxTurns = 4,});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());
// Seed history as if a prior turn already happened — RunAsync appends to it rather than// starting a fresh system+user pair.var history = new List<object?>{ new Dictionary<string, object?> { ["role"] = "system", ["content"] = "You are terse." }, new Dictionary<string, object?> { ["role"] = "user", ["content"] = "part one" }, new Dictionary<string, object?> { ["role"] = "assistant", ["content"] = "ack" },};
var result = await client.RunAsync("part two", tk, history);
if (result.Status != "done") throw new Exception($"status: {result.Status}");if (result.Turns != 1) throw new Exception($"expected 1 turn, got {result.Turns}");if (result.Usage.TotalTokens != 24) throw new Exception($"usage: {result.Usage.TotalTokens}");// The returned Messages carries the full transcript forward — feed it to the next RunAsync call.if (result.Messages.Count != history.Count + 2) throw new Exception($"messages: {result.Messages.Count}");if (result.Model != "gpt-4o-mini") throw new Exception(result.Model);
Console.WriteLine($"ok: {result.Text} (usage={result.Usage.TotalTokens}, messages={result.Messages.Count})");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}Parameters
Section titled “Parameters”| Parameter | Type | What it is |
|---|---|---|
prompt |
string |
The user turn to send. |
toolkit |
Toolkit |
Tools available to the model this run. |
history |
List<object?>? |
Prior transcript to continue, in provider message shape. null/empty ⇒ start fresh with the system prompt. |
cancellationToken |
CancellationToken |
External cancellation — surfaces as OperationCanceledException, distinct from a TimeoutMs deadline (RunTimeoutException). |
See also
Section titled “See also”LlmClient.Create— The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.LlmClient.StreamAsync— The streaming loop: text deltas, tool-call events, and suspension events as they happen.LlmClient.Hooks— Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.LlmClient.Conversation— Keep a transcript across turns so the model remembers what it already did.