Skip to main content

cel_brief/
types.rs

1//! Core types: [`Brief`], [`BriefMessage`], [`Role`], [`BriefContext`],
2//! [`TokenBudget`], [`Priority`], [`ToolSchema`], [`ImageData`], [`SourceId`].
3//!
4//! These are the provider-agnostic value types the briefing layer assembles and
5//! emits. See this crate's `README.md` for the public overview.
6
7use std::collections::HashMap;
8use std::time::SystemTime;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::receipt::BriefReceipt;
14
15/// Stable identifier for a [`crate::source::Source`].
16///
17/// Used to tag every [`BriefMessage`] / [`ToolSchema`] with its origin, and to
18/// key the per-source statistics in [`BriefReceipt`]. Sources own their IDs and
19/// must keep them stable across turns for receipts and debugging to make sense.
20///
21/// IDs should be short, snake_case, and unique within a single
22/// [`crate::builder::BriefBuilder`]. A `BriefBuilder` rejects two sources with
23/// the same ID at registration time.
24#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(transparent)]
26pub struct SourceId(pub String);
27
28impl SourceId {
29    /// Construct a new [`SourceId`] from anything string-like.
30    pub fn new(id: impl Into<String>) -> Self {
31        SourceId(id.into())
32    }
33
34    /// The underlying string slice.
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl std::fmt::Display for SourceId {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str(&self.0)
43    }
44}
45
46impl From<&str> for SourceId {
47    fn from(s: &str) -> Self {
48        SourceId(s.to_owned())
49    }
50}
51
52impl From<String> for SourceId {
53    fn from(s: String) -> Self {
54        SourceId(s)
55    }
56}
57
58/// Conversation role for a [`BriefMessage`].
59///
60/// Mirrors the four-way role split shared by OpenAI's and Anthropic's chat
61/// APIs. Providers that collapse `Tool` into a user-style message do that
62/// mapping in the renderer, not here.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum Role {
66    /// The system prompt role.
67    System,
68    /// A user message — humans or upstream agents.
69    User,
70    /// An assistant message — the model's prior turn.
71    Assistant,
72    /// A tool result — outcome of a previously issued tool call.
73    Tool,
74}
75
76/// Raw image bytes plus enough metadata for a renderer to encode them.
77///
78/// `cel-brief` is provider-agnostic, so we never pre-encode for OpenAI /
79/// Anthropic / local wire formats here. The renderer reads `media_type` and
80/// the bytes and emits whatever the target API wants (base64 data URL,
81/// multi-part upload, etc.).
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct ImageData {
84    /// IANA media type, e.g. `image/png`, `image/jpeg`, `image/webp`.
85    pub media_type: String,
86    /// Raw image bytes.
87    pub bytes: Vec<u8>,
88    /// Optional pixel width — populated by sources that know it.
89    #[serde(default)]
90    pub width: Option<u32>,
91    /// Optional pixel height — populated by sources that know it.
92    #[serde(default)]
93    pub height: Option<u32>,
94}
95
96/// One message in a [`Brief`]'s conversation transcript.
97///
98/// Tagged with the [`SourceId`] of the contributing source so the
99/// [`BriefReceipt`] can attribute every visible byte. Variants mirror the
100/// `Text | Image | ToolCall | ToolResult` matrix.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102#[serde(tag = "kind", rename_all = "snake_case")]
103pub enum BriefMessage {
104    /// Plain text content under a role.
105    Text {
106        /// Role this message is attributed to.
107        role: Role,
108        /// Message body.
109        content: String,
110        /// Source that contributed the message.
111        source: SourceId,
112    },
113    /// Image content under a role (typically `User` for vision turns).
114    Image {
115        /// Role this image is attributed to.
116        role: Role,
117        /// The image payload.
118        data: ImageData,
119        /// Optional alt / caption text for accessibility and fall-back.
120        #[serde(default)]
121        alt: Option<String>,
122        /// Source that contributed the image.
123        source: SourceId,
124    },
125    /// A tool invocation the model previously emitted. Carries the original
126    /// tool call ID so a [`BriefMessage::ToolResult`] can be matched to it.
127    ToolCall {
128        /// Provider-issued tool-call ID.
129        id: String,
130        /// Tool name (matches a [`ToolSchema::name`]).
131        name: String,
132        /// JSON arguments the model passed.
133        args: Value,
134        /// Source that contributed the call (typically a history source).
135        source: SourceId,
136    },
137    /// The result returned for a prior tool call.
138    ToolResult {
139        /// Tool-call ID this result responds to.
140        id: String,
141        /// Serialised result content.
142        content: String,
143        /// Source that contributed the result.
144        source: SourceId,
145    },
146}
147
148impl BriefMessage {
149    /// The [`SourceId`] that produced this message.
150    pub fn source(&self) -> &SourceId {
151        match self {
152            BriefMessage::Text { source, .. }
153            | BriefMessage::Image { source, .. }
154            | BriefMessage::ToolCall { source, .. }
155            | BriefMessage::ToolResult { source, .. } => source,
156        }
157    }
158}
159
160/// Provider-agnostic description of a tool the model can call.
161///
162/// `input_schema` is JSON Schema. Renderers translate to the
163/// provider-specific shape (OpenAI `tools[]`, Anthropic `tools[]`, etc.).
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165pub struct ToolSchema {
166    /// Stable tool name. Must match the `name` on any
167    /// [`BriefMessage::ToolCall`] that targets this tool.
168    pub name: String,
169    /// Human-readable description used by the model to decide when to call.
170    pub description: String,
171    /// JSON Schema describing the tool's input arguments.
172    pub input_schema: Value,
173    /// Source that contributed this tool.
174    pub source: SourceId,
175}
176
177/// Priority bucket for a [`crate::source::Source`] / [`crate::source::Contribution`].
178///
179/// Drives both ordering and the per-priority floor in [`TokenBudget`].
180/// Sources should pick the lowest priority that still preserves correctness:
181/// over-claiming `Critical` defeats the budget's purpose.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum Priority {
185    /// Lowest priority. Pruned first when over budget.
186    Low,
187    /// Default priority for most sources.
188    Normal,
189    /// High-value contributions (tools, fresh perception).
190    High,
191    /// Must-keep contributions (system prompt, the user's message).
192    Critical,
193}
194
195impl Priority {
196    /// All priority levels in ascending order. Useful for budget pruning
197    /// loops that sweep from low to high.
198    pub const ALL: [Priority; 4] = [
199        Priority::Low,
200        Priority::Normal,
201        Priority::High,
202        Priority::Critical,
203    ];
204}
205
206/// Token budget for one [`Brief`].
207///
208/// `total` is the inclusive ceiling for the assembled brief; the builder
209/// reserves `reserve_for_response` tokens for the model's reply and prunes
210/// contributions until the visible total fits within
211/// `total - reserve_for_response`. `floor_per_priority` lets callers
212/// guarantee a minimum allocation per [`Priority`] so a flood of
213/// [`Priority::Low`] contributions can't squeeze out [`Priority::Critical`]
214/// content.
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct TokenBudget {
217    /// Inclusive ceiling for prompt + reserved response, in tokens.
218    pub total: usize,
219    /// Tokens held back from the prompt so the model has room to reply.
220    pub reserve_for_response: usize,
221    /// Minimum tokens guaranteed per priority bucket (best-effort: a higher
222    /// priority can still borrow from a lower floor when over budget).
223    pub floor_per_priority: HashMap<Priority, usize>,
224}
225
226impl TokenBudget {
227    /// Build a budget with `total` tokens, `reserve_for_response` held back,
228    /// and no per-priority floors. Floors can be added with
229    /// [`TokenBudget::with_floor`].
230    pub fn new(total: usize, reserve_for_response: usize) -> Self {
231        TokenBudget {
232            total,
233            reserve_for_response,
234            floor_per_priority: HashMap::new(),
235        }
236    }
237
238    /// Set the minimum tokens guaranteed for `priority`. Returns `self` for
239    /// chaining.
240    pub fn with_floor(mut self, priority: Priority, floor: usize) -> Self {
241        self.floor_per_priority.insert(priority, floor);
242        self
243    }
244
245    /// Tokens available to the prompt (i.e. `total - reserve_for_response`),
246    /// saturating at zero if the reserve exceeds the total.
247    pub fn prompt_budget(&self) -> usize {
248        self.total.saturating_sub(self.reserve_for_response)
249    }
250}
251
252impl Default for TokenBudget {
253    /// A reasonable default for an 8k-class model: 8000 total, 1024 reserved
254    /// for the response, no per-priority floors.
255    fn default() -> Self {
256        TokenBudget::new(8000, 1024)
257    }
258}
259
260/// Per-turn input handed to every [`crate::source::Source`].
261///
262/// Sources read from `ctx` to decide what to contribute. `turn`, `goal`, and
263/// `user_message` are caller-supplied; `budget` is informational here
264/// (enforcement is the builder's job); `now` lets sources timestamp without
265/// reaching for the clock directly, which keeps tests deterministic.
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267pub struct BriefContext {
268    /// Turn number within the current session. Monotonically increasing.
269    pub turn: u64,
270    /// Optional running goal carried across turns.
271    #[serde(default)]
272    pub goal: Option<String>,
273    /// The user's latest message, if any.
274    #[serde(default)]
275    pub user_message: Option<String>,
276    /// Token budget for this turn.
277    pub budget: TokenBudget,
278    /// Wall-clock time the builder considers "now". Sources should prefer
279    /// this over `SystemTime::now()` so tests are reproducible.
280    pub now: SystemTime,
281}
282
283impl BriefContext {
284    /// Construct a [`BriefContext`] with the supplied budget and
285    /// `now = SystemTime::now()`. The remaining optional fields default to
286    /// `None` / `0`.
287    pub fn new(budget: TokenBudget) -> Self {
288        BriefContext {
289            turn: 0,
290            goal: None,
291            user_message: None,
292            budget,
293            now: SystemTime::now(),
294        }
295    }
296
297    /// Set the turn number.
298    pub fn with_turn(mut self, turn: u64) -> Self {
299        self.turn = turn;
300        self
301    }
302
303    /// Set the running goal.
304    pub fn with_goal(mut self, goal: impl Into<String>) -> Self {
305        self.goal = Some(goal.into());
306        self
307    }
308
309    /// Set the user message.
310    pub fn with_user_message(mut self, message: impl Into<String>) -> Self {
311        self.user_message = Some(message.into());
312        self
313    }
314
315    /// Override `now` (used in tests for deterministic time).
316    pub fn with_now(mut self, now: SystemTime) -> Self {
317        self.now = now;
318        self
319    }
320}
321
322/// The assembled, budgeted, governance-reviewed bundle handed to the LLM.
323///
324/// Produced by [`crate::builder::BriefBuilder::build`] (Phase 2). The
325/// [`BriefReceipt`] makes the assembly process auditable — per-source token
326/// counts, dropped items, redactions, and timings.
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328pub struct Brief {
329    /// Optional system prompt assembled from contributing sources.
330    #[serde(default)]
331    pub system: Option<String>,
332    /// Conversation messages, in render order.
333    pub messages: Vec<BriefMessage>,
334    /// Tool schemas exposed to the model this turn.
335    pub tools: Vec<ToolSchema>,
336    /// Receipt detailing how this brief was assembled.
337    pub receipt: BriefReceipt,
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn source_id_round_trips_through_string() {
346        let id = SourceId::new("system_prompt");
347        assert_eq!(id.as_str(), "system_prompt");
348        assert_eq!(format!("{id}"), "system_prompt");
349
350        let from_str: SourceId = "history".into();
351        let from_string: SourceId = String::from("memory").into();
352        assert_eq!(from_str, SourceId::new("history"));
353        assert_eq!(from_string, SourceId::new("memory"));
354    }
355
356    #[test]
357    fn priority_all_is_sorted_ascending() {
358        let all = Priority::ALL;
359        for window in all.windows(2) {
360            assert!(
361                window[0] < window[1],
362                "{:?} should be < {:?}",
363                window[0],
364                window[1]
365            );
366        }
367        assert_eq!(all.len(), 4);
368    }
369
370    #[test]
371    fn token_budget_prompt_budget_subtracts_reserve() {
372        let budget = TokenBudget::new(1000, 200);
373        assert_eq!(budget.prompt_budget(), 800);
374
375        // Saturating: reserve > total → 0, not underflow.
376        let zero = TokenBudget::new(100, 500);
377        assert_eq!(zero.prompt_budget(), 0);
378    }
379
380    #[test]
381    fn token_budget_with_floor_inserts_floor() {
382        let budget = TokenBudget::new(1000, 100)
383            .with_floor(Priority::Critical, 200)
384            .with_floor(Priority::High, 100);
385        assert_eq!(
386            budget.floor_per_priority.get(&Priority::Critical),
387            Some(&200)
388        );
389        assert_eq!(budget.floor_per_priority.get(&Priority::High), Some(&100));
390        assert!(!budget.floor_per_priority.contains_key(&Priority::Low));
391    }
392
393    #[test]
394    fn brief_message_reports_its_source() {
395        let sid = SourceId::new("test");
396        let msg = BriefMessage::Text {
397            role: Role::User,
398            content: "hi".into(),
399            source: sid.clone(),
400        };
401        assert_eq!(msg.source(), &sid);
402
403        let tool_call = BriefMessage::ToolCall {
404            id: "call_1".into(),
405            name: "search".into(),
406            args: serde_json::json!({"q": "rust"}),
407            source: sid.clone(),
408        };
409        assert_eq!(tool_call.source(), &sid);
410    }
411
412    #[test]
413    fn brief_context_builder_helpers_set_fields() {
414        let budget = TokenBudget::new(4000, 512);
415        let ctx = BriefContext::new(budget.clone())
416            .with_turn(7)
417            .with_goal("ship phase 1")
418            .with_user_message("hi");
419        assert_eq!(ctx.turn, 7);
420        assert_eq!(ctx.goal.as_deref(), Some("ship phase 1"));
421        assert_eq!(ctx.user_message.as_deref(), Some("hi"));
422        assert_eq!(ctx.budget, budget);
423    }
424
425    #[test]
426    fn types_round_trip_through_serde_json() {
427        let msg = BriefMessage::Text {
428            role: Role::System,
429            content: "be helpful".into(),
430            source: SourceId::new("sys"),
431        };
432        let json = serde_json::to_string(&msg).expect("serialize");
433        let back: BriefMessage = serde_json::from_str(&json).expect("deserialize");
434        assert_eq!(msg, back);
435
436        let tool = ToolSchema {
437            name: "echo".into(),
438            description: "echoes input".into(),
439            input_schema: serde_json::json!({"type": "object"}),
440            source: SourceId::new("tools"),
441        };
442        let json = serde_json::to_string(&tool).expect("serialize tool");
443        let back: ToolSchema = serde_json::from_str(&json).expect("deserialize tool");
444        assert_eq!(tool, back);
445    }
446}