Packages
AI agent framework for Elixir built on OTP. TEA-based agents with crash isolation, inter-agent messaging, team supervision, and real SSE streaming to Anthropic, OpenAI, Ollama, and more.
Current section
Files
Jump to
Current section
Files
lib/raxol/agent/context_compactor.ex
defmodule Raxol.Agent.ContextCompactor do
@moduledoc """
Token-aware message history compaction for agent sessions.
When an agent's conversation history exceeds a token budget,
older messages are summarized into a single system message
while preserving recent messages verbatim.
## Usage
config = %{max_tokens: 8_000, preserve_recent: 4}
case ContextCompactor.compact(messages, config) do
%{compacted: true, messages: compacted_messages} ->
# Use compacted_messages for next LLM call
%{compacted: false} ->
# History is within budget, no changes needed
end
Token estimation uses a simple byte_size/4 heuristic (no tokenizer dependency).
Summaries are generated purely from message metadata -- no LLM call required.
"""
@type message :: %{role: atom(), content: String.t()}
@type config :: %{
max_tokens: pos_integer(),
preserve_recent: pos_integer(),
summary_max_tokens: pos_integer()
}
@type compaction_result :: %{
messages: [message()],
compacted: boolean(),
original_tokens: non_neg_integer(),
compacted_tokens: non_neg_integer(),
summary: String.t() | nil
}
@type summary :: %{
message_count: non_neg_integer(),
role_counts: %{atom() => non_neg_integer()},
recent_user_requests: [String.t()],
key_topics: [String.t()],
file_paths: [String.t()],
pending_work: [String.t()]
}
@default_config %{
max_tokens: 8_000,
preserve_recent: 4,
summary_max_tokens: 1_000
}
@file_path_regex ~r"(?:^|[\s\"'`(])([a-zA-Z_./][a-zA-Z0-9_./\-]*\.(?:ex|exs|erl|hrl|rs|ts|js|py|rb|go|md|json|toml|yaml|yml|sh|sql|html|css))\b"
@pending_keywords ~w(todo next pending will should must need)
@user_request_max_chars 200
@message_overhead 4
# -- Public API ---------------------------------------------------------------
@doc """
Estimate token count for a string or message list.
Uses a byte_size/4 heuristic. Not exact, but sufficient for budget decisions.
"""
@spec estimate_tokens(String.t()) :: non_neg_integer()
def estimate_tokens(text) when is_binary(text) do
div(byte_size(text), 4) + 1
end
@spec estimate_tokens([message()]) :: non_neg_integer()
def estimate_tokens(messages) when is_list(messages) do
Enum.reduce(messages, 0, fn msg, acc ->
acc + estimate_tokens(message_content(msg)) + @message_overhead
end)
end
@doc """
Check if compaction is needed for the given messages.
"""
@spec needs_compaction?([message()], config()) :: boolean()
def needs_compaction?(messages, config \\ @default_config) do
estimate_tokens(messages) > config.max_tokens
end
@doc """
Compact messages if over token budget.
Splits system messages from conversation, preserves the last N non-system
messages verbatim, and summarizes the rest into a continuation system message.
Returns a `compaction_result` map. If no compaction was needed, `compacted`
is `false` and messages are returned unchanged.
"""
@spec compact([message()], config()) :: compaction_result()
def compact(messages, config \\ @default_config)
def compact([], _config) do
%{
messages: [],
compacted: false,
original_tokens: 0,
compacted_tokens: 0,
summary: nil
}
end
def compact(messages, config) do
total = estimate_tokens(messages)
if total <= config.max_tokens do
%{
messages: messages,
compacted: false,
original_tokens: total,
compacted_tokens: total,
summary: nil
}
else
do_compact(messages, config, total)
end
end
@doc """
Build a structured summary from a list of messages.
Extracts metadata (role counts, file paths, recent requests, pending work)
without requiring an LLM call.
"""
@spec build_summary([message()]) :: summary()
def build_summary(messages) when is_list(messages) do
%{
message_count: length(messages),
role_counts: count_by_role(messages),
recent_user_requests: extract_recent_user(messages, 3),
key_topics: extract_topics(messages),
file_paths: extract_file_paths(messages),
pending_work: extract_pending(messages)
}
end
@doc """
Format a summary struct into a human-readable continuation message.
"""
@spec format_continuation(summary()) :: String.t()
def format_continuation(summary) do
role_desc = format_role_counts(summary.role_counts)
header = [
"[Session compacted] This conversation continues from a previous exchange.",
"Summary of #{summary.message_count} earlier messages (#{role_desc}):"
]
body =
[]
|> maybe_add_section("Pending", summary.pending_work)
|> maybe_add_section("Files discussed", summary.file_paths)
|> maybe_add_section("Recent requests", summary.recent_user_requests)
(header ++ body)
|> Enum.join("\n\n")
|> then(&(&1 <> "\n\nRecent messages are preserved verbatim below."))
end
# -- Private: Compaction -------------------------------------------------------
defp do_compact(messages, config, original_tokens) do
{system_msgs, non_system} = Enum.split_with(messages, &(&1.role == :system))
{compactable, preserved} =
split_preserve(non_system, config.preserve_recent)
if compactable == [] do
%{
messages: messages,
compacted: false,
original_tokens: original_tokens,
compacted_tokens: original_tokens,
summary: nil
}
else
summary = build_summary(compactable)
continuation = format_continuation(summary)
# Truncate summary if it exceeds the summary token budget
continuation = truncate_to_tokens(continuation, config.summary_max_tokens)
summary_msg = %{role: :system, content: continuation}
compacted = system_msgs ++ [summary_msg] ++ preserved
%{
messages: compacted,
compacted: true,
original_tokens: original_tokens,
compacted_tokens: estimate_tokens(compacted),
summary: continuation
}
end
end
# -- Private: Summary Extraction -----------------------------------------------
defp count_by_role(messages) do
Enum.frequencies_by(messages, & &1.role)
end
defp extract_recent_user(messages, n) do
messages
|> Enum.filter(&(&1.role == :user))
|> Enum.take(-n)
|> Enum.map(fn msg ->
msg |> message_content() |> truncate(@user_request_max_chars)
end)
end
defp extract_file_paths(messages) do
messages
|> Enum.flat_map(fn msg ->
Regex.scan(@file_path_regex, message_content(msg), capture: :all_but_first)
end)
|> List.flatten()
|> Enum.uniq()
|> Enum.take(20)
end
defp extract_pending(messages) do
messages
|> Enum.filter(&(&1.role == :assistant))
|> Enum.take(-3)
|> Enum.flat_map(fn msg ->
msg
|> message_content()
|> String.split("\n")
|> Enum.filter(&line_has_pending_keyword?/1)
|> Enum.map(&String.trim/1)
|> Enum.take(3)
end)
|> Enum.uniq()
|> Enum.take(5)
end
defp line_has_pending_keyword?(line) do
lower = String.downcase(line)
Enum.any?(@pending_keywords, &String.contains?(lower, &1))
end
defp extract_topics(messages) do
messages
|> Enum.flat_map(fn msg ->
msg
|> message_content()
|> String.split(~r/[\s,.:;!?\[\](){}<>]+/)
|> Enum.filter(&(String.length(&1) > 4))
|> Enum.map(&String.downcase/1)
end)
|> Enum.frequencies()
|> Enum.sort_by(fn {_word, count} -> -count end)
|> Enum.take(10)
|> Enum.map(fn {word, _count} -> word end)
end
# -- Private: Formatting -------------------------------------------------------
defp format_role_counts(counts) do
counts
|> Enum.sort()
|> Enum.map_join(", ", fn {role, count} -> "#{count} #{role}" end)
end
defp maybe_add_section(sections, _label, []), do: sections
defp maybe_add_section(sections, "Files discussed", items) do
["Files discussed: #{Enum.join(items, ", ")}" | sections]
end
defp maybe_add_section(sections, label, items) do
bullet_list = Enum.map_join(items, "\n", &("- " <> &1))
["#{label}:\n#{bullet_list}" | sections]
end
# -- Private: Helpers -----------------------------------------------------------
defp message_content(%{content: content}) when is_binary(content), do: content
defp message_content(_), do: ""
defp truncate(text, max_length) do
if String.length(text) > max_length do
String.slice(text, 0, max_length) <> "..."
else
text
end
end
defp split_preserve(list, n) do
len = length(list)
if len <= n do
{[], list}
else
Enum.split(list, len - n)
end
end
defp truncate_to_tokens(text, max_tokens) do
if estimate_tokens(text) > max_tokens do
max_bytes = (max_tokens - 1) * 4
String.slice(text, 0, max_bytes)
else
text
end
end
end