cel_brief/source.rs
1//! [`Source`] trait + [`Contribution`] + [`ContributionContent`] + [`SourceError`].
2//!
3//! A `Source` is the unit of pluggability: every per-turn
4//! input — memory, perception, history, tools, the user message — implements
5//! the same trait, returns the same [`Contribution`] shape, and is composed
6//! by [`crate::builder::BriefBuilder`] (Phase 2). The crate has zero opinions
7//! about what sources you wire in.
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::types::{BriefContext, ImageData, Priority, Role, SourceId, ToolSchema};
14
15/// What a [`Source`] can contribute to a [`crate::types::Brief`].
16///
17/// One [`Contribution`] becomes (eventually) one
18/// [`crate::types::BriefMessage`] or one [`ToolSchema`] in the final brief,
19/// modulo budget pruning and governance redaction. The variants intentionally
20/// mirror [`crate::types::BriefMessage`] plus a [`ContributionContent::System`]
21/// variant for system-prompt text and a [`ContributionContent::Tool`] variant
22/// for tool catalog entries.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(tag = "kind", rename_all = "snake_case")]
25pub enum ContributionContent {
26 /// System-prompt text. Multiple contributions are concatenated in source
27 /// order by the builder.
28 System {
29 /// System-prompt text.
30 text: String,
31 },
32 /// Plain text content under a role.
33 Text {
34 /// Role this message is attributed to.
35 role: Role,
36 /// Message body.
37 content: String,
38 },
39 /// Image content under a role.
40 Image {
41 /// Role this image is attributed to.
42 role: Role,
43 /// The image payload.
44 data: ImageData,
45 /// Optional alt / caption text.
46 #[serde(default)]
47 alt: Option<String>,
48 },
49 /// A prior tool invocation, replayed into the conversation.
50 ToolCall {
51 /// Provider-issued tool-call ID.
52 id: String,
53 /// Tool name (matches a [`ToolSchema::name`]).
54 name: String,
55 /// JSON arguments the model passed.
56 args: serde_json::Value,
57 },
58 /// The result returned for a prior tool call.
59 ToolResult {
60 /// Tool-call ID this result responds to.
61 id: String,
62 /// Serialised result content.
63 content: String,
64 },
65 /// A tool schema to be made available to the model this turn.
66 Tool {
67 /// The tool schema. Its `source` field is set by the builder when
68 /// the contribution is admitted.
69 schema: ToolSchema,
70 },
71}
72
73/// One item produced by [`Source::contribute`].
74///
75/// The builder treats `estimated_tokens` as a hint (always re-tokenized
76/// ground-truth on the builder side), but uses `importance` and the source's
77/// [`Priority`] to drive budget pruning. `redactable` lets governance
78/// (Phase 4) selectively rewrite content rather than dropping it.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct Contribution {
81 /// What this contribution carries.
82 pub content: ContributionContent,
83 /// Source-estimated token cost. Treated as a hint — the builder
84 /// re-tokenizes for ground truth before pruning.
85 pub estimated_tokens: usize,
86 /// Importance in `[0.0, 1.0]`. Higher importance survives pruning.
87 /// Out-of-range values are clamped on admission.
88 pub importance: f32,
89 /// If `true`, governance may rewrite the content rather than dropping
90 /// it. If `false`, governance can only allow or reject.
91 pub redactable: bool,
92 /// Free-form tags (e.g. `"summary"`, `"recent"`) used by governance and
93 /// debugging.
94 ///
95 /// Originally sketched as `Vec<&'static str>`, widened to
96 /// `Vec<String>` so [`Contribution`] can satisfy [`serde::Deserialize`]
97 /// — required so all brief types round-trip through serde. Static
98 /// callers pay one allocation per tag; receipt-side consumers gain
99 /// round-trippability.
100 #[serde(default)]
101 pub tags: Vec<String>,
102}
103
104impl Contribution {
105 /// Build a [`ContributionContent::System`] contribution with sensible
106 /// defaults (Critical-importance text, not redactable, no tags).
107 pub fn system(text: impl Into<String>, estimated_tokens: usize) -> Self {
108 Contribution {
109 content: ContributionContent::System { text: text.into() },
110 estimated_tokens,
111 importance: 1.0,
112 redactable: false,
113 tags: Vec::new(),
114 }
115 }
116
117 /// Build a [`ContributionContent::Text`] contribution.
118 pub fn text(role: Role, content: impl Into<String>, estimated_tokens: usize) -> Self {
119 Contribution {
120 content: ContributionContent::Text {
121 role,
122 content: content.into(),
123 },
124 estimated_tokens,
125 importance: 0.5,
126 redactable: true,
127 tags: Vec::new(),
128 }
129 }
130
131 /// Build a [`ContributionContent::Tool`] contribution.
132 pub fn tool(schema: ToolSchema, estimated_tokens: usize) -> Self {
133 Contribution {
134 content: ContributionContent::Tool { schema },
135 estimated_tokens,
136 importance: 0.9,
137 redactable: false,
138 tags: Vec::new(),
139 }
140 }
141
142 /// Set the importance, clamped to `[0.0, 1.0]`.
143 pub fn with_importance(mut self, importance: f32) -> Self {
144 self.importance = importance.clamp(0.0, 1.0);
145 self
146 }
147
148 /// Mark the contribution as redactable (or not) by governance.
149 pub fn with_redactable(mut self, redactable: bool) -> Self {
150 self.redactable = redactable;
151 self
152 }
153
154 /// Append a free-form tag.
155 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
156 self.tags.push(tag.into());
157 self
158 }
159}
160
161/// Error returned by [`Source::contribute`].
162///
163/// Bubble up via [`crate::error::BriefError::Source`] at the builder layer.
164/// Sources should map their own backend errors into one of these variants;
165/// `Other` is the catch-all when no better fit exists.
166#[derive(Error, Debug)]
167pub enum SourceError {
168 /// Backend (database, network, adapter) was reachable but returned an
169 /// error.
170 #[error("backend error: {0}")]
171 Backend(String),
172
173 /// Source was misconfigured (e.g. missing required input on the
174 /// [`crate::types::BriefContext`]).
175 #[error("misconfigured: {0}")]
176 Misconfigured(String),
177
178 /// Source decided not to contribute this turn for a non-error reason.
179 /// The builder treats this as zero contributions, not as a failure.
180 #[error("source skipped: {0}")]
181 Skipped(String),
182
183 /// Catch-all for sources whose backend errors don't fit the above.
184 #[error("source error: {0}")]
185 Other(String),
186}
187
188/// A pluggable contributor to the per-turn [`crate::types::Brief`].
189///
190/// `id` is the stable label that ends up on every emitted
191/// [`crate::types::BriefMessage`] / [`crate::types::ToolSchema`] and in the
192/// [`crate::receipt::BriefReceipt`]. `priority` drives the budget floor
193/// pre-pruning. `contribute` is the work — read from `ctx`, return zero or
194/// more [`Contribution`]s.
195///
196/// Implementations should:
197/// - Keep `contribute` deterministic given `ctx` where possible.
198/// - Honour `ctx.budget` as a hint but not a hard cap — pruning is the
199/// builder's job.
200/// - Return cheap, pre-rendered text; don't perform heavy formatting per
201/// turn.
202#[async_trait]
203pub trait Source: Send + Sync {
204 /// Stable identifier for this source. Receipts and message attribution
205 /// key off this value.
206 fn id(&self) -> SourceId;
207
208 /// Priority bucket. Drives budget floors and prune ordering.
209 fn priority(&self) -> Priority;
210
211 /// Produce zero or more [`Contribution`]s for this turn.
212 async fn contribute(&self, ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError>;
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::types::TokenBudget;
219
220 /// Hard-coded source — returns a fixed system prompt every turn.
221 /// Exercises the trait surface end-to-end.
222 struct FixedSystemSource;
223
224 #[async_trait]
225 impl Source for FixedSystemSource {
226 fn id(&self) -> SourceId {
227 SourceId::new("fixed_system")
228 }
229
230 fn priority(&self) -> Priority {
231 Priority::Critical
232 }
233
234 async fn contribute(&self, _ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
235 Ok(vec![Contribution::system(
236 "You are a helpful assistant.",
237 7,
238 )])
239 }
240 }
241
242 /// Source that mirrors the user message back as a Text contribution.
243 /// Returns `Skipped` when no user message is present.
244 struct EchoUserSource;
245
246 #[async_trait]
247 impl Source for EchoUserSource {
248 fn id(&self) -> SourceId {
249 SourceId::new("echo_user")
250 }
251
252 fn priority(&self) -> Priority {
253 Priority::Critical
254 }
255
256 async fn contribute(&self, ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
257 let Some(msg) = ctx.user_message.as_deref() else {
258 return Err(SourceError::Skipped("no user message".into()));
259 };
260 Ok(vec![Contribution::text(
261 Role::User,
262 msg.to_owned(),
263 // 4 chars per token rule of thumb, rounded up.
264 msg.len().div_ceil(4),
265 )
266 .with_importance(1.0)
267 .with_redactable(false)])
268 }
269 }
270
271 #[tokio::test]
272 async fn fixed_source_returns_contribution() {
273 let source = FixedSystemSource;
274 assert_eq!(source.id(), SourceId::new("fixed_system"));
275 assert_eq!(source.priority(), Priority::Critical);
276
277 let ctx = BriefContext::new(TokenBudget::default());
278 let contributions = source.contribute(&ctx).await.expect("contribute ok");
279 assert_eq!(contributions.len(), 1);
280 match &contributions[0].content {
281 ContributionContent::System { text } => {
282 assert_eq!(text, "You are a helpful assistant.");
283 }
284 other => panic!("expected System, got {other:?}"),
285 }
286 assert_eq!(contributions[0].estimated_tokens, 7);
287 assert_eq!(contributions[0].importance, 1.0);
288 }
289
290 #[tokio::test]
291 async fn echo_source_returns_skipped_when_no_message() {
292 let source = EchoUserSource;
293 let ctx = BriefContext::new(TokenBudget::default());
294 let err = source.contribute(&ctx).await.expect_err("should skip");
295 match err {
296 SourceError::Skipped(_) => {}
297 other => panic!("expected Skipped, got {other:?}"),
298 }
299 }
300
301 #[tokio::test]
302 async fn echo_source_round_trips_user_message() {
303 let source = EchoUserSource;
304 let ctx = BriefContext::new(TokenBudget::default()).with_user_message("Hello, world!");
305 let contributions = source.contribute(&ctx).await.expect("contribute ok");
306 assert_eq!(contributions.len(), 1);
307 match &contributions[0].content {
308 ContributionContent::Text { role, content } => {
309 assert_eq!(*role, Role::User);
310 assert_eq!(content, "Hello, world!");
311 }
312 other => panic!("expected Text, got {other:?}"),
313 }
314 }
315
316 #[test]
317 fn contribution_with_importance_clamps() {
318 let c = Contribution::text(Role::User, "hi", 1).with_importance(2.0);
319 assert_eq!(c.importance, 1.0);
320 let c = Contribution::text(Role::User, "hi", 1).with_importance(-0.5);
321 assert_eq!(c.importance, 0.0);
322 }
323
324 #[test]
325 fn contribution_tags_chain() {
326 let c = Contribution::system("hi", 1)
327 .with_tag("system")
328 .with_tag(String::from("default"));
329 assert_eq!(c.tags, vec!["system".to_owned(), "default".to_owned()]);
330 }
331
332 #[test]
333 fn contribution_round_trips_through_serde_json() {
334 let c = Contribution::text(Role::Assistant, "ok", 1)
335 .with_importance(0.75)
336 .with_redactable(false);
337 let json = serde_json::to_string(&c).expect("serialize");
338 let back: Contribution = serde_json::from_str(&json).expect("deserialize");
339 assert_eq!(c, back);
340 }
341}