phi_core/session/model.rs
1use crate::types::*;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Deserializer, Serialize};
4
5/// Deserialize a value that may be `null` or missing as `T::default()`.
6/// Combines `#[serde(default)]` (handles missing) with null-as-default (handles explicit null).
7fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
8where
9 D: Deserializer<'de>,
10 T: Default + Deserialize<'de>,
11{
12 let opt = Option::deserialize(deserializer)?;
13 Ok(opt.unwrap_or_default())
14}
15
16// ---------------------------------------------------------------------------
17// SessionFormation
18// ---------------------------------------------------------------------------
19
20/// How this [`Session`] was initially created.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum SessionFormation {
23 /// Created by direct construction — e.g. when the caller manually builds a
24 /// [`Session`] value (e.g. in tests or tooling).
25 ///
26 /// [`SessionRecorder`] never sets this variant; it always writes
27 /// [`FirstLoop`][Self::FirstLoop] when it opens a session.
28 Explicit { timestamp: DateTime<Utc> },
29
30 /// Created automatically when a new `session_id` first appeared in an `AgentStart`
31 /// event (the recorder saw the session_id for the first time).
32 FirstLoop { timestamp: DateTime<Utc> },
33
34 /// A new session was opened because the agent had been idle longer than `threshold_secs`.
35 ///
36 /// Requires the caller to have rotated the `session_id` beforehand — for example
37 /// via [`BasicAgent::check_and_rotate`]. The recorder detects the new `session_id`
38 /// when the next `AgentStart` arrives.
39 InactivityTimeout {
40 /// Idle threshold that triggered the new session.
41 threshold_secs: u64,
42 /// The `session_id` of the session that preceded this one (if known).
43 previous_session_id: Option<String>,
44 timestamp: DateTime<Utc>,
45 },
46}
47
48// ---------------------------------------------------------------------------
49// LoopStatus
50// ---------------------------------------------------------------------------
51
52/// Lifecycle state of a [`LoopRecord`].
53///
54/// ```text
55/// ┌─────────┐ AgentStart ┌─────────┐ AgentEnd (ok) ┌───────────┐
56/// │ Pending ├─────────────►│ Running ├───────────────────►│ Completed │
57/// └─────────┘ └────┬────┘ AgentEnd (reject) └───────────┘
58/// │ ┌──────────┐
59/// ├─────────────────────────►│ Rejected │
60/// │ flush() └──────────┘
61/// │ ┌─────────┐
62/// └─────────────────────────►│ Aborted │
63/// └─────────┘
64/// ```
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub enum LoopStatus {
67 /// Loop id appeared in `ParallelLoopStart` but `AgentStart` has not yet arrived.
68 ///
69 /// Only used for parallel-evaluation branches that are pre-registered when
70 /// [`AgentEvent::ParallelLoopStart`] is processed, before their individual
71 /// `AgentStart` events fire.
72 Pending,
73
74 /// `AgentStart` was received; the loop is executing.
75 Running,
76
77 /// `AgentEnd` was received and `rejection` is `None`; the loop finished normally.
78 Completed,
79
80 /// `AgentEnd` was received with `rejection: Some(_)`; an input filter blocked the run.
81 Rejected,
82
83 /// [`SessionRecorder::flush`] was called before `AgentEnd` arrived
84 /// (e.g. process shutdown or unclean shutdown of the event channel).
85 Aborted,
86}
87
88// ---------------------------------------------------------------------------
89// LoopConfigSnapshot
90// ---------------------------------------------------------------------------
91
92/// A lightweight, serialisable snapshot of the model that ran a loop.
93///
94/// ## Why not store the full `AgentLoopConfig`?
95///
96/// `AgentLoopConfig` contains API keys (in `ModelConfig.api_key`) and
97/// non-serialisable hook closures (`BeforeTurnFn`, `AfterTurnFn`, etc.).
98/// Storing the full config would require stripping secrets and skipping
99/// closures, yielding little extra value.
100///
101/// `LoopConfigSnapshot` captures just enough to:
102/// - Identify which model/provider produced the messages (cost attribution,
103/// analysis).
104/// - Support replay by telling the caller which config to reconstruct.
105/// - Distinguish branches in evaluational parallelism (e.g. "haiku vs. opus").
106/// - Track per-loop config (thinking_level, temperature) for debugging.
107///
108/// Populated from `AgentStart.config_snapshot` (preferred) or extracted from
109/// the first `Message::Assistant` seen in the loop (fallback for older sessions).
110///
111/// New fields (added after the initial struct) are `Option` with
112/// `#[serde(default, skip_serializing_if = "Option::is_none")]` for backward
113/// compatibility with existing serialized sessions.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct LoopConfigSnapshot {
116 /// The model id string (e.g. `"claude-opus-4-6"`, `"gpt-4o"`).
117 pub model: String,
118 /// Provider name (e.g. `"anthropic"`, `"openai"`).
119 pub provider: String,
120 /// The stable config identity from `AgentLoopConfig.config_id` (if set).
121 ///
122 /// Matches the `config_segment` component embedded in the `loop_id` format
123 /// `{session_id}.{config_segment}.{N}`. Useful to correlate a `LoopRecord`
124 /// back to its named configuration.
125 pub config_id: Option<String>,
126
127 // ── Extended fields (all Optional for backward compat) ─────────────────
128 /// Human-friendly model name (e.g. `"Claude Sonnet 4"`, `"GPT-4o"`).
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub name: Option<String>,
131 /// Which API protocol was used.
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub api: Option<crate::provider::ApiProtocol>,
134 /// Base URL for API requests (useful for debugging which endpoint was hit).
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub base_url: Option<String>,
137 /// Whether this model supports reasoning/thinking.
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub reasoning: Option<bool>,
140 /// Context window size in tokens.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub context_window: Option<u32>,
143 /// Default max output tokens.
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub max_tokens: Option<u32>,
146 /// Thinking/reasoning level used for this loop.
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub thinking_level: Option<crate::types::ThinkingLevel>,
149 /// Sampling temperature used for this loop.
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub temperature: Option<f32>,
152}
153
154// ---------------------------------------------------------------------------
155// Cross-session sub-agent references
156// ---------------------------------------------------------------------------
157
158/// Outbound cross-session link — recorded on the **parent** [`LoopRecord`] when
159/// a tool call in that loop spawned a sub-agent loop.
160///
161/// Sub-agents run with their own `session_id`. This ref allows the parent session
162/// to link outward to the child session for tracing agent-spawning chains.
163///
164/// The inverse link is [`SpawnRef`] on [`Session::parent_spawn_ref`]
165/// (child → parent).
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct ChildLoopRef {
168 /// The `ToolCall.id` that triggered sub-agent execution.
169 pub tool_call_id: String,
170 /// The tool name that performed the spawn.
171 pub tool_name: String,
172 /// The sub-agent's `AgentStart.loop_id`.
173 pub child_loop_id: String,
174 /// The sub-agent's `AgentStart.session_id`.
175 ///
176 /// Extracted from the `child_loop_id` prefix — loop ids follow the format
177 /// `{session_id}.{config_segment}.{N}` where `session_id` is a UUID
178 /// containing hyphens but no dots.
179 pub child_session_id: String,
180}
181
182/// Inbound cross-session link — recorded on the **child** [`Session`] when the
183/// session was spawned by a tool call in a different (parent) session.
184///
185/// Together with [`ChildLoopRef`] in the parent session this forms a complete
186/// bidirectional cross-session spawn graph.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SpawnRef {
189 /// The parent session's `session_id`.
190 pub parent_session_id: String,
191 /// The parent loop's `loop_id` (the loop whose tool call triggered this spawn).
192 pub parent_loop_id: String,
193 /// The `ToolCall.id` in the parent loop.
194 pub tool_call_id: String,
195 /// The tool name in the parent loop.
196 pub tool_name: String,
197}
198
199// ---------------------------------------------------------------------------
200// ParallelGroupRecord
201// ---------------------------------------------------------------------------
202
203/// Links a [`LoopRecord`] to its evaluational-parallelism group.
204///
205/// All branches in the same `agent_loop_parallel` call share identical
206/// `all_loop_ids` / `selected_loop_id` values — only `is_selected` differs.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct ParallelGroupRecord {
209 /// All branch `loop_id`s in config order (matches `ParallelLoopStart.loop_ids`).
210 pub all_loop_ids: Vec<String>,
211 /// The `loop_id` selected as winner by the evaluation strategy.
212 pub selected_loop_id: String,
213 /// 0-based index into the original `configs` slice of the winning branch.
214 pub selected_config_index: usize,
215 /// Token usage incurred by the judge LLM (zero for non-judge strategies).
216 pub evaluation_usage: Usage,
217 /// `true` if this [`LoopRecord`] is the evaluation winner.
218 pub is_selected: bool,
219}
220
221// ---------------------------------------------------------------------------
222// LoopEvent
223// ---------------------------------------------------------------------------
224
225/// One event in a [`LoopRecord`]'s ordered event stream.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct LoopEvent {
228 /// Monotonic counter within this loop (0-based). Gaps indicate filtered events
229 /// (e.g. `MessageUpdate` streaming deltas when
230 /// `SessionRecorderConfig::include_streaming_events` is `false`).
231 pub sequence: u64,
232 /// The original event. `event.loop_id()` matches the [`LoopRecord::loop_id`].
233 pub event: AgentEvent,
234}
235
236// ---------------------------------------------------------------------------
237// Turn
238// ---------------------------------------------------------------------------
239
240/// A materialized record of one LLM turn within a loop.
241///
242/// Each turn represents one LLM call-response cycle plus any tool executions
243/// that followed. Built by [`SessionRecorder`] from `TurnStart`/`TurnEnd`
244/// event pairs.
245///
246/// ## Message partitioning
247///
248/// - `input_messages` — user prompts, steering messages, and follow-ups injected
249/// at the start of this turn (between `TurnStart` and the assistant response).
250/// - `output_message` — the assistant's streamed response (from `TurnEnd.message`).
251/// - `tool_results` — tool result messages executed this turn (from `TurnEnd.tool_results`).
252/// Empty when no tool calls were made (`StopReason::Stop`).
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct Turn {
255 /// Identifies this turn: `loop_id` + `turn_index`.
256 pub turn_id: TurnId,
257
258 /// What caused this turn to begin.
259 pub triggered_by: TurnTrigger,
260
261 /// Per-turn token usage (from `TurnEnd.usage`).
262 pub usage: Usage,
263
264 /// Messages injected at the start of this turn (user prompts, steering
265 /// messages, follow-ups). Empty for continuation turns that only have
266 /// tool results from the prior turn feeding back in.
267 pub input_messages: Vec<AgentMessage>,
268
269 /// The assistant message produced by the LLM this turn.
270 pub output_message: AgentMessage,
271
272 /// Tool result messages from this turn. Empty when no tool calls were made.
273 pub tool_results: Vec<AgentMessage>,
274
275 /// Wall-clock time when this turn began (from `TurnStart.timestamp`).
276 pub started_at: DateTime<Utc>,
277
278 /// Wall-clock time when this turn completed (from `TurnEnd.timestamp`).
279 pub ended_at: DateTime<Utc>,
280}
281
282impl Turn {
283 /// The zero-based turn index within its loop.
284 pub fn index(&self) -> u32 {
285 self.turn_id.turn_index
286 }
287
288 /// Duration of this turn.
289 pub fn duration(&self) -> chrono::Duration {
290 self.ended_at - self.started_at
291 }
292
293 /// Whether this turn included tool calls.
294 pub fn has_tool_calls(&self) -> bool {
295 !self.tool_results.is_empty()
296 }
297
298 /// All messages in this turn in chronological order:
299 /// input_messages, then output_message, then tool_results.
300 pub fn all_messages(&self) -> Vec<&AgentMessage> {
301 let mut msgs: Vec<&AgentMessage> = self.input_messages.iter().collect();
302 msgs.push(&self.output_message);
303 msgs.extend(self.tool_results.iter());
304 msgs
305 }
306}
307
308// ---------------------------------------------------------------------------
309// LoopRecord
310// ---------------------------------------------------------------------------
311
312/// A complete record of one agent-loop execution.
313///
314/// ## Loop origin classification
315///
316/// | `parent_loop_id` | `continuation_kind` | Meaning |
317/// |---|---|---|
318/// | `None` | `Initial` | Fresh origin loop (`agent_loop`) |
319/// | `Some(p)`, same session | `Default` | Regular continuation |
320/// | `Some(p)`, same session | `Rerun` | Retry / error recovery |
321/// | `Some(p)`, same session | `Branch` | Branch exploration |
322/// | `Some(p)`, different session | `Initial` | Sub-agent loop (spawned by a tool) |
323///
324/// ## Tree navigation
325///
326/// - Parent → children: iterate [`children_loop_ids`][Self::children_loop_ids]
327/// - Child → parent: read [`parent_loop_id`][Self::parent_loop_id]
328/// - Sub-agent children (cross-session): iterate [`child_loop_refs`][Self::child_loop_refs]
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct LoopRecord {
331 // ── Identity ────────────────────────────────────────────────────────────
332 /// Unique identifier for this loop execution.
333 pub loop_id: String,
334 /// Session this loop belongs to.
335 pub session_id: String,
336 /// Agent that ran this loop.
337 pub agent_id: String,
338
339 // ── Loop origin classification ────────────────────────────────────────
340 /// `loop_id` of the loop that directly preceded this one (if any).
341 ///
342 /// - `None` for origin loops (started by `agent_loop`).
343 /// - `Some(id)` for continuations started by `agent_loop_continue`.
344 /// - For sub-agent loops, `parent_loop_id` refers to the tool call loop
345 /// in a **different** session.
346 pub parent_loop_id: Option<String>,
347
348 /// How this loop relates to its parent.
349 ///
350 /// - `Initial` for origin loops (`agent_loop`) and sub-agent loops.
351 /// - `Default` for regular same-session continuations.
352 /// - `Rerun` for retries / error recovery.
353 /// - `Branch {..}` for branch explorations.
354 #[serde(default, deserialize_with = "deserialize_null_default")]
355 pub continuation_kind: ContinuationKind,
356
357 // ── Timing ────────────────────────────────────────────────────────────
358 /// Timestamp from `AgentStart`.
359 pub started_at: DateTime<Utc>,
360 /// Timestamp from `AgentEnd` (`None` while running or pending).
361 pub ended_at: Option<DateTime<Utc>>,
362
363 // ── Status ────────────────────────────────────────────────────────────
364 pub status: LoopStatus,
365 /// Set when `AgentEnd.rejection` is `Some(_)` (input filter blocked the run).
366 pub rejection: Option<String>,
367
368 // ── Model ─────────────────────────────────────────────────────────────
369 /// Identifies the model and provider that ran this loop.
370 ///
371 /// Populated from the first `Message::Assistant` seen in the loop.
372 /// `None` if the loop ended before any assistant message was produced.
373 pub config: Option<LoopConfigSnapshot>,
374
375 // ── Messages ──────────────────────────────────────────────────────────
376 /// All new messages produced by this loop — taken directly from `AgentEnd.messages`.
377 ///
378 /// These are the authoritative messages for replay and branching. To resume
379 /// from a loop, reconstruct an `AgentContext` with the full message history
380 /// (prior loop messages + these) and call `agent_loop_continue`.
381 pub messages: Vec<AgentMessage>,
382
383 // ── Turns ────────────────────────────────────────────────────────────
384 /// Materialized turn records, one per LLM call-response cycle.
385 ///
386 /// Built by [`SessionRecorder`] from `TurnStart`/`TurnEnd` event pairs.
387 /// Empty for old sessions that predate turn materialization, or for loops
388 /// that ended before any turn completed (rejected, aborted).
389 #[serde(default)]
390 pub turns: Vec<Turn>,
391
392 // ── Usage ─────────────────────────────────────────────────────────────
393 /// Token usage from `AgentEnd.usage`.
394 pub usage: Usage,
395
396 // ── Caller context ────────────────────────────────────────────────────
397 /// Opaque metadata passed to `AgentStart` by the caller (e.g. request id).
398 pub metadata: Option<serde_json::Value>,
399
400 // ── Full event stream ─────────────────────────────────────────────────
401 /// Ordered event stream for this loop.
402 ///
403 /// `MessageUpdate` (streaming delta) events are included only when
404 /// [`SessionRecorderConfig::include_streaming_events`] is `true`.
405 pub events: Vec<LoopEvent>,
406
407 // ── Same-session tree ─────────────────────────────────────────────────
408 /// `loop_id`s of same-session child loops (continuations / reruns / branches).
409 ///
410 /// This is the parent→children direction of the bidirectional loop tree.
411 /// The inverse (`children → parent`) is [`parent_loop_id`][Self::parent_loop_id].
412 ///
413 /// Does **not** include cross-session sub-agent children — those are in
414 /// [`child_loop_refs`][Self::child_loop_refs].
415 pub children_loop_ids: Vec<String>,
416
417 /// Cross-session links to sub-agent loops spawned by tool calls in this loop.
418 ///
419 /// Each entry corresponds to a `ToolExecutionEnd.child_loop_id` that is
420 /// `Some(_)`. Use the `child_session_id` to load the child [`Session`].
421 pub child_loop_refs: Vec<ChildLoopRef>,
422
423 // ── Parallel evaluation ───────────────────────────────────────────────
424 /// Set when this loop was part of an evaluational-parallelism group.
425 pub parallel_group: Option<ParallelGroupRecord>,
426
427 // ── Compaction ──────────────────────────────────────────────────────
428 /// Non-destructive compaction overlay. When `Some`, the context loader
429 /// uses this block instead of raw `self.messages`. The original messages
430 /// remain untouched.
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub compaction_block: Option<crate::context::CompactionBlock>,
433}
434
435impl LoopRecord {
436 /// Get a turn by its index. Returns `None` if turns are not materialized
437 /// or the index is out of range.
438 pub fn get_turn(&self, turn_index: u32) -> Option<&Turn> {
439 self.turns.get(turn_index as usize)
440 }
441
442 /// Number of materialized turns. Returns 0 if turns are not materialized.
443 pub fn turn_count(&self) -> usize {
444 self.turns.len()
445 }
446}
447
448// ---------------------------------------------------------------------------
449// SessionScope
450// ---------------------------------------------------------------------------
451
452/// Whether session data is kept in memory only or persisted to disk.
453///
454/// - `Ephemeral` (default): session exists only in memory for the process lifetime.
455/// - `Persistent`: session data is written to a store and survives restarts.
456#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
457#[serde(rename_all = "lowercase")]
458pub enum SessionScope {
459 #[default]
460 Ephemeral,
461 Persistent,
462}
463
464// ---------------------------------------------------------------------------
465// Session
466// ---------------------------------------------------------------------------
467
468/// A named container grouping all [`LoopRecord`]s for one agent session.
469///
470/// ## Loop tree structure
471///
472/// The tree is implicit via `parent_loop_id` / `children_loop_ids` links:
473///
474/// - **Root loops** — `parent_loop_id` is `None` (or points to a loop in a
475/// different session for sub-agent roots).
476/// - **Continuation chains** — `parent_loop_id` → `loop_id` within the same
477/// session.
478/// - **Parallel branches** — siblings sharing the same `parent_loop_id`, each
479/// with `parallel_group` set.
480/// - **Sub-agent children** — in `child_loop_refs` on the parent loop
481/// (cross-session, not in `loops` vec).
482///
483/// ## Cross-session sub-agent tracking
484///
485/// When this session was itself spawned as a sub-agent, [`parent_spawn_ref`]
486/// points back to the parent session and loop that triggered it. This is the
487/// inverse of [`LoopRecord::child_loop_refs`] in the parent session, and together
488/// they form a complete bidirectional cross-session spawn graph.
489#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct Session {
491 /// Stable identifier for this session — matches `AgentStart.session_id`.
492 pub session_id: String,
493 /// The `agent_id` from the first `AgentStart` seen for this session.
494 pub agent_id: String,
495 /// Timestamp of the first `AgentStart` event seen for this session.
496 pub created_at: DateTime<Utc>,
497 /// Timestamp of the most recent `AgentStart` event seen for this session.
498 ///
499 /// Updated each time a new loop opens (on `AgentStart`), so it reflects
500 /// when the last loop _started_, not when it last had activity.
501 pub last_active_at: DateTime<Utc>,
502 /// Why this session was created.
503 pub formation: SessionFormation,
504
505 /// Set when this session was spawned as a sub-agent by a loop in a different
506 /// session. Populated by [`SessionRecorder`] when a new session's first
507 /// `AgentStart` carries a `parent_loop_id` that belongs to a different
508 /// `session_id`.
509 pub parent_spawn_ref: Option<SpawnRef>,
510
511 /// Session scope — ephemeral (in-memory only) or persistent (written to store) (G7).
512 #[serde(default)]
513 pub scope: SessionScope,
514
515 /// All completed and in-progress [`LoopRecord`]s, ordered by [`LoopRecord::started_at`].
516 pub loops: Vec<LoopRecord>,
517}
518
519impl Session {
520 /// Return root loops — those whose `parent_loop_id` is `None` or whose parent
521 /// belongs to a different session.
522 pub fn root_loops(&self) -> impl Iterator<Item = &LoopRecord> {
523 let loop_ids: std::collections::HashSet<&str> =
524 self.loops.iter().map(|l| l.loop_id.as_str()).collect();
525 self.loops.iter().filter(move |l| {
526 l.parent_loop_id
527 .as_deref()
528 .map(|pid| !loop_ids.contains(pid))
529 .unwrap_or(true)
530 })
531 }
532
533 /// Return all direct same-session children of `loop_id`.
534 pub fn children_of<'a>(&'a self, loop_id: &str) -> impl Iterator<Item = &'a LoopRecord> {
535 let record = self.loops.iter().find(|l| l.loop_id == loop_id);
536 let ids: Vec<&str> = record
537 .map(|r| r.children_loop_ids.iter().map(|s| s.as_str()).collect())
538 .unwrap_or_default();
539 self.loops
540 .iter()
541 .filter(move |l| ids.contains(&l.loop_id.as_str()))
542 }
543
544 /// Return all loops in the same parallel group as `loop_id`.
545 pub fn parallel_siblings<'a>(&'a self, loop_id: &str) -> impl Iterator<Item = &'a LoopRecord> {
546 let all_ids: Option<Vec<String>> = self
547 .loops
548 .iter()
549 .find(|l| l.loop_id == loop_id)
550 .and_then(|l| l.parallel_group.as_ref())
551 .map(|pg| pg.all_loop_ids.clone());
552
553 self.loops.iter().filter(move |l| {
554 all_ids
555 .as_ref()
556 .map(|ids| ids.contains(&l.loop_id))
557 .unwrap_or(false)
558 })
559 }
560
561 /// Look up a loop by its `loop_id`.
562 pub fn get_loop(&self, loop_id: &str) -> Option<&LoopRecord> {
563 self.loops.iter().find(|l| l.loop_id == loop_id)
564 }
565
566 /// Mutable look up a loop by its `loop_id`.
567 pub fn get_loop_mut(&mut self, loop_id: &str) -> Option<&mut LoopRecord> {
568 self.loops.iter_mut().find(|l| l.loop_id == loop_id)
569 }
570
571 /// Build the linear chain of loops from root to `target_loop_id`
572 /// by walking `parent_loop_id` links backward. Returns loop IDs
573 /// in chronological order (root first).
574 ///
575 /// This naturally handles parallel branches (only the selected path)
576 /// and reruns (only the active ancestor chain).
577 pub fn loop_chain_to(&self, target_loop_id: &str) -> Vec<String> {
578 let mut chain = Vec::new();
579 let mut current = target_loop_id.to_string();
580 loop {
581 chain.push(current.clone());
582 match self
583 .get_loop(¤t)
584 .and_then(|r| r.parent_loop_id.as_ref())
585 {
586 Some(parent) => current = parent.clone(),
587 None => break,
588 }
589 }
590 chain.reverse();
591 chain
592 }
593
594 /// Cumulative token usage across all loops in this session.
595 pub fn total_usage(&self) -> Usage {
596 self.loops.iter().fold(Usage::default(), |mut acc, l| {
597 acc.input += l.usage.input;
598 acc.output += l.usage.output;
599 acc.reasoning += l.usage.reasoning;
600 acc.cache_read += l.usage.cache_read;
601 acc.cache_write += l.usage.cache_write;
602 acc.total_tokens += l.usage.total_tokens;
603 acc
604 })
605 }
606}
607
608// ---------------------------------------------------------------------------
609// SessionError
610// ---------------------------------------------------------------------------
611
612/// Errors from session I/O.
613#[derive(Debug, thiserror::Error)]
614pub enum SessionError {
615 #[error("I/O error: {0}")]
616 Io(#[from] std::io::Error),
617 #[error("Serialization error: {0}")]
618 Serialize(#[from] serde_json::Error),
619 #[error("Session not found: {session_id}")]
620 NotFound { session_id: String },
621 /// Returned by [`SessionStore`](crate::session::SessionStore) when an exclusive
622 /// advisory lock could not be acquired on the target session file within the retry
623 /// budget — typically because another process is currently writing the same session.
624 #[error("Session {session_id} is locked by another writer")]
625 Locked { session_id: String },
626 /// Async runtime failure when spawning blocking I/O work (e.g. `tokio::task::JoinError`).
627 #[error("Background task error: {0}")]
628 Task(String),
629}
630
631// ---------------------------------------------------------------------------
632// OpenLoop
633// ---------------------------------------------------------------------------
634
635/// An open (in-progress) loop record stored inside the recorder.
636pub(crate) struct OpenLoop {
637 pub(crate) record: LoopRecord,
638 /// Monotonic event counter for this loop.
639 pub(crate) next_seq: u64,
640}