Lightweight conversation history compaction.
Provides utility functions for managing conversation message lists
without requiring an LLM call. For LLM-powered summarization,
see Nous.Plugins.Summarization.
Usage
messages = [msg1, msg2, msg3, ..., msg20]
# Keep last 10 messages, summarize the rest
compacted = Nous.Transcript.compact(messages, 10)
# Auto-compact: every 20 messages, keep last 10
compacted = Nous.Transcript.maybe_compact(messages, every: 20, keep_last: 10)
# Auto-compact: at 80% of token budget
compacted = Nous.Transcript.maybe_compact(messages,
token_budget: 128_000,
keep_last: 10
)
# Both triggers (whichever fires first)
compacted = Nous.Transcript.maybe_compact(messages,
every: 30,
token_budget: 128_000,
threshold: 0.8,
keep_last: 10
)
# Run compaction in the background (returns a Task)
task = Nous.Transcript.compact_async(messages, 10)
compacted = Task.await(task)
# Fire-and-forget with callback
Nous.Transcript.compact_async(messages, 10, fn compacted ->
send(self(), {:compacted, compacted})
end)
# Prune oversized tool results in place (no LLM, no reordering)
pruned = Nous.Transcript.prune_tool_results(messages, 8192)
# Estimate token count (coarse ~4-bytes-per-token heuristic)
tokens = Nous.Transcript.estimate_tokens("Hello world, how are you?")
#=> 6Token estimates are coarse
estimate_tokens/1 and estimate_messages_tokens/1 divide UTF-8 byte
length by 4. That ratio is roughly right for English prose and
systematically wrong elsewhere: it under-counts code and JSON (dense in
punctuation, which tokenizes finely) and badly over-counts CJK text (3
bytes per character, often ~1 token per character). Both maybe_compact/2
triggers are built on it, so :token_budget is approximate in both
directions — size the budget with headroom, or measure with a real
tokenizer and drive compaction from compact/2 directly.
Summary
Functions
Moves messages across an {old, recent} boundary so it never splits a
tool_call/tool_result pair.
Compacts a message list by keeping the last keep_last messages.
Compacts messages asynchronously under Nous.TaskSupervisor.
Compacts messages in the background with a callback.
Estimates total tokens across a list of messages.
Estimates the token count of a string as UTF-8 bytes divided by four.
Automatically compacts messages when a trigger condition is met.
Like maybe_compact/2 but runs asynchronously with a callback.
Truncates oversized tool results in place, without an LLM call.
Checks if a message list should be compacted based on a threshold.
Functions
@spec balance_tool_call_boundary([Nous.Message.t()], [Nous.Message.t()]) :: {[Nous.Message.t()], [Nous.Message.t()]}
Moves messages across an {old, recent} boundary so it never splits a
tool_call/tool_result pair.
Anthropic, OpenAI and Gemini all reject a request whose tool results have
no preceding assistant tool_call (and vice versa), so any code that
splits a conversation — compaction, summarization, windowing — must pass
its boundary through here.
Leading :tool messages in recent are orphans: their assistant prelude
is the last message of old. They are moved into old, which both closes
the pair and keeps every message in its original relative order.
The reverse case needs no work: if old ends with an assistant
tool_call, its results are the very next messages, so they are exactly
the leading :tool messages this function pulls back.
Examples
iex> old = [Nous.Message.assistant("calling", tool_calls: [%{id: "c1"}])]
iex> recent = [Nous.Message.tool("c1", "done"), Nous.Message.user("next")]
iex> {old, recent} = Nous.Transcript.balance_tool_call_boundary(old, recent)
iex> {length(old), length(recent)}
{2, 1}
@spec compact([Nous.Message.t()], pos_integer()) :: [Nous.Message.t()]
Compacts a message list by keeping the last keep_last messages.
If messages exceed the threshold, older messages are replaced with a summary system message. System messages at the start are always preserved.
Returns the original list if it's already within the limit.
Examples
iex> messages = for i <- 1..20, do: Nous.Message.user("Message #{i}")
iex> compacted = Nous.Transcript.compact(messages, 10)
iex> length(compacted)
11
@spec compact_async([Nous.Message.t()], pos_integer()) :: Task.t()
Compacts messages asynchronously under Nous.TaskSupervisor.
Returns a Task that resolves to the compacted message list.
Useful when compaction runs inside a GenServer and you don't
want to block the current process.
Examples
task = Nous.Transcript.compact_async(messages, 10)
# ... do other work ...
compacted = Task.await(task)With a callback (fire-and-forget)
Nous.Transcript.compact_async(messages, 10, fn compacted ->
send(self(), {:compacted, compacted})
end)
@spec compact_async([Nous.Message.t()], pos_integer(), ([Nous.Message.t()] -> any())) :: {:ok, pid()}
Compacts messages in the background with a callback.
Starts a fire-and-forget task under Nous.TaskSupervisor.
The callback receives the compacted message list when done.
Returns {:ok, pid}.
Examples
{:ok, _pid} = Nous.Transcript.compact_async(messages, 10, fn compacted ->
GenServer.cast(self, {:update_messages, compacted})
end)
@spec estimate_messages_tokens([Nous.Message.t()]) :: non_neg_integer()
Estimates total tokens across a list of messages.
Sums message text in bytes and divides once, so the result matches
estimate_tokens/1 on the concatenated text rather than accumulating a
rounding error per message. Same caveats as estimate_tokens/1: it is a
byte ratio, not a tokenizer.
Examples
iex> messages = [Nous.Message.user("Hello"), Nous.Message.assistant("Hi there")]
iex> Nous.Transcript.estimate_messages_tokens(messages)
3
@spec estimate_tokens(String.t() | nil) :: non_neg_integer()
Estimates the token count of a string as UTF-8 bytes divided by four.
A coarse byte-ratio estimate, not a tokenizer. Four bytes per token is a passable average for English prose; it under-counts code, JSON and other punctuation-dense text (which tokenizes far finer than 4 bytes per token) and heavily over-counts CJK, where a 3-byte character is often a single token. Anything that must be exact — a hard context-window check, billing — needs a real tokenizer.
This is deliberately the same arithmetic as the agent runner's pre-request
reservation estimate (estimate_request_tokens in Nous.AgentRunner.RequestDispatch),
so the framework has one token heuristic rather than two that disagree.
Examples
iex> Nous.Transcript.estimate_tokens("Hello world")
2
iex> Nous.Transcript.estimate_tokens("antidisestablishmentarianism")
7
iex> Nous.Transcript.estimate_tokens("")
0
@spec maybe_compact( [Nous.Message.t()], keyword() ) :: [Nous.Message.t()]
Automatically compacts messages when a trigger condition is met.
Returns the original messages unchanged if no trigger fires. Supports message count, token budget, or both (OR logic).
Options
:every— compact when message count exceeds this number:token_budget— total token budget for the conversation:threshold— fraction of token budget that triggers compaction (default0.8):keep_last— how many recent messages to keep (required)
Examples
# Compact every 20 messages
messages = Nous.Transcript.maybe_compact(messages, every: 20, keep_last: 10)
# Compact at 80% of 128k token budget
messages = Nous.Transcript.maybe_compact(messages,
token_budget: 128_000,
keep_last: 10
)
# Both triggers — whichever fires first
messages = Nous.Transcript.maybe_compact(messages,
every: 30,
token_budget: 128_000,
threshold: 0.75,
keep_last: 10
)
@spec maybe_compact_async([Nous.Message.t()], keyword(), (term() -> any())) :: {:ok, pid()}
Like maybe_compact/2 but runs asynchronously with a callback.
The callback receives {:compacted, messages} if compaction happened,
or {:unchanged, messages} if no trigger fired.
Examples
Nous.Transcript.maybe_compact_async(messages,
[every: 20, keep_last: 10],
fn
{:compacted, msgs} -> GenServer.cast(self, {:update, msgs})
{:unchanged, _msgs} -> :ok
end
)
@spec prune_tool_results([Nous.Message.t()], pos_integer()) :: [Nous.Message.t()]
Truncates oversized tool results in place, without an LLM call.
A single tool result — a 1 MB file read, a wide SELECT, a verbose build
log — can dominate a context window and is re-sent on every subsequent
turn. This rewrites the content of any :tool message whose text exceeds
max_result_chars, keeping the head and tail (where the useful signal
almost always is) and replacing the middle with a marker naming how many
bytes were dropped.
Structural guarantees, relied on by callers that split conversations:
- the returned list has the same length, in the same order — only
content is rewritten, so a
tool_call/tool_resultpair can never be broken by pruning; - non-
:toolmessages are returned identically; - a tool result whose content is not plain text (a content-part list) is returned identically rather than flattened into a string.
Examples
iex> big = Nous.Message.tool("c1", String.duplicate("x", 20_000))
iex> [pruned] = Nous.Transcript.prune_tool_results([big])
iex> String.contains?(pruned.content, "bytes elided")
true
iex> small = Nous.Message.tool("c1", "ok")
iex> Nous.Transcript.prune_tool_results([small]) == [small]
true
@spec should_compact?([Nous.Message.t()], pos_integer()) :: boolean()
Checks if a message list should be compacted based on a threshold.
Examples
iex> messages = for i <- 1..25, do: Nous.Message.user("msg #{i}")
iex> Nous.Transcript.should_compact?(messages, 20)
true