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; 16] = [
58    "SubAgent",
59    "Plan",
60    "Task",
61    "session_history_current",
62    "session_message",
63    "Read",
64    "Grep",
65    "Glob",
66    "GetFileInfo",
67    "ViewImage",
68    "work_overview",
69    "work_search",
70    "work_inspect",
71    "work_changes",
72    "work_update",
73    "work_dispatch",
74];
75
76pub fn orchestration_only_allows_execution_name(execution_name: &str) -> bool {
77    ROOT_ORCHESTRATION_TOOLS.contains(&execution_name)
78}
79
80/// Exact management tools with their own live Supervisor/target authority gate.
81/// The history executor restricts Supervisor reads to this scope too.
82pub const SUPERVISOR_ORCHESTRATION_TOOLS: [&str; 2] = ["session_control", "session_history"];
83
84pub fn orchestration_only_allows_execution_name_for_supervisor(
85    execution_name: &str,
86    executing_supervisor: bool,
87) -> bool {
88    orchestration_only_allows_execution_name(execution_name)
89        || (executing_supervisor && SUPERVISOR_ORCHESTRATION_TOOLS.contains(&execution_name))
90}
91
92#[derive(Debug, thiserror::Error, PartialEq, Eq)]
93pub enum RootToolAuthorityError {
94    #[error("only a Root Session may select orchestration-only tool authority")]
95    NotRoot,
96    #[error("Root tool authority revision cannot advance")]
97    RevisionOverflow,
98    #[error("Root tool authority snapshot is stale or inconsistent")]
99    StaleSnapshot,
100    #[error("orchestration-only Root cannot enter while legacy Plan mode is active")]
101    LegacyPlanActive,
102    #[error("orchestration-only Root cannot use a selected Skill or Workflow")]
103    WorkflowSelected,
104}
105
106impl Session {
107    pub fn root_thinking_mode(&self) -> RootThinkingMode {
108        RootThinkingMode::from_enabled(self.root_orchestration_only_enabled())
109    }
110
111    pub fn root_orchestration_only_enabled(&self) -> bool {
112        self.kind == SessionKind::Root
113            && self.parent_session_id.is_none()
114            && self.root_orchestration_only
115    }
116
117    /// Called only by a host-owned selection, never by a model tool.
118    pub fn set_root_orchestration_only(
119        &mut self,
120        enabled: bool,
121    ) -> Result<(), RootToolAuthorityError> {
122        if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
123            return Err(RootToolAuthorityError::NotRoot);
124        }
125        if enabled {
126            self.validate_root_orchestration_compatibility()?;
127        }
128        if self.root_orchestration_only == enabled {
129            return Ok(());
130        }
131        let next = self
132            .root_tool_authority_revision
133            .checked_add(1)
134            .ok_or(RootToolAuthorityError::RevisionOverflow)?;
135        self.root_orchestration_only = enabled;
136        self.root_tool_authority_revision = next;
137        if self.model_context_state.is_some() {
138            self.reset_model_context_epoch(ModelContextResetReason::CacheScopeChanged);
139        }
140        Ok(())
141    }
142
143    /// Keep the narrow tool catalog usable when an old turn or a new request
144    /// carries a mode that requires tools outside the Root allowlist.
145    pub fn validate_root_orchestration_compatibility(&self) -> Result<(), RootToolAuthorityError> {
146        if self
147            .agent_runtime_state
148            .as_ref()
149            .is_some_and(|state| state.plan_mode.is_some())
150        {
151            return Err(RootToolAuthorityError::LegacyPlanActive);
152        }
153        if self.selected_skill_ids().is_some_and(|ids| !ids.is_empty()) {
154            return Err(RootToolAuthorityError::WorkflowSelected);
155        }
156        Ok(())
157    }
158
159    /// Defense in depth at both catalog projection and final dispatch.
160    pub fn allows_model_tool_execution(&self, execution_name: &str) -> bool {
161        !self.root_orchestration_only_enabled()
162            || orchestration_only_allows_execution_name_for_supervisor(
163                execution_name,
164                self.supervisor_incarnation_id().is_some(),
165            )
166    }
167
168    /// Refresh a running snapshot from the durable control plane before tool
169    /// dispatch. An older snapshot can never loosen a selected Root policy.
170    pub fn adopt_root_tool_authority_from(
171        &mut self,
172        latest: &Session,
173    ) -> Result<(), RootToolAuthorityError> {
174        // A tool policy revision is meaningful only within the same Session
175        // lifetime. A deleted Root ID can be recreated with a fresh birth, so
176        // matching the ID and revision alone would authorize an old SDK run.
177        fn root_id(session: &Session) -> &str {
178            if session.kind == SessionKind::Root && session.root_session_id.is_empty() {
179                session.id.as_str()
180            } else {
181                session.root_session_id.as_str()
182            }
183        }
184        if self.id != latest.id
185            || self.kind != latest.kind
186            || self.created_at != latest.created_at
187            || self.authority_identity != latest.authority_identity
188            || self.parent_session_id != latest.parent_session_id
189            || root_id(self) != root_id(latest)
190            || self.spawn_depth != latest.spawn_depth
191            || (self.root_orchestration_only && self.root_tool_authority_revision == 0)
192            || (latest.root_orchestration_only && latest.root_tool_authority_revision == 0)
193            || latest.root_tool_authority_revision < self.root_tool_authority_revision
194            || (latest.root_tool_authority_revision == self.root_tool_authority_revision
195                && latest.root_orchestration_only != self.root_orchestration_only)
196            || latest.root_mode_transition_epoch < self.root_mode_transition_epoch
197            || (latest.root_mode_transition_epoch == self.root_mode_transition_epoch
198                && latest.root_mode_operations != self.root_mode_operations)
199        {
200            return Err(RootToolAuthorityError::StaleSnapshot);
201        }
202        let selections = latest.root_tool_authority_revision - self.root_tool_authority_revision;
203        self.root_orchestration_only = latest.root_orchestration_only;
204        self.root_tool_authority_revision = latest.root_tool_authority_revision;
205        self.root_mode_transition_epoch = latest.root_mode_transition_epoch;
206        self.root_mode_operations = latest.root_mode_operations.clone();
207        if selections > 0 {
208            // A running snapshot may miss several selections, including a
209            // round trip to the same mode. Adopt the durable invalidation;
210            // resetting the local ledger once could retain an older epoch.
211            self.model_context_state = latest.model_context_state.clone();
212            self.provider_transcript.invalidate_repeated(
213                super::provider_transcript::ProviderTranscriptResetReason::CacheScopeChanged,
214                selections,
215            );
216        }
217        Ok(())
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::session::{AgentRuntimeState, ModelContextState, PlanModeState, PlanModeStatus};
225
226    #[test]
227    fn trusted_supervisor_session_management_survives_thinking_mode_selection() {
228        use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
229        let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
230        supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
231            incarnation_id: uuid::Uuid::new_v4(),
232        };
233        for ultra in [false, true] {
234            supervisor.set_root_orchestration_only(ultra).unwrap();
235            let restored: Session =
236                serde_json::from_value(serde_json::to_value(&supervisor).unwrap()).unwrap();
237            for name in ["session_control", "session_history"] {
238                assert!(
239                    restored.allows_model_tool_execution(name),
240                    "trusted Supervisor lost {name} in {:?}",
241                    restored.root_thinking_mode()
242                );
243            }
244        }
245    }
246
247    #[test]
248    fn ultra_management_exception_requires_canonical_supervisor_and_exact_tool() {
249        use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
250        let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
251        supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
252            incarnation_id: uuid::Uuid::new_v4(),
253        };
254        supervisor.set_root_orchestration_only(true).unwrap();
255        for mutation in [
256            |s: &mut Session| s.authority_identity = SessionAuthorityIdentity::Ordinary,
257            |s: &mut Session| s.id = "other".into(),
258            |s: &mut Session| s.root_session_id = "other".into(),
259            |s: &mut Session| s.spawn_depth = 1,
260            |s: &mut Session| {
261                s.authority_identity = SessionAuthorityIdentity::Supervisor {
262                    incarnation_id: uuid::Uuid::nil(),
263                }
264            },
265        ] {
266            let mut invalid = supervisor.clone();
267            mutation(&mut invalid);
268            invalid.metadata.insert("role".into(), "supervisor".into());
269            for name in ["session_control", "session_history"] {
270                assert!(!invalid.allows_model_tool_execution(name));
271            }
272        }
273        for name in [
274            "default::session_control",
275            "vendor::session_control",
276            "exec_command",
277            "Write",
278        ] {
279            assert!(!supervisor.allows_model_tool_execution(name), "{name}");
280        }
281        let child = Session::new_child_of("child", &supervisor, "model", "child");
282        assert_eq!(child.supervisor_incarnation_id(), None);
283        let mut ordinary = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
284        ordinary.set_root_orchestration_only(true).unwrap();
285        ordinary.metadata.insert("role".into(), "supervisor".into());
286        for name in ["session_control", "session_history"] {
287            assert!(!ordinary.allows_model_tool_execution(name));
288        }
289    }
290
291    #[test]
292    fn thinking_mode_is_a_derived_root_policy_independent_of_effort() {
293        use crate::ReasoningEffort;
294        let mut root = Session::new("ultra-root", "model");
295        root.reasoning_effort = Some(ReasoningEffort::Max);
296        root.set_root_orchestration_prompt_enabled(true);
297        assert_eq!(root.root_thinking_mode(), RootThinkingMode::Standard);
298        root.set_root_orchestration_only(true).unwrap();
299        let persisted = serde_json::to_value(&root).unwrap();
300        assert!(
301            persisted.get("thinking_mode").is_none(),
302            "no second persisted authority"
303        );
304        let reloaded: Session = serde_json::from_value(persisted.clone()).unwrap();
305        assert_eq!(reloaded.root_thinking_mode(), RootThinkingMode::Ultra);
306        assert_eq!(reloaded.reasoning_effort, Some(ReasoningEffort::Max));
307        assert_eq!(
308            serde_json::to_value(&reloaded).unwrap(),
309            persisted,
310            "projection never writes"
311        );
312        let mut child = Session::new_child_of("ultra-child", &reloaded, "model", "child");
313        assert_eq!(child.root_thinking_mode(), RootThinkingMode::Standard);
314        assert_eq!(child.reasoning_effort, None);
315        child.root_orchestration_only = true;
316        assert_eq!(
317            child.root_thinking_mode(),
318            RootThinkingMode::Standard,
319            "raw child bool has no authority"
320        );
321        assert_eq!(
322            child.set_root_orchestration_only(true),
323            Err(RootToolAuthorityError::NotRoot)
324        );
325        assert_eq!(
326            serde_json::to_string(&RootThinkingMode::Ultra).unwrap(),
327            "\"ultra\""
328        );
329        assert_eq!(
330            serde_json::from_str::<RootThinkingMode>("\"standard\"").unwrap(),
331            RootThinkingMode::Standard
332        );
333        assert!(serde_json::from_str::<RootThinkingMode>("\"max\"").is_err());
334        assert!(serde_json::from_str::<ReasoningEffort>("\"ultra\"").is_err());
335    }
336
337    #[test]
338    fn canonical_and_legacy_thinking_selectors_share_one_boolean_request() {
339        assert_eq!(RootThinkingMode::resolve_selection(None, None), Ok(None));
340        for enabled in [false, true] {
341            let mode = RootThinkingMode::from_enabled(enabled);
342            assert_eq!(
343                RootThinkingMode::resolve_selection(Some(mode), None),
344                Ok(Some(enabled))
345            );
346            assert_eq!(
347                RootThinkingMode::resolve_selection(None, Some(enabled)),
348                Ok(Some(enabled))
349            );
350            assert_eq!(
351                RootThinkingMode::resolve_selection(Some(mode), Some(enabled)),
352                Ok(Some(enabled))
353            );
354            assert_eq!(
355                RootThinkingMode::resolve_selection(Some(mode), Some(!enabled)),
356                Err(RootThinkingModeConflict)
357            );
358        }
359    }
360
361    #[test]
362    fn root_selection_is_persistent_and_child_does_not_inherit_it() {
363        let mut root = Session::new("root", "model");
364        root.set_root_orchestration_only(true).unwrap();
365        assert_eq!(root.root_tool_authority_revision, 1);
366        let reloaded: Session =
367            serde_json::from_str(&serde_json::to_string(&root).unwrap()).unwrap();
368        assert!(reloaded.root_orchestration_only_enabled());
369        let mut child = Session::new_child_of("child", &reloaded, "model", "child");
370        assert!(!child.root_orchestration_only_enabled());
371        assert_eq!(child.root_tool_authority_revision, 0);
372        assert_eq!(
373            child.set_root_orchestration_only(false),
374            Err(RootToolAuthorityError::NotRoot)
375        );
376        assert!(child.allows_model_tool_execution("Bash"));
377    }
378
379    #[test]
380    fn root_allowlist_is_exact_and_denies_unknown_tools() {
381        let mut root = Session::new("root", "model");
382        root.set_root_orchestration_only(true).unwrap();
383        for tool in ROOT_ORCHESTRATION_TOOLS {
384            assert!(root.allows_model_tool_execution(tool), "{tool}");
385        }
386        for tool in [
387            "exec_command",
388            "write_stdin",
389            "Bash",
390            "Edit",
391            "Write",
392            "WebFetch",
393            "workflow_run",
394            "mcp__x__read",
395            "read_file",
396            "default::Read",
397        ] {
398            assert!(!root.allows_model_tool_execution(tool), "{tool}");
399        }
400        assert_eq!(root.root_tool_authority_revision, 1);
401        root.set_root_orchestration_only(true).unwrap();
402        assert_eq!(root.root_tool_authority_revision, 1);
403        root.set_root_orchestration_only(false).unwrap();
404        assert_eq!(root.root_tool_authority_revision, 2);
405        assert!(root.allows_model_tool_execution("Bash"));
406    }
407
408    #[test]
409    fn durable_refresh_adopts_tightening_and_rejects_stale_loosen() {
410        let mut running = Session::new("root", "model");
411        let mut durable = running.clone();
412        durable.set_root_orchestration_only(true).unwrap();
413        running.adopt_root_tool_authority_from(&durable).unwrap();
414        assert!(!running.allows_model_tool_execution("Bash"));
415        assert_eq!(
416            durable.adopt_root_tool_authority_from(&Session::new("root", "model")),
417            Err(RootToolAuthorityError::StaleSnapshot)
418        );
419        durable.set_root_orchestration_only(false).unwrap();
420        running.adopt_root_tool_authority_from(&durable).unwrap();
421        assert!(running.allows_model_tool_execution("Bash"));
422    }
423
424    #[test]
425    fn durable_refresh_rejects_a_different_birth_identity_or_lineage() {
426        let running = Session::new("root", "model");
427        let mut other_birth = running.clone();
428        other_birth.created_at += chrono::Duration::microseconds(1);
429        assert_eq!(
430            running.clone().adopt_root_tool_authority_from(&other_birth),
431            Err(RootToolAuthorityError::StaleSnapshot)
432        );
433
434        let mut other_identity = running.clone();
435        other_identity.authority_identity = super::super::SessionAuthorityIdentity::Supervisor {
436            incarnation_id: uuid::Uuid::new_v4(),
437        };
438        assert_eq!(
439            running
440                .clone()
441                .adopt_root_tool_authority_from(&other_identity),
442            Err(RootToolAuthorityError::StaleSnapshot)
443        );
444
445        let mut other_lineage = running.clone();
446        other_lineage.root_session_id = "another-root".into();
447        assert_eq!(
448            running
449                .clone()
450                .adopt_root_tool_authority_from(&other_lineage),
451            Err(RootToolAuthorityError::StaleSnapshot)
452        );
453        other_lineage = running.clone();
454        other_lineage.spawn_depth = 1;
455        assert_eq!(
456            running
457                .clone()
458                .adopt_root_tool_authority_from(&other_lineage),
459            Err(RootToolAuthorityError::StaleSnapshot)
460        );
461
462        // Legacy Roots can omit this derived field without changing lineage.
463        let mut legacy = running.clone();
464        legacy.root_session_id.clear();
465        legacy.adopt_root_tool_authority_from(&running).unwrap();
466    }
467
468    #[test]
469    fn root_selection_rejects_legacy_plan_or_workflow_that_needs_other_tools() {
470        let mut root = Session::new("root", "model");
471        let runtime = AgentRuntimeState {
472            plan_mode: Some(PlanModeState {
473                entered_at: chrono::Utc::now(),
474                pre_permission_mode: "default".to_string(),
475                plan_file_path: None,
476                status: PlanModeStatus::Exploring,
477            }),
478            ..AgentRuntimeState::default()
479        };
480        root.agent_runtime_state = Some(runtime);
481        assert_eq!(
482            root.set_root_orchestration_only(true),
483            Err(RootToolAuthorityError::LegacyPlanActive)
484        );
485        root.agent_runtime_state = None;
486        root.set_selected_skill_ids(vec!["workflow".to_string()]);
487        assert_eq!(
488            root.set_root_orchestration_only(true),
489            Err(RootToolAuthorityError::WorkflowSelected)
490        );
491        root.clear_selected_skill_ids();
492        root.set_root_orchestration_only(true).unwrap();
493        root.set_selected_skill_ids(vec!["workflow".to_string()]);
494        assert_eq!(
495            root.validate_root_orchestration_compatibility(),
496            Err(RootToolAuthorityError::WorkflowSelected)
497        );
498    }
499
500    #[test]
501    fn live_authority_adoption_resets_prior_model_context_epoch() {
502        for enabled in [false, true] {
503            let mut running = Session::new("root", "model");
504            if !enabled {
505                running.set_root_orchestration_only(true).unwrap();
506            }
507            running.model_context_state = Some(ModelContextState {
508                prefix_epoch: 4,
509                cache_scope_sha256: Some("prior-provider-scope".to_string()),
510                ..ModelContextState::default()
511            });
512            let mut durable = running.clone();
513            durable.set_root_orchestration_only(enabled).unwrap();
514            running.adopt_root_tool_authority_from(&durable).unwrap();
515            let context = running.model_context_state.as_ref().unwrap();
516            assert_eq!(context.prefix_epoch, 5);
517            assert!(context.cache_scope_sha256.is_none());
518            assert_eq!(
519                context.last_reset_reason,
520                Some(ModelContextResetReason::CacheScopeChanged)
521            );
522        }
523    }
524
525    #[test]
526    fn live_authority_adoption_preserves_all_missed_mode_resets() {
527        let mut running = Session::new("root", "model");
528        running.model_context_state = Some(ModelContextState {
529            prefix_epoch: 4,
530            cache_scope_sha256: Some("old scope".into()),
531            ..Default::default()
532        });
533        running
534            .activate_provider_transcript_route(
535                super::super::provider_transcript::ProviderFamily::OpenAi,
536                super::super::provider_transcript::ProviderProtocol::OpenAiResponsesV1,
537                &"a".repeat(64),
538            )
539            .unwrap();
540        let mut durable = running.clone();
541        durable.set_root_orchestration_only(true).unwrap();
542        // A prepared round between selections makes the second reset a new
543        // ledger boundary rather than the existing pending-reset coalescing.
544        durable
545            .model_context_state
546            .as_mut()
547            .unwrap()
548            .cache_scope_sha256 = Some("new scope".into());
549        durable.set_root_orchestration_only(false).unwrap();
550        assert_eq!(running.root_thinking_mode(), durable.root_thinking_mode());
551        running.adopt_root_tool_authority_from(&durable).unwrap();
552        assert_eq!(running.model_context_state, durable.model_context_state);
553        assert_eq!(
554            running.model_context_state.as_ref().unwrap().prefix_epoch,
555            6
556        );
557        assert_eq!(running.provider_transcript, durable.provider_transcript);
558        // A recovery-only epoch advance must preserve current owner work.
559        running
560            .model_context_state
561            .as_mut()
562            .unwrap()
563            .cache_scope_sha256 = Some("owner scope".into());
564        let owner_context = running.model_context_state.clone();
565        durable.root_mode_transition_epoch += 1;
566        running.adopt_root_tool_authority_from(&durable).unwrap();
567        assert_eq!(running.model_context_state, owner_context);
568    }
569}