use a2a_agents_common::context::{ContextBudget, Turn};
use a2a_rs::domain::{Conversation, Message, Role, part};
use crate::core::config::ContextConfig;
pub fn budget_from(config: &ContextConfig) -> ContextBudget {
ContextBudget {
max_input_tokens: if config.max_input_tokens == 0 {
usize::MAX
} else {
config.max_input_tokens
},
reserve_for_output: config.reserve_for_output,
compact_at: f32::from(config.compact_at_percent.min(100)) / 100.0,
keep_recent_turns: config.keep_recent_turns,
max_tool_result_chars: config.max_tool_result_chars,
}
}
pub fn turns_from(conversation: &Conversation) -> Vec<Turn> {
conversation
.tail
.iter()
.filter_map(|entry| {
let text = text_of(&entry.message);
if text.trim().is_empty() {
return None;
}
match entry.message.role.as_known()? {
Role::User => Some(Turn::user(text)),
Role::Agent => Some(Turn::agent(text)),
_ => None,
}
})
.collect()
}
pub fn text_of(message: &Message) -> String {
message
.parts
.iter()
.filter_map(|p| match &p.content {
Some(part::Content::Text(text)) => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ")
}
pub const SUMMARY_INSTRUCTION: &str = "\
Summarize the conversation above for your own future reference. You are writing \
notes to yourself, not a report for a reader.
Keep: decisions made, facts established, values and identifiers mentioned, what \
the user is trying to achieve, and anything still unresolved. Drop: pleasantries, \
restatements, and anything already superseded.
Write it as compact plain text. Do not add commentary about the summary itself.";
#[cfg(test)]
mod tests {
use super::*;
use a2a_rs::domain::{Digest, Part, Seq, SequencedMessage};
fn message(role: Role, text: &str) -> Message {
Message::builder()
.role(role)
.parts(vec![Part::text(text.to_string())])
.message_id("m".to_string())
.build()
}
fn conversation(messages: Vec<Message>) -> Conversation {
Conversation {
digest: None,
tail: messages
.into_iter()
.enumerate()
.map(|(index, message)| SequencedMessage {
seq: Seq::new(index as u64 + 1),
message,
})
.collect(),
}
}
#[test]
fn roles_carry_across_to_turns() {
let turns = turns_from(&conversation(vec![
message(Role::User, "what is it"),
message(Role::Agent, "Oslo"),
]));
assert_eq!(turns, vec![Turn::user("what is it"), Turn::agent("Oslo")]);
}
#[test]
fn messages_with_no_text_are_dropped() {
let empty = Message::builder()
.role(Role::Agent)
.parts(vec![])
.message_id("m".to_string())
.build();
assert!(turns_from(&conversation(vec![empty])).is_empty());
}
#[test]
fn a_zero_ceiling_means_no_ceiling() {
let config = ContextConfig {
max_input_tokens: 0,
..ContextConfig::default()
};
assert_eq!(budget_from(&config).max_input_tokens, usize::MAX);
}
#[test]
fn the_percent_threshold_becomes_a_fraction() {
let config = ContextConfig {
compact_at_percent: 75,
..ContextConfig::default()
};
assert!((budget_from(&config).compact_at - 0.75).abs() < f32::EPSILON);
}
#[test]
fn a_percentage_over_a_hundred_is_clamped() {
let config = ContextConfig {
compact_at_percent: 250,
..ContextConfig::default()
};
assert!((budget_from(&config).compact_at - 1.0).abs() < f32::EPSILON);
}
#[test]
fn a_digest_is_not_a_turn() {
let conversation = Conversation {
digest: Some(Digest {
covers_through: Seq::new(3),
summary: "they talked".to_string(),
replaced_messages: 3,
model: "test".to_string(),
}),
tail: vec![SequencedMessage {
seq: Seq::new(4),
message: message(Role::User, "and now"),
}],
};
assert_eq!(turns_from(&conversation), vec![Turn::user("and now")]);
assert_eq!(conversation.summary(), Some("they talked"));
}
}