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::{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    /// Full source-visible prompt text for the authorized session detail API.
50    #[serde(default, skip_serializing_if = "String::is_empty")]
51    pub text: String,
52    pub preview: String,
53    #[serde(default, skip_serializing_if = "String::is_empty")]
54    pub tag: String,
55    /// Source-visible semantic responsibility path after applying this prompt.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub task_path: Vec<String>,
58}
59
60impl UserPrompt {
61    pub fn prompt_key(&self) -> String {
62        format!("{}:{}", self.index, self.text_hash)
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct ToolPath {
68    /// Path as recorded by the native agent. Consumers resolve relative paths
69    /// against the session cwd and must reject paths outside their scope.
70    pub path: String,
71    /// One of read, write, create, delete, rename_from, or rename.
72    pub access: String,
73    /// Source path for a rename. Kept on the destination so one tool event can
74    /// carry several independent rename pairs without positional guessing.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub previous_path: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ToolEvent {
81    pub ts_ms: Option<i64>,
82    pub prompt_index: usize,
83    pub tool_name: String,
84    pub category: String,
85    pub command: String,
86    pub command_name: String,
87    pub effect: String,
88    pub process_chain: Vec<String>,
89    pub status: String,
90    pub path_groups: Vec<String>,
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub paths: Vec<ToolPath>,
93    pub domains: Vec<String>,
94    pub call_id: Option<String>,
95    /// Exact nonempty `input.skill` on a source-native Skill tool call.
96    #[serde(default, skip_serializing_if = "String::is_empty")]
97    pub invoked_skill: String,
98    /// Exact source-recorded skill scope active at this tool invocation.
99    #[serde(default, skip_serializing_if = "String::is_empty")]
100    pub skill: String,
101    /// Source-visible semantic responsibility path active at this operation.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub task_path: Vec<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LlmResponse {
108    pub ts_ms: Option<i64>,
109    pub prompt_index: usize,
110    pub model: String,
111    /// Source-native completion identity used to merge split JSONL records.
112    #[serde(default, skip_serializing_if = "String::is_empty")]
113    pub source_id: String,
114    pub text_hash: String,
115    /// Full source-visible response text for the authorized session detail API.
116    #[serde(default, skip_serializing_if = "String::is_empty")]
117    pub text: String,
118    pub preview: String,
119    pub input_tokens: u64,
120    pub output_tokens: u64,
121    pub cache_tokens: u64,
122    pub total_tokens: u64,
123    #[serde(default, skip_serializing_if = "String::is_empty")]
124    pub tag: String,
125    /// Source-native response lifecycle when the agent records one explicitly.
126    /// Examples are `commentary`, `final_answer`, and `assistant_message`.
127    #[serde(default, skip_serializing_if = "String::is_empty")]
128    pub response_phase: String,
129    /// Exact source-recorded skill scope active when this response began.
130    #[serde(default, skip_serializing_if = "String::is_empty")]
131    pub skill: String,
132    /// Source-visible semantic responsibility path active at this response.
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub task_path: Vec<String>,
135}
136
137/// Latest source-recorded coding plan entry.
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct PlanStep {
140    pub step: String,
141    pub status: String,
142}
143
144impl LlmResponse {
145    pub fn token_components(&self) -> Vec<(&'static str, u64)> {
146        const MAX_REPORTED_TOKEN_COMPONENT: u64 = 10_000_000;
147        const MAX_ESTIMATED_TOKEN_COMPONENT: u64 = 2_000_000;
148        let mut out = Vec::new();
149        if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.input_tokens) {
150            out.push(("input", self.input_tokens));
151        }
152        if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.output_tokens) {
153            out.push(("output", self.output_tokens));
154        }
155        if (1..=MAX_REPORTED_TOKEN_COMPONENT).contains(&self.cache_tokens) {
156            out.push(("cache", self.cache_tokens));
157        }
158        if out.is_empty() && (1..=MAX_ESTIMATED_TOKEN_COMPONENT).contains(&self.total_tokens) {
159            out.push(("estimate", self.total_tokens));
160        }
161        if out.is_empty() {
162            out.push(("unknown", 1));
163        }
164        out
165    }
166}
167
168/// Vendor-neutral interaction events extracted from an agent-native transcript.
169#[derive(Debug, Clone, Serialize, Deserialize, Default)]
170pub struct SessionEvents {
171    pub prompts: Vec<UserPrompt>,
172    pub tools: Vec<ToolEvent>,
173    pub llm_responses: Vec<LlmResponse>,
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub plan: Vec<PlanStep>,
176}
177
178/// A parsed agent session with metadata, token usage, and tool invocations.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct AgentSession {
181    pub agent_type: String,
182    pub session_id: String,
183    pub conversation_id: Option<String>,
184    pub display_id: String,
185    pub path: PathBuf,
186    pub updated: SystemTime,
187    pub start_timestamp_ms: Option<u64>,
188    pub end_timestamp_ms: Option<u64>,
189    pub model: Option<String>,
190    pub usage: TokenUsage,
191    pub model_usage: BTreeMap<String, TokenUsage>,
192    pub tools: BTreeMap<String, usize>,
193    pub files: BTreeMap<String, usize>,
194    pub prompt_preview: Option<String>,
195    pub duration_ms: u64,
196    pub cwd: Option<String>,
197    pub last_message_at: Option<String>,
198    /// Vendor-neutral interaction events extracted from agent-native transcripts.
199    #[serde(default)]
200    pub events: SessionEvents,
201}
202
203/// A candidate session file discovered on disk.
204#[derive(Debug, Clone)]
205pub struct SessionCandidate {
206    pub agent: &'static str,
207    pub path: PathBuf,
208    pub updated: SystemTime,
209}
210
211/// Statistics about a session directory.
212#[derive(Debug, Clone)]
213pub struct SessionDirStat {
214    pub agent: &'static str,
215    pub dir: PathBuf,
216    pub sessions: usize,
217    pub bytes: u64,
218}
219
220/// Cache for discovered and parsed sessions.
221#[derive(Default)]
222pub struct SessionCache {
223    entries: HashMap<PathBuf, CacheEntry>,
224    cached_sessions: Vec<AgentSession>,
225    last_refresh: Option<Instant>,
226    last_limit: usize,
227    last_excluded_agents: Vec<String>,
228}
229
230struct CacheEntry {
231    mtime: SystemTime,
232    session: Option<AgentSession>,
233    pinned: bool,
234}
235
236impl SessionCache {
237    pub fn new() -> Self {
238        Self::default()
239    }
240
241    /// Parse one known transcript without widening the bounded discovery scan.
242    /// The parsed session is reused until the file's modification time changes.
243    pub fn parse_path_cached(&mut self, path: &Path) -> Option<AgentSession> {
244        let Some(candidate) = crate::session_candidate_from_path(path) else {
245            self.entries.remove(path);
246            return None;
247        };
248        self.parse_candidate_cached(&candidate)
249    }
250
251    /// Parse a discovered transcript while preserving its provider identity.
252    pub fn parse_candidate_cached(&mut self, candidate: &SessionCandidate) -> Option<AgentSession> {
253        if let Some(entry) = self.entries.get_mut(&candidate.path)
254            && entry.mtime == candidate.updated
255        {
256            entry.pinned = true;
257            let session = entry.session.clone();
258            self.trim_pinned_details();
259            return session;
260        }
261        let parsed = parse_session_file(candidate);
262        self.entries.insert(
263            candidate.path.clone(),
264            CacheEntry {
265                mtime: candidate.updated,
266                session: parsed.clone(),
267                pinned: true,
268            },
269        );
270        self.trim_pinned_details();
271        parsed
272    }
273
274    fn trim_pinned_details(&mut self) {
275        const MAX_PINNED_DETAILS: usize = 8;
276        let mut pinned = self
277            .entries
278            .iter()
279            .filter(|(_, entry)| entry.pinned)
280            .map(|(path, entry)| (path.clone(), entry.mtime))
281            .collect::<Vec<_>>();
282        if pinned.len() <= MAX_PINNED_DETAILS {
283            return;
284        }
285        let overflow = pinned.len() - MAX_PINNED_DETAILS;
286        pinned.sort_by_key(|(_, mtime)| *mtime);
287        for (path, _) in pinned.into_iter().take(overflow) {
288            self.entries.remove(&path);
289        }
290    }
291
292    pub fn discover_cached(&mut self, limit: usize, max_age: Duration) -> Vec<AgentSession> {
293        self.discover_cached_excluding(limit, max_age, &[])
294    }
295
296    pub fn discover_cached_excluding(
297        &mut self,
298        limit: usize,
299        max_age: Duration,
300        excluded_agents: &[&str],
301    ) -> Vec<AgentSession> {
302        let target = limit.clamp(1, 25);
303        let mut excluded_agents = excluded_agents
304            .iter()
305            .map(|agent| (*agent).to_string())
306            .collect::<Vec<_>>();
307        excluded_agents.sort();
308        if self.last_limit < target
309            || self.last_excluded_agents != excluded_agents
310            || self
311                .last_refresh
312                .is_none_or(|last| last.elapsed() >= max_age)
313        {
314            self.refresh(target, &excluded_agents);
315        }
316        self.cached_sessions.iter().take(target).cloned().collect()
317    }
318
319    fn refresh(&mut self, limit: usize, excluded_agents: &[String]) {
320        let mut candidates = discover_session_files();
321        candidates
322            .retain(|candidate| !excluded_agents.iter().any(|agent| agent == candidate.agent));
323        candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.updated));
324        let target = limit.clamp(1, 25);
325        let mut live_paths = HashSet::new();
326        let mut sessions = Vec::new();
327        let mut seen = HashSet::new();
328
329        for candidate in candidates
330            .into_iter()
331            .take(target.saturating_mul(3).clamp(10, 75))
332        {
333            live_paths.insert(candidate.path.clone());
334            let session = match self.entries.get(&candidate.path) {
335                Some(entry) if entry.mtime == candidate.updated => entry.session.clone(),
336                _ => {
337                    let parsed = parse_session_file(&candidate);
338                    self.entries.insert(
339                        candidate.path.clone(),
340                        CacheEntry {
341                            mtime: candidate.updated,
342                            session: parsed.clone(),
343                            pinned: false,
344                        },
345                    );
346                    parsed
347                }
348            };
349            if let Some(session) = session
350                && seen.insert(session.display_id.clone())
351            {
352                sessions.push(session);
353                if sessions.len() >= target {
354                    break;
355                }
356            }
357        }
358        self.entries
359            .retain(|path, entry| live_paths.contains(path) || (entry.pinned && path.is_file()));
360        self.cached_sessions = sessions;
361        self.last_refresh = Some(Instant::now());
362        self.last_limit = target;
363        self.last_excluded_agents = excluded_agents.to_vec();
364    }
365}