1use async_trait::async_trait;
2
3use bamboo_agent_core::tools::{
4 normalize_tool_name, parse_tool_args_best_effort, Tool, ToolCall, ToolError,
5 ToolExecutionContext, ToolExecutor, ToolOutcome, ToolResult, ToolSchema,
6};
7use bamboo_tools::normalize_tool_ref;
8
9pub struct OverlayToolExecutor {
14 base: std::sync::Arc<dyn ToolExecutor>,
15 overlay: std::sync::Arc<dyn Tool>,
16}
17
18impl OverlayToolExecutor {
19 pub fn new(base: std::sync::Arc<dyn ToolExecutor>, overlay: std::sync::Arc<dyn Tool>) -> Self {
20 Self { base, overlay }
21 }
22}
23
24#[async_trait]
25impl ToolExecutor for OverlayToolExecutor {
26 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
27 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
28 .await
29 }
30
31 async fn execute_with_context(
32 &self,
33 call: &ToolCall,
34 ctx: ToolExecutionContext<'_>,
35 ) -> Result<ToolResult, ToolError> {
36 let name = normalize_tool_name(&call.function.name);
37 let is_overlay_call = name == self.overlay.name()
38 || normalize_tool_ref(name)
39 .as_deref()
40 .is_some_and(|normalized| normalized == self.overlay.name());
41 if is_overlay_call {
42 if let Some(outcome) = self.base.check_permissions_for(call, &ctx).await? {
48 return Ok(outcome.into_tool_result());
49 }
50 let args_raw = call.function.arguments.trim();
51 let (args, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
52 if let Some(warning) = parse_warning {
53 tracing::warn!(
54 "Overlay tool argument parsing fallback applied: tool_call_id={}, tool_name={}, args_len={}, warning={}",
55 call.id,
56 call.function.name,
57 args_raw.len(),
58 warning
59 );
60 }
61 return self
62 .overlay
63 .invoke(args, ctx.to_tool_ctx())
64 .await
65 .map(|outcome| outcome.into_tool_result());
66 }
67 self.base.execute_with_context(call, ctx).await
68 }
69
70 async fn execute_with_context_outcome(
71 &self,
72 call: &ToolCall,
73 ctx: ToolExecutionContext<'_>,
74 ) -> Result<ToolOutcome, ToolError> {
75 let name = normalize_tool_name(&call.function.name);
76 let is_overlay_call = name == self.overlay.name()
77 || normalize_tool_ref(name)
78 .as_deref()
79 .is_some_and(|normalized| normalized == self.overlay.name());
80 if is_overlay_call {
81 if let Some(outcome) = self.base.check_permissions_for(call, &ctx).await? {
84 return Ok(outcome);
85 }
86 let (args, _) = parse_tool_args_best_effort(&call.function.arguments);
87 return self.overlay.invoke(args, ctx.to_tool_ctx()).await;
88 }
89 self.base.execute_with_context_outcome(call, ctx).await
90 }
91
92 async fn check_permissions_for(
96 &self,
97 call: &ToolCall,
98 ctx: &ToolExecutionContext<'_>,
99 ) -> Result<Option<ToolOutcome>, ToolError> {
100 self.base.check_permissions_for(call, ctx).await
101 }
102
103 fn list_tools(&self) -> Vec<ToolSchema> {
104 let mut tools = self.base.list_tools();
105
106 let overlay_schema = self.overlay.to_schema();
108 let overlay_name = overlay_schema.function.name.clone();
109 tools.retain(|t| t.function.name != overlay_name);
110 tools.push(overlay_schema);
111
112 tools.sort_by_key(|t| t.function.name.clone());
113 tools
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 use serde_json::json;
122
123 use bamboo_agent_core::tools::{FunctionCall, ToolCtx};
124
125 struct BaseExecutor;
126
127 #[async_trait]
128 impl ToolExecutor for BaseExecutor {
129 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
130 Err(ToolError::Execution(format!(
131 "base executor called for {}",
132 call.function.name
133 )))
134 }
135
136 async fn execute_with_context(
137 &self,
138 call: &ToolCall,
139 _ctx: ToolExecutionContext<'_>,
140 ) -> Result<ToolResult, ToolError> {
141 self.execute(call).await
142 }
143
144 fn list_tools(&self) -> Vec<ToolSchema> {
145 Vec::new()
146 }
147 }
148
149 struct SubAgentOverlayTool;
150
151 #[async_trait]
152 impl Tool for SubAgentOverlayTool {
153 fn name(&self) -> &str {
154 "SubAgent"
155 }
156
157 fn description(&self) -> &str {
158 "overlay sub agent"
159 }
160
161 fn parameters_schema(&self) -> serde_json::Value {
162 json!({"type":"object","properties":{}})
163 }
164
165 async fn invoke(
166 &self,
167 _args: serde_json::Value,
168 _ctx: ToolCtx,
169 ) -> Result<ToolOutcome, ToolError> {
170 Ok(ToolOutcome::Completed(ToolResult {
171 success: true,
172 result: "overlay".to_string(),
173 display_preference: None,
174 images: Vec::new(),
175 }))
176 }
177 }
178
179 fn make_call(name: &str) -> ToolCall {
180 ToolCall {
181 id: "call_1".to_string(),
182 tool_type: "function".to_string(),
183 function: FunctionCall {
184 name: name.to_string(),
185 arguments: "{}".to_string(),
186 },
187 }
188 }
189
190 #[tokio::test]
191 async fn overlay_executor_routes_spawn_alias_to_overlay_tool() {
192 let overlay = OverlayToolExecutor::new(
193 std::sync::Arc::new(BaseExecutor),
194 std::sync::Arc::new(SubAgentOverlayTool),
195 );
196
197 let result = overlay
198 .execute(&make_call("sub_task"))
199 .await
200 .expect("spawn alias should route to overlay");
201
202 assert!(result.success);
203 assert_eq!(result.result, "overlay");
204 }
205
206 #[tokio::test]
207 async fn overlay_executor_keeps_non_overlay_calls_on_base_executor() {
208 let overlay = OverlayToolExecutor::new(
209 std::sync::Arc::new(BaseExecutor),
210 std::sync::Arc::new(SubAgentOverlayTool),
211 );
212
213 let err = overlay
214 .execute(&make_call("Read"))
215 .await
216 .expect_err("non-overlay call should stay on base executor");
217
218 assert!(
219 matches!(err, ToolError::Execution(msg) if msg.contains("base executor called for Read"))
220 );
221 }
222
223 use std::sync::atomic::{AtomicBool, Ordering};
226
227 fn make_call_with_args(name: &str, args: &str) -> ToolCall {
228 ToolCall {
229 id: "call_1".to_string(),
230 tool_type: "function".to_string(),
231 function: FunctionCall {
232 name: name.to_string(),
233 arguments: args.to_string(),
234 },
235 }
236 }
237
238 struct RecordingMemoryOverlayTool {
241 invoked: std::sync::Arc<AtomicBool>,
242 }
243
244 #[async_trait]
245 impl Tool for RecordingMemoryOverlayTool {
246 fn name(&self) -> &str {
247 "memory"
248 }
249
250 fn description(&self) -> &str {
251 "overlay memory tool"
252 }
253
254 fn parameters_schema(&self) -> serde_json::Value {
255 json!({"type":"object","properties":{"action":{"type":"string"}}})
256 }
257
258 async fn invoke(
259 &self,
260 _args: serde_json::Value,
261 _ctx: ToolCtx,
262 ) -> Result<ToolOutcome, ToolError> {
263 self.invoked.store(true, Ordering::SeqCst);
264 Ok(ToolOutcome::Completed(ToolResult {
265 success: true,
266 result: "purged".to_string(),
267 display_preference: None,
268 images: Vec::new(),
269 }))
270 }
271 }
272
273 #[tokio::test]
274 async fn overlay_memory_purge_is_denied_by_base_permission_gate() {
275 let invoked = std::sync::Arc::new(AtomicBool::new(false));
281 let base = bamboo_tools::BuiltinToolExecutor::new_with_permissions(std::sync::Arc::new(
282 bamboo_tools::permission::DenyDangerousPermissionChecker,
283 ));
284 let overlay = OverlayToolExecutor::new(
285 std::sync::Arc::new(base),
286 std::sync::Arc::new(RecordingMemoryOverlayTool {
287 invoked: invoked.clone(),
288 }),
289 );
290
291 let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
292 let result = overlay.execute(&call).await;
293
294 assert!(
295 matches!(result, Err(ToolError::Execution(_))),
296 "gated overlay call must be denied, got: {result:?}"
297 );
298 assert!(
299 !invoked.load(Ordering::SeqCst),
300 "overlay tool must NOT run when the permission gate denies it"
301 );
302 }
303
304 #[tokio::test]
305 async fn overlay_read_only_memory_action_passes_gate_and_runs() {
306 let invoked = std::sync::Arc::new(AtomicBool::new(false));
310 let base = bamboo_tools::BuiltinToolExecutor::new_with_permissions(std::sync::Arc::new(
311 bamboo_tools::permission::DenyDangerousPermissionChecker,
312 ));
313 let overlay = OverlayToolExecutor::new(
314 std::sync::Arc::new(base),
315 std::sync::Arc::new(RecordingMemoryOverlayTool {
316 invoked: invoked.clone(),
317 }),
318 );
319
320 let call = make_call_with_args("memory", r#"{"action":"query"}"#);
321 let result = overlay
322 .execute(&call)
323 .await
324 .expect("read-only overlay action should pass the gate");
325
326 assert!(result.success);
327 assert_eq!(result.result, "purged");
328 assert!(
329 invoked.load(Ordering::SeqCst),
330 "read-only overlay action must actually run"
331 );
332 }
333
334 struct GateDenyingBaseExecutor;
338
339 #[async_trait]
340 impl ToolExecutor for GateDenyingBaseExecutor {
341 async fn execute(&self, _call: &ToolCall) -> Result<ToolResult, ToolError> {
342 panic!("base execute must not be reached for a denied overlay call");
343 }
344
345 async fn execute_with_context(
346 &self,
347 _call: &ToolCall,
348 _ctx: ToolExecutionContext<'_>,
349 ) -> Result<ToolResult, ToolError> {
350 panic!("base execute_with_context must not be reached for a denied overlay call");
351 }
352
353 async fn check_permissions_for(
354 &self,
355 _call: &ToolCall,
356 _ctx: &ToolExecutionContext<'_>,
357 ) -> Result<Option<ToolOutcome>, ToolError> {
358 Err(ToolError::Execution("denied-by-base-gate".to_string()))
359 }
360
361 fn list_tools(&self) -> Vec<ToolSchema> {
362 Vec::new()
363 }
364 }
365
366 #[tokio::test]
367 async fn overlay_call_short_circuits_on_base_gate_error_before_invoke() {
368 let invoked = std::sync::Arc::new(AtomicBool::new(false));
372 let overlay = OverlayToolExecutor::new(
373 std::sync::Arc::new(GateDenyingBaseExecutor),
374 std::sync::Arc::new(RecordingMemoryOverlayTool {
375 invoked: invoked.clone(),
376 }),
377 );
378
379 let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
380 let err = overlay
381 .execute(&call)
382 .await
383 .expect_err("base gate error must short-circuit the overlay call");
384
385 assert!(
386 matches!(err, ToolError::Execution(msg) if msg.contains("denied-by-base-gate")),
387 "overlay must return the base gate's error verbatim"
388 );
389 assert!(
390 !invoked.load(Ordering::SeqCst),
391 "overlay tool must NOT run when the base gate errors"
392 );
393 }
394
395 #[tokio::test]
396 async fn overlay_check_permissions_for_delegates_to_base() {
397 let overlay = OverlayToolExecutor::new(
400 std::sync::Arc::new(GateDenyingBaseExecutor),
401 std::sync::Arc::new(RecordingMemoryOverlayTool {
402 invoked: std::sync::Arc::new(AtomicBool::new(false)),
403 }),
404 );
405
406 let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
407 let ctx = ToolExecutionContext::none(&call.id);
408 let result = overlay.check_permissions_for(&call, &ctx).await;
409
410 assert!(
411 matches!(result, Err(ToolError::Execution(ref msg)) if msg.contains("denied-by-base-gate")),
412 "overlay check_permissions_for must return the base's decision, got: {result:?}"
413 );
414 }
415}