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