zeph-tools 0.22.0

Tool executor trait with shell, web scrape, and composite executors for Zeph
Documentation
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

use std::path::PathBuf;

use schemars::JsonSchema;
use serde::Deserialize;

use zeph_common::ToolName;

use crate::executor::{
    ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
};
use crate::file::expand_tilde;
use crate::registry::{InvocationHint, ToolDef};

const TOOL_NAME: &str = "set_working_directory";

const TOOL_DESCRIPTION: &str = "Change the agent's working directory. \
Shell commands (`bash`) run in child processes — a `cd` inside them does NOT persist. \
Use this tool when you need to change the working context for subsequent operations. \
Returns the new absolute working directory path on success.";

#[derive(Deserialize, JsonSchema)]
struct SetCwdParams {
    /// Target directory path (absolute or relative to current working directory).
    path: String,
}

/// Tool executor that changes the agent process working directory.
///
/// Implements the `set_working_directory` tool. The LLM calls this when it needs
/// to change context for a series of operations. Shell `cd` inside child processes
/// has no effect on the agent's cwd — this tool is the only persistent mechanism.
#[derive(Debug, Default)]
pub struct SetCwdExecutor;

impl ToolExecutor for SetCwdExecutor {
    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
        if call.tool_id != TOOL_NAME {
            return Ok(None);
        }
        let params: SetCwdParams = deserialize_params(&call.params)?;
        let target = expand_tilde(PathBuf::from(&params.path));

        // Resolve relative paths against current cwd before changing.
        let resolved = if target.is_absolute() {
            target
        } else {
            std::env::current_dir()
                .map_err(ToolError::Execution)?
                .join(target)
        };

        std::env::set_current_dir(&resolved).map_err(ToolError::Execution)?;

        let new_cwd = std::env::current_dir().map_err(ToolError::Execution)?;
        let summary = new_cwd.display().to_string();

        Ok(Some(ToolOutput {
            tool_name: ToolName::new(TOOL_NAME),
            summary,
            blocks_executed: 1,
            filter_stats: None,
            diff: None,
            streamed: false,
            terminal_id: None,
            locations: None,
            raw_response: None,
            claim_source: Some(ClaimSource::FileSystem),
        }))
    }

    fn tool_definitions(&self) -> Vec<ToolDef> {
        vec![ToolDef {
            id: TOOL_NAME.into(),
            description: TOOL_DESCRIPTION.into(),
            schema: schemars::schema_for!(SetCwdParams),
            invocation: InvocationHint::ToolCall,
            output_schema: None,
            server_id: None,
        }]
    }

    fn is_tool_retryable(&self, _tool_id: &str) -> bool {
        false
    }

    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
        Ok(None)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_call(path: &str) -> ToolCall {
        let mut params = serde_json::Map::new();
        params.insert(
            "path".to_owned(),
            serde_json::Value::String(path.to_owned()),
        );
        ToolCall {
            tool_id: ToolName::new(TOOL_NAME),
            params,
            caller_id: None,
            context: None,

            tool_call_id: String::new(),
            skill_name: None,
        }
    }

    #[tokio::test]
    async fn set_cwd_changes_process_cwd() {
        let original_cwd = std::env::current_dir().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let executor = SetCwdExecutor;
        let call = make_call(dir.path().to_str().unwrap());
        let result = executor.execute_tool_call(&call).await.unwrap();
        assert!(result.is_some());
        let out = result.unwrap();
        // The returned summary is the new cwd.
        let new_cwd = std::env::current_dir().unwrap();
        assert_eq!(out.summary, new_cwd.display().to_string());
        // Restore cwd so parallel tests are not affected.
        let _ = std::env::set_current_dir(&original_cwd);
    }

    // --- tilde expansion regression (#5415) ---

    #[tokio::test]
    async fn set_cwd_expands_tilde_in_runtime_argument() {
        // Regression for #5415: a `~`-prefixed path coming from an LLM tool
        // call must resolve to the real home directory, mirroring the fix
        // for `DiagnosticsExecutor::validate_path`.
        let original_cwd = std::env::current_dir().unwrap();
        let home = dirs::home_dir().expect("home dir must be resolvable in test env");
        let subdir = tempfile::Builder::new()
            .prefix("zeph_test_cwd_tilde_")
            .tempdir_in(&home)
            .expect("failed to create temp dir under home");
        let dir_name = subdir.path().file_name().unwrap().to_str().unwrap();

        let executor = SetCwdExecutor;
        let call = make_call(&format!("~/{dir_name}"));
        let result = executor.execute_tool_call(&call).await.unwrap();
        assert!(result.is_some());

        let new_cwd = std::env::current_dir().unwrap();
        assert_eq!(new_cwd, subdir.path().canonicalize().unwrap());
        assert!(
            !new_cwd.to_string_lossy().contains('~'),
            "tilde must not appear in resolved cwd: {new_cwd:?}"
        );

        // Restore cwd so parallel tests are not affected.
        let _ = std::env::set_current_dir(&original_cwd);
    }

    #[tokio::test]
    async fn set_cwd_returns_none_for_unknown_tool() {
        let executor = SetCwdExecutor;
        let call = ToolCall {
            tool_id: ToolName::new("other_tool"),
            params: serde_json::Map::new(),
            caller_id: None,
            context: None,

            tool_call_id: String::new(),
            skill_name: None,
        };
        let result = executor.execute_tool_call(&call).await.unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn set_cwd_errors_on_nonexistent_path() {
        let executor = SetCwdExecutor;
        let call = make_call("/nonexistent/path/that/does/not/exist");
        let result = executor.execute_tool_call(&call).await;
        assert!(result.is_err());
    }

    #[test]
    fn tool_definitions_contains_set_working_directory() {
        let executor = SetCwdExecutor;
        let defs = executor.tool_definitions();
        assert_eq!(defs.len(), 1);
        assert_eq!(defs[0].id.as_ref(), TOOL_NAME);
    }
}