Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions agent/agent.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ pub(all) struct Agent {
mut web_search : Bool
/// Queue for receiving external events from the environment.
priv external_events : @event.ExternalEventQueue
/// Compactor for managing context compaction when conversations get too long.
priv compactor : @compact.Compactor
/// Total tokens used across the conversation, used to trigger auto-compaction.
priv mut total_usage_tokens : Int
}

///|
Expand Down Expand Up @@ -207,6 +211,75 @@ fn Agent::cancel(agent : Agent) -> Unit {
agent.emit(@event.Cancelled)
}

///|
/// Runs automatic context compaction when the conversation exceeds token limits.
///
/// This function:
/// 1. Builds a summarization prompt from the current conversation
/// 2. Calls the LLM to generate a summary
/// 3. Adds a ContextCompacted event (history remains append-only)
/// 4. Resets token tracking
/// 5. Emits `ContextCompacted` event to listeners
async fn Agent::run_auto_compact(agent : Agent) -> Unit {
agent.logger.info("Starting auto-compaction", data={
"total_usage_tokens": agent.total_usage_tokens,
})

// Get current messages for summarization
let messages = agent.history.messages(include_system=false)

// Build the summarization prompt
let compact_prompt = agent.compactor.build_summarization_prompt(messages)

// Make API call to get summary
let summary = agent.get_compact_summary(compact_prompt)

// Build the full summary with prefix
let full_summary = "\{@compact.SUMMARY_PREFIX}\n\{summary}"

// Simply add the ContextCompacted event - history remains append-only
// The Conversation::messages() method will handle building the compacted view
agent.history.add_event(
@event.Event::new(
id=agent.uuid.v4(),
ContextCompacted(summary=full_summary),
),
)

// Reset token counter (will be recounted on next turn)
agent.total_usage_tokens = 0

// Emit the event to listeners
agent.emit(ContextCompacted(summary=full_summary))

// Save the conversation
agent.session_manager.save(agent.history)
agent.logger.info("Auto-compaction completed", data={
"summary_length": summary.length(),
})
}

///|
/// Makes an API call to get a conversation summary for compaction.
async fn Agent::get_compact_summary(agent : Agent, prompt : String) -> String {
// Create messages for compact request
let messages = [@ai.user_message(content=prompt).to_openai()]

// Make API request with a smaller model or same model
let response = @openai.chat(
model=agent.model,
logger=agent.logger,
@openai.chat_completion(messages~, model=agent.model.model_name, tools=[]),
extra_body={},
)

// Extract summary from response
guard response is { choices: [{ message, .. }, ..], .. } else {
return "(no summary available)"
}
message.content.unwrap_or("(no summary available)")
}

///|
/// Starts the agent's conversation loop and executes tool calls until completion.
///
Expand Down Expand Up @@ -309,6 +382,18 @@ pub async fn Agent::start(agent : Agent) -> Unit {
),
)

// Track total token usage for auto-compaction
if usage is Some(u) {
agent.total_usage_tokens = agent.total_usage_tokens + u.total_tokens
}

// Check if auto-compaction is needed after this turn
// Only compact if there are more tool calls to process (conversation continues)
if not(message.tool_calls.is_empty()) &&
agent.compactor.should_compact(agent.total_usage_tokens) {
agent.run_auto_compact()
}

// Add all staged messages to permanent conversation history
agent.input_queue = []
// Persist the updated conversation history to disk
Expand Down Expand Up @@ -429,6 +514,11 @@ pub async fn new(
skills,
web_search,
external_events,
compactor: @compact.Compactor::new(
safe_zone_tokens=model.safe_zone_tokens,
logger~,
),
total_usage_tokens: 0,
}
rules.load()
skills.load()
Expand Down
3 changes: 2 additions & 1 deletion agent/moon.pkg.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"moonbitlang/maria/internal/diagnostics",
"moonbitlang/maria/internal/os",
"moonbitlang/maria/internal/broadcast",
"moonbitlang/maria/internal/jsonx"
"moonbitlang/maria/internal/jsonx",
"moonbitlang/maria/internal/compact"
],
"test-import": [
"moonbitlang/maria/internal/mock",
Expand Down
17 changes: 17 additions & 0 deletions event/event.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ pub(all) enum EventDesc {
/// Failed. We cannot use `Error` here because it is impossible to deserialize
/// `Error` from JSON.
Failed(Json)
/// Event triggered when context has been compacted.
/// Contains the summary text used for the compacted context.
ContextCompacted(summary~ : String)
} derive(Eq, Show)

///|
Expand Down Expand Up @@ -211,6 +214,8 @@ pub impl ToJson for EventDesc with to_json(self : EventDesc) -> Json {
Cancelled => { "msg": "Cancelled" }
UserMessage(content) => { "msg": "UserMessage", "content": content }
Failed(error) => { "msg": "Failed", "error": error }
ContextCompacted(summary~) =>
{ "msg": "ContextCompacted", "summary": summary }
}
}

