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}