1use crate::events::{EventContext, EventRequest, ToolCallRequestedData};
15use crate::tool_types::{
16 CONFIRM_URL_ELICITATION_TOOL, ToolCall, ToolDefinition, ToolResult, UrlElicitationRequired,
17};
18use crate::{event_emitter::EventEmitter, tool_context::ToolContext};
19use async_trait::async_trait;
20pub(crate) use everruns_core::tool_hooks::{PostToolExecHook, PreToolUseDecision, PreToolUseHook};
21use serde_json::json;
22use std::sync::Arc;
23use uuid::Uuid;
24
25use super::ExecutionContext;
26use super::act::ActResult;
27
28pub(super) async fn run_pre_tool_use_hooks(
33 hooks: &[Arc<dyn PreToolUseHook>],
34 mut tool_call: ToolCall,
35 tool_def: &ToolDefinition,
36 context: &ToolContext,
37) -> PreToolUseDecision {
38 for hook in hooks {
39 match hook.before_exec(tool_call.clone(), tool_def, context).await {
40 PreToolUseDecision::Continue(updated) => {
41 tool_call = updated;
42 }
43 block @ PreToolUseDecision::Block { .. } => return block,
44 }
45 }
46 PreToolUseDecision::Continue(tool_call)
47}
48
49pub(super) async fn run_post_tool_exec_hooks(
53 hooks: &[Arc<dyn PostToolExecHook>],
54 final_hooks: &[Arc<dyn PostToolExecHook>],
55 tool_call: &ToolCall,
56 tool_def: &ToolDefinition,
57 result: &mut ToolResult,
58 context: &ToolContext,
59) {
60 for hook in hooks {
61 hook.after_exec(tool_call, tool_def, result, context).await;
62 }
63 for hook in final_hooks {
64 hook.after_exec(tool_call, tool_def, result, context).await;
65 }
66}
67
68const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
77
78const TRUNCATION_SUFFIX: &str =
79 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
80
81pub struct OutputHardLimitHook;
90
91impl OutputHardLimitHook {
92 fn truncate(text: String) -> String {
94 if text.len() <= MAX_TOOL_RESULT_BYTES {
95 return text;
96 }
97 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
98 let mut end = content_budget;
99 while end > 0 && !text.is_char_boundary(end) {
100 end -= 1;
101 }
102 let mut truncated = text[..end].to_string();
103 truncated.push_str(TRUNCATION_SUFFIX);
104 truncated
105 }
106}
107
108#[async_trait]
109impl PostToolExecHook for OutputHardLimitHook {
110 async fn after_exec(
111 &self,
112 tool_call: &ToolCall,
113 _tool_def: &ToolDefinition,
114 result: &mut ToolResult,
115 _context: &ToolContext,
116 ) {
117 if let Some(val) = result.result.take() {
119 match val {
120 serde_json::Value::String(s) => {
121 let original_len = s.len();
122 let truncated = Self::truncate(s);
123 if truncated.len() < original_len {
124 tracing::warn!(
125 tool_name = %tool_call.name,
126 tool_call_id = %tool_call.id,
127 result_bytes = original_len,
128 limit = MAX_TOOL_RESULT_BYTES,
129 "Tool result exceeded hard limit, truncated"
130 );
131 }
132 result.result = Some(serde_json::Value::String(truncated));
133 }
134 other => {
135 let serialized = serde_json::to_string(&other).unwrap_or_default();
138 if serialized.len() > MAX_TOOL_RESULT_BYTES {
139 tracing::warn!(
140 tool_name = %tool_call.name,
141 tool_call_id = %tool_call.id,
142 result_bytes = serialized.len(),
143 limit = MAX_TOOL_RESULT_BYTES,
144 "Tool result exceeded hard limit, truncated"
145 );
146 let truncated = Self::truncate(serialized);
147 result.result = Some(serde_json::Value::String(truncated));
148 } else {
149 result.result = Some(other);
150 }
151 }
152 }
153 }
154
155 if let Some(err) = result.error.take() {
157 if err.len() > MAX_TOOL_RESULT_BYTES {
158 tracing::warn!(
159 tool_name = %tool_call.name,
160 tool_call_id = %tool_call.id,
161 result_bytes = err.len(),
162 limit = MAX_TOOL_RESULT_BYTES,
163 "Tool error exceeded hard limit, truncated"
164 );
165 }
166 result.error = Some(Self::truncate(err));
167 }
168
169 if let Some(images) = result.images.as_mut() {
174 let original_count = images.len();
175 let mut cumulative = 0usize;
176 images.retain(|img| {
177 let len = img.base64.len();
178 if len > MAX_TOOL_RESULT_BYTES {
179 return false;
180 }
181 match cumulative.checked_add(len) {
182 Some(total) if total <= MAX_TOOL_RESULT_BYTES => {
183 cumulative = total;
184 true
185 }
186 _ => false,
187 }
188 });
189 let dropped = original_count.saturating_sub(images.len());
190 if dropped > 0 {
191 tracing::warn!(
192 tool_name = %tool_call.name,
193 tool_call_id = %tool_call.id,
194 dropped_images = dropped,
195 kept_images = images.len(),
196 kept_bytes = cumulative,
197 limit = MAX_TOOL_RESULT_BYTES,
198 "Tool images exceeded hard limit and were dropped"
199 );
200 }
201 if images.is_empty() {
202 result.images = None;
203 }
204 }
205 }
206}
207
208#[derive(Debug, Clone)]
214pub enum PostActAction {
215 EmitToolCallRequested {
217 tool_calls: Vec<ToolCall>,
218 tool_definitions: Vec<ToolDefinition>,
219 },
220}
221
222pub trait PostActHook: Send + Sync {
231 fn on_completed(
233 &self,
234 result: &mut ActResult,
235 tool_definitions: &[ToolDefinition],
236 ) -> Vec<PostActAction>;
237}
238
239pub struct ConnectionSetupHook;
250
251impl PostActHook for ConnectionSetupHook {
252 fn on_completed(
253 &self,
254 result: &mut ActResult,
255 _tool_definitions: &[ToolDefinition],
256 ) -> Vec<PostActAction> {
257 let providers: Vec<String> = result
258 .results
259 .iter()
260 .filter_map(|r| r.connection_required.clone())
261 .collect();
262
263 if providers.is_empty() {
264 return vec![];
265 }
266
267 result.waiting_for_tool_results = true;
268
269 let tool_calls: Vec<ToolCall> = providers
270 .iter()
271 .map(|provider| ToolCall {
272 id: format!("setup_conn_{}", Uuid::now_v7()),
273 name: "setup_connection".to_string(),
274 arguments: json!({ "provider": provider }),
275 })
276 .collect();
277
278 vec![PostActAction::EmitToolCallRequested {
279 tool_calls,
280 tool_definitions: vec![],
281 }]
282 }
283}
284
285pub struct UrlElicitationHook;
304
305impl PostActHook for UrlElicitationHook {
306 fn on_completed(
307 &self,
308 result: &mut ActResult,
309 _tool_definitions: &[ToolDefinition],
310 ) -> Vec<PostActAction> {
311 let pending: Vec<UrlElicitationRequired> = result
312 .results
313 .iter()
314 .filter_map(|r| UrlElicitationRequired::from_tool_result(&r.result))
315 .filter(|elicitation| !elicitation.declined)
318 .collect();
319
320 if pending.is_empty() {
321 return vec![];
322 }
323
324 result.waiting_for_tool_results = true;
325 result.waiting_for_url_elicitation = true;
326
327 let tool_calls: Vec<ToolCall> = pending
328 .iter()
329 .map(|elicitation| ToolCall {
330 id: format!("url_elicitation_{}", Uuid::now_v7()),
331 name: CONFIRM_URL_ELICITATION_TOOL.to_string(),
332 arguments: json!({
336 "server": elicitation.server,
337 "tool": elicitation.tool,
338 "retry_tool": elicitation.retry_tool,
339 "message": elicitation.message,
340 "url": elicitation.url,
341 "url_host": elicitation.url_host,
342 "url_is_punycode": elicitation.url_is_punycode,
343 }),
344 })
345 .collect();
346
347 vec![PostActAction::EmitToolCallRequested {
348 tool_calls,
349 tool_definitions: vec![],
350 }]
351 }
352}
353
354pub struct ClientSideToolHook;
368
369impl PostActHook for ClientSideToolHook {
370 fn on_completed(
371 &self,
372 result: &mut ActResult,
373 _tool_definitions: &[ToolDefinition],
374 ) -> Vec<PostActAction> {
375 if result.client_tool_calls.is_empty() {
376 return vec![];
377 }
378
379 result.waiting_for_tool_results = true;
380
381 vec![PostActAction::EmitToolCallRequested {
382 tool_calls: result.client_tool_calls.clone(),
383 tool_definitions: result.client_tool_definitions.clone(),
384 }]
385 }
386}
387
388pub(super) async fn run_post_act_hooks<E: EventEmitter>(
398 hooks: &[Box<dyn PostActHook>],
399 context: &ExecutionContext,
400 result: &mut ActResult,
401 tool_definitions: &[ToolDefinition],
402 event_emitter: &E,
403 locale: Option<&str>,
404) {
405 for hook in hooks {
406 let actions = hook.on_completed(result, tool_definitions);
407 for action in actions {
408 match action {
409 PostActAction::EmitToolCallRequested {
410 tool_calls,
411 tool_definitions: action_defs,
412 } => {
413 let event = EventRequest::new(
414 context.session_id,
415 EventContext::from_execution_context(context),
416 ToolCallRequestedData::with_definitions_and_locale(
417 &tool_calls,
418 &action_defs,
419 locale,
420 ),
421 );
422 if let Err(e) = event_emitter.emit(event).await {
423 tracing::warn!(
424 error = %e,
425 "PostActHook: failed to emit tool.call_requested event"
426 );
427 }
428 }
429 }
430 }
431 }
432}
433
434#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::execution::act::ToolCallResult;
442 use crate::tool_types::ToolResult;
443 use std::sync::Mutex;
444
445 fn make_tool_call_result(connection_required: Option<&str>) -> ToolCallResult {
446 ToolCallResult {
447 tool_call: ToolCall {
448 id: "call_1".to_string(),
449 name: "some_tool".to_string(),
450 arguments: json!({}),
451 },
452 result: ToolResult {
453 tool_call_id: "call_1".to_string(),
454 result: Some(json!({})),
455 images: None,
456 error: None,
457 connection_required: connection_required.map(|s| s.to_string()),
458 raw_output: None,
459 },
460 success: true,
461 status: "success".to_string(),
462 connection_required: connection_required.map(|s| s.to_string()),
463 determinism_fatal: None,
464 }
465 }
466
467 #[test]
468 fn test_connection_setup_hook_no_connections() {
469 let hook = ConnectionSetupHook;
470 let mut result = ActResult {
471 results: vec![make_tool_call_result(None)],
472 completed: true,
473 success_count: 1,
474 error_count: 0,
475 waiting_for_tool_results: false,
476 waiting_for_url_elicitation: false,
477 blocked: false,
478 client_tool_calls: vec![],
479 client_tool_definitions: vec![],
480 };
481
482 let actions = hook.on_completed(&mut result, &[]);
483 assert!(actions.is_empty());
484 assert!(!result.waiting_for_tool_results);
485 }
486
487 #[test]
488 fn test_connection_setup_hook_with_connection() {
489 let hook = ConnectionSetupHook;
490 let mut result = ActResult {
491 results: vec![make_tool_call_result(Some("github"))],
492 completed: true,
493 success_count: 0,
494 error_count: 0,
495 waiting_for_tool_results: false,
496 waiting_for_url_elicitation: false,
497 blocked: false,
498 client_tool_calls: vec![],
499 client_tool_definitions: vec![],
500 };
501
502 let actions = hook.on_completed(&mut result, &[]);
503 assert_eq!(actions.len(), 1);
504 assert!(result.waiting_for_tool_results);
505
506 match &actions[0] {
507 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
508 assert_eq!(tool_calls.len(), 1);
509 assert_eq!(tool_calls[0].name, "setup_connection");
510 assert_eq!(tool_calls[0].arguments["provider"], "github");
511 }
512 }
513 }
514
515 fn make_elicitation_result(declined: bool) -> ToolCallResult {
516 let payload = UrlElicitationRequired {
517 code: crate::tool_types::URL_ELICITATION_REQUIRED_CODE.to_string(),
518 error: "needs a person".to_string(),
519 url: "https://pay.example.com/authorize/42".to_string(),
520 url_host: "pay.example.com".to_string(),
521 url_is_punycode: false,
522 server: "billing".to_string(),
523 tool: "charge".to_string(),
524 retry_tool: "mcp_billing_charge".to_string(),
525 message: "Authorize the charge".to_string(),
526 declined,
527 };
528 ToolCallResult {
529 tool_call: ToolCall {
530 id: "call_1".to_string(),
531 name: "mcp_billing_charge".to_string(),
532 arguments: json!({}),
533 },
534 result: ToolResult {
535 tool_call_id: "call_1".to_string(),
536 result: Some(serde_json::to_value(&payload).expect("serialize")),
537 images: None,
538 error: None,
539 connection_required: None,
540 raw_output: None,
541 },
542 success: true,
543 status: "success".to_string(),
544 connection_required: None,
545 determinism_fatal: None,
546 }
547 }
548
549 fn act_result(results: Vec<ToolCallResult>) -> ActResult {
550 ActResult {
551 results,
552 completed: true,
553 success_count: 1,
554 error_count: 0,
555 waiting_for_tool_results: false,
556 waiting_for_url_elicitation: false,
557 blocked: false,
558 client_tool_calls: vec![],
559 client_tool_definitions: vec![],
560 }
561 }
562
563 #[test]
564 fn url_elicitation_hook_pauses_and_asks_for_consent() {
565 let mut result = act_result(vec![make_elicitation_result(false)]);
566
567 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
568
569 assert!(
570 result.waiting_for_tool_results,
571 "the turn must hold while a human decides"
572 );
573 assert_eq!(actions.len(), 1);
574 match &actions[0] {
575 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
576 assert_eq!(tool_calls.len(), 1);
577 assert_eq!(tool_calls[0].name, CONFIRM_URL_ELICITATION_TOOL);
578 let arguments = &tool_calls[0].arguments;
579 assert_eq!(arguments["url"], "https://pay.example.com/authorize/42");
581 assert_eq!(arguments["url_host"], "pay.example.com");
582 assert_eq!(arguments["server"], "billing");
583 assert_eq!(arguments["tool"], "charge");
584 assert_eq!(arguments["retry_tool"], "mcp_billing_charge");
585 assert_eq!(arguments["message"], "Authorize the charge");
586 assert_eq!(arguments["url_is_punycode"], false);
587 }
588 }
589 }
590
591 #[test]
592 fn url_elicitation_hook_does_not_re_ask_after_a_refusal() {
593 let mut result = act_result(vec![make_elicitation_result(true)]);
594
595 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
596
597 assert!(actions.is_empty());
598 assert!(
599 !result.waiting_for_tool_results,
600 "a refusal is a decision; the turn continues"
601 );
602 }
603
604 #[test]
605 fn url_elicitation_hook_ignores_ordinary_results() {
606 let mut result = act_result(vec![make_tool_call_result(None)]);
607
608 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
609
610 assert!(actions.is_empty());
611 assert!(!result.waiting_for_tool_results);
612 }
613
614 #[test]
615 fn test_client_side_tool_hook_no_client_tools() {
616 let hook = ClientSideToolHook;
617 let mut result = ActResult {
618 results: vec![],
619 completed: true,
620 success_count: 0,
621 error_count: 0,
622 waiting_for_tool_results: false,
623 waiting_for_url_elicitation: false,
624 blocked: false,
625 client_tool_calls: vec![],
626 client_tool_definitions: vec![],
627 };
628
629 let actions = hook.on_completed(&mut result, &[]);
630 assert!(actions.is_empty());
631 assert!(!result.waiting_for_tool_results);
632 }
633
634 #[test]
635 fn test_client_side_tool_hook_with_client_tools() {
636 let hook = ClientSideToolHook;
637 let client_call = ToolCall {
638 id: "call_client".to_string(),
639 name: "browser_click".to_string(),
640 arguments: json!({"selector": "#btn"}),
641 };
642
643 let mut result = ActResult {
644 results: vec![],
645 completed: true,
646 success_count: 0,
647 error_count: 0,
648 waiting_for_tool_results: false,
649 waiting_for_url_elicitation: false,
650 blocked: false,
651 client_tool_calls: vec![client_call.clone()],
652 client_tool_definitions: vec![],
653 };
654
655 let actions = hook.on_completed(&mut result, &[]);
656 assert_eq!(actions.len(), 1);
657 assert!(result.waiting_for_tool_results);
658
659 match &actions[0] {
660 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
661 assert_eq!(tool_calls.len(), 1);
662 assert_eq!(tool_calls[0].name, "browser_click");
663 }
664 }
665 }
666
667 use crate::tool_context::ToolContext;
672 use crate::typed_id::SessionId;
673
674 fn make_tool_call() -> ToolCall {
675 ToolCall {
676 id: "call_test".to_string(),
677 name: "test_tool".to_string(),
678 arguments: json!({}),
679 }
680 }
681
682 fn make_tool_def() -> ToolDefinition {
683 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
684 name: "test_tool".to_string(),
685 display_name: None,
686 description: "test".to_string(),
687 parameters: json!({}),
688 policy: crate::tool_types::ToolPolicy::Auto,
689 category: None,
690 deferrable: crate::tool_types::DeferrablePolicy::Never,
691 hints: Default::default(),
692 full_parameters: None,
693 })
694 }
695
696 struct MarkerHook {
697 name: &'static str,
698 calls: Arc<Mutex<Vec<&'static str>>>,
699 }
700
701 #[async_trait]
702 impl PostToolExecHook for MarkerHook {
703 async fn after_exec(
704 &self,
705 _tool_call: &ToolCall,
706 _tool_def: &ToolDefinition,
707 result: &mut ToolResult,
708 _context: &ToolContext,
709 ) {
710 self.calls.lock().unwrap().push(self.name);
711 let value = result
712 .result
713 .take()
714 .and_then(|value| value.as_str().map(str::to_owned))
715 .unwrap_or_default();
716 result.result = Some(json!(format!("{value}-{}", self.name)));
717 }
718 }
719
720 #[tokio::test]
721 async fn capability_hooks_run_before_runtime_final_hooks() {
722 let calls = Arc::new(Mutex::new(Vec::new()));
723 let capability_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
724 name: "capability",
725 calls: Arc::clone(&calls),
726 })];
727 let final_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
728 name: "final",
729 calls: Arc::clone(&calls),
730 })];
731 let mut result = ToolResult {
732 tool_call_id: "call_test".into(),
733 result: Some(json!("start")),
734 images: None,
735 error: None,
736 connection_required: None,
737 raw_output: None,
738 };
739
740 run_post_tool_exec_hooks(
741 &capability_hooks,
742 &final_hooks,
743 &make_tool_call(),
744 &make_tool_def(),
745 &mut result,
746 &ToolContext::new(SessionId::new()),
747 )
748 .await;
749
750 assert_eq!(*calls.lock().unwrap(), ["capability", "final"]);
751 assert_eq!(result.result, Some(json!("start-capability-final")));
752 }
753
754 #[tokio::test]
755 async fn test_output_hard_limit_passthrough_small() {
756 let hook = OutputHardLimitHook;
757 let tc = make_tool_call();
758 let td = make_tool_def();
759 let ctx = ToolContext::new(SessionId::new());
760 let mut result = ToolResult {
761 tool_call_id: "call_test".into(),
762 result: Some(json!("hello")),
763 images: None,
764 error: None,
765 connection_required: None,
766 raw_output: None,
767 };
768
769 hook.after_exec(&tc, &td, &mut result, &ctx).await;
770 assert_eq!(result.result, Some(json!("hello")));
771 }
772
773 #[tokio::test]
774 async fn test_output_hard_limit_truncates_large_string() {
775 let hook = OutputHardLimitHook;
776 let tc = make_tool_call();
777 let td = make_tool_def();
778 let ctx = ToolContext::new(SessionId::new());
779 let big = "x".repeat(MAX_TOOL_RESULT_BYTES + 1000);
780 let mut result = ToolResult {
781 tool_call_id: "call_test".into(),
782 result: Some(json!(big)),
783 images: None,
784 error: None,
785 connection_required: None,
786 raw_output: None,
787 };
788
789 hook.after_exec(&tc, &td, &mut result, &ctx).await;
790
791 let text = result.result.unwrap();
792 let s = text.as_str().unwrap();
793 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
794 assert!(s.ends_with(TRUNCATION_SUFFIX));
795 }
796
797 #[tokio::test]
798 async fn test_output_hard_limit_at_exact_limit() {
799 let hook = OutputHardLimitHook;
800 let tc = make_tool_call();
801 let td = make_tool_def();
802 let ctx = ToolContext::new(SessionId::new());
803 let exact = "a".repeat(MAX_TOOL_RESULT_BYTES);
804 let mut result = ToolResult {
805 tool_call_id: "call_test".into(),
806 result: Some(json!(exact)),
807 images: None,
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 let text = result.result.unwrap();
816 let s = text.as_str().unwrap();
817 assert_eq!(s.len(), MAX_TOOL_RESULT_BYTES);
819 assert!(!s.contains("[Output truncated"));
820 }
821
822 #[tokio::test]
823 async fn test_output_hard_limit_multibyte_boundary() {
824 let hook = OutputHardLimitHook;
825 let tc = make_tool_call();
826 let td = make_tool_def();
827 let ctx = ToolContext::new(SessionId::new());
828 let ch = "€"; let count = MAX_TOOL_RESULT_BYTES / ch.len() + 1;
830 let big = ch.repeat(count);
831 let mut result = ToolResult {
832 tool_call_id: "call_test".into(),
833 result: Some(json!(big)),
834 images: None,
835 error: None,
836 connection_required: None,
837 raw_output: None,
838 };
839
840 hook.after_exec(&tc, &td, &mut result, &ctx).await;
841
842 let text = result.result.unwrap();
843 let s = text.as_str().unwrap();
844 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
845 assert!(s.contains("[Output truncated"));
846 }
847
848 #[tokio::test]
849 async fn test_output_hard_limit_truncates_error() {
850 let hook = OutputHardLimitHook;
851 let tc = make_tool_call();
852 let td = make_tool_def();
853 let ctx = ToolContext::new(SessionId::new());
854 let big_err = "e".repeat(MAX_TOOL_RESULT_BYTES + 500);
855 let mut result = ToolResult {
856 tool_call_id: "call_test".into(),
857 result: None,
858 images: None,
859 error: Some(big_err),
860 connection_required: None,
861 raw_output: None,
862 };
863
864 hook.after_exec(&tc, &td, &mut result, &ctx).await;
865
866 let err = result.error.unwrap();
867 assert!(err.len() <= MAX_TOOL_RESULT_BYTES);
868 assert!(err.ends_with(TRUNCATION_SUFFIX));
869 }
870
871 #[tokio::test]
872 async fn test_output_hard_limit_non_string_json() {
873 let hook = OutputHardLimitHook;
874 let tc = make_tool_call();
875 let td = make_tool_def();
876 let ctx = ToolContext::new(SessionId::new());
877 let mut result = ToolResult {
879 tool_call_id: "call_test".into(),
880 result: Some(json!({"key": "value", "num": 42})),
881 images: None,
882 error: None,
883 connection_required: None,
884 raw_output: None,
885 };
886
887 hook.after_exec(&tc, &td, &mut result, &ctx).await;
888
889 assert_eq!(result.result, Some(json!({"key": "value", "num": 42})));
891 }
892
893 #[tokio::test]
894 async fn test_output_hard_limit_drops_oversized_images() {
895 let hook = OutputHardLimitHook;
896 let tc = make_tool_call();
897 let td = make_tool_def();
898 let ctx = ToolContext::new(SessionId::new());
899
900 let mut result = ToolResult {
901 tool_call_id: "call_test".into(),
902 result: Some(json!({"ok": true})),
903 images: Some(vec![
904 everruns_provider::ToolResultImage {
905 base64: "a".repeat(32),
906 media_type: "image/png".to_string(),
907 },
908 everruns_provider::ToolResultImage {
909 base64: "b".repeat(MAX_TOOL_RESULT_BYTES + 1),
910 media_type: "image/png".to_string(),
911 },
912 ]),
913 error: None,
914 connection_required: None,
915 raw_output: None,
916 };
917
918 hook.after_exec(&tc, &td, &mut result, &ctx).await;
919
920 let images = result.images.unwrap();
921 assert_eq!(images.len(), 1);
922 assert_eq!(images[0].base64.len(), 32);
923 }
924
925 #[tokio::test]
926 async fn test_output_hard_limit_enforces_cumulative_image_budget() {
927 let hook = OutputHardLimitHook;
928 let tc = make_tool_call();
929 let td = make_tool_def();
930 let ctx = ToolContext::new(SessionId::new());
931
932 let half = MAX_TOOL_RESULT_BYTES / 2;
935 let mut result = ToolResult {
936 tool_call_id: "call_test".into(),
937 result: Some(json!({"ok": true})),
938 images: Some(vec![
939 everruns_provider::ToolResultImage {
940 base64: "a".repeat(half),
941 media_type: "image/png".to_string(),
942 },
943 everruns_provider::ToolResultImage {
944 base64: "b".repeat(half),
945 media_type: "image/png".to_string(),
946 },
947 everruns_provider::ToolResultImage {
948 base64: "c".repeat(half),
949 media_type: "image/png".to_string(),
950 },
951 ]),
952 error: None,
953 connection_required: None,
954 raw_output: None,
955 };
956
957 hook.after_exec(&tc, &td, &mut result, &ctx).await;
958
959 let images = result.images.unwrap();
960 assert_eq!(
961 images.len(),
962 2,
963 "third image should be dropped by cumulative budget"
964 );
965 assert!(images.iter().all(|i| i.base64.len() == half));
966 }
967
968 #[tokio::test]
969 async fn test_output_hard_limit_normalizes_empty_images_to_none() {
970 let hook = OutputHardLimitHook;
971 let tc = make_tool_call();
972 let td = make_tool_def();
973 let ctx = ToolContext::new(SessionId::new());
974
975 let mut result = ToolResult {
976 tool_call_id: "call_test".into(),
977 result: Some(json!({"ok": true})),
978 images: Some(vec![everruns_provider::ToolResultImage {
979 base64: "a".repeat(MAX_TOOL_RESULT_BYTES + 1),
980 media_type: "image/png".to_string(),
981 }]),
982 error: None,
983 connection_required: None,
984 raw_output: None,
985 };
986
987 hook.after_exec(&tc, &td, &mut result, &ctx).await;
988
989 assert!(
990 result.images.is_none(),
991 "images vec emptied by retain should normalize to None"
992 );
993 }
994
995 #[test]
996 fn test_truncate_helper_short() {
997 let s = "hello".to_string();
998 assert_eq!(OutputHardLimitHook::truncate(s.clone()), s);
999 }
1000
1001 #[test]
1002 fn test_truncate_helper_over() {
1003 let s = "a".repeat(MAX_TOOL_RESULT_BYTES + 100);
1004 let t = OutputHardLimitHook::truncate(s);
1005 assert!(t.len() <= MAX_TOOL_RESULT_BYTES);
1006 assert!(t.ends_with(TRUNCATION_SUFFIX));
1007 }
1008}