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_supervisor: None,
201 session_id: Some(std::sync::Arc::from(session.as_str())),
202 tool_call_id: std::sync::Arc::from("call_1"),
203 event_tx: None,
204 available_tool_schemas: std::sync::Arc::from(Vec::new()),
205 bypass_permissions: false,
206 auto_approve_permissions: false,
207 plan_read_only: false,
208 can_async_resume: false,
209 async_completion_sink: None,
210 bash_completion_sink: None,
211 },
212 )
213 .await
214 .unwrap();
215 let ToolOutcome::Completed(result) = out else {
216 panic!("expected Completed")
217 };
218 assert!(result.success);
219 assert_eq!(
220 result.result,
221 bamboo_config::paths::path_to_display_string(&workspace)
222 );
223 }
224
225 #[tokio::test]
226 async fn workspace_set_changes_session_workspace() {
227 let dir = tempfile::tempdir().unwrap();
228 let workspace = dir.path().join("ws");
229 tokio::fs::create_dir_all(&workspace).await.unwrap();
230 let session = format!("session_{}", uuid::Uuid::new_v4());
231
232 let tool = WorkspaceTool::new();
233 let out = tool
234 .invoke(
235 json!({"path": workspace.to_string_lossy()}),
236 ToolCtx {
237 executing_supervisor: None,
238 session_id: Some(std::sync::Arc::from(session.as_str())),
239 tool_call_id: std::sync::Arc::from("call_1"),
240 event_tx: None,
241 available_tool_schemas: std::sync::Arc::from(Vec::new()),
242 bypass_permissions: false,
243 auto_approve_permissions: false,
244 plan_read_only: false,
245 can_async_resume: false,
246 async_completion_sink: None,
247 bash_completion_sink: None,
248 },
249 )
250 .await
251 .unwrap();
252 let ToolOutcome::Completed(result) = out else {
253 panic!("expected Completed")
254 };
255 assert!(result.success);
256
257 let get_out = tool
259 .invoke(
260 json!({}),
261 ToolCtx {
262 executing_supervisor: None,
263 session_id: Some(std::sync::Arc::from(session.as_str())),
264 tool_call_id: std::sync::Arc::from("call_2"),
265 event_tx: None,
266 available_tool_schemas: std::sync::Arc::from(Vec::new()),
267 bypass_permissions: false,
268 auto_approve_permissions: false,
269 plan_read_only: false,
270 can_async_resume: false,
271 async_completion_sink: None,
272 bash_completion_sink: None,
273 },
274 )
275 .await
276 .unwrap();
277 let ToolOutcome::Completed(get_result) = get_out else {
278 panic!("expected Completed")
279 };
280 assert!(get_result.success);
281 let expected = workspace.canonicalize().unwrap();
282 assert_eq!(
283 get_result.result,
284 bamboo_config::paths::path_to_display_string(&expected)
285 );
286 }
287
288 #[tokio::test]
289 async fn workspace_set_rejects_missing_path() {
290 let tool = WorkspaceTool::new();
291 let out = tool
292 .invoke(
293 json!({"path": "/tmp/bamboo-no-such-workspace-xyz-99999"}),
294 ToolCtx {
295 executing_supervisor: None,
296 session_id: Some(std::sync::Arc::from("session_1")),
297 tool_call_id: std::sync::Arc::from("call_1"),
298 event_tx: None,
299 available_tool_schemas: std::sync::Arc::from(Vec::new()),
300 bypass_permissions: false,
301 auto_approve_permissions: false,
302 plan_read_only: false,
303 can_async_resume: false,
304 async_completion_sink: None,
305 bash_completion_sink: None,
306 },
307 )
308 .await
309 .unwrap();
310 let ToolOutcome::Completed(result) = out else {
311 panic!("expected Completed")
312 };
313 assert!(!result.success);
314 assert!(result.result.contains("does not exist"));
315 }
316
317 #[tokio::test]
318 async fn workspace_set_requires_session_context() {
319 let tool = WorkspaceTool::new();
320 let err = tool
321 .invoke(json!({"path": "/"}), ToolCtx::none("Workspace"))
322 .await
323 .expect_err("missing session should fail");
324 assert!(matches!(err, ToolError::Execution(msg) if msg.contains("session_id")));
325 }
326
327 }