Agent Session Store
Persist per-agent state with per-field TTL and sliding-window refresh. Store intent, intermediate results, and reasoning chains across invocations.
The session tier is a per-thread key-value store for agent state. It is designed for the patterns that come up in multi-step, multi-turn agents: storing user intent, intermediate reasoning, extracted entities, and tool call context between invocations.
How It Works
Each session field is stored as an individual Valkey key:
betterdb_ac:session:{threadId}:{field}
This differs from storing everything in a Redis HASH. Individual keys allow per-field TTL - different fields can have different expiry times - and get() automatically refreshes the TTL on each read (sliding window). The trade-off is that getAll() and destroyThread() require a SCAN + pipeline rather than a single HGETALL or DEL.
Basic Operations
import Valkey from 'iovalkey';
import { AgentCache } from '@betterdb/agent-cache';
const client = new Valkey({ host: 'localhost', port: 6379 });
const cache = new AgentCache({
client,
tierDefaults: { session: { ttl: 1800 } }, // 30 min sliding window
});
const threadId = 'user-42-thread-001';
// Write state fields
await cache.session.set(threadId, 'intent', 'book_flight');
await cache.session.set(threadId, 'destination', 'Sofia');
await cache.session.set(threadId, 'departure', '2026-06-01');
// Read a field (also refreshes its TTL)
const intent = await cache.session.get(threadId, 'intent');
// 'book_flight'
// Returns null for fields that don't exist or have expired
const missing = await cache.session.get(threadId, 'return_date');
// null
Getting All Fields
const state = await cache.session.getAll(threadId);
console.log(state);
// { intent: 'book_flight', destination: 'Sofia', departure: '2026-06-01' }
getAll() uses SCAN to find all keys for the thread and fetches them in a pipeline. It is designed for sessions with dozens of fields - not thousands. If you have thousands of fields per thread, consider a HASH-based approach instead.
Refreshing TTLs
// On each user interaction, refresh all session fields to keep the session alive
await cache.session.touch(threadId);
touch() extends the TTL on every field for the thread. Call it at the start of each agent invocation to prevent partial expiry where some fields outlive others.
Deleting State
// Delete a single field
await cache.session.delete(threadId, 'departure');
// Destroy the entire thread - session state + LangGraph checkpoints
const deleted = await cache.session.destroyThread(threadId);
console.log(`Deleted ${deleted} keys`);
destroyThread() is the clean shutdown path. Call it when a conversation ends or a user explicitly resets their session.
Multi-Step Agent Example
Here is a booking agent that accumulates state across multiple tool calls:
interface BookingState {
intent?: string;
origin?: string;
destination?: string;
departure?: string;
passengerCount?: string;
searchResults?: string;
}
async function runBookingAgent(threadId: string, userMessage: string) {
// Load existing state
const state = await cache.session.getAll(threadId) as BookingState;
// Extract intent from message (simplified)
if (userMessage.includes('book') && userMessage.includes('flight')) {
await cache.session.set(threadId, 'intent', 'book_flight');
}
// Extract destination
const destMatch = userMessage.match(/to (\w+)/i);
if (destMatch) {
await cache.session.set(threadId, 'destination', destMatch[1]);
}
// Extract departure date
const dateMatch = userMessage.match(/on (\d{4}-\d{2}-\d{2})/);
if (dateMatch) {
await cache.session.set(threadId, 'departure', dateMatch[1]);
}
// Refresh all TTLs so the session doesn't partially expire
await cache.session.touch(threadId);
// Read current state
const currentState = await cache.session.getAll(threadId) as BookingState;
// Search for flights only when we have enough context
if (currentState.destination && currentState.departure && !currentState.searchResults) {
const results = await cache.tool.check('search_flights', {
destination: currentState.destination,
date: currentState.departure,
});
if (!results.hit) {
const flightData = await searchFlights(currentState);
await cache.tool.store('search_flights', {
destination: currentState.destination,
date: currentState.departure,
}, JSON.stringify(flightData));
await cache.session.set(threadId, 'searchResults', JSON.stringify(flightData));
} else {
await cache.session.set(threadId, 'searchResults', results.response!);
}
}
return cache.session.getAll(threadId);
}
Session State Patterns
| Pattern | Implementation |
|---|---|
| User intent tracking | set(thread, 'intent', value) on each turn |
| Entity accumulation | One field per entity: set(thread, 'city', 'Sofia') |
| Conversation stage | set(thread, 'stage', 'collecting_dates') |
| Intermediate results | Store JSON: set(thread, 'search_results', JSON.stringify(data)) |
| Error recovery | set(thread, 'last_failed_step', 'payment') |
| Session keepalive | touch(thread) at start of each invocation |
Per-Field TTL Override
For fields with different freshness requirements, override TTL per write:
// Short-lived field: search results that go stale quickly
await cache.session.set(threadId, 'flight_prices', JSON.stringify(prices), {
ttl: 300, // 5 minutes
});
// Long-lived field: user preferences that persist across sessions
await cache.session.set(threadId, 'preferred_seat', 'aisle', {
ttl: 86400 * 30, // 30 days
});
The per-call TTL overrides tierDefaults.session.ttl for that specific field.