agent-sdk 0.8.0

Rust Agent SDK for building LLM agents
Documentation
use crate::{Environment, PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;

use super::PrimitiveToolContext;

pub struct WriteTool<E: Environment> {
    ctx: PrimitiveToolContext<E>,
}

impl<E: Environment> WriteTool<E> {
    #[must_use]
    pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
        Self {
            ctx: PrimitiveToolContext::new(environment, capabilities),
        }
    }
}

#[derive(Debug, Deserialize)]
struct WriteInput {
    #[serde(alias = "file_path")]
    path: String,
    content: String,
}

impl<E: Environment + 'static> Tool<()> for WriteTool<E> {
    type Name = PrimitiveToolName;

    fn name(&self) -> PrimitiveToolName {
        PrimitiveToolName::Write
    }

    fn display_name(&self) -> &'static str {
        "Write File"
    }

    fn description(&self) -> &'static str {
        "Write content to a file. Creates the file if it doesn't exist, overwrites if it does."
    }

    fn tier(&self) -> ToolTier {
        ToolTier::Confirm
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Path to the file to write"
                },
                "content": {
                    "type": "string",
                    "description": "Content to write to the file"
                }
            },
            "required": ["path", "content"]
        })
    }

    async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
        let input: WriteInput =
            serde_json::from_value(input).context("Invalid input for write tool")?;

        let path = self.ctx.environment.resolve_path(&input.path);

        if let Err(reason) = self.ctx.capabilities.check_write(&path) {
            return Ok(ToolResult::error(format!(
                "Permission denied: cannot write to '{path}': {reason}"
            )));
        }

        let exists = self
            .ctx
            .environment
            .exists(&path)
            .await
            .context("Failed to check path existence")?;

        if exists {
            let is_dir = self
                .ctx
                .environment
                .is_dir(&path)
                .await
                .context("Failed to check if path is directory")?;

            if is_dir {
                return Ok(ToolResult::error(format!(
                    "'{path}' is a directory, cannot write"
                )));
            }
        }

        self.ctx
            .environment
            .write_file(&path, &input.content)
            .await
            .context("Failed to write file")?;

        let lines = input.content.lines().count();
        let bytes = input.content.len();

        Ok(ToolResult::success(format!(
            "Successfully wrote {lines} lines ({bytes} bytes) to '{path}'"
        )))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{AgentCapabilities, InMemoryFileSystem};

    fn create_test_tool(
        fs: Arc<InMemoryFileSystem>,
        capabilities: AgentCapabilities,
    ) -> WriteTool<InMemoryFileSystem> {
        WriteTool::new(fs, capabilities)
    }

    fn tool_ctx() -> ToolContext<()> {
        ToolContext::new(())
    }

    #[tokio::test]
    async fn writes_new_file() -> anyhow::Result<()> {
        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));

        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
        let result = tool
            .execute(
                &tool_ctx(),
                json!({"path": "/workspace/new.txt", "content": "Hello, World!"}),
            )
            .await?;

        assert!(result.success);
        assert!(result.output.contains("1 lines"));
        assert!(result.output.contains("13 bytes"));

        let content = fs.read_file("/workspace/new.txt").await?;
        assert_eq!(content, "Hello, World!");
        Ok(())
    }

    #[tokio::test]
    async fn overwrites_existing_file() -> anyhow::Result<()> {
        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
        fs.write_file("existing.txt", "old content").await?;

        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
        let result = tool
            .execute(
                &tool_ctx(),
                json!({"path": "/workspace/existing.txt", "content": "new content"}),
            )
            .await?;

        assert!(result.success);
        let content = fs.read_file("/workspace/existing.txt").await?;
        assert_eq!(content, "new content");
        Ok(())
    }

    #[tokio::test]
    async fn writes_multiline_content() -> anyhow::Result<()> {
        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
        let content = "line 1\nline 2\nline 3\nline 4";

        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
        let result = tool
            .execute(
                &tool_ctx(),
                json!({"path": "/workspace/multi.txt", "content": content}),
            )
            .await?;

        assert!(result.success);
        assert!(result.output.contains("4 lines"));
        let read_content = fs.read_file("/workspace/multi.txt").await?;
        assert_eq!(read_content, content);
        Ok(())
    }

    #[tokio::test]
    async fn errors_on_permission_denied() -> anyhow::Result<()> {
        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
        let tool = create_test_tool(fs, AgentCapabilities::read_only());

        let result = tool
            .execute(
                &tool_ctx(),
                json!({"path": "/workspace/test.txt", "content": "content"}),
            )
            .await?;

        assert!(!result.success);
        assert!(result.output.contains("Permission denied"));
        Ok(())
    }

    #[tokio::test]
    async fn errors_on_denied_paths() -> anyhow::Result<()> {
        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
        let caps = AgentCapabilities::full_access()
            .with_denied_paths(vec!["/workspace/secrets/**".into()]);

        let tool = create_test_tool(fs, caps);
        let result = tool
            .execute(
                &tool_ctx(),
                json!({"path": "/workspace/secrets/key.txt", "content": "secret"}),
            )
            .await?;

        assert!(!result.success);
        assert!(result.output.contains("Permission denied"));
        Ok(())
    }

    #[tokio::test]
    async fn errors_on_directory_target() -> anyhow::Result<()> {
        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
        fs.create_dir("/workspace/subdir").await?;

        let tool = create_test_tool(fs, AgentCapabilities::full_access());
        let result = tool
            .execute(
                &tool_ctx(),
                json!({"path": "/workspace/subdir", "content": "content"}),
            )
            .await?;

        assert!(!result.success);
        assert!(result.output.contains("is a directory"));
        Ok(())
    }

    #[tokio::test]
    async fn writes_to_nested_directory() -> anyhow::Result<()> {
        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));

        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
        let result = tool
            .execute(
                &tool_ctx(),
                json!({"path": "/workspace/deep/nested/file.txt", "content": "nested"}),
            )
            .await?;

        assert!(result.success);
        let content = fs.read_file("/workspace/deep/nested/file.txt").await?;
        assert_eq!(content, "nested");
        Ok(())
    }

    #[tokio::test]
    async fn writes_empty_content() -> anyhow::Result<()> {
        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));

        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
        let result = tool
            .execute(
                &tool_ctx(),
                json!({"path": "/workspace/empty.txt", "content": ""}),
            )
            .await?;

        assert!(result.success);
        assert!(result.output.contains("0 lines"));
        assert!(result.output.contains("0 bytes"));
        Ok(())
    }

    #[tokio::test]
    async fn tool_metadata() {
        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
        let tool = create_test_tool(fs, AgentCapabilities::full_access());

        assert_eq!(tool.name(), PrimitiveToolName::Write);
        assert_eq!(tool.tier(), ToolTier::Confirm);

        let schema = tool.input_schema();
        assert!(schema["properties"].get("path").is_some());
        assert!(schema["properties"].get("content").is_some());
    }
}