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    /// Orchestrator session and timing configuration.
33    pub orchestrator: OrchestratorConfig,
34
35    /// Web retrieval tool configuration.
36    pub web_retrieval: WebRetrievalConfig,
37
38    /// CLI tools (grep, glob, ls) configuration.
39    pub cli_tools: CliToolsConfig,
40
41    /// Logging and diagnostics configuration.
42    pub logging: LoggingConfig,
43}
44
45//
46// ─────────────────────────────────────────────────────────────────────────────
47// SUBAGENTS CONFIG
48// ─────────────────────────────────────────────────────────────────────────────
49//
50
51/// Configuration for coding-agent-tools subagents (`ask_agent` tool).
52#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53#[serde(default)]
54pub struct SubagentsConfig {
55    // TODO(3): Model name handling could be more type-safe:
56    // - Consider documenting supported models in code (enum or const list)
57    // - Standardize approach between anthropic-async, claudecode_rs, and consumers
58    // - Current string-based approach works but lacks IDE completion and validation
59    /// Model for Locator subagent (fast discovery). Uses Claude CLI format.
60    pub locator_model: String,
61    /// Model for Analyzer subagent (deep analysis). Uses Claude CLI format.
62    pub analyzer_model: String,
63}
64
65impl Default for SubagentsConfig {
66    fn default() -> Self {
67        Self {
68            locator_model: "claude-haiku-4-5".into(),
69            analyzer_model: "claude-sonnet-4-6".into(),
70        }
71    }
72}
73
74//
75// ─────────────────────────────────────────────────────────────────────────────
76// REASONING CONFIG
77// ─────────────────────────────────────────────────────────────────────────────
78//
79
80/// Schema-only enum for `reasoning_effort` IDE autocomplete.
81/// Runtime storage remains Option<String> for advisory validation semantics.
82#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
83#[serde(rename_all = "lowercase")]
84enum ReasoningEffortLevel {
85    Low,
86    Medium,
87    High,
88    Xhigh,
89}
90
91// Note on external type dependencies: We investigated using model types from the
92// async-openai crate but found they use plain `String` for most model fields (chat
93// completions, embeddings, assistants, fine-tuning, audio transcription). Only image
94// generation (ImageModel) and TTS (SpeechModel) have typed enums, and those include
95// `Other(String)` escape hatches with #[serde(untagged)]. Their Model struct (for
96// listing available models) also uses `id: String`. Copying their types would not
97// improve our type safety since they face the same constraints we do and chose the
98// same approach. See research/pr127-group7-type-safety-external-type-dependencies.md.
99
100/// Configuration for gpt5-reasoner tool.
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
102#[serde(default)]
103pub struct ReasoningConfig {
104    /// `OpenRouter` model ID for optimizer step.
105    pub optimizer_model: String,
106    /// `OpenRouter` model ID for executor/reasoner step.
107    pub executor_model: String,
108    /// Optional reasoning effort level: low, medium, high, xhigh.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    #[schemars(with = "Option<ReasoningEffortLevel>")]
111    pub reasoning_effort: Option<String>,
112    /// Optional API base URL override for reasoning service.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub api_base_url: Option<String>,
115    /// Max tokens allowed in the final input prompt after file injection.
116    /// If None, `gpt5_reasoner` enforces its internal default (`250_000`).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub max_input_tokens: Option<u32>,
119    /// Upper bound for generated completion tokens (visible + reasoning).
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub max_completion_tokens: Option<u32>,
122    /// Executor timeout in seconds.
123    pub executor_timeout_secs: u64,
124    /// Suppress empty-response retry when attempt duration exceeds this threshold.
125    pub empty_response_no_retry_after_secs: u64,
126    /// Heartbeat cadence for executor streaming logs.
127    pub stream_heartbeat_secs: u64,
128}
129
130impl Default for ReasoningConfig {
131    fn default() -> Self {
132        Self {
133            optimizer_model: "anthropic/claude-sonnet-4.6".into(),
134            executor_model: "openai/gpt-5.2".into(),
135            reasoning_effort: None,
136            api_base_url: None,
137            max_input_tokens: None,
138            max_completion_tokens: Some(128_000),
139            executor_timeout_secs: 2700,
140            empty_response_no_retry_after_secs: 600,
141            stream_heartbeat_secs: 30,
142        }
143    }
144}
145
146//
147// ─────────────────────────────────────────────────────────────────────────────
148// ORCHESTRATOR CONFIG
149// ─────────────────────────────────────────────────────────────────────────────
150//
151
152/// Configuration for opencode-orchestrator-mcp.
153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
154#[serde(default)]
155pub struct OrchestratorConfig {
156    /// Maximum session duration in seconds (default: 3600 = 1 hour).
157    pub session_deadline_secs: u64,
158    /// Inactivity timeout in seconds before session ends (default: 300 = 5 minutes).
159    pub inactivity_timeout_secs: u64,
160    /// Context compaction threshold as fraction 0.0-1.0 (default: 0.80).
161    pub compaction_threshold: f64,
162    /// Command filtering policy for orchestrator-exposed `OpenCode` commands.
163    pub commands: OrchestratorCommandsConfig,
164}
165
166/// Command filtering policy for orchestrator-exposed `OpenCode` commands.
167#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
168#[serde(default)]
169pub struct OrchestratorCommandsConfig {
170    /// Exact case-sensitive command names to allow. Empty means no allowlist restriction.
171    pub allow: Vec<String>,
172    /// Exact case-sensitive command names to deny. Deny wins over allow.
173    pub deny: Vec<String>,
174}
175
176impl Default for OrchestratorConfig {
177    fn default() -> Self {
178        Self {
179            session_deadline_secs: 3600,
180            inactivity_timeout_secs: 300,
181            compaction_threshold: 0.80,
182            commands: OrchestratorCommandsConfig::default(),
183        }
184    }
185}
186
187//
188// ─────────────────────────────────────────────────────────────────────────────
189// WEB RETRIEVAL CONFIG
190// ─────────────────────────────────────────────────────────────────────────────
191//
192
193/// Configuration for web-retrieval tools (`web_fetch`, `web_search`).
194#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
195#[serde(default)]
196pub struct WebRetrievalConfig {
197    /// HTTP request timeout in seconds (default: 30).
198    pub request_timeout_secs: u64,
199    /// Default maximum bytes to fetch (default: 5MB).
200    pub default_max_bytes: u64,
201    /// Default number of search results (default: 8).
202    pub default_search_results: u32,
203    /// Maximum number of search results allowed (default: 20).
204    pub max_search_results: u32,
205    /// Summarizer configuration for Haiku-based summarization.
206    pub summarizer: WebSummarizerConfig,
207}
208
209impl Default for WebRetrievalConfig {
210    fn default() -> Self {
211        Self {
212            request_timeout_secs: 30,
213            default_max_bytes: 5 * 1024 * 1024, // 5MB
214            default_search_results: 8,
215            max_search_results: 20,
216            summarizer: WebSummarizerConfig::default(),
217        }
218    }
219}
220
221/// Configuration for the web summarizer (Haiku).
222#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
223#[serde(default)]
224pub struct WebSummarizerConfig {
225    /// Model to use for summarization (default: claude-haiku-4-5).
226    pub model: String,
227    /// Maximum tokens for summary output (default: 300).
228    pub max_tokens: u32,
229    /// Temperature for summary generation (default: 0.2).
230    pub temperature: f64,
231}
232
233impl Default for WebSummarizerConfig {
234    fn default() -> Self {
235        Self {
236            model: "claude-haiku-4-5".into(),
237            max_tokens: 300,
238            temperature: 0.2,
239        }
240    }
241}
242
243//
244// ─────────────────────────────────────────────────────────────────────────────
245// CLI TOOLS CONFIG
246// ─────────────────────────────────────────────────────────────────────────────
247//
248
249/// Configuration for CLI tools (grep, glob, ls).
250#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
251#[serde(default)]
252pub struct CliToolsConfig {
253    /// Default page size for ls results (default: 100).
254    pub ls_page_size: u32,
255    /// Default `head_limit` for grep results (default: 200).
256    pub grep_default_limit: u32,
257    /// Default `head_limit` for glob results (default: 500).
258    pub glob_default_limit: u32,
259    /// Maximum directory traversal depth (default: 10).
260    pub max_depth: u32,
261    /// Pagination cache TTL in seconds (default: 300 = 5 minutes).
262    pub pagination_cache_ttl_secs: u64,
263    /// Additional ignore patterns to append to builtin ignores.
264    #[serde(default)]
265    pub extra_ignore_patterns: Vec<String>,
266}
267
268impl Default for CliToolsConfig {
269    fn default() -> Self {
270        Self {
271            ls_page_size: 100,
272            grep_default_limit: 200,
273            glob_default_limit: 500,
274            max_depth: 10,
275            pagination_cache_ttl_secs: 300,
276            extra_ignore_patterns: vec![],
277        }
278    }
279}
280
281//
282// ─────────────────────────────────────────────────────────────────────────────
283// SERVICES CONFIG
284// ─────────────────────────────────────────────────────────────────────────────
285//
286
287/// External service configurations.
288#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
289#[serde(default)]
290pub struct ServicesConfig {
291    /// Anthropic API configuration.
292    pub anthropic: AnthropicServiceConfig,
293    /// Exa search API configuration.
294    pub exa: ExaServiceConfig,
295}
296
297/// Anthropic API service configuration.
298#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
299#[serde(default)]
300pub struct AnthropicServiceConfig {
301    /// Base URL for the Anthropic API.
302    pub base_url: String,
303}
304
305impl Default for AnthropicServiceConfig {
306    fn default() -> Self {
307        Self {
308            base_url: "https://api.anthropic.com".into(),
309        }
310    }
311}
312
313/// Exa search API service configuration.
314#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
315#[serde(default)]
316pub struct ExaServiceConfig {
317    /// Base URL for the Exa API.
318    pub base_url: String,
319}
320
321impl Default for ExaServiceConfig {
322    fn default() -> Self {
323        Self {
324            base_url: "https://api.exa.ai".into(),
325        }
326    }
327}
328
329//
330// ─────────────────────────────────────────────────────────────────────────────
331// LOGGING CONFIG
332// ─────────────────────────────────────────────────────────────────────────────
333//
334
335/// Logging and diagnostics configuration.
336#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
337#[serde(default)]
338pub struct LoggingConfig {
339    /// Log level (trace, debug, info, warn, error).
340    pub level: String,
341
342    /// Whether to enable JSON-formatted logs.
343    pub json: bool,
344}
345
346impl Default for LoggingConfig {
347    fn default() -> Self {
348        Self {
349            level: "info".into(),
350            json: false,
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_default_config_serializes() {
361        let config = AgenticConfig::default();
362        let toml_str = toml::to_string_pretty(&config).unwrap();
363        assert!(toml_str.contains("[subagents]"));
364        assert!(toml_str.contains("[reasoning]"));
365        // Services sections serialize as [services.anthropic], [services.exa], etc.
366        assert!(toml_str.contains("[services.anthropic]"));
367        assert!(toml_str.contains("[services.exa]"));
368        assert!(toml_str.contains("[orchestrator]"));
369        assert!(toml_str.contains("[orchestrator.commands]"));
370        assert!(toml_str.contains("[web_retrieval]"));
371        assert!(toml_str.contains("[cli_tools]"));
372        assert!(toml_str.contains("[logging]"));
373        // Ensure old sections are NOT present
374        assert!(!toml_str.contains("[thoughts]"));
375        assert!(!toml_str.contains("[models]"));
376    }
377
378    #[test]
379    fn test_default_models_use_undated_names() {
380        let subagents = SubagentsConfig::default();
381        assert!(!subagents.locator_model.contains("20"));
382        assert!(!subagents.analyzer_model.contains("20"));
383
384        let reasoning = ReasoningConfig::default();
385        assert!(!reasoning.optimizer_model.contains("20"));
386        assert!(!reasoning.executor_model.contains("20"));
387    }
388
389    #[test]
390    fn test_partial_config_deserializes() {
391        let toml_str = r#"
392[subagents]
393locator_model = "custom-model"
394"#;
395        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
396        assert_eq!(config.subagents.locator_model, "custom-model");
397        // Other fields get defaults
398        assert_eq!(config.subagents.analyzer_model, "claude-sonnet-4-6");
399        assert_eq!(
400            config.services.anthropic.base_url,
401            "https://api.anthropic.com"
402        );
403        assert!(config.orchestrator.commands.allow.is_empty());
404        assert!(config.orchestrator.commands.deny.is_empty());
405    }
406
407    #[test]
408    fn test_orchestrator_commands_deserialize() {
409        let toml_str = r#"
410[orchestrator.commands]
411allow = ["plan", "research"]
412deny = ["commit"]
413"#;
414
415        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
416
417        assert_eq!(config.orchestrator.commands.allow, ["plan", "research"]);
418        assert_eq!(config.orchestrator.commands.deny, ["commit"]);
419    }
420
421    #[test]
422    fn test_schema_field_optional() {
423        let toml_str = r#""$schema" = "file://./agentic.schema.json""#;
424        let config: AgenticConfig = toml::from_str(toml_str).unwrap();
425        assert_eq!(config.schema, Some("file://./agentic.schema.json".into()));
426    }
427
428    // Default value assertion tests - ensure defaults match current hardcoded behavior
429    #[test]
430    fn test_web_retrieval_defaults_match_hardcoded() {
431        let cfg = WebRetrievalConfig::default();
432        assert_eq!(cfg.request_timeout_secs, 30);
433        assert_eq!(cfg.default_max_bytes, 5 * 1024 * 1024); // 5MB
434        assert_eq!(cfg.default_search_results, 8);
435        assert_eq!(cfg.max_search_results, 20);
436        assert_eq!(cfg.summarizer.model, "claude-haiku-4-5");
437        assert_eq!(cfg.summarizer.max_tokens, 300);
438        assert!((cfg.summarizer.temperature - 0.2).abs() < f64::EPSILON);
439    }
440
441    #[test]
442    fn test_cli_tools_defaults_match_hardcoded() {
443        let cfg = CliToolsConfig::default();
444        assert_eq!(cfg.ls_page_size, 100);
445        assert_eq!(cfg.grep_default_limit, 200);
446        assert_eq!(cfg.glob_default_limit, 500);
447        assert_eq!(cfg.max_depth, 10);
448        assert_eq!(cfg.pagination_cache_ttl_secs, 300);
449        assert!(cfg.extra_ignore_patterns.is_empty());
450    }
451
452    #[test]
453    fn test_orchestrator_defaults_match_hardcoded() {
454        let cfg = OrchestratorConfig::default();
455        assert_eq!(cfg.session_deadline_secs, 3600);
456        assert_eq!(cfg.inactivity_timeout_secs, 300);
457        assert!((cfg.compaction_threshold - 0.80).abs() < f64::EPSILON);
458        assert!(cfg.commands.allow.is_empty());
459        assert!(cfg.commands.deny.is_empty());
460    }
461
462    #[test]
463    fn test_services_defaults_match_hardcoded() {
464        let cfg = ServicesConfig::default();
465
466        // Anthropic
467        assert_eq!(cfg.anthropic.base_url, "https://api.anthropic.com");
468
469        // Exa
470        assert_eq!(cfg.exa.base_url, "https://api.exa.ai");
471    }
472}