Skip to main content

leviath_core/
config.rs

1//! Plain configuration value types shared across crates.
2//!
3//! The full CLI [`Config`](../../leviath_cli/config/struct.Config.html) lives in
4//! `leviath-cli`, but a few plain sub-configs are also needed by the engine in
5//! `leviath-runtime` (e.g. title generation). Those live here so the runtime can
6//! reference them without a CLI dependency; the CLI re-exports them for compat.
7
8use serde::{Deserialize, Serialize};
9
10/// Configuration for auto-generating a short human-readable run title.
11///
12/// Example config:
13/// ```toml
14/// [title]
15/// enabled = true
16/// provider = "anthropic"
17/// model = "claude-haiku-4-5-20251001"
18/// ```
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct TitleConfig {
21    /// Whether to generate titles at all (default: true).
22    #[serde(default = "crate::default_true")]
23    pub enabled: bool,
24
25    /// Provider to use for title generation.
26    /// Falls back to the run's own first-stage provider when absent.
27    pub provider: Option<String>,
28
29    /// Model to use for title generation.
30    /// Falls back to the run's own first-stage model when absent - but only if
31    /// `provider` is also absent or matches the run's provider, since one
32    /// provider's model name means nothing to another.
33    pub model: Option<String>,
34}
35
36impl Default for TitleConfig {
37    fn default() -> Self {
38        Self {
39            enabled: true,
40            provider: None,
41            model: None,
42        }
43    }
44}
45
46/// The default cap on each log file Leviath writes for itself (the daemon's
47/// `daemon.log`, a server's `serve-<name>.log`): 5 MiB, with one rolled
48/// backup, so a long-lived process holds at most about 10 MiB of its own
49/// output on disk.
50pub const DEFAULT_LOG_FILE_MAX_BYTES: u64 = 5 * 1024 * 1024;
51
52/// Configuration for structured observability export, and for the daemon's
53/// own log file.
54///
55/// Export is off by default: telemetry costs a background export pipeline and
56/// most interactive users have the dashboard instead. When enabled, spans,
57/// metrics and log records for every run flow to the configured exporter.
58///
59/// Example config:
60/// ```toml
61/// [observability]
62/// enabled = true
63/// exporter = "otlp"
64/// endpoint = "http://localhost:4318"
65/// service_name = "leviath"
66/// log_file_max_bytes = 5242880
67/// ```
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ObservabilityConfig {
70    /// Whether to export telemetry at all (default: false).
71    #[serde(default)]
72    pub enabled: bool,
73
74    /// Which exporter to use (default: `otlp`).
75    #[serde(default)]
76    pub exporter: TelemetryExporterKind,
77
78    /// OTLP endpoint. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`, then to
79    /// `http://localhost:4318` - the OTLP **HTTP** port. Leviath exports OTLP
80    /// over HTTP/protobuf, not gRPC, so a collector's 4317 gRPC endpoint will
81    /// not work here.
82    pub endpoint: Option<String>,
83
84    /// The `service.name` resource attribute. Falls back to
85    /// `OTEL_SERVICE_NAME`, then to `"leviath"`.
86    pub service_name: Option<String>,
87
88    /// Bytes a log file Leviath writes for itself may reach before it is
89    /// rolled to `<name>.1`, replacing the previous one: the daemon's
90    /// `daemon.log` and each `lev serve`'s `serve-<name>.log`, under the data
91    /// directory. `0` never rolls. Default: 5 MiB.
92    #[serde(default = "default_log_file_max_bytes")]
93    pub log_file_max_bytes: u64,
94}
95
96fn default_log_file_max_bytes() -> u64 {
97    DEFAULT_LOG_FILE_MAX_BYTES
98}
99
100impl Default for ObservabilityConfig {
101    /// Export off, the OTLP exporter when it is turned on, and the default
102    /// log cap. Hand-written so the empty-table serde default and `Default`
103    /// agree on the cap.
104    fn default() -> Self {
105        Self {
106            enabled: false,
107            exporter: TelemetryExporterKind::Otlp,
108            endpoint: None,
109            service_name: None,
110            log_file_max_bytes: DEFAULT_LOG_FILE_MAX_BYTES,
111        }
112    }
113}
114
115/// Which telemetry exporter to build.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum TelemetryExporterKind {
119    /// OTLP over HTTP/protobuf to `endpoint`.
120    #[default]
121    Otlp,
122    /// Human-readable lines through the process logger (stderr).
123    Stdout,
124    /// Build no exporter even when `enabled` is set.
125    None,
126}
127
128impl TelemetryExporterKind {
129    /// True when this kind exports nothing.
130    pub fn is_none(self) -> bool {
131        matches!(self, Self::None)
132    }
133}
134
135/// The machine-wide toggles for the framework-authored system-prompt hints,
136/// carried from the CLI's config into a spawn.
137///
138/// Each field is the *global* end of a stage → agent → global cascade
139/// ([`crate::taint::resolve_batch_tool_hint`],
140/// [`crate::taint::resolve_shell_hint`]): a blueprint's `[agent]` or
141/// `[stages.<name>]` block overrides it per agent or per stage. They travel
142/// together as one value so adding a hint doesn't grow the arity of every spawn
143/// entry point.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct PromptHints {
146    /// Global default for the batch-tool-calls hint.
147    pub batch_tool: bool,
148    /// Global default for the platform shell hint.
149    pub shell: bool,
150}
151
152impl Default for PromptHints {
153    /// Both hints on, matching the CLI config's `default_true` for each.
154    fn default() -> Self {
155        Self {
156            batch_tool: true,
157            shell: true,
158        }
159    }
160}
161
162/// One narrower scope's overrides of [`PromptHints`], as a blueprint's `[agent]`
163/// or `[stages.<name>]` block writes them. `None` inherits the broader scope.
164#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
165pub struct PromptHintOverrides {
166    /// Override for the batch-tool-calls hint.
167    pub batch_tool: Option<bool>,
168    /// Override for the platform shell hint.
169    pub shell: Option<bool>,
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn prompt_hints_default_on_and_overrides_default_inherit() {
178        // The default has to match the CLI config's `default_true` for each
179        // field, or an embedder and the daemon would disagree about the same
180        // unset config.
181        let hints = PromptHints::default();
182        assert!(hints.batch_tool);
183        assert!(hints.shell);
184        assert_eq!(
185            hints,
186            PromptHints {
187                batch_tool: true,
188                shell: true
189            }
190        );
191        assert_ne!(
192            hints,
193            PromptHints {
194                batch_tool: true,
195                shell: false
196            }
197        );
198
199        // An override that names nothing inherits everything.
200        let overrides = PromptHintOverrides::default();
201        assert_eq!(overrides.batch_tool, None);
202        assert_eq!(overrides.shell, None);
203        assert_eq!(
204            overrides,
205            PromptHintOverrides {
206                batch_tool: None,
207                shell: None
208            }
209        );
210        assert!(format!("{hints:?} {overrides:?}").contains("batch_tool"));
211    }
212
213    #[test]
214    fn test_title_config_default() {
215        let cfg = TitleConfig::default();
216        assert!(cfg.enabled);
217        assert!(cfg.provider.is_none());
218        assert!(cfg.model.is_none());
219    }
220
221    #[test]
222    fn test_title_config_fields() {
223        let cfg = TitleConfig {
224            enabled: false,
225            provider: Some("anthropic".to_string()),
226            model: Some("claude-haiku-4-5-20251001".to_string()),
227        };
228        assert!(!cfg.enabled);
229        assert_eq!(cfg.provider.as_deref(), Some("anthropic"));
230        assert_eq!(cfg.model.as_deref(), Some("claude-haiku-4-5-20251001"));
231    }
232
233    #[test]
234    fn test_title_config_clone_and_debug() {
235        let cfg = TitleConfig::default();
236        let cloned = cfg.clone();
237        assert_eq!(cloned.enabled, cfg.enabled);
238        // Debug impl is derived; exercise it so the derive is covered.
239        assert!(format!("{:?}", cfg).contains("TitleConfig"));
240    }
241
242    #[test]
243    fn test_title_config_serde_roundtrip() {
244        let cfg = TitleConfig {
245            enabled: true,
246            provider: Some("openai".to_string()),
247            model: Some("gpt-4o-mini".to_string()),
248        };
249        let json = serde_json::to_string(&cfg).unwrap();
250        let back: TitleConfig = serde_json::from_str(&json).unwrap();
251        assert_eq!(back.enabled, cfg.enabled);
252        assert_eq!(back.provider, cfg.provider);
253        assert_eq!(back.model, cfg.model);
254    }
255
256    #[test]
257    fn test_title_config_deserialize_defaults_enabled_true() {
258        // `enabled` omitted → default_true() supplies `true`.
259        let toml_str = r#"
260provider = "anthropic"
261model = "claude-haiku-4-5-20251001"
262"#;
263        let cfg: TitleConfig = toml::from_str(toml_str).unwrap();
264        assert!(cfg.enabled);
265        assert_eq!(cfg.provider.as_deref(), Some("anthropic"));
266    }
267
268    #[test]
269    fn test_title_config_deserialize_explicit_disabled() {
270        let toml_str = r#"
271enabled = false
272"#;
273        let cfg: TitleConfig = toml::from_str(toml_str).unwrap();
274        assert!(!cfg.enabled);
275        assert!(cfg.provider.is_none());
276        assert!(cfg.model.is_none());
277    }
278
279    #[test]
280    fn observability_config_defaults_to_disabled_otlp() {
281        let cfg = ObservabilityConfig::default();
282        assert!(!cfg.enabled);
283        assert_eq!(cfg.exporter, TelemetryExporterKind::Otlp);
284        assert!(cfg.endpoint.is_none());
285        assert!(cfg.service_name.is_none());
286        assert_eq!(cfg.log_file_max_bytes, DEFAULT_LOG_FILE_MAX_BYTES);
287        // An empty TOML table and the hand-written Default must agree.
288        let parsed: ObservabilityConfig = toml::from_str("").unwrap();
289        assert_eq!(parsed, cfg);
290    }
291
292    #[test]
293    fn observability_config_full_roundtrip() {
294        let toml_str = r#"
295enabled = true
296exporter = "stdout"
297endpoint = "http://collector:4318"
298service_name = "leviath-prod"
299log_file_max_bytes = 1048576
300"#;
301        let cfg: ObservabilityConfig = toml::from_str(toml_str).unwrap();
302        assert!(cfg.enabled);
303        assert_eq!(cfg.exporter, TelemetryExporterKind::Stdout);
304        assert_eq!(cfg.endpoint.as_deref(), Some("http://collector:4318"));
305        assert_eq!(cfg.service_name.as_deref(), Some("leviath-prod"));
306        assert_eq!(cfg.log_file_max_bytes, 1_048_576);
307        let serialized = toml::to_string(&cfg).unwrap();
308        let back: ObservabilityConfig = toml::from_str(&serialized).unwrap();
309        assert_eq!(back, cfg);
310        assert!(format!("{cfg:?}").contains("ObservabilityConfig"));
311    }
312
313    #[test]
314    fn telemetry_exporter_kind_round_trips_through_config_syntax() {
315        for (kind, text) in [
316            (TelemetryExporterKind::Otlp, "otlp"),
317            (TelemetryExporterKind::Stdout, "stdout"),
318            (TelemetryExporterKind::None, "none"),
319        ] {
320            let toml_str = format!("exporter = \"{text}\"");
321            let cfg: ObservabilityConfig = toml::from_str(&toml_str).unwrap();
322            assert_eq!(cfg.exporter, kind);
323            let serialized = toml::to_string(&cfg).unwrap();
324            assert!(serialized.contains(text), "{serialized} missing {text}");
325        }
326    }
327
328    #[test]
329    fn telemetry_exporter_kind_is_none_helper() {
330        assert!(TelemetryExporterKind::None.is_none());
331        assert!(!TelemetryExporterKind::Otlp.is_none());
332        assert!(!TelemetryExporterKind::Stdout.is_none());
333    }
334
335    #[test]
336    fn telemetry_exporter_kind_rejects_unknown_value() {
337        let err = toml::from_str::<ObservabilityConfig>("exporter = \"grpc\"");
338        assert!(err.is_err());
339    }
340}