Skip to main content

everruns_engine/execution/
act_hooks.rs

1// Post-act hooks for ActAtom
2//
3// Decision: Hooks are pure functions that inspect ActResult and return
4// declarative PostActActions. ActAtom interprets them (event emission, etc).
5// This keeps hooks testable without mocking EventEmitter.
6//
7// Decision: Hooks set `waiting_for_tool_results` on ActResult so workers
8// see a single generic flag — they never need to know WHY the act paused.
9//
10// PostToolExecHook (EVE-222): async hooks that run after each individual tool
11// execution. Unlike PostActHook (runs once after all tools), these run per-tool
12// and can mutate the result (e.g. persist output to VFS, inject metadata).
13
14use crate::events::{EventContext, EventRequest, ToolCallRequestedData};
15use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
16use crate::{event_emitter::EventEmitter, tool_context::ToolContext};
17use async_trait::async_trait;
18pub(crate) use everruns_core::tool_hooks::{PostToolExecHook, PreToolUseDecision, PreToolUseHook};
19use serde_json::json;
20use std::sync::Arc;
21use uuid::Uuid;
22
23use super::ExecutionContext;
24use super::act::ActResult;
25
26/// Run every registered `PreToolUseHook` against `tool_call`. Hooks chain
27/// sequentially; the first `Block` aborts the chain and is returned. If
28/// every hook returns `Continue`, the final (potentially mutated)
29/// `ToolCall` is returned.
30pub(super) async fn run_pre_tool_use_hooks(
31    hooks: &[Arc<dyn PreToolUseHook>],
32    mut tool_call: ToolCall,
33    tool_def: &ToolDefinition,
34    context: &ToolContext,
35) -> PreToolUseDecision {
36    for hook in hooks {
37        match hook.before_exec(tool_call.clone(), tool_def, context).await {
38            PreToolUseDecision::Continue(updated) => {
39                tool_call = updated;
40            }
41            block @ PreToolUseDecision::Block { .. } => return block,
42        }
43    }
44    PreToolUseDecision::Continue(tool_call)
45}
46
47/// Execute post-tool-exec hooks on a single tool result.
48///
49/// Runs capability-contributed hooks first, then final (infrastructure) hooks.
50pub(super) async fn run_post_tool_exec_hooks(
51    hooks: &[Arc<dyn PostToolExecHook>],
52    final_hooks: &[Arc<dyn PostToolExecHook>],
53    tool_call: &ToolCall,
54    tool_def: &ToolDefinition,
55    result: &mut ToolResult,
56    context: &ToolContext,
57) {
58    for hook in hooks {
59        hook.after_exec(tool_call, tool_def, result, context).await;
60    }
61    for hook in final_hooks {
62        hook.after_exec(tool_call, tool_def, result, context).await;
63    }
64}
65
66// ============================================================================
67// OutputHardLimitHook (EVE-225)
68// ============================================================================
69
70/// Maximum tool result size in bytes before truncation (64 KiB).
71///
72/// THREAT[TM-AGENT-012]: large results consume context window, increase cost,
73/// and expand the prompt injection surface.
74const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
75
76const TRUNCATION_SUFFIX: &str =
77    "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
78
79/// Infrastructure hook that enforces a hard 64 KiB ceiling on tool result text.
80///
81/// Always registered as a `final_post_tool_hook` in ActAtom — cannot be removed
82/// by capabilities. Runs after all capability-contributed hooks so that
83/// persistence hooks (EVE-222) can capture full output before truncation.
84///
85/// Head-truncation with UTF-8 safety: keeps the first N bytes (on a char
86/// boundary) and appends an LLM-actionable suffix.
87pub struct OutputHardLimitHook;
88
89impl OutputHardLimitHook {
90    /// Truncate `text` to `MAX_TOOL_RESULT_BYTES` with a UTF-8-safe cut.
91    fn truncate(text: String) -> String {
92        if text.len() <= MAX_TOOL_RESULT_BYTES {
93            return text;
94        }
95        let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
96        let mut end = content_budget;
97        while end > 0 && !text.is_char_boundary(end) {
98            end -= 1;
99        }
100        let mut truncated = text[..end].to_string();
101        truncated.push_str(TRUNCATION_SUFFIX);
102        truncated
103    }
104}
105
106#[async_trait]
107impl PostToolExecHook for OutputHardLimitHook {
108    async fn after_exec(
109        &self,
110        tool_call: &ToolCall,
111        _tool_def: &ToolDefinition,
112        result: &mut ToolResult,
113        _context: &ToolContext,
114    ) {
115        // Truncate the result JSON value if it exceeds the limit.
116        if let Some(val) = result.result.take() {
117            match val {
118                serde_json::Value::String(s) => {
119                    let original_len = s.len();
120                    let truncated = Self::truncate(s);
121                    if truncated.len() < original_len {
122                        tracing::warn!(
123                            tool_name = %tool_call.name,
124                            tool_call_id = %tool_call.id,
125                            result_bytes = original_len,
126                            limit = MAX_TOOL_RESULT_BYTES,
127                            "Tool result exceeded hard limit, truncated"
128                        );
129                    }
130                    result.result = Some(serde_json::Value::String(truncated));
131                }
132                other => {
133                    // Non-string JSON: serialize, check size, convert to
134                    // truncated string if over limit.
135                    let serialized = serde_json::to_string(&other).unwrap_or_default();
136                    if serialized.len() > MAX_TOOL_RESULT_BYTES {
137                        tracing::warn!(
138                            tool_name = %tool_call.name,
139                            tool_call_id = %tool_call.id,
140                            result_bytes = serialized.len(),
141                            limit = MAX_TOOL_RESULT_BYTES,
142                            "Tool result exceeded hard limit, truncated"
143                        );
144                        let truncated = Self::truncate(serialized);
145                        result.result = Some(serde_json::Value::String(truncated));
146                    } else {
147                        result.result = Some(other);
148                    }
149                }
150            }
151        }
152
153        // Also cap error messages (unlikely to be huge, but defense in depth).
154        if let Some(err) = result.error.take() {
155            if err.len() > MAX_TOOL_RESULT_BYTES {
156                tracing::warn!(
157                    tool_name = %tool_call.name,
158                    tool_call_id = %tool_call.id,
159                    result_bytes = err.len(),
160                    limit = MAX_TOOL_RESULT_BYTES,
161                    "Tool error exceeded hard limit, truncated"
162                );
163            }
164            result.error = Some(Self::truncate(err));
165        }
166
167        // Cap native image payloads too. These bypass `result.result` JSON size
168        // checks and are appended directly as ContentPart::Image later. Enforce
169        // both a per-image ceiling (no single image larger than the budget) and
170        // a cumulative budget (many smaller images cannot blow past it either).
171        if let Some(images) = result.images.as_mut() {
172            let original_count = images.len();
173            let mut cumulative = 0usize;
174            images.retain(|img| {
175                let len = img.base64.len();
176                if len > MAX_TOOL_RESULT_BYTES {
177                    return false;
178                }
179                match cumulative.checked_add(len) {
180                    Some(total) if total <= MAX_TOOL_RESULT_BYTES => {
181                        cumulative = total;
182                        true
183                    }
184                    _ => false,
185                }
186            });
187            let dropped = original_count.saturating_sub(images.len());
188            if dropped > 0 {
189                tracing::warn!(
190                    tool_name = %tool_call.name,
191                    tool_call_id = %tool_call.id,
192                    dropped_images = dropped,
193                    kept_images = images.len(),
194                    kept_bytes = cumulative,
195                    limit = MAX_TOOL_RESULT_BYTES,
196                    "Tool images exceeded hard limit and were dropped"
197                );
198            }
199            if images.is_empty() {
200                result.images = None;
201            }
202        }
203    }
204}
205
206// ============================================================================
207// PostActHook trait
208// ============================================================================
209
210/// Action a post-act hook wants ActAtom to perform.
211#[derive(Debug, Clone)]
212pub enum PostActAction {
213    /// Emit a `tool.call_requested` event with synthetic client-side tool calls.
214    EmitToolCallRequested {
215        tool_calls: Vec<ToolCall>,
216        tool_definitions: Vec<ToolDefinition>,
217    },
218}
219
220/// Hook that runs after ActAtom finishes executing tools.
221///
222/// Hooks inspect the completed results and may:
223/// - Set `waiting_for_tool_results` on `ActResult`
224/// - Return actions for ActAtom to execute (e.g. emit events)
225///
226/// Hooks are pure: they return declarative actions rather than
227/// touching the event emitter directly. This makes them trivially testable.
228pub trait PostActHook: Send + Sync {
229    /// Inspect completed results, optionally mutate the result and return actions.
230    fn on_completed(
231        &self,
232        result: &mut ActResult,
233        tool_definitions: &[ToolDefinition],
234    ) -> Vec<PostActAction>;
235}
236
237// ============================================================================
238// ConnectionSetupHook
239// ============================================================================
240
241/// Hook that detects tools requiring user connection setup and emits
242/// synthetic `setup_connection` tool calls so the client can prompt the user.
243///
244/// When any tool returns `connection_required`, this hook:
245/// 1. Sets `waiting_for_tool_results = true` on ActResult
246/// 2. Returns a `PostActAction::EmitToolCallRequested` with synthetic tool calls
247pub struct ConnectionSetupHook;
248
249impl PostActHook for ConnectionSetupHook {
250    fn on_completed(
251        &self,
252        result: &mut ActResult,
253        _tool_definitions: &[ToolDefinition],
254    ) -> Vec<PostActAction> {
255        let providers: Vec<String> = result
256            .results
257            .iter()
258            .filter_map(|r| r.connection_required.clone())
259            .collect();
260
261        if providers.is_empty() {
262            return vec![];
263        }
264
265        result.waiting_for_tool_results = true;
266
267        let tool_calls: Vec<ToolCall> = providers
268            .iter()
269            .map(|provider| ToolCall {
270                id: format!("setup_conn_{}", Uuid::now_v7()),
271                name: "setup_connection".to_string(),
272                arguments: json!({ "provider": provider }),
273            })
274            .collect();
275
276        vec![PostActAction::EmitToolCallRequested {
277            tool_calls,
278            tool_definitions: vec![],
279        }]
280    }
281}
282
283// ============================================================================
284// ClientSideToolHook
285// ============================================================================
286
287/// Hook that handles client-side tool calls from the ReasonResult.
288///
289/// When ActAtom receives tool calls that include client-side tools,
290/// those tools are NOT executed (they're filtered out before execution).
291/// Instead, this hook emits `tool.call_requested` events so the client
292/// can execute them and return results.
293///
294/// This hook reads client-side tool calls stored on ActResult by ActAtom's
295/// partitioning logic, then emits the appropriate event.
296pub struct ClientSideToolHook;
297
298impl PostActHook for ClientSideToolHook {
299    fn on_completed(
300        &self,
301        result: &mut ActResult,
302        _tool_definitions: &[ToolDefinition],
303    ) -> Vec<PostActAction> {
304        if result.client_tool_calls.is_empty() {
305            return vec![];
306        }
307
308        result.waiting_for_tool_results = true;
309
310        vec![PostActAction::EmitToolCallRequested {
311            tool_calls: result.client_tool_calls.clone(),
312            tool_definitions: result.client_tool_definitions.clone(),
313        }]
314    }
315}
316
317// ============================================================================
318// Hook execution helper
319// ============================================================================
320
321/// Execute all post-act hooks and apply their actions.
322///
323/// This is called by ActAtom after tool execution completes. It:
324/// 1. Runs each hook to collect actions
325/// 2. Emits events for each action
326pub(super) async fn run_post_act_hooks<E: EventEmitter>(
327    hooks: &[Box<dyn PostActHook>],
328    context: &ExecutionContext,
329    result: &mut ActResult,
330    tool_definitions: &[ToolDefinition],
331    event_emitter: &E,
332    locale: Option<&str>,
333) {
334    for hook in hooks {
335        let actions = hook.on_completed(result, tool_definitions);
336        for action in actions {
337            match action {
338                PostActAction::EmitToolCallRequested {
339                    tool_calls,
340                    tool_definitions: action_defs,
341                } => {
342                    let event = EventRequest::new(
343                        context.session_id,
344                        EventContext::from_execution_context(context),
345                        ToolCallRequestedData::with_definitions_and_locale(
346                            &tool_calls,
347                            &action_defs,
348                            locale,
349                        ),
350                    );
351                    if let Err(e) = event_emitter.emit(event).await {
352                        tracing::warn!(
353                            error = %e,
354                            "PostActHook: failed to emit tool.call_requested event"
355                        );
356                    }
357                }
358            }
359        }
360    }
361}
362
363// ============================================================================
364// Tests
365// ============================================================================
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::execution::act::ToolCallResult;
371    use crate::tool_types::ToolResult;
372    use std::sync::Mutex;
373
374    fn make_tool_call_result(connection_required: Option<&str>) -> ToolCallResult {
375        ToolCallResult {
376            tool_call: ToolCall {
377                id: "call_1".to_string(),
378                name: "some_tool".to_string(),
379                arguments: json!({}),
380            },
381            result: ToolResult {
382                tool_call_id: "call_1".to_string(),
383                result: Some(json!({})),
384                images: None,
385                error: None,
386                connection_required: connection_required.map(|s| s.to_string()),
387                raw_output: None,
388            },
389            success: true,
390            status: "success".to_string(),
391            connection_required: connection_required.map(|s| s.to_string()),
392            determinism_fatal: None,
393        }
394    }
395
396    #[test]
397    fn test_connection_setup_hook_no_connections() {
398        let hook = ConnectionSetupHook;
399        let mut result = ActResult {
400            results: vec![make_tool_call_result(None)],
401            completed: true,
402            success_count: 1,
403            error_count: 0,
404            waiting_for_tool_results: false,
405            blocked: false,
406            client_tool_calls: vec![],
407            client_tool_definitions: vec![],
408        };
409
410        let actions = hook.on_completed(&mut result, &[]);
411        assert!(actions.is_empty());
412        assert!(!result.waiting_for_tool_results);
413    }
414
415    #[test]
416    fn test_connection_setup_hook_with_connection() {
417        let hook = ConnectionSetupHook;
418        let mut result = ActResult {
419            results: vec![make_tool_call_result(Some("github"))],
420            completed: true,
421            success_count: 0,
422            error_count: 0,
423            waiting_for_tool_results: false,
424            blocked: false,
425            client_tool_calls: vec![],
426            client_tool_definitions: vec![],
427        };
428
429        let actions = hook.on_completed(&mut result, &[]);
430        assert_eq!(actions.len(), 1);
431        assert!(result.waiting_for_tool_results);
432
433        match &actions[0] {
434            PostActAction::EmitToolCallRequested { tool_calls, .. } => {
435                assert_eq!(tool_calls.len(), 1);
436                assert_eq!(tool_calls[0].name, "setup_connection");
437                assert_eq!(tool_calls[0].arguments["provider"], "github");
438            }
439        }
440    }
441
442    #[test]
443    fn test_client_side_tool_hook_no_client_tools() {
444        let hook = ClientSideToolHook;
445        let mut result = ActResult {
446            results: vec![],
447            completed: true,
448            success_count: 0,
449            error_count: 0,
450            waiting_for_tool_results: false,
451            blocked: false,
452            client_tool_calls: vec![],
453            client_tool_definitions: vec![],
454        };
455
456        let actions = hook.on_completed(&mut result, &[]);
457        assert!(actions.is_empty());
458        assert!(!result.waiting_for_tool_results);
459    }
460
461    #[test]
462    fn test_client_side_tool_hook_with_client_tools() {
463        let hook = ClientSideToolHook;
464        let client_call = ToolCall {
465            id: "call_client".to_string(),
466            name: "browser_click".to_string(),
467            arguments: json!({"selector": "#btn"}),
468        };
469
470        let mut result = ActResult {
471            results: vec![],
472            completed: true,
473            success_count: 0,
474            error_count: 0,
475            waiting_for_tool_results: false,
476            blocked: false,
477            client_tool_calls: vec![client_call.clone()],
478            client_tool_definitions: vec![],
479        };
480
481        let actions = hook.on_completed(&mut result, &[]);
482        assert_eq!(actions.len(), 1);
483        assert!(result.waiting_for_tool_results);
484
485        match &actions[0] {
486            PostActAction::EmitToolCallRequested { tool_calls, .. } => {
487                assert_eq!(tool_calls.len(), 1);
488                assert_eq!(tool_calls[0].name, "browser_click");
489            }
490        }
491    }
492
493    // ========================================================================
494    // OutputHardLimitHook tests (EVE-225)
495    // ========================================================================
496
497    use crate::tool_context::ToolContext;
498    use crate::typed_id::SessionId;
499
500    fn make_tool_call() -> ToolCall {
501        ToolCall {
502            id: "call_test".to_string(),
503            name: "test_tool".to_string(),
504            arguments: json!({}),
505        }
506    }
507
508    fn make_tool_def() -> ToolDefinition {
509        ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
510            name: "test_tool".to_string(),
511            display_name: None,
512            description: "test".to_string(),
513            parameters: json!({}),
514            policy: crate::tool_types::ToolPolicy::Auto,
515            category: None,
516            deferrable: crate::tool_types::DeferrablePolicy::Never,
517            hints: Default::default(),
518            full_parameters: None,
519        })
520    }
521
522    struct MarkerHook {
523        name: &'static str,
524        calls: Arc<Mutex<Vec<&'static str>>>,
525    }
526
527    #[async_trait]
528    impl PostToolExecHook for MarkerHook {
529        async fn after_exec(
530            &self,
531            _tool_call: &ToolCall,
532            _tool_def: &ToolDefinition,
533            result: &mut ToolResult,
534            _context: &ToolContext,
535        ) {
536            self.calls.lock().unwrap().push(self.name);
537            let value = result
538                .result
539                .take()
540                .and_then(|value| value.as_str().map(str::to_owned))
541                .unwrap_or_default();
542            result.result = Some(json!(format!("{value}-{}", self.name)));
543        }
544    }
545
546    #[tokio::test]
547    async fn capability_hooks_run_before_runtime_final_hooks() {
548        let calls = Arc::new(Mutex::new(Vec::new()));
549        let capability_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
550            name: "capability",
551            calls: Arc::clone(&calls),
552        })];
553        let final_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
554            name: "final",
555            calls: Arc::clone(&calls),
556        })];
557        let mut result = ToolResult {
558            tool_call_id: "call_test".into(),
559            result: Some(json!("start")),
560            images: None,
561            error: None,
562            connection_required: None,
563            raw_output: None,
564        };
565
566        run_post_tool_exec_hooks(
567            &capability_hooks,
568            &final_hooks,
569            &make_tool_call(),
570            &make_tool_def(),
571            &mut result,
572            &ToolContext::new(SessionId::new()),
573        )
574        .await;
575
576        assert_eq!(*calls.lock().unwrap(), ["capability", "final"]);
577        assert_eq!(result.result, Some(json!("start-capability-final")));
578    }
579
580    #[tokio::test]
581    async fn test_output_hard_limit_passthrough_small() {
582        let hook = OutputHardLimitHook;
583        let tc = make_tool_call();
584        let td = make_tool_def();
585        let ctx = ToolContext::new(SessionId::new());
586        let mut result = ToolResult {
587            tool_call_id: "call_test".into(),
588            result: Some(json!("hello")),
589            images: None,
590            error: None,
591            connection_required: None,
592            raw_output: None,
593        };
594
595        hook.after_exec(&tc, &td, &mut result, &ctx).await;
596        assert_eq!(result.result, Some(json!("hello")));
597    }
598
599    #[tokio::test]
600    async fn test_output_hard_limit_truncates_large_string() {
601        let hook = OutputHardLimitHook;
602        let tc = make_tool_call();
603        let td = make_tool_def();
604        let ctx = ToolContext::new(SessionId::new());
605        let big = "x".repeat(MAX_TOOL_RESULT_BYTES + 1000);
606        let mut result = ToolResult {
607            tool_call_id: "call_test".into(),
608            result: Some(json!(big)),
609            images: None,
610            error: None,
611            connection_required: None,
612            raw_output: None,
613        };
614
615        hook.after_exec(&tc, &td, &mut result, &ctx).await;
616
617        let text = result.result.unwrap();
618        let s = text.as_str().unwrap();
619        assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
620        assert!(s.ends_with(TRUNCATION_SUFFIX));
621    }
622
623    #[tokio::test]
624    async fn test_output_hard_limit_at_exact_limit() {
625        let hook = OutputHardLimitHook;
626        let tc = make_tool_call();
627        let td = make_tool_def();
628        let ctx = ToolContext::new(SessionId::new());
629        let exact = "a".repeat(MAX_TOOL_RESULT_BYTES);
630        let mut result = ToolResult {
631            tool_call_id: "call_test".into(),
632            result: Some(json!(exact)),
633            images: None,
634            error: None,
635            connection_required: None,
636            raw_output: None,
637        };
638
639        hook.after_exec(&tc, &td, &mut result, &ctx).await;
640
641        let text = result.result.unwrap();
642        let s = text.as_str().unwrap();
643        // Should NOT be truncated (equal to limit)
644        assert_eq!(s.len(), MAX_TOOL_RESULT_BYTES);
645        assert!(!s.contains("[Output truncated"));
646    }
647
648    #[tokio::test]
649    async fn test_output_hard_limit_multibyte_boundary() {
650        let hook = OutputHardLimitHook;
651        let tc = make_tool_call();
652        let td = make_tool_def();
653        let ctx = ToolContext::new(SessionId::new());
654        let ch = "€"; // 3 bytes
655        let count = MAX_TOOL_RESULT_BYTES / ch.len() + 1;
656        let big = ch.repeat(count);
657        let mut result = ToolResult {
658            tool_call_id: "call_test".into(),
659            result: Some(json!(big)),
660            images: None,
661            error: None,
662            connection_required: None,
663            raw_output: None,
664        };
665
666        hook.after_exec(&tc, &td, &mut result, &ctx).await;
667
668        let text = result.result.unwrap();
669        let s = text.as_str().unwrap();
670        assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
671        assert!(s.contains("[Output truncated"));
672    }
673
674    #[tokio::test]
675    async fn test_output_hard_limit_truncates_error() {
676        let hook = OutputHardLimitHook;
677        let tc = make_tool_call();
678        let td = make_tool_def();
679        let ctx = ToolContext::new(SessionId::new());
680        let big_err = "e".repeat(MAX_TOOL_RESULT_BYTES + 500);
681        let mut result = ToolResult {
682            tool_call_id: "call_test".into(),
683            result: None,
684            images: None,
685            error: Some(big_err),
686            connection_required: None,
687            raw_output: None,
688        };
689
690        hook.after_exec(&tc, &td, &mut result, &ctx).await;
691
692        let err = result.error.unwrap();
693        assert!(err.len() <= MAX_TOOL_RESULT_BYTES);
694        assert!(err.ends_with(TRUNCATION_SUFFIX));
695    }
696
697    #[tokio::test]
698    async fn test_output_hard_limit_non_string_json() {
699        let hook = OutputHardLimitHook;
700        let tc = make_tool_call();
701        let td = make_tool_def();
702        let ctx = ToolContext::new(SessionId::new());
703        // Small JSON object — should pass through
704        let mut result = ToolResult {
705            tool_call_id: "call_test".into(),
706            result: Some(json!({"key": "value", "num": 42})),
707            images: None,
708            error: None,
709            connection_required: None,
710            raw_output: None,
711        };
712
713        hook.after_exec(&tc, &td, &mut result, &ctx).await;
714
715        // Should remain as-is (small non-string JSON)
716        assert_eq!(result.result, Some(json!({"key": "value", "num": 42})));
717    }
718
719    #[tokio::test]
720    async fn test_output_hard_limit_drops_oversized_images() {
721        let hook = OutputHardLimitHook;
722        let tc = make_tool_call();
723        let td = make_tool_def();
724        let ctx = ToolContext::new(SessionId::new());
725
726        let mut result = ToolResult {
727            tool_call_id: "call_test".into(),
728            result: Some(json!({"ok": true})),
729            images: Some(vec![
730                everruns_provider::ToolResultImage {
731                    base64: "a".repeat(32),
732                    media_type: "image/png".to_string(),
733                },
734                everruns_provider::ToolResultImage {
735                    base64: "b".repeat(MAX_TOOL_RESULT_BYTES + 1),
736                    media_type: "image/png".to_string(),
737                },
738            ]),
739            error: None,
740            connection_required: None,
741            raw_output: None,
742        };
743
744        hook.after_exec(&tc, &td, &mut result, &ctx).await;
745
746        let images = result.images.unwrap();
747        assert_eq!(images.len(), 1);
748        assert_eq!(images[0].base64.len(), 32);
749    }
750
751    #[tokio::test]
752    async fn test_output_hard_limit_enforces_cumulative_image_budget() {
753        let hook = OutputHardLimitHook;
754        let tc = make_tool_call();
755        let td = make_tool_def();
756        let ctx = ToolContext::new(SessionId::new());
757
758        // Each image is half the limit, so the third one tips the cumulative
759        // budget past MAX_TOOL_RESULT_BYTES and must be dropped.
760        let half = MAX_TOOL_RESULT_BYTES / 2;
761        let mut result = ToolResult {
762            tool_call_id: "call_test".into(),
763            result: Some(json!({"ok": true})),
764            images: Some(vec![
765                everruns_provider::ToolResultImage {
766                    base64: "a".repeat(half),
767                    media_type: "image/png".to_string(),
768                },
769                everruns_provider::ToolResultImage {
770                    base64: "b".repeat(half),
771                    media_type: "image/png".to_string(),
772                },
773                everruns_provider::ToolResultImage {
774                    base64: "c".repeat(half),
775                    media_type: "image/png".to_string(),
776                },
777            ]),
778            error: None,
779            connection_required: None,
780            raw_output: None,
781        };
782
783        hook.after_exec(&tc, &td, &mut result, &ctx).await;
784
785        let images = result.images.unwrap();
786        assert_eq!(
787            images.len(),
788            2,
789            "third image should be dropped by cumulative budget"
790        );
791        assert!(images.iter().all(|i| i.base64.len() == half));
792    }
793
794    #[tokio::test]
795    async fn test_output_hard_limit_normalizes_empty_images_to_none() {
796        let hook = OutputHardLimitHook;
797        let tc = make_tool_call();
798        let td = make_tool_def();
799        let ctx = ToolContext::new(SessionId::new());
800
801        let mut result = ToolResult {
802            tool_call_id: "call_test".into(),
803            result: Some(json!({"ok": true})),
804            images: Some(vec![everruns_provider::ToolResultImage {
805                base64: "a".repeat(MAX_TOOL_RESULT_BYTES + 1),
806                media_type: "image/png".to_string(),
807            }]),
808            error: None,
809            connection_required: None,
810            raw_output: None,
811        };
812
813        hook.after_exec(&tc, &td, &mut result, &ctx).await;
814
815        assert!(
816            result.images.is_none(),
817            "images vec emptied by retain should normalize to None"
818        );
819    }
820
821    #[test]
822    fn test_truncate_helper_short() {
823        let s = "hello".to_string();
824        assert_eq!(OutputHardLimitHook::truncate(s.clone()), s);
825    }
826
827    #[test]
828    fn test_truncate_helper_over() {
829        let s = "a".repeat(MAX_TOOL_RESULT_BYTES + 100);
830        let t = OutputHardLimitHook::truncate(s);
831        assert!(t.len() <= MAX_TOOL_RESULT_BYTES);
832        assert!(t.ends_with(TRUNCATION_SUFFIX));
833    }
834}