Skip to main content

construct/tools/
model_routing_config.rs

1use super::traits::{Tool, ToolResult};
2use crate::config::{ClassificationRule, Config, DelegateAgentConfig, ModelRouteConfig};
3use crate::security::SecurityPolicy;
4use crate::util::MaybeSet;
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use std::collections::BTreeMap;
8use std::fs;
9use std::sync::Arc;
10
11const DEFAULT_AGENT_MAX_DEPTH: u32 = 3;
12const DEFAULT_AGENT_MAX_ITERATIONS: usize = 10;
13
14pub struct ModelRoutingConfigTool {
15    config: Arc<Config>,
16    security: Arc<SecurityPolicy>,
17}
18
19impl ModelRoutingConfigTool {
20    pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
21        Self { config, security }
22    }
23
24    fn load_config_without_env(&self) -> anyhow::Result<Config> {
25        let contents = fs::read_to_string(&self.config.config_path).map_err(|error| {
26            anyhow::anyhow!(
27                "Failed to read config file {}: {error}",
28                self.config.config_path.display()
29            )
30        })?;
31
32        let mut parsed: Config = toml::from_str(&contents).map_err(|error| {
33            anyhow::anyhow!(
34                "Failed to parse config file {}: {error}",
35                self.config.config_path.display()
36            )
37        })?;
38        parsed.config_path = self.config.config_path.clone();
39        parsed.workspace_dir = self.config.workspace_dir.clone();
40        Ok(parsed)
41    }
42
43    fn require_write_access(&self) -> Option<ToolResult> {
44        if !self.security.can_act() {
45            return Some(ToolResult {
46                success: false,
47                output: String::new(),
48                error: Some("Action blocked: autonomy is read-only".into()),
49            });
50        }
51
52        if !self.security.record_action() {
53            return Some(ToolResult {
54                success: false,
55                output: String::new(),
56                error: Some("Action blocked: rate limit exceeded".into()),
57            });
58        }
59
60        None
61    }
62
63    fn parse_string_list(raw: &Value, field: &str) -> anyhow::Result<Vec<String>> {
64        if let Some(raw_string) = raw.as_str() {
65            return Ok(raw_string
66                .split(',')
67                .map(str::trim)
68                .filter(|entry| !entry.is_empty())
69                .map(ToOwned::to_owned)
70                .collect());
71        }
72
73        if let Some(array) = raw.as_array() {
74            let mut out = Vec::new();
75            for item in array {
76                let value = item
77                    .as_str()
78                    .ok_or_else(|| anyhow::anyhow!("'{field}' array must only contain strings"))?;
79                let trimmed = value.trim();
80                if !trimmed.is_empty() {
81                    out.push(trimmed.to_string());
82                }
83            }
84            return Ok(out);
85        }
86
87        anyhow::bail!("'{field}' must be a string or string[]")
88    }
89
90    fn parse_non_empty_string(args: &Value, field: &str) -> anyhow::Result<String> {
91        let value = args
92            .get(field)
93            .and_then(Value::as_str)
94            .ok_or_else(|| anyhow::anyhow!("Missing '{field}'"))?
95            .trim();
96
97        if value.is_empty() {
98            anyhow::bail!("'{field}' must not be empty");
99        }
100
101        Ok(value.to_string())
102    }
103
104    fn parse_optional_string_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<String>> {
105        let Some(raw) = args.get(field) else {
106            return Ok(MaybeSet::Unset);
107        };
108
109        if raw.is_null() {
110            return Ok(MaybeSet::Null);
111        }
112
113        let value = raw
114            .as_str()
115            .ok_or_else(|| anyhow::anyhow!("'{field}' must be a string or null"))?
116            .trim()
117            .to_string();
118
119        let output = if value.is_empty() {
120            MaybeSet::Null
121        } else {
122            MaybeSet::Set(value)
123        };
124        Ok(output)
125    }
126
127    fn parse_optional_f64_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<f64>> {
128        let Some(raw) = args.get(field) else {
129            return Ok(MaybeSet::Unset);
130        };
131
132        if raw.is_null() {
133            return Ok(MaybeSet::Null);
134        }
135
136        let value = raw
137            .as_f64()
138            .ok_or_else(|| anyhow::anyhow!("'{field}' must be a number or null"))?;
139        Ok(MaybeSet::Set(value))
140    }
141
142    fn parse_optional_usize_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<usize>> {
143        let Some(raw) = args.get(field) else {
144            return Ok(MaybeSet::Unset);
145        };
146
147        if raw.is_null() {
148            return Ok(MaybeSet::Null);
149        }
150
151        let raw_value = raw
152            .as_u64()
153            .ok_or_else(|| anyhow::anyhow!("'{field}' must be a non-negative integer or null"))?;
154        let value = usize::try_from(raw_value)
155            .map_err(|_| anyhow::anyhow!("'{field}' is too large for this platform"))?;
156        Ok(MaybeSet::Set(value))
157    }
158
159    fn parse_optional_u32_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<u32>> {
160        let Some(raw) = args.get(field) else {
161            return Ok(MaybeSet::Unset);
162        };
163
164        if raw.is_null() {
165            return Ok(MaybeSet::Null);
166        }
167
168        let raw_value = raw
169            .as_u64()
170            .ok_or_else(|| anyhow::anyhow!("'{field}' must be a non-negative integer or null"))?;
171        let value =
172            u32::try_from(raw_value).map_err(|_| anyhow::anyhow!("'{field}' must fit in u32"))?;
173        Ok(MaybeSet::Set(value))
174    }
175
176    fn parse_optional_i32_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<i32>> {
177        let Some(raw) = args.get(field) else {
178            return Ok(MaybeSet::Unset);
179        };
180
181        if raw.is_null() {
182            return Ok(MaybeSet::Null);
183        }
184
185        let raw_value = raw
186            .as_i64()
187            .ok_or_else(|| anyhow::anyhow!("'{field}' must be an integer or null"))?;
188        let value =
189            i32::try_from(raw_value).map_err(|_| anyhow::anyhow!("'{field}' must fit in i32"))?;
190        Ok(MaybeSet::Set(value))
191    }
192
193    fn parse_optional_bool(args: &Value, field: &str) -> anyhow::Result<Option<bool>> {
194        let Some(raw) = args.get(field) else {
195            return Ok(None);
196        };
197
198        let value = raw
199            .as_bool()
200            .ok_or_else(|| anyhow::anyhow!("'{field}' must be a boolean"))?;
201        Ok(Some(value))
202    }
203
204    fn scenario_row(route: &ModelRouteConfig, rule: Option<&ClassificationRule>) -> Value {
205        let classification = rule.map(|r| {
206            json!({
207                "keywords": r.keywords,
208                "patterns": r.patterns,
209                "min_length": r.min_length,
210                "max_length": r.max_length,
211                "priority": r.priority,
212            })
213        });
214
215        json!({
216            "hint": route.hint,
217            "provider": route.provider,
218            "model": route.model,
219            "api_key_configured": route
220                .api_key
221                .as_ref()
222                .is_some_and(|value| !value.trim().is_empty()),
223            "classification": classification,
224        })
225    }
226
227    fn snapshot(cfg: &Config) -> Value {
228        let mut routes = cfg.model_routes.clone();
229        routes.sort_by(|a, b| a.hint.cmp(&b.hint));
230
231        let mut rules = cfg.query_classification.rules.clone();
232        rules.sort_by(|a, b| {
233            b.priority
234                .cmp(&a.priority)
235                .then_with(|| a.hint.cmp(&b.hint))
236        });
237
238        let mut scenarios = Vec::with_capacity(routes.len());
239        for route in &routes {
240            let rule = rules.iter().find(|r| r.hint == route.hint);
241            scenarios.push(Self::scenario_row(route, rule));
242        }
243
244        let classification_only_rules: Vec<Value> = rules
245            .iter()
246            .filter(|rule| !routes.iter().any(|route| route.hint == rule.hint))
247            .map(|rule| {
248                json!({
249                    "hint": rule.hint,
250                    "keywords": rule.keywords,
251                    "patterns": rule.patterns,
252                    "min_length": rule.min_length,
253                    "max_length": rule.max_length,
254                    "priority": rule.priority,
255                })
256            })
257            .collect();
258
259        let mut agents: BTreeMap<String, Value> = BTreeMap::new();
260        for (name, agent) in &cfg.agents {
261            agents.insert(
262                name.clone(),
263                json!({
264                    "provider": agent.provider,
265                    "model": agent.model,
266                    "system_prompt": agent.system_prompt,
267                    "api_key_configured": agent
268                        .api_key
269                        .as_ref()
270                        .is_some_and(|value| !value.trim().is_empty()),
271                    "temperature": agent.temperature,
272                    "max_depth": agent.max_depth,
273                    "agentic": agent.agentic,
274                    "allowed_tools": agent.allowed_tools,
275                    "max_iterations": agent.max_iterations,
276                }),
277            );
278        }
279
280        json!({
281            "default": {
282                "provider": cfg.default_provider,
283                "model": cfg.default_model,
284                "temperature": cfg.default_temperature,
285            },
286            "query_classification": {
287                "enabled": cfg.query_classification.enabled,
288                "rules_count": cfg.query_classification.rules.len(),
289            },
290            "scenarios": scenarios,
291            "classification_only_rules": classification_only_rules,
292            "agents": agents,
293        })
294    }
295
296    fn normalize_and_sort_routes(routes: &mut Vec<ModelRouteConfig>) {
297        routes.retain(|route| !route.hint.trim().is_empty());
298        routes.sort_by(|a, b| a.hint.cmp(&b.hint));
299    }
300
301    fn normalize_and_sort_rules(rules: &mut Vec<ClassificationRule>) {
302        rules.retain(|rule| !rule.hint.trim().is_empty());
303        rules.sort_by(|a, b| {
304            b.priority
305                .cmp(&a.priority)
306                .then_with(|| a.hint.cmp(&b.hint))
307        });
308    }
309
310    fn has_rule_matcher(rule: &ClassificationRule) -> bool {
311        !rule.keywords.is_empty()
312            || !rule.patterns.is_empty()
313            || rule.min_length.is_some()
314            || rule.max_length.is_some()
315    }
316
317    fn ensure_rule_defaults(rule: &mut ClassificationRule, hint: &str) {
318        if !Self::has_rule_matcher(rule) {
319            rule.keywords = vec![hint.to_string()];
320        }
321    }
322
323    fn handle_get(&self) -> anyhow::Result<ToolResult> {
324        let cfg = self.load_config_without_env()?;
325        Ok(ToolResult {
326            success: true,
327            output: serde_json::to_string_pretty(&Self::snapshot(&cfg))?,
328            error: None,
329        })
330    }
331
332    fn handle_list_hints(&self) -> anyhow::Result<ToolResult> {
333        let cfg = self.load_config_without_env()?;
334        let mut route_hints: Vec<String> =
335            cfg.model_routes.iter().map(|r| r.hint.clone()).collect();
336        route_hints.sort();
337        route_hints.dedup();
338
339        let mut classification_hints: Vec<String> = cfg
340            .query_classification
341            .rules
342            .iter()
343            .map(|r| r.hint.clone())
344            .collect();
345        classification_hints.sort();
346        classification_hints.dedup();
347
348        Ok(ToolResult {
349            success: true,
350            output: serde_json::to_string_pretty(&json!({
351                "model_route_hints": route_hints,
352                "classification_hints": classification_hints,
353                "example": {
354                    "conversation": {
355                        "action": "upsert_scenario",
356                        "hint": "conversation",
357                        "provider": "kimi",
358                        "model": "moonshot-v1-8k",
359                        "classification_enabled": false
360                    },
361                    "coding": {
362                        "action": "upsert_scenario",
363                        "hint": "coding",
364                        "provider": "openai",
365                        "model": "gpt-5.3-codex",
366                        "classification_enabled": true,
367                        "keywords": ["code", "bug", "refactor", "test"],
368                        "patterns": ["```"],
369                        "priority": 50
370                    }
371                }
372            }))?,
373            error: None,
374        })
375    }
376
377    async fn handle_set_default(&self, args: &Value) -> anyhow::Result<ToolResult> {
378        let provider_update = Self::parse_optional_string_update(args, "provider")?;
379        let model_update = Self::parse_optional_string_update(args, "model")?;
380        let temperature_update = Self::parse_optional_f64_update(args, "temperature")?;
381
382        let any_update = !matches!(provider_update, MaybeSet::Unset)
383            || !matches!(model_update, MaybeSet::Unset)
384            || !matches!(temperature_update, MaybeSet::Unset);
385
386        if !any_update {
387            anyhow::bail!("set_default requires at least one of: provider, model, temperature");
388        }
389
390        let mut cfg = self.load_config_without_env()?;
391
392        // Capture previous values for rollback on probe failure.
393        let previous_provider = cfg.default_provider.clone();
394        let previous_model = cfg.default_model.clone();
395        let previous_temperature = cfg.default_temperature;
396
397        match provider_update {
398            MaybeSet::Set(provider) => cfg.default_provider = Some(provider),
399            MaybeSet::Null => cfg.default_provider = None,
400            MaybeSet::Unset => {}
401        }
402
403        match model_update {
404            MaybeSet::Set(model) => cfg.default_model = Some(model),
405            MaybeSet::Null => cfg.default_model = None,
406            MaybeSet::Unset => {}
407        }
408
409        match temperature_update {
410            MaybeSet::Set(temperature) => {
411                if !(0.0..=2.0).contains(&temperature) {
412                    anyhow::bail!("'temperature' must be between 0.0 and 2.0");
413                }
414                cfg.default_temperature = temperature;
415            }
416            MaybeSet::Null => {
417                cfg.default_temperature = Config::default().default_temperature;
418            }
419            MaybeSet::Unset => {}
420        }
421
422        cfg.save().await?;
423
424        // Probe the new model with a minimal API call to catch invalid model IDs
425        // before the channel hot-reload picks up the change.
426        if let (Some(provider_name), Some(model_name)) =
427            (cfg.default_provider.clone(), cfg.default_model.clone())
428        {
429            if let Err(probe_err) = self.probe_model(&provider_name, &model_name).await {
430                if crate::providers::reliable::is_non_retryable(&probe_err) {
431                    let reverted_model = previous_model.as_deref().unwrap_or("(none)").to_string();
432
433                    // Rollback to previous config.
434                    cfg.default_provider = previous_provider;
435                    cfg.default_model = previous_model;
436                    cfg.default_temperature = previous_temperature;
437                    cfg.save().await?;
438
439                    return Ok(ToolResult {
440                        success: false,
441                        output: format!(
442                            "Model '{model_name}' is not available: {probe_err}. Reverted to '{reverted_model}'.",
443                        ),
444                        error: None,
445                    });
446                }
447                // Retryable errors (e.g. transient network issues) — keep the
448                // new config and let the resilient wrapper handle retries.
449                tracing::warn!(
450                    model = %model_name,
451                    "Model probe returned retryable error (keeping new config): {probe_err}"
452                );
453            }
454        }
455
456        Ok(ToolResult {
457            success: true,
458            output: serde_json::to_string_pretty(&json!({
459                "message": "Default provider/model settings updated",
460                "config": Self::snapshot(&cfg),
461            }))?,
462            error: None,
463        })
464    }
465
466    /// Send a minimal 1-token chat request to verify the model is accessible.
467    /// Returns `Ok(())` if the probe succeeds **or** if no API key is available
468    /// (the probe would fail with an auth error unrelated to model validity).
469    /// Provider construction failures are also treated as non-fatal.
470    async fn probe_model(&self, provider_name: &str, model: &str) -> anyhow::Result<()> {
471        use crate::providers;
472
473        // Use the runtime config's API key (which includes env-sourced keys),
474        // not the on-disk config (which may have no key at all).
475        let api_key = self.config.api_key.as_deref();
476        if api_key.is_none_or(|k| k.trim().is_empty()) {
477            return Ok(());
478        }
479
480        let provider = match providers::create_provider_with_url(
481            provider_name,
482            api_key,
483            self.config.api_url.as_deref(),
484        ) {
485            Ok(p) => p,
486            Err(_) => return Ok(()),
487        };
488
489        provider
490            .chat_with_system(Some("Respond with OK."), "ping", model, 0.0)
491            .await?;
492
493        Ok(())
494    }
495
496    async fn handle_upsert_scenario(&self, args: &Value) -> anyhow::Result<ToolResult> {
497        let hint = Self::parse_non_empty_string(args, "hint")?;
498        let provider = Self::parse_non_empty_string(args, "provider")?;
499        let model = Self::parse_non_empty_string(args, "model")?;
500        let api_key_update = Self::parse_optional_string_update(args, "api_key")?;
501
502        let keywords_update = if let Some(raw) = args.get("keywords") {
503            Some(Self::parse_string_list(raw, "keywords")?)
504        } else {
505            None
506        };
507        let patterns_update = if let Some(raw) = args.get("patterns") {
508            Some(Self::parse_string_list(raw, "patterns")?)
509        } else {
510            None
511        };
512        let min_length_update = Self::parse_optional_usize_update(args, "min_length")?;
513        let max_length_update = Self::parse_optional_usize_update(args, "max_length")?;
514        let priority_update = Self::parse_optional_i32_update(args, "priority")?;
515        let classification_enabled = Self::parse_optional_bool(args, "classification_enabled")?;
516
517        let should_touch_rule = classification_enabled.is_some()
518            || keywords_update.is_some()
519            || patterns_update.is_some()
520            || !matches!(min_length_update, MaybeSet::Unset)
521            || !matches!(max_length_update, MaybeSet::Unset)
522            || !matches!(priority_update, MaybeSet::Unset);
523
524        let mut cfg = self.load_config_without_env()?;
525
526        let existing_route = cfg
527            .model_routes
528            .iter()
529            .find(|route| route.hint == hint)
530            .cloned();
531
532        let mut next_route = existing_route.unwrap_or(ModelRouteConfig {
533            hint: hint.clone(),
534            provider: provider.clone(),
535            model: model.clone(),
536            api_key: None,
537        });
538
539        next_route.hint = hint.clone();
540        next_route.provider = provider;
541        next_route.model = model;
542
543        match api_key_update {
544            MaybeSet::Set(api_key) => next_route.api_key = Some(api_key),
545            MaybeSet::Null => next_route.api_key = None,
546            MaybeSet::Unset => {}
547        }
548
549        cfg.model_routes.retain(|route| route.hint != hint);
550        cfg.model_routes.push(next_route);
551        Self::normalize_and_sort_routes(&mut cfg.model_routes);
552
553        if should_touch_rule {
554            if matches!(classification_enabled, Some(false)) {
555                cfg.query_classification
556                    .rules
557                    .retain(|rule| rule.hint != hint);
558            } else {
559                let existing_rule = cfg
560                    .query_classification
561                    .rules
562                    .iter()
563                    .find(|rule| rule.hint == hint)
564                    .cloned();
565
566                let mut next_rule = existing_rule.unwrap_or_else(|| ClassificationRule {
567                    hint: hint.clone(),
568                    ..ClassificationRule::default()
569                });
570
571                if let Some(keywords) = keywords_update {
572                    next_rule.keywords = keywords;
573                }
574                if let Some(patterns) = patterns_update {
575                    next_rule.patterns = patterns;
576                }
577
578                match min_length_update {
579                    MaybeSet::Set(value) => next_rule.min_length = Some(value),
580                    MaybeSet::Null => next_rule.min_length = None,
581                    MaybeSet::Unset => {}
582                }
583
584                match max_length_update {
585                    MaybeSet::Set(value) => next_rule.max_length = Some(value),
586                    MaybeSet::Null => next_rule.max_length = None,
587                    MaybeSet::Unset => {}
588                }
589
590                match priority_update {
591                    MaybeSet::Set(value) => next_rule.priority = value,
592                    MaybeSet::Null => next_rule.priority = 0,
593                    MaybeSet::Unset => {}
594                }
595
596                if matches!(classification_enabled, Some(true)) {
597                    Self::ensure_rule_defaults(&mut next_rule, &hint);
598                }
599
600                if !Self::has_rule_matcher(&next_rule) {
601                    anyhow::bail!(
602                        "Classification rule for hint '{hint}' has no matching criteria. Provide keywords/patterns or set min_length/max_length."
603                    );
604                }
605
606                cfg.query_classification
607                    .rules
608                    .retain(|rule| rule.hint != hint);
609                cfg.query_classification.rules.push(next_rule);
610            }
611        }
612
613        Self::normalize_and_sort_rules(&mut cfg.query_classification.rules);
614        cfg.query_classification.enabled = !cfg.query_classification.rules.is_empty();
615
616        cfg.save().await?;
617
618        Ok(ToolResult {
619            success: true,
620            output: serde_json::to_string_pretty(&json!({
621                "message": "Scenario route upserted",
622                "hint": hint,
623                "config": Self::snapshot(&cfg),
624            }))?,
625            error: None,
626        })
627    }
628
629    async fn handle_remove_scenario(&self, args: &Value) -> anyhow::Result<ToolResult> {
630        let hint = Self::parse_non_empty_string(args, "hint")?;
631        let remove_classification = args
632            .get("remove_classification")
633            .and_then(Value::as_bool)
634            .unwrap_or(true);
635
636        let mut cfg = self.load_config_without_env()?;
637
638        let before_routes = cfg.model_routes.len();
639        cfg.model_routes.retain(|route| route.hint != hint);
640        let routes_removed = before_routes.saturating_sub(cfg.model_routes.len());
641
642        let mut rules_removed = 0usize;
643        if remove_classification {
644            let before_rules = cfg.query_classification.rules.len();
645            cfg.query_classification
646                .rules
647                .retain(|rule| rule.hint != hint);
648            rules_removed = before_rules.saturating_sub(cfg.query_classification.rules.len());
649        }
650
651        if routes_removed == 0 && rules_removed == 0 {
652            anyhow::bail!("No scenario found for hint '{hint}'");
653        }
654
655        Self::normalize_and_sort_routes(&mut cfg.model_routes);
656        Self::normalize_and_sort_rules(&mut cfg.query_classification.rules);
657        cfg.query_classification.enabled = !cfg.query_classification.rules.is_empty();
658
659        cfg.save().await?;
660
661        Ok(ToolResult {
662            success: true,
663            output: serde_json::to_string_pretty(&json!({
664                "message": "Scenario removed",
665                "hint": hint,
666                "routes_removed": routes_removed,
667                "classification_rules_removed": rules_removed,
668                "config": Self::snapshot(&cfg),
669            }))?,
670            error: None,
671        })
672    }
673
674    async fn handle_upsert_agent(&self, args: &Value) -> anyhow::Result<ToolResult> {
675        let name = Self::parse_non_empty_string(args, "name")?;
676        let provider = Self::parse_non_empty_string(args, "provider")?;
677        let model = Self::parse_non_empty_string(args, "model")?;
678
679        let system_prompt_update = Self::parse_optional_string_update(args, "system_prompt")?;
680        let api_key_update = Self::parse_optional_string_update(args, "api_key")?;
681        let temperature_update = Self::parse_optional_f64_update(args, "temperature")?;
682        let max_depth_update = Self::parse_optional_u32_update(args, "max_depth")?;
683        let max_iterations_update = Self::parse_optional_usize_update(args, "max_iterations")?;
684        let agentic_update = Self::parse_optional_bool(args, "agentic")?;
685
686        let allowed_tools_update = if let Some(raw) = args.get("allowed_tools") {
687            Some(Self::parse_string_list(raw, "allowed_tools")?)
688        } else {
689            None
690        };
691
692        let mut cfg = self.load_config_without_env()?;
693
694        let mut next_agent = cfg
695            .agents
696            .get(&name)
697            .cloned()
698            .unwrap_or(DelegateAgentConfig {
699                provider: provider.clone(),
700                model: model.clone(),
701                system_prompt: None,
702                api_key: None,
703                temperature: None,
704                max_depth: DEFAULT_AGENT_MAX_DEPTH,
705                agentic: false,
706                allowed_tools: Vec::new(),
707                max_iterations: DEFAULT_AGENT_MAX_ITERATIONS,
708                timeout_secs: None,
709                agentic_timeout_secs: None,
710                skills_directory: None,
711            });
712
713        next_agent.provider = provider;
714        next_agent.model = model;
715
716        match system_prompt_update {
717            MaybeSet::Set(value) => next_agent.system_prompt = Some(value),
718            MaybeSet::Null => next_agent.system_prompt = None,
719            MaybeSet::Unset => {}
720        }
721
722        match api_key_update {
723            MaybeSet::Set(value) => next_agent.api_key = Some(value),
724            MaybeSet::Null => next_agent.api_key = None,
725            MaybeSet::Unset => {}
726        }
727
728        match temperature_update {
729            MaybeSet::Set(value) => {
730                if !(0.0..=2.0).contains(&value) {
731                    anyhow::bail!("'temperature' must be between 0.0 and 2.0");
732                }
733                next_agent.temperature = Some(value);
734            }
735            MaybeSet::Null => next_agent.temperature = None,
736            MaybeSet::Unset => {}
737        }
738
739        match max_depth_update {
740            MaybeSet::Set(value) => next_agent.max_depth = value,
741            MaybeSet::Null => next_agent.max_depth = DEFAULT_AGENT_MAX_DEPTH,
742            MaybeSet::Unset => {}
743        }
744
745        match max_iterations_update {
746            MaybeSet::Set(value) => next_agent.max_iterations = value,
747            MaybeSet::Null => next_agent.max_iterations = DEFAULT_AGENT_MAX_ITERATIONS,
748            MaybeSet::Unset => {}
749        }
750
751        if let Some(agentic) = agentic_update {
752            next_agent.agentic = agentic;
753        }
754
755        if let Some(allowed_tools) = allowed_tools_update {
756            next_agent.allowed_tools = allowed_tools;
757        }
758
759        if next_agent.max_depth == 0 {
760            anyhow::bail!("'max_depth' must be greater than 0");
761        }
762
763        if next_agent.max_iterations == 0 {
764            anyhow::bail!("'max_iterations' must be greater than 0");
765        }
766
767        if next_agent.agentic && next_agent.allowed_tools.is_empty() {
768            anyhow::bail!(
769                "Agent '{name}' has agentic=true but allowed_tools is empty. Set allowed_tools or disable agentic mode."
770            );
771        }
772
773        cfg.agents.insert(name.clone(), next_agent);
774        cfg.save().await?;
775
776        Ok(ToolResult {
777            success: true,
778            output: serde_json::to_string_pretty(&json!({
779                "message": "Delegate agent upserted",
780                "name": name,
781                "config": Self::snapshot(&cfg),
782            }))?,
783            error: None,
784        })
785    }
786
787    async fn handle_remove_agent(&self, args: &Value) -> anyhow::Result<ToolResult> {
788        let name = Self::parse_non_empty_string(args, "name")?;
789
790        let mut cfg = self.load_config_without_env()?;
791        if cfg.agents.remove(&name).is_none() {
792            anyhow::bail!("No delegate agent found with name '{name}'");
793        }
794
795        cfg.save().await?;
796
797        Ok(ToolResult {
798            success: true,
799            output: serde_json::to_string_pretty(&json!({
800                "message": "Delegate agent removed",
801                "name": name,
802                "config": Self::snapshot(&cfg),
803            }))?,
804            error: None,
805        })
806    }
807}
808
809#[async_trait]
810impl Tool for ModelRoutingConfigTool {
811    fn name(&self) -> &str {
812        "model_routing_config"
813    }
814
815    fn description(&self) -> &str {
816        "Manage default model settings, scenario-based provider/model routes, classification rules, and delegate sub-agent profiles"
817    }
818
819    fn parameters_schema(&self) -> Value {
820        json!({
821            "type": "object",
822            "properties": {
823                "action": {
824                    "type": "string",
825                    "enum": [
826                        "get",
827                        "list_hints",
828                        "set_default",
829                        "upsert_scenario",
830                        "remove_scenario",
831                        "upsert_agent",
832                        "remove_agent"
833                    ],
834                    "default": "get"
835                },
836                "hint": {
837                    "type": "string",
838                    "description": "Scenario hint name (for example: conversation, coding, reasoning)"
839                },
840                "provider": {
841                    "type": "string",
842                    "description": "Provider for set_default/upsert_scenario/upsert_agent"
843                },
844                "model": {
845                    "type": "string",
846                    "description": "Model for set_default/upsert_scenario/upsert_agent"
847                },
848                "temperature": {
849                    "type": ["number", "null"],
850                    "description": "Optional temperature override (0.0-2.0)"
851                },
852                "api_key": {
853                    "type": ["string", "null"],
854                    "description": "Optional API key override for scenario route or delegate agent"
855                },
856                "keywords": {
857                    "description": "Classification keywords for upsert_scenario (string or string array)",
858                    "oneOf": [
859                        {"type": "string"},
860                        {"type": "array", "items": {"type": "string"}}
861                    ]
862                },
863                "patterns": {
864                    "description": "Classification literal patterns for upsert_scenario (string or string array)",
865                    "oneOf": [
866                        {"type": "string"},
867                        {"type": "array", "items": {"type": "string"}}
868                    ]
869                },
870                "min_length": {
871                    "type": ["integer", "null"],
872                    "minimum": 0,
873                    "description": "Optional minimum message length matcher"
874                },
875                "max_length": {
876                    "type": ["integer", "null"],
877                    "minimum": 0,
878                    "description": "Optional maximum message length matcher"
879                },
880                "priority": {
881                    "type": ["integer", "null"],
882                    "description": "Classification priority (higher runs first)"
883                },
884                "classification_enabled": {
885                    "type": "boolean",
886                    "description": "When true, upsert classification rule for this hint; false removes it"
887                },
888                "remove_classification": {
889                    "type": "boolean",
890                    "description": "When remove_scenario, whether to remove matching classification rule (default true)"
891                },
892                "name": {
893                    "type": "string",
894                    "description": "Delegate sub-agent name for upsert_agent/remove_agent"
895                },
896                "system_prompt": {
897                    "type": ["string", "null"],
898                    "description": "Optional system prompt override for delegate agent"
899                },
900                "max_depth": {
901                    "type": ["integer", "null"],
902                    "minimum": 1,
903                    "description": "Delegate max recursion depth"
904                },
905                "agentic": {
906                    "type": "boolean",
907                    "description": "Enable tool-call loop mode for delegate agent"
908                },
909                "allowed_tools": {
910                    "description": "Allowed tools for agentic delegate mode (string or string array)",
911                    "oneOf": [
912                        {"type": "string"},
913                        {"type": "array", "items": {"type": "string"}}
914                    ]
915                },
916                "max_iterations": {
917                    "type": ["integer", "null"],
918                    "minimum": 1,
919                    "description": "Maximum tool-call iterations for agentic delegate mode"
920                }
921            },
922            "additionalProperties": false
923        })
924    }
925
926    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
927        let action = args
928            .get("action")
929            .and_then(Value::as_str)
930            .unwrap_or("get")
931            .to_ascii_lowercase();
932
933        let result = match action.as_str() {
934            "get" => self.handle_get(),
935            "list_hints" => self.handle_list_hints(),
936            "set_default" | "upsert_scenario" | "remove_scenario" | "upsert_agent"
937            | "remove_agent" => {
938                if let Some(blocked) = self.require_write_access() {
939                    return Ok(blocked);
940                }
941
942                match action.as_str() {
943                    "set_default" => Box::pin(self.handle_set_default(&args)).await,
944                    "upsert_scenario" => Box::pin(self.handle_upsert_scenario(&args)).await,
945                    "remove_scenario" => Box::pin(self.handle_remove_scenario(&args)).await,
946                    "upsert_agent" => Box::pin(self.handle_upsert_agent(&args)).await,
947                    "remove_agent" => Box::pin(self.handle_remove_agent(&args)).await,
948                    _ => unreachable!("validated above"),
949                }
950            }
951            _ => anyhow::bail!(
952                "Unknown action '{action}'. Valid: get, list_hints, set_default, upsert_scenario, remove_scenario, upsert_agent, remove_agent"
953            ),
954        };
955
956        match result {
957            Ok(outcome) => Ok(outcome),
958            Err(error) => Ok(ToolResult {
959                success: false,
960                output: String::new(),
961                error: Some(error.to_string()),
962            }),
963        }
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use crate::security::{AutonomyLevel, SecurityPolicy};
971    use tempfile::TempDir;
972
973    fn test_security() -> Arc<SecurityPolicy> {
974        Arc::new(SecurityPolicy {
975            autonomy: AutonomyLevel::Supervised,
976            workspace_dir: std::env::temp_dir(),
977            ..SecurityPolicy::default()
978        })
979    }
980
981    fn readonly_security() -> Arc<SecurityPolicy> {
982        Arc::new(SecurityPolicy {
983            autonomy: AutonomyLevel::ReadOnly,
984            workspace_dir: std::env::temp_dir(),
985            ..SecurityPolicy::default()
986        })
987    }
988
989    async fn test_config(tmp: &TempDir) -> Arc<Config> {
990        let config = Config {
991            workspace_dir: tmp.path().join("workspace"),
992            config_path: tmp.path().join("config.toml"),
993            ..Config::default()
994        };
995        config.save().await.unwrap();
996        Arc::new(config)
997    }
998
999    #[tokio::test]
1000    async fn set_default_updates_provider_model_and_temperature() {
1001        let tmp = TempDir::new().unwrap();
1002        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1003
1004        let result = tool
1005            .execute(json!({
1006                "action": "set_default",
1007                "provider": "kimi",
1008                "model": "moonshot-v1-8k",
1009                "temperature": 0.2
1010            }))
1011            .await
1012            .unwrap();
1013
1014        assert!(result.success, "{:?}", result.error);
1015        let output: Value = serde_json::from_str(&result.output).unwrap();
1016        assert_eq!(
1017            output["config"]["default"]["provider"].as_str(),
1018            Some("kimi")
1019        );
1020        assert_eq!(
1021            output["config"]["default"]["model"].as_str(),
1022            Some("moonshot-v1-8k")
1023        );
1024        assert_eq!(
1025            output["config"]["default"]["temperature"].as_f64(),
1026            Some(0.2)
1027        );
1028    }
1029
1030    #[tokio::test]
1031    async fn upsert_scenario_creates_route_and_rule() {
1032        let tmp = TempDir::new().unwrap();
1033        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1034
1035        let result = tool
1036            .execute(json!({
1037                "action": "upsert_scenario",
1038                "hint": "coding",
1039                "provider": "openai",
1040                "model": "gpt-5.3-codex",
1041                "classification_enabled": true,
1042                "keywords": ["code", "bug", "refactor"],
1043                "patterns": ["```"],
1044                "priority": 50
1045            }))
1046            .await
1047            .unwrap();
1048
1049        assert!(result.success, "{:?}", result.error);
1050
1051        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
1052        assert!(get_result.success);
1053        let output: Value = serde_json::from_str(&get_result.output).unwrap();
1054
1055        assert_eq!(output["query_classification"]["enabled"], json!(true));
1056
1057        let scenarios = output["scenarios"].as_array().unwrap();
1058        assert!(scenarios.iter().any(|item| {
1059            item["hint"] == json!("coding")
1060                && item["provider"] == json!("openai")
1061                && item["model"] == json!("gpt-5.3-codex")
1062        }));
1063    }
1064
1065    #[tokio::test]
1066    async fn remove_scenario_also_removes_rule() {
1067        let tmp = TempDir::new().unwrap();
1068        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1069
1070        let _ = tool
1071            .execute(json!({
1072                "action": "upsert_scenario",
1073                "hint": "coding",
1074                "provider": "openai",
1075                "model": "gpt-5.3-codex",
1076                "classification_enabled": true,
1077                "keywords": ["code"]
1078            }))
1079            .await
1080            .unwrap();
1081
1082        let removed = tool
1083            .execute(json!({
1084                "action": "remove_scenario",
1085                "hint": "coding"
1086            }))
1087            .await
1088            .unwrap();
1089        assert!(removed.success, "{:?}", removed.error);
1090
1091        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
1092        let output: Value = serde_json::from_str(&get_result.output).unwrap();
1093        assert_eq!(output["query_classification"]["enabled"], json!(false));
1094        assert!(output["scenarios"].as_array().unwrap().is_empty());
1095    }
1096
1097    #[tokio::test]
1098    async fn upsert_and_remove_delegate_agent() {
1099        let tmp = TempDir::new().unwrap();
1100        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1101
1102        let upsert = tool
1103            .execute(json!({
1104                "action": "upsert_agent",
1105                "name": "coder",
1106                "provider": "openai",
1107                "model": "gpt-5.3-codex",
1108                "agentic": true,
1109                "allowed_tools": ["file_read", "file_write", "shell"],
1110                "max_iterations": 6
1111            }))
1112            .await
1113            .unwrap();
1114        assert!(upsert.success, "{:?}", upsert.error);
1115
1116        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
1117        let output: Value = serde_json::from_str(&get_result.output).unwrap();
1118        assert_eq!(output["agents"]["coder"]["provider"], json!("openai"));
1119        assert_eq!(output["agents"]["coder"]["model"], json!("gpt-5.3-codex"));
1120        assert_eq!(output["agents"]["coder"]["agentic"], json!(true));
1121
1122        let remove = tool
1123            .execute(json!({
1124                "action": "remove_agent",
1125                "name": "coder"
1126            }))
1127            .await
1128            .unwrap();
1129        assert!(remove.success, "{:?}", remove.error);
1130
1131        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
1132        let output: Value = serde_json::from_str(&get_result.output).unwrap();
1133        assert!(output["agents"]["coder"].is_null());
1134    }
1135
1136    #[tokio::test]
1137    async fn read_only_mode_blocks_mutating_actions() {
1138        let tmp = TempDir::new().unwrap();
1139        let tool =
1140            ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, readonly_security());
1141
1142        let result = tool
1143            .execute(json!({
1144                "action": "set_default",
1145                "provider": "openai"
1146            }))
1147            .await
1148            .unwrap();
1149
1150        assert!(!result.success);
1151        assert!(result.error.unwrap_or_default().contains("read-only"));
1152    }
1153
1154    #[tokio::test]
1155    async fn set_default_skips_probe_without_api_key() {
1156        // When no API key is configured (test_config has none), the probe is
1157        // skipped and any model string is accepted. This verifies the probe-
1158        // skip path doesn't accidentally reject valid config changes.
1159        let tmp = TempDir::new().unwrap();
1160        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1161
1162        let result = tool
1163            .execute(json!({
1164                "action": "set_default",
1165                "provider": "anthropic",
1166                "model": "totally-fake-model-12345"
1167            }))
1168            .await
1169            .unwrap();
1170
1171        assert!(result.success, "{:?}", result.error);
1172        let output: Value = serde_json::from_str(&result.output).unwrap();
1173        assert_eq!(
1174            output["config"]["default"]["model"].as_str(),
1175            Some("totally-fake-model-12345")
1176        );
1177    }
1178
1179    #[tokio::test]
1180    async fn set_default_temperature_only_skips_probe() {
1181        // Temperature-only changes don't set a new model, so the probe should
1182        // not fire at all (no provider/model to probe).
1183        let tmp = TempDir::new().unwrap();
1184        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1185
1186        let result = tool
1187            .execute(json!({
1188                "action": "set_default",
1189                "temperature": 1.5
1190            }))
1191            .await
1192            .unwrap();
1193
1194        assert!(result.success, "{:?}", result.error);
1195        let output: Value = serde_json::from_str(&result.output).unwrap();
1196        assert_eq!(
1197            output["config"]["default"]["temperature"].as_f64(),
1198            Some(1.5)
1199        );
1200    }
1201}