Conversation history and context
As a chat grows, how the UI, storage, and model context each stay within bounds — and how that differs from memory across sessions.
Source docs/en/site/chat-compression.md
In user language: This article is for developers and operators. It explains how Cadau handles growing conversation history at three layers — UI display, database persistence, and model context — and how that splits from a work agent’s memory across conversations. Product language is in docs/core-mechanisms/智能体记忆.md; where memory files land is in Where work-agent memory lives.
Date: 2026-07-16 (tool-loop checkpoint added) Related code: backend/internal/api/handlers/chat.go, backend/internal/chatsvc/chat_context_roll.go, backend/internal/store/chat.go, backend/internal/agentmemory/flush.go
Takeaway
Cadau treats “the conversation got long” with three separate layers:
| Layer | Strategy | Are old rows deleted? |
|---|---|---|
| Database | Persist everything in chat_sessions + chat_messages | No (no automatic TTL) |
| Frontend | Paginated load; “load earlier messages” on demand | — |
| Model context | Rolling summary: earlier rounds compress into a summary; the latest rounds stay verbatim | No (only what is sent to the model) |
A work agent also flushes the fragment about to roll out into a daily note before compaction, and may extract long-term memory, so important facts are not lost with the summary. When the user wants another conversation’s original text, that is on-demand retrieval (conversation_search / conversation_get) — the current round does not stuff all history in. See Looking at past conversations.
Three-layer overview
flowchart LR
subgraph UI["Frontend display"]
A1[Latest 100 messages]
A2[cursor loads earlier]
end
subgraph DB["Database"]
B1[chat_messages in full]
B2[chat_sessions.context_summary]
end
subgraph LLM["Model call"]
C1[History summary]
C2[Recent verbatim]
C3[Current user message]
end
UI --> DB
DB --> LLMThese are not the same thing: the user can see the full history (paginated), messages in the database are not deleted because of context compaction, but the payload sent to the model is kept within a character budget.
1. Persistence: the database keeps everything
Schema
| Table / field | Content |
|---|---|
chat_sessions | Conversation metadata (title, owning user / workspace / agent, etc.) |
chat_sessions.context_summary | Rolling-summary body (model context only) |
chat_sessions.verbatim_since_created_at | Time anchor of the first message kept verbatim |
chat_sessions.verbatim_since_msg_id | ID anchor of the first message kept verbatim |
chat_messages | Each user / assistant message body, tool_trace_json, attachment_ids_json |
Schema: backend/internal/db/schema_postgres.sql (SQLite: schema.sql). chat_messages.session_id is a foreign key ON DELETE CASCADE; deleting a conversation deletes its messages.
Current policy
- No time-based auto-clean, archive, or TTL.
- History grows until a user or admin deletes the conversation, or edit-and-resend truncates later messages (
POST /api/v1/chat/truncate).
Related APIs
| Method | Path | Use |
|---|---|---|
| GET | /api/v1/chat/history | Read messages for a conversation; limit + cursor |
| GET | /api/v1/chat/sessions | Conversation list, paginated |
| DELETE | /api/v1/chat/sessions/{id} | Delete a conversation (cascade messages) |
| POST | /api/v1/chat/truncate | Delete that user message and everything after it |
2. Frontend: paginated load and a bounded window
The frontend loads on demand via GET /chat/history. It does not pull everything at once:
- Default: the latest page first (clients often use
limit=50; server default/max is on the API). - Response includes
older_cursor/newer_cursor/at_live_edge(next_cursorstill means the older direction for compatibility). - Bounded window: memory keeps about a hundred messages near the viewport; load both ways and recycle the far side. Mechanism: Windowed conversation message loading (WeChat-style).
- Question navigation:
GET /chat/history/turnsis a light index, decoupled from the message window. - The conversation list is paginated too (
GET /chat/sessions, defaultlimit=20).
Implementation: client/web/src/api/chat.ts, client/web/src/chatMessageWindow.ts, client/web/src/useChatSessions.ts.
The UI does not hold every bubble body just because a conversation has thousands of rounds. The database still has the full record.
3. Model context: rolling summary (the core)
What the context window actually limits is what goes into the model on each LLM call. Entry points:
PrepareLLMConversationWithRollingContext—backend/internal/chatsvc/chat_context_roll.go- On send, first
ListChatMessages(..., 8000)from the DB, then rolling compact —backend/internal/api/handlers/chat.go
Defaults
Environment variables (also overridable in mindlink.json → chat_context):
| Parameter | Environment variable | Default | Meaning |
|---|---|---|---|
| Total character budget | CHAT_CONTEXT_MAX_RUNES | 120000 | Rough upper bound for context |
| Reply reserve | CHAT_CONTEXT_REPLY_RESERVE_RUNES | 8000 | Reserved for model output |
| Actual input budget | — | ≈ 112000 | max_runes - reply_reserve |
| Summarize threshold | CHAT_CONTEXT_SUMMARIZE_THRESHOLD_PCT | 88 | Start compacting past 88% of budget |
| Minimum verbatim messages | CHAT_CONTEXT_MIN_VERBATIM_MESSAGES | 6 | Keep at least the latest 6 messages verbatim |
| Messages per compact batch | CHAT_CONTEXT_SUMMARIZE_BATCH_MESSAGES | 4 | Roll the oldest 4 into the summary each time |
| Derive window from model | CHAT_CONTEXT_RESOLVE_MAX_FROM_MODEL | false | If true, derive max_runes from the model token window at startup |
Budget math: Config.ChatContextInputBudgetRunes() — backend/internal/config/config.go.
Flow
flowchart TD
A[User sends a new message] --> B[Read up to 8000 history rows from DB]
B --> C{Estimated characters over threshold / hard cap?}
C -->|no| D[Summary + recent verbatim + current message → call model]
C -->|yes| E[Take earliest batch from priorRows]
E --> F{Work agent?}
F -->|yes| G[FlushBeforeCompaction: daily note + optional long-term extract]
F -->|no| H[Skip flush]
G --> I[LLM mergeRollingSummary into context_summary]
H --> I
I --> J[Update verbatim_since anchors and write DB]
J --> C
D --> K[Return reply + context_budget]Compaction details
- Take the verbatim subset from the
verbatim_since_*anchors; content before the anchors is already incontext_summary. - Estimate
system prompt + conversation bodycharacters (estimateLLMPayloadRunes). - If over the threshold (default 88%) or the hard cap:
- Take a batch from the head of priorRows (default 4); - Work agent: agentmemory.FlushBeforeCompaction first; - Call LLM mergeRollingSummary into the summary; - Update context_summary and verbatim_since_*, write chat_sessions.
- Loop at most 48 times until under the threshold or it cannot roll further (still keep
min_verbatimverbatim messages). - Prefix the summary onto the system prompt (background, not a standing order):
`` 【历史对话摘要(背景,不是本轮口令;较早轮次已压缩)】 …summary body… 本轮以用户最新一条消息为准;摘要里的词不要当成必须继续执行的任务。 ``
The merge prompt drops finished side threads so leftover keywords do not become tasks. The current user message is wrapped separately; see Cursor conversation prompts vs Cadau.
Important boundaries
| Behavior | Notes |
|---|---|
| Does not delete DB rows | Compaction only changes the payload sent to the model; chat_messages originals stay |
| UI still shows them | “Load earlier messages” still shows full text of rounds already covered by the summary |
| Cannot roll further | If still over budget at min_verbatim, the call continues with over-budget context (log chat_context_cannot_roll_more) |
| Anchor recovery | If anchors make verbatim empty, clear the summary and anchors and recompute from full history (chat_context_anchor_recover) |
Frontend feedback
Chat responses include context_budget: used_runes, limit_runes, used_pct. Web shows a context-usage ring (client/web/src/api/chat.ts → parseContextBudget).
4. Work agent: rescue memory before compaction
If this is a workspace “My agent” conversation, call FlushBeforeCompaction (backend/internal/agentmemory/flush.go) before rolling into the summary:
- Daily note: append the fragment to
memory/YYYY-MM-DD.md(title includes “context compaction archive”). - Long-term extract (optional): if
memory_auto_extractis true (on by default), callExtractFromBatchintomemory/entries/*.md.
Matching product mechanism: docs/core-mechanisms/智能体记忆.md — “Archive before context compaction”.
Toggles live on the agent config_json:
MemoryAutoAppend *bool `json:"memory_auto_append,omitempty"` // default off
MemoryAutoExtract *bool `json:"memory_auto_extract,omitempty"` // default on
Defined in backend/internal/runtimews/agentconfig.go.
5. Split from long-term memory
| Kind | Storage | Lifetime | Use |
|---|---|---|---|
| Conversation history | DB chat_messages | Until the conversation is deleted / truncated | Working memory: full transcript of this conversation; other conversations are retrieved on demand |
| Rolling summary | DB chat_sessions.context_summary | Updates with the conversation | Model context only |
| Daily notes | Disk memory/YYYY-MM-DD.md | Today/yesterday injected, then fade | Compaction archive + short-lived context |
| Long-term memory | Disk memory/entries/*.md + MEMORY.md index | Across conversations | Preferences, decisions, project milestones, etc. |
A long conversation does not automatically become long-term memory. That takes the user tapping Remember, saying “remember / don’t forget” in chat, auto-extract before compaction, or the agent calling memory_write. To reread past conversation text (even if it was never written as memory), a work agent uses conversation_search / conversation_get.
Details: Where work-agent memory lives.
6. Other truncation and compact (not conversation rolling summary)
These are independent of conversation-level rolling summary, but also stop a single request’s context from ballooning.
6.1 Tool results: the main conversation is not truncated
Tool-loop returns written into model context are full text (runner.go). Frontend SSE display may still truncate very long output for the UI; that does not change what the model sees.
Rune caps for knowledge inject, attachment extract, and similar live in those modules; they are unrelated to the tool-loop checkpoint.
6.2 Inside the tool loop: LLM checkpoint summary only near budget
A multi-round tools loop from one user message (RunAgentToolLoop) can grow because of repeated fetches / file writes.
| Point | Approach |
|---|---|
| When to compact | Estimate current messages in runes vs ChatContextInputBudgetRunes(); trigger around 88% (hard threshold about 95%, aligned with conversation summary and Claude Code) |
| Why it used to fire too early | Old code triggered around 100KB bytes; Chinese JSON is about 3 bytes per character, so it compacted at about a third of budget |
| Main path | LLM “work so far” handoff summary only; no mechanical truncation of the main conversation |
| On failure | Keep the original and continue; do not fall back to clipping |
| Must keep | User’s main goal and unfinished work; upload_id / download_url / size_bytes |
| Recent verbatim | Keep the latest several tool round-trips in full |
| Code | context_compact.go, context_compact_llm.go; maybeCompactAgentToolContext |
Logs: chat_agent_context_compact_llm, chat_agent_context_compact_llm_failed, chat_agent_context_compact_skipped.
Split from conversation rolling summary: conversation summary compresses user/assistant history across rounds; this section compresses tool round-trips inside one round.
7. Troubleshooting and ops
Log keywords
| Log event | Meaning |
|---|---|
chat_context_summarized | Finished one rolling-summary batch |
chat_context_cannot_roll_more | At minimum verbatim, still over budget |
chat_context_anchor_recover | Bad anchors; summary reset |
chat_memory_flush_ok / chat_memory_flush_failed | Memory flush before compaction |
chat_llm_history | This conv message count, characters, budget |
chat_agent_context_compact_llm | Tool-loop LLM checkpoint summary succeeded |
chat_agent_context_compact_llm_failed | Checkpoint summary failed (keep original, no clip) |
chat_agent_context_compact_skipped | Skipped compact when no model is available |
Looking locally
- Database: inspect
chat_sessions.context_summary,verbatim_since_*, andchat_messagescounts. - Runtime memory: daily notes and entries under
{RUNTIME_DIR}/{agent-id}/memory/(locally oftenbackend/tmp/runtime/). - Config: environment variables or
mindlink.json→chat_context.
Known gaps (later)
| Item | Today | Direction |
|---|---|---|
| DB size | No auto archive / TTL | Ops: clean old conversations by workspace/user, or cold storage |
| Conversations over 8000 messages | DB read cap 8000; older messages skip rolling | Consider a higher cap or segmented summaries |
| Summary quality | Lossy LLM merge | Steer users to Remember, or tune extract |
| SQLite schema | Older chat_sessions may lack context_summary | Migration to match postgres schema |
| Multiple tool-loop checkpoints | Chained summary loss | Cursor-style self-summary; stronger structured “unfinished” fields |
8. Related
| Document | Notes |
|---|---|
| Where work-agent memory lives | Memory file tree vs DB split |
| core-mechanisms/智能体记忆.md | Product mechanism: layers, write, inject |
| 产品规格.md §3.5 | Workspace file model |
| 后端与Web设计.md | Conversation / history API as product |