Skip to main content

laser_sdk/
context.rs

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
10/// The most raw records a context read examines in each partition: the newest
11/// ones, or the newest ones before a [`Checkpoint`] for a point-in-time read.
12/// The conversation filter runs after the read, so on a busy shared partition
13/// turns older than this window are not returned. Python and TypeScript use
14/// the same window.
15pub const CONTEXT_READ_WINDOW: usize = 10_000;
16
17const _: () = assert!(CONTEXT_READ_WINDOW == crate::poll::MAX_DRAIN_MESSAGES);
18
19/// One message read back from the log for context assembly.
20#[derive(Debug, Clone)]
21pub struct ContextMessage {
22    /// Where the message sits on the log.
23    pub id: MessageId,
24    /// Provenance decoded off the message (synthesized from the envelope for an
25    /// AGDX message).
26    pub provenance: Provenance,
27    /// The raw message body. Owned `Vec<u8>` so the public API never leaks the
28    /// `bytes` crate.
29    pub payload: Vec<u8>,
30    /// The decoded AGDX envelope when the message carries one, else `None`.
31    pub envelope: Option<laser_wire::agent::AgentEnvelope>,
32    /// The name of the topic the message was read from.
33    pub topic: String,
34    /// The broker's append time in microseconds.
35    pub timestamp_micros: u64,
36    /// The numeric id of the stream the message was read from.
37    pub stream_id: u32,
38    /// The numeric id of the topic the message was read from.
39    pub topic_id: u32,
40}
41
42/// A point in a conversation's log: the next offset each partition of each
43/// named topic will write, as captured by
44/// [`ContextScope::checkpoint`](crate::context_scope::ContextScope::checkpoint).
45/// A [`ContextAssembler`] built with `to_checkpoint` folds history up to it,
46/// and one built with `from_checkpoint` resumes after it. It is a client-side
47/// bookmark keyed by topic ([`AgentTopic::topic_string`]), never a record on
48/// the log.
49#[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    /// The per-partition offsets for `topic` (by [`AgentTopic::topic_string`]),
60    /// or `None` when the checkpoint was not taken over that topic.
61    pub fn topic_offsets(&self, topic: &str) -> Option<&BTreeMap<u32, u64>> {
62        self.per_topic.get(topic)
63    }
64
65    /// Every checkpointed topic with its partition offsets, by topic name.
66    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    /// True when no named topic was checkpointed.
73    pub fn is_empty(&self) -> bool {
74        self.per_topic.is_empty()
75    }
76}
77
78/// The current tail of `topics` on `laser`'s default stream, one entry per
79/// topic. Every partition that exists at capture time gets an entry,
80/// an empty one at offset `0`, so a later bounded read can tell an empty
81/// partition apart from one created after the checkpoint.
82pub 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
125/// Selects which assembled messages feed an LLM call.
126pub trait ContextPolicy: Send + Sync {
127    fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage>;
128
129    /// The policy name a context manifest records.
130    fn name(&self) -> String {
131        "custom".to_owned()
132    }
133
134    /// The policy version a context manifest records.
135    fn version(&self) -> String {
136        "1".to_owned()
137    }
138
139    /// The kept and dropped records of one selection, and why.
140    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/// What a [`ContextPolicy`] kept and dropped, and the policy that decided.
160#[derive(Debug, Clone)]
161pub struct Selection {
162    pub kept: Vec<ContextMessage>,
163    pub dropped: Vec<ContextMessage>,
164    pub reason: String,
165}
166
167/// Keep the most recent N messages.
168pub 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
181/// Keep only messages from the given agents.
182pub 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
204/// Apply several policies in order, each narrowing the previous result: the
205/// composable form, so a caller pipelines (say) a `RoleFilter` then a `LastN`
206/// then a `TokenBudget` into the one `policy` slot instead of picking exactly one.
207pub 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(&current);
219        }
220        current
221    }
222}
223
224/// Keep the most-recent messages that fit within `max_tokens`, estimated per
225/// message. Meets memory's `to_context_block(token_budget)` in the middle so a
226/// prompt built from history and recalled memory shares one budget notion. The
227/// default estimate is a coarse ~4-bytes-per-token heuristic over the payload.
228/// Pass a real tokenizer with [`with_estimator`](Self::with_estimator).
229pub struct TokenBudget {
230    max_tokens: usize,
231    estimate: Box<dyn Fn(&ContextMessage) -> usize + Send + Sync>,
232}
233
234impl TokenBudget {
235    /// A budget using the coarse `payload.len() / 4` token heuristic.
236    #[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    /// A budget with a caller-supplied per-message token estimate (a real
248    /// tokenizer, or a model-specific counter).
249    #[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        // Walk newest-first, keeping messages while the running estimate fits, so
268        // the kept set is the most-recent tail under budget. Always keep at least
269        // one message so a single over-budget message is not silently dropped.
270        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/// Reads a conversation's history off the log and applies a `ContextPolicy`.
286#[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    /// Per-partition start offsets: partition `p` is read from
296    /// `from_offsets[p]` (default `0`). The incremental-resume seam: a fold
297    /// seeded from a snapshot passes the snapshot's resume offsets here and
298    /// replays only the tail (the bounded-reads law). One map for every topic
299    /// in `topics`. A [`from_checkpoint`](Self::from_checkpoint) takes
300    /// precedence for the entire read, with missing entries starting at zero.
301    #[builder(default)]
302    from_offsets: BTreeMap<u32, u64>,
303    /// Resume after a [`Checkpoint`], per topic and partition, and read to the
304    /// tail.
305    from_checkpoint: Option<Checkpoint>,
306    /// Stop at a [`Checkpoint`], per topic and partition: the point-in-time
307    /// read behind `ReplayBound::At`. `None` reads to the current tail.
308    to_checkpoint: Option<Checkpoint>,
309}
310
311impl ContextAssembler {
312    /// Read the configured topics, order by Iggy timestamp, and apply the policy.
313    /// Every (topic, partition) is drained concurrently: a conversation can span
314    /// many partitions across several topics, and reading them serially makes
315    /// recovery pay one round trip after another. Each partition read covers
316    /// at most the newest [`CONTEXT_READ_WINDOW`] records of its range.
317    pub async fn assemble(self, laser: &Laser) -> Result<Vec<ContextMessage>, LaserError> {
318        self.read(laser, ReadSpan::Window).await
319    }
320
321    // Like `assemble`, but every partition read covers its whole range in
322    // bounded chunks, for a state fold that must see every record.
323    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        // A topic named twice is read once.
333        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        // Resolve each topic's id and partition count concurrently.
343        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        // Drain every partition concurrently.
374        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                // The conversation filter runs per chunk, so a replay holds only
404                // the matching records, never a whole partition.
405                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        // Order by Iggy-assigned timestamp: a single global clock across topics,
452        // since each topic has its own independent offset space. Ties break on
453        // (topic, partition, offset), which is deterministic but not strictly
454        // chronological for messages stamped in the same microsecond on different
455        // topics, ordering Apache Iggy cannot provide across offset spaces.
456        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// How much of its range one partition read covers.
473#[derive(Clone, Copy, Debug, PartialEq, Eq)]
474enum ReadSpan {
475    // The newest `CONTEXT_READ_WINDOW` records, the context window.
476    Window,
477    // Every record, read in bounded chunks.
478    Whole,
479}
480
481// The offsets one partition read covers, `None` when the range is empty.
482#[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    // A checkpoint holds the next offset to write, and so does the tail a
494    // replay pins at its start, so a bounded read ends one before it. Pinning
495    // the tail keeps a replay of a busy partition from chasing new writes.
496    let next = match (until, span) {
497        (Some(next), _) => next,
498        // Context selection keeps the most recent records, so a partition
499        // longer than the drain ceiling is read from a tail-anchored window
500        // rather than from its head.
501        (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
521// Where a read ending at `end` starts. The context window is anchored at
522// `end`, not at a tail that may have moved far past it. A replay starts at
523// `from`.
524fn 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
531// Whether a replay drains another chunk after one that read `read` records
532// and resumes at `next_offset`. A drain stops at its record ceiling, so only a
533// full chunk can leave records behind, and never past `end`.
534fn 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]; // ~100 tokens each (4 bytes/token)
651                message
652            })
653            .collect();
654        // ~250 tokens fits two 100-token messages. The third would cross it.
655        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}