Skip to main content

turnframe_runtime/
config.rs

1//! Orchestration modes and runtime configuration (spec §11.1, §11.4, Appendix A).
2//!
3//! Two things live here. The first is [`OrchestrationMode`]: how much rope the
4//! model gets before the deterministic pipeline takes over. The second is
5//! [`OrchestratorConfig`]: the knobs an application turns, every one of which
6//! defaults to the careful setting.
7//!
8//! # Safety settings are not knobs
9//!
10//! A few fields exist only so that a configuration file which tries to disable
11//! them fails loudly instead of quietly working. [`OrchestratorConfig::validate`]
12//! refuses `reject_unknown_fields = false`, `require_evidence_for_mutations =
13//! false` and `fail_closed_on_policy_store_error = false`, because the library
14//! does not implement the permissive behaviour at all: the plan types deny
15//! unknown fields unconditionally, evidence is validated unconditionally, and
16//! there is no fail-open path for an unavailable policy source (I19). A config
17//! that asks for the permissive behaviour is a config whose author believes
18//! something untrue, and that is worth an error.
19//!
20//! Sandboxed autonomy goes further: the variant cannot be written down without
21//! a [`SandboxAcknowledgement`], whose only constructor is named after what it
22//! gives up and logs a warning when called.
23
24use std::fmt;
25use std::time::Duration;
26
27use serde::de::{Error as _, Unexpected};
28use serde::{Deserialize, Deserializer, Serialize, Serializer};
29use turnframe_core::command::RiskClass;
30use turnframe_core::flow::BriefingBudget;
31use turnframe_core::plan::limits::PlanLimits;
32use turnframe_core::policy::PolicySnapshot;
33use turnframe_core::reduce::SourcePolicy;
34use turnframe_core::response::ToneProfile;
35use turnframe_core::turn::TurnLimits;
36use turnframe_tasks::{Budget, TaskProfiles};
37use turnframe_understand::Settings;
38
39/// A configuration value the library refuses to work with.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
41#[non_exhaustive]
42pub enum ConfigError {
43    /// A bound that must allow at least one of something was zero.
44    #[error("{field} must be at least 1")]
45    MustBePositive {
46        /// Dotted path of the field, e.g. `execution.max_commands_per_turn`.
47        field: &'static str,
48    },
49    /// A safety setting was turned off, and the library has no such mode.
50    #[error("{field} may not be disabled: {because}")]
51    UnsafeSetting {
52        /// Dotted path of the field.
53        field: &'static str,
54        /// Why the setting cannot be disabled.
55        because: &'static str,
56    },
57    /// Two settings ask for incompatible things.
58    #[error("{first} contradicts {second}")]
59    Contradiction {
60        /// Dotted path of the first field.
61        first: &'static str,
62        /// Dotted path of the second field.
63        second: &'static str,
64    },
65    /// A ratio expressed in per mille left the `0..=1000` range.
66    #[error("{field} must be between 0 and 1000 per mille")]
67    OutOfRange {
68        /// Dotted path of the field.
69        field: &'static str,
70    },
71}
72
73/// The exact phrase that acknowledges sandboxed autonomy, on the wire and in
74/// the name of [`SandboxAcknowledgement::i_accept_unreviewed_autonomous_writes`].
75pub const SANDBOX_ACKNOWLEDGEMENT: &str = "i-accept-unreviewed-autonomous-writes";
76
77/// Proof that the caller knows what
78/// [`OrchestrationMode::SandboxedAutonomous`] gives up.
79///
80/// The type has no public field and no `Default`, so the variant cannot be
81/// written as a struct literal from another crate; the only way in is
82/// [`Self::i_accept_unreviewed_autonomous_writes`], which logs a warning. On the
83/// wire it is the literal string [`SANDBOX_ACKNOWLEDGEMENT`], so a TOML or JSON
84/// configuration has to spell the sentence out too.
85#[derive(Clone, Copy, PartialEq, Eq, Hash)]
86pub struct SandboxAcknowledgement(());
87
88impl SandboxAcknowledgement {
89    /// Acknowledges that sandboxed autonomy lets the model drive writes that no
90    /// human reviewed, and emits a `WARN` on the `turnframe.config` target.
91    ///
92    /// Use it only for reversible, non-regulated domains. Even then
93    /// [`OrchestrationMode::allows_risk`] keeps refusing the destructive,
94    /// irreversible and externally regulated classes (§11.4).
95    #[must_use]
96    pub fn i_accept_unreviewed_autonomous_writes() -> Self {
97        tracing::warn!(
98            target: "turnframe.config",
99            mode = "sandboxed_autonomous",
100            acknowledgement = SANDBOX_ACKNOWLEDGEMENT,
101            "sandboxed autonomous orchestration enabled: the model may drive writes without human review"
102        );
103        Self(())
104    }
105}
106
107impl fmt::Debug for SandboxAcknowledgement {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str(SANDBOX_ACKNOWLEDGEMENT)
110    }
111}
112
113impl Serialize for SandboxAcknowledgement {
114    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
115        serializer.serialize_str(SANDBOX_ACKNOWLEDGEMENT)
116    }
117}
118
119impl<'de> Deserialize<'de> for SandboxAcknowledgement {
120    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
121        let raw = String::deserialize(deserializer)?;
122        if raw == SANDBOX_ACKNOWLEDGEMENT {
123            Ok(Self::i_accept_unreviewed_autonomous_writes())
124        } else {
125            Err(D::Error::invalid_value(
126                Unexpected::Str(&raw),
127                &SANDBOX_ACKNOWLEDGEMENT,
128            ))
129        }
130    }
131}
132
133/// What a sandboxed autonomous run may spend before it is cut off.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136#[non_exhaustive]
137pub struct ResourceBudget {
138    /// Model calls of every purpose, across the whole turn.
139    pub max_model_calls: u16,
140    /// Read-tool calls across the whole turn.
141    pub max_read_calls: u16,
142    /// Prompt tokens the turn may send in total.
143    pub max_prompt_tokens: u64,
144    /// Wall clock the turn may take.
145    pub max_wall_clock: Duration,
146}
147
148impl ResourceBudget {
149    /// A small budget: 8 model calls, 16 read calls, 200k prompt tokens, 60s.
150    #[must_use]
151    pub const fn conservative() -> Self {
152        Self {
153            max_model_calls: 8,
154            max_read_calls: 16,
155            max_prompt_tokens: 200_000,
156            max_wall_clock: Duration::from_secs(60),
157        }
158    }
159
160    /// Returns a copy with another model-call budget.
161    #[must_use]
162    pub const fn with_max_model_calls(mut self, max_model_calls: u16) -> Self {
163        self.max_model_calls = max_model_calls;
164        self
165    }
166
167    /// Returns a copy with another read-call budget.
168    #[must_use]
169    pub const fn with_max_read_calls(mut self, max_read_calls: u16) -> Self {
170        self.max_read_calls = max_read_calls;
171        self
172    }
173
174    /// Returns a copy with another token budget.
175    #[must_use]
176    pub const fn with_max_prompt_tokens(mut self, max_prompt_tokens: u64) -> Self {
177        self.max_prompt_tokens = max_prompt_tokens;
178        self
179    }
180
181    /// Returns a copy with another wall-clock budget.
182    #[must_use]
183    pub const fn with_max_wall_clock(mut self, max_wall_clock: Duration) -> Self {
184        self.max_wall_clock = max_wall_clock;
185        self
186    }
187
188    fn validate(&self) -> Result<(), ConfigError> {
189        positive(
190            "mode.budget.max_model_calls",
191            u64::from(self.max_model_calls),
192        )?;
193        positive("mode.budget.max_read_calls", u64::from(self.max_read_calls))?;
194        positive("mode.budget.max_prompt_tokens", self.max_prompt_tokens)?;
195        if self.max_wall_clock.is_zero() {
196            return Err(ConfigError::MustBePositive {
197                field: "mode.budget.max_wall_clock",
198            });
199        }
200        Ok(())
201    }
202}
203
204impl Default for ResourceBudget {
205    fn default() -> Self {
206        Self::conservative()
207    }
208}
209
210/// How much autonomy the model gets: which risk classes are eligible at all (§11.4).
211///
212/// | Risk class | `Deterministic` | `SandboxedAutonomous` |
213/// | --- | --- | --- |
214/// | `ReadOnly`, `ReversibleLowRisk`, `SensitiveDataChange` | yes | yes |
215/// | `Destructive`, `Irreversible`, `ExternalRegulated` | yes | **no** |
216///
217/// A domain that classifies a money movement as `ReversibleLowRisk` has mislabelled it,
218/// and no mode can rescue that.
219#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
221#[non_exhaustive]
222pub enum OrchestrationMode {
223    /// Understanding proposes, the reducer and policy decide. The default.
224    #[default]
225    Deterministic,
226    /// Experimental mode for sandboxed, reversible domains only (§11.4). Build it with
227    /// [`Self::sandboxed_autonomous`]; the acknowledgement cannot be built elsewhere.
228    SandboxedAutonomous {
229        /// What the turn may spend.
230        budget: ResourceBudget,
231        /// Proof the operator knows what this gives up.
232        acknowledgement: SandboxAcknowledgement,
233    },
234}
235
236impl OrchestrationMode {
237    /// Builds the sandboxed autonomous mode.
238    ///
239    /// The `acknowledgement` argument is the whole point: obtaining one means
240    /// calling [`SandboxAcknowledgement::i_accept_unreviewed_autonomous_writes`],
241    /// which logs a warning.
242    #[must_use]
243    pub const fn sandboxed_autonomous(
244        budget: ResourceBudget,
245        acknowledgement: SandboxAcknowledgement,
246    ) -> Self {
247        Self::SandboxedAutonomous {
248            budget,
249            acknowledgement,
250        }
251    }
252
253    /// Whether commands of `risk` are eligible in this mode (§11.4).
254    ///
255    /// See the table on [`OrchestrationMode`].
256    #[must_use]
257    pub fn allows_risk(&self, risk: RiskClass) -> bool {
258        match self {
259            Self::SandboxedAutonomous { .. } => !matches!(
260                risk,
261                RiskClass::Destructive | RiskClass::Irreversible | RiskClass::ExternalRegulated
262            ),
263            Self::Deterministic => true,
264        }
265    }
266
267    /// The risk classes this mode refuses, in ladder order.
268    ///
269    /// Feed them to [`PolicySnapshot::forbidden_risk_classes`] — or let
270    /// [`OrchestratorConfig::policy_snapshot`] do it.
271    #[must_use]
272    pub fn forbidden_risk_classes(&self) -> Vec<RiskClass> {
273        [
274            RiskClass::ReadOnly,
275            RiskClass::ReversibleLowRisk,
276            RiskClass::SensitiveDataChange,
277            RiskClass::Destructive,
278            RiskClass::Irreversible,
279            RiskClass::ExternalRegulated,
280        ]
281        .into_iter()
282        .filter(|risk| !self.allows_risk(*risk))
283        .collect()
284    }
285
286    /// The resource budget, for the sandboxed mode only.
287    #[must_use]
288    pub fn budget(&self) -> Option<&ResourceBudget> {
289        match self {
290            Self::SandboxedAutonomous { budget, .. } => Some(budget),
291            _ => None,
292        }
293    }
294
295    /// Returns `true` for [`Self::SandboxedAutonomous`].
296    #[must_use]
297    pub fn is_sandboxed(&self) -> bool {
298        matches!(self, Self::SandboxedAutonomous { .. })
299    }
300
301    fn validate(&self) -> Result<(), ConfigError> {
302        if let Some(budget) = self.budget() {
303            budget.validate()?;
304        }
305        Ok(())
306    }
307}
308
309/// How much of a turn's files reaches a model.
310///
311/// # Why nothing ships
312///
313/// A limit an adopter cannot raise is not configurable, so there is no shipped
314/// ceiling here either: an adopter who sets nothing sends every file the turn
315/// carried, which is what the user attached and what they expect to be looked
316/// at. Attachments are bounded by what one person put in one message, unlike a
317/// conversation or a briefing, so there is no runaway to protect anybody from.
318///
319/// What a budget buys, when one is set, is that the runtime says which files it
320/// left out — deterministically to the user and as a fact to the writing stage.
321/// A silently truncated request produces an answer about the wrong document,
322/// which is worse than an answer about none.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(deny_unknown_fields)]
325#[non_exhaustive]
326pub struct AttachmentConfig {
327    /// How many files may go into one request, or `None` for all of them.
328    pub max_files: Option<usize>,
329    /// How many bytes of file may go into one request, or `None` for all.
330    ///
331    /// Counted before encoding, which is what an application can reason about;
332    /// the wire form is larger and the provider's own limit is the one that
333    /// finally applies.
334    pub max_total_bytes: Option<usize>,
335}
336
337impl AttachmentConfig {
338    /// No limits, which is what ships.
339    #[must_use]
340    pub const fn conservative() -> Self {
341        Self {
342            max_files: None,
343            max_total_bytes: None,
344        }
345    }
346
347    /// Returns a copy taking at most `max_files` files per request.
348    #[must_use]
349    pub const fn with_max_files(mut self, max_files: Option<usize>) -> Self {
350        self.max_files = max_files;
351        self
352    }
353
354    /// Returns a copy taking at most `max_total_bytes` of file per request.
355    #[must_use]
356    pub const fn with_max_total_bytes(mut self, max_total_bytes: Option<usize>) -> Self {
357        self.max_total_bytes = max_total_bytes;
358        self
359    }
360}
361
362impl Default for AttachmentConfig {
363    fn default() -> Self {
364        Self::conservative()
365    }
366}
367
368/// How a turn is understood: the limits on what it may ask, the conversation shown, the
369/// budget of its model tasks, which acts are verified, and each task kind's settings.
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371#[serde(default, deny_unknown_fields)]
372#[non_exhaustive]
373pub struct UnderstandingConfig {
374    /// Size limits on what one turn may be understood to ask.
375    pub plan_limits: PlanLimits,
376    /// Size limits on the turn input.
377    pub turn_limits: TurnLimits,
378    /// How many past turns are loaded, or `None` for all of them.
379    pub transcript_turns: Option<usize>,
380    /// How much of a workflow's per-phase guidance reaches a task, per record.
381    pub briefing_budget: BriefingBudget,
382    /// What understanding one turn may spend.
383    pub budget: Budget,
384    /// Which acts are verified, and how much conversation extraction sees.
385    pub settings: Settings,
386    /// Each task kind's model, sampling, votes, repairs and escalation.
387    pub tasks: TaskProfiles,
388}
389
390impl UnderstandingConfig {
391    /// No limits, the whole conversation, the shipped budget, settings and profiles.
392    #[must_use]
393    pub const fn conservative() -> Self {
394        Self {
395            plan_limits: PlanLimits::conservative(),
396            turn_limits: TurnLimits::conservative(),
397            transcript_turns: None,
398            briefing_budget: BriefingBudget::conservative(),
399            budget: Budget::understanding(),
400            settings: Settings::conservative(),
401            tasks: TaskProfiles::new(),
402        }
403    }
404
405    /// Returns a copy with other plan limits.
406    #[must_use]
407    pub fn with_plan_limits(mut self, plan_limits: PlanLimits) -> Self {
408        self.plan_limits = plan_limits;
409        self
410    }
411
412    /// Returns a copy with other turn limits.
413    #[must_use]
414    pub fn with_turn_limits(mut self, turn_limits: TurnLimits) -> Self {
415        self.turn_limits = turn_limits;
416        self
417    }
418
419    /// Returns a copy loading at most `transcript_turns` past turns.
420    #[must_use]
421    pub fn with_transcript_turns(mut self, transcript_turns: Option<usize>) -> Self {
422        self.transcript_turns = transcript_turns;
423        self
424    }
425
426    /// Returns a copy with another briefing budget.
427    #[must_use]
428    pub fn with_briefing_budget(mut self, budget: BriefingBudget) -> Self {
429        self.briefing_budget = budget;
430        self
431    }
432
433    /// Returns a copy with another task budget.
434    #[must_use]
435    pub fn with_budget(mut self, budget: Budget) -> Self {
436        self.budget = budget;
437        self
438    }
439
440    /// Returns a copy with other pipeline settings.
441    #[must_use]
442    pub fn with_settings(mut self, settings: Settings) -> Self {
443        self.settings = settings;
444        self
445    }
446
447    /// Returns a copy with other task profiles.
448    #[must_use]
449    pub fn with_tasks(mut self, tasks: TaskProfiles) -> Self {
450        self.tasks = tasks;
451        self
452    }
453
454    /// The act limit, when one is set.
455    #[must_use]
456    pub const fn max_acts(&self) -> Option<usize> {
457        self.plan_limits.max_acts
458    }
459
460    /// The question limit, when one is set.
461    #[must_use]
462    pub const fn max_questions(&self) -> Option<usize> {
463        self.plan_limits.max_questions
464    }
465
466    fn validate(&self) -> Result<(), ConfigError> {
467        // A limit that is not set is not a limit of zero.
468        for (field, limit) in [
469            ("understanding.plan_limits.max_acts", self.max_acts()),
470            (
471                "understanding.plan_limits.max_questions",
472                self.max_questions(),
473            ),
474            (
475                "understanding.plan_limits.max_constraints",
476                self.plan_limits.max_constraints,
477            ),
478            (
479                "understanding.turn_limits.max_text_bytes",
480                self.turn_limits.max_text_bytes,
481            ),
482        ] {
483            if let Some(limit) = limit {
484                positive(field, limit as u64)?;
485            }
486        }
487        positive(
488            "understanding.budget.max_parallel",
489            self.budget.max_parallel as u64,
490        )?;
491        positive(
492            "understanding.budget.per_call_timeout_secs",
493            self.budget.per_call_timeout_secs,
494        )
495    }
496}
497
498impl Default for UnderstandingConfig {
499    fn default() -> Self {
500        Self::conservative()
501    }
502}
503
504/// How the assistant is allowed to talk (spec §18, §19).
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(deny_unknown_fields)]
507#[non_exhaustive]
508pub struct NarrationConfig {
509    /// Whether a model writes the answer and transition blocks at all. With
510    /// narration off the turn is receipts, notices and cards only.
511    pub enabled: bool,
512    /// What the narration tasks of one turn may spend.
513    #[serde(default = "Budget::narration")]
514    pub budget: Budget,
515    /// Voice the narrator should use.
516    pub tone: ToneProfile,
517    /// Cap on one written block, in characters; `None`, the default, is no cap. A
518    /// longer block is refused whole and reported, never cut mid-sentence.
519    pub max_answer_chars: Option<usize>,
520    /// Source requirement for answers resting on general domain knowledge.
521    /// Answers resting on case state are always `AuthoritativeOnly`.
522    pub default_source_policy: SourcePolicy,
523    /// Whether narration may be regenerated after commands committed. This is
524    /// the only retry I17 permits once effects may exist.
525    pub allow_retry_after_commit: bool,
526    /// Whether each understanding step is also said in the turn's language, as a
527    /// `TurnEvent::StepSaid`: one small model call per step. Off by default.
528    #[serde(default)]
529    pub steps: bool,
530}
531
532impl NarrationConfig {
533    /// Narration on, neutral tone, no cap on answer length, any source,
534    /// post-commit narration retries allowed.
535    #[must_use]
536    pub const fn conservative() -> Self {
537        Self {
538            enabled: true,
539            budget: Budget::narration(),
540            tone: ToneProfile::Neutral,
541            max_answer_chars: None,
542            default_source_policy: SourcePolicy::AnySource,
543            allow_retry_after_commit: true,
544            steps: false,
545        }
546    }
547
548    /// Returns a copy with narration switched on or off.
549    ///
550    /// With narration off the turn is receipts, notices and cards only, and
551    /// every question still gets a block — an explicitly unsupported one
552    /// (spec §19.4). Silence is never how a question disappears.
553    #[must_use]
554    pub const fn with_enabled(mut self, enabled: bool) -> Self {
555        self.enabled = enabled;
556        self
557    }
558
559    /// Returns a copy with another budget for the narration tasks.
560    #[must_use]
561    pub const fn with_budget(mut self, budget: Budget) -> Self {
562        self.budget = budget;
563        self
564    }
565
566    /// Returns a copy that says, or stops saying, each understanding step.
567    #[must_use]
568    pub const fn with_steps(mut self, steps: bool) -> Self {
569        self.steps = steps;
570        self
571    }
572
573    /// Returns a copy with another tone.
574    #[must_use]
575    pub const fn with_tone(mut self, tone: ToneProfile) -> Self {
576        self.tone = tone;
577        self
578    }
579
580    /// Returns a copy with another source policy for general-knowledge answers.
581    #[must_use]
582    pub const fn with_default_source_policy(mut self, policy: SourcePolicy) -> Self {
583        self.default_source_policy = policy;
584        self
585    }
586
587    /// Returns a copy with a cap on one generated answer, or with none.
588    #[must_use]
589    pub const fn with_max_answer_chars(mut self, max_answer_chars: Option<usize>) -> Self {
590        self.max_answer_chars = max_answer_chars;
591        self
592    }
593
594    fn validate(&self) -> Result<(), ConfigError> {
595        match self.max_answer_chars {
596            Some(max) => positive("narration.max_answer_chars", max as u64),
597            None => Ok(()),
598        }
599    }
600}
601
602impl Default for NarrationConfig {
603    fn default() -> Self {
604        Self::conservative()
605    }
606}
607
608/// How server-created cards behave (spec §15).
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
610#[serde(deny_unknown_fields)]
611#[non_exhaustive]
612pub struct InteractionConfig {
613    /// Time to live given to cards the runtime creates. `None` means no expiry.
614    pub default_ttl: Option<Duration>,
615    /// How many candidates a server-defined selection card may offer.
616    ///
617    /// Beyond this the act is rejected and the user is asked to narrow it down,
618    /// rather than shown a truncated list — truncating would let list order
619    /// decide which cases the user may pick, which is exactly what I8 forbids.
620    pub max_selection_candidates: usize,
621    /// Whether a selection card owns unqualified answers for the case it is
622    /// filed under (I5).
623    ///
624    /// The default is `false`, and that is a considered choice. A card asking
625    /// "which of these did you mean?" has to be stored against *some* case row,
626    /// but the whole point is that nobody knows yet which case the user meant.
627    /// Letting it block that row would wedge a case the user may never have been
628    /// talking about, and would compete with the blocking card that case might
629    /// legitimately need. Turn it on only where a selection really does own the
630    /// conversation until it is answered.
631    pub selection_cards_block_the_case: bool,
632    /// Whether confirmation cards are invalidated by a revision change.
633    pub confirmation_cards_bind_to_revision: bool,
634    /// Whether a card whose command failed goes back to `Active` so the user
635    /// may answer again, instead of being recorded `Failed` (spec §15.5).
636    ///
637    /// The default is `false`, and that is the careful reading. A command that
638    /// the domain refused, or that was planned against a revision the case has
639    /// left, will usually be refused again for the same reason; re-offering the
640    /// same button invites the user to keep pressing it. Turn it on where the
641    /// failure is genuinely transient and the card is still the right question.
642    pub restore_card_after_failed_command: bool,
643}
644
645impl InteractionConfig {
646    /// One-day expiry, at most 8 candidates, non-blocking selection cards,
647    /// confirmations bound to the revision they were rendered against.
648    #[must_use]
649    pub const fn conservative() -> Self {
650        Self {
651            default_ttl: Some(Duration::from_secs(24 * 60 * 60)),
652            max_selection_candidates: 8,
653            selection_cards_block_the_case: false,
654            confirmation_cards_bind_to_revision: true,
655            restore_card_after_failed_command: false,
656        }
657    }
658
659    /// Returns a copy that restores a card whose command failed.
660    #[must_use]
661    pub const fn restoring_failed_cards(mut self) -> Self {
662        self.restore_card_after_failed_command = true;
663        self
664    }
665
666    /// Returns a copy with another time to live.
667    #[must_use]
668    pub const fn with_default_ttl(mut self, ttl: Option<Duration>) -> Self {
669        self.default_ttl = ttl;
670        self
671    }
672
673    /// Returns a copy with another candidate cap.
674    #[must_use]
675    pub const fn with_max_selection_candidates(mut self, max: usize) -> Self {
676        self.max_selection_candidates = max;
677        self
678    }
679
680    fn validate(&self) -> Result<(), ConfigError> {
681        if self.max_selection_candidates < 2 {
682            return Err(ConfigError::MustBePositive {
683                field: "interaction.max_selection_candidates",
684            });
685        }
686        if self.default_ttl.is_some_and(|ttl| ttl.is_zero()) {
687            return Err(ConfigError::MustBePositive {
688                field: "interaction.default_ttl",
689            });
690        }
691        Ok(())
692    }
693}
694
695impl Default for InteractionConfig {
696    fn default() -> Self {
697        Self::conservative()
698    }
699}
700
701/// How commands are executed (Appendix A, spec §13.4, §16).
702#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(deny_unknown_fields)]
704#[non_exhaustive]
705pub struct ExecutionConfig {
706    /// Time budget for one command batch.
707    pub default_timeout: Duration,
708    /// Cap on the commands one turn may compile. Exceeding it refuses the turn
709    /// rather than executing a prefix.
710    pub max_commands_per_turn: usize,
711    /// Must stay `true`: an unavailable policy source stops the turn (I19).
712    pub fail_closed_on_policy_store_error: bool,
713    /// Whether one case may commit while another fails in the same turn.
714    pub allow_cross_case_partial_success: bool,
715    /// Whether mutations on one case default to committing together
716    /// ([`AtomicityScope::PerCase`](turnframe_core::command::AtomicityScope::PerCase)).
717    pub group_mutations_per_case: bool,
718}
719
720impl ExecutionConfig {
721    /// 30 seconds, 32 commands, fail closed, no cross-case partial success,
722    /// per-case grouping.
723    #[must_use]
724    pub const fn conservative() -> Self {
725        Self {
726            default_timeout: Duration::from_secs(30),
727            max_commands_per_turn: 32,
728            fail_closed_on_policy_store_error: true,
729            allow_cross_case_partial_success: false,
730            group_mutations_per_case: true,
731        }
732    }
733
734    /// Returns a copy with another command budget.
735    #[must_use]
736    pub const fn with_max_commands_per_turn(mut self, max: usize) -> Self {
737        self.max_commands_per_turn = max;
738        self
739    }
740
741    /// Returns a copy with another batch timeout.
742    #[must_use]
743    pub const fn with_default_timeout(mut self, timeout: Duration) -> Self {
744        self.default_timeout = timeout;
745        self
746    }
747
748    fn validate(&self) -> Result<(), ConfigError> {
749        if self.default_timeout.is_zero() {
750            return Err(ConfigError::MustBePositive {
751                field: "execution.default_timeout",
752            });
753        }
754        positive(
755            "execution.max_commands_per_turn",
756            self.max_commands_per_turn as u64,
757        )?;
758        if !self.fail_closed_on_policy_store_error {
759            return Err(ConfigError::UnsafeSetting {
760                field: "execution.fail_closed_on_policy_store_error",
761                because: "there is no fail-open path for an unavailable policy source (I19)",
762            });
763        }
764        Ok(())
765    }
766}
767
768impl Default for ExecutionConfig {
769    fn default() -> Self {
770        Self::conservative()
771    }
772}
773
774/// What the runtime records about itself (spec §26).
775#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
776#[serde(deny_unknown_fields)]
777#[non_exhaustive]
778pub struct ObservabilityConfig {
779    /// Whether [`Signal`](turnframe_core::observe::Signal)s are emitted.
780    pub emit_metrics: bool,
781    /// Whether a [`ReplayRecord`](turnframe_core::replay::ReplayRecord) is
782    /// persisted for every turn (I20).
783    pub record_replay: bool,
784    /// Trace sampling, in per mille, so the configuration stays exactly
785    /// comparable and hashable.
786    pub trace_sample_per_mille: u16,
787}
788
789impl ObservabilityConfig {
790    /// Metrics on, replay on, every turn traced.
791    #[must_use]
792    pub const fn conservative() -> Self {
793        Self {
794            emit_metrics: true,
795            record_replay: true,
796            trace_sample_per_mille: 1000,
797        }
798    }
799
800    /// Returns a copy with another trace sample rate.
801    #[must_use]
802    pub const fn with_trace_sample_per_mille(mut self, per_mille: u16) -> Self {
803        self.trace_sample_per_mille = per_mille;
804        self
805    }
806
807    fn validate(&self) -> Result<(), ConfigError> {
808        if self.trace_sample_per_mille > 1000 {
809            return Err(ConfigError::OutOfRange {
810                field: "observability.trace_sample_per_mille",
811            });
812        }
813        Ok(())
814    }
815}
816
817impl Default for ObservabilityConfig {
818    fn default() -> Self {
819        Self::conservative()
820    }
821}
822
823/// What may leave the runtime and for how long it is kept (spec §25.5).
824#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
825#[serde(deny_unknown_fields)]
826#[non_exhaustive]
827pub struct PrivacyConfig {
828    /// Whether the user's raw text may be attached to traces and logs.
829    pub allow_user_text_in_telemetry: bool,
830    /// Whether prompts sent to providers are stored with the replay record.
831    pub store_model_prompts: bool,
832    /// How long a replay record is kept.
833    pub replay_retention_days: u32,
834    /// Whether attachment file names are redacted from telemetry.
835    pub redact_attachment_filenames: bool,
836}
837
838impl PrivacyConfig {
839    /// No user text in telemetry, no stored prompts, 90 days of replay,
840    /// file names redacted.
841    #[must_use]
842    pub const fn conservative() -> Self {
843        Self {
844            allow_user_text_in_telemetry: false,
845            store_model_prompts: false,
846            replay_retention_days: 90,
847            redact_attachment_filenames: true,
848        }
849    }
850
851    /// Returns a copy with another retention window.
852    #[must_use]
853    pub const fn with_replay_retention_days(mut self, days: u32) -> Self {
854        self.replay_retention_days = days;
855        self
856    }
857
858    fn validate(&self) -> Result<(), ConfigError> {
859        positive(
860            "privacy.replay_retention_days",
861            u64::from(self.replay_retention_days),
862        )
863    }
864}
865
866impl Default for PrivacyConfig {
867    fn default() -> Self {
868        Self::conservative()
869    }
870}
871
872/// Everything the runtime needs to know before it handles a turn (Appendix A).
873///
874/// Provider routing is deliberately absent: it belongs to the provider layer and
875/// this crate depends on `turnframe-core` alone.
876#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
877#[serde(deny_unknown_fields)]
878#[non_exhaustive]
879pub struct OrchestratorConfig {
880    /// How much autonomy the model gets.
881    pub mode: OrchestrationMode,
882    /// How the turn is understood.
883    pub understanding: UnderstandingConfig,
884    /// How much of the turn's files reaches a model.
885    pub attachments: AttachmentConfig,
886    /// How the assistant talks.
887    pub narration: NarrationConfig,
888    /// How cards behave.
889    pub interaction: InteractionConfig,
890    /// How commands execute.
891    pub execution: ExecutionConfig,
892    /// What is measured and recorded.
893    pub observability: ObservabilityConfig,
894    /// What may leave the runtime.
895    pub privacy: PrivacyConfig,
896    /// The default effort, and what each level changes.
897    #[serde(default)]
898    pub effort: crate::effort::EffortConfig,
899}
900
901impl OrchestratorConfig {
902    /// Every section at its conservative default, in
903    /// [`OrchestrationMode::Deterministic`].
904    #[must_use]
905    pub const fn conservative() -> Self {
906        Self {
907            mode: OrchestrationMode::Deterministic,
908            understanding: UnderstandingConfig::conservative(),
909            attachments: AttachmentConfig::conservative(),
910            narration: NarrationConfig::conservative(),
911            interaction: InteractionConfig::conservative(),
912            execution: ExecutionConfig::conservative(),
913            observability: ObservabilityConfig::conservative(),
914            privacy: PrivacyConfig::conservative(),
915            effort: crate::effort::EffortConfig::conservative(),
916        }
917    }
918
919    /// Returns a copy with another default effort and other level changes.
920    #[must_use]
921    pub fn with_effort(mut self, effort: crate::effort::EffortConfig) -> Self {
922        self.effort = effort;
923        self
924    }
925
926    /// Returns a copy with another attachment budget.
927    #[must_use]
928    pub const fn with_attachments(mut self, attachments: AttachmentConfig) -> Self {
929        self.attachments = attachments;
930        self
931    }
932
933    /// Returns a copy in another orchestration mode.
934    #[must_use]
935    pub fn with_mode(mut self, mode: OrchestrationMode) -> Self {
936        self.mode = mode;
937        self
938    }
939
940    /// Returns a copy with another understanding section.
941    #[must_use]
942    pub fn with_understanding(mut self, understanding: UnderstandingConfig) -> Self {
943        self.understanding = understanding;
944        self
945    }
946
947    /// Returns a copy with another narration section.
948    #[must_use]
949    pub const fn with_narration(mut self, narration: NarrationConfig) -> Self {
950        self.narration = narration;
951        self
952    }
953
954    /// Returns a copy with another interaction section.
955    #[must_use]
956    pub const fn with_interaction(mut self, interaction: InteractionConfig) -> Self {
957        self.interaction = interaction;
958        self
959    }
960
961    /// Returns a copy with another execution section.
962    #[must_use]
963    pub const fn with_execution(mut self, execution: ExecutionConfig) -> Self {
964        self.execution = execution;
965        self
966    }
967
968    /// Returns a copy with another observability section.
969    #[must_use]
970    pub const fn with_observability(mut self, observability: ObservabilityConfig) -> Self {
971        self.observability = observability;
972        self
973    }
974
975    /// Returns a copy with another privacy section.
976    #[must_use]
977    pub const fn with_privacy(mut self, privacy: PrivacyConfig) -> Self {
978        self.privacy = privacy;
979        self
980    }
981
982    /// Tightens `base` with the risk classes the mode refuses (§11.4).
983    ///
984    /// The result only ever forbids more than `base` did: a mode cannot widen a
985    /// policy snapshot, and [`origin_satisfies`](turnframe_core::command::origin_satisfies)
986    /// keeps its own floor regardless of either.
987    #[must_use]
988    pub fn policy_snapshot(&self, base: PolicySnapshot) -> PolicySnapshot {
989        let mut snapshot = base;
990        for risk in self.mode.forbidden_risk_classes() {
991            if !snapshot.forbidden_risk_classes.contains(&risk) {
992                snapshot.forbidden_risk_classes.push(risk);
993            }
994        }
995        snapshot.forbidden_risk_classes.sort_unstable();
996        snapshot
997    }
998
999    /// Checks every section and the rules that span sections.
1000    ///
1001    /// Cross-section rules:
1002    ///
1003    /// * storing model prompts while the replay record is switched off asks for
1004    ///   prompts nothing will ever write;
1005    /// * sandboxed autonomy without a replay record leaves the one mode where
1006    ///   the model drives writes with no way to explain what it did (I20).
1007    pub fn validate(&self) -> Result<(), ConfigError> {
1008        self.mode.validate()?;
1009        self.understanding.validate()?;
1010        self.narration.validate()?;
1011        self.interaction.validate()?;
1012        self.execution.validate()?;
1013        self.observability.validate()?;
1014        self.privacy.validate()?;
1015        self.validate_effort()?;
1016        if self.privacy.store_model_prompts && !self.observability.record_replay {
1017            return Err(ConfigError::Contradiction {
1018                first: "privacy.store_model_prompts",
1019                second: "observability.record_replay",
1020            });
1021        }
1022        if self.mode.is_sandboxed() && !self.observability.record_replay {
1023            return Err(ConfigError::Contradiction {
1024                first: "mode.sandboxed_autonomous",
1025                second: "observability.record_replay",
1026            });
1027        }
1028        Ok(())
1029    }
1030}
1031
1032impl Default for OrchestratorConfig {
1033    fn default() -> Self {
1034        Self::conservative()
1035    }
1036}
1037
1038impl OrchestratorConfig {
1039    /// A level's budget replacing the configured one is held to the same bounds.
1040    fn validate_effort(&self) -> Result<(), ConfigError> {
1041        use turnframe_core::effort::Effort;
1042        const FIELDS: [(Effort, [&str; 4]); 3] = [
1043            (
1044                Effort::Low,
1045                [
1046                    "effort.low.budget.max_parallel",
1047                    "effort.low.budget.per_call_timeout_secs",
1048                    "effort.low.reply_budget.max_parallel",
1049                    "effort.low.reply_budget.per_call_timeout_secs",
1050                ],
1051            ),
1052            (
1053                Effort::Medium,
1054                [
1055                    "effort.medium.budget.max_parallel",
1056                    "effort.medium.budget.per_call_timeout_secs",
1057                    "effort.medium.reply_budget.max_parallel",
1058                    "effort.medium.reply_budget.per_call_timeout_secs",
1059                ],
1060            ),
1061            (
1062                Effort::High,
1063                [
1064                    "effort.high.budget.max_parallel",
1065                    "effort.high.budget.per_call_timeout_secs",
1066                    "effort.high.reply_budget.max_parallel",
1067                    "effort.high.reply_budget.per_call_timeout_secs",
1068                ],
1069            ),
1070        ];
1071        for (effort, fields) in FIELDS {
1072            let overrides = self.effort.overrides(effort);
1073            for (budget, [parallel, timeout]) in [
1074                (overrides.budget, [fields[0], fields[1]]),
1075                (overrides.reply_budget, [fields[2], fields[3]]),
1076            ] {
1077                if let Some(budget) = budget {
1078                    positive(parallel, budget.max_parallel as u64)?;
1079                    positive(timeout, budget.per_call_timeout_secs)?;
1080                }
1081            }
1082        }
1083        Ok(())
1084    }
1085}
1086
1087fn positive(field: &'static str, value: u64) -> Result<(), ConfigError> {
1088    if value == 0 {
1089        Err(ConfigError::MustBePositive { field })
1090    } else {
1091        Ok(())
1092    }
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097    use super::*;
1098
1099    fn sandbox() -> OrchestrationMode {
1100        OrchestrationMode::sandboxed_autonomous(
1101            ResourceBudget::conservative(),
1102            SandboxAcknowledgement::i_accept_unreviewed_autonomous_writes(),
1103        )
1104    }
1105
1106    #[test]
1107    fn mode_risk_table() {
1108        let sandboxed = sandbox();
1109        let table = [
1110            (RiskClass::ReadOnly, true),
1111            (RiskClass::ReversibleLowRisk, true),
1112            (RiskClass::SensitiveDataChange, true),
1113            (RiskClass::Destructive, false),
1114            (RiskClass::Irreversible, false),
1115            (RiskClass::ExternalRegulated, false),
1116        ];
1117        for (risk, sandboxed_allows) in table {
1118            assert!(OrchestrationMode::Deterministic.allows_risk(risk));
1119            assert_eq!(sandboxed.allows_risk(risk), sandboxed_allows, "{risk:?}");
1120        }
1121        assert!(
1122            OrchestrationMode::Deterministic
1123                .forbidden_risk_classes()
1124                .is_empty()
1125        );
1126    }
1127
1128    #[test]
1129    fn mode_shape_helpers() {
1130        assert!(sandbox().is_sandboxed());
1131        assert_eq!(sandbox().budget(), Some(&ResourceBudget::conservative()));
1132        assert_eq!(OrchestrationMode::Deterministic.budget(), None);
1133        assert_eq!(
1134            OrchestrationMode::default(),
1135            OrchestrationMode::Deterministic
1136        );
1137    }
1138
1139    #[test]
1140    fn a_sandbox_tightens_the_policy_snapshot_and_never_widens_it() {
1141        let config = OrchestratorConfig::conservative().with_mode(sandbox());
1142        let snapshot = config.policy_snapshot(PolicySnapshot::conservative());
1143        assert!(snapshot.is_risk_forbidden(RiskClass::Destructive));
1144        assert!(snapshot.is_risk_forbidden(RiskClass::ExternalRegulated));
1145        assert!(!snapshot.is_risk_forbidden(RiskClass::ReversibleLowRisk));
1146        // A snapshot that already forbade more keeps forbidding it.
1147        let strict = PolicySnapshot::sandbox();
1148        let merged = OrchestratorConfig::conservative().policy_snapshot(strict.clone());
1149        assert_eq!(
1150            merged.forbidden_risk_classes.len(),
1151            strict.forbidden_risk_classes.len()
1152        );
1153        assert!(merged.is_risk_forbidden(RiskClass::SensitiveDataChange));
1154    }
1155
1156    #[test]
1157    fn the_acknowledgement_is_the_only_way_into_the_sandbox() {
1158        let json = serde_json::to_value(sandbox()).unwrap();
1159        assert_eq!(json["kind"], "sandboxed_autonomous");
1160        assert_eq!(json["acknowledgement"], SANDBOX_ACKNOWLEDGEMENT);
1161        let back: OrchestrationMode = serde_json::from_value(json).unwrap();
1162        assert_eq!(back, sandbox());
1163        let mut wrong = serde_json::to_value(sandbox()).unwrap();
1164        wrong["acknowledgement"] = serde_json::json!("sure why not");
1165        assert!(serde_json::from_value::<OrchestrationMode>(wrong).is_err());
1166        let mut missing = serde_json::to_value(sandbox()).unwrap();
1167        missing
1168            .as_object_mut()
1169            .unwrap()
1170            .remove("acknowledgement")
1171            .unwrap();
1172        assert!(serde_json::from_value::<OrchestrationMode>(missing).is_err());
1173        assert_eq!(
1174            format!(
1175                "{:?}",
1176                SandboxAcknowledgement::i_accept_unreviewed_autonomous_writes()
1177            ),
1178            SANDBOX_ACKNOWLEDGEMENT
1179        );
1180    }
1181
1182    #[test]
1183    fn conservative_config_validates_and_round_trips() {
1184        let config = OrchestratorConfig::conservative();
1185        assert_eq!(config.validate(), Ok(()));
1186        assert_eq!(config, OrchestratorConfig::default());
1187        let json = serde_json::to_string(&config).unwrap();
1188        let back: OrchestratorConfig = serde_json::from_str(&json).unwrap();
1189        assert_eq!(back, config);
1190        assert!(serde_json::from_str::<OrchestratorConfig>(r#"{"extra": 1}"#).is_err());
1191        // Nothing is bounded unless a deployment bounds it.
1192        assert_eq!(config.understanding.max_acts(), None);
1193        assert_eq!(config.understanding.max_questions(), None);
1194    }
1195
1196    #[test]
1197    fn config_validation_table() {
1198        let base = OrchestratorConfig::conservative();
1199        let cases: Vec<(&str, OrchestratorConfig, Option<ConfigError>)> = vec![
1200            ("conservative", base.clone(), None),
1201            (
1202                "no acts allowed",
1203                base.clone().with_understanding(
1204                    UnderstandingConfig::conservative()
1205                        .with_plan_limits(PlanLimits::conservative().with_max_acts(Some(0))),
1206                ),
1207                Some(ConfigError::MustBePositive {
1208                    field: "understanding.plan_limits.max_acts",
1209                }),
1210            ),
1211            (
1212                "silent answers",
1213                base.clone().with_narration(NarrationConfig {
1214                    max_answer_chars: Some(0),
1215                    ..NarrationConfig::conservative()
1216                }),
1217                Some(ConfigError::MustBePositive {
1218                    field: "narration.max_answer_chars",
1219                }),
1220            ),
1221            (
1222                "selection with one candidate",
1223                base.clone().with_interaction(
1224                    InteractionConfig::conservative().with_max_selection_candidates(1),
1225                ),
1226                Some(ConfigError::MustBePositive {
1227                    field: "interaction.max_selection_candidates",
1228                }),
1229            ),
1230            (
1231                "cards expiring instantly",
1232                base.clone().with_interaction(
1233                    InteractionConfig::conservative().with_default_ttl(Some(Duration::ZERO)),
1234                ),
1235                Some(ConfigError::MustBePositive {
1236                    field: "interaction.default_ttl",
1237                }),
1238            ),
1239            (
1240                "no command budget",
1241                base.clone()
1242                    .with_execution(ExecutionConfig::conservative().with_max_commands_per_turn(0)),
1243                Some(ConfigError::MustBePositive {
1244                    field: "execution.max_commands_per_turn",
1245                }),
1246            ),
1247            (
1248                "instant timeout",
1249                base.clone().with_execution(
1250                    ExecutionConfig::conservative().with_default_timeout(Duration::ZERO),
1251                ),
1252                Some(ConfigError::MustBePositive {
1253                    field: "execution.default_timeout",
1254                }),
1255            ),
1256            (
1257                "fail open on policy",
1258                base.clone().with_execution(ExecutionConfig {
1259                    fail_closed_on_policy_store_error: false,
1260                    ..ExecutionConfig::conservative()
1261                }),
1262                Some(ConfigError::UnsafeSetting {
1263                    field: "execution.fail_closed_on_policy_store_error",
1264                    because: "there is no fail-open path for an unavailable policy source (I19)",
1265                }),
1266            ),
1267            (
1268                "impossible sampling",
1269                base.clone().with_observability(
1270                    ObservabilityConfig::conservative().with_trace_sample_per_mille(1001),
1271                ),
1272                Some(ConfigError::OutOfRange {
1273                    field: "observability.trace_sample_per_mille",
1274                }),
1275            ),
1276            (
1277                "no retention",
1278                base.clone()
1279                    .with_privacy(PrivacyConfig::conservative().with_replay_retention_days(0)),
1280                Some(ConfigError::MustBePositive {
1281                    field: "privacy.replay_retention_days",
1282                }),
1283            ),
1284            (
1285                "prompts stored but no replay",
1286                base.clone()
1287                    .with_privacy(PrivacyConfig {
1288                        store_model_prompts: true,
1289                        ..PrivacyConfig::conservative()
1290                    })
1291                    .with_observability(ObservabilityConfig {
1292                        record_replay: false,
1293                        ..ObservabilityConfig::conservative()
1294                    }),
1295                Some(ConfigError::Contradiction {
1296                    first: "privacy.store_model_prompts",
1297                    second: "observability.record_replay",
1298                }),
1299            ),
1300            (
1301                "sandbox without replay",
1302                base.clone()
1303                    .with_mode(sandbox())
1304                    .with_observability(ObservabilityConfig {
1305                        record_replay: false,
1306                        ..ObservabilityConfig::conservative()
1307                    }),
1308                Some(ConfigError::Contradiction {
1309                    first: "mode.sandboxed_autonomous",
1310                    second: "observability.record_replay",
1311                }),
1312            ),
1313            (
1314                "empty sandbox budget",
1315                base.with_mode(OrchestrationMode::sandboxed_autonomous(
1316                    ResourceBudget::conservative().with_max_model_calls(0),
1317                    SandboxAcknowledgement::i_accept_unreviewed_autonomous_writes(),
1318                )),
1319                Some(ConfigError::MustBePositive {
1320                    field: "mode.budget.max_model_calls",
1321                }),
1322            ),
1323        ];
1324        for (name, config, expected) in cases {
1325            assert_eq!(config.validate().err(), expected, "case {name}");
1326        }
1327    }
1328
1329    #[test]
1330    fn budget_builders_and_defaults() {
1331        let budget = ResourceBudget::default()
1332            .with_max_read_calls(3)
1333            .with_max_prompt_tokens(10)
1334            .with_max_wall_clock(Duration::from_secs(5));
1335        assert_eq!(budget.max_read_calls, 3);
1336        assert_eq!(budget.max_prompt_tokens, 10);
1337        assert_eq!(budget.max_wall_clock, Duration::from_secs(5));
1338        assert_eq!(budget.max_model_calls, 8);
1339        assert_eq!(
1340            ResourceBudget::conservative()
1341                .with_max_wall_clock(Duration::ZERO)
1342                .validate(),
1343            Err(ConfigError::MustBePositive {
1344                field: "mode.budget.max_wall_clock"
1345            })
1346        );
1347    }
1348
1349    #[test]
1350    fn section_builders_keep_the_rest() {
1351        let narration = NarrationConfig::conservative()
1352            .with_tone(ToneProfile::Neutral)
1353            .with_default_source_policy(SourcePolicy::AuthoritativeOnly);
1354        assert_eq!(
1355            narration.default_source_policy,
1356            SourcePolicy::AuthoritativeOnly
1357        );
1358        assert!(narration.enabled);
1359        let understanding =
1360            UnderstandingConfig::conservative().with_turn_limits(TurnLimits::conservative());
1361        assert_eq!(understanding.budget, Budget::understanding());
1362        assert_eq!(
1363            UnderstandingConfig::default(),
1364            UnderstandingConfig::conservative()
1365        );
1366        assert_eq!(NarrationConfig::default(), NarrationConfig::conservative());
1367        assert_eq!(
1368            InteractionConfig::default(),
1369            InteractionConfig::conservative()
1370        );
1371        assert_eq!(ExecutionConfig::default(), ExecutionConfig::conservative());
1372        assert_eq!(
1373            ObservabilityConfig::default(),
1374            ObservabilityConfig::conservative()
1375        );
1376        assert_eq!(PrivacyConfig::default(), PrivacyConfig::conservative());
1377    }
1378}