bamboo_tools/tools/
workspace.rs1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolExecutionContext, 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 mutability(&self) -> crate::ToolMutability {
39 crate::ToolMutability::Mutating
40 }
41
42 fn call_mutability(&self, args: &serde_json::Value) -> crate::ToolMutability {
43 let has_path = args
44 .get("path")
45 .and_then(|v| v.as_str())
46 .map(str::trim)
47 .is_some_and(|v| !v.is_empty());
48 if has_path {
49 crate::ToolMutability::Mutating
50 } else {
51 crate::ToolMutability::ReadOnly
52 }
53 }
54
55 fn call_concurrency_safe(&self, args: &serde_json::Value) -> bool {
56 self.call_mutability(args) == crate::ToolMutability::ReadOnly
57 }
58
59 fn parameters_schema(&self) -> serde_json::Value {
60 json!({
61 "type": "object",
62 "properties": {
63 "path": {
64 "type": "string",
65 "description": "Path of the workspace directory to set. Omit to just read the current workspace."
66 }
67 },
68 "additionalProperties": false
69 })
70 }
71
72 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
73 self.execute_with_context(args, ToolExecutionContext::none("Workspace"))
74 .await
75 }
76
77 async fn execute_with_context(
78 &self,
79 args: serde_json::Value,
80 ctx: ToolExecutionContext<'_>,
81 ) -> Result<ToolResult, ToolError> {
82 let path_arg = args
83 .get("path")
84 .and_then(|v| v.as_str())
85 .map(|s| s.trim())
86 .filter(|s| !s.is_empty());
87
88 match path_arg {
89 Some(path) => {
91 let session_id = ctx.session_id.ok_or_else(|| {
92 ToolError::Execution(
93 "Workspace(set) requires a session_id in tool context".to_string(),
94 )
95 })?;
96
97 let base = workspace_state::workspace_or_process_cwd(Some(session_id));
98 let raw_path = Path::new(path);
99 let path_obj: PathBuf = if raw_path.is_absolute() {
100 raw_path.to_path_buf()
101 } else {
102 base.join(raw_path)
103 };
104
105 if !path_obj.exists() {
106 return Ok(ToolResult {
107 success: false,
108 result: format!("Path does not exist: {}", path_obj.display()),
109 display_preference: Some("error".to_string()),
110 images: Vec::new(),
111 });
112 }
113 if !path_obj.is_dir() {
114 return Ok(ToolResult {
115 success: false,
116 result: format!("Path is not a directory: {}", path_obj.display()),
117 display_preference: Some("error".to_string()),
118 images: Vec::new(),
119 });
120 }
121
122 let absolute_path = path_obj.canonicalize().map_err(|e| {
123 ToolError::Execution(format!("Failed to canonicalize path: {e}"))
124 })?;
125
126 workspace_state::set_workspace(session_id, absolute_path.clone());
127
128 Ok(ToolResult {
129 success: true,
130 result: json!({
131 "session_id": session_id,
132 "workspace": bamboo_config::paths::path_to_display_string(&absolute_path)
133 })
134 .to_string(),
135 display_preference: Some("json".to_string()),
136 images: Vec::new(),
137 })
138 }
139
140 None => {
142 if let Some(session_id) = ctx.session_id {
143 if let Some(workspace) = workspace_state::get_workspace(session_id) {
144 return Ok(ToolResult {
145 success: true,
146 result: bamboo_config::paths::path_to_display_string(&workspace),
147 display_preference: None,
148 images: Vec::new(),
149 });
150 }
151 }
152
153 match std::env::current_dir() {
154 Ok(dir) => Ok(ToolResult {
155 success: true,
156 result: bamboo_config::paths::path_to_display_string(&dir),
157 display_preference: None,
158 images: Vec::new(),
159 }),
160 Err(error) => Ok(ToolResult {
161 success: false,
162 result: format!("Failed to get current directory: {error}"),
163 display_preference: Some("error".to_string()),
164 images: Vec::new(),
165 }),
166 }
167 }
168 }
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[tokio::test]
177 async fn workspace_get_returns_non_empty_path() {
178 let tool = WorkspaceTool::new();
179 let result = tool.execute(json!({})).await.unwrap();
180 assert!(result.success);
181 assert!(!result.result.trim().is_empty());
182 }
183
184 #[tokio::test]
185 async fn workspace_get_prefers_session_workspace() {
186 let dir = tempfile::tempdir().unwrap();
187 let workspace = dir.path().join("workspace");
188 tokio::fs::create_dir_all(&workspace).await.unwrap();
189 let session = format!("session_{}", uuid::Uuid::new_v4());
190 workspace_state::set_workspace(&session, workspace.clone());
191
192 let tool = WorkspaceTool::new();
193 let result = tool
194 .execute_with_context(
195 json!({}),
196 ToolExecutionContext {
197 session_id: Some(&session),
198 tool_call_id: "call_1",
199 event_tx: None,
200 available_tool_schemas: None,
201 bypass_permissions: false,
202 can_async_resume: false,
203 },
204 )
205 .await
206 .unwrap();
207 assert!(result.success);
208 assert_eq!(
209 result.result,
210 bamboo_config::paths::path_to_display_string(&workspace)
211 );
212 }
213
214 #[tokio::test]
215 async fn workspace_set_changes_session_workspace() {
216 let dir = tempfile::tempdir().unwrap();
217 let workspace = dir.path().join("ws");
218 tokio::fs::create_dir_all(&workspace).await.unwrap();
219 let session = format!("session_{}", uuid::Uuid::new_v4());
220
221 let tool = WorkspaceTool::new();
222 let result = tool
223 .execute_with_context(
224 json!({"path": workspace.to_string_lossy()}),
225 ToolExecutionContext {
226 session_id: Some(&session),
227 tool_call_id: "call_1",
228 event_tx: None,
229 available_tool_schemas: None,
230 bypass_permissions: false,
231 can_async_resume: false,
232 },
233 )
234 .await
235 .unwrap();
236 assert!(result.success);
237
238 let get_result = tool
240 .execute_with_context(
241 json!({}),
242 ToolExecutionContext {
243 session_id: Some(&session),
244 tool_call_id: "call_2",
245 event_tx: None,
246 available_tool_schemas: None,
247 bypass_permissions: false,
248 can_async_resume: false,
249 },
250 )
251 .await
252 .unwrap();
253 assert!(get_result.success);
254 let expected = workspace.canonicalize().unwrap();
255 assert_eq!(
256 get_result.result,
257 bamboo_config::paths::path_to_display_string(&expected)
258 );
259 }
260
261 #[tokio::test]
262 async fn workspace_set_rejects_missing_path() {
263 let tool = WorkspaceTool::new();
264 let result = tool
265 .execute_with_context(
266 json!({"path": "/tmp/bamboo-no-such-workspace-xyz-99999"}),
267 ToolExecutionContext {
268 session_id: Some("session_1"),
269 tool_call_id: "call_1",
270 event_tx: None,
271 available_tool_schemas: None,
272 bypass_permissions: false,
273 can_async_resume: false,
274 },
275 )
276 .await
277 .unwrap();
278 assert!(!result.success);
279 assert!(result.result.contains("does not exist"));
280 }
281
282 #[tokio::test]
283 async fn workspace_set_requires_session_context() {
284 let tool = WorkspaceTool::new();
285 let err = tool
286 .execute(json!({"path": "/"}))
287 .await
288 .expect_err("missing session should fail");
289 assert!(matches!(err, ToolError::Execution(msg) if msg.contains("session_id")));
290 }
291}