1use crate::events::{EventContext, EventRequest, ToolCallRequestedData};
15use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
16use crate::traits::{EventEmitter, ToolContext};
17use async_trait::async_trait;
18use serde_json::json;
19use std::sync::Arc;
20use uuid::Uuid;
21
22use super::AtomContext;
23use super::act::ActResult;
24
25#[derive(Debug, Clone)]
31pub enum PreToolUseDecision {
32 Continue(ToolCall),
34 Block {
37 tool_call: ToolCall,
38 reason: String,
39 user_message: Option<String>,
42 },
43}
44
45#[async_trait]
57pub trait PreToolUseHook: Send + Sync {
58 async fn before_exec(
59 &self,
60 tool_call: ToolCall,
61 tool_def: &ToolDefinition,
62 context: &ToolContext,
63 ) -> PreToolUseDecision;
64}
65
66pub(super) async fn run_pre_tool_use_hooks(
71 hooks: &[Arc<dyn PreToolUseHook>],
72 mut tool_call: ToolCall,
73 tool_def: &ToolDefinition,
74 context: &ToolContext,
75) -> PreToolUseDecision {
76 for hook in hooks {
77 match hook.before_exec(tool_call.clone(), tool_def, context).await {
78 PreToolUseDecision::Continue(updated) => {
79 tool_call = updated;
80 }
81 block @ PreToolUseDecision::Block { .. } => return block,
82 }
83 }
84 PreToolUseDecision::Continue(tool_call)
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
102pub enum PostToolExecHookPriority {
103 Guardrail = 0,
105 Normal = 100,
107}
108
109#[async_trait]
110pub trait PostToolExecHook: Send + Sync {
111 fn priority(&self) -> PostToolExecHookPriority {
113 PostToolExecHookPriority::Normal
114 }
115
116 async fn after_exec(
118 &self,
119 tool_call: &ToolCall,
120 tool_def: &ToolDefinition,
121 result: &mut ToolResult,
122 context: &ToolContext,
123 );
124}
125
126pub(super) async fn run_post_tool_exec_hooks(
130 hooks: &[Arc<dyn PostToolExecHook>],
131 final_hooks: &[Arc<dyn PostToolExecHook>],
132 tool_call: &ToolCall,
133 tool_def: &ToolDefinition,
134 result: &mut ToolResult,
135 context: &ToolContext,
136) {
137 for hook in hooks {
138 hook.after_exec(tool_call, tool_def, result, context).await;
139 }
140 for hook in final_hooks {
141 hook.after_exec(tool_call, tool_def, result, context).await;
142 }
143}
144
145const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
153
154const TRUNCATION_SUFFIX: &str =
155 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
156
157pub struct OutputHardLimitHook;
166
167impl OutputHardLimitHook {
168 fn truncate(text: String) -> String {
170 if text.len() <= MAX_TOOL_RESULT_BYTES {
171 return text;
172 }
173 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
174 let mut end = content_budget;
175 while end > 0 && !text.is_char_boundary(end) {
176 end -= 1;
177 }
178 let mut truncated = text[..end].to_string();
179 truncated.push_str(TRUNCATION_SUFFIX);
180 truncated
181 }
182}
183
184#[async_trait]
185impl PostToolExecHook for OutputHardLimitHook {
186 async fn after_exec(
187 &self,
188 tool_call: &ToolCall,
189 _tool_def: &ToolDefinition,
190 result: &mut ToolResult,
191 _context: &ToolContext,
192 ) {
193 if let Some(val) = result.result.take() {
195 match val {
196 serde_json::Value::String(s) => {
197 let original_len = s.len();
198 let truncated = Self::truncate(s);
199 if truncated.len() < original_len {
200 tracing::warn!(
201 tool_name = %tool_call.name,
202 tool_call_id = %tool_call.id,
203 result_bytes = original_len,
204 limit = MAX_TOOL_RESULT_BYTES,
205 "Tool result exceeded hard limit, truncated"
206 );
207 }
208 result.result = Some(serde_json::Value::String(truncated));
209 }
210 other => {
211 let serialized = serde_json::to_string(&other).unwrap_or_default();
214 if serialized.len() > MAX_TOOL_RESULT_BYTES {
215 tracing::warn!(
216 tool_name = %tool_call.name,
217 tool_call_id = %tool_call.id,
218 result_bytes = serialized.len(),
219 limit = MAX_TOOL_RESULT_BYTES,
220 "Tool result exceeded hard limit, truncated"
221 );
222 let truncated = Self::truncate(serialized);
223 result.result = Some(serde_json::Value::String(truncated));
224 } else {
225 result.result = Some(other);
226 }
227 }
228 }
229 }
230
231 if let Some(err) = result.error.take() {
233 if err.len() > MAX_TOOL_RESULT_BYTES {
234 tracing::warn!(
235 tool_name = %tool_call.name,
236 tool_call_id = %tool_call.id,
237 result_bytes = err.len(),
238 limit = MAX_TOOL_RESULT_BYTES,
239 "Tool error exceeded hard limit, truncated"
240 );
241 }
242 result.error = Some(Self::truncate(err));
243 }
244
245 if let Some(images) = result.images.as_mut() {
250 let original_count = images.len();
251 let mut cumulative = 0usize;
252 images.retain(|img| {
253 let len = img.base64.len();
254 if len > MAX_TOOL_RESULT_BYTES {
255 return false;
256 }
257 match cumulative.checked_add(len) {
258 Some(total) if total <= MAX_TOOL_RESULT_BYTES => {
259 cumulative = total;
260 true
261 }
262 _ => false,
263 }
264 });
265 let dropped = original_count.saturating_sub(images.len());
266 if dropped > 0 {
267 tracing::warn!(
268 tool_name = %tool_call.name,
269 tool_call_id = %tool_call.id,
270 dropped_images = dropped,
271 kept_images = images.len(),
272 kept_bytes = cumulative,
273 limit = MAX_TOOL_RESULT_BYTES,
274 "Tool images exceeded hard limit and were dropped"
275 );
276 }
277 if images.is_empty() {
278 result.images = None;
279 }
280 }
281 }
282}
283
284#[derive(Debug, Clone)]
290pub enum PostActAction {
291 EmitToolCallRequested {
293 tool_calls: Vec<ToolCall>,
294 tool_definitions: Vec<ToolDefinition>,
295 },
296}
297
298pub trait PostActHook: Send + Sync {
307 fn on_completed(
309 &self,
310 result: &mut ActResult,
311 tool_definitions: &[ToolDefinition],
312 ) -> Vec<PostActAction>;
313}
314
315pub struct ConnectionSetupHook;
326
327impl PostActHook for ConnectionSetupHook {
328 fn on_completed(
329 &self,
330 result: &mut ActResult,
331 _tool_definitions: &[ToolDefinition],
332 ) -> Vec<PostActAction> {
333 let providers: Vec<String> = result
334 .results
335 .iter()
336 .filter_map(|r| r.connection_required.clone())
337 .collect();
338
339 if providers.is_empty() {
340 return vec![];
341 }
342
343 result.waiting_for_tool_results = true;
344
345 let tool_calls: Vec<ToolCall> = providers
346 .iter()
347 .map(|provider| ToolCall {
348 id: format!("setup_conn_{}", Uuid::now_v7()),
349 name: "setup_connection".to_string(),
350 arguments: json!({ "provider": provider }),
351 })
352 .collect();
353
354 vec![PostActAction::EmitToolCallRequested {
355 tool_calls,
356 tool_definitions: vec![],
357 }]
358 }
359}
360
361pub struct ClientSideToolHook;
375
376impl PostActHook for ClientSideToolHook {
377 fn on_completed(
378 &self,
379 result: &mut ActResult,
380 _tool_definitions: &[ToolDefinition],
381 ) -> Vec<PostActAction> {
382 if result.client_tool_calls.is_empty() {
383 return vec![];
384 }
385
386 result.waiting_for_tool_results = true;
387
388 vec![PostActAction::EmitToolCallRequested {
389 tool_calls: result.client_tool_calls.clone(),
390 tool_definitions: result.client_tool_definitions.clone(),
391 }]
392 }
393}
394
395pub(super) async fn run_post_act_hooks<E: EventEmitter>(
405 hooks: &[Box<dyn PostActHook>],
406 context: &AtomContext,
407 result: &mut ActResult,
408 tool_definitions: &[ToolDefinition],
409 event_emitter: &E,
410 locale: Option<&str>,
411) {
412 for hook in hooks {
413 let actions = hook.on_completed(result, tool_definitions);
414 for action in actions {
415 match action {
416 PostActAction::EmitToolCallRequested {
417 tool_calls,
418 tool_definitions: action_defs,
419 } => {
420 let event = EventRequest::new(
421 context.session_id,
422 EventContext::turn(context.turn_id, context.input_message_id),
423 ToolCallRequestedData::with_definitions_and_locale(
424 &tool_calls,
425 &action_defs,
426 locale,
427 ),
428 );
429 if let Err(e) = event_emitter.emit(event).await {
430 tracing::warn!(
431 error = %e,
432 "PostActHook: failed to emit tool.call_requested event"
433 );
434 }
435 }
436 }
437 }
438 }
439}
440
441#[cfg(test)]
446mod tests {
447 use super::*;
448 use crate::atoms::act::ToolCallResult;
449 use crate::tool_types::ToolResult;
450
451 fn make_tool_call_result(connection_required: Option<&str>) -> ToolCallResult {
452 ToolCallResult {
453 tool_call: ToolCall {
454 id: "call_1".to_string(),
455 name: "some_tool".to_string(),
456 arguments: json!({}),
457 },
458 result: ToolResult {
459 tool_call_id: "call_1".to_string(),
460 result: Some(json!({})),
461 images: None,
462 error: None,
463 connection_required: connection_required.map(|s| s.to_string()),
464 raw_output: None,
465 },
466 success: true,
467 status: "success".to_string(),
468 connection_required: connection_required.map(|s| s.to_string()),
469 determinism_fatal: None,
470 }
471 }
472
473 #[test]
474 fn test_connection_setup_hook_no_connections() {
475 let hook = ConnectionSetupHook;
476 let mut result = ActResult {
477 results: vec![make_tool_call_result(None)],
478 completed: true,
479 success_count: 1,
480 error_count: 0,
481 waiting_for_tool_results: false,
482 blocked: false,
483 client_tool_calls: vec![],
484 client_tool_definitions: vec![],
485 };
486
487 let actions = hook.on_completed(&mut result, &[]);
488 assert!(actions.is_empty());
489 assert!(!result.waiting_for_tool_results);
490 }
491
492 #[test]
493 fn test_connection_setup_hook_with_connection() {
494 let hook = ConnectionSetupHook;
495 let mut result = ActResult {
496 results: vec![make_tool_call_result(Some("github"))],
497 completed: true,
498 success_count: 0,
499 error_count: 0,
500 waiting_for_tool_results: false,
501 blocked: false,
502 client_tool_calls: vec![],
503 client_tool_definitions: vec![],
504 };
505
506 let actions = hook.on_completed(&mut result, &[]);
507 assert_eq!(actions.len(), 1);
508 assert!(result.waiting_for_tool_results);
509
510 match &actions[0] {
511 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
512 assert_eq!(tool_calls.len(), 1);
513 assert_eq!(tool_calls[0].name, "setup_connection");
514 assert_eq!(tool_calls[0].arguments["provider"], "github");
515 }
516 }
517 }
518
519 #[test]
520 fn test_client_side_tool_hook_no_client_tools() {
521 let hook = ClientSideToolHook;
522 let mut result = ActResult {
523 results: vec![],
524 completed: true,
525 success_count: 0,
526 error_count: 0,
527 waiting_for_tool_results: false,
528 blocked: false,
529 client_tool_calls: vec![],
530 client_tool_definitions: vec![],
531 };
532
533 let actions = hook.on_completed(&mut result, &[]);
534 assert!(actions.is_empty());
535 assert!(!result.waiting_for_tool_results);
536 }
537
538 #[test]
539 fn test_client_side_tool_hook_with_client_tools() {
540 let hook = ClientSideToolHook;
541 let client_call = ToolCall {
542 id: "call_client".to_string(),
543 name: "browser_click".to_string(),
544 arguments: json!({"selector": "#btn"}),
545 };
546
547 let mut result = ActResult {
548 results: vec![],
549 completed: true,
550 success_count: 0,
551 error_count: 0,
552 waiting_for_tool_results: false,
553 blocked: false,
554 client_tool_calls: vec![client_call.clone()],
555 client_tool_definitions: vec![],
556 };
557
558 let actions = hook.on_completed(&mut result, &[]);
559 assert_eq!(actions.len(), 1);
560 assert!(result.waiting_for_tool_results);
561
562 match &actions[0] {
563 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
564 assert_eq!(tool_calls.len(), 1);
565 assert_eq!(tool_calls[0].name, "browser_click");
566 }
567 }
568 }
569
570 use crate::traits::ToolContext;
575 use crate::typed_id::SessionId;
576
577 fn make_tool_call() -> ToolCall {
578 ToolCall {
579 id: "call_test".to_string(),
580 name: "test_tool".to_string(),
581 arguments: json!({}),
582 }
583 }
584
585 fn make_tool_def() -> ToolDefinition {
586 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
587 name: "test_tool".to_string(),
588 display_name: None,
589 description: "test".to_string(),
590 parameters: json!({}),
591 policy: crate::tool_types::ToolPolicy::Auto,
592 category: None,
593 deferrable: crate::tool_types::DeferrablePolicy::Never,
594 hints: Default::default(),
595 full_parameters: None,
596 })
597 }
598
599 #[tokio::test]
600 async fn test_output_hard_limit_passthrough_small() {
601 let hook = OutputHardLimitHook;
602 let tc = make_tool_call();
603 let td = make_tool_def();
604 let ctx = ToolContext::new(SessionId::new());
605 let mut result = ToolResult {
606 tool_call_id: "call_test".into(),
607 result: Some(json!("hello")),
608 images: None,
609 error: None,
610 connection_required: None,
611 raw_output: None,
612 };
613
614 hook.after_exec(&tc, &td, &mut result, &ctx).await;
615 assert_eq!(result.result, Some(json!("hello")));
616 }
617
618 #[tokio::test]
619 async fn test_output_hard_limit_truncates_large_string() {
620 let hook = OutputHardLimitHook;
621 let tc = make_tool_call();
622 let td = make_tool_def();
623 let ctx = ToolContext::new(SessionId::new());
624 let big = "x".repeat(MAX_TOOL_RESULT_BYTES + 1000);
625 let mut result = ToolResult {
626 tool_call_id: "call_test".into(),
627 result: Some(json!(big)),
628 images: None,
629 error: None,
630 connection_required: None,
631 raw_output: None,
632 };
633
634 hook.after_exec(&tc, &td, &mut result, &ctx).await;
635
636 let text = result.result.unwrap();
637 let s = text.as_str().unwrap();
638 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
639 assert!(s.ends_with(TRUNCATION_SUFFIX));
640 }
641
642 #[tokio::test]
643 async fn test_output_hard_limit_at_exact_limit() {
644 let hook = OutputHardLimitHook;
645 let tc = make_tool_call();
646 let td = make_tool_def();
647 let ctx = ToolContext::new(SessionId::new());
648 let exact = "a".repeat(MAX_TOOL_RESULT_BYTES);
649 let mut result = ToolResult {
650 tool_call_id: "call_test".into(),
651 result: Some(json!(exact)),
652 images: None,
653 error: None,
654 connection_required: None,
655 raw_output: None,
656 };
657
658 hook.after_exec(&tc, &td, &mut result, &ctx).await;
659
660 let text = result.result.unwrap();
661 let s = text.as_str().unwrap();
662 assert_eq!(s.len(), MAX_TOOL_RESULT_BYTES);
664 assert!(!s.contains("[Output truncated"));
665 }
666
667 #[tokio::test]
668 async fn test_output_hard_limit_multibyte_boundary() {
669 let hook = OutputHardLimitHook;
670 let tc = make_tool_call();
671 let td = make_tool_def();
672 let ctx = ToolContext::new(SessionId::new());
673 let ch = "€"; let count = MAX_TOOL_RESULT_BYTES / ch.len() + 1;
675 let big = ch.repeat(count);
676 let mut result = ToolResult {
677 tool_call_id: "call_test".into(),
678 result: Some(json!(big)),
679 images: None,
680 error: None,
681 connection_required: None,
682 raw_output: None,
683 };
684
685 hook.after_exec(&tc, &td, &mut result, &ctx).await;
686
687 let text = result.result.unwrap();
688 let s = text.as_str().unwrap();
689 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
690 assert!(s.contains("[Output truncated"));
691 }
692
693 #[tokio::test]
694 async fn test_output_hard_limit_truncates_error() {
695 let hook = OutputHardLimitHook;
696 let tc = make_tool_call();
697 let td = make_tool_def();
698 let ctx = ToolContext::new(SessionId::new());
699 let big_err = "e".repeat(MAX_TOOL_RESULT_BYTES + 500);
700 let mut result = ToolResult {
701 tool_call_id: "call_test".into(),
702 result: None,
703 images: None,
704 error: Some(big_err),
705 connection_required: None,
706 raw_output: None,
707 };
708
709 hook.after_exec(&tc, &td, &mut result, &ctx).await;
710
711 let err = result.error.unwrap();
712 assert!(err.len() <= MAX_TOOL_RESULT_BYTES);
713 assert!(err.ends_with(TRUNCATION_SUFFIX));
714 }
715
716 #[tokio::test]
717 async fn test_output_hard_limit_non_string_json() {
718 let hook = OutputHardLimitHook;
719 let tc = make_tool_call();
720 let td = make_tool_def();
721 let ctx = ToolContext::new(SessionId::new());
722 let mut result = ToolResult {
724 tool_call_id: "call_test".into(),
725 result: Some(json!({"key": "value", "num": 42})),
726 images: None,
727 error: None,
728 connection_required: None,
729 raw_output: None,
730 };
731
732 hook.after_exec(&tc, &td, &mut result, &ctx).await;
733
734 assert_eq!(result.result, Some(json!({"key": "value", "num": 42})));
736 }
737
738 #[tokio::test]
739 async fn test_output_hard_limit_drops_oversized_images() {
740 let hook = OutputHardLimitHook;
741 let tc = make_tool_call();
742 let td = make_tool_def();
743 let ctx = ToolContext::new(SessionId::new());
744
745 let mut result = ToolResult {
746 tool_call_id: "call_test".into(),
747 result: Some(json!({"ok": true})),
748 images: Some(vec![
749 crate::tools::ToolResultImage {
750 base64: "a".repeat(32),
751 media_type: "image/png".to_string(),
752 },
753 crate::tools::ToolResultImage {
754 base64: "b".repeat(MAX_TOOL_RESULT_BYTES + 1),
755 media_type: "image/png".to_string(),
756 },
757 ]),
758 error: None,
759 connection_required: None,
760 raw_output: None,
761 };
762
763 hook.after_exec(&tc, &td, &mut result, &ctx).await;
764
765 let images = result.images.unwrap();
766 assert_eq!(images.len(), 1);
767 assert_eq!(images[0].base64.len(), 32);
768 }
769
770 #[tokio::test]
771 async fn test_output_hard_limit_enforces_cumulative_image_budget() {
772 let hook = OutputHardLimitHook;
773 let tc = make_tool_call();
774 let td = make_tool_def();
775 let ctx = ToolContext::new(SessionId::new());
776
777 let half = MAX_TOOL_RESULT_BYTES / 2;
780 let mut result = ToolResult {
781 tool_call_id: "call_test".into(),
782 result: Some(json!({"ok": true})),
783 images: Some(vec![
784 crate::tools::ToolResultImage {
785 base64: "a".repeat(half),
786 media_type: "image/png".to_string(),
787 },
788 crate::tools::ToolResultImage {
789 base64: "b".repeat(half),
790 media_type: "image/png".to_string(),
791 },
792 crate::tools::ToolResultImage {
793 base64: "c".repeat(half),
794 media_type: "image/png".to_string(),
795 },
796 ]),
797 error: None,
798 connection_required: None,
799 raw_output: None,
800 };
801
802 hook.after_exec(&tc, &td, &mut result, &ctx).await;
803
804 let images = result.images.unwrap();
805 assert_eq!(
806 images.len(),
807 2,
808 "third image should be dropped by cumulative budget"
809 );
810 assert!(images.iter().all(|i| i.base64.len() == half));
811 }
812
813 #[tokio::test]
814 async fn test_output_hard_limit_normalizes_empty_images_to_none() {
815 let hook = OutputHardLimitHook;
816 let tc = make_tool_call();
817 let td = make_tool_def();
818 let ctx = ToolContext::new(SessionId::new());
819
820 let mut result = ToolResult {
821 tool_call_id: "call_test".into(),
822 result: Some(json!({"ok": true})),
823 images: Some(vec![crate::tools::ToolResultImage {
824 base64: "a".repeat(MAX_TOOL_RESULT_BYTES + 1),
825 media_type: "image/png".to_string(),
826 }]),
827 error: None,
828 connection_required: None,
829 raw_output: None,
830 };
831
832 hook.after_exec(&tc, &td, &mut result, &ctx).await;
833
834 assert!(
835 result.images.is_none(),
836 "images vec emptied by retain should normalize to None"
837 );
838 }
839
840 #[test]
841 fn test_truncate_helper_short() {
842 let s = "hello".to_string();
843 assert_eq!(OutputHardLimitHook::truncate(s.clone()), s);
844 }
845
846 #[test]
847 fn test_truncate_helper_over() {
848 let s = "a".repeat(MAX_TOOL_RESULT_BYTES + 100);
849 let t = OutputHardLimitHook::truncate(s);
850 assert!(t.len() <= MAX_TOOL_RESULT_BYTES);
851 assert!(t.ends_with(TRUNCATION_SUFFIX));
852 }
853}