1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
41#[non_exhaustive]
42pub enum ConfigError {
43 #[error("{field} must be at least 1")]
45 MustBePositive {
46 field: &'static str,
48 },
49 #[error("{field} may not be disabled: {because}")]
51 UnsafeSetting {
52 field: &'static str,
54 because: &'static str,
56 },
57 #[error("{first} contradicts {second}")]
59 Contradiction {
60 first: &'static str,
62 second: &'static str,
64 },
65 #[error("{field} must be between 0 and 1000 per mille")]
67 OutOfRange {
68 field: &'static str,
70 },
71}
72
73pub const SANDBOX_ACKNOWLEDGEMENT: &str = "i-accept-unreviewed-autonomous-writes";
76
77#[derive(Clone, Copy, PartialEq, Eq, Hash)]
86pub struct SandboxAcknowledgement(());
87
88impl SandboxAcknowledgement {
89 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136#[non_exhaustive]
137pub struct ResourceBudget {
138 pub max_model_calls: u16,
140 pub max_read_calls: u16,
142 pub max_prompt_tokens: u64,
144 pub max_wall_clock: Duration,
146}
147
148impl ResourceBudget {
149 #[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 #[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 #[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 #[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 #[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#[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 #[default]
225 Deterministic,
226 SandboxedAutonomous {
229 budget: ResourceBudget,
231 acknowledgement: SandboxAcknowledgement,
233 },
234}
235
236impl OrchestrationMode {
237 #[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 #[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 #[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 #[must_use]
288 pub fn budget(&self) -> Option<&ResourceBudget> {
289 match self {
290 Self::SandboxedAutonomous { budget, .. } => Some(budget),
291 _ => None,
292 }
293 }
294
295 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(deny_unknown_fields)]
325#[non_exhaustive]
326pub struct AttachmentConfig {
327 pub max_files: Option<usize>,
329 pub max_total_bytes: Option<usize>,
335}
336
337impl AttachmentConfig {
338 #[must_use]
340 pub const fn conservative() -> Self {
341 Self {
342 max_files: None,
343 max_total_bytes: None,
344 }
345 }
346
347 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371#[serde(default, deny_unknown_fields)]
372#[non_exhaustive]
373pub struct UnderstandingConfig {
374 pub plan_limits: PlanLimits,
376 pub turn_limits: TurnLimits,
378 pub transcript_turns: Option<usize>,
380 pub briefing_budget: BriefingBudget,
382 pub budget: Budget,
384 pub settings: Settings,
386 pub tasks: TaskProfiles,
388}
389
390impl UnderstandingConfig {
391 #[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 #[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 #[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 #[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 #[must_use]
428 pub fn with_briefing_budget(mut self, budget: BriefingBudget) -> Self {
429 self.briefing_budget = budget;
430 self
431 }
432
433 #[must_use]
435 pub fn with_budget(mut self, budget: Budget) -> Self {
436 self.budget = budget;
437 self
438 }
439
440 #[must_use]
442 pub fn with_settings(mut self, settings: Settings) -> Self {
443 self.settings = settings;
444 self
445 }
446
447 #[must_use]
449 pub fn with_tasks(mut self, tasks: TaskProfiles) -> Self {
450 self.tasks = tasks;
451 self
452 }
453
454 #[must_use]
456 pub const fn max_acts(&self) -> Option<usize> {
457 self.plan_limits.max_acts
458 }
459
460 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(deny_unknown_fields)]
507#[non_exhaustive]
508pub struct NarrationConfig {
509 pub enabled: bool,
512 #[serde(default = "Budget::narration")]
514 pub budget: Budget,
515 pub tone: ToneProfile,
517 pub max_answer_chars: Option<usize>,
520 pub default_source_policy: SourcePolicy,
523 pub allow_retry_after_commit: bool,
526 #[serde(default)]
529 pub steps: bool,
530}
531
532impl NarrationConfig {
533 #[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 #[must_use]
554 pub const fn with_enabled(mut self, enabled: bool) -> Self {
555 self.enabled = enabled;
556 self
557 }
558
559 #[must_use]
561 pub const fn with_budget(mut self, budget: Budget) -> Self {
562 self.budget = budget;
563 self
564 }
565
566 #[must_use]
568 pub const fn with_steps(mut self, steps: bool) -> Self {
569 self.steps = steps;
570 self
571 }
572
573 #[must_use]
575 pub const fn with_tone(mut self, tone: ToneProfile) -> Self {
576 self.tone = tone;
577 self
578 }
579
580 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
610#[serde(deny_unknown_fields)]
611#[non_exhaustive]
612pub struct InteractionConfig {
613 pub default_ttl: Option<Duration>,
615 pub max_selection_candidates: usize,
621 pub selection_cards_block_the_case: bool,
632 pub confirmation_cards_bind_to_revision: bool,
634 pub restore_card_after_failed_command: bool,
643}
644
645impl InteractionConfig {
646 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(deny_unknown_fields)]
704#[non_exhaustive]
705pub struct ExecutionConfig {
706 pub default_timeout: Duration,
708 pub max_commands_per_turn: usize,
711 pub fail_closed_on_policy_store_error: bool,
713 pub allow_cross_case_partial_success: bool,
715 pub group_mutations_per_case: bool,
718}
719
720impl ExecutionConfig {
721 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
776#[serde(deny_unknown_fields)]
777#[non_exhaustive]
778pub struct ObservabilityConfig {
779 pub emit_metrics: bool,
781 pub record_replay: bool,
784 pub trace_sample_per_mille: u16,
787}
788
789impl ObservabilityConfig {
790 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
825#[serde(deny_unknown_fields)]
826#[non_exhaustive]
827pub struct PrivacyConfig {
828 pub allow_user_text_in_telemetry: bool,
830 pub store_model_prompts: bool,
832 pub replay_retention_days: u32,
834 pub redact_attachment_filenames: bool,
836}
837
838impl PrivacyConfig {
839 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
877#[serde(deny_unknown_fields)]
878#[non_exhaustive]
879pub struct OrchestratorConfig {
880 pub mode: OrchestrationMode,
882 pub understanding: UnderstandingConfig,
884 pub attachments: AttachmentConfig,
886 pub narration: NarrationConfig,
888 pub interaction: InteractionConfig,
890 pub execution: ExecutionConfig,
892 pub observability: ObservabilityConfig,
894 pub privacy: PrivacyConfig,
896 #[serde(default)]
898 pub effort: crate::effort::EffortConfig,
899}
900
901impl OrchestratorConfig {
902 #[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 #[must_use]
921 pub fn with_effort(mut self, effort: crate::effort::EffortConfig) -> Self {
922 self.effort = effort;
923 self
924 }
925
926 #[must_use]
928 pub const fn with_attachments(mut self, attachments: AttachmentConfig) -> Self {
929 self.attachments = attachments;
930 self
931 }
932
933 #[must_use]
935 pub fn with_mode(mut self, mode: OrchestrationMode) -> Self {
936 self.mode = mode;
937 self
938 }
939
940 #[must_use]
942 pub fn with_understanding(mut self, understanding: UnderstandingConfig) -> Self {
943 self.understanding = understanding;
944 self
945 }
946
947 #[must_use]
949 pub const fn with_narration(mut self, narration: NarrationConfig) -> Self {
950 self.narration = narration;
951 self
952 }
953
954 #[must_use]
956 pub const fn with_interaction(mut self, interaction: InteractionConfig) -> Self {
957 self.interaction = interaction;
958 self
959 }
960
961 #[must_use]
963 pub const fn with_execution(mut self, execution: ExecutionConfig) -> Self {
964 self.execution = execution;
965 self
966 }
967
968 #[must_use]
970 pub const fn with_observability(mut self, observability: ObservabilityConfig) -> Self {
971 self.observability = observability;
972 self
973 }
974
975 #[must_use]
977 pub const fn with_privacy(mut self, privacy: PrivacyConfig) -> Self {
978 self.privacy = privacy;
979 self
980 }
981
982 #[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 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 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 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 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}