Skip to main content

bamboo_domain/session/
tool_authority.rs

1//! Durable Root tool authority, separate from prompt or permission suggestions.
2
3use super::{model_context::ModelContextResetReason, Session, SessionKind};
4
5/// Root product policy, independent of a model call's `ReasoningEffort`.
6/// Ultra delegates planning/execution and keeps verification at the Root.
7/// It is projected from existing durable Root authority, never persisted twice.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum RootThinkingMode {
11    Standard,
12    Ultra,
13}
14
15#[derive(Debug, thiserror::Error, PartialEq, Eq)]
16#[error("thinking_mode and root orchestration enabled selection conflict")]
17pub struct RootThinkingModeConflict;
18
19impl RootThinkingMode {
20    /// Optional DTO field: omission is allowed, explicit null is not a mode.
21    pub fn deserialize_selection<'de, D>(deserializer: D) -> Result<Option<Self>, D::Error>
22    where
23        D: serde::Deserializer<'de>,
24    {
25        <Self as serde::Deserialize>::deserialize(deserializer).map(Some)
26    }
27
28    pub fn from_enabled(enabled: bool) -> Self {
29        if enabled {
30            Self::Ultra
31        } else {
32            Self::Standard
33        }
34    }
35
36    pub fn enabled(self) -> bool {
37        self == Self::Ultra
38    }
39
40    /// Normalize canonical and legacy selectors before existing admission.
41    pub fn resolve_selection(
42        mode: Option<Self>,
43        enabled: Option<bool>,
44    ) -> Result<Option<bool>, RootThinkingModeConflict> {
45        if let (Some(mode), Some(enabled)) = (mode, enabled) {
46            if mode.enabled() != enabled {
47                return Err(RootThinkingModeConflict);
48            }
49        }
50        Ok(mode.map(Self::enabled).or(enabled))
51    }
52}
53
54/// Exact execution identities admitted for an orchestration-only Root.
55/// Unknown tools, external providers, and legacy aliases do not enter this
56/// list; callers first resolve a registered execution identity.
57pub const ROOT_ORCHESTRATION_TOOLS: [&str; 15] = [
58    "SubAgent",
59    "Plan",
60    "Task",
61    "session_history_current",
62    "Read",
63    "Grep",
64    "Glob",
65    "GetFileInfo",
66    "ViewImage",
67    "work_overview",
68    "work_search",
69    "work_inspect",
70    "work_changes",
71    "work_update",
72    "work_dispatch",
73];
74
75pub fn orchestration_only_allows_execution_name(execution_name: &str) -> bool {
76    ROOT_ORCHESTRATION_TOOLS.contains(&execution_name)
77}
78
79#[derive(Debug, thiserror::Error, PartialEq, Eq)]
80pub enum RootToolAuthorityError {
81    #[error("only a Root Session may select orchestration-only tool authority")]
82    NotRoot,
83    #[error("Root tool authority revision cannot advance")]
84    RevisionOverflow,
85    #[error("Root tool authority snapshot is stale or inconsistent")]
86    StaleSnapshot,
87    #[error("orchestration-only Root cannot enter while legacy Plan mode is active")]
88    LegacyPlanActive,
89    #[error("orchestration-only Root cannot use a selected Skill or Workflow")]
90    WorkflowSelected,
91}
92
93impl Session {
94    pub fn root_thinking_mode(&self) -> RootThinkingMode {
95        RootThinkingMode::from_enabled(self.root_orchestration_only_enabled())
96    }
97
98    pub fn root_orchestration_only_enabled(&self) -> bool {
99        self.kind == SessionKind::Root
100            && self.parent_session_id.is_none()
101            && self.root_orchestration_only
102    }
103
104    /// Called only by a host-owned selection, never by a model tool.
105    pub fn set_root_orchestration_only(
106        &mut self,
107        enabled: bool,
108    ) -> Result<(), RootToolAuthorityError> {
109        if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
110            return Err(RootToolAuthorityError::NotRoot);
111        }
112        if enabled {
113            self.validate_root_orchestration_compatibility()?;
114        }
115        if self.root_orchestration_only == enabled {
116            return Ok(());
117        }
118        let next = self
119            .root_tool_authority_revision
120            .checked_add(1)
121            .ok_or(RootToolAuthorityError::RevisionOverflow)?;
122        self.root_orchestration_only = enabled;
123        self.root_tool_authority_revision = next;
124        if self.model_context_state.is_some() {
125            self.reset_model_context_epoch(ModelContextResetReason::CacheScopeChanged);
126        }
127        Ok(())
128    }
129
130    /// Keep the narrow tool catalog usable when an old turn or a new request
131    /// carries a mode that requires tools outside the Root allowlist.
132    pub fn validate_root_orchestration_compatibility(&self) -> Result<(), RootToolAuthorityError> {
133        if self
134            .agent_runtime_state
135            .as_ref()
136            .is_some_and(|state| state.plan_mode.is_some())
137        {
138            return Err(RootToolAuthorityError::LegacyPlanActive);
139        }
140        if self.selected_skill_ids().is_some_and(|ids| !ids.is_empty()) {
141            return Err(RootToolAuthorityError::WorkflowSelected);
142        }
143        Ok(())
144    }
145
146    /// Defense in depth at both catalog projection and final dispatch.
147    pub fn allows_model_tool_execution(&self, execution_name: &str) -> bool {
148        !self.root_orchestration_only_enabled()
149            || orchestration_only_allows_execution_name(execution_name)
150    }
151
152    /// Refresh a running snapshot from the durable control plane before tool
153    /// dispatch. An older snapshot can never loosen a selected Root policy.
154    pub fn adopt_root_tool_authority_from(
155        &mut self,
156        latest: &Session,
157    ) -> Result<(), RootToolAuthorityError> {
158        // A tool policy revision is meaningful only within the same Session
159        // lifetime. A deleted Root ID can be recreated with a fresh birth, so
160        // matching the ID and revision alone would authorize an old SDK run.
161        fn root_id(session: &Session) -> &str {
162            if session.kind == SessionKind::Root && session.root_session_id.is_empty() {
163                session.id.as_str()
164            } else {
165                session.root_session_id.as_str()
166            }
167        }
168        if self.id != latest.id
169            || self.kind != latest.kind
170            || self.created_at != latest.created_at
171            || self.authority_identity != latest.authority_identity
172            || self.parent_session_id != latest.parent_session_id
173            || root_id(self) != root_id(latest)
174            || self.spawn_depth != latest.spawn_depth
175            || (self.root_orchestration_only && self.root_tool_authority_revision == 0)
176            || (latest.root_orchestration_only && latest.root_tool_authority_revision == 0)
177            || latest.root_tool_authority_revision < self.root_tool_authority_revision
178            || (latest.root_tool_authority_revision == self.root_tool_authority_revision
179                && latest.root_orchestration_only != self.root_orchestration_only)
180            || latest.root_mode_transition_epoch < self.root_mode_transition_epoch
181            || (latest.root_mode_transition_epoch == self.root_mode_transition_epoch
182                && latest.root_mode_operations != self.root_mode_operations)
183        {
184            return Err(RootToolAuthorityError::StaleSnapshot);
185        }
186        let selections = latest.root_tool_authority_revision - self.root_tool_authority_revision;
187        self.root_orchestration_only = latest.root_orchestration_only;
188        self.root_tool_authority_revision = latest.root_tool_authority_revision;
189        self.root_mode_transition_epoch = latest.root_mode_transition_epoch;
190        self.root_mode_operations = latest.root_mode_operations.clone();
191        if selections > 0 {
192            // A running snapshot may miss several selections, including a
193            // round trip to the same mode. Adopt the durable invalidation;
194            // resetting the local ledger once could retain an older epoch.
195            self.model_context_state = latest.model_context_state.clone();
196            self.provider_transcript.invalidate_repeated(
197                super::provider_transcript::ProviderTranscriptResetReason::CacheScopeChanged,
198                selections,
199            );
200        }
201        Ok(())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::session::{AgentRuntimeState, ModelContextState, PlanModeState, PlanModeStatus};
209
210    #[test]
211    fn thinking_mode_is_a_derived_root_policy_independent_of_effort() {
212        use crate::ReasoningEffort;
213        let mut root = Session::new("ultra-root", "model");
214        root.reasoning_effort = Some(ReasoningEffort::Max);
215        root.set_root_orchestration_prompt_enabled(true);
216        assert_eq!(root.root_thinking_mode(), RootThinkingMode::Standard);
217        root.set_root_orchestration_only(true).unwrap();
218        let persisted = serde_json::to_value(&root).unwrap();
219        assert!(
220            persisted.get("thinking_mode").is_none(),
221            "no second persisted authority"
222        );
223        let reloaded: Session = serde_json::from_value(persisted.clone()).unwrap();
224        assert_eq!(reloaded.root_thinking_mode(), RootThinkingMode::Ultra);
225        assert_eq!(reloaded.reasoning_effort, Some(ReasoningEffort::Max));
226        assert_eq!(
227            serde_json::to_value(&reloaded).unwrap(),
228            persisted,
229            "projection never writes"
230        );
231        let mut child = Session::new_child_of("ultra-child", &reloaded, "model", "child");
232        assert_eq!(child.root_thinking_mode(), RootThinkingMode::Standard);
233        assert_eq!(child.reasoning_effort, None);
234        child.root_orchestration_only = true;
235        assert_eq!(
236            child.root_thinking_mode(),
237            RootThinkingMode::Standard,
238            "raw child bool has no authority"
239        );
240        assert_eq!(
241            child.set_root_orchestration_only(true),
242            Err(RootToolAuthorityError::NotRoot)
243        );
244        assert_eq!(
245            serde_json::to_string(&RootThinkingMode::Ultra).unwrap(),
246            "\"ultra\""
247        );
248        assert_eq!(
249            serde_json::from_str::<RootThinkingMode>("\"standard\"").unwrap(),
250            RootThinkingMode::Standard
251        );
252        assert!(serde_json::from_str::<RootThinkingMode>("\"max\"").is_err());
253        assert!(serde_json::from_str::<ReasoningEffort>("\"ultra\"").is_err());
254    }
255
256    #[test]
257    fn canonical_and_legacy_thinking_selectors_share_one_boolean_request() {
258        assert_eq!(RootThinkingMode::resolve_selection(None, None), Ok(None));
259        for enabled in [false, true] {
260            let mode = RootThinkingMode::from_enabled(enabled);
261            assert_eq!(
262                RootThinkingMode::resolve_selection(Some(mode), None),
263                Ok(Some(enabled))
264            );
265            assert_eq!(
266                RootThinkingMode::resolve_selection(None, Some(enabled)),
267                Ok(Some(enabled))
268            );
269            assert_eq!(
270                RootThinkingMode::resolve_selection(Some(mode), Some(enabled)),
271                Ok(Some(enabled))
272            );
273            assert_eq!(
274                RootThinkingMode::resolve_selection(Some(mode), Some(!enabled)),
275                Err(RootThinkingModeConflict)
276            );
277        }
278    }
279
280    #[test]
281    fn root_selection_is_persistent_and_child_does_not_inherit_it() {
282        let mut root = Session::new("root", "model");
283        root.set_root_orchestration_only(true).unwrap();
284        assert_eq!(root.root_tool_authority_revision, 1);
285        let reloaded: Session =
286            serde_json::from_str(&serde_json::to_string(&root).unwrap()).unwrap();
287        assert!(reloaded.root_orchestration_only_enabled());
288        let mut child = Session::new_child_of("child", &reloaded, "model", "child");
289        assert!(!child.root_orchestration_only_enabled());
290        assert_eq!(child.root_tool_authority_revision, 0);
291        assert_eq!(
292            child.set_root_orchestration_only(false),
293            Err(RootToolAuthorityError::NotRoot)
294        );
295        assert!(child.allows_model_tool_execution("Bash"));
296    }
297
298    #[test]
299    fn root_allowlist_is_exact_and_denies_unknown_tools() {
300        let mut root = Session::new("root", "model");
301        root.set_root_orchestration_only(true).unwrap();
302        for tool in ROOT_ORCHESTRATION_TOOLS {
303            assert!(root.allows_model_tool_execution(tool), "{tool}");
304        }
305        for tool in [
306            "Bash",
307            "Edit",
308            "Write",
309            "WebFetch",
310            "workflow_run",
311            "mcp__x__read",
312            "read_file",
313            "default::Read",
314        ] {
315            assert!(!root.allows_model_tool_execution(tool), "{tool}");
316        }
317        assert_eq!(root.root_tool_authority_revision, 1);
318        root.set_root_orchestration_only(true).unwrap();
319        assert_eq!(root.root_tool_authority_revision, 1);
320        root.set_root_orchestration_only(false).unwrap();
321        assert_eq!(root.root_tool_authority_revision, 2);
322        assert!(root.allows_model_tool_execution("Bash"));
323    }
324
325    #[test]
326    fn durable_refresh_adopts_tightening_and_rejects_stale_loosen() {
327        let mut running = Session::new("root", "model");
328        let mut durable = running.clone();
329        durable.set_root_orchestration_only(true).unwrap();
330        running.adopt_root_tool_authority_from(&durable).unwrap();
331        assert!(!running.allows_model_tool_execution("Bash"));
332        assert_eq!(
333            durable.adopt_root_tool_authority_from(&Session::new("root", "model")),
334            Err(RootToolAuthorityError::StaleSnapshot)
335        );
336        durable.set_root_orchestration_only(false).unwrap();
337        running.adopt_root_tool_authority_from(&durable).unwrap();
338        assert!(running.allows_model_tool_execution("Bash"));
339    }
340
341    #[test]
342    fn durable_refresh_rejects_a_different_birth_identity_or_lineage() {
343        let running = Session::new("root", "model");
344        let mut other_birth = running.clone();
345        other_birth.created_at += chrono::Duration::microseconds(1);
346        assert_eq!(
347            running.clone().adopt_root_tool_authority_from(&other_birth),
348            Err(RootToolAuthorityError::StaleSnapshot)
349        );
350
351        let mut other_identity = running.clone();
352        other_identity.authority_identity = super::super::SessionAuthorityIdentity::Supervisor {
353            incarnation_id: uuid::Uuid::new_v4(),
354        };
355        assert_eq!(
356            running
357                .clone()
358                .adopt_root_tool_authority_from(&other_identity),
359            Err(RootToolAuthorityError::StaleSnapshot)
360        );
361
362        let mut other_lineage = running.clone();
363        other_lineage.root_session_id = "another-root".into();
364        assert_eq!(
365            running
366                .clone()
367                .adopt_root_tool_authority_from(&other_lineage),
368            Err(RootToolAuthorityError::StaleSnapshot)
369        );
370        other_lineage = running.clone();
371        other_lineage.spawn_depth = 1;
372        assert_eq!(
373            running
374                .clone()
375                .adopt_root_tool_authority_from(&other_lineage),
376            Err(RootToolAuthorityError::StaleSnapshot)
377        );
378
379        // Legacy Roots can omit this derived field without changing lineage.
380        let mut legacy = running.clone();
381        legacy.root_session_id.clear();
382        legacy.adopt_root_tool_authority_from(&running).unwrap();
383    }
384
385    #[test]
386    fn root_selection_rejects_legacy_plan_or_workflow_that_needs_other_tools() {
387        let mut root = Session::new("root", "model");
388        let runtime = AgentRuntimeState {
389            plan_mode: Some(PlanModeState {
390                entered_at: chrono::Utc::now(),
391                pre_permission_mode: "default".to_string(),
392                plan_file_path: None,
393                status: PlanModeStatus::Exploring,
394            }),
395            ..AgentRuntimeState::default()
396        };
397        root.agent_runtime_state = Some(runtime);
398        assert_eq!(
399            root.set_root_orchestration_only(true),
400            Err(RootToolAuthorityError::LegacyPlanActive)
401        );
402        root.agent_runtime_state = None;
403        root.set_selected_skill_ids(vec!["workflow".to_string()]);
404        assert_eq!(
405            root.set_root_orchestration_only(true),
406            Err(RootToolAuthorityError::WorkflowSelected)
407        );
408        root.clear_selected_skill_ids();
409        root.set_root_orchestration_only(true).unwrap();
410        root.set_selected_skill_ids(vec!["workflow".to_string()]);
411        assert_eq!(
412            root.validate_root_orchestration_compatibility(),
413            Err(RootToolAuthorityError::WorkflowSelected)
414        );
415    }
416
417    #[test]
418    fn live_authority_adoption_resets_prior_model_context_epoch() {
419        for enabled in [false, true] {
420            let mut running = Session::new("root", "model");
421            if !enabled {
422                running.set_root_orchestration_only(true).unwrap();
423            }
424            running.model_context_state = Some(ModelContextState {
425                prefix_epoch: 4,
426                cache_scope_sha256: Some("prior-provider-scope".to_string()),
427                ..ModelContextState::default()
428            });
429            let mut durable = running.clone();
430            durable.set_root_orchestration_only(enabled).unwrap();
431            running.adopt_root_tool_authority_from(&durable).unwrap();
432            let context = running.model_context_state.as_ref().unwrap();
433            assert_eq!(context.prefix_epoch, 5);
434            assert!(context.cache_scope_sha256.is_none());
435            assert_eq!(
436                context.last_reset_reason,
437                Some(ModelContextResetReason::CacheScopeChanged)
438            );
439        }
440    }
441
442    #[test]
443    fn live_authority_adoption_preserves_all_missed_mode_resets() {
444        let mut running = Session::new("root", "model");
445        running.model_context_state = Some(ModelContextState {
446            prefix_epoch: 4,
447            cache_scope_sha256: Some("old scope".into()),
448            ..Default::default()
449        });
450        running
451            .activate_provider_transcript_route(
452                super::super::provider_transcript::ProviderFamily::OpenAi,
453                super::super::provider_transcript::ProviderProtocol::OpenAiResponsesV1,
454                &"a".repeat(64),
455            )
456            .unwrap();
457        let mut durable = running.clone();
458        durable.set_root_orchestration_only(true).unwrap();
459        // A prepared round between selections makes the second reset a new
460        // ledger boundary rather than the existing pending-reset coalescing.
461        durable
462            .model_context_state
463            .as_mut()
464            .unwrap()
465            .cache_scope_sha256 = Some("new scope".into());
466        durable.set_root_orchestration_only(false).unwrap();
467        assert_eq!(running.root_thinking_mode(), durable.root_thinking_mode());
468        running.adopt_root_tool_authority_from(&durable).unwrap();
469        assert_eq!(running.model_context_state, durable.model_context_state);
470        assert_eq!(
471            running.model_context_state.as_ref().unwrap().prefix_epoch,
472            6
473        );
474        assert_eq!(running.provider_transcript, durable.provider_transcript);
475        // A recovery-only epoch advance must preserve current owner work.
476        running
477            .model_context_state
478            .as_mut()
479            .unwrap()
480            .cache_scope_sha256 = Some("owner scope".into());
481        let owner_context = running.model_context_state.clone();
482        durable.root_mode_transition_epoch += 1;
483        running.adopt_root_tool_authority_from(&durable).unwrap();
484        assert_eq!(running.model_context_state, owner_context);
485    }
486}