use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::types::{BriefContext, ImageData, Priority, Role, SourceId, ToolSchema};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ContributionContent {
System {
text: String,
},
Text {
role: Role,
content: String,
},
Image {
role: Role,
data: ImageData,
#[serde(default)]
alt: Option<String>,
},
ToolCall {
id: String,
name: String,
args: serde_json::Value,
},
ToolResult {
id: String,
content: String,
},
Tool {
schema: ToolSchema,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Contribution {
pub content: ContributionContent,
pub estimated_tokens: usize,
pub importance: f32,
pub redactable: bool,
#[serde(default)]
pub tags: Vec<String>,
}
impl Contribution {
pub fn system(text: impl Into<String>, estimated_tokens: usize) -> Self {
Contribution {
content: ContributionContent::System { text: text.into() },
estimated_tokens,
importance: 1.0,
redactable: false,
tags: Vec::new(),
}
}
pub fn text(role: Role, content: impl Into<String>, estimated_tokens: usize) -> Self {
Contribution {
content: ContributionContent::Text {
role,
content: content.into(),
},
estimated_tokens,
importance: 0.5,
redactable: true,
tags: Vec::new(),
}
}
pub fn tool(schema: ToolSchema, estimated_tokens: usize) -> Self {
Contribution {
content: ContributionContent::Tool { schema },
estimated_tokens,
importance: 0.9,
redactable: false,
tags: Vec::new(),
}
}
pub fn with_importance(mut self, importance: f32) -> Self {
self.importance = importance.clamp(0.0, 1.0);
self
}
pub fn with_redactable(mut self, redactable: bool) -> Self {
self.redactable = redactable;
self
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
}
#[derive(Error, Debug)]
pub enum SourceError {
#[error("backend error: {0}")]
Backend(String),
#[error("misconfigured: {0}")]
Misconfigured(String),
#[error("source skipped: {0}")]
Skipped(String),
#[error("source error: {0}")]
Other(String),
}
#[async_trait]
pub trait Source: Send + Sync {
fn id(&self) -> SourceId;
fn priority(&self) -> Priority;
async fn contribute(&self, ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::TokenBudget;
struct FixedSystemSource;
#[async_trait]
impl Source for FixedSystemSource {
fn id(&self) -> SourceId {
SourceId::new("fixed_system")
}
fn priority(&self) -> Priority {
Priority::Critical
}
async fn contribute(&self, _ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
Ok(vec![Contribution::system(
"You are a helpful assistant.",
7,
)])
}
}
struct EchoUserSource;
#[async_trait]
impl Source for EchoUserSource {
fn id(&self) -> SourceId {
SourceId::new("echo_user")
}
fn priority(&self) -> Priority {
Priority::Critical
}
async fn contribute(&self, ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
let Some(msg) = ctx.user_message.as_deref() else {
return Err(SourceError::Skipped("no user message".into()));
};
Ok(vec![Contribution::text(
Role::User,
msg.to_owned(),
msg.len().div_ceil(4),
)
.with_importance(1.0)
.with_redactable(false)])
}
}
#[tokio::test]
async fn fixed_source_returns_contribution() {
let source = FixedSystemSource;
assert_eq!(source.id(), SourceId::new("fixed_system"));
assert_eq!(source.priority(), Priority::Critical);
let ctx = BriefContext::new(TokenBudget::default());
let contributions = source.contribute(&ctx).await.expect("contribute ok");
assert_eq!(contributions.len(), 1);
match &contributions[0].content {
ContributionContent::System { text } => {
assert_eq!(text, "You are a helpful assistant.");
}
other => panic!("expected System, got {other:?}"),
}
assert_eq!(contributions[0].estimated_tokens, 7);
assert_eq!(contributions[0].importance, 1.0);
}
#[tokio::test]
async fn echo_source_returns_skipped_when_no_message() {
let source = EchoUserSource;
let ctx = BriefContext::new(TokenBudget::default());
let err = source.contribute(&ctx).await.expect_err("should skip");
match err {
SourceError::Skipped(_) => {}
other => panic!("expected Skipped, got {other:?}"),
}
}
#[tokio::test]
async fn echo_source_round_trips_user_message() {
let source = EchoUserSource;
let ctx = BriefContext::new(TokenBudget::default()).with_user_message("Hello, world!");
let contributions = source.contribute(&ctx).await.expect("contribute ok");
assert_eq!(contributions.len(), 1);
match &contributions[0].content {
ContributionContent::Text { role, content } => {
assert_eq!(*role, Role::User);
assert_eq!(content, "Hello, world!");
}
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn contribution_with_importance_clamps() {
let c = Contribution::text(Role::User, "hi", 1).with_importance(2.0);
assert_eq!(c.importance, 1.0);
let c = Contribution::text(Role::User, "hi", 1).with_importance(-0.5);
assert_eq!(c.importance, 0.0);
}
#[test]
fn contribution_tags_chain() {
let c = Contribution::system("hi", 1)
.with_tag("system")
.with_tag(String::from("default"));
assert_eq!(c.tags, vec!["system".to_owned(), "default".to_owned()]);
}
#[test]
fn contribution_round_trips_through_serde_json() {
let c = Contribution::text(Role::Assistant, "ok", 1)
.with_importance(0.75)
.with_redactable(false);
let json = serde_json::to_string(&c).expect("serialize");
let back: Contribution = serde_json::from_str(&json).expect("deserialize");
assert_eq!(c, back);
}
}