bamboo_server_tools/skill_runtime/
read_resource.rs1use 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, ToolCtx, ToolError, ToolOutcome, 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 invoke(
83 &self,
84 args: serde_json::Value,
85 ctx: ToolCtx,
86 ) -> Result<ToolOutcome, ToolError> {
87 let parsed: ReadSkillResourceArgs = serde_json::from_value(args).map_err(|err| {
88 ToolError::InvalidArguments(format!("Invalid read_skill_resource args: {err}"))
89 })?;
90 let skill_id = parsed.skill_id.trim();
91 if skill_id.is_empty() {
92 return Err(ToolError::InvalidArguments(
93 "skill_id must be a non-empty string".to_string(),
94 ));
95 }
96
97 access_control::ensure_skill_allowed(&self.access, skill_id, ctx.session_id())
98 .await
99 .map_err(skill_access_error_to_tool_error)?;
100 access_control::ensure_skill_loaded(&self.access, skill_id, ctx.session_id())
101 .await
102 .map_err(skill_access_error_to_tool_error)?;
103 let skill_mode = access_control::selected_skill_mode(&self.access, ctx.session_id()).await;
104
105 let resource_path = normalize_relative_resource_path(&parsed.resource_path)
106 .map_err(ToolError::InvalidArguments)?;
107 if resource_path == Path::new("SKILL.md") {
108 return Err(ToolError::InvalidArguments(
109 "Use load_skill for SKILL.md instructions; read_skill_resource is for auxiliary files"
110 .to_string(),
111 ));
112 }
113
114 let skill_root = self
115 .access
116 .skill_root(skill_id, skill_mode.as_deref())
117 .await?;
118 let canonical_root = tokio::fs::canonicalize(&skill_root).await.map_err(|_| {
119 ToolError::Execution(format!(
120 "Skill directory not found for '{skill_id}'. Load the skill list first."
121 ))
122 })?;
123 let canonical_resource = tokio::fs::canonicalize(skill_root.join(&resource_path))
124 .await
125 .map_err(|_| {
126 ToolError::Execution(format!(
127 "Skill resource not found: {}/{}",
128 skill_id,
129 display_relative_path(&resource_path)
130 ))
131 })?;
132
133 if !canonical_resource.starts_with(&canonical_root) {
134 return Err(ToolError::InvalidArguments(
135 "resource_path must stay inside the skill directory".to_string(),
136 ));
137 }
138
139 let metadata = tokio::fs::metadata(&canonical_resource)
140 .await
141 .map_err(|err| ToolError::Execution(format!("Failed to stat resource: {err}")))?;
142 if !metadata.is_file() {
143 return Err(ToolError::InvalidArguments(format!(
144 "resource_path must reference a file: {}",
145 display_relative_path(&resource_path)
146 )));
147 }
148
149 let bytes = tokio::fs::read(&canonical_resource)
150 .await
151 .map_err(|err| ToolError::Execution(format!("Failed to read skill resource: {err}")))?;
152 let size_bytes = bytes.len();
153
154 let result = match String::from_utf8(bytes) {
155 Ok(text) => {
156 let offset = parsed.offset.unwrap_or(0);
157 let (paged, start, end, total_lines) = page_text_lines(&text, offset, parsed.limit);
158 let (excerpt, truncated) = truncate_text(&paged, MAX_RESOURCE_CONTENT_CHARS);
159 let has_more = end < total_lines;
160 let summary = json!({
161 "skill_id": skill_id,
162 "resource_path": display_relative_path(&resource_path),
163 "offset": start,
164 "limit": parsed.limit,
165 "returned_lines": end.saturating_sub(start),
166 "total_lines": total_lines,
167 "has_more": has_more,
168 "truncated": truncated,
169 "binary": false
170 });
171 if let Some(session_id) = ctx.session_id() {
172 if let Some(mut session) =
173 self.access.session_for_context(Some(session_id)).await
174 {
175 session.metadata.insert(
176 LAST_RESOURCE_READ_SUMMARY_METADATA_KEY.to_string(),
177 summary.to_string(),
178 );
179 self.access.session_repo.save_and_cache(&mut session).await;
180 }
181 }
182 json!({
183 "skill_id": skill_id,
184 "resource_path": display_relative_path(&resource_path),
185 "size_bytes": size_bytes,
186 "offset": start,
187 "limit": parsed.limit,
188 "returned_lines": end.saturating_sub(start),
189 "total_lines": total_lines,
190 "has_more": has_more,
191 "next_offset": if has_more { Some(end) } else { None::<usize> },
192 "truncated": truncated,
193 "content": excerpt
194 })
195 }
196 Err(_) => json!({
197 "skill_id": skill_id,
198 "resource_path": display_relative_path(&resource_path),
199 "size_bytes": size_bytes,
200 "binary": true,
201 "message": "Resource is not UTF-8 text. Use file tools when binary handling is required."
202 }),
203 };
204
205 Ok(ToolOutcome::Completed(ToolResult {
206 success: true,
207 result: result.to_string(),
208 display_preference: Some("Collapsible".to_string()),
209 images: Vec::new(),
210 }))
211 }
212}