1use crate::events::{EventContext, EventRequest, ToolCallRequestedData};
15use crate::tool_types::{
16 ASK_USER_TOOL_NAME, CONFIRM_URL_ELICITATION_TOOL, FORM_ELICITATION_CALL_ID_PREFIX,
17 FormElicitationRequired, MCP_ELICITATION_ARGUMENT, ToolCall, ToolDefinition, ToolResult,
18 UrlElicitationRequired,
19};
20use crate::{event_emitter::EventEmitter, tool_context::ToolContext};
21use async_trait::async_trait;
22pub(crate) use everruns_core::tool_hooks::{PostToolExecHook, PreToolUseDecision, PreToolUseHook};
23use serde_json::json;
24use std::sync::Arc;
25use uuid::Uuid;
26
27use super::ExecutionContext;
28use super::act::ActResult;
29
30pub(super) async fn run_pre_tool_use_hooks(
35 hooks: &[Arc<dyn PreToolUseHook>],
36 mut tool_call: ToolCall,
37 tool_def: &ToolDefinition,
38 context: &ToolContext,
39) -> PreToolUseDecision {
40 for hook in hooks {
41 match hook.before_exec(tool_call.clone(), tool_def, context).await {
42 PreToolUseDecision::Continue(updated) => {
43 tool_call = updated;
44 }
45 block @ PreToolUseDecision::Block { .. } => return block,
46 }
47 }
48 PreToolUseDecision::Continue(tool_call)
49}
50
51pub(super) async fn run_post_tool_exec_hooks(
55 hooks: &[Arc<dyn PostToolExecHook>],
56 final_hooks: &[Arc<dyn PostToolExecHook>],
57 tool_call: &ToolCall,
58 tool_def: &ToolDefinition,
59 result: &mut ToolResult,
60 context: &ToolContext,
61) {
62 for hook in hooks {
63 hook.after_exec(tool_call, tool_def, result, context).await;
64 }
65 for hook in final_hooks {
66 hook.after_exec(tool_call, tool_def, result, context).await;
67 }
68}
69
70const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
79
80const TRUNCATION_SUFFIX: &str =
81 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
82
83pub struct OutputHardLimitHook;
92
93impl OutputHardLimitHook {
94 fn truncate(text: String) -> String {
96 if text.len() <= MAX_TOOL_RESULT_BYTES {
97 return text;
98 }
99 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
100 let mut end = content_budget;
101 while end > 0 && !text.is_char_boundary(end) {
102 end -= 1;
103 }
104 let mut truncated = text[..end].to_string();
105 truncated.push_str(TRUNCATION_SUFFIX);
106 truncated
107 }
108}
109
110#[async_trait]
111impl PostToolExecHook for OutputHardLimitHook {
112 async fn after_exec(
113 &self,
114 tool_call: &ToolCall,
115 _tool_def: &ToolDefinition,
116 result: &mut ToolResult,
117 _context: &ToolContext,
118 ) {
119 if let Some(val) = result.result.take() {
121 match val {
122 serde_json::Value::String(s) => {
123 let original_len = s.len();
124 let truncated = Self::truncate(s);
125 if truncated.len() < original_len {
126 tracing::warn!(
127 tool_name = %tool_call.name,
128 tool_call_id = %tool_call.id,
129 result_bytes = original_len,
130 limit = MAX_TOOL_RESULT_BYTES,
131 "Tool result exceeded hard limit, truncated"
132 );
133 }
134 result.result = Some(serde_json::Value::String(truncated));
135 }
136 other => {
137 let serialized = serde_json::to_string(&other).unwrap_or_default();
140 if serialized.len() > MAX_TOOL_RESULT_BYTES {
141 tracing::warn!(
142 tool_name = %tool_call.name,
143 tool_call_id = %tool_call.id,
144 result_bytes = serialized.len(),
145 limit = MAX_TOOL_RESULT_BYTES,
146 "Tool result exceeded hard limit, truncated"
147 );
148 let truncated = Self::truncate(serialized);
149 result.result = Some(serde_json::Value::String(truncated));
150 } else {
151 result.result = Some(other);
152 }
153 }
154 }
155 }
156
157 if let Some(err) = result.error.take() {
159 if err.len() > MAX_TOOL_RESULT_BYTES {
160 tracing::warn!(
161 tool_name = %tool_call.name,
162 tool_call_id = %tool_call.id,
163 result_bytes = err.len(),
164 limit = MAX_TOOL_RESULT_BYTES,
165 "Tool error exceeded hard limit, truncated"
166 );
167 }
168 result.error = Some(Self::truncate(err));
169 }
170
171 if let Some(images) = result.images.as_mut() {
176 let original_count = images.len();
177 let mut cumulative = 0usize;
178 images.retain(|img| {
179 let len = img.base64.len();
180 if len > MAX_TOOL_RESULT_BYTES {
181 return false;
182 }
183 match cumulative.checked_add(len) {
184 Some(total) if total <= MAX_TOOL_RESULT_BYTES => {
185 cumulative = total;
186 true
187 }
188 _ => false,
189 }
190 });
191 let dropped = original_count.saturating_sub(images.len());
192 if dropped > 0 {
193 tracing::warn!(
194 tool_name = %tool_call.name,
195 tool_call_id = %tool_call.id,
196 dropped_images = dropped,
197 kept_images = images.len(),
198 kept_bytes = cumulative,
199 limit = MAX_TOOL_RESULT_BYTES,
200 "Tool images exceeded hard limit and were dropped"
201 );
202 }
203 if images.is_empty() {
204 result.images = None;
205 }
206 }
207 }
208}
209
210#[derive(Debug, Clone)]
216pub enum PostActAction {
217 EmitToolCallRequested {
219 tool_calls: Vec<ToolCall>,
220 tool_definitions: Vec<ToolDefinition>,
221 },
222}
223
224pub trait PostActHook: Send + Sync {
233 fn on_completed(
235 &self,
236 result: &mut ActResult,
237 tool_definitions: &[ToolDefinition],
238 ) -> Vec<PostActAction>;
239}
240
241pub struct ConnectionSetupHook;
252
253impl PostActHook for ConnectionSetupHook {
254 fn on_completed(
255 &self,
256 result: &mut ActResult,
257 _tool_definitions: &[ToolDefinition],
258 ) -> Vec<PostActAction> {
259 let connections: Vec<crate::tool_types::ConnectionRequired> = result
260 .results
261 .iter()
262 .filter_map(|r| r.connection_required.clone())
263 .collect();
264 if connections.is_empty() {
265 return vec![];
266 }
267
268 result.waiting_for_tool_results = true;
269
270 let tool_calls: Vec<ToolCall> = connections
271 .iter()
272 .map(|required| {
273 let mut arguments = json!({ "provider": required.provider });
274 if let Some(subject) = required.subject {
275 arguments["subject"] = json!(subject);
276 }
277 if let Some(setup_url) = required.setup_url.as_deref() {
278 arguments["setup_url"] = json!(setup_url);
279 }
280 ToolCall {
281 id: format!("setup_conn_{}", Uuid::now_v7()),
282 name: "setup_connection".to_string(),
283 arguments,
284 }
285 })
286 .collect();
287
288 vec![PostActAction::EmitToolCallRequested {
289 tool_calls,
290 tool_definitions: vec![],
291 }]
292 }
293}
294
295pub struct UrlElicitationHook;
314
315impl PostActHook for UrlElicitationHook {
316 fn on_completed(
317 &self,
318 result: &mut ActResult,
319 _tool_definitions: &[ToolDefinition],
320 ) -> Vec<PostActAction> {
321 let pending: Vec<UrlElicitationRequired> = result
322 .results
323 .iter()
324 .filter_map(|r| UrlElicitationRequired::from_tool_result(&r.result))
325 .filter(|elicitation| !elicitation.declined)
328 .collect();
329
330 if pending.is_empty() {
331 return vec![];
332 }
333
334 result.waiting_for_tool_results = true;
335 result.waiting_for_url_elicitation = true;
336
337 let tool_calls: Vec<ToolCall> = pending
338 .iter()
339 .map(|elicitation| ToolCall {
340 id: format!("url_elicitation_{}", Uuid::now_v7()),
341 name: CONFIRM_URL_ELICITATION_TOOL.to_string(),
342 arguments: json!({
346 "server": elicitation.server,
347 "tool": elicitation.tool,
348 "retry_tool": elicitation.retry_tool,
349 "message": elicitation.message,
350 "url": elicitation.url,
351 "url_host": elicitation.url_host,
352 "url_is_punycode": elicitation.url_is_punycode,
353 }),
354 })
355 .collect();
356
357 vec![PostActAction::EmitToolCallRequested {
358 tool_calls,
359 tool_definitions: vec![],
360 }]
361 }
362}
363
364pub(super) fn runs_on_server(call: &ToolCall, tool_definitions: &[ToolDefinition]) -> bool {
369 match tool_definitions.iter().find(|td| td.name() == call.name) {
370 Some(td) => !matches!(td, ToolDefinition::ClientSide(_)),
371 None => call.name != everruns_provider::openai_hosted_tools::OPENAI_MCP_APPROVAL_TOOL,
372 }
373}
374
375const FORM_ELICITATION_TIMEOUT_SECONDS: i64 = 300;
382const FORM_ELICITATION_NUDGE_LEAD_SECONDS: i64 = 60;
384
385pub struct FormElicitationHook;
401
402impl PostActHook for FormElicitationHook {
403 fn on_completed(
404 &self,
405 result: &mut ActResult,
406 _tool_definitions: &[ToolDefinition],
407 ) -> Vec<PostActAction> {
408 let pending: Vec<FormElicitationRequired> = result
409 .results
410 .iter()
411 .filter_map(|r| FormElicitationRequired::from_tool_result(&r.result))
412 .collect();
413 if pending.is_empty() {
414 return vec![];
415 }
416
417 let asked_at = chrono::Utc::now();
418 let expires_at = asked_at + chrono::Duration::seconds(FORM_ELICITATION_TIMEOUT_SECONDS);
419 let nudge_at = expires_at - chrono::Duration::seconds(FORM_ELICITATION_NUDGE_LEAD_SECONDS);
420
421 for elicitation in pending {
422 result.client_tool_calls.push(ToolCall {
423 id: format!("{FORM_ELICITATION_CALL_ID_PREFIX}{}", Uuid::now_v7()),
424 name: ASK_USER_TOOL_NAME.to_string(),
425 arguments: json!({
426 "questions": elicitation.questions,
427 "timeout_seconds": FORM_ELICITATION_TIMEOUT_SECONDS,
428 "asked_at": asked_at.to_rfc3339(),
429 "nudge_at": nudge_at.to_rfc3339(),
430 "expires_at": expires_at.to_rfc3339(),
431 MCP_ELICITATION_ARGUMENT: {
432 "server": elicitation.server,
433 "tool": elicitation.tool,
434 "retry_tool": elicitation.retry_tool,
435 "message": elicitation.message,
436 "fingerprint": elicitation.fingerprint,
437 },
438 }),
439 });
440 }
441 vec![]
442 }
443}
444
445pub struct ClientSideToolHook;
459
460impl PostActHook for ClientSideToolHook {
461 fn on_completed(
462 &self,
463 result: &mut ActResult,
464 _tool_definitions: &[ToolDefinition],
465 ) -> Vec<PostActAction> {
466 if result.client_tool_calls.is_empty() {
467 return vec![];
468 }
469
470 result.waiting_for_tool_results = true;
471
472 vec![PostActAction::EmitToolCallRequested {
473 tool_calls: result.client_tool_calls.clone(),
474 tool_definitions: result.client_tool_definitions.clone(),
475 }]
476 }
477}
478
479pub(super) async fn run_post_act_hooks<E: EventEmitter>(
489 hooks: &[Box<dyn PostActHook>],
490 context: &ExecutionContext,
491 result: &mut ActResult,
492 tool_definitions: &[ToolDefinition],
493 event_emitter: &E,
494 locale: Option<&str>,
495) {
496 for hook in hooks {
497 let actions = hook.on_completed(result, tool_definitions);
498 for action in actions {
499 match action {
500 PostActAction::EmitToolCallRequested {
501 tool_calls,
502 tool_definitions: action_defs,
503 } => {
504 let event = EventRequest::new(
505 context.session_id,
506 EventContext::from_execution_context(context),
507 ToolCallRequestedData::with_definitions_and_locale(
508 &tool_calls,
509 &action_defs,
510 locale,
511 ),
512 );
513 if let Err(e) = event_emitter.emit(event).await {
514 tracing::warn!(
515 error = %e,
516 "PostActHook: failed to emit tool.call_requested event"
517 );
518 }
519 }
520 }
521 }
522 }
523}
524
525#[cfg(test)]
530mod tests {
531
532 #[test]
533 fn provider_approval_waits_for_the_client_but_other_unknown_tools_do_not() {
534 let call = |name: &str| ToolCall {
535 id: "c".into(),
536 name: name.into(),
537 arguments: json!({}),
538 };
539 let approval = everruns_provider::openai_hosted_tools::OPENAI_MCP_APPROVAL_TOOL;
540 assert!(!runs_on_server(&call(approval), &[]));
541 assert!(runs_on_server(&call("missing_tool"), &[]));
542 }
543
544 use super::*;
545 use crate::execution::act::ToolCallResult;
546 use crate::tool_types::{ConnectionRequired, ConnectionRequiredSubject, ToolResult};
547 use std::sync::Mutex;
548
549 fn make_tool_call_result(connection_required: Option<&str>) -> ToolCallResult {
550 ToolCallResult {
551 tool_call: ToolCall {
552 id: "call_1".to_string(),
553 name: "some_tool".to_string(),
554 arguments: json!({}),
555 },
556 result: ToolResult {
557 tool_call_id: "call_1".to_string(),
558 result: Some(json!({})),
559 images: None,
560 error: None,
561 connection_required: connection_required.map(ConnectionRequired::provider_only),
562 raw_output: None,
563 },
564 success: true,
565 status: "success".to_string(),
566 connection_required: connection_required.map(ConnectionRequired::provider_only),
567 determinism_fatal: None,
568 }
569 }
570
571 #[test]
572 fn test_connection_setup_hook_no_connections() {
573 let hook = ConnectionSetupHook;
574 let mut result = ActResult {
575 results: vec![make_tool_call_result(None)],
576 completed: true,
577 success_count: 1,
578 error_count: 0,
579 waiting_for_tool_results: false,
580 waiting_for_url_elicitation: false,
581 blocked: false,
582 client_tool_calls: vec![],
583 client_tool_definitions: vec![],
584 };
585
586 let actions = hook.on_completed(&mut result, &[]);
587 assert!(actions.is_empty());
588 assert!(!result.waiting_for_tool_results);
589 }
590
591 #[test]
592 fn test_connection_setup_hook_with_connection() {
593 let hook = ConnectionSetupHook;
594 let mut result = ActResult {
595 results: vec![make_tool_call_result(Some("github"))],
596 completed: true,
597 success_count: 0,
598 error_count: 0,
599 waiting_for_tool_results: false,
600 waiting_for_url_elicitation: false,
601 blocked: false,
602 client_tool_calls: vec![],
603 client_tool_definitions: vec![],
604 };
605
606 let actions = hook.on_completed(&mut result, &[]);
607 assert_eq!(actions.len(), 1);
608 assert!(result.waiting_for_tool_results);
609
610 match &actions[0] {
611 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
612 assert_eq!(tool_calls.len(), 1);
613 assert_eq!(tool_calls[0].name, "setup_connection");
614 assert_eq!(tool_calls[0].arguments["provider"], "github");
615 }
616 }
617 }
618
619 #[test]
620 fn connection_setup_hook_preserves_subject_and_setup_url() {
621 let required = ConnectionRequired::with_setup(
622 "mcp_oauth_linear",
623 ConnectionRequiredSubject::Agent,
624 "/agents/agent_123?tab=mcp",
625 );
626 let mut call_result = make_tool_call_result(None);
627 call_result.result.connection_required = Some(required.clone());
628 call_result.connection_required = Some(required);
629 let mut result = ActResult {
630 results: vec![call_result],
631 completed: true,
632 success_count: 0,
633 error_count: 0,
634 waiting_for_tool_results: false,
635 waiting_for_url_elicitation: false,
636 blocked: false,
637 client_tool_calls: vec![],
638 client_tool_definitions: vec![],
639 };
640
641 let actions = ConnectionSetupHook.on_completed(&mut result, &[]);
642
643 match &actions[0] {
644 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
645 assert_eq!(
646 tool_calls[0].arguments,
647 json!({
648 "provider": "mcp_oauth_linear",
649 "subject": "agent",
650 "setup_url": "/agents/agent_123?tab=mcp",
651 })
652 );
653 }
654 }
655 }
656
657 fn make_elicitation_result(declined: bool) -> ToolCallResult {
658 let payload = UrlElicitationRequired {
659 code: crate::tool_types::URL_ELICITATION_REQUIRED_CODE.to_string(),
660 error: "needs a person".to_string(),
661 url: "https://pay.example.com/authorize/42".to_string(),
662 url_host: "pay.example.com".to_string(),
663 url_is_punycode: false,
664 server: "billing".to_string(),
665 tool: "charge".to_string(),
666 retry_tool: "mcp_billing_charge".to_string(),
667 message: "Authorize the charge".to_string(),
668 declined,
669 };
670 ToolCallResult {
671 tool_call: ToolCall {
672 id: "call_1".to_string(),
673 name: "mcp_billing_charge".to_string(),
674 arguments: json!({}),
675 },
676 result: ToolResult {
677 tool_call_id: "call_1".to_string(),
678 result: Some(serde_json::to_value(&payload).expect("serialize")),
679 images: None,
680 error: None,
681 connection_required: None,
682 raw_output: None,
683 },
684 success: true,
685 status: "success".to_string(),
686 connection_required: None,
687 determinism_fatal: None,
688 }
689 }
690
691 fn act_result(results: Vec<ToolCallResult>) -> ActResult {
692 ActResult {
693 results,
694 completed: true,
695 success_count: 1,
696 error_count: 0,
697 waiting_for_tool_results: false,
698 waiting_for_url_elicitation: false,
699 blocked: false,
700 client_tool_calls: vec![],
701 client_tool_definitions: vec![],
702 }
703 }
704
705 #[test]
706 fn url_elicitation_hook_pauses_and_asks_for_consent() {
707 let mut result = act_result(vec![make_elicitation_result(false)]);
708
709 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
710
711 assert!(
712 result.waiting_for_tool_results,
713 "the turn must hold while a human decides"
714 );
715 assert_eq!(actions.len(), 1);
716 match &actions[0] {
717 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
718 assert_eq!(tool_calls.len(), 1);
719 assert_eq!(tool_calls[0].name, CONFIRM_URL_ELICITATION_TOOL);
720 let arguments = &tool_calls[0].arguments;
721 assert_eq!(arguments["url"], "https://pay.example.com/authorize/42");
723 assert_eq!(arguments["url_host"], "pay.example.com");
724 assert_eq!(arguments["server"], "billing");
725 assert_eq!(arguments["tool"], "charge");
726 assert_eq!(arguments["retry_tool"], "mcp_billing_charge");
727 assert_eq!(arguments["message"], "Authorize the charge");
728 assert_eq!(arguments["url_is_punycode"], false);
729 }
730 }
731 }
732
733 #[test]
734 fn url_elicitation_hook_does_not_re_ask_after_a_refusal() {
735 let mut result = act_result(vec![make_elicitation_result(true)]);
736
737 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
738
739 assert!(actions.is_empty());
740 assert!(
741 !result.waiting_for_tool_results,
742 "a refusal is a decision; the turn continues"
743 );
744 }
745
746 #[test]
747 fn url_elicitation_hook_ignores_ordinary_results() {
748 let mut result = act_result(vec![make_tool_call_result(None)]);
749
750 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
751
752 assert!(actions.is_empty());
753 assert!(!result.waiting_for_tool_results);
754 }
755
756 fn make_form_elicitation_result() -> ToolCallResult {
757 let payload = FormElicitationRequired {
758 code: crate::tool_types::FORM_ELICITATION_REQUIRED_CODE.to_string(),
759 error: "The server needs answers".to_string(),
760 server: "deploys".to_string(),
761 tool: "release".to_string(),
762 retry_tool: "mcp_deploys_release".to_string(),
763 message: "Which environment?".to_string(),
764 questions: vec![json!({"kind": "choice", "id": "environment"})],
765 fingerprint: "abc123".to_string(),
766 };
767 let mut result = make_tool_call_result(None);
768 result.result.result = Some(serde_json::to_value(&payload).expect("serialize"));
769 result
770 }
771
772 #[test]
773 fn form_elicitation_hook_asks_through_the_ask_user_pause() {
774 let mut result = act_result(vec![make_form_elicitation_result()]);
775
776 let actions = FormElicitationHook.on_completed(&mut result, &[]);
777 assert!(actions.is_empty(), "ClientSideToolHook emits the call");
778 assert_eq!(result.client_tool_calls.len(), 1);
779 let call = &result.client_tool_calls[0];
780 assert_eq!(call.name, ASK_USER_TOOL_NAME);
781 assert!(call.id.starts_with(FORM_ELICITATION_CALL_ID_PREFIX));
782 let elicitation = &call.arguments[MCP_ELICITATION_ARGUMENT];
783 assert_eq!(elicitation["server"], "deploys");
784 assert_eq!(elicitation["tool"], "release");
785 assert_eq!(elicitation["retry_tool"], "mcp_deploys_release");
786 assert_eq!(elicitation["fingerprint"], "abc123");
787 assert_eq!(call.arguments["questions"][0]["id"], "environment");
788 assert!(call.arguments["expires_at"].is_string());
789
790 let actions = ClientSideToolHook.on_completed(&mut result, &[]);
792 assert_eq!(actions.len(), 1);
793 assert!(result.waiting_for_tool_results);
794 }
795
796 #[test]
797 fn form_elicitation_hook_ignores_ordinary_results() {
798 let mut result = act_result(vec![make_tool_call_result(None)]);
799
800 FormElicitationHook.on_completed(&mut result, &[]);
801
802 assert!(result.client_tool_calls.is_empty());
803 }
804
805 #[test]
806 fn test_client_side_tool_hook_no_client_tools() {
807 let hook = ClientSideToolHook;
808 let mut result = ActResult {
809 results: vec![],
810 completed: true,
811 success_count: 0,
812 error_count: 0,
813 waiting_for_tool_results: false,
814 waiting_for_url_elicitation: false,
815 blocked: false,
816 client_tool_calls: vec![],
817 client_tool_definitions: vec![],
818 };
819
820 let actions = hook.on_completed(&mut result, &[]);
821 assert!(actions.is_empty());
822 assert!(!result.waiting_for_tool_results);
823 }
824
825 #[test]
826 fn test_client_side_tool_hook_with_client_tools() {
827 let hook = ClientSideToolHook;
828 let client_call = ToolCall {
829 id: "call_client".to_string(),
830 name: "browser_click".to_string(),
831 arguments: json!({"selector": "#btn"}),
832 };
833
834 let mut result = ActResult {
835 results: vec![],
836 completed: true,
837 success_count: 0,
838 error_count: 0,
839 waiting_for_tool_results: false,
840 waiting_for_url_elicitation: false,
841 blocked: false,
842 client_tool_calls: vec![client_call.clone()],
843 client_tool_definitions: vec![],
844 };
845
846 let actions = hook.on_completed(&mut result, &[]);
847 assert_eq!(actions.len(), 1);
848 assert!(result.waiting_for_tool_results);
849
850 match &actions[0] {
851 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
852 assert_eq!(tool_calls.len(), 1);
853 assert_eq!(tool_calls[0].name, "browser_click");
854 }
855 }
856 }
857
858 use crate::tool_context::ToolContext;
863 use crate::typed_id::SessionId;
864
865 fn make_tool_call() -> ToolCall {
866 ToolCall {
867 id: "call_test".to_string(),
868 name: "test_tool".to_string(),
869 arguments: json!({}),
870 }
871 }
872
873 fn make_tool_def() -> ToolDefinition {
874 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
875 name: "test_tool".to_string(),
876 display_name: None,
877 description: "test".to_string(),
878 parameters: json!({}),
879 policy: crate::tool_types::ToolPolicy::Auto,
880 category: None,
881 deferrable: crate::tool_types::DeferrablePolicy::Never,
882 hints: Default::default(),
883 full_parameters: None,
884 })
885 }
886
887 struct MarkerHook {
888 name: &'static str,
889 calls: Arc<Mutex<Vec<&'static str>>>,
890 }
891
892 #[async_trait]
893 impl PostToolExecHook for MarkerHook {
894 async fn after_exec(
895 &self,
896 _tool_call: &ToolCall,
897 _tool_def: &ToolDefinition,
898 result: &mut ToolResult,
899 _context: &ToolContext,
900 ) {
901 self.calls.lock().unwrap().push(self.name);
902 let value = result
903 .result
904 .take()
905 .and_then(|value| value.as_str().map(str::to_owned))
906 .unwrap_or_default();
907 result.result = Some(json!(format!("{value}-{}", self.name)));
908 }
909 }
910
911 #[tokio::test]
912 async fn capability_hooks_run_before_runtime_final_hooks() {
913 let calls = Arc::new(Mutex::new(Vec::new()));
914 let capability_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
915 name: "capability",
916 calls: Arc::clone(&calls),
917 })];
918 let final_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
919 name: "final",
920 calls: Arc::clone(&calls),
921 })];
922 let mut result = ToolResult {
923 tool_call_id: "call_test".into(),
924 result: Some(json!("start")),
925 images: None,
926 error: None,
927 connection_required: None,
928 raw_output: None,
929 };
930
931 run_post_tool_exec_hooks(
932 &capability_hooks,
933 &final_hooks,
934 &make_tool_call(),
935 &make_tool_def(),
936 &mut result,
937 &ToolContext::new(SessionId::new()),
938 )
939 .await;
940
941 assert_eq!(*calls.lock().unwrap(), ["capability", "final"]);
942 assert_eq!(result.result, Some(json!("start-capability-final")));
943 }
944
945 #[tokio::test]
946 async fn test_output_hard_limit_passthrough_small() {
947 let hook = OutputHardLimitHook;
948 let tc = make_tool_call();
949 let td = make_tool_def();
950 let ctx = ToolContext::new(SessionId::new());
951 let mut result = ToolResult {
952 tool_call_id: "call_test".into(),
953 result: Some(json!("hello")),
954 images: None,
955 error: None,
956 connection_required: None,
957 raw_output: None,
958 };
959
960 hook.after_exec(&tc, &td, &mut result, &ctx).await;
961 assert_eq!(result.result, Some(json!("hello")));
962 }
963
964 #[tokio::test]
965 async fn test_output_hard_limit_truncates_large_string() {
966 let hook = OutputHardLimitHook;
967 let tc = make_tool_call();
968 let td = make_tool_def();
969 let ctx = ToolContext::new(SessionId::new());
970 let big = "x".repeat(MAX_TOOL_RESULT_BYTES + 1000);
971 let mut result = ToolResult {
972 tool_call_id: "call_test".into(),
973 result: Some(json!(big)),
974 images: None,
975 error: None,
976 connection_required: None,
977 raw_output: None,
978 };
979
980 hook.after_exec(&tc, &td, &mut result, &ctx).await;
981
982 let text = result.result.unwrap();
983 let s = text.as_str().unwrap();
984 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
985 assert!(s.ends_with(TRUNCATION_SUFFIX));
986 }
987
988 #[tokio::test]
989 async fn test_output_hard_limit_at_exact_limit() {
990 let hook = OutputHardLimitHook;
991 let tc = make_tool_call();
992 let td = make_tool_def();
993 let ctx = ToolContext::new(SessionId::new());
994 let exact = "a".repeat(MAX_TOOL_RESULT_BYTES);
995 let mut result = ToolResult {
996 tool_call_id: "call_test".into(),
997 result: Some(json!(exact)),
998 images: None,
999 error: None,
1000 connection_required: None,
1001 raw_output: None,
1002 };
1003
1004 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1005
1006 let text = result.result.unwrap();
1007 let s = text.as_str().unwrap();
1008 assert_eq!(s.len(), MAX_TOOL_RESULT_BYTES);
1010 assert!(!s.contains("[Output truncated"));
1011 }
1012
1013 #[tokio::test]
1014 async fn test_output_hard_limit_multibyte_boundary() {
1015 let hook = OutputHardLimitHook;
1016 let tc = make_tool_call();
1017 let td = make_tool_def();
1018 let ctx = ToolContext::new(SessionId::new());
1019 let ch = "€"; let count = MAX_TOOL_RESULT_BYTES / ch.len() + 1;
1021 let big = ch.repeat(count);
1022 let mut result = ToolResult {
1023 tool_call_id: "call_test".into(),
1024 result: Some(json!(big)),
1025 images: None,
1026 error: None,
1027 connection_required: None,
1028 raw_output: None,
1029 };
1030
1031 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1032
1033 let text = result.result.unwrap();
1034 let s = text.as_str().unwrap();
1035 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
1036 assert!(s.contains("[Output truncated"));
1037 }
1038
1039 #[tokio::test]
1040 async fn test_output_hard_limit_truncates_error() {
1041 let hook = OutputHardLimitHook;
1042 let tc = make_tool_call();
1043 let td = make_tool_def();
1044 let ctx = ToolContext::new(SessionId::new());
1045 let big_err = "e".repeat(MAX_TOOL_RESULT_BYTES + 500);
1046 let mut result = ToolResult {
1047 tool_call_id: "call_test".into(),
1048 result: None,
1049 images: None,
1050 error: Some(big_err),
1051 connection_required: None,
1052 raw_output: None,
1053 };
1054
1055 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1056
1057 let err = result.error.unwrap();
1058 assert!(err.len() <= MAX_TOOL_RESULT_BYTES);
1059 assert!(err.ends_with(TRUNCATION_SUFFIX));
1060 }
1061
1062 #[tokio::test]
1063 async fn test_output_hard_limit_non_string_json() {
1064 let hook = OutputHardLimitHook;
1065 let tc = make_tool_call();
1066 let td = make_tool_def();
1067 let ctx = ToolContext::new(SessionId::new());
1068 let mut result = ToolResult {
1070 tool_call_id: "call_test".into(),
1071 result: Some(json!({"key": "value", "num": 42})),
1072 images: None,
1073 error: None,
1074 connection_required: None,
1075 raw_output: None,
1076 };
1077
1078 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1079
1080 assert_eq!(result.result, Some(json!({"key": "value", "num": 42})));
1082 }
1083
1084 #[tokio::test]
1085 async fn test_output_hard_limit_drops_oversized_images() {
1086 let hook = OutputHardLimitHook;
1087 let tc = make_tool_call();
1088 let td = make_tool_def();
1089 let ctx = ToolContext::new(SessionId::new());
1090
1091 let mut result = ToolResult {
1092 tool_call_id: "call_test".into(),
1093 result: Some(json!({"ok": true})),
1094 images: Some(vec![
1095 everruns_provider::ToolResultImage {
1096 base64: "a".repeat(32),
1097 media_type: "image/png".to_string(),
1098 },
1099 everruns_provider::ToolResultImage {
1100 base64: "b".repeat(MAX_TOOL_RESULT_BYTES + 1),
1101 media_type: "image/png".to_string(),
1102 },
1103 ]),
1104 error: None,
1105 connection_required: None,
1106 raw_output: None,
1107 };
1108
1109 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1110
1111 let images = result.images.unwrap();
1112 assert_eq!(images.len(), 1);
1113 assert_eq!(images[0].base64.len(), 32);
1114 }
1115
1116 #[tokio::test]
1117 async fn test_output_hard_limit_enforces_cumulative_image_budget() {
1118 let hook = OutputHardLimitHook;
1119 let tc = make_tool_call();
1120 let td = make_tool_def();
1121 let ctx = ToolContext::new(SessionId::new());
1122
1123 let half = MAX_TOOL_RESULT_BYTES / 2;
1126 let mut result = ToolResult {
1127 tool_call_id: "call_test".into(),
1128 result: Some(json!({"ok": true})),
1129 images: Some(vec![
1130 everruns_provider::ToolResultImage {
1131 base64: "a".repeat(half),
1132 media_type: "image/png".to_string(),
1133 },
1134 everruns_provider::ToolResultImage {
1135 base64: "b".repeat(half),
1136 media_type: "image/png".to_string(),
1137 },
1138 everruns_provider::ToolResultImage {
1139 base64: "c".repeat(half),
1140 media_type: "image/png".to_string(),
1141 },
1142 ]),
1143 error: None,
1144 connection_required: None,
1145 raw_output: None,
1146 };
1147
1148 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1149
1150 let images = result.images.unwrap();
1151 assert_eq!(
1152 images.len(),
1153 2,
1154 "third image should be dropped by cumulative budget"
1155 );
1156 assert!(images.iter().all(|i| i.base64.len() == half));
1157 }
1158
1159 #[tokio::test]
1160 async fn test_output_hard_limit_normalizes_empty_images_to_none() {
1161 let hook = OutputHardLimitHook;
1162 let tc = make_tool_call();
1163 let td = make_tool_def();
1164 let ctx = ToolContext::new(SessionId::new());
1165
1166 let mut result = ToolResult {
1167 tool_call_id: "call_test".into(),
1168 result: Some(json!({"ok": true})),
1169 images: Some(vec![everruns_provider::ToolResultImage {
1170 base64: "a".repeat(MAX_TOOL_RESULT_BYTES + 1),
1171 media_type: "image/png".to_string(),
1172 }]),
1173 error: None,
1174 connection_required: None,
1175 raw_output: None,
1176 };
1177
1178 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1179
1180 assert!(
1181 result.images.is_none(),
1182 "images vec emptied by retain should normalize to None"
1183 );
1184 }
1185
1186 #[test]
1187 fn test_truncate_helper_short() {
1188 let s = "hello".to_string();
1189 assert_eq!(OutputHardLimitHook::truncate(s.clone()), s);
1190 }
1191
1192 #[test]
1193 fn test_truncate_helper_over() {
1194 let s = "a".repeat(MAX_TOOL_RESULT_BYTES + 100);
1195 let t = OutputHardLimitHook::truncate(s);
1196 assert!(t.len() <= MAX_TOOL_RESULT_BYTES);
1197 assert!(t.ends_with(TRUNCATION_SUFFIX));
1198 }
1199}