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, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ToolPath {
62    /// Path as recorded by the native agent. Consumers resolve relative paths
63    /// against the session cwd and must reject paths outside their scope.
64    pub path: String,
65    /// One of read, write, create, delete, rename_from, or rename.
66    pub access: String,
67    /// Source path for a rename. Kept on the destination so one tool event can
68    /// carry several independent rename pairs without positional guessing.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub previous_path: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ToolEvent {
75    pub ts_ms: Option<i64>,
76    pub prompt_index: usize,
77    pub tool_name: String,
78    pub category: String,
79    pub command: String,
80    pub command_name: String,
81    pub effect: String,
82    pub process_chain: Vec<String>,
83    pub status: String,
84    pub path_groups: Vec<String>,
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub paths: Vec<ToolPath>,
87    pub domains: Vec<String>,
88    pub call_id: Option<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct LlmResponse {
93    pub ts_ms: Option<i64>,
94    pub prompt_index: usize,
95    pub model: String,
96    pub text_hash: String,
97    pub preview: String,
98    pub input_tokens: u64,
99    pub output_tokens: u64,
100    pub cache_tokens: u64,
101    pub total_tokens: u64,
102    #[serde(default, skip_serializing_if = "String::is_empty")]
103    pub tag: String,
104}
105
106impl LlmResponse {
107    pub fn token_components(&self) -> Vec<(&'static str, u64)> {
108        const MAX_REPORTED_TOKEN_COMPONENT: u64 = 10_000_000;
109        const MAX_ESTIMATED_TOKEN_COMPONENT: u64 = 2_000_000;
110        let mut out = Vec::new();
111        if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.input_tokens) {
112            out.push(("input", self.input_tokens));
113        }
114        if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.output_tokens) {
115            out.push(("output", self.output_tokens));
116        }
117        if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.cache_tokens) {
118            out.push(("cache", self.cache_tokens));
119        }
120        if out.is_empty() && (1..=MAX_ESTIMATED_TOKEN_COMPONENT).contains(&self.total_tokens) {
121            out.push(("estimate", self.total_tokens));
122        }
123        if out.is_empty() {
124            out.push(("unknown", 1));
125        }
126        out
127    }
128}
129
130/// Vendor-neutral interaction events extracted from an agent-native transcript.
131#[derive(Debug, Clone, Serialize, Deserialize, Default)]
132pub struct SessionEvents {
133    pub prompts: Vec<UserPrompt>,
134    pub tools: Vec<ToolEvent>,
135    pub llm_responses: Vec<LlmResponse>,
136}
137
138/// A parsed agent session with metadata, token usage, and tool invocations.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct AgentSession {
141    pub agent_type: String,
142    pub session_id: String,
143    pub conversation_id: Option<String>,
144    pub display_id: String,
145    pub path: PathBuf,
146    pub updated: SystemTime,
147    pub start_timestamp_ms: Option<u64>,
148    pub end_timestamp_ms: Option<u64>,
149    pub model: Option<String>,
150    pub usage: TokenUsage,
151    pub model_usage: BTreeMap<String, TokenUsage>,
152    pub tools: BTreeMap<String, usize>,
153    pub files: BTreeMap<String, usize>,
154    pub prompt_preview: Option<String>,
155    pub duration_ms: u64,
156    pub cwd: Option<String>,
157    pub last_message_at: Option<String>,
158    /// Vendor-neutral interaction events extracted from agent-native transcripts.
159    #[serde(default)]
160    pub events: SessionEvents,
161}
162
163/// A candidate session file discovered on disk.
164#[derive(Debug, Clone)]
165pub struct SessionCandidate {
166    pub agent: &'static str,
167    pub path: PathBuf,
168    pub updated: SystemTime,
169}
170
171/// Statistics about a session directory.
172#[derive(Debug, Clone)]
173pub struct SessionDirStat {
174    pub agent: &'static str,
175    pub dir: PathBuf,
176    pub sessions: usize,
177    pub bytes: u64,
178}
179
180/// Cache for discovered and parsed sessions.
181#[derive(Default)]
182pub struct SessionCache {
183    entries: HashMap<PathBuf, CacheEntry>,
184    cached_sessions: Vec<AgentSession>,
185    last_refresh: Option<Instant>,
186    last_limit: usize,
187    last_excluded_agents: Vec<String>,
188}
189
190struct CacheEntry {
191    mtime: SystemTime,
192    session: Option<AgentSession>,
193}
194
195impl SessionCache {
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    pub fn discover_cached(&mut self, limit: usize, max_age: Duration) -> Vec<AgentSession> {
201        self.discover_cached_excluding(limit, max_age, &[])
202    }
203
204    pub fn discover_cached_excluding(
205        &mut self,
206        limit: usize,
207        max_age: Duration,
208        excluded_agents: &[&str],
209    ) -> Vec<AgentSession> {
210        let target = limit.clamp(1, 25);
211        let mut excluded_agents = excluded_agents
212            .iter()
213            .map(|agent| (*agent).to_string())
214            .collect::<Vec<_>>();
215        excluded_agents.sort();
216        if self.last_limit < target
217            || self.last_excluded_agents != excluded_agents
218            || self
219                .last_refresh
220                .is_none_or(|last| last.elapsed() >= max_age)
221        {
222            self.refresh(target, &excluded_agents);
223        }
224        self.cached_sessions.iter().take(target).cloned().collect()
225    }
226
227    fn refresh(&mut self, limit: usize, excluded_agents: &[String]) {
228        let mut candidates = discover_session_files();
229        candidates
230            .retain(|candidate| !excluded_agents.iter().any(|agent| agent == candidate.agent));
231        candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.updated));
232        let target = limit.clamp(1, 25);
233        let mut live_paths = HashSet::new();
234        let mut sessions = Vec::new();
235        let mut seen = HashSet::new();
236
237        for candidate in candidates
238            .into_iter()
239            .take(target.saturating_mul(3).clamp(10, 75))
240        {
241            live_paths.insert(candidate.path.clone());
242            let session = match self.entries.get(&candidate.path) {
243                Some(entry) if entry.mtime == candidate.updated => entry.session.clone(),
244                _ => {
245                    let parsed = parse_session_file(&candidate);
246                    self.entries.insert(
247                        candidate.path.clone(),
248                        CacheEntry {
249                            mtime: candidate.updated,
250                            session: parsed.clone(),
251                        },
252                    );
253                    parsed
254                }
255            };
256            if let Some(session) = session
257                && seen.insert(session.display_id.clone())
258            {
259                sessions.push(session);
260                if sessions.len() >= target {
261                    break;
262                }
263            }
264        }
265        self.entries.retain(|path, _| live_paths.contains(path));
266        self.cached_sessions = sessions;
267        self.last_refresh = Some(Instant::now());
268        self.last_limit = target;
269        self.last_excluded_agents = excluded_agents.to_vec();
270    }
271}