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