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::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 path: String,
27}
28
29#[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(¶ms.path);
44
45 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 let new_cwd = std::env::current_dir().unwrap();
123 assert_eq!(out.summary, new_cwd.display().to_string());
124 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}