use crate::agent::Laser;
use crate::error::LaserError;
use crate::provenance::{AgentTopic, Provenance};
use crate::types::{AgentId, ConversationId, MessageId};
use iggy::prelude::*;
use std::collections::{BTreeMap, HashSet};
const READ_BATCH: u32 = 1000;
#[derive(Debug, Clone)]
pub struct ContextMessage {
pub id: MessageId,
pub provenance: Provenance,
pub payload: Vec<u8>,
pub envelope: Option<laser_wire::agent::AgentEnvelope>,
}
pub trait ContextPolicy: Send + Sync {
fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage>;
}
pub struct LastN(pub usize);
impl ContextPolicy for LastN {
fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage> {
let start = history.len().saturating_sub(self.0);
history[start..].to_vec()
}
}
pub struct RoleFilter(pub HashSet<AgentId>);
impl ContextPolicy for RoleFilter {
fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage> {
history
.iter()
.filter(|message| {
message
.provenance
.agent
.as_ref()
.is_some_and(|agent| self.0.contains(agent))
})
.cloned()
.collect()
}
}
pub struct Chain(pub Vec<Box<dyn ContextPolicy>>);
impl ContextPolicy for Chain {
fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage> {
let mut current = history.to_vec();
for policy in &self.0 {
current = policy.select(¤t);
}
current
}
}
pub struct TokenBudget {
max_tokens: usize,
estimate: Box<dyn Fn(&ContextMessage) -> usize + Send + Sync>,
}
impl TokenBudget {
#[must_use]
pub fn new(max_tokens: usize) -> Self {
Self {
max_tokens,
estimate: Box::new(|message| message.payload.len().div_ceil(4)),
}
}
#[must_use]
pub fn with_estimator(
max_tokens: usize,
estimate: impl Fn(&ContextMessage) -> usize + Send + Sync + 'static,
) -> Self {
Self {
max_tokens,
estimate: Box::new(estimate),
}
}
}
impl ContextPolicy for TokenBudget {
fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage> {
let mut kept = Vec::new();
let mut total = 0usize;
for message in history.iter().rev() {
let cost = (self.estimate)(message);
if !kept.is_empty() && total.saturating_add(cost) > self.max_tokens {
break;
}
total = total.saturating_add(cost);
kept.push(message.clone());
}
kept.reverse();
kept
}
}
#[derive(bon::Builder)]
pub struct ContextAssembler {
conversation_id: ConversationId,
#[builder(default = false)]
across_subconversations: bool,
#[builder(default = vec![AgentTopic::Commands, AgentTopic::Responses])]
topics: Vec<AgentTopic<'static>>,
#[builder(default = Box::new(LastN(50)))]
policy: Box<dyn ContextPolicy>,
#[builder(default)]
from_offsets: BTreeMap<u32, u64>,
}
impl ContextAssembler {
pub async fn assemble(self, laser: &Laser) -> Result<Vec<ContextMessage>, LaserError> {
let stream = Identifier::named(laser.stream_required()?)?;
let mut meta = tokio::task::JoinSet::new();
for (topic_idx, topic) in self.topics.iter().enumerate() {
let laser = laser.clone();
let stream = stream.clone();
let topic_id = topic.as_identifier();
meta.spawn(async move {
let count = laser
.client()
.get_topic(&stream, &topic_id)
.await?
.map(|details| crate::poll::bounded_partitions(details.partitions_count));
Ok::<_, LaserError>((topic_idx, topic_id, count))
});
}
let mut sources = Vec::new();
while let Some(joined) = meta.join_next().await {
let (topic_idx, topic_id, count) = joined.map_err(join_failed)??;
for partition in 0..count.unwrap_or(0) {
sources.push((topic_idx, topic_id.clone(), partition));
}
}
let mut drains = tokio::task::JoinSet::new();
for (topic_idx, topic_id, partition) in sources {
let laser = laser.clone();
let stream = stream.clone();
let start = self.from_offsets.get(&partition).copied().unwrap_or(0);
drains.spawn(async move {
let consumer = Consumer::new(Identifier::named("laser-context-reader")?);
let start = crate::poll::tail_anchored_offset(
laser.client(),
&stream,
&topic_id,
&consumer,
partition,
start,
)
.await?;
let batch = crate::poll::drain_partition(
laser.client(),
&stream,
&topic_id,
&consumer,
partition,
start,
READ_BATCH,
)
.await?;
Ok::<_, LaserError>((topic_idx, partition, batch.messages))
});
}
let mut collected: Vec<(u64, usize, ContextMessage)> = Vec::new();
while let Some(joined) = drains.join_next().await {
let (topic_idx, partition, messages) = joined.map_err(join_failed)??;
for message in messages {
let Ok((provenance, envelope)) = crate::agent::provenance_and_envelope(&message)
else {
continue;
};
if self.matches(&provenance) {
collected.push((
message.header.timestamp,
topic_idx,
ContextMessage {
id: MessageId::new(partition, message.header.offset),
provenance,
payload: message.payload.to_vec(),
envelope,
},
));
}
}
}
collected.sort_by_key(|(timestamp, topic_idx, message)| {
(
*timestamp,
*topic_idx,
message.id.partition_id,
message.id.offset,
)
});
let ordered: Vec<ContextMessage> = collected
.into_iter()
.map(|(_, _, message)| message)
.collect();
Ok(self.policy.select(&ordered))
}
fn matches(&self, provenance: &Provenance) -> bool {
if provenance.conversation_id == self.conversation_id {
return true;
}
self.across_subconversations
&& (provenance.root_conversation_id == Some(self.conversation_id)
|| provenance.parent_conversation_id == Some(self.conversation_id))
}
}
fn join_failed(error: tokio::task::JoinError) -> LaserError {
LaserError::HandlerConfig(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn message(agent: &str, offset: u64) -> ContextMessage {
ContextMessage {
id: MessageId::new(1, offset),
provenance: Provenance::builder()
.conversation_id(ConversationId::new())
.agent(agent.parse().expect("agent id is valid"))
.build(),
payload: Vec::new(),
envelope: None,
}
}
#[test]
fn given_a_history_when_applying_last_n_then_should_keep_the_tail() {
let history = vec![message("a", 0), message("b", 1), message("c", 2)];
let selected = LastN(2).select(&history);
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].id.offset, 1);
assert_eq!(selected[1].id.offset, 2);
}
#[test]
fn given_a_history_over_budget_when_applying_token_budget_then_should_keep_the_recent_tail() {
let history: Vec<ContextMessage> = (0..5)
.map(|offset| {
let mut message = message("a", offset);
message.payload = vec![b'x'; 400]; message
})
.collect();
let selected = TokenBudget::new(250).select(&history);
assert_eq!(selected.len(), 2);
assert_eq!(selected[1].id.offset, 4, "keeps the most-recent tail");
}
#[test]
fn given_composed_policies_when_chained_then_should_apply_in_order() {
let history = vec![
message("planner", 0),
message("executor", 1),
message("planner", 2),
];
let chain = Chain(vec![
Box::new(RoleFilter(HashSet::from(["planner"
.parse()
.expect("planner is a valid agent id")]))),
Box::new(LastN(1)),
]);
let selected = chain.select(&history);
assert_eq!(selected.len(), 1);
assert_eq!(
selected[0].id.offset, 2,
"role filter then last-1 keeps the latest planner"
);
}
#[test]
fn given_a_history_when_applying_a_role_filter_then_should_keep_only_matching_agents() {
let history = vec![message("planner", 0), message("executor", 1)];
let planner = HashSet::from(["planner".parse().expect("planner is a valid agent id")]);
let selected = RoleFilter(planner).select(&history);
assert_eq!(selected.len(), 1);
assert_eq!(
selected[0]
.provenance
.agent
.as_ref()
.expect("agent should be set")
.as_str(),
"planner"
);
}
}