Skip to content
Merged
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
151 changes: 87 additions & 64 deletions crates/agent_core/src/runtime.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! The agent loop. Application behavior plugs in through the hook traits in
//! [`crate::hooks`]; application state travels type-erased in `extensions`.

mod handoff;
#[cfg(test)]
mod tests;
mod tool_execution;
Expand All @@ -27,6 +28,11 @@ use tools_core::{
};
use tracing::{debug, trace, warn};

/// Appended to the compaction prompt when the model answered it with tool
/// calls instead of text.
const HANDOFF_TOOL_CALL_REMINDER: &str = "Reminder: this is a compaction request. Do not call any \
tools; reply with the hand-off text only.";

/// Everything an [`AgentRuntime`] is built from.
pub struct AgentRuntimeComponents {
pub llm_provider: Box<dyn LLMProvider>,
Expand Down Expand Up @@ -357,6 +363,13 @@ impl AgentRuntime {

loop {
self.cancellation.check()?;
// Compact before a pending user message is appended: the hand-off
// covers the history so far and the new request follows it.
if self.should_trigger_compaction()? {
self.perform_compaction().await?;
continue;
}

// Check for pending user message and add it to history at start of each iteration
if let Some(pending_blocks) = self.get_and_clear_pending_message() {
let text_summary = text_summary_from_blocks(&pending_blocks);
Expand All @@ -371,11 +384,6 @@ impl AgentRuntime {
.await?;
}

if self.should_trigger_compaction()? {
self.perform_compaction().await?;
continue;
}

let messages = self.render_tool_results_in_messages();

// Pre-allocate the node_id for this assistant message.
Expand Down Expand Up @@ -848,33 +856,43 @@ impl AgentRuntime {
Ok((response, request_id))
}

fn format_compaction_summary_for_prompt(summary: &str) -> String {
let trimmed = summary.trim();
if trimmed.is_empty() {
"Conversation summary: (empty)".to_string()
} else {
format!("Conversation summary:\n{trimmed}")
}
fn handoff_text(blocks: &[ContentBlock]) -> Option<String> {
let text = blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}

fn extract_compaction_summary_text(blocks: &[ContentBlock]) -> String {
let mut collected = Vec::new();
for block in blocks {
match block {
ContentBlock::Text { text, .. } => collected.push(text.as_str()),
ContentBlock::Thinking { thinking, .. } => {
collected.push(thinking.as_str());
}
_ => {}
/// Asks the model for the hand-off text. The request keeps the tool
/// definitions so the cached prompt prefix stays valid; a model that
/// answers with tool calls instead of text is asked once more.
async fn request_handoff(&mut self) -> Result<String> {
let prompt = self.hooks.compaction.compaction_prompt().to_string();
let messages = self.render_tool_results_in_messages();
for reminder in [None, Some(HANDOFF_TOOL_CALL_REMINDER)] {
let text = match reminder {
None => prompt.to_string(),
Some(reminder) => format!("{prompt}\n\n{reminder}"),
};
let mut request = messages.clone();
request.push(Message {
role: MessageRole::User,
content: MessageContent::Text(text),
..Default::default()
});
let (response, _) = self.get_non_streaming_response(request).await?;
if let Some(text) = Self::handoff_text(&response.content) {
return Ok(text);
}
warn!("Compaction response contained no text; asking once more");
}

let merged = collected.join("\n").trim().to_string();
if merged.is_empty() {
"No summary was generated.".to_string()
} else {
merged
}
anyhow::bail!("The model did not produce a hand-off text for compaction")
}

/// The active-path messages from the last compaction summary onwards.
Expand All @@ -888,23 +906,44 @@ impl AgentRuntime {
messages
}

/// The messages the next request is built from: everything from the last
/// compaction summary onwards, with the summary rendered as the hand-off
/// message that also carries the user's earlier messages verbatim.
fn prompt_messages(&self) -> Vec<Message> {
let path = self.conversation.path();
let nodes = self.conversation.nodes();
let start = path
.iter()
.rposition(|id| {
self.conversation
.nodes()
nodes
.get(id)
.is_some_and(|node| node.message.is_compaction_summary)
})
.unwrap_or(0);
path[start..]
let mut messages: Vec<Message> = path[start..]
.iter()
.filter(|id| !self.prompt_projection.omitted_nodes.contains(id))
.filter_map(|id| self.conversation.nodes().get(id))
.filter_map(|id| nodes.get(id))
.map(|node| node.message.clone())
.collect()
.collect();
if let Some(summary) = messages
.first_mut()
.filter(|message| message.is_compaction_summary)
{
let user_messages = handoff::user_message_texts(
path[..start]
.iter()
.filter_map(|id| nodes.get(id))
.map(|node| &node.message),
);
let summary_text = match &summary.content {
MessageContent::Text(text) => text.as_str(),
MessageContent::Structured(_) => "",
};
summary.content =
MessageContent::Text(handoff::render_handoff(&user_messages, summary_text));
}
messages
}

fn context_usage_ratio(&mut self) -> Result<Option<f32>> {
Expand Down Expand Up @@ -1129,26 +1168,16 @@ impl AgentRuntime {
async fn perform_compaction(&mut self) -> Result<()> {
debug!("Starting context compaction");

let compaction_message = Message {
role: MessageRole::User,
content: MessageContent::Text(self.hooks.compaction.compaction_prompt().to_string()),
..Default::default()
};

let mut messages = self.render_tool_results_in_messages();
messages.push(compaction_message);
self.send_ui(AgentUiEvent::ActivityChanged {
activity: AgentActivity::WaitingForResponse,
})
.await?;
let response_result = self.get_non_streaming_response(messages).await;
let summary_result = self.request_handoff().await;
self.send_ui(AgentUiEvent::ActivityChanged {
activity: AgentActivity::Running,
})
.await?;
let (response, _) = response_result?;

let summary_text = Self::extract_compaction_summary_text(&response.content);
let summary_text = summary_result?;

// The compaction policy may contribute an addendum to the summary
// message — e.g. reminding the model which skills it had loaded, since
Expand Down Expand Up @@ -1294,28 +1323,22 @@ impl AgentRuntime {
}

for message in &mut messages {
match &mut message.content {
MessageContent::Structured(blocks) => {
for block in blocks {
if let ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
..
} = block
&& let Some((output, error)) = outputs.get(tool_use_id)
{
*content = output.clone();
if *error {
*is_error = Some(true);
}
if let MessageContent::Structured(blocks) = &mut message.content {
for block in blocks {
if let ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
..
} = block
&& let Some((output, error)) = outputs.get(tool_use_id)
{
*content = output.clone();
if *error {
*is_error = Some(true);
}
}
}
MessageContent::Text(text) if message.is_compaction_summary => {
*text = Self::format_compaction_summary_for_prompt(text);
}
_ => {}
}
}
messages
Expand Down
159 changes: 159 additions & 0 deletions crates/agent_core/src/runtime/handoff.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//! The hand-off message a compacted conversation resumes from.
//!
//! After compaction the prompt no longer contains the exchanges before the
//! summary. The summary is rendered as a single user message that frames it
//! as a hand-off from a previous instance and carries the user's own
//! messages verbatim, so what was asked for survives the compaction.
use llm::{ContentBlock, Message, MessageContent, MessageRole};

/// Rough budget for the verbatim user messages, in characters (about 20k
/// tokens). The newest messages take precedence.
const USER_MESSAGES_CHAR_BUDGET: usize = 80_000;

const PREAMBLE: &str = "Another instance of this assistant was working in this session and \
reached the context limit. It wrote the hand-off below. The user's messages are \
reproduced verbatim so nothing about what they asked for is lost. Build on the \
work already done instead of repeating it; the workspace reflects everything the \
previous instance did.";

/// The verbatim text of the real user messages among `messages`. Tool-result
/// messages and earlier compaction summaries share the user role but are not
/// user messages; images are dropped.
pub(super) fn user_message_texts<'a>(messages: impl Iterator<Item = &'a Message>) -> Vec<String> {
messages
.filter(|message| message.role == MessageRole::User && !message.is_compaction_summary)
.filter_map(|message| match &message.content {
MessageContent::Text(text) => Some(text.clone()),
MessageContent::Structured(blocks) => {
let text = blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
(!text.trim().is_empty()).then_some(text)
}
})
.collect()
}

/// Renders the hand-off message from the user's messages and the summary the
/// previous instance wrote.
pub(super) fn render_handoff(user_messages: &[String], summary: &str) -> String {
render_handoff_within(user_messages, summary, USER_MESSAGES_CHAR_BUDGET)
}

fn render_handoff_within(user_messages: &[String], summary: &str, budget: usize) -> String {
let mut remaining = budget;
let mut kept = 0;
for message in user_messages.iter().rev() {
if message.len() > remaining {
break;
}
remaining -= message.len();
kept += 1;
}
let omitted = user_messages.len() - kept;

let mut out = String::new();
out.push_str("<handoff>\n");
out.push_str(PREAMBLE);
out.push_str("\n\n<user_messages>\n");
if omitted > 0 {
out.push_str(&format!("({omitted} earlier messages omitted)\n"));
}
for (index, message) in user_messages.iter().enumerate().skip(omitted) {
out.push_str(&format!(
"<message index=\"{}\">\n{}\n</message>\n",
index + 1,
message.trim()
));
}
out.push_str("</user_messages>\n\n<summary>\n");
let summary = summary.trim();
out.push_str(if summary.is_empty() {
"(no summary available)"
} else {
summary
});
out.push_str("\n</summary>\n</handoff>");
out
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn user_message_texts_skips_tool_results_and_summaries() {
let messages = [
Message::new_user("Explain the compaction feature"),
Message::new_assistant("Looking."),
Message::new_user_content(vec![ContentBlock::ToolResult {
tool_use_id: "t1".into(),
content: llm::ToolResultContent::text("file contents"),
is_error: None,
start_time: None,
end_time: None,
}]),
Message {
content: MessageContent::Text("old summary".into()),
is_compaction_summary: true,
..Default::default()
},
Message::new_user_content(vec![
ContentBlock::new_text("Here is a screenshot"),
ContentBlock::Image {
media_type: "image/png".into(),
data: "aaaa".into(),
start_time: None,
end_time: None,
},
]),
];

assert_eq!(
user_message_texts(messages.iter()),
vec![
"Explain the compaction feature".to_string(),
"Here is a screenshot".to_string()
]
);
}

#[test]
fn handoff_carries_user_messages_verbatim_before_the_summary() {
let rendered = render_handoff(
&["First ask".to_string(), "Second ask".to_string()],
"Did A, B remains",
);

let messages_at = rendered.find("<user_messages>").unwrap();
let summary_at = rendered.find("<summary>").unwrap();
assert!(rendered.starts_with("<handoff>\n"));
assert!(rendered.ends_with("</summary>\n</handoff>"));
assert!(messages_at < summary_at);
assert!(rendered.contains("<message index=\"1\">\nFirst ask\n</message>"));
assert!(rendered.contains("<message index=\"2\">\nSecond ask\n</message>"));
assert!(rendered.contains("<summary>\nDid A, B remains\n</summary>"));
}

#[test]
fn handoff_keeps_the_newest_user_messages_within_the_budget() {
let messages = ["x".repeat(30), "y".repeat(30), "z".repeat(30)];
let rendered = render_handoff_within(&messages, "summary", 70);

assert!(rendered.contains("(1 earlier messages omitted)"));
assert!(!rendered.contains(&"x".repeat(30)));
assert!(rendered.contains("<message index=\"2\">"));
assert!(rendered.contains("<message index=\"3\">"));
}

#[test]
fn handoff_marks_a_missing_summary() {
let rendered = render_handoff(&[], " ");
assert!(rendered.contains("<summary>\n(no summary available)\n</summary>"));
}
}
Loading
Loading