Skip to content

Conversation and automatic compaction

Message structure

Conversation.messages stores typed message objects (defined in src/xun/conversation_message.py): each class is itself responsible for producing its OpenAI request parameters (completion_param()) and its on-disk format (to_json()), and message_from_json() is the deserialisation factory.

Type Role Carries
SystemPrompt system the prompt body, is_compressed (whether the body is a summary), persist_sections (sections still kept after compaction, AGENTS.md lives here)
UserMessage user text, normalised image URLs (data or http(s)), an optional response_schema
RawOpenAIMessage assistant / tool the OpenAI parameter dict stored verbatim (the message returned by the model and the tool results)
Method Description
completion_params() expands the whole conversation into the list of ChatCompletionMessageParam sent to the model; msg.role gives the role
set_system_message_content(content, is_compressed=False) replaces or inserts the system message at index 0 (where agent.system() lands), preserving existing sections
set_persistent_section(name, content) writes or deletes a persistent section (empty content deletes it); agent.sync_project_instructions() uses it to inject AGENTS.md
add_user_message(content, images=None) appends a user message; with images, content becomes a list of text/image_url blocks and PIL images are converted to data URLs
set_response_schema(schema) attaches the JSON of a pydantic schema to the last user message (the structured-output injection point); raises ValueError if the last message is not a user message
add_agent_message(msg) writes an assistant message, automatically dropping an empty tool_calls: []
add_tool_result(tool_call_id, content) writes a role="tool" message whose content is content.value_str(); a serialisation failure is written as [Error] Failed to serialize...
pop_last_message_if_user() rolls back the last user message on cancellation
pop_from_last_user_message(inclusive=True) the implementation behind /retry (exclusive) and /revise (inclusive); returns the popped messages
compact_toolcall(keep_max=12) / compacted_toolcall_result(id) tier-1 reclamation, and fetching back a reclaimed original by id
compact(summarize, keep_recent) tier-2 summary compaction; summarize(messages) returning None means giving up
clear() clears everything but keeps the system message, and resets the token counter, the compaction counters and the stored originals of compacted tool results
to_history(truncate=False) / content_to_text(content, truncate=False) {role, content} records for the UI (truncate cuts at 1000 characters)
dumps() / dump(path) / loads() / load(path) serialisation; the on-disk key tokens_used is kept for backward compatibility, messages are distinguished by kind (system / user / raw)

UI rendering does not live in this layer: /history uses to_history(), while /render renders the event stream cached by the display layer — see Display & web service.

Tool calls are stored in the tool_calls array of the assistant message (including the argument strings), and each tool result is a separate role="tool" message linked by tool_call_id. Reasoning content is detected automatically through model.reasoning_field (reasoning or reasoning_content) and written back verbatim so that it survives across turns.

Old archives cannot be loaded

From 1.3.0 on, the on-disk format gives every message a kind and a raw; earlier versions stored the OpenAI parameter dictionaries directly, so the new code raises KeyError: 'raw' when loading them, and such files can only be read by the old version.

Two length metrics

Metric Source Purpose
total_tokens usage.total_tokens returned by the model the sole criterion for automatic compaction (reset to None after compaction so it does not trigger again immediately)
estimated_message_length() sums character counts over the content / text / reasoning / reasoning_content fields (recursing into nested dicts and lists) estimates the reclaimed fraction and decides between "reclaim again" and "escalate to a summary"

Tiered compaction

flowchart TD
  S["check before each call"] --> A["reclaim old tool results"]
  A --> B{"still over?"}
  B -->|no| E["round done"]
  B -->|yes| C["summarise"]
  C --> D{"still over?"}
  D -->|no| E
  D -->|yes| A

Tier 1: reclaim old tool results

Old role="tool" messages beyond the most recent keep_max (default 12) are replaced in place by a placeholder:

[Compacted, ID: <toolcall_id>. If this content is still needed, call extract_compacted_tool_result with this ID or re-run the tool.]

The original text stays in memory and the model can fetch it back with extract_compacted_tool_result(toolcall_id) — this is the key to "reclaim cheaply first, without losing information". A message is replaced only when the replacement really is shorter.

Tier 2: summary compaction

The cut point is chosen so the tail never starts with an orphaned tool result, and the last user message is force-kept in the tail when necessary (some servers require a user message to be present in the request). The summary is produced by a sub-agent:

Agent.inherit(agent, share_display=False, copy_toolbox=False, copy_command=False)
# disable auto_compact / enable_extensions, enable auto_confirm
# the messages to be compacted are put straight into its conversation, the persistent sections are stripped from its system message
# execute(max_iterations=1)

The summary is required as markdown sections (system_context, overview, key_facts, user_preferences, decisions, pending_tasks, open_questions, tone_context), kept within 1024 tokens in total.

On success the summary is written into SystemPrompt.content and is_compressed is set to true: at request time it is wrapped in the "Earlier conversation history has been compacted into the summary below" template (COMPACTED_SYSTEM_PROMPT), and persist_sections (including AGENTS.md) is appended after it verbatim. At the same time total_tokens is cleared, compaction_counter.summary_rounds increases by one, and the tool-round counter goes back to zero.

When there is nothing to compact or the summary fails, the conversation is left untouched; the returned statuses are NOTHING_TO_CONDENSE / SUMMARIZE_FAILED, respectively.

Parameter overview

Policy parameters are fields of AutoCompactor (code level), while the switch and the threshold are configuration (user level):

Location Name Default Description
AutoCompactor toolcall_keep_max 12 how many recent tool results to keep
AutoCompactor summary_keep_max 16 number of trailing messages kept when summarising
AutoCompactor escalation_rounds 2 how many consecutive cheap-reclaim rounds before escalating to a summary; if the estimate after reclaiming is still above threshold × ESCALATION_RATIO, escalation happens in the same round
AutoCompactor max_retries 2 extra compaction rounds allowed when the limit is still exceeded after a summary; each further round halves both toolcall_keep_max and summary_keep_max
module constant ESCALATION_RATIO 0.95 the estimate must fall below 95% of the threshold to count as real progress; it lives at module level in compact, so it cannot be tuned per AutoCompactor instance
config auto_compact.enabled true on/off switch
config auto_compact.token_threshold 192000 triggering threshold

A custom strategy only has to subclass CompactorAbstract and implement auto_compact(agent), then pass Agent(compactor=MyCompactor()).

Manual triggering

Way Behaviour
/compact runs the tier-2 summarisation only (equivalent to compact_conversation)
/compact toolcall tier-1 reclamation only
agent.conversation.compact_toolcall(keep_max=12) programmatic tier-1 call (a Conversation method), returns ToolCallCompactResult(reclaimed_count, reclaimed_fraction)
xun.compact.compact_conversation(agent, keep_recent=16) programmatic tier-2 call (a module function, not exported at the top level), returns SummaryCompactResult