Skip to main content

nika_engine/ast/
agent.rs

1//! Agent Action Parameters
2//!
3//! The `agent:` verb enables agentic execution with MCP tool access.
4//! Unlike `infer:` (one-shot LLM call), `agent:` runs in a loop with
5//! tool calling capabilities.
6//!
7//! # Example
8//!
9//! ```yaml
10//! - agent:
11//!     prompt: |
12//!       Generate native content for the homepage hero block.
13//!       Use @entity:qr-code-generator for the main concept.
14//!     provider: claude
15//!     model: claude-sonnet-4-6
16//!     mcp:
17//!       - novanet
18//!     max_turns: 10
19//! ```
20
21use serde::Deserialize;
22
23use crate::ast::completion::CompletionConfig;
24use crate::error::NikaError;
25
26// ═══════════════════════════════════════════════════════════════════════════
27// Tool Choice
28// ═══════════════════════════════════════════════════════════════════════════
29
30/// Tool choice behavior for agent execution
31///
32/// Controls when the agent uses tools during execution.
33///
34/// # YAML Syntax
35/// ```yaml
36/// agent:
37///   prompt: "..."
38///   tool_choice: auto    # auto | required | none
39/// ```
40#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum ToolChoice {
43    /// LLM decides when to use tools (default behavior)
44    #[default]
45    Auto,
46    /// Must use at least one tool per turn
47    Required,
48    /// Never use tools (text-only response)
49    None,
50}
51
52impl ToolChoice {
53    /// Convert to rig-core compatible string representation
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            Self::Auto => "auto",
57            Self::Required => "required",
58            Self::None => "none",
59        }
60    }
61}
62
63// ═══════════════════════════════════════════════════════════════════════════
64// Constants
65// ═══════════════════════════════════════════════════════════════════════════
66
67/// Default maximum turns for agent loop
68const DEFAULT_MAX_TURNS: u32 = 10;
69
70/// Maximum allowed turns to prevent runaway agents
71const MAX_ALLOWED_TURNS: u32 = 100;
72
73/// Default thinking budget for extended thinking (4096 tokens)
74const DEFAULT_THINKING_BUDGET: u64 = 4096;
75
76/// Default depth limit for nested agent spawning (MVP 8 Phase 2)
77const DEFAULT_DEPTH_LIMIT: u32 = 3;
78
79/// Maximum allowed depth limit (prevent deep recursion)
80const MAX_DEPTH_LIMIT: u32 = 10;
81
82/// Parameters for the `agent:` verb
83///
84/// Enables agentic execution with MCP tool access. The agent runs
85/// in a loop, calling tools as needed until a stop condition is met
86/// or max_turns is reached.
87#[derive(Debug, Clone, Default, Deserialize)]
88pub struct AgentParams {
89    /// System/user prompt for the agent
90    pub prompt: String,
91
92    /// System prompt (optional, sets the agent's behavior/persona)
93    #[serde(default)]
94    pub system: Option<String>,
95
96    /// LLM provider override (defaults to workflow provider)
97    #[serde(default)]
98    pub provider: Option<String>,
99
100    /// Model override (defaults to workflow model)
101    #[serde(default)]
102    pub model: Option<String>,
103
104    /// MCP servers the agent can access for tool calling
105    #[serde(default)]
106    pub mcp: Vec<String>,
107
108    /// Builtin tools the agent can use (nika:read, nika:write, etc.)
109    #[serde(default)]
110    pub tools: Vec<String>,
111
112    /// Maximum agentic turns before stopping
113    #[serde(default)]
114    pub max_turns: Option<u32>,
115
116    /// Token budget for the entire agent session
117    /// Stops gracefully when budget is exceeded
118    #[serde(default)]
119    pub token_budget: Option<u32>,
120
121    /// Sequences that stop generation (passed to LLM)
122    #[serde(default)]
123    pub stop_sequences: Vec<String>,
124
125    /// Scope preset (full, minimal, debug)
126    #[serde(default)]
127    pub scope: Option<String>,
128
129    /// Enable extended thinking for Claude models
130    ///
131    /// When enabled, the agent captures Claude's reasoning process
132    /// in the `thinking` field of AgentTurn events. Only supported
133    /// for Claude provider.
134    #[serde(default)]
135    pub extended_thinking: Option<bool>,
136
137    /// Token budget for extended thinking (default: 4096)
138    ///
139    /// Controls how many tokens Claude can use for reasoning in
140    /// extended thinking mode. Higher values allow deeper analysis
141    /// but cost more. Only used when `extended_thinking: true`.
142    #[serde(default)]
143    pub thinking_budget: Option<u64>,
144
145    /// Maximum depth for nested agent spawning (default: 3, max: 10)
146    ///
147    /// Controls how many levels of sub-agents can be spawned recursively.
148    /// A depth_limit of 1 means no sub-agents can be spawned.
149    /// Used by `spawn_agent` internal tool to prevent infinite recursion.
150    #[serde(default)]
151    pub depth_limit: Option<u32>,
152
153    /// Tool choice behavior
154    ///
155    /// Controls when the agent uses tools:
156    /// - `auto` (default): LLM decides when to use tools
157    /// - `required`: Must use at least one tool per turn
158    /// - `none`: Never use tools (text-only response)
159    #[serde(default)]
160    pub tool_choice: Option<ToolChoice>,
161
162    /// Model temperature for response randomness
163    ///
164    /// Controls the randomness/creativity of responses:
165    /// - 0.0: Deterministic, most focused
166    /// - 1.0: Balanced creativity (typical default)
167    /// - 2.0: Maximum creativity/randomness
168    ///
169    /// If not set, uses the provider's default.
170    #[serde(default)]
171    pub temperature: Option<f32>,
172
173    /// Maximum tokens to generate per turn
174    ///
175    /// Required when using `extended_thinking: true` with Claude.
176    /// Must be greater than `thinking_budget`. If not set with extended
177    /// thinking enabled, defaults to `thinking_budget + 8192`.
178    #[serde(default)]
179    pub max_tokens: Option<u32>,
180
181    /// Skills to inject into the agent's system prompt
182    ///
183    /// List of skill aliases that will be loaded and prepended to
184    /// the system prompt before each LLM call. Skills are resolved
185    /// from the workflow's `skills:` map.
186    #[serde(default)]
187    pub skills: Option<Vec<String>>,
188
189    /// Completion behavior configuration
190    ///
191    /// Controls how the agent signals task completion:
192    /// - `mode: explicit` - Agent must call `nika:complete` tool (recommended)
193    /// - `mode: natural` - Completes when agent has no more tool calls
194    /// - `mode: pattern` - Completes on pattern match
195    ///
196    /// When not set, defaults to `natural` mode.
197    #[serde(default)]
198    pub completion: Option<CompletionConfig>,
199
200    /// Guardrails for validating agent outputs
201    ///
202    /// Guardrails are checked after the agent signals completion.
203    /// If any guardrail fails, the agent is asked to retry.
204    #[serde(default)]
205    pub guardrails: Vec<crate::ast::guardrails::GuardrailConfig>,
206
207    /// Execution limits for cost control
208    ///
209    /// Limits control resource consumption:
210    /// - `max_turns`: Maximum agentic loop iterations
211    /// - `max_tokens`: Total token budget (input + output)
212    /// - `max_cost_usd`: Cost ceiling per execution
213    /// - `max_duration_secs`: Wall-clock timeout
214    ///
215    /// When a limit is reached, the action specified in `on_limit_reached`
216    /// is taken (complete_partial, fail, or escalate).
217    #[serde(default)]
218    pub limits: Option<crate::ast::limits::LimitsConfig>,
219}
220
221impl AgentParams {
222    /// Get effective max turns (with default).
223    ///
224    /// Returns the configured `max_turns` if set, otherwise returns
225    /// the default value of 10.
226    #[inline]
227    pub fn effective_max_turns(&self) -> u32 {
228        self.max_turns.unwrap_or(DEFAULT_MAX_TURNS)
229    }
230
231    /// Get effective token budget (with default).
232    ///
233    /// Returns the configured `token_budget` if set, otherwise returns
234    /// `u32::MAX` (effectively unlimited).
235    #[inline]
236    pub fn effective_token_budget(&self) -> u32 {
237        self.token_budget.unwrap_or(u32::MAX)
238    }
239
240    /// Get effective thinking budget (with default).
241    ///
242    /// Returns the configured `thinking_budget` if set, otherwise returns
243    /// the default value of 4096 tokens.
244    #[inline]
245    pub fn effective_thinking_budget(&self) -> u64 {
246        self.thinking_budget.unwrap_or(DEFAULT_THINKING_BUDGET)
247    }
248
249    /// Get effective depth limit for nested agent spawning (with default).
250    ///
251    /// Returns the configured `depth_limit` if set, otherwise returns
252    /// the default value of 3.
253    #[inline]
254    pub fn effective_depth_limit(&self) -> u32 {
255        self.depth_limit.unwrap_or(DEFAULT_DEPTH_LIMIT)
256    }
257
258    /// Get effective max tokens for LLM calls.
259    ///
260    /// When `extended_thinking` is enabled, Claude requires `max_tokens > thinking_budget`.
261    /// If `max_tokens` is not set but extended thinking is enabled, this returns
262    /// `thinking_budget + 8192` to ensure valid configuration.
263    ///
264    /// Returns `None` if neither max_tokens is set nor extended_thinking is enabled.
265    #[inline]
266    pub fn effective_max_tokens(&self) -> Option<u32> {
267        if let Some(max_tokens) = self.max_tokens {
268            Some(max_tokens)
269        } else if self.extended_thinking.unwrap_or(false) {
270            // Claude requires max_tokens > thinking_budget for extended thinking
271            let thinking_budget = self.effective_thinking_budget() as u32;
272            Some(thinking_budget + 8192)
273        } else {
274            None
275        }
276    }
277
278    /// Get effective tool choice behavior.
279    ///
280    /// Returns the configured `tool_choice` if set, otherwise returns
281    /// `ToolChoice::Auto` (LLM decides when to use tools).
282    #[inline]
283    pub fn effective_tool_choice(&self) -> ToolChoice {
284        self.tool_choice.clone().unwrap_or_default()
285    }
286
287    /// Check if tool_choice was explicitly set by the user.
288    ///
289    /// Returns `true` only if the user explicitly specified `tool_choice` in YAML.
290    /// Used to avoid redundant `.tool_choice(Auto)` calls when the default is sufficient.
291    ///
292    /// # Why This Matters
293    ///
294    /// Some providers handle `tool_choice` differently:
295    /// - **Perplexity**: Logs warning, ignores tool_choice
296    /// - **Cohere**: Rejects `Auto` explicitly (rig-core skips if Auto)
297    /// - **Claude/OpenAI**: Full support
298    ///
299    /// By only setting tool_choice when explicit, we avoid:
300    /// 1. Unnecessary API calls with default values
301    /// 2. Provider-specific edge cases with Auto
302    #[inline]
303    pub fn has_explicit_tool_choice(&self) -> bool {
304        self.tool_choice.is_some()
305    }
306
307    /// Get effective temperature.
308    ///
309    /// Returns the configured `temperature` if set, otherwise returns `None`
310    /// to use the provider's default.
311    #[inline]
312    pub fn effective_temperature(&self) -> Option<f32> {
313        self.temperature
314    }
315
316    /// Get effective completion configuration.
317    ///
318    /// Returns the completion config if set, otherwise None (natural mode).
319    pub fn effective_completion(&self) -> Option<CompletionConfig> {
320        self.completion.clone()
321    }
322
323    /// Generate system instruction for completion.
324    ///
325    /// Returns the auto-generated instruction to append to the system prompt,
326    /// or empty string if no special completion instruction is needed.
327    pub fn completion_system_instruction(&self) -> String {
328        self.completion
329            .as_ref()
330            .map(|c| c.generate_system_instruction())
331            .unwrap_or_default()
332    }
333
334    /// Validate agent parameters.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error string if:
339    /// - `prompt` is empty
340    /// - `max_turns` is 0 or exceeds 100
341    /// - `token_budget` is 0
342    pub fn validate(&self) -> Result<(), NikaError> {
343        if self.prompt.is_empty() {
344            return Err(NikaError::ValidationError {
345                reason: "Agent prompt cannot be empty".into(),
346            });
347        }
348
349        if let Some(max) = self.max_turns {
350            if max == 0 {
351                return Err(NikaError::ValidationError {
352                    reason: "max_turns must be > 0".into(),
353                });
354            }
355            if max > MAX_ALLOWED_TURNS {
356                return Err(NikaError::ValidationError {
357                    reason: format!("max_turns cannot exceed {}", MAX_ALLOWED_TURNS),
358                });
359            }
360        }
361
362        if let Some(budget) = self.token_budget {
363            if budget == 0 {
364                return Err(NikaError::ValidationError {
365                    reason: "token_budget must be > 0".into(),
366                });
367            }
368        }
369
370        // Extended thinking is only supported for Claude
371        if self.extended_thinking == Some(true) {
372            if let Some(ref provider) = self.provider {
373                if provider != "claude" {
374                    return Err(NikaError::ValidationError {
375                        reason: format!(
376                            "extended_thinking only supported for claude provider, got '{}'",
377                            provider
378                        ),
379                    });
380                }
381            }
382        }
383
384        // Validate depth_limit (MVP 8 Phase 2)
385        if let Some(depth) = self.depth_limit {
386            if depth == 0 {
387                return Err(NikaError::ValidationError {
388                    reason: "depth_limit must be > 0".into(),
389                });
390            }
391            if depth > MAX_DEPTH_LIMIT {
392                return Err(NikaError::ValidationError {
393                    reason: format!("depth_limit cannot exceed {}", MAX_DEPTH_LIMIT),
394                });
395            }
396        }
397
398        // Validate temperature
399        if let Some(temp) = self.temperature {
400            if !(0.0..=2.0).contains(&temp) {
401                return Err(NikaError::ValidationError {
402                    reason: format!("temperature must be between 0.0 and 2.0, got {}", temp),
403                });
404            }
405        }
406
407        // Validate completion config
408        if let Some(ref completion) = self.completion {
409            completion.validate()?;
410        }
411
412        // Validate limits config
413        if let Some(ref limits) = self.limits {
414            limits.validate()?;
415        }
416
417        Ok(())
418    }
419
420    /// Get effective limits configuration.
421    ///
422    /// Returns the limits config if set, or a default (no limits) otherwise.
423    pub fn effective_limits(&self) -> crate::ast::limits::LimitsConfig {
424        self.limits.clone().unwrap_or_default()
425    }
426
427    /// Check if any limits are configured.
428    pub fn has_limits(&self) -> bool {
429        self.limits
430            .as_ref()
431            .map(|l| l.has_limits())
432            .unwrap_or(false)
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::serde_yaml;
440
441    #[test]
442    fn parse_agent_params_basic() {
443        let yaml = r#"
444prompt: "Test prompt"
445provider: claude
446model: claude-sonnet-4-6
447"#;
448        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
449        assert_eq!(params.prompt, "Test prompt");
450        assert_eq!(params.provider, Some("claude".to_string()));
451        assert_eq!(params.model, Some("claude-sonnet-4-6".to_string()));
452    }
453
454    #[test]
455    fn parse_agent_params_mcp_list() {
456        let yaml = r#"
457prompt: "Test"
458mcp:
459  - novanet
460  - filesystem
461"#;
462        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
463        assert_eq!(params.mcp, vec!["novanet", "filesystem"]);
464    }
465
466    #[test]
467    fn effective_max_turns_default() {
468        let params = AgentParams::default();
469        assert_eq!(params.effective_max_turns(), DEFAULT_MAX_TURNS);
470    }
471
472    #[test]
473    fn effective_max_turns_custom() {
474        let params = AgentParams {
475            max_turns: Some(20),
476            ..Default::default()
477        };
478        assert_eq!(params.effective_max_turns(), 20);
479    }
480
481    #[test]
482    fn validate_empty_prompt() {
483        let params = AgentParams::default();
484        assert!(params.validate().is_err());
485    }
486
487    #[test]
488    fn validate_zero_max_turns() {
489        let params = AgentParams {
490            prompt: "test".to_string(),
491            max_turns: Some(0),
492            ..Default::default()
493        };
494        assert!(params.validate().is_err());
495    }
496
497    #[test]
498    fn validate_excessive_max_turns() {
499        let params = AgentParams {
500            prompt: "test".to_string(),
501            max_turns: Some(101),
502            ..Default::default()
503        };
504        assert!(params.validate().is_err());
505    }
506
507    #[test]
508    fn validate_ok() {
509        let params = AgentParams {
510            prompt: "test".to_string(),
511            max_turns: Some(50),
512            ..Default::default()
513        };
514        assert!(params.validate().is_ok());
515    }
516
517    // ========================================================================
518    // Token Budget Tests
519    // ========================================================================
520
521    #[test]
522    fn parse_token_budget() {
523        let yaml = r#"
524prompt: "Test"
525token_budget: 100000
526"#;
527        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
528        assert_eq!(params.token_budget, Some(100000));
529    }
530
531    #[test]
532    fn effective_token_budget_default() {
533        let params = AgentParams {
534            prompt: "test".to_string(),
535            ..Default::default()
536        };
537        // Default should be unlimited (None -> max u32)
538        assert_eq!(params.effective_token_budget(), u32::MAX);
539    }
540
541    #[test]
542    fn effective_token_budget_custom() {
543        let params = AgentParams {
544            prompt: "test".to_string(),
545            token_budget: Some(50000),
546            ..Default::default()
547        };
548        assert_eq!(params.effective_token_budget(), 50000);
549    }
550
551    #[test]
552    fn validate_zero_token_budget() {
553        let params = AgentParams {
554            prompt: "test".to_string(),
555            token_budget: Some(0),
556            ..Default::default()
557        };
558        assert!(params.validate().is_err());
559    }
560
561    // ========================================================================
562    // System Prompt Tests
563    // ========================================================================
564
565    #[test]
566    fn parse_system_prompt() {
567        let yaml = r#"
568prompt: "User prompt"
569system: "You are a helpful assistant."
570"#;
571        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
572        assert_eq!(
573            params.system,
574            Some("You are a helpful assistant.".to_string())
575        );
576    }
577
578    #[test]
579    fn system_prompt_defaults_to_none() {
580        let params = AgentParams::default();
581        assert!(params.system.is_none());
582    }
583
584    // ========================================================================
585    // Extended Thinking Tests
586    // ========================================================================
587
588    #[test]
589    fn parse_extended_thinking_true() {
590        let yaml = r#"
591prompt: "Test"
592extended_thinking: true
593"#;
594        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
595        assert_eq!(params.extended_thinking, Some(true));
596    }
597
598    #[test]
599    fn parse_extended_thinking_false() {
600        let yaml = r#"
601prompt: "Test"
602extended_thinking: false
603"#;
604        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
605        assert_eq!(params.extended_thinking, Some(false));
606    }
607
608    #[test]
609    fn extended_thinking_defaults_to_none() {
610        let params = AgentParams::default();
611        assert!(params.extended_thinking.is_none());
612    }
613
614    #[test]
615    fn validate_extended_thinking_with_openai_fails() {
616        let params = AgentParams {
617            prompt: "test".to_string(),
618            extended_thinking: Some(true),
619            provider: Some("openai".to_string()),
620            ..Default::default()
621        };
622        let err = params.validate().unwrap_err();
623        assert!(err
624            .to_string()
625            .contains("extended_thinking only supported for claude"));
626    }
627
628    #[test]
629    fn validate_extended_thinking_with_claude_ok() {
630        let params = AgentParams {
631            prompt: "test".to_string(),
632            extended_thinking: Some(true),
633            provider: Some("claude".to_string()),
634            ..Default::default()
635        };
636        assert!(params.validate().is_ok());
637    }
638
639    #[test]
640    fn validate_extended_thinking_without_provider_ok() {
641        // When provider is not specified, validation passes
642        // (workflow provider will be used at runtime)
643        let params = AgentParams {
644            prompt: "test".to_string(),
645            extended_thinking: Some(true),
646            provider: None,
647            ..Default::default()
648        };
649        assert!(params.validate().is_ok());
650    }
651
652    // ========================================================================
653    // Thinking Budget Tests
654    // ========================================================================
655
656    #[test]
657    fn parse_thinking_budget() {
658        let yaml = r#"
659prompt: "Test"
660extended_thinking: true
661thinking_budget: 8192
662"#;
663        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
664        assert_eq!(params.thinking_budget, Some(8192));
665    }
666
667    #[test]
668    fn effective_thinking_budget_default() {
669        let params = AgentParams {
670            prompt: "test".to_string(),
671            ..Default::default()
672        };
673        // Default should be 4096
674        assert_eq!(params.effective_thinking_budget(), DEFAULT_THINKING_BUDGET);
675        assert_eq!(params.effective_thinking_budget(), 4096);
676    }
677
678    #[test]
679    fn effective_thinking_budget_custom() {
680        let params = AgentParams {
681            prompt: "test".to_string(),
682            thinking_budget: Some(16384),
683            ..Default::default()
684        };
685        assert_eq!(params.effective_thinking_budget(), 16384);
686    }
687
688    #[test]
689    fn thinking_budget_defaults_to_none() {
690        let params = AgentParams::default();
691        assert!(params.thinking_budget.is_none());
692    }
693
694    // ========================================================================
695    // MaxTokens Tests
696    // ========================================================================
697
698    #[test]
699    fn effective_max_tokens_explicit() {
700        let params = AgentParams {
701            prompt: "test".to_string(),
702            max_tokens: Some(16384),
703            ..Default::default()
704        };
705        assert_eq!(params.effective_max_tokens(), Some(16384));
706    }
707
708    #[test]
709    fn effective_max_tokens_with_extended_thinking() {
710        let params = AgentParams {
711            prompt: "test".to_string(),
712            extended_thinking: Some(true),
713            thinking_budget: Some(8192),
714            max_tokens: None, // Not set explicitly
715            ..Default::default()
716        };
717        // Should return thinking_budget + 8192
718        assert_eq!(params.effective_max_tokens(), Some(8192 + 8192));
719    }
720
721    #[test]
722    fn effective_max_tokens_explicit_overrides_auto() {
723        let params = AgentParams {
724            prompt: "test".to_string(),
725            extended_thinking: Some(true),
726            thinking_budget: Some(8192),
727            max_tokens: Some(32768), // Explicitly set
728            ..Default::default()
729        };
730        // Explicit value should be used
731        assert_eq!(params.effective_max_tokens(), Some(32768));
732    }
733
734    #[test]
735    fn effective_max_tokens_none_without_thinking() {
736        let params = AgentParams {
737            prompt: "test".to_string(),
738            extended_thinking: None,
739            max_tokens: None,
740            ..Default::default()
741        };
742        // Should return None (no override needed)
743        assert_eq!(params.effective_max_tokens(), None);
744    }
745
746    // ========================================================================
747    // ToolChoice Tests
748    // ========================================================================
749
750    #[test]
751    fn test_parse_tool_choice_auto() {
752        let yaml = r#"
753prompt: "Test"
754tool_choice: auto
755"#;
756        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
757        assert_eq!(params.tool_choice, Some(ToolChoice::Auto));
758    }
759
760    #[test]
761    fn test_parse_tool_choice_required() {
762        let yaml = r#"
763prompt: "Test"
764tool_choice: required
765"#;
766        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
767        assert_eq!(params.tool_choice, Some(ToolChoice::Required));
768    }
769
770    #[test]
771    fn test_parse_tool_choice_none() {
772        let yaml = r#"
773prompt: "Test"
774tool_choice: none
775"#;
776        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
777        assert_eq!(params.tool_choice, Some(ToolChoice::None));
778    }
779
780    #[test]
781    fn test_tool_choice_default() {
782        let params = AgentParams::default();
783        assert!(params.tool_choice.is_none());
784        assert_eq!(params.effective_tool_choice(), ToolChoice::Auto);
785    }
786
787    #[test]
788    fn test_tool_choice_as_str() {
789        assert_eq!(ToolChoice::Auto.as_str(), "auto");
790        assert_eq!(ToolChoice::Required.as_str(), "required");
791        assert_eq!(ToolChoice::None.as_str(), "none");
792    }
793
794    // ========================================================================
795    // Temperature Tests
796    // ========================================================================
797
798    #[test]
799    fn test_parse_temperature() {
800        let yaml = r#"
801prompt: "Test"
802temperature: 0.7
803"#;
804        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
805        assert_eq!(params.temperature, Some(0.7));
806    }
807
808    #[test]
809    fn test_temperature_default() {
810        let params = AgentParams::default();
811        assert!(params.temperature.is_none());
812        assert_eq!(params.effective_temperature(), None);
813    }
814
815    #[test]
816    fn test_temperature_validation_valid_range() {
817        // Valid range: 0.0 to 2.0
818        for temp in [0.0, 0.5, 1.0, 1.5, 2.0] {
819            let params = AgentParams {
820                prompt: "test".to_string(),
821                temperature: Some(temp),
822                ..Default::default()
823            };
824            assert!(
825                params.validate().is_ok(),
826                "temperature {} should be valid",
827                temp
828            );
829        }
830    }
831
832    #[test]
833    fn test_temperature_validation_too_low() {
834        let params = AgentParams {
835            prompt: "test".to_string(),
836            temperature: Some(-0.1),
837            ..Default::default()
838        };
839        let err = params.validate().unwrap_err();
840        assert!(err
841            .to_string()
842            .contains("temperature must be between 0.0 and 2.0"));
843    }
844
845    #[test]
846    fn test_temperature_validation_too_high() {
847        let params = AgentParams {
848            prompt: "test".to_string(),
849            temperature: Some(2.1),
850            ..Default::default()
851        };
852        let err = params.validate().unwrap_err();
853        assert!(err
854            .to_string()
855            .contains("temperature must be between 0.0 and 2.0"));
856    }
857
858    #[test]
859    fn test_effective_temperature_custom() {
860        let params = AgentParams {
861            prompt: "test".to_string(),
862            temperature: Some(0.3),
863            ..Default::default()
864        };
865        assert_eq!(params.effective_temperature(), Some(0.3));
866    }
867
868    #[test]
869    fn test_combined_tool_choice_and_temperature() {
870        let yaml = r#"
871prompt: "Generate creative content"
872tool_choice: required
873temperature: 1.5
874"#;
875        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
876        assert_eq!(params.tool_choice, Some(ToolChoice::Required));
877        assert_eq!(params.temperature, Some(1.5));
878        assert!(params.validate().is_ok());
879    }
880
881    // ═══════════════════════════════════════════════════════════════════════════
882    // has_explicit_tool_choice Tests
883    // ═══════════════════════════════════════════════════════════════════════════
884
885    #[test]
886    fn test_has_explicit_tool_choice_when_not_set() {
887        let params = AgentParams {
888            prompt: "test".to_string(),
889            ..Default::default()
890        };
891        assert!(!params.has_explicit_tool_choice());
892        // effective_tool_choice still returns Auto (default)
893        assert_eq!(params.effective_tool_choice(), ToolChoice::Auto);
894    }
895
896    #[test]
897    fn test_has_explicit_tool_choice_when_auto() {
898        let params = AgentParams {
899            prompt: "test".to_string(),
900            tool_choice: Some(ToolChoice::Auto),
901            ..Default::default()
902        };
903        // User explicitly set Auto, so has_explicit returns true
904        assert!(params.has_explicit_tool_choice());
905        assert_eq!(params.effective_tool_choice(), ToolChoice::Auto);
906    }
907
908    #[test]
909    fn test_has_explicit_tool_choice_when_required() {
910        let params = AgentParams {
911            prompt: "test".to_string(),
912            tool_choice: Some(ToolChoice::Required),
913            ..Default::default()
914        };
915        assert!(params.has_explicit_tool_choice());
916        assert_eq!(params.effective_tool_choice(), ToolChoice::Required);
917    }
918
919    #[test]
920    fn test_has_explicit_tool_choice_when_none() {
921        let params = AgentParams {
922            prompt: "test".to_string(),
923            tool_choice: Some(ToolChoice::None),
924            ..Default::default()
925        };
926        assert!(params.has_explicit_tool_choice());
927        assert_eq!(params.effective_tool_choice(), ToolChoice::None);
928    }
929
930    #[test]
931    fn test_has_explicit_tool_choice_from_yaml_absent() {
932        let yaml = r#"
933prompt: "Test prompt"
934"#;
935        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
936        assert!(!params.has_explicit_tool_choice());
937    }
938
939    #[test]
940    fn test_has_explicit_tool_choice_from_yaml_present() {
941        let yaml = r#"
942prompt: "Test prompt"
943tool_choice: none
944"#;
945        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
946        assert!(params.has_explicit_tool_choice());
947        assert_eq!(params.effective_tool_choice(), ToolChoice::None);
948    }
949
950    // ═══════════════════════════════════════════════════════════════════════════
951    // Completion Config Tests
952    // ═══════════════════════════════════════════════════════════════════════════
953
954    #[test]
955    fn test_parse_completion_explicit_mode() {
956        let yaml = r#"
957prompt: "Test"
958completion:
959  mode: explicit
960  signal:
961    fields:
962      required: [result]
963      optional: [confidence]
964"#;
965        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
966        assert!(params.completion.is_some());
967
968        let completion = params.completion.clone().unwrap();
969        assert_eq!(completion.mode, crate::ast::CompletionMode::Explicit);
970        assert!(completion.signal.is_some());
971    }
972
973    #[test]
974    fn test_parse_completion_natural_mode() {
975        let yaml = r#"
976prompt: "Test"
977completion:
978  mode: natural
979"#;
980        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
981        let completion = params.completion.clone().unwrap();
982        assert_eq!(completion.mode, crate::ast::CompletionMode::Natural);
983    }
984
985    #[test]
986    fn test_parse_completion_pattern_mode() {
987        let yaml = r#"
988prompt: "Test"
989completion:
990  mode: pattern
991  patterns:
992    - value: "DONE"
993      type: contains
994    - value: "^COMPLETE:"
995      type: regex
996"#;
997        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
998        let completion = params.completion.clone().unwrap();
999        assert_eq!(completion.mode, crate::ast::CompletionMode::Pattern);
1000        assert_eq!(completion.patterns.len(), 2);
1001    }
1002
1003    #[test]
1004    fn test_effective_completion_uses_completion_field() {
1005        let yaml = r#"
1006prompt: "Test"
1007completion:
1008  mode: explicit
1009"#;
1010        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1011
1012        let effective = params.effective_completion().unwrap();
1013        assert_eq!(effective.mode, crate::ast::CompletionMode::Explicit);
1014    }
1015
1016    #[test]
1017    fn test_effective_completion_returns_none_when_empty() {
1018        let params = AgentParams {
1019            prompt: "test".to_string(),
1020            ..Default::default()
1021        };
1022
1023        assert!(params.effective_completion().is_none());
1024    }
1025
1026    #[test]
1027    fn test_completion_system_instruction_explicit_mode() {
1028        let yaml = r#"
1029prompt: "Test"
1030completion:
1031  mode: explicit
1032  signal:
1033    fields:
1034      required: [result, confidence]
1035"#;
1036        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1037        let instruction = params.completion_system_instruction();
1038
1039        // Should contain instruction about nika:complete tool
1040        assert!(instruction.contains("nika:complete"));
1041        assert!(instruction.contains("result"));
1042        assert!(instruction.contains("confidence"));
1043    }
1044
1045    #[test]
1046    fn test_completion_system_instruction_natural_mode() {
1047        let yaml = r#"
1048prompt: "Test"
1049completion:
1050  mode: natural
1051"#;
1052        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1053        let instruction = params.completion_system_instruction();
1054
1055        // Natural mode should have empty instruction
1056        assert!(instruction.is_empty());
1057    }
1058
1059    #[test]
1060    fn test_completion_system_instruction_pattern_mode() {
1061        let yaml = r#"
1062prompt: "Test"
1063completion:
1064  mode: pattern
1065  patterns:
1066    - value: "TASK_DONE"
1067      type: exact
1068"#;
1069        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1070        let instruction = params.completion_system_instruction();
1071
1072        // Pattern mode should have instruction about the pattern
1073        assert!(instruction.contains("TASK_DONE"));
1074    }
1075
1076    #[test]
1077    fn test_completion_system_instruction_empty_when_none() {
1078        let params = AgentParams {
1079            prompt: "test".to_string(),
1080            ..Default::default()
1081        };
1082
1083        let instruction = params.completion_system_instruction();
1084        assert!(instruction.is_empty());
1085    }
1086
1087    #[test]
1088    fn test_validate_completion_config_valid() {
1089        let yaml = r#"
1090prompt: "Test"
1091completion:
1092  mode: pattern
1093  patterns:
1094    - value: "^DONE$"
1095      type: regex
1096"#;
1097        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1098        assert!(params.validate().is_ok());
1099    }
1100
1101    #[test]
1102    fn test_validate_completion_config_invalid_regex() {
1103        let yaml = r#"
1104prompt: "Test"
1105completion:
1106  mode: pattern
1107  patterns:
1108    - value: "[invalid"
1109      type: regex
1110"#;
1111        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1112        let err = params.validate().unwrap_err();
1113        assert!(err.to_string().contains("Invalid regex pattern"));
1114    }
1115
1116    #[test]
1117    fn test_completion_with_confidence_config() {
1118        let yaml = r#"
1119prompt: "Test"
1120completion:
1121  mode: explicit
1122  confidence:
1123    threshold: 0.8
1124    on_low:
1125      action: escalate
1126"#;
1127        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1128        let completion = params.completion.clone().unwrap();
1129
1130        let confidence = completion.confidence.clone().unwrap();
1131        assert!((confidence.threshold - 0.8).abs() < f64::EPSILON);
1132        assert_eq!(
1133            confidence.on_low.action,
1134            crate::ast::LowConfidenceAction::Escalate
1135        );
1136    }
1137
1138    #[test]
1139    fn test_completion_with_instruction_config() {
1140        let yaml = r#"
1141prompt: "Test"
1142completion:
1143  mode: explicit
1144  instruction:
1145    tone: detailed
1146    lang: fr
1147"#;
1148        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1149        let completion = params.completion.clone().unwrap();
1150
1151        let instruction_config = completion.instruction.clone().unwrap();
1152        assert_eq!(
1153            instruction_config.tone,
1154            crate::ast::completion::InstructionTone::Detailed
1155        );
1156        assert_eq!(instruction_config.lang, Some("fr".to_string()));
1157    }
1158
1159    #[test]
1160    fn test_full_completion_config_yaml() {
1161        let yaml = r#"
1162prompt: "Generate content for QR Code AI"
1163provider: claude
1164model: claude-sonnet-4-6
1165mcp:
1166  - novanet
1167max_turns: 10
1168completion:
1169  mode: explicit
1170  signal:
1171    fields:
1172      required: [result]
1173      optional: [confidence, reasoning]
1174  confidence:
1175    threshold: 0.75
1176    on_low:
1177      action: retry
1178  instruction:
1179    tone: concise
1180    lang: en
1181"#;
1182        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1183
1184        // Validate entire config
1185        assert!(params.validate().is_ok());
1186
1187        // Check completion
1188        let completion = params.completion.clone().unwrap();
1189        assert_eq!(completion.mode, crate::ast::CompletionMode::Explicit);
1190
1191        // Check signal
1192        let signal = completion.signal.clone().unwrap();
1193        assert_eq!(signal.fields.required, vec!["result"]);
1194        assert_eq!(signal.fields.optional, vec!["confidence", "reasoning"]);
1195
1196        // Check confidence
1197        let confidence = completion.confidence.clone().unwrap();
1198        assert!((confidence.threshold - 0.75).abs() < f64::EPSILON);
1199
1200        // Check instruction
1201        let instruction = completion.instruction.clone().unwrap();
1202        assert_eq!(instruction.lang, Some("en".to_string()));
1203    }
1204
1205    // ═══════════════════════════════════════════════════════════════════════════
1206    // Limits Config Tests
1207    // ═══════════════════════════════════════════════════════════════════════════
1208
1209    #[test]
1210    fn test_parse_limits_full() {
1211        let yaml = r#"
1212prompt: "Test"
1213limits:
1214  max_turns: 20
1215  max_tokens: 50000
1216  max_cost_usd: 2.00
1217  max_duration_secs: 300
1218  on_limit_reached:
1219    action: complete_partial
1220    save_progress: true
1221"#;
1222        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1223        assert!(params.limits.is_some());
1224
1225        let limits = params.limits.clone().unwrap();
1226        assert_eq!(limits.max_turns, 20);
1227        assert_eq!(limits.max_tokens, 50000);
1228        assert!((limits.max_cost_usd - 2.00).abs() < f64::EPSILON);
1229        assert_eq!(limits.max_duration_secs, 300);
1230        assert_eq!(
1231            limits.on_limit_reached.action,
1232            crate::ast::limits::LimitAction::CompletePartial
1233        );
1234        assert!(limits.on_limit_reached.save_progress);
1235    }
1236
1237    #[test]
1238    fn test_parse_limits_partial() {
1239        let yaml = r#"
1240prompt: "Test"
1241limits:
1242  max_turns: 10
1243  max_cost_usd: 1.50
1244"#;
1245        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1246        let limits = params.limits.clone().unwrap();
1247        assert_eq!(limits.max_turns, 10);
1248        assert!((limits.max_cost_usd - 1.50).abs() < f64::EPSILON);
1249        assert_eq!(limits.max_tokens, 0); // default
1250        assert_eq!(limits.max_duration_secs, 0); // default
1251    }
1252
1253    #[test]
1254    fn test_parse_limits_action_fail() {
1255        let yaml = r#"
1256prompt: "Test"
1257limits:
1258  max_turns: 5
1259  on_limit_reached:
1260    action: fail
1261"#;
1262        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1263        let limits = params.limits.clone().unwrap();
1264        assert_eq!(
1265            limits.on_limit_reached.action,
1266            crate::ast::limits::LimitAction::Fail
1267        );
1268    }
1269
1270    #[test]
1271    fn test_parse_limits_action_escalate() {
1272        let yaml = r#"
1273prompt: "Test"
1274limits:
1275  max_cost_usd: 0.50
1276  on_limit_reached:
1277    action: escalate
1278    message: "Budget exceeded, needs approval"
1279"#;
1280        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1281        let limits = params.limits.clone().unwrap();
1282        assert_eq!(
1283            limits.on_limit_reached.action,
1284            crate::ast::limits::LimitAction::Escalate
1285        );
1286        assert_eq!(
1287            limits.on_limit_reached.message,
1288            Some("Budget exceeded, needs approval".to_string())
1289        );
1290    }
1291
1292    #[test]
1293    fn test_limits_defaults_to_none() {
1294        let params = AgentParams::default();
1295        assert!(params.limits.is_none());
1296        assert!(!params.has_limits());
1297    }
1298
1299    #[test]
1300    fn test_effective_limits_default() {
1301        let params = AgentParams {
1302            prompt: "test".to_string(),
1303            ..Default::default()
1304        };
1305        let limits = params.effective_limits();
1306        assert_eq!(limits.max_turns, 0);
1307        assert_eq!(limits.max_tokens, 0);
1308        assert!((limits.max_cost_usd - 0.0).abs() < f64::EPSILON);
1309        assert!(!limits.has_limits());
1310    }
1311
1312    #[test]
1313    fn test_has_limits_true_when_configured() {
1314        let yaml = r#"
1315prompt: "Test"
1316limits:
1317  max_turns: 10
1318"#;
1319        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1320        assert!(params.has_limits());
1321    }
1322
1323    #[test]
1324    fn test_has_limits_false_when_all_zero() {
1325        let yaml = r#"
1326prompt: "Test"
1327limits:
1328  max_turns: 0
1329  max_tokens: 0
1330"#;
1331        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1332        assert!(!params.has_limits());
1333    }
1334
1335    #[test]
1336    fn test_validate_limits_negative_cost() {
1337        let yaml = r#"
1338prompt: "Test"
1339limits:
1340  max_cost_usd: -1.00
1341"#;
1342        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1343        let err = params.validate().unwrap_err();
1344        assert!(err.to_string().contains("max_cost_usd"));
1345        assert!(err.to_string().contains("non-negative"));
1346    }
1347
1348    #[test]
1349    fn test_validate_limits_valid() {
1350        let yaml = r#"
1351prompt: "Test"
1352limits:
1353  max_turns: 20
1354  max_tokens: 50000
1355  max_cost_usd: 5.00
1356  max_duration_secs: 600
1357"#;
1358        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1359        assert!(params.validate().is_ok());
1360    }
1361
1362    #[test]
1363    fn test_full_agent_config_with_limits() {
1364        let yaml = r#"
1365prompt: "Generate comprehensive research report"
1366provider: claude
1367model: claude-sonnet-4-6
1368mcp:
1369  - novanet
1370  - perplexity
1371max_turns: 20
1372completion:
1373  mode: explicit
1374  confidence:
1375    threshold: 0.8
1376guardrails:
1377  - type: length
1378    min_words: 500
1379limits:
1380  max_turns: 15
1381  max_tokens: 100000
1382  max_cost_usd: 3.00
1383  max_duration_secs: 600
1384  on_limit_reached:
1385    action: complete_partial
1386    save_progress: true
1387    message: "Research incomplete due to limits"
1388"#;
1389        let params: AgentParams = serde_yaml::from_str(yaml).unwrap();
1390        assert!(params.validate().is_ok());
1391        assert!(params.has_limits());
1392
1393        let limits = params.effective_limits();
1394        assert_eq!(limits.max_turns, 15);
1395        assert_eq!(limits.max_tokens, 100000);
1396        assert!((limits.max_cost_usd - 3.00).abs() < f64::EPSILON);
1397    }
1398}