roder-tools 0.1.0

Agentic software development tools and SDKs for Roder.
Documentation
use std::sync::Arc;

use anyhow::bail;
use roder_api::tools::{
    ToolCall, ToolExecutionContext, ToolExecutor, ToolRegistry, ToolResult, ToolSpec,
};
use serde::Deserialize;
use serde_json::json;

use crate::backend::{WorkspaceBackendHandle, backend_from_context_or_fallback};
use crate::paging::{
    DEFAULT_PAGE_LINES, MAX_PAGE_LINES, append_continuation_instruction, clamp_limit,
    omitted_lines, page_lines, page_metadata_with_continuation,
};
use crate::response_format::ResponseFormat;
use crate::workspace::Workspace;

pub(crate) fn register(
    registry: &mut ToolRegistry,
    workspace: Workspace,
    backend: WorkspaceBackendHandle,
) -> anyhow::Result<()> {
    registry.register(Arc::new(ReadFileTool {
        workspace: workspace.clone(),
        backend: backend.clone(),
    }))?;
    registry.register(Arc::new(ListFilesTool { workspace, backend }))
}

struct ReadFileTool {
    workspace: Workspace,
    backend: WorkspaceBackendHandle,
}

#[async_trait::async_trait]
impl ToolExecutor for ReadFileTool {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "read_file".to_string(),
            description: "Read a UTF-8 text file, optionally by line range. Relative paths resolve from the workspace root."
                .to_string(),
            parameters: json!({
                "type": "object",
                "properties": {
                    "path": { "type": "string" },
                    "start_line": { "type": "integer", "minimum": 1 },
                    "limit": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": MAX_PAGE_LINES,
                        "default": DEFAULT_PAGE_LINES,
                        "description": "Maximum number of lines to return. Use start_line from the response to continue reading."
                    },
                    "response_format": ResponseFormat::schema_property()
                },
                "required": ["path"],
                "additionalProperties": false
            }),
        }
    }

    async fn execute(
        &self,
        ctx: ToolExecutionContext,
        call: ToolCall,
    ) -> anyhow::Result<ToolResult> {
        ctx.require_workspace()?;
        let args = parse::<ReadFileArgs>(&call)?;
        let backend = backend_from_context_or_fallback(&ctx, &self.workspace, &self.backend)?;
        let (path, text) = backend.read_text(&args.path).await?;
        let start_line = args.start_line.unwrap_or(1).max(1);
        let limit = clamp_limit(args.limit);
        let response_format = args.response_format.unwrap_or_default();
        let lines = text
            .lines()
            .enumerate()
            .map(|(index, line)| format!("{:>5}: {}", index + 1, response_format.format_line(line)))
            .collect::<Vec<_>>();
        let page = page_lines(&lines, start_line - 1, limit);
        let next_start_line = page.next_offset.map(|offset| offset + 1);
        let continuation_args = next_start_line.map(|next| {
            json!({
                "path": path.clone(),
                "start_line": next,
                "limit": limit,
                "response_format": response_format.as_str(),
            })
        });
        let mut text = page.text.clone();
        if let Some(args) = continuation_args.as_ref() {
            append_continuation_instruction(&mut text, &page, "read_file", args);
        }
        Ok(result(
            call,
            text,
            json!({
                "path": path,
                "start_line": start_line,
                "limit": limit,
                "response_format": response_format.as_str(),
                "shown": page.shown,
                "total_lines": page.total,
                "omitted_lines": omitted_lines(&page),
                "next_start_line": next_start_line,
                "truncated": next_start_line.is_some(),
                "continuation_tool": if next_start_line.is_some() { json!("read_file") } else { serde_json::Value::Null },
                "continuation_args": continuation_args.unwrap_or(serde_json::Value::Null),
            }),
            false,
        ))
    }
}

struct ListFilesTool {
    workspace: Workspace,
    backend: WorkspaceBackendHandle,
}

#[async_trait::async_trait]
impl ToolExecutor for ListFilesTool {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "list_files".to_string(),
            description: "List direct children of a directory with paginated output. Relative paths resolve from the workspace root."
                .to_string(),
            parameters: json!({
                "type": "object",
                "properties": {
                    "path": { "type": "string", "default": "." },
                    "offset": {
                        "type": "integer",
                        "minimum": 0,
                        "default": 0,
                        "description": "Zero-based line offset for pagination."
                    },
                    "limit": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": MAX_PAGE_LINES,
                        "default": DEFAULT_PAGE_LINES,
                        "description": "Maximum number of entries to return."
                    },
                    "response_format": ResponseFormat::schema_property()
                },
                "additionalProperties": false
            }),
        }
    }

    async fn execute(
        &self,
        ctx: ToolExecutionContext,
        call: ToolCall,
    ) -> anyhow::Result<ToolResult> {
        ctx.require_workspace()?;
        let args = parse::<ListFilesArgs>(&call)?;
        let backend = backend_from_context_or_fallback(&ctx, &self.workspace, &self.backend)?;
        let (path, names) = backend
            .list_files(args.path.as_deref().unwrap_or("."))
            .await?;
        let offset = args.offset.unwrap_or_default();
        let limit = clamp_limit(args.limit);
        let response_format = args.response_format.unwrap_or_default();
        let page = page_lines(&names, offset, limit);
        let continuation_args = page.next_offset.map(|next| {
            json!({
                "path": path.clone(),
                "offset": next,
                "limit": limit,
                "response_format": response_format.as_str(),
            })
        });
        let mut text = page.text.clone();
        if let Some(args) = continuation_args.as_ref() {
            append_continuation_instruction(&mut text, &page, "list_files", args);
        }
        let data = page_metadata_with_continuation(
            path,
            offset,
            limit,
            &page,
            "list_files",
            continuation_args.unwrap_or(serde_json::Value::Null),
        );
        let mut data = data;
        data["response_format"] = json!(response_format.as_str());
        Ok(result(call, text, data, false))
    }
}

