Skip to main content

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}