Skip to main content

zeph_tools/
cwd.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::PathBuf;
5
6use schemars::JsonSchema;
7use serde::Deserialize;
8
9use zeph_common::ToolName;
10
11use crate::executor::{
12    ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
13};
14use crate::file::expand_tilde;
15use crate::registry::{InvocationHint, ToolDef};
16
17const TOOL_NAME: &str = "set_working_directory";
18
19const TOOL_DESCRIPTION: &str = "Change the agent's working directory. \
20Shell commands (`bash`) run in child processes — a `cd` inside them does NOT persist. \
21Use this tool when you need to change the working context for subsequent operations. \
22Returns the new absolute working directory path on success.";
23
24#[derive(Deserialize, JsonSchema)]
25struct SetCwdParams {
26    /// Target directory path (absolute or relative to current working directory).
27    path: String,
28}
29
30/// Tool executor that changes the agent process working directory.
31///
32/// Implements the `set_working_directory` tool. The LLM calls this when it needs
33/// to change context for a series of operations. Shell `cd` inside child processes
34/// has no effect on the agent's cwd — this tool is the only persistent mechanism.
35#[derive(Debug, Default)]
36pub struct SetCwdExecutor;
37
38impl ToolExecutor for SetCwdExecutor {
39    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
40        if call.tool_id != TOOL_NAME {
41            return Ok(None);
42        }
43        let params: SetCwdParams = deserialize_params(&call.params)?;
44        let target = expand_tilde(PathBuf::from(&params.path));
45
46        // Resolve relative paths against current cwd before changing.
47        let resolved = if target.is_absolute() {
48            target
49        } else {
50            std::env::current_dir()
51                .map_err(ToolError::Execution)?
52                .join(target)
53        };
54
55        std::env::set_current_dir(&resolved).map_err(ToolError::Execution)?;
56
57        let new_cwd = std::env::current_dir().map_err(ToolError::Execution)?;
58        let summary = new_cwd.display().to_string();
59
60        Ok(Some(ToolOutput {
61            tool_name: ToolName::new(TOOL_NAME),
62            summary,
63            blocks_executed: 1,
64            filter_stats: None,
65            diff: None,
66            streamed: false,
67            terminal_id: None,
68            locations: None,
69            raw_response: None,
70            claim_source: Some(ClaimSource::FileSystem),
71        }))
72    }
73
74    fn tool_definitions(&self) -> Vec<ToolDef> {
75        vec![ToolDef {
76            id: TOOL_NAME.into(),
77            description: TOOL_DESCRIPTION.into(),
78            schema: schemars::schema_for!(SetCwdParams),
79            invocation: InvocationHint::ToolCall,
80            output_schema: None,
81            server_id: None,
82        }]
83    }
84
85    fn is_tool_retryable(&self, _tool_id: &str) -> bool {
86        false
87    }
88
89    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
90        Ok(None)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn make_call(path: &str) -> ToolCall {
99        let mut params = serde_json::Map::new();
100        params.insert(
101            "path".to_owned(),
102            serde_json::Value::String(path.to_owned()),
103        );
104        ToolCall {
105            tool_id: ToolName::new(TOOL_NAME),
106            params,
107            caller_id: None,
108            context: None,
109
110            tool_call_id: String::new(),
111            skill_name: None,
112        }
113    }
114
115    #[tokio::test]
116    async fn set_cwd_changes_process_cwd() {
117        let original_cwd = std::env::current_dir().unwrap();
118        let dir = tempfile::tempdir().unwrap();
119        let executor = SetCwdExecutor;
120        let call = make_call(dir.path().to_str().unwrap());
121        let result = executor.execute_tool_call(&call).await.unwrap();
122        assert!(result.is_some());
123        let out = result.unwrap();
124        // The returned summary is the new cwd.
125        let new_cwd = std::env::current_dir().unwrap();
126        assert_eq!(out.summary, new_cwd.display().to_string());
127        // Restore cwd so parallel tests are not affected.
128        let _ = std::env::set_current_dir(&original_cwd);
129    }
130
131    // --- tilde expansion regression (#5415) ---
132
133    #[tokio::test]
134    async fn set_cwd_expands_tilde_in_runtime_argument() {
135        // Regression for #5415: a `~`-prefixed path coming from an LLM tool
136        // call must resolve to the real home directory, mirroring the fix
137        // for `DiagnosticsExecutor::validate_path`.
138        let original_cwd = std::env::current_dir().unwrap();
139        let home = dirs::home_dir().expect("home dir must be resolvable in test env");
140        let subdir = tempfile::Builder::new()
141            .prefix("zeph_test_cwd_tilde_")
142            .tempdir_in(&home)
143            .expect("failed to create temp dir under home");
144        let dir_name = subdir.path().file_name().unwrap().to_str().unwrap();
145
146        let executor = SetCwdExecutor;
147        let call = make_call(&format!("~/{dir_name}"));
148        let result = executor.execute_tool_call(&call).await.unwrap();
149        assert!(result.is_some());
150
151        let new_cwd = std::env::current_dir().unwrap();
152        assert_eq!(new_cwd, subdir.path().canonicalize().unwrap());
153        assert!(
154            !new_cwd.to_string_lossy().contains('~'),
155            "tilde must not appear in resolved cwd: {new_cwd:?}"
156        );
157
158        // Restore cwd so parallel tests are not affected.
159        let _ = std::env::set_current_dir(&original_cwd);
160    }
161
162    #[tokio::test]
163    async fn set_cwd_returns_none_for_unknown_tool() {
164        let executor = SetCwdExecutor;
165        let call = ToolCall {
166            tool_id: ToolName::new("other_tool"),
167            params: serde_json::Map::new(),
168            caller_id: None,
169            context: None,
170
171            tool_call_id: String::new(),
172            skill_name: None,
173        };
174        let result = executor.execute_tool_call(&call).await.unwrap();
175        assert!(result.is_none());
176    }
177
178    #[tokio::test]
179    async fn set_cwd_errors_on_nonexistent_path() {
180        let executor = SetCwdExecutor;
181        let call = make_call("/nonexistent/path/that/does/not/exist");
182        let result = executor.execute_tool_call(&call).await;
183        assert!(result.is_err());
184    }
185
186    #[test]
187    fn tool_definitions_contains_set_working_directory() {
188        let executor = SetCwdExecutor;
189        let defs = executor.tool_definitions();
190        assert_eq!(defs.len(), 1);
191        assert_eq!(defs[0].id.as_ref(), TOOL_NAME);
192    }
193}