use crate::llm::actions::executor::{execute_actions, ExecutionResult};
use crate::llm::actions::protocol_trait::Server;
use crate::scripting::EventHandlerType;
use crate::state::app_state::AppState;
use crate::state::ServerId;
use anyhow::{Context as AnyhowContext, Result};
use tracing::{debug, error, warn};
pub const HANDLER_INSTRUCTION_HEADER: &str = "Handler instruction for this event";
pub enum EventHandlerResult {
Handled(ExecutionResult),
FallbackToLlm { instruction: Option<String> },
}
pub async fn try_execute_event_handler(
state: &AppState,
server_id: ServerId,
connection_id: Option<crate::server::connection::ConnectionId>,
event_type_id: &str,
event_description: &str,
event_data: Option<serde_json::Value>,
protocol: Option<&dyn Server>,
) -> Result<EventHandlerResult> {
if event_data.as_ref().is_some_and(|value| {
!crate::utils::json_budget::within_budget(
value,
crate::scripting::event_handler::MAX_INTERPOLATION_BYTES,
crate::scripting::event_handler::MAX_INTERPOLATION_NODES,
crate::scripting::event_handler::MAX_INTERPOLATION_DEPTH,
)
}) {
if let Some(value) = event_data {
crate::utils::json_budget::drop_iteratively(value);
}
anyhow::bail!("event exceeds the shared JSON budget");
}
let private = event_data
.as_ref()
.is_some_and(crate::utils::redact::contains_credentials);
let run = try_execute_event_handler_inner(
state,
server_id,
connection_id,
event_type_id,
event_description,
event_data,
protocol,
private,
);
if private {
use tracing::instrument::WithSubscriber;
run.with_subscriber(tracing::subscriber::NoSubscriber::default())
.await
.map_err(|_| anyhow::anyhow!("credential-bearing handler failed; diagnostics hidden"))
} else {
run.await
}
}
#[allow(clippy::too_many_arguments)]
async fn try_execute_event_handler_inner(
state: &AppState,
server_id: ServerId,
connection_id: Option<crate::server::connection::ConnectionId>,
event_type_id: &str,
event_description: &str,
event_data: Option<serde_json::Value>,
protocol: Option<&dyn Server>,
private_payloads: bool,
) -> Result<EventHandlerResult> {
let event_handler_config = state.get_event_handler_config(server_id).await;
let Some(config) = event_handler_config else {
return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
};
let Some(handler_type) = config.find_handler(event_type_id) else {
debug!("No handler matches event '{}', using LLM", event_type_id);
return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
};
match handler_type {
EventHandlerType::Llm { instruction } => {
debug!(
"LLM handler configured for event '{}' with instruction: {}",
event_type_id, instruction
);
Ok(EventHandlerResult::FallbackToLlm {
instruction: Some(instruction.to_string()),
})
}
EventHandlerType::Script {
language,
code,
resident,
scope,
} => {
execute_script_handler(
state,
server_id,
connection_id,
event_type_id,
event_description,
event_data,
language,
code,
*resident,
scope.as_deref(),
protocol,
private_payloads,
)
.await
}
EventHandlerType::Static { actions } => {
execute_static_handler(
state,
server_id,
event_type_id,
event_description,
actions,
event_data.as_ref(),
protocol,
)
.await
}
EventHandlerType::Manual { timeout_secs } => {
let actions = await_manual_answer(
state,
crate::state::intercepts::InterceptOwner::Server(server_id),
connection_id.map(|c| c.as_u32()),
event_type_id,
event_description,
event_data.clone(),
*timeout_secs,
)
.await?;
execute_static_handler(
state,
server_id,
event_type_id,
event_description,
&actions,
event_data.as_ref(),
protocol,
)
.await
}
}
}
async fn await_manual_answer(
state: &AppState,
owner: crate::state::intercepts::InterceptOwner,
connection_id: Option<u32>,
event_type_id: &str,
event_description: &str,
event_data: Option<serde_json::Value>,
timeout_secs: u64,
) -> Result<Vec<serde_json::Value>> {
let (id, reply_rx) = state
.park_intercept(
owner,
connection_id,
event_type_id,
event_description,
event_data,
timeout_secs,
)
.await;
tracing::info!(
"manual handler: event '{}' parked as intercept #{} — waiting up to {}s for an \
operator answer at the dashboard",
event_type_id,
id,
timeout_secs
);
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), reply_rx).await {
Ok(Ok(actions)) => {
debug!(
"manual handler: intercept #{} answered with {} action(s)",
id,
actions.len()
);
Ok(actions)
}
Ok(Err(_)) => {
anyhow::bail!(
"manual handler: the operator dismissed request #{id} for event '{event_type_id}'"
)
}
Err(_) => {
state.remove_intercept(id).await;
warn!(
"manual handler: intercept #{} for event '{}' got no operator answer within \
{}s — failing closed",
id, event_type_id, timeout_secs
);
anyhow::bail!(
"manual handler: no operator answer within {timeout_secs}s for event \
'{event_type_id}'"
)
}
}
}
pub enum ClientEventHandlerResult {
Handled { actions: Vec<serde_json::Value> },
FallbackToLlm { instruction: Option<String> },
}
pub async fn try_execute_client_event_handler(
state: &AppState,
client_id: crate::state::ClientId,
event_type_id: &str,
event_description: &str,
event_data: Option<serde_json::Value>,
) -> Result<ClientEventHandlerResult> {
try_execute_client_event_handler_with_privacy(
state,
client_id,
event_type_id,
event_description,
event_data,
false,
)
.await
}
pub async fn try_execute_client_event_handler_with_privacy(
state: &AppState,
client_id: crate::state::ClientId,
event_type_id: &str,
event_description: &str,
event_data: Option<serde_json::Value>,
private_payloads: bool,
) -> Result<ClientEventHandlerResult> {
if event_data.as_ref().is_some_and(|value| {
!crate::utils::json_budget::within_budget(
value,
crate::scripting::event_handler::MAX_INTERPOLATION_BYTES,
crate::scripting::event_handler::MAX_INTERPOLATION_NODES,
crate::scripting::event_handler::MAX_INTERPOLATION_DEPTH,
)
}) {
if let Some(value) = event_data {
crate::utils::json_budget::drop_iteratively(value);
}
anyhow::bail!("event exceeds the shared JSON budget");
}
let private_payloads = private_payloads
|| event_data
.as_ref()
.is_some_and(crate::utils::redact::contains_credentials);
let run = try_execute_client_event_handler_inner(
state,
client_id,
event_type_id,
event_description,
event_data,
private_payloads,
);
if private_payloads {
use tracing::instrument::WithSubscriber;
run.with_subscriber(tracing::subscriber::NoSubscriber::default())
.await
.map_err(crate::utils::redact::hide_error_details)
} else {
run.await
}
}
async fn try_execute_client_event_handler_inner(
state: &AppState,
client_id: crate::state::ClientId,
event_type_id: &str,
event_description: &str,
event_data: Option<serde_json::Value>,
private_payloads: bool,
) -> Result<ClientEventHandlerResult> {
let Some(config) = state.get_client_event_handler_config(client_id).await else {
return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
};
let Some(handler_type) = config.find_handler(event_type_id) else {
debug!(
"No client handler matches event '{}', using LLM",
event_type_id
);
return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
};
match handler_type {
EventHandlerType::Llm { instruction } => Ok(ClientEventHandlerResult::FallbackToLlm {
instruction: Some(instruction.to_string()),
}),
EventHandlerType::Static { actions } => {
debug!(
"Static client handler executing for event '{}' ({} actions)",
event_type_id,
actions.len()
);
let actions =
crate::scripting::event_handler::interpolate_actions(actions, event_data.as_ref())
.with_context(|| {
format!(
"Static handler for client event '{}' could not be rendered",
event_type_id
)
})?;
Ok(ClientEventHandlerResult::Handled { actions })
}
EventHandlerType::Script {
language,
code,
resident,
scope,
} => {
execute_client_script_handler(
state,
client_id,
event_type_id,
event_description,
event_data,
language,
code,
*resident,
scope.as_deref(),
private_payloads,
)
.await
}
EventHandlerType::Manual { timeout_secs } => {
let actions = await_manual_answer(
state,
crate::state::intercepts::InterceptOwner::Client(client_id),
None,
event_type_id,
event_description,
event_data.clone(),
*timeout_secs,
)
.await?;
let actions =
crate::scripting::event_handler::interpolate_actions(&actions, event_data.as_ref())
.with_context(|| {
format!(
"Manual answer for client event '{}' could not be rendered",
event_type_id
)
})?;
Ok(ClientEventHandlerResult::Handled { actions })
}
}
}
#[allow(clippy::too_many_arguments)]
async fn execute_client_script_handler(
state: &AppState,
client_id: crate::state::ClientId,
event_type_id: &str,
event_description: &str,
event_data: Option<serde_json::Value>,
language: &str,
code: &str,
resident: bool,
scope: Option<&str>,
private_payloads: bool,
) -> Result<ClientEventHandlerResult> {
let Some(client) = state.get_client(client_id).await else {
warn!(
"Client #{} not found for script execution",
client_id.as_u32()
);
return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
};
let event_json =
event_data.unwrap_or_else(|| serde_json::json!({"description": event_description}));
let script_input = crate::scripting::types::ScriptInput {
event_type_id: event_type_id.to_string(),
server: None,
client: Some(crate::scripting::types::ClientContext {
id: client.id.as_u32(),
remote_addr: client.remote_addr.clone(),
protocol: client.protocol_name.clone(),
memory: client.memory.clone(),
instruction: client.instruction.clone(),
}),
connection: None,
event: event_json,
};
let script_language = match language.to_lowercase().as_str() {
"python" => crate::scripting::ScriptLanguage::Python,
"javascript" | "js" => crate::scripting::ScriptLanguage::JavaScript,
"go" => crate::scripting::ScriptLanguage::Go,
"perl" => crate::scripting::ScriptLanguage::Perl,
_ => {
warn!(
"Unknown script language '{}', falling back to LLM",
language
);
return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
}
};
let scripting_env = state.get_scripting_env().await;
if !scripting_env.is_available(script_language) {
warn!(
"Script language {} not available, falling back to LLM",
script_language.as_str()
);
return Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None });
}
let script_config = crate::scripting::types::ScriptConfig {
language: script_language,
source: crate::scripting::types::ScriptSource::Inline(code.to_string()),
handles_contexts: vec![event_type_id.to_string()],
};
let use_resident =
resident && crate::scripting::resident::resident_language_supported(script_language);
if resident && !use_resident {
warn!(
"Resident mode requested for '{}' but that language runs per-event only; \
falling back to per-event execution",
script_language.as_str()
);
}
let script_result = if private_payloads {
use tracing::instrument::WithSubscriber;
let run = async {
if use_resident {
crate::scripting::ResidentScriptManager::dispatch_private(
&script_config,
&script_input,
crate::scripting::ResidentScope::parse(scope),
)
.await
} else {
crate::scripting::executor::execute_script_async(&script_config, &script_input)
.await
}
};
run.with_subscriber(tracing::subscriber::NoSubscriber::default())
.await
.map_err(|_| anyhow::anyhow!("credential-bearing script failed; diagnostics hidden"))
} else if use_resident {
crate::scripting::ResidentScriptManager::dispatch(
&script_config,
&script_input,
crate::scripting::ResidentScope::parse(scope),
)
.await
} else {
crate::scripting::executor::execute_script_async(&script_config, &script_input).await
};
match script_result {
Ok(response) => {
debug!(
"Script handled client event '{}' ({} actions)",
event_type_id,
response.actions.len()
);
Ok(ClientEventHandlerResult::Handled {
actions: response.actions,
})
}
Err(e) => {
warn!("Client script execution failed: {}, falling back to LLM", e);
Ok(ClientEventHandlerResult::FallbackToLlm { instruction: None })
}
}
}
#[allow(clippy::too_many_arguments)]
async fn execute_script_handler(
state: &AppState,
server_id: ServerId,
connection_id: Option<crate::server::connection::ConnectionId>,
event_type_id: &str,
event_description: &str,
event_data: Option<serde_json::Value>,
language: &str,
code: &str,
resident: bool,
scope: Option<&str>,
protocol: Option<&dyn Server>,
private_payloads: bool,
) -> Result<EventHandlerResult> {
let server_info = state.get_server(server_id).await;
let Some(server) = server_info else {
warn!(
"Server #{} not found for script execution",
server_id.as_u32()
);
return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
};
let connection_context = if let Some(conn_id) = connection_id {
server.connections.get(&conn_id).map(|conn_state| {
crate::scripting::types::ConnectionContext {
id: conn_id.to_string(),
remote_addr: conn_state.remote_addr.to_string(),
bytes_received: conn_state.bytes_received,
bytes_sent: conn_state.bytes_sent,
}
})
} else {
None
};
let event_json =
event_data.unwrap_or_else(|| serde_json::json!({"description": event_description}));
let script_input = crate::scripting::types::ScriptInput {
event_type_id: event_type_id.to_string(),
client: None,
server: Some(crate::scripting::types::ServerContext {
id: server.id.as_u32(),
port: server.port,
stack: crate::protocol::server_registry::registry()
.stack_name_by_protocol(&server.protocol_name)
.unwrap_or("UNKNOWN")
.to_string(),
memory: server.memory.clone(),
instruction: server.instruction.clone(),
}),
connection: connection_context,
event: event_json,
};
let script_language = match language.to_lowercase().as_str() {
"python" => crate::scripting::ScriptLanguage::Python,
"javascript" | "js" => crate::scripting::ScriptLanguage::JavaScript,
"go" => crate::scripting::ScriptLanguage::Go,
"perl" => crate::scripting::ScriptLanguage::Perl,
_ => {
warn!(
"Unknown script language '{}', falling back to LLM",
language
);
return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
}
};
let scripting_env = state.get_scripting_env().await;
if !scripting_env.is_available(script_language) {
warn!(
"Script language {} not available, falling back to LLM",
script_language.as_str()
);
return Ok(EventHandlerResult::FallbackToLlm { instruction: None });
}
let script_config = crate::scripting::types::ScriptConfig {
language: script_language,
source: crate::scripting::types::ScriptSource::Inline(code.to_string()),
handles_contexts: vec![event_type_id.to_string()],
};
let use_resident =
resident && crate::scripting::resident::resident_language_supported(script_language);
if resident && !use_resident {
warn!(
"Resident mode requested for '{}' but that language runs per-event only; \
falling back to per-event execution",
script_language.as_str()
);
}
let script_result = if private_payloads {
use tracing::instrument::WithSubscriber;
let run = async {
if use_resident {
crate::scripting::ResidentScriptManager::dispatch_private(
&script_config,
&script_input,
crate::scripting::ResidentScope::parse(scope),
)
.await
} else {
crate::scripting::executor::execute_script_async(&script_config, &script_input)
.await
}
};
run.with_subscriber(tracing::subscriber::NoSubscriber::default())
.await
.map_err(|_| anyhow::anyhow!("credential-bearing script failed; diagnostics hidden"))
} else if use_resident {
let resident_scope = crate::scripting::ResidentScope::parse(scope);
crate::scripting::ResidentScriptManager::dispatch(
&script_config,
&script_input,
resident_scope,
)
.await
} else {
crate::scripting::executor::execute_script_async(&script_config, &script_input).await
};
match script_result {
Ok(response) => {
debug!(
"Script handled event '{}' ({} actions)",
event_type_id,
response.actions.len()
);
let truncated_desc = if private_payloads {
"SCRIPT credential-bearing event".to_string()
} else {
format!(
"SCRIPT \"{}\"",
crate::utils::truncate_for_log(event_description, 27)
)
};
let conv_id = format!(
"script-{}-{:x}",
crate::utils::clock::SystemTime::now()
.duration_since(crate::utils::clock::UNIX_EPOCH)
.unwrap()
.as_millis(),
rand::random::<u32>()
);
state
.register_conversation(
conv_id.clone(),
crate::state::app_state::ConversationSource::Network {
server_id,
connection_id,
},
truncated_desc,
)
.await;
let result = execute_actions(response.actions, state, protocol, Some(server_id), None)
.await
.context("Failed to execute script actions")?;
state.end_conversation(&conv_id).await;
Ok(EventHandlerResult::Handled(result))
}
Err(e) => {
warn!("Script execution failed: {}, falling back to LLM", e);
Ok(EventHandlerResult::FallbackToLlm { instruction: None })
}
}
}
#[allow(clippy::too_many_arguments)]
async fn execute_static_handler(
state: &AppState,
server_id: ServerId,
event_type_id: &str,
event_description: &str,
actions: &[serde_json::Value],
event_data: Option<&serde_json::Value>,
protocol: Option<&dyn Server>,
) -> Result<EventHandlerResult> {
debug!(
"Static handler executing for event '{}' ({} actions)",
event_type_id,
actions.len()
);
let actions = match crate::scripting::event_handler::interpolate_actions(actions, event_data) {
Ok(actions) => actions,
Err(e) => {
error!(
"Static handler for event '{}' has an unresolvable reference: {}",
event_type_id, e
);
return Err(e).with_context(|| {
format!(
"Static handler for event '{}' could not be rendered",
event_type_id
)
});
}
};
let result = execute_actions(actions, state, protocol, Some(server_id), None)
.await
.context("Failed to execute static actions")?;
debug!("Static handler completed: {}", event_description);
Ok(EventHandlerResult::Handled(result))
}