Expand Down Expand Up @@ -463,6 +468,18 @@ pub impl @json.FromJson for EventDesc with from_json(
}
Failed(error_json)
}
"ContextCompacted" => {
guard json_object.get("summary") is Some(summary_json) else {
raise @json.JsonDecodeError(
(json_path, "ContextCompacted event missing 'summary' field"),
)
}
let summary : String = @json.from_json(
summary_json,
path=json_path.add_key("summary"),
)
ContextCompacted(summary~)
}
_ =>
raise @json.JsonDecodeError(
(json_path, "Unknown 'msg' value in EventDesc: \{msg}"),
Expand Down
1 change: 1 addition & 0 deletions event/pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub(all) enum EventDesc {
UserMessage(String)
Cancelled
Failed(Json)
ContextCompacted(summary~ : String)
}
pub impl Eq for EventDesc
pub impl Show for EventDesc
Expand Down
85 changes: 85 additions & 0 deletions internal/compact/compact.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
///|
/// The Compactor handles context compaction for long conversations.
///
/// It monitors token usage and triggers compaction when the conversation
/// exceeds a configurable threshold, summarizing the conversation history
/// to stay within model context limits.
pub struct Compactor {
logger : @pino.Logger
/// Token threshold that triggers auto-compaction
auto_compact_token_limit : Int
}

///|
/// Creates a new Compactor with the specified configuration.
///
/// Parameters:
///
/// * `logger` : Logger instance for recording compaction events.
/// * `safe_zone_tokens` : The model's safe zone token limit. The auto-compact
/// threshold will be set to 90% of this value.
/// * `auto_compact_token_limit` : Optional override for the auto-compact
/// threshold. If not provided, defaults to 90% of `safe_zone_tokens`.
///
/// Returns a new `Compactor` instance.
pub fn Compactor::new(
logger~ : @pino.Logger,
safe_zone_tokens~ : Int,
auto_compact_token_limit? : Int,
) -> Compactor {
let limit = match auto_compact_token_limit {
Some(limit) => limit
None => safe_zone_tokens * 9 / 10
}
{ logger, auto_compact_token_limit: limit }
}

///|
/// Checks if compaction should be triggered based on current token usage.
///
/// Parameters:
///
/// * `self` : The compactor instance.
/// * `current_tokens` : The current total token count of the conversation.
///
/// Returns `true` if the conversation should be compacted.
pub fn Compactor::should_compact(
self : Compactor,
current_tokens : Int,
) -> Bool {
current_tokens >= self.auto_compact_token_limit
}

///|
/// Builds the prompt to send to the LLM for generating a compaction summary.
///
/// Parameters:
///
/// * `self` : The compactor instance.
/// * `messages` : The full conversation history to summarize.
///
/// Returns a string containing the summarization prompt with conversation
/// context.
pub fn Compactor::build_summarization_prompt(
self : Compactor,
messages : Array[@ai.Message],
) -> String {
let _ = self
let mut conversation = ""
for message in messages {
let role = match message {
@ai.Message::User(_) => "User"
@ai.Message::System(_) => "System"
@ai.Message::Assistant(_, ..) => "Assistant"
@ai.Message::Tool(_, ..) => "Tool"
}
conversation = "\{conversation}\n[\{role}]: \{message.content()}\n"
}
(
$|\{SUMMARIZATION_PROMPT}
$|
$|---
$|CONVERSATION TO SUMMARIZE:
$|\{conversation}
)
}
Loading
Loading