1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde_json::json;
4use std::path::{Path, PathBuf};
5
6use super::workspace_state;
7
8pub struct WorkspaceTool;
15
16impl WorkspaceTool {
17 pub fn new() -> Self {
18 Self
19 }
20}
21
22impl Default for WorkspaceTool {
23 fn default() -> Self {
24 Self::new()
25 }
26}
27
28#[async_trait]
29impl Tool for WorkspaceTool {
30 fn name(&self) -> &str {
31 "Workspace"
32 }
33
34 fn description(&self) -> &str {
35 "Get or set the current session workspace directory. Call without 'path' to get the current workspace; call with 'path' to change it."
36 }
37
38 fn classify(&self, args: &serde_json::Value) -> ToolClass {
39 let has_path = args
40 .get("path")
41 .and_then(|v| v.as_str())
42 .map(str::trim)
43 .is_some_and(|v| !v.is_empty());
44 if has_path {
45 ToolClass::MUTATING_SERIAL
46 } else {
47 ToolClass::READONLY_PARALLEL
48 }
49 }
50
51 fn parameters_schema(&self) -> serde_json::Value {
52 json!({
53 "type": "object",
54 "properties": {
55 "path": {
56 "type": "string",
57 "description": "Path of the workspace directory to set. Omit to just read the current workspace."
58 }
59 },
60 "additionalProperties": false
61 })
62 }
63
64 async fn invoke(
65 &self,
66 args: serde_json::Value,
67 ctx: ToolCtx,
68 ) -> Result<ToolOutcome, ToolError> {
69 let path_arg = args
70 .get("path")
71 .and_then(|v| v.as_str())
72 .map(|s| s.trim())
73 .filter(|s| !s.is_empty());
74
75 match path_arg {
76 Some(path) => {
78 let session_id = ctx.session_id().ok_or_else(|| {
79 ToolError::Execution(
80 "Workspace(set) requires a session_id in tool context".to_string(),
81 )
82 })?;
83
84 let base = workspace_state::workspace_or_process_cwd(Some(session_id));
85 let raw_path = Path::new(path);
86 let path_obj: PathBuf = if raw_path.is_absolute() {
87 raw_path.to_path_buf()
88 } else {
89 base.join(raw_path)
90 };
91
92 if !path_obj.exists() {
93 return Ok(ToolOutcome::Completed(ToolResult {
94 success: false,
95 result: format!("Path does not exist: {}", path_obj.display()),
96 display_preference: Some("error".to_string()),
97 images: Vec::new(),
98 }));
99 }
100 if !path_obj.is_dir() {
101 return Ok(ToolOutcome::Completed(ToolResult {
102 success: false,
103 result: format!("Path is not a directory: {}", path_obj.display()),
104 display_preference: Some("error".to_string()),
105 images: Vec::new(),
106 }));
107 }
108
109 let absolute_path = path_obj.canonicalize().map_err(|e| {
110 ToolError::Execution(format!("Failed to canonicalize path: {e}"))
111 })?;
112
113 let stored = workspace_state::set_workspace(session_id, absolute_path.clone());
118 let relocated = stored != absolute_path;
119
120 let mut payload = json!({
121 "session_id": session_id,
122 "workspace": bamboo_config::paths::path_to_display_string(&stored)
123 });
124 if relocated {
125 payload["relocated_from"] =
126 json!(bamboo_config::paths::path_to_display_string(&absolute_path));
127 }
128
129 Ok(ToolOutcome::Completed(ToolResult {
130 success: true,
131 result: payload.to_string(),
132 display_preference: Some("json".to_string()),
133 images: Vec::new(),
134 }))
135 }
136
137 None => {
139 if let Some(session_id) = ctx.session_id() {
140 if let Some(workspace) = workspace_state::get_workspace(session_id) {
141 return Ok(ToolOutcome::Completed(ToolResult {
142 success: true,
143 result: bamboo_config::paths::path_to_display_string(&workspace),
144 display_preference: None,
145 images: Vec::new(),
146 }));
147 }
148 }
149
150 match std::env::current_dir() {
151 Ok(dir) => Ok(ToolOutcome::Completed(ToolResult {
152 success: true,
153 result: bamboo_config::paths::path_to_display_string(&dir),
154 display_preference: None,
155 images: Vec::new(),
156 })),
157 Err(error) => Ok(ToolOutcome::Completed(ToolResult {
158 success: false,
159 result: format!("Failed to get current directory: {error}"),
160 display_preference: Some("error".to_string()),
161 images: Vec::new(),
162 })),
163 }
164 }
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[tokio::test]
174 async fn workspace_get_returns_non_empty_path() {
175 let tool = WorkspaceTool::new();
176 let out = tool
177 .invoke(json!({}), ToolCtx::none("Workspace"))
178 .await
179 .unwrap();
180 let ToolOutcome::Completed(result) = out else {
181 panic!("expected Completed")
182 };
183 assert!(result.success);
184 assert!(!result.result.trim().is_empty());
185 }
186
187 #[tokio::test]
188 async fn workspace_get_prefers_session_workspace() {
189 let dir = tempfile::tempdir().unwrap();
190 let workspace = dir.path().join("workspace");
191 tokio::fs::create_dir_all(&workspace).await.unwrap();
192 let session = format!("session_{}", uuid::Uuid::new_v4());
193 workspace_state::set_workspace(&session, workspace.clone());
194
195 let tool = WorkspaceTool::new();
196 let out = tool
197 .invoke(
198 json!({}),
199 ToolCtx {
200 executing_caller: None,
201 executing_supervisor: None,
202 session_id: Some(std::sync::Arc::from(session.as_str())),
203 tool_call_id: std::sync::Arc::from("call_1"),
204 event_tx: None,
205 available_tool_schemas: std::sync::Arc::from(Vec::new()),
206 bypass_permissions: false,
207 auto_approve_permissions: false,
208 plan_read_only: false,
209 can_async_resume: false,
210 async_completion_sink: None,
211 bash_completion_sink: None,
212 },
213 )
214 .await
215 .unwrap();
216 let ToolOutcome::Completed(result) = out else {
217 panic!("expected Completed")
218 };
219 assert!(result.success);
220 assert_eq!(
221 result.result,
222 bamboo_config::paths::path_to_display_string(&workspace)
223 );
224 }
225
226 #[tokio::test]
227 async fn workspace_set_changes_session_workspace() {
228 let dir = tempfile::tempdir().unwrap();
229 let workspace = dir.path().join("ws");
230 tokio::fs::create_dir_all(&workspace).await.unwrap();
231 let session = format!("session_{}", uuid::Uuid::new_v4());
232
233 let tool = WorkspaceTool::new();
234 let out = tool
235 .invoke(
236 json!({"path": workspace.to_string_lossy()}),
237 ToolCtx {
238 executing_caller: None,
239 executing_supervisor: None,
240 session_id: Some(std::sync::Arc::from(session.as_str())),
241 tool_call_id: std::sync::Arc::from("call_1"),
242 event_tx: None,
243 available_tool_schemas: std::sync::Arc::from(Vec::new()),
244 bypass_permissions: false,
245 auto_approve_permissions: false,
246 plan_read_only: false,
247 can_async_resume: false,
248 async_completion_sink: None,
249 bash_completion_sink: None,
250 },
251 )
252 .await
253 .unwrap();
254 let ToolOutcome::Completed(result) = out else {
255 panic!("expected Completed")
256 };
257 assert!(result.success);
258
259 let get_out = tool
261 .invoke(
262 json!({}),
263 ToolCtx {
264 executing_caller: None,
265 executing_supervisor: None,
266 session_id: Some(std::sync::Arc::from(session.as_str())),
267 tool_call_id: std::sync::Arc::from("call_2"),
268 event_tx: None,
269 available_tool_schemas: std::sync::Arc::from(Vec::new()),
270 bypass_permissions: false,
271 auto_approve_permissions: false,
272 plan_read_only: false,
273 can_async_resume: false,
274 async_completion_sink: None,
275 bash_completion_sink: None,
276 },
277 )
278 .await
279 .unwrap();
280 let ToolOutcome::Completed(get_result) = get_out else {
281 panic!("expected Completed")
282 };
283 assert!(get_result.success);
284 let expected = workspace.canonicalize().unwrap();
285 assert_eq!(
286 get_result.result,
287 bamboo_config::paths::path_to_display_string(&expected)
288 );
289 }
290
291 #[tokio::test]
292 async fn workspace_set_rejects_missing_path() {
293 let tool = WorkspaceTool::new();
294 let out = tool
295 .invoke(
296 json!({"path": "/tmp/bamboo-no-such-workspace-xyz-99999"}),
297 ToolCtx {
298 executing_caller: None,
299 executing_supervisor: None,
300 session_id: Some(std::sync::Arc::from("session_1")),
301 tool_call_id: std::sync::Arc::from("call_1"),
302 event_tx: None,
303 available_tool_schemas: std::sync::Arc::from(Vec::new()),
304 bypass_permissions: false,
305 auto_approve_permissions: false,
306 plan_read_only: false,
307 can_async_resume: false,
308 async_completion_sink: None,
309 bash_completion_sink: None,
310 },
311 )
312 .await
313 .unwrap();
314 let ToolOutcome::Completed(result) = out else {
315 panic!("expected Completed")
316 };
317 assert!(!result.success);
318 assert!(result.result.contains("does not exist"));
319 }
320
321 #[tokio::test]
322 async fn workspace_set_requires_session_context() {
323 let tool = WorkspaceTool::new();
324 let err = tool
325 .invoke(json!({"path": "/"}), ToolCtx::none("Workspace"))
326 .await
327 .expect_err("missing session should fail");
328 assert!(matches!(err, ToolError::Execution(msg) if msg.contains("session_id")));
329 }
330
331 }