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 workspace_state::set_workspace(session_id, absolute_path.clone());
114
115 Ok(ToolOutcome::Completed(ToolResult {
116 success: true,
117 result: json!({
118 "session_id": session_id,
119 "workspace": bamboo_config::paths::path_to_display_string(&absolute_path)
120 })
121 .to_string(),
122 display_preference: Some("json".to_string()),
123 images: Vec::new(),
124 }))
125 }
126
127 None => {
129 if let Some(session_id) = ctx.session_id() {
130 if let Some(workspace) = workspace_state::get_workspace(session_id) {
131 return Ok(ToolOutcome::Completed(ToolResult {
132 success: true,
133 result: bamboo_config::paths::path_to_display_string(&workspace),
134 display_preference: None,
135 images: Vec::new(),
136 }));
137 }
138 }
139
140 match std::env::current_dir() {
141 Ok(dir) => Ok(ToolOutcome::Completed(ToolResult {
142 success: true,
143 result: bamboo_config::paths::path_to_display_string(&dir),
144 display_preference: None,
145 images: Vec::new(),
146 })),
147 Err(error) => Ok(ToolOutcome::Completed(ToolResult {
148 success: false,
149 result: format!("Failed to get current directory: {error}"),
150 display_preference: Some("error".to_string()),
151 images: Vec::new(),
152 })),
153 }
154 }
155 }
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[tokio::test]
164 async fn workspace_get_returns_non_empty_path() {
165 let tool = WorkspaceTool::new();
166 let out = tool
167 .invoke(json!({}), ToolCtx::none("Workspace"))
168 .await
169 .unwrap();
170 let ToolOutcome::Completed(result) = out else {
171 panic!("expected Completed")
172 };
173 assert!(result.success);
174 assert!(!result.result.trim().is_empty());
175 }
176
177 #[tokio::test]
178 async fn workspace_get_prefers_session_workspace() {
179 let dir = tempfile::tempdir().unwrap();
180 let workspace = dir.path().join("workspace");
181 tokio::fs::create_dir_all(&workspace).await.unwrap();
182 let session = format!("session_{}", uuid::Uuid::new_v4());
183 workspace_state::set_workspace(&session, workspace.clone());
184
185 let tool = WorkspaceTool::new();
186 let out = tool
187 .invoke(
188 json!({}),
189 ToolCtx {
190 session_id: Some(std::sync::Arc::from(session.as_str())),
191 tool_call_id: std::sync::Arc::from("call_1"),
192 event_tx: None,
193 available_tool_schemas: std::sync::Arc::from(Vec::new()),
194 bypass_permissions: false,
195 can_async_resume: false,
196 async_completion_sink: None,
197 bash_completion_sink: None,
198 },
199 )
200 .await
201 .unwrap();
202 let ToolOutcome::Completed(result) = out else {
203 panic!("expected Completed")
204 };
205 assert!(result.success);
206 assert_eq!(
207 result.result,
208 bamboo_config::paths::path_to_display_string(&workspace)
209 );
210 }
211
212 #[tokio::test]
213 async fn workspace_set_changes_session_workspace() {
214 let dir = tempfile::tempdir().unwrap();
215 let workspace = dir.path().join("ws");
216 tokio::fs::create_dir_all(&workspace).await.unwrap();
217 let session = format!("session_{}", uuid::Uuid::new_v4());
218
219 let tool = WorkspaceTool::new();
220 let out = tool
221 .invoke(
222 json!({"path": workspace.to_string_lossy()}),
223 ToolCtx {
224 session_id: Some(std::sync::Arc::from(session.as_str())),
225 tool_call_id: std::sync::Arc::from("call_1"),
226 event_tx: None,
227 available_tool_schemas: std::sync::Arc::from(Vec::new()),
228 bypass_permissions: false,
229 can_async_resume: false,
230 async_completion_sink: None,
231 bash_completion_sink: None,
232 },
233 )
234 .await
235 .unwrap();
236 let ToolOutcome::Completed(result) = out else {
237 panic!("expected Completed")
238 };
239 assert!(result.success);
240
241 let get_out = tool
243 .invoke(
244 json!({}),
245 ToolCtx {
246 session_id: Some(std::sync::Arc::from(session.as_str())),
247 tool_call_id: std::sync::Arc::from("call_2"),
248 event_tx: None,
249 available_tool_schemas: std::sync::Arc::from(Vec::new()),
250 bypass_permissions: false,
251 can_async_resume: false,
252 async_completion_sink: None,
253 bash_completion_sink: None,
254 },
255 )
256 .await
257 .unwrap();
258 let ToolOutcome::Completed(get_result) = get_out else {
259 panic!("expected Completed")
260 };
261 assert!(get_result.success);
262 let expected = workspace.canonicalize().unwrap();
263 assert_eq!(
264 get_result.result,
265 bamboo_config::paths::path_to_display_string(&expected)
266 );
267 }
268
269 #[tokio::test]
270 async fn workspace_set_rejects_missing_path() {
271 let tool = WorkspaceTool::new();
272 let out = tool
273 .invoke(
274 json!({"path": "/tmp/bamboo-no-such-workspace-xyz-99999"}),
275 ToolCtx {
276 session_id: Some(std::sync::Arc::from("session_1")),
277 tool_call_id: std::sync::Arc::from("call_1"),
278 event_tx: None,
279 available_tool_schemas: std::sync::Arc::from(Vec::new()),
280 bypass_permissions: false,
281 can_async_resume: false,
282 async_completion_sink: None,
283 bash_completion_sink: None,
284 },
285 )
286 .await
287 .unwrap();
288 let ToolOutcome::Completed(result) = out else {
289 panic!("expected Completed")
290 };
291 assert!(!result.success);
292 assert!(result.result.contains("does not exist"));
293 }
294
295 #[tokio::test]
296 async fn workspace_set_requires_session_context() {
297 let tool = WorkspaceTool::new();
298 let err = tool
299 .invoke(json!({"path": "/"}), ToolCtx::none("Workspace"))
300 .await
301 .expect_err("missing session should fail");
302 assert!(matches!(err, ToolError::Execution(msg) if msg.contains("session_id")));
303 }
304}