Skip to main content

bamboo_server_tools/skill_runtime/
mod.rs

1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use tokio::sync::RwLock;
7
8use bamboo_llm::Config;
9use bamboo_skills::access_control::{SkillAccessError, SkillSessionPort};
10use bamboo_skills::runtime_metadata::validate_pinned_activation_metadata;
11use bamboo_skills::{SkillManager, SkillStore};
12
13use bamboo_agent_core::tools::ToolError;
14use bamboo_agent_core::Session;
15use bamboo_domain::ProjectId;
16
17mod load_skill;
18mod read_resource;
19
20#[cfg(test)]
21mod tests;
22
23pub use load_skill::LoadSkillTool;
24pub use read_resource::ReadSkillResourceTool;
25
26pub(super) const MAX_RESOURCE_CONTENT_CHARS: usize = 50_000;
27
28#[derive(Clone)]
29pub(super) struct SkillToolAccess {
30    pub(super) skill_manager: Arc<SkillManager>,
31    config: Arc<RwLock<Config>>,
32    pub(super) session_repo: bamboo_engine::SessionRepository,
33    project_store: Option<Arc<bamboo_projects::ProjectStore>>,
34}
35
36struct SessionSkillScope {
37    workspace: Option<PathBuf>,
38    project: Option<(ProjectId, PathBuf)>,
39}
40
41impl SkillToolAccess {
42    pub(super) fn new(
43        skill_manager: Arc<SkillManager>,
44        config: Arc<RwLock<Config>>,
45        session_repo: bamboo_engine::SessionRepository,
46    ) -> Self {
47        Self {
48            skill_manager,
49            config,
50            session_repo,
51            project_store: None,
52        }
53    }
54
55    pub(super) fn with_project_store(
56        mut self,
57        project_store: Arc<bamboo_projects::ProjectStore>,
58    ) -> Self {
59        self.project_store = Some(project_store);
60        self
61    }
62
63    pub(super) async fn session_for_context(&self, session_id: Option<&str>) -> Option<Session> {
64        self.session_repo.load(session_id?).await
65    }
66
67    pub(super) async fn skill_store(
68        &self,
69        session_id: Option<&str>,
70    ) -> Result<Arc<SkillStore>, ToolError> {
71        let scope = self.session_skill_scope(session_id).await?;
72        match scope.project {
73            Some((project_id, project_home)) => {
74                self.skill_manager
75                    .store_for_project_workspace(
76                        &project_id,
77                        &project_home,
78                        scope.workspace.as_deref(),
79                    )
80                    .await
81            }
82            None => {
83                self.skill_manager
84                    .store_for_workspace(scope.workspace.as_deref())
85                    .await
86            }
87        }
88        .map_err(|error| {
89            ToolError::Execution(format!("Failed to resolve session skill store: {error}"))
90        })
91    }
92
93    async fn session_skill_scope(
94        &self,
95        session_id: Option<&str>,
96    ) -> Result<SessionSkillScope, ToolError> {
97        let session_id = session_id.ok_or_else(|| {
98            ToolError::Execution(
99                "Skill runtime tools require a session_id in tool context".to_string(),
100            )
101        })?;
102        let session = self
103            .session_for_context(Some(session_id))
104            .await
105            .ok_or_else(|| {
106                ToolError::Execution(format!(
107                    "Session '{session_id}' not found while resolving skill workspace"
108                ))
109            })?;
110        let workspace = session.workspace_path_meta().map(PathBuf::from);
111        let project = match session.project_id_meta() {
112            Some(raw_project_id) => {
113                let project_id = ProjectId::parse(&raw_project_id).map_err(|error| {
114                    ToolError::Execution(format!(
115                        "Session '{session_id}' has invalid Project identity: {error}"
116                    ))
117                })?;
118                let project_store = self.project_store.as_ref().ok_or_else(|| {
119                    ToolError::Execution(
120                        "Assigned Project skill resolution is unavailable".to_string(),
121                    )
122                })?;
123                project_store.get(&project_id).map_err(|error| {
124                    ToolError::Execution(format!("Assigned Project is unavailable: {error}"))
125                })?;
126                Some((
127                    project_id.clone(),
128                    project_store.paths().project_home(&project_id),
129                ))
130            }
131            None => None,
132        };
133        Ok(SessionSkillScope { workspace, project })
134    }
135
136    pub(super) async fn pin_current_activation(
137        &self,
138        session_id: &str,
139        selected_skill_ids: &[String],
140        selected_skill_mode: Option<&str>,
141    ) -> Result<(), ToolError> {
142        let scope = self.session_skill_scope(Some(session_id)).await?;
143        let result = match scope.project {
144            Some((project_id, project_home)) => {
145                self.skill_manager
146                    .pin_current_activation_for_project_workspace(
147                        &project_id,
148                        &project_home,
149                        scope.workspace.as_deref(),
150                        session_id,
151                        selected_skill_ids,
152                        selected_skill_mode,
153                    )
154                    .await
155            }
156            None => {
157                self.skill_manager
158                    .pin_current_activation_for_workspace(
159                        session_id,
160                        scope.workspace.as_deref(),
161                        selected_skill_ids,
162                        selected_skill_mode,
163                    )
164                    .await
165            }
166        };
167        result.map(|_| ()).map_err(|error| {
168            ToolError::Execution(format!("Failed to pin workflow activation: {error}"))
169        })
170    }
171}
172
173/// Validate Bamboo runner-owned immutable activation metadata. Returns `false`
174/// only for legacy/direct-tool callers that have no generation marker and may
175/// establish their pin lazily.
176pub(super) async fn validate_runtime_activation(
177    access: &SkillToolAccess,
178    store: &SkillStore,
179    session_id: &str,
180    skill_id: &str,
181) -> Result<bool, ToolError> {
182    let session = access
183        .session_for_context(Some(session_id))
184        .await
185        .ok_or_else(|| ToolError::Execution(format!("Session '{session_id}' not found")))?;
186    let descriptor = store.activation_descriptor(session_id).await;
187    let validated =
188        validate_pinned_activation_metadata(&session.metadata, descriptor.as_ref(), Some(skill_id))
189            .map_err(ToolError::Execution)?;
190    if validated {
191        bamboo_skills::access_control::ensure_skill_enabled(access, skill_id)
192            .await
193            .map_err(skill_access_error_to_tool_error)?;
194    }
195    Ok(validated)
196}
197
198pub(super) async fn validate_runtime_activation_descriptor(
199    access: &SkillToolAccess,
200    descriptor: &bamboo_skills::SkillActivationDescriptor,
201    session_id: &str,
202    skill_id: &str,
203) -> Result<bool, ToolError> {
204    let session = access
205        .session_for_context(Some(session_id))
206        .await
207        .ok_or_else(|| ToolError::Execution(format!("Session '{session_id}' not found")))?;
208    validate_pinned_activation_metadata(&session.metadata, Some(descriptor), Some(skill_id))
209        .map_err(ToolError::Execution)
210}
211
212#[async_trait]
213impl SkillSessionPort for SkillToolAccess {
214    async fn load_session_metadata(&self, session_id: &str) -> Option<HashMap<String, String>> {
215        self.session_for_context(Some(session_id))
216            .await
217            .map(|session| session.metadata.clone())
218    }
219
220    async fn save_metadata_updates(
221        &self,
222        session_id: &str,
223        updates: &[(String, Option<String>)],
224    ) -> Result<(), String> {
225        let mut session = self
226            .session_repo
227            .try_load(session_id)
228            .await
229            .map_err(|e| e.to_string())?
230            .ok_or_else(|| format!("Session '{session_id}' not found"))?;
231
232        for (key, value) in updates {
233            if let Some(val) = value {
234                session.metadata.insert(key.clone(), val.clone());
235            } else {
236                session.metadata.remove(key);
237            }
238        }
239
240        self.session_repo
241            .save(&mut session)
242            .await
243            .map_err(|e| e.to_string())?;
244
245        Ok(())
246    }
247
248    async fn disabled_skill_ids(&self) -> HashSet<String> {
249        let config = self.config.read().await;
250        config.disabled_skill_ids().into_iter().collect()
251    }
252}
253
254pub(super) fn skill_access_error_to_tool_error(error: SkillAccessError) -> ToolError {
255    match error {
256        SkillAccessError::NotAllowed(msg)
257        | SkillAccessError::NotLoaded(msg)
258        | SkillAccessError::SessionRequired(msg)
259        | SkillAccessError::SessionNotFound(msg)
260        | SkillAccessError::PersistenceError(msg) => ToolError::Execution(msg),
261    }
262}