Skip to main content

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    /// Fully-assembled LLM request payload captured from
282    /// [`crate::AgentEvent::TurnRequest`] when
283    /// [`crate::session::SessionRecorderConfig::capture_turn_requests`] is
284    /// enabled. `None` when capture is off (default) or for sessions persisted
285    /// before phi-core 0.9.0.
286    ///
287    /// Added in phi-core 0.9.0. Serialization is skipped when `None` for
288    /// back-compat (existing session JSON loads cleanly into 0.9.0 readers).
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub request_payload: Option<AnnotatedRequestPayload>,
291}
292
293impl Turn {
294    /// The zero-based turn index within its loop.
295    pub fn index(&self) -> u32 {
296        self.turn_id.turn_index
297    }
298
299    /// Duration of this turn.
300    pub fn duration(&self) -> chrono::Duration {
301        self.ended_at - self.started_at
302    }
303
304    /// Whether this turn included tool calls.
305    pub fn has_tool_calls(&self) -> bool {
306        !self.tool_results.is_empty()
307    }
308
309    /// All messages in this turn in chronological order:
310    /// input_messages, then output_message, then tool_results.
311    pub fn all_messages(&self) -> Vec<&AgentMessage> {
312        let mut msgs: Vec<&AgentMessage> = self.input_messages.iter().collect();
313        msgs.push(&self.output_message);
314        msgs.extend(self.tool_results.iter());
315        msgs
316    }
317}
318
319// ---------------------------------------------------------------------------
320// LoopRecord
321// ---------------------------------------------------------------------------
322
323/// A complete record of one agent-loop execution.
324///
325/// ## Loop origin classification
326///
327/// | `parent_loop_id` | `continuation_kind` | Meaning |
328/// |---|---|---|
329/// | `None` | `Initial` | Fresh origin loop (`agent_loop`) |
330/// | `Some(p)`, same session | `Default` | Regular continuation |
331/// | `Some(p)`, same session | `Rerun` | Retry / error recovery |
332/// | `Some(p)`, same session | `Branch` | Branch exploration |
333/// | `Some(p)`, different session | `Initial` | Sub-agent loop (spawned by a tool) |
334///
335/// ## Tree navigation
336///
337/// - Parent → children: iterate [`children_loop_ids`][Self::children_loop_ids]
338/// - Child → parent: read [`parent_loop_id`][Self::parent_loop_id]
339/// - Sub-agent children (cross-session): iterate [`child_loop_refs`][Self::child_loop_refs]
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct LoopRecord {
342    // ── Identity ────────────────────────────────────────────────────────────
343    /// Unique identifier for this loop execution.
344    pub loop_id: String,
345    /// Session this loop belongs to.
346    pub session_id: String,
347    /// Agent that ran this loop.
348    pub agent_id: String,
349
350    // ── Loop origin classification ────────────────────────────────────────
351    /// `loop_id` of the loop that directly preceded this one (if any).
352    ///
353    /// - `None` for origin loops (started by `agent_loop`).
354    /// - `Some(id)` for continuations started by `agent_loop_continue`.
355    /// - For sub-agent loops, `parent_loop_id` refers to the tool call loop
356    ///   in a **different** session.
357    pub parent_loop_id: Option<String>,
358
359    /// How this loop relates to its parent.
360    ///
361    /// - `Initial` for origin loops (`agent_loop`) and sub-agent loops.
362    /// - `Default` for regular same-session continuations.
363    /// - `Rerun` for retries / error recovery.
364    /// - `Branch {..}` for branch explorations.
365    #[serde(default, deserialize_with = "deserialize_null_default")]
366    pub continuation_kind: ContinuationKind,
367
368    // ── Timing ────────────────────────────────────────────────────────────
369    /// Timestamp from `AgentStart`.
370    pub started_at: DateTime<Utc>,
371    /// Timestamp from `AgentEnd` (`None` while running or pending).
372    pub ended_at: Option<DateTime<Utc>>,
373
374    // ── Status ────────────────────────────────────────────────────────────
375    pub status: LoopStatus,
376    /// Set when `AgentEnd.rejection` is `Some(_)` (input filter blocked the run).
377    pub rejection: Option<String>,
378
379    // ── Model ─────────────────────────────────────────────────────────────
380    /// Identifies the model and provider that ran this loop.
381    ///
382    /// Populated from the first `Message::Assistant` seen in the loop.
383    /// `None` if the loop ended before any assistant message was produced.
384    pub config: Option<LoopConfigSnapshot>,
385
386    // ── Messages ──────────────────────────────────────────────────────────
387    /// All new messages produced by this loop — taken directly from `AgentEnd.messages`.
388    ///
389    /// These are the authoritative messages for replay and branching. To resume
390    /// from a loop, reconstruct an `AgentContext` with the full message history
391    /// (prior loop messages + these) and call `agent_loop_continue`.
392    pub messages: Vec<AgentMessage>,
393
394    // ── Turns ────────────────────────────────────────────────────────────
395    /// Materialized turn records, one per LLM call-response cycle.
396    ///
397    /// Built by [`SessionRecorder`] from `TurnStart`/`TurnEnd` event pairs.
398    /// Empty for old sessions that predate turn materialization, or for loops
399    /// that ended before any turn completed (rejected, aborted).
400    #[serde(default)]
401    pub turns: Vec<Turn>,
402
403    // ── Usage ─────────────────────────────────────────────────────────────
404    /// Token usage from `AgentEnd.usage`.
405    pub usage: Usage,
406
407    // ── Caller context ────────────────────────────────────────────────────
408    /// Opaque metadata passed to `AgentStart` by the caller (e.g. request id).
409    pub metadata: Option<serde_json::Value>,
410
411    // ── Full event stream ─────────────────────────────────────────────────
412    /// Ordered event stream for this loop.
413    ///
414    /// `MessageUpdate` (streaming delta) events are included only when
415    /// [`SessionRecorderConfig::include_streaming_events`] is `true`.
416    pub events: Vec<LoopEvent>,
417
418    // ── Same-session tree ─────────────────────────────────────────────────
419    /// `loop_id`s of same-session child loops (continuations / reruns / branches).
420    ///
421    /// This is the parent→children direction of the bidirectional loop tree.
422    /// The inverse (`children → parent`) is [`parent_loop_id`][Self::parent_loop_id].
423    ///
424    /// Does **not** include cross-session sub-agent children — those are in
425    /// [`child_loop_refs`][Self::child_loop_refs].
426    pub children_loop_ids: Vec<String>,
427
428    /// Cross-session links to sub-agent loops spawned by tool calls in this loop.
429    ///
430    /// Each entry corresponds to a `ToolExecutionEnd.child_loop_id` that is
431    /// `Some(_)`. Use the `child_session_id` to load the child [`Session`].
432    pub child_loop_refs: Vec<ChildLoopRef>,
433
434    // ── Parallel evaluation ───────────────────────────────────────────────
435    /// Set when this loop was part of an evaluational-parallelism group.
436    pub parallel_group: Option<ParallelGroupRecord>,
437
438    // ── Compaction ──────────────────────────────────────────────────────
439    /// Non-destructive compaction overlay. When `Some`, the context loader
440    /// uses this block instead of raw `self.messages`. The original messages
441    /// remain untouched.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub compaction_block: Option<crate::context::CompactionBlock>,
444}
445
446impl LoopRecord {
447    /// Get a turn by its index. Returns `None` if turns are not materialized
448    /// or the index is out of range.
449    pub fn get_turn(&self, turn_index: u32) -> Option<&Turn> {
450        self.turns.get(turn_index as usize)
451    }
452
453    /// Number of materialized turns. Returns 0 if turns are not materialized.
454    pub fn turn_count(&self) -> usize {
455        self.turns.len()
456    }
457}
458
459// ---------------------------------------------------------------------------
460// SessionScope
461// ---------------------------------------------------------------------------
462
463/// Whether session data is kept in memory only or persisted to disk.
464///
465/// - `Ephemeral` (default): session exists only in memory for the process lifetime.
466/// - `Persistent`: session data is written to a store and survives restarts.
467#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
468#[serde(rename_all = "lowercase")]
469pub enum SessionScope {
470    #[default]
471    Ephemeral,
472    Persistent,
473}
474
475// ---------------------------------------------------------------------------
476// Session
477// ---------------------------------------------------------------------------
478
479/// A named container grouping all [`LoopRecord`]s for one agent session.
480///
481/// ## Loop tree structure
482///
483/// The tree is implicit via `parent_loop_id` / `children_loop_ids` links:
484///
485/// - **Root loops** — `parent_loop_id` is `None` (or points to a loop in a
486///   different session for sub-agent roots).
487/// - **Continuation chains** — `parent_loop_id` → `loop_id` within the same
488///   session.
489/// - **Parallel branches** — siblings sharing the same `parent_loop_id`, each
490///   with `parallel_group` set.
491/// - **Sub-agent children** — in `child_loop_refs` on the parent loop
492///   (cross-session, not in `loops` vec).
493///
494/// ## Cross-session sub-agent tracking
495///
496/// When this session was itself spawned as a sub-agent, [`parent_spawn_ref`]
497/// points back to the parent session and loop that triggered it. This is the
498/// inverse of [`LoopRecord::child_loop_refs`] in the parent session, and together
499/// they form a complete bidirectional cross-session spawn graph.
500#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct Session {
502    /// Stable identifier for this session — matches `AgentStart.session_id`.
503    pub session_id: String,
504    /// The `agent_id` from the first `AgentStart` seen for this session.
505    pub agent_id: String,
506    /// Timestamp of the first `AgentStart` event seen for this session.
507    pub created_at: DateTime<Utc>,
508    /// Timestamp of the most recent `AgentStart` event seen for this session.
509    ///
510    /// Updated each time a new loop opens (on `AgentStart`), so it reflects
511    /// when the last loop _started_, not when it last had activity.
512    pub last_active_at: DateTime<Utc>,
513    /// Why this session was created.
514    pub formation: SessionFormation,
515
516    /// Set when this session was spawned as a sub-agent by a loop in a different
517    /// session. Populated by [`SessionRecorder`] when a new session's first
518    /// `AgentStart` carries a `parent_loop_id` that belongs to a different
519    /// `session_id`.
520    pub parent_spawn_ref: Option<SpawnRef>,
521
522    /// Session scope — ephemeral (in-memory only) or persistent (written to store) (G7).
523    #[serde(default)]
524    pub scope: SessionScope,
525
526    /// All completed and in-progress [`LoopRecord`]s, ordered by [`LoopRecord::started_at`].
527    pub loops: Vec<LoopRecord>,
528}
529
530impl Session {
531    /// Return root loops — those whose `parent_loop_id` is `None` or whose parent
532    /// belongs to a different session.
533    pub fn root_loops(&self) -> impl Iterator<Item = &LoopRecord> {
534        let loop_ids: std::collections::HashSet<&str> =
535            self.loops.iter().map(|l| l.loop_id.as_str()).collect();
536        self.loops.iter().filter(move |l| {
537            l.parent_loop_id
538                .as_deref()
539                .map(|pid| !loop_ids.contains(pid))
540                .unwrap_or(true)
541        })
542    }
543
544    /// Return all direct same-session children of `loop_id`.
545    pub fn children_of<'a>(&'a self, loop_id: &str) -> impl Iterator<Item = &'a LoopRecord> {
546        let record = self.loops.iter().find(|l| l.loop_id == loop_id);
547        let ids: Vec<&str> = record
548            .map(|r| r.children_loop_ids.iter().map(|s| s.as_str()).collect())
549            .unwrap_or_default();
550        self.loops
551            .iter()
552            .filter(move |l| ids.contains(&l.loop_id.as_str()))
553    }
554
555    /// Return all loops in the same parallel group as `loop_id`.
556    pub fn parallel_siblings<'a>(&'a self, loop_id: &str) -> impl Iterator<Item = &'a LoopRecord> {
557        let all_ids: Option<Vec<String>> = self
558            .loops
559            .iter()
560            .find(|l| l.loop_id == loop_id)
561            .and_then(|l| l.parallel_group.as_ref())
562            .map(|pg| pg.all_loop_ids.clone());
563
564        self.loops.iter().filter(move |l| {
565            all_ids
566                .as_ref()
567                .map(|ids| ids.contains(&l.loop_id))
568                .unwrap_or(false)
569        })
570    }
571
572    /// Look up a loop by its `loop_id`.
573    pub fn get_loop(&self, loop_id: &str) -> Option<&LoopRecord> {
574        self.loops.iter().find(|l| l.loop_id == loop_id)
575    }
576
577    /// Mutable look up a loop by its `loop_id`.
578    pub fn get_loop_mut(&mut self, loop_id: &str) -> Option<&mut LoopRecord> {
579        self.loops.iter_mut().find(|l| l.loop_id == loop_id)
580    }
581
582    /// Build the linear chain of loops from root to `target_loop_id`
583    /// by walking `parent_loop_id` links backward. Returns loop IDs
584    /// in chronological order (root first).
585    ///
586    /// This naturally handles parallel branches (only the selected path)
587    /// and reruns (only the active ancestor chain).
588    pub fn loop_chain_to(&self, target_loop_id: &str) -> Vec<String> {
589        let mut chain = Vec::new();
590        let mut current = target_loop_id.to_string();
591        loop {
592            chain.push(current.clone());
593            match self
594                .get_loop(&current)
595                .and_then(|r| r.parent_loop_id.as_ref())
596            {
597                Some(parent) => current = parent.clone(),
598                None => break,
599            }
600        }
601        chain.reverse();
602        chain
603    }
604
605    /// Cumulative token usage across all loops in this session.
606    pub fn total_usage(&self) -> Usage {
607        self.loops.iter().fold(Usage::default(), |mut acc, l| {
608            acc.input += l.usage.input;
609            acc.output += l.usage.output;
610            acc.reasoning += l.usage.reasoning;
611            acc.cache_read += l.usage.cache_read;
612            acc.cache_write += l.usage.cache_write;
613            acc.total_tokens += l.usage.total_tokens;
614            acc
615        })
616    }
617}
618
619// ---------------------------------------------------------------------------
620// SessionError
621// ---------------------------------------------------------------------------
622
623/// Errors from session I/O.
624#[derive(Debug, thiserror::Error)]
625pub enum SessionError {
626    #[error("I/O error: {0}")]
627    Io(#[from] std::io::Error),
628    #[error("Serialization error: {0}")]
629    Serialize(#[from] serde_json::Error),
630    #[error("Session not found: {session_id}")]
631    NotFound { session_id: String },
632    /// Returned by [`SessionStore`](crate::session::SessionStore) when an exclusive
633    /// advisory lock could not be acquired on the target session file within the retry
634    /// budget — typically because another process is currently writing the same session.
635    #[error("Session {session_id} is locked by another writer")]
636    Locked { session_id: String },
637    /// Async runtime failure when spawning blocking I/O work (e.g. `tokio::task::JoinError`).
638    #[error("Background task error: {0}")]
639    Task(String),
640}
641
642// ---------------------------------------------------------------------------
643// OpenLoop
644// ---------------------------------------------------------------------------
645
646/// An open (in-progress) loop record stored inside the recorder.
647pub(crate) struct OpenLoop {
648    pub(crate) record: LoopRecord,
649    /// Monotonic event counter for this loop.
650    pub(crate) next_seq: u64,
651}