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(¤t)
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}