Skip to content

LlmClient.IConversationStore

C# · package Toolnexus · SPEC §8 · LlmClient.cs

public interface IConversationStore
{
Task<List<object?>?> GetAsync(string id);
Task SaveAsync(string id, List<object?> messages);
}
public sealed class InMemoryConversationStore : IConversationStore { /* default */ }

Where AskAsync and StreamAsync remember a conversation by id: GetAsync loads a transcript before a turn runs (null ⇒ no prior history — start fresh), SaveAsync persists the updated transcript after. LlmClient.Create uses InMemoryConversationStore — a ConcurrentDictionary scoped to the client’s own lifetime — unless Options.Store supplies your own. client.ConversationStore() returns whichever one is active, so a caller can read/write it directly without keeping a shadow copy.

A conversation needs to outlive the process (a chat that survives a restart or deploy), or several processes/instances need to share one thread by id (a horizontally-scaled API in front of one Postgres/Redis-backed store). Implement IConversationStore against whatever you already run — a file, a database, Redis — and hand it to Options.Store.

1. The default — InMemoryConversationStore, read back directly

Section titled “1. The default — InMemoryConversationStore, read back directly”
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":"got it"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}
""");
});
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());
await client.AskAsync("remember: I like tea", tk, id: "user-42");
// No custom Store was supplied — the client fell back to InMemoryConversationStore, and
// ConversationStore() hands back that exact instance.
var store = client.ConversationStore();
var saved = await store.GetAsync("user-42");
if (saved == null || saved.Count == 0) throw new Exception("expected a saved transcript for user-42");
if (await store.GetAsync("nobody") != null) throw new Exception("unknown id must return null");
Console.WriteLine($"ok: {saved.Count} messages saved for user-42");
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 custom store — a plain file per conversation id

Section titled “2. A custom store — a plain file per conversation id”
using System.Net;
using System.Text;
using System.Text.Json;
using Toolnexus;
using var stub = new Stub(ctx =>
{
Stub.Json(ctx, 200, """
{"id":"c1","choices":[{"message":{"role":"assistant","content":"saved to disk"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}
""");
});
var dir = Path.Combine(Path.GetTempPath(), "toolnexus-docs-store-" + Guid.NewGuid());
Directory.CreateDirectory(dir);
var client = LlmClient.Create(new LlmClient.Options
{
BaseUrl = stub.BaseUrl,
Style = "openai",
Model = "gpt-4o-mini",
ApiKey = "test-key",
Store = new FileConversationStore(dir),
});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());
var result = await client.AskAsync("remember: deadline is Friday", tk, id: "thread-7");
var path = Path.Combine(dir, "thread-7.json");
if (!File.Exists(path)) throw new Exception($"expected {path} to exist");
if (result.Text != "saved to disk") throw new Exception(result.Text);
Console.WriteLine($"ok: {Path.GetFileName(path)} written by a custom IConversationStore");
Directory.Delete(dir, recursive: true);
sealed class FileConversationStore : IConversationStore
{
readonly string _dir;
public FileConversationStore(string dir) => _dir = dir;
string PathFor(string id) => Path.Combine(_dir, $"{id}.json");
public async Task<List<object?>?> GetAsync(string id)
{
var path = PathFor(id);
if (!File.Exists(path)) return null;
var json = await File.ReadAllTextAsync(path);
return JsonSerializer.Deserialize<List<object?>>(json);
}
public async Task SaveAsync(string id, List<object?> messages)
=> await File.WriteAllTextAsync(PathFor(id), JsonSerializer.Serialize(messages));
}
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 — two client instances sharing one store, continuing across a “restart”

Section titled “3. Full surface — two client instances sharing one store, continuing across a “restart””
using System.Net;
using System.Text;
using Toolnexus;
var turn = 0;
using var stub = new Stub(ctx =>
{
turn++;
var text = turn == 1 ? "hi, first client" : "still remember you, second client";
var body = """
{"id":"cN","choices":[{"message":{"role":"assistant","content":"TEXT"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}
""".Replace("cN", "c" + turn).Replace("TEXT", text);
Stub.Json(ctx, 200, body);
});
// One store instance, shared by two DIFFERENT LlmClient instances — simulating a process
// restart where a new client is built but the store (a real db/file/Redis) persists.
var shared = new InMemoryConversationStore();
var clientA = LlmClient.Create(new LlmClient.Options
{
BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Store = shared,
});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());
var r1 = await clientA.AskAsync("I'm here", tk, id: "durable-thread");
var clientB = LlmClient.Create(new LlmClient.Options
{
BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Store = shared,
});
var r2 = await clientB.AskAsync("are you still there?", tk, id: "durable-thread");
if (r1.Text != "hi, first client") throw new Exception(r1.Text);
if (r2.Text != "still remember you, second client") throw new Exception(r2.Text);
// clientB picked up clientA's transcript through the shared store, not through in-process state.
if (clientA.ConversationStore() != clientB.ConversationStore()) throw new Exception("expected the same store instance");
var transcript = await shared.GetAsync("durable-thread");
if (transcript == null || transcript.Count < 4) throw new Exception($"expected an accumulated transcript, got {transcript?.Count}");
Console.WriteLine($"ok: {r1.Text} | {r2.Text} | {transcript.Count} messages total");
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 { }
}
}
Member Type What it is
GetAsync(id) Task<List<object?>?> Return the stored transcript for id, or null if none.
SaveAsync(id, messages) Task Persist the (updated) transcript for id.
  • 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.