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        if let Some(entry) = self.entries.get_mut(path)
249            && entry.mtime == candidate.updated
250        {
251            entry.pinned = true;
252            let session = entry.session.clone();
253            self.trim_pinned_details();
254            return session;
255        }
256        let parsed = parse_session_file(&candidate);
257        self.entries.insert(
258            path.to_path_buf(),
259            CacheEntry {
260                mtime: candidate.updated,
261                session: parsed.clone(),
262                pinned: true,
263            },
264        );
265        self.trim_pinned_details();
266        parsed
267    }
268
269    fn trim_pinned_details(&mut self) {
270        const MAX_PINNED_DETAILS: usize = 8;
271        let mut pinned = self
272            .entries
273            .iter()
274            .filter(|(_, entry)| entry.pinned)
275            .map(|(path, entry)| (path.clone(), entry.mtime))
276            .collect::<Vec<_>>();
277        if pinned.len() <= MAX_PINNED_DETAILS {
278            return;
279        }
280        let overflow = pinned.len() - MAX_PINNED_DETAILS;
281        pinned.sort_by_key(|(_, mtime)| *mtime);
282        for (path, _) in pinned.into_iter().take(overflow) {
283            self.entries.remove(&path);
284        }
285    }
286
287    pub fn discover_cached(&mut self, limit: usize, max_age: Duration) -> Vec<AgentSession> {
288        self.discover_cached_excluding(limit, max_age, &[])
289    }
290
291    pub fn discover_cached_excluding(
292        &mut self,
293        limit: usize,
294        max_age: Duration,
295        excluded_agents: &[&str],
296    ) -> Vec<AgentSession> {
297        let target = limit.clamp(1, 25);
298        let mut excluded_agents = excluded_agents
299            .iter()
300            .map(|agent| (*agent).to_string())
301            .collect::<Vec<_>>();
302        excluded_agents.sort();
303        if self.last_limit < target
304            || self.last_excluded_agents != excluded_agents
305            || self
306                .last_refresh
307                .is_none_or(|last| last.elapsed() >= max_age)
308        {
309            self.refresh(target, &excluded_agents);
310        }
311        self.cached_sessions.iter().take(target).cloned().collect()
312    }
313
314    fn refresh(&mut self, limit: usize, excluded_agents: &[String]) {
315        let mut candidates = discover_session_files();
316        candidates
317            .retain(|candidate| !excluded_agents.iter().any(|agent| agent == candidate.agent));
318        candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.updated));
319        let target = limit.clamp(1, 25);
320        let mut live_paths = HashSet::new();
321        let mut sessions = Vec::new();
322        let mut seen = HashSet::new();
323
324        for candidate in candidates
325            .into_iter()
326            .take(target.saturating_mul(3).clamp(10, 75))
327        {
328            live_paths.insert(candidate.path.clone());
329            let session = match self.entries.get(&candidate.path) {
330                Some(entry) if entry.mtime == candidate.updated => entry.session.clone(),
331                _ => {
332                    let parsed = parse_session_file(&candidate);
333                    self.entries.insert(
334                        candidate.path.clone(),
335                        CacheEntry {
336                            mtime: candidate.updated,
337                            session: parsed.clone(),
338                            pinned: false,
339                        },
340                    );
341                    parsed
342                }
343            };
344            if let Some(session) = session
345                && seen.insert(session.display_id.clone())
346            {
347                sessions.push(session);
348                if sessions.len() >= target {
349                    break;
350                }
351            }
352        }
353        self.entries
354            .retain(|path, entry| live_paths.contains(path) || (entry.pinned && path.is_file()));
355        self.cached_sessions = sessions;
356        self.last_refresh = Some(Instant::now());
357        self.last_limit = target;
358        self.last_excluded_agents = excluded_agents.to_vec();
359    }
360}