Skip to main content

agentic_config/
types.rs

1//! Configuration types for the agentic tools ecosystem.
2//!
3//! The root type is [`AgenticConfig`], which contains namespaced sub-configs
4//! for different concerns: subagents, reasoning, services, orchestrator,
5//! web retrieval, CLI tools, and logging.
6
7use schemars::JsonSchema;
8use serde::Deserialize;
9use serde::Serialize;
10
11/// Root configuration for all agentic tools.
12///
13/// This is the unified configuration that gets loaded from `agentic.toml` files.
14/// All fields use `#[serde(default)]` so partial configs work correctly.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
16#[serde(default)]
17pub struct AgenticConfig {
18    /// Optional JSON Schema URL for IDE autocomplete support.
19    /// In TOML: `"$schema" = "file://./agentic.schema.json"`
20    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
21    pub schema: Option<String>,
22
23    /// Tool-specific config for coding-agent-tools subagents.
24    pub subagents: SubagentsConfig,
25
26    /// Tool-specific config for gpt5-reasoner.
27    pub reasoning: ReasoningConfig,
28
29    /// External service configurations (Anthropic, Exa).
30    pub services: ServicesConfig,
31
32    /// Review tools configuration.
33    pub review: ReviewConfig,
34
35    /// Thoughts tool configuration.
36    pub thoughts: ThoughtsConfig,
37
38    /// Orchestrator session and timing configuration.
39    pub orchestrator: OrchestratorConfig,
40
41    /// Web retrieval tool configuration.
42    pub web_retrieval: WebRetrievalConfig,
43
44    /// CLI tools (grep, glob, ls) configuration.
45    pub cli_tools: CliToolsConfig,
46
47    /// Logging and diagnostics configuration.
48    pub logging: LoggingConfig,
49}
50
51//
52// ─────────────────────────────────────────────────────────────────────────────
53// SUBAGENTS CONFIG
54// ─────────────────────────────────────────────────────────────────────────────
55//
56
57/// Configuration for coding-agent-tools subagents (`ask_agent` tool).
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59#[serde(default)]
60pub struct SubagentsConfig {
61    // TODO(3): Model name handling could be more type-safe:
62    // - Consider documenting supported models in code (enum or const list)
63    // - Standardize approach between anthropic-async, claudecode_rs, and consumers
64    // - Current string-based approach works but lacks IDE completion and validation
65    /// Model for Locator subagent (fast discovery). Uses Claude CLI format.
66    pub locator_model: String,
67    /// Model for Analyzer subagent (deep analysis). Uses Claude CLI format.
68    pub analyzer_model: String,
69    /// Wall-clock timeout for `ask_agent` runtime in seconds. `0` disables the timeout.
70    pub runtime_timeout_secs: u64,
71}
72
73impl Default for SubagentsConfig {
74    fn default() -> Self {
75        Self {
76            locator_model: "claude-haiku-4-5".into(),
77            analyzer_model: "claude-sonnet-4-6".into(),
78            runtime_timeout_secs: 3600,
79        }
80    }
81}
82
83//
84// ─────────────────────────────────────────────────────────────────────────────
85// REASONING CONFIG
86// ─────────────────────────────────────────────────────────────────────────────
87//
88
89/// Schema-only enum for `reasoning_effort` IDE autocomplete.
90/// Runtime storage remains Option<String> for advisory validation semantics.
91#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "lowercase")]
93enum ReasoningEffortLevel {
94    Low,
95    Medium,
96    High,
97    Xhigh,
98}
99
100// Note on external type dependencies: We investigated using model types from the
101// async-openai crate but found they use plain `String` for most model fields (chat
102// completions, embeddings, assistants, fine-tuning, audio transcription). Only image
103// generation (ImageModel) and TTS (SpeechModel) have typed enums, and those include
104// `Other(String)` escape hatches with #[serde(untagged)]. Their Model struct (for
105// listing available models) also uses `id: String`. Copying their types would not
106// improve our type safety since they face the same constraints we do and chose the
107// same approach. See research/pr127-group7-type-safety-external-type-dependencies.md.
108
109/// Configuration for gpt5-reasoner tool.
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111#[serde(default)]
112pub struct ReasoningConfig {
113    /// `OpenRouter` model ID for optimizer step.
114    pub optimizer_model: String,
115    /// `OpenRouter` model ID for executor/reasoner step.
116    pub executor_model: String,
117    /// Optional reasoning effort level: low, medium, high, xhigh.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    #[schemars(with = "Option<ReasoningEffortLevel>")]
120    pub reasoning_effort: Option<String>,
121    /// Optional API base URL override for reasoning service.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub api_base_url: Option<String>,
124    /// Max tokens allowed in the final input prompt after file injection.
125    /// If None, `gpt5_reasoner` enforces its internal default (`250_000`).
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub max_input_tokens: Option<u32>,
128    /// Upper bound for generated completion tokens (visible + reasoning).
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub max_completion_tokens: Option<u32>,
131    /// Executor timeout in seconds.
132    pub executor_timeout_secs: u64,
133    /// Suppress empty-response retry when attempt duration exceeds this threshold.
134    pub empty_response_no_retry_after_secs: u64,
135    /// Heartbeat cadence for executor streaming logs.
136    pub stream_heartbeat_secs: u64,
137}
138
139impl Default for ReasoningConfig {
140    fn default() -> Self {
141        Self {
142            optimizer_model: "anthropic/claude-sonnet-4.6".into(),
143            executor_model: "openai/gpt-5.2".into(),
144            reasoning_effort: None,
145            api_base_url: None,
146            max_input_tokens: None,
147            max_completion_tokens: Some(128_000),
148            executor_timeout_secs: 2700,
149            empty_response_no_retry_after_secs: 600,
150            stream_heartbeat_secs: 30,
151        }
152    }
153}
154
155//
156// ─────────────────────────────────────────────────────────────────────────────
157// ORCHESTRATOR CONFIG
158// ─────────────────────────────────────────────────────────────────────────────
159//
160
161/// Configuration for opencode-orchestrator-mcp.
162#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
163#[serde(default)]
164pub struct OrchestratorConfig {
165    /// Maximum session duration in seconds (default: 3600 = 1 hour).
166    pub session_deadline_secs: u64,
167    /// Inactivity timeout in seconds before session ends (default: 300 = 5 minutes).
168    pub inactivity_timeout_secs: u64,
169    /// Context compaction threshold as fraction 0.0-1.0 (default: 0.80).
170    pub compaction_threshold: f64,
171    /// Command filtering policy for orchestrator-exposed `OpenCode` commands.
172    pub commands: OrchestratorCommandsConfig,
173    /// Agent filtering policy for explicit orchestrator agent listing/selection.
174    pub agents: OrchestratorAgentsConfig,
175}
176
177/// Command filtering policy for orchestrator-exposed `OpenCode` commands.
178#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
179#[serde(default)]
180pub struct OrchestratorCommandsConfig {
181    /// Exact case-sensitive command names to allow. Empty means no allowlist restriction.
182    pub allow: Vec<String>,
183    /// Exact case-sensitive command names to deny. Deny wins over allow.
184    pub deny: Vec<String>,
185}
186
187/// Agent filtering policy for explicit orchestrator agent listing/selection.
188#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
189#[serde(default)]
190pub struct OrchestratorAgentsConfig {
191    /// Exact case-sensitive agent names to allow. Empty means no allowlist restriction.
192    pub allow: Vec<String>,
193    /// Exact case-sensitive agent names to deny. Deny wins over allow.
194    pub deny: Vec<String>,
195}
196
197impl Default for OrchestratorConfig {
198    fn default() -> Self {
199        Self {
200            session_deadline_secs: 3600,
201            inactivity_timeout_secs: 300,
202            compaction_threshold: 0.80,
203            commands: OrchestratorCommandsConfig::default(),
204            agents: OrchestratorAgentsConfig::default(),
205        }
206    }
207}
208
209//
210// ─────────────────────────────────────────────────────────────────────────────
211// WEB RETRIEVAL CONFIG
212// ─────────────────────────────────────────────────────────────────────────────
213//
214
215/// Configuration for web-retrieval tools (`web_fetch`, `web_search`).
216#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
217#[serde(default)]
218pub struct WebRetrievalConfig {
219    /// HTTP request timeout in seconds (default: 30).
220    pub request_timeout_secs: u64,
221    /// Default maximum bytes to fetch (default: 5MB).
222    pub default_max_bytes: u64,
223    /// Default number of search results (default: 8).
224    pub default_search_results: u32,
225    /// Maximum number of search results allowed (default: 20).
226    pub max_search_results: u32,
227    /// Summarizer configuration for Haiku-based summarization.
228    pub summarizer: WebSummarizerConfig,
229}
230
231impl Default for WebRetrievalConfig {
232    fn default() -> Self {
233        Self {
234            request_timeout_secs: 30,
235            default_max_bytes: 5 * 1024 * 1024, // 5MB
236            default_search_results: 8,
237            max_search_results: 20,
238            summarizer: WebSummarizerConfig::default(),
239        }
240    }
241}
242
243/// Configuration for the web summarizer (Haiku).
244#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
245#[serde(default)]
246pub struct WebSummarizerConfig {
247    /// Model to use for summarization (default: claude-haiku-4-5).
248    pub model: String,
249    /// Maximum tokens for summary output (default: 300).
250    pub max_tokens: u32,
251    /// Temperature for summary generation (default: 0.2).
252    pub temperature: f64,
253}
254
255impl Default for WebSummarizerConfig {
256    fn default() -> Self {
257        Self {
258            model: "claude-haiku-4-5".into(),
259            max_tokens: 300,
260            temperature: 0.2,
261        }
262    }
263}
264
265//
266// ─────────────────────────────────────────────────────────────────────────────
267// CLI TOOLS CONFIG
268// ─────────────────────────────────────────────────────────────────────────────
269//
270
271/// Configuration for CLI tools (grep, glob, ls).
272#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
273#[serde(default)]
274pub struct CliToolsConfig {
275    /// Default page size for ls results (default: 100).
276    pub ls_page_size: u32,
277    /// Default `head_limit` for grep results (default: 200).
278    pub grep_default_limit: u32,
279    /// Default `head_limit` for glob results (default: 500).
280    pub glob_default_limit: u32,
281    /// Maximum directory traversal depth (default: 10).
282    pub max_depth: u32,
283    /// Pagination cache TTL in seconds (default: 300 = 5 minutes).
284    pub pagination_cache_ttl_secs: u64,
285    /// Wall-clock timeout for `cli_just_execute` in seconds. `0` disables the timeout.
286    pub just_execute_timeout_secs: u64,
287    /// Wall-clock timeout for `cli_just_search` in seconds. `0` disables the timeout.
288    pub just_search_timeout_secs: u64,
289    /// Additional ignore patterns to append to builtin ignores.
290    #[serde(default)]
291    pub extra_ignore_patterns: Vec<String>,
292}
293
294impl Default for CliToolsConfig {
295    fn default() -> Self {
296        Self {
297            ls_page_size: 100,
298            grep_default_limit: 200,
299            glob_default_limit: 500,
300            max_depth: 10,
301            pagination_cache_ttl_secs: 300,
302            just_execute_timeout_secs: 1800,
303            just_search_timeout_secs: 30,
304            extra_ignore_patterns: vec![],
305        }
306    }
307}
308
309//
310// ─────────────────────────────────────────────────────────────────────────────
311// SERVICES CONFIG
312// ─────────────────────────────────────────────────────────────────────────────
313//
314
315/// External service configurations.
316#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
317#[serde(default)]
318pub struct ServicesConfig {
319    /// Anthropic API configuration.
320    pub anthropic: AnthropicServiceConfig,
321    /// Exa search API configuration.
322    pub exa: ExaServiceConfig,
323    /// Linear API configuration.
324    pub linear: LinearServiceConfig,
325    /// GitHub API configuration.
326    pub github: GitHubServiceConfig,
327}
328
329/// Anthropic API service configuration.
330#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
331#[serde(default)]
332pub struct AnthropicServiceConfig {
333    /// Base URL for the Anthropic API.
334    pub base_url: String,
335}
336
337impl Default for AnthropicServiceConfig {
338    fn default() -> Self {
339        Self {
340            base_url: "https://api.anthropic.com".into(),
341        }
342    }
343}
344
345/// Exa search API service configuration.
346#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
347#[serde(default)]
348pub struct ExaServiceConfig {
349    /// Base URL for the Exa API.
350    pub base_url: String,
351}
352
353impl Default for ExaServiceConfig {
354    fn default() -> Self {
355        Self {
356            base_url: "https://api.exa.ai".into(),
357        }
358    }
359}
360
361/// Linear API service configuration.
362#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
363#[serde(default)]
364pub struct LinearServiceConfig {
365    /// Base URL for the Linear GraphQL API.
366    pub base_url: String,
367    /// Connection establishment timeout in seconds. `0` disables the timeout.
368    pub connect_timeout_secs: u64,
369    /// Per-request timeout in seconds. `0` disables the timeout.
370    pub request_timeout_secs: u64,
371}
372
373impl Default for LinearServiceConfig {
374    fn default() -> Self {
375        Self {
376            base_url: "https://api.linear.app/graphql".into(),
377            connect_timeout_secs: 10,
378            request_timeout_secs: 60,
379        }
380    }
381}
382
383/// GitHub API service configuration.
384#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
385#[serde(default)]
386pub struct GitHubServiceConfig {
387    /// Base URL for the GitHub API.
388    pub base_url: String,
389    /// Total timeout for multi-request operations in seconds. `0` disables the timeout.
390    pub total_timeout_secs: u64,
391}
392
393impl Default for GitHubServiceConfig {
394    fn default() -> Self {
395        Self {
396            base_url: "https://api.github.com".into(),
397            total_timeout_secs: 120,
398        }
399    }
400}
401
402//
403// ─────────────────────────────────────────────────────────────────────────────
404// REVIEW CONFIG
405// ─────────────────────────────────────────────────────────────────────────────
406//
407
408/// Configuration for review tools.
409#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
410#[serde(default)]
411pub struct ReviewConfig {
412    /// Wall-clock timeout for `review_run` sessions in seconds. `0` disables the timeout.
413    pub run_timeout_secs: u64,
414}
415
416impl Default for ReviewConfig {
417    fn default() -> Self {
418        Self {
419            run_timeout_secs: 1800,
420        }
421    }
422}
423
424//
425// ─────────────────────────────────────────────────────────────────────────────
426// THOUGHTS CONFIG
427// ─────────────────────────────────────────────────────────────────────────────
428//
429
430/// Configuration for thoughts MCP-adjacent operations.
431#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
432#[serde(default)]
433pub struct ThoughtsConfig {
434    /// Wall-clock timeout for `thoughts_add_reference` in seconds. `0` disables the timeout.
435    pub add_reference_timeout_secs: u64,
436}
437
438impl Default for ThoughtsConfig {
439    fn default() -> Self {
440        Self {
441            add_reference_timeout_secs: 600,
442        }
443    }
444}
445
446//
447// ─────────────────────────────────────────────────────────────────────────────
448// LOGGING CONFIG
449// ─────────────────────────────────────────────────────────────────────────────
450//
451
452/// Logging and diagnostics configuration.
453#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
454#[serde(default)]
455pub struct LoggingConfig {
456    /// Log level (trace, debug, info, warn, error).
457    pub level: String,
458
459    /// Whether to enable JSON-formatted logs.
460    pub json: bool,
461}
462
463impl Default for LoggingConfig {
464    fn default() -> Self {
465        Self {
466            level: "info".into(),
467            json: false,
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn test_default_config_serializes() {
478        let config = AgenticConfig::default();
479        let toml_str = toml::to_string_pretty(&config).unwrap();
480        assert!(toml_str.contains("[subagents]"));
481        assert!(toml_str.contains("[reasoning]"));
482        // Services sections serialize as [services.anthropic], [services.exa], etc.
483        assert!(toml_str.contains("[services.anthropic]"));
484        assert!(toml_str.contains("[services.exa]"));
485        assert!(toml_str.contains("[services.linear]"));
486        assert!(toml_str.contains("[services.github]"));
487        assert!(toml_str.contains("[review]"));
488        assert!(toml_str.contains("[thoughts]"));
489        assert!(toml_str.contains("[orchestrator]"));
490        assert!(toml_str.contains("[orchestrator.commands]"));
491        assert!(toml_str.contains("[orchestrator.agents]"));
492        assert!(toml_str.contains("[web_retrieval]"));
493        assert!(toml_str.contains("[cli_tools]"));
494        assert!(toml_str.contains("[logging]"));
495        // Ensure old sections are NOT present
496        assert!(!toml_str.contains("[models]"));
497    }
498
499    #[test]
500    fn test_default_models_use_undated_names() {
501        let subagents = SubagentsConfig::default();
502        assert!(!subagents.locator_model.contains("20"));
503        assert!(!subagents.analyzer_model.contains("20"));
504
505        let reasoning = ReasoningConfig::default();
506        assert!(!reasoning.optimizer_model.contains("20"));
507        assert!(!reasoning.executor_model.contains("20"));
508    }
509
510    #[test]
511    fn test_partial_config_deserializes() {
512        let toml_str = r#"
513[subagents]
514locator_model = "custom-model"
515"#;
516        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
517        assert_eq!(config.subagents.locator_model, "custom-model");
518        // Other fields get defaults
519        assert_eq!(config.subagents.analyzer_model, "claude-sonnet-4-6");
520        assert_eq!(config.subagents.runtime_timeout_secs, 3600);
521        assert_eq!(
522            config.services.anthropic.base_url,
523            "https://api.anthropic.com"
524        );
525        assert_eq!(
526            config.services.linear.base_url,
527            "https://api.linear.app/graphql"
528        );
529        assert!(config.orchestrator.commands.allow.is_empty());
530        assert!(config.orchestrator.commands.deny.is_empty());
531        assert!(config.orchestrator.agents.allow.is_empty());
532        assert!(config.orchestrator.agents.deny.is_empty());
533    }
534
535    #[test]
536    fn test_orchestrator_commands_deserialize() {
537        let toml_str = r#"
538[orchestrator.commands]
539allow = ["plan", "research"]
540deny = ["commit"]
541"#;
542
543        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
544
545        assert_eq!(config.orchestrator.commands.allow, ["plan", "research"]);
546        assert_eq!(config.orchestrator.commands.deny, ["commit"]);
547    }
548
549    #[test]
550    fn test_orchestrator_agents_partial_deserializes_to_empty_lists() {
551        let toml_str = r"
552[orchestrator.agents]
553";
554
555        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
556
557        assert!(config.orchestrator.agents.allow.is_empty());
558        assert!(config.orchestrator.agents.deny.is_empty());
559    }
560
561    #[test]
562    fn test_orchestrator_agents_round_trip() {
563        let toml_str = r#"
564[orchestrator.agents]
565allow = ["Plan", "Research"]
566deny = ["Bash"]
567"#;
568
569        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
570
571        assert_eq!(config.orchestrator.agents.allow, ["Plan", "Research"]);
572        assert_eq!(config.orchestrator.agents.deny, ["Bash"]);
573
574        let serialized = toml::to_string_pretty(&config).unwrap();
575        assert!(serialized.contains("[orchestrator.agents]"));
576        assert!(serialized.contains("allow = ["));
577        assert!(serialized.contains("\"Plan\""));
578        assert!(serialized.contains("\"Research\""));
579        assert!(serialized.contains("deny = ["));
580        assert!(serialized.contains("\"Bash\""));
581    }
582
583    #[test]
584    fn test_schema_field_optional() {
585        let toml_str = r#""$schema" = "file://./agentic.schema.json""#;
586        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
587        assert_eq!(config.schema, Some("file://./agentic.schema.json".into()));
588    }
589
590    // Default value assertion tests - ensure defaults match current hardcoded behavior
591    #[test]
592    fn test_web_retrieval_defaults_match_hardcoded() {
593        let cfg = WebRetrievalConfig::default();
594        assert_eq!(cfg.request_timeout_secs, 30);
595        assert_eq!(cfg.default_max_bytes, 5 * 1024 * 1024); // 5MB
596        assert_eq!(cfg.default_search_results, 8);
597        assert_eq!(cfg.max_search_results, 20);
598        assert_eq!(cfg.summarizer.model, "claude-haiku-4-5");
599        assert_eq!(cfg.summarizer.max_tokens, 300);
600        assert!((cfg.summarizer.temperature - 0.2).abs() < f64::EPSILON);
601    }
602
603    #[test]
604    fn test_cli_tools_defaults_match_hardcoded() {
605        let cfg = CliToolsConfig::default();
606        assert_eq!(cfg.ls_page_size, 100);
607        assert_eq!(cfg.grep_default_limit, 200);
608        assert_eq!(cfg.glob_default_limit, 500);
609        assert_eq!(cfg.max_depth, 10);
610        assert_eq!(cfg.pagination_cache_ttl_secs, 300);
611        assert_eq!(cfg.just_execute_timeout_secs, 1800);
612        assert_eq!(cfg.just_search_timeout_secs, 30);
613        assert!(cfg.extra_ignore_patterns.is_empty());
614    }
615
616    #[test]
617    fn test_orchestrator_defaults_match_hardcoded() {
618        let cfg = OrchestratorConfig::default();
619        assert_eq!(cfg.session_deadline_secs, 3600);
620        assert_eq!(cfg.inactivity_timeout_secs, 300);
621        assert!((cfg.compaction_threshold - 0.80).abs() < f64::EPSILON);
622        assert!(cfg.commands.allow.is_empty());
623        assert!(cfg.commands.deny.is_empty());
624        assert!(cfg.agents.allow.is_empty());
625        assert!(cfg.agents.deny.is_empty());
626    }
627
628    #[test]
629    fn test_services_defaults_match_hardcoded() {
630        let cfg = ServicesConfig::default();
631
632        // Anthropic
633        assert_eq!(cfg.anthropic.base_url, "https://api.anthropic.com");
634
635        // Exa
636        assert_eq!(cfg.exa.base_url, "https://api.exa.ai");
637
638        // Linear
639        assert_eq!(cfg.linear.base_url, "https://api.linear.app/graphql");
640        assert_eq!(cfg.linear.connect_timeout_secs, 10);
641        assert_eq!(cfg.linear.request_timeout_secs, 60);
642
643        // GitHub
644        assert_eq!(cfg.github.base_url, "https://api.github.com");
645        assert_eq!(cfg.github.total_timeout_secs, 120);
646    }
647
648    #[test]
649    fn test_new_timeout_defaults_match_plan() {
650        let cfg = AgenticConfig::default();
651
652        assert_eq!(cfg.subagents.runtime_timeout_secs, 3600);
653        assert_eq!(cfg.review.run_timeout_secs, 1800);
654        assert_eq!(cfg.thoughts.add_reference_timeout_secs, 600);
655    }
656}