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