Context management
tnsai-intelligence has three groups of context tools:
| Group | Package | What it does |
|---|---|---|
| Compaction | com.tnsai.context.compaction | Shrinks a message list to fit a token budget |
| Context graph | com.tnsai.context.integration | Records snapshots and decision traces for an agent |
| Sessions | com.tnsai.session and subpackages | Records conversations, searches them, extracts knowledge and consolidates memory |
None of them runs on its own. You call the compactors and the session tools from your code. The context graph records chat calls once you turn it on for an agent.
Compaction
A compactor takes a message list (List<Map<String, Object>>, each map with role and content) and a CompactionConfig, and returns a CompactionResult:
import com.tnsai.context.compaction.CompactionConfig;
import com.tnsai.context.compaction.CompactionResult;
import com.tnsai.context.compaction.ContextCompactor;
import com.tnsai.context.compaction.TruncatingCompactor;
ContextCompactor compactor = new TruncatingCompactor();
CompactionConfig config = new CompactionConfig();
if (compactor.shouldCompact(currentTokens, maxTokens, config)) {
CompactionResult result = compactor.compact(messages, config);
List<Map<String, Object>> compacted = result.messages();
}shouldCompact returns true when currentTokens / maxTokens reaches thresholdRatio. compact itself does not check the token count. estimateTokens(messages) gives the compactor's own estimate.
| Compactor | LLM calls | What it changes |
|---|---|---|
TruncatingCompactor | None | Shortens long tool results and tool-call arguments |
LLMContextCompactor | One | Replaces older messages with a summary |
TwoPhaseCompactor | One | Runs the truncating compactor, then the LLM compactor |
TruncatingCompactor
new TruncatingCompactor() cuts at 200 characters. new TruncatingCompactor(maxArgLength) sets another limit, at least 1.
It never removes a message. Outside the system prompt and the last preserveLastN messages, it cuts:
- the
contentof messages with roletool; - tool-call arguments, either
function.argumentsor a top-levelarguments.
A cut value keeps its first maxArgLength characters and ends with ...[truncated]. When nothing is long enough to cut, the result reports no compaction.
LLMContextCompactor
import com.tnsai.context.compaction.LLMContextCompactor;
LLMContextCompactor compactor = new LLMContextCompactor(summarizerClient);
CompactionResult result = compactor.compact(messages, config);The compactor keeps the system prompt and the last preserveLastN messages. It sends everything in between to the client in one call, built from summaryPromptTemplate with {messages} replaced by [role]: content lines. The result is:
- the system prompt, if kept;
- one system message,
[Conversation Summary]followed by the summary; - the preserved messages.
If the call fails, the summary text says so: [Previous N messages summarized - summarization failed: …].
With toolResultPruningEnabled(true), long tool results are shortened before summarizing. A message counts as a tool result when its role is tool or its content starts with Tool execution results:. A result over toolResultPruningThreshold tokens becomes [<tool> result: N chars, <first 100 characters>...].
TwoPhaseCompactor
import com.tnsai.context.compaction.TwoPhaseCompactor;
TwoPhaseCompactor compactor = new TwoPhaseCompactor(
new TruncatingCompactor(),
new LLMContextCompactor(summarizerClient));Both arguments are required. Phase 2 always runs after phase 1. The compactor has no token budget, so it cannot tell whether truncation alone was enough. Use shouldCompact before calling it.
CompactionConfig
Build it with CompactionConfig.builder(), new CompactionConfig() (all defaults) or new CompactionConfig(thresholdRatio, preserveLastN).
| Field | Default | Meaning |
|---|---|---|
thresholdRatio | 0.80 | Ratio for shouldCompact. Above 0, at most 1. |
preserveLastN | 5 | Recent messages left unchanged |
preserveSystemPrompt | true | Keep the first system message |
minMessagesForCompaction | 10 | Below this, nothing is compacted |
summaryPromptTemplate | built-in | Summary prompt, with a {messages} placeholder |
toolResultPruningEnabled | false | Shorten tool results before summarizing |
toolResultPruningThreshold | 500 | Token size above which a tool result is shortened |
summarizerModel | null | Stored only. The compactor uses the client you pass. |
maxSummaryTokens | 500 | Stored only. The summary length is not limited. |
CompactionResult
| Accessor | Meaning |
|---|---|
messages() | The new message list |
compactionPerformed() | False when nothing changed |
originalMessageCount(), compactedMessageCount(), summarizedMessageCount() | Message counts |
originalTokens(), compactedTokens() | Token estimates before and after |
summary() | The summary text, or null |
tokenReductionRatio(), messageReduction() | Derived figures |
Context graph
AgentContextManager records snapshots of an agent's context and traces of its decisions.
Turn it on for an agent
import com.tnsai.context.decision.impl.InMemoryDecisionTraceStore;
import com.tnsai.context.snapshot.impl.InMemorySnapshotStore;
agent.enableContextGraph(new InMemorySnapshotStore(), new InMemoryDecisionTraceStore());
agent.getContextManager().ifPresent(cm -> cm.getRecentDecisions(10));enableContextGraph finds the manager through a service file in tnsai-intelligence. Without that module on the classpath, it logs a warning and does nothing, and getContextManager() stays empty.
Once it is on, each chat call:
- starts a conversation if none is open;
- records a decision trace with action
CHAT, the message as trigger and the reply or error as outcome.
agent.stop() ends the conversation. Tool calls and actions are not traced.
Use it directly
import com.tnsai.context.integration.AgentContextManager;
AgentContextManager contextManager = new AgentContextManager(
"agent-001",
new InMemorySnapshotStore(),
new InMemoryDecisionTraceStore());Snapshots
A snapshot holds the tracked entities, the context variables and the current conversation id. diffSnapshots(beforeId, afterId) compares two of them.
Agent has no getBeliefs() / getDesires() / getIntentions(). Use the trigger-only snapshot, or pass application-owned BDI lists if you maintain them yourself.
package com.example.tnsai.docs;
import com.tnsai.context.integration.AgentContextManager;
import com.tnsai.context.snapshot.ContextSnapshot;
import com.tnsai.context.snapshot.SnapshotDiff;
import com.tnsai.context.snapshot.SnapshotTrigger;
import java.util.List;
import java.util.Optional;
public final class ContextSnapshotExample {
private ContextSnapshotExample() {}
public static ContextSnapshot capture(AgentContextManager contextManager) {
return contextManager.createSnapshot(SnapshotTrigger.PRE_DECISION);
}
public static Optional<SnapshotDiff> recentDiff(
AgentContextManager contextManager,
String beforeId,
String afterId
) {
List<ContextSnapshot> recent = contextManager.getRecentSnapshots(10);
return recent.isEmpty()
? Optional.empty()
: contextManager.diffSnapshots(beforeId, afterId);
}
}createSnapshot takes a SnapshotTrigger such as PRE_DECISION, POST_DECISION, MANUAL or CHECKPOINT. findSnapshot(id) and getRecentSnapshots(limit) read them back.
Decision traces
executeWithTrace runs an operation and records it:
String reply = contextManager.executeWithTrace(
"Summarize report",
() -> agent.chat("Summarize this..."));It takes a snapshot before and after, runs the operation and saves a trace with the outcome. If the operation throws, the trace records the failure and the exception is thrown again.
A five-argument overload also takes suppliers of Belief, Desire and Intention lists. The first intention's task description becomes the trace action, in upper case with underscores. The two-argument form records no action, so findDecisionsByAction does not find its traces.
createTrace(DecisionTrace.Builder) saves a trace you build yourself.
Query traces
| Method | Returns |
|---|---|
getRecentDecisions(limit), getAllDecisions() | This agent's traces |
findDecisionsInCurrentConversation() | Traces in the open conversation, empty when none is open |
findDecisionsByAction(action) | Traces with that action |
findSuccessfulDecisions(), findFailedDecisions() | Traces by outcome |
findTrace(id), explainDecision(id) | One trace, or its explanation text |
findPrecedents(trace, threshold) | Traces similar to the given one |
summarizeRecentDecisions(limit) | One summary line per recent trace |
The action, outcome and precedent queries search the whole store, not only this agent's traces. With the in-memory store, a precedent is scored on two things: the same action and the same trigger type.
Entities, variables and conversations
| Method | Meaning |
|---|---|
trackEntity(entity), untrackEntity(id), getTrackedEntities(), clearTrackedEntities() | Entities included in snapshots |
setVariable(key, value), getVariable(key), getVariable(key, type), removeVariable(key), clearVariables() | Variables included in snapshots |
startConversation(), startConversation(id) | Open a conversation and take a CONVERSATION_START snapshot |
ensureConversation() | Return the open conversation id, or open one |
endConversation() | Take a CONVERSATION_END snapshot and close the conversation |
getCurrentConversationId() | The open conversation, if any |
Statistics and cleanup
getDecisionCount(), getSnapshotCount(), getTraceStatistics() and getSnapshotStatistics() report store sizes. cleanupOldTraces(instant) and cleanupOldSnapshots(instant) delete entries older than the cutoff and return how many were deleted.
Sessions
AgentSession records one conversation. You add the turns yourself; agents do not write sessions.
import com.tnsai.session.AgentSession;
AgentSession session = AgentSession.start("code-assistant", "/path/to/project");
session.addUserMessage("Fix the authentication bug")
.addAssistantMessage("I'll look at the auth module.")
.addToolCall("readFile", Map.of("path", "Auth.java"))
.addToolResult("readFile", "public class Auth { ... }")
.addMetadata("model", "gpt-4o")
.addTags("security", "bug-fix");
session.success();fail(error) stores the error under the metadata key error. cancel() and complete(outcome) end the session with another outcome. The outcome starts as IN_PROGRESS; the others are SUCCESS, FAILED, CANCELLED, TIMEOUT and MAX_ITERATIONS.
| Method | Returns |
|---|---|
getTurns(), getTurnCount(), getLastTurn() | Turns |
getTurnsByType(TurnType) | Turns of one type: USER_MESSAGE, ASSISTANT_MESSAGE, SYSTEM_MESSAGE, TOOL_CALL, TOOL_RESULT, ERROR, CHECKPOINT |
getFirstUserMessage() | The first user message, or null |
getDuration() | Time from start to end, or to now while in progress |
isComplete(), isSuccess() | Outcome checks |
Store sessions
import com.tnsai.session.SessionStore;
SessionStore store = SessionStore.file(".tnsai/history/");
store.save(session);
Optional<AgentSession> loaded = store.load(session.getId());
List<AgentSession> recent = store.findRecent(10);FileSessionStore writes one JSON file per session in a folder per date, plus an index.json. new FileSessionStore(path, maxAgeDays) deletes sessions older than maxAgeDays when it is created and when you call evictExpired().
Other queries: findByAgent, findByProject, findByTag, findByOutcome, findByDate, findByDateRange, listSessionIds, count, exists, delete and clear.
Search sessions
SearchableSessionStore.file(path) adds text search over turn contents:
import com.tnsai.session.SearchableSessionStore;
import com.tnsai.session.SessionQuery;
SearchableSessionStore store = SearchableSessionStore.file(".tnsai/history/");
SessionQuery query = SessionQuery.builder()
.text("authentication bug")
.agent("code-assistant")
.tags("security")
.outcome(SessionOutcome.SUCCESS)
.lastDays(7)
.limit(20)
.build();
List<SessionSearchResult> results = store.search(query);All filters must match, and a session must have every tag listed. limit defaults to 50. Text matching ignores case unless caseSensitive(true) is set.
SearchMode | Matches |
|---|---|
ANY (default) | Any of the words |
ALL | All of the words, in the order given |
EXACT | The exact phrase |
REGEX | The text as a regular expression, at most 500 characters |
A regular expression from untrusted input can take a long time to match. Prefer the other modes for such input.
SessionQuery.text(text) and SessionQuery.recent(limit) are shortcuts. findSimilar(sessionId, limit) and findByToolUsage(toolName) are also available.
Extract knowledge
LLMKnowledgeExtractor asks an LLM for reusable knowledge in a session:
import com.tnsai.session.extraction.ExtractionType;
import com.tnsai.session.extraction.KnowledgeExtractor;
import com.tnsai.session.extraction.LLMKnowledgeExtractor;
KnowledgeExtractor extractor = new LLMKnowledgeExtractor(llmClient, 0.5);
List<ExtractedKnowledge> all = extractor.extract(session);
List<ExtractedKnowledge> decisions = extractor.extract(session,
EnumSet.of(ExtractionType.DECISION, ExtractionType.LEARNING));It makes one LLM call per type, so five calls for all types. Turns longer than 2,000 characters are cut. Items below the minimum confidence are dropped; the default is 0.3. An answer that cannot be parsed gives no items for that type, with no error. withMinConfidence(value) returns a copy with another minimum.
ExtractionType | Key | Meaning |
|---|---|---|
SOLUTION | problem-solution | A problem and how it was solved |
PATTERN | pattern | A reusable code or design pattern |
DECISION | decision | A design decision with its rationale |
LEARNING | learning | A lesson learned |
ANTIPATTERN | antipattern | An approach to avoid |
An ExtractedKnowledge record has content, summary, problem, codeSnippets, relatedFiles, tags, confidence and extractedAt. isHighConfidence() means 0.8 or more.
Store knowledge
import com.tnsai.session.extraction.KnowledgeStore;
KnowledgeStore store = KnowledgeStore.file(".tnsai/knowledge/"); // or KnowledgeStore.inMemory()
store.saveAll(all);
List<ExtractedKnowledge> solutions = store.listByType(ExtractionType.SOLUTION);
List<ExtractedKnowledge> found = store.search("connection pool");The file store writes one JSON file per item in a folder per type. Other methods: load, delete, listBySession, listRecent, findSimilar, count, countByType, getStats and clear.
Consolidate memory
MemoryConsolidationPipeline extracts knowledge from a session, stores it and summarizes the conversation:
import com.tnsai.session.consolidation.ConsolidationResult;
import com.tnsai.session.consolidation.ForgettingCurve;
import com.tnsai.session.consolidation.MemoryConsolidationPipeline;
MemoryConsolidationPipeline pipeline = MemoryConsolidationPipeline.builder()
.knowledgeExtractor(extractor)
.knowledgeStore(store)
.contextCompactor(new LLMContextCompactor(summarizerClient))
.compactionConfig(new CompactionConfig())
.forgettingCurve(ForgettingCurve.EXPONENTIAL)
.minKnowledgeConfidence(0.5)
.build();
ConsolidationResult result = pipeline.consolidate(session);Only knowledgeExtractor is required. consolidate runs these steps:
- Extract knowledge and keep items at or above
minKnowledgeConfidence(default 0.5). - With a store and a curve other than
NONE, delete stored items whose decayed confidence is below 0.1. Only the 100 most recent items are checked. - Save the kept items to the store, if there is one.
- With a compactor and a config, compact the session turns and keep the summary.
The session itself is not changed. A failure in one step is logged and the other steps still run.
ConsolidationResult has sessionId, summary, extractedKnowledge, knowledgeCount(), hasKnowledge(), originalMessageCount, consolidatedMessageCount and duration.
ForgettingCurve | Decayed confidence after d days |
|---|---|
NONE (default) | Unchanged |
LINEAR | confidence × max(0, 1 − 0.01 d) |
EXPONENTIAL | confidence × e^(−0.05 d) |
Consolidate automatically
AutoConsolidationManager runs the pipeline when a trigger fires:
import com.tnsai.session.consolidation.AutoConsolidationManager;
import com.tnsai.session.consolidation.ConsolidationTrigger;
AutoConsolidationManager manager = AutoConsolidationManager.builder()
.pipeline(pipeline)
.triggers(Set.of(ConsolidationTrigger.MESSAGE_COUNT, ConsolidationTrigger.ON_SESSION_END))
.messageCountThreshold(50)
.build();
ConsolidationResult result = manager.onMessageAdded(session, estimatedTokens); // after each message
manager.onSessionEnd(session); // when the session ends| Trigger | Fires when | Builder setting |
|---|---|---|
MESSAGE_COUNT | onMessageAdded has been called this many times since the last consolidation | messageCountThreshold, default 50 |
TOKEN_LIMIT | estimatedTokens is above the given percentage of the budget | tokenBudget, default 8192; tokenThresholdPercent, default 80 |
ON_SESSION_END | onSessionEnd is called | |
TIME_INTERVAL | Never. The manager does not handle it. |
The default triggers are MESSAGE_COUNT, TOKEN_LIMIT and ON_SESSION_END. Both methods return null when nothing ran. A trigger that fires while a consolidation is running is skipped. A failed consolidation is logged, returns null and does not reset the message count.