feat: compaction in the GDK (#11042)

This commit is contained in:
Alex Hancock
2026-08-10 17:10:36 -04:00
committed by GitHub
parent b7ddf933c4
commit b6f3402d9b
16 changed files with 790 additions and 237 deletions
Generated
+18
View File
@@ -4950,6 +4950,7 @@ dependencies = [
"futures",
"gethostname",
"goose-acp-macros",
"goose-context-management",
"goose-download-manager",
"goose-mcp",
"goose-providers",
@@ -5111,6 +5112,22 @@ dependencies = [
"zip 8.6.0",
]
[[package]]
name = "goose-context-management"
version = "0.1.0-alpha.5"
dependencies = [
"anyhow",
"async-trait",
"goose-providers",
"include_dir",
"minijinja",
"rmcp 3.0.0",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "goose-download-manager"
version = "0.1.0-alpha.5"
@@ -5259,6 +5276,7 @@ dependencies = [
"anyhow",
"base64 0.23.0",
"futures",
"goose-context-management",
"goose-providers",
"goose-sdk-types",
"rmcp 3.0.0",
@@ -0,0 +1,26 @@
[package]
name = "goose-context-management"
version = "0.1.0-alpha.5"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Conversation compaction for Goose"
[lints]
workspace = true
[dependencies]
goose-providers = { version = "0.1.0-alpha.5", path = "../goose-providers", features = ["rustls-tls"] }
anyhow = { workspace = true }
async-trait = { workspace = true }
include_dir = { workspace = true }
minijinja = { version = "2.18", default-features = false, features = ["loader", "multi_template", "serde"] }
rmcp = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
@@ -0,0 +1,86 @@
use goose_providers::conversation::message::{ActionRequiredData, Message, MessageContent};
use rmcp::model::Role;
pub fn format_message_for_compacting(msg: &Message) -> String {
let content_parts: Vec<String> = msg
.content
.iter()
.filter_map(|content| match content {
MessageContent::Text(text) => Some(text.text.clone()),
MessageContent::Image(img) => Some(format!("[image: {}]", img.mime_type)),
MessageContent::ToolRequest(req) => {
if let Ok(call) = &req.tool_call {
Some(format!(
"tool_request({}): {}",
call.name,
serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "<<invalid json>>".to_string())
))
} else {
Some("tool_request: [error]".to_string())
}
}
MessageContent::ToolResponse(res) => {
if let Ok(result) = &res.tool_result {
let text_items: Vec<String> = result
.content
.iter()
.filter_map(|content| {
content.as_text().map(|text_str| text_str.text.clone())
})
.collect();
if !text_items.is_empty() {
Some(format!("tool_response: {}", text_items.join("\n")))
} else {
Some("tool_response: [non-text content]".to_string())
}
} else {
Some("tool_response: [error]".to_string())
}
}
MessageContent::ToolConfirmationRequest(req) => {
Some(format!("tool_confirmation_request: {}", req.tool_name))
}
MessageContent::ActionRequired(action) => match &action.data {
ActionRequiredData::ToolConfirmation { tool_name, .. } => {
Some(format!("action_required(tool_confirmation): {}", tool_name))
}
ActionRequiredData::Elicitation { message, .. } => {
Some(format!("action_required(elicitation): {}", message))
}
ActionRequiredData::ElicitationResponse { id, .. } => {
Some(format!("action_required(elicitation_response): {}", id))
}
ActionRequiredData::ToolConfirmationResponse { id, .. } => Some(format!(
"action_required(tool_confirmation_response): {}",
id
)),
},
MessageContent::FrontendToolRequest(req) => {
if let Ok(call) = &req.tool_call {
Some(format!("frontend_tool_request: {}", call.name))
} else {
Some("frontend_tool_request: [error]".to_string())
}
}
MessageContent::Thinking(_) => None,
MessageContent::RedactedThinking(_) => None,
MessageContent::SystemNotification(notification) => {
Some(format!("system_notification: {}", notification.msg))
}
MessageContent::Error(error) => Some(format!("error: {}", error.message)),
})
.collect();
let role_str = match msg.role {
Role::User => "user",
Role::Assistant => "assistant",
};
if content_parts.is_empty() {
format!("[{}]: <empty message>", role_str)
} else {
format!("[{}]: {}", role_str, content_parts.join("\n"))
}
}
@@ -0,0 +1,70 @@
//! Conversation compaction: summarizing a message history down to a single
//! message so a conversation can continue past a model's context window.
//!
//! Three layers, smallest first:
//!
//! * [`summarize`] - given a model and messages, produce one summary message.
//! * [`compact`] - the trait-based API ([`CompactionInput`] /
//! [`CompactionOutput`]) letting a caller read from and write back to its own
//! conversation representation. Rust only.
//!
//! Cross-language (Python/Kotlin) access is exposed by `goose-sdk`, which
//! wraps this crate in its uniffi bindings.
pub mod format;
pub mod model;
pub mod provider;
pub mod structured;
pub mod summarize;
pub mod templates;
use anyhow::Result;
use goose_providers::conversation::message::Message;
use goose_providers::conversation::token_usage::ProviderUsage;
pub use format::format_message_for_compacting;
pub use model::{CompactionModel, ProviderModel, TokenEstimator};
pub use provider::CompactingProvider;
pub use structured::{FileActivity, StructuredSummary};
pub use summarize::{summarize, Summary};
pub use templates::Templates;
pub const DEFAULT_COMPACTION_THRESHOLD: f64 = 0.8;
/// Everything compaction reads from the caller's conversation.
pub trait CompactionInput {
fn messages(&self) -> Vec<Message>;
fn templates(&self) -> Templates {
Templates::default()
}
}
/// Where compaction writes its result back into the caller's conversation.
pub trait CompactionOutput {
fn set_summary(&mut self, summary: Message);
fn set_usage(&mut self, usage: ProviderUsage);
}
pub async fn compact<I, O>(
model: &dyn CompactionModel,
estimator: Option<&dyn TokenEstimator>,
input: &I,
output: &mut O,
) -> Result<()>
where
I: CompactionInput + ?Sized,
O: CompactionOutput + ?Sized,
{
let templates = input.templates();
let summary = summarize(model, estimator, &templates, &input.messages()).await?;
output.set_summary(summary.message);
output.set_usage(summary.usage);
Ok(())
}
impl CompactionInput for Vec<Message> {
fn messages(&self) -> Vec<Message> {
self.clone()
}
}
@@ -0,0 +1,53 @@
use std::sync::Arc;
use async_trait::async_trait;
use goose_providers::base::Provider;
use goose_providers::conversation::message::Message;
use goose_providers::conversation::token_usage::ProviderUsage;
use goose_providers::errors::ProviderError;
use goose_providers::model::ModelConfig;
/// The single completion call compaction needs. Implementations decide model
/// selection, fallbacks and session plumbing.
#[async_trait]
pub trait CompactionModel: Send + Sync {
async fn complete(
&self,
system: &str,
messages: &[Message],
) -> Result<(Message, ProviderUsage), ProviderError>;
}
/// Counts tokens for usage estimation and retained-context reporting.
#[async_trait]
pub trait TokenEstimator: Send + Sync {
async fn count_chat_tokens(&self, system: &str, messages: &[Message]) -> usize;
async fn count_text_tokens(&self, text: &str) -> usize;
}
pub struct ProviderModel {
provider: Arc<dyn Provider>,
model_config: ModelConfig,
}
impl ProviderModel {
pub fn new(provider: Arc<dyn Provider>, model_config: ModelConfig) -> Self {
Self {
provider,
model_config,
}
}
}
#[async_trait]
impl CompactionModel for ProviderModel {
async fn complete(
&self,
system: &str,
messages: &[Message],
) -> Result<(Message, ProviderUsage), ProviderError> {
self.provider
.complete(&self.model_config, system, messages, &[])
.await
}
}
@@ -0,0 +1,106 @@
//! Provider-level compaction: retry a completion that overflowed the context
//! window against a compacted history instead of surfacing the error.
use std::sync::Arc;
use async_trait::async_trait;
use goose_providers::base::{MessageStream, Provider};
use goose_providers::conversation::message::Message;
use goose_providers::conversation::token_usage::ProviderUsage;
use goose_providers::errors::ProviderError;
use goose_providers::model::ModelConfig;
use crate::model::ProviderModel;
use crate::templates::Templates;
/// Wraps a provider so a `ContextLengthExceeded` response triggers compaction
/// and one retry with the summary standing in for the prior history.
pub struct CompactingProvider {
inner: Arc<dyn Provider>,
templates: Templates,
}
impl CompactingProvider {
pub fn new(inner: Arc<dyn Provider>) -> Self {
Self {
inner,
templates: Templates::default(),
}
}
pub fn with_templates(mut self, templates: Templates) -> Self {
self.templates = templates;
self
}
async fn compacted_messages(
&self,
model_config: &ModelConfig,
messages: &[Message],
) -> Result<Vec<Message>, ProviderError> {
let model = ProviderModel::new(self.inner.clone(), model_config.clone());
let summary = crate::summarize(&model, None, &self.templates, messages)
.await
.map_err(|error| ProviderError::ExecutionError(error.to_string()))?;
Ok(vec![summary.message])
}
}
#[async_trait]
impl Provider for CompactingProvider {
fn get_name(&self) -> &str {
self.inner.get_name()
}
async fn stream(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[rmcp::model::Tool],
) -> Result<MessageStream, ProviderError> {
match self
.inner
.stream(model_config, system, messages, tools)
.await
{
Err(ProviderError::ContextLengthExceeded(_)) => {
let compacted = self.compacted_messages(model_config, messages).await?;
self.inner
.stream(model_config, system, &compacted, tools)
.await
}
other => other,
}
}
async fn complete(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[rmcp::model::Tool],
) -> Result<(Message, ProviderUsage), ProviderError> {
match self
.inner
.complete(model_config, system, messages, tools)
.await
{
Err(ProviderError::ContextLengthExceeded(_)) => {
let compacted = self.compacted_messages(model_config, messages).await?;
self.inner
.complete(model_config, system, &compacted, tools)
.await
}
other => other,
}
}
async fn get_context_limit(&self, model_config: &ModelConfig) -> Result<usize, ProviderError> {
self.inner.get_context_limit(model_config).await
}
fn manages_own_context(&self) -> bool {
true
}
}
@@ -1,4 +1,4 @@
use crate::prompt_template::render_template;
use crate::templates::{self, COMPACTION_SUMMARY_TEMPLATE};
use goose_providers::json::safely_parse_json;
use serde::{Deserialize, Serialize};
@@ -138,7 +138,14 @@ impl StructuredSummary {
}
pub fn render(&self) -> Result<String, minijinja::Error> {
render_template("compaction_summary.md", self)
self.render_with(
&templates::builtin_template(COMPACTION_SUMMARY_TEMPLATE)
.expect("builtin compaction summary template"),
)
}
pub fn render_with(&self, template: &str) -> Result<String, minijinja::Error> {
templates::render(template, self)
}
/// Drops blank entries so a response of blank strings counts as empty
@@ -0,0 +1,175 @@
use anyhow::Result;
use goose_providers::conversation::message::{Message, MessageContent};
use goose_providers::conversation::token_usage::ProviderUsage;
use goose_providers::errors::ProviderError;
use rmcp::model::Role;
use serde::Serialize;
use tracing::warn;
use crate::format::format_message_for_compacting;
use crate::model::{CompactionModel, TokenEstimator};
use crate::structured::StructuredSummary;
use crate::templates::{render, Templates};
const REMOVAL_PERCENTAGES: [u32; 5] = [0, 10, 20, 50, 100];
const SUMMARIZE_REQUEST_TEXT: &str =
"Please summarize the conversation history provided in the system prompt.";
#[derive(Serialize)]
struct SummarizeContext {
messages: String,
}
pub struct Summary {
pub message: Message,
pub usage: ProviderUsage,
}
fn has_tool_response(msg: &Message) -> bool {
msg.content
.iter()
.any(|c| matches!(c, MessageContent::ToolResponse(_)))
}
/// Drops tool responses from the middle outwards, where context is least
/// likely to matter, to fit an oversized history into the summarizer.
fn filter_tool_responses(messages: &[Message], remove_percent: u32) -> Vec<&Message> {
if remove_percent == 0 {
return messages.iter().collect();
}
let tool_indices: Vec<usize> = messages
.iter()
.enumerate()
.filter(|(_, msg)| has_tool_response(msg))
.map(|(i, _)| i)
.collect();
if tool_indices.is_empty() {
return messages.iter().collect();
}
let num_to_remove = ((tool_indices.len() * remove_percent as usize) / 100).max(1);
let middle = tool_indices.len() / 2;
let mut indices_to_remove = Vec::new();
for i in 0..num_to_remove {
let offset = i / 2;
if i % 2 == 0 {
if middle > offset {
indices_to_remove.push(tool_indices[middle - offset - 1]);
}
} else if middle + offset < tool_indices.len() {
indices_to_remove.push(tool_indices[middle + offset]);
}
}
messages
.iter()
.enumerate()
.filter(|(i, _)| !indices_to_remove.contains(i))
.map(|(_, msg)| msg)
.collect()
}
/// When the model didn't follow the structured output format (schema-ignoring
/// models, user-customized prompts), the raw response text is kept unchanged
/// as the summary.
fn apply_structured_summary(response: &mut Message, summary_template: &str) {
let Some(summary) = StructuredSummary::parse(&response.as_concat_text()) else {
return;
};
match summary.render_with(summary_template) {
Ok(rendered) if !rendered.trim().is_empty() => {
response.content = vec![MessageContent::text(rendered)];
}
Ok(_) => warn!(
"Structured compaction summary rendered empty (broken template override?), keeping raw output"
),
Err(e) => warn!("Failed to render structured compaction summary, keeping raw output: {e}"),
}
}
async fn ensure_usage_tokens(
usage: &mut ProviderUsage,
estimator: &dyn TokenEstimator,
system_prompt: &str,
request: &[Message],
response: &Message,
) {
if usage.usage.input_tokens.is_none() {
let count = estimator.count_chat_tokens(system_prompt, request).await;
usage.usage.input_tokens = Some(count as i32);
}
if usage.usage.output_tokens.is_none() {
let text = response
.content
.iter()
.map(|c| format!("{}", c))
.collect::<Vec<_>>()
.join(" ");
let count = estimator.count_text_tokens(&text).await;
usage.usage.output_tokens = Some(count as i32);
}
if let (Some(input), Some(output)) = (usage.usage.input_tokens, usage.usage.output_tokens) {
usage.usage.total_tokens = Some(input + output);
}
}
/// Summarizes `messages` into a single user-role message, retrying with
/// progressively more tool responses removed when the summarizer itself
/// overflows its context window.
pub async fn summarize(
model: &dyn CompactionModel,
estimator: Option<&dyn TokenEstimator>,
templates: &Templates,
messages: &[Message],
) -> Result<Summary> {
let request = vec![Message::user().with_text(SUMMARIZE_REQUEST_TEXT)];
for (attempt, &remove_percent) in REMOVAL_PERCENTAGES.iter().enumerate() {
let filtered = filter_tool_responses(messages, remove_percent);
let context = SummarizeContext {
messages: filtered
.iter()
.map(|&msg| format_message_for_compacting(msg))
.collect::<Vec<_>>()
.join("\n"),
};
let system_prompt = render(&templates.compaction, &context)?;
match model.complete(&system_prompt, &request).await {
Ok((mut response, mut usage)) => {
response.role = Role::User;
// Usage must reflect the raw model output (billable tokens),
// so estimate before the response is rewritten to the smaller
// rendered summary.
if let Some(estimator) = estimator {
ensure_usage_tokens(&mut usage, estimator, &system_prompt, &request, &response)
.await;
}
apply_structured_summary(&mut response, &templates.summary);
return Ok(Summary {
message: response,
usage,
});
}
Err(ProviderError::ContextLengthExceeded(_))
if attempt < REMOVAL_PERCENTAGES.len() - 1 => {}
Err(ProviderError::ContextLengthExceeded(_)) => {
return Err(anyhow::anyhow!(
"Failed to compact: context limit exceeded even after removing all tool responses"
));
}
Err(e) => return Err(e.into()),
}
}
Err(anyhow::anyhow!(
"Unexpected: exhausted all attempts without returning"
))
}
@@ -0,0 +1,59 @@
use include_dir::{include_dir, Dir};
use minijinja::{Environment, Error as MiniJinjaError, Value as MJValue};
use serde::Serialize;
static PROMPTS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/prompts");
pub const COMPACTION_TEMPLATE: &str = "compaction.md";
pub const COMPACTION_SUMMARY_TEMPLATE: &str = "compaction_summary.md";
pub fn builtin_template(name: &str) -> Option<String> {
PROMPTS
.get_file(name)
.map(|file| String::from_utf8_lossy(file.contents()).to_string())
}
/// Prompt sources for a compaction run, letting callers substitute
/// user-customized templates for the built-in ones.
#[derive(Debug, Clone)]
pub struct Templates {
pub compaction: String,
pub summary: String,
}
impl Default for Templates {
fn default() -> Self {
Self {
compaction: builtin_template(COMPACTION_TEMPLATE).expect("builtin compaction template"),
summary: builtin_template(COMPACTION_SUMMARY_TEMPLATE)
.expect("builtin compaction summary template"),
}
}
}
fn code_fence(code: String) -> String {
let longest_run = code
.chars()
.fold((0usize, 0usize), |(max, run), c| {
if c == '`' {
(max.max(run + 1), run + 1)
} else {
(max, 0)
}
})
.0;
let fence = "`".repeat((longest_run + 1).max(3));
format!("{fence}\n{}\n{fence}", code.trim_end_matches('\n'))
}
pub fn render<T: Serialize>(template: &str, context: &T) -> Result<String, MiniJinjaError> {
let mut env = Environment::new();
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env.add_filter("code_fence", code_fence);
env.add_template("template", template)?;
let rendered = env
.get_template("template")?
.render(MJValue::from_serialize(context))?;
Ok(rendered.trim().to_string())
}
+2
View File
@@ -21,6 +21,7 @@ required-features = ["uniffi"]
default = []
uniffi = [
"dep:uniffi",
"dep:goose-context-management",
"dep:thiserror",
"dep:anyhow",
"dep:goose-providers",
@@ -39,6 +40,7 @@ agent-client-protocol-schema = { workspace = true }
uniffi = { version = "0.32", features = ["cli"], optional = true }
thiserror = { version = "2", optional = true }
goose-providers = { version = "0.1.0-alpha.5", path = "../goose-providers", features = ["rustls-tls"], optional = true }
goose-context-management = { version = "0.1.0-alpha.5", path = "../goose-context-management", optional = true }
futures = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
tokio = { workspace = true, features = ["rt-multi-thread", "sync"], optional = true }
+91 -1
View File
@@ -25,7 +25,8 @@ use goose_providers::{
utils::sanitize_unicode_tags,
};
use rmcp::model::{
CallToolRequestParams, CallToolResult, Content, ErrorCode, ErrorData, Role, Tool,
CallToolRequestParams, CallToolResult, ContentBlock as Content, ErrorCode, ErrorData, Role,
Tool,
};
use serde_json::Value;
@@ -755,6 +756,95 @@ impl Provider {
) -> Result<ProviderCompletion, GooseError> {
self.handle.complete(model, system, messages, tools).await
}
/// Summarizes a conversation down to a single message so it can continue
/// past this model's context window.
pub async fn compact(
&self,
model_name: String,
messages: Vec<CompactionMessage>,
templates: Option<CompactionTemplates>,
) -> Result<CompactionSummary, GooseError> {
let messages: Vec<Message> = messages
.iter()
.map(CompactionMessage::to_goose_message)
.collect();
let templates = templates.map(Into::into).unwrap_or_default();
let model = goose_context_management::ProviderModel::new(
self.handle.provider.clone(),
ModelConfig::new(&model_name),
);
let summary = run_on_runtime(async move {
goose_context_management::summarize(&model, None, &templates, &messages).await
})
.await?
.map_err(GooseError::generic)?;
Ok(CompactionSummary {
text: summary.message.as_concat_text(),
input_tokens: summary.usage.usage.input_tokens,
output_tokens: summary.usage.usage.output_tokens,
total_tokens: summary.usage.usage.total_tokens,
})
}
}
/// A text-only message. Compaction reads conversations as text, so this is the
/// whole input shape callers need across the language boundary.
#[derive(Debug, Clone, uniffi::Record)]
pub struct CompactionMessage {
pub role: MessageRole,
pub text: String,
}
impl CompactionMessage {
fn to_goose_message(&self) -> Message {
let role = match self.role {
MessageRole::User | MessageRole::Tool => Role::User,
MessageRole::Assistant => Role::Assistant,
};
let mut message = match role {
Role::User => Message::user(),
Role::Assistant => Message::assistant(),
}
.with_text(&self.text);
message.role = role;
message
}
}
/// Overrides for the summarization and summary-rendering prompts.
#[derive(Debug, Clone, uniffi::Record)]
pub struct CompactionTemplates {
pub compaction: String,
pub summary: String,
}
impl From<CompactionTemplates> for goose_context_management::Templates {
fn from(value: CompactionTemplates) -> Self {
Self {
compaction: value.compaction,
summary: value.summary,
}
}
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct CompactionSummary {
pub text: String,
pub input_tokens: Option<i32>,
pub output_tokens: Option<i32>,
pub total_tokens: Option<i32>,
}
#[uniffi::export]
pub fn default_compaction_templates() -> CompactionTemplates {
let templates = goose_context_management::Templates::default();
CompactionTemplates {
compaction: templates.compaction,
summary: templates.summary,
}
}
#[uniffi::export]
+1
View File
@@ -135,6 +135,7 @@ strum = { workspace = true }
once_cell = { workspace = true }
etcetera = { workspace = true }
fs-err = { version = "3.1", default-features = false }
goose-context-management = { path = "../goose-context-management" }
goose-providers = { path = "../goose-providers", default-features = false }
goose-download-manager = { path = "../goose-download-manager" }
goose-sdk-types = { path = "../goose-sdk-types" }
+65 -225
View File
@@ -1,10 +1,8 @@
pub mod structured;
pub use goose_context_management::structured;
use crate::context_mgmt::structured::StructuredSummary;
use crate::conversation::message::{ActionRequiredData, MessageMetadata};
use crate::conversation::message::MessageMetadata;
use crate::conversation::message::{Message, MessageContent};
use crate::conversation::{merge_consecutive_messages, Conversation};
use crate::prompt_template::render_template;
use crate::providers::base::Provider;
#[cfg(test)]
use crate::providers::base::{stream_from_single_message, MessageStream};
@@ -17,13 +15,12 @@ use indoc::indoc;
use rmcp::model::Role;
#[cfg(test)]
use rmcp::model::{Annotations, ContentBlock, TextContent};
use serde::Serialize;
use std::sync::Arc;
use tokio::task::JoinHandle;
use tracing::info;
use tracing::log::warn;
pub const DEFAULT_COMPACTION_THRESHOLD: f64 = 0.8;
pub use goose_context_management::DEFAULT_COMPACTION_THRESHOLD;
pub(crate) const TOOLCALL_SUMMARIZATION_BATCH_SIZE: usize = 10;
@@ -48,11 +45,6 @@ const MANUAL_COMPACT_CONTINUATION_TEXT: &str =
Do not mention that you read a summary or that conversation summarization occurred.
Just continue the conversation naturally based on the summarized context.";
#[derive(Serialize)]
struct SummarizeContext {
messages: String,
}
pub struct CompactionResult {
pub conversation: Conversation,
/// Billable usage of the summarization call, counting the raw model
@@ -284,54 +276,61 @@ pub async fn check_if_compaction_needed(
Ok(needs_compaction)
}
fn filter_tool_responses(messages: &[Message], remove_percent: u32) -> Vec<&Message> {
fn has_tool_response(msg: &Message) -> bool {
msg.content
.iter()
.any(|c| matches!(c, MessageContent::ToolResponse(_)))
struct FastCompactionModel<'a> {
provider: &'a dyn Provider,
model_config: &'a ModelConfig,
session_id: &'a str,
}
#[async_trait::async_trait]
impl goose_context_management::CompactionModel for FastCompactionModel<'_> {
async fn complete(
&self,
system: &str,
messages: &[Message],
) -> Result<(Message, ProviderUsage), ProviderError> {
crate::model_config::complete_fast(
self.provider,
self.model_config,
self.session_id,
system,
messages,
&[],
)
.await
}
}
if remove_percent == 0 {
return messages.iter().collect();
}
struct GooseTokenEstimator;
let tool_indices: Vec<usize> = messages
.iter()
.enumerate()
.filter(|(_, msg)| has_tool_response(msg))
.map(|(i, _)| i)
.collect();
if tool_indices.is_empty() {
return messages.iter().collect();
}
let num_to_remove = ((tool_indices.len() * remove_percent as usize) / 100).max(1);
let middle = tool_indices.len() / 2;
let mut indices_to_remove = Vec::new();
// Middle out
for i in 0..num_to_remove {
if i % 2 == 0 {
let offset = i / 2;
if middle > offset {
indices_to_remove.push(tool_indices[middle - offset - 1]);
}
} else {
let offset = i / 2;
if middle + offset < tool_indices.len() {
indices_to_remove.push(tool_indices[middle + offset]);
#[async_trait::async_trait]
impl goose_context_management::TokenEstimator for GooseTokenEstimator {
async fn count_chat_tokens(&self, system: &str, messages: &[Message]) -> usize {
match create_token_counter().await {
Ok(counter) => counter.count_chat_tokens(system, messages, &[]),
Err(error) => {
warn!("Failed to create token counter: {error}");
0
}
}
}
messages
.iter()
.enumerate()
.filter(|(i, _)| !indices_to_remove.contains(i))
.map(|(_, msg)| msg)
.collect()
async fn count_text_tokens(&self, text: &str) -> usize {
match create_token_counter().await {
Ok(counter) => counter.count_tokens(text),
Err(error) => {
warn!("Failed to create token counter: {error}");
0
}
}
}
}
fn compaction_templates() -> Result<goose_context_management::Templates> {
Ok(goose_context_management::Templates {
compaction: crate::prompt_template::template_source("compaction.md")?,
summary: crate::prompt_template::template_source("compaction_summary.md")?,
})
}
async fn do_compact(
@@ -349,182 +348,23 @@ async fn do_compact(
)
.agent_visible_messages();
// Try progressively removing more tool response messages from the middle to reduce context length
let removal_percentages = [0, 10, 20, 50, 100];
for (attempt, &remove_percent) in removal_percentages.iter().enumerate() {
let filtered_messages = filter_tool_responses(&agent_visible_messages, remove_percent);
let messages_text = filtered_messages
.iter()
.map(|&msg| format_message_for_compacting(msg))
.collect::<Vec<_>>()
.join("\n");
let context = SummarizeContext {
messages: messages_text,
};
let system_prompt = render_template("compaction.md", &context)?;
let user_message = Message::user()
.with_text("Please summarize the conversation history provided in the system prompt.");
let summarization_request = vec![user_message];
match crate::model_config::complete_fast(
provider,
model_config,
session_id,
&system_prompt,
&summarization_request,
&[],
)
.await
{
Ok((mut response, mut provider_usage)) => {
response.role = Role::User;
// Usage must reflect the raw model output (billable tokens),
// so estimate before the response is rewritten to the smaller
// rendered summary.
crate::providers::usage_estimator::ensure_usage_tokens(
&mut provider_usage,
&system_prompt,
&summarization_request,
&response,
&[],
)
.await
.map_err(|e| anyhow::anyhow!("Failed to ensure usage tokens: {}", e))?;
apply_structured_summary(&mut response);
return Ok((response, provider_usage));
}
Err(e) => {
if matches!(e, ProviderError::ContextLengthExceeded(_)) {
if attempt < removal_percentages.len() - 1 {
continue;
} else {
return Err(anyhow::anyhow!(
"Failed to compact: context limit exceeded even after removing all tool responses"
));
}
}
return Err(e.into());
}
}
}
Err(anyhow::anyhow!(
"Unexpected: exhausted all attempts without returning"
))
}
/// When the model didn't follow the structured output format (schema-ignoring
/// models, user-customized prompts), the raw response text is kept unchanged
/// as the summary.
fn apply_structured_summary(response: &mut Message) {
let Some(summary) = StructuredSummary::parse(&response.as_concat_text()) else {
return;
let model = FastCompactionModel {
provider,
model_config,
session_id,
};
match summary.render() {
Ok(rendered) if !rendered.trim().is_empty() => {
response.content = vec![MessageContent::text(rendered)];
}
Ok(_) => warn!(
"Structured compaction summary rendered empty (broken template override?), keeping raw output"
),
Err(e) => warn!(
"Failed to render structured compaction summary, keeping raw output: {}",
e
),
}
let summary = goose_context_management::summarize(
&model,
Some(&GooseTokenEstimator),
&compaction_templates()?,
&agent_visible_messages,
)
.await?;
Ok((summary.message, summary.usage))
}
pub fn format_message_for_compacting(msg: &Message) -> String {
let content_parts: Vec<String> = msg
.content
.iter()
.filter_map(|content| match content {
MessageContent::Text(text) => Some(text.text.clone()),
MessageContent::Image(img) => Some(format!("[image: {}]", img.mime_type)),
MessageContent::ToolRequest(req) => {
if let Ok(call) = &req.tool_call {
Some(format!(
"tool_request({}): {}",
call.name,
serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "<<invalid json>>".to_string())
))
} else {
Some("tool_request: [error]".to_string())
}
}
MessageContent::ToolResponse(res) => {
if let Ok(result) = &res.tool_result {
let text_items: Vec<String> = result
.content
.iter()
.filter_map(|content| {
content.as_text().map(|text_str| text_str.text.clone())
})
.collect();
if !text_items.is_empty() {
Some(format!("tool_response: {}", text_items.join("\n")))
} else {
Some("tool_response: [non-text content]".to_string())
}
} else {
Some("tool_response: [error]".to_string())
}
}
MessageContent::ToolConfirmationRequest(req) => {
Some(format!("tool_confirmation_request: {}", req.tool_name))
}
MessageContent::ActionRequired(action) => match &action.data {
ActionRequiredData::ToolConfirmation { tool_name, .. } => {
Some(format!("action_required(tool_confirmation): {}", tool_name))
}
ActionRequiredData::Elicitation { message, .. } => {
Some(format!("action_required(elicitation): {}", message))
}
ActionRequiredData::ElicitationResponse { id, .. } => {
Some(format!("action_required(elicitation_response): {}", id))
}
ActionRequiredData::ToolConfirmationResponse { id, .. } => Some(format!(
"action_required(tool_confirmation_response): {}",
id
)),
},
MessageContent::FrontendToolRequest(req) => {
if let Ok(call) = &req.tool_call {
Some(format!("frontend_tool_request: {}", call.name))
} else {
Some("frontend_tool_request: [error]".to_string())
}
}
MessageContent::Thinking(_) => None,
MessageContent::RedactedThinking(_) => None,
MessageContent::SystemNotification(notification) => {
Some(format!("system_notification: {}", notification.msg))
}
MessageContent::Error(error) => Some(format!("error: {}", error.message)),
})
.collect();
let role_str = match msg.role {
Role::User => "user",
Role::Assistant => "assistant",
};
if content_parts.is_empty() {
format!("[{}]: <empty message>", role_str)
} else {
format!("[{}]: {}", role_str, content_parts.join("\n"))
}
}
pub use goose_context_management::format_message_for_compacting;
pub fn compute_tool_call_cutoff(context_limit: usize, compaction_threshold: f64) -> usize {
let threshold = if compaction_threshold > 0.0 && compaction_threshold <= 1.0 {
+29 -9
View File
@@ -64,6 +64,13 @@ pub struct Template {
pub is_customized: bool,
}
fn builtin_content(name: &str) -> Option<String> {
CORE_PROMPTS_DIR
.get_file(name)
.map(|file| String::from_utf8_lossy(file.contents()).to_string())
.or_else(|| goose_context_management::templates::builtin_template(name))
}
fn user_prompts_dir() -> PathBuf {
Paths::config_dir().join("prompts")
}
@@ -121,24 +128,39 @@ pub fn render_template<T: Serialize>(name: &str, context: &T) -> Result<String,
)
})?
} else {
let file = CORE_PROMPTS_DIR.get_file(name).ok_or_else(|| {
builtin_content(name).ok_or_else(|| {
MiniJinjaError::new(
minijinja::ErrorKind::TemplateNotFound,
format!("Built-in template '{}' not found", name),
)
})?;
String::from_utf8_lossy(file.contents()).to_string()
})?
};
render_string(&template_str, context)
}
pub fn template_source(name: &str) -> Result<String, MiniJinjaError> {
let user_path = user_prompts_dir().join(name);
if user_path.exists() {
return std::fs::read_to_string(&user_path).map_err(|e| {
MiniJinjaError::new(
minijinja::ErrorKind::InvalidOperation,
format!("Failed to read user template: {}", e),
)
});
}
builtin_content(name).ok_or_else(|| {
MiniJinjaError::new(
minijinja::ErrorKind::TemplateNotFound,
format!("Built-in template '{}' not found", name),
)
})
}
pub fn get_template(name: &str) -> Option<Template> {
let (_, description) = TEMPLATE_REGISTRY.iter().find(|(n, _)| *n == name)?;
let default_content = CORE_PROMPTS_DIR
.get_file(name)
.map(|file| String::from_utf8_lossy(file.contents()).to_string())?;
let default_content = builtin_content(name)?;
let user_path = user_prompts_dir().join(name);
let user_content = if user_path.exists() {
@@ -193,9 +215,7 @@ pub fn list_templates() -> Vec<Template> {
TEMPLATE_REGISTRY
.iter()
.filter_map(|(name, description)| {
let default_content = CORE_PROMPTS_DIR
.get_file(name)
.map(|file| String::from_utf8_lossy(file.contents()).to_string())?;
let default_content = builtin_content(name)?;
let user_path = user_prompts_dir().join(name);
let user_content = if user_path.exists() {