#[derive(Deserialize)]
struct ReadFileArgs {
    path: String,
    start_line: Option<usize>,
    limit: Option<usize>,
    response_format: Option<ResponseFormat>,
}

#[derive(Deserialize)]
struct ListFilesArgs {
    path: Option<String>,
    offset: Option<usize>,
    limit: Option<usize>,
    response_format: Option<ResponseFormat>,
}

pub(crate) fn parse<T: for<'de> Deserialize<'de>>(call: &ToolCall) -> anyhow::Result<T> {
    serde_json::from_value(call.arguments.clone()).map_err(Into::into)
}

pub(crate) fn result(
    call: ToolCall,
    text: String,
    data: serde_json::Value,
    is_error: bool,
) -> ToolResult {
    ToolResult {
        id: call.id,
        name: call.name,
        text,
        data,
        is_error,
    }
}

pub(crate) fn require_nonempty(value: &str, name: &str) -> anyhow::Result<()> {
    if value.is_empty() {
        bail!("{name} is required");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backend::LocalWorkspaceBackend;
    use roder_api::tools::{LocalWorkspaceHandle, ToolExecutionContext};
    use std::path::{Path, PathBuf};
    use std::sync::Arc;

    #[tokio::test]
    async fn read_file_paging_result_includes_continuation_text_and_data() {
        let root = test_workspace("read-file-paging");
        let file = root.join("notes.txt");
        std::fs::write(&file, "one\ntwo\nthree\n").unwrap();
        let workspace = Workspace::new(root.clone()).unwrap();
        let tool = ReadFileTool {
            workspace: workspace.clone(),
            backend: Arc::new(LocalWorkspaceBackend::new(workspace)),
        };

        let result = tool
            .execute(
                context(&root),
                call(
                    "read_file",
                    json!({"path": "notes.txt", "start_line": 1, "limit": 2}),
                ),
            )
            .await
            .unwrap();

        assert!(result.text.contains("next_offset=2"));
        assert!(result.text.contains("call read_file"));
        assert!(result.text.contains("\"start_line\":3"));
        assert_eq!(result.data["omitted_lines"], 1);
        assert_eq!(result.data["next_start_line"], 3);
        assert_eq!(result.data["continuation_tool"], "read_file");
        assert_eq!(result.data["continuation_args"]["start_line"], 3);

        let _ = std::fs::remove_dir_all(root);
    }

    #[tokio::test]
    async fn list_files_paging_result_includes_continuation_text_and_data() {
        let root = test_workspace("list-files-paging");
        let dir = root.join("src");
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("a.rs"), "").unwrap();
        std::fs::write(dir.join("b.rs"), "").unwrap();
        let workspace = Workspace::new(root.clone()).unwrap();
        let tool = ListFilesTool {
            workspace: workspace.clone(),
            backend: Arc::new(LocalWorkspaceBackend::new(workspace)),
        };

        let result = tool
            .execute(
                context(&root),
                call("list_files", json!({"path": "src", "limit": 1})),
            )
            .await
            .unwrap();

        assert!(result.text.contains("call list_files"));
        assert!(result.text.contains("\"offset\":1"));
        assert_eq!(result.data["omitted_lines"], 1);
        assert_eq!(result.data["continuation_tool"], "list_files");
        assert_eq!(result.data["continuation_args"]["offset"], 1);

        let _ = std::fs::remove_dir_all(root);
    }

    #[tokio::test]
    async fn read_file_response_format_concise_truncates_long_lines() {
        let root = test_workspace("read-file-response-format");
        let file = root.join("notes.txt");
        std::fs::write(&file, format!("prefix {}\n", "x".repeat(400))).unwrap();
        let workspace = Workspace::new(root.clone()).unwrap();
        let tool = ReadFileTool {
            workspace: workspace.clone(),
            backend: Arc::new(LocalWorkspaceBackend::new(workspace)),
        };

        let concise = tool
            .execute(
                context(&root),
                call("read_file", json!({"path": "notes.txt"})),
            )
            .await
            .unwrap();
        let detailed = tool
            .execute(
                context(&root),
                call(
                    "read_file",
                    json!({"path": "notes.txt", "response_format": "detailed"}),
                ),
            )
            .await
            .unwrap();

        assert_eq!(concise.data["response_format"], "concise");
        assert!(concise.text.contains("..."));
        assert!(concise.text.len() < detailed.text.len());
        assert_eq!(detailed.data["response_format"], "detailed");
        assert!(!detailed.text.contains("..."));

        let _ = std::fs::remove_dir_all(root);
    }

    fn context(workspace: &Path) -> ToolExecutionContext {
        ToolExecutionContext::new(
            "thread-a",
            "turn-a",
            roder_api::policy_mode::PolicyMode::Default,
        )
        .with_workspace_handle(Arc::new(LocalWorkspaceHandle::new(workspace)))
    }

    fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
        ToolCall {
            id: format!("call-{name}"),
            name: name.to_string(),
            raw_arguments: arguments.to_string(),
            arguments,
            thread_id: "thread-a".to_string(),
            turn_id: "turn-a".to_string(),
        }
    }

    fn test_workspace(name: &str) -> PathBuf {
        let stamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("roder-tools-{name}-{stamp}"));
        let _ = std::fs::remove_dir_all(&path);
        std::fs::create_dir_all(&path).unwrap();
        path
    }
}