Skip to main content

bamboo_tools/tools/
workspace.rs

1use 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
8/// Unified workspace tool: get or set the session working directory.
9///
10/// - When called **without** `path`  → returns the current workspace directory.
11/// - When called **with** `path`     → sets the workspace and returns the new path.
12///
13/// This replaces the previous `GetCurrentDir` + `SetWorkspace` pair.
14pub 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            // ── SET mode ──────────────────────────────────────────────
90            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            // ── GET mode ──────────────────────────────────────────────
141            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                    pre_parsed_args: None,
204                },
205            )
206            .await
207            .unwrap();
208        assert!(result.success);
209        assert_eq!(
210            result.result,
211            bamboo_config::paths::path_to_display_string(&workspace)
212        );
213    }
214
215    #[tokio::test]
216    async fn workspace_set_changes_session_workspace() {
217        let dir = tempfile::tempdir().unwrap();
218        let workspace = dir.path().join("ws");
219        tokio::fs::create_dir_all(&workspace).await.unwrap();
220        let session = format!("session_{}", uuid::Uuid::new_v4());
221
222        let tool = WorkspaceTool::new();
223        let result = tool
224            .execute_with_context(
225                json!({"path": workspace.to_string_lossy()}),
226                ToolExecutionContext {
227                    session_id: Some(&session),
228                    tool_call_id: "call_1",
229                    event_tx: None,
230                    available_tool_schemas: None,
231                    bypass_permissions: false,
232                    can_async_resume: false,
233                    pre_parsed_args: None,
234                },
235            )
236            .await
237            .unwrap();
238        assert!(result.success);
239
240        // Verify get mode now returns the new workspace
241        let get_result = tool
242            .execute_with_context(
243                json!({}),
244                ToolExecutionContext {
245                    session_id: Some(&session),
246                    tool_call_id: "call_2",
247                    event_tx: None,
248                    available_tool_schemas: None,
249                    bypass_permissions: false,
250                    can_async_resume: false,
251                    pre_parsed_args: None,
252                },
253            )
254            .await
255            .unwrap();
256        assert!(get_result.success);
257        let expected = workspace.canonicalize().unwrap();
258        assert_eq!(
259            get_result.result,
260            bamboo_config::paths::path_to_display_string(&expected)
261        );
262    }
263
264    #[tokio::test]
265    async fn workspace_set_rejects_missing_path() {
266        let tool = WorkspaceTool::new();
267        let result = tool
268            .execute_with_context(
269                json!({"path": "/tmp/bamboo-no-such-workspace-xyz-99999"}),
270                ToolExecutionContext {
271                    session_id: Some("session_1"),
272                    tool_call_id: "call_1",
273                    event_tx: None,
274                    available_tool_schemas: None,
275                    bypass_permissions: false,
276                    can_async_resume: false,
277                    pre_parsed_args: None,
278                },
279            )
280            .await
281            .unwrap();
282        assert!(!result.success);
283        assert!(result.result.contains("does not exist"));
284    }
285
286    #[tokio::test]
287    async fn workspace_set_requires_session_context() {
288        let tool = WorkspaceTool::new();
289        let err = tool
290            .execute(json!({"path": "/"}))
291            .await
292            .expect_err("missing session should fail");
293        assert!(matches!(err, ToolError::Execution(msg) if msg.contains("session_id")));
294    }
295}