Skip to content

Conversation — multi-turn memory

Rust · crate toolnexus · SPEC §8

impl Client { pub fn conversation(&self, tk: &Toolkit) -> Conversation }
pub struct Conversation { pub messages: Vec<Value>, .. }
impl Conversation { pub async fn send(&mut self, prompt: impl Into<Prompt>) -> Result<RunResult, ClientError> }

Keep a transcript across turns so the model remembers what it already did.

For a chat inside one process: each send carries the transcript so far, so the model remembers what it did.

Use Conversation when the history lives with your object; use ask(prompt, tk, id) with a store when it must survive restarts or be shared by id.

Each example is a complete program. The model is an in-process stand-in (create_in_process_client): the loop is exactly the one create_client runs, with a host function in place of the HTTP call, so no network and no API key are involved.

1. The smallest useful call — two turns, transcript grows

Section titled “1. The smallest useful call — two turns, transcript grows”
use std::sync::Arc;
use toolnexus::client::{GenerateFn, InProcessOptions, InProcessResponse, create_in_process_client};
use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let model: GenerateFn = Arc::new(|_req| Box::pin(async {
Ok(InProcessResponse { content: "ack".into(), ..Default::default() })
}));
let client = create_in_process_client(InProcessOptions { model: "m".into(), generate: Some(model), ..Default::default() });
let tk = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), ..Default::default() }).await?;
let mut conv = client.conversation(&tk);
conv.send("one").await?;
let after_first = conv.messages.len();
conv.send("two").await?;
assert!(conv.messages.len() > after_first, "transcript did not grow");
println!("ok: transcript grew from {} to {}", after_first, conv.messages.len());
Ok(())
}

2. The realistic case — the system prompt is sent once, not per turn

Section titled “2. The realistic case — the system prompt is sent once, not per turn”
use std::sync::{Arc, Mutex};
use toolnexus::client::{GenerateFn, InProcessOptions, InProcessResponse, create_in_process_client};
use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Record the roles of every message the model is sent.
let last_roles: Arc<Mutex<Vec<String>>> = Arc::default();
let seen = last_roles.clone();
let model: GenerateFn = Arc::new(move |req| {
*seen.lock().unwrap() = req.messages.iter().map(|m| m["role"].as_str().unwrap_or("").to_string()).collect();
Box::pin(async { Ok(InProcessResponse { content: "ok".into(), ..Default::default() }) })
});
let client = create_in_process_client(InProcessOptions {
model: "m".into(), generate: Some(model), system_prompt: "You are terse.".into(), ..Default::default()
});
let tk = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), ..Default::default() }).await?;
let mut conv = client.conversation(&tk);
conv.send("first").await?;
assert_eq!(*last_roles.lock().unwrap(), ["system", "user"]);
conv.send("second").await?;
// Turn 2: [system, user, assistant, user]; the system message is NOT repeated.
let roles = last_roles.lock().unwrap().clone();
assert_eq!(roles, ["system", "user", "assistant", "user"]);
println!("ok: system sent once, transcript on turn 2 = {roles:?}");
Ok(())
}

3. The full surface — reset clears memory, then continues fresh

Section titled “3. The full surface — reset clears memory, then continues fresh”
use std::sync::Arc;
use toolnexus::client::{GenerateFn, InProcessOptions, InProcessResponse, create_in_process_client};
use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let model: GenerateFn = Arc::new(|_req| Box::pin(async {
Ok(InProcessResponse { content: "ack".into(), ..Default::default() })
}));
let client = create_in_process_client(InProcessOptions { model: "m".into(), generate: Some(model), ..Default::default() });
let tk = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), ..Default::default() }).await?;
let mut conv = client.conversation(&tk);
conv.send("hello").await?;
assert!(!conv.messages.is_empty());
conv.reset();
assert!(conv.messages.is_empty());
// A fresh send after reset starts a new transcript from scratch.
let res = conv.send("hello again").await?;
assert_eq!((res.turns, conv.messages.len()), (1, 2)); // [user, assistant]
println!("ok: reset then {} messages after one fresh turn", conv.messages.len());
Ok(())
}
Member What it does
Client::conversation(&self, tk: &Toolkit) -> Conversation Builds a Conversation bound to this client and toolkit.
messages: Vec<Value> The full running transcript — a public field; read it to inspect or persist it yourself.
send(&mut self, prompt) -> Result<RunResult, ClientError> Sends the next user turn with prior history retained; updates messages to the returned RunResult.messages.
reset(&mut self) Clears messages — the next send starts a brand-new transcript.
  • create_client — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
  • Client.run — Send a prompt, let the loop call tools until the model stops, get a RunResult.
  • Client.stream — The streaming loop: text deltas, tool-call events, and suspension events as they happen.
  • Hooks — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.