Nous.Transcript (nous v0.17.1)

Copy Markdown View Source

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?")
#=> 6

Token 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

balance_tool_call_boundary(old, recent)

@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}

compact(messages, keep_last)

@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

compact_async(messages, keep_last)

@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)

compact_async(messages, keep_last, callback)

@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)

estimate_messages_tokens(messages)

@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

estimate_tokens(text)

@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

maybe_compact(messages, opts)

@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 (default 0.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
)

maybe_compact_async(messages, opts, callback)

@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
)

prune_tool_results(messages, max_result_chars \\ 8192)

@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_result pair can never be broken by pruning;
  • non-:tool messages 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

should_compact?(messages, compact_after)

@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