1use std::collections::BTreeMap;
5
6use rig_core::message::{AssistantContent, Message, UserContent};
7use serde::Serialize;
8
9use super::Conversation;
10
11#[derive(Debug, Serialize)]
13#[serde(rename_all = "camelCase")]
14pub struct Summary {
15 pub backend: Option<crate::config::Backend>,
16 pub model: Option<crate::config::ModelId>,
17 pub reasoning: Option<crate::config::Reasoning>,
18 pub started_at: Option<crate::types::Timestamp>,
20 pub ended_at: Option<crate::types::Timestamp>,
21 #[serde(serialize_with = "crate::config::validation::milliseconds::serialize")]
22 pub duration_ms: Option<std::time::Duration>,
23 pub follow_ups: usize,
24 pub turns: Vec<Turn>,
26 pub tokens: BTreeMap<String, u64>,
28 pub tool_calls: BTreeMap<crate::types::CalledTool, ToolCalls>,
30}
31
32#[derive(Debug, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub struct Turn {
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub follow_up: Option<u64>,
39 pub turn: u64,
40 pub attempts: u64,
41 pub tokens: BTreeMap<String, u64>,
43}
44
45#[derive(Debug, Default, Serialize)]
46pub struct ToolCalls {
47 pub calls: u64,
48 pub failed: u64,
51}
52
53impl Summary {
54 pub fn new(conversation: &Conversation) -> Self {
55 let header = conversation.header();
56
57 let (started_at, ended_at) = (
58 conversation.events.first().and_then(|event| event.at),
59 conversation.events.last().and_then(|event| event.at),
60 );
61 let duration_ms = started_at
62 .zip(ended_at)
63 .map(|(start, end)| end.since(start));
64 let mut summary = Self {
65 backend: header.backend,
66 model: header.model.clone(),
67 reasoning: header.reasoning,
68 started_at,
69 ended_at,
70 duration_ms,
71 follow_ups: conversation.sends(),
72 turns: Vec::new(),
73 tokens: BTreeMap::new(),
74 tool_calls: BTreeMap::new(),
75 };
76 let mut answered = BTreeMap::<crate::types::CalledTool, (u64, u64)>::new();
78 for event in &conversation.events {
79 match &event.kind {
80 super::Kind::Attempt(attempt) => summary.attempt(event.send, attempt),
81 super::Kind::Message(event) => match &event.message {
82 Message::Assistant { content, .. } => {
83 for part in content {
84 if let AssistantContent::ToolCall(call) = part {
85 let name =
86 crate::types::CalledTool::called(call.function.name.as_str());
87 summary.tool_calls.entry(name).or_default().calls += 1;
88 }
89 }
90 }
91 Message::User { content } => {
92 let results = content.iter().filter_map(|part| match part {
93 UserContent::ToolResult(result) => Some(result),
94 _ => None,
95 });
96 for (index, result) in results.enumerate() {
97 let (count, failed) = answered
98 .entry(crate::types::CalledTool::called(result.name.as_str()))
99 .or_default();
100 *count += 1;
101 *failed += u64::from(event.is_error.get(index) == Some(&true));
102 }
103 }
104 Message::System { .. } => {}
105 },
106 _ => {}
107 }
108 }
109 for (name, calls) in &mut summary.tool_calls {
110 let (count, failed) = answered.get(name).copied().unwrap_or_default();
111 calls.failed = failed + calls.calls.saturating_sub(count);
112 }
113 summary
114 }
115
116 fn attempt(&mut self, send: Option<usize>, attempt: &crate::llm::Attempt) {
118 let follow_up = send.map(|send| send as u64);
119 let turn = attempt.turn as u64;
120 if self
121 .turns
122 .last()
123 .is_none_or(|last| (last.follow_up, last.turn) != (follow_up, turn))
124 {
125 self.turns.push(Turn {
126 follow_up,
127 turn,
128 attempts: 0,
129 tokens: BTreeMap::new(),
130 });
131 }
132 let current = self.turns.last_mut().expect("the attempt's turn");
133 current.attempts += 1;
134 for (name, value) in attempt.usage.iter() {
135 for tokens in [&mut current.tokens, &mut self.tokens] {
136 let total = tokens.entry(name.clone()).or_default();
137 *total = total.saturating_add(*value);
138 }
139 }
140 }
141}
142
143pub fn tokens_text(tokens: &BTreeMap<String, u64>) -> Option<String> {
145 let parts: Vec<_> = [
146 ("inputTokens", "input"),
147 ("outputTokens", "output"),
148 ("cacheReadTokens", "cache read"),
149 ("cacheWriteTokens", "cache write"),
150 ("reasoningTokens", "reasoning"),
151 ]
152 .into_iter()
153 .filter_map(|(name, label)| Some(format!("{label} {}", tokens.get(name)?)))
154 .collect();
155 (!parts.is_empty()).then(|| parts.join(" · "))
156}
157
158#[cfg(test)]
159mod tests {
160 use rig_core::message::{ToolCall, ToolFunction, ToolName, ToolResultContent};
161 use serde_json::json;
162
163 use super::*;
164
165 fn call(id: &str, name: &str) -> ToolCall {
166 ToolCall::from_wire(
167 id,
168 ToolFunction::new(ToolName::new(name).unwrap(), json!({})),
169 )
170 }
171
172 #[test]
173 fn a_summary_counts_turns_tokens_and_failed_calls() {
174 let answer = Message::Assistant {
175 id: None,
176 content: vec![
177 AssistantContent::ToolCall(call("c1", "read")),
178 AssistantContent::ToolCall(call("c2", "list")),
179 ],
180 };
181 let results = Message::tool_results(
182 [call("c1", "read"), call("c2", "list")]
183 .into_iter()
184 .map(|call| call.result(vec![ToolResultContent::text("ok")]))
185 .collect(),
186 );
187 let unanswered = Message::Assistant {
188 id: None,
189 content: vec![AssistantContent::ToolCall(call("c3", "read"))],
190 };
191 let events = vec![
192 json!({"kind":"review","backend":"openai","model":"m","at":"2026-10-06T01:00:00Z"}),
193 json!({"kind":"attempt","turn":1,"attempt":1,"usage":{},"error":"busy"}),
194 json!({
195 "kind":"attempt",
196 "turn":1,
197 "attempt":2,
198 "usage":{"inputTokens":10,"outputTokens":4,"cacheReadTokens":2},
199 }),
200 json!({"kind":"message","turn":1,"message":answer}),
201 json!({"kind":"message","turn":2,"message":results,"isError":[true,false]}),
202 json!({
203 "kind":"attempt",
204 "turn":2,
205 "attempt":1,
206 "usage":{"inputTokens":20,"outputTokens":6},
207 }),
208 json!({"kind":"end","result":{"verdict":"GREEN"}}),
209 json!({"kind":"send","send":1}),
210 json!({
211 "kind":"attempt",
212 "send":1,
213 "turn":1,
214 "attempt":1,
215 "usage":{"inputTokens":30,"outputTokens":1,"reasoningTokens":1},
216 }),
217 json!({"kind":"message","send":1,"turn":1,"message":unanswered}),
218 json!({"kind":"answer","send":1,"error":"stopped","at":"2026-10-06T01:00:02.5Z"}),
219 ];
220 let summary = Summary::new(&Conversation {
221 path: "session.jsonl".into(),
222 wire_events: Vec::new(),
223 events: events
224 .into_iter()
225 .map(|event| serde_json::from_value(event).unwrap())
226 .collect(),
227 });
228 assert_eq!(summary.backend, Some(crate::config::Backend::Openai));
229 assert_eq!(
230 summary.duration_ms,
231 Some(std::time::Duration::from_millis(2500))
232 );
233 assert_eq!(summary.follow_ups, 1);
234 let turns: Vec<_> = summary
235 .turns
236 .iter()
237 .map(|turn| (turn.follow_up, turn.turn, turn.attempts))
238 .collect();
239 assert_eq!(turns, [(None, 1, 2), (None, 2, 1), (Some(1), 1, 1)]);
240 assert_eq!(
241 json!(summary.tokens),
242 json!({"inputTokens":60,"outputTokens":11,"cacheReadTokens":2,"reasoningTokens":1})
243 );
244 assert_eq!(
245 json!(summary.tool_calls),
246 json!({"list":{"calls":1,"failed":0},"read":{"calls":2,"failed":2}})
247 );
248 assert_eq!(
249 tokens_text(&summary.tokens).unwrap(),
250 "input 60 · output 11 · cache read 2 · reasoning 1"
251 );
252 }
253}