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