Skip to main content

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