1use crate::agent::Laser;
2use crate::error::LaserError;
3use crate::provenance::{AgentTopic, Provenance};
4use crate::types::{AgentId, ConversationId, MessageId};
5use iggy::prelude::*;
6use std::collections::{BTreeMap, HashSet};
7
8pub(crate) const READ_BATCH: u32 = 1000;
9
10pub const CONTEXT_READ_WINDOW: usize = 10_000;
16
17const _: () = assert!(CONTEXT_READ_WINDOW == crate::poll::MAX_DRAIN_MESSAGES);
18
19#[derive(Debug, Clone)]
21pub struct ContextMessage {
22 pub id: MessageId,
24 pub provenance: Provenance,
27 pub payload: Vec<u8>,
30 pub envelope: Option<laser_wire::agent::AgentEnvelope>,
32 pub topic: String,
34 pub timestamp_micros: u64,
36 pub stream_id: u32,
38 pub topic_id: u32,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50pub struct Checkpoint {
51 per_topic: BTreeMap<String, BTreeMap<u32, u64>>,
52}
53
54impl Checkpoint {
55 pub(crate) fn from_topic_offsets(per_topic: BTreeMap<String, BTreeMap<u32, u64>>) -> Self {
56 Self { per_topic }
57 }
58
59 pub fn topic_offsets(&self, topic: &str) -> Option<&BTreeMap<u32, u64>> {
62 self.per_topic.get(topic)
63 }
64
65 pub fn topics(&self) -> impl Iterator<Item = (&str, &BTreeMap<u32, u64>)> {
67 self.per_topic
68 .iter()
69 .map(|(topic, offsets)| (topic.as_str(), offsets))
70 }
71
72 pub fn is_empty(&self) -> bool {
74 self.per_topic.is_empty()
75 }
76}
77
78pub async fn checkpoint(
83 laser: &Laser,
84 topics: &[AgentTopic<'static>],
85) -> Result<Checkpoint, LaserError> {
86 let stream = Identifier::named(laser.stream_required()?)?;
87 let mut per_topic = BTreeMap::new();
88 for topic in topics {
89 let name = topic.topic_string();
90 let topic_id = topic.as_identifier();
91 let Some(details) = laser.client().get_topic(&stream, &topic_id).await? else {
92 per_topic.insert(name, BTreeMap::new());
93 continue;
94 };
95 let count = crate::poll::bounded_partitions(details.partitions_count);
96 let consumer = Consumer::new(Identifier::named("laser-checkpoint")?);
97 let mut tails = tokio::task::JoinSet::new();
98 for partition in 0..count {
99 let laser = laser.clone();
100 let stream = stream.clone();
101 let topic_id = topic_id.clone();
102 let consumer = consumer.clone();
103 tails.spawn(async move {
104 let offset = crate::poll::current_tail_offset(
105 &laser.client(),
106 &stream,
107 &topic_id,
108 &consumer,
109 partition,
110 )
111 .await?;
112 Ok::<_, LaserError>((partition, offset))
113 });
114 }
115 let mut offsets = BTreeMap::new();
116 while let Some(joined) = tails.join_next().await {
117 let (partition, offset) = joined.map_err(join_failed)??;
118 offsets.insert(partition, offset);
119 }
120 per_topic.insert(name, offsets);
121 }
122 Ok(Checkpoint { per_topic })
123}
124
125pub trait ContextPolicy: Send + Sync {
127 fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage>;
128
129 fn name(&self) -> String {
131 "custom".to_owned()
132 }
133
134 fn version(&self) -> String {
136 "1".to_owned()
137 }
138
139 fn selection(&self, history: &[ContextMessage]) -> Selection {
141 let kept = self.select(history);
142 let dropped = history
143 .iter()
144 .filter(|message| {
145 !kept
146 .iter()
147 .any(|kept| kept.id == message.id && kept.topic == message.topic)
148 })
149 .cloned()
150 .collect();
151 Selection {
152 kept,
153 dropped,
154 reason: self.name(),
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct Selection {
162 pub kept: Vec<ContextMessage>,
163 pub dropped: Vec<ContextMessage>,
164 pub reason: String,
165}
166
167pub struct LastN(pub usize);
169
170impl ContextPolicy for LastN {
171 fn name(&self) -> String {
172 format!("last_n({})", self.0)
173 }
174
175 fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage> {
176 let start = history.len().saturating_sub(self.0);
177 history[start..].to_vec()
178 }
179}
180
181pub struct RoleFilter(pub HashSet<AgentId>);
183
184impl ContextPolicy for RoleFilter {
185 fn name(&self) -> String {
186 "role_filter".to_owned()
187 }
188
189 fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage> {
190 history
191 .iter()
192 .filter(|message| {
193 message
194 .provenance
195 .agent
196 .as_ref()
197 .is_some_and(|agent| self.0.contains(agent))
198 })
199 .cloned()
200 .collect()
201 }
202}
203
204pub struct Chain(pub Vec<Box<dyn ContextPolicy>>);
208
209impl ContextPolicy for Chain {
210 fn name(&self) -> String {
211 let names: Vec<String> = self.0.iter().map(|policy| policy.name()).collect();
212 format!("chain({})", names.join(","))
213 }
214
215 fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage> {
216 let mut current = history.to_vec();
217 for policy in &self.0 {
218 current = policy.select(¤t);
219 }
220 current
221 }
222}
223
224pub struct TokenBudget {
230 max_tokens: usize,
231 estimate: Box<dyn Fn(&ContextMessage) -> usize + Send + Sync>,
232}
233
234impl TokenBudget {
235 #[must_use]
237 pub fn new(max_tokens: usize) -> Self {
238 Self {
239 max_tokens,
240 estimate: Box::new(|message| {
241 usize::try_from(laser_wire::agent::estimate_tokens(message.payload.len()))
242 .unwrap_or(usize::MAX)
243 }),
244 }
245 }
246
247 #[must_use]
250 pub fn with_estimator(
251 max_tokens: usize,
252 estimate: impl Fn(&ContextMessage) -> usize + Send + Sync + 'static,
253 ) -> Self {
254 Self {
255 max_tokens,
256 estimate: Box::new(estimate),
257 }
258 }
259}
260
261impl ContextPolicy for TokenBudget {
262 fn name(&self) -> String {
263 format!("token_budget({})", self.max_tokens)
264 }
265
266 fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage> {
267 let mut kept = Vec::new();
271 let mut total = 0usize;
272 for message in history.iter().rev() {
273 let cost = (self.estimate)(message);
274 if !kept.is_empty() && total.saturating_add(cost) > self.max_tokens {
275 break;
276 }
277 total = total.saturating_add(cost);
278 kept.push(message.clone());
279 }
280 kept.reverse();
281 kept
282 }
283}
284
285#[derive(bon::Builder)]
287pub struct ContextAssembler {
288 conversation_id: ConversationId,
289 #[builder(default = false)]
290 across_subconversations: bool,
291 #[builder(default = vec![AgentTopic::Sessions])]
292 topics: Vec<AgentTopic<'static>>,
293 #[builder(default = Box::new(LastN(50)))]
294 policy: Box<dyn ContextPolicy>,
295 #[builder(default)]
302 from_offsets: BTreeMap<u32, u64>,
303 from_checkpoint: Option<Checkpoint>,
306 to_checkpoint: Option<Checkpoint>,
309}
310
311impl ContextAssembler {
312 pub async fn assemble(self, laser: &Laser) -> Result<Vec<ContextMessage>, LaserError> {
318 self.read(laser, ReadSpan::Window).await
319 }
320
321 pub(crate) async fn replay(self, laser: &Laser) -> Result<Vec<ContextMessage>, LaserError> {
324 self.read(laser, ReadSpan::Whole).await
325 }
326
327 async fn read(
328 mut self,
329 laser: &Laser,
330 span: ReadSpan,
331 ) -> Result<Vec<ContextMessage>, LaserError> {
332 let mut seen = std::collections::BTreeSet::new();
334 self.topics
335 .retain(|topic| seen.insert(topic.topic_string()));
336 let stream = Identifier::named(laser.stream_required()?)?;
337 let Some(stream_details) = laser.client().get_stream(&stream).await? else {
338 return Ok(Vec::new());
339 };
340 let stream_id = stream_details.id;
341
342 let mut meta = tokio::task::JoinSet::new();
344 for (topic_idx, topic) in self.topics.iter().enumerate() {
345 let laser = laser.clone();
346 let stream = stream.clone();
347 let topic_id = topic.as_identifier();
348 meta.spawn(async move {
349 let details = laser
350 .client()
351 .get_topic(&stream, &topic_id)
352 .await?
353 .map(|details| {
354 (
355 details.id,
356 crate::poll::bounded_partitions(details.partitions_count),
357 )
358 });
359 Ok::<_, LaserError>((topic_idx, topic_id, details))
360 });
361 }
362 let mut sources = Vec::new();
363 while let Some(joined) = meta.join_next().await {
364 let (topic_idx, topic_id, details) = joined.map_err(join_failed)??;
365 let Some((numeric_topic, count)) = details else {
366 continue;
367 };
368 for partition in 0..count {
369 sources.push((topic_idx, topic_id.clone(), numeric_topic, partition));
370 }
371 }
372
373 let mut drains = tokio::task::JoinSet::new();
375 for (topic_idx, topic_id, numeric_topic, partition) in sources {
376 let laser = laser.clone();
377 let stream = stream.clone();
378 let topic_name = self.topics[topic_idx].topic_string();
379 let from = match &self.from_checkpoint {
380 Some(checkpoint) => checkpoint
381 .topic_offsets(&topic_name)
382 .and_then(|offsets| offsets.get(&partition).copied())
383 .unwrap_or(0),
384 None => self.from_offsets.get(&partition).copied().unwrap_or(0),
385 };
386 let until = self.to_checkpoint.as_ref().map(|checkpoint| {
387 checkpoint
388 .topic_offsets(&topic_name)
389 .and_then(|offsets| offsets.get(&partition).copied())
390 .unwrap_or(0)
391 });
392 let lens = (self.conversation_id, self.across_subconversations);
393 drains.spawn(async move {
394 let client = laser.client();
395 let consumer = Consumer::new(Identifier::named("laser-context-reader")?);
396 let Some(mut range) = partition_range(
397 &client, &stream, &topic_id, &consumer, partition, from, until, span,
398 )
399 .await?
400 else {
401 return Ok::<_, LaserError>((topic_idx, Vec::new()));
402 };
403 let mut matched = Vec::new();
406 loop {
407 let batch = crate::poll::drain_partition(
408 &client, &stream, &topic_id, &consumer, range, READ_BATCH,
409 )
410 .await?;
411 let read = batch.messages.len();
412 for message in batch.messages {
413 let Ok((provenance, envelope)) =
414 crate::agent::provenance_and_envelope(&message)
415 else {
416 continue;
417 };
418 if in_conversation(&provenance, lens.0, lens.1) {
419 matched.push((
420 message.header.timestamp,
421 ContextMessage {
422 id: MessageId::new(partition, message.header.offset),
423 provenance,
424 payload: message.payload.to_vec(),
425 envelope,
426 topic: topic_name.clone(),
427 timestamp_micros: message.header.timestamp,
428 stream_id,
429 topic_id: numeric_topic,
430 },
431 ));
432 }
433 }
434 if !reads_further(span, read, batch.next_offset, range.end) {
435 break;
436 }
437 range.from = batch.next_offset;
438 }
439 Ok::<_, LaserError>((topic_idx, matched))
440 });
441 }
442 let mut collected: Vec<(u64, usize, ContextMessage)> = Vec::new();
443 while let Some(joined) = drains.join_next().await {
444 let (topic_idx, matched) = joined.map_err(join_failed)??;
445 collected.extend(
446 matched
447 .into_iter()
448 .map(|(timestamp, message)| (timestamp, topic_idx, message)),
449 );
450 }
451 collected.sort_by_key(|(timestamp, topic_idx, message)| {
457 (
458 *timestamp,
459 *topic_idx,
460 message.id.partition_id,
461 message.id.offset,
462 )
463 });
464 let ordered: Vec<ContextMessage> = collected
465 .into_iter()
466 .map(|(_, _, message)| message)
467 .collect();
468 Ok(self.policy.select(&ordered))
469 }
470}
471
472#[derive(Clone, Copy, Debug, PartialEq, Eq)]
474enum ReadSpan {
475 Window,
477 Whole,
479}
480
481#[allow(clippy::too_many_arguments)]
483async fn partition_range(
484 client: &IggyClient,
485 stream: &Identifier,
486 topic: &Identifier,
487 consumer: &Consumer,
488 partition: u32,
489 from: u64,
490 until: Option<u64>,
491 span: ReadSpan,
492) -> Result<Option<crate::poll::DrainRange>, LaserError> {
493 let next = match (until, span) {
497 (Some(next), _) => next,
498 (None, ReadSpan::Window) => {
502 let start =
503 crate::poll::tail_anchored_offset(client, stream, topic, consumer, partition, from)
504 .await?;
505 return Ok(Some(crate::poll::DrainRange::open(partition, start)));
506 }
507 (None, ReadSpan::Whole) => {
508 crate::poll::current_tail_offset(client, stream, topic, consumer, partition).await?
509 }
510 };
511 let Some(end) = next.checked_sub(1).filter(|end| *end >= from) else {
512 return Ok(None);
513 };
514 Ok(Some(crate::poll::DrainRange::until(
515 partition,
516 range_start(span, from, end),
517 end,
518 )))
519}
520
521fn range_start(span: ReadSpan, from: u64, end: u64) -> u64 {
525 match span {
526 ReadSpan::Window => from.max(end.saturating_sub(CONTEXT_READ_WINDOW as u64 - 1)),
527 ReadSpan::Whole => from,
528 }
529}
530
531fn reads_further(span: ReadSpan, read: usize, next_offset: u64, end: Option<u64>) -> bool {
535 span == ReadSpan::Whole
536 && read >= CONTEXT_READ_WINDOW
537 && end.is_none_or(|end| next_offset <= end)
538}
539
540fn in_conversation(
541 provenance: &Provenance,
542 conversation: ConversationId,
543 across_subconversations: bool,
544) -> bool {
545 if provenance.conversation_id == conversation {
546 return true;
547 }
548 across_subconversations
549 && (provenance.root_conversation_id == Some(conversation)
550 || provenance.parent_conversation_id == Some(conversation))
551}
552
553fn join_failed(error: tokio::task::JoinError) -> LaserError {
554 LaserError::HandlerConfig(error.to_string())
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn given_a_range_longer_than_the_window_when_replayed_then_should_start_at_its_first_offset() {
563 let end = 25_000;
564 assert_eq!(range_start(ReadSpan::Whole, 0, end), 0);
565 assert_eq!(
566 range_start(ReadSpan::Window, 0, end),
567 end - CONTEXT_READ_WINDOW as u64 + 1
568 );
569 assert_eq!(range_start(ReadSpan::Window, 20_000, end), 20_000);
570 }
571
572 #[test]
573 fn given_a_full_chunk_when_replaying_then_should_read_on_until_the_range_ends() {
574 assert!(reads_further(
575 ReadSpan::Whole,
576 CONTEXT_READ_WINDOW,
577 10_000,
578 Some(24_999)
579 ));
580 assert!(!reads_further(
581 ReadSpan::Whole,
582 CONTEXT_READ_WINDOW,
583 25_000,
584 Some(24_999)
585 ));
586 assert!(
587 !reads_further(ReadSpan::Whole, 42, 10_042, Some(24_999)),
588 "a short chunk reached the tail"
589 );
590 assert!(
591 !reads_further(ReadSpan::Window, CONTEXT_READ_WINDOW, 10_000, Some(24_999)),
592 "a context read covers one window"
593 );
594 }
595
596 #[test]
597 fn given_a_checkpoint_when_queried_by_topic_then_should_return_only_its_own_offsets() {
598 let checkpoint = Checkpoint {
599 per_topic: BTreeMap::from([
600 (
601 "agent.sessions".to_owned(),
602 BTreeMap::from([(0, 5), (1, 2)]),
603 ),
604 ("agent.streams".to_owned(), BTreeMap::new()),
605 ]),
606 };
607 assert_eq!(
608 checkpoint.topic_offsets("agent.sessions"),
609 Some(&BTreeMap::from([(0, 5), (1, 2)]))
610 );
611 assert_eq!(
612 checkpoint.topic_offsets("agent.streams"),
613 Some(&BTreeMap::new())
614 );
615 assert_eq!(checkpoint.topic_offsets("agent.memory"), None);
616 assert!(!checkpoint.is_empty());
617 assert!(Checkpoint::default().is_empty());
618 }
619
620 fn message(agent: &str, offset: u64) -> ContextMessage {
621 ContextMessage {
622 id: MessageId::new(1, offset),
623 provenance: Provenance::builder()
624 .conversation_id(ConversationId::new())
625 .agent(agent.parse().expect("agent id is valid"))
626 .build(),
627 payload: Vec::new(),
628 envelope: None,
629 topic: "agent.sessions".to_owned(),
630 timestamp_micros: offset,
631 stream_id: 1,
632 topic_id: 1,
633 }
634 }
635
636 #[test]
637 fn given_a_history_when_applying_last_n_then_should_keep_the_tail() {
638 let history = vec![message("a", 0), message("b", 1), message("c", 2)];
639 let selected = LastN(2).select(&history);
640 assert_eq!(selected.len(), 2);
641 assert_eq!(selected[0].id.offset, 1);
642 assert_eq!(selected[1].id.offset, 2);
643 }
644
645 #[test]
646 fn given_a_history_over_budget_when_applying_token_budget_then_should_keep_the_recent_tail() {
647 let history: Vec<ContextMessage> = (0..5)
648 .map(|offset| {
649 let mut message = message("a", offset);
650 message.payload = vec![b'x'; 400]; message
652 })
653 .collect();
654 let selected = TokenBudget::new(250).select(&history);
656 assert_eq!(selected.len(), 2);
657 assert_eq!(selected[1].id.offset, 4, "keeps the most-recent tail");
658 }
659
660 #[test]
661 fn given_composed_policies_when_chained_then_should_apply_in_order() {
662 let history = vec![
663 message("planner", 0),
664 message("executor", 1),
665 message("planner", 2),
666 ];
667 let chain = Chain(vec![
668 Box::new(RoleFilter(HashSet::from(["planner"
669 .parse()
670 .expect("planner is a valid agent id")]))),
671 Box::new(LastN(1)),
672 ]);
673 let selected = chain.select(&history);
674 assert_eq!(selected.len(), 1);
675 assert_eq!(
676 selected[0].id.offset, 2,
677 "role filter then last-1 keeps the latest planner"
678 );
679 }
680
681 #[test]
682 fn given_a_history_when_applying_a_role_filter_then_should_keep_only_matching_agents() {
683 let history = vec![message("planner", 0), message("executor", 1)];
684 let planner = HashSet::from(["planner".parse().expect("planner is a valid agent id")]);
685 let selected = RoleFilter(planner).select(&history);
686 assert_eq!(selected.len(), 1);
687 assert_eq!(
688 selected[0]
689 .provenance
690 .agent
691 .as_ref()
692 .expect("agent should be set")
693 .as_str(),
694 "planner"
695 );
696 }
697}