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 crate::executor::{
10    ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
11};
12use crate::registry::{InvocationHint, ToolDef};
13
14const TOOL_NAME: &str = "set_working_directory";
15
16const TOOL_DESCRIPTION: &str = "Change the agent's working directory. \
17Shell commands (`bash`) run in child processes — a `cd` inside them does NOT persist. \
18Use this tool when you need to change the working context for subsequent operations. \
19Returns the new absolute working directory path on success.";
20
21#[derive(Deserialize, JsonSchema)]
22struct SetCwdParams {
23    /// Target directory path (absolute or relative to current working directory).
24    path: String,
25}
26
27/// Tool executor that changes the agent process working directory.
28///
29/// Implements the `set_working_directory` tool. The LLM calls this when it needs
30/// to change context for a series of operations. Shell `cd` inside child processes
31/// has no effect on the agent's cwd — this tool is the only persistent mechanism.
32#[derive(Debug, Default)]
33pub struct SetCwdExecutor;
34
35impl ToolExecutor for SetCwdExecutor {
36    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
37        if call.tool_id != TOOL_NAME {
38            return Ok(None);
39        }
40        let params: SetCwdParams = deserialize_params(&call.params)?;
41        let target = PathBuf::from(&params.path);
42
43        // Resolve relative paths against current cwd before changing.
44        let resolved = if target.is_absolute() {
45            target
46        } else {
47            std::env::current_dir()
48                .map_err(ToolError::Execution)?
49                .join(target)
50        };
51
52        std::env::set_current_dir(&resolved).map_err(ToolError::Execution)?;
53
54        let new_cwd = std::env::current_dir().map_err(ToolError::Execution)?;
55        let summary = new_cwd.display().to_string();
56
57        Ok(Some(ToolOutput {
58            tool_name: TOOL_NAME.to_owned(),
59            summary,
60            blocks_executed: 1,
61            filter_stats: None,
62            diff: None,
63            streamed: false,
64            terminal_id: None,
65            locations: None,
66            raw_response: None,
67            claim_source: Some(ClaimSource::FileSystem),
68        }))
69    }
70
71    fn tool_definitions(&self) -> Vec<ToolDef> {
72        vec![ToolDef {
73            id: TOOL_NAME.into(),
74            description: TOOL_DESCRIPTION.into(),
75            schema: schemars::schema_for!(SetCwdParams),
76            invocation: InvocationHint::ToolCall,
77        }]
78    }
79
80    fn is_tool_retryable(&self, _tool_id: &str) -> bool {
81        false
82    }
83
84    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
85        Ok(None)
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn make_call(path: &str) -> ToolCall {
94        let mut params = serde_json::Map::new();
95        params.insert(
96            "path".to_owned(),
97            serde_json::Value::String(path.to_owned()),
98        );
99        ToolCall {
100            tool_id: TOOL_NAME.to_owned(),
101            params,
102        }
103    }
104
105    #[tokio::test]
106    async fn set_cwd_changes_process_cwd() {
107        let original_cwd = std::env::current_dir().unwrap();
108        let dir = tempfile::tempdir().unwrap();
109        let executor = SetCwdExecutor;
110        let call = make_call(dir.path().to_str().unwrap());
111        let result = executor.execute_tool_call(&call).await.unwrap();
112        assert!(result.is_some());
113        let out = result.unwrap();
114        // The returned summary is the new cwd.
115        let new_cwd = std::env::current_dir().unwrap();
116        assert_eq!(out.summary, new_cwd.display().to_string());
117        // Restore cwd so parallel tests are not affected.
118        let _ = std::env::set_current_dir(&original_cwd);
119    }
120
121    #[tokio::test]
122    async fn set_cwd_returns_none_for_unknown_tool() {
123        let executor = SetCwdExecutor;
124        let call = ToolCall {
125            tool_id: "other_tool".to_owned(),
126            params: serde_json::Map::new(),
127        };
128        let result = executor.execute_tool_call(&call).await.unwrap();
129        assert!(result.is_none());
130    }
131
132    #[tokio::test]
133    async fn set_cwd_errors_on_nonexistent_path() {
134        let executor = SetCwdExecutor;
135        let call = make_call("/nonexistent/path/that/does/not/exist");
136        let result = executor.execute_tool_call(&call).await;
137        assert!(result.is_err());
138    }
139
140    #[test]
141    fn tool_definitions_contains_set_working_directory() {
142        let executor = SetCwdExecutor;
143        let defs = executor.tool_definitions();
144        assert_eq!(defs.len(), 1);
145        assert_eq!(defs[0].id.as_ref(), TOOL_NAME);
146    }
147}