Toolnexus.Client.InMemoryConversationStore
Elixir · package toolnexus · SPEC §8 · elixir/lib/toolnexus/client.ex
defmodule Toolnexus.Client.ConversationStore do @callback get(store :: struct(), id :: String.t()) :: [map()] | nil @callback save(store :: struct(), id :: String.t(), messages :: [map()]) :: any()end
defmodule Toolnexus.Client.InMemoryConversationStore do @behaviour Toolnexus.Client.ConversationStore defstruct [:pid] @spec new() :: t() def new()endA ConversationStore is any struct whose module implements two callbacks — get/2, save/3.
The client dispatches on the struct’s module, so implementing this behaviour for a file, a
database table, or Redis is all it takes to make ask/4 conversations outlive the process.
InMemoryConversationStore is the default: an Agent-backed map, gone when the client’s
process tree ends.
When to use it
Section titled “When to use it”- You want conversations to survive a restart — implement
ConversationStoreagainst whatever you already persist to (Postgres, Redis, a file), and pass it as:storeonToolnexus.Client.create/1. - You just need per-process memory for now — do nothing; the default in-memory store is created for you and lives as long as the client’s process.
- You need to read or rewind a transcript from outside
ask/4—Toolnexus.Client.conversation_store/1returns the exact instance the client is using, so a host can read (or, with your own store, mutate) it directly.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — the default store, used directly
Section titled “1. The smallest useful call — the default store, used directly”Client.create/1 always gives you a store, even if you never mention :store — read/write it
through the two behaviour callbacks, dispatched via the struct’s module.
alias Toolnexus.Clientalias Toolnexus.Client.{ConversationStore, InMemoryConversationStore}
client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "k")
store = Client.conversation_store(client)true = match?(%InMemoryConversationStore{}, store)
# Nothing saved yet.true = InMemoryConversationStore.get(store, "conv-1") == nil
InMemoryConversationStore.save(store, "conv-1", [%{"role" => "user", "content" => "hi"}])true = InMemoryConversationStore.get(store, "conv-1") == [%{"role" => "user", "content" => "hi"}]
# The behaviour also dispatches generically, by module — this is what the client itself does.%mod{} = storetrue = mod.get(store, "conv-1") == [%{"role" => "user", "content" => "hi"}]true = ConversationStore in InMemoryConversationStore.__info__(:attributes)[:behaviour]
IO.puts("ok: default store round-tripped a transcript for conv-1")2. A realistic case — a custom store plugged in at create/1
Section titled “2. A realistic case — a custom store plugged in at create/1”Any struct implementing the two callbacks works. This one is still in-memory (kept hermetic for
the docs), but the shape is exactly what a Postgres- or file-backed store would look like: get
reads, save writes, and ask/4 never knows the difference.
alias Toolnexus.Clientalias Toolnexus.Client.ConversationStore
defmodule DocsFileLikeStore do @behaviour ConversationStore defstruct [:pid]
def new do {:ok, pid} = Agent.start_link(fn -> %{} end) %__MODULE__{pid: pid} end
@impl true def get(%__MODULE__{pid: pid}, id), do: Agent.get(pid, &Map.get(&1, id))
@impl true def save(%__MODULE__{pid: pid}, id, messages), do: Agent.update(pid, &Map.put(&1, id, messages))end
plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "noted"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp))end
client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], store: DocsFileLikeStore.new() )
_ = Client.ask(client, "remember this", [], id: "conv-2")
true = Client.conversation_store(client).__struct__ == DocsFileLikeStorehistory = DocsFileLikeStore.get(Client.conversation_store(client), "conv-2")true = length(history) == 2
IO.puts("ok: ask/4 persisted #{length(history)} messages into the custom store")3. The full surface — sharing the store outside ask/4
Section titled “3. The full surface — sharing the store outside ask/4”conversation_store/1 returns the exact instance the client uses (Gap 4 in SPEC §8) — you
can write to it directly and ask/4 will pick that up as history on its next call, with no
shadow copy anywhere.
alias Toolnexus.Clientalias Toolnexus.Client.InMemoryConversationStore
{:ok, seen} = Agent.start_link(fn -> [] end)
plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) Agent.update(seen, fn s -> s ++ [Jason.decode!(raw)] end) resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "ok"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp))end
client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug] )
# Seed history directly on the store the client already holds — no prior ask/4 call at all.store = Client.conversation_store(client)InMemoryConversationStore.save(store, "conv-3", [ %{"role" => "user", "content" => "my favorite color is teal"}, %{"role" => "assistant", "content" => "got it, teal"}])
_ = Client.ask(client, "what's my favorite color?", [], id: "conv-3")
[sent] = Agent.get(seen, & &1)# The seeded history was there BEFORE ask/4 ever ran for this id.true = length(sent["messages"]) == 3true = hd(sent["messages"]) == %{"role" => "user", "content" => "my favorite color is teal"}
IO.puts("ok: seeded #{length(sent["messages"]) - 1} prior message(s) were visible to ask/4")ConversationStore behaviour
Section titled “ConversationStore behaviour”| Callback | Signature | What it does |
|---|---|---|
get/2 |
(store, id) -> [map()] | nil |
Load a transcript by id, or nil if none exists yet. |
save/3 |
(store, id, messages) -> any() |
Persist the transcript under an id. Return value is ignored. |
See also
Section titled “See also”Toolnexus.Client.create— pass a custom store via:store.Toolnexus.Client conversation—ask/4, the load/save cycle this store backs.Toolnexus.Client.run— the store-free continuation primitive (history:).