use super::catalog::{digest, truncate};
use super::discovery::{MAX_RESOURCE_BYTES, read_text, validate_resource};
use super::{SkillExecution, SkillManager};
use crate::{
context::BaseCtx,
extension::{hooked_call, tool_definition},
hook::DynToolHook,
};
use anda_core::{BoxError, FunctionDefinition, Resource, StateFeatures, Tool, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SkillsListArgs {
pub query: Option<String>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillListing {
pub id: String,
pub source_id: String,
pub name: String,
pub description: String,
pub active: bool,
pub explicit_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillsListOutput {
pub generation: u64,
pub skills: Vec<SkillListing>,
pub scan_truncated: bool,
pub diagnostic_count: usize,
pub next_cursor: Option<String>,
}
pub type SkillsListHook = DynToolHook<SkillsListArgs, SkillsListOutput>;
pub struct SkillsListTool {
manager: Arc<SkillManager>,
}
impl SkillsListTool {
pub const NAME: &'static str = "skills_list";
pub fn new(manager: Arc<SkillManager>) -> Self {
Self { manager }
}
async fn list(&self, args: SkillsListArgs) -> Result<SkillsListOutput, BoxError> {
if args.query.as_ref().is_some_and(|query| query.len() > 1_024) {
return Err("Skill query exceeds 1024 bytes".into());
}
self.manager.ensure_loaded().await?;
let snapshot = self.manager.catalog();
let query = args.query.unwrap_or_default().trim().to_string();
let lowered = query.to_lowercase();
let items = snapshot
.skills
.iter()
.filter(|skill| {
let exact = query == skill.name || query == skill.id;
exact
|| (skill.metadata.policy.allow_implicit_invocation
&& (query.is_empty()
|| skill.name.to_lowercase().contains(&lowered)
|| skill.description.to_lowercase().contains(&lowered)))
})
.map(|skill| SkillListing {
id: skill.id.clone(),
source_id: skill.source_id.clone(),
name: skill.name.clone(),
description: truncate(
&skill
.metadata
.interface
.short_description
.as_deref()
.unwrap_or(&skill.description)
.split_whitespace()
.collect::<Vec<_>>()
.join(" "),
512,
)
.to_string(),
active: skill.active,
explicit_only: !skill.metadata.policy.allow_implicit_invocation,
})
.collect::<Vec<_>>();
let fingerprint = digest(&serde_json::to_vec(&(
snapshot.report.generation,
&query,
&items,
))?);
let start = cursor_offset(args.cursor.as_deref(), &fingerprint, items.len())?;
let mut response = SkillsListOutput {
generation: snapshot.report.generation,
skills: vec![],
scan_truncated: snapshot.report.truncated,
diagnostic_count: snapshot.report.diagnostics.len(),
next_cursor: None,
};
for (index, item) in items.iter().enumerate().skip(start).take(20) {
response.skills.push(item.clone());
response.next_cursor =
(index + 1 < items.len()).then(|| cursor(&fingerprint, index + 1));
if response.skills.len() == 1 {
while serde_json::to_vec(&response)?.len() > self.manager.limits.response_bytes {
let description = &mut response.skills[0].description;
if description.is_empty() {
break;
}
*description = truncate(description, description.len() / 2).to_string();
}
}
if serde_json::to_vec(&response)?.len() > self.manager.limits.response_bytes {
response.skills.pop();
response.next_cursor = Some(cursor(&fingerprint, index));
if response.skills.is_empty() {
return Err("Skill metadata exceeds response budget".into());
}
break;
}
}
if serde_json::to_vec(&response)?.len() > self.manager.limits.response_bytes {
return Err("Skill list exceeds response budget".into());
}
Ok(response)
}
}
impl Tool<BaseCtx> for SkillsListTool {
type Args = SkillsListArgs;
type Output = SkillsListOutput;
fn name(&self) -> String {
Self::NAME.into()
}
fn description(&self) -> String {
"List or search available skills with stable IDs. Follow next_cursor for more results. Use exact names or IDs for explicitly requested skills; use skills_read to read their instructions and bundled resources.".into()
}
fn definition(&self) -> FunctionDefinition {
tool_definition::<Self::Args>(self.name(), self.description())
}
fn group(&self) -> Option<anda_core::ToolGroupInfo> {
Some(skill_group())
}
async fn call(
&self,
ctx: BaseCtx,
args: Self::Args,
_resources: Vec<Resource>,
) -> Result<ToolOutput<Self::Output>, BoxError> {
hooked_call(&ctx, args, |args| async {
let cancellation = ctx.cancellation_token();
tokio::select! {
_ = cancellation.cancelled() => Err("call was cancelled".into()),
result = self.list(args) => result.map(ToolOutput::new),
}
})
.await
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SkillsReadArgs {
pub skill: String,
pub resource: Option<String>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillsReadOutput {
pub skill_id: String,
pub name: String,
pub execution: SkillExecution,
pub callable: Option<String>,
pub base_dir: String,
pub resource: String,
pub fingerprint: String,
pub content: String,
pub next_cursor: Option<String>,
}
pub type SkillsReadHook = DynToolHook<SkillsReadArgs, SkillsReadOutput>;
pub struct SkillsReadTool {
manager: Arc<SkillManager>,
}
impl SkillsReadTool {
pub const NAME: &'static str = "skills_read";
pub fn new(manager: Arc<SkillManager>) -> Self {
Self { manager }
}
async fn read(&self, args: SkillsReadArgs) -> Result<SkillsReadOutput, BoxError> {
let resource = args.resource.unwrap_or_else(|| "SKILL.md".into());
validate_resource(&resource)?;
let (_summary, main_content, entry) = self.manager.read_selected(&args.skill).await?;
let content = if resource == "SKILL.md" {
main_content
} else {
let relative = entry
.skill
.base_dir
.strip_prefix(&entry.root)?
.join(&resource);
read_text(&entry.canonical_root, &relative, MAX_RESOURCE_BYTES).await?
};
let summary = self.manager.current_summary(&entry)?;
let fingerprint = digest(&serde_json::to_vec(&(
&summary.id,
&resource,
&entry.fingerprint,
&content,
))?);
let start = cursor_offset(args.cursor.as_deref(), &fingerprint, content.len())?;
if !content.is_char_boundary(start) {
return Err("Invalid skill cursor character boundary".into());
}
let mut output = SkillsReadOutput {
skill_id: summary.id,
name: summary.name,
execution: summary.execution,
callable: summary.callable,
base_dir: summary.base_dir.display().to_string(),
resource,
fingerprint,
content: content[start..].into(),
next_cursor: None,
};
if serde_json::to_vec(&output)?.len() <= self.manager.limits.response_bytes {
return Ok(output);
}
let mut low = start;
let mut high = content.len();
let mut best = None;
while low < high {
let mut end = low + (high - low).div_ceil(2);
while !content.is_char_boundary(end) {
end += 1;
}
output.content = content[start..end].into();
output.next_cursor = Some(cursor(&output.fingerprint, end));
if serde_json::to_vec(&output)?.len() <= self.manager.limits.response_bytes {
low = end;
best = Some(output.clone());
} else {
high = end - 1;
while !content.is_char_boundary(high) {
high -= 1;
}
}
}
best.ok_or_else(|| "Skill response budget leaves no room for content".into())
}
}
impl Tool<BaseCtx> for SkillsReadTool {
type Args = SkillsReadArgs;
type Output = SkillsReadOutput;
fn name(&self) -> String {
Self::NAME.into()
}
fn description(&self) -> String {
"Read a skill's SKILL.md or bundled text resource by exact name or ID. Resolve resource paths relative to this skill package. Follow every next_cursor until EOF before acting on instructions. A stale cursor requires restarting the read. Reading never grants shell or file-write permissions; non-active subagent copies have no callable.".into()
}
fn definition(&self) -> FunctionDefinition {
tool_definition::<Self::Args>(self.name(), self.description())
}
fn group(&self) -> Option<anda_core::ToolGroupInfo> {
Some(skill_group())
}
async fn call(
&self,
ctx: BaseCtx,
args: Self::Args,
_resources: Vec<Resource>,
) -> Result<ToolOutput<Self::Output>, BoxError> {
hooked_call(&ctx, args, |args| async {
let cancellation = ctx.cancellation_token();
tokio::select! {
_ = cancellation.cancelled() => Err("call was cancelled".into()),
result = self.read(args) => result.map(ToolOutput::new),
}
})
.await
}
}
fn cursor(fingerprint: &str, offset: usize) -> String {
format!("{fingerprint}:{offset}")
}
fn cursor_offset(value: Option<&str>, fingerprint: &str, length: usize) -> Result<usize, BoxError> {
let Some(value) = value else {
return Ok(0);
};
if value.len() > 96 {
return Err("Invalid skill cursor".into());
}
let (hash, offset) = value.split_once(':').ok_or("Invalid skill cursor")?;
if hash != fingerprint {
return Err("Skill cursor is stale; restart without cursor".into());
}
let offset = offset
.parse::<usize>()
.map_err(|_| "Invalid skill cursor offset")?;
if offset > length {
return Err("Invalid skill cursor offset".into());
}
Ok(offset)
}
pub(super) fn skill_group() -> anda_core::ToolGroupInfo {
anda_core::ToolGroupInfo {
id: "skills".into(), title: "Skills".into(),
description: "Discover reusable skills and read their complete instructions and bundled text resources.".into(),
instructions: Some("Use skills_list to discover identities; skills_manager reads small SKILL.md files by name; skills_read pages large documents and package resources. Follow next_cursor until EOF. Reading a skill never grants execution permissions.".into()),
}
}