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