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};
pub(crate) const READ_BATCH: u32 = 1000;
pub const CONTEXT_READ_WINDOW: usize = 10_000;
const _: () = assert!(CONTEXT_READ_WINDOW == crate::poll::MAX_DRAIN_MESSAGES);
#[derive(Debug, Clone)]
pub struct ContextMessage {
pub id: MessageId,
pub provenance: Provenance,
pub payload: Vec<u8>,
pub envelope: Option<laser_wire::agent::AgentEnvelope>,
pub topic: String,
pub timestamp_micros: u64,
pub stream_id: u32,
pub topic_id: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Checkpoint {
per_topic: BTreeMap<String, BTreeMap<u32, u64>>,
}
impl Checkpoint {
pub(crate) fn from_topic_offsets(per_topic: BTreeMap<String, BTreeMap<u32, u64>>) -> Self {
Self { per_topic }
}
pub fn topic_offsets(&self, topic: &str) -> Option<&BTreeMap<u32, u64>> {
self.per_topic.get(topic)
}
pub fn topics(&self) -> impl Iterator<Item = (&str, &BTreeMap<u32, u64>)> {
self.per_topic
.iter()
.map(|(topic, offsets)| (topic.as_str(), offsets))
}
pub fn is_empty(&self) -> bool {
self.per_topic.is_empty()
}
}
pub async fn checkpoint(
laser: &Laser,
topics: &[AgentTopic<'static>],
) -> Result<Checkpoint, LaserError> {
let stream = Identifier::named(laser.stream_required()?)?;
let mut per_topic = BTreeMap::new();
for topic in topics {
let name = topic.topic_string();
let topic_id = topic.as_identifier();
let Some(details) = laser.client().get_topic(&stream, &topic_id).await? else {
per_topic.insert(name, BTreeMap::new());
continue;
};
let count = crate::poll::bounded_partitions(details.partitions_count);
let consumer = Consumer::new(Identifier::named("laser-checkpoint")?);
let mut tails = tokio::task::JoinSet::new();
for partition in 0..count {
let laser = laser.clone();
let stream = stream.clone();
let topic_id = topic_id.clone();
let consumer = consumer.clone();
tails.spawn(async move {
let offset = crate::poll::current_tail_offset(
&laser.client(),
&stream,
&topic_id,
&consumer,
partition,
)
.await?;
Ok::<_, LaserError>((partition, offset))
});
}
let mut offsets = BTreeMap::new();
while let Some(joined) = tails.join_next().await {
let (partition, offset) = joined.map_err(join_failed)??;
offsets.insert(partition, offset);
}
per_topic.insert(name, offsets);
}
Ok(Checkpoint { per_topic })
}
pub trait ContextPolicy: Send + Sync {
fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage>;
fn name(&self) -> String {
"custom".to_owned()
}
fn version(&self) -> String {
"1".to_owned()
}
fn selection(&self, history: &[ContextMessage]) -> Selection {
let kept = self.select(history);
let dropped = history
.iter()
.filter(|message| {
!kept
.iter()
.any(|kept| kept.id == message.id && kept.topic == message.topic)
})
.cloned()
.collect();
Selection {
kept,
dropped,
reason: self.name(),
}
}
}
#[derive(Debug, Clone)]
pub struct Selection {
pub kept: Vec<ContextMessage>,
pub dropped: Vec<ContextMessage>,
pub reason: String,
}
pub struct LastN(pub usize);
impl ContextPolicy for LastN {
fn name(&self) -> String {
format!("last_n({})", self.0)
}
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 name(&self) -> String {
"role_filter".to_owned()
}
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 name(&self) -> String {
let names: Vec<String> = self.0.iter().map(|policy| policy.name()).collect();
format!("chain({})", names.join(","))
}
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| {
usize::try_from(laser_wire::agent::estimate_tokens(message.payload.len()))
.unwrap_or(usize::MAX)
}),
}
}
#[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 name(&self) -> String {
format!("token_budget({})", self.max_tokens)
}
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::Sessions])]
topics: Vec<AgentTopic<'static>>,
#[builder(default = Box::new(LastN(50)))]
policy: Box<dyn ContextPolicy>,
#[builder(default)]
from_offsets: BTreeMap<u32, u64>,
from_checkpoint: Option<Checkpoint>,
to_checkpoint: Option<Checkpoint>,
}
impl ContextAssembler {
pub async fn assemble(self, laser: &Laser) -> Result<Vec<ContextMessage>, LaserError> {
self.read(laser, ReadSpan::Window).await
}
pub(crate) async fn replay(self, laser: &Laser) -> Result<Vec<ContextMessage>, LaserError> {
self.read(laser, ReadSpan::Whole).await
}
async fn read(
mut self,
laser: &Laser,
span: ReadSpan,
) -> Result<Vec<ContextMessage>, LaserError> {
let mut seen = std::collections::BTreeSet::new();
self.topics
.retain(|topic| seen.insert(topic.topic_string()));
let stream = Identifier::named(laser.stream_required()?)?;
let Some(stream_details) = laser.client().get_stream(&stream).await? else {
return Ok(Vec::new());
};
let stream_id = stream_details.id;
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 details = laser
.client()
.get_topic(&stream, &topic_id)
.await?
.map(|details| {
(
details.id,
crate::poll::bounded_partitions(details.partitions_count),
)
});
Ok::<_, LaserError>((topic_idx, topic_id, details))
});
}
let mut sources = Vec::new();
while let Some(joined) = meta.join_next().await {
let (topic_idx, topic_id, details) = joined.map_err(join_failed)??;
let Some((numeric_topic, count)) = details else {
continue;
};
for partition in 0..count {
sources.push((topic_idx, topic_id.clone(), numeric_topic, partition));
}
}
let mut drains = tokio::task::JoinSet::new();
for (topic_idx, topic_id, numeric_topic, partition) in sources {
let laser = laser.clone();
let stream = stream.clone();
let topic_name = self.topics[topic_idx].topic_string();
let from = match &self.from_checkpoint {
Some(checkpoint) => checkpoint
.topic_offsets(&topic_name)
.and_then(|offsets| offsets.get(&partition).copied())
.unwrap_or(0),
None => self.from_offsets.get(&partition).copied().unwrap_or(0),
};
let until = self.to_checkpoint.as_ref().map(|checkpoint| {
checkpoint
.topic_offsets(&topic_name)
.and_then(|offsets| offsets.get(&partition).copied())
.unwrap_or(0)
});
let lens = (self.conversation_id, self.across_subconversations);
drains.spawn(async move {
let client = laser.client();
let consumer = Consumer::new(Identifier::named("laser-context-reader")?);
let Some(mut range) = partition_range(
&client, &stream, &topic_id, &consumer, partition, from, until, span,
)
.await?
else {
return Ok::<_, LaserError>((topic_idx, Vec::new()));
};
let mut matched = Vec::new();
loop {
let batch = crate::poll::drain_partition(
&client, &stream, &topic_id, &consumer, range, READ_BATCH,
)
.await?;
let read = batch.messages.len();
for message in batch.messages {
let Ok((provenance, envelope)) =
crate::agent::provenance_and_envelope(&message)
else {
continue;
};
if in_conversation(&provenance, lens.0, lens.1) {
matched.push((
message.header.timestamp,
ContextMessage {
id: MessageId::new(partition, message.header.offset),
provenance,
payload: message.payload.to_vec(),
envelope,
topic: topic_name.clone(),
timestamp_micros: message.header.timestamp,
stream_id,
topic_id: numeric_topic,
},
));
}
}
if !reads_further(span, read, batch.next_offset, range.end) {
break;
}
range.from = batch.next_offset;
}
Ok::<_, LaserError>((topic_idx, matched))
});
}
let mut collected: Vec<(u64, usize, ContextMessage)> = Vec::new();
while let Some(joined) = drains.join_next().await {
let (topic_idx, matched) = joined.map_err(join_failed)??;
collected.extend(
matched
.into_iter()
.map(|(timestamp, message)| (timestamp, topic_idx, message)),
);
}
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))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ReadSpan {
Window,
Whole,
}
#[allow(clippy::too_many_arguments)]
async fn partition_range(
client: &IggyClient,
stream: &Identifier,
topic: &Identifier,
consumer: &Consumer,
partition: u32,
from: u64,
until: Option<u64>,
span: ReadSpan,
) -> Result<Option<crate::poll::DrainRange>, LaserError> {
let next = match (until, span) {
(Some(next), _) => next,
(None, ReadSpan::Window) => {
let start =
crate::poll::tail_anchored_offset(client, stream, topic, consumer, partition, from)
.await?;
return Ok(Some(crate::poll::DrainRange::open(partition, start)));
}
(None, ReadSpan::Whole) => {
crate::poll::current_tail_offset(client, stream, topic, consumer, partition).await?
}
};
let Some(end) = next.checked_sub(1).filter(|end| *end >= from) else {
return Ok(None);
};
Ok(Some(crate::poll::DrainRange::until(
partition,
range_start(span, from, end),
end,
)))
}
fn range_start(span: ReadSpan, from: u64, end: u64) -> u64 {
match span {
ReadSpan::Window => from.max(end.saturating_sub(CONTEXT_READ_WINDOW as u64 - 1)),
ReadSpan::Whole => from,
}
}
fn reads_further(span: ReadSpan, read: usize, next_offset: u64, end: Option<u64>) -> bool {
span == ReadSpan::Whole
&& read >= CONTEXT_READ_WINDOW
&& end.is_none_or(|end| next_offset <= end)
}
fn in_conversation(
provenance: &Provenance,
conversation: ConversationId,
across_subconversations: bool,
) -> bool {
if provenance.conversation_id == conversation {
return true;
}
across_subconversations
&& (provenance.root_conversation_id == Some(conversation)
|| provenance.parent_conversation_id == Some(conversation))
}
fn join_failed(error: tokio::task::JoinError) -> LaserError {
LaserError::HandlerConfig(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_a_range_longer_than_the_window_when_replayed_then_should_start_at_its_first_offset() {
let end = 25_000;
assert_eq!(range_start(ReadSpan::Whole, 0, end), 0);
assert_eq!(
range_start(ReadSpan::Window, 0, end),
end - CONTEXT_READ_WINDOW as u64 + 1
);
assert_eq!(range_start(ReadSpan::Window, 20_000, end), 20_000);
}
#[test]
fn given_a_full_chunk_when_replaying_then_should_read_on_until_the_range_ends() {
assert!(reads_further(
ReadSpan::Whole,
CONTEXT_READ_WINDOW,
10_000,
Some(24_999)
));
assert!(!reads_further(
ReadSpan::Whole,
CONTEXT_READ_WINDOW,
25_000,
Some(24_999)
));
assert!(
!reads_further(ReadSpan::Whole, 42, 10_042, Some(24_999)),
"a short chunk reached the tail"
);
assert!(
!reads_further(ReadSpan::Window, CONTEXT_READ_WINDOW, 10_000, Some(24_999)),
"a context read covers one window"
);
}
#[test]
fn given_a_checkpoint_when_queried_by_topic_then_should_return_only_its_own_offsets() {
let checkpoint = Checkpoint {
per_topic: BTreeMap::from([
(
"agent.sessions".to_owned(),
BTreeMap::from([(0, 5), (1, 2)]),
),
("agent.streams".to_owned(), BTreeMap::new()),
]),
};
assert_eq!(
checkpoint.topic_offsets("agent.sessions"),
Some(&BTreeMap::from([(0, 5), (1, 2)]))
);
assert_eq!(
checkpoint.topic_offsets("agent.streams"),
Some(&BTreeMap::new())
);
assert_eq!(checkpoint.topic_offsets("agent.memory"), None);
assert!(!checkpoint.is_empty());
assert!(Checkpoint::default().is_empty());
}
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,
topic: "agent.sessions".to_owned(),
timestamp_micros: offset,
stream_id: 1,
topic_id: 1,
}
}
#[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"
);
}
}