roder-tools 0.1.0

Agentic software development tools and SDKs for Roder.
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use roder_api::discovery::{
    DiscoveryCacheStatus, DiscoveryCatalog, DiscoveryCatalogItem, DiscoveryPromotionRecord,
    DiscoveryPromotionState,
};
use roder_api::tools::{
    ToolCall, ToolExecutionContext, ToolExecutor, ToolRegistry, ToolResult, ToolSpec,
};
use serde::Deserialize;
use serde_json::json;
use time::OffsetDateTime;

const DEFAULT_LIMIT: usize = 50;
const MAX_READ_LINES: usize = 200;

pub fn register(registry: &mut ToolRegistry) -> anyhow::Result<()> {
    registry.register(Arc::new(DiscoveryListTool))?;
    registry.register(Arc::new(DiscoverySearchTool))?;
    registry.register(Arc::new(DiscoveryReadTool))
}

struct DiscoveryListTool;

#[async_trait::async_trait]
impl ToolExecutor for DiscoveryListTool {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "discovery.list".to_string(),
            description:
                "List lazy capability discovery catalog groups and compact item summaries."
                    .to_string(),
            parameters: json!({
                "type": "object",
                "properties": {
                    "limit": { "type": "integer", "minimum": 1, "maximum": 200 }
                },
                "x-roder": {
                    "retrievalMode": "discovery",
                    "retrievalMetadata": true
                }
            }),
        }
    }

    async fn execute(
        &self,
        _ctx: ToolExecutionContext,
        call: ToolCall,
    ) -> anyhow::Result<ToolResult> {
        let args = parse::<ListArgs>(&call)?;
        let catalog = load_catalog()?;
        let limit = args.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 200);
        let summaries = compact_items(&catalog, limit);
        let text = if summaries.is_empty() {
            "discovery catalog is empty".to_string()
        } else {
            summaries.join("\n")
        };
        Ok(ToolResult {
            id: call.id,
            name: call.name,
            text,
            data: json!({
                "catalogId": catalog.id,
                "groupCount": catalog.groups.len(),
                "hiddenItemCount": catalog.hidden_item_count,
                "retrieval_mode": "discovery",
                "items": summaries,
            }),
            is_error: false,
        })
    }
}

struct DiscoverySearchTool;

#[async_trait::async_trait]
impl ToolExecutor for DiscoverySearchTool {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "discovery.search".to_string(),
            description: "Search lazy capability discovery catalog items by name, title, description, tag, or hint."
                .to_string(),
            parameters: json!({
                "type": "object",
                "properties": {
                    "query": { "type": "string" },
                    "limit": { "type": "integer", "minimum": 1, "maximum": 200 }
                },
                "required": ["query"],
                "x-roder": {
                    "retrievalMode": "discovery",
                    "retrievalMetadata": true
                }
            }),
        }
    }

    async fn execute(
        &self,
        _ctx: ToolExecutionContext,
        call: ToolCall,
    ) -> anyhow::Result<ToolResult> {
        let args = parse::<SearchArgs>(&call)?;
        let query = args.query.trim().to_ascii_lowercase();
        if query.is_empty() {
            anyhow::bail!("query is required");
        }
        let catalog = load_catalog()?;
        let limit = args.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 200);
        let matches = catalog
            .groups
            .iter()
            .flat_map(|group| group.items.iter())
            .filter(|item| item_matches(item, &query))
            .take(limit)
            .map(item_summary)
            .collect::<Vec<_>>();
        Ok(ToolResult {
            id: call.id,
            name: call.name,
            text: if matches.is_empty() {
                format!("no discovery items matched {query:?}")
            } else {
                matches.join("\n")
            },
            data: json!({
                "query": query,
                "retrieval_mode": "discovery",
                "matches": matches,
            }),
            is_error: false,
        })
    }
}

struct DiscoveryReadTool;

