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}
174
175/// Command filtering policy for orchestrator-exposed `OpenCode` commands.
176#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
177#[serde(default)]
178pub struct OrchestratorCommandsConfig {
179    /// Exact case-sensitive command names to allow. Empty means no allowlist restriction.
180    pub allow: Vec<String>,
181    /// Exact case-sensitive command names to deny. Deny wins over allow.
182    pub deny: Vec<String>,
183}
184
185impl Default for OrchestratorConfig {
186    fn default() -> Self {
187        Self {
188            session_deadline_secs: 3600,
189            inactivity_timeout_secs: 300,
190            compaction_threshold: 0.80,
191            commands: OrchestratorCommandsConfig::default(),
192        }
193    }
194}
195
196//
197// ─────────────────────────────────────────────────────────────────────────────
198// WEB RETRIEVAL CONFIG
199// ─────────────────────────────────────────────────────────────────────────────
200//
201
202/// Configuration for web-retrieval tools (`web_fetch`, `web_search`).
203#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
204#[serde(default)]
205pub struct WebRetrievalConfig {
206    /// HTTP request timeout in seconds (default: 30).
207    pub request_timeout_secs: u64,
208    /// Default maximum bytes to fetch (default: 5MB).
209    pub default_max_bytes: u64,
210    /// Default number of search results (default: 8).
211    pub default_search_results: u32,
212    /// Maximum number of search results allowed (default: 20).
213    pub max_search_results: u32,
214    /// Summarizer configuration for Haiku-based summarization.
215    pub summarizer: WebSummarizerConfig,
216}
217
218impl Default for WebRetrievalConfig {
219    fn default() -> Self {
220        Self {
221            request_timeout_secs: 30,
222            default_max_bytes: 5 * 1024 * 1024, // 5MB
223            default_search_results: 8,
224            max_search_results: 20,
225            summarizer: WebSummarizerConfig::default(),
226        }
227    }
228}
229
230/// Configuration for the web summarizer (Haiku).
231#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
232#[serde(default)]
233pub struct WebSummarizerConfig {
234    /// Model to use for summarization (default: claude-haiku-4-5).
235    pub model: String,
236    /// Maximum tokens for summary output (default: 300).
237    pub max_tokens: u32,
238    /// Temperature for summary generation (default: 0.2).
239    pub temperature: f64,
240}
241
242impl Default for WebSummarizerConfig {
243    fn default() -> Self {
244        Self {
245            model: "claude-haiku-4-5".into(),
246            max_tokens: 300,
247            temperature: 0.2,
248        }
249    }
250}
251
252//
253// ─────────────────────────────────────────────────────────────────────────────
254// CLI TOOLS CONFIG
255// ─────────────────────────────────────────────────────────────────────────────
256//
257
258/// Configuration for CLI tools (grep, glob, ls).
259#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
260#[serde(default)]
261pub struct CliToolsConfig {
262    /// Default page size for ls results (default: 100).
263    pub ls_page_size: u32,
264    /// Default `head_limit` for grep results (default: 200).
265    pub grep_default_limit: u32,
266    /// Default `head_limit` for glob results (default: 500).
267    pub glob_default_limit: u32,
268    /// Maximum directory traversal depth (default: 10).
269    pub max_depth: u32,
270    /// Pagination cache TTL in seconds (default: 300 = 5 minutes).
271    pub pagination_cache_ttl_secs: u64,
272    /// Wall-clock timeout for `cli_just_execute` in seconds. `0` disables the timeout.
273    pub just_execute_timeout_secs: u64,
274    /// Wall-clock timeout for `cli_just_search` in seconds. `0` disables the timeout.
275    pub just_search_timeout_secs: u64,
276    /// Additional ignore patterns to append to builtin ignores.
277    #[serde(default)]
278    pub extra_ignore_patterns: Vec<String>,
279}
280
281impl Default for CliToolsConfig {
282    fn default() -> Self {
283        Self {
284            ls_page_size: 100,
285            grep_default_limit: 200,
286            glob_default_limit: 500,
287            max_depth: 10,
288            pagination_cache_ttl_secs: 300,
289            just_execute_timeout_secs: 1800,
290            just_search_timeout_secs: 30,
291            extra_ignore_patterns: vec![],
292        }
293    }
294}
295
296//
297// ─────────────────────────────────────────────────────────────────────────────
298// SERVICES CONFIG
299// ─────────────────────────────────────────────────────────────────────────────
300//
301
302/// External service configurations.
303#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
304#[serde(default)]
305pub struct ServicesConfig {
306    /// Anthropic API configuration.
307    pub anthropic: AnthropicServiceConfig,
308    /// Exa search API configuration.
309    pub exa: ExaServiceConfig,
310    /// Linear API configuration.
311    pub linear: LinearServiceConfig,
312    /// GitHub API configuration.
313    pub github: GitHubServiceConfig,
314}
315
316/// Anthropic API service configuration.
317#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
318#[serde(default)]
319pub struct AnthropicServiceConfig {
320    /// Base URL for the Anthropic API.
321    pub base_url: String,
322}
323
324impl Default for AnthropicServiceConfig {
325    fn default() -> Self {
326        Self {
327            base_url: "https://api.anthropic.com".into(),
328        }
329    }
330}
331
332/// Exa search API service configuration.
333#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
334#[serde(default)]
335pub struct ExaServiceConfig {
336    /// Base URL for the Exa API.
337    pub base_url: String,
338}
339
340impl Default for ExaServiceConfig {
341    fn default() -> Self {
342        Self {
343            base_url: "https://api.exa.ai".into(),
344        }
345    }
346}
347
348/// Linear API service configuration.
349#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
350#[serde(default)]
351pub struct LinearServiceConfig {
352    /// Base URL for the Linear GraphQL API.
353    pub base_url: String,
354    /// Connection establishment timeout in seconds. `0` disables the timeout.
355    pub connect_timeout_secs: u64,
356    /// Per-request timeout in seconds. `0` disables the timeout.
357    pub request_timeout_secs: u64,
358}
359
360impl Default for LinearServiceConfig {
361    fn default() -> Self {
362        Self {
363            base_url: "https://api.linear.app/graphql".into(),
364            connect_timeout_secs: 10,
365            request_timeout_secs: 60,
366        }
367    }
368}
369
370/// GitHub API service configuration.
371#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372#[serde(default)]
373pub struct GitHubServiceConfig {
374    /// Base URL for the GitHub API.
375    pub base_url: String,
376    /// Total timeout for multi-request operations in seconds. `0` disables the timeout.
377    pub total_timeout_secs: u64,
378}
379
380impl Default for GitHubServiceConfig {
381    fn default() -> Self {
382        Self {
383            base_url: "https://api.github.com".into(),
384            total_timeout_secs: 120,
385        }
386    }
387}
388
389//
390// ─────────────────────────────────────────────────────────────────────────────
391// REVIEW CONFIG
392// ─────────────────────────────────────────────────────────────────────────────
393//
394
395/// Configuration for review tools.
396#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
397#[serde(default)]
398pub struct ReviewConfig {
399    /// Wall-clock timeout for `review_run` sessions in seconds. `0` disables the timeout.
400    pub run_timeout_secs: u64,
401}
402
403impl Default for ReviewConfig {
404    fn default() -> Self {
405        Self {
406            run_timeout_secs: 1800,
407        }
408    }
409}
410
411//
412// ─────────────────────────────────────────────────────────────────────────────
413// THOUGHTS CONFIG
414// ─────────────────────────────────────────────────────────────────────────────
415//
416
417/// Configuration for thoughts MCP-adjacent operations.
418#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
419#[serde(default)]
420pub struct ThoughtsConfig {
421    /// Wall-clock timeout for `thoughts_add_reference` in seconds. `0` disables the timeout.
422    pub add_reference_timeout_secs: u64,
423}
424
425impl Default for ThoughtsConfig {
426    fn default() -> Self {
427        Self {
428            add_reference_timeout_secs: 600,
429        }
430    }
431}
432
433//
434// ─────────────────────────────────────────────────────────────────────────────
435// LOGGING CONFIG
436// ─────────────────────────────────────────────────────────────────────────────
437//
438
439/// Logging and diagnostics configuration.
440#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
441#[serde(default)]
442pub struct LoggingConfig {
443    /// Log level (trace, debug, info, warn, error).
444    pub level: String,
445
446    /// Whether to enable JSON-formatted logs.
447    pub json: bool,
448}
449
450impl Default for LoggingConfig {
451    fn default() -> Self {
452        Self {
453            level: "info".into(),
454            json: false,
455        }
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_default_config_serializes() {
465        let config = AgenticConfig::default();
466        let toml_str = toml::to_string_pretty(&config).unwrap();
467        assert!(toml_str.contains("[subagents]"));
468        assert!(toml_str.contains("[reasoning]"));
469        // Services sections serialize as [services.anthropic], [services.exa], etc.
470        assert!(toml_str.contains("[services.anthropic]"));
471        assert!(toml_str.contains("[services.exa]"));
472        assert!(toml_str.contains("[services.linear]"));
473        assert!(toml_str.contains("[services.github]"));
474        assert!(toml_str.contains("[review]"));
475        assert!(toml_str.contains("[thoughts]"));
476        assert!(toml_str.contains("[orchestrator]"));
477        assert!(toml_str.contains("[orchestrator.commands]"));
478        assert!(toml_str.contains("[web_retrieval]"));
479        assert!(toml_str.contains("[cli_tools]"));
480        assert!(toml_str.contains("[logging]"));
481        // Ensure old sections are NOT present
482        assert!(!toml_str.contains("[models]"));
483    }
484
485    #[test]
486    fn test_default_models_use_undated_names() {
487        let subagents = SubagentsConfig::default();
488        assert!(!subagents.locator_model.contains("20"));
489        assert!(!subagents.analyzer_model.contains("20"));
490
491        let reasoning = ReasoningConfig::default();
492        assert!(!reasoning.optimizer_model.contains("20"));
493        assert!(!reasoning.executor_model.contains("20"));
494    }
495
496    #[test]
497    fn test_partial_config_deserializes() {
498        let toml_str = r#"
499[subagents]
500locator_model = "custom-model"
501"#;
502        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
503        assert_eq!(config.subagents.locator_model, "custom-model");
504        // Other fields get defaults
505        assert_eq!(config.subagents.analyzer_model, "claude-sonnet-4-6");
506        assert_eq!(config.subagents.runtime_timeout_secs, 3600);
507        assert_eq!(
508            config.services.anthropic.base_url,
509            "https://api.anthropic.com"
510        );
511        assert_eq!(
512            config.services.linear.base_url,
513            "https://api.linear.app/graphql"
514        );
515        assert!(config.orchestrator.commands.allow.is_empty());
516        assert!(config.orchestrator.commands.deny.is_empty());
517    }
518
519    #[test]
520    fn test_orchestrator_commands_deserialize() {
521        let toml_str = r#"
522[orchestrator.commands]
523allow = ["plan", "research"]
524deny = ["commit"]
525"#;
526
527        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
528
529        assert_eq!(config.orchestrator.commands.allow, ["plan", "research"]);
530        assert_eq!(config.orchestrator.commands.deny, ["commit"]);
531    }
532
533    #[test]
534    fn test_schema_field_optional() {
535        let toml_str = r#""$schema" = "file://./agentic.schema.json""#;
536        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
537        assert_eq!(config.schema, Some("file://./agentic.schema.json".into()));
538    }
539
540    // Default value assertion tests - ensure defaults match current hardcoded behavior
541    #[test]
542    fn test_web_retrieval_defaults_match_hardcoded() {
543        let cfg = WebRetrievalConfig::default();
544        assert_eq!(cfg.request_timeout_secs, 30);
545        assert_eq!(cfg.default_max_bytes, 5 * 1024 * 1024); // 5MB
546        assert_eq!(cfg.default_search_results, 8);
547        assert_eq!(cfg.max_search_results, 20);
548        assert_eq!(cfg.summarizer.model, "claude-haiku-4-5");
549        assert_eq!(cfg.summarizer.max_tokens, 300);
550        assert!((cfg.summarizer.temperature - 0.2).abs() < f64::EPSILON);
551    }
552
553    #[test]
554    fn test_cli_tools_defaults_match_hardcoded() {
555        let cfg = CliToolsConfig::default();
556        assert_eq!(cfg.ls_page_size, 100);
557        assert_eq!(cfg.grep_default_limit, 200);
558        assert_eq!(cfg.glob_default_limit, 500);
559        assert_eq!(cfg.max_depth, 10);
560        assert_eq!(cfg.pagination_cache_ttl_secs, 300);
561        assert_eq!(cfg.just_execute_timeout_secs, 1800);
562        assert_eq!(cfg.just_search_timeout_secs, 30);
563        assert!(cfg.extra_ignore_patterns.is_empty());
564    }
565
566    #[test]
567    fn test_orchestrator_defaults_match_hardcoded() {
568        let cfg = OrchestratorConfig::default();
569        assert_eq!(cfg.session_deadline_secs, 3600);
570        assert_eq!(cfg.inactivity_timeout_secs, 300);
571        assert!((cfg.compaction_threshold - 0.80).abs() < f64::EPSILON);
572        assert!(cfg.commands.allow.is_empty());
573        assert!(cfg.commands.deny.is_empty());
574    }
575
576    #[test]
577    fn test_services_defaults_match_hardcoded() {
578        let cfg = ServicesConfig::default();
579
580        // Anthropic
581        assert_eq!(cfg.anthropic.base_url, "https://api.anthropic.com");
582
583        // Exa
584        assert_eq!(cfg.exa.base_url, "https://api.exa.ai");
585
586        // Linear
587        assert_eq!(cfg.linear.base_url, "https://api.linear.app/graphql");
588        assert_eq!(cfg.linear.connect_timeout_secs, 10);
589        assert_eq!(cfg.linear.request_timeout_secs, 60);
590
591        // GitHub
592        assert_eq!(cfg.github.base_url, "https://api.github.com");
593        assert_eq!(cfg.github.total_timeout_secs, 120);
594    }
595
596    #[test]
597    fn test_new_timeout_defaults_match_plan() {
598        let cfg = AgenticConfig::default();
599
600        assert_eq!(cfg.subagents.runtime_timeout_secs, 3600);
601        assert_eq!(cfg.review.run_timeout_secs, 1800);
602        assert_eq!(cfg.thoughts.add_reference_timeout_secs, 600);
603    }
604}