bamboo_server_tools/skill_runtime/
mod.rs1use 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
173pub(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}