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 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 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 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 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 async fn probe_model(&self, provider_name: &str, model: &str) -> anyhow::Result<()> {
471 use crate::providers;
472
473 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 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 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}