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}