use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use tokio::sync::RwLock;
use bamboo_llm::Config;
use bamboo_skills::access_control;
use bamboo_skills::resource_helpers::{
display_relative_path, normalize_relative_resource_path, page_text_lines, truncate_text,
};
use bamboo_skills::runtime_metadata::LAST_RESOURCE_READ_SUMMARY_METADATA_KEY;
use bamboo_skills::SkillManager;
use bamboo_agent_core::tools::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
use super::{
skill_access_error_to_tool_error, validate_runtime_activation,
validate_runtime_activation_descriptor, SkillToolAccess, MAX_RESOURCE_CONTENT_CHARS,
};
#[derive(Debug, Deserialize)]
struct ReadSkillResourceArgs {
skill_id: String,
resource_path: String,
#[serde(default)]
offset: Option<usize>,
#[serde(default)]
limit: Option<usize>,
}
pub struct ReadSkillResourceTool {
access: SkillToolAccess,
}
impl ReadSkillResourceTool {
pub fn new(
skill_manager: Arc<SkillManager>,
config: Arc<RwLock<Config>>,
session_repo: bamboo_engine::SessionRepository,
) -> Self {
Self {
access: SkillToolAccess::new(skill_manager, config, session_repo),
}
}
pub fn with_project_store(mut self, project_store: Arc<bamboo_projects::ProjectStore>) -> Self {
self.access = self.access.with_project_store(project_store);
self
}
}
#[async_trait]
impl Tool for ReadSkillResourceTool {
fn name(&self) -> &str {
"read_skill_resource"
}
fn description(&self) -> &str {
"Read a resource file under a skill directory by relative resource_path."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"skill_id": {
"type": "string",
"description": "Skill ID that owns the resource."
},
"resource_path": {
"type": "string",
"description": "Relative path inside the skill folder (for example: references/policies.md)."
},
"offset": {
"type": "number",
"description": "Optional 0-based line offset for paged text reads."
},
"limit": {
"type": "number",
"description": "Optional line limit for paged text reads."
}
},
"required": ["skill_id", "resource_path"]
})
}
async fn invoke(
&self,
args: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutcome, ToolError> {
let parsed: ReadSkillResourceArgs = serde_json::from_value(args).map_err(|err| {
ToolError::InvalidArguments(format!("Invalid read_skill_resource args: {err}"))
})?;
let skill_id = parsed.skill_id.trim();
if skill_id.is_empty() {
return Err(ToolError::InvalidArguments(
"skill_id must be a non-empty string".to_string(),
));
}
let session_id = ctx.session_id().ok_or_else(|| {
ToolError::Execution(
"read_skill_resource requires a session_id in tool context".to_string(),
)
})?;
let store = self.access.skill_store(ctx.session_id()).await?;
if !validate_runtime_activation(&self.access, store.as_ref(), session_id, skill_id).await? {
access_control::ensure_skill_allowed(&self.access, skill_id, ctx.session_id())
.await
.map_err(skill_access_error_to_tool_error)?;
}
access_control::ensure_skill_loaded(&self.access, skill_id, ctx.session_id())
.await
.map_err(skill_access_error_to_tool_error)?;
let resource_path = normalize_relative_resource_path(&parsed.resource_path)
.map_err(ToolError::InvalidArguments)?;
if resource_path == Path::new("SKILL.md") {
return Err(ToolError::InvalidArguments(
"Use load_skill for SKILL.md instructions; read_skill_resource is for auxiliary files"
.to_string(),
));
}
let (bytes, payload_descriptor) = store
.read_pinned_skill_resource_with_descriptor(session_id, skill_id, &resource_path)
.await
.map_err(|_| {
ToolError::Execution(format!(
"Skill resource not found: {}/{}",
skill_id,
display_relative_path(&resource_path)
))
})?;
validate_runtime_activation_descriptor(
&self.access,
&payload_descriptor,
session_id,
skill_id,
)
.await?;
let size_bytes = bytes.len();
let result = match String::from_utf8(bytes) {
Ok(text) => {
let offset = parsed.offset.unwrap_or(0);
let (paged, start, end, total_lines) = page_text_lines(&text, offset, parsed.limit);
let (excerpt, truncated) = truncate_text(&paged, MAX_RESOURCE_CONTENT_CHARS);
let has_more = end < total_lines;
let summary = json!({
"skill_id": skill_id,
"resource_path": display_relative_path(&resource_path),
"offset": start,
"limit": parsed.limit,
"returned_lines": end.saturating_sub(start),
"total_lines": total_lines,
"has_more": has_more,
"truncated": truncated,
"binary": false
});
if let Some(session_id) = ctx.session_id() {
if let Some(mut session) =
self.access.session_for_context(Some(session_id)).await
{
session.metadata.insert(
LAST_RESOURCE_READ_SUMMARY_METADATA_KEY.to_string(),
summary.to_string(),
);
self.access.session_repo.save_and_cache(&mut session).await;
}
}
json!({
"skill_id": skill_id,
"resource_path": display_relative_path(&resource_path),
"size_bytes": size_bytes,
"offset": start,
"limit": parsed.limit,
"returned_lines": end.saturating_sub(start),
"total_lines": total_lines,
"has_more": has_more,
"next_offset": if has_more { Some(end) } else { None::<usize> },
"truncated": truncated,
"content": excerpt
})
}
Err(_) => json!({
"skill_id": skill_id,
"resource_path": display_relative_path(&resource_path),
"size_bytes": size_bytes,
"binary": true,
"message": "Resource is not UTF-8 text. Use file tools when binary handling is required."
}),
};
Ok(ToolOutcome::Completed(ToolResult {
success: true,
result: result.to_string(),
display_preference: Some("Collapsible".to_string()),
images: Vec::new(),
}))
}
}