Skip to main content

laser_sdk/
context_scope.rs

1use crate::agent::{ConversationState, ReplayBound};
2use crate::context::{Checkpoint, ContextAssembler, ContextMessage, ContextPolicy, LastN};
3use crate::error::LaserError;
4use crate::laser::Laser;
5use crate::memory::{
6    ConsolidationReport, Feedback, MemoryBackend, MemoryHandle, MemoryId, MemoryScope,
7    RecallBuilder, RememberBuilder,
8};
9use crate::provenance::{AgentTopic, Provenance};
10use crate::snapshot::SnapshotStore;
11use crate::types::ConversationId;
12
13impl Laser {
14    /// The context accessor: the working record of one `conversation` on the
15    /// log. Append rides an ordinary publish keyed by the conversation, reads
16    /// ride the context assembler, so the context is never a second store.
17    /// Free and synchronous, IO happens at the verbs.
18    pub fn context(&self, conversation: ConversationId) -> ContextScope {
19        ContextScope {
20            laser: self.clone(),
21            conversation,
22        }
23    }
24}
25
26/// One conversation's working context: append to it, read it back bounded, or
27/// render the prompt-ready block. Build it with [`Laser::context`].
28#[derive(Clone)]
29pub struct ContextScope {
30    laser: Laser,
31    conversation: ConversationId,
32}
33
34impl ContextScope {
35    /// Append `payload` to `topic` within this conversation. The provenance is
36    /// pinned to the conversation, so a later [`fetch`](Self::fetch) reads it
37    /// back in order.
38    pub async fn append(
39        &self,
40        topic: AgentTopic<'_>,
41        payload: impl Into<Vec<u8>>,
42    ) -> Result<(), LaserError> {
43        let provenance = Provenance::builder()
44            .conversation_id(self.conversation)
45            .build();
46        self.laser.send_agent(topic, payload, &provenance).await
47    }
48
49    /// Read this conversation's history from `topics`, bounded to the last
50    /// `n` messages. `token_budget` trims the selected messages to an
51    /// estimated token count after `n`, so the read is bounded by turns and by
52    /// prompt size at once. The bound is required: an unbounded read of a
53    /// long-lived conversation is a replay nobody asked for, so the full walk
54    /// has its own deliberate spelling ([`fetch_with`](Self::fetch_with)).
55    pub async fn fetch(
56        &self,
57        topics: Vec<AgentTopic<'static>>,
58        n: usize,
59        token_budget: Option<usize>,
60    ) -> Result<Vec<ContextMessage>, LaserError> {
61        self.fetch_with(topics, bounded_policy(n, token_budget))
62            .await
63    }
64
65    /// Read this conversation's history from `topics` under an explicit
66    /// [`ContextPolicy`], the deep form behind [`fetch`](Self::fetch).
67    pub async fn fetch_with(
68        &self,
69        topics: Vec<AgentTopic<'static>>,
70        policy: Box<dyn ContextPolicy>,
71    ) -> Result<Vec<ContextMessage>, LaserError> {
72        ContextAssembler::builder()
73            .conversation_id(self.conversation)
74            .topics(topics)
75            .policy(policy)
76            .build()
77            .assemble(&self.laser)
78            .await
79    }
80
81    /// The last `n` messages rendered as one newline-joined text block, the
82    /// prompt-ready form (each payload as UTF-8, lossy). `token_budget` trims
83    /// the selected messages to an estimated token count after `n`, so the
84    /// block is bounded by turns and by prompt size at once.
85    pub async fn block(
86        &self,
87        topics: Vec<AgentTopic<'static>>,
88        n: usize,
89        token_budget: Option<usize>,
90    ) -> Result<String, LaserError> {
91        let messages = self.fetch(topics, n, token_budget).await?;
92        Ok(messages
93            .iter()
94            .map(|message| String::from_utf8_lossy(&message.payload))
95            .collect::<Vec<_>>()
96            .join("\n"))
97    }
98
99    /// Rebuild in-memory state by folding this conversation's log under the
100    /// explicit `bound` (the [`ReplayBound`] vocabulary: offsets, last-n, or
101    /// the deliberately spelled full walk).
102    pub async fn state<S, F>(
103        &self,
104        topics: Vec<AgentTopic<'static>>,
105        bound: ReplayBound,
106        init: S,
107        fold: F,
108    ) -> Result<S, LaserError>
109    where
110        F: FnMut(S, &ContextMessage) -> S,
111    {
112        ConversationState::load(&self.laser, self.conversation, topics, bound, init, fold).await
113    }
114
115    /// Like [`state`](Self::state) but seeded through a [`SnapshotStore`]:
116    /// the newest snapshot's state (JSON-decoded into `S`) plus a replay of
117    /// only the tail past it.
118    pub async fn state_with<Store, S, F>(
119        &self,
120        store: &Store,
121        topics: Vec<AgentTopic<'static>>,
122        init: S,
123        fold: F,
124    ) -> Result<S, LaserError>
125    where
126        Store: SnapshotStore + Sync,
127        S: serde::de::DeserializeOwned,
128        F: FnMut(S, &ContextMessage) -> S,
129    {
130        ConversationState::load_with(&self.laser, store, self.conversation, topics, init, fold)
131            .await
132    }
133
134    /// This conversation's memory in `namespace`, pre-scoped so recall and
135    /// remember never repeat the conversation id: the same session the
136    /// messages belong to, seen as memory. Durable facts and the knowledge
137    /// graph stay deliberately cross-conversation (a fact learned in one
138    /// session is worth recalling in the next), so they are reached through
139    /// [`Laser::memory`]/[`Laser::graph`] and not narrowed here.
140    pub fn memory(&self, namespace: impl Into<String>) -> ScopedMemory {
141        self.memory_with(namespace, MemoryBackend::Auto)
142    }
143
144    /// [`memory`](Self::memory) on an explicit [`MemoryBackend`].
145    pub fn memory_with(
146        &self,
147        namespace: impl Into<String>,
148        backend: MemoryBackend,
149    ) -> ScopedMemory {
150        ScopedMemory {
151            handle: self.laser.memory_with(namespace, backend),
152            conversation: self.conversation,
153            origin: None,
154            producer: None,
155        }
156    }
157
158    /// The knowledge graph `name`, reached from this scope for the common flow
159    /// where a task streams messages, keeps session memory, and resolves the
160    /// dependencies between them. The graph is returned unnarrowed on purpose:
161    /// a dependency or knowledge graph is shared across conversations (a
162    /// service-to-component edge holds no matter which task asked), so scoping
163    /// it to one conversation would hide the very relationships the caller
164    /// wants. Identical to [`Laser::graph`], offered here so one scope reaches
165    /// every primitive. Feature `graph`.
166    #[cfg(feature = "graph")]
167    pub fn graph(&self, name: impl Into<String>) -> crate::graph::GraphHandle<'_> {
168        self.laser.graph(name)
169    }
170
171    /// The current tail of `topics` as a [`Checkpoint`]: fold up to it with
172    /// [`ReplayBound::At`] or resume after it with
173    /// [`ReplayBound::FromCheckpoint`]. Offsets are per topic partition, not
174    /// per conversation.
175    pub async fn checkpoint(
176        &self,
177        topics: &[AgentTopic<'static>],
178    ) -> Result<Checkpoint, LaserError> {
179        crate::context::checkpoint(&self.laser, topics).await
180    }
181
182    /// This context's conversation id.
183    pub fn conversation(&self) -> ConversationId {
184        self.conversation
185    }
186
187    /// The `Laser` this scope reads and writes through.
188    pub fn laser(&self) -> &Laser {
189        &self.laser
190    }
191}
192
193// The last `n` messages, trimmed to `token_budget` estimated tokens when set.
194fn bounded_policy(n: usize, token_budget: Option<usize>) -> Box<dyn ContextPolicy> {
195    match token_budget {
196        Some(max_tokens) => Box::new(crate::context::Chain(vec![
197            Box::new(LastN(n)),
198            Box::new(crate::context::TokenBudget::new(max_tokens)),
199        ])),
200        None => Box::new(LastN(n)),
201    }
202}
203
204/// One conversation's memory: a [`MemoryHandle`] with the conversation already
205/// applied, so [`recall`](Self::recall) and [`remember`](Self::remember) take
206/// no conversation argument. Build it with [`ContextScope::memory`]. The
207/// underlying handle stays reachable through [`handle`](Self::handle) for the
208/// cross-conversation verbs (durable remember, graph writes).
209pub struct ScopedMemory {
210    handle: MemoryHandle,
211    conversation: ConversationId,
212    origin: Option<laser_wire::graph::SourceRef>,
213    producer: Option<laser_wire::graph::ProducerInfo>,
214}
215
216impl ScopedMemory {
217    /// Recall within this conversation. Chain `.semantic`/`.keyword`/`.limit`
218    /// and finish with `.fetch().await`, exactly like the unscoped builder
219    /// minus the conversation argument.
220    pub fn recall(&self) -> RecallBuilder<'_> {
221        self.handle.recall(self.conversation)
222    }
223
224    /// Remember `payload` in this conversation's session scope. Chain `.kind`
225    /// /`.dedup` and finish with `.send().await`.
226    pub fn remember(&self, payload: impl Into<Vec<u8>>) -> RememberBuilder<'_> {
227        let mut builder = self.handle.remember(payload).scope(self.conversation);
228        if let Some(origin) = &self.origin {
229            builder = builder.origin(origin.clone());
230        }
231        if let Some(producer) = &self.producer {
232            builder = builder.producer(producer.clone());
233        }
234        builder
235    }
236
237    /// This scope with `origin` and `producer` stamped on every remembered item.
238    #[must_use]
239    pub fn with_lineage(
240        mut self,
241        origin: Option<laser_wire::graph::SourceRef>,
242        producer: Option<laser_wire::graph::ProducerInfo>,
243    ) -> Self {
244        self.origin = origin;
245        self.producer = producer;
246        self
247    }
248
249    /// The one-call context altitude: recall this conversation's most relevant
250    /// items rendered as one prompt-ready block under an optional token budget.
251    pub async fn block(&self, token_budget: Option<usize>) -> Result<String, LaserError> {
252        self.handle.context(self.conversation, token_budget).await
253    }
254
255    /// Keyword recall for `query` within this conversation, up to 50 items.
256    /// It needs no [`Embedder`](crate::memory::Embedder), so it works on the
257    /// default log-backed memory. Await it directly, or chain `.limit(n)` and
258    /// `.folded()` first. Use [`recall`](Self::recall) for semantic or hybrid
259    /// recall.
260    pub fn search(&self, query: impl Into<String>) -> RecallBuilder<'_> {
261        self.recall().keyword(query)
262    }
263
264    /// One consolidation pass over this conversation, keeping the newest
265    /// `max_items` and pruning the rest.
266    pub async fn consolidate(&self, max_items: usize) -> Result<ConsolidationReport, LaserError> {
267        self.handle.consolidate(&self.scope(), max_items).await
268    }
269
270    /// [`consolidate`](Self::consolidate) with the summarize pass, see
271    /// [`MemoryHandle::consolidate_with`].
272    pub async fn consolidate_with(
273        &self,
274        max_items: usize,
275        summarizer: impl crate::memory::Summarizer + Sync,
276        prune_summarized: bool,
277    ) -> Result<ConsolidationReport, LaserError> {
278        self.handle
279            .consolidate_with(&self.scope(), max_items, summarizer, prune_summarized)
280            .await
281    }
282
283    /// Forget the item `id` if it was remembered in this conversation. A
284    /// tombstone for an item of another conversation changes nothing.
285    pub async fn forget(&self, id: MemoryId) -> Result<(), LaserError> {
286        self.handle.forget(&self.scope(), id).await
287    }
288
289    /// Record `feedback` on the item it targets. Like [`forget`](Self::forget),
290    /// it counts only when the item was remembered in this conversation.
291    pub async fn improve(&self, feedback: Feedback) -> Result<MemoryId, LaserError> {
292        self.handle.improve(&self.scope(), feedback).await
293    }
294
295    /// The underlying handle, for the cross-conversation verbs this scoped face
296    /// does not narrow.
297    pub fn handle(&self) -> &MemoryHandle {
298        &self.handle
299    }
300
301    /// This scoped memory's conversation id.
302    pub fn conversation(&self) -> ConversationId {
303        self.conversation
304    }
305
306    /// The origin stamped on every remembered item, if any.
307    pub fn origin(&self) -> Option<&laser_wire::graph::SourceRef> {
308        self.origin.as_ref()
309    }
310
311    /// The producer stamped on every remembered item, if any.
312    pub fn producer(&self) -> Option<&laser_wire::graph::ProducerInfo> {
313        self.producer.as_ref()
314    }
315
316    fn scope(&self) -> MemoryScope {
317        MemoryScope::builder()
318            .conversation(self.conversation)
319            .build()
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::memory::Embedder;
327
328    struct UnitEmbedder;
329
330    impl Embedder for UnitEmbedder {
331        async fn embed(&self, _text: &str) -> Result<Vec<f32>, LaserError> {
332            Ok(vec![1.0])
333        }
334    }
335
336    #[tokio::test]
337    async fn given_an_item_of_another_conversation_when_forgotten_through_a_scope_then_should_keep_it()
338     {
339        let owner = ConversationId::new();
340        let handle = MemoryHandle::vector(UnitEmbedder);
341        let id = handle
342            .remember("a fact")
343            .scope(owner)
344            .send()
345            .await
346            .expect("remember");
347        let elsewhere = ScopedMemory {
348            handle,
349            conversation: ConversationId::new(),
350            origin: None,
351            producer: None,
352        };
353        elsewhere.forget(id).await.expect("forget by id");
354        let left = elsewhere
355            .handle()
356            .recall(owner)
357            .fetch()
358            .await
359            .expect("recall");
360        assert_eq!(left.len(), 1, "another conversation cannot forget the item");
361    }
362
363    struct JoinSummarizer;
364
365    impl crate::memory::Summarizer for JoinSummarizer {
366        async fn summarize(&self, bodies: Vec<Vec<u8>>) -> Result<Vec<u8>, LaserError> {
367            Ok(bodies.concat())
368        }
369    }
370
371    fn scoped(handle: MemoryHandle, conversation: ConversationId) -> ScopedMemory {
372        ScopedMemory {
373            handle,
374            conversation,
375            origin: None,
376            producer: None,
377        }
378    }
379
380    #[tokio::test]
381    async fn given_two_conversations_when_recalled_without_one_then_should_return_both() {
382        let handle = MemoryHandle::vector(UnitEmbedder);
383        for conversation in [ConversationId::new(), ConversationId::new()] {
384            handle
385                .remember("a fact")
386                .scope(conversation)
387                .send()
388                .await
389                .expect("remember");
390        }
391        let everywhere = handle
392            .recall(None)
393            .await
394            .expect("recall every conversation");
395        assert_eq!(everywhere.len(), 2);
396    }
397
398    #[tokio::test]
399    async fn given_a_search_when_limited_then_should_cap_the_items() {
400        let conversation = ConversationId::new();
401        let memory = scoped(MemoryHandle::vector(UnitEmbedder), conversation);
402        for body in ["deploy one", "deploy two", "deploy three"] {
403            memory.remember(body).send().await.expect("remember");
404        }
405        assert_eq!(memory.search("deploy").await.expect("search").len(), 3);
406        let capped = memory.search("deploy").limit(1).await.expect("search");
407        assert_eq!(capped.len(), 1);
408    }
409
410    #[tokio::test]
411    async fn given_message_turns_when_consolidated_with_a_summarizer_then_should_replace_them() {
412        let conversation = ConversationId::new();
413        let memory = scoped(MemoryHandle::vector(UnitEmbedder), conversation);
414        for body in ["one ", "two"] {
415            memory
416                .remember(body)
417                .kind(crate::memory::MemoryKind::Message)
418                .send()
419                .await
420                .expect("remember");
421        }
422        let report = memory
423            .consolidate_with(10, JoinSummarizer, true)
424            .await
425            .expect("consolidate");
426        assert_eq!(report.summarized, 2);
427        assert_eq!(report.pruned, 2);
428    }
429}