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