1use 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 path: String,
28}
29
30#[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(¶ms.path));
45
46 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 let new_cwd = std::env::current_dir().unwrap();
126 assert_eq!(out.summary, new_cwd.display().to_string());
127 let _ = std::env::set_current_dir(&original_cwd);
129 }
130
131 #[tokio::test]
134 async fn set_cwd_expands_tilde_in_runtime_argument() {
135 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 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}