Skip to main content

rig_compose/
context.rs

1//! [`InvestigationContext`] — the runtime object that flows through every
2//! [`super::Skill`] in an agent step.
3//!
4//! Skills mutate the context by appending [`Evidence`] and adjusting
5//! confidence; they do not own it. The owning [`super::Agent`] threads a
6//! single context through its skill chain for one investigation.
7
8use std::time::SystemTime;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use uuid::Uuid;
13
14/// Provider-neutral category for a piece of context that may enter a model
15/// window.
16///
17/// The enum names where the item came from without coupling the kernel to a
18/// concrete backend such as Memvid, MCP, a vector database, or a provider SDK.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum ContextSourceKind {
21    /// Long-term memory, episodic recall, summaries, or structured memory cards.
22    Memory,
23    /// Result returned by a tool call.
24    ToolResult,
25    /// Resource lookup such as a graph, baseline, policy, or document store.
26    Resource,
27    /// File or document content selected for the task.
28    File,
29    /// Working notes, plans, hypotheses, or other non-durable reasoning state.
30    Reasoning,
31    /// System, developer, or application instructions carried into context.
32    Instruction,
33    /// Current user input or task text.
34    UserInput,
35    /// Caller-defined source kind.
36    Other(String),
37}
38
39/// One ranked piece of context that may be packed into a bounded model window.
40///
41/// `ContextItem` is intentionally backend-neutral. Memory crates, MCP/resource
42/// adapters, and harnesses can all project their native records into this shape
43/// so tests can assert what context was selected, omitted, and rendered.
44///
45/// ```rust
46/// use rig_compose::{ContextItem, ContextSourceKind};
47///
48/// let item = ContextItem::new(
49///     ContextSourceKind::Memory,
50///     "profile/alice/location",
51///     "fact alice lives in Berlin",
52/// )
53/// .with_rank(0)
54/// .with_score(9.5);
55///
56/// assert_eq!(item.estimated_chars, item.text.chars().count());
57/// ```
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct ContextItem {
60    /// Backend-neutral source category.
61    pub source: ContextSourceKind,
62    /// Stable id inside the source system.
63    pub source_id: String,
64    /// Zero-based rank after source-local selection.
65    pub rank: usize,
66    /// Relevance score used for ordering within the source or planner.
67    pub score: f64,
68    /// Prompt-ready text.
69    pub text: String,
70    /// Character count estimate for early context packing.
71    pub estimated_chars: usize,
72    /// Source-specific provenance such as frame id, URI, tool call id, or path.
73    pub provenance: Value,
74    /// Caller-defined metadata not required for packing.
75    pub metadata: Value,
76}
77
78impl ContextItem {
79    /// Build a context item with a source, source id, and prompt-ready text.
80    #[must_use]
81    pub fn new(
82        source: ContextSourceKind,
83        source_id: impl Into<String>,
84        text: impl Into<String>,
85    ) -> Self {
86        let text = text.into();
87        Self {
88            source,
89            source_id: source_id.into(),
90            rank: 0,
91            score: 0.0,
92            estimated_chars: text.chars().count(),
93            text,
94            provenance: Value::Null,
95            metadata: Value::Null,
96        }
97    }
98
99    /// Set the source-local rank used by [`ContextPack::pack`].
100    #[must_use]
101    pub fn with_rank(mut self, rank: usize) -> Self {
102        self.rank = rank;
103        self
104    }
105
106    /// Set the relevance score attached by the source or planner.
107    #[must_use]
108    pub fn with_score(mut self, score: f64) -> Self {
109        self.score = score;
110        self
111    }
112
113    /// Override the character estimate when a caller has a better tokenizer or
114    /// sizing approximation.
115    #[must_use]
116    pub fn with_estimated_chars(mut self, estimated_chars: usize) -> Self {
117        self.estimated_chars = estimated_chars;
118        self
119    }
120
121    /// Attach source-specific provenance.
122    #[must_use]
123    pub fn with_provenance(mut self, provenance: Value) -> Self {
124        self.provenance = provenance;
125        self
126    }
127
128    /// Attach caller-defined metadata.
129    #[must_use]
130    pub fn with_metadata(mut self, metadata: Value) -> Self {
131        self.metadata = metadata;
132        self
133    }
134}
135
136/// Reason a context item was not selected for a [`ContextPack`].
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub enum ContextOmissionReason {
139    /// The pack already reached [`ContextPackConfig::max_items`].
140    MaxItems,
141    /// Adding the item would exceed the available character budget.
142    OverBudget,
143}
144
145/// Context item plus the reason it was omitted.
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct OmittedContextItem {
148    /// Item considered by the packer.
149    pub item: ContextItem,
150    /// Why the item was not selected.
151    pub reason: ContextOmissionReason,
152}
153
154/// Configuration for packing context items into a bounded model window.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct ContextPackConfig {
157    /// Maximum characters available to selected item text, including separators.
158    pub max_chars: usize,
159    /// Maximum number of items to include.
160    pub max_items: usize,
161    /// Characters reserved for instructions, user input, or other context.
162    pub reserve_chars: usize,
163    /// Separator inserted between selected item text when rendering.
164    pub separator: String,
165}
166
167impl Default for ContextPackConfig {
168    fn default() -> Self {
169        Self {
170            max_chars: 4_000,
171            max_items: 16,
172            reserve_chars: 0,
173            separator: "\n".into(),
174        }
175    }
176}
177
178impl ContextPackConfig {
179    /// Build a config with a character budget and otherwise default limits.
180    #[must_use]
181    pub fn new(max_chars: usize) -> Self {
182        Self {
183            max_chars,
184            ..Self::default()
185        }
186    }
187
188    /// Set the maximum number of selected items.
189    #[must_use]
190    pub fn with_max_items(mut self, max_items: usize) -> Self {
191        self.max_items = max_items;
192        self
193    }
194
195    /// Reserve part of the character budget for non-packed context.
196    #[must_use]
197    pub fn with_reserve_chars(mut self, reserve_chars: usize) -> Self {
198        self.reserve_chars = reserve_chars;
199        self
200    }
201
202    /// Use a custom separator when rendering selected context.
203    #[must_use]
204    pub fn with_separator(mut self, separator: impl Into<String>) -> Self {
205        self.separator = separator.into();
206        self
207    }
208
209    fn context_budget(&self) -> usize {
210        self.max_chars.saturating_sub(self.reserve_chars)
211    }
212}
213
214/// Selected and omitted context for one bounded model window.
215///
216/// ```rust
217/// use rig_compose::{ContextItem, ContextPack, ContextPackConfig, ContextSourceKind};
218///
219/// let item = ContextItem::new(ContextSourceKind::Memory, "m1", "fact alice lives in Berlin");
220/// let pack = ContextPack::pack(vec![item], ContextPackConfig::new(1_000));
221/// assert_eq!(pack.render_text(), "fact alice lives in Berlin");
222/// ```
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224pub struct ContextPack {
225    /// Configuration used to build this pack.
226    pub config: ContextPackConfig,
227    /// Items selected for prompt context, in render order.
228    pub selected: Vec<ContextItem>,
229    /// Items considered but omitted, with explicit reasons.
230    pub omitted: Vec<OmittedContextItem>,
231    /// Estimated characters consumed by selected text and separators.
232    pub total_estimated_chars: usize,
233}
234
235impl ContextPack {
236    /// Pack ranked context items into the configured character window.
237    ///
238    /// Items are sorted by `rank` before packing so recorded fixtures can be
239    /// replayed even if a source returns equivalent items in a different order.
240    #[must_use]
241    pub fn pack(mut items: Vec<ContextItem>, config: ContextPackConfig) -> Self {
242        items.sort_by_key(|item| item.rank);
243
244        let budget = config.context_budget();
245        let separator_chars = config.separator.chars().count();
246        let mut selected = Vec::new();
247        let mut omitted = Vec::new();
248        let mut total_estimated_chars = 0usize;
249
250        for item in items {
251            if selected.len() >= config.max_items {
252                omitted.push(OmittedContextItem {
253                    item,
254                    reason: ContextOmissionReason::MaxItems,
255                });
256                continue;
257            }
258
259            let item_chars = item.estimated_chars.max(item.text.chars().count());
260            let separator_cost = if selected.is_empty() {
261                0
262            } else {
263                separator_chars
264            };
265            let Some(next_total) = total_estimated_chars
266                .checked_add(separator_cost)
267                .and_then(|total| total.checked_add(item_chars))
268            else {
269                omitted.push(OmittedContextItem {
270                    item,
271                    reason: ContextOmissionReason::OverBudget,
272                });
273                continue;
274            };
275
276            if next_total > budget {
277                omitted.push(OmittedContextItem {
278                    item,
279                    reason: ContextOmissionReason::OverBudget,
280                });
281                continue;
282            }
283
284            total_estimated_chars = next_total;
285            selected.push(item);
286        }
287
288        Self {
289            config,
290            selected,
291            omitted,
292            total_estimated_chars,
293        }
294    }
295
296    /// Render selected item text as prompt-ready context.
297    #[must_use]
298    pub fn render_text(&self) -> String {
299        self.selected
300            .iter()
301            .map(|item| item.text.as_str())
302            .collect::<Vec<_>>()
303            .join(&self.config.separator)
304    }
305}
306
307/// A named, lightweight signal lifted from a sketch, baseline check, or
308/// upstream skill. Skills key their `applies` predicate on signal names.
309#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
310pub struct Signal(pub String);
311
312impl Signal {
313    pub fn new(s: impl Into<String>) -> Self {
314        Self(s.into())
315    }
316    pub fn as_str(&self) -> &str {
317        &self.0
318    }
319}
320
321/// A single piece of evidence accumulated during an investigation.
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct Evidence {
324    pub source_skill: String,
325    pub label: String,
326    pub detail: Value,
327    pub recorded_at: SystemTime,
328}
329
330impl Evidence {
331    pub fn new(source_skill: impl Into<String>, label: impl Into<String>) -> Self {
332        Self {
333            source_skill: source_skill.into(),
334            label: label.into(),
335            detail: Value::Null,
336            recorded_at: SystemTime::now(),
337        }
338    }
339
340    pub fn with_detail(mut self, detail: Value) -> Self {
341        self.detail = detail;
342        self
343    }
344}
345
346/// Hint a skill may emit to drive subsequent skill selection. The agent
347/// loop is free to honour or ignore these — they are advisory.
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub enum NextAction {
350    /// Suggest a follow-up skill by id.
351    RunSkill(String),
352    /// Suggest invoking a named tool with prepared args.
353    InvokeTool { tool: String, args: Value },
354    /// Stop the investigation; sufficient evidence has been gathered.
355    Conclude,
356    /// Drop the investigation; the entity is benign.
357    Discard,
358}
359
360/// Runtime state for one investigation. Cheap to construct; passed by
361/// `&mut` reference through the skill chain.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct InvestigationContext {
364    /// Stable identifier for the entity under investigation. May be a block
365    /// id stringified, an actor id from the grammar layer (Phase 2), or any
366    /// caller-defined key.
367    pub entity_id: String,
368
369    /// Optional originating block — present when the investigation was
370    /// triggered by an upstream pipeline. Stored as an opaque UUID so the
371    /// kernel does not depend on any specific block-id newtype.
372    pub block_id: Option<Uuid>,
373
374    /// Free-form partition tag (caller-defined).
375    pub partition: String,
376
377    /// Signals that triggered this investigation and any signals lifted by
378    /// earlier skills. Skills add to this set as evidence accumulates.
379    pub signals: Vec<Signal>,
380
381    /// Accumulated evidence in chronological order.
382    pub evidence: Vec<Evidence>,
383
384    /// Running confidence in `[0, 1]` that the entity exhibits malicious
385    /// behaviour. Skills emit deltas; the agent clamps after each step.
386    pub confidence: f32,
387
388    /// Hints from the most recently executed skill.
389    pub pending_actions: Vec<NextAction>,
390}
391
392impl InvestigationContext {
393    pub fn new(entity_id: impl Into<String>, partition: impl Into<String>) -> Self {
394        Self {
395            entity_id: entity_id.into(),
396            block_id: None,
397            partition: partition.into(),
398            signals: Vec::new(),
399            evidence: Vec::new(),
400            confidence: 0.0,
401            pending_actions: Vec::new(),
402        }
403    }
404
405    pub fn with_block<I: Into<Uuid>>(mut self, id: I) -> Self {
406        self.block_id = Some(id.into());
407        self
408    }
409
410    pub fn with_signal(mut self, s: impl Into<String>) -> Self {
411        self.signals.push(Signal::new(s));
412        self
413    }
414
415    pub fn has_signal(&self, name: &str) -> bool {
416        self.signals.iter().any(|s| s.as_str() == name)
417    }
418}