Memory
What it does
TnsAI.Core provides pluggable conversation memory for agents. A memory store retains history, supports retrieval and pruning, and can persist or share that history when the selected implementation supports it.
Declarative (annotations)
The store-oriented declarative path nests @MemorySpec inside @AgentSpec.
This complete example creates file-backed history, retains at most 200 entries,
and applies an 8,000-token budget:
package com.example.tnsai.docs;
import com.tnsai.agents.Agent;
import com.tnsai.annotations.AgentSpec;
import com.tnsai.annotations.MemorySpec;
import com.tnsai.llm.LLMClient;
import com.tnsai.llm.providers.AnthropicClient;
import com.tnsai.roles.Role;
import java.util.List;
@AgentSpec(
description = "Support agent with persistent history",
memory = @MemorySpec(
type = MemorySpec.MemoryType.CONVERSATION,
capacity = 200,
pruneStrategy = MemorySpec.PruneStrategy.SLIDING_WINDOW,
persistence = MemorySpec.Persistence.FILE,
maxContextTokens = 8000))
public final class MemoryTopicAnnotationsExample extends Agent {
@Override
protected LLMClient getLLM() {
return new AnthropicClient("claude-sonnet-4-20250514");
}
@Override
protected List<Role> getRoles() {
return List.of();
}
}The separate top-level @Memory annotation configures shared namespaces,
conversation consolidation, and persistence resolution. It has a different
runtime path from @MemorySpec; do not combine their field sets as if they
were one annotation.
Programmatic (builder)
MemoryConfig mirrors all eight @MemorySpec fields and flows through the
same MemoryStoreFactory. The application composition root supplies the LLM,
Role, principal, liability sink, and authority scope used below:
package com.example.tnsai.docs;
import com.tnsai.accountability.AuthorityScope;
import com.tnsai.accountability.LiabilitySink;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.annotations.MemorySpec;
import com.tnsai.identity.AgentPrincipal;
import com.tnsai.llm.LLMClient;
import com.tnsai.memory.MemoryConfig;
import com.tnsai.roles.Role;
public final class MemoryTopicBuilderExample {
private MemoryTopicBuilderExample() {}
public static Agent build(
LLMClient llmClient,
Role supportRole,
AgentPrincipal principal,
LiabilitySink liabilitySink,
AuthorityScope authorityScope
) {
return AgentBuilder.create()
.llm(llmClient)
.role(supportRole)
.memoryConfig(MemoryConfig.builder()
.type(MemorySpec.MemoryType.CONVERSATION)
.capacity(200)
.pruneStrategy(MemorySpec.PruneStrategy.SLIDING_WINDOW)
.persistence(MemorySpec.Persistence.FILE)
.maxContextTokens(8000)
.build())
.principal(principal)
.liabilitySink(liabilitySink)
.authorityScope(authorityScope)
.build();
}
}For a custom implementation, pass the store directly with
AgentBuilder.memoryStore(MemoryStore).
Which one to use
Use nested @AgentSpec.memory / @MemorySpec configuration when values belong
to the agent class. Use AgentBuilder.memoryConfig(MemoryConfig) when
deployment code owns the same values, and memoryStore(MemoryStore) when the
application owns a custom store.
An explicitly injected builder store wins over annotations. In the annotation
path, top-level @Memory or @VectorMemory wins over nested @MemorySpec.
The default when no path supplies a store is InMemoryStore.
Field reference
live means the runtime consumes the field, partial means only the named
subset is available, and not implemented means changing the field has no
runtime effect. A field copied into MemoryConfig is not considered live until
the production factory uses it.
@MemorySpec / MemoryConfig field | Effect | Status |
|---|---|---|
type | Declares a memory flavor, but does not change store selection. | not implemented |
capacity | Wraps the store with a positive entry limit unless pruning is NONE; 0 is unbounded. | live |
pruneStrategy | Selects sliding-window, relevance, summary, token-limit, or no pruning. | live |
persistence | Selects in-memory/file storage or optional Redis/database discovery. | partial |
maxContextTokens | Adds token-aware pruning and supplies the agent context limit. | live |
includeSystemMessages | Is copied into config but has no runtime consumer. | not implemented |
summarizeOnPrune | Wraps the store with summarization before capacity pruning. | live |
connectionUrl | Is passed into discovery but not delivered to a discovered provider. | not implemented |
The broader @Memory and @VectorMemory consolidation work is tracked
separately; the annotation catalog
calls out its current partial fields.
MemoryStore Interface
MemoryStore is the contract that all memory implementations must follow. It defines how an agent saves, retrieves, prunes, and searches its conversation history. You pick an implementation based on your persistence needs (in-memory for development, file-based for production, shared for multi-agent setups).
public interface MemoryStore {
void init(String agentId);
void addMessage(String role, String content);
void addMessage(Map<String, Object> message);
List<Map<String, Object>> getHistory();
List<Map<String, Object>> getRecentHistory(int limit);
void clear();
void prune(int maxTokens);
default List<String> search(String query, int limit);
}| Method | Description |
|---|---|
init(String agentId) | Initializes the store for a specific agent. Triggers history loading for persistent stores. |
addMessage(String role, String content) | Adds a simple message with role (user, assistant, system) and content. |
addMessage(Map<String, Object> message) | Adds a structured message (e.g., with tool calls, metadata). The map is defensively copied. |
getHistory() | Returns the full conversation history as an unmodifiable list. |
getRecentHistory(int limit) | Returns the last N messages. |
clear() | Removes all messages. |
prune(int maxTokens) | Removes oldest messages until the estimated token count is within the limit. System prompts are preserved when possible. |
search(String query, int limit) | Semantic search over history. Default returns empty list. AbstractMemoryStore provides a TF-IDF implementation. |
AbstractMemoryStore
If you want to create your own memory store, extend AbstractMemoryStore instead of implementing MemoryStore from scratch. It handles thread safety, pruning, and search out of the box -- you only need to define how history is loaded and what happens after it changes.
All built-in implementations extend AbstractMemoryStore, which provides:
- Thread safety via
ReentrantLock(Virtual Thread friendly, avoids carrier thread pinning) - LinkedList backing for O(1) removal during pruning
- TF-IDF search using log-normalized term frequency and smooth inverse document frequency
- Extension hooks:
onHistoryChanged()andloadHistory()
Subclasses only need to override two methods:
// Called after any modification (addMessage, clear, prune)
protected void onHistoryChanged() { }
// Called during init() to load persisted history
protected void loadHistory() { }Additional utility method:
// Returns current history size
public int size();InMemoryStore
The simplest memory store -- it keeps conversation history in a plain list in memory. This is the default when you do not configure any memory store on your agent. It is fast and requires zero setup, but all data is lost when the process exits.
public class InMemoryStore extends AbstractMemoryStore {
public InMemoryStore();
}All functionality is inherited from AbstractMemoryStore with no persistence hooks. Suitable for:
- Development and testing
- Short-lived conversations
- Scenarios where persistence is not required
This is the default store used by AgentBuilder when no store is explicitly configured.
FileMemoryStore
When you need conversations to survive restarts, use FileMemoryStore. It writes each agent's history to a JSON file on disk after every change and reloads it automatically when the agent initializes.
public class FileMemoryStore extends AbstractMemoryStore {
public FileMemoryStore(); // uses default dir: .tnsai/memory/
public FileMemoryStore(String storageDirPath);
public Path getAgentFile(); // path to agent's JSON file
}Each agent's history is stored in a separate file: {storageDirPath}/{agentId}.json.
Suitable for:
- Production environments requiring conversation persistence
- Long-running agents that need to resume conversations
- Multi-session scenarios
Example:
FileMemoryStore store = new FileMemoryStore("./data/memory");
store.init("agent-1");
store.addMessage("user", "Hello");
// Automatically saved to ./data/memory/agent-1.json
// On restart, history is loaded from file
FileMemoryStore restored = new FileMemoryStore("./data/memory");
restored.init("agent-1");
List<Map<String, Object>> history = restored.getHistory(); // Contains previous messagesSharedMemoryStore
In multi-agent systems, you often want several agents to read and write the same conversation history. SharedMemoryStore wraps any other MemoryStore and adds an access-control layer so only authorized agents can interact with the shared memory.
public class SharedMemoryStore implements MemoryStore {
public SharedMemoryStore(MemoryStore delegate, String namespace);
public void grantAccess(String agentId);
public void revokeAccess(String agentId);
public boolean hasAccess(String agentId);
public String getNamespace();
}Thread safety uses ReentrantReadWriteLock for high-throughput concurrent reads with exclusive writes. Only the first authorized agent to call init() initializes the underlying store.
Calling init() with an unauthorized agent ID throws IllegalStateException.
Example:
MemoryStore base = new InMemoryStore();
SharedMemoryStore shared = new SharedMemoryStore(base, "team-chat");
shared.grantAccess("agent-a");
shared.grantAccess("agent-b");
// First agent initializes the shared memory
shared.init("agent-a");
// Both agents can read and write
shared.addMessage("assistant", "Hello from agent-a");
List<Map<String, Object>> history = shared.getHistory(); // visible to all authorized agentsSharedMemoryRegistry
When you have many shared memory namespaces across your application, SharedMemoryRegistry acts as a central lookup. It ensures each namespace has exactly one SharedMemoryStore instance, creating one on demand if it does not exist yet. The framework uses this internally when processing @Memory(shared = true, shareWith = {"agent-a"}) annotations.
public final class SharedMemoryRegistry {
public static SharedMemoryRegistry getInstance();
public SharedMemoryStore getOrCreate(String namespace);
public SharedMemoryStore getOrCreate(String namespace, MemoryStore baseStore);
public Optional<SharedMemoryStore> find(String namespace);
public boolean remove(String namespace);
public void clear();
public int size();
}getOrCreate(String namespace) creates a SharedMemoryStore wrapping an InMemoryStore if the namespace does not exist. Use the two-argument overload to provide a custom base store (e.g., FileMemoryStore for persistence).
Example:
SharedMemoryRegistry registry = SharedMemoryRegistry.getInstance();
// Get or create a shared namespace
SharedMemoryStore shared = registry.getOrCreate("project-alpha");
shared.grantAccess("researcher");
shared.grantAccess("writer");
// With custom persistence
SharedMemoryStore persistent = registry.getOrCreate(
"persistent-namespace",
new FileMemoryStore("./shared-memory")
);
// Look up existing namespace
Optional<SharedMemoryStore> found = registry.find("project-alpha");
// Clean up
registry.remove("project-alpha");MemoryStoreFactory
If you use the @MemorySpec annotation to configure memory declaratively, MemoryStoreFactory is what turns that annotation into an actual MemoryStore instance at runtime. It reads the annotation's fields and automatically wraps the base store with decorators for capacity limits, token budgets, or summarization as needed.
public final class MemoryStoreFactory {
public static MemoryStore create(MemorySpec config);
public static MemoryStore createDefault(); // returns new InMemoryStore()
}Supported persistence types: IN_MEMORY, FILE, REDIS (via SPI), DATABASE (via SPI).
The factory automatically wraps the base store with decorators based on configuration:
- CapacityAwareMemoryStore -- enforces message count limits with configurable prune strategy
- SummarizingMemoryStore -- summarizes old messages instead of deleting them when capacity is exceeded
- TokenAwareMemoryStore -- enforces token limits by pruning after each message
TnsAI 0.14.0 (TnsAI@834d8972, TAN-5177) forwards search() to the
delegate and size() to getHistory(). Before that commit those
wrappers inherited empty AbstractMemoryStore implementations, so any
non-default @MemorySpec (capacity, summarize, or token budget)
silently returned no retrieval hits. Maven Central 0.13.0 had the
hole; 0.14.1 forwards those methods.
Integration with AgentBuilder
The most common way to configure memory is through the AgentBuilder. Call .memoryStore() to plug in any MemoryStore implementation. If you skip this step, the agent defaults to InMemoryStore (volatile, no persistence). There is no AgentBuilder.model(String) — pass an LLMClient with .llm(...). build() still requires the accountability trio (TNS-298).
package com.example.tnsai.docs;
import com.tnsai.accountability.AuthorityScope;
import com.tnsai.accountability.LiabilitySink;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.identity.AgentPrincipal;
import com.tnsai.llm.providers.AnthropicClient;
import com.tnsai.llm.providers.OpenAIClient;
import com.tnsai.memory.FileMemoryStore;
import com.tnsai.memory.SharedMemoryRegistry;
import com.tnsai.memory.SharedMemoryStore;
import com.tnsai.roles.Role;
public final class MemoryBuilderIntegrationExample {
private MemoryBuilderIntegrationExample() {}
public static Agent defaultMemory(
Role role,
AgentPrincipal principal,
LiabilitySink sink,
AuthorityScope scope
) {
return AgentBuilder.create()
.llm(new AnthropicClient("claude-sonnet-4-20250514"))
.role(role)
.principal(principal)
.liabilitySink(sink)
.authorityScope(scope)
.build();
}
public static Agent fileMemory(
Role role,
AgentPrincipal principal,
LiabilitySink sink,
AuthorityScope scope
) {
return AgentBuilder.create()
.llm(new AnthropicClient("claude-sonnet-4-20250514"))
.role(role)
.memoryStore(new FileMemoryStore("./data/memory"))
.principal(principal)
.liabilitySink(sink)
.authorityScope(scope)
.build();
}
public static Agent[] sharedMemory(
Role role1,
Role role2,
AgentPrincipal principal1,
AgentPrincipal principal2,
LiabilitySink sink,
AuthorityScope scope
) {
SharedMemoryStore shared = SharedMemoryRegistry.getInstance()
.getOrCreate("team-namespace");
shared.grantAccess("agent-1");
shared.grantAccess("agent-2");
Agent agent1 = AgentBuilder.create()
.id("agent-1")
.llm(new AnthropicClient("claude-sonnet-4-20250514"))
.role(role1)
.memoryStore(shared)
.principal(principal1)
.liabilitySink(sink)
.authorityScope(scope)
.build();
Agent agent2 = AgentBuilder.create()
.id("agent-2")
.llm(new OpenAIClient("gpt-4o"))
.role(role2)
.memoryStore(shared)
.principal(principal2)
.liabilitySink(sink)
.authorityScope(scope)
.build();
return new Agent[] { agent1, agent2 };
}
}Code Examples
These examples demonstrate typical memory usage patterns, from basic conversation persistence to semantic search and token-aware pruning.
Conversation with History Management
This example creates an agent with file-based memory so conversations survive restarts. You can also access the memory store directly to inspect recent history.
MemoryStore memory = new FileMemoryStore("./memory");
Agent agent = AgentBuilder.create()
.llm(new AnthropicClient("claude-sonnet-4-20250514"))
.role(role)
.memoryStore(memory)
.principal(principal)
.liabilitySink(sink)
.authorityScope(scope)
.build();
// Conversation persists across restarts
agent.chat("What is the capital of France?");
agent.chat("What about Germany?");
// Keep the store you passed to the builder — Agent.getMemoryStore() is protected.
List<Map<String, Object>> recent = memory.getRecentHistory(5);Semantic Search over History
The built-in TF-IDF search lets you find past messages by meaning rather than scrolling through the full history. This is useful for agents that need to recall earlier context from a long conversation.
MemoryStore store = new InMemoryStore();
store.init("search-agent");
store.addMessage("user", "Tell me about Java generics");
store.addMessage("assistant", "Java generics provide compile-time type safety.");
store.addMessage("user", "How do I create a REST API?");
store.addMessage("assistant", "You can use Spring Boot or Javalin.");
// TF-IDF search finds relevant messages
List<String> results = store.search("generics type safety", 2);
// Returns messages about Java generics ranked by relevanceToken-Aware Pruning
LLMs have a maximum context window size. When your conversation history grows too large, call prune() to remove the oldest messages until the estimated token count fits within the model's limit. System prompts are preserved when possible.
MemoryStore store = new InMemoryStore();
store.init("pruning-agent");
// Add many messages
for (int i = 0; i < 1000; i++) {
store.addMessage("user", "Message " + i + " with some content");
store.addMessage("assistant", "Response to message " + i);
}
// Prune to fit context window
store.prune(4096);
// Oldest messages are removed, keeping history within token limitAdvanced Memory
The com.tnsai.memory.advanced package provides production-grade memory capabilities for agents that need more than simple history management. These include vector-based semantic search, keyword-based BM25 indexing, hybrid retrieval that combines both, skill persistence, importance-based pruning, staleness detection, and full session serialization.
VectorMemoryStore
When you need to find past messages based on meaning (not just keywords), VectorMemoryStore converts each message into an embedding vector and uses cosine similarity to find the closest matches. This powers true semantic retrieval over conversation history.
VectorMemoryStore vectorStore = VectorMemoryStore.builder()
.embeddingClient(embeddingClient)
.dimensions(1536)
.build();
vectorStore.init("agent-1");
vectorStore.addMessage("user", "The deployment uses Kubernetes with 3 replicas");
// Semantic search -- finds relevant messages even with different wording
List<String> results = vectorStore.search("container orchestration setup", 5);BM25Index
While vector search excels at finding semantically similar content, sometimes you need exact keyword matching. BM25Index uses the BM25 scoring algorithm (the same approach behind traditional search engines) to rank documents by how well they match specific terms.
BM25Index index = new BM25Index();
index.addDocument("doc-1", "Kubernetes deployment configuration");
index.addDocument("doc-2", "Database connection pooling settings");
List<BM25Index.ScoredDocument> results = index.search("Kubernetes", 5);HybridMemoryRetriever
For the best retrieval quality, combine both approaches. HybridMemoryRetriever runs a vector search and a BM25 keyword search in parallel, then merges the results using Reciprocal Rank Fusion (RRF). This captures both semantic meaning and exact keyword matches in a single query.
HybridMemoryRetriever retriever = HybridMemoryRetriever.builder()
.vectorStore(vectorStore)
.bm25Index(bm25Index)
.vectorWeight(0.6)
.keywordWeight(0.4)
.fusionK(60)
.build();
List<String> results = retriever.search("deployment configuration", 10);RRF fusion merges the ranked lists from both retrieval methods, giving high-quality results that capture both semantic meaning and exact keyword matches.
SkillMemoryStore
Agents can learn reusable procedures and remember them across sessions. SkillMemoryStore saves these skills as Markdown files on disk, making them human-readable, easy to version-control with Git, and editable by hand if needed.
SkillMemoryStore skillStore = new SkillMemoryStore(".tnsai/skills/");
skillStore.saveSkill("deploy-k8s", SkillEntry.builder()
.name("deploy-k8s")
.description("Deploy application to Kubernetes cluster")
.steps(List.of("Build Docker image", "Push to registry", "Apply manifests"))
.tags(Set.of("devops", "kubernetes"))
.build());
Optional<SkillEntry> skill = skillStore.loadSkill("deploy-k8s");
List<SkillEntry> devopsSkills = skillStore.findByTag("devops");MemoryImportanceScorer
When memory grows too large and needs pruning, not all entries are equally valuable. MemoryImportanceScorer assigns an importance score to each entry based on recency, access frequency, semantic distinctiveness, and user-marked importance, so the pruning process can keep the most valuable memories.
MemoryImportanceScorer scorer = new MemoryImportanceScorer();
double importance = scorer.score(memoryEntry);
// Factors: recency, access frequency, semantic distinctiveness, user-marked importanceStalenessDetector
Over time, memory accumulates near-duplicate or outdated entries that waste context window space. StalenessDetector uses Jaccard similarity to find entries that are too similar to each other, and age thresholds to flag entries that are too old, so you can clean them up.
StalenessDetector detector = StalenessDetector.builder()
.similarityThreshold(0.85) // entries above 85% similarity are stale
.maxAge(Duration.ofDays(30)) // entries older than 30 days are candidates
.build();
List<String> staleIds = detector.detectStale(memoryEntries);SessionSerializer / SessionRestorer
If you need to save and restore an agent's complete session state (not just conversation history, but all runtime state), these utilities serialize the session to bytes and restore it later. This is useful for long-running agents that need to survive process restarts without losing their place.
// Save session state
SessionSerializer serializer = new SessionSerializer();
byte[] data = serializer.serialize(agent.getSession());
Files.write(Path.of("session-backup.bin"), data);
// Restore session state
SessionRestorer restorer = new SessionRestorer();
AgentSession restored = restorer.restore(Files.readAllBytes(Path.of("session-backup.bin")));
agent.restoreSession(restored);