File size: 12,463 Bytes
d90101d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 | use std::str::FromStr;
use chrono::{DateTime, Utc};
use derive_more::derive::Display;
use derive_setters::Setters;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{Context, Error, Metrics, Result, TokenCount};
#[derive(Debug, Default, Display, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct ConversationId(Uuid);
impl Copy for ConversationId {}
impl ConversationId {
pub fn generate() -> Self {
Self(Uuid::new_v4())
}
pub fn into_string(&self) -> String {
self.0.to_string()
}
pub fn parse(value: impl ToString) -> Result<Self> {
Ok(Self(
Uuid::parse_str(&value.to_string()).map_err(Error::ConversationId)?,
))
}
}
impl FromStr for ConversationId {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::parse(s)
}
}
#[derive(Debug, Setters, Serialize, Deserialize, Clone)]
#[setters(into)]
pub struct Conversation {
pub id: ConversationId,
pub title: Option<String>,
pub context: Option<Context>,
pub metrics: Metrics,
pub metadata: MetaData,
}
#[derive(Debug, Setters, Serialize, Deserialize, Clone)]
#[setters(into)]
pub struct MetaData {
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
impl MetaData {
pub fn new(created_at: DateTime<Utc>) -> Self {
Self { created_at, updated_at: None }
}
}
impl Conversation {
pub fn new(id: ConversationId) -> Self {
let created_at = Utc::now();
let metrics = Metrics::default().started_at(created_at);
Self {
id,
metrics,
metadata: MetaData::new(created_at),
title: None,
context: None,
}
}
/// Creates a new conversation with a new conversation ID.
///
/// This is a convenience constructor that automatically generates a unique
/// conversation ID, making it easy to create new conversations without
/// having to manually create the ID.
pub fn generate() -> Self {
Self::new(ConversationId::generate())
}
/// Generates an HTML representation of the conversation
///
/// This method uses Handlebars to render the conversation as HTML
/// from the template file, including all agents, events, and variables.
///
/// # Errors
/// - If the template file cannot be found or read
/// - If the Handlebars template registration fails
/// - If the template rendering fails
pub fn to_html(&self) -> String {
// Instead of using Handlebars, we now use our Element DSL
crate::conversation_html::render_conversation_html(self)
}
/// Generates an HTML representation with related agent conversations
///
/// Creates a single HTML document containing the main conversation
/// and all related agent conversations with anchor links for navigation.
pub fn to_html_with_related(&self, related: &[Conversation]) -> String {
crate::conversation_html::render_conversation_html_with_related(self, related)
}
/// Returns a vector of user messages, selecting the first message from
/// each consecutive sequence of user messages.
pub fn first_user_messages(&self) -> Vec<&crate::ContextMessage> {
self.context
.as_ref()
.map(|ctx| ctx.first_user_messages())
.unwrap_or_default()
}
/// Returns the total token usage across all messages in the conversation.
///
/// This is a convenience method that aggregates usage from the context,
/// if available.
pub fn accumulated_usage(&self) -> Option<crate::Usage> {
self.context.as_ref().and_then(|ctx| ctx.accumulate_usage())
}
pub fn usage(&self) -> Option<crate::Usage> {
self.context
.iter()
.flat_map(|ctx| ctx.messages.iter())
.flat_map(|msg| msg.usage.into_iter())
.last()
}
pub fn token_count(&self) -> Option<TokenCount> {
self.context.as_ref().map(|ctx| ctx.token_count())
}
pub fn accumulated_cost(&self) -> Option<f64> {
self.accumulated_usage().and_then(|usage| usage.cost)
}
/// Calculates the total cost including related conversations.
///
/// This method sums the costs of the current conversation and all related
/// conversations provided. It's useful for computing the total cost of a
/// conversation tree when agent tools spawn child conversations.
///
/// # Arguments
///
/// * `conversations` - A slice of conversations to include in cost
/// calculation
pub fn total_cost(conversations: &[Conversation]) -> Option<f64> {
let costs: Vec<f64> = conversations
.iter()
.filter_map(|conv| conv.accumulated_cost())
.collect();
if costs.is_empty() {
return None;
}
Some(costs.iter().sum())
}
/// Returns the number of messages in the conversation context.
///
/// Returns `0` if the context has not been initialized yet.
pub fn len(&self) -> usize {
self.context
.as_ref()
.map(|ctx| ctx.messages.len())
.unwrap_or(0)
}
/// Returns `true` if the conversation context has no messages.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Extracts all related conversation IDs from agent tool calls.
///
/// This method scans through all tool results in the conversation's context
/// and collects conversation IDs from AI tool values, which are created
/// when agent tools are called and trigger new conversations.
pub fn related_conversation_ids(&self) -> Vec<ConversationId> {
self.context
.as_ref()
.map(|ctx| {
ctx.messages
.iter()
.filter_map(|msg| msg.as_tool_result())
.flat_map(|result| &result.output.values)
.filter_map(|value| {
if let crate::ToolValue::AI { conversation_id, .. } = value {
Some(conversation_id)
} else {
None
}
})
.copied()
.collect()
})
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
use crate::{Context, ContextMessage, ToolOutput, ToolResult, ToolValue};
#[test]
fn test_related_conversation_ids_empty() {
let conversation = Conversation::generate();
let actual = conversation.related_conversation_ids();
assert_eq!(actual, vec![]);
}
#[test]
fn test_related_conversation_ids_with_agent_calls() {
let agent_conv_id_1 = ConversationId::generate();
let agent_conv_id_2 = ConversationId::generate();
let context = Context::default()
.add_message(ContextMessage::user("Test task", None))
.add_message(ContextMessage::assistant("Working on it", None, None, None))
.add_message(ContextMessage::Tool(ToolResult::new("agent_tool").output(
Ok(ToolOutput {
is_error: false,
values: vec![ToolValue::AI {
value: "Agent result".to_string(),
conversation_id: agent_conv_id_1,
}],
}),
)))
.add_message(ContextMessage::assistant("Continuing", None, None, None))
.add_message(ContextMessage::Tool(
ToolResult::new("another_agent").output(Ok(ToolOutput {
is_error: false,
values: vec![ToolValue::AI {
value: "Another agent result".to_string(),
conversation_id: agent_conv_id_2,
}],
})),
));
let conversation = Conversation::generate().context(context);
let actual = conversation.related_conversation_ids();
assert_eq!(actual.len(), 2);
assert!(actual.contains(&agent_conv_id_1));
assert!(actual.contains(&agent_conv_id_2));
}
#[test]
fn test_related_conversation_ids_mixed_tool_results() {
let agent_conv_id = ConversationId::generate();
let context = Context::default()
.add_message(ContextMessage::user("Test task", None))
.add_message(ContextMessage::Tool(
ToolResult::new("regular_tool").output(Ok(ToolOutput::text("Regular result"))),
))
.add_message(ContextMessage::Tool(ToolResult::new("agent_tool").output(
Ok(ToolOutput {
is_error: false,
values: vec![ToolValue::AI {
value: "Agent result".to_string(),
conversation_id: agent_conv_id,
}],
}),
)))
.add_message(ContextMessage::Tool(
ToolResult::new("another_regular_tool")
.output(Ok(ToolOutput::text("Another regular result"))),
));
let conversation = Conversation::generate().context(context);
let actual = conversation.related_conversation_ids();
assert_eq!(actual, vec![agent_conv_id]);
}
#[test]
fn test_total_cost() {
use crate::{MessageEntry, Usage};
// Create main conversation with cost
// Create main conversation with cost
let main_usage = Usage { cost: Some(0.01), ..Usage::default() };
let main_entry: MessageEntry =
ContextMessage::assistant("Response", None, None, None).into();
let main_context = Context::default()
.add_message(ContextMessage::user("Test", None))
.add_entry(main_entry.usage(main_usage));
let main_conv = Conversation::generate().context(main_context);
// Create related conversations with costs
let related_usage_1 = Usage { cost: Some(0.02), ..Usage::default() };
let related_entry_1: MessageEntry =
ContextMessage::assistant("Result 1", None, None, None).into();
let related_context_1 = Context::default()
.add_message(ContextMessage::user("Task 1", None))
.add_entry(related_entry_1.usage(related_usage_1));
let related_conv_1 = Conversation::generate().context(related_context_1);
let related_usage_2 = Usage { cost: Some(0.03), ..Usage::default() };
let related_entry_2: MessageEntry =
ContextMessage::assistant("Result 2", None, None, None).into();
let related_context_2 = Context::default()
.add_message(ContextMessage::user("Task 2", None))
.add_entry(related_entry_2.usage(related_usage_2));
let related_conv_2 = Conversation::generate().context(related_context_2);
let actual = Conversation::total_cost(&[main_conv, related_conv_1, related_conv_2]);
// Check that cost is approximately 0.06 (accounting for floating point
// precision)
assert!(actual.is_some());
assert!((actual.unwrap() - 0.06).abs() < 0.0001);
}
#[test]
fn test_total_cost_no_costs() {
let main_conv = Conversation::generate();
let related_conv = Conversation::generate();
let actual = Conversation::total_cost(&[main_conv, related_conv]);
let expected = None;
assert_eq!(actual, expected);
}
#[test]
fn test_total_cost_partial_costs() {
use crate::{MessageEntry, Usage};
// Main conversation has no cost
let main_conv = Conversation::generate();
// Related conversation has cost
let related_usage = Usage { cost: Some(0.05), ..Usage::default() };
let related_entry: MessageEntry =
ContextMessage::assistant("Result", None, None, None).into();
let related_context = Context::default()
.add_message(ContextMessage::user("Task", None))
.add_entry(related_entry.usage(related_usage));
let related_conv = Conversation::generate().context(related_context);
let actual = Conversation::total_cost(&[main_conv, related_conv]);
let expected = Some(0.05);
assert_eq!(actual, expected);
}
}
|