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 connections: Vec<crate::tool_types::ConnectionRequired> = result
258 .results
259 .iter()
260 .filter_map(|r| r.connection_required.clone())
261 .collect();
262 if connections.is_empty() {
263 return vec![];
264 }
265
266 result.waiting_for_tool_results = true;
267
268 let tool_calls: Vec<ToolCall> = connections
269 .iter()
270 .map(|required| {
271 let mut arguments = json!({ "provider": required.provider });
272 if let Some(subject) = required.subject {
273 arguments["subject"] = json!(subject);
274 }
275 if let Some(setup_url) = required.setup_url.as_deref() {
276 arguments["setup_url"] = json!(setup_url);
277 }
278 ToolCall {
279 id: format!("setup_conn_{}", Uuid::now_v7()),
280 name: "setup_connection".to_string(),
281 arguments,
282 }
283 })
284 .collect();
285
286 vec![PostActAction::EmitToolCallRequested {
287 tool_calls,
288 tool_definitions: vec![],
289 }]
290 }
291}
292
293pub struct UrlElicitationHook;
312
313impl PostActHook for UrlElicitationHook {
314 fn on_completed(
315 &self,
316 result: &mut ActResult,
317 _tool_definitions: &[ToolDefinition],
318 ) -> Vec<PostActAction> {
319 let pending: Vec<UrlElicitationRequired> = result
320 .results
321 .iter()
322 .filter_map(|r| UrlElicitationRequired::from_tool_result(&r.result))
323 .filter(|elicitation| !elicitation.declined)
326 .collect();
327
328 if pending.is_empty() {
329 return vec![];
330 }
331
332 result.waiting_for_tool_results = true;
333 result.waiting_for_url_elicitation = true;
334
335 let tool_calls: Vec<ToolCall> = pending
336 .iter()
337 .map(|elicitation| ToolCall {
338 id: format!("url_elicitation_{}", Uuid::now_v7()),
339 name: CONFIRM_URL_ELICITATION_TOOL.to_string(),
340 arguments: json!({
344 "server": elicitation.server,
345 "tool": elicitation.tool,
346 "retry_tool": elicitation.retry_tool,
347 "message": elicitation.message,
348 "url": elicitation.url,
349 "url_host": elicitation.url_host,
350 "url_is_punycode": elicitation.url_is_punycode,
351 }),
352 })
353 .collect();
354
355 vec![PostActAction::EmitToolCallRequested {
356 tool_calls,
357 tool_definitions: vec![],
358 }]
359 }
360}
361
362pub struct ClientSideToolHook;
376
377impl PostActHook for ClientSideToolHook {
378 fn on_completed(
379 &self,
380 result: &mut ActResult,
381 _tool_definitions: &[ToolDefinition],
382 ) -> Vec<PostActAction> {
383 if result.client_tool_calls.is_empty() {
384 return vec![];
385 }
386
387 result.waiting_for_tool_results = true;
388
389 vec![PostActAction::EmitToolCallRequested {
390 tool_calls: result.client_tool_calls.clone(),
391 tool_definitions: result.client_tool_definitions.clone(),
392 }]
393 }
394}
395
396pub(super) async fn run_post_act_hooks<E: EventEmitter>(
406 hooks: &[Box<dyn PostActHook>],
407 context: &ExecutionContext,
408 result: &mut ActResult,
409 tool_definitions: &[ToolDefinition],
410 event_emitter: &E,
411 locale: Option<&str>,
412) {
413 for hook in hooks {
414 let actions = hook.on_completed(result, tool_definitions);
415 for action in actions {
416 match action {
417 PostActAction::EmitToolCallRequested {
418 tool_calls,
419 tool_definitions: action_defs,
420 } => {
421 let event = EventRequest::new(
422 context.session_id,
423 EventContext::from_execution_context(context),
424 ToolCallRequestedData::with_definitions_and_locale(
425 &tool_calls,
426 &action_defs,
427 locale,
428 ),
429 );
430 if let Err(e) = event_emitter.emit(event).await {
431 tracing::warn!(
432 error = %e,
433 "PostActHook: failed to emit tool.call_requested event"
434 );
435 }
436 }
437 }
438 }
439 }
440}
441
442#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::execution::act::ToolCallResult;
450 use crate::tool_types::{ConnectionRequired, ConnectionRequiredSubject, ToolResult};
451 use std::sync::Mutex;
452
453 fn make_tool_call_result(connection_required: Option<&str>) -> ToolCallResult {
454 ToolCallResult {
455 tool_call: ToolCall {
456 id: "call_1".to_string(),
457 name: "some_tool".to_string(),
458 arguments: json!({}),
459 },
460 result: ToolResult {
461 tool_call_id: "call_1".to_string(),
462 result: Some(json!({})),
463 images: None,
464 error: None,
465 connection_required: connection_required.map(ConnectionRequired::provider_only),
466 raw_output: None,
467 },
468 success: true,
469 status: "success".to_string(),
470 connection_required: connection_required.map(ConnectionRequired::provider_only),
471 determinism_fatal: None,
472 }
473 }
474
475 #[test]
476 fn test_connection_setup_hook_no_connections() {
477 let hook = ConnectionSetupHook;
478 let mut result = ActResult {
479 results: vec![make_tool_call_result(None)],
480 completed: true,
481 success_count: 1,
482 error_count: 0,
483 waiting_for_tool_results: false,
484 waiting_for_url_elicitation: false,
485 blocked: false,
486 client_tool_calls: vec![],
487 client_tool_definitions: vec![],
488 };
489
490 let actions = hook.on_completed(&mut result, &[]);
491 assert!(actions.is_empty());
492 assert!(!result.waiting_for_tool_results);
493 }
494
495 #[test]
496 fn test_connection_setup_hook_with_connection() {
497 let hook = ConnectionSetupHook;
498 let mut result = ActResult {
499 results: vec![make_tool_call_result(Some("github"))],
500 completed: true,
501 success_count: 0,
502 error_count: 0,
503 waiting_for_tool_results: false,
504 waiting_for_url_elicitation: false,
505 blocked: false,
506 client_tool_calls: vec![],
507 client_tool_definitions: vec![],
508 };
509
510 let actions = hook.on_completed(&mut result, &[]);
511 assert_eq!(actions.len(), 1);
512 assert!(result.waiting_for_tool_results);
513
514 match &actions[0] {
515 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
516 assert_eq!(tool_calls.len(), 1);
517 assert_eq!(tool_calls[0].name, "setup_connection");
518 assert_eq!(tool_calls[0].arguments["provider"], "github");
519 }
520 }
521 }
522
523 #[test]
524 fn connection_setup_hook_preserves_subject_and_setup_url() {
525 let required = ConnectionRequired::with_setup(
526 "mcp_oauth_linear",
527 ConnectionRequiredSubject::Agent,
528 "/agents/agent_123?tab=mcp",
529 );
530 let mut call_result = make_tool_call_result(None);
531 call_result.result.connection_required = Some(required.clone());
532 call_result.connection_required = Some(required);
533 let mut result = ActResult {
534 results: vec![call_result],
535 completed: true,
536 success_count: 0,
537 error_count: 0,
538 waiting_for_tool_results: false,
539 waiting_for_url_elicitation: false,
540 blocked: false,
541 client_tool_calls: vec![],
542 client_tool_definitions: vec![],
543 };
544
545 let actions = ConnectionSetupHook.on_completed(&mut result, &[]);
546
547 match &actions[0] {
548 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
549 assert_eq!(
550 tool_calls[0].arguments,
551 json!({
552 "provider": "mcp_oauth_linear",
553 "subject": "agent",
554 "setup_url": "/agents/agent_123?tab=mcp",
555 })
556 );
557 }
558 }
559 }
560
561 fn make_elicitation_result(declined: bool) -> ToolCallResult {
562 let payload = UrlElicitationRequired {
563 code: crate::tool_types::URL_ELICITATION_REQUIRED_CODE.to_string(),
564 error: "needs a person".to_string(),
565 url: "https://pay.example.com/authorize/42".to_string(),
566 url_host: "pay.example.com".to_string(),
567 url_is_punycode: false,
568 server: "billing".to_string(),
569 tool: "charge".to_string(),
570 retry_tool: "mcp_billing_charge".to_string(),
571 message: "Authorize the charge".to_string(),
572 declined,
573 };
574 ToolCallResult {
575 tool_call: ToolCall {
576 id: "call_1".to_string(),
577 name: "mcp_billing_charge".to_string(),
578 arguments: json!({}),
579 },
580 result: ToolResult {
581 tool_call_id: "call_1".to_string(),
582 result: Some(serde_json::to_value(&payload).expect("serialize")),
583 images: None,
584 error: None,
585 connection_required: None,
586 raw_output: None,
587 },
588 success: true,
589 status: "success".to_string(),
590 connection_required: None,
591 determinism_fatal: None,
592 }
593 }
594
595 fn act_result(results: Vec<ToolCallResult>) -> ActResult {
596 ActResult {
597 results,
598 completed: true,
599 success_count: 1,
600 error_count: 0,
601 waiting_for_tool_results: false,
602 waiting_for_url_elicitation: false,
603 blocked: false,
604 client_tool_calls: vec![],
605 client_tool_definitions: vec![],
606 }
607 }
608
609 #[test]
610 fn url_elicitation_hook_pauses_and_asks_for_consent() {
611 let mut result = act_result(vec![make_elicitation_result(false)]);
612
613 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
614
615 assert!(
616 result.waiting_for_tool_results,
617 "the turn must hold while a human decides"
618 );
619 assert_eq!(actions.len(), 1);
620 match &actions[0] {
621 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
622 assert_eq!(tool_calls.len(), 1);
623 assert_eq!(tool_calls[0].name, CONFIRM_URL_ELICITATION_TOOL);
624 let arguments = &tool_calls[0].arguments;
625 assert_eq!(arguments["url"], "https://pay.example.com/authorize/42");
627 assert_eq!(arguments["url_host"], "pay.example.com");
628 assert_eq!(arguments["server"], "billing");
629 assert_eq!(arguments["tool"], "charge");
630 assert_eq!(arguments["retry_tool"], "mcp_billing_charge");
631 assert_eq!(arguments["message"], "Authorize the charge");
632 assert_eq!(arguments["url_is_punycode"], false);
633 }
634 }
635 }
636
637 #[test]
638 fn url_elicitation_hook_does_not_re_ask_after_a_refusal() {
639 let mut result = act_result(vec![make_elicitation_result(true)]);
640
641 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
642
643 assert!(actions.is_empty());
644 assert!(
645 !result.waiting_for_tool_results,
646 "a refusal is a decision; the turn continues"
647 );
648 }
649
650 #[test]
651 fn url_elicitation_hook_ignores_ordinary_results() {
652 let mut result = act_result(vec![make_tool_call_result(None)]);
653
654 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
655
656 assert!(actions.is_empty());
657 assert!(!result.waiting_for_tool_results);
658 }
659
660 #[test]
661 fn test_client_side_tool_hook_no_client_tools() {
662 let hook = ClientSideToolHook;
663 let mut result = ActResult {
664 results: vec![],
665 completed: true,
666 success_count: 0,
667 error_count: 0,
668 waiting_for_tool_results: false,
669 waiting_for_url_elicitation: false,
670 blocked: false,
671 client_tool_calls: vec![],
672 client_tool_definitions: vec![],
673 };
674
675 let actions = hook.on_completed(&mut result, &[]);
676 assert!(actions.is_empty());
677 assert!(!result.waiting_for_tool_results);
678 }
679
680 #[test]
681 fn test_client_side_tool_hook_with_client_tools() {
682 let hook = ClientSideToolHook;
683 let client_call = ToolCall {
684 id: "call_client".to_string(),
685 name: "browser_click".to_string(),
686 arguments: json!({"selector": "#btn"}),
687 };
688
689 let mut result = ActResult {
690 results: vec![],
691 completed: true,
692 success_count: 0,
693 error_count: 0,
694 waiting_for_tool_results: false,
695 waiting_for_url_elicitation: false,
696 blocked: false,
697 client_tool_calls: vec![client_call.clone()],
698 client_tool_definitions: vec![],
699 };
700
701 let actions = hook.on_completed(&mut result, &[]);
702 assert_eq!(actions.len(), 1);
703 assert!(result.waiting_for_tool_results);
704
705 match &actions[0] {
706 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
707 assert_eq!(tool_calls.len(), 1);
708 assert_eq!(tool_calls[0].name, "browser_click");
709 }
710 }
711 }
712
713 use crate::tool_context::ToolContext;
718 use crate::typed_id::SessionId;
719
720 fn make_tool_call() -> ToolCall {
721 ToolCall {
722 id: "call_test".to_string(),
723 name: "test_tool".to_string(),
724 arguments: json!({}),
725 }
726 }
727
728 fn make_tool_def() -> ToolDefinition {
729 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
730 name: "test_tool".to_string(),
731 display_name: None,
732 description: "test".to_string(),
733 parameters: json!({}),
734 policy: crate::tool_types::ToolPolicy::Auto,
735 category: None,
736 deferrable: crate::tool_types::DeferrablePolicy::Never,
737 hints: Default::default(),
738 full_parameters: None,
739 })
740 }
741
742 struct MarkerHook {
743 name: &'static str,
744 calls: Arc<Mutex<Vec<&'static str>>>,
745 }
746
747 #[async_trait]
748 impl PostToolExecHook for MarkerHook {
749 async fn after_exec(
750 &self,
751 _tool_call: &ToolCall,
752 _tool_def: &ToolDefinition,
753 result: &mut ToolResult,
754 _context: &ToolContext,
755 ) {
756 self.calls.lock().unwrap().push(self.name);
757 let value = result
758 .result
759 .take()
760 .and_then(|value| value.as_str().map(str::to_owned))
761 .unwrap_or_default();
762 result.result = Some(json!(format!("{value}-{}", self.name)));
763 }
764 }
765
766 #[tokio::test]
767 async fn capability_hooks_run_before_runtime_final_hooks() {
768 let calls = Arc::new(Mutex::new(Vec::new()));
769 let capability_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
770 name: "capability",
771 calls: Arc::clone(&calls),
772 })];
773 let final_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
774 name: "final",
775 calls: Arc::clone(&calls),
776 })];
777 let mut result = ToolResult {
778 tool_call_id: "call_test".into(),
779 result: Some(json!("start")),
780 images: None,
781 error: None,
782 connection_required: None,
783 raw_output: None,
784 };
785
786 run_post_tool_exec_hooks(
787 &capability_hooks,
788 &final_hooks,
789 &make_tool_call(),
790 &make_tool_def(),
791 &mut result,
792 &ToolContext::new(SessionId::new()),
793 )
794 .await;
795
796 assert_eq!(*calls.lock().unwrap(), ["capability", "final"]);
797 assert_eq!(result.result, Some(json!("start-capability-final")));
798 }
799
800 #[tokio::test]
801 async fn test_output_hard_limit_passthrough_small() {
802 let hook = OutputHardLimitHook;
803 let tc = make_tool_call();
804 let td = make_tool_def();
805 let ctx = ToolContext::new(SessionId::new());
806 let mut result = ToolResult {
807 tool_call_id: "call_test".into(),
808 result: Some(json!("hello")),
809 images: None,
810 error: None,
811 connection_required: None,
812 raw_output: None,
813 };
814
815 hook.after_exec(&tc, &td, &mut result, &ctx).await;
816 assert_eq!(result.result, Some(json!("hello")));
817 }
818
819 #[tokio::test]
820 async fn test_output_hard_limit_truncates_large_string() {
821 let hook = OutputHardLimitHook;
822 let tc = make_tool_call();
823 let td = make_tool_def();
824 let ctx = ToolContext::new(SessionId::new());
825 let big = "x".repeat(MAX_TOOL_RESULT_BYTES + 1000);
826 let mut result = ToolResult {
827 tool_call_id: "call_test".into(),
828 result: Some(json!(big)),
829 images: None,
830 error: None,
831 connection_required: None,
832 raw_output: None,
833 };
834
835 hook.after_exec(&tc, &td, &mut result, &ctx).await;
836
837 let text = result.result.unwrap();
838 let s = text.as_str().unwrap();
839 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
840 assert!(s.ends_with(TRUNCATION_SUFFIX));
841 }
842
843 #[tokio::test]
844 async fn test_output_hard_limit_at_exact_limit() {
845 let hook = OutputHardLimitHook;
846 let tc = make_tool_call();
847 let td = make_tool_def();
848 let ctx = ToolContext::new(SessionId::new());
849 let exact = "a".repeat(MAX_TOOL_RESULT_BYTES);
850 let mut result = ToolResult {
851 tool_call_id: "call_test".into(),
852 result: Some(json!(exact)),
853 images: None,
854 error: None,
855 connection_required: None,
856 raw_output: None,
857 };
858
859 hook.after_exec(&tc, &td, &mut result, &ctx).await;
860
861 let text = result.result.unwrap();
862 let s = text.as_str().unwrap();
863 assert_eq!(s.len(), MAX_TOOL_RESULT_BYTES);
865 assert!(!s.contains("[Output truncated"));
866 }
867
868 #[tokio::test]
869 async fn test_output_hard_limit_multibyte_boundary() {
870 let hook = OutputHardLimitHook;
871 let tc = make_tool_call();
872 let td = make_tool_def();
873 let ctx = ToolContext::new(SessionId::new());
874 let ch = "€"; let count = MAX_TOOL_RESULT_BYTES / ch.len() + 1;
876 let big = ch.repeat(count);
877 let mut result = ToolResult {
878 tool_call_id: "call_test".into(),
879 result: Some(json!(big)),
880 images: None,
881 error: None,
882 connection_required: None,
883 raw_output: None,
884 };
885
886 hook.after_exec(&tc, &td, &mut result, &ctx).await;
887
888 let text = result.result.unwrap();
889 let s = text.as_str().unwrap();
890 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
891 assert!(s.contains("[Output truncated"));
892 }
893
894 #[tokio::test]
895 async fn test_output_hard_limit_truncates_error() {
896 let hook = OutputHardLimitHook;
897 let tc = make_tool_call();
898 let td = make_tool_def();
899 let ctx = ToolContext::new(SessionId::new());
900 let big_err = "e".repeat(MAX_TOOL_RESULT_BYTES + 500);
901 let mut result = ToolResult {
902 tool_call_id: "call_test".into(),
903 result: None,
904 images: None,
905 error: Some(big_err),
906 connection_required: None,
907 raw_output: None,
908 };
909
910 hook.after_exec(&tc, &td, &mut result, &ctx).await;
911
912 let err = result.error.unwrap();
913 assert!(err.len() <= MAX_TOOL_RESULT_BYTES);
914 assert!(err.ends_with(TRUNCATION_SUFFIX));
915 }
916
917 #[tokio::test]
918 async fn test_output_hard_limit_non_string_json() {
919 let hook = OutputHardLimitHook;
920 let tc = make_tool_call();
921 let td = make_tool_def();
922 let ctx = ToolContext::new(SessionId::new());
923 let mut result = ToolResult {
925 tool_call_id: "call_test".into(),
926 result: Some(json!({"key": "value", "num": 42})),
927 images: None,
928 error: None,
929 connection_required: None,
930 raw_output: None,
931 };
932
933 hook.after_exec(&tc, &td, &mut result, &ctx).await;
934
935 assert_eq!(result.result, Some(json!({"key": "value", "num": 42})));
937 }
938
939 #[tokio::test]
940 async fn test_output_hard_limit_drops_oversized_images() {
941 let hook = OutputHardLimitHook;
942 let tc = make_tool_call();
943 let td = make_tool_def();
944 let ctx = ToolContext::new(SessionId::new());
945
946 let mut result = ToolResult {
947 tool_call_id: "call_test".into(),
948 result: Some(json!({"ok": true})),
949 images: Some(vec![
950 everruns_provider::ToolResultImage {
951 base64: "a".repeat(32),
952 media_type: "image/png".to_string(),
953 },
954 everruns_provider::ToolResultImage {
955 base64: "b".repeat(MAX_TOOL_RESULT_BYTES + 1),
956 media_type: "image/png".to_string(),
957 },
958 ]),
959 error: None,
960 connection_required: None,
961 raw_output: None,
962 };
963
964 hook.after_exec(&tc, &td, &mut result, &ctx).await;
965
966 let images = result.images.unwrap();
967 assert_eq!(images.len(), 1);
968 assert_eq!(images[0].base64.len(), 32);
969 }
970
971 #[tokio::test]
972 async fn test_output_hard_limit_enforces_cumulative_image_budget() {
973 let hook = OutputHardLimitHook;
974 let tc = make_tool_call();
975 let td = make_tool_def();
976 let ctx = ToolContext::new(SessionId::new());
977
978 let half = MAX_TOOL_RESULT_BYTES / 2;
981 let mut result = ToolResult {
982 tool_call_id: "call_test".into(),
983 result: Some(json!({"ok": true})),
984 images: Some(vec![
985 everruns_provider::ToolResultImage {
986 base64: "a".repeat(half),
987 media_type: "image/png".to_string(),
988 },
989 everruns_provider::ToolResultImage {
990 base64: "b".repeat(half),
991 media_type: "image/png".to_string(),
992 },
993 everruns_provider::ToolResultImage {
994 base64: "c".repeat(half),
995 media_type: "image/png".to_string(),
996 },
997 ]),
998 error: None,
999 connection_required: None,
1000 raw_output: None,
1001 };
1002
1003 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1004
1005 let images = result.images.unwrap();
1006 assert_eq!(
1007 images.len(),
1008 2,
1009 "third image should be dropped by cumulative budget"
1010 );
1011 assert!(images.iter().all(|i| i.base64.len() == half));
1012 }
1013
1014 #[tokio::test]
1015 async fn test_output_hard_limit_normalizes_empty_images_to_none() {
1016 let hook = OutputHardLimitHook;
1017 let tc = make_tool_call();
1018 let td = make_tool_def();
1019 let ctx = ToolContext::new(SessionId::new());
1020
1021 let mut result = ToolResult {
1022 tool_call_id: "call_test".into(),
1023 result: Some(json!({"ok": true})),
1024 images: Some(vec![everruns_provider::ToolResultImage {
1025 base64: "a".repeat(MAX_TOOL_RESULT_BYTES + 1),
1026 media_type: "image/png".to_string(),
1027 }]),
1028 error: None,
1029 connection_required: None,
1030 raw_output: None,
1031 };
1032
1033 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1034
1035 assert!(
1036 result.images.is_none(),
1037 "images vec emptied by retain should normalize to None"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_truncate_helper_short() {
1043 let s = "hello".to_string();
1044 assert_eq!(OutputHardLimitHook::truncate(s.clone()), s);
1045 }
1046
1047 #[test]
1048 fn test_truncate_helper_over() {
1049 let s = "a".repeat(MAX_TOOL_RESULT_BYTES + 100);
1050 let t = OutputHardLimitHook::truncate(s);
1051 assert!(t.len() <= MAX_TOOL_RESULT_BYTES);
1052 assert!(t.ends_with(TRUNCATION_SUFFIX));
1053 }
1054}