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, ToolApprovalRequired, ToolCall,
18 ToolDefinition, ToolResult, 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 stop @ (PreToolUseDecision::Block { .. } | PreToolUseDecision::Defer { .. }) => {
46 return stop;
47 }
48 }
49 }
50 PreToolUseDecision::Continue(tool_call)
51}
52
53pub(super) async fn pre_tool_use_outcome(
61 hooks: &[Arc<dyn PreToolUseHook>],
62 tool_call: ToolCall,
63 tool_def: &ToolDefinition,
64 context: &ToolContext,
65) -> (ToolCall, Option<ToolResult>) {
66 if hooks.is_empty() {
67 return (tool_call, None);
68 }
69 match run_pre_tool_use_hooks(hooks, tool_call, tool_def, context).await {
70 PreToolUseDecision::Continue(updated) => (updated, None),
71 PreToolUseDecision::Block {
72 tool_call, reason, ..
73 } => {
74 tracing::warn!(
75 session_id = %context.session_id,
76 tool_call_id = %tool_call.id,
77 tool_name = %tool_call.name,
78 reason = %reason,
79 "ActAtom: pre_tool_use hook blocked execution"
80 );
81 let result = ToolResult {
82 tool_call_id: tool_call.id.clone(),
83 result: None,
84 images: None,
85 error: Some(format!("blocked by pre_tool_use hook: {reason}")),
86 connection_required: None,
87 raw_output: None,
88 };
89 (tool_call, Some(result))
90 }
91 PreToolUseDecision::Defer {
92 tool_call,
93 mut result,
94 } => {
95 tracing::info!(
96 session_id = %context.session_id,
97 tool_call_id = %tool_call.id,
98 tool_name = %tool_call.name,
99 "ActAtom: pre_tool_use hook deferred execution"
100 );
101 result.tool_call_id = tool_call.id.clone();
102 (tool_call, Some(result))
103 }
104 }
105}
106
107pub(super) async fn run_post_tool_exec_hooks(
111 hooks: &[Arc<dyn PostToolExecHook>],
112 final_hooks: &[Arc<dyn PostToolExecHook>],
113 tool_call: &ToolCall,
114 tool_def: &ToolDefinition,
115 result: &mut ToolResult,
116 context: &ToolContext,
117) {
118 for hook in hooks {
119 hook.after_exec(tool_call, tool_def, result, context).await;
120 }
121 for hook in final_hooks {
122 hook.after_exec(tool_call, tool_def, result, context).await;
123 }
124}
125
126const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
135
136const TRUNCATION_SUFFIX: &str =
137 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
138
139pub struct OutputHardLimitHook;
148
149impl OutputHardLimitHook {
150 fn truncate(text: String) -> String {
152 if text.len() <= MAX_TOOL_RESULT_BYTES {
153 return text;
154 }
155 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
156 let mut end = content_budget;
157 while end > 0 && !text.is_char_boundary(end) {
158 end -= 1;
159 }
160 let mut truncated = text[..end].to_string();
161 truncated.push_str(TRUNCATION_SUFFIX);
162 truncated
163 }
164}
165
166#[async_trait]
167impl PostToolExecHook for OutputHardLimitHook {
168 async fn after_exec(
169 &self,
170 tool_call: &ToolCall,
171 _tool_def: &ToolDefinition,
172 result: &mut ToolResult,
173 _context: &ToolContext,
174 ) {
175 if let Some(val) = result.result.take() {
177 match val {
178 serde_json::Value::String(s) => {
179 let original_len = s.len();
180 let truncated = Self::truncate(s);
181 if truncated.len() < original_len {
182 tracing::warn!(
183 tool_name = %tool_call.name,
184 tool_call_id = %tool_call.id,
185 result_bytes = original_len,
186 limit = MAX_TOOL_RESULT_BYTES,
187 "Tool result exceeded hard limit, truncated"
188 );
189 }
190 result.result = Some(serde_json::Value::String(truncated));
191 }
192 other => {
193 let serialized = serde_json::to_string(&other).unwrap_or_default();
196 if serialized.len() > MAX_TOOL_RESULT_BYTES {
197 tracing::warn!(
198 tool_name = %tool_call.name,
199 tool_call_id = %tool_call.id,
200 result_bytes = serialized.len(),
201 limit = MAX_TOOL_RESULT_BYTES,
202 "Tool result exceeded hard limit, truncated"
203 );
204 let truncated = Self::truncate(serialized);
205 result.result = Some(serde_json::Value::String(truncated));
206 } else {
207 result.result = Some(other);
208 }
209 }
210 }
211 }
212
213 if let Some(err) = result.error.take() {
215 if err.len() > MAX_TOOL_RESULT_BYTES {
216 tracing::warn!(
217 tool_name = %tool_call.name,
218 tool_call_id = %tool_call.id,
219 result_bytes = err.len(),
220 limit = MAX_TOOL_RESULT_BYTES,
221 "Tool error exceeded hard limit, truncated"
222 );
223 }
224 result.error = Some(Self::truncate(err));
225 }
226
227 if let Some(images) = result.images.as_mut() {
232 let original_count = images.len();
233 let mut cumulative = 0usize;
234 images.retain(|img| {
235 let len = img.base64.len();
236 if len > MAX_TOOL_RESULT_BYTES {
237 return false;
238 }
239 match cumulative.checked_add(len) {
240 Some(total) if total <= MAX_TOOL_RESULT_BYTES => {
241 cumulative = total;
242 true
243 }
244 _ => false,
245 }
246 });
247 let dropped = original_count.saturating_sub(images.len());
248 if dropped > 0 {
249 tracing::warn!(
250 tool_name = %tool_call.name,
251 tool_call_id = %tool_call.id,
252 dropped_images = dropped,
253 kept_images = images.len(),
254 kept_bytes = cumulative,
255 limit = MAX_TOOL_RESULT_BYTES,
256 "Tool images exceeded hard limit and were dropped"
257 );
258 }
259 if images.is_empty() {
260 result.images = None;
261 }
262 }
263 }
264}
265
266#[derive(Debug, Clone)]
272pub enum PostActAction {
273 EmitToolCallRequested {
275 tool_calls: Vec<ToolCall>,
276 tool_definitions: Vec<ToolDefinition>,
277 },
278}
279
280pub trait PostActHook: Send + Sync {
289 fn on_completed(
291 &self,
292 result: &mut ActResult,
293 tool_definitions: &[ToolDefinition],
294 ) -> Vec<PostActAction>;
295}
296
297pub struct ConnectionSetupHook;
308
309impl PostActHook for ConnectionSetupHook {
310 fn on_completed(
311 &self,
312 result: &mut ActResult,
313 _tool_definitions: &[ToolDefinition],
314 ) -> Vec<PostActAction> {
315 let connections: Vec<crate::tool_types::ConnectionRequired> = result
316 .results
317 .iter()
318 .filter_map(|r| r.connection_required.clone())
319 .collect();
320 if connections.is_empty() {
321 return vec![];
322 }
323
324 result.waiting_for_tool_results = true;
325
326 let tool_calls: Vec<ToolCall> = connections
327 .iter()
328 .map(|required| {
329 let mut arguments = json!({ "provider": required.provider });
330 if let Some(subject) = required.subject {
331 arguments["subject"] = json!(subject);
332 }
333 if let Some(setup_url) = required.setup_url.as_deref() {
334 arguments["setup_url"] = json!(setup_url);
335 }
336 ToolCall {
337 id: format!("setup_conn_{}", Uuid::now_v7()),
338 name: "setup_connection".to_string(),
339 arguments,
340 }
341 })
342 .collect();
343
344 vec![PostActAction::EmitToolCallRequested {
345 tool_calls,
346 tool_definitions: vec![],
347 }]
348 }
349}
350
351pub struct UrlElicitationHook;
370
371impl PostActHook for UrlElicitationHook {
372 fn on_completed(
373 &self,
374 result: &mut ActResult,
375 _tool_definitions: &[ToolDefinition],
376 ) -> Vec<PostActAction> {
377 let pending: Vec<UrlElicitationRequired> = result
378 .results
379 .iter()
380 .filter_map(|r| UrlElicitationRequired::from_tool_result(&r.result))
381 .filter(|elicitation| !elicitation.declined)
384 .collect();
385
386 if pending.is_empty() {
387 return vec![];
388 }
389
390 result.waiting_for_tool_results = true;
391 result.waiting_for_url_elicitation = true;
392
393 let tool_calls: Vec<ToolCall> = pending
394 .iter()
395 .map(|elicitation| ToolCall {
396 id: format!("url_elicitation_{}", Uuid::now_v7()),
397 name: CONFIRM_URL_ELICITATION_TOOL.to_string(),
398 arguments: json!({
402 "server": elicitation.server,
403 "tool": elicitation.tool,
404 "retry_tool": elicitation.retry_tool,
405 "message": elicitation.message,
406 "url": elicitation.url,
407 "url_host": elicitation.url_host,
408 "url_is_punycode": elicitation.url_is_punycode,
409 }),
410 })
411 .collect();
412
413 vec![PostActAction::EmitToolCallRequested {
414 tool_calls,
415 tool_definitions: vec![],
416 }]
417 }
418}
419
420pub(super) fn runs_on_server(call: &ToolCall, tool_definitions: &[ToolDefinition]) -> bool {
425 match tool_definitions.iter().find(|td| td.name() == call.name) {
426 Some(td) => !matches!(td, ToolDefinition::ClientSide(_)),
427 None => call.name != everruns_contracts::openai_hosted_tools::OPENAI_MCP_APPROVAL_TOOL,
428 }
429}
430
431const FORM_ELICITATION_TIMEOUT_SECONDS: i64 = 300;
438const FORM_ELICITATION_NUDGE_LEAD_SECONDS: i64 = 60;
440
441pub struct FormElicitationHook;
457
458impl PostActHook for FormElicitationHook {
459 fn on_completed(
460 &self,
461 result: &mut ActResult,
462 _tool_definitions: &[ToolDefinition],
463 ) -> Vec<PostActAction> {
464 let pending: Vec<FormElicitationRequired> = result
465 .results
466 .iter()
467 .filter_map(|r| FormElicitationRequired::from_tool_result(&r.result))
468 .collect();
469 if pending.is_empty() {
470 return vec![];
471 }
472
473 let asked_at = chrono::Utc::now();
474 let expires_at = asked_at + chrono::Duration::seconds(FORM_ELICITATION_TIMEOUT_SECONDS);
475 let nudge_at = expires_at - chrono::Duration::seconds(FORM_ELICITATION_NUDGE_LEAD_SECONDS);
476
477 for elicitation in pending {
478 result.client_tool_calls.push(ToolCall {
479 id: format!("{FORM_ELICITATION_CALL_ID_PREFIX}{}", Uuid::now_v7()),
480 name: ASK_USER_TOOL_NAME.to_string(),
481 arguments: json!({
482 "questions": elicitation.questions,
483 "timeout_seconds": FORM_ELICITATION_TIMEOUT_SECONDS,
484 "asked_at": asked_at.to_rfc3339(),
485 "nudge_at": nudge_at.to_rfc3339(),
486 "expires_at": expires_at.to_rfc3339(),
487 MCP_ELICITATION_ARGUMENT: {
488 "server": elicitation.server,
489 "tool": elicitation.tool,
490 "retry_tool": elicitation.retry_tool,
491 "message": elicitation.message,
492 "fingerprint": elicitation.fingerprint,
493 },
494 }),
495 });
496 }
497 vec![]
498 }
499}
500
501pub struct ToolApprovalPauseHook;
522
523impl PostActHook for ToolApprovalPauseHook {
524 fn on_completed(
525 &self,
526 result: &mut ActResult,
527 _tool_definitions: &[ToolDefinition],
528 ) -> Vec<PostActAction> {
529 let pending: Vec<ToolCall> = result
530 .results
531 .iter()
532 .filter_map(|r| ToolApprovalRequired::from_tool_result(&r.result))
533 .map(|request| request.request_call())
534 .collect();
535 for call in pending {
536 if !result.client_tool_calls.iter().any(|c| c.id == call.id) {
538 result.client_tool_calls.push(call);
539 }
540 }
541 vec![]
542 }
543}
544
545pub fn has_pending_tool_approval(client_tool_calls: &[ToolCall]) -> bool {
547 client_tool_calls
548 .iter()
549 .any(|call| call.name == crate::tool_types::APPROVE_TOOL_CALL_TOOL)
550}
551
552pub struct ClientSideToolHook;
566
567impl PostActHook for ClientSideToolHook {
568 fn on_completed(
569 &self,
570 result: &mut ActResult,
571 _tool_definitions: &[ToolDefinition],
572 ) -> Vec<PostActAction> {
573 if result.client_tool_calls.is_empty() {
574 return vec![];
575 }
576
577 result.waiting_for_tool_results = true;
578
579 vec![PostActAction::EmitToolCallRequested {
580 tool_calls: result.client_tool_calls.clone(),
581 tool_definitions: result.client_tool_definitions.clone(),
582 }]
583 }
584}
585
586pub(super) async fn run_post_act_hooks<E: EventEmitter>(
596 hooks: &[Box<dyn PostActHook>],
597 context: &ExecutionContext,
598 result: &mut ActResult,
599 tool_definitions: &[ToolDefinition],
600 event_emitter: &E,
601 locale: Option<&str>,
602) {
603 for hook in hooks {
604 let actions = hook.on_completed(result, tool_definitions);
605 for action in actions {
606 match action {
607 PostActAction::EmitToolCallRequested {
608 tool_calls,
609 tool_definitions: action_defs,
610 } => {
611 let event = EventRequest::new(
612 context.session_id,
613 EventContext::from_execution_context(context),
614 ToolCallRequestedData::with_definitions_and_locale(
615 &tool_calls,
616 &action_defs,
617 locale,
618 ),
619 );
620 if let Err(e) = event_emitter.emit(event).await {
621 tracing::warn!(
622 error = %e,
623 "PostActHook: failed to emit tool.call_requested event"
624 );
625 }
626 }
627 }
628 }
629 }
630}
631
632#[cfg(test)]
637mod tests {
638
639 #[test]
640 fn provider_approval_waits_for_the_client_but_other_unknown_tools_do_not() {
641 let call = |name: &str| ToolCall {
642 id: "c".into(),
643 name: name.into(),
644 arguments: json!({}),
645 };
646 let approval = everruns_contracts::openai_hosted_tools::OPENAI_MCP_APPROVAL_TOOL;
647 assert!(!runs_on_server(&call(approval), &[]));
648 assert!(runs_on_server(&call("missing_tool"), &[]));
649 }
650
651 use super::*;
652 use crate::execution::act::ToolCallResult;
653 use crate::tool_types::{ConnectionRequired, ConnectionRequiredSubject, ToolResult};
654 use std::sync::Mutex;
655
656 fn make_tool_call_result(connection_required: Option<&str>) -> ToolCallResult {
657 ToolCallResult {
658 tool_call: ToolCall {
659 id: "call_1".to_string(),
660 name: "some_tool".to_string(),
661 arguments: json!({}),
662 },
663 result: ToolResult {
664 tool_call_id: "call_1".to_string(),
665 result: Some(json!({})),
666 images: None,
667 error: None,
668 connection_required: connection_required.map(ConnectionRequired::provider_only),
669 raw_output: None,
670 },
671 success: true,
672 status: "success".to_string(),
673 connection_required: connection_required.map(ConnectionRequired::provider_only),
674 determinism_fatal: None,
675 }
676 }
677
678 #[test]
679 fn test_connection_setup_hook_no_connections() {
680 let hook = ConnectionSetupHook;
681 let mut result = ActResult {
682 results: vec![make_tool_call_result(None)],
683 completed: true,
684 success_count: 1,
685 error_count: 0,
686 waiting_for_tool_results: false,
687 waiting_for_url_elicitation: false,
688 blocked: false,
689 client_tool_calls: vec![],
690 client_tool_definitions: vec![],
691 };
692
693 let actions = hook.on_completed(&mut result, &[]);
694 assert!(actions.is_empty());
695 assert!(!result.waiting_for_tool_results);
696 }
697
698 #[test]
699 fn test_connection_setup_hook_with_connection() {
700 let hook = ConnectionSetupHook;
701 let mut result = ActResult {
702 results: vec![make_tool_call_result(Some("github"))],
703 completed: true,
704 success_count: 0,
705 error_count: 0,
706 waiting_for_tool_results: false,
707 waiting_for_url_elicitation: false,
708 blocked: false,
709 client_tool_calls: vec![],
710 client_tool_definitions: vec![],
711 };
712
713 let actions = hook.on_completed(&mut result, &[]);
714 assert_eq!(actions.len(), 1);
715 assert!(result.waiting_for_tool_results);
716
717 match &actions[0] {
718 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
719 assert_eq!(tool_calls.len(), 1);
720 assert_eq!(tool_calls[0].name, "setup_connection");
721 assert_eq!(tool_calls[0].arguments["provider"], "github");
722 }
723 }
724 }
725
726 #[test]
727 fn connection_setup_hook_preserves_subject_and_setup_url() {
728 let required = ConnectionRequired::with_setup(
729 "mcp_oauth_linear",
730 ConnectionRequiredSubject::Agent,
731 "/agents/agent_123?tab=mcp",
732 );
733 let mut call_result = make_tool_call_result(None);
734 call_result.result.connection_required = Some(required.clone());
735 call_result.connection_required = Some(required);
736 let mut result = ActResult {
737 results: vec![call_result],
738 completed: true,
739 success_count: 0,
740 error_count: 0,
741 waiting_for_tool_results: false,
742 waiting_for_url_elicitation: false,
743 blocked: false,
744 client_tool_calls: vec![],
745 client_tool_definitions: vec![],
746 };
747
748 let actions = ConnectionSetupHook.on_completed(&mut result, &[]);
749
750 match &actions[0] {
751 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
752 assert_eq!(
753 tool_calls[0].arguments,
754 json!({
755 "provider": "mcp_oauth_linear",
756 "subject": "agent",
757 "setup_url": "/agents/agent_123?tab=mcp",
758 })
759 );
760 }
761 }
762 }
763
764 fn make_elicitation_result(declined: bool) -> ToolCallResult {
765 let payload = UrlElicitationRequired {
766 code: crate::tool_types::URL_ELICITATION_REQUIRED_CODE.to_string(),
767 error: "needs a person".to_string(),
768 url: "https://pay.example.com/authorize/42".to_string(),
769 url_host: "pay.example.com".to_string(),
770 url_is_punycode: false,
771 server: "billing".to_string(),
772 tool: "charge".to_string(),
773 retry_tool: "mcp_billing_charge".to_string(),
774 message: "Authorize the charge".to_string(),
775 declined,
776 };
777 ToolCallResult {
778 tool_call: ToolCall {
779 id: "call_1".to_string(),
780 name: "mcp_billing_charge".to_string(),
781 arguments: json!({}),
782 },
783 result: ToolResult {
784 tool_call_id: "call_1".to_string(),
785 result: Some(serde_json::to_value(&payload).expect("serialize")),
786 images: None,
787 error: None,
788 connection_required: None,
789 raw_output: None,
790 },
791 success: true,
792 status: "success".to_string(),
793 connection_required: None,
794 determinism_fatal: None,
795 }
796 }
797
798 fn act_result(results: Vec<ToolCallResult>) -> ActResult {
799 ActResult {
800 results,
801 completed: true,
802 success_count: 1,
803 error_count: 0,
804 waiting_for_tool_results: false,
805 waiting_for_url_elicitation: false,
806 blocked: false,
807 client_tool_calls: vec![],
808 client_tool_definitions: vec![],
809 }
810 }
811
812 #[test]
813 fn url_elicitation_hook_pauses_and_asks_for_consent() {
814 let mut result = act_result(vec![make_elicitation_result(false)]);
815
816 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
817
818 assert!(
819 result.waiting_for_tool_results,
820 "the turn must hold while a human decides"
821 );
822 assert_eq!(actions.len(), 1);
823 match &actions[0] {
824 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
825 assert_eq!(tool_calls.len(), 1);
826 assert_eq!(tool_calls[0].name, CONFIRM_URL_ELICITATION_TOOL);
827 let arguments = &tool_calls[0].arguments;
828 assert_eq!(arguments["url"], "https://pay.example.com/authorize/42");
830 assert_eq!(arguments["url_host"], "pay.example.com");
831 assert_eq!(arguments["server"], "billing");
832 assert_eq!(arguments["tool"], "charge");
833 assert_eq!(arguments["retry_tool"], "mcp_billing_charge");
834 assert_eq!(arguments["message"], "Authorize the charge");
835 assert_eq!(arguments["url_is_punycode"], false);
836 }
837 }
838 }
839
840 #[test]
841 fn url_elicitation_hook_does_not_re_ask_after_a_refusal() {
842 let mut result = act_result(vec![make_elicitation_result(true)]);
843
844 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
845
846 assert!(actions.is_empty());
847 assert!(
848 !result.waiting_for_tool_results,
849 "a refusal is a decision; the turn continues"
850 );
851 }
852
853 #[test]
854 fn url_elicitation_hook_ignores_ordinary_results() {
855 let mut result = act_result(vec![make_tool_call_result(None)]);
856
857 let actions = UrlElicitationHook.on_completed(&mut result, &[]);
858
859 assert!(actions.is_empty());
860 assert!(!result.waiting_for_tool_results);
861 }
862
863 fn make_form_elicitation_result() -> ToolCallResult {
864 let payload = FormElicitationRequired {
865 code: crate::tool_types::FORM_ELICITATION_REQUIRED_CODE.to_string(),
866 error: "The server needs answers".to_string(),
867 server: "deploys".to_string(),
868 tool: "release".to_string(),
869 retry_tool: "mcp_deploys_release".to_string(),
870 message: "Which environment?".to_string(),
871 questions: vec![json!({"kind": "choice", "id": "environment"})],
872 fingerprint: "abc123".to_string(),
873 };
874 let mut result = make_tool_call_result(None);
875 result.result.result = Some(serde_json::to_value(&payload).expect("serialize"));
876 result
877 }
878
879 #[test]
880 fn form_elicitation_hook_asks_through_the_ask_user_pause() {
881 let mut result = act_result(vec![make_form_elicitation_result()]);
882
883 let actions = FormElicitationHook.on_completed(&mut result, &[]);
884 assert!(actions.is_empty(), "ClientSideToolHook emits the call");
885 assert_eq!(result.client_tool_calls.len(), 1);
886 let call = &result.client_tool_calls[0];
887 assert_eq!(call.name, ASK_USER_TOOL_NAME);
888 assert!(call.id.starts_with(FORM_ELICITATION_CALL_ID_PREFIX));
889 let elicitation = &call.arguments[MCP_ELICITATION_ARGUMENT];
890 assert_eq!(elicitation["server"], "deploys");
891 assert_eq!(elicitation["tool"], "release");
892 assert_eq!(elicitation["retry_tool"], "mcp_deploys_release");
893 assert_eq!(elicitation["fingerprint"], "abc123");
894 assert_eq!(call.arguments["questions"][0]["id"], "environment");
895 assert!(call.arguments["expires_at"].is_string());
896
897 let actions = ClientSideToolHook.on_completed(&mut result, &[]);
899 assert_eq!(actions.len(), 1);
900 assert!(result.waiting_for_tool_results);
901 }
902
903 #[test]
904 fn form_elicitation_hook_ignores_ordinary_results() {
905 let mut result = act_result(vec![make_tool_call_result(None)]);
906
907 FormElicitationHook.on_completed(&mut result, &[]);
908
909 assert!(result.client_tool_calls.is_empty());
910 }
911
912 #[test]
913 fn test_client_side_tool_hook_no_client_tools() {
914 let hook = ClientSideToolHook;
915 let mut result = ActResult {
916 results: vec![],
917 completed: true,
918 success_count: 0,
919 error_count: 0,
920 waiting_for_tool_results: false,
921 waiting_for_url_elicitation: false,
922 blocked: false,
923 client_tool_calls: vec![],
924 client_tool_definitions: vec![],
925 };
926
927 let actions = hook.on_completed(&mut result, &[]);
928 assert!(actions.is_empty());
929 assert!(!result.waiting_for_tool_results);
930 }
931
932 #[test]
933 fn test_client_side_tool_hook_with_client_tools() {
934 let hook = ClientSideToolHook;
935 let client_call = ToolCall {
936 id: "call_client".to_string(),
937 name: "browser_click".to_string(),
938 arguments: json!({"selector": "#btn"}),
939 };
940
941 let mut result = ActResult {
942 results: vec![],
943 completed: true,
944 success_count: 0,
945 error_count: 0,
946 waiting_for_tool_results: false,
947 waiting_for_url_elicitation: false,
948 blocked: false,
949 client_tool_calls: vec![client_call.clone()],
950 client_tool_definitions: vec![],
951 };
952
953 let actions = hook.on_completed(&mut result, &[]);
954 assert_eq!(actions.len(), 1);
955 assert!(result.waiting_for_tool_results);
956
957 match &actions[0] {
958 PostActAction::EmitToolCallRequested { tool_calls, .. } => {
959 assert_eq!(tool_calls.len(), 1);
960 assert_eq!(tool_calls[0].name, "browser_click");
961 }
962 }
963 }
964
965 use crate::tool_context::ToolContext;
970 use crate::typed_id::SessionId;
971
972 fn make_tool_call() -> ToolCall {
973 ToolCall {
974 id: "call_test".to_string(),
975 name: "test_tool".to_string(),
976 arguments: json!({}),
977 }
978 }
979
980 fn make_tool_def() -> ToolDefinition {
981 ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
982 name: "test_tool".to_string(),
983 display_name: None,
984 description: "test".to_string(),
985 parameters: json!({}),
986 policy: crate::tool_types::ToolPolicy::Auto,
987 category: None,
988 deferrable: crate::tool_types::DeferrablePolicy::Never,
989 hints: Default::default(),
990 full_parameters: None,
991 })
992 }
993
994 struct MarkerHook {
995 name: &'static str,
996 calls: Arc<Mutex<Vec<&'static str>>>,
997 }
998
999 #[async_trait]
1000 impl PostToolExecHook for MarkerHook {
1001 async fn after_exec(
1002 &self,
1003 _tool_call: &ToolCall,
1004 _tool_def: &ToolDefinition,
1005 result: &mut ToolResult,
1006 _context: &ToolContext,
1007 ) {
1008 self.calls.lock().unwrap().push(self.name);
1009 let value = result
1010 .result
1011 .take()
1012 .and_then(|value| value.as_str().map(str::to_owned))
1013 .unwrap_or_default();
1014 result.result = Some(json!(format!("{value}-{}", self.name)));
1015 }
1016 }
1017
1018 #[tokio::test]
1019 async fn capability_hooks_run_before_runtime_final_hooks() {
1020 let calls = Arc::new(Mutex::new(Vec::new()));
1021 let capability_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
1022 name: "capability",
1023 calls: Arc::clone(&calls),
1024 })];
1025 let final_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
1026 name: "final",
1027 calls: Arc::clone(&calls),
1028 })];
1029 let mut result = ToolResult {
1030 tool_call_id: "call_test".into(),
1031 result: Some(json!("start")),
1032 images: None,
1033 error: None,
1034 connection_required: None,
1035 raw_output: None,
1036 };
1037
1038 run_post_tool_exec_hooks(
1039 &capability_hooks,
1040 &final_hooks,
1041 &make_tool_call(),
1042 &make_tool_def(),
1043 &mut result,
1044 &ToolContext::new(SessionId::new()),
1045 )
1046 .await;
1047
1048 assert_eq!(*calls.lock().unwrap(), ["capability", "final"]);
1049 assert_eq!(result.result, Some(json!("start-capability-final")));
1050 }
1051
1052 #[tokio::test]
1053 async fn test_output_hard_limit_passthrough_small() {
1054 let hook = OutputHardLimitHook;
1055 let tc = make_tool_call();
1056 let td = make_tool_def();
1057 let ctx = ToolContext::new(SessionId::new());
1058 let mut result = ToolResult {
1059 tool_call_id: "call_test".into(),
1060 result: Some(json!("hello")),
1061 images: None,
1062 error: None,
1063 connection_required: None,
1064 raw_output: None,
1065 };
1066
1067 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1068 assert_eq!(result.result, Some(json!("hello")));
1069 }
1070
1071 #[tokio::test]
1072 async fn test_output_hard_limit_truncates_large_string() {
1073 let hook = OutputHardLimitHook;
1074 let tc = make_tool_call();
1075 let td = make_tool_def();
1076 let ctx = ToolContext::new(SessionId::new());
1077 let big = "x".repeat(MAX_TOOL_RESULT_BYTES + 1000);
1078 let mut result = ToolResult {
1079 tool_call_id: "call_test".into(),
1080 result: Some(json!(big)),
1081 images: None,
1082 error: None,
1083 connection_required: None,
1084 raw_output: None,
1085 };
1086
1087 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1088
1089 let text = result.result.unwrap();
1090 let s = text.as_str().unwrap();
1091 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
1092 assert!(s.ends_with(TRUNCATION_SUFFIX));
1093 }
1094
1095 #[tokio::test]
1096 async fn test_output_hard_limit_at_exact_limit() {
1097 let hook = OutputHardLimitHook;
1098 let tc = make_tool_call();
1099 let td = make_tool_def();
1100 let ctx = ToolContext::new(SessionId::new());
1101 let exact = "a".repeat(MAX_TOOL_RESULT_BYTES);
1102 let mut result = ToolResult {
1103 tool_call_id: "call_test".into(),
1104 result: Some(json!(exact)),
1105 images: None,
1106 error: None,
1107 connection_required: None,
1108 raw_output: None,
1109 };
1110
1111 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1112
1113 let text = result.result.unwrap();
1114 let s = text.as_str().unwrap();
1115 assert_eq!(s.len(), MAX_TOOL_RESULT_BYTES);
1117 assert!(!s.contains("[Output truncated"));
1118 }
1119
1120 #[tokio::test]
1121 async fn test_output_hard_limit_multibyte_boundary() {
1122 let hook = OutputHardLimitHook;
1123 let tc = make_tool_call();
1124 let td = make_tool_def();
1125 let ctx = ToolContext::new(SessionId::new());
1126 let ch = "€"; let count = MAX_TOOL_RESULT_BYTES / ch.len() + 1;
1128 let big = ch.repeat(count);
1129 let mut result = ToolResult {
1130 tool_call_id: "call_test".into(),
1131 result: Some(json!(big)),
1132 images: None,
1133 error: None,
1134 connection_required: None,
1135 raw_output: None,
1136 };
1137
1138 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1139
1140 let text = result.result.unwrap();
1141 let s = text.as_str().unwrap();
1142 assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
1143 assert!(s.contains("[Output truncated"));
1144 }
1145
1146 #[tokio::test]
1147 async fn test_output_hard_limit_truncates_error() {
1148 let hook = OutputHardLimitHook;
1149 let tc = make_tool_call();
1150 let td = make_tool_def();
1151 let ctx = ToolContext::new(SessionId::new());
1152 let big_err = "e".repeat(MAX_TOOL_RESULT_BYTES + 500);
1153 let mut result = ToolResult {
1154 tool_call_id: "call_test".into(),
1155 result: None,
1156 images: None,
1157 error: Some(big_err),
1158 connection_required: None,
1159 raw_output: None,
1160 };
1161
1162 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1163
1164 let err = result.error.unwrap();
1165 assert!(err.len() <= MAX_TOOL_RESULT_BYTES);
1166 assert!(err.ends_with(TRUNCATION_SUFFIX));
1167 }
1168
1169 #[tokio::test]
1170 async fn test_output_hard_limit_non_string_json() {
1171 let hook = OutputHardLimitHook;
1172 let tc = make_tool_call();
1173 let td = make_tool_def();
1174 let ctx = ToolContext::new(SessionId::new());
1175 let mut result = ToolResult {
1177 tool_call_id: "call_test".into(),
1178 result: Some(json!({"key": "value", "num": 42})),
1179 images: None,
1180 error: None,
1181 connection_required: None,
1182 raw_output: None,
1183 };
1184
1185 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1186
1187 assert_eq!(result.result, Some(json!({"key": "value", "num": 42})));
1189 }
1190
1191 #[tokio::test]
1192 async fn test_output_hard_limit_drops_oversized_images() {
1193 let hook = OutputHardLimitHook;
1194 let tc = make_tool_call();
1195 let td = make_tool_def();
1196 let ctx = ToolContext::new(SessionId::new());
1197
1198 let mut result = ToolResult {
1199 tool_call_id: "call_test".into(),
1200 result: Some(json!({"ok": true})),
1201 images: Some(vec![
1202 everruns_contracts::ToolResultImage {
1203 base64: "a".repeat(32),
1204 media_type: "image/png".to_string(),
1205 },
1206 everruns_contracts::ToolResultImage {
1207 base64: "b".repeat(MAX_TOOL_RESULT_BYTES + 1),
1208 media_type: "image/png".to_string(),
1209 },
1210 ]),
1211 error: None,
1212 connection_required: None,
1213 raw_output: None,
1214 };
1215
1216 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1217
1218 let images = result.images.unwrap();
1219 assert_eq!(images.len(), 1);
1220 assert_eq!(images[0].base64.len(), 32);
1221 }
1222
1223 #[tokio::test]
1224 async fn test_output_hard_limit_enforces_cumulative_image_budget() {
1225 let hook = OutputHardLimitHook;
1226 let tc = make_tool_call();
1227 let td = make_tool_def();
1228 let ctx = ToolContext::new(SessionId::new());
1229
1230 let half = MAX_TOOL_RESULT_BYTES / 2;
1233 let mut result = ToolResult {
1234 tool_call_id: "call_test".into(),
1235 result: Some(json!({"ok": true})),
1236 images: Some(vec![
1237 everruns_contracts::ToolResultImage {
1238 base64: "a".repeat(half),
1239 media_type: "image/png".to_string(),
1240 },
1241 everruns_contracts::ToolResultImage {
1242 base64: "b".repeat(half),
1243 media_type: "image/png".to_string(),
1244 },
1245 everruns_contracts::ToolResultImage {
1246 base64: "c".repeat(half),
1247 media_type: "image/png".to_string(),
1248 },
1249 ]),
1250 error: None,
1251 connection_required: None,
1252 raw_output: None,
1253 };
1254
1255 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1256
1257 let images = result.images.unwrap();
1258 assert_eq!(
1259 images.len(),
1260 2,
1261 "third image should be dropped by cumulative budget"
1262 );
1263 assert!(images.iter().all(|i| i.base64.len() == half));
1264 }
1265
1266 #[tokio::test]
1267 async fn test_output_hard_limit_normalizes_empty_images_to_none() {
1268 let hook = OutputHardLimitHook;
1269 let tc = make_tool_call();
1270 let td = make_tool_def();
1271 let ctx = ToolContext::new(SessionId::new());
1272
1273 let mut result = ToolResult {
1274 tool_call_id: "call_test".into(),
1275 result: Some(json!({"ok": true})),
1276 images: Some(vec![everruns_contracts::ToolResultImage {
1277 base64: "a".repeat(MAX_TOOL_RESULT_BYTES + 1),
1278 media_type: "image/png".to_string(),
1279 }]),
1280 error: None,
1281 connection_required: None,
1282 raw_output: None,
1283 };
1284
1285 hook.after_exec(&tc, &td, &mut result, &ctx).await;
1286
1287 assert!(
1288 result.images.is_none(),
1289 "images vec emptied by retain should normalize to None"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_truncate_helper_short() {
1295 let s = "hello".to_string();
1296 assert_eq!(OutputHardLimitHook::truncate(s.clone()), s);
1297 }
1298
1299 #[test]
1300 fn test_truncate_helper_over() {
1301 let s = "a".repeat(MAX_TOOL_RESULT_BYTES + 100);
1302 let t = OutputHardLimitHook::truncate(s);
1303 assert!(t.len() <= MAX_TOOL_RESULT_BYTES);
1304 assert!(t.ends_with(TRUNCATION_SUFFIX));
1305 }
1306}