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}