Skip to main content

llm_agent_runtime/
runtime.rs

1//! # Module: AgentRuntime
2//!
3//! ## Responsibility
4//! Wire memory, graph, orchestrator, and agent loop into a single coordinator
5//! using a builder pattern. Provides `run_agent` which executes a ReAct loop,
6//! optionally enriching context from memory and graph lookups.
7//!
8//! ## Guarantees
9//! - Builder uses a typestate parameter to enforce `agent_config` at compile time:
10//!   `build()` is only callable once `with_agent_config` has been called.
11//! - `run_agent` is async and returns a typed `AgentSession` with step count,
12//!   durations, and hits.
13//! - Non-panicking: all paths return `Result`
14//!
15//! ## NOT Responsible For
16//! - Actual LLM inference (callers supply a mock/stub inference fn)
17//! - Persistence across process restarts (unless `persistence` feature is enabled)
18
19use crate::agent::{AgentConfig, ReActLoop, ReActStep, ToolSpec};
20use crate::error::AgentRuntimeError;
21use crate::metrics::RuntimeMetrics;
22use crate::types::AgentId;
23
24#[cfg(feature = "memory")]
25use crate::memory::{EpisodicStore, WorkingMemory};
26use serde::{Deserialize, Serialize};
27use std::fmt::Write as FmtWrite;
28use std::marker::PhantomData;
29use std::sync::atomic::Ordering;
30use std::sync::Arc;
31use std::time::Instant;
32
33#[cfg(feature = "graph")]
34use crate::graph::GraphStore;
35
36#[cfg(feature = "orchestrator")]
37use crate::orchestrator::BackpressureGuard;
38
39// ── Typestate markers ─────────────────────────────────────────────────────────
40
41/// Builder state: agent config has not been provided yet.
42pub struct NeedsConfig;
43/// Builder state: agent config has been provided; `build()` is available.
44pub struct HasConfig;
45
46// ── AgentSession ──────────────────────────────────────────────────────────────
47
48/// The result of a single agent run.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct AgentSession {
51    /// Stable unique identifier for this session (UUID v4 string).
52    pub session_id: String,
53    /// The agent ID used for this session.
54    pub agent_id: AgentId,
55    /// All ReAct steps executed during the session.
56    pub steps: Vec<ReActStep>,
57    /// Number of episodic memory retrievals made during the session.
58    pub memory_hits: usize,
59    /// Number of graph lookups made during the session.
60    pub graph_lookups: usize,
61    /// Wall-clock duration of the session in milliseconds.
62    pub duration_ms: u64,
63    /// Non-fatal errors encountered while saving per-step checkpoints.
64    ///
65    /// Populated only when a persistence backend is configured.  A non-empty
66    /// list means some step snapshots may be missing from storage, but the
67    /// session itself completed successfully.
68    #[serde(default)]
69    pub checkpoint_errors: Vec<String>,
70}
71
72impl AgentSession {
73    /// Return the number of steps in the session.
74    ///
75    /// Each [`ReActStep`] in `steps` carries a `step_duration_ms` field measuring
76    /// wall-clock time from inference call to observation for that individual step.
77    /// Use this to identify slow steps:
78    /// ```rust,ignore
79    /// for (i, step) in session.steps.iter().enumerate() {
80    ///     println!("step {i}: {}ms", step.step_duration_ms);
81    /// }
82    /// ```
83    pub fn step_count(&self) -> usize {
84        self.steps.len()
85    }
86
87    /// Return `true` if the session has no recorded steps.
88    pub fn is_empty(&self) -> bool {
89        self.steps.is_empty()
90    }
91
92    /// Return the final answer text from the last step, if available.
93    ///
94    /// Extracts the content after `FINAL_ANSWER` in the last step's `action` field.
95    /// Returns `None` if there are no steps or the last action is not a FINAL_ANSWER.
96    pub fn final_answer(&self) -> Option<String> {
97        let last = self.steps.last()?;
98        let upper = last.action.trim().to_ascii_uppercase();
99        if upper.starts_with("FINAL_ANSWER") {
100            let answer = last.action.trim()["FINAL_ANSWER".len()..].trim().to_owned();
101            Some(answer)
102        } else {
103            None
104        }
105    }
106
107    /// Return `true` if the session ended with a `FINAL_ANSWER` action.
108    ///
109    /// This is the normal successful exit from a ReAct loop.  `false` means the
110    /// loop was cut short by a timeout, max-iterations limit, or an error.
111    pub fn is_successful(&self) -> bool {
112        self.final_answer().is_some()
113    }
114
115    /// Return the session wall-clock duration as a [`std::time::Duration`].
116    pub fn elapsed(&self) -> std::time::Duration {
117        std::time::Duration::from_millis(self.duration_ms)
118    }
119
120    /// Return the number of tool-call actions dispatched during the session.
121    ///
122    /// Each [`ReActStep`] whose `action` parses as a `ToolCall` (not a `FinalAnswer`)
123    /// is counted.
124    pub fn tool_calls_made(&self) -> usize {
125        self.steps
126            .iter()
127            .filter(|s| {
128                // A ToolCall action contains a JSON object.  A FinalAnswer starts
129                // with the literal "FINAL_ANSWER" prefix.
130                !s.action.trim().to_ascii_uppercase().starts_with("FINAL_ANSWER")
131                    && !s.action.trim().is_empty()
132            })
133            .count()
134    }
135
136    /// Return the sum of all individual step durations in milliseconds.
137    ///
138    /// This is the cumulative inference + tool execution time across all steps,
139    /// which may differ from `duration_ms` due to overhead between steps.
140    pub fn total_step_duration_ms(&self) -> u64 {
141        self.steps.iter().map(|s| s.step_duration_ms).sum()
142    }
143
144    /// Return the average step duration in milliseconds.
145    ///
146    /// Returns `0` when there are no steps.
147    pub fn average_step_duration_ms(&self) -> u64 {
148        if self.steps.is_empty() {
149            return 0;
150        }
151        self.total_step_duration_ms() / self.steps.len() as u64
152    }
153
154    /// Return a reference to the slowest step (highest `step_duration_ms`).
155    ///
156    /// Returns `None` when there are no steps.
157    pub fn slowest_step(&self) -> Option<&ReActStep> {
158        self.steps.iter().max_by_key(|s| s.step_duration_ms)
159    }
160
161    /// Return a reference to the fastest step (lowest `step_duration_ms`).
162    ///
163    /// Returns `None` when there are no steps.
164    pub fn fastest_step(&self) -> Option<&ReActStep> {
165        self.steps.iter().min_by_key(|s| s.step_duration_ms)
166    }
167
168    /// Return references to all steps that are tool calls (not `FINAL_ANSWER`).
169    pub fn filter_tool_call_steps(&self) -> Vec<&ReActStep> {
170        self.steps.iter().filter(|s| s.is_tool_call()).collect()
171    }
172
173    /// Return the zero-based index of the slowest step, or `None` if there are no steps.
174    pub fn slowest_step_index(&self) -> Option<usize> {
175        self.steps
176            .iter()
177            .enumerate()
178            .max_by_key(|(_, s)| s.step_duration_ms)
179            .map(|(i, _)| i)
180    }
181
182    /// Return the zero-based index of the fastest step, or `None` if there are no steps.
183    pub fn fastest_step_index(&self) -> Option<usize> {
184        self.steps
185            .iter()
186            .enumerate()
187            .min_by_key(|(_, s)| s.step_duration_ms)
188            .map(|(i, _)| i)
189    }
190
191    /// Return a reference to the last step, or `None` if there are no steps.
192    pub fn last_step(&self) -> Option<&ReActStep> {
193        self.steps.last()
194    }
195
196    /// Return a reference to the first step taken, or `None` if there are no steps.
197    pub fn first_step(&self) -> Option<&ReActStep> {
198        self.steps.first()
199    }
200
201    /// Return a reference to the step at zero-based index `idx`, or `None` if out of bounds.
202    pub fn step_at(&self, idx: usize) -> Option<&ReActStep> {
203        self.steps.get(idx)
204    }
205
206    /// Return the observation string at step `idx`, or `None` if out of bounds.
207    pub fn observation_at(&self, idx: usize) -> Option<&str> {
208        self.steps.get(idx).map(|s| s.observation.as_str())
209    }
210
211    /// Return the action string at step `idx`, or `None` if out of bounds.
212    pub fn action_at(&self, idx: usize) -> Option<&str> {
213        self.steps.get(idx).map(|s| s.action.as_str())
214    }
215
216    /// Return steps whose observation contains `pattern` (case-insensitive).
217    pub fn observations_matching(&self, pattern: &str) -> Vec<&ReActStep> {
218        let lower = pattern.to_ascii_lowercase();
219        self.steps
220            .iter()
221            .filter(|s| s.observation.to_ascii_lowercase().contains(&lower))
222            .collect()
223    }
224
225    /// Return steps whose thought contains `pattern` (case-insensitive).
226    pub fn thoughts_containing(&self, pattern: &str) -> Vec<&ReActStep> {
227        let lower = pattern.to_ascii_lowercase();
228        self.steps
229            .iter()
230            .filter(|s| s.thought.to_ascii_lowercase().contains(&lower))
231            .collect()
232    }
233
234    /// Return `true` if any step in this session used `action_name`.
235    pub fn has_action(&self, action_name: &str) -> bool {
236        self.steps.iter().any(|s| s.action == action_name)
237    }
238
239    /// Return the thought string at step `idx`, or `None` if out of bounds.
240    pub fn thought_at(&self, idx: usize) -> Option<&str> {
241        self.steps.get(idx).map(|s| s.thought.as_str())
242    }
243
244    /// Count how many steps used `action_name` as their action.
245    ///
246    /// Returns `0` if the action was never invoked.  Complements
247    /// [`has_action`], which only tests for presence.
248    ///
249    /// [`has_action`]: AgentSession::has_action
250    pub fn step_count_for_action(&self, action_name: &str) -> usize {
251        self.steps.iter().filter(|s| s.action == action_name).count()
252    }
253
254    /// Return all observation strings in step order.
255    ///
256    /// Each string is a borrow of the corresponding `ReActStep::observation`
257    /// field.  Useful for bulk post-processing of tool results.
258    pub fn observations(&self) -> Vec<&str> {
259        self.steps.iter().map(|s| s.observation.as_str()).collect()
260    }
261
262    /// Return the number of steps that have a non-empty observation string.
263    pub fn observation_count(&self) -> usize {
264        self.steps.iter().filter(|s| !s.observation.is_empty()).count()
265    }
266
267    /// Return up to the last `n` non-empty observation strings, ordered oldest
268    /// to newest.
269    ///
270    /// Empty observations are skipped.  If the session has fewer than `n`
271    /// non-empty observations, all of them are returned.
272    pub fn last_n_observations(&self, n: usize) -> Vec<&str> {
273        let all: Vec<&str> = self
274            .steps
275            .iter()
276            .filter(|s| !s.observation.is_empty())
277            .map(|s| s.observation.as_str())
278            .collect();
279        let skip = all.len().saturating_sub(n);
280        all[skip..].to_vec()
281    }
282
283    /// Return the action names from the last `n` steps, ordered oldest to newest.
284    ///
285    /// If the session has fewer than `n` steps, all action names are returned.
286    pub fn actions_in_window(&self, n: usize) -> Vec<&str> {
287        let skip = self.steps.len().saturating_sub(n);
288        self.steps[skip..]
289            .iter()
290            .map(|s| s.action.as_str())
291            .collect()
292    }
293
294    /// Return the number of steps whose observation string is empty.
295    pub fn steps_without_observation(&self) -> usize {
296        self.steps.iter().filter(|s| s.observation.is_empty()).count()
297    }
298
299    /// Return the thought string from the first step, or `None` if the session
300    /// has no steps.
301    pub fn first_thought(&self) -> Option<&str> {
302        self.steps.first().map(|s| s.thought.as_str())
303    }
304
305    /// Return the thought string from the last step, or `None` if the session
306    /// has no steps.
307    pub fn last_thought(&self) -> Option<&str> {
308        self.steps.last().map(|s| s.thought.as_str())
309    }
310
311    /// Return the action name from the first step, or `None` if the session
312    /// has no steps.
313    pub fn first_action(&self) -> Option<&str> {
314        self.steps.first().map(|s| s.action.as_str())
315    }
316
317    /// Return the action name from the last step, or `None` if the session
318    /// has no steps.
319    pub fn last_action(&self) -> Option<&str> {
320        self.steps.last().map(|s| s.action.as_str())
321    }
322
323    /// Return a slice of the last `n` steps.
324    ///
325    /// If `n` is greater than or equal to the total step count, all steps are
326    /// returned.  An empty slice is returned for sessions with no steps.
327    pub fn last_n_steps(&self, n: usize) -> &[crate::agent::ReActStep] {
328        let len = self.steps.len();
329        let start = len.saturating_sub(n);
330        &self.steps[start..]
331    }
332
333    /// Return all per-step durations in milliseconds, in order.
334    ///
335    /// Useful for computing custom percentiles or detecting slow outlier steps.
336    pub fn step_durations_ms(&self) -> Vec<u64> {
337        self.steps.iter().map(|s| s.step_duration_ms).collect()
338    }
339
340    /// Return the sum of all step durations in milliseconds.
341    ///
342    /// Equivalent to `step_durations_ms().iter().sum()` but avoids allocating
343    /// a temporary Vec.
344    pub fn total_latency_ms(&self) -> u64 {
345        self.steps.iter().map(|s| s.step_duration_ms).sum()
346    }
347
348    /// Return the arithmetic mean step duration in milliseconds.
349    ///
350    /// Returns `0.0` for sessions with no steps.
351    pub fn avg_step_duration_ms(&self) -> f64 {
352        if self.steps.is_empty() {
353            return 0.0;
354        }
355        self.total_latency_ms() as f64 / self.steps.len() as f64
356    }
357
358    /// Return a reference to the step with the largest `step_duration_ms`.
359    ///
360    /// Returns `None` if the session has no steps.  When multiple steps share
361    /// the maximum duration the first one (lowest index) is returned.
362    pub fn longest_step(&self) -> Option<&crate::agent::ReActStep> {
363        self.steps.iter().max_by_key(|s| s.step_duration_ms)
364    }
365
366    /// Return a reference to the step with the smallest `step_duration_ms`.
367    ///
368    /// Returns `None` if the session has no steps.  When multiple steps share
369    /// the minimum duration the first one (lowest index) is returned.
370    pub fn shortest_step(&self) -> Option<&crate::agent::ReActStep> {
371        self.steps.iter().min_by_key(|s| s.step_duration_ms)
372    }
373
374    /// Return the sequence of action names taken, in step order.
375    ///
376    /// Unlike `all_actions()` this returns owned `String`s so the result can
377    /// outlive the session borrow.
378    pub fn action_sequence(&self) -> Vec<String> {
379        self.steps.iter().map(|s| s.action.clone()).collect()
380    }
381
382    /// Return the sorted, deduplicated set of tool names invoked during the session.
383    ///
384    /// Tool-call steps are identified by the same heuristic as `tool_calls_made`:
385    /// a non-empty action that does not start with `FINAL_ANSWER`.
386    pub fn unique_tools_used(&self) -> Vec<String> {
387        let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
388        for step in &self.steps {
389            let action = step.action.trim();
390            if action.is_empty() || action.to_ascii_uppercase().starts_with("FINAL_ANSWER") {
391                continue;
392            }
393            // Tool name is the JSON "tool" field, or the whole action string if not JSON.
394            if let Ok(v) = serde_json::from_str::<serde_json::Value>(action) {
395                if let Some(name) = v.get("tool").and_then(|n| n.as_str()) {
396                    names.insert(name.to_owned());
397                    continue;
398                }
399            }
400            names.insert(action.to_owned());
401        }
402        let mut sorted: Vec<String> = names.into_iter().collect();
403        sorted.sort_unstable();
404        sorted
405    }
406
407    /// Collect all thought strings from every step, in order.
408    pub fn all_thoughts(&self) -> Vec<&str> {
409        self.steps.iter().map(|s| s.thought.as_str()).collect()
410    }
411
412    /// Collect all action strings from every step, in order.
413    pub fn all_actions(&self) -> Vec<&str> {
414        self.steps.iter().map(|s| s.action.as_str()).collect()
415    }
416
417    /// Collect all observation strings from every step, in order.
418    pub fn all_observations(&self) -> Vec<&str> {
419        self.steps.iter().map(|s| s.observation.as_str()).collect()
420    }
421
422    /// Return references to steps where the observation indicates a tool error.
423    ///
424    /// A step is classified as failed when its observation starts with
425    /// `{"error"` (the structured error JSON produced by required-field
426    /// validation) or contains the substring `"error"` (case-insensitive).
427    pub fn failed_steps(&self) -> Vec<&crate::agent::ReActStep> {
428        self.steps
429            .iter()
430            .filter(|s| {
431                let obs = s.observation.trim();
432                obs.starts_with("{\"error\"")
433                    || obs.to_ascii_lowercase().contains("\"error\"")
434            })
435            .collect()
436    }
437
438    /// Return the number of tool-call steps whose observation indicates an error.
439    ///
440    /// Equivalent to `failed_steps().len()` but avoids collecting a `Vec`.
441    pub fn failed_tool_call_count(&self) -> usize {
442        self.steps
443            .iter()
444            .filter(|s| {
445                let obs = s.observation.trim();
446                obs.starts_with("{\"error\"")
447                    || obs.to_ascii_lowercase().contains("\"error\"")
448            })
449            .count()
450    }
451
452    /// Return a count of how many times each action was taken in this session.
453    ///
454    /// The map key is the action name (e.g. `"search"`, `"FINAL_ANSWER"`).
455    pub fn action_counts(&self) -> std::collections::HashMap<String, usize> {
456        let mut counts = std::collections::HashMap::new();
457        for step in &self.steps {
458            *counts.entry(step.action.clone()).or_insert(0) += 1;
459        }
460        counts
461    }
462
463    /// Return a sorted list of unique action names used in this session.
464    pub fn unique_actions(&self) -> Vec<String> {
465        let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
466        for step in &self.steps {
467            seen.insert(step.action.clone());
468        }
469        seen.into_iter().collect()
470    }
471
472    /// Return the action name used most often during the session.
473    ///
474    /// Returns `None` for sessions with no steps.  When multiple actions tie
475    /// for the maximum count, any one of them may be returned.
476    pub fn most_used_action(&self) -> Option<String> {
477        let counts = self.action_counts();
478        counts
479            .into_iter()
480            .max_by_key(|(_, count)| *count)
481            .map(|(name, _)| name)
482    }
483
484    /// Return the observation string from the most recent step that has one.
485    ///
486    /// Steps with an empty observation are skipped.  Returns `None` when no
487    /// step has produced an observation yet.
488    pub fn last_observation(&self) -> Option<&str> {
489        self.steps
490            .iter()
491            .rev()
492            .find(|s| !s.observation.is_empty())
493            .map(|s| s.observation.as_str())
494    }
495
496    /// Return the number of steps that have a non-empty thought string.
497    pub fn thought_count(&self) -> usize {
498        self.steps.iter().filter(|s| !s.thought.is_empty()).count()
499    }
500
501    /// Return the fraction of steps that contain a non-empty observation.
502    ///
503    /// Returns `0.0` for sessions with no steps.
504    pub fn observation_rate(&self) -> f64 {
505        let n = self.steps.len();
506        if n == 0 {
507            return 0.0;
508        }
509        let with_obs = self
510            .steps
511            .iter()
512            .filter(|s| !s.observation.is_empty())
513            .count();
514        with_obs as f64 / n as f64
515    }
516
517    /// Return `true` if at least one knowledge-graph lookup was performed during
518    /// this session.
519    pub fn has_graph_lookups(&self) -> bool {
520        self.graph_lookups > 0
521    }
522
523    /// Return how many times the last action in the session repeats consecutively
524    /// at the end of the step list.
525    ///
526    /// Returns `0` for empty sessions or single-step sessions where no repeat
527    /// is possible.  Useful for detecting a stuck agent that keeps retrying the
528    /// same action.
529    pub fn consecutive_same_action_at_end(&self) -> usize {
530        let n = self.steps.len();
531        if n == 0 {
532            return 0;
533        }
534        let last_action = &self.steps[n - 1].action;
535        self.steps
536            .iter()
537            .rev()
538            .take_while(|s| &s.action == last_action)
539            .count()
540            .saturating_sub(1) // don't count the step itself; only the *repeats*
541    }
542
543    /// Return the fraction of steps (from the second onward) that repeat the
544    /// immediately preceding action.
545    ///
546    /// Returns `0.0` for sessions with fewer than two steps.  A high value
547    /// may indicate the agent is stuck in a loop.
548    pub fn action_repetition_rate(&self) -> f64 {
549        let n = self.steps.len();
550        if n < 2 {
551            return 0.0;
552        }
553        let repeats = self
554            .steps
555            .windows(2)
556            .filter(|w| w[0].action == w[1].action)
557            .count();
558        repeats as f64 / (n - 1) as f64
559    }
560
561    /// Return the length of the longest consecutive run of failed steps.
562    ///
563    /// A step is considered failed when its observation starts with `{"error"`
564    /// or contains the substring `"error"` (case-insensitive).
565    /// Returns `0` for sessions with no steps or no failures.
566    pub fn max_consecutive_failures(&self) -> usize {
567        let mut max_run = 0usize;
568        let mut current = 0usize;
569        for step in &self.steps {
570            let obs = step.observation.trim();
571            if obs.starts_with("{\"error\"") || obs.to_ascii_lowercase().contains("\"error\"") {
572                current += 1;
573                if current > max_run {
574                    max_run = current;
575                }
576            } else {
577                current = 0;
578            }
579        }
580        max_run
581    }
582
583    /// Return the mean character length of non-empty thought strings.
584    ///
585    /// Only steps with a non-empty `thought` field are included.
586    /// Returns `0.0` when no step has a thought.
587    pub fn avg_thought_length(&self) -> f64 {
588        let thoughts: Vec<_> = self
589            .steps
590            .iter()
591            .filter(|s| !s.thought.is_empty())
592            .collect();
593        if thoughts.is_empty() {
594            return 0.0;
595        }
596        let total: usize = thoughts.iter().map(|s| s.thought.len()).sum();
597        total as f64 / thoughts.len() as f64
598    }
599
600    /// Return the rate of knowledge-graph lookups per step.
601    ///
602    /// Computed as `graph_lookups / step_count`.  Returns `0.0` when there
603    /// are no steps, to avoid division by zero.
604    pub fn graph_lookup_rate(&self) -> f64 {
605        let steps = self.steps.len();
606        if steps == 0 {
607            return 0.0;
608        }
609        self.graph_lookups as f64 / steps as f64
610    }
611
612    /// Return `true` if any checkpoint errors were recorded during the session.
613    ///
614    /// A non-empty `checkpoint_errors` list means some step snapshots may be
615    /// missing from storage, but the session itself completed successfully.
616    pub fn has_checkpoint_errors(&self) -> bool {
617        !self.checkpoint_errors.is_empty()
618    }
619
620    /// Return the number of checkpoint errors recorded during this session.
621    pub fn checkpoint_error_count(&self) -> usize {
622        self.checkpoint_errors.len()
623    }
624
625    /// Return the number of knowledge-graph lookups performed during this session.
626    pub fn graph_lookup_count(&self) -> usize {
627        self.graph_lookups
628    }
629
630    /// Return the episodic memory hit rate for this session.
631    ///
632    /// Computed as `memory_hits / step_count`. Returns `0.0` when there are
633    /// no steps, to avoid division by zero.
634    pub fn memory_hit_rate(&self) -> f64 {
635        let steps = self.steps.len();
636        if steps == 0 {
637            return 0.0;
638        }
639        self.memory_hits as f64 / steps as f64
640    }
641
642    /// Return the raw count of episodic memory hits for this session.
643    pub fn total_memory_hits(&self) -> usize {
644        self.memory_hits
645    }
646
647    /// Return the session throughput in steps per second.
648    ///
649    /// Computed as `step_count / (duration_ms / 1000.0)`.  Returns `0.0`
650    /// if `duration_ms` is zero.
651    pub fn throughput_steps_per_sec(&self) -> f64 {
652        if self.duration_ms == 0 {
653            return 0.0;
654        }
655        self.steps.len() as f64 / (self.duration_ms as f64 / 1000.0)
656    }
657
658    /// Return the session duration in full seconds (rounded down).
659    pub fn duration_secs(&self) -> u64 {
660        self.duration_ms / 1000
661    }
662
663    /// Return the count of steps whose thought string is longer than `threshold` bytes.
664    pub fn steps_above_thought_length(&self, threshold: usize) -> usize {
665        self.steps.iter().filter(|s| s.thought.len() > threshold).count()
666    }
667
668    /// Return `true` if any step's action begins with `"FINAL_ANSWER"` (case-insensitive).
669    pub fn has_final_answer(&self) -> bool {
670        self.steps
671            .iter()
672            .any(|s| s.action.to_ascii_uppercase().starts_with("FINAL_ANSWER"))
673    }
674
675    /// Return the mean byte length of all step action strings.
676    ///
677    /// Returns `0.0` for empty sessions.
678    pub fn avg_action_length(&self) -> f64 {
679        if self.steps.is_empty() {
680            return 0.0;
681        }
682        let total: usize = self.steps.iter().map(|s| s.action.len()).sum();
683        total as f64 / self.steps.len() as f64
684    }
685
686    /// Return `true` if any tool-call steps had error observations.
687    pub fn has_tool_failures(&self) -> bool {
688        self.failed_tool_call_count() > 0
689    }
690
691    /// Return the fraction of steps that were tool calls.
692    ///
693    /// Computed as `tool_calls_made / step_count`.  Returns `0.0` for empty
694    /// sessions to avoid division by zero.
695    pub fn tool_call_rate(&self) -> f64 {
696        let total = self.steps.len();
697        if total == 0 {
698            return 0.0;
699        }
700        self.tool_calls_made() as f64 / total as f64
701    }
702
703    /// Return the fraction of tool-call steps that succeeded.
704    ///
705    /// Computed as `1.0 - (failed_tool_call_count / step_count)`.  Returns
706    /// `1.0` for empty sessions (no failures possible).
707    pub fn step_success_rate(&self) -> f64 {
708        let total = self.steps.len();
709        if total == 0 {
710            return 1.0;
711        }
712        1.0 - (self.failed_tool_call_count() as f64 / total as f64)
713    }
714
715    /// Return the ratio of unique actions to total steps.
716    ///
717    /// Returns `0.0` for sessions with no steps.  A value of `1.0` means every
718    /// step used a different action; lower values indicate repeated actions.
719    pub fn action_diversity(&self) -> f64 {
720        let total = self.steps.len();
721        if total == 0 {
722            return 0.0;
723        }
724        let unique: std::collections::HashSet<&str> =
725            self.steps.iter().map(|s| s.action.as_str()).collect();
726        unique.len() as f64 / total as f64
727    }
728
729    /// Return the total byte length of all thought strings across all steps.
730    pub fn total_thought_length(&self) -> usize {
731        self.steps.iter().map(|s| s.thought.len()).sum()
732    }
733
734    /// Return the number of steps whose observation string is empty.
735    pub fn steps_with_empty_observations(&self) -> usize {
736        self.steps.iter().filter(|s| s.observation.is_empty()).count()
737    }
738
739    /// Return the byte length of each observation, in step order.
740    pub fn observation_lengths(&self) -> Vec<usize> {
741        self.steps.iter().map(|s| s.observation.len()).collect()
742    }
743
744    /// Return the mean observation byte length across all steps.
745    ///
746    /// Returns `0.0` for empty sessions.
747    pub fn avg_observation_length(&self) -> f64 {
748        let n = self.steps.len();
749        if n == 0 {
750            return 0.0;
751        }
752        let total: usize = self.steps.iter().map(|s| s.observation.len()).sum();
753        total as f64 / n as f64
754    }
755
756    /// Return the byte length of the shortest non-empty thought, or `0` if
757    /// no non-empty thoughts exist.
758    pub fn min_thought_length(&self) -> usize {
759        self.steps
760            .iter()
761            .filter(|s| !s.thought.is_empty())
762            .map(|s| s.thought.len())
763            .min()
764            .unwrap_or(0)
765    }
766
767    /// Return the longest observation string in the session, or `None` if
768    /// the session is empty.
769    pub fn longest_observation(&self) -> Option<&str> {
770        self.steps
771            .iter()
772            .max_by_key(|s| s.observation.len())
773            .map(|s| s.observation.as_str())
774    }
775
776    /// Return the byte length of each step's thought string, in step order.
777    pub fn thought_lengths(&self) -> Vec<usize> {
778        self.steps.iter().map(|s| s.thought.len()).collect()
779    }
780
781    /// Return the action string that appears most often across all steps.
782    ///
783    /// Returns `None` if the session has no steps.
784    pub fn most_common_action(&self) -> Option<&str> {
785        if self.steps.is_empty() {
786            return None;
787        }
788        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
789        for s in &self.steps {
790            *counts.entry(s.action.as_str()).or_insert(0) += 1;
791        }
792        counts.into_iter().max_by_key(|(_, c)| *c).map(|(a, _)| a)
793    }
794
795    /// Return the byte length of each step's action string, in step order.
796    pub fn action_lengths(&self) -> Vec<usize> {
797        self.steps.iter().map(|s| s.action.len()).collect()
798    }
799
800    /// Return the count of steps that did not have a tool failure.
801    pub fn step_success_count(&self) -> usize {
802        self.steps.len() - self.failed_tool_call_count()
803    }
804
805    /// Return the thought string of the step with the most bytes.
806    ///
807    /// Returns `None` if the session has no steps.
808    pub fn longest_thought(&self) -> Option<&str> {
809        self.steps
810            .iter()
811            .max_by_key(|s| s.thought.len())
812            .map(|s| s.thought.as_str())
813    }
814
815    /// Return the action string of the step with the fewest bytes.
816    ///
817    /// Returns `None` if the session has no steps.
818    pub fn shortest_action(&self) -> Option<&str> {
819        self.steps
820            .iter()
821            .min_by_key(|s| s.action.len())
822            .map(|s| s.action.as_str())
823    }
824
825    /// Return the sum of byte lengths of all thought strings in the session.
826    pub fn total_thought_bytes(&self) -> usize {
827        self.steps.iter().map(|s| s.thought.len()).sum()
828    }
829
830    /// Return the sum of byte lengths of all observation strings in the session.
831    pub fn total_observation_bytes(&self) -> usize {
832        self.steps.iter().map(|s| s.observation.len()).sum()
833    }
834
835    /// Return the action string of the first step in the session.
836    ///
837    /// Returns `None` if the session has no steps.
838    pub fn first_step_action(&self) -> Option<&str> {
839        self.steps.first().map(|s| s.action.as_str())
840    }
841
842    /// Return the action string of the last step in the session.
843    ///
844    /// Returns `None` if the session has no steps.
845    pub fn last_step_action(&self) -> Option<&str> {
846        self.steps.last().map(|s| s.action.as_str())
847    }
848
849    /// Return the count of steps that have a non-empty thought string.
850    pub fn count_nonempty_thoughts(&self) -> usize {
851        self.steps.iter().filter(|s| !s.thought.is_empty()).count()
852    }
853
854    /// Return the count of steps whose observation contains `substring`.
855    pub fn observation_contains_count(&self, substring: &str) -> usize {
856        self.steps.iter().filter(|s| s.observation.contains(substring)).count()
857    }
858
859    /// Return the number of steps whose action string matches `action` exactly.
860    pub fn count_steps_with_action(&self, action: &str) -> usize {
861        self.steps.iter().filter(|s| s.action == action).count()
862    }
863
864    /// Return the number of steps whose thought contains `substring`.
865    pub fn thought_contains_count(&self, substring: &str) -> usize {
866        self.steps.iter().filter(|s| s.thought.contains(substring)).count()
867    }
868
869    /// Return the fraction of steps that had a tool failure observation.
870    ///
871    /// Computed as `failed_tool_call_count / step_count`.  Returns `0.0` for
872    /// empty sessions.
873    pub fn failure_rate(&self) -> f64 {
874        let total = self.steps.len();
875        if total == 0 {
876            return 0.0;
877        }
878        self.failed_tool_call_count() as f64 / total as f64
879    }
880
881    /// Return the number of distinct action names used across all steps.
882    pub fn unique_action_count(&self) -> usize {
883        let unique: std::collections::HashSet<&str> =
884            self.steps.iter().map(|s| s.action.as_str()).collect();
885        unique.len()
886    }
887
888    /// Persist this session as a checkpoint under `"session:<session_id>"`.
889    #[cfg(feature = "persistence")]
890    pub async fn save_checkpoint(
891        &self,
892        backend: &dyn crate::persistence::PersistenceBackend,
893    ) -> Result<(), AgentRuntimeError> {
894        let key = format!("session:{}", self.session_id);
895        let bytes = serde_json::to_vec(self)
896            .map_err(|e| AgentRuntimeError::Persistence(format!("serialize: {e}")))?;
897        backend.save(&key, &bytes).await
898    }
899
900    /// Load a previously saved checkpoint by `session_id`.
901    ///
902    /// Returns `None` if no checkpoint exists for the given ID.
903    #[cfg(feature = "persistence")]
904    pub async fn load_checkpoint(
905        backend: &dyn crate::persistence::PersistenceBackend,
906        session_id: &str,
907    ) -> Result<Option<AgentSession>, AgentRuntimeError> {
908        let key = format!("session:{session_id}");
909        match backend.load(&key).await? {
910            None => Ok(None),
911            Some(bytes) => {
912                let session = serde_json::from_slice(&bytes)
913                    .map_err(|e| AgentRuntimeError::Persistence(format!("deserialize: {e}")))?;
914                Ok(Some(session))
915            }
916        }
917    }
918
919    /// Load the session snapshot saved after step `step` completed.
920    ///
921    /// Alias for [`load_checkpoint_at_step`] — provided for ergonomic
922    /// compatibility with call sites that prefer this naming convention.
923    ///
924    /// [`load_checkpoint_at_step`]: AgentSession::load_checkpoint_at_step
925    #[cfg(feature = "persistence")]
926    #[deprecated(since = "1.1.0", note = "Use load_checkpoint_at_step instead")]
927    pub async fn load_step_checkpoint(
928        backend: &dyn crate::persistence::PersistenceBackend,
929        session_id: &str,
930        step: usize,
931    ) -> Result<Option<AgentSession>, AgentRuntimeError> {
932        Self::load_checkpoint_at_step(backend, session_id, step).await
933    }
934
935    /// Load the session snapshot saved after step `step` completed.
936    ///
937    /// Returns `None` if no checkpoint exists for the given session/step pair.
938    /// The step number is 1-based (step 1 = after the first ReAct iteration).
939    #[cfg(feature = "persistence")]
940    pub async fn load_checkpoint_at_step(
941        backend: &dyn crate::persistence::PersistenceBackend,
942        session_id: &str,
943        step: usize,
944    ) -> Result<Option<AgentSession>, AgentRuntimeError> {
945        let key = format!("session:{session_id}:step:{step}");
946        match backend.load(&key).await? {
947            None => Ok(None),
948            Some(bytes) => {
949                let session = serde_json::from_slice(&bytes)
950                    .map_err(|e| AgentRuntimeError::Persistence(format!("deserialize: {e}")))?;
951                Ok(Some(session))
952            }
953        }
954    }
955}
956
957// ── AgentRuntimeBuilder ───────────────────────────────────────────────────────
958
959/// Builder for `AgentRuntime`.
960///
961/// Uses a typestate parameter `S` to enforce that `with_agent_config` is called
962/// before `build()`.  Calling `build()` on a `AgentRuntimeBuilder<NeedsConfig>`
963/// is a **compile-time error**.
964///
965/// Typical usage:
966/// ```ignore
967/// let runtime = AgentRuntime::builder()      // AgentRuntimeBuilder<NeedsConfig>
968///     .with_memory(store)
969///     .with_agent_config(cfg)                // → AgentRuntimeBuilder<HasConfig>
970///     .build();                              // → AgentRuntime (infallible)
971/// ```
972/// Builder for [`AgentRuntime`].
973pub struct AgentRuntimeBuilder<S = NeedsConfig> {
974    #[cfg(feature = "memory")]
975    memory: Option<EpisodicStore>,
976    #[cfg(feature = "memory")]
977    working: Option<WorkingMemory>,
978    #[cfg(feature = "graph")]
979    graph: Option<GraphStore>,
980    #[cfg(feature = "orchestrator")]
981    backpressure: Option<BackpressureGuard>,
982    agent_config: Option<AgentConfig>,
983    tools: Vec<Arc<ToolSpec>>,
984    metrics: Arc<RuntimeMetrics>,
985    #[cfg(feature = "persistence")]
986    checkpoint_backend: Option<Arc<dyn crate::persistence::PersistenceBackend>>,
987    token_estimator: Option<Arc<dyn TokenEstimator>>,
988    _state: PhantomData<S>,
989}
990
991// ── DebugBuilderState sealed trait ────────────────────────────────────────────
992
993/// Private trait used to drive the single generic `Debug` impl for
994/// `AgentRuntimeBuilder<S>`.  Only `NeedsConfig` and `HasConfig` implement it.
995trait DebugBuilderState {
996    /// Name shown in the debug output.
997    const NAME: &'static str;
998    /// Whether to emit the `agent_config` field (only `HasConfig` has one).
999    const HAS_CONFIG: bool;
1000}
1001
1002impl DebugBuilderState for NeedsConfig {
1003    const NAME: &'static str = "AgentRuntimeBuilder<NeedsConfig>";
1004    const HAS_CONFIG: bool = false;
1005}
1006
1007impl DebugBuilderState for HasConfig {
1008    const NAME: &'static str = "AgentRuntimeBuilder<HasConfig>";
1009    const HAS_CONFIG: bool = true;
1010}
1011
1012impl<S: DebugBuilderState> std::fmt::Debug for AgentRuntimeBuilder<S> {
1013    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1014        let mut s = f.debug_struct(S::NAME);
1015        #[cfg(feature = "memory")]
1016        {
1017            s.field("memory", &self.memory.is_some())
1018                .field("working", &self.working.is_some());
1019        }
1020        #[cfg(feature = "graph")]
1021        s.field("graph", &self.graph.is_some());
1022        #[cfg(feature = "orchestrator")]
1023        s.field("backpressure", &self.backpressure.is_some());
1024        if S::HAS_CONFIG {
1025            s.field("agent_config", &self.agent_config.is_some());
1026        }
1027        s.field("tools", &self.tools.len()).finish()
1028    }
1029}
1030
1031impl Default for AgentRuntimeBuilder<NeedsConfig> {
1032    fn default() -> Self {
1033        Self {
1034            #[cfg(feature = "memory")]
1035            memory: None,
1036            #[cfg(feature = "memory")]
1037            working: None,
1038            #[cfg(feature = "graph")]
1039            graph: None,
1040            #[cfg(feature = "orchestrator")]
1041            backpressure: None,
1042            agent_config: None,
1043            tools: Vec::new(),
1044            metrics: RuntimeMetrics::new(),
1045            #[cfg(feature = "persistence")]
1046            checkpoint_backend: None,
1047            token_estimator: None,
1048            _state: PhantomData,
1049        }
1050    }
1051}
1052
1053// Methods available on ALL builder states.
1054impl<S> AgentRuntimeBuilder<S> {
1055    /// Attach an episodic memory store.
1056    #[cfg(feature = "memory")]
1057    pub fn with_memory(mut self, store: EpisodicStore) -> Self {
1058        self.memory = Some(store);
1059        self
1060    }
1061
1062    /// Attach a working memory store.
1063    #[cfg(feature = "memory")]
1064    pub fn with_working_memory(mut self, wm: WorkingMemory) -> Self {
1065        self.working = Some(wm);
1066        self
1067    }
1068
1069    /// Attach a graph store.
1070    #[cfg(feature = "graph")]
1071    pub fn with_graph(mut self, graph: GraphStore) -> Self {
1072        self.graph = Some(graph);
1073        self
1074    }
1075
1076    /// Attach a backpressure guard.
1077    #[cfg(feature = "orchestrator")]
1078    pub fn with_backpressure(mut self, guard: BackpressureGuard) -> Self {
1079        self.backpressure = Some(guard);
1080        self
1081    }
1082
1083    /// Register a tool available to the agent loop.
1084    pub fn register_tool(mut self, spec: ToolSpec) -> Self {
1085        self.tools.push(Arc::new(spec));
1086        self
1087    }
1088
1089    /// Register multiple tools at once.
1090    ///
1091    /// Equivalent to calling [`register_tool`] for each spec.
1092    ///
1093    /// [`register_tool`]: AgentRuntimeBuilder::register_tool
1094    pub fn register_tools(mut self, specs: impl IntoIterator<Item = ToolSpec>) -> Self {
1095        for spec in specs {
1096            self.tools.push(Arc::new(spec));
1097        }
1098        self
1099    }
1100
1101    /// Attach a shared `RuntimeMetrics` instance.
1102    pub fn with_metrics(mut self, metrics: Arc<RuntimeMetrics>) -> Self {
1103        self.metrics = metrics;
1104        self
1105    }
1106
1107    /// Attach a persistence backend for session checkpointing.
1108    #[cfg(feature = "persistence")]
1109    pub fn with_checkpoint_backend(
1110        mut self,
1111        backend: Arc<dyn crate::persistence::PersistenceBackend>,
1112    ) -> Self {
1113        self.checkpoint_backend = Some(backend);
1114        self
1115    }
1116
1117    /// Provide a custom [`TokenEstimator`] for memory budget calculations.
1118    ///
1119    /// Replaces the default `len / 4` byte-counting heuristic.  Use this to
1120    /// plug in a model-specific tokenizer (e.g. tiktoken, sentencepiece) so
1121    /// that `AgentConfig::max_memory_tokens` is respected accurately.
1122    pub fn with_token_estimator(mut self, estimator: Arc<dyn TokenEstimator>) -> Self {
1123        self.token_estimator = Some(estimator);
1124        self
1125    }
1126}
1127
1128// `with_agent_config` transitions NeedsConfig → HasConfig.
1129impl AgentRuntimeBuilder<NeedsConfig> {
1130    /// Create a new builder (equivalent to `Default::default()`).
1131    pub fn new() -> Self {
1132        Self::default()
1133    }
1134
1135    /// Set the agent loop configuration.
1136    ///
1137    /// After this call the builder transitions to `AgentRuntimeBuilder<HasConfig>`,
1138    /// making `build()` available.
1139    pub fn with_agent_config(self, config: AgentConfig) -> AgentRuntimeBuilder<HasConfig> {
1140        AgentRuntimeBuilder {
1141            memory: self.memory,
1142            working: self.working,
1143            #[cfg(feature = "graph")]
1144            graph: self.graph,
1145            #[cfg(feature = "orchestrator")]
1146            backpressure: self.backpressure,
1147            agent_config: Some(config),
1148            tools: self.tools,
1149            metrics: self.metrics,
1150            #[cfg(feature = "persistence")]
1151            checkpoint_backend: self.checkpoint_backend,
1152            token_estimator: self.token_estimator,
1153            _state: PhantomData,
1154        }
1155    }
1156}
1157
1158// `build()` is only available once we have a config.
1159impl AgentRuntimeBuilder<HasConfig> {
1160    /// Build the `AgentRuntime`.
1161    ///
1162    /// This is infallible: the typestate guarantees `agent_config` is present.
1163    pub fn build(self) -> AgentRuntime {
1164        // SAFETY: `agent_config` is always `Some` in `HasConfig` state because
1165        // `with_agent_config` is the only way to reach this state.
1166        #[allow(clippy::unwrap_used)]
1167        let agent_config = self.agent_config.unwrap();
1168
1169        AgentRuntime {
1170            #[cfg(feature = "memory")]
1171            memory: self.memory,
1172            #[cfg(feature = "memory")]
1173            working: self.working,
1174            #[cfg(feature = "graph")]
1175            graph: self.graph,
1176            #[cfg(feature = "orchestrator")]
1177            backpressure: self.backpressure,
1178            agent_config,
1179            tools: self.tools,
1180            metrics: self.metrics,
1181            token_estimator: self
1182                .token_estimator
1183                .unwrap_or_else(|| Arc::new(CharDivTokenEstimator)),
1184            #[cfg(feature = "persistence")]
1185            checkpoint_backend: self.checkpoint_backend,
1186        }
1187    }
1188}
1189
1190// ── TokenEstimator ────────────────────────────────────────────────────────────
1191
1192/// Estimates the number of tokens in a string.
1193///
1194/// Implement this trait to replace the default `len / 4` heuristic with a
1195/// model-specific tokenizer (e.g. tiktoken, sentencepiece).
1196///
1197/// # Example
1198/// ```rust,ignore
1199/// struct TiktokenEstimator { enc: tiktoken::Encoding }
1200/// impl TokenEstimator for TiktokenEstimator {
1201///     fn count_tokens(&self, text: &str) -> usize {
1202///         self.enc.encode_ordinary(text).len()
1203///     }
1204/// }
1205/// ```
1206pub trait TokenEstimator: Send + Sync {
1207    /// Return an approximate token count for `text`.
1208    fn count_tokens(&self, text: &str) -> usize;
1209}
1210
1211/// Default heuristic: 1 token ≈ 4 bytes.
1212pub struct CharDivTokenEstimator;
1213
1214impl TokenEstimator for CharDivTokenEstimator {
1215    fn count_tokens(&self, text: &str) -> usize {
1216        (text.len() / 4).max(1)
1217    }
1218}
1219
1220// ── AgentRuntime ──────────────────────────────────────────────────────────────
1221
1222/// Unified runtime that coordinates memory, graph, orchestration, and agent loop.
1223pub struct AgentRuntime {
1224    #[cfg(feature = "memory")]
1225    memory: Option<EpisodicStore>,
1226    #[cfg(feature = "memory")]
1227    working: Option<WorkingMemory>,
1228    #[cfg(feature = "graph")]
1229    graph: Option<GraphStore>,
1230    #[cfg(feature = "orchestrator")]
1231    backpressure: Option<BackpressureGuard>,
1232    agent_config: AgentConfig,
1233    tools: Vec<Arc<ToolSpec>>,
1234    metrics: Arc<RuntimeMetrics>,
1235    #[cfg(feature = "persistence")]
1236    checkpoint_backend: Option<Arc<dyn crate::persistence::PersistenceBackend>>,
1237    token_estimator: Arc<dyn TokenEstimator>,
1238}
1239
1240impl std::fmt::Debug for AgentRuntime {
1241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1242        let mut s = f.debug_struct("AgentRuntime");
1243        s.field("memory", &self.memory.is_some())
1244            .field("working", &self.working.is_some());
1245        #[cfg(feature = "graph")]
1246        s.field("graph", &self.graph.is_some());
1247        #[cfg(feature = "orchestrator")]
1248        s.field("backpressure", &self.backpressure.is_some());
1249        s.field("tools", &self.tools.len());
1250        #[cfg(feature = "persistence")]
1251        s.field("checkpoint_backend", &self.checkpoint_backend.is_some());
1252        s.finish()
1253    }
1254}
1255
1256impl AgentRuntime {
1257    /// Return a new builder in the `NeedsConfig` state.
1258    pub fn builder() -> AgentRuntimeBuilder<NeedsConfig> {
1259        AgentRuntimeBuilder::new()
1260    }
1261
1262    /// Construct a minimal `AgentRuntime` in one call with sensible defaults.
1263    pub fn quick(max_iterations: usize, model: impl Into<String>) -> Self {
1264        AgentRuntime::builder()
1265            .with_agent_config(AgentConfig::new(max_iterations, model))
1266            .build()
1267    }
1268
1269    /// Return a shared reference to the runtime metrics.
1270    pub fn metrics(&self) -> Arc<RuntimeMetrics> {
1271        Arc::clone(&self.metrics)
1272    }
1273
1274    /// Run the agent loop for the given prompt.
1275    ///
1276    /// Optionally recalls episodic memories and injects them into the context.
1277    /// Optionally enforces backpressure before starting.
1278    ///
1279    /// # Arguments
1280    /// * `agent_id` — identifies the agent for memory retrieval
1281    /// * `prompt` — the user's input prompt
1282    /// * `infer` — async inference function: `(context: String) -> impl Future<Output = String>`
1283    ///
1284    /// # Returns
1285    /// An `AgentSession` with step count, hits, duration, and a stable session ID.
1286    #[tracing::instrument(skip(self, infer), fields(agent_id = %agent_id))]
1287    pub async fn run_agent<F, Fut>(
1288        &self,
1289        agent_id: AgentId,
1290        prompt: &str,
1291        infer: F,
1292    ) -> Result<AgentSession, AgentRuntimeError>
1293    where
1294        F: FnMut(String) -> Fut,
1295        Fut: std::future::Future<Output = String>,
1296    {
1297        // Acquire backpressure slot before counting the session — shed requests
1298        // must not inflate total_sessions or active_sessions.
1299        #[cfg(feature = "orchestrator")]
1300        {
1301            let backpressure_result = if let Some(ref guard) = self.backpressure {
1302                guard.try_acquire()
1303            } else {
1304                Ok(())
1305            };
1306            if let Err(e) = backpressure_result {
1307                tracing::warn!(agent_id = %agent_id, error = %e, "backpressure shed: rejecting session");
1308                self.metrics
1309                    .backpressure_shed_count
1310                    .fetch_add(1, Ordering::Relaxed);
1311                return Err(e);
1312            }
1313        }
1314
1315        self.metrics.total_sessions.fetch_add(1, Ordering::Relaxed);
1316        self.metrics.active_sessions.fetch_add(1, Ordering::Relaxed);
1317
1318        tracing::info!(agent_id = %agent_id, "agent session starting");
1319        let outcome = self.run_agent_inner(agent_id.clone(), prompt, infer).await;
1320
1321        // Always release backpressure — success or error.
1322        #[cfg(feature = "orchestrator")]
1323        if let Some(ref guard) = self.backpressure {
1324            let _ = guard.release();
1325        }
1326
1327        // Saturating decrement — guards against underflow to usize::MAX if
1328        // active_sessions is somehow already 0 (e.g. double-decrement bug).
1329        let _ = self.metrics.active_sessions.fetch_update(
1330            Ordering::Relaxed,
1331            Ordering::Relaxed,
1332            |v| Some(v.saturating_sub(1)),
1333        );
1334
1335        match &outcome {
1336            Ok(session) => {
1337                tracing::info!(
1338                    agent_id = %agent_id,
1339                    session_id = %session.session_id,
1340                    steps = session.step_count(),
1341                    duration_ms = session.duration_ms,
1342                    "agent session completed"
1343                );
1344                self.metrics
1345                    .total_steps
1346                    .fetch_add(session.step_count() as u64, Ordering::Relaxed);
1347            }
1348            Err(e) => {
1349                tracing::error!(agent_id = %agent_id, error = %e, "agent session failed");
1350            }
1351        }
1352
1353        outcome
1354    }
1355
1356    /// Inner implementation of `run_agent`, called after backpressure is acquired.
1357    #[tracing::instrument(skip(self, infer), fields(agent_id = %agent_id, session_id = tracing::field::Empty))]
1358    async fn run_agent_inner<F, Fut>(
1359        &self,
1360        agent_id: AgentId,
1361        prompt: &str,
1362        infer: F,
1363    ) -> Result<AgentSession, AgentRuntimeError>
1364    where
1365        F: FnMut(String) -> Fut,
1366        Fut: std::future::Future<Output = String>,
1367    {
1368        let start = Instant::now();
1369        let session_id = uuid::Uuid::new_v4().to_string();
1370
1371        let mut memory_hits = 0usize;
1372        let mut graph_lookups = 0usize;
1373
1374        // Build enriched prompt from episodic memory.
1375        #[cfg(feature = "memory")]
1376        let enriched_prompt = if let Some(ref store) = self.memory {
1377            let memories = store.recall(&agent_id, self.agent_config.max_memory_recalls)?;
1378
1379            // Apply token budget if configured.
1380            let memories = if let Some(token_budget) = self.agent_config.max_memory_tokens {
1381                let mut used = 0usize;
1382                memories
1383                    .into_iter()
1384                    .filter(|m| {
1385                        let tokens = self.token_estimator.count_tokens(&m.content);
1386                        if used + tokens <= token_budget {
1387                            used += tokens;
1388                            true
1389                        } else {
1390                            false
1391                        }
1392                    })
1393                    .collect::<Vec<_>>()
1394            } else {
1395                memories
1396            };
1397
1398            memory_hits = memories.len();
1399            self.metrics
1400                .memory_recall_count
1401                .fetch_add(1, Ordering::Relaxed);
1402
1403            if let Some(budget) = self.agent_config.max_memory_tokens {
1404                tracing::debug!(
1405                    "memory token budget: {budget}, injecting {} items",
1406                    memory_hits
1407                );
1408            } else {
1409                tracing::debug!("enriched prompt with {} memory items", memory_hits);
1410            }
1411
1412            if memories.is_empty() {
1413                prompt.to_owned()
1414            } else {
1415                // Build the enriched prompt directly into a String without an
1416                // intermediate Vec<String> allocation.
1417                let mut enriched =
1418                    String::with_capacity(prompt.len() + memories.len() * 64 + 32);
1419                enriched.push_str("Relevant memories:\n");
1420                for m in &memories {
1421                    let _ = writeln!(enriched, "- {}", m.content);
1422                }
1423                let _ = write!(enriched, "\nCurrent prompt: {prompt}");
1424                enriched
1425            }
1426        } else {
1427            prompt.to_owned()
1428        };
1429        #[cfg(not(feature = "memory"))]
1430        let enriched_prompt = prompt.to_owned();
1431
1432        // Inject working memory into prompt.
1433        #[cfg(feature = "memory")]
1434        let enriched_prompt = if let Some(ref wm) = self.working {
1435            let entries = wm.entries()?;
1436            if entries.is_empty() {
1437                enriched_prompt
1438            } else {
1439                // Build working-memory section without an intermediate Vec<String>.
1440                let mut out = String::with_capacity(
1441                    enriched_prompt.len() + entries.len() * 32 + 32,
1442                );
1443                out.push_str(&enriched_prompt);
1444                out.push_str("\n\nCurrent working state:\n");
1445                for (k, v) in &entries {
1446                    let _ = writeln!(out, "  {k}: {v}");
1447                }
1448                // Remove trailing newline added by writeln for the last entry.
1449                if out.ends_with('\n') {
1450                    out.pop();
1451                }
1452                out
1453            }
1454        } else {
1455            enriched_prompt
1456        };
1457
1458        // Count graph entities as "lookups" for session metadata.
1459        #[cfg(feature = "graph")]
1460        if let Some(ref graph) = self.graph {
1461            graph_lookups = graph.entity_count()?;
1462            tracing::debug!("graph has {} entities", graph_lookups);
1463        }
1464
1465        // Build the ReAct loop and register tools.
1466        // Each ToolSpec is stored as an Arc so we can clone the Arc into the
1467        // handler closure without moving ownership out of self.tools.
1468        // Required fields and the per-tool circuit breaker are preserved so
1469        // that validation and fast-fail behaviour work correctly at run time.
1470        let mut react_loop = ReActLoop::new(self.agent_config.clone())
1471            .with_metrics(Arc::clone(&self.metrics));
1472
1473        // Item 11 — wire per-step loop checkpointing.
1474        #[cfg(feature = "persistence")]
1475        if let Some(ref backend) = self.checkpoint_backend {
1476            react_loop = react_loop
1477                .with_step_checkpoint(Arc::clone(backend), session_id.clone());
1478        }
1479
1480        for tool in &self.tools {
1481            let tool_arc = Arc::clone(tool);
1482            let required_fields = tool_arc.required_fields.clone();
1483            #[cfg(feature = "orchestrator")]
1484            let circuit_breaker = tool_arc.circuit_breaker.clone();
1485
1486            let mut spec = ToolSpec::new_async(
1487                tool_arc.name.clone(),
1488                tool_arc.description.clone(),
1489                move |args| {
1490                    let t = Arc::clone(&tool_arc);
1491                    Box::pin(async move { t.call(args).await })
1492                },
1493            )
1494            .with_required_fields(required_fields);
1495
1496            #[cfg(feature = "orchestrator")]
1497            if let Some(cb) = circuit_breaker {
1498                spec = spec.with_circuit_breaker(cb);
1499            }
1500
1501            react_loop.register_tool(spec);
1502        }
1503
1504        // Record the session_id into the current tracing span so that all
1505        // child spans (ReActLoop iterations, tool calls) carry this field.
1506        tracing::Span::current().record("session_id", &session_id.as_str());
1507
1508        let steps = react_loop.run(&enriched_prompt, infer).await?;
1509        let duration_ms = start.elapsed().as_millis() as u64;
1510
1511        // Item 6 — collect per-step checkpoint errors; surfaced in AgentSession.
1512        #[cfg(feature = "persistence")]
1513        let mut ckpt_errors: Vec<String> = Vec::new();
1514
1515        // Save final checkpoint if a backend is configured.
1516        #[cfg(feature = "persistence")]
1517        if let Some(ref backend) = self.checkpoint_backend {
1518            tracing::info!(session_id = %session_id, "saving session checkpoint");
1519
1520            // Build a temporary session without errors to save as the base checkpoint.
1521            let tmp = AgentSession {
1522                session_id: session_id.clone(),
1523                agent_id: agent_id.clone(),
1524                steps: steps.clone(),
1525                memory_hits,
1526                graph_lookups,
1527                duration_ms,
1528                checkpoint_errors: vec![],
1529            };
1530            tmp.save_checkpoint(backend.as_ref()).await?;
1531
1532            // Save incremental per-step consolidated snapshots.
1533            for i in 1..=steps.len() {
1534                let partial = AgentSession {
1535                    session_id: session_id.clone(),
1536                    agent_id: agent_id.clone(),
1537                    steps: steps[..i].to_vec(),
1538                    memory_hits,
1539                    graph_lookups,
1540                    duration_ms,
1541                    checkpoint_errors: vec![],
1542                };
1543                let key = format!("session:{session_id}:step:{i}");
1544                match serde_json::to_vec(&partial) {
1545                    Ok(bytes) => {
1546                        if let Err(e) = backend.save(&key, &bytes).await {
1547                            let msg = format!("session:{session_id} step:{i} save: {e}");
1548                            tracing::warn!("{}", msg);
1549                            ckpt_errors.push(msg);
1550                        }
1551                    }
1552                    Err(e) => {
1553                        let msg =
1554                            format!("session:{session_id} step:{i} serialise: {e}");
1555                        tracing::warn!("{}", msg);
1556                        ckpt_errors.push(msg);
1557                    }
1558                }
1559            }
1560        }
1561
1562        let session = AgentSession {
1563            session_id,
1564            agent_id,
1565            steps,
1566            memory_hits,
1567            graph_lookups,
1568            duration_ms,
1569            #[cfg(feature = "persistence")]
1570            checkpoint_errors: ckpt_errors,
1571            #[cfg(not(feature = "persistence"))]
1572            checkpoint_errors: vec![],
1573        };
1574
1575        Ok(session)
1576    }
1577
1578    /// Return a reference to the episodic memory store, if configured.
1579    #[cfg(feature = "memory")]
1580    pub fn memory(&self) -> Option<&EpisodicStore> {
1581        self.memory.as_ref()
1582    }
1583
1584    /// Return a reference to the graph store, if configured.
1585    #[cfg(feature = "graph")]
1586    pub fn graph(&self) -> Option<&GraphStore> {
1587        self.graph.as_ref()
1588    }
1589
1590    /// Return a reference to the working memory, if configured.
1591    #[cfg(feature = "memory")]
1592    pub fn working_memory(&self) -> Option<&WorkingMemory> {
1593        self.working.as_ref()
1594    }
1595
1596    /// Return `true` if episodic memory was configured for this runtime.
1597    #[cfg(feature = "memory")]
1598    pub fn has_memory(&self) -> bool {
1599        self.memory.is_some()
1600    }
1601
1602    /// Return `true` if a graph store was configured for this runtime.
1603    #[cfg(feature = "graph")]
1604    pub fn has_graph(&self) -> bool {
1605        self.graph.is_some()
1606    }
1607
1608    /// Return `true` if working memory was configured for this runtime.
1609    #[cfg(feature = "memory")]
1610    pub fn has_working_memory(&self) -> bool {
1611        self.working.is_some()
1612    }
1613
1614    /// Gracefully shut down the runtime.
1615    ///
1616    /// Logs a structured shutdown event with the final metrics snapshot.
1617    /// If the `persistence` feature is enabled and a checkpoint backend is
1618    /// configured, writes a sentinel key so operators can confirm clean shutdown.
1619    ///
1620    /// After calling `shutdown`, the runtime should not be used again.
1621    pub async fn shutdown(&self) {
1622        tracing::info!("AgentRuntime shutting down");
1623        tracing::info!(
1624            active_sessions = self.metrics.active_sessions(),
1625            total_sessions = self.metrics.total_sessions(),
1626            total_steps = self.metrics.total_steps(),
1627            total_tool_calls = self.metrics.total_tool_calls(),
1628            failed_tool_calls = self.metrics.failed_tool_calls(),
1629            "final metrics snapshot on shutdown"
1630        );
1631
1632        #[cfg(feature = "persistence")]
1633        if let Some(ref backend) = self.checkpoint_backend {
1634            let ts = chrono::Utc::now().to_rfc3339();
1635            match backend.save("runtime:shutdown", ts.as_bytes()).await {
1636                Ok(()) => tracing::debug!("shutdown sentinel saved"),
1637                Err(e) => tracing::warn!(error = %e, "failed to save shutdown sentinel"),
1638            }
1639        }
1640
1641        tracing::info!("AgentRuntime shutdown complete");
1642    }
1643
1644    /// Run an agent session using a shared [`LlmProvider`].
1645    ///
1646    /// Convenience wrapper around [`run_agent`] that wires the provider's
1647    /// `complete` method as the inference closure.  Inference errors are
1648    /// converted to a `FINAL ANSWER` string so the loop terminates gracefully
1649    /// rather than panicking.
1650    ///
1651    /// [`run_agent`]: AgentRuntime::run_agent
1652    /// [`LlmProvider`]: crate::providers::LlmProvider
1653    #[cfg(feature = "providers")]
1654    pub async fn run_agent_with_provider(
1655        &self,
1656        agent_id: AgentId,
1657        prompt: &str,
1658        provider: std::sync::Arc<dyn crate::providers::LlmProvider>,
1659    ) -> Result<AgentSession, AgentRuntimeError> {
1660        let model = self.agent_config.model.clone();
1661        self.run_agent(agent_id, prompt, |ctx| {
1662            let provider = provider.clone();
1663            let model = model.clone();
1664            async move {
1665                provider
1666                    .complete(&ctx, &model)
1667                    .await
1668                    .unwrap_or_else(|e| format!("FINAL ANSWER: inference error: {e}"))
1669            }
1670        })
1671        .await
1672    }
1673}
1674
1675// ── Tests ─────────────────────────────────────────────────────────────────────
1676
1677#[cfg(test)]
1678mod tests {
1679    use super::*;
1680    use crate::graph::{Entity, GraphStore, Relationship};
1681    use crate::memory::{EpisodicStore, WorkingMemory};
1682
1683    fn simple_config() -> AgentConfig {
1684        AgentConfig::new(5, "test")
1685    }
1686
1687    async fn final_answer_infer(_ctx: String) -> String {
1688        "Thought: done\nAction: FINAL_ANSWER 42".into()
1689    }
1690
1691    // ── Builder ───────────────────────────────────────────────────────────────
1692
1693    // NOTE: test_builder_fails_without_agent_config has been removed.
1694    // The typestate pattern makes calling .build() without .with_agent_config()
1695    // a *compile-time error* — AgentRuntimeBuilder<NeedsConfig> has no build()
1696    // method.  There is nothing to test at runtime.
1697
1698    /// Verifies that the builder compiles and produces a runtime when config is
1699    /// provided.  This is the runtime-observable counterpart to the former
1700    /// "fails without config" test.
1701    #[tokio::test]
1702    async fn test_builder_with_config_compiles() {
1703        let _runtime = AgentRuntime::builder()
1704            .with_agent_config(simple_config())
1705            .build();
1706        // If this compiles and runs, the typestate transition worked correctly.
1707    }
1708
1709    #[tokio::test]
1710    async fn test_builder_succeeds_with_minimal_config() {
1711        let _runtime = AgentRuntime::builder()
1712            .with_agent_config(simple_config())
1713            .build();
1714    }
1715
1716    #[tokio::test]
1717    async fn test_builder_with_all_subsystems() {
1718        let _runtime = AgentRuntime::builder()
1719            .with_agent_config(simple_config())
1720            .with_memory(EpisodicStore::new())
1721            .with_graph(GraphStore::new())
1722            .with_working_memory(WorkingMemory::new(10).unwrap())
1723            .with_backpressure(BackpressureGuard::new(5).unwrap())
1724            .build();
1725    }
1726
1727    #[tokio::test]
1728    async fn test_builder_produces_runtime_with_config() {
1729        // Confirm the built runtime accepts a run_agent call — the most direct
1730        // evidence that the builder wired everything correctly.
1731        let runtime = AgentRuntime::builder()
1732            .with_agent_config(simple_config())
1733            .build();
1734        let session = runtime
1735            .run_agent(AgentId::new("agent-x"), "hello", final_answer_infer)
1736            .await
1737            .unwrap();
1738        assert!(session.step_count() >= 1);
1739        assert!(!session.session_id.is_empty());
1740    }
1741
1742    // ── run_agent ─────────────────────────────────────────────────────────────
1743
1744    #[tokio::test]
1745    async fn test_run_agent_returns_session_with_steps() {
1746        let runtime = AgentRuntime::builder()
1747            .with_agent_config(simple_config())
1748            .build();
1749
1750        let session = runtime
1751            .run_agent(AgentId::new("agent-1"), "hello", final_answer_infer)
1752            .await
1753            .unwrap();
1754
1755        assert_eq!(session.step_count(), 1);
1756    }
1757
1758    #[tokio::test]
1759    async fn test_run_agent_session_has_agent_id() {
1760        let runtime = AgentRuntime::builder()
1761            .with_agent_config(simple_config())
1762            .build();
1763
1764        let session = runtime
1765            .run_agent(AgentId::new("agent-42"), "hello", final_answer_infer)
1766            .await
1767            .unwrap();
1768
1769        assert_eq!(session.agent_id.0, "agent-42");
1770    }
1771
1772    #[tokio::test]
1773    async fn test_run_agent_session_duration_is_set() {
1774        let runtime = AgentRuntime::builder()
1775            .with_agent_config(simple_config())
1776            .build();
1777
1778        let session = runtime
1779            .run_agent(AgentId::new("a"), "hello", final_answer_infer)
1780            .await
1781            .unwrap();
1782
1783        // Duration should be non-negative (0 ms is valid for a fast mock)
1784        let _ = session.duration_ms; // just verify it compiles and is set
1785    }
1786
1787    #[tokio::test]
1788    async fn test_run_agent_session_has_session_id() {
1789        let runtime = AgentRuntime::builder()
1790            .with_agent_config(simple_config())
1791            .build();
1792
1793        let session = runtime
1794            .run_agent(AgentId::new("a"), "hello", final_answer_infer)
1795            .await
1796            .unwrap();
1797
1798        // session_id must be a non-empty UUID string
1799        assert!(!session.session_id.is_empty());
1800        assert_eq!(session.session_id.len(), 36); // UUID v4 canonical form
1801    }
1802
1803    #[tokio::test]
1804    async fn test_run_agent_memory_hits_zero_without_memory() {
1805        let runtime = AgentRuntime::builder()
1806            .with_agent_config(simple_config())
1807            .build();
1808
1809        let session = runtime
1810            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
1811            .await
1812            .unwrap();
1813
1814        assert_eq!(session.memory_hits, 0);
1815    }
1816
1817    #[tokio::test]
1818    async fn test_run_agent_memory_hits_counts_recalled_items() {
1819        let store = EpisodicStore::new();
1820        let agent = AgentId::new("mem-agent");
1821        store
1822            .add_episode(agent.clone(), "remembered fact", 0.8)
1823            .unwrap();
1824
1825        let runtime = AgentRuntime::builder()
1826            .with_agent_config(simple_config())
1827            .with_memory(store)
1828            .build();
1829
1830        let session = runtime
1831            .run_agent(agent, "prompt", final_answer_infer)
1832            .await
1833            .unwrap();
1834
1835        assert_eq!(session.memory_hits, 1);
1836    }
1837
1838    #[tokio::test]
1839    async fn test_run_agent_graph_lookups_counts_entities() {
1840        let graph = GraphStore::new();
1841        graph.add_entity(Entity::new("e1", "Node")).unwrap();
1842        graph.add_entity(Entity::new("e2", "Node")).unwrap();
1843
1844        let runtime = AgentRuntime::builder()
1845            .with_agent_config(simple_config())
1846            .with_graph(graph)
1847            .build();
1848
1849        let session = runtime
1850            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
1851            .await
1852            .unwrap();
1853
1854        assert_eq!(session.graph_lookups, 2);
1855    }
1856
1857    #[tokio::test]
1858    async fn test_run_agent_backpressure_released_after_run() {
1859        let guard = BackpressureGuard::new(3).unwrap();
1860
1861        let runtime = AgentRuntime::builder()
1862            .with_agent_config(simple_config())
1863            .with_backpressure(guard.clone())
1864            .build();
1865
1866        runtime
1867            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
1868            .await
1869            .unwrap();
1870
1871        assert_eq!(guard.depth().unwrap(), 0);
1872    }
1873
1874    #[tokio::test]
1875    async fn test_run_agent_backpressure_sheds_when_full() {
1876        let guard = BackpressureGuard::new(1).unwrap();
1877        guard.try_acquire().unwrap(); // pre-fill
1878
1879        let runtime = AgentRuntime::builder()
1880            .with_agent_config(simple_config())
1881            .with_backpressure(guard)
1882            .build();
1883
1884        let result = runtime
1885            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
1886            .await;
1887        assert!(matches!(
1888            result,
1889            Err(AgentRuntimeError::BackpressureShed { .. })
1890        ));
1891    }
1892
1893    #[tokio::test]
1894    async fn test_run_agent_max_iterations_error_propagated() {
1895        let cfg = AgentConfig::new(2, "model");
1896        let runtime = AgentRuntime::builder().with_agent_config(cfg).build();
1897
1898        // Simulate an infer fn that always produces FINAL_ANSWER immediately
1899        let result = runtime
1900            .run_agent(AgentId::new("a"), "prompt", |_ctx: String| async {
1901                "Thought: looping\nAction: FINAL_ANSWER done".to_string()
1902            })
1903            .await;
1904        assert!(result.is_ok()); // final answer on first call, ok
1905    }
1906
1907    #[tokio::test]
1908    async fn test_agent_session_step_count_matches_steps() {
1909        let session = AgentSession {
1910            session_id: "test-session-id".into(),
1911            agent_id: AgentId::new("a"),
1912            steps: vec![
1913                ReActStep {
1914                    thought: "t".into(),
1915                    action: "a".into(),
1916                    observation: "o".into(),
1917                    step_duration_ms: 0,
1918                },
1919                ReActStep {
1920                    thought: "t2".into(),
1921                    action: "FINAL_ANSWER".into(),
1922                    observation: "done".into(),
1923                    step_duration_ms: 0,
1924                },
1925            ],
1926            memory_hits: 0,
1927            graph_lookups: 0,
1928            duration_ms: 10,
1929            checkpoint_errors: vec![],
1930        };
1931        assert_eq!(session.step_count(), 2);
1932    }
1933
1934    // ── Accessor methods ──────────────────────────────────────────────────────
1935
1936    #[tokio::test]
1937    async fn test_runtime_memory_accessor_returns_none_when_not_configured() {
1938        let runtime = AgentRuntime::builder()
1939            .with_agent_config(simple_config())
1940            .build();
1941        assert!(runtime.memory().is_none());
1942    }
1943
1944    #[tokio::test]
1945    async fn test_runtime_memory_accessor_returns_some_when_configured() {
1946        let runtime = AgentRuntime::builder()
1947            .with_agent_config(simple_config())
1948            .with_memory(EpisodicStore::new())
1949            .build();
1950        assert!(runtime.memory().is_some());
1951    }
1952
1953    #[tokio::test]
1954    async fn test_runtime_graph_accessor_returns_none_when_not_configured() {
1955        let runtime = AgentRuntime::builder()
1956            .with_agent_config(simple_config())
1957            .build();
1958        assert!(runtime.graph().is_none());
1959    }
1960
1961    #[tokio::test]
1962    async fn test_runtime_graph_accessor_returns_some_when_configured() {
1963        let runtime = AgentRuntime::builder()
1964            .with_agent_config(simple_config())
1965            .with_graph(GraphStore::new())
1966            .build();
1967        assert!(runtime.graph().is_some());
1968    }
1969
1970    #[tokio::test]
1971    async fn test_runtime_working_memory_accessor() {
1972        let runtime = AgentRuntime::builder()
1973            .with_agent_config(simple_config())
1974            .with_working_memory(WorkingMemory::new(5).unwrap())
1975            .build();
1976        assert!(runtime.working_memory().is_some());
1977    }
1978
1979    #[tokio::test]
1980    async fn test_runtime_with_tool_registered() {
1981        let runtime = AgentRuntime::builder()
1982            .with_agent_config(simple_config())
1983            .register_tool(ToolSpec::new("calc", "math", |_| serde_json::json!(99)))
1984            .build();
1985
1986        let mut call_count = 0;
1987        let session = runtime
1988            .run_agent(AgentId::new("a"), "compute", move |_ctx: String| {
1989                call_count += 1;
1990                let count = call_count;
1991                async move {
1992                    if count == 1 {
1993                        "Thought: use calc\nAction: calc {}".into()
1994                    } else {
1995                        "Thought: done\nAction: FINAL_ANSWER result".into()
1996                    }
1997                }
1998            })
1999            .await
2000            .unwrap();
2001
2002        assert!(session.step_count() >= 1);
2003    }
2004
2005    #[tokio::test]
2006    async fn test_run_agent_with_graph_relationship_lookup() {
2007        let graph = GraphStore::new();
2008        graph.add_entity(Entity::new("a", "X")).unwrap();
2009        graph.add_entity(Entity::new("b", "Y")).unwrap();
2010        graph
2011            .add_relationship(Relationship::new("a", "b", "LINKS", 1.0))
2012            .unwrap();
2013
2014        let runtime = AgentRuntime::builder()
2015            .with_agent_config(simple_config())
2016            .with_graph(graph)
2017            .build();
2018
2019        let session = runtime
2020            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
2021            .await
2022            .unwrap();
2023
2024        assert_eq!(session.graph_lookups, 2); // 2 entities
2025    }
2026
2027    // ── Metrics ───────────────────────────────────────────────────────────────
2028
2029    #[tokio::test]
2030    async fn test_metrics_active_sessions_decrements_after_run() {
2031        let runtime = AgentRuntime::builder()
2032            .with_agent_config(simple_config())
2033            .build();
2034
2035        runtime
2036            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
2037            .await
2038            .unwrap();
2039
2040        assert_eq!(runtime.metrics().active_sessions(), 0);
2041    }
2042
2043    #[tokio::test]
2044    async fn test_metrics_total_sessions_increments() {
2045        let runtime = AgentRuntime::builder()
2046            .with_agent_config(simple_config())
2047            .build();
2048
2049        runtime
2050            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
2051            .await
2052            .unwrap();
2053        runtime
2054            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
2055            .await
2056            .unwrap();
2057
2058        assert_eq!(runtime.metrics().total_sessions(), 2);
2059    }
2060
2061    #[tokio::test]
2062    async fn test_metrics_backpressure_shed_increments_on_shed() {
2063        let guard = BackpressureGuard::new(1).unwrap();
2064        guard.try_acquire().unwrap(); // pre-fill
2065
2066        let runtime = AgentRuntime::builder()
2067            .with_agent_config(simple_config())
2068            .with_backpressure(guard)
2069            .build();
2070
2071        let _ = runtime
2072            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
2073            .await;
2074
2075        assert_eq!(runtime.metrics().backpressure_shed_count(), 1);
2076    }
2077
2078    #[tokio::test]
2079    async fn test_metrics_memory_recall_count_increments() {
2080        let store = EpisodicStore::new();
2081        let agent = AgentId::new("a");
2082        store.add_episode(agent.clone(), "fact", 0.9).unwrap();
2083
2084        let runtime = AgentRuntime::builder()
2085            .with_agent_config(simple_config())
2086            .with_memory(store)
2087            .build();
2088
2089        runtime
2090            .run_agent(agent, "prompt", final_answer_infer)
2091            .await
2092            .unwrap();
2093
2094        assert_eq!(runtime.metrics().memory_recall_count(), 1);
2095    }
2096
2097    // ── Memory token budgeting ────────────────────────────────────────────────
2098
2099    #[tokio::test]
2100    async fn test_agent_config_max_memory_tokens_limits_injection() {
2101        let store = EpisodicStore::new();
2102        let agent = AgentId::new("budget-agent");
2103        // Each memory has ~100 chars → ~25 tokens each
2104        for i in 0..5 {
2105            let content = format!("{:0>100}", i); // 100-char string
2106            store.add_episode(agent.clone(), content, 0.9).unwrap();
2107        }
2108
2109        // Token budget of 10 allows at most ~1 memory (each is ~25 tokens).
2110        let cfg = AgentConfig::new(5, "test").with_max_memory_tokens(10);
2111        let runtime = AgentRuntime::builder()
2112            .with_agent_config(cfg)
2113            .with_memory(store)
2114            .build();
2115
2116        let session = runtime
2117            .run_agent(agent, "prompt", final_answer_infer)
2118            .await
2119            .unwrap();
2120
2121        assert!(
2122            session.memory_hits <= 1,
2123            "expected at most 1 memory hit with tight token budget, got {}",
2124            session.memory_hits
2125        );
2126    }
2127
2128    // ── Working memory injection ──────────────────────────────────────────────
2129
2130    #[tokio::test]
2131    async fn test_working_memory_injected_into_prompt() {
2132        let wm = WorkingMemory::new(10).unwrap();
2133        wm.set("task", "write tests").unwrap();
2134        wm.set("status", "in progress").unwrap();
2135
2136        let runtime = AgentRuntime::builder()
2137            .with_agent_config(simple_config())
2138            .with_working_memory(wm)
2139            .build();
2140
2141        let mut captured_ctx: Option<String> = None;
2142        let captured_ref = &mut captured_ctx;
2143
2144        runtime
2145            .run_agent(AgentId::new("a"), "do stuff", |ctx: String| {
2146                *captured_ref = Some(ctx.clone());
2147                async move { "Thought: done\nAction: FINAL_ANSWER ok".to_string() }
2148            })
2149            .await
2150            .unwrap();
2151
2152        let ctx = captured_ctx.expect("infer should have been called");
2153        assert!(
2154            ctx.contains("Current working state:"),
2155            "expected working memory injection in context, got: {ctx}"
2156        );
2157        assert!(ctx.contains("task: write tests"));
2158        assert!(ctx.contains("status: in progress"));
2159    }
2160
2161    // ── Task 15: Token budget edge case tests ─────────────────────────────────
2162
2163    #[tokio::test]
2164    async fn test_token_budget_zero_returns_no_memories() {
2165        // A budget of 0 should result in no memories being injected.
2166        let store = EpisodicStore::new();
2167        let agent = AgentId::new("budget-agent");
2168        store.add_episode(agent.clone(), "short", 0.9).unwrap();
2169
2170        let mut config = AgentConfig::new(5, "test-model");
2171        config.max_memory_tokens = Some(0);
2172        config.max_memory_recalls = 10;
2173
2174        let runtime = AgentRuntime::builder()
2175            .with_memory(store)
2176            .with_agent_config(config)
2177            .build();
2178
2179        let steps = runtime
2180            .run_agent(
2181                agent,
2182                "test",
2183                |_ctx| async { "Thought: ok\nAction: FINAL_ANSWER done".to_string() },
2184            )
2185            .await
2186            .unwrap();
2187
2188        // The run should succeed; we just verify it doesn't panic or error.
2189        assert_eq!(steps.steps.len(), 1);
2190    }
2191
2192    #[tokio::test]
2193    async fn test_token_budget_smaller_than_smallest_item_returns_no_memories() {
2194        let store = EpisodicStore::new();
2195        let agent = AgentId::new("budget-agent2");
2196        // Content is 40 chars → ~10 tokens (40/4). Budget = 1 → none fit.
2197        store
2198            .add_episode(agent.clone(), "a".repeat(40), 0.9)
2199            .unwrap();
2200
2201        let mut config = AgentConfig::new(5, "test-model");
2202        config.max_memory_tokens = Some(1);
2203        config.max_memory_recalls = 10;
2204
2205        let runtime = AgentRuntime::builder()
2206            .with_memory(store)
2207            .with_agent_config(config)
2208            .build();
2209
2210        let session = runtime
2211            .run_agent(
2212                agent,
2213                "test",
2214                |_ctx| async { "Thought: ok\nAction: FINAL_ANSWER done".to_string() },
2215            )
2216            .await
2217            .unwrap();
2218
2219        assert_eq!(session.memory_hits, 0);
2220    }
2221
2222    // ── Improvement 8: AgentRuntime::quick() ──────────────────────────────────
2223
2224    #[tokio::test]
2225    async fn test_agent_runtime_quick_runs_agent() {
2226        let runtime = AgentRuntime::quick(5, "test-model");
2227        let agent = AgentId::new("quick-agent");
2228        let session = runtime
2229            .run_agent(agent, "hello", |_ctx| async {
2230                "Thought: done\nAction: FINAL_ANSWER ok".to_string()
2231            })
2232            .await
2233            .unwrap();
2234        assert_eq!(session.step_count(), 1);
2235    }
2236
2237    // ── #1 final_answer() ─────────────────────────────────────────────────────
2238
2239    #[test]
2240    fn test_final_answer_extracts_text() {
2241        let session = AgentSession {
2242            session_id: "s".into(),
2243            agent_id: AgentId::new("a"),
2244            steps: vec![ReActStep {
2245                thought: "done".into(),
2246                action: "FINAL_ANSWER Paris".into(),
2247                observation: "".into(),
2248                step_duration_ms: 0,
2249            }],
2250            memory_hits: 0,
2251            graph_lookups: 0,
2252            duration_ms: 0,
2253            checkpoint_errors: vec![],
2254        };
2255        assert_eq!(session.final_answer(), Some("Paris".to_string()));
2256    }
2257
2258    #[test]
2259    fn test_final_answer_returns_none_without_final_step() {
2260        let session = AgentSession {
2261            session_id: "s".into(),
2262            agent_id: AgentId::new("a"),
2263            steps: vec![ReActStep {
2264                thought: "thinking".into(),
2265                action: "search {}".into(),
2266                observation: "result".into(),
2267                step_duration_ms: 0,
2268            }],
2269            memory_hits: 0,
2270            graph_lookups: 0,
2271            duration_ms: 0,
2272            checkpoint_errors: vec![],
2273        };
2274        assert_eq!(session.final_answer(), None);
2275
2276        let empty_session = AgentSession {
2277            session_id: "s2".into(),
2278            agent_id: AgentId::new("a"),
2279            steps: vec![],
2280            memory_hits: 0,
2281            graph_lookups: 0,
2282            duration_ms: 0,
2283            checkpoint_errors: vec![],
2284        };
2285        assert_eq!(empty_session.final_answer(), None);
2286    }
2287
2288    #[test]
2289    fn test_all_actions_returns_actions_in_order() {
2290        let session = AgentSession {
2291            session_id: "s".into(),
2292            agent_id: AgentId::new("a"),
2293            steps: vec![
2294                ReActStep::new("think1", "search {}", "result"),
2295                ReActStep::new("think2", "FINAL_ANSWER done", ""),
2296            ],
2297            memory_hits: 0,
2298            graph_lookups: 0,
2299            duration_ms: 10,
2300            checkpoint_errors: vec![],
2301        };
2302        assert_eq!(session.all_actions(), vec!["search {}", "FINAL_ANSWER done"]);
2303    }
2304
2305    #[test]
2306    fn test_has_checkpoint_errors_false_when_empty() {
2307        let session = AgentSession {
2308            session_id: "s".into(),
2309            agent_id: AgentId::new("a"),
2310            steps: vec![],
2311            memory_hits: 0,
2312            graph_lookups: 0,
2313            duration_ms: 0,
2314            checkpoint_errors: vec![],
2315        };
2316        assert!(!session.has_checkpoint_errors());
2317    }
2318
2319    #[test]
2320    fn test_has_checkpoint_errors_true_when_non_empty() {
2321        let session = AgentSession {
2322            session_id: "s".into(),
2323            agent_id: AgentId::new("a"),
2324            steps: vec![],
2325            memory_hits: 0,
2326            graph_lookups: 0,
2327            duration_ms: 0,
2328            checkpoint_errors: vec!["err".into()],
2329        };
2330        assert!(session.has_checkpoint_errors());
2331    }
2332
2333    #[test]
2334    fn test_memory_hit_rate_zero_with_no_steps() {
2335        let session = AgentSession {
2336            session_id: "s".into(),
2337            agent_id: AgentId::new("a"),
2338            steps: vec![],
2339            memory_hits: 5,
2340            graph_lookups: 0,
2341            duration_ms: 0,
2342            checkpoint_errors: vec![],
2343        };
2344        assert_eq!(session.memory_hit_rate(), 0.0);
2345    }
2346
2347    #[test]
2348    fn test_memory_hit_rate_correct_proportion() {
2349        let session = AgentSession {
2350            session_id: "s".into(),
2351            agent_id: AgentId::new("a"),
2352            steps: vec![
2353                ReActStep::new("t", "a", "o"),
2354                ReActStep::new("t", "a", "o"),
2355                ReActStep::new("t", "a", "o"),
2356                ReActStep::new("t", "a", "o"),
2357            ],
2358            memory_hits: 2,
2359            graph_lookups: 0,
2360            duration_ms: 0,
2361            checkpoint_errors: vec![],
2362        };
2363        assert!((session.memory_hit_rate() - 0.5).abs() < 1e-9);
2364    }
2365
2366    #[test]
2367    fn test_filter_tool_call_steps_excludes_final_answer() {
2368        let session = AgentSession {
2369            session_id: "s".into(),
2370            agent_id: AgentId::new("a"),
2371            steps: vec![
2372                ReActStep::new("t1", "search {}", "res"),
2373                ReActStep::new("t2", "FINAL_ANSWER done", ""),
2374            ],
2375            memory_hits: 0,
2376            graph_lookups: 0,
2377            duration_ms: 0,
2378            checkpoint_errors: vec![],
2379        };
2380        let tool_steps = session.filter_tool_call_steps();
2381        assert_eq!(tool_steps.len(), 1);
2382        assert_eq!(tool_steps[0].action, "search {}");
2383    }
2384
2385    #[test]
2386    fn test_slowest_step_index() {
2387        let mut s0 = ReActStep::new("t", "a", "o");
2388        s0.step_duration_ms = 5;
2389        let mut s1 = ReActStep::new("t", "a", "o");
2390        s1.step_duration_ms = 100;
2391        let mut s2 = ReActStep::new("t", "a", "o");
2392        s2.step_duration_ms = 10;
2393        let session = AgentSession {
2394            session_id: "s".into(),
2395            agent_id: AgentId::new("a"),
2396            steps: vec![s0, s1, s2],
2397            memory_hits: 0,
2398            graph_lookups: 0,
2399            duration_ms: 0,
2400            checkpoint_errors: vec![],
2401        };
2402        assert_eq!(session.slowest_step_index(), Some(1));
2403        assert_eq!(session.fastest_step_index(), Some(0));
2404    }
2405
2406    #[test]
2407    fn test_slowest_step_index_none_when_empty() {
2408        let session = AgentSession {
2409            session_id: "s".into(),
2410            agent_id: AgentId::new("a"),
2411            steps: vec![],
2412            memory_hits: 0,
2413            graph_lookups: 0,
2414            duration_ms: 0,
2415            checkpoint_errors: vec![],
2416        };
2417        assert_eq!(session.slowest_step_index(), None);
2418        assert_eq!(session.fastest_step_index(), None);
2419    }
2420
2421    #[test]
2422    fn test_last_step_returns_last() {
2423        let session = AgentSession {
2424            session_id: "s".into(),
2425            agent_id: AgentId::new("a"),
2426            steps: vec![
2427                ReActStep::new("t1", "a1", "o1"),
2428                ReActStep::new("t2", "FINAL_ANSWER done", ""),
2429            ],
2430            memory_hits: 0,
2431            graph_lookups: 0,
2432            duration_ms: 0,
2433            checkpoint_errors: vec![],
2434        };
2435        assert_eq!(session.last_step().map(|s| s.action.as_str()), Some("FINAL_ANSWER done"));
2436    }
2437
2438    #[test]
2439    fn test_last_step_none_when_empty() {
2440        let session = AgentSession {
2441            session_id: "s".into(),
2442            agent_id: AgentId::new("a"),
2443            steps: vec![],
2444            memory_hits: 0,
2445            graph_lookups: 0,
2446            duration_ms: 0,
2447            checkpoint_errors: vec![],
2448        };
2449        assert!(session.last_step().is_none());
2450    }
2451
2452    #[test]
2453    fn test_step_at_returns_correct_step() {
2454        let session = AgentSession {
2455            session_id: "s".into(),
2456            agent_id: AgentId::new("a"),
2457            steps: vec![
2458                ReActStep::new("t0", "a0", "o0"),
2459                ReActStep::new("t1", "a1", "o1"),
2460            ],
2461            memory_hits: 0,
2462            graph_lookups: 0,
2463            duration_ms: 0,
2464            checkpoint_errors: vec![],
2465        };
2466        assert_eq!(session.step_at(1).map(|s| s.thought.as_str()), Some("t1"));
2467        assert!(session.step_at(99).is_none());
2468    }
2469
2470    // ── Round 3: failed_steps ─────────────────────────────────────────────────
2471
2472    #[test]
2473    fn test_failed_steps_returns_steps_with_error_observation() {
2474        use crate::agent::ReActStep;
2475        let session = AgentSession {
2476            session_id: "s".into(),
2477            agent_id: AgentId::new("a"),
2478            steps: vec![
2479                ReActStep::new("t", "tool_a {}", r#"{"error":"bad input","ok":false}"#),
2480                ReActStep::new("t", "tool_b {}", r#"{"result":"ok","ok":true}"#),
2481            ],
2482            memory_hits: 0,
2483            graph_lookups: 0,
2484            duration_ms: 0,
2485            checkpoint_errors: vec![],
2486        };
2487        let failed = session.failed_steps();
2488        assert_eq!(failed.len(), 1);
2489        assert!(failed[0].observation.contains("bad input"));
2490    }
2491
2492    #[test]
2493    fn test_failed_steps_empty_when_no_errors() {
2494        use crate::agent::ReActStep;
2495        let session = AgentSession {
2496            session_id: "s".into(),
2497            agent_id: AgentId::new("a"),
2498            steps: vec![ReActStep::new("t", "FINAL_ANSWER done", "")],
2499            memory_hits: 0,
2500            graph_lookups: 0,
2501            duration_ms: 0,
2502            checkpoint_errors: vec![],
2503        };
2504        assert!(session.failed_steps().is_empty());
2505    }
2506
2507    // ── Round 17: untested AgentSession methods ───────────────────────────────
2508
2509    fn make_step(thought: &str, action: &str, observation: &str) -> ReActStep {
2510        ReActStep::new(thought, action, observation)
2511    }
2512
2513    fn make_session(steps: Vec<ReActStep>, duration_ms: u64) -> AgentSession {
2514        AgentSession {
2515            session_id: "s".into(),
2516            agent_id: AgentId::new("a"),
2517            steps,
2518            memory_hits: 0,
2519            graph_lookups: 0,
2520            duration_ms,
2521            checkpoint_errors: vec![],
2522        }
2523    }
2524
2525    #[test]
2526    fn test_step_count_returns_number_of_steps() {
2527        let s = make_session(vec![ReActStep::new("t", "a", "o"), ReActStep::new("t", "a", "o")], 0);
2528        assert_eq!(s.step_count(), 2);
2529    }
2530
2531    #[test]
2532    fn test_is_empty_true_for_no_steps() {
2533        let s = make_session(vec![], 0);
2534        assert!(s.is_empty());
2535    }
2536
2537    #[test]
2538    fn test_is_empty_false_with_steps() {
2539        let s = make_session(vec![ReActStep::new("t", "a", "o")], 0);
2540        assert!(!s.is_empty());
2541    }
2542
2543    #[test]
2544    fn test_is_successful_true_with_final_answer() {
2545        let s = make_session(vec![ReActStep::new("t", "FINAL_ANSWER yes", "")], 0);
2546        assert!(s.is_successful());
2547    }
2548
2549    #[test]
2550    fn test_is_successful_false_without_final_answer() {
2551        let s = make_session(vec![ReActStep::new("t", "search {}", "result")], 0);
2552        assert!(!s.is_successful());
2553    }
2554
2555    #[test]
2556    fn test_elapsed_returns_duration_from_duration_ms() {
2557        let s = make_session(vec![], 500);
2558        assert_eq!(s.elapsed(), std::time::Duration::from_millis(500));
2559    }
2560
2561    #[test]
2562    fn test_tool_calls_made_excludes_final_answer() {
2563        let s = make_session(vec![
2564            ReActStep::new("t", "search {}", "res"),
2565            ReActStep::new("t", "lookup {}", "res"),
2566            ReActStep::new("t", "FINAL_ANSWER done", ""),
2567        ], 0);
2568        assert_eq!(s.tool_calls_made(), 2);
2569    }
2570
2571    #[test]
2572    fn test_total_step_duration_ms_sums_all_steps() {
2573        let mut s1 = ReActStep::new("t", "a", "o"); s1.step_duration_ms = 10;
2574        let mut s2 = ReActStep::new("t", "a", "o"); s2.step_duration_ms = 30;
2575        let s = make_session(vec![s1, s2], 0);
2576        assert_eq!(s.total_step_duration_ms(), 40);
2577    }
2578
2579    #[test]
2580    fn test_average_step_duration_ms() {
2581        let mut s1 = ReActStep::new("t", "a", "o"); s1.step_duration_ms = 20;
2582        let mut s2 = ReActStep::new("t", "a", "o"); s2.step_duration_ms = 40;
2583        let s = make_session(vec![s1, s2], 0);
2584        assert_eq!(s.average_step_duration_ms(), 30);
2585    }
2586
2587    #[test]
2588    fn test_all_thoughts_returns_thoughts_in_order() {
2589        let s = make_session(vec![
2590            ReActStep::new("first thought", "a1", "o1"),
2591            ReActStep::new("second thought", "a2", "o2"),
2592        ], 0);
2593        assert_eq!(s.all_thoughts(), vec!["first thought", "second thought"]);
2594    }
2595
2596    #[test]
2597    fn test_all_observations_returns_observations_in_order() {
2598        let s = make_session(vec![
2599            ReActStep::new("t1", "a1", "obs one"),
2600            ReActStep::new("t2", "a2", "obs two"),
2601        ], 0);
2602        assert_eq!(s.all_observations(), vec!["obs one", "obs two"]);
2603    }
2604
2605    #[test]
2606    fn test_observations_matching_finds_matching_steps() {
2607        let s = make_session(vec![
2608            ReActStep::new("t1", "a1", "found the answer"),
2609            ReActStep::new("t2", "a2", "nothing relevant"),
2610        ], 0);
2611        let matching = s.observations_matching("answer");
2612        assert_eq!(matching.len(), 1);
2613        assert!(matching[0].observation.contains("answer"));
2614    }
2615
2616    #[test]
2617    fn test_first_step_returns_first() {
2618        let s = make_session(vec![
2619            ReActStep::new("first", "a1", "o1"),
2620            ReActStep::new("second", "a2", "o2"),
2621        ], 0);
2622        assert_eq!(s.first_step().map(|s| s.thought.as_str()), Some("first"));
2623    }
2624
2625    #[test]
2626    fn test_first_step_none_when_empty() {
2627        let s = make_session(vec![], 0);
2628        assert!(s.first_step().is_none());
2629    }
2630
2631    // ── Round 18: graph_lookup_count ─────────────────────────────────────────
2632
2633    #[test]
2634    fn test_graph_lookup_count_returns_field() {
2635        let session = AgentSession {
2636            session_id: "s".into(),
2637            agent_id: AgentId::new("a"),
2638            steps: vec![],
2639            memory_hits: 0,
2640            graph_lookups: 7,
2641            duration_ms: 0,
2642            checkpoint_errors: vec![],
2643        };
2644        assert_eq!(session.graph_lookup_count(), 7usize);
2645    }
2646
2647    // ── Round 7: action_counts / unique_actions ───────────────────────────────
2648
2649    #[test]
2650    fn test_action_counts_counts_each_action() {
2651        let session = make_session(
2652            vec![
2653                ReActStep::new("t1", "search", "r1"),
2654                ReActStep::new("t2", "search", "r2"),
2655                ReActStep::new("t3", "FINAL_ANSWER", "done"),
2656            ],
2657            0,
2658        );
2659        let counts = session.action_counts();
2660        assert_eq!(counts.get("search").copied().unwrap_or(0), 2);
2661        assert_eq!(counts.get("FINAL_ANSWER").copied().unwrap_or(0), 1);
2662    }
2663
2664    #[test]
2665    fn test_unique_actions_returns_sorted_deduped() {
2666        let session = make_session(
2667            vec![
2668                ReActStep::new("t", "b_action", "r"),
2669                ReActStep::new("t", "a_action", "r"),
2670                ReActStep::new("t", "b_action", "r"),
2671            ],
2672            0,
2673        );
2674        assert_eq!(session.unique_actions(), vec!["a_action", "b_action"]);
2675    }
2676
2677    #[test]
2678    fn test_unique_actions_empty_when_no_steps() {
2679        let session = make_session(vec![], 0);
2680        assert!(session.unique_actions().is_empty());
2681    }
2682
2683    // ── Round 8: total_latency_ms / action_sequence ───────────────────────────
2684
2685    #[test]
2686    fn test_total_latency_ms_sums_step_durations() {
2687        let mut steps = vec![
2688            ReActStep::new("t1", "a1", "o1"),
2689            ReActStep::new("t2", "a2", "o2"),
2690        ];
2691        steps[0].step_duration_ms = 100;
2692        steps[1].step_duration_ms = 250;
2693        let session = make_session(steps, 350);
2694        assert_eq!(session.total_latency_ms(), 350);
2695    }
2696
2697    #[test]
2698    fn test_total_latency_ms_zero_for_empty_session() {
2699        let session = make_session(vec![], 0);
2700        assert_eq!(session.total_latency_ms(), 0);
2701    }
2702
2703    #[test]
2704    fn test_action_sequence_returns_actions_in_order() {
2705        let session = make_session(
2706            vec![
2707                ReActStep::new("t", "search", "r"),
2708                ReActStep::new("t", "FINAL_ANSWER", "done"),
2709            ],
2710            0,
2711        );
2712        assert_eq!(session.action_sequence(), vec!["search", "FINAL_ANSWER"]);
2713    }
2714
2715    // ── Round 9: has_action / thought_at ─────────────────────────────────────
2716
2717    #[test]
2718    fn test_has_action_returns_true_for_present_action() {
2719        let session = make_session(
2720            vec![ReActStep::new("t", "search", "r")],
2721            0,
2722        );
2723        assert!(session.has_action("search"));
2724    }
2725
2726    #[test]
2727    fn test_has_action_returns_false_for_absent_action() {
2728        let session = make_session(
2729            vec![ReActStep::new("t", "search", "r")],
2730            0,
2731        );
2732        assert!(!session.has_action("compute"));
2733    }
2734
2735    #[test]
2736    fn test_thought_at_returns_thought_for_valid_index() {
2737        let session = make_session(
2738            vec![
2739                ReActStep::new("first thought", "a1", "r1"),
2740                ReActStep::new("second thought", "a2", "r2"),
2741            ],
2742            0,
2743        );
2744        assert_eq!(session.thought_at(0), Some("first thought"));
2745        assert_eq!(session.thought_at(1), Some("second thought"));
2746    }
2747
2748    #[test]
2749    fn test_thought_at_returns_none_for_out_of_bounds_index() {
2750        let session = make_session(vec![ReActStep::new("t", "a", "r")], 0);
2751        assert!(session.thought_at(99).is_none());
2752    }
2753
2754    // ── Round 10: step_count_for_action / observations ────────────────────────
2755
2756    #[test]
2757    fn test_step_count_for_action_counts_correctly() {
2758        let session = make_session(
2759            vec![
2760                ReActStep::new("t", "search", "r1"),
2761                ReActStep::new("t", "search", "r2"),
2762                ReActStep::new("t", "FINAL_ANSWER", "done"),
2763            ],
2764            0,
2765        );
2766        assert_eq!(session.step_count_for_action("search"), 2);
2767        assert_eq!(session.step_count_for_action("FINAL_ANSWER"), 1);
2768        assert_eq!(session.step_count_for_action("unknown"), 0);
2769    }
2770
2771    #[test]
2772    fn test_observations_returns_all_observation_strings() {
2773        let session = make_session(
2774            vec![
2775                ReActStep::new("t1", "a", "obs_one"),
2776                ReActStep::new("t2", "b", "obs_two"),
2777            ],
2778            0,
2779        );
2780        let obs = session.observations();
2781        assert_eq!(obs, vec!["obs_one", "obs_two"]);
2782    }
2783
2784    #[test]
2785    fn test_observations_empty_for_no_steps() {
2786        let session = make_session(vec![], 0);
2787        assert!(session.observations().is_empty());
2788    }
2789
2790    // ── Round 10: unique_tools_used ──────────────────────────────────────────
2791
2792    #[test]
2793    fn test_unique_tools_used_deduplicates_actions() {
2794        let session = make_session(
2795            vec![
2796                ReActStep::new("t", "search", "r1"),
2797                ReActStep::new("t", "lookup", "r2"),
2798                ReActStep::new("t", "search", "r3"),
2799            ],
2800            0,
2801        );
2802        let tools = session.unique_tools_used();
2803        assert_eq!(tools.len(), 2);
2804        assert!(tools.contains(&"search".to_string()));
2805        assert!(tools.contains(&"lookup".to_string()));
2806    }
2807
2808    #[test]
2809    fn test_unique_tools_used_excludes_final_answer() {
2810        let session = make_session(
2811            vec![
2812                ReActStep::new("t", "search", "r1"),
2813                ReActStep::new("t", "FINAL_ANSWER: done", "r2"),
2814            ],
2815            0,
2816        );
2817        let tools = session.unique_tools_used();
2818        assert_eq!(tools.len(), 1);
2819        assert!(tools.contains(&"search".to_string()));
2820    }
2821
2822    #[test]
2823    fn test_unique_tools_used_empty_for_no_steps() {
2824        let session = make_session(vec![], 0);
2825        assert!(session.unique_tools_used().is_empty());
2826    }
2827
2828    // ── Round 11: avg_step_duration_ms / longest_step / shortest_step ─────────
2829
2830    #[test]
2831    fn test_avg_step_duration_zero_for_empty_session() {
2832        let session = make_session(vec![], 0);
2833        assert!((session.avg_step_duration_ms() - 0.0).abs() < 1e-9);
2834    }
2835
2836    #[test]
2837    fn test_avg_step_duration_single_step() {
2838        let mut step = ReActStep::new("t", "a", "r");
2839        step.step_duration_ms = 100;
2840        let session = make_session(vec![step], 0);
2841        assert!((session.avg_step_duration_ms() - 100.0).abs() < 1e-9);
2842    }
2843
2844    #[test]
2845    fn test_avg_step_duration_multiple_steps() {
2846        let mut s1 = ReActStep::new("t1", "a", "r");
2847        s1.step_duration_ms = 100;
2848        let mut s2 = ReActStep::new("t2", "b", "r");
2849        s2.step_duration_ms = 200;
2850        let session = make_session(vec![s1, s2], 0);
2851        assert!((session.avg_step_duration_ms() - 150.0).abs() < 1e-9);
2852    }
2853
2854    #[test]
2855    fn test_longest_step_returns_step_with_max_duration() {
2856        let mut s1 = ReActStep::new("t1", "a", "r");
2857        s1.step_duration_ms = 50;
2858        let mut s2 = ReActStep::new("t2", "b", "r");
2859        s2.step_duration_ms = 200;
2860        let session = make_session(vec![s1, s2], 0);
2861        assert_eq!(session.longest_step().map(|s| s.step_duration_ms), Some(200));
2862    }
2863
2864    #[test]
2865    fn test_longest_step_returns_none_for_empty_session() {
2866        let session = make_session(vec![], 0);
2867        assert!(session.longest_step().is_none());
2868    }
2869
2870    #[test]
2871    fn test_shortest_step_returns_step_with_min_duration() {
2872        let mut s1 = ReActStep::new("t1", "a", "r");
2873        s1.step_duration_ms = 50;
2874        let mut s2 = ReActStep::new("t2", "b", "r");
2875        s2.step_duration_ms = 200;
2876        let session = make_session(vec![s1, s2], 0);
2877        assert_eq!(session.shortest_step().map(|s| s.step_duration_ms), Some(50));
2878    }
2879
2880    // ── Round 12: first_thought / last_thought ────────────────────────────────
2881
2882    #[test]
2883    fn test_first_thought_returns_thought_from_first_step() {
2884        let session = make_session(
2885            vec![
2886                ReActStep::new("alpha", "a1", "r1"),
2887                ReActStep::new("beta", "a2", "r2"),
2888            ],
2889            0,
2890        );
2891        assert_eq!(session.first_thought(), Some("alpha"));
2892    }
2893
2894    #[test]
2895    fn test_last_thought_returns_thought_from_last_step() {
2896        let session = make_session(
2897            vec![
2898                ReActStep::new("alpha", "a1", "r1"),
2899                ReActStep::new("beta", "a2", "r2"),
2900            ],
2901            0,
2902        );
2903        assert_eq!(session.last_thought(), Some("beta"));
2904    }
2905
2906    #[test]
2907    fn test_first_thought_none_for_empty_session() {
2908        let session = make_session(vec![], 0);
2909        assert!(session.first_thought().is_none());
2910    }
2911
2912    #[test]
2913    fn test_last_thought_none_for_empty_session() {
2914        let session = make_session(vec![], 0);
2915        assert!(session.last_thought().is_none());
2916    }
2917
2918    // ── Round 13: first_action / last_action ──────────────────────────────────
2919
2920    #[test]
2921    fn test_first_action_returns_action_from_first_step() {
2922        let session = make_session(
2923            vec![
2924                ReActStep::new("t1", "search", "r1"),
2925                ReActStep::new("t2", "FINAL_ANSWER", "r2"),
2926            ],
2927            0,
2928        );
2929        assert_eq!(session.first_action(), Some("search"));
2930    }
2931
2932    #[test]
2933    fn test_last_action_returns_action_from_last_step() {
2934        let session = make_session(
2935            vec![
2936                ReActStep::new("t1", "search", "r1"),
2937                ReActStep::new("t2", "FINAL_ANSWER", "r2"),
2938            ],
2939            0,
2940        );
2941        assert_eq!(session.last_action(), Some("FINAL_ANSWER"));
2942    }
2943
2944    #[test]
2945    fn test_first_action_none_for_empty_session() {
2946        let session = make_session(vec![], 0);
2947        assert!(session.first_action().is_none());
2948    }
2949
2950    #[test]
2951    fn test_last_action_equals_first_action_for_single_step() {
2952        let session = make_session(vec![ReActStep::new("t", "calc", "r")], 0);
2953        assert_eq!(session.first_action(), session.last_action());
2954    }
2955
2956    // ── Round 14: AgentSession::checkpoint_error_count ───────────────────────
2957
2958    #[test]
2959    fn test_checkpoint_error_count_zero_when_none() {
2960        let session = make_session(vec![], 0);
2961        assert_eq!(session.checkpoint_error_count(), 0);
2962    }
2963
2964    #[test]
2965    fn test_checkpoint_error_count_reflects_errors() {
2966        let mut session = make_session(vec![], 0);
2967        session.checkpoint_errors.push("save failed".into());
2968        session.checkpoint_errors.push("disk full".into());
2969        assert_eq!(session.checkpoint_error_count(), 2);
2970    }
2971
2972    // ── Round 27: failed_tool_call_count ─────────────────────────────────────
2973
2974    #[test]
2975    fn test_failed_tool_call_count_zero_when_no_errors() {
2976        let step = ReActStep::new("think", "search", "results found");
2977        let session = make_session(vec![step], 0);
2978        assert_eq!(session.failed_tool_call_count(), 0);
2979    }
2980
2981    #[test]
2982    fn test_failed_tool_call_count_matches_failed_steps() {
2983        let ok_step = ReActStep::new("ok", "search", "all good");
2984        let err_step = ReActStep::new("err", "lookup", "{\"error\": \"not found\"}");
2985        let session = make_session(vec![ok_step, err_step], 0);
2986        assert_eq!(session.failed_tool_call_count(), session.failed_steps().len());
2987        assert_eq!(session.failed_tool_call_count(), 1);
2988    }
2989
2990    #[test]
2991    fn test_failed_tool_call_count_counts_all_errors() {
2992        let err1 = ReActStep::new("e1", "a", "{\"error\": \"bad\"}");
2993        let err2 = ReActStep::new("e2", "b", "some \"error\" text");
2994        let ok = ReActStep::new("ok", "c", "success");
2995        let session = make_session(vec![err1, err2, ok], 0);
2996        assert_eq!(session.failed_tool_call_count(), 2);
2997    }
2998
2999    // ── Round 15: AgentSession::total_memory_hits / action_diversity ─────────
3000
3001    #[test]
3002    fn test_total_memory_hits_returns_memory_hits_field() {
3003        let mut session = make_session(vec![], 0);
3004        session.memory_hits = 7;
3005        assert_eq!(session.total_memory_hits(), 7);
3006    }
3007
3008    #[test]
3009    fn test_total_memory_hits_zero_by_default() {
3010        let session = make_session(vec![], 0);
3011        assert_eq!(session.total_memory_hits(), 0);
3012    }
3013
3014    #[test]
3015    fn test_action_diversity_all_unique_is_one() {
3016        let steps = vec![
3017            ReActStep::new("t", "search", "r"),
3018            ReActStep::new("t", "calc", "r"),
3019            ReActStep::new("t", "lookup", "r"),
3020        ];
3021        let session = make_session(steps, 0);
3022        assert!((session.action_diversity() - 1.0).abs() < 1e-9);
3023    }
3024
3025    #[test]
3026    fn test_action_diversity_all_same_is_fraction() {
3027        let steps = vec![
3028            ReActStep::new("t", "search", "r"),
3029            ReActStep::new("t", "search", "r"),
3030            ReActStep::new("t", "search", "r"),
3031        ];
3032        let session = make_session(steps, 0);
3033        // 1 unique / 3 total = 1/3
3034        assert!((session.action_diversity() - 1.0 / 3.0).abs() < 1e-9);
3035    }
3036
3037    #[test]
3038    fn test_action_diversity_zero_for_empty_session() {
3039        let session = make_session(vec![], 0);
3040        assert!((session.action_diversity() - 0.0).abs() < 1e-9);
3041    }
3042
3043    // ── Round 14: AgentSession::last_n_steps ──────────────────────────────────
3044
3045    #[test]
3046    fn test_last_n_steps_returns_last_n() {
3047        let steps = vec![
3048            ReActStep::new("t1", "a", "r1"),
3049            ReActStep::new("t2", "b", "r2"),
3050            ReActStep::new("t3", "c", "r3"),
3051        ];
3052        let session = make_session(steps, 0);
3053        let last2 = session.last_n_steps(2);
3054        assert_eq!(last2.len(), 2);
3055        assert_eq!(last2[0].action, "b");
3056        assert_eq!(last2[1].action, "c");
3057    }
3058
3059    #[test]
3060    fn test_last_n_steps_returns_all_when_n_exceeds_count() {
3061        let steps = vec![
3062            ReActStep::new("t1", "a", "r1"),
3063            ReActStep::new("t2", "b", "r2"),
3064        ];
3065        let session = make_session(steps, 0);
3066        assert_eq!(session.last_n_steps(10).len(), 2);
3067    }
3068
3069    #[test]
3070    fn test_last_n_steps_empty_for_no_steps() {
3071        let session = make_session(vec![], 0);
3072        assert!(session.last_n_steps(3).is_empty());
3073    }
3074
3075    #[test]
3076    fn test_last_n_steps_zero_returns_empty() {
3077        let steps = vec![ReActStep::new("t1", "a", "r1")];
3078        let session = make_session(steps, 0);
3079        assert!(session.last_n_steps(0).is_empty());
3080    }
3081
3082    // ── Round 16: observation_count / steps_without_observation ──────────────
3083
3084    #[test]
3085    fn test_observation_count_counts_non_empty() {
3086        let steps = vec![
3087            ReActStep::new("t", "a", "result"),
3088            ReActStep::new("t", "b", ""),
3089            ReActStep::new("t", "c", "data"),
3090        ];
3091        let session = make_session(steps, 0);
3092        assert_eq!(session.observation_count(), 2);
3093    }
3094
3095    #[test]
3096    fn test_observation_count_zero_when_all_empty() {
3097        let steps = vec![
3098            ReActStep::new("t", "a", ""),
3099            ReActStep::new("t", "b", ""),
3100        ];
3101        let session = make_session(steps, 0);
3102        assert_eq!(session.observation_count(), 0);
3103    }
3104
3105    #[test]
3106    fn test_steps_without_observation_counts_empty_obs() {
3107        let steps = vec![
3108            ReActStep::new("t", "a", ""),
3109            ReActStep::new("t", "b", "data"),
3110            ReActStep::new("t", "c", ""),
3111        ];
3112        let session = make_session(steps, 0);
3113        assert_eq!(session.steps_without_observation(), 2);
3114    }
3115
3116    #[test]
3117    fn test_steps_without_observation_zero_when_all_filled() {
3118        let steps = vec![
3119            ReActStep::new("t", "a", "r1"),
3120            ReActStep::new("t", "b", "r2"),
3121        ];
3122        let session = make_session(steps, 0);
3123        assert_eq!(session.steps_without_observation(), 0);
3124    }
3125
3126    // ── Round 17: AgentSession::throughput_steps_per_sec ─────────────────────
3127
3128    #[test]
3129    fn test_throughput_steps_per_sec_correct_ratio() {
3130        let steps = vec![
3131            ReActStep::new("t", "a", "r"),
3132            ReActStep::new("t", "b", "r"),
3133        ];
3134        // 2 steps in 1000ms = 2.0 steps/sec
3135        let session = make_session(steps, 1000);
3136        assert!((session.throughput_steps_per_sec() - 2.0).abs() < 1e-9);
3137    }
3138
3139    #[test]
3140    fn test_throughput_steps_per_sec_zero_when_no_duration() {
3141        let steps = vec![ReActStep::new("t", "a", "r")];
3142        let session = make_session(steps, 0);
3143        assert!((session.throughput_steps_per_sec() - 0.0).abs() < 1e-9);
3144    }
3145
3146    // ── Round 20: thoughts_containing, step_durations_ms, fastest_step_index ─
3147
3148    #[test]
3149    fn test_thoughts_containing_returns_matching_steps() {
3150        let session = make_session(
3151            vec![
3152                ReActStep::new("I need to search", "search", "found"),
3153                ReActStep::new("Let me calculate", "calc", "done"),
3154                ReActStep::new("search again", "search", "ok"),
3155            ],
3156            0,
3157        );
3158        let matches = session.thoughts_containing("search");
3159        assert_eq!(matches.len(), 2);
3160    }
3161
3162    #[test]
3163    fn test_thoughts_containing_is_case_insensitive() {
3164        let session = make_session(
3165            vec![ReActStep::new("SEARCH the web", "search", "r")],
3166            0,
3167        );
3168        assert_eq!(session.thoughts_containing("search").len(), 1);
3169    }
3170
3171    #[test]
3172    fn test_thoughts_containing_empty_when_no_match() {
3173        let session = make_session(vec![ReActStep::new("think about x", "a", "r")], 0);
3174        assert!(session.thoughts_containing("zebra").is_empty());
3175    }
3176
3177    #[test]
3178    fn test_step_durations_ms_returns_all_durations() {
3179        let mut steps = vec![
3180            ReActStep::new("t", "a", "r"),
3181            ReActStep::new("t", "b", "r"),
3182        ];
3183        steps[0].step_duration_ms = 100;
3184        steps[1].step_duration_ms = 200;
3185        let session = make_session(steps, 300);
3186        assert_eq!(session.step_durations_ms(), vec![100, 200]);
3187    }
3188
3189    #[test]
3190    fn test_fastest_step_index_returns_index_of_shortest_step() {
3191        let mut steps = vec![
3192            ReActStep::new("t", "a", "r"),
3193            ReActStep::new("t", "b", "r"),
3194            ReActStep::new("t", "c", "r"),
3195        ];
3196        steps[0].step_duration_ms = 300;
3197        steps[1].step_duration_ms = 50;
3198        steps[2].step_duration_ms = 200;
3199        let session = make_session(steps, 550);
3200        assert_eq!(session.fastest_step_index(), Some(1));
3201    }
3202
3203    #[test]
3204    fn test_fastest_step_index_none_for_empty_session() {
3205        let session = make_session(vec![], 0);
3206        assert!(session.fastest_step_index().is_none());
3207    }
3208
3209    // ── Round 18: most_used_action / graph_lookup_rate ────────────────────────
3210
3211    #[test]
3212    fn test_most_used_action_returns_most_frequent() {
3213        let steps = vec![
3214            ReActStep::new("t", "search", "r"),
3215            ReActStep::new("t", "calc", "r"),
3216            ReActStep::new("t", "search", "r"),
3217        ];
3218        let session = make_session(steps, 0);
3219        assert_eq!(session.most_used_action().as_deref(), Some("search"));
3220    }
3221
3222    #[test]
3223    fn test_most_used_action_none_for_empty_session() {
3224        let session = make_session(vec![], 0);
3225        assert!(session.most_used_action().is_none());
3226    }
3227
3228    #[test]
3229    fn test_graph_lookup_rate_correct_ratio() {
3230        let steps = vec![
3231            ReActStep::new("t", "a", "r"),
3232            ReActStep::new("t", "b", "r"),
3233            ReActStep::new("t", "c", "r"),
3234            ReActStep::new("t", "d", "r"),
3235        ];
3236        let mut session = make_session(steps, 0);
3237        session.graph_lookups = 2;
3238        assert!((session.graph_lookup_rate() - 0.5).abs() < 1e-9);
3239    }
3240
3241    #[test]
3242    fn test_graph_lookup_rate_zero_for_empty_session() {
3243        let session = make_session(vec![], 0);
3244        assert!((session.graph_lookup_rate() - 0.0).abs() < 1e-9);
3245    }
3246
3247    // ── Round 22: has_tool_failures, tool_call_rate, step_success_rate ────────
3248
3249    #[test]
3250    fn test_has_tool_failures_false_when_no_errors() {
3251        let steps = vec![
3252            make_step("t", "action1", "ok"),
3253            make_step("t", "action2", "done"),
3254        ];
3255        let session = make_session(steps, 0);
3256        assert!(!session.has_tool_failures());
3257    }
3258
3259    #[test]
3260    fn test_has_tool_failures_true_when_error_observation() {
3261        let steps = vec![
3262            make_step("t", "action1", "{\"error\": \"timeout\"}"),
3263        ];
3264        let session = make_session(steps, 0);
3265        assert!(session.has_tool_failures());
3266    }
3267
3268    #[test]
3269    fn test_tool_call_rate_zero_for_empty_session() {
3270        let session = make_session(vec![], 0);
3271        assert!((session.tool_call_rate() - 0.0).abs() < 1e-9);
3272    }
3273
3274    #[test]
3275    fn test_tool_call_rate_correct_ratio() {
3276        let steps = vec![
3277            make_step("t", "tool_action", "ok"),
3278            make_step("t", "FINAL_ANSWER: done", ""),
3279            make_step("t", "another_tool", "ok"),
3280        ];
3281        let session = make_session(steps, 0);
3282        // 2 tool calls out of 3 steps
3283        assert!((session.tool_call_rate() - 2.0 / 3.0).abs() < 1e-9);
3284    }
3285
3286    #[test]
3287    fn test_step_success_rate_one_for_empty_session() {
3288        let session = make_session(vec![], 0);
3289        assert!((session.step_success_rate() - 1.0).abs() < 1e-9);
3290    }
3291
3292    #[test]
3293    fn test_step_success_rate_less_than_one_when_failures() {
3294        let steps = vec![
3295            make_step("t", "act", "{\"error\": \"fail\"}"),
3296            make_step("t", "act", "success"),
3297        ];
3298        let session = make_session(steps, 0);
3299        // 1 failed out of 2 → 1.0 - 0.5 = 0.5
3300        assert!((session.step_success_rate() - 0.5).abs() < 1e-9);
3301    }
3302
3303    // ── Round 23: collection helpers and rate methods ─────────────────────────
3304
3305    #[test]
3306    fn test_avg_step_duration_ms_zero_for_empty() {
3307        let session = make_session(vec![], 0);
3308        assert!((session.avg_step_duration_ms() - 0.0).abs() < 1e-9);
3309    }
3310
3311    #[test]
3312    fn test_avg_step_duration_ms_correct_mean() {
3313        let mut s1 = make_step("t", "a", "o");
3314        s1.step_duration_ms = 100;
3315        let mut s2 = make_step("t", "b", "o");
3316        s2.step_duration_ms = 200;
3317        let session = make_session(vec![s1, s2], 0);
3318        assert!((session.avg_step_duration_ms() - 150.0).abs() < 1e-9);
3319    }
3320
3321    #[test]
3322    fn test_longest_step_none_for_empty() {
3323        let session = make_session(vec![], 0);
3324        assert!(session.longest_step().is_none());
3325    }
3326
3327    #[test]
3328    fn test_longest_step_middle_has_max_duration() {
3329        let mut s1 = make_step("t", "a", "o");
3330        s1.step_duration_ms = 10;
3331        let mut s2 = make_step("t", "b", "o");
3332        s2.step_duration_ms = 500;
3333        let mut s3 = make_step("t", "c", "o");
3334        s3.step_duration_ms = 20;
3335        let session = make_session(vec![s1, s2, s3], 0);
3336        assert_eq!(session.longest_step().unwrap().action, "b");
3337    }
3338
3339    #[test]
3340    fn test_unique_tools_used_deduplicates_and_sorts() {
3341        let steps = vec![
3342            make_step("t", "search", "o"),
3343            make_step("t", "lookup", "o"),
3344            make_step("t", "search", "o"),
3345        ];
3346        let session = make_session(steps, 0);
3347        assert_eq!(session.unique_tools_used(), vec!["lookup", "search"]);
3348    }
3349
3350    #[test]
3351    fn test_all_thoughts_collects_in_order() {
3352        let steps = vec![make_step("think1", "a", "o"), make_step("think2", "b", "o")];
3353        let session = make_session(steps, 0);
3354        assert_eq!(session.all_thoughts(), vec!["think1", "think2"]);
3355    }
3356
3357    #[test]
3358    fn test_all_actions_collects_in_order() {
3359        let steps = vec![make_step("t", "act1", "o"), make_step("t", "act2", "o")];
3360        let session = make_session(steps, 0);
3361        assert_eq!(session.all_actions(), vec!["act1", "act2"]);
3362    }
3363
3364    #[test]
3365    fn test_all_observations_collects_in_order() {
3366        let steps = vec![make_step("t", "a", "obs1"), make_step("t", "b", "obs2")];
3367        let session = make_session(steps, 0);
3368        assert_eq!(session.all_observations(), vec!["obs1", "obs2"]);
3369    }
3370
3371    #[test]
3372    fn test_action_counts_returns_frequency_map() {
3373        let steps = vec![
3374            make_step("t", "search", "o"),
3375            make_step("t", "lookup", "o"),
3376            make_step("t", "search", "o"),
3377        ];
3378        let session = make_session(steps, 0);
3379        let counts = session.action_counts();
3380        assert_eq!(counts["search"], 2);
3381        assert_eq!(counts["lookup"], 1);
3382    }
3383
3384    #[test]
3385    fn test_unique_actions_three_with_repeat_yields_two() {
3386        let steps = vec![
3387            make_step("t", "beta", "o"),
3388            make_step("t", "alpha", "o"),
3389            make_step("t", "beta", "o"),
3390        ];
3391        let session = make_session(steps, 0);
3392        assert_eq!(session.unique_actions(), vec!["alpha", "beta"]);
3393    }
3394
3395    #[test]
3396    fn test_action_diversity_zero_for_empty() {
3397        let session = make_session(vec![], 0);
3398        assert!((session.action_diversity() - 0.0).abs() < 1e-9);
3399    }
3400
3401    #[test]
3402    fn test_action_diversity_one_when_all_actions_unique() {
3403        let steps = vec![
3404            make_step("t", "a", "o"),
3405            make_step("t", "b", "o"),
3406            make_step("t", "c", "o"),
3407        ];
3408        let session = make_session(steps, 0);
3409        assert!((session.action_diversity() - 1.0).abs() < 1e-9);
3410    }
3411
3412    #[test]
3413    fn test_action_diversity_fraction_when_repeated() {
3414        let steps = vec![
3415            make_step("t", "x", "o"),
3416            make_step("t", "x", "o"),
3417        ];
3418        let session = make_session(steps, 0);
3419        assert!((session.action_diversity() - 0.5).abs() < 1e-9);
3420    }
3421
3422    #[test]
3423    fn test_has_checkpoint_errors_false_for_new_session() {
3424        let session = make_session(vec![], 0);
3425        assert!(!session.has_checkpoint_errors());
3426    }
3427
3428    #[test]
3429    fn test_has_checkpoint_errors_true_when_errors_present() {
3430        let mut session = make_session(vec![], 0);
3431        session.checkpoint_errors.push("err1".to_string());
3432        assert!(session.has_checkpoint_errors());
3433    }
3434
3435    #[test]
3436    fn test_graph_lookup_count_returns_raw_value() {
3437        let mut session = make_session(vec![make_step("t", "a", "o")], 0);
3438        session.graph_lookups = 7;
3439        assert_eq!(session.graph_lookup_count(), 7);
3440    }
3441
3442    #[test]
3443    fn test_memory_hit_rate_zero_for_empty_session() {
3444        let session = make_session(vec![], 0);
3445        assert!((session.memory_hit_rate() - 0.0).abs() < 1e-9);
3446    }
3447
3448    #[test]
3449    fn test_memory_hit_rate_correct_ratio() {
3450        let steps = vec![
3451            make_step("t", "a", "o"),
3452            make_step("t", "b", "o"),
3453            make_step("t", "c", "o"),
3454            make_step("t", "d", "o"),
3455        ];
3456        let mut session = make_session(steps, 0);
3457        session.memory_hits = 2;
3458        assert!((session.memory_hit_rate() - 0.5).abs() < 1e-9);
3459    }
3460
3461    #[test]
3462    fn test_total_memory_hits_returns_raw_value() {
3463        let mut session = make_session(vec![], 0);
3464        session.memory_hits = 13;
3465        assert_eq!(session.total_memory_hits(), 13);
3466    }
3467
3468    // ── Round 29: has_memory, has_graph, has_working_memory ──────────────────
3469
3470    #[cfg(feature = "memory")]
3471    #[test]
3472    fn test_has_memory_false_without_memory() {
3473        let runtime = AgentRuntime::builder()
3474            .with_agent_config(simple_config())
3475            .build();
3476        assert!(!runtime.has_memory());
3477    }
3478
3479    #[cfg(feature = "memory")]
3480    #[test]
3481    fn test_has_memory_true_with_memory() {
3482        let runtime = AgentRuntime::builder()
3483            .with_agent_config(simple_config())
3484            .with_memory(EpisodicStore::new())
3485            .build();
3486        assert!(runtime.has_memory());
3487    }
3488
3489    #[cfg(feature = "graph")]
3490    #[test]
3491    fn test_has_graph_false_without_graph() {
3492        let runtime = AgentRuntime::builder()
3493            .with_agent_config(simple_config())
3494            .build();
3495        assert!(!runtime.has_graph());
3496    }
3497
3498    #[cfg(feature = "graph")]
3499    #[test]
3500    fn test_has_graph_true_with_graph() {
3501        let runtime = AgentRuntime::builder()
3502            .with_agent_config(simple_config())
3503            .with_graph(GraphStore::new())
3504            .build();
3505        assert!(runtime.has_graph());
3506    }
3507
3508    #[cfg(feature = "memory")]
3509    #[test]
3510    fn test_has_working_memory_false_without_working_memory() {
3511        let runtime = AgentRuntime::builder()
3512            .with_agent_config(simple_config())
3513            .build();
3514        assert!(!runtime.has_working_memory());
3515    }
3516
3517    #[cfg(feature = "memory")]
3518    #[test]
3519    fn test_has_working_memory_true_with_working_memory() {
3520        let runtime = AgentRuntime::builder()
3521            .with_agent_config(simple_config())
3522            .with_working_memory(WorkingMemory::new(10).unwrap())
3523            .build();
3524        assert!(runtime.has_working_memory());
3525    }
3526
3527    // ── Round 23: last_observation / thought_count / observation_rate ─────────
3528
3529    #[test]
3530    fn test_last_observation_returns_most_recent_nonempty() {
3531        let steps = vec![
3532            make_step("t1", "act", "first obs"),
3533            make_step("t2", "act", ""),
3534            make_step("t3", "act", "last obs"),
3535        ];
3536        let session = make_session(steps, 0);
3537        assert_eq!(session.last_observation(), Some("last obs"));
3538    }
3539
3540    #[test]
3541    fn test_last_observation_skips_empty_steps() {
3542        let steps = vec![
3543            make_step("t1", "act", "only obs"),
3544            make_step("t2", "act", ""),
3545        ];
3546        let session = make_session(steps, 0);
3547        assert_eq!(session.last_observation(), Some("only obs"));
3548    }
3549
3550    #[test]
3551    fn test_last_observation_none_for_empty_session() {
3552        let session = make_session(vec![], 0);
3553        assert!(session.last_observation().is_none());
3554    }
3555
3556    #[test]
3557    fn test_thought_count_counts_nonempty_thoughts() {
3558        let steps = vec![
3559            make_step("think", "act", "obs"),
3560            make_step("", "act", "obs"),
3561            make_step("think again", "act", "obs"),
3562        ];
3563        let session = make_session(steps, 0);
3564        assert_eq!(session.thought_count(), 2);
3565    }
3566
3567    #[test]
3568    fn test_thought_count_zero_for_empty_session() {
3569        let session = make_session(vec![], 0);
3570        assert_eq!(session.thought_count(), 0);
3571    }
3572
3573    #[test]
3574    fn test_observation_rate_correct_fraction() {
3575        let steps = vec![
3576            make_step("t", "a", "obs"),
3577            make_step("t", "a", ""),
3578            make_step("t", "a", "obs"),
3579            make_step("t", "a", ""),
3580        ];
3581        let session = make_session(steps, 0);
3582        assert!((session.observation_rate() - 0.5).abs() < 1e-9);
3583    }
3584
3585    #[test]
3586    fn test_observation_rate_zero_for_empty_session() {
3587        let session = make_session(vec![], 0);
3588        assert!((session.observation_rate() - 0.0).abs() < 1e-9);
3589    }
3590
3591    // ── Round 24: action_repetition_rate / max_consecutive_failures / avg_thought_length
3592
3593    #[test]
3594    fn test_action_repetition_rate_zero_for_empty_session() {
3595        let session = make_session(vec![], 0);
3596        assert!((session.action_repetition_rate() - 0.0).abs() < 1e-9);
3597    }
3598
3599    #[test]
3600    fn test_action_repetition_rate_zero_for_single_step() {
3601        let session = make_session(vec![make_step("t", "search", "r")], 0);
3602        assert!((session.action_repetition_rate() - 0.0).abs() < 1e-9);
3603    }
3604
3605    #[test]
3606    fn test_action_repetition_rate_one_when_all_same() {
3607        let steps = vec![
3608            make_step("t", "search", "r"),
3609            make_step("t", "search", "r"),
3610            make_step("t", "search", "r"),
3611        ];
3612        let session = make_session(steps, 0);
3613        assert!((session.action_repetition_rate() - 1.0).abs() < 1e-9);
3614    }
3615
3616    #[test]
3617    fn test_action_repetition_rate_partial_repeats() {
3618        // [search, search, calc] → 1 repeat out of 2 transitions → 0.5
3619        let steps = vec![
3620            make_step("t", "search", "r"),
3621            make_step("t", "search", "r"),
3622            make_step("t", "calc", "r"),
3623        ];
3624        let session = make_session(steps, 0);
3625        assert!((session.action_repetition_rate() - 0.5).abs() < 1e-9);
3626    }
3627
3628    #[test]
3629    fn test_max_consecutive_failures_zero_for_no_errors() {
3630        let steps = vec![make_step("t", "a", "ok"), make_step("t", "b", "done")];
3631        let session = make_session(steps, 0);
3632        assert_eq!(session.max_consecutive_failures(), 0);
3633    }
3634
3635    #[test]
3636    fn test_max_consecutive_failures_counts_run() {
3637        let steps = vec![
3638            make_step("t", "a", "ok"),
3639            make_step("t", "b", r#"{"error":"x"}"#),
3640            make_step("t", "c", r#"{"error":"y"}"#),
3641            make_step("t", "d", "ok"),
3642        ];
3643        let session = make_session(steps, 0);
3644        assert_eq!(session.max_consecutive_failures(), 2);
3645    }
3646
3647    #[test]
3648    fn test_avg_thought_length_zero_for_empty_session() {
3649        let session = make_session(vec![], 0);
3650        assert!((session.avg_thought_length() - 0.0).abs() < 1e-9);
3651    }
3652
3653    #[test]
3654    fn test_avg_thought_length_excludes_empty_thoughts() {
3655        let steps = vec![
3656            make_step("hello", "a", "r"),  // 5 chars
3657            make_step("", "b", "r"),        // excluded
3658            make_step("hi", "c", "r"),      // 2 chars
3659        ];
3660        // mean = (5 + 2) / 2 = 3.5
3661        let session = make_session(steps, 0);
3662        assert!((session.avg_thought_length() - 3.5).abs() < 1e-9);
3663    }
3664
3665    // ── Round 25: last_n_observations / actions_in_window ─────────────────────
3666
3667    #[test]
3668    fn test_last_n_observations_empty_session() {
3669        let session = make_session(vec![], 0);
3670        assert!(session.last_n_observations(3).is_empty());
3671    }
3672
3673    #[test]
3674    fn test_last_n_observations_returns_last_n_nonempty() {
3675        let steps = vec![
3676            make_step("t", "a", "obs1"),
3677            make_step("t", "b", ""),        // skipped
3678            make_step("t", "c", "obs2"),
3679            make_step("t", "d", "obs3"),
3680        ];
3681        let session = make_session(steps, 0);
3682        let last2 = session.last_n_observations(2);
3683        assert_eq!(last2, vec!["obs2", "obs3"]);
3684    }
3685
3686    #[test]
3687    fn test_last_n_observations_returns_all_when_fewer_than_n() {
3688        let steps = vec![make_step("t", "a", "only")];
3689        let session = make_session(steps, 0);
3690        assert_eq!(session.last_n_observations(5), vec!["only"]);
3691    }
3692
3693    #[test]
3694    fn test_actions_in_window_empty_session() {
3695        let session = make_session(vec![], 0);
3696        assert!(session.actions_in_window(3).is_empty());
3697    }
3698
3699    #[test]
3700    fn test_actions_in_window_returns_last_n_steps() {
3701        let steps = vec![
3702            make_step("t", "alpha", "r"),
3703            make_step("t", "beta", "r"),
3704            make_step("t", "gamma", "r"),
3705        ];
3706        let session = make_session(steps, 0);
3707        let window = session.actions_in_window(2);
3708        assert_eq!(window, vec!["beta", "gamma"]);
3709    }
3710
3711    #[test]
3712    fn test_actions_in_window_all_when_fewer_than_n() {
3713        let steps = vec![make_step("t", "solo", "r")];
3714        let session = make_session(steps, 0);
3715        assert_eq!(session.actions_in_window(10), vec!["solo"]);
3716    }
3717
3718    // ── Round 31: observation_at, action_at ───────────────────────────────────
3719
3720    #[test]
3721    fn test_observation_at_returns_correct_observation() {
3722        let steps = vec![
3723            make_step("t1", "a1", "obs-zero"),
3724            make_step("t2", "a2", "obs-one"),
3725        ];
3726        let session = make_session(steps, 0);
3727        assert_eq!(session.observation_at(0), Some("obs-zero"));
3728        assert_eq!(session.observation_at(1), Some("obs-one"));
3729    }
3730
3731    #[test]
3732    fn test_observation_at_returns_none_out_of_bounds() {
3733        let session = make_session(vec![], 0);
3734        assert!(session.observation_at(0).is_none());
3735    }
3736
3737    #[test]
3738    fn test_action_at_returns_correct_action() {
3739        let steps = vec![
3740            make_step("t1", "first-action", "obs"),
3741            make_step("t2", "second-action", "obs"),
3742        ];
3743        let session = make_session(steps, 0);
3744        assert_eq!(session.action_at(0), Some("first-action"));
3745        assert_eq!(session.action_at(1), Some("second-action"));
3746    }
3747
3748    #[test]
3749    fn test_action_at_returns_none_out_of_bounds() {
3750        let session = make_session(vec![], 0);
3751        assert!(session.action_at(5).is_none());
3752    }
3753
3754    // ── Round 26: has_graph_lookups / consecutive_same_action_at_end ──────────
3755
3756    #[test]
3757    fn test_has_graph_lookups_false_when_zero() {
3758        let session = make_session(vec![], 0);
3759        assert!(!session.has_graph_lookups());
3760    }
3761
3762    #[test]
3763    fn test_has_graph_lookups_true_when_positive() {
3764        let session = AgentSession {
3765            session_id: "s".into(),
3766            agent_id: AgentId::new("a"),
3767            steps: vec![],
3768            memory_hits: 0,
3769            graph_lookups: 5,
3770            duration_ms: 0,
3771            checkpoint_errors: vec![],
3772        };
3773        assert!(session.has_graph_lookups());
3774    }
3775
3776    #[test]
3777    fn test_consecutive_same_action_at_end_empty_session() {
3778        let session = make_session(vec![], 0);
3779        assert_eq!(session.consecutive_same_action_at_end(), 0);
3780    }
3781
3782    #[test]
3783    fn test_consecutive_same_action_at_end_single_step() {
3784        let steps = vec![make_step("t", "act", "obs")];
3785        let session = make_session(steps, 0);
3786        assert_eq!(session.consecutive_same_action_at_end(), 0);
3787    }
3788
3789    #[test]
3790    fn test_consecutive_same_action_at_end_two_same_at_end() {
3791        let steps = vec![
3792            make_step("t", "other", "obs"),
3793            make_step("t", "repeat", "obs"),
3794            make_step("t", "repeat", "obs"),
3795        ];
3796        let session = make_session(steps, 0);
3797        assert_eq!(session.consecutive_same_action_at_end(), 1);
3798    }
3799
3800    #[test]
3801    fn test_consecutive_same_action_at_end_all_same() {
3802        let steps = vec![
3803            make_step("t", "same", "obs"),
3804            make_step("t", "same", "obs"),
3805            make_step("t", "same", "obs"),
3806        ];
3807        let session = make_session(steps, 0);
3808        assert_eq!(session.consecutive_same_action_at_end(), 2);
3809    }
3810
3811    // ── Round 27: failure_rate / unique_action_count ───────────────────────────
3812
3813    #[test]
3814    fn test_failure_rate_zero_for_empty_session() {
3815        let session = make_session(vec![], 0);
3816        assert!((session.failure_rate() - 0.0).abs() < 1e-9);
3817    }
3818
3819    #[test]
3820    fn test_failure_rate_zero_when_no_failures() {
3821        let steps = vec![
3822            make_step("t", "lookup", "ok"),
3823            make_step("t", "search", "ok"),
3824        ];
3825        let session = make_session(steps, 0);
3826        assert!((session.failure_rate() - 0.0).abs() < 1e-9);
3827    }
3828
3829    #[test]
3830    fn test_unique_action_count_zero_for_empty_session() {
3831        let session = make_session(vec![], 0);
3832        assert_eq!(session.unique_action_count(), 0);
3833    }
3834
3835    #[test]
3836    fn test_unique_action_count_counts_distinct_actions() {
3837        let steps = vec![
3838            make_step("t", "search", "r"),
3839            make_step("t", "lookup", "r"),
3840            make_step("t", "search", "r"), // duplicate
3841        ];
3842        let session = make_session(steps, 0);
3843        assert_eq!(session.unique_action_count(), 2);
3844    }
3845
3846    // ── Round 28: total_thought_length / longest_observation ──────────────────
3847
3848    #[test]
3849    fn test_total_thought_length_zero_for_empty_session() {
3850        let session = make_session(vec![], 0);
3851        assert_eq!(session.total_thought_length(), 0);
3852    }
3853
3854    #[test]
3855    fn test_total_thought_length_sums_all_thoughts() {
3856        let steps = vec![
3857            make_step("hi", "a", "r"),   // 2 bytes
3858            make_step("hello", "b", "r"), // 5 bytes
3859        ];
3860        let session = make_session(steps, 0);
3861        assert_eq!(session.total_thought_length(), 7);
3862    }
3863
3864    #[test]
3865    fn test_longest_observation_none_for_empty_session() {
3866        let session = make_session(vec![], 0);
3867        assert!(session.longest_observation().is_none());
3868    }
3869
3870    #[test]
3871    fn test_longest_observation_returns_longest() {
3872        let steps = vec![
3873            make_step("t", "a", "short"),
3874            make_step("t", "b", "a much longer observation"),
3875        ];
3876        let session = make_session(steps, 0);
3877        assert_eq!(session.longest_observation(), Some("a much longer observation"));
3878    }
3879
3880    // ── Round 29: steps_with_empty_observations / min_thought_length ──────────
3881
3882    #[test]
3883    fn test_steps_with_empty_observations_zero_when_all_filled() {
3884        let steps = vec![make_step("t", "a", "obs"), make_step("t", "b", "obs2")];
3885        let session = make_session(steps, 0);
3886        assert_eq!(session.steps_with_empty_observations(), 0);
3887    }
3888
3889    #[test]
3890    fn test_steps_with_empty_observations_counts_empty_ones() {
3891        let steps = vec![
3892            make_step("t", "a", ""),    // empty
3893            make_step("t", "b", "ok"),
3894            make_step("t", "c", ""),    // empty
3895        ];
3896        let session = make_session(steps, 0);
3897        assert_eq!(session.steps_with_empty_observations(), 2);
3898    }
3899
3900    #[test]
3901    fn test_min_thought_length_zero_for_empty_session() {
3902        let session = make_session(vec![], 0);
3903        assert_eq!(session.min_thought_length(), 0);
3904    }
3905
3906    #[test]
3907    fn test_min_thought_length_returns_shortest_non_empty() {
3908        let steps = vec![
3909            make_step("hi", "a", "r"),        // 2 bytes
3910            make_step("hello", "b", "r"),     // 5 bytes
3911            make_step("", "c", "r"),          // empty, excluded
3912        ];
3913        let session = make_session(steps, 0);
3914        assert_eq!(session.min_thought_length(), 2);
3915    }
3916
3917    // ── Round 30: observation_lengths / avg_observation_length ────────────────
3918
3919    #[test]
3920    fn test_observation_lengths_empty_for_empty_session() {
3921        let session = make_session(vec![], 0);
3922        assert!(session.observation_lengths().is_empty());
3923    }
3924
3925    #[test]
3926    fn test_observation_lengths_returns_lengths_in_order() {
3927        let steps = vec![
3928            make_step("t", "a", "hi"),    // 2
3929            make_step("t", "b", "hello"), // 5
3930        ];
3931        let session = make_session(steps, 0);
3932        assert_eq!(session.observation_lengths(), vec![2, 5]);
3933    }
3934
3935    #[test]
3936    fn test_avg_observation_length_zero_for_empty_session() {
3937        let session = make_session(vec![], 0);
3938        assert!((session.avg_observation_length() - 0.0).abs() < 1e-9);
3939    }
3940
3941    #[test]
3942    fn test_avg_observation_length_correct_mean() {
3943        let steps = vec![
3944            make_step("t", "a", "hi"),   // 2
3945            make_step("t", "b", "hello"), // 5
3946        ];
3947        let session = make_session(steps, 0);
3948        // mean = 3.5
3949        assert!((session.avg_observation_length() - 3.5).abs() < 1e-9);
3950    }
3951
3952    #[test]
3953    fn test_duration_secs_converts_ms_to_seconds() {
3954        let session = make_session(vec![], 7000);
3955        assert_eq!(session.duration_secs(), 7);
3956    }
3957
3958    #[test]
3959    fn test_steps_above_thought_length_counts_qualifying_steps() {
3960        let steps = vec![
3961            make_step("hi", "a", "obs"),
3962            make_step("a longer thought here", "b", "obs"),
3963            make_step("medium thought", "c", "obs"),
3964        ];
3965        let session = make_session(steps, 0);
3966        // "hi" (2) <= 5, "a longer thought here" (21) > 5, "medium thought" (13) > 5
3967        assert_eq!(session.steps_above_thought_length(5), 2);
3968    }
3969
3970    #[test]
3971    fn test_has_final_answer_true_when_step_has_final_answer_action() {
3972        let steps = vec![
3973            make_step("think", "search", "result"),
3974            make_step("done", "FINAL_ANSWER: 42", ""),
3975        ];
3976        let session = make_session(steps, 0);
3977        assert!(session.has_final_answer());
3978    }
3979
3980    #[test]
3981    fn test_has_final_answer_false_when_no_final_answer_step() {
3982        let steps = vec![make_step("think", "search", "result")];
3983        let session = make_session(steps, 0);
3984        assert!(!session.has_final_answer());
3985    }
3986
3987    #[test]
3988    fn test_avg_action_length_correct_mean() {
3989        let steps = vec![
3990            make_step("t", "ab", "o"),    // 2
3991            make_step("t", "abcd", "o"),  // 4
3992        ];
3993        let session = make_session(steps, 0);
3994        assert!((session.avg_action_length() - 3.0).abs() < 1e-9);
3995    }
3996
3997    #[test]
3998    fn test_avg_action_length_empty_returns_zero() {
3999        let session = make_session(vec![], 0);
4000        assert_eq!(session.avg_action_length(), 0.0);
4001    }
4002
4003    #[test]
4004    fn test_thought_lengths_returns_lengths_in_order() {
4005        let steps = vec![
4006            make_step("hi", "a", "o"),
4007            make_step("hello", "b", "o"),
4008        ];
4009        let session = make_session(steps, 0);
4010        assert_eq!(session.thought_lengths(), vec![2, 5]);
4011    }
4012
4013    #[test]
4014    fn test_most_common_action_returns_most_frequent() {
4015        let steps = vec![
4016            make_step("t", "search", "o"),
4017            make_step("t", "search", "o"),
4018            make_step("t", "other", "o"),
4019        ];
4020        let session = make_session(steps, 0);
4021        assert_eq!(session.most_common_action(), Some("search"));
4022    }
4023
4024    #[test]
4025    fn test_most_common_action_none_for_empty_session() {
4026        let session = make_session(vec![], 0);
4027        assert!(session.most_common_action().is_none());
4028    }
4029
4030    #[test]
4031    fn test_count_steps_with_action_counts_exact_matches() {
4032        let steps = vec![
4033            make_step("t", "search", "o"),
4034            make_step("t", "search", "o"),
4035            make_step("t", "other", "o"),
4036        ];
4037        let session = make_session(steps, 0);
4038        assert_eq!(session.count_steps_with_action("search"), 2);
4039        assert_eq!(session.count_steps_with_action("other"), 1);
4040        assert_eq!(session.count_steps_with_action("missing"), 0);
4041    }
4042
4043    #[test]
4044    fn test_thought_contains_count_counts_matching_steps() {
4045        let steps = vec![
4046            make_step("search for rust", "a", "o"),
4047            make_step("think about python", "b", "o"),
4048            make_step("rust is great", "c", "o"),
4049        ];
4050        let session = make_session(steps, 0);
4051        assert_eq!(session.thought_contains_count("rust"), 2);
4052        assert_eq!(session.thought_contains_count("python"), 1);
4053        assert_eq!(session.thought_contains_count("java"), 0);
4054    }
4055
4056    #[test]
4057    fn test_count_nonempty_thoughts_counts_steps_with_thoughts() {
4058        let steps = vec![
4059            make_step("hello", "a", "o"),
4060            make_step("", "b", "o"),
4061            make_step("world", "c", "o"),
4062        ];
4063        let session = make_session(steps, 0);
4064        assert_eq!(session.count_nonempty_thoughts(), 2);
4065    }
4066
4067    #[test]
4068    fn test_observation_contains_count_counts_matching_observations() {
4069        let steps = vec![
4070            make_step("t", "a", "result: success"),
4071            make_step("t", "b", "result: failure"),
4072            make_step("t", "c", "no match here"),
4073        ];
4074        let session = make_session(steps, 0);
4075        assert_eq!(session.observation_contains_count("result"), 2);
4076        assert_eq!(session.observation_contains_count("success"), 1);
4077    }
4078
4079    // ── Round 36 ──────────────────────────────────────────────────────────────
4080
4081    #[test]
4082    fn test_action_lengths_returns_byte_lengths_in_order() {
4083        let steps = vec![
4084            make_step("t", "ab", "o"),
4085            make_step("t", "hello", "o"),
4086            make_step("t", "", "o"),
4087        ];
4088        let session = make_session(steps, 0);
4089        assert_eq!(session.action_lengths(), vec![2, 5, 0]);
4090    }
4091
4092    #[test]
4093    fn test_action_lengths_empty_session_returns_empty_vec() {
4094        let session = make_session(vec![], 0);
4095        assert!(session.action_lengths().is_empty());
4096    }
4097
4098    #[test]
4099    fn test_step_success_count_excludes_failed_steps() {
4100        let steps = vec![
4101            make_step("t", "a", "ok"),
4102            make_step("t", "b", "{\"error\": \"timeout\"}"),
4103            make_step("t", "c", "ok"),
4104        ];
4105        let session = make_session(steps, 0);
4106        assert_eq!(session.step_success_count(), 2);
4107    }
4108
4109    #[test]
4110    fn test_step_success_count_all_success_when_no_failures() {
4111        let steps = vec![make_step("t", "a", "ok"), make_step("t", "b", "ok")];
4112        let session = make_session(steps, 0);
4113        assert_eq!(session.step_success_count(), 2);
4114    }
4115
4116    // ── Round 37 ──────────────────────────────────────────────────────────────
4117
4118    #[test]
4119    fn test_longest_thought_returns_step_with_most_bytes() {
4120        let steps = vec![
4121            make_step("hi", "a", "o"),
4122            make_step("hello world", "b", "o"),
4123            make_step("hey", "c", "o"),
4124        ];
4125        let session = make_session(steps, 0);
4126        assert_eq!(session.longest_thought(), Some("hello world"));
4127    }
4128
4129    #[test]
4130    fn test_longest_thought_returns_none_for_empty_session() {
4131        let session = make_session(vec![], 0);
4132        assert!(session.longest_thought().is_none());
4133    }
4134
4135    #[test]
4136    fn test_shortest_action_returns_step_with_fewest_bytes() {
4137        let steps = vec![
4138            make_step("t", "search", "o"),
4139            make_step("t", "go", "o"),
4140            make_step("t", "lookup", "o"),
4141        ];
4142        let session = make_session(steps, 0);
4143        assert_eq!(session.shortest_action(), Some("go"));
4144    }
4145
4146    #[test]
4147    fn test_shortest_action_returns_none_for_empty_session() {
4148        let session = make_session(vec![], 0);
4149        assert!(session.shortest_action().is_none());
4150    }
4151
4152    // ── Round 38 ──────────────────────────────────────────────────────────────
4153
4154    #[test]
4155    fn test_first_step_action_returns_action_of_first_step() {
4156        let steps = vec![
4157            make_step("t", "first", "o"),
4158            make_step("t", "second", "o"),
4159        ];
4160        let session = make_session(steps, 0);
4161        assert_eq!(session.first_step_action(), Some("first"));
4162    }
4163
4164    #[test]
4165    fn test_first_step_action_returns_none_for_empty_session() {
4166        let session = make_session(vec![], 0);
4167        assert!(session.first_step_action().is_none());
4168    }
4169
4170    #[test]
4171    fn test_last_step_action_returns_action_of_last_step() {
4172        let steps = vec![
4173            make_step("t", "first", "o"),
4174            make_step("t", "last_one", "o"),
4175        ];
4176        let session = make_session(steps, 0);
4177        assert_eq!(session.last_step_action(), Some("last_one"));
4178    }
4179
4180    #[test]
4181    fn test_last_step_action_returns_none_for_empty_session() {
4182        let session = make_session(vec![], 0);
4183        assert!(session.last_step_action().is_none());
4184    }
4185
4186    // ── Round 39 ──────────────────────────────────────────────────────────────
4187
4188    #[test]
4189    fn test_total_thought_bytes_sums_all_thought_lengths() {
4190        let steps = vec![
4191            make_step("hi", "a", "o"),   // thought = "hi" → 2
4192            make_step("hello", "b", "o"), // thought = "hello" → 5
4193        ];
4194        let session = make_session(steps, 0);
4195        assert_eq!(session.total_thought_bytes(), 7);
4196    }
4197
4198    #[test]
4199    fn test_total_observation_bytes_sums_all_observation_lengths() {
4200        let steps = vec![
4201            make_step("t", "a", "ok"),     // 2
4202            make_step("t", "b", "done!"),  // 5
4203        ];
4204        let session = make_session(steps, 0);
4205        assert_eq!(session.total_observation_bytes(), 7);
4206    }
4207}