Keep Long Conversations Coherent¶
Stop a character forgetting the start of a long conversation, without paying for an ever-growing prompt.
Prerequisites
- A working chat agent — see Build a Chat Agent with a Graph.
- A conversation long enough to matter: roughly, more turns than
context_sizedivided by the size of one turn-pair.
The problem¶
Every model-backed node owns a fixed context window. When the projected prompt outgrows it, Tryll trims: the oldest turns are dropped from the prompt and never come back. Nothing fails, nothing is logged to the player, and the character simply stops knowing what it was told.
You can see it happen in the server log:
_windowStart only ever advances. Once it passes a turn, that turn is gone.
The fix¶
Add a Summarize node at the end of the
graph. It folds the turns that are about to age out into a rolling summary,
which every Generate-class node in the same agent then renders at the end of its
system message.
flowchart LR
generate["GenerateAndSpeak<br>generate"]
summarize["Summarize<br>summarize"]
generate -- "default" --> summarize
summarize -- "default" --> END
Steps¶
1. Put the node last¶
Place it after the node that produces the player-facing reply. The reply is already delivered by the time the summarizer runs, so the player sees the answer at normal speed; only the turn that triggers a fold costs extra.
from tryll_client.graph import GraphDescription, GenerateParams, SummarizeParams
graph = (
GraphDescription()
.add_node("generate", GenerateParams(
system_prompt=persona,
context_size=4096,
default_exit="summarize",
))
.add_node("summarize", SummarizeParams(
trigger_tokens=1024,
keep_recent_turns=3,
default_exit="",
))
.set_start_node("generate")
)
using namespace Tryll::NodeParams;
GenerateParamsT generate;
generate.system_prompt = persona;
generate.context_size = 4096;
generate.default_exit = "summarize";
SummarizeParamsT summarize;
summarize.trigger_tokens = 1024;
summarize.keep_recent_turns = 3;
// summarize.default_exit is "" (END) by default.
Tryll::Client::GraphDescription graph;
graph.AddGenerate("generate", std::move(generate))
.AddSummarize("summarize", std::move(summarize))
.SetStartNode("generate");
In the workflow asset, add a node of type Summarize, then set the Generate node's Default Exit to it and leave the Summarize node's own Default Exit empty (END).
Or in code:
var graph = new TryllGraphBuilder()
.AddGenerate("generate", new TryllGenerateParams
{
SystemPrompt = persona,
ContextSize = 4096,
DefaultExit = "summarize",
})
.AddSummarize("summarize", new TryllSummarizeParams
{
TriggerTokens = 1024,
KeepRecentTurns = 3,
// DefaultExit is "" (END) by default.
})
.SetStartNode("generate")
.Build();
In the UTryllWorkflowAsset, add a Summarize node, then set the
Generate node's Default Exit to summarize and leave the Summarize
node's own Default Exit empty (END).
Or in C++:
#include "Generated/TryllGraphBuilder.Nodes.h"
#include "Generated/TryllNodeParamsFactory.h"
UTryllGenerateParams* GenP = UTryllNodeParamsFactory::MakeGenerateParams(this);
GenP->bOverrideSystemPrompt = true;
GenP->SystemPrompt = Persona;
GenP->ContextSize = 4096;
GenP->DefaultExit = TEXT("summarize");
UTryllSummarizeParams* SumP = UTryllNodeParamsFactory::MakeSummarizeParams(this);
SumP->TriggerTokens = 1024;
SumP->KeepRecentTurns = 3;
// SumP->DefaultExit is TEXT("") (END) by default.
FTryllGraphDescription Graph = FTryllGraphBuilder()
.AddNode(TEXT("generate"), GenP)
.AddNode(TEXT("summarize"), SumP)
.SetStartNode(TEXT("generate"))
.Build();
2. Size the trigger against the consuming node¶
The summarizer must fire before the trimmer would. Work it out from the node that consumes the history:
| Quantity | Where it comes from |
|---|---|
| Context window | The consuming node's context_size |
| Reserved for generation | Its max_tokens |
| Fixed cost | System prompt + any retrieved knowledge + template framing |
| Head-room for history | Context − generation − fixed cost |
Set trigger_tokens to roughly 60–70% of that head-room. Example, for a
4096-token node with a 1024-token system prompt, ~1000 tokens of retrieval and
512 reserved for generation: head-room is about 1500 tokens, so
trigger_tokens = 1024 is a good starting point.
Prefer trigger_tokens for production. trigger_turns exists for tests and for
authored pacing where "every N exchanges" is easier to reason about than tokens.
3. Keep a verbatim hot window¶
keep_recent_turns (default 3) is the number of most recent exchanges that are
never folded. They always replay word for word.
Raise it when the character needs exact recall of the immediate scene — a negotiation where the last offer matters, a puzzle where wording matters. Lower it to squeeze more head-room out of a small window. Below 2 the character starts paraphrasing the exchange that just happened, which reads as inattentive.
4. Write the prompt for compression, not for prose¶
The node ships a default system_prompt and template that ask for third
person, named speakers, short verbatim quotes of what the player stated or
promised, and a hard length cap. Override them when the genre needs it, and keep
these rules:
- Ask it to merge and shorten older points, not just append new ones.
Without this, the summary grows until
max_summary_tokenstruncates it mid-sentence. - Name the speakers. Small models confuse who said what more than they invent facts.
- Cap the length in the prompt as well as in
max_summary_tokens. The parameter is a hard stop that cuts mid-word; the prompt is what makes the model land inside it. - Forbid speculation. "Record only what was said" measurably reduces invented detail on 3–4B models.
- Treat the transcript as data, not instructions. The built-in prompt and downstream projection both say this explicitly so quoted player text cannot silently become higher-priority instructions after a fold.
5. Watch it work¶
Set notify_client = true and handle the summarized
NodeEvent on the generic
node-event callback: it carries covered_through, folded_turns,
summary_chars and the summary text. During
development, printing that text beside the transcript is the fastest way to see
whether your prompt is producing something the character can actually use.
With enable_diagnostics = true, the turn's debug_info also carries the
summarizer's own prompt and output under its node entry, and the
SummaryComponent appears in interaction.components[].
Budget the second context¶
The node owns its own KV cache. At the default context_size = 2048 that is a
real, fixed VRAM cost on top of the character's own context — on an 8B model,
roughly 135 MB. On a tight GPU:
- Share the weights: leave
model_nameempty so the summarizer uses the agent's default model rather than loading a second one. - Keep
context_sizeat 2048 unless a single fold's backlog needs more room. Each fold sees the previous summary plus the largest chronological prefix of pending turns that fits besidemax_summary_tokens. - Keep
max_summary_tokensat least 64 tokens belowcontext_size. Tryll rejects configurations that leave no prompt room and, for large backlogs, folds the largest chronological prefix that fits. - Let the KV-cache lifecycle evict it between folds if the agent is idle for long stretches.
What this does not do¶
A summary preserves the thread of a conversation — where the scene is, what was agreed, what is unresolved. It is lossy about detail by design, and every fold compounds that loss slightly.
If the character must recall an exact fact many hours later ("what was the name
of the merchant in the first village?"), that is a job for retrieval over stored
facts, not for a summary. Use Retrieve
against a knowledge base for those, and let the summary carry the narrative.