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