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