Skip to main content

netget/llm/
event_handler_executor.rs

1//! Event handler executor - executes configured event handlers (script/static/llm)
2//!
3//! This module checks if an event has a configured handler and executes it accordingly.
4//! Supports three handler types:
5//! - Script: Execute inline script code
6//! - Static: Execute predefined actions
7//! - LLM: Delegate to LLM (fallback/default)
8
9use crate::llm::actions::executor::{execute_actions, ExecutionResult};
10use crate::llm::actions::protocol_trait::Server;
11use crate::scripting::EventHandlerType;
12use crate::state::app_state::AppState;
13use crate::state::ServerId;
14use anyhow::{Context as AnyhowContext, Result};
15use tracing::{debug, error, warn};
16
17/// Heading under which a `{"type":"llm","instruction":"…"}` handler's instruction is
18/// added to the event trigger message.
19///
20/// It is deliberately distinct from any wording the server-wide instruction uses, so a
21/// test (and a human reading `netget.log`) can tell the two apart.
22pub const HANDLER_INSTRUCTION_HEADER: &str = "Handler instruction for this event";
23
24/// Result from checking event handlers
25pub enum EventHandlerResult {
26    /// Handler executed successfully with result
27    Handled(ExecutionResult),
28    /// No handler configured or handler requested LLM fallback.
29    ///
30    /// `instruction` is `Some` only for an explicit `{"type":"llm","instruction":"…"}`
31    /// handler; the caller must add it to the prompt for this event. It used to be
32    /// dropped here behind a `// TODO`, which meant an MCP caller could configure a
33    /// per-event instruction, get no error, and silently receive the server-wide
34    /// instruction instead.
35    FallbackToLlm { instruction: Option<String> },
36}
37
38/// Check and execute event handler for the given event
39///
40/// # Arguments
41/// * `state` - Application state
42/// * `server_id` - Server ID for context
43/// * `connection_id` - Optional connection ID
44/// * `event_type_id` - Event type identifier (e.g., "tcp_data_received")
45/// * `event_description` - Human-readable event description
46/// * `event_data` - Structured event data for scripts
47/// * `protocol` - Optional protocol for action execution
48///
49/// # Returns
50/// * `Ok(EventHandlerResult::Handled(...))` - Handler executed successfully
51/// * `Ok(EventHandlerResult::FallbackToLlm { instruction: None })` - No handler or fallback requested
52/// * `Err(_)` - Handler execution failed critically
53pub async fn try_execute_event_handler(
54    state: &AppState,
55    server_id: ServerId,
56    connection_id: Option<crate::server::connection::ConnectionId>,
57    event_type_id: &str,
58    event_description: &str,
59    event_data: Option<serde_json::Value>,
60    protocol: Option<&dyn Server>,
61) -> Result<EventHandlerResult> {
62    if event_data.as_ref().is_some_and(|value| {
63        !crate::utils::json_budget::within_budget(
64            value,
65            crate::scripting::event_handler::MAX_INTERPOLATION_BYTES,
66            crate::scripting::event_handler::MAX_INTERPOLATION_NODES,
67            crate::scripting::event_handler::MAX_INTERPOLATION_DEPTH,
68        )
69    }) {
70        if let Some(value) = event_data {
71            crate::utils::json_budget::drop_iteratively(value);
72        }
73        anyhow::bail!("event exceeds the shared JSON budget");
74    }
75    let private = event_data
76        .as_ref()
77        .is_some_and(crate::utils::redact::contains_credentials);
78    let run = try_execute_event_handler_inner(
79        state,
80        server_id,
81        connection_id,
82        event_type_id,
83        event_description,
84        event_data,
85        protocol,
86        private,
87    );
88    if private {
89        use tracing::instrument::WithSubscriber;
90        run.with_subscriber(tracing::subscriber::NoSubscriber::default())
91            .await
92            .map_err(|_| anyhow::anyhow!("credential-bearing handler failed; diagnostics hidden"))
93    } else {
94        run.await
95    }
96}
97
98#[allow(clippy::too_many_arguments)]
99async fn try_execute_event_handler_inner(
100    state: &AppState,
101    server_id: ServerId,
102    connection_id: Option<crate::server::connection::ConnectionId>,
103    event_type_id: &str,
104    event_description: &str,
105    event_data: Option<serde_json::Value>,
106    protocol: Option<&dyn Server>,
107    private_payloads: bool,
108) -> Result<EventHandlerResult> {
109    // Get event handler configuration
110    let event_handler_config = state.get_event_handler_config(server_id).await;
111
112    let Some(config) = event_handler_config else {
113        // No event handler configuration - use LLM
114        return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
115    };
116
117    // Find matching handler for this event type
118    let Some(handler_type) = config.find_handler(event_type_id) else {
119        // No matching handler - use LLM
120        debug!("No handler matches event '{}', using LLM", event_type_id);
121        return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
122    };
123
124    match handler_type {
125        EventHandlerType::Llm { instruction } => {
126            // LLM handler explicitly configured with instruction: hand it back to the
127            // caller, which adds it to this event's prompt.
128            debug!(
129                "LLM handler configured for event '{}' with instruction: {}",
130                event_type_id, instruction
131            );
132            Ok(EventHandlerResult::FallbackToLlm {
133                instruction: Some(instruction.to_string()),
134            })
135        }
136
137        EventHandlerType::Script {
138            language,
139            code,
140            resident,
141            scope,
142        } => {
143            // Execute script handler
144            execute_script_handler(
145                state,
146                server_id,
147                connection_id,
148                event_type_id,
149                event_description,
150                event_data,
151                language,
152                code,
153                *resident,
154                scope.as_deref(),
155                protocol,
156                private_payloads,
157            )
158            .await
159        }
160
161        EventHandlerType::Static { actions } => {
162            // Execute static handler (may interpolate {{event.field}} from event_data)
163            execute_static_handler(
164                state,
165                server_id,
166                event_type_id,
167                event_description,
168                actions,
169                event_data.as_ref(),
170                protocol,
171            )
172            .await
173        }
174
175        EventHandlerType::Manual { timeout_secs } => {
176            let actions = await_manual_answer(
177                state,
178                crate::state::intercepts::InterceptOwner::Server(server_id),
179                connection_id.map(|c| c.as_u32()),
180                event_type_id,
181                event_description,
182                event_data.clone(),
183                *timeout_secs,
184            )
185            .await?;
186            // The operator's actions run exactly as a static handler's would —
187            // same interpolation, same executor — so "manual" is a static
188            // response composed at answer time rather than configured up front.
189            execute_static_handler(
190                state,
191                server_id,
192                event_type_id,
193                event_description,
194                &actions,
195                event_data.as_ref(),
196                protocol,
197            )
198            .await
199        }
200    }
201}
202
203/// Park an event for the operator and wait for their actions.
204///
205/// Shared by the server and client dispatchers. On timeout, dismissal, or a
206/// dead channel this returns `Err`, which the caller propagates — landing on
207/// the same fail-closed path as an LLM failure, so the peer gets a category
208/// error (`crate::utils::wire_failure`), never silence and never an invented
209/// success.
210async fn await_manual_answer(
211    state: &AppState,
212    owner: crate::state::intercepts::InterceptOwner,
213    connection_id: Option<u32>,
214    event_type_id: &str,
215    event_description: &str,
216    event_data: Option<serde_json::Value>,
217    timeout_secs: u64,
218) -> Result<Vec<serde_json::Value>> {
219    let (id, reply_rx) = state
220        .park_intercept(
221            owner,
222            connection_id,
223            event_type_id,
224            event_description,
225            event_data,
226            timeout_secs,
227        )
228        .await;
229    tracing::info!(
230        "manual handler: event '{}' parked as intercept #{} — waiting up to {}s for an \
231         operator answer at the dashboard",
232        event_type_id,
233        id,
234        timeout_secs
235    );
236
237    match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), reply_rx).await {
238        Ok(Ok(actions)) => {
239            debug!(
240                "manual handler: intercept #{} answered with {} action(s)",
241                id,
242                actions.len()
243            );
244            Ok(actions)
245        }
246        Ok(Err(_)) => {
247            // Sender dropped: the operator dismissed it (fail closed, now).
248            anyhow::bail!(
249                "manual handler: the operator dismissed request #{id} for event '{event_type_id}'"
250            )
251        }
252        Err(_) => {
253            state.remove_intercept(id).await;
254            warn!(
255                "manual handler: intercept #{} for event '{}' got no operator answer within \
256                 {}s — failing closed",
257                id, event_type_id, timeout_secs
258            );
259            anyhow::bail!(
260                "manual handler: no operator answer within {timeout_secs}s for event \
261                 '{event_type_id}'"
262            )
263        }
264    }
265}
266
267/// Result from checking a **client's** event handlers.
268///
269/// Unlike the server variant, `Handled` carries the raw action JSON rather than
270/// an `ExecutionResult`: for servers, handler actions are executed centrally
271/// via `execute_actions` + the `Server` trait, but a client's protocol actions
272/// can only be executed by its own connection loop, which owns the socket. The
273/// caller (`client::llm_budget::call_llm_for_client`) returns these actions to
274/// the loop exactly as it returns LLM-produced ones, so the loop cannot tell —
275/// and need not care — which source answered.
276pub enum ClientEventHandlerResult {
277    /// Handler produced actions for the client's loop to execute.
278    Handled { actions: Vec<serde_json::Value> },
279    /// No handler configured or handler requested LLM fallback. `instruction`
280    /// is `Some` only for an explicit `{"type":"llm","instruction":"…"}`
281    /// handler; the caller must add it to the prompt for this event.
282    FallbackToLlm { instruction: Option<String> },
283}
284
285/// Check and execute the configured event handler for a **client** event.
286///
287/// The client mirror of [`try_execute_event_handler`]. Reads the client's own
288/// `event_handler_config` (stored since forever, dispatched from nowhere until
289/// now), matches first-match-wins, and:
290/// - `Static` — interpolates `{{event.field}}` references and returns the
291///   rendered actions. An unresolvable reference is a hard `Err`, same as the
292///   server path.
293/// - `Script` — runs the script with a client-shaped `ScriptInput` (`client`
294///   set, `server` absent) and returns its actions. A script failure falls
295///   back to the LLM, same as the server path.
296/// - `Llm { instruction }` — falls back with the per-event instruction.
297pub async fn try_execute_client_event_handler(
298    state: &AppState,
299    client_id: crate::state::ClientId,
300    event_type_id: &str,
301    event_description: &str,
302    event_data: Option<serde_json::Value>,
303) -> Result<ClientEventHandlerResult> {
304    try_execute_client_event_handler_with_privacy(
305        state,
306        client_id,
307        event_type_id,
308        event_description,
309        event_data,
310        false,
311    )
312    .await
313}
314
315/// The shared client dispatcher with a request-local credential logging policy.
316pub async fn try_execute_client_event_handler_with_privacy(
317    state: &AppState,
318    client_id: crate::state::ClientId,
319    event_type_id: &str,
320    event_description: &str,
321    event_data: Option<serde_json::Value>,
322    private_payloads: bool,
323) -> Result<ClientEventHandlerResult> {
324    if event_data.as_ref().is_some_and(|value| {
325        !crate::utils::json_budget::within_budget(
326            value,
327            crate::scripting::event_handler::MAX_INTERPOLATION_BYTES,
328            crate::scripting::event_handler::MAX_INTERPOLATION_NODES,
329            crate::scripting::event_handler::MAX_INTERPOLATION_DEPTH,
330        )
331    }) {
332        if let Some(value) = event_data {
333            crate::utils::json_budget::drop_iteratively(value);
334        }
335        anyhow::bail!("event exceeds the shared JSON budget");
336    }
337    let private_payloads = private_payloads
338        || event_data
339            .as_ref()
340            .is_some_and(crate::utils::redact::contains_credentials);
341    let run = try_execute_client_event_handler_inner(
342        state,
343        client_id,
344        event_type_id,
345        event_description,
346        event_data,
347        private_payloads,
348    );
349    if private_payloads {
350        use tracing::instrument::WithSubscriber;
351        run.with_subscriber(tracing::subscriber::NoSubscriber::default())
352            .await
353            .map_err(crate::utils::redact::hide_error_details)
354    } else {
355        run.await
356    }
357}
358
359async fn try_execute_client_event_handler_inner(
360    state: &AppState,
361    client_id: crate::state::ClientId,
362    event_type_id: &str,
363    event_description: &str,
364    event_data: Option<serde_json::Value>,
365    private_payloads: bool,
366) -> Result<ClientEventHandlerResult> {
367    let Some(config) = state.get_client_event_handler_config(client_id).await else {
368        return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
369    };
370
371    let Some(handler_type) = config.find_handler(event_type_id) else {
372        debug!(
373            "No client handler matches event '{}', using LLM",
374            event_type_id
375        );
376        return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
377    };
378
379    match handler_type {
380        EventHandlerType::Llm { instruction } => Ok(ClientEventHandlerResult::FallbackToLlm {
381            instruction: Some(instruction.to_string()),
382        }),
383
384        EventHandlerType::Static { actions } => {
385            debug!(
386                "Static client handler executing for event '{}' ({} actions)",
387                event_type_id,
388                actions.len()
389            );
390            let actions =
391                crate::scripting::event_handler::interpolate_actions(actions, event_data.as_ref())
392                    .with_context(|| {
393                        format!(
394                            "Static handler for client event '{}' could not be rendered",
395                            event_type_id
396                        )
397                    })?;
398            Ok(ClientEventHandlerResult::Handled { actions })
399        }
400
401        EventHandlerType::Script {
402            language,
403            code,
404            resident,
405            scope,
406        } => {
407            execute_client_script_handler(
408                state,
409                client_id,
410                event_type_id,
411                event_description,
412                event_data,
413                language,
414                code,
415                *resident,
416                scope.as_deref(),
417                private_payloads,
418            )
419            .await
420        }
421
422        EventHandlerType::Manual { timeout_secs } => {
423            let actions = await_manual_answer(
424                state,
425                crate::state::intercepts::InterceptOwner::Client(client_id),
426                None,
427                event_type_id,
428                event_description,
429                event_data.clone(),
430                *timeout_secs,
431            )
432            .await?;
433            // Interpolate exactly as a static handler would, then hand the
434            // actions to the client's own loop — only it owns the socket.
435            let actions =
436                crate::scripting::event_handler::interpolate_actions(&actions, event_data.as_ref())
437                    .with_context(|| {
438                        format!(
439                            "Manual answer for client event '{}' could not be rendered",
440                            event_type_id
441                        )
442                    })?;
443            Ok(ClientEventHandlerResult::Handled { actions })
444        }
445    }
446}
447
448/// Execute a script handler in client context, returning its actions for the
449/// client's loop.
450#[allow(clippy::too_many_arguments)]
451async fn execute_client_script_handler(
452    state: &AppState,
453    client_id: crate::state::ClientId,
454    event_type_id: &str,
455    event_description: &str,
456    event_data: Option<serde_json::Value>,
457    language: &str,
458    code: &str,
459    resident: bool,
460    scope: Option<&str>,
461    private_payloads: bool,
462) -> Result<ClientEventHandlerResult> {
463    let Some(client) = state.get_client(client_id).await else {
464        warn!(
465            "Client #{} not found for script execution",
466            client_id.as_u32()
467        );
468        return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
469    };
470
471    let event_json =
472        event_data.unwrap_or_else(|| serde_json::json!({"description": event_description}));
473
474    let script_input = crate::scripting::types::ScriptInput {
475        event_type_id: event_type_id.to_string(),
476        server: None,
477        client: Some(crate::scripting::types::ClientContext {
478            id: client.id.as_u32(),
479            remote_addr: client.remote_addr.clone(),
480            protocol: client.protocol_name.clone(),
481            memory: client.memory.clone(),
482            instruction: client.instruction.clone(),
483        }),
484        connection: None,
485        event: event_json,
486    };
487
488    let script_language = match language.to_lowercase().as_str() {
489        "python" => crate::scripting::ScriptLanguage::Python,
490        "javascript" | "js" => crate::scripting::ScriptLanguage::JavaScript,
491        "go" => crate::scripting::ScriptLanguage::Go,
492        "perl" => crate::scripting::ScriptLanguage::Perl,
493        _ => {
494            warn!(
495                "Unknown script language '{}', falling back to LLM",
496                language
497            );
498            return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
499        }
500    };
501
502    let scripting_env = state.get_scripting_env().await;
503    if !scripting_env.is_available(script_language) {
504        warn!(
505            "Script language {} not available, falling back to LLM",
506            script_language.as_str()
507        );
508        return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
509    }
510
511    let script_config = crate::scripting::types::ScriptConfig {
512        language: script_language,
513        source: crate::scripting::types::ScriptSource::Inline(code.to_string()),
514        handles_contexts: vec![event_type_id.to_string()],
515    };
516
517    let use_resident =
518        resident && crate::scripting::resident::resident_language_supported(script_language);
519    if resident && !use_resident {
520        warn!(
521            "Resident mode requested for '{}' but that language runs per-event only; \
522             falling back to per-event execution",
523            script_language.as_str()
524        );
525    }
526
527    let script_result = if private_payloads {
528        use tracing::instrument::WithSubscriber;
529        let run = async {
530            if use_resident {
531                crate::scripting::ResidentScriptManager::dispatch_private(
532                    &script_config,
533                    &script_input,
534                    crate::scripting::ResidentScope::parse(scope),
535                )
536                .await
537            } else {
538                crate::scripting::executor::execute_script_async(&script_config, &script_input)
539                    .await
540            }
541        };
542        run.with_subscriber(tracing::subscriber::NoSubscriber::default())
543            .await
544            .map_err(|_| anyhow::anyhow!("credential-bearing script failed; diagnostics hidden"))
545    } else if use_resident {
546        crate::scripting::ResidentScriptManager::dispatch(
547            &script_config,
548            &script_input,
549            crate::scripting::ResidentScope::parse(scope),
550        )
551        .await
552    } else {
553        crate::scripting::executor::execute_script_async(&script_config, &script_input).await
554    };
555
556    match script_result {
557        Ok(response) => {
558            debug!(
559                "Script handled client event '{}' ({} actions)",
560                event_type_id,
561                response.actions.len()
562            );
563            Ok(ClientEventHandlerResult::Handled {
564                actions: response.actions,
565            })
566        }
567        Err(e) => {
568            warn!("Client script execution failed: {}, falling back to LLM", e);
569            Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None })
570        }
571    }
572}
573
574/// Execute a script handler
575#[allow(clippy::too_many_arguments)]
576async fn execute_script_handler(
577    state: &AppState,
578    server_id: ServerId,
579    connection_id: Option<crate::server::connection::ConnectionId>,
580    event_type_id: &str,
581    event_description: &str,
582    event_data: Option<serde_json::Value>,
583    language: &str,
584    code: &str,
585    resident: bool,
586    scope: Option<&str>,
587    protocol: Option<&dyn Server>,
588    private_payloads: bool,
589) -> Result<EventHandlerResult> {
590    // Get server info to build script input
591    let server_info = state.get_server(server_id).await;
592
593    let Some(server) = server_info else {
594        warn!(
595            "Server #{} not found for script execution",
596            server_id.as_u32()
597        );
598        return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
599    };
600
601    // Build connection context if available
602    let connection_context = if let Some(conn_id) = connection_id {
603        server.connections.get(&conn_id).map(|conn_state| {
604            crate::scripting::types::ConnectionContext {
605                id: conn_id.to_string(),
606                remote_addr: conn_state.remote_addr.to_string(),
607                bytes_received: conn_state.bytes_received,
608                bytes_sent: conn_state.bytes_sent,
609            }
610        })
611    } else {
612        None
613    };
614
615    // Build structured input for script
616    let event_json =
617        event_data.unwrap_or_else(|| serde_json::json!({"description": event_description}));
618
619    let script_input = crate::scripting::types::ScriptInput {
620        event_type_id: event_type_id.to_string(),
621        client: None,
622        server: Some(crate::scripting::types::ServerContext {
623            id: server.id.as_u32(),
624            port: server.port,
625            stack: crate::protocol::server_registry::registry()
626                .stack_name_by_protocol(&server.protocol_name)
627                .unwrap_or("UNKNOWN")
628                .to_string(),
629            memory: server.memory.clone(),
630            instruction: server.instruction.clone(),
631        }),
632        connection: connection_context,
633        event: event_json,
634    };
635
636    // Parse language
637    let script_language = match language.to_lowercase().as_str() {
638        "python" => crate::scripting::ScriptLanguage::Python,
639        "javascript" | "js" => crate::scripting::ScriptLanguage::JavaScript,
640        "go" => crate::scripting::ScriptLanguage::Go,
641        "perl" => crate::scripting::ScriptLanguage::Perl,
642        _ => {
643            warn!(
644                "Unknown script language '{}', falling back to LLM",
645                language
646            );
647            return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
648        }
649    };
650
651    // Check if language is available
652    let scripting_env = state.get_scripting_env().await;
653    if !scripting_env.is_available(script_language) {
654        warn!(
655            "Script language {} not available, falling back to LLM",
656            script_language.as_str()
657        );
658        return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
659    }
660
661    // Build ScriptConfig for execution
662    let script_config = crate::scripting::types::ScriptConfig {
663        language: script_language,
664        source: crate::scripting::types::ScriptSource::Inline(code.to_string()),
665        handles_contexts: vec![event_type_id.to_string()],
666    };
667
668    // Choose resident (persistent) or per-event execution.
669    //
670    // Resident mode is opt-in and keeps one interpreter process alive per scope
671    // so in-process state survives between events. A resident handler for a
672    // language that has no persistent form (Go) transparently falls back to the
673    // per-event path so the request is still handled.
674    let use_resident =
675        resident && crate::scripting::resident::resident_language_supported(script_language);
676    if resident && !use_resident {
677        warn!(
678            "Resident mode requested for '{}' but that language runs per-event only; \
679             falling back to per-event execution",
680            script_language.as_str()
681        );
682    }
683
684    let script_result = if private_payloads {
685        use tracing::instrument::WithSubscriber;
686        let run = async {
687            if use_resident {
688                crate::scripting::ResidentScriptManager::dispatch_private(
689                    &script_config,
690                    &script_input,
691                    crate::scripting::ResidentScope::parse(scope),
692                )
693                .await
694            } else {
695                crate::scripting::executor::execute_script_async(&script_config, &script_input)
696                    .await
697            }
698        };
699        run.with_subscriber(tracing::subscriber::NoSubscriber::default())
700            .await
701            .map_err(|_| anyhow::anyhow!("credential-bearing script failed; diagnostics hidden"))
702    } else if use_resident {
703        let resident_scope = crate::scripting::ResidentScope::parse(scope);
704        crate::scripting::ResidentScriptManager::dispatch(
705            &script_config,
706            &script_input,
707            resident_scope,
708        )
709        .await
710    } else {
711        crate::scripting::executor::execute_script_async(&script_config, &script_input).await
712    };
713
714    // Execute the script
715    match script_result {
716        Ok(response) => {
717            debug!(
718                "Script handled event '{}' ({} actions)",
719                event_type_id,
720                response.actions.len()
721            );
722
723            // Register SCRIPT conversation for tracking
724            let truncated_desc = if private_payloads {
725                "SCRIPT credential-bearing event".to_string()
726            } else {
727                format!(
728                    "SCRIPT \"{}\"",
729                    crate::utils::truncate_for_log(event_description, 27)
730                )
731            };
732            let conv_id = format!(
733                "script-{}-{:x}",
734                crate::utils::clock::SystemTime::now()
735                    .duration_since(crate::utils::clock::UNIX_EPOCH)
736                    .unwrap()
737                    .as_millis(),
738                rand::random::<u32>()
739            );
740            state
741                .register_conversation(
742                    conv_id.clone(),
743                    crate::state::app_state::ConversationSource::Network {
744                        server_id,
745                        connection_id,
746                    },
747                    truncated_desc,
748                )
749                .await;
750
751            // Execute the script's actions with server context
752            let result = execute_actions(response.actions, state, protocol, Some(server_id), None)
753                .await
754                .context("Failed to execute script actions")?;
755
756            // End conversation tracking
757            state.end_conversation(&conv_id).await;
758
759            Ok(EventHandlerResult::Handled(result))
760        }
761        Err(e) => {
762            warn!("Script execution failed: {}, falling back to LLM", e);
763            Ok(EventHandlerResult::FallbackToLlm { instruction: None })
764        }
765    }
766}
767
768/// Execute a static handler
769///
770/// Before dispatch, every `{{event.field}}` reference in the configured actions is
771/// replaced with the matching value from `event_data`. A whole-string reference keeps the
772/// referenced value's JSON type, which is what makes static mode usable for protocols that
773/// must echo a correlation id (DNS `query_id`, DHCP `xid`, SNMP `request-id`, STUN
774/// transaction id). See `crate::scripting::event_handler` for the full syntax.
775///
776/// Actions with no references are passed through untouched. An unresolvable reference is a
777/// hard error rather than a silent `null`, so a typo'd field name cannot look like it works.
778#[allow(clippy::too_many_arguments)]
779async fn execute_static_handler(
780    state: &AppState,
781    server_id: ServerId,
782    event_type_id: &str,
783    event_description: &str,
784    actions: &[serde_json::Value],
785    event_data: Option<&serde_json::Value>,
786    protocol: Option<&dyn Server>,
787) -> Result<EventHandlerResult> {
788    debug!(
789        "Static handler executing for event '{}' ({} actions)",
790        event_type_id,
791        actions.len()
792    );
793
794    // Substitute {{event.…}} references from the triggering event
795    let actions = match crate::scripting::event_handler::interpolate_actions(actions, event_data) {
796        Ok(actions) => actions,
797        Err(e) => {
798            error!(
799                "Static handler for event '{}' has an unresolvable reference: {}",
800                event_type_id, e
801            );
802            return Err(e).with_context(|| {
803                format!(
804                    "Static handler for event '{}' could not be rendered",
805                    event_type_id
806                )
807            });
808        }
809    };
810
811    // Execute the static actions with server context
812    let result = execute_actions(actions, state, protocol, Some(server_id), None)
813        .await
814        .context("Failed to execute static actions")?;
815
816    // Log as STATIC interaction (no conversation tracking needed for static responses)
817    debug!("Static handler completed: {}", event_description);
818
819    Ok(EventHandlerResult::Handled(result))
820}