Context and message history
An agent’s message history can outlive the process and be rewritten while the agent runs. The agents_resume.rs example saves the history of one agent as JSON (JavaScript Object Notation) and loads it into a second one:
// Let's store the messages in a database, retrieve them back, and start a new agentlet stored_history = serde_json::to_string(&first_agent.history().await?)?;let retrieved_history: Vec<_> = serde_json::from_str(&stored_history)?;
let restored_context = DefaultContext::default() .with_existing_messages(retrieved_history) .await? .to_owned();Source: examples/agents_resume.rs#L23-L30
let mut second_agent = agents::Agent::builder() .llm(&openai) .context(restored_context) // We'll use the one from the first agent, alternatively we could also pop it from the // previous history and add a new one here .no_system_prompt() .build()?;
second_agent.query("What did you say?").await?;Source: examples/agents_resume.rs#L34-L42
Agent::history() returns every ChatMessage the context holds. ChatMessage implements Serialize and Deserialize, so serde_json turns the history into a string you can store anywhere. DefaultContext::with_existing_messages loads the messages back into a fresh context. The second agent gets that context through the builder’s context method and continues the conversation with query. The next completion sends the whole restored history to the model.
The first agent sets no system prompt, so no_system_prompt() changes nothing in this example. Prompt removal explains when it matters.
run without a new message only continues when the history ends in a user message, a tool output or a pending tool call. When it ends in a plain assistant answer, as it does here, run stops at once with StopReason::NoNewMessages.
Default context
Section titled “Default context”The context stores the message history, decides when the next completion runs and what it contains, holds the tool executor and records feedback for tools that wait on a person. Every agent built without .context(..) gets a DefaultContext:
- the message history is an in-memory
Mutex<Vec<ChatMessage>>; - tools run commands through a
LocalExecutorin the current directory; - the agent stops when the last message is an assistant answer and no tool feedback is pending.
DefaultContext::from_executor keeps those defaults and swaps the tool executor. It takes a value that converts into Arc<dyn ToolExecutor>. Tool executors covers the conversion and the tool executors themselves.
with_stop_on_assistant(false) turns off the last rule. A text answer then no longer ends the run, and the agent asks the model again. Only a tool that stops the agent, the builder’s limit, or a model reply with no content and no tool calls ends the loop. An agent has no limit by default, so set one (Stop reasons).
A clone of a DefaultContext shares its history and counters.
Message history
Section titled “Message history”Where the messages live is the job of the MessageHistory trait. It has three required methods:
historyreturns all messages in order;push_ownedappends one message;overwritereplaces all messages.
The trait requires Send + Sync + Debug and is declared with #[async_trait], so annotate your impl the same way. push, extend and extend_owned have default implementations on top of push_owned. Implement the trait for your database and pass the value to DefaultContext::with_message_history. The backend only stores the list. Each completion sends the full history, cut at the latest summary, and the context’s counters only decide whether a new completion runs.
Custom backend
Section titled “Custom backend”This backend keeps one conversation in a JSON file. It uses the async-trait crate for the #[async_trait] attribute:
/// Keeps the message history of one conversation in a JSON file.#[derive(Debug)]struct JsonFileHistory { path: PathBuf, // Serializes read-modify-write cycles on the file. lock: Mutex<()>,}
impl JsonFileHistory { async fn read(&self) -> anyhow::Result<Vec<ChatMessage>> { match tokio::fs::read_to_string(&self.path).await { Ok(json) => Ok(serde_json::from_str(&json)?), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), Err(err) => Err(err.into()), } }
async fn write(&self, messages: &[ChatMessage]) -> anyhow::Result<()> { tokio::fs::write(&self.path, serde_json::to_vec(messages)?).await?; Ok(()) }}
#[async_trait]impl MessageHistory for JsonFileHistory { async fn history(&self) -> anyhow::Result<Vec<ChatMessage>> { let _guard = self.lock.lock().await; self.read().await }
async fn push_owned(&self, item: ChatMessage) -> anyhow::Result<()> { let _guard = self.lock.lock().await; let mut messages = self.read().await?; messages.push(item); self.write(&messages).await }
async fn overwrite(&self, items: Vec<ChatMessage>) -> anyhow::Result<()> { let _guard = self.lock.lock().await; self.write(&items).await }}Source: snippets/src/agents/context_and_history.rs#L71-L112
let mut context = DefaultContext::default();context.with_message_history(JsonFileHistory { path: "session-42.json".into(), lock: Mutex::new(()),});
let mut agent = Agent::builder().llm(&openai).context(context).build()?;Source: snippets/src/agents/context_and_history.rs#L118-L124
Every method takes &self, so the backend guards its own state; here a tokio::sync::Mutex keeps two writes from interleaving. history returns an empty list for a file that does not exist yet. with_message_history takes the value by ownership and stores it behind an Arc.
Redis storage
Section titled “Redis storage”The redis feature flag adds a Redis integration that implements MessageHistory:
cargo add swiftide@0.34 --features swiftide-agents,openai,redislet redis = Redis::try_from_url("redis://localhost:6379", "session-42")?;
let mut context = DefaultContext::default();context.with_message_history(redis);
let mut agent = Agent::builder() .llm(&openai) .context(context) .system_prompt("You help maintain the swiftide repository.") .build()?;
agent.query("Which crates changed this week?").await?;Source: snippets/src/agents/context_and_history.rs#L17-L28
The messages go into a Redis list under <prefix>:message_history, here session-42:message_history, one JSON string per message. Use one prefix per conversation. A new process that builds a context with the same prefix sees the whole conversation again. Build that agent without .system_prompt(..), or with no_system_prompt(), as in the first example. Otherwise its first run pushes another system message onto the list.
The connection opens on first use. If Redis is unreachable, the agent returns AgentError::MessageHistoryError.
Approvals across restarts
Section titled “Approvals across restarts”Approvals are not part of the message history. They live in the context’s memory. When the agent stops with StopReason::FeedbackRequired, store the ToolCall it carries; the person’s ToolFeedback comes later. Store each decision as a (ToolCall, ToolFeedback) pair, since serde_json cannot use a struct as a map key. Both types implement Serialize and Deserialize. Collect the pairs into a HashMap for DefaultContext::with_tool_feedback in the new process. Human in the loop covers approvals.
// First process: the agent waits for approval; store the person's decision with its call.if let Some(StopReason::FeedbackRequired { tool_call, .. }) = agent.stop_reason() { let decisions = vec![(tool_call.clone(), ToolFeedback::approved())]; std::fs::write("approvals.json", serde_json::to_string(&decisions)?)?;}
// Second process: load the decisions next to the restored message history, then resume.let decisions: Vec<(ToolCall, ToolFeedback)> = serde_json::from_str(&std::fs::read_to_string("approvals.json")?)?;
let mut context = DefaultContext::default();context.with_message_history(Redis::try_from_url("redis://localhost:6379", "session-42")?);context.with_tool_feedback(decisions.into_iter().collect::<HashMap<_, _>>());
let mut resumed = Agent::builder() .llm(&openai) .context(context) .no_system_prompt() .build()?;resumed.run().await?;Source: snippets/src/agents/context_and_history.rs#L133-L152
with_tool_feedback accepts anything that converts into a HashMap<ToolCall, ToolFeedback> and adds the entries to the feedback the context already holds. The restored history still ends in the pending tool call, so run calls the tool again, and the tool now finds its approval.
History rewrites
Section titled “History rewrites”Since 0.33, AgentContext::replace_history swaps the whole history while the agent runs. It takes &self, so any hook can call it through agent.context(). This after_each hook shortens old tool outputs once the history passes MAX_MESSAGES:
const MAX_MESSAGES: usize = 40;const KEEP_RECENT: usize = 10;
let mut agent = Agent::builder() .llm(&openai) .after_each(|agent: &mut Agent| { Box::pin(async move { let history = agent.history().await?; if history.len() <= MAX_MESSAGES { return Ok(()); }
let keep_from = history.len() - KEEP_RECENT; let trimmed = history .into_iter() .enumerate() .map(|(index, message)| match message { ChatMessage::ToolOutput(call, ToolOutput::Text(_)) if index < keep_from => { ChatMessage::ToolOutput(call, ToolOutput::text("(output removed)")) } other => other, }) .collect();
agent.context().replace_history(trimmed).await?; Ok(()) }) }) .build()?;Source: snippets/src/agents/context_and_history.rs#L36-L64
The hook only replaces the text of older tool outputs. Each tool call keeps its output, which the providers require. DefaultContext writes the new list through overwrite and resets its completion counters. The next completion then runs even if no new message was added since the last one.
Summaries
Section titled “Summaries”A ChatMessage::Summary shortens what the model sees without rewriting storage. When the history holds one, DefaultContext sends the system messages plus everything from the latest summary on. Add one with ChatMessage::new_summary through agent.context().add_message(..). OpenAI-compatible providers send a summary as an assistant message. ContextCompression in agents-contrib writes these summaries for you; see Contrib.
Custom contexts
Section titled “Custom contexts”For behavior the message history cannot express, implement AgentContext yourself. Wrap a DefaultContext and forward the calls you do not change. The upgrading guide has a context that counts added messages:
/// Counts the messages an agent adds and delegates everything else.#[derive(Clone)]pub struct CountingContext { inner: DefaultContext, added: Arc<std::sync::atomic::AtomicUsize>,}
#[async_trait]impl AgentContext for CountingContext { async fn next_completion(&self) -> anyhow::Result<Option<Vec<ChatMessage>>> { self.inner.next_completion().await }
async fn current_new_messages(&self) -> anyhow::Result<Vec<ChatMessage>> { self.inner.current_new_messages().await }
async fn add_messages(&self, items: Vec<ChatMessage>) -> anyhow::Result<()> { self.added .fetch_add(items.len(), std::sync::atomic::Ordering::Relaxed); self.inner.add_messages(items).await }
async fn add_message(&self, item: ChatMessage) -> anyhow::Result<()> { self.added .fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.inner.add_message(item).await }
#[allow(deprecated)] async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError> { self.inner.exec_cmd(cmd).await }
fn executor(&self) -> &Arc<dyn ToolExecutor> { self.inner.executor() }
async fn history(&self) -> anyhow::Result<Vec<ChatMessage>> { self.inner.history().await }
async fn replace_history(&self, items: Vec<ChatMessage>) -> anyhow::Result<()> { self.inner.replace_history(items).await }
async fn redrive(&self) -> anyhow::Result<()> { self.inner.redrive().await }
async fn has_received_feedback(&self, tool_call: &ToolCall) -> Option<ToolFeedback> { self.inner.has_received_feedback(tool_call).await }
async fn feedback_received( &self, tool_call: &ToolCall, feedback: &ToolFeedback, ) -> anyhow::Result<()> { self.inner.feedback_received(tool_call, feedback).await }}Source: snippets/src/reference/upgrading.rs#L211-L272
Gotchas
Section titled “Gotchas”with_existing_messagescallsoverwriteon the backend. With Redis it deletes the list under the key and writes the given messages.with_existing_messagesandwith_message_historyreturn&mut DefaultContext. Use alet mutbinding, or end the chain with.to_owned()as the example does.- A builder-made
Redisuses the keymessage_historyunless you setmessage_history_key(..)on the builder or callwith_message_history_keyafterwards. Agents that share that key also share one history. - Messages added through
agent.context()do not triggeron_new_messagehooks. OnlyAgent::add_messageruns them.
Reference
Section titled “Reference”DefaultContextDefaultContext::with_existing_messagesDefaultContext::with_message_historyDefaultContext::with_tool_feedbackAgentContextAgentContext::replace_historyMessageHistoryRedisRedis::try_from_urlChatMessageChatMessage::new_summaryToolFeedbackAgentBuilder::contextAgentBuilder::no_system_promptAgent::history