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            caller_id: None,
103        }
104    }
105
106    #[tokio::test]
107    async fn set_cwd_changes_process_cwd() {
108        let original_cwd = std::env::current_dir().unwrap();
109        let dir = tempfile::tempdir().unwrap();
110        let executor = SetCwdExecutor;
111        let call = make_call(dir.path().to_str().unwrap());
112        let result = executor.execute_tool_call(&call).await.unwrap();
113        assert!(result.is_some());
114        let out = result.unwrap();
115        // The returned summary is the new cwd.
116        let new_cwd = std::env::current_dir().unwrap();
117        assert_eq!(out.summary, new_cwd.display().to_string());
118        // Restore cwd so parallel tests are not affected.
119        let _ = std::env::set_current_dir(&original_cwd);
120    }
121
122    #[tokio::test]
123    async fn set_cwd_returns_none_for_unknown_tool() {
124        let executor = SetCwdExecutor;
125        let call = ToolCall {
126            tool_id: "other_tool".to_owned(),
127            params: serde_json::Map::new(),
128            caller_id: None,
129        };
130        let result = executor.execute_tool_call(&call).await.unwrap();
131        assert!(result.is_none());
132    }
133
134    #[tokio::test]
135    async fn set_cwd_errors_on_nonexistent_path() {
136        let executor = SetCwdExecutor;
137        let call = make_call("/nonexistent/path/that/does/not/exist");
138        let result = executor.execute_tool_call(&call).await;
139        assert!(result.is_err());
140    }
141
142    #[test]
143    fn tool_definitions_contains_set_working_directory() {
144        let executor = SetCwdExecutor;
145        let defs = executor.tool_definitions();
146        assert_eq!(defs.len(), 1);
147        assert_eq!(defs[0].id.as_ref(), TOOL_NAME);
148    }
149}