Skip to main content

agent_session/
types.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4//! Data types for agent session representation.
5
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::path::PathBuf;
9use std::time::{Duration, Instant, SystemTime};
10
11use crate::{discover_session_files, parse_session_file};
12
13/// Token usage statistics for a model or session.
14#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
15pub struct TokenUsage {
16    pub input_tokens: i64,
17    pub output_tokens: i64,
18    pub cache_creation_tokens: i64,
19    pub cache_read_tokens: i64,
20    pub total_tokens: i64,
21}
22
23impl TokenUsage {
24    pub(crate) fn add(
25        &mut self,
26        input: i64,
27        output: i64,
28        cache_creation: i64,
29        cache_read: i64,
30        total: i64,
31    ) {
32        self.input_tokens += input;
33        self.output_tokens += output;
34        self.cache_creation_tokens += cache_creation;
35        self.cache_read_tokens += cache_read;
36        self.total_tokens += if total > 0 {
37            total
38        } else {
39            input + output + cache_creation + cache_read
40        };
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct UserPrompt {
46    pub index: usize,
47    pub ts_ms: Option<i64>,
48    pub text_hash: String,
49    pub preview: String,
50    #[serde(default, skip_serializing_if = "String::is_empty")]
51    pub tag: String,
52}
53
54impl UserPrompt {
55    pub fn prompt_key(&self) -> String {
56        format!("{}:{}", self.index, self.text_hash)
57    }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolEvent {
62    pub ts_ms: Option<i64>,
63    pub prompt_index: usize,
64    pub tool_name: String,
65    pub category: String,
66    pub command: String,
67    pub command_name: String,
68    pub effect: String,
69    pub process_chain: Vec<String>,
70    pub status: String,
71    pub path_groups: Vec<String>,
72    pub domains: Vec<String>,
73    pub call_id: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct LlmResponse {
78    pub ts_ms: Option<i64>,
79    pub prompt_index: usize,
80    pub model: String,
81    pub text_hash: String,
82    pub preview: String,
83    pub input_tokens: u64,
84    pub output_tokens: u64,
85    pub cache_tokens: u64,
86    pub total_tokens: u64,
87    #[serde(default, skip_serializing_if = "String::is_empty")]
88    pub tag: String,
89}
90
91impl LlmResponse {
92    pub fn token_components(&self) -> Vec<(&'static str, u64)> {
93        const MAX_REPORTED_TOKEN_COMPONENT: u64 = 10_000_000;
94        const MAX_ESTIMATED_TOKEN_COMPONENT: u64 = 2_000_000;
95        let mut out = Vec::new();
96        if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.input_tokens) {
97            out.push(("input", self.input_tokens));
98        }
99        if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.output_tokens) {
100            out.push(("output", self.output_tokens));
101        }
102        if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.cache_tokens) {
103            out.push(("cache", self.cache_tokens));
104        }
105        if out.is_empty() && (1..=MAX_ESTIMATED_TOKEN_COMPONENT).contains(&self.total_tokens) {
106            out.push(("estimate", self.total_tokens));
107        }
108        if out.is_empty() {
109            out.push(("unknown", 1));
110        }
111        out
112    }
113}
114
115/// Vendor-neutral interaction events extracted from an agent-native transcript.
116#[derive(Debug, Clone, Serialize, Deserialize, Default)]
117pub struct SessionEvents {
118    pub prompts: Vec<UserPrompt>,
119    pub tools: Vec<ToolEvent>,
120    pub llm_responses: Vec<LlmResponse>,
121}
122
123/// A parsed agent session with metadata, token usage, and tool invocations.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct AgentSession {
126    pub agent_type: String,
127    pub session_id: String,
128    pub conversation_id: Option<String>,
129    pub display_id: String,
130    pub path: PathBuf,
131    pub updated: SystemTime,
132    pub start_timestamp_ms: Option<u64>,
133    pub end_timestamp_ms: Option<u64>,
134    pub model: Option<String>,
135    pub usage: TokenUsage,
136    pub model_usage: BTreeMap<String, TokenUsage>,
137    pub tools: BTreeMap<String, usize>,
138    pub files: BTreeMap<String, usize>,
139    pub prompt_preview: Option<String>,
140    pub duration_ms: u64,
141    pub cwd: Option<String>,
142    pub last_message_at: Option<String>,
143    /// Vendor-neutral interaction events extracted from agent-native transcripts.
144    #[serde(default)]
145    pub events: SessionEvents,
146}
147
148/// A candidate session file discovered on disk.
149#[derive(Debug, Clone)]
150pub struct SessionCandidate {
151    pub agent: &'static str,
152    pub path: PathBuf,
153    pub updated: SystemTime,
154}
155
156/// Statistics about a session directory.
157#[derive(Debug, Clone)]
158pub struct SessionDirStat {
159    pub agent: &'static str,
160    pub dir: PathBuf,
161    pub sessions: usize,
162    pub bytes: u64,
163}
164
165/// Cache for discovered and parsed sessions.
166#[derive(Default)]
167pub struct SessionCache {
168    entries: HashMap<PathBuf, CacheEntry>,
169    cached_sessions: Vec<AgentSession>,
170    last_refresh: Option<Instant>,
171    last_limit: usize,
172}
173
174struct CacheEntry {
175    mtime: SystemTime,
176    session: Option<AgentSession>,
177}
178
179impl SessionCache {
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    pub fn discover_cached(&mut self, limit: usize, max_age: Duration) -> Vec<AgentSession> {
185        let target = limit.clamp(1, 25);
186        if self.last_limit < target
187            || self
188                .last_refresh
189                .is_none_or(|last| last.elapsed() >= max_age)
190        {
191            self.refresh(target);
192        }
193        self.cached_sessions.iter().take(target).cloned().collect()
194    }
195
196    fn refresh(&mut self, limit: usize) {
197        let mut candidates = discover_session_files();
198        candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.updated));
199        let target = limit.clamp(1, 25);
200        let mut live_paths = HashSet::new();
201        let mut sessions = Vec::new();
202        let mut seen = HashSet::new();
203
204        for candidate in candidates
205            .into_iter()
206            .take(target.saturating_mul(3).clamp(10, 75))
207        {
208            live_paths.insert(candidate.path.clone());
209            let session = match self.entries.get(&candidate.path) {
210                Some(entry) if entry.mtime == candidate.updated => entry.session.clone(),
211                _ => {
212                    let parsed = parse_session_file(&candidate);
213                    self.entries.insert(
214                        candidate.path.clone(),
215                        CacheEntry {
216                            mtime: candidate.updated,
217                            session: parsed.clone(),
218                        },
219                    );
220                    parsed
221                }
222            };
223            if let Some(session) = session
224                && seen.insert(session.display_id.clone())
225            {
226                sessions.push(session);
227                if sessions.len() >= target {
228                    break;
229                }
230            }
231        }
232        self.entries.retain(|path, _| live_paths.contains(path));
233        self.cached_sessions = sessions;
234        self.last_refresh = Some(Instant::now());
235        self.last_limit = target;
236    }
237}