Skip to main content

artifactize_core/agent/session/
summary.rs

1//! `session show --summary`: what a saved conversation spent and did, computed from its
2//! session file alone.
3
4use std::collections::BTreeMap;
5
6use rig_core::message::{AssistantContent, Message, UserContent};
7use serde::Serialize;
8
9use super::Conversation;
10
11/// A saved conversation's backend, time span, turns, tokens and tool calls.
12#[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    /// The first and last event's time.
19    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    /// Every provider turn in order: the review's, then each follow-up's.
25    pub turns: Vec<Turn>,
26    /// The reported token counters of every attempt, summed.
27    pub tokens: BTreeMap<String, u64>,
28    /// By tool name.
29    pub tool_calls: BTreeMap<crate::types::CalledTool, ToolCalls>,
30}
31
32/// One provider request and its attempts.
33#[derive(Debug, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub struct Turn {
36    /// The follow-up the turn answered; absent for the review's turns.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub follow_up: Option<u64>,
39    pub turn: u64,
40    pub attempts: u64,
41    /// The reported token counters of its attempts, summed.
42    pub tokens: BTreeMap<String, u64>,
43}
44
45#[derive(Debug, Default, Serialize)]
46pub struct ToolCalls {
47    pub calls: u64,
48    /// Calls whose result was an error, and calls never answered because the review or
49    /// follow-up stopped first.
50    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        // Calls by tool, then the results that answered them and those that failed.
77        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    /// Count an attempt in its turn and in the totals.
117    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
143/// Token counters as text, in a fixed order with readable names; `None` without any.
144pub 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}