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