Skip to main content

netget/protocol/
event_type.rs

1//! Event type definitions for protocol-specific events
2//!
3//! Each protocol defines a set of event types that can trigger LLM calls or script execution.
4//! Event types have unique IDs and associated actions that can be used to respond to the event.
5
6use crate::llm::actions::{ActionDefinition, Parameter};
7use crate::protocol::log_template::LogTemplate;
8use serde_json::Value as JsonValue;
9
10/// Represents a type of event that a protocol can emit
11///
12/// Events are the triggers for LLM calls or script execution.
13/// Each event has a unique ID and a list of actions that can be used to respond.
14#[derive(Clone, Debug)]
15pub struct EventType {
16    /// Unique identifier for this event type (e.g., "http_request", "ssh_auth")
17    pub id: String,
18
19    /// Human-readable description of when this event occurs
20    pub description: String,
21
22    /// Actions that can be used to respond to this event
23    /// These are protocol-specific sync actions
24    ///
25    /// This list — not [`Server::get_sync_actions`](crate::llm::actions::Server::get_sync_actions)
26    /// — is what `call_llm` advertises to the model, so an event that leaves it empty offers the
27    /// model nothing protocol-specific and every protocol action it returns is rejected as
28    /// unknown. Narrowing is deliberate and supported (SSH's `ssh_auth` accepts only
29    /// `ssh_auth_decision`), but *silently* empty is always a bug. If an event genuinely needs no
30    /// protocol action, say so with [`EventType::with_no_actions`] rather than leaving this empty.
31    pub actions: Vec<ActionDefinition>,
32
33    /// Set by [`EventType::with_no_actions`] to record that this event was *deliberately* left
34    /// without protocol actions, as opposed to having had `with_actions(...)` forgotten.
35    ///
36    /// `call_llm` treats an empty `actions` list as a bug unless this is set: see
37    /// [`EventType::has_no_usable_actions`].
38    no_actions_intentional: bool,
39
40    /// Parameters describing the expected structure of event data
41    /// This documents what fields should be present in the event data JSON
42    /// Uses the same Parameter structure as actions
43    pub parameters: Vec<Parameter>,
44
45    /// Required response example for this event
46    /// Used in prompt templates to show the expected action response
47    /// This MUST use protocol-specific action types (e.g., send_dns_a_response, not send_data)
48    ///
49    /// This field is required - every event type must have at least one valid response example.
50    /// This ensures prompts always show relevant, protocol-specific examples.
51    pub response_example: JsonValue,
52
53    /// Optional alternative response examples
54    /// Shows other valid ways to respond to this event
55    /// Displayed after the primary response_example
56    pub alternative_examples: Vec<JsonValue>,
57
58    /// Log template for this event type
59    /// Defines protocol-specific log formats at INFO/DEBUG/TRACE levels
60    pub log_template: Option<LogTemplate>,
61
62    /// Raised for every new connection before the peer has sent anything, and answered with
63    /// nothing whenever the instruction does not ask the server to speak first. Set with
64    /// [`EventType::raised_on_every_connection`].
65    ///
66    /// Read by the dashboard (`src/tui/modal/form.rs`): an interactively created server routes
67    /// these to a zero-action static rule ahead of its `*` → manual wildcard, so a human is
68    /// not asked "someone connected — say anything?" for every connection.
69    pub on_every_connection: bool,
70}
71
72/// The event data of a connect event (one declared
73/// [`EventType::raised_on_every_connection`]): how much the peer has sent, which is nothing,
74/// and which answer that calls for.
75///
76/// An empty object was not enough. With `{}` as the whole event, llama3.1:8b told to "echo back
77/// what the client sent" greeted every connection with "Hello, client! What's your request?",
78/// and told to "send back the client's text in upper case" sent `CLIENT TEXT IN UPPER CASE` -
79/// it read the connect event as a request and invented the request (`tcp/echo` 5/5 -> 1/5,
80/// `tcp/uppercase` 5/5 -> 0/5 when these events began firing on every connection). Two
81/// wordings of this data were measured before this one, and both are worth knowing:
82///
83/// * a `received: "nothing - the client has only connected"` field was echoed back verbatim -
84///   any string here is something an echo instruction will send;
85/// * "no actions unless the instruction explicitly says to greet" made the model answer "ask
86///   for a login name as soon as somebody connects" with `show_message`, which never reaches
87///   the client (`telnet/login-prompt` 5/5 -> 1/5). The hint has to say where a greeting goes.
88pub fn connect_event_data() -> JsonValue {
89    serde_json::json!({
90        "bytes_received": 0,
91        "answer_with": "If the instruction says to greet, show a banner, prompt or ask for \
92                        something as soon as someone connects, send that text to the client \
93                        now with this protocol's send action (show_message only reaches the \
94                        operator's screen, never the client). Otherwise answer with no actions \
95                        ({\"actions\": []}): the client has sent 0 bytes, so there is nothing \
96                        to echo, transform or answer yet.",
97    })
98}
99
100/// The parameters [`connect_event_data`] fills, for a connect event's declaration.
101pub fn connect_event_parameters() -> Vec<Parameter> {
102    vec![
103        Parameter {
104            name: "bytes_received".to_string(),
105            type_hint: "number".to_string(),
106            description: "How much the client has sent: always 0 on a connect event".to_string(),
107            required: true,
108        },
109        Parameter {
110            name: "answer_with".to_string(),
111            type_hint: "string".to_string(),
112            description: "Which answer a connect event calls for".to_string(),
113            required: true,
114        },
115    ]
116}
117
118impl EventType {
119    /// Create a new event type with a required response example
120    ///
121    /// # Arguments
122    /// * `id` - Unique identifier for this event type (e.g., "http_request", "dns_query")
123    /// * `description` - Human-readable description of when this event occurs
124    /// * `response_example` - Required example showing how to respond to this event
125    ///
126    /// The response_example MUST use protocol-specific action types (e.g., send_dns_a_response)
127    /// not generic actions like send_data. This ensures prompts show relevant examples.
128    ///
129    /// # Example
130    /// ```rust,ignore
131    /// EventType::new(
132    ///     "dns_query",
133    ///     "DNS query received from client",
134    ///     json!({"type": "send_dns_a_response", "query_id": 0, "domain": "example.com", "ip": "1.2.3.4"})
135    /// )
136    /// ```
137    pub fn new(
138        id: impl Into<String>,
139        description: impl Into<String>,
140        response_example: JsonValue,
141    ) -> Self {
142        Self {
143            id: id.into(),
144            description: description.into(),
145            actions: Vec::new(),
146            no_actions_intentional: false,
147            parameters: Vec::new(),
148            response_example,
149            alternative_examples: Vec::new(),
150            log_template: None,
151            on_every_connection: false,
152        }
153    }
154
155    /// Declare that this is a connect event: raised for every new connection before the peer
156    /// has sent anything, answered with no actions when there is nothing to say first. See
157    /// [`EventType::on_every_connection`].
158    pub fn raised_on_every_connection(mut self) -> Self {
159        self.on_every_connection = true;
160        self
161    }
162
163    /// Add an action to this event type
164    pub fn with_action(mut self, action: ActionDefinition) -> Self {
165        self.actions.push(action);
166        self
167    }
168
169    /// Add multiple actions to this event type
170    pub fn with_actions(mut self, actions: Vec<ActionDefinition>) -> Self {
171        self.actions.extend(actions);
172        self
173    }
174
175    /// Declare that this event deliberately offers the model no protocol-specific action.
176    ///
177    /// Use this for events that are purely informational (a connection closed, a peer went away)
178    /// where the only sensible responses are the common actions — `set_memory`, `show_message`,
179    /// `append_to_log`. It exists so that "this event needs no action" is written down and
180    /// distinguishable from "somebody forgot `with_actions(...)`", which is the failure mode that
181    /// silently disabled sixteen protocols. `call_llm` reports the latter and repairs it at
182    /// runtime; see [`EventType::has_no_usable_actions`].
183    pub fn with_no_actions(mut self) -> Self {
184        self.no_actions_intentional = true;
185        self
186    }
187
188    /// The response example to actually show a model, with placeholders repaired.
189    ///
190    /// `response_example` is rendered verbatim into the prompt (`src/llm/actions/tools.rs`) and
191    /// into the MCP protocol docs (`src/mcp_stdio/docs.rs`), so it teaches the model how to
192    /// answer this event. 215 of them across 92 files are the literal
193    /// `{"type": "placeholder", "event_id": "..."}`, which teaches an action type that does not
194    /// exist and that the executor rejects as unknown.
195    ///
196    /// Rather than leave those rendering as-is, derive a real one: the first action attached to
197    /// this event is by construction a valid answer to it, and its `example` is a complete,
198    /// protocol-specific action object. Events that legitimately have no action
199    /// ([`Self::with_no_actions`]) fall back to `show_message`, which is a common action and is
200    /// always accepted.
201    ///
202    /// A declared example that is not a placeholder is returned untouched, so this changes
203    /// nothing for the events that already carry a real one. `tests/placeholder_examples_test.rs`
204    /// asserts no registered protocol renders a placeholder through this path.
205    pub fn effective_response_example(&self) -> JsonValue {
206        if !Self::is_placeholder(&self.response_example) {
207            return self.response_example.clone();
208        }
209        if let Some(action) = self.actions.first() {
210            if !Self::is_placeholder(&action.example) {
211                return action.example.clone();
212            }
213        }
214        serde_json::json!({
215            "type": "show_message",
216            "message": format!("Handled {}", self.id),
217        })
218    }
219
220    /// Whether a response example is the `{"type": "placeholder", ...}` stand-in.
221    pub fn is_placeholder(example: &JsonValue) -> bool {
222        example.get("type").and_then(|t| t.as_str()) == Some("placeholder")
223    }
224
225    /// True when this event advertises no protocol action *and* never said it meant to.
226    ///
227    /// This is exactly the bug shape: the protocol declares sync actions, but the event type the
228    /// model is prompted with lists none of them, so the model's only vocabulary is the common
229    /// actions and every protocol action it produces is rejected as unknown.
230    pub fn has_no_usable_actions(&self) -> bool {
231        self.actions.is_empty() && !self.no_actions_intentional
232    }
233
234    /// Add a parameter describing expected event data field
235    pub fn with_parameter(mut self, parameter: Parameter) -> Self {
236        self.parameters.push(parameter);
237        self
238    }
239
240    /// Add multiple parameters describing expected event data
241    pub fn with_parameters(mut self, parameters: Vec<Parameter>) -> Self {
242        self.parameters.extend(parameters);
243        self
244    }
245
246    /// Add an alternative response example for this event
247    ///
248    /// This shows another valid way to respond to this event.
249    /// The primary response_example is set in the constructor;
250    /// use this method to add additional alternatives.
251    ///
252    /// # Example
253    /// ```rust,ignore
254    /// .with_alternative_example(json!({
255    ///     "type": "disconnect"
256    /// }))
257    /// ```
258    pub fn with_alternative_example(mut self, example: JsonValue) -> Self {
259        self.alternative_examples.push(example);
260        self
261    }
262
263    /// Add a log template for this event type
264    ///
265    /// The log template defines protocol-specific log formats at INFO/DEBUG/TRACE levels.
266    /// This enables standardized, centralized logging without per-protocol logging code.
267    ///
268    /// # Example
269    /// ```rust,ignore
270    /// EventType::new("http_request", "HTTP request received", json!({...}))
271    ///     .with_log_template(
272    ///         LogTemplate::new()
273    ///             .with_info("{client_ip} {method} {path} -> {status}")
274    ///             .with_debug("HTTP {method} {path} from {client_ip}:{client_port}")
275    ///             .with_trace("HTTP request: {json_pretty(.)}")
276    ///     )
277    /// ```
278    pub fn with_log_template(mut self, template: LogTemplate) -> Self {
279        self.log_template = Some(template);
280        self
281    }
282
283    /// Get action names for this event type
284    pub fn action_names(&self) -> Vec<String> {
285        self.actions.iter().map(|a| a.name.clone()).collect()
286    }
287
288    /// Convert this event type to a prompt description
289    ///
290    /// This creates a formatted string that describes the event and what actions
291    /// are available to respond to it. Used in LLM prompts.
292    ///
293    /// # Returns
294    /// A formatted string describing the event type, its context, and available actions
295    pub fn to_prompt_description(&self) -> String {
296        let mut result = String::new();
297
298        // Event type header
299        result.push_str(&format!("Event Type: {}\n", self.id));
300        result.push_str(&format!("Description: {}\n\n", self.description));
301
302        // Event input parameters (if available)
303        if !self.parameters.is_empty() {
304            result.push_str("Event Input Data:\n");
305            for param in &self.parameters {
306                result.push_str(&format!(
307                    "  - {} ({}){}: {}\n",
308                    param.name,
309                    param.type_hint,
310                    if param.required {
311                        ", required"
312                    } else {
313                        ", optional"
314                    },
315                    param.description
316                ));
317            }
318            result.push('\n');
319        }
320
321        // Available actions for this event
322        if !self.actions.is_empty() {
323            result.push_str("Available actions for this event:\n\n");
324            for (i, action) in self.actions.iter().enumerate() {
325                result.push_str(&format!("{}. {}\n\n", i + 1, action.to_prompt_text()));
326            }
327        } else {
328            result.push_str("No specific actions available for this event.\n");
329        }
330
331        result
332    }
333}
334
335/// Represents a specific event instance with type and data
336///
337/// This combines an EventType (which defines what can happen) with
338/// the actual event data (what did happen). It's the complete package
339/// that gets passed to call_llm().
340///
341/// # Example
342/// ```rust,ignore
343/// // Create an event instance for HTTP request
344/// let event = Event::new(
345///     &HTTP_REQUEST_EVENT,  // EventType constant
346///     json!({
347///         "method": "GET",
348///         "path": "/api/users",
349///         "headers": {"User-Agent": "curl/7.0"}
350///     })
351/// );
352///
353/// call_llm(&llm_client, &state, server_id, conn_id, &event, &protocol).await?;
354/// ```
355#[derive(Clone, Debug)]
356pub struct Event {
357    /// The type of event (reference to EventType constant)
358    pub event_type: &'static EventType,
359
360    /// The event-specific data (e.g., HTTP headers, SSH username, etc.)
361    pub data: JsonValue,
362}
363
364impl Event {
365    /// Create a new event instance
366    ///
367    /// # Arguments
368    /// * `event_type` - Reference to the EventType constant
369    /// * `data` - JSON data with event-specific context
370    ///
371    /// # Example
372    /// ```rust,ignore
373    /// let event = Event::new(
374    ///     &SSH_AUTH_EVENT,
375    ///     json!({"username": "alice", "auth_type": "password"})
376    /// );
377    /// ```
378    pub fn new(event_type: &'static EventType, data: JsonValue) -> Self {
379        Self { event_type, data }
380    }
381
382    /// Get the event type ID (for script routing)
383    pub fn id(&self) -> &str {
384        &self.event_type.id
385    }
386
387    /// Get the event description for prompts
388    pub fn to_prompt_description(&self) -> String {
389        self.event_type.to_prompt_description()
390    }
391}
392
393/// Format event types for inclusion in LLM prompts
394pub fn format_event_types_for_prompt(event_types: &[EventType]) -> String {
395    if event_types.is_empty() {
396        return String::new();
397    }
398
399    let mut result = String::from("\nEVENT TYPES:\n");
400    result.push_str("This protocol can emit the following event types:\n\n");
401
402    for event_type in event_types {
403        result.push_str(&format!(
404            "• {} - {}\n",
405            event_type.id, event_type.description
406        ));
407        result.push_str(&format!(
408            "  Available actions: {}\n",
409            event_type.action_names().join(", ")
410        ));
411    }
412
413    result.push('\n');
414    result
415}
416
417/// Generate script template instructions for event types
418pub fn format_script_template_for_prompt(event_types: &[EventType]) -> String {
419    if event_types.is_empty() {
420        return String::new();
421    }
422
423    let event_ids: Vec<String> = event_types
424        .iter()
425        .map(|e| format!("\"{}\"", e.id))
426        .collect();
427
428    format!(
429        r#"
430SCRIPT TEMPLATE for this protocol:
431When creating a script, structure it with a switch/case on the event type:
432
433Python example:
434import json, sys
435data = json.load(sys.stdin)
436event_type = data['event_type_id']
437
438if event_type == "event_id_1":
439    # Handle this event type
440    result = {{"actions": [{{"type": "action_name", "param": value}}]}}
441elif event_type == "event_id_2":
442    # Handle another event type
443    result = {{"actions": [{{"type": "other_action", "param": value}}]}}
444else:
445    # Unknown event - fallback to LLM
446    result = {{"fallback_to_llm": true, "fallback_reason": "Unknown event type"}}
447
448print(json.dumps(result))
449
450JavaScript example:
451const data = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
452const eventType = data.event_type_id;
453let result;
454
455switch (eventType) {{
456  case "event_id_1":
457    result = {{"actions": [{{"type": "action_name", "param": value}}]}};
458    break;
459  case "event_id_2":
460    result = {{"actions": [{{"type": "other_action", "param": value}}]}};
461    break;
462  default:
463    result = {{"fallback_to_llm": true, "fallback_reason": "Unknown event type"}};
464}}
465
466console.log(JSON.stringify(result));
467
468Event types for this protocol: {}
469"#,
470        event_ids.join(", ")
471    )
472}