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}