#[async_trait::async_trait]
impl ToolExecutor for DiscoveryReadTool {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "discovery.read".to_string(),
            description: "Read and promote one lazy discovery catalog item by id, including bounded schema or instruction content."
                .to_string(),
            parameters: json!({
                "type": "object",
                "properties": {
                    "item_id": { "type": "string" },
                    "start_line": { "type": "integer", "minimum": 1 },
                    "limit": { "type": "integer", "minimum": 1, "maximum": 200 },
                    "promote": { "type": "boolean" }
                },
                "required": ["item_id"],
                "x-roder": {
                    "retrievalMode": "promotion",
                    "retrievalMetadata": true
                }
            }),
        }
    }

    async fn execute(
        &self,
        ctx: ToolExecutionContext,
        call: ToolCall,
    ) -> anyhow::Result<ToolResult> {
        let args = parse::<ReadArgs>(&call)?;
        let catalog = load_catalog()?;
        let root = catalog_root()?;
        let item = find_item(&catalog, &args.item_id)?;
        let limit = args
            .limit
            .unwrap_or(MAX_READ_LINES)
            .clamp(1, MAX_READ_LINES);
        let start_line = args.start_line.unwrap_or(1).max(1);
        let content = read_item_content(&root, &item, start_line, limit)?;
        let promoted = args.promote.unwrap_or(true);
        if promoted {
            record_promotion(&ctx, &item)?;
        }
        let text = format!(
            "{}\n{}\n{}",
            item_summary(&item),
            if promoted {
                "promotion: recorded for this thread"
            } else {
                "promotion: skipped"
            },
            content.text
        );
        Ok(ToolResult {
            id: call.id,
            name: call.name,
            text,
            data: json!({
                "item": item,
                "promoted": promoted,
                "retrieval_mode": "promotion",
                "page": content,
            }),
            is_error: false,
        })
    }
}

#[derive(Debug, Deserialize)]
struct ListArgs {
    limit: Option<usize>,
}

#[derive(Debug, Deserialize)]
struct SearchArgs {
    query: String,
    limit: Option<usize>,
}

#[derive(Debug, Deserialize)]
struct ReadArgs {
    item_id: String,
    start_line: Option<usize>,
    limit: Option<usize>,
    promote: Option<bool>,
}

#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ReadPage {
    text: String,
    start_line: usize,
    end_line: usize,
    total_lines: usize,
    truncated: bool,
}

fn parse<T: for<'de> Deserialize<'de>>(call: &ToolCall) -> anyhow::Result<T> {
    Ok(serde_json::from_value(call.arguments.clone())?)
}

fn load_catalog() -> anyhow::Result<DiscoveryCatalog> {
    let path = catalog_root()?.join("index.json");
    Ok(serde_json::from_str(&fs::read_to_string(&path)?)?)
}

fn catalog_root() -> anyhow::Result<PathBuf> {
    if let Ok(path) = std::env::var("RODER_DISCOVERY_CATALOG_DIR") {
        return Ok(PathBuf::from(path));
    }
    if let Some(path) = roder_data_dir() {
        return Ok(path.join("discovery"));
    }
    Ok(dirs::home_dir()
        .ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?
        .join(".roder")
        .join("discovery"))
}

fn promotion_state_dir() -> anyhow::Result<PathBuf> {
    if let Ok(path) = std::env::var("RODER_DISCOVERY_STATE_DIR") {
        return Ok(PathBuf::from(path));
    }
    if let Some(path) = roder_data_dir() {
        return Ok(path.join("threads").join("discovery-state"));
    }
    Ok(dirs::home_dir()
        .ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?
        .join(".roder")
        .join("threads")
        .join("discovery-state"))
}

fn roder_data_dir() -> Option<PathBuf> {
    std::env::var_os("RODER_DATA_DIR")
        .or_else(|| std::env::var_os("RODER_CONFIG_DIR"))
        .map(PathBuf::from)
}

