use std::collections::HashMap;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::receipt::BriefReceipt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SourceId(pub String);
impl SourceId {
pub fn new(id: impl Into<String>) -> Self {
SourceId(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SourceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for SourceId {
fn from(s: &str) -> Self {
SourceId(s.to_owned())
}
}
impl From<String> for SourceId {
fn from(s: String) -> Self {
SourceId(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageData {
pub media_type: String,
pub bytes: Vec<u8>,
#[serde(default)]
pub width: Option<u32>,
#[serde(default)]
pub height: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BriefMessage {
Text {
role: Role,
content: String,
source: SourceId,
},
Image {
role: Role,
data: ImageData,
#[serde(default)]
alt: Option<String>,
source: SourceId,
},
ToolCall {
id: String,
name: String,
args: Value,
source: SourceId,
},
ToolResult {
id: String,
content: String,
source: SourceId,
},
}
impl BriefMessage {
pub fn source(&self) -> &SourceId {
match self {
BriefMessage::Text { source, .. }
| BriefMessage::Image { source, .. }
| BriefMessage::ToolCall { source, .. }
| BriefMessage::ToolResult { source, .. } => source,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolSchema {
pub name: String,
pub description: String,
pub input_schema: Value,
pub source: SourceId,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
Low,
Normal,
High,
Critical,
}
impl Priority {
pub const ALL: [Priority; 4] = [
Priority::Low,
Priority::Normal,
Priority::High,
Priority::Critical,
];
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TokenBudget {
pub total: usize,
pub reserve_for_response: usize,
pub floor_per_priority: HashMap<Priority, usize>,
}
impl TokenBudget {
pub fn new(total: usize, reserve_for_response: usize) -> Self {
TokenBudget {
total,
reserve_for_response,
floor_per_priority: HashMap::new(),
}
}
pub fn with_floor(mut self, priority: Priority, floor: usize) -> Self {
self.floor_per_priority.insert(priority, floor);
self
}
pub fn prompt_budget(&self) -> usize {
self.total.saturating_sub(self.reserve_for_response)
}
}
impl Default for TokenBudget {
fn default() -> Self {
TokenBudget::new(8000, 1024)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BriefContext {
pub turn: u64,
#[serde(default)]
pub goal: Option<String>,
#[serde(default)]
pub user_message: Option<String>,
pub budget: TokenBudget,
pub now: SystemTime,
}
impl BriefContext {
pub fn new(budget: TokenBudget) -> Self {
BriefContext {
turn: 0,
goal: None,
user_message: None,
budget,
now: SystemTime::now(),
}
}
pub fn with_turn(mut self, turn: u64) -> Self {
self.turn = turn;
self
}
pub fn with_goal(mut self, goal: impl Into<String>) -> Self {
self.goal = Some(goal.into());
self
}
pub fn with_user_message(mut self, message: impl Into<String>) -> Self {
self.user_message = Some(message.into());
self
}
pub fn with_now(mut self, now: SystemTime) -> Self {
self.now = now;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Brief {
#[serde(default)]
pub system: Option<String>,
pub messages: Vec<BriefMessage>,
pub tools: Vec<ToolSchema>,
pub receipt: BriefReceipt,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_id_round_trips_through_string() {
let id = SourceId::new("system_prompt");
assert_eq!(id.as_str(), "system_prompt");
assert_eq!(format!("{id}"), "system_prompt");
let from_str: SourceId = "history".into();
let from_string: SourceId = String::from("memory").into();
assert_eq!(from_str, SourceId::new("history"));
assert_eq!(from_string, SourceId::new("memory"));
}
#[test]
fn priority_all_is_sorted_ascending() {
let all = Priority::ALL;
for window in all.windows(2) {
assert!(
window[0] < window[1],
"{:?} should be < {:?}",
window[0],
window[1]
);
}
assert_eq!(all.len(), 4);
}
#[test]
fn token_budget_prompt_budget_subtracts_reserve() {
let budget = TokenBudget::new(1000, 200);
assert_eq!(budget.prompt_budget(), 800);
let zero = TokenBudget::new(100, 500);
assert_eq!(zero.prompt_budget(), 0);
}
#[test]
fn token_budget_with_floor_inserts_floor() {
let budget = TokenBudget::new(1000, 100)
.with_floor(Priority::Critical, 200)
.with_floor(Priority::High, 100);
assert_eq!(
budget.floor_per_priority.get(&Priority::Critical),
Some(&200)
);
assert_eq!(budget.floor_per_priority.get(&Priority::High), Some(&100));
assert!(!budget.floor_per_priority.contains_key(&Priority::Low));
}
#[test]
fn brief_message_reports_its_source() {
let sid = SourceId::new("test");
let msg = BriefMessage::Text {
role: Role::User,
content: "hi".into(),
source: sid.clone(),
};
assert_eq!(msg.source(), &sid);
let tool_call = BriefMessage::ToolCall {
id: "call_1".into(),
name: "search".into(),
args: serde_json::json!({"q": "rust"}),
source: sid.clone(),
};
assert_eq!(tool_call.source(), &sid);
}
#[test]
fn brief_context_builder_helpers_set_fields() {
let budget = TokenBudget::new(4000, 512);
let ctx = BriefContext::new(budget.clone())
.with_turn(7)
.with_goal("ship phase 1")
.with_user_message("hi");
assert_eq!(ctx.turn, 7);
assert_eq!(ctx.goal.as_deref(), Some("ship phase 1"));
assert_eq!(ctx.user_message.as_deref(), Some("hi"));
assert_eq!(ctx.budget, budget);
}
#[test]
fn types_round_trip_through_serde_json() {
let msg = BriefMessage::Text {
role: Role::System,
content: "be helpful".into(),
source: SourceId::new("sys"),
};
let json = serde_json::to_string(&msg).expect("serialize");
let back: BriefMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(msg, back);
let tool = ToolSchema {
name: "echo".into(),
description: "echoes input".into(),
input_schema: serde_json::json!({"type": "object"}),
source: SourceId::new("tools"),
};
let json = serde_json::to_string(&tool).expect("serialize tool");
let back: ToolSchema = serde_json::from_str(&json).expect("deserialize tool");
assert_eq!(tool, back);
}
}