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