LlmClient.Conversation
C# · package Toolnexus · SPEC §8 · LlmClient.cs
public LlmClient.Conversation NewConversation(Toolkit toolkit)
public sealed class Conversation{ public Task<RunResult> SendAsync(string prompt, CancellationToken cancellationToken = default); public List<object?> Messages { get; } public void Reset();}An in-process object that carries a transcript forward for you. client.NewConversation(toolkit)
returns one bound to that Toolkit; each SendAsync call runs
RunAsync with the accumulated Messages as history, then replaces
Messages with the result’s updated transcript — so the next SendAsync on the same instance
continues where the last one left off, with no id and no store plumbing. Reset() drops the
transcript and starts over.
When to use it
Section titled “When to use it”A short-lived, single-process multi-turn exchange — a REPL, a CLI session, a single request
handler juggling one chat — where the transcript only needs to live as long as the Conversation
object does.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful conversation — two turns, one object
Section titled “1. The smallest useful conversation — two turns, one object”using System.Net;using System.Text;using Toolnexus;
var turn = 0;using var stub = new Stub(ctx =>{ turn++; var text = turn == 1 ? "hi there" : "still here"; var body = """ {"id":"cN","choices":[{"message":{"role":"assistant","content":"TEXT"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}} """.Replace("cN", "c" + turn).Replace("TEXT", text); Stub.Json(ctx, 200, body);});
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 conv = client.NewConversation(tk);
var first = await conv.SendAsync("hello");var second = await conv.SendAsync("still there?");
if (first.Text != "hi there") throw new Exception(first.Text);if (second.Text != "still here") throw new Exception(second.Text);
Console.WriteLine($"ok: {first.Text} -> {second.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. Inspecting Messages, then Reset()
Section titled “2. Inspecting Messages, then Reset()”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":"noted"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":1,"total_tokens":5}} """);});
var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", SystemPrompt = "You are terse.",});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());var conv = client.NewConversation(tk);
await conv.SendAsync("remember: the sky is blue");
// system + user + assistant = 3 messages after one turn.if (conv.Messages.Count != 3) throw new Exception($"expected 3 messages, got {conv.Messages.Count}");
conv.Reset();if (conv.Messages.Count != 0) throw new Exception("Reset should drop the transcript entirely");
Console.WriteLine($"ok: 3 messages before reset, {conv.Messages.Count} after");
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 — several turns, a tool call in the middle, and independent conversations
Section titled “3. Full surface — several turns, a tool call in the middle, and independent conversations”using System.Net;using System.Text;using Toolnexus;
var turn = 0;using var stub = new Stub(ctx =>{ turn++; if (turn == 1) { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi Ada"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}} """); } else if (turn == 2) { Stub.Json(ctx, 200, """ {"id":"c2","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"double","arguments":"{\"n\":21}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}} """); } else { Stub.Json(ctx, 200, """ {"id":"c3","choices":[{"message":{"role":"assistant","content":"double of 21 is 42"},"finish_reason":"stop"}],"usage":{"prompt_tokens":14,"completion_tokens":5,"total_tokens":19}} """); }});
var doubleTool = NativeTool.Of("double", "Double a number", null, (IDictionary<string, object?> a) => (Convert.ToInt32(a["n"]) * 2).ToString());
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 { ExtraTools = new List<ITool> { doubleTool } });
// Two independent conversations on the same client/toolkit never share a transcript.var alice = client.NewConversation(tk);var bob = client.NewConversation(tk);
var r1 = await alice.SendAsync("I'm Ada");if (r1.Text != "hi Ada") throw new Exception(r1.Text);
var r2 = await alice.SendAsync("what's double 21?"); // spans two stub turns (tool call + answer)if (r2.Text != "double of 21 is 42") throw new Exception(r2.Text);if (r2.ToolCallCount != 1 || r2.ToolCalls[0].Output != "42") throw new Exception(r2.ToolCalls[0].Output);
if (bob.Messages.Count != 0) throw new Exception("a fresh Conversation must start with no history");if (alice.Messages.Count == 0) throw new Exception("alice's transcript should have accumulated");
Console.WriteLine($"ok: {r2.Text} | alice has {alice.Messages.Count} messages, bob has {bob.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 { } }}Members
Section titled “Members”| Member | Type | What it is |
|---|---|---|
SendAsync(prompt, cancellationToken) |
Task<RunResult> |
Run one turn with the accumulated history, then replace Messages with the result’s updated transcript. |
Messages |
List<object?> |
The current transcript, in provider message shape. Read-only view — mutate via SendAsync/Reset. |
Reset() |
void |
Drop the transcript; the next SendAsync starts fresh. |
See also
Section titled “See also”LlmClient.Create— The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.LlmClient.RunAsync— Send a prompt, let the loop call tools until the model stops, get a RunResult.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.