Skip to main content

defect_config/
types.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::PathBuf;
4
5use defect_agent::error::BoxError;
6use defect_agent::session::{
7    BackgroundProgressConfig, SessionCapabilitiesConfig, TurnConfig, WebSearchCapabilityConfig,
8};
9use serde::{Deserialize, Serialize};
10use toml::Value as TomlValue;
11
12pub(crate) const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-5";
13pub(crate) const DEFAULT_OPENAI_MODEL: &str = "gpt-4o-mini";
14pub(crate) const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-chat";
15pub(crate) const DEFAULT_ECHO_MODEL: &str = "echo";
16pub(crate) const DEFAULT_BASH_TIMEOUT_MS: u64 = 30_000;
17pub(crate) const DEFAULT_BASH_MAX_TIMEOUT_MS: u64 = 600_000;
18pub(crate) const DEFAULT_BASH_OUTPUT_MAX_BYTES: usize = 1024 * 1024;
19pub(crate) const DEFAULT_FS_READ_LIMIT: u32 = 2_000;
20pub(crate) const DEFAULT_FS_READ_MAX_LIMIT: u32 = 5_000;
21
22pub(crate) const USER_CONFIG_RELATIVE: &str = "defect/config.toml";
23pub(crate) const PROJECT_CONFIG_RELATIVE: &str = ".defect/config.toml";
24pub(crate) const PROJECT_LOCAL_CONFIG_RELATIVE: &str = ".defect/config.local.toml";
25
26const PROVIDER_DEFECT: &str = "defect";
27const PROVIDER_ANTHROPIC: &str = "anthropic";
28const PROVIDER_OPENAI: &str = "openai";
29const PROVIDER_DEEPSEEK: &str = "deepseek";
30const PROVIDER_LITELLM: &str = "litellm";
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(from = "String", into = "String")]
34pub enum ProviderKind {
35    /// Built-in placeholder provider: echoes the user's most recent message back as-is
36    /// (model id `echo`).
37    /// Requires no external credentials; serves as the fallback default for
38    /// `default.provider`.
39    #[default]
40    Defect,
41    Anthropic,
42    Openai,
43    Deepseek,
44    Litellm,
45    Custom(String),
46}
47
48impl ProviderKind {
49    #[must_use]
50    pub fn as_str(&self) -> &str {
51        match self {
52            Self::Defect => PROVIDER_DEFECT,
53            Self::Anthropic => PROVIDER_ANTHROPIC,
54            Self::Openai => PROVIDER_OPENAI,
55            Self::Deepseek => PROVIDER_DEEPSEEK,
56            Self::Litellm => PROVIDER_LITELLM,
57            Self::Custom(value) => value,
58        }
59    }
60}
61
62impl fmt::Display for ProviderKind {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.write_str(self.as_str())
65    }
66}
67
68impl From<ProviderKind> for String {
69    fn from(value: ProviderKind) -> Self {
70        value.to_string()
71    }
72}
73
74impl From<String> for ProviderKind {
75    fn from(value: String) -> Self {
76        match value.as_str() {
77            PROVIDER_DEFECT => Self::Defect,
78            PROVIDER_ANTHROPIC => Self::Anthropic,
79            PROVIDER_OPENAI => Self::Openai,
80            PROVIDER_DEEPSEEK => Self::Deepseek,
81            PROVIDER_LITELLM => Self::Litellm,
82            _ => Self::Custom(value),
83        }
84    }
85}
86
87impl From<&str> for ProviderKind {
88    fn from(value: &str) -> Self {
89        match value {
90            PROVIDER_DEFECT => Self::Defect,
91            PROVIDER_ANTHROPIC => Self::Anthropic,
92            PROVIDER_OPENAI => Self::Openai,
93            PROVIDER_DEEPSEEK => Self::Deepseek,
94            PROVIDER_LITELLM => Self::Litellm,
95            other => Self::Custom(other.to_string()),
96        }
97    }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum ConfigSource {
102    Defaults,
103    User,
104    Project,
105    ProjectLocal,
106    Cli,
107}
108
109#[derive(Debug, Clone, PartialEq)]
110pub struct ConfigLayerEntry {
111    pub source: ConfigSource,
112    pub path: Option<PathBuf>,
113    pub raw_toml: Option<String>,
114    pub value: TomlValue,
115}
116
117#[derive(Debug, Clone, Default, PartialEq)]
118pub struct ConfigLayerStack {
119    pub layers: Vec<ConfigLayerEntry>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum ConfigWarning {
124    DeprecatedKey {
125        path: PathBuf,
126        old: String,
127        new: String,
128    },
129    /// A configuration section exists in the file but is inactive under the current mode.
130    ///
131    /// See capabilities semantic mapping.
132    /// Typical scenario: a configuration section is written but the corresponding
133    /// capability is disabled (e.g., the legacy `capabilities.search` section has been
134    /// deprecated in favor of `[capabilities.web_search]`, and the old key no longer
135    /// takes effect).
136    InactiveSection {
137        path: PathBuf,
138        section: String,
139        reason: String,
140    },
141    /// Conflicting MCP tools are renamed to `mcp__<server>__<name>` during session
142    /// startup.
143    ///
144    /// See capabilities for MCP tool classification. All MCP tools are renamed
145    /// (regardless of capability mode or tool enabled) to prevent MCP bypass name
146    /// squatting.
147    McpToolRenamed {
148        server: String,
149        original: String,
150        renamed: String,
151    },
152    /// A `.mcp.json` server was shadowed by a same-named TOML `[mcp.servers.<name>]`
153    /// entry. The TOML entry (more explicit, layered source) wins; the `.mcp.json`
154    /// definition is ignored.
155    McpJsonOverridden { path: PathBuf, server: String },
156}
157
158#[non_exhaustive]
159#[derive(Debug, thiserror::Error)]
160pub enum ConfigError {
161    #[error("failed to read config file {path}: {source}")]
162    Io {
163        path: PathBuf,
164        #[source]
165        source: BoxError,
166    },
167
168    #[error("failed to parse config file {path}: {source}")]
169    Parse {
170        path: PathBuf,
171        #[source]
172        source: BoxError,
173    },
174
175    #[error("invalid config at {path}: {message}")]
176    Invalid { path: PathBuf, message: String },
177
178    #[error(transparent)]
179    Source(#[from] BoxError),
180}
181
182#[derive(Debug, Clone, Default)]
183pub struct CliOverrides {
184    pub provider: Option<ProviderKind>,
185    pub model: Option<String>,
186    pub sandbox: Option<SandboxMode>,
187    pub config_overrides: Vec<(String, TomlValue)>,
188}
189
190#[derive(Debug, Clone, Default)]
191pub struct LoadConfigOptions {
192    pub cwd: PathBuf,
193    pub cli: CliOverrides,
194    pub xdg_config_home: Option<PathBuf>,
195    pub home_dir: Option<PathBuf>,
196    /// `--local` sandbox mode: ignores global/user-level config and user-level
197    /// agents/skills directories,
198    /// only uses the project root `.defect/`. When `true`, user layers are always absent
199    /// (see
200    /// `resolve_user_config_path` / `resolve_user_agents_dir` /
201    /// `resolve_user_skills_dir`).
202    pub local: bool,
203}
204
205#[derive(Debug, Clone)]
206pub struct LoadedConfig {
207    pub layers: ConfigLayerStack,
208    pub effective: EffectiveConfig,
209    pub warnings: Vec<ConfigWarning>,
210}
211
212#[derive(Debug, Clone)]
213pub struct EffectiveConfig {
214    pub cli: CliConfig,
215    pub turn: TurnConfig,
216    pub base_prompt: BasePromptConfigFile,
217    pub prompt: PromptConfigFile,
218    /// Global capability source selection. Overridden by `providers.<p>.capabilities`
219    /// during session startup.
220    pub capabilities: CapabilitiesConfig,
221    pub providers: ProviderConfigs,
222    pub tools: ToolsConfig,
223    pub sandbox: SandboxConfig,
224    pub tracing: TracingConfig,
225    pub mcp: McpConfig,
226    pub http: HttpClientConfig,
227    /// Resolved hook configuration.
228    pub hooks: HooksConfig,
229}
230
231// Hooks
232
233/// Valid configuration for the hook system: pipelines are grouped by step `event_name`
234/// and executed in declaration order within each group.
235///
236/// Bucket keys are the mount point's `event_name` (snake_case, e.g. `before_turn_end`) —
237/// the same set of names as `defect_agent::hooks::step::ALL_EVENT_NAMES`. A map is used
238/// instead of fixed fields so that adding a new mount point requires no changes at the
239/// config layer.
240#[derive(Debug, Clone, Default, PartialEq)]
241pub struct HooksConfig {
242    /// `event_name` → entries declared under that event, in declaration order.
243    pub buckets: std::collections::BTreeMap<String, Vec<HookEntry>>,
244}
245
246impl HooksConfig {
247    /// Whether any hooks have been declared on this config. When `false`, CLI assembly
248    /// can use the noop engine directly.
249    pub fn is_empty(&self) -> bool {
250        self.buckets.values().all(Vec::is_empty)
251    }
252
253    /// Returns the entries for a given event, or an empty slice if none exist.
254    pub fn get(&self, event_name: &str) -> &[HookEntry] {
255        self.buckets
256            .get(event_name)
257            .map(Vec::as_slice)
258            .unwrap_or(&[])
259    }
260
261    /// Appends a hook entry under the given event name.
262    pub fn push(&mut self, event_name: impl Into<String>, entry: HookEntry) {
263        self.buckets
264            .entry(event_name.into())
265            .or_default()
266            .push(entry);
267    }
268}
269
270/// A single hook configuration: matcher + handler + source layer.
271#[derive(Debug, Clone, PartialEq)]
272pub struct HookEntry {
273    /// Optional human-readable name, used only for tracing/observability to identify this
274    /// hook.
275    /// `None` ⇒ falls back to an anonymous label at assembly time (see `defect-cli`'s
276    /// hook assembly).
277    /// Does not participate in deduplication or disable matching (that only uses matcher
278    /// + handler) — purely for display.
279    pub name: Option<String>,
280    pub matcher: HookMatcher,
281    pub handler: HookHandlerSpec,
282    /// The source layer of this hook. Phase G's trust gating uses it to decide whether
283    /// explicit trust is required.
284    pub source: ConfigSource,
285}
286
287/// Event matcher. Empty fields match all triggers under that event; see the hooks trust
288/// model.
289#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
290pub struct HookMatcher {
291    /// Exact tool name match (only `*ToolUse*` events).
292    pub tool: Option<String>,
293    /// Tool name glob match (only `*ToolUse*` events).
294    pub tool_glob: Option<String>,
295    /// `SafetyClass` filter (only for `PreToolUse`); any match triggers. Empty vec = no
296    /// filtering.
297    pub safety: Vec<defect_agent::tool::SafetyClass>,
298}
299
300/// Handler spec — three variants.
301#[non_exhaustive]
302#[derive(Debug, Clone, PartialEq, Eq, Hash)]
303pub enum HookHandlerSpec {
304    /// In-process Rust handler, referenced by name via
305    /// `crate::hooks::builtin::registry()`.
306    Builtin { name: String },
307    /// External command (see hooks command handler).
308    Command(HookCommandSpec),
309    /// Calls an LLM (see hooks prompt handler).
310    Prompt(HookPromptSpec),
311}
312
313#[non_exhaustive]
314#[derive(Debug, Clone, PartialEq, Eq, Hash)]
315pub enum HookCommandSpec {
316    /// Spawn `argv` directly, without any shell.
317    Argv {
318        argv: Vec<String>,
319        /// Windows override; `None` falls back to `argv`.
320        argv_windows: Option<Vec<String>>,
321        cwd: Option<PathBuf>,
322        env: BTreeMap<String, String>,
323        timeout_sec: Option<u64>,
324    },
325    /// Explicit shell. The `shell` field is required; the engine no longer auto-selects
326    /// `sh`.
327    Shell {
328        shell: HookShellKind,
329        command: String,
330        cwd: Option<PathBuf>,
331        env: BTreeMap<String, String>,
332        timeout_sec: Option<u64>,
333    },
334}
335
336#[non_exhaustive]
337#[derive(Debug, Clone, PartialEq, Eq, Hash)]
338pub enum HookShellKind {
339    Sh,
340    Bash,
341    Pwsh,
342    Cmd,
343    /// `program` plus passthrough `args` (excluding the command itself).
344    Custom {
345        program: String,
346        args: Vec<String>,
347    },
348}
349
350#[non_exhaustive]
351#[derive(Debug, Clone, PartialEq, Eq, Hash)]
352pub struct HookPromptSpec {
353    /// `None` = use the session's default model.
354    pub model: Option<String>,
355    pub system: String,
356    pub render: HookPromptRender,
357    pub timeout_sec: Option<u64>,
358}
359
360impl HookPromptSpec {
361    /// Cross-crate construction entry point — struct literals are unavailable after
362    /// `#[non_exhaustive]`.
363    #[must_use]
364    pub fn new(
365        model: Option<String>,
366        system: String,
367        render: HookPromptRender,
368        timeout_sec: Option<u64>,
369    ) -> Self {
370        Self {
371            model,
372            system,
373            render,
374            timeout_sec,
375        }
376    }
377}
378
379#[non_exhaustive]
380#[derive(Debug, Clone, PartialEq, Eq, Hash)]
381pub enum HookPromptRender {
382    /// Feed the JSON-serialized `HookEvent` directly.
383    Json,
384    /// Renders a handlebars template using fields from the event.
385    Template { template: String },
386}
387
388/// Top-level capability configuration entry point.
389///
390/// Structurally equivalent to [`SessionCapabilitiesConfig`]; a separate type on
391/// `EffectiveConfig` is kept so that future non-session-level capabilities can be
392/// added without touching the agent crate. Currently P1 only has `web_search`
393/// (hosted-only); local grep/glob tools are not part of the capability layer and
394/// are managed separately by `[tools.search]`.
395#[non_exhaustive]
396#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
397pub struct CapabilitiesConfig {
398    pub web_search: WebSearchCapabilityConfig,
399}
400
401impl CapabilitiesConfig {
402    /// Construct with a single [`WebSearchCapabilityConfig`]. Cross-crate callers need
403    /// this entry point because the struct is `#[non_exhaustive]` and cannot be built
404    /// with a struct literal directly.
405    #[must_use]
406    pub const fn with_web_search(web_search: WebSearchCapabilityConfig) -> Self {
407        Self { web_search }
408    }
409
410    /// Converts to the agent-side [`SessionCapabilitiesConfig`], for direct consumption
411    /// by
412    /// `DefaultAgentCoreBuilder::capabilities`.
413    #[must_use]
414    pub fn to_session_capabilities(self) -> SessionCapabilitiesConfig {
415        SessionCapabilitiesConfig::with_web_search(self.web_search)
416    }
417}
418
419/// Overrides for global capabilities under a single provider.
420///
421/// A `None` field means "follow the global setting".
422#[non_exhaustive]
423#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
424pub struct ProviderCapabilityOverrides {
425    pub web_search: Option<WebSearchCapabilityConfig>,
426}
427
428impl ProviderCapabilityOverrides {
429    /// Construct with a single `web_search` override. `None` means follow the global
430    /// setting.
431    #[must_use]
432    pub const fn with_web_search(web_search: Option<WebSearchCapabilityConfig>) -> Self {
433        Self { web_search }
434    }
435
436    /// Merges the global [`CapabilitiesConfig`] with this provider's overrides.
437    /// Unset fields fall back to the global values.
438    #[must_use]
439    pub fn merge_into(&self, base: CapabilitiesConfig) -> CapabilitiesConfig {
440        CapabilitiesConfig::with_web_search(self.web_search.unwrap_or(base.web_search))
441    }
442}
443
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct CliConfig {
446    pub provider: ProviderKind,
447    pub model: String,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Default)]
451pub struct BasePromptConfigFile {
452    pub file: Option<PathBuf>,
453    pub text: Option<String>,
454}
455
456#[derive(Debug, Clone, PartialEq, Eq, Default)]
457pub struct PromptConfigFile {
458    pub file: String,
459    pub text: Option<String>,
460    pub provider_overlays: BTreeMap<String, String>,
461    pub model_overlays: BTreeMap<String, String>,
462}
463
464#[derive(Debug, Clone, PartialEq, Default)]
465pub struct ProviderConfigs {
466    pub anthropic: ProviderConfigFile,
467    pub openai: ProviderConfigFile,
468    pub deepseek: ProviderConfigFile,
469    pub litellm: ProviderConfigFile,
470    pub custom: BTreeMap<String, ProviderConfigFile>,
471}
472
473impl ProviderConfigs {
474    #[must_use]
475    pub fn get(&self, provider: &ProviderKind) -> Option<&ProviderConfigFile> {
476        match provider {
477            ProviderKind::Defect => None,
478            ProviderKind::Anthropic => Some(&self.anthropic),
479            ProviderKind::Openai => Some(&self.openai),
480            ProviderKind::Deepseek => Some(&self.deepseek),
481            ProviderKind::Litellm => Some(&self.litellm),
482            ProviderKind::Custom(name) => self.custom.get(name),
483        }
484    }
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Default)]
488pub struct ToolsConfig {
489    pub bash: BashToolConfig,
490    pub fs: FsToolConfig,
491    /// The `[tools.fetch]` section. Reserved — P1 only lands the schema; the tool
492    /// implementation will follow in a later PR.
493    pub fetch: FetchToolConfig,
494    /// The `[tools.search]` section. Parameters for the local `search` tool (grep/glob).
495    /// Independent of `[capabilities.web_search]`; registration is controlled separately
496    /// by `enabled`.
497    /// See search tool config.
498    pub search: SearchToolConfig,
499    /// `[tools.background]` section. Configuration for the background subagent progress
500    /// view (progress ring capacity / per-block text character limit) — the "last N
501    /// blocks" that the main agent sees via `inspect_background_task`. The source of
502    /// truth is on the agent side ([`BackgroundProgressConfig`]); this is a direct reuse.
503    pub background: BackgroundProgressConfig,
504}
505
506/// Configuration for the local fetch tool.
507#[non_exhaustive]
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct FetchToolConfig {
510    pub enabled: bool,
511    pub default_timeout_secs: u32,
512    pub max_timeout_secs: u32,
513    pub max_response_bytes: u64,
514    pub default_format: FetchFormat,
515    pub html_to_markdown: bool,
516    pub follow_redirects: bool,
517}
518
519impl Default for FetchToolConfig {
520    fn default() -> Self {
521        Self {
522            enabled: true,
523            default_timeout_secs: 30,
524            max_timeout_secs: 120,
525            max_response_bytes: 5 * 1024 * 1024,
526            default_format: FetchFormat::Markdown,
527            html_to_markdown: true,
528            follow_redirects: true,
529        }
530    }
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
534#[serde(rename_all = "snake_case")]
535pub enum FetchFormat {
536    #[default]
537    Markdown,
538    Html,
539    Text,
540}
541
542#[derive(Debug, Clone, PartialEq, Eq)]
543pub struct BashToolConfig {
544    pub default_timeout_ms: u64,
545    pub max_timeout_ms: u64,
546    /// Maximum bytes of merged stdout/stderr captured per command; output beyond this is
547    /// dropped and reported as truncated. Applies to the local shell backend.
548    pub output_max_bytes: usize,
549}
550
551impl Default for BashToolConfig {
552    fn default() -> Self {
553        Self {
554            default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
555            max_timeout_ms: DEFAULT_BASH_MAX_TIMEOUT_MS,
556            output_max_bytes: DEFAULT_BASH_OUTPUT_MAX_BYTES,
557        }
558    }
559}
560
561#[derive(Debug, Clone, PartialEq, Eq)]
562pub struct FsToolConfig {
563    pub read_default_limit: u32,
564    pub read_max_limit: u32,
565}
566
567impl Default for FsToolConfig {
568    fn default() -> Self {
569        Self {
570            read_default_limit: DEFAULT_FS_READ_LIMIT,
571            read_max_limit: DEFAULT_FS_READ_MAX_LIMIT,
572        }
573    }
574}
575
576/// Configuration for the local search tool.
577#[non_exhaustive]
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub struct SearchToolConfig {
580    pub enabled: bool,
581    pub default_head_limit: u32,
582    pub max_head_limit: u32,
583    pub max_file_size_bytes: u64,
584    pub max_result_bytes: u64,
585    pub max_walk_files: u64,
586    pub respect_gitignore_default: bool,
587}
588
589impl Default for SearchToolConfig {
590    fn default() -> Self {
591        Self {
592            enabled: true,
593            default_head_limit: 100,
594            max_head_limit: 1000,
595            max_file_size_bytes: 16 * 1024 * 1024,
596            max_result_bytes: 256 * 1024,
597            max_walk_files: 100_000,
598            respect_gitignore_default: true,
599        }
600    }
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct SandboxConfig {
605    pub mode: SandboxMode,
606}
607
608impl Default for SandboxConfig {
609    fn default() -> Self {
610        Self {
611            mode: SandboxMode::AskWrites,
612        }
613    }
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
617#[serde(rename_all = "kebab-case")]
618pub enum SandboxMode {
619    ReadOnly,
620    #[default]
621    AskWrites,
622    Open,
623    DenyAll,
624}
625
626impl SandboxMode {
627    pub fn as_str(self) -> &'static str {
628        match self {
629            Self::ReadOnly => "read-only",
630            Self::AskWrites => "ask-writes",
631            Self::Open => "open",
632            Self::DenyAll => "deny-all",
633        }
634    }
635}
636
637pub type AnthropicConfigFile = ProviderConfigFile;
638pub type OpenAiConfigFile = ProviderConfigFile;
639pub type DeepSeekConfigFile = ProviderConfigFile;
640pub type LiteLlmConfigFile = ProviderConfigFile;
641
642#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
643#[serde(rename_all = "kebab-case")]
644pub enum ProviderProtocol {
645    AnthropicMessages,
646    OpenaiChat,
647}
648
649/// A model candidate configuration declaration.
650///
651/// TOML supports two forms (accepted via [`Deserialize`] with `untagged`):
652/// - Plain string `"gpt-5.5"`: only the id is given; the UI display name falls back to
653///   the id.
654/// - Table `{ id = "...", name = "Opus 4.8" }`: pairs a long id with a short display
655///   name.
656///
657/// `name` is mapped to [`defect_agent::llm::ModelInfo::display_name`]; the ACP uses it as
658/// the label for model selector options. When `None`, the wire layer falls back to the
659/// id.
660///
661/// `context_window` / `max_output_tokens` let the user declare model metadata that the
662/// provider cannot discover at runtime — most importantly for **Bedrock**, whose SDK does
663/// not return model limits, so without this the compaction watermarks have no window to key
664/// off and the context can grow unbounded. Both are optional; when absent the provider's
665/// own value (if any) or the compaction fallback applies.
666#[derive(Debug, Clone, PartialEq, Deserialize)]
667#[serde(untagged)]
668pub enum ModelEntry {
669    /// A plain ID (legacy format).
670    Id(String),
671    /// A detailed entry with a display name and optional limits.
672    Detailed {
673        id: String,
674        #[serde(default)]
675        name: Option<String>,
676        #[serde(default)]
677        context_window: Option<u64>,
678        #[serde(default)]
679        max_output_tokens: Option<u64>,
680    },
681}
682
683impl ModelEntry {
684    /// The model ID (present in both variants).
685    #[must_use]
686    pub fn id(&self) -> &str {
687        match self {
688            Self::Id(id) => id,
689            Self::Detailed { id, .. } => id,
690        }
691    }
692
693    /// Optional display name (only available in table form).
694    #[must_use]
695    pub fn name(&self) -> Option<&str> {
696        match self {
697            Self::Id(_) => None,
698            Self::Detailed { name, .. } => name.as_deref(),
699        }
700    }
701
702    /// Optional context-window size (tokens), only from the table form.
703    #[must_use]
704    pub fn context_window(&self) -> Option<u64> {
705        match self {
706            Self::Id(_) => None,
707            Self::Detailed { context_window, .. } => *context_window,
708        }
709    }
710
711    /// Optional max output tokens, only from the table form.
712    #[must_use]
713    pub fn max_output_tokens(&self) -> Option<u64> {
714        match self {
715            Self::Id(_) => None,
716            Self::Detailed {
717                max_output_tokens, ..
718            } => *max_output_tokens,
719        }
720    }
721}
722
723#[derive(Debug, Clone, PartialEq, Default)]
724pub struct ProviderConfigFile {
725    pub protocol: Option<ProviderProtocol>,
726    pub base_url: Option<String>,
727    pub default_model: Option<String>,
728    pub models: Option<Vec<ModelEntry>>,
729    pub display_name: Option<String>,
730    pub api_key_env: Option<String>,
731    pub organization: Option<String>,
732    pub project: Option<String>,
733    pub aws: Option<ProviderAwsConfigFile>,
734    pub headers: BTreeMap<String, String>,
735    pub capabilities: ProviderCapabilityOverrides,
736    /// `reasoning_effort` wire parameter. `None` = do not send, use provider default.
737    pub reasoning_effort: Option<ReasoningEffort>,
738}
739
740#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
741#[serde(deny_unknown_fields)]
742pub struct ProviderAwsConfigFile {
743    pub profile: Option<String>,
744    pub region: Option<String>,
745    /// `anthropic_beta` flags injected into the Bedrock request body (Anthropic Messages
746    /// `anthropic_beta` array). Default (absent) sends nothing. Some newer models (e.g. Opus
747    /// 4.8) reject the default data retention mode and require `["no-data-retention-v1"]`;
748    /// set it explicitly. The flag is shared by every model under this provider, and Bedrock
749    /// 400s a model that does not support a given flag — so only enable it on a
750    /// provider whose models all accept the flag.
751    pub anthropic_beta: Option<Vec<String>>,
752}
753
754/// Values for the `reasoning_effort` parameter in the OpenAI-compatible protocol.
755///
756/// 1:1 mapping with the official OpenAI wire enum: `xhigh` is only supported after
757/// `gpt-5.1-codex-max`, and `none` is only supported after `gpt-5.1`. The configuration
758/// layer does not distinguish between models; values are passed through as-is and
759/// validated upstream.
760#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
761#[serde(rename_all = "snake_case")]
762pub enum ReasoningEffort {
763    None,
764    Minimal,
765    Low,
766    Medium,
767    High,
768    Xhigh,
769}
770
771/// Typed configuration for the HTTP client stack.
772///
773/// Only captures user intent (`None` always means "use the HTTP stack layer default");
774/// the CLI entry point converts it to `defect_http::HttpStackConfig` when assembling the
775/// provider.
776///
777/// Not sharing a type directly with `defect_http::HttpStackConfig` preserves a one-way
778/// crate dependency: `defect-config` does not depend on `defect-http`, preventing
779/// downstream consumers like fetch tool from creating a reverse dependency.
780#[derive(Debug, Clone, PartialEq, Eq, Default)]
781pub struct HttpClientConfig {
782    /// Total timeout for a single request; `None` means use the HTTP stack default
783    /// (600s).
784    pub total_timeout_ms: Option<u64>,
785    /// Maximum number of transport error retries (excluding the first attempt); `None`
786    /// means default 2, `Some(0)` disables retries.
787    pub transport_retries: Option<u8>,
788    /// Initial backoff for retries; `None` = default 200ms.
789    pub initial_backoff_ms: Option<u64>,
790    /// Override the `User-Agent` header; `None` uses the compile-time default
791    /// `defect-http/{version} ({git_sha})`.
792    pub user_agent: Option<String>,
793    /// Proxy sub-configuration. `mode` defaults to `FromEnv` (reads `HTTP_PROXY` etc.).
794    pub proxy: HttpProxyConfig,
795}
796
797#[derive(Debug, Clone, PartialEq, Eq)]
798pub struct HttpProxyConfig {
799    pub mode: HttpProxyMode,
800    /// Explicit proxy; only effective when `mode = Explicit`.
801    pub explicit: HttpProxySettings,
802}
803
804impl Default for HttpProxyConfig {
805    fn default() -> Self {
806        Self {
807            mode: HttpProxyMode::FromEnv,
808            explicit: HttpProxySettings::default(),
809        }
810    }
811}
812
813#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
814#[serde(rename_all = "kebab-case")]
815pub enum HttpProxyMode {
816    #[default]
817    FromEnv,
818    Disabled,
819    Explicit,
820}
821
822impl HttpProxyMode {
823    pub fn as_str(self) -> &'static str {
824        match self {
825            Self::FromEnv => "from-env",
826            Self::Disabled => "disabled",
827            Self::Explicit => "explicit",
828        }
829    }
830}
831
832#[derive(Debug, Clone, PartialEq, Eq, Default)]
833pub struct HttpProxySettings {
834    pub http_proxy: Option<String>,
835    pub https_proxy: Option<String>,
836    pub no_proxy: Vec<String>,
837}
838
839#[derive(Debug, Clone, PartialEq, Default)]
840pub struct TracingConfig {
841    pub filter: Option<String>,
842    pub otlp: Option<OtlpTracingConfig>,
843    pub langfuse: Option<LangfuseConfig>,
844}
845
846#[derive(Debug, Clone, PartialEq, Default)]
847pub struct OtlpTracingConfig {
848    pub endpoint: Option<String>,
849}
850
851/// Langfuse upload configuration.
852///
853/// Disabled by default; if `enabled = true` but keys are missing, the assembly layer
854/// warns and disables it (no silent success).
855#[derive(Debug, Clone, PartialEq, Eq, Default)]
856pub struct LangfuseConfig {
857    pub enabled: bool,
858    /// Langfuse host, e.g. `https://cloud.langfuse.com`. `None` uses the assembly layer
859    /// default.
860    pub host: Option<String>,
861    pub public_key: Option<String>,
862    pub secret_key: Option<String>,
863    /// Flush interval in milliseconds. `None` uses the assembly layer default.
864    pub flush_interval_ms: Option<u64>,
865    /// Maximum number of events per batch. `None` uses the assembly layer default.
866    pub max_batch: Option<usize>,
867}
868
869#[derive(Debug, Clone, PartialEq, Eq, Default)]
870pub struct McpConfig {
871    pub enabled_servers: Vec<String>,
872    pub servers: BTreeMap<String, McpServerConfig>,
873}
874
875#[derive(Debug, Clone, PartialEq, Eq)]
876pub enum McpServerConfig {
877    Stdio(McpStdioServerConfig),
878    Http(McpRemoteServerConfig),
879    Sse(McpRemoteServerConfig),
880}
881
882#[derive(Debug, Clone, PartialEq, Eq)]
883pub struct McpStdioServerConfig {
884    pub command: String,
885    pub args: Vec<String>,
886    pub env: BTreeMap<String, String>,
887}
888
889#[derive(Debug, Clone, PartialEq, Eq)]
890pub struct McpRemoteServerConfig {
891    pub url: String,
892    pub headers: BTreeMap<String, String>,
893}
894
895#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
896#[serde(rename_all = "snake_case")]
897pub(crate) enum McpTransportKind {
898    Stdio,
899    Http,
900    Sse,
901}
902
903#[derive(Debug, Clone, Default, Deserialize)]
904#[serde(deny_unknown_fields)]
905pub(crate) struct ConfigToml {
906    #[serde(default)]
907    pub(crate) default: DefaultSection,
908    #[serde(default)]
909    pub(crate) base_prompt: BasePromptSection,
910    #[serde(default)]
911    pub(crate) prompt: PromptSection,
912    #[serde(default)]
913    pub(crate) turn: TurnSection,
914    #[serde(default)]
915    pub(crate) capabilities: CapabilitiesSection,
916    #[serde(default)]
917    pub(crate) providers: ProvidersSection,
918    #[serde(default)]
919    pub(crate) tools: ToolsSection,
920    #[serde(default)]
921    pub(crate) sandbox: SandboxSection,
922    #[serde(default)]
923    pub(crate) tracing: TracingSection,
924    #[serde(default)]
925    pub(crate) mcp: McpSection,
926    #[serde(default)]
927    pub(crate) http: HttpSection,
928    /// The `[hooks]` section is not processed by `ConfigToml::try_into` (its array
929    /// semantics are append+dedupe; see `crate::hooks`). We absorb it here as
930    /// `toml::Value` to prevent `deny_unknown_fields` from misidentifying `[[hooks.*]]`
931    /// as an unknown section; hooks' own parser performs schema validation.
932    #[serde(default)]
933    #[allow(dead_code)]
934    pub(crate) hooks: Option<TomlValue>,
935}
936
937#[derive(Debug, Clone, Default, Deserialize)]
938#[serde(deny_unknown_fields)]
939pub(crate) struct CapabilitiesSection {
940    pub(crate) web_search: Option<WebSearchCapabilitySection>,
941}
942
943#[derive(Debug, Clone, Default, Deserialize)]
944#[serde(deny_unknown_fields)]
945pub(crate) struct WebSearchCapabilitySection {
946    pub(crate) mode: Option<defect_agent::session::WebSearchCapabilityMode>,
947}
948
949#[derive(Debug, Clone, Default, Deserialize)]
950#[serde(deny_unknown_fields)]
951pub(crate) struct ProviderCapabilitiesSection {
952    pub(crate) web_search: Option<WebSearchCapabilitySection>,
953}
954
955#[derive(Debug, Clone, Default, Deserialize)]
956#[serde(deny_unknown_fields)]
957pub(crate) struct DefaultSection {
958    pub(crate) provider: Option<ProviderKind>,
959    pub(crate) model: Option<String>,
960}
961
962#[derive(Debug, Clone, Default, Deserialize)]
963#[serde(deny_unknown_fields)]
964pub(crate) struct BasePromptSection {
965    pub(crate) file: Option<String>,
966    pub(crate) text: Option<String>,
967}
968
969/// How `[turn] request_limit` is interpreted. The numeric `request_limit` supplies `N`;
970/// this key selects the strategy. Defaults to `Adaptive` when omitted, so a bare
971/// `request_limit = N` keeps its historical self-expanding behavior.
972#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
973#[serde(rename_all = "snake_case")]
974pub(crate) enum RequestLimitMode {
975    /// Hard cap at `N` LLM calls; never expands.
976    Fixed,
977    /// Start at `N`; each executed tool raises the cap by one (the default).
978    Adaptive,
979    /// No cap at all; `N` is ignored.
980    Unbounded,
981}
982
983#[derive(Debug, Clone, Default, Deserialize)]
984#[serde(deny_unknown_fields)]
985pub(crate) struct TurnSection {
986    pub(crate) system_prompt: Option<String>,
987    /// `[turn.sampling]` — per-call generation parameters for the **main agent**
988    /// (`max_tokens` / `temperature` / `top_p` / `top_k`). Mirrors the subagent profile's
989    /// `[sampling]` section. Omitted fields fall back to the provider default; in
990    /// particular, a missing `max_tokens` lets the Anthropic protocol layer apply its own
991    /// fallback. `reasoning_effort` is *not* here — it is a provider-level concern wired in
992    /// `cli/providers.rs` and switchable per-session via ACP.
993    pub(crate) sampling: Option<SamplingSection>,
994    pub(crate) request_limit: Option<u32>,
995    /// Strategy for `request_limit`. `None` ⇒ `Adaptive` (back-compatible).
996    pub(crate) request_limit_mode: Option<RequestLimitMode>,
997    pub(crate) compact_threshold_tokens: Option<u64>,
998    pub(crate) compact_ratio: Option<f64>,
999    /// Enables background full compaction (asynchronous summarization when the soft
1000    /// watermark is exceeded, without blocking the current turn).
1001    pub(crate) background_compact_enabled: Option<bool>,
1002    /// Background compaction soft watermark as a fraction of `context_window` (default
1003    /// 0.7).
1004    pub(crate) compact_soft_ratio: Option<f64>,
1005    /// Enables micro-compaction: cleans oversized `tool_result` entries from older turns
1006    /// without invoking the LLM.
1007    pub(crate) microcompact_enabled: Option<bool>,
1008    /// Micro‑compact watermark as a fraction of `context_window` (default 0.6).
1009    pub(crate) microcompact_ratio: Option<f64>,
1010    pub(crate) max_llm_retries: Option<u32>,
1011    pub(crate) max_concurrent_tools: Option<usize>,
1012    /// Hard upper limit on forced continues from the `before turn-end` hook. `None` ⇒ use
1013    /// the agent-side default (3).
1014    pub(crate) max_hook_continues: Option<u32>,
1015    /// Maximum subagent vertical recursion depth. `None` ⇒ use the agent-side default
1016    /// (4). `0` ⇒ disallow dispatching any subagent (the top-level tool set does not
1017    /// contain `spawn_agent`).
1018    pub(crate) subagent_max_depth: Option<u32>,
1019}
1020
1021/// `[turn.sampling]` — main-agent generation parameters. Each field is independently
1022/// optional and overrides the corresponding [`SamplingParams`](defect_agent::llm::SamplingParams)
1023/// default only when present. Mirrors the subagent profile's `[sampling]` section.
1024#[derive(Debug, Clone, Default, Deserialize)]
1025#[serde(deny_unknown_fields)]
1026pub(crate) struct SamplingSection {
1027    /// Maximum tokens the model may generate in a single response. Omitted ⇒ the protocol
1028    /// layer's own fallback applies (e.g. Anthropic's `DEFAULT_MAX_TOKENS`).
1029    pub(crate) max_tokens: Option<u32>,
1030    pub(crate) temperature: Option<f32>,
1031    pub(crate) top_p: Option<f32>,
1032    pub(crate) top_k: Option<u32>,
1033}
1034
1035#[derive(Debug, Clone, Default, Deserialize)]
1036#[serde(deny_unknown_fields)]
1037pub(crate) struct PromptSection {
1038    pub(crate) file: Option<String>,
1039    pub(crate) text: Option<String>,
1040    pub(crate) providers: Option<BTreeMap<String, PromptOverlaySection>>,
1041    pub(crate) models: Option<BTreeMap<String, String>>,
1042}
1043
1044#[derive(Debug, Clone, Default, Deserialize)]
1045#[serde(deny_unknown_fields)]
1046pub(crate) struct PromptOverlaySection {
1047    pub(crate) text: Option<String>,
1048}
1049
1050#[derive(Debug, Clone, Default, Deserialize)]
1051pub(crate) struct ProvidersSection {
1052    pub(crate) anthropic: Option<AnthropicProviderSection>,
1053    pub(crate) openai: Option<OpenAiProviderSection>,
1054    pub(crate) deepseek: Option<DeepSeekProviderSection>,
1055    pub(crate) litellm: Option<LiteLlmProviderSection>,
1056    #[serde(flatten)]
1057    pub(crate) custom: BTreeMap<String, ProviderSection>,
1058}
1059
1060pub(crate) type AnthropicProviderSection = ProviderSection;
1061pub(crate) type OpenAiProviderSection = ProviderSection;
1062pub(crate) type DeepSeekProviderSection = ProviderSection;
1063pub(crate) type LiteLlmProviderSection = ProviderSection;
1064
1065#[derive(Debug, Clone, Default, Deserialize)]
1066#[serde(deny_unknown_fields)]
1067pub(crate) struct ProviderSection {
1068    pub(crate) protocol: Option<ProviderProtocol>,
1069    pub(crate) base_url: Option<String>,
1070    pub(crate) default_model: Option<String>,
1071    pub(crate) models: Option<Vec<ModelEntry>>,
1072    pub(crate) display_name: Option<String>,
1073    pub(crate) api_key_env: Option<String>,
1074    pub(crate) organization: Option<String>,
1075    pub(crate) project: Option<String>,
1076    pub(crate) aws: Option<ProviderAwsConfigFile>,
1077    pub(crate) headers: Option<BTreeMap<String, String>>,
1078    pub(crate) capabilities: Option<ProviderCapabilitiesSection>,
1079    pub(crate) reasoning_effort: Option<ReasoningEffort>,
1080}
1081
1082#[derive(Debug, Clone, Default, Deserialize)]
1083#[serde(deny_unknown_fields)]
1084pub(crate) struct ToolsSection {
1085    pub(crate) bash: Option<BashToolSection>,
1086    pub(crate) fs: Option<FsToolSection>,
1087    pub(crate) fetch: Option<FetchToolSection>,
1088    /// `[tools.search]`: parameters for the local `search` tool (grep/glob). Registration
1089    /// depends solely on `enabled` and is completely independent of
1090    /// `[capabilities.web_search]`.
1091    pub(crate) search: Option<SearchToolSection>,
1092    /// `[tools.background]`: background subagent progress view (ring capacity / text
1093    /// limit).
1094    pub(crate) background: Option<BackgroundToolSection>,
1095}
1096
1097#[derive(Debug, Clone, Default, Deserialize)]
1098#[serde(deny_unknown_fields)]
1099pub(crate) struct BackgroundToolSection {
1100    /// Default number of recent message blocks returned when `inspect` is called without
1101    /// `recent_blocks`. Defaults to 10.
1102    pub(crate) default_recent_blocks: Option<usize>,
1103    /// Character limit for free-form body text in a single block (assistant/thought/tool
1104    /// result). Default 0 = keep only summary/metadata.
1105    pub(crate) block_text_limit: Option<usize>,
1106    /// How many finished background-task entries to retain in the task table before the
1107    /// oldest are evicted. Default 64. Bounds memory for long sessions with many tasks.
1108    pub(crate) finished_tasks_cap: Option<usize>,
1109}
1110
1111#[derive(Debug, Clone, Default, Deserialize)]
1112#[serde(deny_unknown_fields)]
1113pub(crate) struct SearchToolSection {
1114    pub(crate) enabled: Option<bool>,
1115    pub(crate) default_head_limit: Option<u32>,
1116    pub(crate) max_head_limit: Option<u32>,
1117    pub(crate) max_file_size_bytes: Option<u64>,
1118    pub(crate) max_result_bytes: Option<u64>,
1119    pub(crate) max_walk_files: Option<u64>,
1120    pub(crate) respect_gitignore_default: Option<bool>,
1121}
1122
1123#[derive(Debug, Clone, Default, Deserialize)]
1124#[serde(deny_unknown_fields)]
1125pub(crate) struct FetchToolSection {
1126    pub(crate) enabled: Option<bool>,
1127    pub(crate) default_timeout_secs: Option<u32>,
1128    pub(crate) max_timeout_secs: Option<u32>,
1129    pub(crate) max_response_bytes: Option<u64>,
1130    pub(crate) default_format: Option<FetchFormat>,
1131    pub(crate) html_to_markdown: Option<bool>,
1132    pub(crate) follow_redirects: Option<bool>,
1133}
1134
1135#[derive(Debug, Clone, Default, Deserialize)]
1136#[serde(deny_unknown_fields)]
1137pub(crate) struct BashToolSection {
1138    pub(crate) default_timeout_ms: Option<u64>,
1139    pub(crate) max_timeout_ms: Option<u64>,
1140    pub(crate) output_max_bytes: Option<usize>,
1141}
1142
1143#[derive(Debug, Clone, Default, Deserialize)]
1144#[serde(deny_unknown_fields)]
1145pub(crate) struct FsToolSection {
1146    pub(crate) read_default_limit: Option<u32>,
1147    pub(crate) read_max_limit: Option<u32>,
1148}
1149
1150#[derive(Debug, Clone, Default, Deserialize)]
1151#[serde(deny_unknown_fields)]
1152pub(crate) struct SandboxSection {
1153    pub(crate) mode: Option<SandboxMode>,
1154}
1155
1156#[derive(Debug, Clone, Default, Deserialize)]
1157#[serde(deny_unknown_fields)]
1158pub(crate) struct TracingSection {
1159    pub(crate) filter: Option<String>,
1160    pub(crate) otlp: Option<OtlpTracingSection>,
1161    pub(crate) langfuse: Option<LangfuseSection>,
1162}
1163
1164#[derive(Debug, Clone, Default, Deserialize)]
1165#[serde(deny_unknown_fields)]
1166pub(crate) struct OtlpTracingSection {
1167    pub(crate) endpoint: Option<String>,
1168}
1169
1170#[derive(Debug, Clone, Default, Deserialize)]
1171#[serde(rename_all = "snake_case", deny_unknown_fields)]
1172pub(crate) struct LangfuseSection {
1173    pub(crate) enabled: Option<bool>,
1174    pub(crate) host: Option<String>,
1175    pub(crate) public_key: Option<String>,
1176    pub(crate) secret_key: Option<String>,
1177    pub(crate) flush_interval_ms: Option<u64>,
1178    pub(crate) max_batch: Option<usize>,
1179}
1180
1181#[derive(Debug, Clone, Default, Deserialize)]
1182#[serde(deny_unknown_fields)]
1183pub(crate) struct McpSection {
1184    pub(crate) enabled_servers: Option<Vec<String>>,
1185    pub(crate) servers: Option<BTreeMap<String, McpServerSection>>,
1186}
1187
1188#[derive(Debug, Clone, Default, Deserialize)]
1189#[serde(deny_unknown_fields)]
1190pub(crate) struct HttpSection {
1191    pub(crate) total_timeout_ms: Option<u64>,
1192    pub(crate) transport_retries: Option<u8>,
1193    pub(crate) initial_backoff_ms: Option<u64>,
1194    pub(crate) user_agent: Option<String>,
1195    pub(crate) proxy: Option<HttpProxySection>,
1196}
1197
1198#[derive(Debug, Clone, Default, Deserialize)]
1199#[serde(deny_unknown_fields)]
1200pub(crate) struct HttpProxySection {
1201    pub(crate) mode: Option<HttpProxyMode>,
1202    pub(crate) http_proxy: Option<String>,
1203    pub(crate) https_proxy: Option<String>,
1204    pub(crate) no_proxy: Option<Vec<String>>,
1205}
1206
1207#[derive(Debug, Clone, Default, Deserialize)]
1208#[serde(deny_unknown_fields)]
1209pub(crate) struct McpServerSection {
1210    pub(crate) transport: Option<McpTransportKind>,
1211    pub(crate) command: Option<String>,
1212    pub(crate) args: Option<Vec<String>>,
1213    pub(crate) env: Option<BTreeMap<String, String>>,
1214    pub(crate) url: Option<String>,
1215    pub(crate) headers: Option<BTreeMap<String, String>>,
1216}