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 fn resolve_args(&self, call: &ToolCall, ctx: &ToolExecutionContext<'_>) -> serde_json::Value {
30 if let Some(pre_parsed) = ctx.pre_parsed_args {
31 return pre_parsed.clone();
32 }
33 let args_raw = call.function.arguments.trim();
34 let (args, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
35 if let Some(warning) = parse_warning {
36 tracing::warn!(
37 "Overlay tool argument parsing fallback applied: tool_call_id={}, tool_name={}, args_len={}, warning={}",
38 call.id,
39 call.function.name,
40 args_raw.len(),
41 warning
42 );
43 }
44 args
45 }
46}
47
48#[async_trait]
49impl ToolExecutor for OverlayToolExecutor {
50 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
51 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
52 .await
53 }
54
55 async fn execute_with_context(
56 &self,
57 call: &ToolCall,
58 ctx: ToolExecutionContext<'_>,
59 ) -> Result<ToolResult, ToolError> {
60 let name = normalize_tool_name(&call.function.name);
61 let is_overlay_call = name == self.overlay.name()
62 || normalize_tool_ref(name)
63 .as_deref()
64 .is_some_and(|normalized| normalized == self.overlay.name());
65 if is_overlay_call {
66 if let Some(outcome) = self.base.check_permissions_for(call, &ctx).await? {
72 return Ok(outcome.into_tool_result());
73 }
74 let args = self.resolve_args(call, &ctx);
83 return self
84 .overlay
85 .invoke(args, ctx.to_tool_ctx())
86 .await
87 .map(|outcome| outcome.into_tool_result());
88 }
89 self.base.execute_with_context(call, ctx).await
90 }
91
92 async fn execute_with_context_outcome(
93 &self,
94 call: &ToolCall,
95 ctx: ToolExecutionContext<'_>,
96 ) -> Result<ToolOutcome, ToolError> {
97 let name = normalize_tool_name(&call.function.name);
98 let is_overlay_call = name == self.overlay.name()
99 || normalize_tool_ref(name)
100 .as_deref()
101 .is_some_and(|normalized| normalized == self.overlay.name());
102 if is_overlay_call {
103 if let Some(outcome) = self.base.check_permissions_for(call, &ctx).await? {
106 return Ok(outcome);
107 }
108 let args = self.resolve_args(call, &ctx);
111 return self.overlay.invoke(args, ctx.to_tool_ctx()).await;
112 }
113 self.base.execute_with_context_outcome(call, ctx).await
114 }
115
116 async fn check_permissions_for(
120 &self,
121 call: &ToolCall,
122 ctx: &ToolExecutionContext<'_>,
123 ) -> Result<Option<ToolOutcome>, ToolError> {
124 self.base.check_permissions_for(call, ctx).await
125 }
126
127 fn list_tools(&self) -> Vec<ToolSchema> {
128 let mut tools = self.base.list_tools();
129
130 let overlay_schema = self.overlay.to_schema();
132 let overlay_name = overlay_schema.function.name.clone();
133 tools.retain(|t| t.function.name != overlay_name);
134 tools.push(overlay_schema);
135
136 tools.sort_by_key(|t| t.function.name.clone());
137 tools
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 use serde_json::json;
146
147 use bamboo_agent_core::tools::{FunctionCall, ToolCtx};
148
149 struct BaseExecutor;
150
151 #[async_trait]
152 impl ToolExecutor for BaseExecutor {
153 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
154 Err(ToolError::Execution(format!(
155 "base executor called for {}",
156 call.function.name
157 )))
158 }
159
160 async fn execute_with_context(
161 &self,
162 call: &ToolCall,
163 _ctx: ToolExecutionContext<'_>,
164 ) -> Result<ToolResult, ToolError> {
165 self.execute(call).await
166 }
167
168 fn list_tools(&self) -> Vec<ToolSchema> {
169 Vec::new()
170 }
171 }
172
173 struct SubAgentOverlayTool;
174
175 #[async_trait]
176 impl Tool for SubAgentOverlayTool {
177 fn name(&self) -> &str {
178 "SubAgent"
179 }
180
181 fn description(&self) -> &str {
182 "overlay sub agent"
183 }
184
185 fn parameters_schema(&self) -> serde_json::Value {
186 json!({"type":"object","properties":{}})
187 }
188
189 async fn invoke(
190 &self,
191 _args: serde_json::Value,
192 _ctx: ToolCtx,
193 ) -> Result<ToolOutcome, ToolError> {
194 Ok(ToolOutcome::Completed(ToolResult {
195 success: true,
196 result: "overlay".to_string(),
197 display_preference: None,
198 images: Vec::new(),
199 }))
200 }
201 }
202
203 fn make_call(name: &str) -> ToolCall {
204 ToolCall {
205 id: "call_1".to_string(),
206 tool_type: "function".to_string(),
207 function: FunctionCall {
208 name: name.to_string(),
209 arguments: "{}".to_string(),
210 },
211 }
212 }
213
214 #[tokio::test]
215 async fn overlay_executor_routes_spawn_alias_to_overlay_tool() {
216 let overlay = OverlayToolExecutor::new(
217 std::sync::Arc::new(BaseExecutor),
218 std::sync::Arc::new(SubAgentOverlayTool),
219 );
220
221 let result = overlay
222 .execute(&make_call("sub_task"))
223 .await
224 .expect("spawn alias should route to overlay");
225
226 assert!(result.success);
227 assert_eq!(result.result, "overlay");
228 }
229
230 #[tokio::test]
231 async fn overlay_executor_keeps_non_overlay_calls_on_base_executor() {
232 let overlay = OverlayToolExecutor::new(
233 std::sync::Arc::new(BaseExecutor),
234 std::sync::Arc::new(SubAgentOverlayTool),
235 );
236
237 let err = overlay
238 .execute(&make_call("Read"))
239 .await
240 .expect_err("non-overlay call should stay on base executor");
241
242 assert!(
243 matches!(err, ToolError::Execution(msg) if msg.contains("base executor called for Read"))
244 );
245 }
246
247 use std::sync::atomic::{AtomicBool, Ordering};
250
251 fn make_call_with_args(name: &str, args: &str) -> ToolCall {
252 ToolCall {
253 id: "call_1".to_string(),
254 tool_type: "function".to_string(),
255 function: FunctionCall {
256 name: name.to_string(),
257 arguments: args.to_string(),
258 },
259 }
260 }
261
262 struct RecordingMemoryOverlayTool {
265 invoked: std::sync::Arc<AtomicBool>,
266 }
267
268 #[async_trait]
269 impl Tool for RecordingMemoryOverlayTool {
270 fn name(&self) -> &str {
271 "memory"
272 }
273
274 fn description(&self) -> &str {
275 "overlay memory tool"
276 }
277
278 fn parameters_schema(&self) -> serde_json::Value {
279 json!({"type":"object","properties":{"action":{"type":"string"}}})
280 }
281
282 async fn invoke(
283 &self,
284 _args: serde_json::Value,
285 _ctx: ToolCtx,
286 ) -> Result<ToolOutcome, ToolError> {
287 self.invoked.store(true, Ordering::SeqCst);
288 Ok(ToolOutcome::Completed(ToolResult {
289 success: true,
290 result: "purged".to_string(),
291 display_preference: None,
292 images: Vec::new(),
293 }))
294 }
295 }
296
297 #[tokio::test]
298 async fn overlay_memory_purge_is_denied_by_base_permission_gate() {
299 let invoked = std::sync::Arc::new(AtomicBool::new(false));
305 let base = bamboo_tools::BuiltinToolExecutor::new_with_permissions(std::sync::Arc::new(
306 bamboo_tools::permission::DenyDangerousPermissionChecker,
307 ));
308 let overlay = OverlayToolExecutor::new(
309 std::sync::Arc::new(base),
310 std::sync::Arc::new(RecordingMemoryOverlayTool {
311 invoked: invoked.clone(),
312 }),
313 );
314
315 let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
316 let result = overlay.execute(&call).await;
317
318 assert!(
319 matches!(result, Err(ToolError::Execution(_))),
320 "gated overlay call must be denied, got: {result:?}"
321 );
322 assert!(
323 !invoked.load(Ordering::SeqCst),
324 "overlay tool must NOT run when the permission gate denies it"
325 );
326 }
327
328 #[tokio::test]
329 async fn overlay_read_only_memory_action_passes_gate_and_runs() {
330 let invoked = std::sync::Arc::new(AtomicBool::new(false));
334 let base = bamboo_tools::BuiltinToolExecutor::new_with_permissions(std::sync::Arc::new(
335 bamboo_tools::permission::DenyDangerousPermissionChecker,
336 ));
337 let overlay = OverlayToolExecutor::new(
338 std::sync::Arc::new(base),
339 std::sync::Arc::new(RecordingMemoryOverlayTool {
340 invoked: invoked.clone(),
341 }),
342 );
343
344 let call = make_call_with_args("memory", r#"{"action":"query"}"#);
345 let result = overlay
346 .execute(&call)
347 .await
348 .expect("read-only overlay action should pass the gate");
349
350 assert!(result.success);
351 assert_eq!(result.result, "purged");
352 assert!(
353 invoked.load(Ordering::SeqCst),
354 "read-only overlay action must actually run"
355 );
356 }
357
358 struct GateDenyingBaseExecutor;
362
363 #[async_trait]
364 impl ToolExecutor for GateDenyingBaseExecutor {
365 async fn execute(&self, _call: &ToolCall) -> Result<ToolResult, ToolError> {
366 panic!("base execute must not be reached for a denied overlay call");
367 }
368
369 async fn execute_with_context(
370 &self,
371 _call: &ToolCall,
372 _ctx: ToolExecutionContext<'_>,
373 ) -> Result<ToolResult, ToolError> {
374 panic!("base execute_with_context must not be reached for a denied overlay call");
375 }
376
377 async fn check_permissions_for(
378 &self,
379 _call: &ToolCall,
380 _ctx: &ToolExecutionContext<'_>,
381 ) -> Result<Option<ToolOutcome>, ToolError> {
382 Err(ToolError::Execution("denied-by-base-gate".to_string()))
383 }
384
385 fn list_tools(&self) -> Vec<ToolSchema> {
386 Vec::new()
387 }
388 }
389
390 #[tokio::test]
391 async fn overlay_call_short_circuits_on_base_gate_error_before_invoke() {
392 let invoked = std::sync::Arc::new(AtomicBool::new(false));
396 let overlay = OverlayToolExecutor::new(
397 std::sync::Arc::new(GateDenyingBaseExecutor),
398 std::sync::Arc::new(RecordingMemoryOverlayTool {
399 invoked: invoked.clone(),
400 }),
401 );
402
403 let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
404 let err = overlay
405 .execute(&call)
406 .await
407 .expect_err("base gate error must short-circuit the overlay call");
408
409 assert!(
410 matches!(err, ToolError::Execution(msg) if msg.contains("denied-by-base-gate")),
411 "overlay must return the base gate's error verbatim"
412 );
413 assert!(
414 !invoked.load(Ordering::SeqCst),
415 "overlay tool must NOT run when the base gate errors"
416 );
417 }
418
419 #[tokio::test]
420 async fn overlay_check_permissions_for_delegates_to_base() {
421 let overlay = OverlayToolExecutor::new(
424 std::sync::Arc::new(GateDenyingBaseExecutor),
425 std::sync::Arc::new(RecordingMemoryOverlayTool {
426 invoked: std::sync::Arc::new(AtomicBool::new(false)),
427 }),
428 );
429
430 let call = make_call_with_args("memory", r#"{"action":"purge"}"#);
431 let ctx = ToolExecutionContext::none(&call.id);
432 let result = overlay.check_permissions_for(&call, &ctx).await;
433
434 assert!(
435 matches!(result, Err(ToolError::Execution(ref msg)) if msg.contains("denied-by-base-gate")),
436 "overlay check_permissions_for must return the base's decision, got: {result:?}"
437 );
438 }
439
440 use std::sync::atomic::AtomicUsize;
443 use std::sync::Mutex as StdMutex;
444
445 struct ArgsRecordingOverlayTool {
449 seen: std::sync::Arc<StdMutex<Option<serde_json::Value>>>,
450 }
451
452 #[async_trait]
453 impl Tool for ArgsRecordingOverlayTool {
454 fn name(&self) -> &str {
455 "memory"
456 }
457
458 fn description(&self) -> &str {
459 "records the args it was invoked with"
460 }
461
462 fn parameters_schema(&self) -> serde_json::Value {
463 json!({"type":"object","properties":{}})
464 }
465
466 async fn invoke(
467 &self,
468 args: serde_json::Value,
469 _ctx: ToolCtx,
470 ) -> Result<ToolOutcome, ToolError> {
471 *self.seen.lock().unwrap() = Some(args);
472 Ok(ToolOutcome::Completed(ToolResult {
473 success: true,
474 result: "ok".to_string(),
475 display_preference: None,
476 images: Vec::new(),
477 }))
478 }
479 }
480
481 #[derive(Clone, Default)]
485 struct WarnCounter {
486 warns: std::sync::Arc<AtomicUsize>,
487 }
488
489 impl tracing::Subscriber for WarnCounter {
490 fn enabled(&self, _m: &tracing::Metadata<'_>) -> bool {
491 true
492 }
493 fn new_span(&self, _a: &tracing::span::Attributes<'_>) -> tracing::span::Id {
494 tracing::span::Id::from_u64(1)
495 }
496 fn record(&self, _s: &tracing::span::Id, _v: &tracing::span::Record<'_>) {}
497 fn record_follows_from(&self, _s: &tracing::span::Id, _f: &tracing::span::Id) {}
498 fn event(&self, event: &tracing::Event<'_>) {
499 if *event.metadata().level() == tracing::Level::WARN {
500 self.warns.fetch_add(1, Ordering::SeqCst);
501 }
502 }
503 fn enter(&self, _s: &tracing::span::Id) {}
504 fn exit(&self, _s: &tracing::span::Id) {}
505 }
506
507 #[tokio::test]
508 async fn overlay_reuses_pre_parsed_args_and_skips_refallback_warn() {
509 let seen = std::sync::Arc::new(StdMutex::new(None));
514 let overlay = OverlayToolExecutor::new(
515 std::sync::Arc::new(BaseExecutor),
516 std::sync::Arc::new(ArgsRecordingOverlayTool { seen: seen.clone() }),
517 );
518
519 let pre_parsed = json!({"action": "query", "threaded": true});
522 let call = make_call_with_args("memory", "{ this is not valid json");
523 let mut ctx = ToolExecutionContext::none(&call.id);
524 ctx.pre_parsed_args = Some(&pre_parsed);
525
526 let counter = WarnCounter::default();
527 let warns = counter.warns.clone();
528 {
529 let _guard = tracing::subscriber::set_default(counter);
530 overlay
531 .execute_with_context(&call, ctx)
532 .await
533 .expect("overlay call should succeed");
534 }
535
536 assert_eq!(
537 seen.lock().unwrap().clone(),
538 Some(pre_parsed),
539 "overlay must invoke with the threaded pre-parsed args, not a re-parse of the raw string"
540 );
541 assert_eq!(
542 warns.load(Ordering::SeqCst),
543 0,
544 "reusing pre-parsed args must NOT re-emit the malformed-args fallback warning"
545 );
546 }
547
548 #[tokio::test]
549 async fn overlay_without_pre_parsed_reparses_and_warns_on_malformed_args() {
550 let seen = std::sync::Arc::new(StdMutex::new(None));
554 let overlay = OverlayToolExecutor::new(
555 std::sync::Arc::new(BaseExecutor),
556 std::sync::Arc::new(ArgsRecordingOverlayTool { seen: seen.clone() }),
557 );
558
559 let call = make_call_with_args("memory", "{ this is not valid json");
560 let ctx = ToolExecutionContext::none(&call.id); let counter = WarnCounter::default();
563 let warns = counter.warns.clone();
564 {
565 let _guard = tracing::subscriber::set_default(counter);
566 overlay
567 .execute_with_context(&call, ctx)
568 .await
569 .expect("overlay call should succeed");
570 }
571
572 assert_eq!(
573 seen.lock().unwrap().clone(),
574 Some(json!({})),
575 "no pre-parsed args → the malformed raw string parses to the empty-object fallback"
576 );
577 assert_eq!(
578 warns.load(Ordering::SeqCst),
579 1,
580 "the malformed-args fallback must warn exactly once when it actually parses"
581 );
582 }
583}