Skip to main content

bamboo_server/server_tools/
skill_runtime.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::json;
8use tokio::sync::RwLock;
9
10use bamboo_engine::access_control::{self, SkillAccessError, SkillSessionPort};
11use bamboo_engine::resource_helpers::{
12    display_relative_path, list_skill_resource_paths, normalize_relative_resource_path,
13    page_text_lines, truncate_text,
14};
15use bamboo_engine::runtime_metadata::LAST_RESOURCE_READ_SUMMARY_METADATA_KEY;
16use bamboo_engine::SkillManager;
17use bamboo_infrastructure::Config;
18
19use bamboo_agent_core::storage::Storage;
20use bamboo_agent_core::tools::{Tool, ToolError, ToolExecutionContext, ToolResult};
21use bamboo_agent_core::Session;
22
23const MAX_RESOURCE_CONTENT_CHARS: usize = 50_000;
24
25#[derive(Clone)]
26struct SkillToolAccess {
27    skill_manager: Arc<SkillManager>,
28    config: Arc<RwLock<Config>>,
29    sessions: Arc<RwLock<HashMap<String, Session>>>,
30    storage: Arc<dyn Storage>,
31}
32
33impl SkillToolAccess {
34    fn new(
35        skill_manager: Arc<SkillManager>,
36        config: Arc<RwLock<Config>>,
37        sessions: Arc<RwLock<HashMap<String, Session>>>,
38        storage: Arc<dyn Storage>,
39    ) -> Self {
40        Self {
41            skill_manager,
42            config,
43            sessions,
44            storage,
45        }
46    }
47
48    async fn session_for_context(&self, session_id: Option<&str>) -> Option<Session> {
49        let session_id = session_id?;
50
51        let in_memory = {
52            let sessions = self.sessions.read().await;
53            sessions.get(session_id).cloned()
54        };
55
56        match in_memory {
57            Some(session) => Some(session),
58            None => self.storage.load_session(session_id).await.ok().flatten(),
59        }
60    }
61
62    async fn skill_root(
63        &self,
64        skill_id: &str,
65        skill_mode: Option<&str>,
66    ) -> Result<PathBuf, ToolError> {
67        self.skill_manager
68            .store()
69            .get_skill_root_for_mode(skill_id, skill_mode)
70            .await
71            .map_err(|err| ToolError::Execution(format!("Failed to resolve skill root: {err}")))
72    }
73}
74
75#[async_trait]
76impl SkillSessionPort for SkillToolAccess {
77    async fn load_session_metadata(&self, session_id: &str) -> Option<HashMap<String, String>> {
78        self.session_for_context(Some(session_id))
79            .await
80            .map(|session| session.metadata.clone())
81    }
82
83    async fn save_metadata_updates(
84        &self,
85        session_id: &str,
86        updates: &[(String, Option<String>)],
87    ) -> Result<(), String> {
88        let mut session = {
89            let sessions = self.sessions.read().await;
90            sessions.get(session_id).cloned()
91        };
92
93        if session.is_none() {
94            session = self
95                .storage
96                .load_session(session_id)
97                .await
98                .map_err(|e| e.to_string())?;
99        }
100
101        let mut session = session.ok_or_else(|| format!("Session '{session_id}' not found"))?;
102
103        for (key, value) in updates {
104            if let Some(val) = value {
105                session.metadata.insert(key.clone(), val.clone());
106            } else {
107                session.metadata.remove(key);
108            }
109        }
110
111        self.storage
112            .save_session(&session)
113            .await
114            .map_err(|e| e.to_string())?;
115
116        let mut sessions = self.sessions.write().await;
117        sessions.insert(session_id.to_string(), session);
118
119        Ok(())
120    }
121
122    async fn disabled_skill_ids(&self) -> HashSet<String> {
123        let config = self.config.read().await;
124        config.disabled_skill_ids().into_iter().collect()
125    }
126}
127
128fn skill_access_error_to_tool_error(error: SkillAccessError) -> ToolError {
129    match error {
130        SkillAccessError::NotAllowed(msg)
131        | SkillAccessError::NotLoaded(msg)
132        | SkillAccessError::SessionRequired(msg)
133        | SkillAccessError::SessionNotFound(msg)
134        | SkillAccessError::PersistenceError(msg) => ToolError::Execution(msg),
135    }
136}
137
138#[derive(Debug, Deserialize)]
139struct LoadSkillArgs {
140    skill_id: String,
141}
142
143pub struct LoadSkillTool {
144    access: SkillToolAccess,
145}
146
147impl LoadSkillTool {
148    pub fn new(
149        skill_manager: Arc<SkillManager>,
150        config: Arc<RwLock<Config>>,
151        sessions: Arc<RwLock<HashMap<String, Session>>>,
152        storage: Arc<dyn Storage>,
153    ) -> Self {
154        Self {
155            access: SkillToolAccess::new(skill_manager, config, sessions, storage),
156        }
157    }
158}
159
160#[async_trait]
161impl Tool for LoadSkillTool {
162    fn name(&self) -> &str {
163        "load_skill"
164    }
165
166    fn description(&self) -> &str {
167        "Load a skill's detailed SKILL.md instructions by skill_id."
168    }
169
170    fn parameters_schema(&self) -> serde_json::Value {
171        json!({
172            "type": "object",
173            "properties": {
174                "skill_id": {
175                    "type": "string",
176                    "description": "Skill ID from the advertised skill list (for example: skill-creator)."
177                }
178            },
179            "required": ["skill_id"]
180        })
181    }
182
183    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
184        self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
185            .await
186    }
187
188    async fn execute_with_context(
189        &self,
190        args: serde_json::Value,
191        ctx: ToolExecutionContext<'_>,
192    ) -> Result<ToolResult, ToolError> {
193        let parsed: LoadSkillArgs = serde_json::from_value(args).map_err(|err| {
194            ToolError::InvalidArguments(format!("Invalid load_skill args: {err}"))
195        })?;
196        let skill_id = parsed.skill_id.trim();
197        if skill_id.is_empty() {
198            return Err(ToolError::InvalidArguments(
199                "skill_id must be a non-empty string".to_string(),
200            ));
201        }
202
203        access_control::ensure_skill_allowed(&self.access, skill_id, ctx.session_id)
204            .await
205            .map_err(skill_access_error_to_tool_error)?;
206        let skill_mode = access_control::selected_skill_mode(&self.access, ctx.session_id).await;
207
208        let skill = self
209            .access
210            .skill_manager
211            .store()
212            .get_skill_for_mode(skill_id, skill_mode.as_deref())
213            .await
214            .map_err(|err| {
215                ToolError::Execution(format!("Failed to load skill '{skill_id}': {err}"))
216            })?;
217        let skill_root = self
218            .access
219            .skill_root(skill_id, skill_mode.as_deref())
220            .await?;
221        let resources = list_skill_resource_paths(&skill_root).map_err(|err| {
222            ToolError::Execution(format!("Failed to list skill resources: {err}"))
223        })?;
224        let canonical_skill_root = tokio::fs::canonicalize(&skill_root)
225            .await
226            .unwrap_or(skill_root);
227        access_control::mark_skill_loaded(&self.access, skill_id, ctx.session_id)
228            .await
229            .map_err(skill_access_error_to_tool_error)?;
230
231        Ok(ToolResult {
232            success: true,
233            result: json!({
234                "skill_id": skill.id,
235                "name": skill.name,
236                "description": skill.description,
237                "license": skill.license,
238                "compatibility": skill.compatibility,
239                "allowed_tools": skill.tool_refs,
240                "instructions": skill.prompt,
241                "skill_base_dir": bamboo_infrastructure::paths::path_to_display_string(&canonical_skill_root),
242                "resource_files": resources
243            })
244            .to_string(),
245            display_preference: Some("Collapsible".to_string()),
246        })
247    }
248}
249
250#[derive(Debug, Deserialize)]
251struct ReadSkillResourceArgs {
252    skill_id: String,
253    resource_path: String,
254    #[serde(default)]
255    offset: Option<usize>,
256    #[serde(default)]
257    limit: Option<usize>,
258}
259
260pub struct ReadSkillResourceTool {
261    access: SkillToolAccess,
262}
263
264impl ReadSkillResourceTool {
265    pub fn new(
266        skill_manager: Arc<SkillManager>,
267        config: Arc<RwLock<Config>>,
268        sessions: Arc<RwLock<HashMap<String, Session>>>,
269        storage: Arc<dyn Storage>,
270    ) -> Self {
271        Self {
272            access: SkillToolAccess::new(skill_manager, config, sessions, storage),
273        }
274    }
275}
276
277#[async_trait]
278impl Tool for ReadSkillResourceTool {
279    fn name(&self) -> &str {
280        "read_skill_resource"
281    }
282
283    fn description(&self) -> &str {
284        "Read a resource file under a skill directory by relative resource_path."
285    }
286
287    fn parameters_schema(&self) -> serde_json::Value {
288        json!({
289            "type": "object",
290            "properties": {
291                "skill_id": {
292                    "type": "string",
293                    "description": "Skill ID that owns the resource."
294                },
295                "resource_path": {
296                    "type": "string",
297                    "description": "Relative path inside the skill folder (for example: references/policies.md)."
298                },
299                "offset": {
300                    "type": "number",
301                    "description": "Optional 0-based line offset for paged text reads."
302                },
303                "limit": {
304                    "type": "number",
305                    "description": "Optional line limit for paged text reads."
306                }
307            },
308            "required": ["skill_id", "resource_path"]
309        })
310    }
311
312    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
313        self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
314            .await
315    }
316
317    async fn execute_with_context(
318        &self,
319        args: serde_json::Value,
320        ctx: ToolExecutionContext<'_>,
321    ) -> Result<ToolResult, ToolError> {
322        let parsed: ReadSkillResourceArgs = serde_json::from_value(args).map_err(|err| {
323            ToolError::InvalidArguments(format!("Invalid read_skill_resource args: {err}"))
324        })?;
325        let skill_id = parsed.skill_id.trim();
326        if skill_id.is_empty() {
327            return Err(ToolError::InvalidArguments(
328                "skill_id must be a non-empty string".to_string(),
329            ));
330        }
331
332        access_control::ensure_skill_allowed(&self.access, skill_id, ctx.session_id)
333            .await
334            .map_err(skill_access_error_to_tool_error)?;
335        access_control::ensure_skill_loaded(&self.access, skill_id, ctx.session_id)
336            .await
337            .map_err(skill_access_error_to_tool_error)?;
338        let skill_mode = access_control::selected_skill_mode(&self.access, ctx.session_id).await;
339
340        let resource_path = normalize_relative_resource_path(&parsed.resource_path)
341            .map_err(ToolError::InvalidArguments)?;
342        if resource_path == Path::new("SKILL.md") {
343            return Err(ToolError::InvalidArguments(
344                "Use load_skill for SKILL.md instructions; read_skill_resource is for auxiliary files"
345                    .to_string(),
346            ));
347        }
348
349        let skill_root = self
350            .access
351            .skill_root(skill_id, skill_mode.as_deref())
352            .await?;
353        let canonical_root = tokio::fs::canonicalize(&skill_root).await.map_err(|_| {
354            ToolError::Execution(format!(
355                "Skill directory not found for '{skill_id}'. Load the skill list first."
356            ))
357        })?;
358        let canonical_resource = tokio::fs::canonicalize(skill_root.join(&resource_path))
359            .await
360            .map_err(|_| {
361                ToolError::Execution(format!(
362                    "Skill resource not found: {}/{}",
363                    skill_id,
364                    display_relative_path(&resource_path)
365                ))
366            })?;
367
368        if !canonical_resource.starts_with(&canonical_root) {
369            return Err(ToolError::InvalidArguments(
370                "resource_path must stay inside the skill directory".to_string(),
371            ));
372        }
373
374        let metadata = tokio::fs::metadata(&canonical_resource)
375            .await
376            .map_err(|err| ToolError::Execution(format!("Failed to stat resource: {err}")))?;
377        if !metadata.is_file() {
378            return Err(ToolError::InvalidArguments(format!(
379                "resource_path must reference a file: {}",
380                display_relative_path(&resource_path)
381            )));
382        }
383
384        let bytes = tokio::fs::read(&canonical_resource)
385            .await
386            .map_err(|err| ToolError::Execution(format!("Failed to read skill resource: {err}")))?;
387        let size_bytes = bytes.len();
388
389        let result = match String::from_utf8(bytes) {
390            Ok(text) => {
391                let offset = parsed.offset.unwrap_or(0);
392                let (paged, start, end, total_lines) = page_text_lines(&text, offset, parsed.limit);
393                let (excerpt, truncated) = truncate_text(&paged, MAX_RESOURCE_CONTENT_CHARS);
394                let has_more = end < total_lines;
395                let summary = json!({
396                    "skill_id": skill_id,
397                    "resource_path": display_relative_path(&resource_path),
398                    "offset": start,
399                    "limit": parsed.limit,
400                    "returned_lines": end.saturating_sub(start),
401                    "total_lines": total_lines,
402                    "has_more": has_more,
403                    "truncated": truncated,
404                    "binary": false
405                });
406                if let Some(session_id) = ctx.session_id {
407                    if let Some(mut session) =
408                        self.access.session_for_context(Some(session_id)).await
409                    {
410                        session.metadata.insert(
411                            LAST_RESOURCE_READ_SUMMARY_METADATA_KEY.to_string(),
412                            summary.to_string(),
413                        );
414                        let _ = self.access.storage.save_session(&session).await;
415                        let mut sessions = self.access.sessions.write().await;
416                        sessions.insert(session_id.to_string(), session);
417                    }
418                }
419                json!({
420                    "skill_id": skill_id,
421                    "resource_path": display_relative_path(&resource_path),
422                    "size_bytes": size_bytes,
423                    "offset": start,
424                    "limit": parsed.limit,
425                    "returned_lines": end.saturating_sub(start),
426                    "total_lines": total_lines,
427                    "has_more": has_more,
428                    "next_offset": if has_more { Some(end) } else { None::<usize> },
429                    "truncated": truncated,
430                    "content": excerpt
431                })
432            }
433            Err(_) => json!({
434                "skill_id": skill_id,
435                "resource_path": display_relative_path(&resource_path),
436                "size_bytes": size_bytes,
437                "binary": true,
438                "message": "Resource is not UTF-8 text. Use file tools when binary handling is required."
439            }),
440        };
441
442        Ok(ToolResult {
443            success: true,
444            result: result.to_string(),
445            display_preference: Some("Collapsible".to_string()),
446        })
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::{LoadSkillTool, ReadSkillResourceTool};
453    use bamboo_engine::access_control::{parse_loaded_skill_ids, serialize_loaded_skill_ids};
454    use bamboo_engine::runtime_metadata::{
455        LAST_LOADED_SKILL_SUMMARY_METADATA_KEY, LAST_RESOURCE_READ_SUMMARY_METADATA_KEY,
456    };
457    use std::collections::{HashMap, HashSet};
458    use std::sync::Arc;
459
460    use tokio::sync::RwLock;
461
462    use bamboo_agent_core::storage::Storage;
463    use bamboo_agent_core::tools::{Tool, ToolExecutionContext};
464    use bamboo_agent_core::Session;
465    use bamboo_engine::{SkillManager, SkillStoreConfig};
466    use bamboo_infrastructure::Config;
467
468    #[test]
469    fn parse_loaded_skill_ids_supports_json_and_csv() {
470        let from_json = parse_loaded_skill_ids(r#"["skill-b","skill-a","skill-a"]"#);
471        assert_eq!(from_json.len(), 2);
472        assert!(from_json.contains("skill-a"));
473        assert!(from_json.contains("skill-b"));
474
475        let from_csv = parse_loaded_skill_ids("skill-c, skill-d , skill-c");
476        assert_eq!(from_csv.len(), 2);
477        assert!(from_csv.contains("skill-c"));
478        assert!(from_csv.contains("skill-d"));
479    }
480
481    #[test]
482    fn serialize_loaded_skill_ids_is_stable_and_sorted() {
483        let mut ids = HashSet::new();
484        ids.insert("skill-b".to_string());
485        ids.insert("skill-a".to_string());
486
487        assert_eq!(serialize_loaded_skill_ids(&ids), r#"["skill-a","skill-b"]"#);
488    }
489
490    #[derive(Default)]
491    struct TestStorage {
492        sessions: RwLock<HashMap<String, Session>>,
493    }
494
495    #[async_trait::async_trait]
496    impl Storage for TestStorage {
497        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
498            self.sessions
499                .write()
500                .await
501                .insert(session.id.clone(), session.clone());
502            Ok(())
503        }
504
505        async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
506            Ok(self.sessions.read().await.get(session_id).cloned())
507        }
508
509        async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
510            Ok(self.sessions.write().await.remove(session_id).is_some())
511        }
512    }
513
514    #[tokio::test]
515    async fn load_skill_rejects_globally_disabled_skill() {
516        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
517        let skill_dir = temp_dir.path().join("skills").join("demo-skill");
518        std::fs::create_dir_all(&skill_dir).expect("skill dir should exist");
519        std::fs::write(
520            skill_dir.join("SKILL.md"),
521            r#"---
522name: demo-skill
523description: Demo description
524---
525Use this demo skill."#,
526        )
527        .expect("skill file should be written");
528
529        let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
530            skills_dir: temp_dir.path().join("skills"),
531            project_dir: None,
532            active_mode: None,
533        }));
534        skill_manager
535            .initialize()
536            .await
537            .expect("skill manager should initialize");
538
539        let config = Arc::new(RwLock::new(Config::default()));
540        {
541            let mut cfg = config.write().await;
542            cfg.skills.disabled = vec!["demo-skill".to_string()];
543            cfg.normalize_skill_settings();
544        }
545
546        let session_id = "session-1";
547        let session = Session::new(session_id, "model");
548        let sessions = Arc::new(RwLock::new(HashMap::from([(
549            session_id.to_string(),
550            session.clone(),
551        )])));
552        let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
553        storage
554            .save_session(&session)
555            .await
556            .expect("session should be saved");
557
558        let tool = LoadSkillTool::new(skill_manager, config, sessions, storage);
559        let ctx = ToolExecutionContext {
560            session_id: Some(session_id),
561            tool_call_id: "tool-call-1",
562            event_tx: None,
563            available_tool_schemas: None,
564        };
565
566        let error = tool
567            .execute_with_context(serde_json::json!({ "skill_id": "demo-skill" }), ctx)
568            .await
569            .expect_err("disabled skill should be rejected");
570
571        assert!(error
572            .to_string()
573            .contains("globally disabled in Bamboo settings"));
574    }
575
576    #[tokio::test]
577    async fn load_skill_persists_last_loaded_skill_summary() {
578        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
579        let skill_dir = temp_dir.path().join("skills").join("demo-skill");
580        std::fs::create_dir_all(&skill_dir).expect("skill dir should exist");
581        std::fs::write(
582            skill_dir.join("SKILL.md"),
583            r#"---
584name: demo-skill
585description: Demo description
586---
587Use this demo skill."#,
588        )
589        .expect("skill file should be written");
590
591        let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
592            skills_dir: temp_dir.path().join("skills"),
593            project_dir: None,
594            active_mode: None,
595        }));
596        skill_manager
597            .initialize()
598            .await
599            .expect("skill manager should initialize");
600
601        let config = Arc::new(RwLock::new(Config::default()));
602        let session_id = "session-2";
603        let session = Session::new(session_id, "model");
604        let sessions = Arc::new(RwLock::new(HashMap::from([(
605            session_id.to_string(),
606            session.clone(),
607        )])));
608        let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
609        storage
610            .save_session(&session)
611            .await
612            .expect("session should be saved");
613
614        let tool = LoadSkillTool::new(skill_manager, config, sessions.clone(), storage.clone());
615        let ctx = ToolExecutionContext {
616            session_id: Some(session_id),
617            tool_call_id: "tool-call-2",
618            event_tx: None,
619            available_tool_schemas: None,
620        };
621
622        let _ = tool
623            .execute_with_context(serde_json::json!({ "skill_id": "demo-skill" }), ctx)
624            .await
625            .expect("load_skill should succeed");
626
627        let saved = storage
628            .load_session(session_id)
629            .await
630            .expect("load session should succeed")
631            .expect("session should exist");
632        let summary = saved
633            .metadata
634            .get(LAST_LOADED_SKILL_SUMMARY_METADATA_KEY)
635            .expect("last loaded skill summary should be present");
636        assert!(summary.contains("demo-skill"));
637    }
638
639    #[tokio::test]
640    async fn read_skill_resource_persists_last_resource_read_summary() {
641        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
642        let skill_dir = temp_dir.path().join("skills").join("demo-skill");
643        let refs_dir = skill_dir.join("references");
644        std::fs::create_dir_all(&refs_dir).expect("references dir should exist");
645        std::fs::write(
646            skill_dir.join("SKILL.md"),
647            r#"---
648name: demo-skill
649description: Demo description
650---
651Use this demo skill."#,
652        )
653        .expect("skill file should be written");
654        std::fs::write(refs_dir.join("policy.md"), "line1\nline2\nline3\n")
655            .expect("resource file should be written");
656
657        let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
658            skills_dir: temp_dir.path().join("skills"),
659            project_dir: None,
660            active_mode: None,
661        }));
662        skill_manager
663            .initialize()
664            .await
665            .expect("skill manager should initialize");
666
667        let config = Arc::new(RwLock::new(Config::default()));
668        let session_id = "session-3";
669        let session = Session::new(session_id, "model");
670        let sessions = Arc::new(RwLock::new(HashMap::from([(
671            session_id.to_string(),
672            session.clone(),
673        )])));
674        let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
675        storage
676            .save_session(&session)
677            .await
678            .expect("session should be saved");
679
680        let load_tool = LoadSkillTool::new(
681            skill_manager.clone(),
682            config.clone(),
683            sessions.clone(),
684            storage.clone(),
685        );
686        let read_tool =
687            ReadSkillResourceTool::new(skill_manager, config, sessions, storage.clone());
688
689        let load_ctx = ToolExecutionContext {
690            session_id: Some(session_id),
691            tool_call_id: "tool-call-load",
692            event_tx: None,
693            available_tool_schemas: None,
694        };
695        let read_ctx = ToolExecutionContext {
696            session_id: Some(session_id),
697            tool_call_id: "tool-call-read",
698            event_tx: None,
699            available_tool_schemas: None,
700        };
701
702        let _ = load_tool
703            .execute_with_context(serde_json::json!({ "skill_id": "demo-skill" }), load_ctx)
704            .await
705            .expect("load_skill should succeed");
706
707        let _ = read_tool
708            .execute_with_context(
709                serde_json::json!({
710                    "skill_id": "demo-skill",
711                    "resource_path": "references/policy.md",
712                    "offset": 1,
713                    "limit": 1
714                }),
715                read_ctx,
716            )
717            .await
718            .expect("read_skill_resource should succeed");
719
720        let saved = storage
721            .load_session(session_id)
722            .await
723            .expect("load session should succeed")
724            .expect("session should exist");
725        let summary = saved
726            .metadata
727            .get(LAST_RESOURCE_READ_SUMMARY_METADATA_KEY)
728            .expect("last resource read summary should be present");
729        assert!(summary.contains("demo-skill"));
730        assert!(summary.contains("references/policy.md"));
731        assert!(summary.contains("\"offset\":1"));
732    }
733}