fn compact_items(catalog: &DiscoveryCatalog, limit: usize) -> Vec<String> {
    catalog
        .groups
        .iter()
        .flat_map(|group| group.items.iter())
        .take(limit)
        .map(item_summary)
        .collect()
}

fn item_summary(item: &DiscoveryCatalogItem) -> String {
    format!(
        "- {} [{} {:?}/{:?}] {}",
        item.id,
        item.source.id,
        item.status,
        item.promotion,
        item.description.as_deref().unwrap_or(&item.title)
    )
}

fn item_matches(item: &DiscoveryCatalogItem, query: &str) -> bool {
    let haystack = [
        item.id.as_str(),
        item.name.as_str(),
        item.title.as_str(),
        item.description.as_deref().unwrap_or_default(),
        &item.tags.join(" "),
        &item.hints.join(" "),
    ]
    .join("\n")
    .to_ascii_lowercase();
    haystack.contains(query)
}

fn find_item(catalog: &DiscoveryCatalog, item_id: &str) -> anyhow::Result<DiscoveryCatalogItem> {
    catalog
        .groups
        .iter()
        .flat_map(|group| group.items.iter())
        .find(|item| item.id == item_id)
        .cloned()
        .ok_or_else(|| anyhow::anyhow!("discovery item not found: {item_id}"))
}

fn read_item_content(
    root: &Path,
    item: &DiscoveryCatalogItem,
    start_line: usize,
    limit: usize,
) -> anyhow::Result<ReadPage> {
    let Some(schema) = item.schema.as_ref() else {
        return Ok(ReadPage {
            text: "item has no detailed schema or instruction file".to_string(),
            start_line: 1,
            end_line: 1,
            total_lines: 1,
            truncated: false,
        });
    };
    let path = safe_catalog_path(root, &schema.uri)?;
    let text = fs::read_to_string(path)?;
    let lines = text.lines().collect::<Vec<_>>();
    let total_lines = lines.len();
    let start_index = start_line.saturating_sub(1).min(total_lines);
    let end_index = (start_index + limit).min(total_lines);
    Ok(ReadPage {
        text: lines[start_index..end_index].join("\n"),
        start_line,
        end_line: end_index,
        total_lines,
        truncated: end_index < total_lines,
    })
}

fn safe_catalog_path(root: &Path, relative: &str) -> anyhow::Result<PathBuf> {
    if relative.contains("..") || relative.starts_with('/') {
        anyhow::bail!("discovery schema path escapes catalog root");
    }
    Ok(root.join(relative))
}

fn record_promotion(ctx: &ToolExecutionContext, item: &DiscoveryCatalogItem) -> anyhow::Result<()> {
    let path = promotion_state_dir()?
        .join("discovery")
        .join("promotions.json");
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut records = if path.exists() {
        serde_json::from_str::<Vec<DiscoveryPromotionRecord>>(&fs::read_to_string(&path)?)?
    } else {
        Vec::new()
    };
    if let Some(existing) = records
        .iter_mut()
        .find(|record| record.item_id == item.id && record.thread_id == ctx.thread_id)
    {
        existing.promotion = DiscoveryPromotionState::Reused;
        existing.cache_status = DiscoveryCacheStatus::Hit;
        existing.reused_count += 1;
        existing.turn_id = Some(ctx.turn_id.clone());
        existing.timestamp = OffsetDateTime::now_utc();
    } else {
        records.push(DiscoveryPromotionRecord {
            item_id: item.id.clone(),
            group_id: item.group_id.clone(),
            thread_id: ctx.thread_id.clone(),
            turn_id: Some(ctx.turn_id.clone()),
            promotion: DiscoveryPromotionState::Promoted,
            cache_status: DiscoveryCacheStatus::Warm,
            reused_count: 0,
            timestamp: OffsetDateTime::now_utc(),
        });
    }
    fs::write(path, serde_json::to_string_pretty(&records)?)?;
    Ok(())
}

#[cfg(test)]
#[path = "discovery/tests.rs"]
mod tests;