Skip to main content

bamboo_server_tools/skill_runtime/
read_resource.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use serde::Deserialize;
6use serde_json::json;
7use tokio::sync::RwLock;
8
9use bamboo_llm::Config;
10use bamboo_skills::access_control;
11use bamboo_skills::resource_helpers::{
12    display_relative_path, normalize_relative_resource_path, page_text_lines, truncate_text,
13};
14use bamboo_skills::runtime_metadata::LAST_RESOURCE_READ_SUMMARY_METADATA_KEY;
15use bamboo_skills::SkillManager;
16
17use bamboo_agent_core::tools::{Tool, ToolError, ToolExecutionContext, ToolResult};
18
19use super::{skill_access_error_to_tool_error, SkillToolAccess, MAX_RESOURCE_CONTENT_CHARS};
20
21#[derive(Debug, Deserialize)]
22struct ReadSkillResourceArgs {
23    skill_id: String,
24    resource_path: String,
25    #[serde(default)]
26    offset: Option<usize>,
27    #[serde(default)]
28    limit: Option<usize>,
29}
30
31pub struct ReadSkillResourceTool {
32    access: SkillToolAccess,
33}
34
35impl ReadSkillResourceTool {
36    pub fn new(
37        skill_manager: Arc<SkillManager>,
38        config: Arc<RwLock<Config>>,
39        session_repo: bamboo_engine::SessionRepository,
40    ) -> Self {
41        Self {
42            access: SkillToolAccess::new(skill_manager, config, session_repo),
43        }
44    }
45}
46
47#[async_trait]
48impl Tool for ReadSkillResourceTool {
49    fn name(&self) -> &str {
50        "read_skill_resource"
51    }
52
53    fn description(&self) -> &str {
54        "Read a resource file under a skill directory by relative resource_path."
55    }
56
57    fn parameters_schema(&self) -> serde_json::Value {
58        json!({
59            "type": "object",
60            "properties": {
61                "skill_id": {
62                    "type": "string",
63                    "description": "Skill ID that owns the resource."
64                },
65                "resource_path": {
66                    "type": "string",
67                    "description": "Relative path inside the skill folder (for example: references/policies.md)."
68                },
69                "offset": {
70                    "type": "number",
71                    "description": "Optional 0-based line offset for paged text reads."
72                },
73                "limit": {
74                    "type": "number",
75                    "description": "Optional line limit for paged text reads."
76                }
77            },
78            "required": ["skill_id", "resource_path"]
79        })
80    }
81
82    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
83        self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
84            .await
85    }
86
87    async fn execute_with_context(
88        &self,
89        args: serde_json::Value,
90        ctx: ToolExecutionContext<'_>,
91    ) -> Result<ToolResult, ToolError> {
92        let parsed: ReadSkillResourceArgs = serde_json::from_value(args).map_err(|err| {
93            ToolError::InvalidArguments(format!("Invalid read_skill_resource args: {err}"))
94        })?;
95        let skill_id = parsed.skill_id.trim();
96        if skill_id.is_empty() {
97            return Err(ToolError::InvalidArguments(
98                "skill_id must be a non-empty string".to_string(),
99            ));
100        }
101
102        access_control::ensure_skill_allowed(&self.access, skill_id, ctx.session_id)
103            .await
104            .map_err(skill_access_error_to_tool_error)?;
105        access_control::ensure_skill_loaded(&self.access, skill_id, ctx.session_id)
106            .await
107            .map_err(skill_access_error_to_tool_error)?;
108        let skill_mode = access_control::selected_skill_mode(&self.access, ctx.session_id).await;
109
110        let resource_path = normalize_relative_resource_path(&parsed.resource_path)
111            .map_err(ToolError::InvalidArguments)?;
112        if resource_path == Path::new("SKILL.md") {
113            return Err(ToolError::InvalidArguments(
114                "Use load_skill for SKILL.md instructions; read_skill_resource is for auxiliary files"
115                    .to_string(),
116            ));
117        }
118
119        let skill_root = self
120            .access
121            .skill_root(skill_id, skill_mode.as_deref())
122            .await?;
123        let canonical_root = tokio::fs::canonicalize(&skill_root).await.map_err(|_| {
124            ToolError::Execution(format!(
125                "Skill directory not found for '{skill_id}'. Load the skill list first."
126            ))
127        })?;
128        let canonical_resource = tokio::fs::canonicalize(skill_root.join(&resource_path))
129            .await
130            .map_err(|_| {
131                ToolError::Execution(format!(
132                    "Skill resource not found: {}/{}",
133                    skill_id,
134                    display_relative_path(&resource_path)
135                ))
136            })?;
137
138        if !canonical_resource.starts_with(&canonical_root) {
139            return Err(ToolError::InvalidArguments(
140                "resource_path must stay inside the skill directory".to_string(),
141            ));
142        }
143
144        let metadata = tokio::fs::metadata(&canonical_resource)
145            .await
146            .map_err(|err| ToolError::Execution(format!("Failed to stat resource: {err}")))?;
147        if !metadata.is_file() {
148            return Err(ToolError::InvalidArguments(format!(
149                "resource_path must reference a file: {}",
150                display_relative_path(&resource_path)
151            )));
152        }
153
154        let bytes = tokio::fs::read(&canonical_resource)
155            .await
156            .map_err(|err| ToolError::Execution(format!("Failed to read skill resource: {err}")))?;
157        let size_bytes = bytes.len();
158
159        let result = match String::from_utf8(bytes) {
160            Ok(text) => {
161                let offset = parsed.offset.unwrap_or(0);
162                let (paged, start, end, total_lines) = page_text_lines(&text, offset, parsed.limit);
163                let (excerpt, truncated) = truncate_text(&paged, MAX_RESOURCE_CONTENT_CHARS);
164                let has_more = end < total_lines;
165                let summary = json!({
166                    "skill_id": skill_id,
167                    "resource_path": display_relative_path(&resource_path),
168                    "offset": start,
169                    "limit": parsed.limit,
170                    "returned_lines": end.saturating_sub(start),
171                    "total_lines": total_lines,
172                    "has_more": has_more,
173                    "truncated": truncated,
174                    "binary": false
175                });
176                if let Some(session_id) = ctx.session_id {
177                    if let Some(mut session) =
178                        self.access.session_for_context(Some(session_id)).await
179                    {
180                        session.metadata.insert(
181                            LAST_RESOURCE_READ_SUMMARY_METADATA_KEY.to_string(),
182                            summary.to_string(),
183                        );
184                        self.access.session_repo.save_and_cache(&mut session).await;
185                    }
186                }
187                json!({
188                    "skill_id": skill_id,
189                    "resource_path": display_relative_path(&resource_path),
190                    "size_bytes": size_bytes,
191                    "offset": start,
192                    "limit": parsed.limit,
193                    "returned_lines": end.saturating_sub(start),
194                    "total_lines": total_lines,
195                    "has_more": has_more,
196                    "next_offset": if has_more { Some(end) } else { None::<usize> },
197                    "truncated": truncated,
198                    "content": excerpt
199                })
200            }
201            Err(_) => json!({
202                "skill_id": skill_id,
203                "resource_path": display_relative_path(&resource_path),
204                "size_bytes": size_bytes,
205                "binary": true,
206                "message": "Resource is not UTF-8 text. Use file tools when binary handling is required."
207            }),
208        };
209
210        Ok(ToolResult {
211            success: true,
212            result: result.to_string(),
213            display_preference: Some("Collapsible".to_string()),
214            images: Vec::new(),
215        })
216    }
217}