Skip to main content

llm_agent_runtime/
agent.rs

1//! # Module: Agent
2//!
3//! ## Responsibility
4//! Provides a ReAct (Thought-Action-Observation) agent loop with pluggable tools.
5//! Mirrors the public API of `wasm-agent`.
6//!
7//! ## Guarantees
8//! - Deterministic: the loop terminates after at most `max_iterations` cycles
9//! - Non-panicking: all operations return `Result`
10//! - Tool handlers support both sync and async `Fn` closures
11//!
12//! ## NOT Responsible For
13//! - Actual LLM inference (callers supply a mock/stub inference fn)
14//! - WASM compilation or browser execution
15//! - Streaming partial responses
16
17use crate::error::AgentRuntimeError;
18use crate::metrics::RuntimeMetrics;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::collections::HashMap;
22use std::fmt::Write as FmtWrite;
23use std::future::Future;
24use std::pin::Pin;
25use std::sync::Arc;
26
27// ── Types ─────────────────────────────────────────────────────────────────────
28
29/// A pinned, boxed future returning a `Value`. Used for async tool handlers.
30pub type AsyncToolFuture = Pin<Box<dyn Future<Output = Value> + Send>>;
31
32/// A pinned, boxed future returning `Result<Value, String>`. Used for fallible async tool handlers.
33pub type AsyncToolResultFuture = Pin<Box<dyn Future<Output = Result<Value, String>> + Send>>;
34
35/// An async tool handler closure.
36pub type AsyncToolHandler = Box<dyn Fn(Value) -> AsyncToolFuture + Send + Sync>;
37
38/// Role of a message in a conversation.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub enum Role {
41    /// System-level instruction injected before the user turn.
42    System,
43    /// Message from the human user.
44    User,
45    /// Message produced by the language model.
46    Assistant,
47    /// Message produced by a tool invocation.
48    Tool,
49}
50
51/// A single message in the conversation history.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Message {
54    /// The role of the speaker who produced this message.
55    pub role: Role,
56    /// The textual content of the message.
57    pub content: String,
58}
59
60impl Message {
61    /// Create a new `Message` with the given role and content.
62    ///
63    /// # Panics
64    ///
65    /// This function does not panic.
66    pub fn new(role: Role, content: impl Into<String>) -> Self {
67        Self {
68            role,
69            content: content.into(),
70        }
71    }
72
73    /// Create a `User` role message.
74    pub fn user(content: impl Into<String>) -> Self {
75        Self::new(Role::User, content)
76    }
77
78    /// Create an `Assistant` role message.
79    pub fn assistant(content: impl Into<String>) -> Self {
80        Self::new(Role::Assistant, content)
81    }
82
83    /// Create a `System` role message.
84    pub fn system(content: impl Into<String>) -> Self {
85        Self::new(Role::System, content)
86    }
87
88    /// Return a reference to the message role.
89    pub fn role(&self) -> &Role {
90        &self.role
91    }
92
93    /// Return the message content as a `&str`.
94    pub fn content(&self) -> &str {
95        &self.content
96    }
97
98    /// Return `true` if this is a `User` role message.
99    pub fn is_user(&self) -> bool {
100        self.role == Role::User
101    }
102
103    /// Return `true` if this is an `Assistant` role message.
104    pub fn is_assistant(&self) -> bool {
105        self.role == Role::Assistant
106    }
107
108    /// Return `true` if this is a `System` role message.
109    pub fn is_system(&self) -> bool {
110        self.role == Role::System
111    }
112
113    /// Return `true` if this is a `Tool` role message.
114    pub fn is_tool(&self) -> bool {
115        self.role == Role::Tool
116    }
117
118    /// Return `true` if the message content is empty.
119    pub fn is_empty(&self) -> bool {
120        self.content.is_empty()
121    }
122
123    /// Return the approximate number of whitespace-separated words in the content.
124    pub fn word_count(&self) -> usize {
125        self.content.split_whitespace().count()
126    }
127
128    /// Return the byte length of the content string.
129    ///
130    /// For ASCII-only content this equals the character count; for multi-byte
131    /// UTF-8 sequences it will be larger.  Useful for rough token estimation.
132    pub fn byte_len(&self) -> usize {
133        self.content.len()
134    }
135
136    /// Return `true` if the content string starts with `prefix`.
137    ///
138    /// The check is byte-exact (case-sensitive).  Returns `true` for an empty
139    /// `prefix` regardless of content.
140    pub fn content_starts_with(&self, prefix: &str) -> bool {
141        self.content.starts_with(prefix)
142    }
143}
144
145impl std::fmt::Display for Role {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            Role::System => write!(f, "system"),
149            Role::User => write!(f, "user"),
150            Role::Assistant => write!(f, "assistant"),
151            Role::Tool => write!(f, "tool"),
152        }
153    }
154}
155
156impl std::fmt::Display for Message {
157    /// Render as `"{role}: {content}"`.
158    ///
159    /// Useful for logging and quick inspection of conversation history.
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        write!(f, "{}: {}", self.role, self.content)
162    }
163}
164
165impl From<(Role, String)> for Message {
166    /// Construct a `Message` from a `(Role, String)` tuple.
167    fn from((role, content): (Role, String)) -> Self {
168        Self::new(role, content)
169    }
170}
171
172impl From<(Role, &str)> for Message {
173    /// Construct a `Message` from a `(Role, &str)` tuple.
174    fn from((role, content): (Role, &str)) -> Self {
175        Self::new(role, content)
176    }
177}
178
179/// A single ReAct step: Thought → Action → Observation.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ReActStep {
182    /// Agent's reasoning about the current state.
183    pub thought: String,
184    /// The action taken (tool name + JSON arguments, or "FINAL_ANSWER").
185    pub action: String,
186    /// The result of the action.
187    pub observation: String,
188    /// Wall-clock duration of this individual step in milliseconds.
189    /// Covers the time from the start of the inference call to the end of the
190    /// tool invocation (or FINAL_ANSWER detection).  Zero for steps that were
191    /// constructed outside the loop (e.g., in tests).
192    #[serde(default)]
193    pub step_duration_ms: u64,
194}
195
196impl ReActStep {
197    /// Construct a step with zero `step_duration_ms`.
198    ///
199    /// Primarily useful in tests that need to build [`AgentSession`] values
200    /// without running the full ReAct loop.
201    pub fn new(
202        thought: impl Into<String>,
203        action: impl Into<String>,
204        observation: impl Into<String>,
205    ) -> Self {
206        Self {
207            thought: thought.into(),
208            action: action.into(),
209            observation: observation.into(),
210            step_duration_ms: 0,
211        }
212    }
213
214    /// Returns `true` if this step's action is a `FINAL_ANSWER`.
215    pub fn is_final_answer(&self) -> bool {
216        self.action.trim().to_ascii_uppercase().starts_with("FINAL_ANSWER")
217    }
218
219    /// Returns `true` if this step's action is a tool call (not a FINAL_ANSWER).
220    pub fn is_tool_call(&self) -> bool {
221        !self.is_final_answer() && !self.action.trim().is_empty()
222    }
223
224    /// Set the `step_duration_ms` field, returning `self` for chaining.
225    ///
226    /// Useful in tests and benchmarks that need to build `AgentSession` values
227    /// with realistic timings without running the full ReAct loop.
228    pub fn with_duration(mut self, ms: u64) -> Self {
229        self.step_duration_ms = ms;
230        self
231    }
232
233    /// Return `true` if all three text fields are empty strings.
234    pub fn is_empty(&self) -> bool {
235        self.thought.is_empty() && self.action.is_empty() && self.observation.is_empty()
236    }
237
238    /// Return `true` if the observation string is empty.
239    ///
240    /// Useful for identifying steps where the tool produced no output.
241    pub fn observation_is_empty(&self) -> bool {
242        self.observation.is_empty()
243    }
244
245    /// Return the approximate number of whitespace-separated words in the thought string.
246    ///
247    /// Returns `0` for steps with an empty thought.
248    pub fn thought_word_count(&self) -> usize {
249        self.thought.split_whitespace().count()
250    }
251
252    /// Return the number of whitespace-delimited words in the observation string.
253    ///
254    /// Returns `0` for empty or whitespace-only observations.
255    pub fn observation_word_count(&self) -> usize {
256        self.observation.split_whitespace().count()
257    }
258
259    /// Return `true` if the thought string is empty or whitespace-only.
260    pub fn thought_is_empty(&self) -> bool {
261        self.thought.trim().is_empty()
262    }
263
264    /// Return a concise single-line summary of this step.
265    ///
266    /// Format: `"[{kind}] thought={thought_preview} action={action_preview} obs={obs_preview}"`
267    /// where each preview is at most 40 bytes, truncated with `…` if longer.
268    /// `{kind}` is `"FINAL"` for a final-answer step and `"TOOL"` otherwise.
269    ///
270    /// Intended for logging and debugging — not a stable serialization format.
271    pub fn summary(&self) -> String {
272        fn preview(s: &str) -> String {
273            if s.len() <= 40 {
274                s.to_owned()
275            } else {
276                format!("{}…", &s[..40])
277            }
278        }
279        let kind = if self.is_final_answer() { "FINAL" } else { "TOOL" };
280        format!(
281            "[{kind}] thought={t} action={a} obs={o}",
282            t = preview(self.thought.trim()),
283            a = preview(self.action.trim()),
284            o = preview(self.observation.trim()),
285        )
286    }
287
288    /// Return the total byte length of `thought`, `action`, and `observation`
289    /// combined.
290    ///
291    /// Useful for estimating token cost or bounding memory usage per step.
292    pub fn combined_byte_length(&self) -> usize {
293        self.thought.len() + self.action.len() + self.observation.len()
294    }
295
296    /// Return `true` if the action string is empty or whitespace-only.
297    pub fn action_is_empty(&self) -> bool {
298        self.action.trim().is_empty()
299    }
300
301    /// Return the total number of words across `thought`, `action`, and
302    /// `observation`, counted by whitespace splitting.
303    ///
304    /// Useful for estimating the token cost of a complete ReAct step.
305    pub fn total_word_count(&self) -> usize {
306        self.thought.split_whitespace().count()
307            + self.action.split_whitespace().count()
308            + self.observation.split_whitespace().count()
309    }
310
311    /// Return `true` if all three fields — `thought`, `action`, and
312    /// `observation` — are non-empty.
313    ///
314    /// A "complete" step is one where the model has produced a thought, taken
315    /// an action, and received an observation.  Incomplete steps (missing any
316    /// field) typically indicate a parsing failure or an in-progress cycle.
317    pub fn is_complete(&self) -> bool {
318        !self.thought.is_empty() && !self.action.is_empty() && !self.observation.is_empty()
319    }
320
321    /// Return `true` if the `observation` field starts with the given `prefix`.
322    ///
323    /// Useful for quick pattern checks on the model's last observation without
324    /// allocating a new string.
325    pub fn observation_starts_with(&self, prefix: &str) -> bool {
326        self.observation.starts_with(prefix)
327    }
328
329    /// Return the number of whitespace-delimited words in the `action` field.
330    ///
331    /// Returns `0` for an empty action.
332    pub fn action_word_count(&self) -> usize {
333        self.action.split_whitespace().count()
334    }
335
336    /// Return the number of bytes in the `thought` field (UTF-8 encoded length).
337    pub fn thought_byte_len(&self) -> usize {
338        self.thought.len()
339    }
340
341    /// Return the number of bytes in the `action` field (UTF-8 encoded length).
342    pub fn action_byte_len(&self) -> usize {
343        self.action.len()
344    }
345
346    /// Return `true` if any of `thought`, `action`, or `observation` is empty.
347    ///
348    /// Useful for detecting incomplete or partially-populated steps before
349    /// processing them further.
350    pub fn has_empty_fields(&self) -> bool {
351        self.thought.is_empty() || self.action.is_empty() || self.observation.is_empty()
352    }
353
354    /// Return the number of bytes in the `observation` field (UTF-8 encoded length).
355    pub fn observation_byte_len(&self) -> usize {
356        self.observation.len()
357    }
358
359    /// Return `true` if all three fields (`thought`, `action`, `observation`)
360    /// each contain at least one whitespace-delimited word.
361    pub fn all_fields_have_words(&self) -> bool {
362        self.thought.split_whitespace().next().is_some()
363            && self.action.split_whitespace().next().is_some()
364            && self.observation.split_whitespace().next().is_some()
365    }
366}
367
368/// Configuration for the ReAct agent loop.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct AgentConfig {
371    /// Maximum number of Thought-Action-Observation cycles.
372    pub max_iterations: usize,
373    /// Model identifier passed to the inference function.
374    pub model: String,
375    /// System prompt injected at the start of the conversation.
376    pub system_prompt: String,
377    /// Maximum number of episodic memories to inject into the prompt.
378    /// Keeping this small prevents silent token-budget overruns.  Default: 3.
379    pub max_memory_recalls: usize,
380    /// Maximum token budget for injected memories.
381    ///
382    /// Token counting is delegated to the [`TokenEstimator`] configured on
383    /// [`AgentRuntimeBuilder`] (default: 1 token ≈ 4 bytes).  `None` means
384    /// no limit.
385    ///
386    /// [`TokenEstimator`]: crate::runtime::TokenEstimator
387    /// [`AgentRuntimeBuilder`]: crate::runtime::AgentRuntimeBuilder
388    pub max_memory_tokens: Option<usize>,
389    /// Optional wall-clock timeout for the entire loop.
390    /// If the loop runs longer than this duration, it returns
391    /// `Err(AgentRuntimeError::AgentLoop("loop timeout ..."))`.
392    pub loop_timeout: Option<std::time::Duration>,
393    /// Model sampling temperature.
394    pub temperature: Option<f32>,
395    /// Maximum output tokens.
396    pub max_tokens: Option<usize>,
397    /// Per-inference timeout.
398    pub request_timeout: Option<std::time::Duration>,
399    /// Maximum number of characters allowed in the running context string.
400    ///
401    /// When set, the oldest Thought/Action/Observation turns are trimmed from
402    /// the **beginning** of the context (after the system prompt) to keep the
403    /// total length below this limit.  This prevents silent context-window
404    /// overruns in long-running agents.  `None` (default) means no limit.
405    pub max_context_chars: Option<usize>,
406    /// Stop sequences passed to the inference function.
407    ///
408    /// The model stops generating when it produces any of these strings.
409    /// An empty `Vec` (default) means no stop sequences.
410    pub stop_sequences: Vec<String>,
411}
412
413impl AgentConfig {
414    /// Create a new config with sensible defaults.
415    pub fn new(max_iterations: usize, model: impl Into<String>) -> Self {
416        Self {
417            max_iterations,
418            model: model.into(),
419            system_prompt: "You are a helpful AI agent.".into(),
420            max_memory_recalls: 3,
421            max_memory_tokens: None,
422            loop_timeout: None,
423            temperature: None,
424            max_tokens: None,
425            request_timeout: None,
426            max_context_chars: None,
427            stop_sequences: vec![],
428        }
429    }
430
431    /// Override the system prompt.
432    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
433        self.system_prompt = prompt.into();
434        self
435    }
436
437    /// Set the maximum number of episodic memories injected per run.
438    pub fn with_max_memory_recalls(mut self, n: usize) -> Self {
439        self.max_memory_recalls = n;
440        self
441    }
442
443    /// Set a maximum token budget for injected memories (~4 chars/token heuristic).
444    pub fn with_max_memory_tokens(mut self, n: usize) -> Self {
445        self.max_memory_tokens = Some(n);
446        self
447    }
448
449    /// Set a wall-clock timeout for the entire ReAct loop.
450    ///
451    /// If the loop has not reached `FINAL_ANSWER` within this duration,
452    /// [`ReActLoop::run`] returns `Err(AgentRuntimeError::AgentLoop(...))`.
453    pub fn with_loop_timeout(mut self, d: std::time::Duration) -> Self {
454        self.loop_timeout = Some(d);
455        self
456    }
457
458    /// Set a wall-clock timeout for the entire ReAct loop using seconds.
459    ///
460    /// Convenience wrapper around [`with_loop_timeout`](Self::with_loop_timeout).
461    pub fn with_loop_timeout_secs(self, secs: u64) -> Self {
462        self.with_loop_timeout(std::time::Duration::from_secs(secs))
463    }
464
465    /// Set a wall-clock timeout for the entire ReAct loop using milliseconds.
466    ///
467    /// Convenience wrapper around [`with_loop_timeout`](Self::with_loop_timeout).
468    pub fn with_loop_timeout_ms(self, ms: u64) -> Self {
469        self.with_loop_timeout(std::time::Duration::from_millis(ms))
470    }
471
472    /// Set the maximum number of ReAct iterations.
473    pub fn with_max_iterations(mut self, n: usize) -> Self {
474        self.max_iterations = n;
475        self
476    }
477
478    /// Return the configured maximum number of ReAct iterations.
479    pub fn max_iterations(&self) -> usize {
480        self.max_iterations
481    }
482
483    /// Set the model sampling temperature.
484    pub fn with_temperature(mut self, t: f32) -> Self {
485        self.temperature = Some(t);
486        self
487    }
488
489    /// Set the maximum output tokens.
490    pub fn with_max_tokens(mut self, n: usize) -> Self {
491        self.max_tokens = Some(n);
492        self
493    }
494
495    /// Set the per-inference timeout.
496    pub fn with_request_timeout(mut self, d: std::time::Duration) -> Self {
497        self.request_timeout = Some(d);
498        self
499    }
500
501    /// Set the per-inference timeout using seconds.
502    ///
503    /// Convenience wrapper around [`with_request_timeout`](Self::with_request_timeout).
504    pub fn with_request_timeout_secs(self, secs: u64) -> Self {
505        self.with_request_timeout(std::time::Duration::from_secs(secs))
506    }
507
508    /// Set the per-inference timeout using milliseconds.
509    ///
510    /// Convenience wrapper around [`with_request_timeout`](Self::with_request_timeout).
511    pub fn with_request_timeout_ms(self, ms: u64) -> Self {
512        self.with_request_timeout(std::time::Duration::from_millis(ms))
513    }
514
515    /// Set a maximum character limit for the running context string.
516    ///
517    /// When the context exceeds this length the oldest
518    /// Thought/Action/Observation turns are trimmed from the front (after the
519    /// initial system prompt + user turn) so the context stays under the limit.
520    pub fn with_max_context_chars(mut self, n: usize) -> Self {
521        self.max_context_chars = Some(n);
522        self
523    }
524
525    /// Change the model used for completions.
526    pub fn with_model(mut self, model: impl Into<String>) -> Self {
527        self.model = model.into();
528        self
529    }
530
531    /// Clone this config with only the `model` field changed.
532    ///
533    /// Useful when the same configuration is shared across multiple agents that
534    /// differ only in the model used for inference.
535    pub fn clone_with_model(&self, model: impl Into<String>) -> Self {
536        let mut copy = self.clone();
537        copy.model = model.into();
538        copy
539    }
540
541    /// Clone this config with only the `system_prompt` field changed.
542    ///
543    /// Useful when the same configuration is shared across multiple agents that
544    /// differ only in the system prompt used for their sessions.
545    pub fn clone_with_system_prompt(&self, prompt: impl Into<String>) -> Self {
546        let mut copy = self.clone();
547        copy.system_prompt = prompt.into();
548        copy
549    }
550
551    /// Clone this config with only the `max_iterations` field changed.
552    ///
553    /// Useful when the same configuration is shared across multiple agents that
554    /// differ only in the iteration budget — for example, a quick draft agent
555    /// and a thorough research agent backed by the same model.
556    pub fn clone_with_max_iterations(&self, n: usize) -> Self {
557        let mut copy = self.clone();
558        copy.max_iterations = n;
559        copy
560    }
561
562    /// Set stop sequences for inference requests.
563    ///
564    /// The model will stop generating when it produces any of these strings.
565    /// Defaults to an empty list (no stop sequences).
566    pub fn with_stop_sequences(mut self, sequences: Vec<String>) -> Self {
567        self.stop_sequences = sequences;
568        self
569    }
570
571    /// Return `true` if this configuration is logically valid.
572    ///
573    /// Specifically, `max_iterations` must be at least 1 and `model` must be
574    /// a non-empty string.
575    pub fn is_valid(&self) -> bool {
576        self.max_iterations >= 1 && !self.model.is_empty()
577    }
578
579    /// Validate the configuration, returning a structured error on failure.
580    ///
581    /// Checks the same invariants as [`is_valid`] but returns
582    /// `Err(AgentRuntimeError::AgentLoop)` with a descriptive message instead
583    /// of `false`, making it more useful in `?`-propagation chains.
584    ///
585    /// [`is_valid`]: AgentConfig::is_valid
586    pub fn validate(&self) -> Result<(), crate::error::AgentRuntimeError> {
587        if self.max_iterations == 0 {
588            return Err(crate::error::AgentRuntimeError::AgentLoop(
589                "AgentConfig: max_iterations must be >= 1".into(),
590            ));
591        }
592        if self.model.is_empty() {
593            return Err(crate::error::AgentRuntimeError::AgentLoop(
594                "AgentConfig: model must not be empty".into(),
595            ));
596        }
597        Ok(())
598    }
599
600    /// Return `true` if a loop timeout has been configured.
601    pub fn has_loop_timeout(&self) -> bool {
602        self.loop_timeout.is_some()
603    }
604
605    /// Return `true` if at least one stop sequence has been configured.
606    pub fn has_stop_sequences(&self) -> bool {
607        !self.stop_sequences.is_empty()
608    }
609
610    /// Return the number of configured stop sequences.
611    pub fn stop_sequence_count(&self) -> usize {
612        self.stop_sequences.len()
613    }
614
615    /// Return `true` if the agent runs at most one iteration.
616    ///
617    /// A single-shot agent executes exactly one Thought-Action-Observation
618    /// cycle and then terminates regardless of whether a `FINAL_ANSWER` was
619    /// produced.
620    pub fn is_single_shot(&self) -> bool {
621        self.max_iterations == 1
622    }
623
624    /// Return `true` if a sampling temperature has been configured.
625    pub fn has_temperature(&self) -> bool {
626        self.temperature.is_some()
627    }
628
629    /// Return the configured sampling temperature, if any.
630    pub fn temperature(&self) -> Option<f32> {
631        self.temperature
632    }
633
634    /// Return the configured maximum output tokens, if any.
635    pub fn max_tokens(&self) -> Option<usize> {
636        self.max_tokens
637    }
638
639    /// Return `true` if a per-inference request timeout has been configured.
640    pub fn has_request_timeout(&self) -> bool {
641        self.request_timeout.is_some()
642    }
643
644    /// Return the configured per-inference request timeout, if any.
645    pub fn request_timeout(&self) -> Option<std::time::Duration> {
646        self.request_timeout
647    }
648
649    /// Return `true` if a maximum context character limit has been configured.
650    pub fn has_max_context_chars(&self) -> bool {
651        self.max_context_chars.is_some()
652    }
653
654    /// Return the configured maximum context character limit, if any.
655    pub fn max_context_chars(&self) -> Option<usize> {
656        self.max_context_chars
657    }
658
659    /// Return the number of iterations still available after `n` have been completed.
660    ///
661    /// Uses saturating subtraction so values beyond `max_iterations` return `0`
662    /// rather than wrapping.
663    pub fn remaining_iterations_after(&self, n: usize) -> usize {
664        self.max_iterations.saturating_sub(n)
665    }
666
667    /// Return the configured system prompt string.
668    pub fn system_prompt(&self) -> &str {
669        &self.system_prompt
670    }
671
672    /// Return `true` if the system prompt is empty or whitespace-only.
673    ///
674    /// A default `AgentConfig` has an empty system prompt.
675    pub fn system_prompt_is_empty(&self) -> bool {
676        self.system_prompt.trim().is_empty()
677    }
678
679    /// Return the model name this config targets.
680    pub fn model(&self) -> &str {
681        &self.model
682    }
683
684    /// Return the loop-level timeout in milliseconds, or `0` if no timeout is
685    /// configured.
686    ///
687    /// This is the `loop_timeout` field expressed as milliseconds.  Useful for
688    /// budget-calculation code that needs a uniform numeric representation of
689    /// the timeout budget.
690    pub fn loop_timeout_ms(&self) -> u64 {
691        self.loop_timeout
692            .map(|d| d.as_millis() as u64)
693            .unwrap_or(0)
694    }
695
696    /// Return the total wall-clock budget for all iterations in milliseconds.
697    ///
698    /// Computed as `loop_timeout_ms + max_iterations * request_timeout_ms`,
699    /// where a missing timeout contributes `0`.  This is a *rough upper bound*
700    /// — actual latency depends on model response times and tool execution.
701    pub fn total_timeout_ms(&self) -> u64 {
702        let loop_ms = self.loop_timeout_ms();
703        let req_ms = self
704            .request_timeout
705            .map(|d| d.as_millis() as u64)
706            .unwrap_or(0);
707        loop_ms.saturating_add(self.max_iterations as u64 * req_ms)
708    }
709
710    /// Return `true` if the configured model name equals `name` (case-sensitive).
711    ///
712    /// A convenience predicate for conditional logic that branches on the model
713    /// being used, e.g. choosing different prompt strategies per model family.
714    pub fn model_is(&self, name: &str) -> bool {
715        self.model == name
716    }
717
718    /// Return the number of whitespace-delimited words in `system_prompt`.
719    ///
720    /// Returns `0` for an empty prompt.
721    pub fn system_prompt_word_count(&self) -> usize {
722        self.system_prompt.split_whitespace().count()
723    }
724
725    /// Return the number of iterations still available after `steps_done`
726    /// steps have been completed.
727    ///
728    /// Saturates at `0` — never returns a negative-equivalent value even if
729    /// `steps_done` exceeds `max_iterations`.
730    pub fn iteration_budget_remaining(&self, steps_done: usize) -> usize {
731        self.max_iterations.saturating_sub(steps_done)
732    }
733
734    /// Return `true` if this config is "minimal" — no system prompt and only
735    /// one allowed iteration.
736    ///
737    /// Useful as a quick sanity-check predicate in tests and diagnostics.
738    pub fn is_minimal(&self) -> bool {
739        self.system_prompt.trim().is_empty() && self.max_iterations == 1
740    }
741
742    /// Return `true` if the configured model name starts with `prefix`.
743    ///
744    /// Useful for branching logic based on provider family without an exact
745    /// string match (e.g. `config.model_starts_with("claude")` or
746    /// `config.model_starts_with("gpt")`).
747    pub fn model_starts_with(&self, prefix: &str) -> bool {
748        self.model.starts_with(prefix)
749    }
750
751    /// Return `true` if `steps_done` meets or exceeds `max_iterations`.
752    ///
753    /// Useful as a termination guard in agent loop implementations.
754    pub fn exceeds_iteration_limit(&self, steps_done: usize) -> bool {
755        steps_done >= self.max_iterations
756    }
757
758    /// Return `true` if a token budget is configured via either `max_tokens`
759    /// or `max_context_chars`.
760    ///
761    /// When `false` the agent will not enforce a token ceiling, which may
762    /// cause silent overruns for very long conversations.
763    pub fn token_budget_configured(&self) -> bool {
764        self.max_tokens.is_some() || self.max_context_chars.is_some()
765    }
766
767    /// Return `max_tokens` if set, or `default` otherwise.
768    ///
769    /// Convenience helper for callers that always need a concrete token limit
770    /// and want to fall back to a safe default when none has been configured.
771    pub fn max_tokens_or_default(&self, default: usize) -> usize {
772        self.max_tokens.unwrap_or(default)
773    }
774
775    /// Return the configured temperature, or `1.0` if none has been set.
776    ///
777    /// Provides a safe default for inference calls that always need a concrete
778    /// value without requiring callers to unwrap an `Option<f32>`.
779    pub fn effective_temperature(&self) -> f32 {
780        self.temperature.unwrap_or(1.0)
781    }
782
783    /// Return `true` if the system prompt starts with the given `prefix`.
784    ///
785    /// Useful for routing or classification logic based on prompt preambles.
786    pub fn system_prompt_starts_with(&self, prefix: &str) -> bool {
787        self.system_prompt.starts_with(prefix)
788    }
789
790    /// Return `true` if `max_iterations` is strictly greater than `n`.
791    ///
792    /// Handy for guard conditions: e.g. `config.max_iterations_above(1)` checks
793    /// that multi-step reasoning is allowed.
794    pub fn max_iterations_above(&self, n: usize) -> bool {
795        self.max_iterations > n
796    }
797
798    /// Return `true` if any configured stop sequence equals `s`.
799    ///
800    /// Returns `false` when no stop sequences have been configured.
801    pub fn stop_sequences_contain(&self, s: &str) -> bool {
802        self.stop_sequences.iter().any(|seq| seq == s)
803    }
804
805    /// Return the byte length of the system prompt string.
806    ///
807    /// Returns `0` when no system prompt has been configured (the default is an
808    /// empty string).
809    pub fn system_prompt_byte_len(&self) -> usize {
810        self.system_prompt.len()
811    }
812
813    /// Return `true` if a temperature has been configured **and** it falls
814    /// within the valid range `[0.0, 2.0]` (inclusive).
815    ///
816    /// Returns `false` when no temperature has been set.
817    pub fn has_valid_temperature(&self) -> bool {
818        self.temperature.map_or(false, |t| (0.0..=2.0).contains(&t))
819    }
820}
821
822// ── ToolSpec ──────────────────────────────────────────────────────────────────
823
824/// Describes and implements a single callable tool.
825pub struct ToolSpec {
826    /// Short identifier used in action strings (e.g. "search").
827    pub name: String,
828    /// Human-readable description passed to the model as part of the system prompt.
829    pub description: String,
830    /// Async handler: receives JSON arguments, returns a future resolving to a JSON result.
831    pub(crate) handler: AsyncToolHandler,
832    /// Field names that must be present in the JSON args object.
833    /// Empty means no validation is performed.
834    pub required_fields: Vec<String>,
835    /// Custom argument validators run after `required_fields` and before the handler.
836    /// All validators must pass; the first failure short-circuits execution.
837    pub validators: Vec<Box<dyn ToolValidator>>,
838    /// Optional per-tool circuit breaker.
839    #[cfg(feature = "orchestrator")]
840    pub circuit_breaker: Option<Arc<crate::orchestrator::CircuitBreaker>>,
841}
842
843impl std::fmt::Debug for ToolSpec {
844    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
845        let mut s = f.debug_struct("ToolSpec");
846        s.field("name", &self.name)
847            .field("description", &self.description)
848            .field("required_fields", &self.required_fields);
849        #[cfg(feature = "orchestrator")]
850        s.field("has_circuit_breaker", &self.circuit_breaker.is_some());
851        s.finish()
852    }
853}
854
855impl ToolSpec {
856    /// Construct a new `ToolSpec` from a synchronous handler closure.
857    /// The closure is wrapped in an `async move` block automatically.
858    pub fn new(
859        name: impl Into<String>,
860        description: impl Into<String>,
861        handler: impl Fn(Value) -> Value + Send + Sync + 'static,
862    ) -> Self {
863        Self {
864            name: name.into(),
865            description: description.into(),
866            handler: Box::new(move |args| {
867                let result = handler(args);
868                Box::pin(async move { result })
869            }),
870            required_fields: Vec::new(),
871            validators: Vec::new(),
872            #[cfg(feature = "orchestrator")]
873            circuit_breaker: None,
874        }
875    }
876
877    /// Construct a new `ToolSpec` from an async handler closure.
878    pub fn new_async(
879        name: impl Into<String>,
880        description: impl Into<String>,
881        handler: impl Fn(Value) -> AsyncToolFuture + Send + Sync + 'static,
882    ) -> Self {
883        Self {
884            name: name.into(),
885            description: description.into(),
886            handler: Box::new(handler),
887            required_fields: Vec::new(),
888            validators: Vec::new(),
889            #[cfg(feature = "orchestrator")]
890            circuit_breaker: None,
891        }
892    }
893
894    /// Construct a new `ToolSpec` from a synchronous fallible handler closure.
895    /// `Err(msg)` is converted to `{"error": msg, "ok": false}`.
896    pub fn new_fallible(
897        name: impl Into<String>,
898        description: impl Into<String>,
899        handler: impl Fn(Value) -> Result<Value, String> + Send + Sync + 'static,
900    ) -> Self {
901        Self {
902            name: name.into(),
903            description: description.into(),
904            handler: Box::new(move |args| {
905                let result = handler(args);
906                let value = match result {
907                    Ok(v) => v,
908                    Err(msg) => serde_json::json!({"error": msg, "ok": false}),
909                };
910                Box::pin(async move { value })
911            }),
912            required_fields: Vec::new(),
913            validators: Vec::new(),
914            #[cfg(feature = "orchestrator")]
915            circuit_breaker: None,
916        }
917    }
918
919    /// Construct a new `ToolSpec` from an async fallible handler closure.
920    /// `Err(msg)` is converted to `{"error": msg, "ok": false}`.
921    pub fn new_async_fallible(
922        name: impl Into<String>,
923        description: impl Into<String>,
924        handler: impl Fn(Value) -> AsyncToolResultFuture + Send + Sync + 'static,
925    ) -> Self {
926        Self {
927            name: name.into(),
928            description: description.into(),
929            handler: Box::new(move |args| {
930                let fut = handler(args);
931                Box::pin(async move {
932                    match fut.await {
933                        Ok(v) => v,
934                        Err(msg) => serde_json::json!({"error": msg, "ok": false}),
935                    }
936                })
937            }),
938            required_fields: Vec::new(),
939            validators: Vec::new(),
940            #[cfg(feature = "orchestrator")]
941            circuit_breaker: None,
942        }
943    }
944
945    /// Set the required fields that must be present in the JSON args object.
946    ///
947    /// Accepts any iterable of string-like values so callers can pass
948    /// `&["field1", "field2"]`, `vec!["f".to_string()]`, or any other
949    /// `IntoIterator<Item: Into<String>>` without manual conversion.
950    pub fn with_required_fields(
951        mut self,
952        fields: impl IntoIterator<Item = impl Into<String>>,
953    ) -> Self {
954        self.required_fields = fields.into_iter().map(Into::into).collect();
955        self
956    }
957
958    /// Attach custom argument validators.
959    ///
960    /// Validators run after `required_fields` checks and before the handler.
961    /// The first failing validator short-circuits execution.
962    pub fn with_validators(mut self, validators: Vec<Box<dyn ToolValidator>>) -> Self {
963        self.validators = validators;
964        self
965    }
966
967    /// Attach a circuit breaker to this tool spec.
968    #[cfg(feature = "orchestrator")]
969    pub fn with_circuit_breaker(mut self, cb: Arc<crate::orchestrator::CircuitBreaker>) -> Self {
970        self.circuit_breaker = Some(cb);
971        self
972    }
973
974    /// Override the human-readable description after construction.
975    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
976        self.description = desc.into();
977        self
978    }
979
980    /// Override the tool name after construction.
981    pub fn with_name(mut self, name: impl Into<String>) -> Self {
982        self.name = name.into();
983        self
984    }
985
986    /// Return the number of required fields configured for this tool.
987    pub fn required_field_count(&self) -> usize {
988        self.required_fields.len()
989    }
990
991    /// Return `true` if this tool requires at least one field to be present in its args.
992    pub fn has_required_fields(&self) -> bool {
993        !self.required_fields.is_empty()
994    }
995
996    /// Return `true` if this tool has at least one custom argument validator attached.
997    pub fn has_validators(&self) -> bool {
998        !self.validators.is_empty()
999    }
1000
1001    /// Invoke the tool with the given JSON arguments.
1002    pub async fn call(&self, args: Value) -> Value {
1003        (self.handler)(args).await
1004    }
1005}
1006
1007// ── ToolCache ─────────────────────────────────────────────────────────────────
1008
1009/// Cache for tool call results.
1010///
1011/// Implement to deduplicate repeated identical tool calls within a single
1012/// [`ReActLoop::run`] invocation.
1013///
1014/// ## Cache key
1015/// Implementations should key on `(tool_name, args)`.  The `args` value is the
1016/// full parsed JSON object passed to the tool.
1017///
1018/// ## Thread safety
1019/// The trait is `Send + Sync`, so implementations must be safe to share across
1020/// threads.  Wrap mutable state in a `Mutex` or use lock-free atomics.
1021///
1022/// ## TTL
1023/// TTL semantics are implementation-defined.  A simple in-memory cache may
1024/// keep results for the lifetime of the [`ReActLoop::run`] call; a distributed
1025/// cache may use Redis with explicit expiry.
1026///
1027/// ## Lifetime
1028/// A cache instance is attached to a `ToolRegistry` and lives for the lifetime
1029/// of that registry.  Results are **not** automatically cleared between
1030/// `ReActLoop::run` calls — clear the cache explicitly if cross-run dedup is
1031/// not desired.
1032pub trait ToolCache: Send + Sync {
1033    /// Look up a cached result for `(tool_name, args)`.
1034    fn get(&self, tool_name: &str, args: &serde_json::Value) -> Option<serde_json::Value>;
1035    /// Store a result for `(tool_name, args)`.
1036    fn set(&self, tool_name: &str, args: &serde_json::Value, result: serde_json::Value);
1037}
1038
1039// ── InMemoryToolCache ─────────────────────────────────────────────────────────
1040
1041/// Inner state for [`InMemoryToolCache`], tracking insertion order for eviction.
1042struct ToolCacheInner {
1043    map: HashMap<(String, String), serde_json::Value>,
1044    /// Insertion-ordered keys for FIFO eviction.
1045    order: std::collections::VecDeque<(String, String)>,
1046}
1047
1048/// A simple in-memory [`ToolCache`] backed by a `Mutex<HashMap>`.
1049///
1050/// Caches tool results keyed on `(tool_name, args_json_string)`.
1051/// Optionally bounded by a maximum number of entries; the oldest entry is
1052/// evicted once the cap is exceeded.
1053///
1054/// # Example
1055/// ```rust,ignore
1056/// use std::sync::Arc;
1057/// use llm_agent_runtime::agent::{InMemoryToolCache, ToolRegistry};
1058///
1059/// let cache = Arc::new(InMemoryToolCache::new());
1060/// let registry = ToolRegistry::new().with_cache(cache);
1061/// ```
1062pub struct InMemoryToolCache {
1063    inner: std::sync::Mutex<ToolCacheInner>,
1064    max_entries: Option<usize>,
1065}
1066
1067impl std::fmt::Debug for InMemoryToolCache {
1068    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1069        let len = self.len();
1070        f.debug_struct("InMemoryToolCache")
1071            .field("entries", &len)
1072            .field("max_entries", &self.max_entries)
1073            .finish()
1074    }
1075}
1076
1077impl InMemoryToolCache {
1078    /// Create a new unbounded cache.
1079    pub fn new() -> Self {
1080        Self {
1081            inner: std::sync::Mutex::new(ToolCacheInner {
1082                map: HashMap::new(),
1083                order: std::collections::VecDeque::new(),
1084            }),
1085            max_entries: None,
1086        }
1087    }
1088
1089    /// Create a cache that evicts the oldest entry once `max` entries are reached.
1090    pub fn with_max_entries(max: usize) -> Self {
1091        Self {
1092            inner: std::sync::Mutex::new(ToolCacheInner {
1093                map: HashMap::new(),
1094                order: std::collections::VecDeque::new(),
1095            }),
1096            max_entries: Some(max),
1097        }
1098    }
1099
1100    /// Remove all cached entries.
1101    pub fn clear(&self) {
1102        if let Ok(mut inner) = self.inner.lock() {
1103            inner.map.clear();
1104            inner.order.clear();
1105        }
1106    }
1107
1108    /// Return the number of cached entries.
1109    pub fn len(&self) -> usize {
1110        self.inner.lock().map(|s| s.map.len()).unwrap_or(0)
1111    }
1112
1113    /// Return `true` if the cache is empty.
1114    pub fn is_empty(&self) -> bool {
1115        self.len() == 0
1116    }
1117
1118    /// Return `true` if a cached result exists for `(tool_name, args)`.
1119    pub fn contains(&self, tool_name: &str, args: &serde_json::Value) -> bool {
1120        let key = (tool_name.to_owned(), args.to_string());
1121        self.inner
1122            .lock()
1123            .map(|s| s.map.contains_key(&key))
1124            .unwrap_or(false)
1125    }
1126
1127    /// Remove the cached result for `(tool_name, args)`.  Returns `true` if found.
1128    pub fn remove(&self, tool_name: &str, args: &serde_json::Value) -> bool {
1129        let key = (tool_name.to_owned(), args.to_string());
1130        if let Ok(mut inner) = self.inner.lock() {
1131            if inner.map.remove(&key).is_some() {
1132                inner.order.retain(|k| k != &key);
1133                return true;
1134            }
1135        }
1136        false
1137    }
1138
1139    /// Return the configured maximum capacity, or `None` if the cache is unbounded.
1140    pub fn capacity(&self) -> Option<usize> {
1141        self.max_entries
1142    }
1143}
1144
1145impl Default for InMemoryToolCache {
1146    fn default() -> Self {
1147        Self::new()
1148    }
1149}
1150
1151impl ToolCache for InMemoryToolCache {
1152    fn get(&self, tool_name: &str, args: &serde_json::Value) -> Option<serde_json::Value> {
1153        let key = (tool_name.to_owned(), args.to_string());
1154        self.inner.lock().ok()?.map.get(&key).cloned()
1155    }
1156
1157    fn set(&self, tool_name: &str, args: &serde_json::Value, result: serde_json::Value) {
1158        let key = (tool_name.to_owned(), args.to_string());
1159        if let Ok(mut inner) = self.inner.lock() {
1160            if !inner.map.contains_key(&key) {
1161                inner.order.push_back(key.clone());
1162            }
1163            inner.map.insert(key, result);
1164            if let Some(max) = self.max_entries {
1165                while inner.map.len() > max {
1166                    if let Some(oldest) = inner.order.pop_front() {
1167                        inner.map.remove(&oldest);
1168                    }
1169                }
1170            }
1171        }
1172    }
1173}
1174
1175// ── ToolRegistry ──────────────────────────────────────────────────────────────
1176
1177/// Registry of available tools for the agent loop.
1178pub struct ToolRegistry {
1179    tools: HashMap<String, ToolSpec>,
1180    /// Optional tool result cache.
1181    cache: Option<Arc<dyn ToolCache>>,
1182}
1183
1184impl std::fmt::Debug for ToolRegistry {
1185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1186        f.debug_struct("ToolRegistry")
1187            .field("tools", &self.tools.keys().collect::<Vec<_>>())
1188            .field("has_cache", &self.cache.is_some())
1189            .finish()
1190    }
1191}
1192
1193impl Default for ToolRegistry {
1194    fn default() -> Self {
1195        Self::new()
1196    }
1197}
1198
1199impl ToolRegistry {
1200    /// Create a new empty registry.
1201    pub fn new() -> Self {
1202        Self {
1203            tools: HashMap::new(),
1204            cache: None,
1205        }
1206    }
1207
1208    /// Attach a tool result cache.
1209    pub fn with_cache(mut self, cache: Arc<dyn ToolCache>) -> Self {
1210        self.cache = Some(cache);
1211        self
1212    }
1213
1214    /// Register a tool. Overwrites any existing tool with the same name.
1215    pub fn register(&mut self, spec: ToolSpec) {
1216        self.tools.insert(spec.name.clone(), spec);
1217    }
1218
1219    /// Register multiple tools at once.
1220    ///
1221    /// Equivalent to calling [`register`] for each spec in order.  Duplicate
1222    /// names overwrite earlier entries.
1223    ///
1224    /// [`register`]: ToolRegistry::register
1225    pub fn register_tools(&mut self, specs: impl IntoIterator<Item = ToolSpec>) {
1226        for spec in specs {
1227            self.register(spec);
1228        }
1229    }
1230
1231    /// Fluent builder: register a tool and return `self`.
1232    ///
1233    /// Allows chaining multiple registrations:
1234    /// ```no_run
1235    /// use llm_agent_runtime::agent::{ToolRegistry, ToolSpec};
1236    ///
1237    /// let registry = ToolRegistry::new()
1238    ///     .with_tool(ToolSpec::new("search", "Search", |args| args.clone()))
1239    ///     .with_tool(ToolSpec::new("calc", "Calculate", |args| args.clone()));
1240    /// ```
1241    pub fn with_tool(mut self, spec: ToolSpec) -> Self {
1242        self.register(spec);
1243        self
1244    }
1245
1246    /// Call a tool by name.
1247    ///
1248    /// # Errors
1249    /// - `AgentRuntimeError::AgentLoop` — tool not found, required field missing, or
1250    ///   custom validator rejected the arguments
1251    /// - `AgentRuntimeError::CircuitOpen` — the tool's circuit breaker is open
1252    ///   (only possible when the `orchestrator` feature is enabled)
1253    #[tracing::instrument(skip_all, fields(tool_name = %name))]
1254    pub async fn call(&self, name: &str, args: Value) -> Result<Value, AgentRuntimeError> {
1255        let spec = self.tools.get(name).ok_or_else(|| {
1256            let mut suggestion = String::new();
1257            let names = self.tool_names();
1258            if !names.is_empty() {
1259                if let Some((closest, dist)) = names
1260                    .iter()
1261                    .map(|n| (n, levenshtein(name, n)))
1262                    .min_by_key(|(_, d)| *d)
1263                {
1264                    if dist <= 3 {
1265                        suggestion = format!(" (did you mean '{closest}'?)");
1266                    }
1267                }
1268            }
1269            AgentRuntimeError::AgentLoop(format!("tool '{name}' not found{suggestion}"))
1270        })?;
1271
1272        // Item 3 — required field validation
1273        if !spec.required_fields.is_empty() {
1274            if let Some(obj) = args.as_object() {
1275                for field in &spec.required_fields {
1276                    if !obj.contains_key(field) {
1277                        return Err(AgentRuntimeError::AgentLoop(format!(
1278                            "tool '{}' missing required field '{}'",
1279                            name, field
1280                        )));
1281                    }
1282                }
1283            } else {
1284                return Err(AgentRuntimeError::AgentLoop(format!(
1285                    "tool '{}' requires JSON object args, got {}",
1286                    name, args
1287                )));
1288            }
1289        }
1290
1291        // Custom validators.
1292        for validator in &spec.validators {
1293            validator.validate(&args)?;
1294        }
1295
1296        // Per-tool circuit breaker check.
1297        #[cfg(feature = "orchestrator")]
1298        if let Some(ref cb) = spec.circuit_breaker {
1299            use crate::orchestrator::CircuitState;
1300            if let Ok(CircuitState::Open { .. }) = cb.state() {
1301                return Err(AgentRuntimeError::CircuitOpen {
1302                    service: format!("tool:{}", name),
1303                });
1304            }
1305        }
1306
1307        // Check cache before invoking handler.
1308        if let Some(ref cache) = self.cache {
1309            if let Some(cached) = cache.get(name, &args) {
1310                return Ok(cached);
1311            }
1312        }
1313
1314        let result = spec.call(args.clone()).await;
1315
1316        // Update circuit breaker based on the tool's result.
1317        // Tools that embed errors as JSON use {"ok": false}; treat those as
1318        // circuit-breaker failures so the breaker can actually open.
1319        #[cfg(feature = "orchestrator")]
1320        if let Some(ref cb) = spec.circuit_breaker {
1321            let is_failure = result
1322                .get("ok")
1323                .and_then(|v| v.as_bool())
1324                .is_some_and(|ok| !ok);
1325            if is_failure {
1326                cb.record_failure();
1327            } else {
1328                cb.record_success();
1329            }
1330        }
1331
1332        // Store result in cache.
1333        if let Some(ref cache) = self.cache {
1334            cache.set(name, &args, result.clone());
1335        }
1336
1337        Ok(result)
1338    }
1339
1340    /// Look up a registered tool by name.  Returns `None` if not registered.
1341    pub fn get(&self, name: &str) -> Option<&ToolSpec> {
1342        self.tools.get(name)
1343    }
1344
1345    /// Return `true` if a tool with the given name is registered.
1346    pub fn has_tool(&self, name: &str) -> bool {
1347        self.tools.contains_key(name)
1348    }
1349
1350    /// Remove a tool by name.  Returns `true` if the tool was registered.
1351    pub fn unregister(&mut self, name: &str) -> bool {
1352        self.tools.remove(name).is_some()
1353    }
1354
1355    /// Return the list of registered tool names.
1356    pub fn tool_names(&self) -> Vec<&str> {
1357        self.tools.keys().map(|s| s.as_str()).collect()
1358    }
1359
1360    /// Return all registered tool names as owned `String`s.
1361    ///
1362    /// Unlike [`tool_names`] this does not borrow `self`, making the result
1363    /// usable after the registry is moved or mutated.
1364    ///
1365    /// [`tool_names`]: ToolRegistry::tool_names
1366    pub fn tool_names_owned(&self) -> Vec<String> {
1367        self.tools.keys().cloned().collect()
1368    }
1369
1370    /// Return all registered tool names sorted alphabetically.
1371    ///
1372    /// Useful for deterministic output in help text, diagnostics, or tests.
1373    pub fn all_tool_names(&self) -> Vec<String> {
1374        let mut names: Vec<String> = self.tools.keys().cloned().collect();
1375        names.sort();
1376        names
1377    }
1378
1379    /// Return references to all registered `ToolSpec`s.
1380    pub fn tool_specs(&self) -> Vec<&ToolSpec> {
1381        self.tools.values().collect()
1382    }
1383
1384    /// Return references to all `ToolSpec`s that satisfy the given predicate.
1385    ///
1386    /// # Example
1387    /// ```rust
1388    /// # use llm_agent_runtime::agent::ToolRegistry;
1389    /// let registry = ToolRegistry::new();
1390    /// let long_desc: Vec<_> = registry.filter_tools(|s| s.description.len() > 20);
1391    /// ```
1392    pub fn filter_tools<F: Fn(&ToolSpec) -> bool>(&self, pred: F) -> Vec<&ToolSpec> {
1393        self.tools.values().filter(|s| pred(s)).collect()
1394    }
1395
1396    /// Rename a registered tool from `old_name` to `new_name`.
1397    ///
1398    /// The tool's `name` field and its registry key are both updated.
1399    /// Returns `true` if the tool was found and renamed, `false` if `old_name`
1400    /// is not registered.  If `new_name` is already registered, it is
1401    /// overwritten.
1402    pub fn rename_tool(&mut self, old_name: &str, new_name: impl Into<String>) -> bool {
1403        let Some(mut spec) = self.tools.remove(old_name) else {
1404            return false;
1405        };
1406        let new_name = new_name.into();
1407        spec.name = new_name.clone();
1408        self.tools.insert(new_name, spec);
1409        true
1410    }
1411
1412    /// Return the number of registered tools.
1413    pub fn tool_count(&self) -> usize {
1414        self.tools.len()
1415    }
1416
1417    /// Return `true` if no tools are registered.
1418    pub fn is_empty(&self) -> bool {
1419        self.tools.is_empty()
1420    }
1421
1422    /// Remove all registered tools.
1423    ///
1424    /// Useful for resetting the registry between test runs or for dynamic
1425    /// agent reconfiguration.
1426    pub fn clear(&mut self) {
1427        self.tools.clear();
1428    }
1429
1430    /// Remove a tool from the registry by name.
1431    ///
1432    /// Returns `Some(spec)` if the tool was registered, `None` if not found.
1433    pub fn remove(&mut self, name: &str) -> Option<ToolSpec> {
1434        self.tools.remove(name)
1435    }
1436
1437    /// Return `true` if a tool with the given name is registered.
1438    pub fn contains(&self, name: &str) -> bool {
1439        self.tools.contains_key(name)
1440    }
1441
1442    /// Return `(name, description)` pairs for all registered tools, sorted by name.
1443    ///
1444    /// Useful for generating help text or logging the tool set at startup.
1445    pub fn descriptions(&self) -> Vec<(&str, &str)> {
1446        let mut pairs: Vec<(&str, &str)> = self
1447            .tools
1448            .values()
1449            .map(|s| (s.name.as_str(), s.description.as_str()))
1450            .collect();
1451        pairs.sort_unstable_by_key(|(name, _)| *name);
1452        pairs
1453    }
1454
1455    /// Return references to all tool specs whose description contains
1456    /// `keyword` (case-insensitive).
1457    pub fn find_by_description_keyword(&self, keyword: &str) -> Vec<&ToolSpec> {
1458        let lower = keyword.to_ascii_lowercase();
1459        self.tools
1460            .values()
1461            .filter(|s| s.description.to_ascii_lowercase().contains(&lower))
1462            .collect()
1463    }
1464
1465    /// Return the number of tools that have at least one required field.
1466    pub fn tool_count_with_required_fields(&self) -> usize {
1467        self.tools.values().filter(|s| s.has_required_fields()).count()
1468    }
1469
1470    /// Return the number of registered tools that have at least one attached validator.
1471    ///
1472    /// Complements [`tool_count_with_required_fields`]; a tool can have validators
1473    /// without any required field declarations (for cross-field or range checks).
1474    ///
1475    /// [`tool_count_with_required_fields`]: ToolRegistry::tool_count_with_required_fields
1476    pub fn tool_count_with_validators(&self) -> usize {
1477        self.tools.values().filter(|s| s.has_validators()).count()
1478    }
1479
1480    /// Return the names of all registered tools, sorted alphabetically.
1481    pub fn names(&self) -> Vec<&str> {
1482        let mut names: Vec<&str> = self.tools.keys().map(|k| k.as_str()).collect();
1483        names.sort_unstable();
1484        names
1485    }
1486
1487    /// Return the names of all registered tools whose name starts with `prefix`,
1488    /// sorted alphabetically.
1489    pub fn tool_names_starting_with(&self, prefix: &str) -> Vec<&str> {
1490        let mut names: Vec<&str> = self
1491            .tools
1492            .keys()
1493            .filter(|k| k.starts_with(prefix))
1494            .map(|k| k.as_str())
1495            .collect();
1496        names.sort_unstable();
1497        names
1498    }
1499
1500    /// Return the description of the tool with the given `name`, or `None` if
1501    /// no such tool is registered.
1502    pub fn description_for(&self, name: &str) -> Option<&str> {
1503        self.tools.get(name).map(|s| s.description.as_str())
1504    }
1505
1506    /// Return the count of tools whose description contains `keyword`
1507    /// (case-insensitive).
1508    pub fn count_with_description_containing(&self, keyword: &str) -> usize {
1509        let lower = keyword.to_ascii_lowercase();
1510        self.tools
1511            .values()
1512            .filter(|s| s.description.to_ascii_lowercase().contains(&lower))
1513            .count()
1514    }
1515
1516    /// Remove all registered tools.
1517    pub fn unregister_all(&mut self) {
1518        self.tools.clear();
1519    }
1520
1521    /// Return a sorted list of tool names that contain `substring` (case-insensitive).
1522    pub fn names_containing(&self, substring: &str) -> Vec<&str> {
1523        let sub = substring.to_ascii_lowercase();
1524        let mut names: Vec<&str> = self
1525            .tools
1526            .keys()
1527            .filter(|name| name.to_ascii_lowercase().contains(&sub))
1528            .map(|s| s.as_str())
1529            .collect();
1530        names.sort_unstable();
1531        names
1532    }
1533
1534    /// Return the description of the tool with the shortest description string.
1535    ///
1536    /// Returns `None` if the registry is empty.
1537    pub fn shortest_description(&self) -> Option<&str> {
1538        self.tools
1539            .values()
1540            .min_by_key(|s| s.description.len())
1541            .map(|s| s.description.as_str())
1542    }
1543
1544    /// Return the description of the tool with the longest description string.
1545    ///
1546    /// Returns `None` if the registry is empty.
1547    pub fn longest_description(&self) -> Option<&str> {
1548        self.tools
1549            .values()
1550            .max_by_key(|s| s.description.len())
1551            .map(|s| s.description.as_str())
1552    }
1553
1554    /// Return a sorted list of all tool descriptions.
1555    pub fn all_descriptions(&self) -> Vec<&str> {
1556        let mut descs: Vec<&str> = self.tools.values().map(|s| s.description.as_str()).collect();
1557        descs.sort_unstable();
1558        descs
1559    }
1560
1561    /// Return the names of tools whose description contains `keyword` (case-insensitive).
1562    pub fn tool_names_with_keyword(&self, keyword: &str) -> Vec<&str> {
1563        let kw = keyword.to_ascii_lowercase();
1564        self.tools
1565            .values()
1566            .filter(|s| s.description.to_ascii_lowercase().contains(&kw))
1567            .map(|s| s.name.as_str())
1568            .collect()
1569    }
1570
1571    /// Return the mean byte length of all tool descriptions.
1572    ///
1573    /// Returns `0.0` if the registry is empty.
1574    pub fn avg_description_length(&self) -> f64 {
1575        if self.tools.is_empty() {
1576            return 0.0;
1577        }
1578        let total: usize = self.tools.values().map(|s| s.description.len()).sum();
1579        total as f64 / self.tools.len() as f64
1580    }
1581
1582    /// Return all registered tool names in ascending sorted order.
1583    pub fn tool_names_sorted(&self) -> Vec<&str> {
1584        let mut names: Vec<&str> = self.tools.keys().map(|k| k.as_str()).collect();
1585        names.sort_unstable();
1586        names
1587    }
1588
1589    /// Return the count of tools whose description contains `keyword` (case-insensitive).
1590    pub fn description_contains_count(&self, keyword: &str) -> usize {
1591        let kw = keyword.to_ascii_lowercase();
1592        self.tools
1593            .values()
1594            .filter(|s| s.description.to_ascii_lowercase().contains(&kw))
1595            .count()
1596    }
1597
1598    /// Return the total byte length of all tool description strings combined.
1599    ///
1600    /// Useful as a rough measure of how much context the tool registry will
1601    /// contribute to a prompt when all descriptions are serialized together.
1602    pub fn total_description_bytes(&self) -> usize {
1603        self.tools.values().map(|s| s.description.len()).sum()
1604    }
1605
1606    /// Return the byte length of the shortest tool description, or `0` if the
1607    /// registry is empty.
1608    pub fn shortest_description_length(&self) -> usize {
1609        self.tools
1610            .values()
1611            .map(|s| s.description.len())
1612            .min()
1613            .unwrap_or(0)
1614    }
1615
1616    /// Return the byte length of the longest tool description, or `0` if the
1617    /// registry is empty.
1618    pub fn longest_description_length(&self) -> usize {
1619        self.tools
1620            .values()
1621            .map(|s| s.description.len())
1622            .max()
1623            .unwrap_or(0)
1624    }
1625
1626    /// Return the count of tools whose description byte length is strictly
1627    /// greater than `min_bytes`.
1628    ///
1629    /// Returns `0` for an empty registry or when no description exceeds
1630    /// `min_bytes`.
1631    pub fn tool_count_above_desc_bytes(&self, min_bytes: usize) -> usize {
1632        self.tools
1633            .values()
1634            .filter(|s| s.description.len() > min_bytes)
1635            .count()
1636    }
1637
1638    /// Return references to all `ToolSpec`s that list `field` in their
1639    /// `required_fields`.
1640    ///
1641    /// Returns an empty `Vec` when no tools declare `field` as required.
1642    pub fn tools_with_required_field(&self, field: &str) -> Vec<&ToolSpec> {
1643        self.tools
1644            .values()
1645            .filter(|s| s.required_fields.iter().any(|f| f == field))
1646            .collect()
1647    }
1648
1649    /// Return references to all `ToolSpec`s that have no required fields.
1650    ///
1651    /// Returns an empty `Vec` when every registered tool declares at least one
1652    /// required field, or when the registry is empty.
1653    pub fn tools_without_required_fields(&self) -> Vec<&ToolSpec> {
1654        self.tools
1655            .values()
1656            .filter(|s| s.required_fields.is_empty())
1657            .collect()
1658    }
1659
1660    /// Return the average number of required fields per registered tool.
1661    ///
1662    /// Returns `0.0` for an empty registry.
1663    pub fn avg_required_fields_count(&self) -> f64 {
1664        if self.tools.is_empty() {
1665            return 0.0;
1666        }
1667        let total: usize = self.tools.values().map(|s| s.required_fields.len()).sum();
1668        total as f64 / self.tools.len() as f64
1669    }
1670
1671    /// Return the total word count across all tool descriptions.
1672    ///
1673    /// Words are split on ASCII whitespace.  Returns `0` for an empty
1674    /// registry or when all descriptions are blank.
1675    pub fn tool_descriptions_total_words(&self) -> usize {
1676        self.tools
1677            .values()
1678            .map(|spec| spec.description.split_ascii_whitespace().count())
1679            .sum()
1680    }
1681
1682    /// Return `true` if any registered tool has a blank description.
1683    ///
1684    /// A blank description is one that is empty or contains only whitespace.
1685    /// Returns `false` for an empty registry (vacuously no blank descriptions).
1686    pub fn has_tools_with_empty_descriptions(&self) -> bool {
1687        self.tools.values().any(|s| s.description.trim().is_empty())
1688    }
1689
1690    /// Return the total number of required fields across all registered tools.
1691    ///
1692    /// Sums the `required_fields` lengths of every `ToolSpec`.
1693    /// Returns `0` for an empty registry or when no tool has required fields.
1694    pub fn total_required_fields(&self) -> usize {
1695        self.tools.values().map(|s| s.required_fields.len()).sum()
1696    }
1697
1698    /// Return `true` if at least one registered tool's description contains
1699    /// `keyword` (case-sensitive substring search).
1700    pub fn has_tool_with_description_containing(&self, keyword: &str) -> bool {
1701        self.tools.values().any(|s| s.description.contains(keyword))
1702    }
1703
1704    /// Return tool names whose description byte length exceeds `min_bytes`, sorted.
1705    ///
1706    /// Returns an empty `Vec` for an empty registry or when no description
1707    /// exceeds the threshold.
1708    pub fn tools_with_description_longer_than(&self, min_bytes: usize) -> Vec<&str> {
1709        let mut names: Vec<&str> = self
1710            .tools
1711            .values()
1712            .filter(|s| s.description.len() > min_bytes)
1713            .map(|s| s.name.as_str())
1714            .collect();
1715        names.sort_unstable();
1716        names
1717    }
1718
1719    /// Return the byte length of the longest tool description, or `0` when empty.
1720    pub fn max_description_bytes(&self) -> usize {
1721        self.tools.values().map(|s| s.description.len()).max().unwrap_or(0)
1722    }
1723
1724    /// Return the byte length of the shortest tool description, or `0` when empty.
1725    pub fn min_description_bytes(&self) -> usize {
1726        self.tools.values().map(|s| s.description.len()).min().unwrap_or(0)
1727    }
1728
1729    /// Return `true` if any tool description starts with any of the given `prefixes`.
1730    ///
1731    /// Useful for checking whether a set of common description templates is
1732    /// in use (e.g. `"Search"`, `"Write"`, `"Read"`).  Returns `false` for an
1733    /// empty registry or empty `prefixes` slice.
1734    pub fn description_starts_with_any(&self, prefixes: &[&str]) -> bool {
1735        self.tools
1736            .values()
1737            .any(|s| prefixes.iter().any(|p| s.description.starts_with(p)))
1738    }
1739
1740    /// Return a reference to the `ToolSpec` with the most required fields.
1741    ///
1742    /// When multiple tools share the maximum required-field count, the one that
1743    /// sorts first alphabetically by name is returned for deterministic output.
1744    /// Returns `None` for an empty registry.
1745    pub fn tool_with_most_required_fields(&self) -> Option<&ToolSpec> {
1746        self.tools.values().max_by(|a, b| {
1747            a.required_fields
1748                .len()
1749                .cmp(&b.required_fields.len())
1750                .then_with(|| b.name.cmp(&a.name))
1751        })
1752    }
1753
1754    /// Return a reference to the `ToolSpec` with the given `name`, or `None`.
1755    pub fn tool_by_name(&self, name: &str) -> Option<&ToolSpec> {
1756        self.tools.get(name)
1757    }
1758
1759    /// Return the names of all tools that have no validators, sorted alphabetically.
1760    ///
1761    /// Complements [`tool_count_with_validators`] by returning the actual names.
1762    /// Returns an empty `Vec` for an empty registry.
1763    ///
1764    /// [`tool_count_with_validators`]: ToolRegistry::tool_count_with_validators
1765    pub fn tools_without_validators(&self) -> Vec<&str> {
1766        let mut names: Vec<&str> = self
1767            .tools
1768            .values()
1769            .filter(|s| s.validators.is_empty())
1770            .map(|s| s.name.as_str())
1771            .collect();
1772        names.sort_unstable();
1773        names
1774    }
1775
1776    /// Return the names of all tools that have at least one required field,
1777    /// sorted alphabetically.
1778    ///
1779    /// Returns an empty `Vec` when no tools have required fields or the
1780    /// registry is empty.
1781    pub fn tool_names_with_required_fields(&self) -> Vec<&str> {
1782        let mut names: Vec<&str> = self
1783            .tools
1784            .values()
1785            .filter(|s| !s.required_fields.is_empty())
1786            .map(|s| s.name.as_str())
1787            .collect();
1788        names.sort_unstable();
1789        names
1790    }
1791
1792    /// Return `true` if **all** of the given `names` are registered in this
1793    /// registry.
1794    ///
1795    /// Returns `true` for an empty `names` slice (vacuously true).
1796    pub fn has_all_tools(&self, names: &[&str]) -> bool {
1797        names.iter().all(|n| self.tools.contains_key(*n))
1798    }
1799
1800    /// Return the number of tools that have at least one required field defined.
1801    ///
1802    /// Returns `0` for an empty registry.
1803    pub fn tools_with_required_fields_count(&self) -> usize {
1804        self.tools
1805            .values()
1806            .filter(|t| !t.required_fields.is_empty())
1807            .count()
1808    }
1809
1810    /// Return the names of all tools whose name starts with `prefix`, sorted
1811    /// alphabetically.
1812    ///
1813    /// Returns an empty `Vec` for an empty registry or when no tool matches.
1814    pub fn tool_names_with_prefix<'a>(&'a self, prefix: &str) -> Vec<&'a str> {
1815        let mut names: Vec<&str> = self
1816            .tools
1817            .keys()
1818            .filter(|n| n.starts_with(prefix))
1819            .map(|n| n.as_str())
1820            .collect();
1821        names.sort_unstable();
1822        names
1823    }
1824}
1825
1826// ── ReActLoop ─────────────────────────────────────────────────────────────────
1827
1828/// Parses a ReAct response string into a `ReActStep`.
1829///
1830/// Case-insensitive; tolerates spaces around the colon.
1831/// e.g. `Thought :`, `thought:`, `THOUGHT :` all match.
1832///
1833/// **Multi-line sections**: everything between a `Thought:` (or `Action:`)
1834/// header and the next section header is included verbatim, so JSON arguments
1835/// that span multiple lines are captured correctly.
1836pub fn parse_react_step(text: &str) -> Result<ReActStep, AgentRuntimeError> {
1837    // Track which section we are currently appending into.
1838    #[derive(PartialEq)]
1839    enum Section { None, Thought, Action }
1840
1841    let mut thought_lines: Vec<&str> = Vec::new();
1842    let mut action_lines: Vec<&str> = Vec::new();
1843    let mut current = Section::None;
1844
1845    for line in text.lines() {
1846        let trimmed = line.trim();
1847        let lower = trimmed.to_ascii_lowercase();
1848        if lower.starts_with("thought") {
1849            if let Some(colon_pos) = trimmed.find(':') {
1850                current = Section::Thought;
1851                thought_lines.clear();
1852                let first = trimmed[colon_pos + 1..].trim();
1853                if !first.is_empty() {
1854                    thought_lines.push(first);
1855                }
1856                continue;
1857            }
1858        } else if lower.starts_with("action") {
1859            if let Some(colon_pos) = trimmed.find(':') {
1860                current = Section::Action;
1861                action_lines.clear();
1862                let first = trimmed[colon_pos + 1..].trim();
1863                if !first.is_empty() {
1864                    action_lines.push(first);
1865                }
1866                continue;
1867            }
1868        } else if lower.starts_with("observation") {
1869            // Stop accumulating when we hit Observation (model may include it).
1870            current = Section::None;
1871            continue;
1872        }
1873        // Continuation line — append to the current section.
1874        match current {
1875            Section::Thought => thought_lines.push(trimmed),
1876            Section::Action => action_lines.push(trimmed),
1877            Section::None => {}
1878        }
1879    }
1880
1881    let thought = thought_lines.join(" ");
1882    let action = action_lines.join("\n").trim().to_owned();
1883
1884    if thought.is_empty() && action.is_empty() {
1885        return Err(AgentRuntimeError::AgentLoop(
1886            "could not parse ReAct step from response".into(),
1887        ));
1888    }
1889
1890    Ok(ReActStep {
1891        thought,
1892        action,
1893        observation: String::new(),
1894        step_duration_ms: 0,
1895    })
1896}
1897
1898/// The ReAct agent loop.
1899pub struct ReActLoop {
1900    config: AgentConfig,
1901    registry: ToolRegistry,
1902    /// Optional metrics sink; increments `total_tool_calls` / `failed_tool_calls`.
1903    metrics: Option<Arc<RuntimeMetrics>>,
1904    /// Optional persistence backend for per-step checkpointing during the loop.
1905    #[cfg(feature = "persistence")]
1906    checkpoint_backend: Option<(Arc<dyn crate::persistence::PersistenceBackend>, String)>,
1907    /// Optional observer for agent loop events.
1908    observer: Option<Arc<dyn Observer>>,
1909    /// Optional action hook called before each tool dispatch.
1910    action_hook: Option<ActionHook>,
1911}
1912
1913impl std::fmt::Debug for ReActLoop {
1914    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1915        let mut s = f.debug_struct("ReActLoop");
1916        s.field("config", &self.config)
1917            .field("registry", &self.registry)
1918            .field("has_metrics", &self.metrics.is_some())
1919            .field("has_observer", &self.observer.is_some())
1920            .field("has_action_hook", &self.action_hook.is_some());
1921        #[cfg(feature = "persistence")]
1922        s.field("has_checkpoint_backend", &self.checkpoint_backend.is_some());
1923        s.finish()
1924    }
1925}
1926
1927impl ReActLoop {
1928    /// Create a new `ReActLoop` with the given configuration and an empty tool registry.
1929    pub fn new(config: AgentConfig) -> Self {
1930        Self {
1931            config,
1932            registry: ToolRegistry::new(),
1933            metrics: None,
1934            #[cfg(feature = "persistence")]
1935            checkpoint_backend: None,
1936            observer: None,
1937            action_hook: None,
1938        }
1939    }
1940
1941    /// Attach an observer for agent loop events.
1942    pub fn with_observer(mut self, observer: Arc<dyn Observer>) -> Self {
1943        self.observer = Some(observer);
1944        self
1945    }
1946
1947    /// Attach an action hook called before each tool dispatch.
1948    pub fn with_action_hook(mut self, hook: ActionHook) -> Self {
1949        self.action_hook = Some(hook);
1950        self
1951    }
1952
1953    /// Attach a shared `RuntimeMetrics` instance.
1954    ///
1955    /// When set, the loop increments `total_tool_calls` on every tool dispatch
1956    /// and `failed_tool_calls` whenever a tool returns an error observation.
1957    pub fn with_metrics(mut self, metrics: Arc<RuntimeMetrics>) -> Self {
1958        self.metrics = Some(metrics);
1959        self
1960    }
1961
1962    /// Attach a persistence backend for per-step loop checkpointing.
1963    ///
1964    /// After each completed step the current `Vec<ReActStep>` is serialised and
1965    /// saved under the key `"loop:<session_id>:step:<n>"`.  Checkpoint errors
1966    /// are logged but never abort the loop.
1967    #[cfg(feature = "persistence")]
1968    pub fn with_step_checkpoint(
1969        mut self,
1970        backend: Arc<dyn crate::persistence::PersistenceBackend>,
1971        session_id: impl Into<String>,
1972    ) -> Self {
1973        self.checkpoint_backend = Some((backend, session_id.into()));
1974        self
1975    }
1976
1977    /// Return a read-only reference to the tool registry.
1978    pub fn registry(&self) -> &ToolRegistry {
1979        &self.registry
1980    }
1981
1982    /// Return the number of tools currently registered.
1983    ///
1984    /// Shorthand for `loop_.registry().tool_count()`.
1985    pub fn tool_count(&self) -> usize {
1986        self.registry.tool_count()
1987    }
1988
1989    /// Remove a registered tool by name.  Returns `true` if the tool was found.
1990    pub fn unregister_tool(&mut self, name: &str) -> bool {
1991        self.registry.unregister(name)
1992    }
1993
1994    /// Register a tool that the agent loop can invoke.
1995    pub fn register_tool(&mut self, spec: ToolSpec) {
1996        self.registry.register(spec);
1997    }
1998
1999    /// Register multiple tools at once.
2000    ///
2001    /// Equivalent to calling [`register_tool`] for each spec.
2002    ///
2003    /// [`register_tool`]: ReActLoop::register_tool
2004    pub fn register_tools(&mut self, specs: impl IntoIterator<Item = ToolSpec>) {
2005        for spec in specs {
2006            self.registry.register(spec);
2007        }
2008    }
2009
2010    /// Trim `context` to at most `max_chars` characters by dropping the oldest
2011    /// Thought/Action/Observation turns from the **front** while preserving the
2012    /// initial system-prompt + user-turn preamble.
2013    ///
2014    /// Turns are delineated by leading `\nThought:` markers.  If no second
2015    /// turn boundary is found the context is left unchanged.
2016    fn maybe_trim_context(context: &mut String, max_chars: usize) {
2017        while context.len() > max_chars {
2018            // Find the second occurrence of "\nThought:" so we preserve the
2019            // preamble (everything up to the first turn) and drop only the
2020            // oldest appended turn.
2021            let first = context.find("\nThought:");
2022            let second = first.and_then(|pos| {
2023                context[pos + 1..].find("\nThought:").map(|p| pos + 1 + p)
2024            });
2025            if let Some(drop_until) = second {
2026                context.drain(..drop_until);
2027            } else {
2028                break; // Only one turn (or none) — nothing safe to drop.
2029            }
2030        }
2031    }
2032
2033    /// Emit a blocked-action observation string.
2034    fn blocked_observation() -> String {
2035        serde_json::json!({
2036            "ok": false,
2037            "error": "action blocked by reviewer",
2038            "kind": "blocked"
2039        })
2040        .to_string()
2041    }
2042
2043    /// Build the error observation JSON for a failed tool call.
2044    fn error_observation(_tool_name: &str, e: &AgentRuntimeError) -> String {
2045        let kind = match e {
2046            AgentRuntimeError::AgentLoop(msg) if msg.contains("not found") => "not_found",
2047            #[cfg(feature = "orchestrator")]
2048            AgentRuntimeError::CircuitOpen { .. } => "transient",
2049            _ => "permanent",
2050        };
2051        serde_json::json!({ "ok": false, "error": e.to_string(), "kind": kind }).to_string()
2052    }
2053
2054    /// Execute the ReAct loop for the given prompt.
2055    ///
2056    /// # Arguments
2057    /// * `prompt` — user input passed as the initial context
2058    /// * `infer`  — async inference function: receives context string, returns response string
2059    ///
2060    /// # Errors
2061    /// - `AgentRuntimeError::AgentLoop("loop timeout …")` — if `loop_timeout` is configured
2062    ///   and the loop runs past the deadline
2063    /// - `AgentRuntimeError::AgentLoop("max iterations … reached")` — if the loop exhausts
2064    ///   `max_iterations` without emitting `FINAL_ANSWER`
2065    /// - `AgentRuntimeError::AgentLoop("could not parse …")` — if the model response cannot
2066    ///   be parsed into a `ReActStep`
2067    #[tracing::instrument(skip(infer))]
2068    pub async fn run<F, Fut>(
2069        &self,
2070        prompt: &str,
2071        mut infer: F,
2072    ) -> Result<Vec<ReActStep>, AgentRuntimeError>
2073    where
2074        F: FnMut(String) -> Fut,
2075        Fut: Future<Output = String>,
2076    {
2077        let mut steps: Vec<ReActStep> = Vec::new();
2078        let mut context = format!("{}\n\nUser: {}\n", self.config.system_prompt, prompt);
2079
2080        // Pre-compute optional deadline once so that each iteration is O(1).
2081        let deadline = self
2082            .config
2083            .loop_timeout
2084            .map(|d| std::time::Instant::now() + d);
2085
2086        // Observer: on_loop_start
2087        if let Some(ref obs) = self.observer {
2088            obs.on_loop_start(prompt);
2089        }
2090
2091        for iteration in 0..self.config.max_iterations {
2092            let iter_span = tracing::info_span!(
2093                "react_iteration",
2094                iteration = iteration,
2095                model = %self.config.model,
2096            );
2097            let _iter_guard = iter_span.enter();
2098
2099            // Wall-clock timeout check.
2100            if let Some(dl) = deadline {
2101                if std::time::Instant::now() >= dl {
2102                    let ms = self
2103                        .config
2104                        .loop_timeout
2105                        .map(|d| d.as_millis())
2106                        .unwrap_or(0);
2107                    let err = AgentRuntimeError::AgentLoop(format!("loop timeout after {ms} ms"));
2108                    if let Some(ref obs) = self.observer {
2109                        obs.on_error(&err);
2110                        obs.on_loop_end(steps.len());
2111                    }
2112                    return Err(err);
2113                }
2114            }
2115
2116            let step_start = std::time::Instant::now();
2117            let response = infer(context.clone()).await;
2118            let mut step = parse_react_step(&response)?;
2119
2120            tracing::debug!(
2121                step = iteration,
2122                thought = %step.thought,
2123                action = %step.action,
2124                "ReAct iteration"
2125            );
2126
2127            if step.action.to_ascii_uppercase().starts_with("FINAL_ANSWER") {
2128                step.observation = step.action.clone();
2129                step.step_duration_ms = step_start.elapsed().as_millis() as u64;
2130                if let Some(ref m) = self.metrics {
2131                    m.record_step_latency(step.step_duration_ms);
2132                }
2133                if let Some(ref obs) = self.observer {
2134                    obs.on_step(iteration, &step);
2135                }
2136                steps.push(step);
2137                tracing::info!(step = iteration, "FINAL_ANSWER reached");
2138                if let Some(ref obs) = self.observer {
2139                    obs.on_loop_end(steps.len());
2140                }
2141                return Ok(steps);
2142            }
2143
2144            // Item 3 — propagate parse errors rather than silently falling back.
2145            let (tool_name, args) = parse_tool_call(&step.action)?;
2146
2147            tracing::debug!(
2148                step = iteration,
2149                tool_name = %tool_name,
2150                "dispatching tool call"
2151            );
2152
2153            // Action hook check.
2154            if let Some(ref hook) = self.action_hook {
2155                if !hook(tool_name.clone(), args.clone()).await {
2156                    if let Some(ref obs) = self.observer {
2157                        obs.on_action_blocked(&tool_name, &args);
2158                    }
2159                    if let Some(ref m) = self.metrics {
2160                        m.record_tool_call(&tool_name);
2161                        m.record_tool_failure(&tool_name);
2162                    }
2163                    step.observation = Self::blocked_observation();
2164                    step.step_duration_ms = step_start.elapsed().as_millis() as u64;
2165                    if let Some(ref m) = self.metrics {
2166                        m.record_step_latency(step.step_duration_ms);
2167                    }
2168                    let _ = write!(
2169                        context,
2170                        "\nThought: {}\nAction: {}\nObservation: {}\n",
2171                        step.thought, step.action, step.observation
2172                    );
2173                    if let Some(max) = self.config.max_context_chars {
2174                        Self::maybe_trim_context(&mut context, max);
2175                    }
2176                    if let Some(ref obs) = self.observer {
2177                        obs.on_step(iteration, &step);
2178                    }
2179                    steps.push(step);
2180                    continue;
2181                }
2182            }
2183
2184            // Observer: on_tool_call
2185            if let Some(ref obs) = self.observer {
2186                obs.on_tool_call(&tool_name, &args);
2187            }
2188
2189            // Count every tool dispatch (global + per-tool).
2190            if let Some(ref m) = self.metrics {
2191                m.record_tool_call(&tool_name);
2192            }
2193
2194            // Structured error categorization in observation.
2195            let tool_span = tracing::info_span!("tool_dispatch", tool = %tool_name);
2196            let _tool_guard = tool_span.enter();
2197            let observation = match self.registry.call(&tool_name, args).await {
2198                Ok(result) => serde_json::json!({ "ok": true, "data": result }).to_string(),
2199                Err(e) => {
2200                    // Count failed tool calls (global + per-tool).
2201                    if let Some(ref m) = self.metrics {
2202                        m.record_tool_failure(&tool_name);
2203                    }
2204                    Self::error_observation(&tool_name, &e)
2205                }
2206            };
2207
2208            step.observation = observation.clone();
2209            step.step_duration_ms = step_start.elapsed().as_millis() as u64;
2210            if let Some(ref m) = self.metrics {
2211                m.record_step_latency(step.step_duration_ms);
2212            }
2213            let _ = write!(
2214                context,
2215                "\nThought: {}\nAction: {}\nObservation: {}\n",
2216                step.thought, step.action, observation
2217            );
2218            if let Some(max) = self.config.max_context_chars {
2219                Self::maybe_trim_context(&mut context, max);
2220            }
2221            if let Some(ref obs) = self.observer {
2222                obs.on_step(iteration, &step);
2223            }
2224            steps.push(step);
2225
2226            // Item 11 — per-step loop checkpoint (behind feature flag).
2227            #[cfg(feature = "persistence")]
2228            if let Some((ref backend, ref session_id)) = self.checkpoint_backend {
2229                let step_idx = steps.len();
2230                let key = format!("loop:{session_id}:step:{step_idx}");
2231                match serde_json::to_vec(&steps) {
2232                    Ok(bytes) => {
2233                        if let Err(e) = backend.save(&key, &bytes).await {
2234                            tracing::warn!(
2235                                key = %key,
2236                                error = %e,
2237                                "loop step checkpoint save failed"
2238                            );
2239                        }
2240                    }
2241                    Err(e) => {
2242                        tracing::warn!(
2243                            step = step_idx,
2244                            error = %e,
2245                            "loop step checkpoint serialisation failed"
2246                        );
2247                    }
2248                }
2249            }
2250        }
2251
2252        let err = AgentRuntimeError::AgentLoop(format!(
2253            "max iterations ({}) reached without final answer",
2254            self.config.max_iterations
2255        ));
2256        tracing::warn!(
2257            max_iterations = self.config.max_iterations,
2258            "ReAct loop exhausted max iterations without FINAL_ANSWER"
2259        );
2260        if let Some(ref obs) = self.observer {
2261            obs.on_error(&err);
2262            obs.on_loop_end(steps.len());
2263        }
2264        Err(err)
2265    }
2266
2267    /// Execute the ReAct loop using a streaming inference function.
2268    ///
2269    /// Identical to [`run`] except the inference closure returns a
2270    /// `tokio::sync::mpsc::Receiver` that streams token chunks.  All chunks
2271    /// are collected into a single `String` before each iteration's response
2272    /// is parsed.  Stream errors result in an empty partial response (the
2273    /// erroring chunk is silently dropped and a warning is logged).
2274    ///
2275    /// [`run`]: ReActLoop::run
2276    #[tracing::instrument(skip(infer_stream))]
2277    pub async fn run_streaming<F, Fut>(
2278        &self,
2279        prompt: &str,
2280        mut infer_stream: F,
2281    ) -> Result<Vec<ReActStep>, AgentRuntimeError>
2282    where
2283        F: FnMut(String) -> Fut,
2284        Fut: Future<
2285            Output = tokio::sync::mpsc::Receiver<Result<String, AgentRuntimeError>>,
2286        >,
2287    {
2288        self.run(prompt, move |ctx| {
2289            let rx_fut = infer_stream(ctx);
2290            async move {
2291                let mut rx = rx_fut.await;
2292                let mut out = String::new();
2293                while let Some(chunk) = rx.recv().await {
2294                    match chunk {
2295                        Ok(s) => out.push_str(&s),
2296                        Err(e) => {
2297                            tracing::warn!(error = %e, "streaming chunk error; skipping");
2298                        }
2299                    }
2300                }
2301                out
2302            }
2303        })
2304        .await
2305    }
2306}
2307
2308/// Declarative argument validator for a `ToolSpec`.
2309///
2310/// Implement this trait to enforce custom argument constraints (type ranges,
2311/// string patterns, etc.) before the handler is invoked.
2312///
2313/// Validators run **after** `required_fields` checks and **before** the handler.
2314/// The first failing validator short-circuits execution.
2315///
2316/// # Basic Example
2317/// ```no_run
2318/// use llm_agent_runtime::agent::ToolValidator;
2319/// use llm_agent_runtime::AgentRuntimeError;
2320/// use serde_json::Value;
2321///
2322/// struct NonEmptyQuery;
2323/// impl ToolValidator for NonEmptyQuery {
2324///     fn validate(&self, args: &Value) -> Result<(), AgentRuntimeError> {
2325///         let q = args.get("q").and_then(|v| v.as_str()).unwrap_or("");
2326///         if q.is_empty() {
2327///             return Err(AgentRuntimeError::AgentLoop(
2328///                 "tool 'search': q must not be empty".into(),
2329///             ));
2330///         }
2331///         Ok(())
2332///     }
2333/// }
2334/// ```
2335///
2336/// # Advanced Example — Parameterised validator
2337/// ```no_run
2338/// use llm_agent_runtime::agent::{ToolSpec, ToolValidator};
2339/// use llm_agent_runtime::AgentRuntimeError;
2340/// use serde_json::Value;
2341///
2342/// /// Validates that a named integer field is within [min, max].
2343/// struct RangeValidator { field: &'static str, min: i64, max: i64 }
2344///
2345/// impl ToolValidator for RangeValidator {
2346///     fn validate(&self, args: &Value) -> Result<(), AgentRuntimeError> {
2347///         let n = args
2348///             .get(self.field)
2349///             .and_then(|v| v.as_i64())
2350///             .ok_or_else(|| {
2351///                 AgentRuntimeError::AgentLoop(format!(
2352///                     "field '{}' must be an integer", self.field
2353///                 ))
2354///             })?;
2355///         if n < self.min || n > self.max {
2356///             return Err(AgentRuntimeError::AgentLoop(format!(
2357///                 "field '{}' = {n} is outside [{}, {}]",
2358///                 self.field, self.min, self.max,
2359///             )));
2360///         }
2361///         Ok(())
2362///     }
2363/// }
2364///
2365/// // Attach to a tool spec:
2366/// let spec = ToolSpec::new("roll_dice", "Roll n dice", |args| {
2367///     serde_json::json!({ "result": args })
2368/// })
2369/// .with_validators(vec![
2370///     Box::new(RangeValidator { field: "n", min: 1, max: 100 }),
2371/// ]);
2372/// ```
2373pub trait ToolValidator: Send + Sync {
2374    /// Validate `args` before the tool handler is invoked.
2375    ///
2376    /// Return `Ok(())` if the arguments are valid, or
2377    /// `Err(AgentRuntimeError::AgentLoop(...))` with a human-readable message.
2378    fn validate(&self, args: &Value) -> Result<(), AgentRuntimeError>;
2379}
2380
2381/// Compute the Levenshtein edit distance between two strings.
2382///
2383/// Used to suggest close matches when a tool name is not found.
2384fn levenshtein(a: &str, b: &str) -> usize {
2385    let a: Vec<char> = a.chars().collect();
2386    let b: Vec<char> = b.chars().collect();
2387    let (m, n) = (a.len(), b.len());
2388    let mut dp = vec![vec![0usize; n + 1]; m + 1];
2389    for i in 0..=m {
2390        dp[i][0] = i;
2391    }
2392    for j in 0..=n {
2393        dp[0][j] = j;
2394    }
2395    for i in 1..=m {
2396        for j in 1..=n {
2397            dp[i][j] = if a[i - 1] == b[j - 1] {
2398                dp[i - 1][j - 1]
2399            } else {
2400                1 + dp[i - 1][j].min(dp[i][j - 1]).min(dp[i - 1][j - 1])
2401            };
2402        }
2403    }
2404    dp[m][n]
2405}
2406
2407/// Split `"tool_name {json}"` into `(tool_name, Value)`.
2408///
2409/// Returns `Err(AgentRuntimeError::AgentLoop)` when:
2410/// - the tool name is empty
2411/// - the argument portion is non-empty but not valid JSON
2412fn parse_tool_call(action: &str) -> Result<(String, Value), AgentRuntimeError> {
2413    let mut parts = action.splitn(2, ' ');
2414    let name = parts.next().unwrap_or("").to_owned();
2415    if name.is_empty() {
2416        return Err(AgentRuntimeError::AgentLoop(
2417            "tool call has an empty tool name".into(),
2418        ));
2419    }
2420    let args_str = parts.next().unwrap_or("{}");
2421    let args: Value = serde_json::from_str(args_str).map_err(|e| {
2422        AgentRuntimeError::AgentLoop(format!(
2423            "invalid JSON args for tool call '{name}': {e} (raw: {args_str})"
2424        ))
2425    })?;
2426    Ok((name, args))
2427}
2428
2429/// Agent-specific errors, mirrors `wasm-agent::AgentError`.
2430///
2431/// Converts to `AgentRuntimeError::AgentLoop` via the `From` implementation.
2432#[derive(Debug, thiserror::Error)]
2433pub enum AgentError {
2434    /// The referenced tool name does not exist in the registry.
2435    #[error("Tool '{0}' not found")]
2436    ToolNotFound(String),
2437    /// The ReAct loop consumed all iterations without emitting `FINAL_ANSWER`.
2438    #[error("Max iterations exceeded: {0}")]
2439    MaxIterations(usize),
2440    /// The model response could not be parsed into a `ReActStep`.
2441    #[error("Parse error: {0}")]
2442    ParseError(String),
2443}
2444
2445impl From<AgentError> for AgentRuntimeError {
2446    fn from(e: AgentError) -> Self {
2447        AgentRuntimeError::AgentLoop(e.to_string())
2448    }
2449}
2450
2451// ── Observer ──────────────────────────────────────────────────────────────────
2452
2453/// Hook trait for observing agent loop events.
2454///
2455/// All methods have no-op default implementations so you only override
2456/// what you care about.
2457pub trait Observer: Send + Sync {
2458    /// Called when a ReAct step completes.
2459    fn on_step(&self, step_index: usize, step: &ReActStep) {
2460        let _ = (step_index, step);
2461    }
2462    /// Called when a tool is about to be dispatched.
2463    fn on_tool_call(&self, tool_name: &str, args: &serde_json::Value) {
2464        let _ = (tool_name, args);
2465    }
2466    /// Called when an action hook blocks a tool call before dispatch.
2467    ///
2468    /// `tool_name` is the name of the blocked tool; `args` are the arguments
2469    /// that were passed to the hook.  This is called *instead of* `on_tool_call`.
2470    fn on_action_blocked(&self, tool_name: &str, args: &serde_json::Value) {
2471        let _ = (tool_name, args);
2472    }
2473    /// Called when the loop starts.
2474    fn on_loop_start(&self, prompt: &str) {
2475        let _ = prompt;
2476    }
2477    /// Called when the loop finishes (success or error).
2478    fn on_loop_end(&self, step_count: usize) {
2479        let _ = step_count;
2480    }
2481    /// Called when the loop terminates with an error.
2482    ///
2483    /// Invoked for timeout, max-iterations, and parse failures.
2484    /// `on_loop_end` is also called immediately after `on_error`.
2485    fn on_error(&self, error: &crate::error::AgentRuntimeError) {
2486        let _ = error;
2487    }
2488}
2489
2490// ── Action ────────────────────────────────────────────────────────────────────
2491
2492/// A parsed action from a ReAct step.
2493#[derive(Debug, Clone, PartialEq)]
2494pub enum Action {
2495    /// The agent has produced a final answer.
2496    FinalAnswer(String),
2497    /// A tool call with a name and JSON arguments.
2498    ToolCall {
2499        /// The tool name.
2500        name: String,
2501        /// The parsed JSON arguments.
2502        args: serde_json::Value,
2503    },
2504}
2505
2506impl Action {
2507    /// Parse an action string into an `Action`.
2508    ///
2509    /// Returns `Action::FinalAnswer` if the string starts with `FINAL_ANSWER` (case-insensitive).
2510    /// Otherwise parses as a tool call via `parse_tool_call`.
2511    pub fn parse(s: &str) -> Result<Action, AgentRuntimeError> {
2512        if s.trim().to_ascii_uppercase().starts_with("FINAL_ANSWER") {
2513            let answer = s.trim()["FINAL_ANSWER".len()..].trim().to_owned();
2514            return Ok(Action::FinalAnswer(answer));
2515        }
2516        let (name, args) = parse_tool_call(s)?;
2517        Ok(Action::ToolCall { name, args })
2518    }
2519}
2520
2521/// Async hook called before each tool action. Return `true` to proceed, `false` to block.
2522///
2523/// When blocked, the loop inserts a synthetic observation
2524/// `{"ok": false, "error": "action blocked by reviewer", "kind": "blocked"}`
2525/// and continues to the next iteration without invoking the tool.
2526///
2527/// ## Observer interaction
2528///
2529/// When a hook **allows** an action (`true`), the normal observer sequence fires:
2530/// 1. `Observer::on_tool_call` — called before the tool is dispatched
2531/// 2. `Observer::on_step` — called after the observation is recorded
2532///
2533/// When a hook **blocks** an action (`false`), the sequence is:
2534/// 1. `Observer::on_action_blocked` — called instead of `on_tool_call`
2535/// 2. `Observer::on_step` — called after the synthetic blocked observation is recorded
2536///
2537/// Use [`make_action_hook`] to construct a hook from a plain `async fn` without
2538/// writing the `Arc<dyn Fn…>` boilerplate by hand.
2539pub type ActionHook = Arc<dyn Fn(String, serde_json::Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>> + Send + Sync>;
2540
2541/// Create an [`ActionHook`] from a plain `async fn` or closure.
2542///
2543/// This helper eliminates the need to manually write
2544/// `Arc::new(|name, args| Box::pin(async move { … }))`.
2545///
2546/// # Example
2547/// ```no_run
2548/// use llm_agent_runtime::agent::make_action_hook;
2549///
2550/// let hook = make_action_hook(|tool_name: String, _args| async move {
2551///     // Block any tool called "dangerous"
2552///     tool_name != "dangerous"
2553/// });
2554/// ```
2555pub fn make_action_hook<F, Fut>(f: F) -> ActionHook
2556where
2557    F: Fn(String, serde_json::Value) -> Fut + Send + Sync + 'static,
2558    Fut: std::future::Future<Output = bool> + Send + 'static,
2559{
2560    Arc::new(move |name, args| Box::pin(f(name, args)))
2561}
2562
2563// ── Tests ─────────────────────────────────────────────────────────────────────
2564
2565#[cfg(test)]
2566mod tests {
2567    use super::*;
2568
2569    #[tokio::test]
2570    async fn test_final_answer_on_first_step() {
2571        let config = AgentConfig::new(5, "test-model");
2572        let loop_ = ReActLoop::new(config);
2573
2574        let steps = loop_
2575            .run("Say hello", |_ctx| async {
2576                "Thought: I will answer directly\nAction: FINAL_ANSWER hello".to_string()
2577            })
2578            .await
2579            .unwrap();
2580
2581        assert_eq!(steps.len(), 1);
2582        assert!(steps[0]
2583            .action
2584            .to_ascii_uppercase()
2585            .starts_with("FINAL_ANSWER"));
2586    }
2587
2588    #[tokio::test]
2589    async fn test_tool_call_then_final_answer() {
2590        let config = AgentConfig::new(5, "test-model");
2591        let mut loop_ = ReActLoop::new(config);
2592
2593        loop_.register_tool(ToolSpec::new("greet", "Greets someone", |_args| {
2594            serde_json::json!("hello!")
2595        }));
2596
2597        let mut call_count = 0;
2598        let steps = loop_
2599            .run("Say hello", |_ctx| {
2600                call_count += 1;
2601                let count = call_count;
2602                async move {
2603                    if count == 1 {
2604                        "Thought: I will greet\nAction: greet {}".to_string()
2605                    } else {
2606                        "Thought: done\nAction: FINAL_ANSWER done".to_string()
2607                    }
2608                }
2609            })
2610            .await
2611            .unwrap();
2612
2613        assert_eq!(steps.len(), 2);
2614        assert_eq!(steps[0].action, "greet {}");
2615        assert!(steps[1]
2616            .action
2617            .to_ascii_uppercase()
2618            .starts_with("FINAL_ANSWER"));
2619    }
2620
2621    #[tokio::test]
2622    async fn test_max_iterations_exceeded() {
2623        let config = AgentConfig::new(2, "test-model");
2624        let loop_ = ReActLoop::new(config);
2625
2626        let result = loop_
2627            .run("loop forever", |_ctx| async {
2628                "Thought: thinking\nAction: noop {}".to_string()
2629            })
2630            .await;
2631
2632        assert!(result.is_err());
2633        let err = result.unwrap_err().to_string();
2634        assert!(err.contains("max iterations"));
2635    }
2636
2637    #[tokio::test]
2638    async fn test_parse_react_step_valid() {
2639        let text = "Thought: I should check\nAction: lookup {\"key\":\"val\"}";
2640        let step = parse_react_step(text).unwrap();
2641        assert_eq!(step.thought, "I should check");
2642        assert_eq!(step.action, "lookup {\"key\":\"val\"}");
2643    }
2644
2645    #[tokio::test]
2646    async fn test_parse_react_step_empty_fails() {
2647        let result = parse_react_step("no prefix lines here");
2648        assert!(result.is_err());
2649    }
2650
2651    #[tokio::test]
2652    async fn test_tool_not_found_returns_error_observation() {
2653        let config = AgentConfig::new(3, "test-model");
2654        let loop_ = ReActLoop::new(config);
2655
2656        let mut call_count = 0;
2657        let steps = loop_
2658            .run("test", |_ctx| {
2659                call_count += 1;
2660                let count = call_count;
2661                async move {
2662                    if count == 1 {
2663                        "Thought: try missing tool\nAction: missing_tool {}".to_string()
2664                    } else {
2665                        "Thought: done\nAction: FINAL_ANSWER done".to_string()
2666                    }
2667                }
2668            })
2669            .await
2670            .unwrap();
2671
2672        assert_eq!(steps.len(), 2);
2673        assert!(steps[0].observation.contains("\"ok\":false"));
2674    }
2675
2676    #[tokio::test]
2677    async fn test_new_async_tool_spec() {
2678        let spec = ToolSpec::new_async("async_tool", "An async tool", |args| {
2679            Box::pin(async move { serde_json::json!({"echo": args}) })
2680        });
2681
2682        let result = spec.call(serde_json::json!({"input": "test"})).await;
2683        assert!(result.get("echo").is_some());
2684    }
2685
2686    // Item 1 — Robust ReAct Parser tests
2687
2688    #[tokio::test]
2689    async fn test_parse_react_step_case_insensitive() {
2690        let text = "THOUGHT: done\nACTION: FINAL_ANSWER";
2691        let step = parse_react_step(text).unwrap();
2692        assert_eq!(step.thought, "done");
2693        assert_eq!(step.action, "FINAL_ANSWER");
2694    }
2695
2696    #[tokio::test]
2697    async fn test_parse_react_step_space_before_colon() {
2698        let text = "Thought : done\nAction : go";
2699        let step = parse_react_step(text).unwrap();
2700        assert_eq!(step.thought, "done");
2701        assert_eq!(step.action, "go");
2702    }
2703
2704    // Item 3 — Tool required field validation tests
2705
2706    #[tokio::test]
2707    async fn test_tool_required_fields_missing_returns_error() {
2708        let config = AgentConfig::new(3, "test-model");
2709        let mut loop_ = ReActLoop::new(config);
2710
2711        loop_.register_tool(
2712            ToolSpec::new(
2713                "search",
2714                "Searches for something",
2715                |args| serde_json::json!({ "result": args }),
2716            )
2717            .with_required_fields(vec!["q".to_string()]),
2718        );
2719
2720        let mut call_count = 0;
2721        let steps = loop_
2722            .run("test", |_ctx| {
2723                call_count += 1;
2724                let count = call_count;
2725                async move {
2726                    if count == 1 {
2727                        // Call with empty object — missing "q"
2728                        "Thought: searching\nAction: search {}".to_string()
2729                    } else {
2730                        "Thought: done\nAction: FINAL_ANSWER done".to_string()
2731                    }
2732                }
2733            })
2734            .await
2735            .unwrap();
2736
2737        assert_eq!(steps.len(), 2);
2738        assert!(
2739            steps[0].observation.contains("missing required field"),
2740            "observation was: {}",
2741            steps[0].observation
2742        );
2743    }
2744
2745    // Item 9 — Structured error observation tests
2746
2747    #[tokio::test]
2748    async fn test_tool_error_observation_includes_kind() {
2749        let config = AgentConfig::new(3, "test-model");
2750        let loop_ = ReActLoop::new(config);
2751
2752        let mut call_count = 0;
2753        let steps = loop_
2754            .run("test", |_ctx| {
2755                call_count += 1;
2756                let count = call_count;
2757                async move {
2758                    if count == 1 {
2759                        "Thought: try missing\nAction: nonexistent_tool {}".to_string()
2760                    } else {
2761                        "Thought: done\nAction: FINAL_ANSWER done".to_string()
2762                    }
2763                }
2764            })
2765            .await
2766            .unwrap();
2767
2768        assert_eq!(steps.len(), 2);
2769        let obs = &steps[0].observation;
2770        assert!(obs.contains("\"ok\":false"), "observation: {obs}");
2771        assert!(obs.contains("\"kind\":\"not_found\""), "observation: {obs}");
2772    }
2773
2774    // ── step_duration_ms ──────────────────────────────────────────────────────
2775
2776    #[tokio::test]
2777    async fn test_step_duration_ms_is_set() {
2778        let config = AgentConfig::new(5, "test-model");
2779        let loop_ = ReActLoop::new(config);
2780
2781        let steps = loop_
2782            .run("time it", |_ctx| async {
2783                "Thought: done\nAction: FINAL_ANSWER ok".to_string()
2784            })
2785            .await
2786            .unwrap();
2787
2788        // step_duration_ms may be 0 on very fast systems but must be a valid u64.
2789        let _ = steps[0].step_duration_ms; // just verify the field exists and is accessible
2790    }
2791
2792    // ── ToolValidator ─────────────────────────────────────────────────────────
2793
2794    struct RequirePositiveN;
2795    impl ToolValidator for RequirePositiveN {
2796        fn validate(&self, args: &Value) -> Result<(), AgentRuntimeError> {
2797            let n = args.get("n").and_then(|v| v.as_i64()).unwrap_or(0);
2798            if n <= 0 {
2799                return Err(AgentRuntimeError::AgentLoop(
2800                    "n must be a positive integer".into(),
2801                ));
2802            }
2803            Ok(())
2804        }
2805    }
2806
2807    #[tokio::test]
2808    async fn test_tool_validator_blocks_invalid_args() {
2809        let mut registry = ToolRegistry::new();
2810        registry.register(
2811            ToolSpec::new("calc", "compute", |args| serde_json::json!({"n": args}))
2812                .with_validators(vec![Box::new(RequirePositiveN)]),
2813        );
2814
2815        // n = -1 should be rejected by the validator.
2816        let result = registry
2817            .call("calc", serde_json::json!({"n": -1}))
2818            .await;
2819        assert!(result.is_err(), "validator should reject n=-1");
2820        assert!(result.unwrap_err().to_string().contains("positive integer"));
2821    }
2822
2823    #[tokio::test]
2824    async fn test_tool_validator_passes_valid_args() {
2825        let mut registry = ToolRegistry::new();
2826        registry.register(
2827            ToolSpec::new("calc", "compute", |_| serde_json::json!(42))
2828                .with_validators(vec![Box::new(RequirePositiveN)]),
2829        );
2830
2831        let result = registry
2832            .call("calc", serde_json::json!({"n": 5}))
2833            .await;
2834        assert!(result.is_ok(), "validator should accept n=5");
2835    }
2836
2837    // ── Empty tool name ───────────────────────────────────────────────────────
2838
2839    #[tokio::test]
2840    async fn test_empty_tool_name_is_rejected() {
2841        // parse_tool_call("") → error because name is empty
2842        let result = parse_tool_call("");
2843        assert!(result.is_err());
2844        assert!(
2845            result.unwrap_err().to_string().contains("empty tool name"),
2846            "expected 'empty tool name' error"
2847        );
2848    }
2849
2850    // ── Bulk register_tools ───────────────────────────────────────────────────
2851
2852    #[tokio::test]
2853    async fn test_register_tools_bulk() {
2854        let mut registry = ToolRegistry::new();
2855        registry.register_tools(vec![
2856            ToolSpec::new("tool_a", "A", |_| serde_json::json!("a")),
2857            ToolSpec::new("tool_b", "B", |_| serde_json::json!("b")),
2858        ]);
2859        assert!(registry.call("tool_a", serde_json::json!({})).await.is_ok());
2860        assert!(registry.call("tool_b", serde_json::json!({})).await.is_ok());
2861    }
2862
2863    // ── run_streaming parity ──────────────────────────────────────────────────
2864
2865    #[tokio::test]
2866    async fn test_run_streaming_parity_with_run() {
2867        use tokio::sync::mpsc;
2868
2869        let config = AgentConfig::new(5, "test-model");
2870        let loop_ = ReActLoop::new(config);
2871
2872        let steps = loop_
2873            .run_streaming("Say hello", |_ctx| async {
2874                let (tx, rx) = mpsc::channel(4);
2875                // Send the response in chunks
2876                tokio::spawn(async move {
2877                    tx.send(Ok("Thought: done\n".to_string())).await.ok();
2878                    tx.send(Ok("Action: FINAL_ANSWER hi".to_string())).await.ok();
2879                });
2880                rx
2881            })
2882            .await
2883            .unwrap();
2884
2885        assert_eq!(steps.len(), 1);
2886        assert!(steps[0]
2887            .action
2888            .to_ascii_uppercase()
2889            .starts_with("FINAL_ANSWER"));
2890    }
2891
2892    #[tokio::test]
2893    async fn test_run_streaming_error_chunk_is_skipped() {
2894        use tokio::sync::mpsc;
2895        use crate::error::AgentRuntimeError;
2896
2897        let config = AgentConfig::new(5, "test-model");
2898        let loop_ = ReActLoop::new(config);
2899
2900        // Even with an error chunk, the loop recovers and returns the valid parts.
2901        let steps = loop_
2902            .run_streaming("test", |_ctx| async {
2903                let (tx, rx) = mpsc::channel(4);
2904                tokio::spawn(async move {
2905                    tx.send(Err(AgentRuntimeError::Provider("stream error".into())))
2906                        .await
2907                        .ok();
2908                    tx.send(Ok("Thought: recovered\nAction: FINAL_ANSWER ok".to_string()))
2909                        .await
2910                        .ok();
2911                });
2912                rx
2913            })
2914            .await
2915            .unwrap();
2916
2917        assert_eq!(steps.len(), 1);
2918    }
2919
2920    // ── Circuit breaker test (only compiled when feature is active) ────────────
2921
2922    #[cfg(feature = "orchestrator")]
2923    #[tokio::test]
2924    async fn test_tool_with_circuit_breaker_passes_when_closed() {
2925        use std::sync::Arc;
2926
2927        let cb = Arc::new(
2928            crate::orchestrator::CircuitBreaker::new(
2929                "echo-tool",
2930                5,
2931                std::time::Duration::from_secs(30),
2932            )
2933            .unwrap(),
2934        );
2935
2936        let spec = ToolSpec::new(
2937            "echo",
2938            "Echoes args",
2939            |args| serde_json::json!({ "echoed": args }),
2940        )
2941        .with_circuit_breaker(cb);
2942
2943        let registry = {
2944            let mut r = ToolRegistry::new();
2945            r.register(spec);
2946            r
2947        };
2948
2949        let result = registry
2950            .call("echo", serde_json::json!({ "msg": "hi" }))
2951            .await;
2952        assert!(result.is_ok(), "expected Ok, got {:?}", result);
2953    }
2954
2955    // ── Improvement 1: AgentConfig builder methods ────────────────────────────
2956
2957    #[test]
2958    fn test_agent_config_builder_methods_set_fields() {
2959        let config = AgentConfig::new(3, "model")
2960            .with_temperature(0.7)
2961            .with_max_tokens(512)
2962            .with_request_timeout(std::time::Duration::from_secs(10));
2963        assert_eq!(config.temperature, Some(0.7));
2964        assert_eq!(config.max_tokens, Some(512));
2965        assert_eq!(config.request_timeout, Some(std::time::Duration::from_secs(10)));
2966    }
2967
2968    // ── Improvement 2: Fallible tool handlers ─────────────────────────────────
2969
2970    #[tokio::test]
2971    async fn test_fallible_tool_returns_error_json_on_err() {
2972        let spec = ToolSpec::new_fallible(
2973            "fail",
2974            "always fails",
2975            |_| Err::<Value, String>("something went wrong".to_string()),
2976        );
2977        let result = spec.call(serde_json::json!({})).await;
2978        assert_eq!(result["ok"], serde_json::json!(false));
2979        assert_eq!(result["error"], serde_json::json!("something went wrong"));
2980    }
2981
2982    #[tokio::test]
2983    async fn test_fallible_tool_returns_value_on_ok() {
2984        let spec = ToolSpec::new_fallible(
2985            "succeed",
2986            "always succeeds",
2987            |_| Ok::<Value, String>(serde_json::json!(42)),
2988        );
2989        let result = spec.call(serde_json::json!({})).await;
2990        assert_eq!(result, serde_json::json!(42));
2991    }
2992
2993    // ── Improvement 4: Did you mean ───────────────────────────────────────────
2994
2995    #[tokio::test]
2996    async fn test_did_you_mean_suggestion_for_typo() {
2997        let mut registry = ToolRegistry::new();
2998        registry.register(ToolSpec::new("search", "search", |_| serde_json::json!("ok")));
2999        let result = registry.call("searc", serde_json::json!({})).await;
3000        assert!(result.is_err());
3001        let msg = result.unwrap_err().to_string();
3002        assert!(msg.contains("did you mean"), "expected suggestion in: {msg}");
3003    }
3004
3005    #[tokio::test]
3006    async fn test_no_suggestion_for_very_different_name() {
3007        let mut registry = ToolRegistry::new();
3008        registry.register(ToolSpec::new("search", "search", |_| serde_json::json!("ok")));
3009        let result = registry.call("xxxxxxxxxxxxxxx", serde_json::json!({})).await;
3010        assert!(result.is_err());
3011        let msg = result.unwrap_err().to_string();
3012        assert!(!msg.contains("did you mean"), "unexpected suggestion in: {msg}");
3013    }
3014
3015    // ── Improvement 11: Action enum ───────────────────────────────────────────
3016
3017    #[test]
3018    fn test_action_parse_final_answer() {
3019        let action = Action::parse("FINAL_ANSWER hello world").unwrap();
3020        assert_eq!(action, Action::FinalAnswer("hello world".to_string()));
3021    }
3022
3023    #[test]
3024    fn test_action_parse_tool_call() {
3025        let action = Action::parse("search {\"q\": \"rust\"}").unwrap();
3026        match action {
3027            Action::ToolCall { name, args } => {
3028                assert_eq!(name, "search");
3029                assert_eq!(args["q"], "rust");
3030            }
3031            _ => panic!("expected ToolCall"),
3032        }
3033    }
3034
3035    #[test]
3036    fn test_action_parse_invalid_returns_err() {
3037        let result = Action::parse("");
3038        assert!(result.is_err());
3039    }
3040
3041    // ── Improvement 13: Observer ──────────────────────────────────────────────
3042
3043    #[tokio::test]
3044    async fn test_observer_on_step_called_for_each_step() {
3045        use std::sync::{Arc, Mutex};
3046
3047        struct CountingObserver {
3048            step_count: Mutex<usize>,
3049        }
3050        impl Observer for CountingObserver {
3051            fn on_step(&self, _step_index: usize, _step: &ReActStep) {
3052                let mut c = self.step_count.lock().unwrap_or_else(|e| e.into_inner());
3053                *c += 1;
3054            }
3055        }
3056
3057        let obs = Arc::new(CountingObserver { step_count: Mutex::new(0) });
3058        let config = AgentConfig::new(5, "test-model");
3059        let mut loop_ = ReActLoop::new(config).with_observer(obs.clone() as Arc<dyn Observer>);
3060        loop_.register_tool(ToolSpec::new("noop", "noop", |_| serde_json::json!("ok")));
3061
3062        let mut call_count = 0;
3063        let _steps = loop_.run("test", |_ctx| {
3064            call_count += 1;
3065            let count = call_count;
3066            async move {
3067                if count == 1 {
3068                    "Thought: call noop\nAction: noop {}".to_string()
3069                } else {
3070                    "Thought: done\nAction: FINAL_ANSWER done".to_string()
3071                }
3072            }
3073        }).await.unwrap();
3074
3075        let count = *obs.step_count.lock().unwrap_or_else(|e| e.into_inner());
3076        assert_eq!(count, 2, "observer should have seen 2 steps");
3077    }
3078
3079    // ── Improvement 14: ToolCache ─────────────────────────────────────────────
3080
3081    #[tokio::test]
3082    async fn test_tool_cache_returns_cached_result_on_second_call() {
3083        use std::collections::HashMap;
3084        use std::sync::Mutex;
3085
3086        struct InMemCache {
3087            map: Mutex<HashMap<String, Value>>,
3088        }
3089        impl ToolCache for InMemCache {
3090            fn get(&self, tool_name: &str, args: &Value) -> Option<Value> {
3091                let key = format!("{tool_name}:{args}");
3092                let map = self.map.lock().unwrap_or_else(|e| e.into_inner());
3093                map.get(&key).cloned()
3094            }
3095            fn set(&self, tool_name: &str, args: &Value, result: Value) {
3096                let key = format!("{tool_name}:{args}");
3097                let mut map = self.map.lock().unwrap_or_else(|e| e.into_inner());
3098                map.insert(key, result);
3099            }
3100        }
3101
3102        let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3103        let call_count_clone = call_count.clone();
3104
3105        let cache = Arc::new(InMemCache { map: Mutex::new(HashMap::new()) });
3106        let registry = ToolRegistry::new()
3107            .with_cache(cache as Arc<dyn ToolCache>);
3108        let mut registry = registry;
3109
3110        registry.register(ToolSpec::new("count", "count calls", move |_| {
3111            call_count_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3112            serde_json::json!({"calls": 1})
3113        }));
3114
3115        let args = serde_json::json!({});
3116        let r1 = registry.call("count", args.clone()).await.unwrap();
3117        let r2 = registry.call("count", args.clone()).await.unwrap();
3118
3119        assert_eq!(r1, r2);
3120        // The handler should only be called once; second call hits cache.
3121        assert_eq!(call_count.load(std::sync::atomic::Ordering::Relaxed), 1);
3122    }
3123
3124    // ── Task 12: Chained validator short-circuit ──────────────────────────────
3125
3126    #[tokio::test]
3127    async fn test_validators_short_circuit_on_first_failure() {
3128        use std::sync::atomic::{AtomicUsize, Ordering as AOrdering};
3129        use std::sync::Arc;
3130
3131        let second_called = Arc::new(AtomicUsize::new(0));
3132        let second_called_clone = Arc::clone(&second_called);
3133
3134        struct AlwaysFail;
3135        impl ToolValidator for AlwaysFail {
3136            fn validate(&self, _args: &Value) -> Result<(), AgentRuntimeError> {
3137                Err(AgentRuntimeError::AgentLoop("first validator failed".into()))
3138            }
3139        }
3140
3141        struct CountCalls(Arc<AtomicUsize>);
3142        impl ToolValidator for CountCalls {
3143            fn validate(&self, _args: &Value) -> Result<(), AgentRuntimeError> {
3144                self.0.fetch_add(1, AOrdering::SeqCst);
3145                Ok(())
3146            }
3147        }
3148
3149        let mut registry = ToolRegistry::new();
3150        registry.register(
3151            ToolSpec::new("guarded", "A guarded tool", |args| args.clone())
3152                .with_validators(vec![
3153                    Box::new(AlwaysFail),
3154                    Box::new(CountCalls(second_called_clone)),
3155                ]),
3156        );
3157
3158        let result = registry.call("guarded", serde_json::json!({})).await;
3159        assert!(result.is_err(), "should fail due to first validator");
3160        assert_eq!(
3161            second_called.load(AOrdering::SeqCst),
3162            0,
3163            "second validator must not be called when first fails"
3164        );
3165    }
3166
3167    // ── Task 14: loop_timeout integration test ────────────────────────────────
3168
3169    #[tokio::test]
3170    async fn test_loop_timeout_fires_between_iterations() {
3171        let mut config = AgentConfig::new(100, "test-model");
3172        // 30 ms deadline; each infer call sleeps 20 ms, so timeout fires after 2 iterations.
3173        config.loop_timeout = Some(std::time::Duration::from_millis(30));
3174        let loop_ = ReActLoop::new(config);
3175
3176        let result = loop_
3177            .run("test", |_ctx| async {
3178                // Sleep just long enough that the cumulative time exceeds the deadline.
3179                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3180                // Return a valid step that keeps the loop going (unknown tool → error observation → next iter).
3181                "Thought: still working\nAction: noop {}".to_string()
3182            })
3183            .await;
3184
3185        assert!(result.is_err(), "loop should time out");
3186        let msg = result.unwrap_err().to_string();
3187        assert!(msg.contains("loop timeout"), "unexpected error: {msg}");
3188    }
3189
3190    // ── Improvement 15: ActionHook ────────────────────────────────────────────
3191
3192    // ── #2 ReActStep::is_final_answer / is_tool_call ──────────────────────────
3193
3194    #[test]
3195    fn test_react_step_is_final_answer() {
3196        let step = ReActStep {
3197            thought: "".into(),
3198            action: "FINAL_ANSWER done".into(),
3199            observation: "".into(),
3200            step_duration_ms: 0,
3201        };
3202        assert!(step.is_final_answer());
3203        assert!(!step.is_tool_call());
3204    }
3205
3206    #[test]
3207    fn test_react_step_is_tool_call() {
3208        let step = ReActStep {
3209            thought: "".into(),
3210            action: "search {}".into(),
3211            observation: "".into(),
3212            step_duration_ms: 0,
3213        };
3214        assert!(!step.is_final_answer());
3215        assert!(step.is_tool_call());
3216    }
3217
3218    // ── #6 Role Display ───────────────────────────────────────────────────────
3219
3220    #[test]
3221    fn test_role_display() {
3222        assert_eq!(Role::System.to_string(), "system");
3223        assert_eq!(Role::User.to_string(), "user");
3224        assert_eq!(Role::Assistant.to_string(), "assistant");
3225        assert_eq!(Role::Tool.to_string(), "tool");
3226    }
3227
3228    // ── #12 Message accessors ─────────────────────────────────────────────────
3229
3230    #[test]
3231    fn test_message_accessors() {
3232        let msg = Message::new(Role::User, "hello");
3233        assert_eq!(msg.role(), &Role::User);
3234        assert_eq!(msg.content(), "hello");
3235    }
3236
3237    // ── #25 Action parse round-trips ──────────────────────────────────────────
3238
3239    #[test]
3240    fn test_action_parse_final_answer_round_trip() {
3241        let step = ReActStep {
3242            thought: "done".into(),
3243            action: "FINAL_ANSWER Paris".into(),
3244            observation: "".into(),
3245            step_duration_ms: 0,
3246        };
3247        assert!(step.is_final_answer());
3248        let action = Action::parse(&step.action).unwrap();
3249        assert!(matches!(action, Action::FinalAnswer(ref s) if s == "Paris"));
3250    }
3251
3252    #[test]
3253    fn test_action_parse_tool_call_round_trip() {
3254        let step = ReActStep {
3255            thought: "searching".into(),
3256            action: "search {\"q\":\"hello\"}".into(),
3257            observation: "".into(),
3258            step_duration_ms: 0,
3259        };
3260        assert!(step.is_tool_call());
3261        let action = Action::parse(&step.action).unwrap();
3262        assert!(matches!(action, Action::ToolCall { ref name, .. } if name == "search"));
3263    }
3264
3265    // ── #26 Observer step indices ─────────────────────────────────────────────
3266
3267    #[tokio::test]
3268    async fn test_observer_receives_correct_step_indices() {
3269        use std::sync::{Arc, Mutex};
3270
3271        struct IndexCollector(Arc<Mutex<Vec<usize>>>);
3272        impl Observer for IndexCollector {
3273            fn on_step(&self, step_index: usize, _step: &ReActStep) {
3274                self.0.lock().unwrap_or_else(|e| e.into_inner()).push(step_index);
3275            }
3276        }
3277
3278        let indices = Arc::new(Mutex::new(Vec::new()));
3279        let obs = Arc::new(IndexCollector(Arc::clone(&indices)));
3280
3281        let config = AgentConfig::new(5, "test");
3282        let mut loop_ = ReActLoop::new(config).with_observer(obs as Arc<dyn Observer>);
3283        loop_.register_tool(ToolSpec::new("noop", "no-op", |_| serde_json::json!({})));
3284
3285        let mut call_count = 0;
3286        loop_.run("test", |_ctx| {
3287            call_count += 1;
3288            let count = call_count;
3289            async move {
3290                if count == 1 {
3291                    "Thought: step1\nAction: noop {}".to_string()
3292                } else {
3293                    "Thought: done\nAction: FINAL_ANSWER ok".to_string()
3294                }
3295            }
3296        }).await.unwrap();
3297
3298        let collected = indices.lock().unwrap_or_else(|e| e.into_inner()).clone();
3299        assert_eq!(collected, vec![0, 1], "expected step indices 0 and 1");
3300    }
3301
3302    #[tokio::test]
3303    async fn test_action_hook_blocking_inserts_blocked_observation() {
3304        let hook: ActionHook = Arc::new(|_name, _args| {
3305            Box::pin(async move { false }) // always block
3306        });
3307
3308        let config = AgentConfig::new(5, "test-model");
3309        let mut loop_ = ReActLoop::new(config).with_action_hook(hook);
3310        loop_.register_tool(ToolSpec::new("noop", "noop", |_| serde_json::json!("ok")));
3311
3312        let mut call_count = 0;
3313        let steps = loop_.run("test", |_ctx| {
3314            call_count += 1;
3315            let count = call_count;
3316            async move {
3317                if count == 1 {
3318                    "Thought: try tool\nAction: noop {}".to_string()
3319                } else {
3320                    "Thought: done\nAction: FINAL_ANSWER done".to_string()
3321                }
3322            }
3323        }).await.unwrap();
3324
3325        assert!(steps[0].observation.contains("blocked"), "expected blocked observation, got: {}", steps[0].observation);
3326    }
3327
3328    #[test]
3329    fn test_react_step_new_constructor() {
3330        let s = ReActStep::new("think", "act", "obs");
3331        assert_eq!(s.thought, "think");
3332        assert_eq!(s.action, "act");
3333        assert_eq!(s.observation, "obs");
3334        assert_eq!(s.step_duration_ms, 0);
3335    }
3336
3337    #[test]
3338    fn test_react_step_new_is_tool_call() {
3339        let s = ReActStep::new("think", "search {}", "result");
3340        assert!(s.is_tool_call());
3341        assert!(!s.is_final_answer());
3342    }
3343
3344    #[test]
3345    fn test_react_step_new_is_final_answer() {
3346        let s = ReActStep::new("done", "FINAL_ANSWER 42", "");
3347        assert!(s.is_final_answer());
3348        assert!(!s.is_tool_call());
3349    }
3350
3351    #[test]
3352    fn test_agent_config_is_valid_with_valid_config() {
3353        let cfg = AgentConfig::new(5, "my-model");
3354        assert!(cfg.is_valid());
3355    }
3356
3357    #[test]
3358    fn test_agent_config_is_valid_with_zero_iterations() {
3359        let mut cfg = AgentConfig::new(1, "my-model");
3360        cfg.max_iterations = 0;
3361        assert!(!cfg.is_valid());
3362    }
3363
3364    #[test]
3365    fn test_agent_config_is_valid_with_empty_model() {
3366        let mut cfg = AgentConfig::new(5, "my-model");
3367        cfg.model = String::new();
3368        assert!(!cfg.is_valid());
3369    }
3370
3371    #[test]
3372    fn test_react_loop_tool_count_delegates_to_registry() {
3373        let cfg = AgentConfig::new(5, "model");
3374        let mut loop_ = ReActLoop::new(cfg);
3375        assert_eq!(loop_.tool_count(), 0);
3376        loop_.register_tool(ToolSpec::new("t1", "desc", |_| serde_json::json!("ok")));
3377        loop_.register_tool(ToolSpec::new("t2", "desc", |_| serde_json::json!("ok")));
3378        assert_eq!(loop_.tool_count(), 2);
3379    }
3380
3381    #[test]
3382    fn test_tool_registry_has_tool_returns_true_when_registered() {
3383        let mut reg = ToolRegistry::new();
3384        reg.register(ToolSpec::new("my-tool", "desc", |_| serde_json::json!("ok")));
3385        assert!(reg.has_tool("my-tool"));
3386        assert!(!reg.has_tool("other-tool"));
3387    }
3388
3389    // ── Round 3: AgentConfig::validate ───────────────────────────────────────
3390
3391    #[test]
3392    fn test_agent_config_validate_ok_for_valid_config() {
3393        let cfg = AgentConfig::new(5, "my-model");
3394        assert!(cfg.validate().is_ok());
3395    }
3396
3397    #[test]
3398    fn test_agent_config_validate_err_for_zero_iterations() {
3399        let cfg = AgentConfig::new(0, "my-model");
3400        let err = cfg.validate().unwrap_err();
3401        assert!(err.to_string().contains("max_iterations"));
3402    }
3403
3404    #[test]
3405    fn test_agent_config_validate_err_for_empty_model() {
3406        let cfg = AgentConfig::new(5, "");
3407        let err = cfg.validate().unwrap_err();
3408        assert!(err.to_string().contains("model"));
3409    }
3410
3411    // ── Round 4: AgentConfig::clone_with_model / ToolSpec::with_name ─────────
3412
3413    #[test]
3414    fn test_clone_with_model_produces_new_model_string() {
3415        let cfg = AgentConfig::new(5, "gpt-4");
3416        let new_cfg = cfg.clone_with_model("claude-3");
3417        assert_eq!(new_cfg.model, "claude-3");
3418        // Original unchanged
3419        assert_eq!(cfg.model, "gpt-4");
3420    }
3421
3422    #[test]
3423    fn test_clone_with_model_preserves_other_fields() {
3424        let cfg = AgentConfig::new(10, "gpt-4").with_stop_sequences(vec!["STOP".to_string()]);
3425        let new_cfg = cfg.clone_with_model("o1");
3426        assert_eq!(new_cfg.max_iterations, 10);
3427        assert_eq!(new_cfg.stop_sequences, cfg.stop_sequences);
3428    }
3429
3430    #[tokio::test]
3431    async fn test_tool_spec_with_name_changes_name() {
3432        let spec = ToolSpec::new("original", "desc", |_| serde_json::json!("ok"))
3433            .with_name("renamed");
3434        assert_eq!(spec.name, "renamed");
3435    }
3436
3437    #[tokio::test]
3438    async fn test_tool_spec_with_name_and_description_chainable() {
3439        let spec = ToolSpec::new("old", "old desc", |_| serde_json::json!("ok"))
3440            .with_name("new")
3441            .with_description("new desc");
3442        assert_eq!(spec.name, "new");
3443        assert_eq!(spec.description, "new desc");
3444    }
3445
3446    // ── Round 16: Message constructors, parse_react_step ─────────────────────
3447
3448    #[test]
3449    fn test_message_user_sets_role_and_content() {
3450        let m = Message::user("hello");
3451        assert_eq!(m.content(), "hello");
3452        assert!(m.is_user());
3453        assert!(!m.is_assistant());
3454    }
3455
3456    #[test]
3457    fn test_message_assistant_sets_role() {
3458        let m = Message::assistant("reply");
3459        assert!(m.is_assistant());
3460        assert!(!m.is_user());
3461        assert!(!m.is_system());
3462    }
3463
3464    #[test]
3465    fn test_message_system_sets_role() {
3466        let m = Message::system("system prompt");
3467        assert!(m.is_system());
3468        assert_eq!(m.content(), "system prompt");
3469    }
3470
3471    #[test]
3472    fn test_parse_react_step_valid_input() {
3473        let text = "Thought: I need to search\nAction: search[query]";
3474        let step = parse_react_step(text).unwrap();
3475        assert!(step.thought.contains("search"));
3476        assert!(step.action.contains("search"));
3477    }
3478
3479    #[test]
3480    fn test_parse_react_step_missing_fields_returns_err() {
3481        let text = "no structured content here";
3482        assert!(parse_react_step(text).is_err());
3483    }
3484
3485    // ── Round 18: ReActStep predicates, ToolRegistry ops, AgentConfig builders
3486
3487    #[test]
3488    fn test_react_step_is_final_answer_true() {
3489        let step = ReActStep::new("t", "FINAL_ANSWER Paris", "");
3490        assert!(step.is_final_answer());
3491        assert!(!step.is_tool_call());
3492    }
3493
3494    #[test]
3495    fn test_react_step_is_tool_call_true() {
3496        let step = ReActStep::new("t", "search {}", "result");
3497        assert!(step.is_tool_call());
3498        assert!(!step.is_final_answer());
3499    }
3500
3501    #[test]
3502    fn test_tool_registry_unregister_returns_true_when_present() {
3503        let mut reg = ToolRegistry::new();
3504        reg.register(ToolSpec::new("tool-x", "desc", |_| serde_json::json!("ok")));
3505        assert!(reg.unregister("tool-x"));
3506        assert!(!reg.has_tool("tool-x"));
3507    }
3508
3509    #[test]
3510    fn test_tool_registry_unregister_returns_false_when_absent() {
3511        let mut reg = ToolRegistry::new();
3512        assert!(!reg.unregister("ghost"));
3513    }
3514
3515    #[test]
3516    fn test_tool_registry_contains_matches_has_tool() {
3517        let mut reg = ToolRegistry::new();
3518        reg.register(ToolSpec::new("alpha", "desc", |_| serde_json::json!("ok")));
3519        assert!(reg.contains("alpha"));
3520        assert!(!reg.contains("beta"));
3521    }
3522
3523    #[test]
3524    fn test_agent_config_with_system_prompt() {
3525        let cfg = AgentConfig::new(5, "model")
3526            .with_system_prompt("You are helpful.");
3527        assert_eq!(cfg.system_prompt, "You are helpful.");
3528    }
3529
3530    #[test]
3531    fn test_agent_config_with_temperature_and_max_tokens() {
3532        let cfg = AgentConfig::new(3, "model")
3533            .with_temperature(0.7)
3534            .with_max_tokens(512);
3535        assert!((cfg.temperature.unwrap() - 0.7).abs() < 1e-6);
3536        assert_eq!(cfg.max_tokens, Some(512));
3537    }
3538
3539    #[test]
3540    fn test_agent_config_clone_with_model() {
3541        let orig = AgentConfig::new(5, "gpt-4");
3542        let cloned = orig.clone_with_model("claude-3");
3543        assert_eq!(cloned.model, "claude-3");
3544        assert_eq!(cloned.max_iterations, 5);
3545    }
3546
3547    // ── Round 19: more AgentConfig builder methods, Message::is_tool ─────────
3548
3549    #[test]
3550    fn test_agent_config_with_loop_timeout_secs() {
3551        let cfg = AgentConfig::new(5, "model").with_loop_timeout_secs(30);
3552        assert_eq!(cfg.loop_timeout, Some(std::time::Duration::from_secs(30)));
3553    }
3554
3555    #[test]
3556    fn test_agent_config_with_max_context_chars() {
3557        let cfg = AgentConfig::new(5, "model").with_max_context_chars(4096);
3558        assert_eq!(cfg.max_context_chars, Some(4096));
3559    }
3560
3561    #[test]
3562    fn test_agent_config_with_stop_sequences() {
3563        let cfg = AgentConfig::new(5, "model")
3564            .with_stop_sequences(vec!["STOP".to_string(), "END".to_string()]);
3565        assert_eq!(cfg.stop_sequences, vec!["STOP", "END"]);
3566    }
3567
3568    #[test]
3569    fn test_message_is_tool_false_for_non_tool_roles() {
3570        assert!(!Message::user("hi").is_tool());
3571        assert!(!Message::assistant("reply").is_tool());
3572        assert!(!Message::system("prompt").is_tool());
3573    }
3574
3575    // ── Round 6: AgentConfig::with_max_iterations / ToolRegistry::tool_names_owned
3576
3577    #[test]
3578    fn test_agent_config_with_max_iterations() {
3579        let cfg = AgentConfig::new(5, "m").with_max_iterations(20);
3580        assert_eq!(cfg.max_iterations, 20);
3581    }
3582
3583    #[test]
3584    fn test_tool_registry_tool_names_owned_returns_strings() {
3585        let mut reg = ToolRegistry::new();
3586        reg.register(ToolSpec::new("alpha", "d", |_| serde_json::json!("ok")));
3587        reg.register(ToolSpec::new("beta", "d", |_| serde_json::json!("ok")));
3588        let mut names = reg.tool_names_owned();
3589        names.sort();
3590        assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]);
3591    }
3592
3593    #[test]
3594    fn test_tool_registry_tool_names_owned_empty_when_no_tools() {
3595        let reg = ToolRegistry::new();
3596        assert!(reg.tool_names_owned().is_empty());
3597    }
3598
3599    // ── Round 7: ToolRegistry::tool_specs ────────────────────────────────────
3600
3601    #[test]
3602    fn test_tool_registry_tool_specs_returns_all_specs() {
3603        let mut reg = ToolRegistry::new();
3604        reg.register(ToolSpec::new("t1", "desc1", |_| serde_json::json!("ok")));
3605        reg.register(ToolSpec::new("t2", "desc2", |_| serde_json::json!("ok")));
3606        let specs = reg.tool_specs();
3607        assert_eq!(specs.len(), 2);
3608    }
3609
3610    #[test]
3611    fn test_tool_registry_tool_specs_empty_when_no_tools() {
3612        let reg = ToolRegistry::new();
3613        assert!(reg.tool_specs().is_empty());
3614    }
3615
3616    // ── Round 8: ToolRegistry::rename_tool ───────────────────────────────────
3617
3618    #[test]
3619    fn test_rename_tool_updates_name_and_key() {
3620        let mut reg = ToolRegistry::new();
3621        reg.register(ToolSpec::new("old", "desc", |_| serde_json::json!("ok")));
3622        assert!(reg.rename_tool("old", "new"));
3623        assert!(reg.has_tool("new"));
3624        assert!(!reg.has_tool("old"));
3625        let spec = reg.get("new").unwrap();
3626        assert_eq!(spec.name, "new");
3627    }
3628
3629    #[test]
3630    fn test_rename_tool_returns_false_for_unknown_name() {
3631        let mut reg = ToolRegistry::new();
3632        assert!(!reg.rename_tool("ghost", "other"));
3633    }
3634
3635    // ── Round 9: filter_tools ─────────────────────────────────────────────────
3636
3637    #[test]
3638    fn test_filter_tools_returns_matching_specs() {
3639        let mut reg = ToolRegistry::new();
3640        reg.register(ToolSpec::new("short_desc", "hi", |_| serde_json::json!({})));
3641        reg.register(ToolSpec::new("long_desc", "a longer description here", |_| serde_json::json!({})));
3642        let long_ones = reg.filter_tools(|s| s.description.len() > 10);
3643        assert_eq!(long_ones.len(), 1);
3644        assert_eq!(long_ones[0].name, "long_desc");
3645    }
3646
3647    #[test]
3648    fn test_filter_tools_returns_empty_when_none_match() {
3649        let mut reg = ToolRegistry::new();
3650        reg.register(ToolSpec::new("t1", "desc", |_| serde_json::json!({})));
3651        let none: Vec<_> = reg.filter_tools(|_| false);
3652        assert!(none.is_empty());
3653    }
3654
3655    #[test]
3656    fn test_filter_tools_returns_all_when_predicate_always_true() {
3657        let mut reg = ToolRegistry::new();
3658        reg.register(ToolSpec::new("a", "d1", |_| serde_json::json!({})));
3659        reg.register(ToolSpec::new("b", "d2", |_| serde_json::json!({})));
3660        let all = reg.filter_tools(|_| true);
3661        assert_eq!(all.len(), 2);
3662    }
3663
3664    // ── Round 10: AgentConfig::max_iterations getter ──────────────────────────
3665
3666    #[test]
3667    fn test_agent_config_max_iterations_getter_returns_configured_value() {
3668        let cfg = AgentConfig::new(5, "model-x");
3669        assert_eq!(cfg.max_iterations(), 5);
3670    }
3671
3672    #[test]
3673    fn test_agent_config_with_max_iterations_updates_getter() {
3674        let cfg = AgentConfig::new(3, "m").with_max_iterations(10);
3675        assert_eq!(cfg.max_iterations(), 10);
3676    }
3677
3678    // ── Round 11: ToolRegistry::is_empty / clear / remove ─────────────────────
3679
3680    #[test]
3681    fn test_tool_registry_is_empty_true_when_new() {
3682        let reg = ToolRegistry::new();
3683        assert!(reg.is_empty());
3684    }
3685
3686    #[test]
3687    fn test_tool_registry_is_empty_false_after_register() {
3688        let mut reg = ToolRegistry::new();
3689        reg.register(ToolSpec::new("t", "d", |_| serde_json::json!({})));
3690        assert!(!reg.is_empty());
3691    }
3692
3693    #[test]
3694    fn test_tool_registry_clear_empties_registry() {
3695        let mut reg = ToolRegistry::new();
3696        reg.register(ToolSpec::new("t1", "d", |_| serde_json::json!({})));
3697        reg.register(ToolSpec::new("t2", "d", |_| serde_json::json!({})));
3698        reg.clear();
3699        assert!(reg.is_empty());
3700        assert_eq!(reg.tool_count(), 0);
3701    }
3702
3703    #[test]
3704    fn test_tool_registry_remove_returns_spec_and_decrements_count() {
3705        let mut reg = ToolRegistry::new();
3706        reg.register(ToolSpec::new("myTool", "desc", |_| serde_json::json!({})));
3707        assert_eq!(reg.tool_count(), 1);
3708        let removed = reg.remove("myTool");
3709        assert!(removed.is_some());
3710        assert_eq!(reg.tool_count(), 0);
3711    }
3712
3713    #[test]
3714    fn test_tool_registry_remove_returns_none_for_absent_tool() {
3715        let mut reg = ToolRegistry::new();
3716        assert!(reg.remove("ghost").is_none());
3717    }
3718
3719    // ── Round 12: all_tool_names ──────────────────────────────────────────────
3720
3721    #[test]
3722    fn test_all_tool_names_returns_sorted_names() {
3723        let mut reg = ToolRegistry::new();
3724        reg.register(ToolSpec::new("zebra", "d", |_| serde_json::json!({})));
3725        reg.register(ToolSpec::new("apple", "d", |_| serde_json::json!({})));
3726        reg.register(ToolSpec::new("mango", "d", |_| serde_json::json!({})));
3727        let names = reg.all_tool_names();
3728        assert_eq!(names, vec!["apple", "mango", "zebra"]);
3729    }
3730
3731    #[test]
3732    fn test_all_tool_names_empty_for_empty_registry() {
3733        let reg = ToolRegistry::new();
3734        assert!(reg.all_tool_names().is_empty());
3735    }
3736
3737    // ── Round 13: AgentConfig::remaining_iterations_after, ToolSpec predicates ──
3738
3739    #[test]
3740    fn test_remaining_iterations_after_full_budget() {
3741        let cfg = AgentConfig::new(10, "m");
3742        assert_eq!(cfg.remaining_iterations_after(0), 10);
3743    }
3744
3745    #[test]
3746    fn test_remaining_iterations_after_partial_use() {
3747        let cfg = AgentConfig::new(10, "m");
3748        assert_eq!(cfg.remaining_iterations_after(3), 7);
3749    }
3750
3751    #[test]
3752    fn test_remaining_iterations_after_saturates_at_zero() {
3753        let cfg = AgentConfig::new(5, "m");
3754        assert_eq!(cfg.remaining_iterations_after(10), 0);
3755    }
3756
3757    #[test]
3758    fn test_tool_spec_required_field_count_zero_by_default() {
3759        let spec = ToolSpec::new("t", "d", |_| serde_json::json!({}));
3760        assert_eq!(spec.required_field_count(), 0);
3761    }
3762
3763    #[test]
3764    fn test_tool_spec_required_field_count_after_adding() {
3765        let spec = ToolSpec::new("t", "d", |_| serde_json::json!({}))
3766            .with_required_fields(["query", "limit"]);
3767        assert_eq!(spec.required_field_count(), 2);
3768    }
3769
3770    #[test]
3771    fn test_tool_spec_has_required_fields_false_by_default() {
3772        let spec = ToolSpec::new("t", "d", |_| serde_json::json!({}));
3773        assert!(!spec.has_required_fields());
3774    }
3775
3776    #[test]
3777    fn test_tool_spec_has_required_fields_true_after_adding() {
3778        let spec = ToolSpec::new("t", "d", |_| serde_json::json!({}))
3779            .with_required_fields(["key"]);
3780        assert!(spec.has_required_fields());
3781    }
3782
3783    #[test]
3784    fn test_tool_spec_has_validators_false_by_default() {
3785        let spec = ToolSpec::new("t", "d", |_| serde_json::json!({}));
3786        assert!(!spec.has_validators());
3787    }
3788
3789    // ── Round 14: ToolRegistry::contains, descriptions, tool_count ───────────
3790
3791    #[test]
3792    fn test_tool_registry_contains_true_for_registered_tool() {
3793        let mut reg = ToolRegistry::new();
3794        reg.register(ToolSpec::new("search", "d", |_| serde_json::json!({})));
3795        assert!(reg.contains("search"));
3796    }
3797
3798    #[test]
3799    fn test_tool_registry_contains_false_for_unknown_tool() {
3800        let reg = ToolRegistry::new();
3801        assert!(!reg.contains("missing"));
3802    }
3803
3804    #[test]
3805    fn test_tool_registry_descriptions_sorted_by_name() {
3806        let mut reg = ToolRegistry::new();
3807        reg.register(ToolSpec::new("zebra", "z-desc", |_| serde_json::json!({})));
3808        reg.register(ToolSpec::new("apple", "a-desc", |_| serde_json::json!({})));
3809        let descs = reg.descriptions();
3810        assert_eq!(descs[0], ("apple", "a-desc"));
3811        assert_eq!(descs[1], ("zebra", "z-desc"));
3812    }
3813
3814    #[test]
3815    fn test_tool_registry_descriptions_empty_when_no_tools() {
3816        let reg = ToolRegistry::new();
3817        assert!(reg.descriptions().is_empty());
3818    }
3819
3820    #[test]
3821    fn test_tool_registry_tool_count_increments_on_register() {
3822        let mut reg = ToolRegistry::new();
3823        assert_eq!(reg.tool_count(), 0);
3824        reg.register(ToolSpec::new("t1", "d", |_| serde_json::json!({})));
3825        assert_eq!(reg.tool_count(), 1);
3826        reg.register(ToolSpec::new("t2", "d", |_| serde_json::json!({})));
3827        assert_eq!(reg.tool_count(), 2);
3828    }
3829
3830    // ── Round 16: ReActStep::observation_is_empty ─────────────────────────────
3831
3832    #[test]
3833    fn test_observation_is_empty_true_for_empty_string() {
3834        let step = ReActStep::new("think", "search", "");
3835        assert!(step.observation_is_empty());
3836    }
3837
3838    #[test]
3839    fn test_observation_is_empty_false_for_non_empty() {
3840        let step = ReActStep::new("think", "search", "found results");
3841        assert!(!step.observation_is_empty());
3842    }
3843
3844    // ── Round 17: AgentConfig::temperature / max_tokens / request_timeout ────
3845
3846    #[test]
3847    fn test_agent_config_temperature_getter_none_by_default() {
3848        let cfg = AgentConfig::new(5, "gpt-4");
3849        assert!(cfg.temperature().is_none());
3850    }
3851
3852    #[test]
3853    fn test_agent_config_temperature_getter_some_when_set() {
3854        let cfg = AgentConfig::new(5, "gpt-4").with_temperature(0.7);
3855        assert!((cfg.temperature().unwrap() - 0.7).abs() < 1e-5);
3856    }
3857
3858    #[test]
3859    fn test_agent_config_max_tokens_getter_none_by_default() {
3860        let cfg = AgentConfig::new(5, "gpt-4");
3861        assert!(cfg.max_tokens().is_none());
3862    }
3863
3864    #[test]
3865    fn test_agent_config_max_tokens_getter_some_when_set() {
3866        let cfg = AgentConfig::new(5, "gpt-4").with_max_tokens(512);
3867        assert_eq!(cfg.max_tokens(), Some(512));
3868    }
3869
3870    #[test]
3871    fn test_agent_config_request_timeout_getter_none_by_default() {
3872        let cfg = AgentConfig::new(5, "gpt-4");
3873        assert!(cfg.request_timeout().is_none());
3874    }
3875
3876    #[test]
3877    fn test_agent_config_request_timeout_getter_some_when_set() {
3878        let cfg = AgentConfig::new(5, "gpt-4")
3879            .with_request_timeout(std::time::Duration::from_secs(10));
3880        assert_eq!(cfg.request_timeout(), Some(std::time::Duration::from_secs(10)));
3881    }
3882
3883    // ── Round 22: has_max_context_chars, max_context_chars, system_prompt, model
3884
3885    #[test]
3886    fn test_agent_config_has_max_context_chars_false_by_default() {
3887        let cfg = AgentConfig::new(5, "gpt-4");
3888        assert!(!cfg.has_max_context_chars());
3889    }
3890
3891    #[test]
3892    fn test_agent_config_has_max_context_chars_true_after_setting() {
3893        let cfg = AgentConfig::new(5, "gpt-4").with_max_context_chars(8192);
3894        assert!(cfg.has_max_context_chars());
3895    }
3896
3897    #[test]
3898    fn test_agent_config_max_context_chars_none_by_default() {
3899        let cfg = AgentConfig::new(5, "gpt-4");
3900        assert_eq!(cfg.max_context_chars(), None);
3901    }
3902
3903    #[test]
3904    fn test_agent_config_max_context_chars_some_after_setting() {
3905        let cfg = AgentConfig::new(5, "gpt-4").with_max_context_chars(4096);
3906        assert_eq!(cfg.max_context_chars(), Some(4096));
3907    }
3908
3909    #[test]
3910    fn test_agent_config_system_prompt_returns_configured_prompt() {
3911        let cfg = AgentConfig::new(5, "gpt-4").with_system_prompt("Be concise.");
3912        assert_eq!(cfg.system_prompt(), "Be concise.");
3913    }
3914
3915    #[test]
3916    fn test_agent_config_model_returns_configured_model() {
3917        let cfg = AgentConfig::new(5, "claude-3");
3918        assert_eq!(cfg.model(), "claude-3");
3919    }
3920
3921    // ── Round 23: Message::is_system, word_count; AgentConfig flags ───────────
3922
3923    #[test]
3924    fn test_message_is_system_true_for_system_role() {
3925        let m = Message::system("context");
3926        assert!(m.is_system());
3927    }
3928
3929    #[test]
3930    fn test_message_is_system_false_for_user_role() {
3931        let m = Message::user("hello");
3932        assert!(!m.is_system());
3933    }
3934
3935    #[test]
3936    fn test_message_word_count_counts_whitespace_words() {
3937        let m = Message::user("hello world foo");
3938        assert_eq!(m.word_count(), 3);
3939    }
3940
3941    #[test]
3942    fn test_message_word_count_zero_for_empty_content() {
3943        let m = Message::user("");
3944        assert_eq!(m.word_count(), 0);
3945    }
3946
3947    #[test]
3948    fn test_agent_config_has_loop_timeout_false_by_default() {
3949        let cfg = AgentConfig::new(5, "m");
3950        assert!(!cfg.has_loop_timeout());
3951    }
3952
3953    #[test]
3954    fn test_agent_config_has_loop_timeout_true_after_setting() {
3955        let cfg = AgentConfig::new(5, "m")
3956            .with_loop_timeout(std::time::Duration::from_secs(30));
3957        assert!(cfg.has_loop_timeout());
3958    }
3959
3960    #[test]
3961    fn test_agent_config_has_stop_sequences_false_by_default() {
3962        let cfg = AgentConfig::new(5, "m");
3963        assert!(!cfg.has_stop_sequences());
3964    }
3965
3966    #[test]
3967    fn test_agent_config_has_stop_sequences_true_after_adding() {
3968        let cfg = AgentConfig::new(5, "m").with_stop_sequences(vec!["STOP".to_string()]);
3969        assert!(cfg.has_stop_sequences());
3970    }
3971
3972    #[test]
3973    fn test_agent_config_is_single_shot_true_when_max_iterations_one() {
3974        let cfg = AgentConfig::new(1, "m");
3975        assert!(cfg.is_single_shot());
3976    }
3977
3978    #[test]
3979    fn test_agent_config_is_single_shot_false_when_max_iterations_gt_one() {
3980        let cfg = AgentConfig::new(5, "m");
3981        assert!(!cfg.is_single_shot());
3982    }
3983
3984    #[test]
3985    fn test_agent_config_has_temperature_false_by_default() {
3986        let cfg = AgentConfig::new(5, "m");
3987        assert!(!cfg.has_temperature());
3988    }
3989
3990    #[test]
3991    fn test_agent_config_has_temperature_true_after_setting() {
3992        let cfg = AgentConfig::new(5, "m").with_temperature(0.7);
3993        assert!(cfg.has_temperature());
3994    }
3995
3996    // ── Round 26: ToolSpec builders ───────────────────────────────────────────
3997
3998    #[test]
3999    fn test_tool_spec_new_fallible_returns_ok_value() {
4000        let rt = tokio::runtime::Runtime::new().unwrap();
4001        let tool = ToolSpec::new_fallible(
4002            "add",
4003            "adds numbers",
4004            |_args| Ok(serde_json::json!({"result": 42})),
4005        );
4006        let result = rt.block_on(tool.call(serde_json::json!({})));
4007        assert_eq!(result["result"], 42);
4008    }
4009
4010    #[test]
4011    fn test_tool_spec_new_fallible_wraps_error_as_json() {
4012        let rt = tokio::runtime::Runtime::new().unwrap();
4013        let tool = ToolSpec::new_fallible(
4014            "fail",
4015            "always fails",
4016            |_| Err("bad input".to_string()),
4017        );
4018        let result = rt.block_on(tool.call(serde_json::json!({})));
4019        assert_eq!(result["error"], "bad input");
4020        assert_eq!(result["ok"], false);
4021    }
4022
4023    #[test]
4024    fn test_tool_spec_new_async_fallible_wraps_error() {
4025        let rt = tokio::runtime::Runtime::new().unwrap();
4026        let tool = ToolSpec::new_async_fallible(
4027            "async_fail",
4028            "async error",
4029            |_| Box::pin(async { Err("async bad".to_string()) }),
4030        );
4031        let result = rt.block_on(tool.call(serde_json::json!({})));
4032        assert_eq!(result["error"], "async bad");
4033    }
4034
4035    #[test]
4036    fn test_tool_spec_with_required_fields_sets_fields() {
4037        let tool = ToolSpec::new("t", "d", |_| serde_json::json!({}))
4038            .with_required_fields(["name", "value"]);
4039        assert_eq!(tool.required_field_count(), 2);
4040    }
4041
4042    #[test]
4043    fn test_tool_spec_with_description_overrides_description() {
4044        let tool = ToolSpec::new("t", "original", |_| serde_json::json!({}))
4045            .with_description("updated description");
4046        assert_eq!(tool.description, "updated description");
4047    }
4048
4049    // ── Round 25: stop_sequence_count / find_by_description_keyword ───────────
4050
4051    #[test]
4052    fn test_agent_config_stop_sequence_count_zero_by_default() {
4053        let cfg = AgentConfig::new(5, "gpt-4");
4054        assert_eq!(cfg.stop_sequence_count(), 0);
4055    }
4056
4057    #[test]
4058    fn test_agent_config_stop_sequence_count_reflects_configured_count() {
4059        let cfg = AgentConfig::new(5, "gpt-4")
4060            .with_stop_sequences(vec!["STOP".to_string(), "END".to_string()]);
4061        assert_eq!(cfg.stop_sequence_count(), 2);
4062    }
4063
4064    #[test]
4065    fn test_tool_registry_find_by_description_keyword_empty_when_no_match() {
4066        let mut reg = ToolRegistry::new();
4067        reg.register(ToolSpec::new("calc", "Performs arithmetic", |_| serde_json::json!({})));
4068        let results = reg.find_by_description_keyword("weather");
4069        assert!(results.is_empty());
4070    }
4071
4072    #[test]
4073    fn test_tool_registry_find_by_description_keyword_case_insensitive() {
4074        let mut reg = ToolRegistry::new();
4075        reg.register(ToolSpec::new("calc", "Performs ARITHMETIC operations", |_| serde_json::json!({})));
4076        reg.register(ToolSpec::new("search", "Searches the web", |_| serde_json::json!({})));
4077        let results = reg.find_by_description_keyword("arithmetic");
4078        assert_eq!(results.len(), 1);
4079        assert_eq!(results[0].name, "calc");
4080    }
4081
4082    #[test]
4083    fn test_tool_registry_find_by_description_keyword_multiple_matches() {
4084        let mut reg = ToolRegistry::new();
4085        reg.register(ToolSpec::new("t1", "query the database", |_| serde_json::json!({})));
4086        reg.register(ToolSpec::new("t2", "query the cache", |_| serde_json::json!({})));
4087        reg.register(ToolSpec::new("t3", "send a message", |_| serde_json::json!({})));
4088        let results = reg.find_by_description_keyword("query");
4089        assert_eq!(results.len(), 2);
4090    }
4091
4092    // ── Round 31: Message::is_user/is_assistant,
4093    //             AgentConfig::stop_sequence_count/has_request_timeout ─────────
4094
4095    #[test]
4096    fn test_message_is_user_true_for_user_role_r31() {
4097        let msg = Message::user("hello");
4098        assert!(msg.is_user());
4099        assert!(!msg.is_assistant());
4100    }
4101
4102    #[test]
4103    fn test_message_is_assistant_true_for_assistant_role_r31() {
4104        let msg = Message::assistant("hi there");
4105        assert!(msg.is_assistant());
4106        assert!(!msg.is_user());
4107    }
4108
4109    #[test]
4110    fn test_agent_config_stop_sequence_count_zero_for_new_config() {
4111        let cfg = AgentConfig::new(5, "model");
4112        assert_eq!(cfg.stop_sequence_count(), 0);
4113    }
4114
4115    #[test]
4116    fn test_agent_config_stop_sequence_count_after_setting() {
4117        let cfg = AgentConfig::new(5, "model")
4118            .with_stop_sequences(vec!["<stop>".to_string(), "END".to_string()]);
4119        assert_eq!(cfg.stop_sequence_count(), 2);
4120    }
4121
4122    #[test]
4123    fn test_agent_config_has_request_timeout_false_by_default() {
4124        let cfg = AgentConfig::new(5, "model");
4125        assert!(!cfg.has_request_timeout());
4126    }
4127
4128    #[test]
4129    fn test_agent_config_has_request_timeout_true_after_setting() {
4130        let cfg = AgentConfig::new(5, "model")
4131            .with_request_timeout(std::time::Duration::from_secs(30));
4132        assert!(cfg.has_request_timeout());
4133    }
4134
4135    // ── Round 29: ReActLoop::unregister_tool ──────────────────────────────────
4136
4137    #[test]
4138    fn test_react_loop_unregister_tool_removes_registered_tool() {
4139        let mut agent = ReActLoop::new(AgentConfig::new(5, "m"));
4140        agent.register_tool(ToolSpec::new("t1", "desc", |_| serde_json::json!({})));
4141        assert!(agent.unregister_tool("t1"));
4142        assert_eq!(agent.tool_count(), 0);
4143    }
4144
4145    #[test]
4146    fn test_react_loop_unregister_tool_returns_false_for_unknown() {
4147        let mut agent = ReActLoop::new(AgentConfig::new(5, "m"));
4148        assert!(!agent.unregister_tool("nonexistent"));
4149    }
4150
4151    // ── Round 26: tool_count_with_required_fields ─────────────────────────────
4152
4153    #[test]
4154    fn test_tool_count_with_required_fields_zero_when_empty() {
4155        let reg = ToolRegistry::new();
4156        assert_eq!(reg.tool_count_with_required_fields(), 0);
4157    }
4158
4159    #[test]
4160    fn test_tool_count_with_required_fields_excludes_tools_without_fields() {
4161        let mut reg = ToolRegistry::new();
4162        reg.register(ToolSpec::new("t1", "d", |_| serde_json::json!({})));
4163        assert_eq!(reg.tool_count_with_required_fields(), 0);
4164    }
4165
4166    #[test]
4167    fn test_tool_count_with_required_fields_counts_only_tools_with_fields() {
4168        let mut reg = ToolRegistry::new();
4169        reg.register(
4170            ToolSpec::new("t1", "d", |_| serde_json::json!({}))
4171                .with_required_fields(["query"]),
4172        );
4173        reg.register(ToolSpec::new("t2", "d", |_| serde_json::json!({}))); // no required
4174        reg.register(
4175            ToolSpec::new("t3", "d", |_| serde_json::json!({}))
4176                .with_required_fields(["url", "method"]),
4177        );
4178        assert_eq!(reg.tool_count_with_required_fields(), 2);
4179    }
4180
4181    // ── Round 27: ToolRegistry::names ─────────────────────────────────────────
4182
4183    #[test]
4184    fn test_tool_registry_names_empty_when_no_tools() {
4185        let reg = ToolRegistry::new();
4186        assert!(reg.names().is_empty());
4187    }
4188
4189    #[test]
4190    fn test_tool_registry_names_sorted_alphabetically() {
4191        let mut reg = ToolRegistry::new();
4192        reg.register(ToolSpec::new("zebra", "d", |_| serde_json::json!({})));
4193        reg.register(ToolSpec::new("alpha", "d", |_| serde_json::json!({})));
4194        reg.register(ToolSpec::new("mango", "d", |_| serde_json::json!({})));
4195        assert_eq!(reg.names(), vec!["alpha", "mango", "zebra"]);
4196    }
4197
4198    // ── Round 28: tool_names_starting_with ────────────────────────────────────
4199
4200    #[test]
4201    fn test_tool_names_starting_with_empty_when_no_match() {
4202        let mut reg = ToolRegistry::new();
4203        reg.register(ToolSpec::new("search", "d", |_| serde_json::json!({})));
4204        assert!(reg.tool_names_starting_with("calc").is_empty());
4205    }
4206
4207    #[test]
4208    fn test_tool_names_starting_with_returns_sorted_matches() {
4209        let mut reg = ToolRegistry::new();
4210        reg.register(ToolSpec::new("db_write", "d", |_| serde_json::json!({})));
4211        reg.register(ToolSpec::new("db_read", "d", |_| serde_json::json!({})));
4212        reg.register(ToolSpec::new("cache_get", "d", |_| serde_json::json!({})));
4213        let results = reg.tool_names_starting_with("db_");
4214        assert_eq!(results, vec!["db_read", "db_write"]);
4215    }
4216
4217    // ── Round 29: description_for ─────────────────────────────────────────────
4218
4219    #[test]
4220    fn test_tool_registry_description_for_none_when_missing() {
4221        let reg = ToolRegistry::new();
4222        assert!(reg.description_for("unknown").is_none());
4223    }
4224
4225    #[test]
4226    fn test_tool_registry_description_for_returns_description() {
4227        let mut reg = ToolRegistry::new();
4228        reg.register(ToolSpec::new("search", "Find web results", |_| serde_json::json!({})));
4229        assert_eq!(reg.description_for("search"), Some("Find web results"));
4230    }
4231
4232    // ── Round 30: count_with_description_containing ───────────────────────────
4233
4234    #[test]
4235    fn test_count_with_description_containing_zero_when_no_match() {
4236        let mut reg = ToolRegistry::new();
4237        reg.register(ToolSpec::new("t1", "database query", |_| serde_json::json!({})));
4238        assert_eq!(reg.count_with_description_containing("weather"), 0);
4239    }
4240
4241    #[test]
4242    fn test_count_with_description_containing_case_insensitive() {
4243        let mut reg = ToolRegistry::new();
4244        reg.register(ToolSpec::new("t1", "Search the WEB", |_| serde_json::json!({})));
4245        reg.register(ToolSpec::new("t2", "web scraper tool", |_| serde_json::json!({})));
4246        reg.register(ToolSpec::new("t3", "database lookup", |_| serde_json::json!({})));
4247        assert_eq!(reg.count_with_description_containing("web"), 2);
4248    }
4249
4250    #[test]
4251    fn test_unregister_all_clears_all_tools() {
4252        let mut reg = ToolRegistry::new();
4253        reg.register(ToolSpec::new("t1", "tool one", |_| serde_json::json!({})));
4254        reg.register(ToolSpec::new("t2", "tool two", |_| serde_json::json!({})));
4255        assert_eq!(reg.tool_count(), 2);
4256        reg.unregister_all();
4257        assert_eq!(reg.tool_count(), 0);
4258    }
4259
4260    #[test]
4261    fn test_tool_names_with_keyword_returns_matching_tool_names() {
4262        let mut reg = ToolRegistry::new();
4263        reg.register(ToolSpec::new("search", "search the web for info", |_| serde_json::json!({})));
4264        reg.register(ToolSpec::new("db", "query database records", |_| serde_json::json!({})));
4265        reg.register(ToolSpec::new("web-fetch", "fetch a WEB page", |_| serde_json::json!({})));
4266        let mut names = reg.tool_names_with_keyword("web");
4267        names.sort_unstable();
4268        assert_eq!(names, vec!["search", "web-fetch"]);
4269    }
4270
4271    #[test]
4272    fn test_tool_names_with_keyword_no_match_returns_empty() {
4273        let mut reg = ToolRegistry::new();
4274        reg.register(ToolSpec::new("t", "some tool", |_| serde_json::json!({})));
4275        assert!(reg.tool_names_with_keyword("missing").is_empty());
4276    }
4277
4278    #[test]
4279    fn test_all_descriptions_returns_sorted_descriptions() {
4280        let mut reg = ToolRegistry::new();
4281        reg.register(ToolSpec::new("t1", "z description", |_| serde_json::json!({})));
4282        reg.register(ToolSpec::new("t2", "a description", |_| serde_json::json!({})));
4283        assert_eq!(reg.all_descriptions(), vec!["a description", "z description"]);
4284    }
4285
4286    #[test]
4287    fn test_all_descriptions_empty_registry_returns_empty() {
4288        let reg = ToolRegistry::new();
4289        assert!(reg.all_descriptions().is_empty());
4290    }
4291
4292    #[test]
4293    fn test_longest_description_returns_longest() {
4294        let mut reg = ToolRegistry::new();
4295        reg.register(ToolSpec::new("t1", "short", |_| serde_json::json!({})));
4296        reg.register(ToolSpec::new("t2", "a much longer description here", |_| serde_json::json!({})));
4297        assert_eq!(reg.longest_description(), Some("a much longer description here"));
4298    }
4299
4300    #[test]
4301    fn test_longest_description_empty_registry_returns_none() {
4302        let reg = ToolRegistry::new();
4303        assert!(reg.longest_description().is_none());
4304    }
4305
4306    #[test]
4307    fn test_names_containing_returns_sorted_matching_names() {
4308        let mut reg = ToolRegistry::new();
4309        reg.register(ToolSpec::new("search-web", "search tool", |_| serde_json::json!({})));
4310        reg.register(ToolSpec::new("web-fetch", "fetch tool", |_| serde_json::json!({})));
4311        reg.register(ToolSpec::new("db-query", "database tool", |_| serde_json::json!({})));
4312        let names = reg.names_containing("web");
4313        assert_eq!(names, vec!["search-web", "web-fetch"]);
4314    }
4315
4316    #[test]
4317    fn test_names_containing_no_match_returns_empty() {
4318        let mut reg = ToolRegistry::new();
4319        reg.register(ToolSpec::new("t", "tool", |_| serde_json::json!({})));
4320        assert!(reg.names_containing("missing").is_empty());
4321    }
4322
4323    // ── Round 36 ──────────────────────────────────────────────────────────────
4324
4325    #[test]
4326    fn test_avg_description_length_returns_mean_byte_length() {
4327        let mut reg = ToolRegistry::new();
4328        reg.register(ToolSpec::new("a", "ab", |_| serde_json::json!({})));    // 2 bytes
4329        reg.register(ToolSpec::new("b", "abcd", |_| serde_json::json!({}))); // 4 bytes
4330        let avg = reg.avg_description_length();
4331        assert!((avg - 3.0).abs() < 1e-9);
4332    }
4333
4334    #[test]
4335    fn test_avg_description_length_returns_zero_when_empty() {
4336        let reg = ToolRegistry::new();
4337        assert_eq!(reg.avg_description_length(), 0.0);
4338    }
4339
4340    // ── Round 37 ──────────────────────────────────────────────────────────────
4341
4342    #[test]
4343    fn test_shortest_description_returns_shortest_string() {
4344        let mut reg = ToolRegistry::new();
4345        reg.register(ToolSpec::new("a", "hello world", |_| serde_json::json!({})));
4346        reg.register(ToolSpec::new("b", "hi", |_| serde_json::json!({})));
4347        reg.register(ToolSpec::new("c", "greetings", |_| serde_json::json!({})));
4348        assert_eq!(reg.shortest_description(), Some("hi"));
4349    }
4350
4351    #[test]
4352    fn test_shortest_description_returns_none_when_empty() {
4353        let reg = ToolRegistry::new();
4354        assert!(reg.shortest_description().is_none());
4355    }
4356
4357    // ── Round 38 ──────────────────────────────────────────────────────────────
4358
4359    #[test]
4360    fn test_tool_names_sorted_returns_names_in_alphabetical_order() {
4361        let mut reg = ToolRegistry::new();
4362        reg.register(ToolSpec::new("zap", "z tool", |_| serde_json::json!({})));
4363        reg.register(ToolSpec::new("alpha", "a tool", |_| serde_json::json!({})));
4364        reg.register(ToolSpec::new("middle", "m tool", |_| serde_json::json!({})));
4365        assert_eq!(reg.tool_names_sorted(), vec!["alpha", "middle", "zap"]);
4366    }
4367
4368    #[test]
4369    fn test_tool_names_sorted_empty_returns_empty() {
4370        let reg = ToolRegistry::new();
4371        assert!(reg.tool_names_sorted().is_empty());
4372    }
4373
4374    // ── Round 39 ──────────────────────────────────────────────────────────────
4375
4376    #[test]
4377    fn test_description_contains_count_counts_matching_descriptions() {
4378        let mut reg = ToolRegistry::new();
4379        reg.register(ToolSpec::new("a", "search the web", |_| serde_json::json!({})));
4380        reg.register(ToolSpec::new("b", "write to disk", |_| serde_json::json!({})));
4381        reg.register(ToolSpec::new("c", "search and filter", |_| serde_json::json!({})));
4382        assert_eq!(reg.description_contains_count("search"), 2);
4383        assert_eq!(reg.description_contains_count("SEARCH"), 2);
4384        assert_eq!(reg.description_contains_count("missing"), 0);
4385    }
4386
4387    #[test]
4388    fn test_description_contains_count_zero_when_empty() {
4389        let reg = ToolRegistry::new();
4390        assert_eq!(reg.description_contains_count("anything"), 0);
4391    }
4392
4393    // ── Round 40: ReActStep::summary ─────────────────────────────────────────
4394
4395    #[test]
4396    fn test_react_step_summary_tool_kind() {
4397        let step = ReActStep::new("I need to search", r#"{"tool":"search","q":"rust"}"#, "results");
4398        let s = step.summary();
4399        assert!(s.starts_with("[TOOL]"));
4400        assert!(s.contains("I need to search"));
4401        assert!(s.contains("results"));
4402    }
4403
4404    #[test]
4405    fn test_react_step_summary_final_kind() {
4406        let step = ReActStep::new("Done", "FINAL_ANSWER hello", "");
4407        let s = step.summary();
4408        assert!(s.starts_with("[FINAL]"));
4409        assert!(s.contains("FINAL_ANSWER hello"));
4410    }
4411
4412    #[test]
4413    fn test_react_step_summary_truncates_long_fields() {
4414        let long = "a".repeat(100);
4415        let step = ReActStep::new(long.clone(), long.clone(), long.clone());
4416        let s = step.summary();
4417        // Each preview is capped at 40 chars plus "…"
4418        assert!(s.contains('…'));
4419    }
4420
4421    #[test]
4422    fn test_react_step_summary_empty_fields() {
4423        let step = ReActStep::new("", "", "");
4424        let s = step.summary();
4425        assert!(s.contains("[TOOL]"));
4426    }
4427
4428    // ── Round 40 ──────────────────────────────────────────────────────────────
4429
4430    #[test]
4431    fn test_tool_registry_total_description_bytes_sums_correctly() {
4432        let mut reg = ToolRegistry::new();
4433        reg.register(ToolSpec::new("a", "hello", |_| serde_json::json!({}))); // 5 bytes
4434        reg.register(ToolSpec::new("b", "world!", |_| serde_json::json!({}))); // 6 bytes
4435        assert_eq!(reg.total_description_bytes(), 11);
4436    }
4437
4438    #[test]
4439    fn test_tool_registry_total_description_bytes_empty_returns_zero() {
4440        let reg = ToolRegistry::new();
4441        assert_eq!(reg.total_description_bytes(), 0);
4442    }
4443
4444    // ── Round 40 (continued): thought_word_count, clone_with_system_prompt, clone_with_max_iterations ──
4445
4446    #[test]
4447    fn test_react_step_thought_word_count_counts_words() {
4448        let step = ReActStep::new("hello world foo", "act", "obs");
4449        assert_eq!(step.thought_word_count(), 3);
4450    }
4451
4452    #[test]
4453    fn test_react_step_thought_word_count_empty_thought_returns_zero() {
4454        let step = ReActStep::new("", "act", "obs");
4455        assert_eq!(step.thought_word_count(), 0);
4456    }
4457
4458    #[test]
4459    fn test_agent_config_clone_with_system_prompt_changes_only_prompt() {
4460        let original = AgentConfig::new(5, "gpt-4");
4461        let cloned = original.clone_with_system_prompt("Custom prompt.");
4462        assert_eq!(cloned.system_prompt, "Custom prompt.");
4463        assert_eq!(cloned.model, "gpt-4");
4464        assert_eq!(cloned.max_iterations, 5);
4465    }
4466
4467    #[test]
4468    fn test_agent_config_clone_with_system_prompt_leaves_original_unchanged() {
4469        let original = AgentConfig::new(3, "claude").with_system_prompt("Original.");
4470        let _cloned = original.clone_with_system_prompt("New.");
4471        assert_eq!(original.system_prompt, "Original.");
4472    }
4473
4474    #[test]
4475    fn test_agent_config_clone_with_max_iterations_changes_only_iterations() {
4476        let original = AgentConfig::new(5, "claude-3");
4477        let cloned = original.clone_with_max_iterations(20);
4478        assert_eq!(cloned.max_iterations, 20);
4479        assert_eq!(cloned.model, "claude-3");
4480    }
4481
4482    #[test]
4483    fn test_agent_config_clone_with_max_iterations_leaves_original_unchanged() {
4484        let original = AgentConfig::new(5, "claude-3");
4485        let _cloned = original.clone_with_max_iterations(10);
4486        assert_eq!(original.max_iterations, 5);
4487    }
4488
4489    // ── Round 41: Message Display and From tuples ─────────────────────────────
4490
4491    #[test]
4492    fn test_message_display_user_role() {
4493        let m = Message::user("hello world");
4494        assert_eq!(m.to_string(), "user: hello world");
4495    }
4496
4497    #[test]
4498    fn test_message_display_assistant_role() {
4499        let m = Message::assistant("I can help");
4500        assert_eq!(m.to_string(), "assistant: I can help");
4501    }
4502
4503    #[test]
4504    fn test_message_display_system_role() {
4505        let m = Message::system("Be helpful");
4506        assert_eq!(m.to_string(), "system: Be helpful");
4507    }
4508
4509    #[test]
4510    fn test_message_from_role_string_tuple() {
4511        let m = Message::from((Role::User, "hello".to_owned()));
4512        assert_eq!(m.role, Role::User);
4513        assert_eq!(m.content, "hello");
4514    }
4515
4516    #[test]
4517    fn test_message_from_role_str_ref_tuple() {
4518        let m = Message::from((Role::Assistant, "ok"));
4519        assert_eq!(m.role, Role::Assistant);
4520        assert_eq!(m.content, "ok");
4521    }
4522
4523    #[test]
4524    fn test_message_into_from_system_tuple() {
4525        let m: Message = (Role::System, "sys prompt").into();
4526        assert!(m.is_system());
4527        assert_eq!(m.content(), "sys prompt");
4528    }
4529
4530    // ── Round 41 ──────────────────────────────────────────────────────────────
4531
4532    #[test]
4533    fn test_tool_registry_shortest_description_length_returns_min_bytes() {
4534        let mut reg = ToolRegistry::new();
4535        reg.register(ToolSpec::new("a", "hello world", |_| serde_json::json!({}))); // 11
4536        reg.register(ToolSpec::new("b", "hi", |_| serde_json::json!({}))); // 2
4537        reg.register(ToolSpec::new("c", "greetings!", |_| serde_json::json!({}))); // 10
4538        assert_eq!(reg.shortest_description_length(), 2);
4539    }
4540
4541    #[test]
4542    fn test_tool_registry_shortest_description_length_empty_returns_zero() {
4543        let reg = ToolRegistry::new();
4544        assert_eq!(reg.shortest_description_length(), 0);
4545    }
4546
4547    // ── Round 41: ToolRegistry::tool_count_with_validators ────────────────────
4548
4549    struct AlwaysOk;
4550    impl ToolValidator for AlwaysOk {
4551        fn validate(&self, _args: &Value) -> Result<(), AgentRuntimeError> {
4552            Ok(())
4553        }
4554    }
4555
4556    #[test]
4557    fn test_tool_count_with_validators_counts_tools_that_have_validators() {
4558        let mut reg = ToolRegistry::new();
4559        reg.register(ToolSpec::new("a", "desc", |_| serde_json::json!({}))
4560            .with_validators(vec![Box::new(AlwaysOk)]));
4561        reg.register(ToolSpec::new("b", "desc", |_| serde_json::json!({}))); // no validators
4562        reg.register(ToolSpec::new("c", "desc", |_| serde_json::json!({}))
4563            .with_validators(vec![Box::new(AlwaysOk)]));
4564        assert_eq!(reg.tool_count_with_validators(), 2);
4565    }
4566
4567    #[test]
4568    fn test_tool_count_with_validators_zero_when_none_have_validators() {
4569        let mut reg = ToolRegistry::new();
4570        reg.register(ToolSpec::new("a", "desc", |_| serde_json::json!({})));
4571        assert_eq!(reg.tool_count_with_validators(), 0);
4572    }
4573
4574    #[test]
4575    fn test_tool_count_with_validators_zero_for_empty_registry() {
4576        let reg = ToolRegistry::new();
4577        assert_eq!(reg.tool_count_with_validators(), 0);
4578    }
4579
4580    // ── Round 42: ToolRegistry::longest_description_length ────────────────────
4581
4582    #[test]
4583    fn test_longest_description_length_returns_max_bytes() {
4584        let mut reg = ToolRegistry::new();
4585        reg.register(ToolSpec::new("a", "hi", |_| serde_json::json!({}))); // 2
4586        reg.register(ToolSpec::new("b", "hello world", |_| serde_json::json!({}))); // 11
4587        reg.register(ToolSpec::new("c", "yo", |_| serde_json::json!({}))); // 2
4588        assert_eq!(reg.longest_description_length(), 11);
4589    }
4590
4591    #[test]
4592    fn test_longest_description_length_zero_for_empty_registry() {
4593        let reg = ToolRegistry::new();
4594        assert_eq!(reg.longest_description_length(), 0);
4595    }
4596
4597    // ── Round 43: ToolRegistry::tools_with_required_field ─────────────────────
4598
4599    #[test]
4600    fn test_tools_with_required_field_returns_matching_tools() {
4601        let mut reg = ToolRegistry::new();
4602        reg.register(
4603            ToolSpec::new("a", "desc", |_| serde_json::json!({}))
4604                .with_required_fields(vec!["query".to_string()]),
4605        );
4606        reg.register(ToolSpec::new("b", "desc", |_| serde_json::json!({}))); // no required fields
4607        reg.register(
4608            ToolSpec::new("c", "desc", |_| serde_json::json!({}))
4609                .with_required_fields(vec!["query".to_string(), "limit".to_string()]),
4610        );
4611        let result = reg.tools_with_required_field("query");
4612        assert_eq!(result.len(), 2);
4613        assert!(result.iter().any(|t| t.name == "a"));
4614        assert!(result.iter().any(|t| t.name == "c"));
4615    }
4616
4617    #[test]
4618    fn test_tools_with_required_field_empty_when_no_match() {
4619        let mut reg = ToolRegistry::new();
4620        reg.register(
4621            ToolSpec::new("a", "desc", |_| serde_json::json!({}))
4622                .with_required_fields(vec!["x".to_string()]),
4623        );
4624        assert!(reg.tools_with_required_field("missing").is_empty());
4625    }
4626
4627    #[test]
4628    fn test_tools_with_required_field_empty_registry_returns_empty() {
4629        let reg = ToolRegistry::new();
4630        assert!(reg.tools_with_required_field("any").is_empty());
4631    }
4632
4633    // ── Round 45: observation_word_count, tool_with_most_required_fields ───────
4634
4635    #[test]
4636    fn test_observation_word_count_counts_words() {
4637        let step = ReActStep {
4638            thought: "t".into(),
4639            action: "a".into(),
4640            observation: "hello world foo".into(),
4641            step_duration_ms: 0,
4642        };
4643        assert_eq!(step.observation_word_count(), 3);
4644    }
4645
4646    #[test]
4647    fn test_observation_word_count_zero_for_empty() {
4648        let step = ReActStep {
4649            thought: "t".into(),
4650            action: "a".into(),
4651            observation: "".into(),
4652            step_duration_ms: 0,
4653        };
4654        assert_eq!(step.observation_word_count(), 0);
4655    }
4656
4657    #[test]
4658    fn test_tool_with_most_required_fields_returns_correct_tool() {
4659        let mut reg = ToolRegistry::new();
4660        reg.register(
4661            ToolSpec::new("few", "d", |_| serde_json::json!({}))
4662                .with_required_fields(vec!["a".to_string()]),
4663        );
4664        reg.register(
4665            ToolSpec::new("many", "d", |_| serde_json::json!({}))
4666                .with_required_fields(vec!["a".to_string(), "b".to_string(), "c".to_string()]),
4667        );
4668        let winner = reg.tool_with_most_required_fields().unwrap();
4669        assert_eq!(winner.name, "many");
4670    }
4671
4672    #[test]
4673    fn test_tool_with_most_required_fields_returns_none_for_empty_registry() {
4674        let reg = ToolRegistry::new();
4675        assert!(reg.tool_with_most_required_fields().is_none());
4676    }
4677
4678    // ── Round 45: tool_count_above_desc_bytes ──────────────────────────────────
4679
4680    #[test]
4681    fn test_tool_count_above_desc_bytes_counts_tools_with_long_descriptions() {
4682        let mut reg = ToolRegistry::new();
4683        reg.register(ToolSpec::new("short", "hi", |_| serde_json::json!({})));
4684        reg.register(ToolSpec::new("long", "a much longer description here", |_| serde_json::json!({})));
4685        assert_eq!(reg.tool_count_above_desc_bytes(2), 1);
4686    }
4687
4688    #[test]
4689    fn test_tool_count_above_desc_bytes_zero_when_none_exceed() {
4690        let mut reg = ToolRegistry::new();
4691        reg.register(ToolSpec::new("t", "ab", |_| serde_json::json!({})));
4692        assert_eq!(reg.tool_count_above_desc_bytes(100), 0);
4693    }
4694
4695    #[test]
4696    fn test_tool_count_above_desc_bytes_zero_for_empty_registry() {
4697        let reg = ToolRegistry::new();
4698        assert_eq!(reg.tool_count_above_desc_bytes(0), 0);
4699    }
4700
4701    // ── Round 44: tool_names_with_required_fields ──────────────────────────────
4702
4703    #[test]
4704    fn test_tool_names_with_required_fields_returns_sorted_names() {
4705        let mut reg = ToolRegistry::new();
4706        reg.register(
4707            ToolSpec::new("b", "desc", |_| serde_json::json!({}))
4708                .with_required_fields(vec!["x".to_string()]),
4709        );
4710        reg.register(
4711            ToolSpec::new("a", "desc", |_| serde_json::json!({}))
4712                .with_required_fields(vec!["y".to_string()]),
4713        );
4714        reg.register(ToolSpec::new("c", "desc", |_| serde_json::json!({}))); // no required fields
4715        assert_eq!(reg.tool_names_with_required_fields(), vec!["a", "b"]);
4716    }
4717
4718    #[test]
4719    fn test_tool_names_with_required_fields_empty_when_none_have_fields() {
4720        let mut reg = ToolRegistry::new();
4721        reg.register(ToolSpec::new("a", "desc", |_| serde_json::json!({})));
4722        assert!(reg.tool_names_with_required_fields().is_empty());
4723    }
4724
4725    #[test]
4726    fn test_tool_names_with_required_fields_empty_for_empty_registry() {
4727        let reg = ToolRegistry::new();
4728        assert!(reg.tool_names_with_required_fields().is_empty());
4729    }
4730
4731    // ── Round 46: tools_without_required_fields ────────────────────────────────
4732
4733    #[test]
4734    fn test_tools_without_required_fields_returns_tools_with_no_required_fields() {
4735        let mut reg = ToolRegistry::new();
4736        reg.register(ToolSpec::new("no-req", "desc", |_| serde_json::json!({})));
4737        reg.register(
4738            ToolSpec::new("with-req", "desc", |_| serde_json::json!({}))
4739                .with_required_fields(vec!["x".to_string()]),
4740        );
4741        let result = reg.tools_without_required_fields();
4742        assert_eq!(result.len(), 1);
4743        assert_eq!(result[0].name, "no-req");
4744    }
4745
4746    #[test]
4747    fn test_tools_without_required_fields_empty_for_empty_registry() {
4748        let reg = ToolRegistry::new();
4749        assert!(reg.tools_without_required_fields().is_empty());
4750    }
4751
4752    // ── Round 47: avg_required_fields_count ───────────────────────────────────
4753
4754    #[test]
4755    fn test_avg_required_fields_count_computes_mean() {
4756        let mut reg = ToolRegistry::new();
4757        reg.register(ToolSpec::new("t1", "d", |_| serde_json::json!({})));
4758        reg.register(
4759            ToolSpec::new("t2", "d", |_| serde_json::json!({}))
4760                .with_required_fields(vec!["a".to_string(), "b".to_string()]),
4761        );
4762        // 0 + 2 = 2 total, 2 tools → avg = 1.0
4763        assert!((reg.avg_required_fields_count() - 1.0).abs() < 1e-9);
4764    }
4765
4766    #[test]
4767    fn test_avg_required_fields_count_zero_for_empty_registry() {
4768        let reg = ToolRegistry::new();
4769        assert_eq!(reg.avg_required_fields_count(), 0.0);
4770    }
4771
4772    // ── Round 47: thought_is_empty, model_is, loop_timeout_ms, total_timeout_ms ──
4773
4774    #[test]
4775    fn test_thought_is_empty_true_for_empty_thought() {
4776        let step = ReActStep::new("", "action", "obs");
4777        assert!(step.thought_is_empty());
4778    }
4779
4780    #[test]
4781    fn test_thought_is_empty_true_for_whitespace_only() {
4782        let step = ReActStep::new("   ", "action", "obs");
4783        assert!(step.thought_is_empty());
4784    }
4785
4786    #[test]
4787    fn test_thought_is_empty_false_for_nonempty_thought() {
4788        let step = ReActStep::new("I need to search", "action", "obs");
4789        assert!(!step.thought_is_empty());
4790    }
4791
4792    #[test]
4793    fn test_model_is_true_for_matching_name() {
4794        let config = AgentConfig::new(10, "claude-sonnet-4-6");
4795        assert!(config.model_is("claude-sonnet-4-6"));
4796    }
4797
4798    #[test]
4799    fn test_model_is_false_for_different_name() {
4800        let config = AgentConfig::new(10, "claude-opus-4-6");
4801        assert!(!config.model_is("claude-sonnet-4-6"));
4802    }
4803
4804    #[test]
4805    fn test_loop_timeout_ms_returns_zero_when_not_configured() {
4806        let config = AgentConfig::new(10, "m");
4807        assert_eq!(config.loop_timeout_ms(), 0);
4808    }
4809
4810    #[test]
4811    fn test_loop_timeout_ms_returns_millis_when_configured() {
4812        let config = AgentConfig::new(10, "m")
4813            .with_loop_timeout(std::time::Duration::from_millis(5000));
4814        assert_eq!(config.loop_timeout_ms(), 5000);
4815    }
4816
4817    #[test]
4818    fn test_total_timeout_ms_zero_when_neither_configured() {
4819        let config = AgentConfig::new(10, "m");
4820        assert_eq!(config.total_timeout_ms(), 0);
4821    }
4822
4823    #[test]
4824    fn test_total_timeout_ms_includes_loop_and_request_budgets() {
4825        let config = AgentConfig::new(4, "m")
4826            .with_loop_timeout(std::time::Duration::from_millis(1000))
4827            .with_request_timeout(std::time::Duration::from_millis(500));
4828        // 1000 + 4 * 500 = 3000
4829        assert_eq!(config.total_timeout_ms(), 3000);
4830    }
4831
4832    // ── Round 48: tool_descriptions_total_words ────────────────────────────────
4833
4834    #[test]
4835    fn test_tool_descriptions_total_words_sums_words() {
4836        let mut reg = ToolRegistry::new();
4837        reg.register(ToolSpec::new("t1", "one two three", |_| serde_json::json!({})));
4838        reg.register(ToolSpec::new("t2", "four five", |_| serde_json::json!({})));
4839        assert_eq!(reg.tool_descriptions_total_words(), 5);
4840    }
4841
4842    #[test]
4843    fn test_tool_descriptions_total_words_zero_for_empty_registry() {
4844        let reg = ToolRegistry::new();
4845        assert_eq!(reg.tool_descriptions_total_words(), 0);
4846    }
4847
4848    // ── Round 49: content_starts_with, system_prompt_is_empty ─────────────────
4849
4850    #[test]
4851    fn test_content_starts_with_true_for_matching_prefix() {
4852        let msg = Message::user("Hello, world!");
4853        assert!(msg.content_starts_with("Hello"));
4854    }
4855
4856    #[test]
4857    fn test_content_starts_with_false_for_non_matching_prefix() {
4858        let msg = Message::user("Hello, world!");
4859        assert!(!msg.content_starts_with("World"));
4860    }
4861
4862    #[test]
4863    fn test_content_starts_with_empty_prefix_always_true() {
4864        let msg = Message::assistant("anything");
4865        assert!(msg.content_starts_with(""));
4866    }
4867
4868    #[test]
4869    fn test_system_prompt_is_empty_true_for_blank_prompt() {
4870        let cfg = AgentConfig::new(5, "m").with_system_prompt("");
4871        assert!(cfg.system_prompt_is_empty());
4872    }
4873
4874    #[test]
4875    fn test_system_prompt_is_empty_false_when_set() {
4876        let cfg = AgentConfig::new(5, "m").with_system_prompt("You are helpful.");
4877        assert!(!cfg.system_prompt_is_empty());
4878    }
4879
4880    // ── Round 49: has_tools_with_empty_descriptions, total_required_fields ─────
4881
4882    #[test]
4883    fn test_has_tools_with_empty_descriptions_true_when_blank_present() {
4884        let mut reg = ToolRegistry::new();
4885        reg.register(ToolSpec::new("t1", "  ", |_| serde_json::json!({})));
4886        assert!(reg.has_tools_with_empty_descriptions());
4887    }
4888
4889    #[test]
4890    fn test_has_tools_with_empty_descriptions_false_when_all_filled() {
4891        let mut reg = ToolRegistry::new();
4892        reg.register(ToolSpec::new("t1", "desc", |_| serde_json::json!({})));
4893        assert!(!reg.has_tools_with_empty_descriptions());
4894    }
4895
4896    #[test]
4897    fn test_total_required_fields_sums_across_tools() {
4898        let mut reg = ToolRegistry::new();
4899        reg.register(
4900            ToolSpec::new("t1", "d", |_| serde_json::json!({}))
4901                .with_required_fields(vec!["a".to_string(), "b".to_string()]),
4902        );
4903        reg.register(
4904            ToolSpec::new("t2", "d", |_| serde_json::json!({}))
4905                .with_required_fields(vec!["c".to_string()]),
4906        );
4907        assert_eq!(reg.total_required_fields(), 3);
4908    }
4909
4910    #[test]
4911    fn test_total_required_fields_zero_for_empty_registry() {
4912        let reg = ToolRegistry::new();
4913        assert_eq!(reg.total_required_fields(), 0);
4914    }
4915
4916    // ── Round 50: tools_with_description_longer_than, max/min_description_bytes ─
4917
4918    #[test]
4919    fn test_tools_with_description_longer_than_returns_matching_tools() {
4920        let mut reg = ToolRegistry::new();
4921        reg.register(ToolSpec::new("short", "hi", |_| serde_json::json!({})));
4922        reg.register(ToolSpec::new("long", "a much longer description", |_| serde_json::json!({})));
4923        let names = reg.tools_with_description_longer_than(5);
4924        assert_eq!(names, vec!["long"]);
4925    }
4926
4927    #[test]
4928    fn test_max_description_bytes_returns_longest() {
4929        let mut reg = ToolRegistry::new();
4930        reg.register(ToolSpec::new("t1", "hi", |_| serde_json::json!({})));
4931        reg.register(ToolSpec::new("t2", "hello world", |_| serde_json::json!({})));
4932        assert_eq!(reg.max_description_bytes(), 11);
4933    }
4934
4935    #[test]
4936    fn test_min_description_bytes_returns_shortest() {
4937        let mut reg = ToolRegistry::new();
4938        reg.register(ToolSpec::new("t1", "hi", |_| serde_json::json!({})));
4939        reg.register(ToolSpec::new("t2", "hello world", |_| serde_json::json!({})));
4940        assert_eq!(reg.min_description_bytes(), 2);
4941    }
4942
4943    #[test]
4944    fn test_max_description_bytes_zero_for_empty_registry() {
4945        let reg = ToolRegistry::new();
4946        assert_eq!(reg.max_description_bytes(), 0);
4947    }
4948
4949    // ── Round 50: has_tool_with_description_containing ────────────────────────
4950
4951    #[test]
4952    fn test_has_tool_with_description_containing_true_when_keyword_found() {
4953        let mut reg = ToolRegistry::new();
4954        reg.register(ToolSpec::new("search", "search the web", |_| serde_json::json!({})));
4955        assert!(reg.has_tool_with_description_containing("web"));
4956    }
4957
4958    #[test]
4959    fn test_has_tool_with_description_containing_false_when_keyword_absent() {
4960        let mut reg = ToolRegistry::new();
4961        reg.register(ToolSpec::new("search", "search the web", |_| serde_json::json!({})));
4962        assert!(!reg.has_tool_with_description_containing("database"));
4963    }
4964
4965    #[test]
4966    fn test_has_tool_with_description_containing_false_for_empty_registry() {
4967        let reg = ToolRegistry::new();
4968        assert!(!reg.has_tool_with_description_containing("anything"));
4969    }
4970
4971    // ── Round 47: system_prompt_word_count ────────────────────────────────────
4972
4973    #[test]
4974    fn test_system_prompt_word_count_counts_words() {
4975        let cfg = AgentConfig::new(10, "m")
4976            .with_system_prompt("You are a helpful AI agent.");
4977        assert_eq!(cfg.system_prompt_word_count(), 6);
4978    }
4979
4980    #[test]
4981    fn test_system_prompt_word_count_zero_for_empty_prompt() {
4982        let cfg = AgentConfig::new(10, "m").with_system_prompt("");
4983        assert_eq!(cfg.system_prompt_word_count(), 0);
4984    }
4985
4986    // ── Round 51: description_starts_with_any ─────────────────────────────────
4987
4988    #[test]
4989    fn test_description_starts_with_any_true_when_prefix_matches() {
4990        let mut reg = ToolRegistry::new();
4991        reg.register(ToolSpec::new("t1", "Search the web", |_| serde_json::json!({})));
4992        assert!(reg.description_starts_with_any(&["Search", "Write"]));
4993    }
4994
4995    #[test]
4996    fn test_description_starts_with_any_false_when_no_prefix_matches() {
4997        let mut reg = ToolRegistry::new();
4998        reg.register(ToolSpec::new("t1", "Read a file", |_| serde_json::json!({})));
4999        assert!(!reg.description_starts_with_any(&["Search", "Write"]));
5000    }
5001
5002    #[test]
5003    fn test_description_starts_with_any_false_for_empty_registry() {
5004        let reg = ToolRegistry::new();
5005        assert!(!reg.description_starts_with_any(&["Search"]));
5006    }
5007
5008    // ── Round 52 ──────────────────────────────────────────────────────────────
5009
5010    #[test]
5011    fn test_combined_byte_length_sums_all_fields() {
5012        let step = ReActStep::new("hello", "search", "result");
5013        assert_eq!(step.combined_byte_length(), 5 + 6 + 6);
5014    }
5015
5016    #[test]
5017    fn test_combined_byte_length_zero_for_empty_step() {
5018        let step = ReActStep::new("", "", "");
5019        assert_eq!(step.combined_byte_length(), 0);
5020    }
5021
5022    #[test]
5023    fn test_iteration_budget_remaining_full_when_no_steps_done() {
5024        let cfg = AgentConfig::new(10, "m");
5025        assert_eq!(cfg.iteration_budget_remaining(0), 10);
5026    }
5027
5028    #[test]
5029    fn test_iteration_budget_remaining_decreases_with_steps() {
5030        let cfg = AgentConfig::new(10, "m");
5031        assert_eq!(cfg.iteration_budget_remaining(7), 3);
5032    }
5033
5034    #[test]
5035    fn test_iteration_budget_remaining_saturates_at_zero() {
5036        let cfg = AgentConfig::new(5, "m");
5037        assert_eq!(cfg.iteration_budget_remaining(10), 0);
5038    }
5039
5040    #[test]
5041    fn test_has_all_tools_true_when_all_registered() {
5042        let mut reg = ToolRegistry::new();
5043        reg.register(ToolSpec::new("search", "Search", |_| serde_json::json!({})));
5044        reg.register(ToolSpec::new("write", "Write", |_| serde_json::json!({})));
5045        assert!(reg.has_all_tools(&["search", "write"]));
5046    }
5047
5048    #[test]
5049    fn test_has_all_tools_false_when_one_missing() {
5050        let mut reg = ToolRegistry::new();
5051        reg.register(ToolSpec::new("search", "Search", |_| serde_json::json!({})));
5052        assert!(!reg.has_all_tools(&["search", "write"]));
5053    }
5054
5055    #[test]
5056    fn test_has_all_tools_true_for_empty_slice() {
5057        let reg = ToolRegistry::new();
5058        assert!(reg.has_all_tools(&[]));
5059    }
5060
5061    // ── Round 52: tool_by_name, tools_without_validators ──────────────────────
5062
5063    #[test]
5064    fn test_tool_by_name_returns_tool_when_present() {
5065        let mut reg = ToolRegistry::new();
5066        reg.register(ToolSpec::new("search", "Search the web", |_| serde_json::json!({})));
5067        assert!(reg.tool_by_name("search").is_some());
5068        assert_eq!(reg.tool_by_name("search").unwrap().name, "search");
5069    }
5070
5071    #[test]
5072    fn test_tool_by_name_returns_none_when_absent() {
5073        let reg = ToolRegistry::new();
5074        assert!(reg.tool_by_name("missing").is_none());
5075    }
5076
5077    #[test]
5078    fn test_tools_without_validators_returns_unvalidated_tools() {
5079        let mut reg = ToolRegistry::new();
5080        reg.register(ToolSpec::new("a", "Tool A", |_| serde_json::json!({})));
5081        reg.register(ToolSpec::new("b", "Tool B", |_| serde_json::json!({})));
5082        let names = reg.tools_without_validators();
5083        assert!(names.contains(&"a"));
5084        assert!(names.contains(&"b"));
5085    }
5086
5087    #[test]
5088    fn test_tools_without_validators_empty_for_empty_registry() {
5089        let reg = ToolRegistry::new();
5090        assert!(reg.tools_without_validators().is_empty());
5091    }
5092
5093    // ── Round 53 ──────────────────────────────────────────────────────────────
5094
5095    #[test]
5096    fn test_action_is_empty_true_for_empty_action() {
5097        let step = ReActStep::new("thought", "", "obs");
5098        assert!(step.action_is_empty());
5099    }
5100
5101    #[test]
5102    fn test_action_is_empty_false_for_nonempty_action() {
5103        let step = ReActStep::new("thought", "search", "obs");
5104        assert!(!step.action_is_empty());
5105    }
5106
5107    #[test]
5108    fn test_action_is_empty_true_for_whitespace_only() {
5109        let step = ReActStep::new("thought", "   ", "obs");
5110        assert!(step.action_is_empty());
5111    }
5112
5113    #[test]
5114    fn test_is_minimal_true_for_single_iteration_no_prompt() {
5115        let cfg = AgentConfig::new(1, "m").with_system_prompt("");
5116        assert!(cfg.is_minimal());
5117    }
5118
5119    #[test]
5120    fn test_is_minimal_false_when_max_iterations_above_one() {
5121        let cfg = AgentConfig::new(5, "m");
5122        assert!(!cfg.is_minimal());
5123    }
5124
5125    #[test]
5126    fn test_is_minimal_false_when_system_prompt_set() {
5127        let cfg = AgentConfig::new(1, "m").with_system_prompt("prompt");
5128        assert!(!cfg.is_minimal());
5129    }
5130
5131    // ── Round 48 ──────────────────────────────────────────────────────────────
5132
5133    #[test]
5134    fn test_model_starts_with_true_when_prefix_matches() {
5135        let cfg = AgentConfig::new(3, "claude-3-opus");
5136        assert!(cfg.model_starts_with("claude"));
5137    }
5138
5139    #[test]
5140    fn test_model_starts_with_false_when_prefix_differs() {
5141        let cfg = AgentConfig::new(3, "gpt-4o");
5142        assert!(!cfg.model_starts_with("claude"));
5143    }
5144
5145    #[test]
5146    fn test_tools_with_required_fields_count_correct() {
5147        let mut registry = ToolRegistry::new();
5148        registry.register(ToolSpec::new(
5149            "search",
5150            "desc",
5151            |_| serde_json::json!("ok"),
5152        ).with_required_fields(vec!["query".to_string()]));
5153        registry.register(ToolSpec::new(
5154            "noop",
5155            "desc",
5156            |_| serde_json::json!("ok"),
5157        ));
5158        assert_eq!(registry.tools_with_required_fields_count(), 1);
5159    }
5160
5161    #[test]
5162    fn test_tools_with_required_fields_count_zero_for_empty_registry() {
5163        let registry = ToolRegistry::new();
5164        assert_eq!(registry.tools_with_required_fields_count(), 0);
5165    }
5166
5167    // ── Round 54 ──────────────────────────────────────────────────────────────
5168
5169    #[test]
5170    fn test_tool_names_with_prefix_returns_matching_names() {
5171        let mut reg = ToolRegistry::new();
5172        reg.register(ToolSpec::new("search_web", "desc", |_| serde_json::json!({})));
5173        reg.register(ToolSpec::new("search_code", "desc", |_| serde_json::json!({})));
5174        reg.register(ToolSpec::new("write_file", "desc", |_| serde_json::json!({})));
5175        let names = reg.tool_names_with_prefix("search_");
5176        assert_eq!(names, vec!["search_code", "search_web"]);
5177    }
5178
5179    #[test]
5180    fn test_tool_names_with_prefix_empty_when_no_match() {
5181        let mut reg = ToolRegistry::new();
5182        reg.register(ToolSpec::new("write_file", "desc", |_| serde_json::json!({})));
5183        assert!(reg.tool_names_with_prefix("search_").is_empty());
5184    }
5185
5186    #[test]
5187    fn test_exceeds_iteration_limit_true_when_at_limit() {
5188        let cfg = AgentConfig::new(5, "m");
5189        assert!(cfg.exceeds_iteration_limit(5));
5190        assert!(cfg.exceeds_iteration_limit(10));
5191    }
5192
5193    #[test]
5194    fn test_exceeds_iteration_limit_false_when_below_limit() {
5195        let cfg = AgentConfig::new(5, "m");
5196        assert!(!cfg.exceeds_iteration_limit(4));
5197        assert!(!cfg.exceeds_iteration_limit(0));
5198    }
5199
5200    // ── Round 57 ──────────────────────────────────────────────────────────────
5201
5202    #[test]
5203    fn test_total_word_count_sums_all_fields() {
5204        let step = ReActStep::new("one two", "three", "four five six");
5205        assert_eq!(step.total_word_count(), 6);
5206    }
5207
5208    #[test]
5209    fn test_total_word_count_zero_for_empty_step() {
5210        let step = ReActStep::new("", "", "");
5211        assert_eq!(step.total_word_count(), 0);
5212    }
5213
5214    #[test]
5215    fn test_token_budget_configured_true_when_max_tokens_set() {
5216        let cfg = AgentConfig::new(3, "m").with_max_tokens(100);
5217        assert!(cfg.token_budget_configured());
5218    }
5219
5220    #[test]
5221    fn test_token_budget_configured_true_when_max_context_chars_set() {
5222        let cfg = AgentConfig::new(3, "m").with_max_context_chars(200);
5223        assert!(cfg.token_budget_configured());
5224    }
5225
5226    #[test]
5227    fn test_token_budget_configured_false_when_neither_set() {
5228        let cfg = AgentConfig::new(3, "m");
5229        assert!(!cfg.token_budget_configured());
5230    }
5231
5232    // ── Round 58 ──────────────────────────────────────────────────────────────
5233
5234    #[test]
5235    fn test_is_complete_true_when_all_fields_nonempty() {
5236        let step = ReActStep::new("thought", "action", "observation");
5237        assert!(step.is_complete());
5238    }
5239
5240    #[test]
5241    fn test_is_complete_false_when_observation_empty() {
5242        let step = ReActStep::new("thought", "action", "");
5243        assert!(!step.is_complete());
5244    }
5245
5246    #[test]
5247    fn test_is_complete_false_when_action_empty() {
5248        let step = ReActStep::new("thought", "", "obs");
5249        assert!(!step.is_complete());
5250    }
5251
5252    #[test]
5253    fn test_max_tokens_or_default_returns_value_when_set() {
5254        let cfg = AgentConfig::new(3, "m").with_max_tokens(512);
5255        assert_eq!(cfg.max_tokens_or_default(100), 512);
5256    }
5257
5258    #[test]
5259    fn test_max_tokens_or_default_returns_default_when_unset() {
5260        let cfg = AgentConfig::new(3, "m");
5261        assert_eq!(cfg.max_tokens_or_default(256), 256);
5262    }
5263
5264    // ── Round 59 ──────────────────────────────────────────────────────────────
5265
5266    #[test]
5267    fn test_observation_starts_with_true_for_matching_prefix() {
5268        let step = ReActStep::new("t", "a", "Result: ok");
5269        assert!(step.observation_starts_with("Result:"));
5270    }
5271
5272    #[test]
5273    fn test_observation_starts_with_false_for_non_matching_prefix() {
5274        let step = ReActStep::new("t", "a", "Error: failed");
5275        assert!(!step.observation_starts_with("Result:"));
5276    }
5277
5278    #[test]
5279    fn test_effective_temperature_returns_configured_value() {
5280        let cfg = AgentConfig::new(3, "m").with_temperature(0.5);
5281        assert!((cfg.effective_temperature() - 0.5_f32).abs() < 1e-6);
5282    }
5283
5284    #[test]
5285    fn test_effective_temperature_returns_default_when_unset() {
5286        let cfg = AgentConfig::new(3, "m");
5287        assert!((cfg.effective_temperature() - 1.0_f32).abs() < 1e-6);
5288    }
5289
5290    // ── Round 62: ReActStep helpers ───────────────────────────────────────────
5291
5292    #[test]
5293    fn test_action_word_count_returns_words_in_action() {
5294        let step = ReActStep::new("think", "do this now", "ok");
5295        assert_eq!(step.action_word_count(), 3);
5296    }
5297
5298    #[test]
5299    fn test_action_word_count_zero_for_empty_action() {
5300        let step = ReActStep::new("think", "", "ok");
5301        assert_eq!(step.action_word_count(), 0);
5302    }
5303
5304    #[test]
5305    fn test_thought_byte_len_matches_string_len() {
5306        let step = ReActStep::new("hello", "act", "obs");
5307        assert_eq!(step.thought_byte_len(), "hello".len());
5308    }
5309
5310    #[test]
5311    fn test_action_byte_len_matches_string_len() {
5312        let step = ReActStep::new("think", "do it", "obs");
5313        assert_eq!(step.action_byte_len(), "do it".len());
5314    }
5315
5316    #[test]
5317    fn test_has_empty_fields_true_when_observation_empty() {
5318        let step = ReActStep::new("think", "act", "");
5319        assert!(step.has_empty_fields());
5320    }
5321
5322    #[test]
5323    fn test_has_empty_fields_false_when_all_populated() {
5324        let step = ReActStep::new("think", "act", "obs");
5325        assert!(!step.has_empty_fields());
5326    }
5327
5328    // ── Round 62: AgentConfig helpers ─────────────────────────────────────────
5329
5330    #[test]
5331    fn test_system_prompt_starts_with_true_for_matching_prefix() {
5332        let cfg = AgentConfig::new(3, "m").with_system_prompt("You are a helpful assistant.");
5333        assert!(cfg.system_prompt_starts_with("You are"));
5334    }
5335
5336    #[test]
5337    fn test_system_prompt_starts_with_false_for_non_matching_prefix() {
5338        let cfg = AgentConfig::new(3, "m").with_system_prompt("Hello world");
5339        assert!(!cfg.system_prompt_starts_with("Goodbye"));
5340    }
5341
5342    #[test]
5343    fn test_max_iterations_above_true_when_greater() {
5344        let cfg = AgentConfig::new(5, "m");
5345        assert!(cfg.max_iterations_above(4));
5346    }
5347
5348    #[test]
5349    fn test_max_iterations_above_false_when_equal() {
5350        let cfg = AgentConfig::new(5, "m");
5351        assert!(!cfg.max_iterations_above(5));
5352    }
5353
5354    #[test]
5355    fn test_stop_sequences_contain_true_for_present_sequence() {
5356        let cfg = AgentConfig::new(3, "m")
5357            .with_stop_sequences(vec!["STOP".to_string(), "END".to_string()]);
5358        assert!(cfg.stop_sequences_contain("STOP"));
5359    }
5360
5361    #[test]
5362    fn test_stop_sequences_contain_false_for_absent_sequence() {
5363        let cfg = AgentConfig::new(3, "m")
5364            .with_stop_sequences(vec!["STOP".to_string()]);
5365        assert!(!cfg.stop_sequences_contain("END"));
5366    }
5367
5368    #[test]
5369    fn test_stop_sequences_contain_false_for_empty_config() {
5370        let cfg = AgentConfig::new(3, "m");
5371        assert!(!cfg.stop_sequences_contain("STOP"));
5372    }
5373
5374    // ── Round 63: observation_byte_len, all_fields_have_words, system_prompt_byte_len, has_valid_temperature ──
5375
5376    #[test]
5377    fn test_observation_byte_len_matches_string_len() {
5378        let step = ReActStep::new("t", "a", "result");
5379        assert_eq!(step.observation_byte_len(), "result".len());
5380    }
5381
5382    #[test]
5383    fn test_observation_byte_len_zero_for_empty() {
5384        let step = ReActStep::new("t", "a", "");
5385        assert_eq!(step.observation_byte_len(), 0);
5386    }
5387
5388    #[test]
5389    fn test_all_fields_have_words_true_when_all_populated() {
5390        let step = ReActStep::new("think", "act", "obs");
5391        assert!(step.all_fields_have_words());
5392    }
5393
5394    #[test]
5395    fn test_all_fields_have_words_false_when_action_empty() {
5396        let step = ReActStep::new("think", "", "obs");
5397        assert!(!step.all_fields_have_words());
5398    }
5399
5400    #[test]
5401    fn test_system_prompt_byte_len_returns_length() {
5402        let cfg = AgentConfig::new(3, "m").with_system_prompt("Hello!");
5403        assert_eq!(cfg.system_prompt_byte_len(), "Hello!".len());
5404    }
5405
5406    #[test]
5407    fn test_system_prompt_byte_len_default_is_nonzero() {
5408        let cfg = AgentConfig::new(3, "m");
5409        // Default system prompt is "You are a helpful AI agent."
5410        assert_eq!(cfg.system_prompt_byte_len(), "You are a helpful AI agent.".len());
5411    }
5412
5413    #[test]
5414    fn test_has_valid_temperature_true_for_in_range() {
5415        let cfg = AgentConfig::new(3, "m").with_temperature(0.7);
5416        assert!(cfg.has_valid_temperature());
5417    }
5418
5419    #[test]
5420    fn test_has_valid_temperature_false_when_unset() {
5421        let cfg = AgentConfig::new(3, "m");
5422        assert!(!cfg.has_valid_temperature());
5423    }
5424
5425    #[test]
5426    fn test_has_valid_temperature_true_at_boundaries() {
5427        assert!(AgentConfig::new(3, "m").with_temperature(0.0).has_valid_temperature());
5428        assert!(AgentConfig::new(3, "m").with_temperature(2.0).has_valid_temperature());
5429    }
5430}