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 },
202 )
203 .await
204 .unwrap();
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 result = tool
221 .execute_with_context(
222 json!({"path": workspace.to_string_lossy()}),
223 ToolExecutionContext {
224 session_id: Some(&session),
225 tool_call_id: "call_1",
226 event_tx: None,
227 available_tool_schemas: None,
228 },
229 )
230 .await
231 .unwrap();
232 assert!(result.success);
233
234 let get_result = tool
236 .execute_with_context(
237 json!({}),
238 ToolExecutionContext {
239 session_id: Some(&session),
240 tool_call_id: "call_2",
241 event_tx: None,
242 available_tool_schemas: None,
243 },
244 )
245 .await
246 .unwrap();
247 assert!(get_result.success);
248 let expected = workspace.canonicalize().unwrap();
249 assert_eq!(
250 get_result.result,
251 bamboo_config::paths::path_to_display_string(&expected)
252 );
253 }
254
255 #[tokio::test]
256 async fn workspace_set_rejects_missing_path() {
257 let tool = WorkspaceTool::new();
258 let result = tool
259 .execute_with_context(
260 json!({"path": "/tmp/bamboo-no-such-workspace-xyz-99999"}),
261 ToolExecutionContext {
262 session_id: Some("session_1"),
263 tool_call_id: "call_1",
264 event_tx: None,
265 available_tool_schemas: None,
266 },
267 )
268 .await
269 .unwrap();
270 assert!(!result.success);
271 assert!(result.result.contains("does not exist"));
272 }
273
274 #[tokio::test]
275 async fn workspace_set_requires_session_context() {
276 let tool = WorkspaceTool::new();
277 let err = tool
278 .execute(json!({"path": "/"}))
279 .await
280 .expect_err("missing session should fail");
281 assert!(matches!(err, ToolError::Execution(msg) if msg.contains("session_id")));
282 }
283}