Chat Session Management Guide
Hey there! This guide explains how TruvaG3's example chat agents keep a conversation going across multiple requests.
If you've ever wondered things like:
- "Where does the
session_idcome from?" - "What exactly gets stored in Redis?"
- "How does the model see previous turns?"
- "What happens if a session expires?"
- "How does the DevOps agent resume the same chat after HITL approval?"
you are in the right place.
This guide is based on the real implementations in:
It is meant to be friendly to someone seeing this system for the first time, while still being precise enough for deeper debugging and design work.
When To Read This Guide
Read this guide when your question is mostly about the session lifecycle:
- how sessions are created
- how they are stored
- how messages are appended
- how sessions are listed, renamed, and deleted
- how SSE requests create or resume sessions
- how HITL resume attempts to continue the same conversation in the DevOps example
If your question is instead:
- "How does TruvaG3 compact long chat history?"
- "What are Tier 1 and Tier 2 conversation-history protection?"
- "How does the framework prepare
<conversation_history>for prompts?"
then the better guide is CONVERSATION_HISTORY_GUIDE.md.
The short version is:
- this guide = where conversation history comes from and how the app maintains it
CONVERSATION_HISTORY_GUIDE.md= how orchestration prepares that history for prompt use
Why This Guide Exists
This repo already had good related documentation, but the story was spread across several places:
CHAT_AGENT_GUIDE.mdexplains the overall chat-agent patternCONVERSATION_HISTORY_GUIDE.mdexplains prompt-side history preparation and compactionAGENT_DEVELOPMENT_GUIDE.mdsummarizes the session-store API
What was missing was a single guide that says:
"Show me the whole thing, from HTTP request to Redis storage to orchestration context to follow-up turns."
That is what this guide is for.
The Big Picture
Think of session management here as a three-part job:
- The agent needs a place to store the conversation.
- The streaming and query handlers need to attach new messages to the right session.
- The orchestrator needs the stored messages converted into prompt context the model can understand.
Here is the full flow in one picture:
Client
|
| POST /chat/stream {session_id?, message}
v
SSE Handler
|
| create session if needed
| or load existing session
| append current user message
v
Redis Session Store
|
| return stored message history
v
Chat Agent
|
| convert []Message -> []ConversationTurn
| attach metadata:
| - conversation_turns
| - conversation_id
v
Orchestrator
|
| prepare <conversation_history>
| compact/budget if needed
v
LLM + Tools
|
| produce response
v
Chat Agent
|
| append assistant message back into session
v
Redis Session Store
For the DevOps chat agent, there is one extra path:
orchestration interrupted for approval
|
v
checkpoint stores session context
|
v
/hitl/resume/{checkpoint_id}
|
v
same session ID reused and the conversation resumed if that session still exists
The Files We Will Keep Referring To
Both example agents follow the same design, so we mainly need to understand a handful of files:
- Session store:
- Session-related HTTP handlers:
- Streaming entrypoint:
- Orchestrator integration:
- DevOps-only HITL resume flow:
The framework-side history preparation path lives in:
orchestration/pipeline_hooks.goorchestration/conversation_history_processor.goorchestration/metadata_keys.gocore/interfaces.go
Start With The Short Answer
If you are skimming, this is the behavior today:
- Both example agents store chat sessions in Redis/Valkey DB 0 under
truvag3:v1:<deployment>:sessions:*. - A session holds metadata plus a bounded list of messages.
- Sessions live for 48 hours and keep at most 50 messages.
POST /chat/sessioncreates a session explicitly.POST /chat/streamcan also create a session automatically if nosession_idis provided.- Each incoming user message is saved before orchestration begins.
- The full retained message list is converted into
ConversationTurnvalues and passed into orchestration metadata. - The session UUID is deliberately mapped to canonical
conversation_id, including on the first turn when no prior history exists. - The orchestration layer prepares prompt-safe
conversation_historyfrom those turns. - The assistant response is saved back into the same session.
- The DevOps example also carries session and conversation context across HITL interruption and resume.
If you want the deeper version, keep going.
Step 1: Agent Startup Creates The Session Store
When either chat agent starts, it creates a Redis-backed SessionStore.
That happens in:
Both examples use the same constructor settings:
- Topology: the application's resolved standalone, Sentinel, or cluster connection
- Redis/Valkey DB: 0
- Namespace:
truvag3:v1:<deployment>:sessions - TTL:
48*time.Hour - Max messages:
50
That choice matters for two reasons:
- sessions are isolated from service discovery by versioned subsystem keys rather than numbered databases
- the examples intentionally keep chat history bounded in both time and size
If neither REDIS_URL nor a valid structured TRUVAG3_REDIS_* topology is
configured, startup fails. In these examples, session storage is a required
part of the chat-agent design.
Step 2: What A Session Looks Like
Each session record stores:
iduser_idtitlecreated_atupdated_atmessagesmetadata
Each message stores:
idrolecontenttimestampmetadata
This is intentionally simple. A session is just a conversation folder, and messages is the transcript inside it.
Step 3: Where The Data Lives In Redis
The implementation uses three kinds of Redis data.
The session record
The main session object is saved under the session ID using the namespaced Redis client.
So conceptually it lives under a key like:
truvag3:v1:<deployment>:sessions:<session_id>
The per-user session index
If a session has a user_id, the session ID is also added to a sorted set:
truvag3:v1:<deployment>:sessions:index:<user_id>
The score is the session's UpdatedAt.UnixMilli().
That gives the app a clean way to list a user's sessions in most-recent-first order.
The active session counter
The store also increments a simple Redis counter:
truvag3:v1:<deployment>:sessions:active_session_count
This is used by the health endpoint.
Advanced note: This counter is incremented on explicit create and decremented on explicit delete, but Redis key expiry does not automatically decrement it. So it is useful operationally, but it is not a perfectly reconciled count of live TTL-valid sessions.
Step 4: How A New Session Gets Created
There are two ways a session begins.
Path A: explicit session creation
POST /chat/session
This path is great for UIs that want to create a chat first and then send messages later.
The handler:
- reads
X-User-ID - optionally decodes request metadata
- calls
sessionStore.Create(userID, metadata) - returns
session_id,created_at, andexpires_at
Path B: implicit session creation during streaming
POST /chat/stream
If the request body does not include a session_id, the SSE handler creates a session on the fly and immediately emits an SSE session event back to the client.
That makes the frontend flow nice and simple:
- send a message
- receive a session ID
- keep using it for later turns
Step 5: What Happens On A Streaming Chat Request
This is the path most people care about, so let's walk it slowly.
Imagine the client sends:
{
"session_id": "optional-existing-id",
"message": "What changed in the cluster today?"
}
The SSE handler does the following:
1. Validate the request
It checks:
- method is
POST - body is valid JSON
messageis present
2. Resolve the session
If session_id is empty:
- create a new session
- send SSE event
sessionwith the new ID
If session_id is present but not found:
- create a replacement session
- send SSE event
sessionwith the new ID
This is a very important behavior difference from the non-streaming /query path:
/chat/streamself-heals by creating a replacement session/querydoes not
3. Append the user message
Before orchestration even starts, the handler writes the incoming user message into the session.
That means the current user turn is already part of stored history when the agent later asks for session history.
4. Start orchestration
The handler calls ProcessWithStreaming(ctx, sessionID, req.Message, callback).
That is where the session transcript becomes orchestration input.
Step 6: How Stored Messages Become Model Context
This is the bridge between "session management" and "conversation history preparation."
Inside ProcessWithStreaming(), both agents do the same core work:
- read the stored transcript with
sessionStore.GetHistory(sessionID) - convert
[]Messageinto[]core.ConversationTurn - attach those turns into metadata
The metadata keys are:
orchestration.MetadataConversationTurnsorchestration.MetadataConversationID
These examples deliberately use the same UUID for the application session and
framework conversation. They are not synonyms: session_id addresses the
Redis transcript and TTL lifecycle; conversation_id correlates framework
executions, traces, logs, and debug records. An application with different
session semantics can use different values.
Both agents also populate:
core.EnrichmentConversationHistory
as a formatted fallback string.
One subtle but important implementation detail:
- the conversion to
ConversationTurncopiesRoleandContent - it does not copy the original message timestamps or per-message metadata into the raw turn slice sent to orchestration
So the session record in Redis is richer than the turn list used for prompt history preparation.
Travel adds one more useful field when available:
user_id
That lets its user-memory hooks know which user the session belongs to.
DevOps adds one more important piece before orchestration:
- request mode in context
- session metadata in context
That extra context is what allows HITL checkpoints to remember which session the request belonged to.
Step 7: What The Orchestrator Does With That History
This is where CONVERSATION_HISTORY_GUIDE.md takes over in much more detail, but the important idea is simple.
The orchestrator sees metadata like:
conversation_turnsconversation_id
and feeds it into the shared ConversationHistoryPreparer.
The agent assigns conversation_id before checking whether history is empty.
This is why a first turn or delegation path with a valid session is correlated
even when there are no stored prior messages.
Both example agents explicitly inject a preparer built with:
BuildCompactionEnabledConversationHistoryPreparer(...)
So the framework does not just dump raw history into the prompt. It prepares it carefully:
- older turns can be compacted into a summary
- recent turns stay verbatim
- the whole block is kept within a token budget
- if still too large, older turns may be dropped, long turns may be elided, and the final text may be truncated
The result becomes the prompt enrichment that the model sees as conversation history.
That is the key separation of responsibilities:
- session store = where turns are persisted
- conversation-history processor = how retained turns are shaped for prompt use
Step 8: How The Assistant Response Gets Saved
After orchestration finishes successfully, both agents append one more message to the same session:
role = "assistant"content = result.Responsetimestamp = time.Now()
They also attach useful metadata such as:
request_idtools_usedconfidenceduration_ms- token counts
The travel agent also stores usage_by_phase.
This means the next user turn sees both:
- what the user asked before
- what the assistant already answered
That is exactly what makes later follow-up requests feel coherent.
Step 9: What Happens On The Next Turn
The next time the client sends a message with the same session_id, the cycle repeats:
- load session
- append new user message
- read retained transcript
- convert transcript to
ConversationTurns - prepare prompt-safe history
- run orchestration
- append assistant response
That is session continuity in these examples.
Step 10: How Listing, Renaming, And Deleting Work
Besides streaming, both examples expose standard session-management endpoints.
GET /chat/session/{id}
Returns session metadata:
session_idcreated_atupdated_atmessage_countmetadata
This does not return the full transcript.
GET /chat/session/{id}/history
Returns the current stored transcript:
session_idmessagescount
GET /chat/sessions
Lists sessions for a user and requires X-User-ID.
This uses the per-user sorted-set index and returns the most recently updated sessions first.
It also supports pagination:
offsetdefaults to0limitdefaults to20limitis capped at50
The store does one nice cleanup trick here:
- if the index still points at a session key that has already expired
- or if the session data is unreadable
- it removes that stale entry from the index asynchronously
That means list results stay healthy over time even though only the main session key has a TTL.
Each returned summary includes:
idtitlemessage_countcreated_atupdated_atpreview
PUT /chat/session/{id}/title
Updates the title and bumps UpdatedAt.
That matters because the session list ordering is based on update time.
POST /chat/session/delete
Deletes the session record and removes the session ID from the user's index.
Step 11: Titles, Previews, And Retention
There are a few nice usability details in the store.
Auto-title behavior
The first user message becomes the session title if the title is still empty.
The implementation caps it at 60 characters:
- first 57 characters
- then
...
Preview behavior
The list preview comes from the first user message found in the transcript.
It is capped at 100 characters:
- first 97 characters
- then
...
Sliding window behavior
Every time a message is appended, the store trims the transcript to the most recent 50 messages.
That means these examples optimize for recent continuity, not permanent archival.
TTL behavior
Every time the session is saved, Redis applies the configured TTL to the session key.
In practice, that means active conversations keep refreshing their session record while inactive sessions eventually age out.
Step 12: What Happens When A Session Expires
This is another place where it helps to know the exact behavior.
If the session key expires:
Get(sessionID)returnsnilGET /chat/session/{id}returns 404GET /chat/session/{id}/historyreturns 404PUT /chat/session/{id}/titlereturns not foundPOST /chat/session/deletereturns not found
For streaming:
POST /chat/streamcreates a replacement session and keeps going
For non-streaming /query:
- it does not create a replacement session
- the attempted message append simply fails quietly
- history lookup returns no turns
- orchestration still runs, but effectively without persisted session continuity
That distinction is worth remembering when debugging "why did this follow-up lose context?"
Step 13: How The DevOps Agent Resumes The Same Chat After HITL
This is the main behavior the DevOps example has that the travel example does not.
When the DevOps chat agent enters orchestration, it stores session-related context before the request runs.
If orchestration gets interrupted for approval:
- the SSE flow emits a
checkpointevent - the checkpoint carries framework-owned
conversation_idplus application metadata such assession_id
Later, when execution resumes through:
POST /hitl/resume/{checkpoint_id}- or
GET /hitl/auto-resume/{checkpoint_id}/stream
the handler:
- loads the checkpoint
- rebuilds resume context
- reads a valid
conversation_idfromcheckpoint.UserContext, withsession_idas the compatibility fallback for older local checkpoints - reuses that value as the session ID because this example maps the two concepts one-to-one
- creates a new session only if neither identity is available
- calls
ProcessWithStreaming()again using the original request
This is the crucial point:
the DevOps agent does not treat resume as a brand-new chat. It continues the same correlated conversation and, because of the example's explicit mapping, reuses that value to load the chat session.
There is one important edge case to know:
- the resume handlers create a new session only when the checkpoint has neither
a valid
conversation_idnor asession_id - if the checkpoint identity exists but that session record has already
expired, the handler still passes that same ID into
ProcessWithStreaming() - in that case the resumed execution keeps orchestration context from the checkpoint, but it does not recreate the expired session record automatically
That is what keeps approval-driven workflows coherent from the user's point of view.
Step 14: What About Agent-To-Agent Delegation?
Both examples also expose /query for non-streaming agent-to-agent use.
If a caller provides session_id and that session still exists:
- the handler tries to append the user turn
ProcessQuery()loads session history- the response is written back into the same session
If no session_id is provided:
- the request is stateless from the session-store point of view
If session_id is provided but the session no longer exists:
/querydoes not create a replacement session- history is effectively empty
- the orchestration still runs, but not with stored session continuity
So /query supports both:
- stateless delegation
- session-aware follow-up delegation
but it is stricter than /chat/stream about missing sessions.
Advanced Notes And Gotchas
Senior developers usually want a few sharp edges called out explicitly, so here they are.
The two examples duplicate the same session pattern
The code is nearly identical between travel and DevOps, but it is not yet extracted into one shared package.
So today this is a shared pattern, not a single reusable session library.
Prompt compaction only sees the retained window
The conversation-history processor can compact older turns, but only from the turns the session store still retains.
That means:
- Redis sliding-window retention happens first
- prompt-side compaction happens second
So if a turn already fell out of the 50-message session window, orchestration cannot compact it because it no longer has it.
Listing is user-scoped; direct lookup is session-ID-based
GET /chat/sessions is explicitly keyed by X-User-ID.
The direct session endpoints operate by session ID lookup. This guide is documenting current implementation behavior, not making a broader security claim.
active_session_count is approximate
It is useful for health and visibility, but TTL expiry does not automatically decrement it.
If you ever need an exact count of currently existing session keys, you would need a different reconciliation strategy.
A Good Mental Model To Keep
If you only want one sentence to carry around in your head, use this one:
A TruvaG3 chat session in these examples is a Redis-backed transcript that the agent updates on every turn, then converts into structured conversation history for orchestration.
And if you want the slightly longer version:
- the app owns persistence, session IDs, TTLs, listing, and resume behavior
- the orchestration layer owns prompt preparation, budgeting, and compaction
conversation_turnscarries prompt history, while canonicalconversation_idcorrelates framework executions independently of history
That separation is the design.