Skip to main content

bamboo_engine/server_tools/skill_runtime/
read_resource.rs

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