Skip to main content

netget/events/
handler.rs

1//! Event handler - coordinates responses to events using LLM
2
3use anyhow::Result;
4use tokio::sync::mpsc;
5use tracing::info;
6
7use super::errors::ActionExecutionError;
8use super::types::{AppEvent, UserCommand};
9use crate::cli::server_startup;
10use crate::llm::actions::{get_all_tool_actions, get_user_input_common_actions};
11use crate::llm::format_indented_dimmed_lines;
12use crate::llm::OllamaClient;
13use crate::llm::{execute_actions, CommonAction, Server};
14use crate::state::app_state::{AppState, Mode};
15use crate::ui::App;
16
17/// Whether reading the protocol documentation is a precondition for
18/// `open_server` / `open_client`.
19///
20/// This is the *only* switch for that policy. It governs both halves of it:
21///
22/// 1. whether the two actions are offered in the action list at all
23///    (`is_open_server_enabled` / `is_open_client_enabled` below), and
24/// 2. whether the first `open_server` / `open_client` of the process is answered
25///    with `ActionExecutionError::DocumentationRequired` instead of being
26///    executed (see `execute_server_management_action`).
27///
28/// The second half used to be unconditional, which cost a full extra model
29/// round-trip on the first server or client opened by every process while
30/// telling the model nothing it could not already ask for: the handler fetches
31/// the documentation itself and marks the protocol documented *before* raising
32/// the error, so falling through and starting the server loses no information.
33/// It also meant every mocked test had to answer a retry it never asked for.
34///
35/// Note the gate is process-global, not per-protocol: `is_server_docs_read()`
36/// returns `!documented_server_protocols.is_empty()`, so with the flag on it
37/// fires once per process and then never again, whatever protocol comes next.
38///
39/// Deliberately still a `const` rather than a runtime setting: it selects
40/// between two *prompting strategies*, not between two behaviours a user would
41/// want to switch per run — there is no CLI flag, MCP argument or settings key
42/// that would sensibly carry it, and promoting it would mean threading a new
43/// field through `AppState` (and the prompt builder, which reads the docs state
44/// separately) to expose a knob whose `true` side we have measured to be worse.
45/// If it ever needs to vary at runtime, that is the moment to add it to
46/// `Settings`, not before.
47const REQUIRE_DOCS_FOR_OPEN_ACTIONS: bool = false;
48
49/// Log detailed open_server action summary to the status channel
50fn log_open_server_summary(
51    status_tx: &mpsc::UnboundedSender<String>,
52    mac_address: &Option<String>,
53    interface: &Option<String>,
54    host: &Option<String>,
55    port: Option<u16>,
56    protocol: &str,
57    send_first: bool,
58    initial_memory: &Option<String>,
59    instruction: &str,
60    startup_params: &Option<serde_json::Value>,
61    event_handlers: &Option<Vec<serde_json::Value>>,
62    scheduled_tasks: &Option<Vec<crate::llm::actions::common::ServerTaskDefinition>>,
63    feedback_instructions: &Option<String>,
64) {
65    // Header
66    let _ = status_tx.send("[INFO] ═══ open_server ═══".to_string());
67
68    // Basic server configuration
69    let _ = status_tx.send(format!("[INFO]   Protocol: {}", protocol));
70    if let Some(p) = port {
71        let _ = status_tx.send(format!("[INFO]   Port: {}", p));
72    }
73    if let Some(h) = host {
74        let _ = status_tx.send(format!("[INFO]   Host: {}", h));
75    }
76    if let Some(i) = interface {
77        let _ = status_tx.send(format!("[INFO]   Interface: {}", i));
78    }
79    if let Some(m) = mac_address {
80        let _ = status_tx.send(format!("[INFO]   MAC Address: {}", m));
81    }
82    if send_first {
83        let _ = status_tx.send("[INFO]   Send First: yes".to_string());
84    }
85
86    // Instruction (truncated if long)
87    if !instruction.is_empty() {
88        let truncated = crate::utils::truncate_for_log(instruction, 100);
89        let _ = status_tx.send(format!("[INFO]   Instruction: {}", truncated));
90    }
91
92    // Initial memory (truncated if long)
93    if let Some(mem) = initial_memory {
94        if !mem.is_empty() {
95            let truncated = crate::utils::truncate_for_log(mem, 100);
96            let _ = status_tx.send(format!("[INFO]   Initial Memory: {}", truncated));
97        }
98    }
99
100    // Startup params
101    if let Some(params) = startup_params {
102        if !params.is_null() {
103            let shown = crate::utils::redact::redact_sensitive(params);
104            let params_str =
105                serde_json::to_string_pretty(&shown).unwrap_or_else(|_| shown.to_string());
106            let _ = status_tx.send("[INFO]   Startup Params:".to_string());
107            for line in format_indented_dimmed_lines(&params_str, 8) {
108                let _ = status_tx.send(format!("[INFO] {}", line));
109            }
110        }
111    }
112
113    // Event handlers summary
114    if let Some(handlers) = event_handlers {
115        let _ = status_tx.send(format!(
116            "[INFO]   Event Handlers: {} configured",
117            handlers.len()
118        ));
119        for handler in handlers {
120            let event_pattern = handler
121                .get("event_pattern")
122                .and_then(|v| v.as_str())
123                .unwrap_or("*");
124            let handler_type = handler
125                .get("handler")
126                .and_then(|h| h.get("type"))
127                .and_then(|t| t.as_str())
128                .unwrap_or("unknown");
129
130            let _ = status_tx.send(format!("[INFO]     • {} → {}", event_pattern, handler_type));
131
132            // For script handlers, show the script code (dimmed)
133            if handler_type == "script" {
134                if let Some(code) = handler
135                    .get("handler")
136                    .and_then(|h| h.get("code"))
137                    .and_then(|c| c.as_str())
138                {
139                    let truncated = crate::utils::truncate_for_log(code, 200);
140                    for line in format_indented_dimmed_lines(&truncated, 8) {
141                        let _ = status_tx.send(format!("[INFO] {}", line));
142                    }
143                }
144            }
145
146            // For static handlers, show the actions (dimmed)
147            if handler_type == "static" {
148                if let Some(actions) = handler.get("handler").and_then(|h| h.get("actions")) {
149                    let actions_str = serde_json::to_string_pretty(actions)
150                        .unwrap_or_else(|_| actions.to_string());
151                    let truncated = crate::utils::truncate_for_log(&actions_str, 300);
152                    for line in format_indented_dimmed_lines(&truncated, 8) {
153                        let _ = status_tx.send(format!("[INFO] {}", line));
154                    }
155                }
156            }
157        }
158    }
159
160    // Scheduled tasks summary
161    if let Some(tasks) = scheduled_tasks {
162        let _ = status_tx.send(format!(
163            "[INFO]   Scheduled Tasks: {} configured",
164            tasks.len()
165        ));
166        for task in tasks {
167            let timing = if task.recurring {
168                task.interval_secs
169                    .map(|i| format!("every {}s", i))
170                    .unwrap_or_else(|| "recurring".to_string())
171            } else {
172                task.delay_secs
173                    .map(|d| format!("after {}s", d))
174                    .unwrap_or_else(|| "one-shot".to_string())
175            };
176            let _ = status_tx.send(format!("[INFO]     • {} ({})", task.task_id, timing));
177            // Task instruction (dimmed)
178            let truncated = crate::utils::truncate_for_log(&task.instruction, 100);
179            for line in format_indented_dimmed_lines(&truncated, 8) {
180                let _ = status_tx.send(format!("[INFO] {}", line));
181            }
182        }
183    }
184
185    // Feedback instructions
186    if let Some(fb) = feedback_instructions {
187        let truncated = crate::utils::truncate_for_log(fb, 100);
188        let _ = status_tx.send(format!("[INFO]   Feedback Instructions: {}", truncated));
189    }
190
191    let _ = status_tx.send("[INFO] ══════════════════".to_string());
192}
193
194/// Log detailed open_client action summary to the status channel
195fn log_open_client_summary(
196    status_tx: &mpsc::UnboundedSender<String>,
197    protocol: &str,
198    remote_addr: &str,
199    instruction: &str,
200    startup_params: &Option<serde_json::Value>,
201    initial_memory: &Option<String>,
202    event_handlers: &Option<Vec<serde_json::Value>>,
203    scheduled_tasks: &Option<Vec<crate::llm::actions::common::ServerTaskDefinition>>,
204    feedback_instructions: &Option<String>,
205) {
206    // Header
207    let _ = status_tx.send("[INFO] ═══ open_client ═══".to_string());
208
209    // Basic client configuration
210    let _ = status_tx.send(format!("[INFO]   Protocol: {}", protocol));
211    let _ = status_tx.send(format!("[INFO]   Remote: {}", remote_addr));
212
213    // Instruction (truncated if long)
214    if !instruction.is_empty() {
215        let truncated = crate::utils::truncate_for_log(instruction, 100);
216        let _ = status_tx.send(format!("[INFO]   Instruction: {}", truncated));
217    }
218
219    // Initial memory (truncated if long)
220    if let Some(mem) = initial_memory {
221        if !mem.is_empty() {
222            let truncated = crate::utils::truncate_for_log(mem, 100);
223            let _ = status_tx.send(format!("[INFO]   Initial Memory: {}", truncated));
224        }
225    }
226
227    // Startup params
228    if let Some(params) = startup_params {
229        if !params.is_null() {
230            let shown = crate::utils::redact::redact_sensitive(params);
231            let params_str =
232                serde_json::to_string_pretty(&shown).unwrap_or_else(|_| shown.to_string());
233            let _ = status_tx.send("[INFO]   Startup Params:".to_string());
234            for line in format_indented_dimmed_lines(&params_str, 8) {
235                let _ = status_tx.send(format!("[INFO] {}", line));
236            }
237        }
238    }
239
240    // Event handlers summary (same logic as server)
241    if let Some(handlers) = event_handlers {
242        let _ = status_tx.send(format!(
243            "[INFO]   Event Handlers: {} configured",
244            handlers.len()
245        ));
246        for handler in handlers {
247            let event_pattern = handler
248                .get("event_pattern")
249                .and_then(|v| v.as_str())
250                .unwrap_or("*");
251            let handler_type = handler
252                .get("handler")
253                .and_then(|h| h.get("type"))
254                .and_then(|t| t.as_str())
255                .unwrap_or("unknown");
256
257            let _ = status_tx.send(format!("[INFO]     • {} → {}", event_pattern, handler_type));
258
259            if handler_type == "script" {
260                if let Some(code) = handler
261                    .get("handler")
262                    .and_then(|h| h.get("code"))
263                    .and_then(|c| c.as_str())
264                {
265                    let truncated = crate::utils::truncate_for_log(code, 200);
266                    for line in format_indented_dimmed_lines(&truncated, 8) {
267                        let _ = status_tx.send(format!("[INFO] {}", line));
268                    }
269                }
270            }
271
272            if handler_type == "static" {
273                if let Some(actions) = handler.get("handler").and_then(|h| h.get("actions")) {
274                    let actions_str = serde_json::to_string_pretty(actions)
275                        .unwrap_or_else(|_| actions.to_string());
276                    let truncated = crate::utils::truncate_for_log(&actions_str, 300);
277                    for line in format_indented_dimmed_lines(&truncated, 8) {
278                        let _ = status_tx.send(format!("[INFO] {}", line));
279                    }
280                }
281            }
282        }
283    }
284
285    // Scheduled tasks summary
286    if let Some(tasks) = scheduled_tasks {
287        let _ = status_tx.send(format!(
288            "[INFO]   Scheduled Tasks: {} configured",
289            tasks.len()
290        ));
291        for task in tasks {
292            let timing = if task.recurring {
293                task.interval_secs
294                    .map(|i| format!("every {}s", i))
295                    .unwrap_or_else(|| "recurring".to_string())
296            } else {
297                task.delay_secs
298                    .map(|d| format!("after {}s", d))
299                    .unwrap_or_else(|| "one-shot".to_string())
300            };
301            let _ = status_tx.send(format!("[INFO]     • {} ({})", task.task_id, timing));
302            let truncated = crate::utils::truncate_for_log(&task.instruction, 100);
303            for line in format_indented_dimmed_lines(&truncated, 8) {
304                let _ = status_tx.send(format!("[INFO] {}", line));
305            }
306        }
307    }
308
309    // Feedback instructions
310    if let Some(fb) = feedback_instructions {
311        let truncated = crate::utils::truncate_for_log(fb, 100);
312        let _ = status_tx.send(format!("[INFO]   Feedback Instructions: {}", truncated));
313    }
314
315    let _ = status_tx.send("[INFO] ══════════════════".to_string());
316}
317
318/// Event handler that coordinates all event processing
319#[derive(Clone)]
320pub struct EventHandler {
321    /// Application state
322    state: AppState,
323    /// Ollama client
324    llm: OllamaClient,
325}
326
327impl EventHandler {
328    /// Create a new event handler
329    pub fn new(state: AppState, llm: OllamaClient) -> Self {
330        Self { state, llm }
331    }
332
333    /// List available models from Ollama
334    pub async fn list_models(&self) -> Result<Vec<String>> {
335        self.llm.list_models().await
336    }
337
338    /// Get a clone of the Ollama client
339    pub fn get_llm_client(&self) -> OllamaClient {
340        self.llm.clone()
341    }
342
343    /// Replace the LLM client (used by /backend command to switch backends at runtime)
344    pub fn set_llm_client(&mut self, client: OllamaClient) {
345        self.llm = client;
346    }
347
348    /// Handle an application event
349    /// Returns Ok(true) if the application should quit
350    pub async fn handle_event(&mut self, event: AppEvent, ui: &mut App) -> Result<bool> {
351        match event {
352            AppEvent::UserCommand(cmd) => self.handle_user_command(cmd, ui).await,
353            AppEvent::Tick => {
354                // Periodic updates can go here
355                Ok(false)
356            }
357            AppEvent::Shutdown => {
358                info!("Shutdown event received");
359                Ok(true)
360            }
361        }
362    }
363
364    /// Handle user commands
365    /// Returns Ok(true) if the application should quit
366    async fn handle_user_command(&mut self, command: UserCommand, ui: &mut App) -> Result<bool> {
367        match command {
368            UserCommand::Status => {
369                self.handle_status(ui).await?;
370                Ok(false)
371            }
372            UserCommand::ShowModel => {
373                self.handle_show_model(ui).await?;
374                Ok(false)
375            }
376            UserCommand::ChangeModel { model } => {
377                self.handle_change_model(model, ui).await?;
378                Ok(false)
379            }
380            UserCommand::ShowBackend => {
381                self.handle_show_backend(ui).await?;
382                Ok(false)
383            }
384            UserCommand::SetBackend { args } => {
385                self.handle_set_backend(args, ui).await?;
386                Ok(false)
387            }
388            UserCommand::ShowLogLevel => {
389                ui.add_llm_message(format!("Current log level: {}", ui.log_level.as_str()));
390                Ok(false)
391            }
392            UserCommand::ChangeLogLevel { level } => {
393                use crate::ui::app::LogLevel;
394                if let Some(log_level) = LogLevel::parse(&level) {
395                    ui.set_log_level(log_level);
396                } else {
397                    ui.add_llm_message(format!(
398                        "Invalid log level: {level}. Use: error, warn, info, debug, or trace"
399                    ));
400                }
401                Ok(false)
402            }
403            UserCommand::TestOutput { count } => {
404                // Generate test output lines (used for terminal overflow testing)
405                for i in 0..count {
406                    ui.add_llm_message(format!("Test line {} of {}", i + 1, count));
407                }
408                Ok(false)
409            }
410            UserCommand::TestAsk => {
411                // Test web search approval prompt by triggering a search to DuckDuckGo
412                use crate::llm::actions::tools::{execute_tool, ToolAction};
413
414                ui.add_llm_message(
415                    "[INFO] Testing web search approval with DuckDuckGo...".to_string(),
416                );
417
418                // Get web search mode and approval channel
419                let web_search_mode = self.state.get_web_search_mode().await;
420                let approval_tx = self.state.get_web_approval_channel().await;
421
422                // Create a web search action for DuckDuckGo with a long path to test truncation
423                let action = ToolAction::WebSearch {
424                    query: "https://duckduckgo.com/?q=test+search+query+with+very+long+parameters&ia=web&category=general&filters=none".to_string(),
425                };
426
427                // Execute the tool (this will trigger approval prompt if in ASK mode)
428                let result = execute_tool(
429                    &action,
430                    approval_tx.as_ref(),
431                    web_search_mode,
432                    Some(&self.state),
433                )
434                .await;
435
436                // Display the result
437                if result.success {
438                    ui.add_llm_message("[INFO] Web search completed successfully".to_string());
439                    ui.add_llm_message(format!("[DEBUG] Result: {}", result.result));
440                } else {
441                    ui.add_llm_message(format!("[ERROR] Web search failed: {}", result.result));
442                }
443
444                Ok(false)
445            }
446            UserCommand::SetFooterStatus { message } => {
447                // This command is only supported in rolling TUI mode
448                if message.is_some() {
449                    ui.add_llm_message(
450                        "Footer status command is only supported in rolling TUI mode".to_string(),
451                    );
452                }
453                Ok(false)
454            }
455            UserCommand::StopAll => {
456                self.handle_stop_all(ui).await?;
457                Ok(false)
458            }
459            UserCommand::StopById { id } => {
460                self.handle_stop_by_id(id, ui).await?;
461                Ok(false)
462            }
463            UserCommand::Save { name, id } => {
464                self.handle_save(name, id, ui).await?;
465                Ok(false)
466            }
467            UserCommand::Load { name } => {
468                self.handle_load(name, ui).await?;
469                Ok(false)
470            }
471            #[cfg(feature = "sqlite")]
472            UserCommand::Sqlite { db_id, query } => {
473                self.handle_sqlite(db_id, query, ui).await?;
474                Ok(false)
475            }
476            UserCommand::Quit => {
477                self.handle_quit(ui).await?;
478                Ok(true) // Signal to quit
479            }
480            UserCommand::UnknownSlashCommand { command } => {
481                ui.add_llm_message(format!("Unknown command: {command}"));
482                ui.add_llm_message(
483                    "Available commands: /status, /model [name], /log [level], /docs [protocol], /quit".to_string(),
484                );
485                Ok(false)
486            }
487            UserCommand::Interpret { input: _ } => {
488                ui.add_llm_message(
489                    "Internal error: Interpret command should use async path".to_string(),
490                );
491                Ok(false)
492            }
493            UserCommand::ShowWebSearch => {
494                // This command is only supported in rolling TUI mode
495                ui.add_llm_message(
496                    "Web search command is only supported in rolling TUI mode".to_string(),
497                );
498                Ok(false)
499            }
500            UserCommand::SetWebSearch { mode: _ } => {
501                // This command is only supported in rolling TUI mode
502                ui.add_llm_message(
503                    "Web search command is only supported in rolling TUI mode".to_string(),
504                );
505                Ok(false)
506            }
507            UserCommand::ShowEventHandler => {
508                // This command is only supported in rolling TUI mode
509                ui.add_llm_message(
510                    "Event handler command is only supported in rolling TUI mode".to_string(),
511                );
512                Ok(false)
513            }
514            UserCommand::SetEventHandler { mode: _ } => {
515                // This command is only supported in rolling TUI mode
516                ui.add_llm_message(
517                    "Event handler command is only supported in rolling TUI mode".to_string(),
518                );
519                Ok(false)
520            }
521            UserCommand::ShowDocs { protocol } => {
522                self.handle_show_docs(protocol, ui).await?;
523                Ok(false)
524            }
525            UserCommand::ShowEnvironment => {
526                self.handle_show_environment(ui).await?;
527                Ok(false)
528            }
529            UserCommand::ShowUsage => {
530                // This command is only supported in rolling TUI mode
531                ui.add_llm_message(
532                    "Usage command is only supported in rolling TUI mode".to_string(),
533                );
534                Ok(false)
535            }
536            UserCommand::ShowStability => {
537                let min = self.state.get_min_stability().await;
538                for line in crate::protocol::stability_report(min) {
539                    ui.add_llm_message(line);
540                }
541                Ok(false)
542            }
543            UserCommand::ListSimple => {
544                // This command is only supported in rolling TUI mode
545                ui.add_llm_message(
546                    "Simple protocol command is only supported in rolling TUI mode".to_string(),
547                );
548                Ok(false)
549            }
550            UserCommand::StartSimple { protocol: _ } => {
551                // This command is only supported in rolling TUI mode
552                ui.add_llm_message(
553                    "Simple protocol command is only supported in rolling TUI mode".to_string(),
554                );
555                Ok(false)
556            }
557            UserCommand::Manage | UserCommand::Update { .. } => {
558                // These commands are only supported in rolling TUI mode
559                ui.add_llm_message(
560                    "The /manage and /update commands are only supported in rolling TUI mode"
561                        .to_string(),
562                );
563                Ok(false)
564            }
565        }
566    }
567
568    /// Handle interpret command using NEW action-based system with multi-turn tool support
569    /// This method can be spawned in a task without blocking the UI
570    pub async fn handle_interpret_with_actions(
571        &mut self,
572        input: String,
573        status_tx: mpsc::UnboundedSender<String>,
574        protocol: Option<Box<dyn Server + Send>>,
575    ) -> Result<()> {
576        use crate::llm::{ConversationHandler, PromptBuilder};
577
578        // Get protocol async actions if available
579        let protocol_async_actions = if let Some(ref proto) = protocol {
580            proto.get_async_actions(&self.state)
581        } else {
582            Vec::new()
583        };
584
585        // Get model, ensuring one is selected
586        let current_model = self.state.get_ollama_model().await;
587        let model = match crate::llm::ensure_model_selected(
588            current_model.clone(),
589            &self.state.get_ollama_url().await,
590        )
591        .await
592        {
593            Ok(m) => m,
594            Err(e) => {
595                return Err(anyhow::anyhow!("Failed to ensure model is selected: {}", e));
596            }
597        };
598
599        // If model was auto-selected (wasn't set before), notify via status_tx
600        if current_model.is_none() {
601            let _ = status_tx.send(format!(
602                "⚠  Auto-selected model: {} (no model was configured)",
603                model
604            ));
605        }
606
607        // Create LLM client with status channel for trace logs
608        let llm_with_status = self.llm.clone().with_status_tx(status_tx.clone());
609
610        // Get web search mode and approval channel
611        let web_search_mode = self.state.get_web_search_mode().await;
612        let approval_tx = self.state.get_web_approval_channel().await;
613
614        // Get conversation history from persistent state
615        let conversation_history = self.state.get_user_conversation_history().await;
616
617        // Check if open_server/open_client should be enabled based on configuration
618        let is_open_server_enabled = if REQUIRE_DOCS_FOR_OPEN_ACTIONS {
619            self.state.is_server_docs_read().await
620        } else {
621            true
622        };
623        let is_open_client_enabled = if REQUIRE_DOCS_FOR_OPEN_ACTIONS {
624            self.state.is_client_docs_read().await
625        } else {
626            true
627        };
628
629        // Build system prompt (without user input - that's added as a message)
630        let system_prompt = PromptBuilder::build_user_input_system_prompt_with_docs(
631            &self.state,
632            protocol_async_actions.clone(),
633            conversation_history,
634            is_open_server_enabled,
635            is_open_client_enabled,
636        )
637        .await;
638
639        // Get available actions for retry correction messages
640        let selected_mode = self.state.get_selected_scripting_mode().await;
641        let scripting_env = self.state.get_scripting_env().await;
642        let mut available_actions = get_user_input_common_actions(
643            selected_mode,
644            &scripting_env,
645            is_open_server_enabled,
646            is_open_client_enabled,
647        );
648        available_actions.extend(get_all_tool_actions(web_search_mode));
649        available_actions.extend(protocol_async_actions);
650
651        // Narrow to what the prompt actually advertises before this list is used as the native
652        // tool schema and as the retry-correction catalogue. The prompt builder applies
653        // filter_actions_by_scripting_mode to what it renders; handing the unfiltered list to
654        // with_native_tools offered the model script parameters the prose told it do not exist,
655        // and validated its reply against a superset of what it was shown.
656        let available_actions =
657            PromptBuilder::advertised_user_input_actions(&self.state, available_actions).await;
658
659        // Get or create persistent conversation state
660        let conversation_state = self.state.get_or_create_user_conversation_state().await;
661
662        // Get rate limiter for user requests
663        let rate_limiter = self.state.get_rate_limiter().await;
664
665        let mut conversation = ConversationHandler::new(
666            system_prompt,
667            std::sync::Arc::new(llm_with_status),
668            model,
669            rate_limiter,
670            crate::llm::RequestSource::User, // User input always waits for rate limits
671        )
672        .with_native_tools(&available_actions)
673        .with_status_tx(status_tx.clone())
674        .with_tracking(
675            self.state.clone(),
676            crate::state::app_state::ConversationSource::User,
677            input.clone(),
678        )
679        .with_conversation_state(conversation_state);
680
681        // Register conversation immediately so it shows in UI
682        self.state
683            .register_conversation(
684                conversation.conversation_id().to_string(),
685                crate::state::app_state::ConversationSource::User,
686                input.clone(),
687            )
688            .await;
689
690        // Mark as registered to prevent duplicate registration in generate_with_tools_and_retry
691        conversation.mark_registered();
692
693        // Add user input as a separate user message
694        conversation.add_user_message(input);
695
696        // Retry loop for execution-time errors (e.g., port conflicts)
697        const MAX_EXECUTION_RETRIES: usize = 1;
698        let mut execution_attempts = 0;
699
700        loop {
701            // Generate actions with tool calling and retry
702            let actions = conversation
703                .generate_with_tools_and_retry(
704                    approval_tx.clone(),
705                    web_search_mode,
706                    available_actions.clone(),
707                )
708                .await;
709
710            match actions {
711                Ok(action_values) => {
712                    let mut should_retry = false;
713                    let mut retry_error: Option<crate::events::ActionExecutionError> = None;
714
715                    // Handle server management actions FIRST (they need to be executed before other actions)
716                    let mut state_changed = false;
717                    for action_value in &action_values {
718                        if let Ok(common_action) = CommonAction::from_json(action_value) {
719                            // Check if this action will modify state (open_server, close_server, open_client, close_client, etc.)
720                            let modifies_state = matches!(
721                                common_action,
722                                CommonAction::OpenServer { .. }
723                                    | CommonAction::CloseServer { .. }
724                                    | CommonAction::CloseAllServers
725                                    | CommonAction::OpenClient { .. }
726                                    | CommonAction::CloseClient { .. }
727                                    | CommonAction::CloseAllClients
728                                    | CommonAction::CloseConnectionById { .. }
729                                    | CommonAction::UpdateServer { .. }
730                                    | CommonAction::UpdateClient { .. }
731                            );
732
733                            match self
734                                .execute_server_management_action(common_action, &status_tx)
735                                .await
736                            {
737                                Ok(_) => {
738                                    // Action executed successfully
739                                    if modifies_state {
740                                        state_changed = true;
741                                    }
742                                }
743                                Err(e)
744                                    if e.is_retryable()
745                                        && execution_attempts < MAX_EXECUTION_RETRIES =>
746                                {
747                                    // Retryable error (e.g., port conflict) - prepare to retry
748                                    should_retry = true;
749                                    retry_error = Some(e);
750                                    break; // Stop processing actions, we'll retry
751                                }
752                                Err(e) => {
753                                    // Non-retryable error or max retries exceeded
754                                    let _ = status_tx
755                                        .send(format!("[ERROR] Error executing action: {e}"));
756                                }
757                            }
758                        }
759                    }
760
761                    // Update conversation state if server state changed
762                    if state_changed && !should_retry {
763                        conversation.update_current_state(&self.state, None).await;
764                        let _ = status_tx.send(
765                            "[DEBUG] Updated conversation state after server changes".to_string(),
766                        );
767                    }
768
769                    // If we should retry, add error to conversation and retry
770                    if should_retry {
771                        if let Some(error) = retry_error {
772                            execution_attempts += 1;
773                            let _ = status_tx.send(format!(
774                                "[INFO] Execution error (attempt {}/{}), retrying with LLM feedback...",
775                                execution_attempts,
776                                MAX_EXECUTION_RETRIES + 1
777                            ));
778
779                            // Add error correction to conversation
780                            let correction = error.build_correction_message();
781                            conversation.add_user_message(correction);
782
783                            // Continue loop to retry
784                            continue;
785                        }
786                    }
787
788                    // Then execute all other actions (including append_to_log)
789                    let protocol_ref: Option<&dyn Server> =
790                        protocol.as_ref().map(|p| p.as_ref() as &dyn Server);
791
792                    // User input context - no specific server/client (global actions)
793                    match execute_actions(
794                        action_values.clone(),
795                        &self.state,
796                        protocol_ref,
797                        None,
798                        None,
799                    )
800                    .await
801                    {
802                        Ok(result) => {
803                            // Display messages
804                            for msg in result.messages {
805                                let _ = status_tx.send(msg);
806                            }
807                        }
808                        Err(e) => {
809                            let _ =
810                                status_tx.send(format!("[ERROR] Failed to execute actions: {e}"));
811                        }
812                    }
813
814                    // Success - break out of retry loop
815                    break;
816                }
817                Err(e) => {
818                    let _ = status_tx.send(format!("[ERROR] LLM error: {e}"));
819                    // End conversation tracking since generate_with_tools_and_retry didn't complete
820                    conversation.end_tracking().await;
821                    break; // LLM errors don't retry at this level
822                }
823            }
824        }
825
826        Ok(())
827    }
828
829    /// Execute server management actions (open_server, close_server, etc.)
830    ///
831    /// Public so `tests/` can drive the real `open_server` / `open_client` executor —
832    /// including `log_open_server_summary`, which used to panic on a multi-byte
833    /// instruction — rather than re-implementing it. See the test-location policy in
834    /// `CLAUDE.md`: tests reach internals through `netget::` public APIs.
835    pub async fn execute_server_management_action(
836        &mut self,
837        action: CommonAction,
838        status_tx: &mpsc::UnboundedSender<String>,
839    ) -> Result<(), crate::events::ActionExecutionError> {
840        match action {
841            CommonAction::OpenServer {
842                mac_address,
843                interface,
844                host,
845                port,
846                protocol,
847                send_first,
848                initial_memory,
849                instruction,
850                startup_params,
851                event_handlers,
852                scheduled_tasks,
853                feedback_instructions,
854            } => {
855                // Documentation gate (off by default, see REQUIRE_DOCS_FOR_OPEN_ACTIONS):
856                // when enabled, the first open_server of the process fetches the
857                // protocol documentation and returns DocumentationRequired, which
858                // makes the model retry the same action with the docs in context.
859                if REQUIRE_DOCS_FOR_OPEN_ACTIONS && !self.state.is_server_docs_read().await {
860                    // Fetch documentation for this protocol
861                    use crate::llm::actions::tools::{execute_tool, ToolAction};
862                    let doc_tool = ToolAction::ReadDocumentation {
863                        protocols: vec![protocol.clone()],
864                        protocol: None,
865                    };
866
867                    // Execute the tool to get documentation
868                    let approval_tx = self.state.get_web_approval_channel().await;
869                    let web_search_mode = self.state.get_web_search_mode().await;
870                    let tool_result = execute_tool(
871                        &doc_tool,
872                        approval_tx.as_ref(),
873                        web_search_mode,
874                        Some(&self.state),
875                    )
876                    .await;
877
878                    if tool_result.success {
879                        // Mark docs as read so we don't loop forever
880                        self.state
881                            .mark_server_protocols_documented(&[protocol.clone()])
882                            .await;
883
884                        // Build the original action JSON for reference
885                        let original_action = serde_json::json!({
886                            "type": "open_server",
887                            "mac_address": mac_address,
888                            "interface": interface,
889                            "host": host,
890                            "port": port,
891                            "protocol": protocol,
892                            "send_first": send_first,
893                            "initial_memory": initial_memory,
894                            "instruction": instruction,
895                            "startup_params": startup_params,
896                            "event_handlers": event_handlers,
897                            "scheduled_tasks": scheduled_tasks,
898                            "feedback_instructions": feedback_instructions,
899                        });
900
901                        // Return DocumentationRequired error with the documentation
902                        return Err(ActionExecutionError::DocumentationRequired {
903                            action_type: "open_server".to_string(),
904                            protocol: protocol.clone(),
905                            documentation: tool_result.result,
906                            original_action,
907                        });
908                    } else {
909                        // If documentation fetch fails, log warning but proceed
910                        let _ = status_tx.send(format!(
911                            "[WARN] Failed to fetch documentation for {}: {}. Proceeding anyway.",
912                            protocol, tool_result.result
913                        ));
914                    }
915                }
916
917                // Log detailed summary of the open_server action
918                log_open_server_summary(
919                    status_tx,
920                    &mac_address,
921                    &interface,
922                    &host,
923                    port,
924                    &protocol,
925                    send_first,
926                    &initial_memory,
927                    &instruction,
928                    &startup_params,
929                    &event_handlers,
930                    &scheduled_tasks,
931                    &feedback_instructions,
932                );
933
934                // Save copies for error handling before values are moved
935                let mac_address_clone = mac_address.clone();
936                let interface_clone = interface.clone();
937                let host_clone = host.clone();
938                let initial_memory_clone = initial_memory.clone();
939                let instruction_clone = instruction.clone();
940                let startup_params_clone = startup_params.clone();
941                let event_handlers_clone = event_handlers.clone();
942                let scheduled_tasks_clone = scheduled_tasks.clone();
943                let feedback_instructions_clone = feedback_instructions.clone();
944
945                // Use start_server_from_action which properly handles flexible binding
946                // (including interface, mac_address, host for migrated protocols)
947                match server_startup::start_server_from_action(
948                    &self.state,
949                    mac_address,
950                    interface,
951                    host,
952                    port,
953                    &protocol,
954                    send_first,
955                    initial_memory,
956                    instruction,
957                    startup_params,
958                    event_handlers,
959                    scheduled_tasks,
960                    feedback_instructions,
961                    status_tx.clone(),
962                )
963                .await
964                {
965                    Ok(server_id) => {
966                        // Server started successfully
967                        let _ = status_tx.send(format!(
968                            "[INFO] Server #{} started successfully",
969                            server_id.as_u32()
970                        ));
971                    }
972                    Err(e) => {
973                        // Check error type and provide appropriate retryable error
974                        let error_msg = e.to_string();
975
976                        // Check if it's a port binding error (retryable)
977                        let is_port_conflict = error_msg.contains("Address already in use")
978                            || error_msg.contains("port conflict")
979                            || error_msg.contains("bind");
980
981                        // Check if it's a validation error about missing or invalid parameters (retryable)
982                        let is_validation_error = error_msg
983                            .contains("Invalid event handler configuration")
984                            || error_msg.contains("Missing 'instruction' field")
985                            || error_msg.contains("instruction is required");
986
987                        if is_port_conflict && port.is_some() {
988                            // Port conflict - create retryable error
989                            return Err(ActionExecutionError::PortConflict {
990                                port: port.unwrap(),
991                                protocol: protocol.to_string(),
992                                underlying_error: error_msg,
993                            });
994                        } else if is_validation_error {
995                            // Build the original action JSON for reference
996                            let original_action = serde_json::json!({
997                                "type": "open_server",
998                                "mac_address": mac_address_clone,
999                                "interface": interface_clone,
1000                                "host": host_clone,
1001                                "port": port,
1002                                "protocol": protocol,
1003                                "send_first": send_first,
1004                                "initial_memory": initial_memory_clone,
1005                                "instruction": instruction_clone,
1006                                "startup_params": startup_params_clone,
1007                                "event_handlers": event_handlers_clone,
1008                                "scheduled_tasks": scheduled_tasks_clone,
1009                                "feedback_instructions": feedback_instructions_clone,
1010                            });
1011
1012                            let parameter_name = event_handler_parameter_name(&error_msg);
1013
1014                            return Err(ActionExecutionError::InvalidActionParameters {
1015                                action_type: "open_server".to_string(),
1016                                parameter_name: parameter_name.to_string(),
1017                                error_message: error_msg.clone(),
1018                                original_action,
1019                            });
1020                        } else {
1021                            // Fatal error - propagate as fatal
1022                            let _ = status_tx
1023                                .send(format!("[ERROR] Failed to start server: {}", error_msg));
1024                            return Err(ActionExecutionError::Fatal(e));
1025                        }
1026                    }
1027                }
1028
1029                // NOTE: scheduled_tasks and event_handlers are now handled by start_server_from_action
1030            }
1031            CommonAction::CloseServer { server_id } => {
1032                use crate::state::server::ServerStatus;
1033
1034                // Close specific server
1035                let sid = crate::state::ServerId::new(server_id);
1036
1037                // Mark server as Stopped instead of removing it (reaper will clean up after 30s)
1038                self.state
1039                    .update_server_status(sid, ServerStatus::Stopped)
1040                    .await;
1041                let _ = status_tx.send(format!("[SERVER] Stopped server #{}", sid.as_u32()));
1042
1043                // Clean up tasks associated with this server
1044                self.state.cleanup_server_tasks(sid).await;
1045                let _ = status_tx.send(format!(
1046                    "[TASK] Cleaned up tasks for server #{}",
1047                    sid.as_u32()
1048                ));
1049
1050                // Shut down any resident script processes for this server
1051                crate::scripting::ResidentScriptManager::shutdown_server(sid.as_u32()).await;
1052
1053                // Check if all servers are stopped/error
1054                let all_stopped =
1055                    self.state.get_all_servers().await.iter().all(|s| {
1056                        matches!(s.status, ServerStatus::Stopped | ServerStatus::Error(_))
1057                    });
1058
1059                if all_stopped {
1060                    self.state.set_mode(Mode::Idle).await;
1061                }
1062
1063                let _ = status_tx.send("__UPDATE_UI__".to_string());
1064            }
1065            CommonAction::CloseAllServers => {
1066                use crate::state::server::ServerStatus;
1067
1068                // Close all servers
1069                let server_ids = self.state.get_all_server_ids().await;
1070
1071                for server_id in server_ids {
1072                    // Mark server as Stopped instead of removing it (reaper will clean up after 30s)
1073                    self.state
1074                        .update_server_status(server_id, ServerStatus::Stopped)
1075                        .await;
1076                    let _ =
1077                        status_tx.send(format!("[SERVER] Stopped server #{}", server_id.as_u32()));
1078
1079                    // Clean up tasks associated with this server
1080                    self.state.cleanup_server_tasks(server_id).await;
1081                    let _ = status_tx.send(format!(
1082                        "[TASK] Cleaned up tasks for server #{}",
1083                        server_id.as_u32()
1084                    ));
1085
1086                    // Shut down any resident script processes for this server
1087                    crate::scripting::ResidentScriptManager::shutdown_server(server_id.as_u32())
1088                        .await;
1089                }
1090
1091                // Set mode to Idle
1092                self.state.set_mode(Mode::Idle).await;
1093
1094                let _ = status_tx.send("__UPDATE_UI__".to_string());
1095            }
1096            CommonAction::UpdateInstruction { instruction } => {
1097                // Update instruction for first server (TODO: support targeting specific server ID)
1098                if let Some(server_id) = self.state.get_first_server_id().await {
1099                    self.state
1100                        .set_instruction(server_id, instruction.clone())
1101                        .await;
1102                    let _ = status_tx.send(format!(
1103                        "[INFO] Server #{} instruction: {}",
1104                        server_id.as_u32(),
1105                        instruction
1106                    ));
1107                } else {
1108                    let _ =
1109                        status_tx.send("[WARN] No server to update instruction for".to_string());
1110                }
1111            }
1112            CommonAction::ChangeModel { model } => {
1113                self.state.set_ollama_model(Some(model.clone())).await;
1114                let _ = status_tx.send(format!("Changed model to: {model}"));
1115
1116                // Signal main loop to update UI
1117                let _ = status_tx.send("__UPDATE_UI__".to_string());
1118            }
1119            // Memory actions need server_id context
1120            CommonAction::SetMemory { value } => {
1121                if let Some(server_id) = self.state.get_first_server_id().await {
1122                    self.state.set_memory(server_id, value).await;
1123                }
1124            }
1125            CommonAction::AppendMemory { value } => {
1126                if let Some(server_id) = self.state.get_first_server_id().await {
1127                    self.state.append_memory(server_id, value).await;
1128                }
1129            }
1130            CommonAction::AppendToLog { .. } => {
1131                // AppendToLog is handled by the action executor, not here
1132                // This match arm exists to satisfy exhaustiveness checking
1133            }
1134            CommonAction::ScheduleTask {
1135                task_id,
1136                recurring,
1137                delay_secs,
1138                interval_secs,
1139                max_executions,
1140                server_id,
1141                connection_id,
1142                client_id,
1143                instruction,
1144                context,
1145                script_runtime,
1146                script_language: _,
1147                script_path: _,
1148                script_inline,
1149                script_handles,
1150            } => {
1151                use crate::state::task::{ScheduledTask, TaskScope};
1152
1153                // Determine scope: Connection > Server > Client > Global
1154                let scope = if let Some(conn_id_str) = connection_id {
1155                    // Connection scope requires server_id
1156                    if let Some(sid) = server_id {
1157                        let server_id_obj = crate::state::ServerId::new(sid);
1158                        match crate::server::connection::ConnectionId::from_string(&conn_id_str) {
1159                            Some(cid) => {
1160                                // Validate connection exists on server
1161                                if let Some(server) = self.state.get_server(server_id_obj).await {
1162                                    if server.connections.contains_key(&cid) {
1163                                        TaskScope::Connection(server_id_obj, cid)
1164                                    } else {
1165                                        let _ = status_tx.send(format!(
1166                                            "[ERROR] Connection {} not found on server #{}",
1167                                            conn_id_str, sid
1168                                        ));
1169                                        return Ok(());
1170                                    }
1171                                } else {
1172                                    let _ = status_tx.send(format!(
1173                                        "[ERROR] Server #{} not found for connection-scoped task",
1174                                        sid
1175                                    ));
1176                                    return Ok(());
1177                                }
1178                            }
1179                            None => {
1180                                let _ = status_tx.send(format!(
1181                                    "[ERROR] Invalid connection_id format: {}. Expected 'conn-123' or '123'",
1182                                    conn_id_str
1183                                ));
1184                                return Ok(());
1185                            }
1186                        }
1187                    } else {
1188                        let _ = status_tx.send(
1189                            "[ERROR] connection_id requires server_id to be specified".to_string(),
1190                        );
1191                        return Ok(());
1192                    }
1193                } else if let Some(sid) = server_id {
1194                    TaskScope::Server(crate::state::ServerId::new(sid))
1195                } else if let Some(cid) = client_id {
1196                    let client_id_obj = crate::state::ClientId::new(cid);
1197                    // Validate client exists
1198                    if self.state.get_client(client_id_obj).await.is_none() {
1199                        let _ = status_tx.send(format!(
1200                            "[ERROR] Client #{} not found for client-scoped task",
1201                            cid
1202                        ));
1203                        return Ok(());
1204                    }
1205                    TaskScope::Client(client_id_obj)
1206                } else {
1207                    TaskScope::Global
1208                };
1209
1210                // Determine delay: for one-shot use delay_secs, for recurring use delay_secs or interval_secs
1211                let delay = if recurring {
1212                    delay_secs.or(interval_secs).unwrap_or(0)
1213                } else {
1214                    delay_secs.unwrap_or(0)
1215                };
1216
1217                let mut task = if recurring {
1218                    let interval = interval_secs.unwrap_or(delay);
1219                    ScheduledTask::new_recurring(
1220                        crate::state::TaskId::new(0), // Temporary, will be assigned by add_task
1221                        task_id.clone(),
1222                        scope,
1223                        interval,
1224                        max_executions,
1225                        instruction,
1226                        context,
1227                    )
1228                } else {
1229                    ScheduledTask::new_one_shot(
1230                        crate::state::TaskId::new(0), // Temporary, will be assigned by add_task
1231                        task_id.clone(),
1232                        scope,
1233                        delay,
1234                        instruction,
1235                        context,
1236                    )
1237                }?;
1238
1239                task.next_execution = crate::state::task::checked_task_deadline(delay)?;
1240
1241                let task_id_num = self.state.add_task(task).await;
1242
1243                // TODO: Add script configuration support for standalone scheduled tasks
1244                // For now, tasks use LLM by default. Script support will be added in a future iteration.
1245                let _ = script_runtime; // Silence unused variable warning
1246                let _ = script_inline; // Silence unused variable warning
1247                let _ = script_handles; // Silence unused variable warning
1248
1249                if recurring {
1250                    let interval = interval_secs.unwrap_or(delay);
1251                    let max_info = if let Some(max) = max_executions {
1252                        format!(" (max {} executions)", max)
1253                    } else {
1254                        String::new()
1255                    };
1256                    let _ = status_tx.send(format!(
1257                        "[TASK] Scheduled recurring task '{}' (ID: {}) to execute every {}s{}",
1258                        task_id, task_id_num, interval, max_info
1259                    ));
1260                } else {
1261                    let _ = status_tx.send(format!(
1262                        "[TASK] Scheduled one-shot task '{}' (ID: {}) to execute in {}s",
1263                        task_id, task_id_num, delay
1264                    ));
1265                }
1266            }
1267            CommonAction::CancelTask { task_id } => {
1268                if let Some(task) = self.state.get_task(&task_id).await {
1269                    self.state.remove_task(task.id).await;
1270                    let _ = status_tx.send(format!("[TASK] Cancelled task '{}'", task_id));
1271                } else {
1272                    let _ = status_tx.send(format!("[WARN] Task '{}' not found", task_id));
1273                }
1274            }
1275            CommonAction::OpenClient {
1276                protocol,
1277                remote_addr,
1278                instruction,
1279                startup_params,
1280                initial_memory,
1281                event_handlers,
1282                scheduled_tasks,
1283                feedback_instructions,
1284            } => {
1285                // Documentation gate (off by default, see REQUIRE_DOCS_FOR_OPEN_ACTIONS):
1286                // when enabled, the first open_client of the process fetches the
1287                // protocol documentation and returns DocumentationRequired, which
1288                // makes the model retry the same action with the docs in context.
1289                if REQUIRE_DOCS_FOR_OPEN_ACTIONS && !self.state.is_client_docs_read().await {
1290                    // Fetch documentation for this protocol
1291                    use crate::llm::actions::tools::{execute_tool, ToolAction};
1292                    let doc_tool = ToolAction::ReadDocumentation {
1293                        protocols: vec![protocol.clone()],
1294                        protocol: None,
1295                    };
1296
1297                    // Execute the tool to get documentation
1298                    let approval_tx = self.state.get_web_approval_channel().await;
1299                    let web_search_mode = self.state.get_web_search_mode().await;
1300                    let tool_result = execute_tool(
1301                        &doc_tool,
1302                        approval_tx.as_ref(),
1303                        web_search_mode,
1304                        Some(&self.state),
1305                    )
1306                    .await;
1307
1308                    if tool_result.success {
1309                        // Mark docs as read so we don't loop forever
1310                        self.state
1311                            .mark_client_protocols_documented(&[protocol.clone()])
1312                            .await;
1313
1314                        // Build the original action JSON for reference
1315                        let original_action = serde_json::json!({
1316                            "type": "open_client",
1317                            "protocol": protocol,
1318                            "remote_addr": remote_addr,
1319                            "instruction": instruction,
1320                            "startup_params": startup_params,
1321                            "initial_memory": initial_memory,
1322                            "event_handlers": event_handlers,
1323                            "scheduled_tasks": scheduled_tasks,
1324                            "feedback_instructions": feedback_instructions,
1325                        });
1326
1327                        // Return DocumentationRequired error with the documentation
1328                        return Err(ActionExecutionError::DocumentationRequired {
1329                            action_type: "open_client".to_string(),
1330                            protocol: protocol.clone(),
1331                            documentation: tool_result.result,
1332                            original_action,
1333                        });
1334                    } else {
1335                        // If documentation fetch fails, log warning but proceed
1336                        let _ = status_tx.send(format!(
1337                            "[WARN] Failed to fetch documentation for {}: {}. Proceeding anyway.",
1338                            protocol, tool_result.result
1339                        ));
1340                    }
1341                }
1342
1343                let prepared_tasks = crate::state::task::prepare_tasks(
1344                    scheduled_tasks.as_deref(),
1345                    crate::state::task::TaskScope::Global,
1346                )?;
1347
1348                // Log detailed summary of the open_client action
1349                log_open_client_summary(
1350                    status_tx,
1351                    &protocol,
1352                    &remote_addr,
1353                    &instruction,
1354                    &startup_params,
1355                    &initial_memory,
1356                    &event_handlers,
1357                    &scheduled_tasks,
1358                    &feedback_instructions,
1359                );
1360
1361                use crate::state::client::{ClientInstance, ClientStatus};
1362
1363                // Save copies for error handling before values are moved
1364                let event_handlers_clone = event_handlers.clone();
1365                let feedback_instructions_clone = feedback_instructions.clone();
1366                let initial_memory_clone = initial_memory.clone();
1367
1368                // Parse the event-handler configuration *before* the client is registered.
1369                //
1370                // Parsing rejects unknown action names, unknown handler types and malformed
1371                // `{{event.…}}` references — all of them things the model gets wrong — so this
1372                // is a routinely reachable failure, not a theoretical one. It used to run
1373                // after `add_client`, which meant every rejection left an orphan client row
1374                // behind in `Connecting`, exactly the way undeclared startup parameters used
1375                // to leave an orphan server. Validate first, register nothing on failure.
1376                let parsed_event_handlers = match event_handlers {
1377                    Some(handlers_json) => match Self::parse_event_handlers(handlers_json) {
1378                        Ok(config) => Some(config),
1379                        Err(e) => {
1380                            // Build the original action JSON for reference
1381                            let original_action = serde_json::json!({
1382                                "type": "open_client",
1383                                "protocol": protocol,
1384                                "remote_addr": remote_addr,
1385                                "instruction": instruction,
1386                                "startup_params": startup_params,
1387                                "initial_memory": initial_memory_clone,
1388                                "event_handlers": event_handlers_clone,
1389                                "scheduled_tasks": scheduled_tasks,
1390                                "feedback_instructions": feedback_instructions_clone,
1391                            });
1392
1393                            let error_msg = e.to_string();
1394
1395                            // Return error instead of just warning - invalid config should fail
1396                            let _ = status_tx.send(format!(
1397                                "[ERROR] Invalid event handler configuration: {}",
1398                                error_msg
1399                            ));
1400                            return Err(ActionExecutionError::InvalidActionParameters {
1401                                action_type: "open_client".to_string(),
1402                                parameter_name: event_handler_parameter_name(&error_msg)
1403                                    .to_string(),
1404                                error_message: error_msg,
1405                                original_action,
1406                            });
1407                        }
1408                    },
1409                    None => None,
1410                };
1411
1412                // Create client instance with temporary ID (add_client will assign real ID)
1413                let mut client = ClientInstance::new(
1414                    crate::state::ClientId::new(0),
1415                    remote_addr.clone(),
1416                    protocol.clone(),
1417                    instruction.clone(),
1418                );
1419
1420                // Set optional fields
1421                if let Some(mem) = initial_memory {
1422                    client.memory = mem;
1423                }
1424                client.startup_params = startup_params.clone();
1425                client.feedback_instructions = feedback_instructions;
1426
1427                // Add client to state (this allocates the real client ID)
1428                let client_id = self.state.add_client(client).await;
1429
1430                // Apply the configuration parsed above. Parsing already happened, before
1431                // `add_client`, so a rejected configuration cannot leave an orphan client.
1432                if let Some(config) = parsed_event_handlers {
1433                    self.state
1434                        .set_client_event_handler_config(client_id, Some(config))
1435                        .await;
1436                    let _ = status_tx
1437                        .send("[INFO] Event handler configuration applied to client".to_string());
1438                }
1439
1440                let _ = status_tx.send(format!(
1441                    "[CLIENT] Opening {} client #{} to {}...",
1442                    protocol,
1443                    client_id.as_u32(),
1444                    remote_addr
1445                ));
1446
1447                // Start the client connection
1448                let llm_client = self.llm.clone();
1449                let status_tx_clone = status_tx.clone();
1450                match crate::cli::client_startup::start_client_by_id(
1451                    &self.state,
1452                    client_id,
1453                    &llm_client,
1454                    &status_tx_clone,
1455                )
1456                .await
1457                {
1458                    Ok(_) => {
1459                        // Client started successfully
1460                        let _ = status_tx.send(format!(
1461                            "[CLIENT] {} client #{} connected",
1462                            protocol,
1463                            client_id.as_u32()
1464                        ));
1465
1466                        for mut task in prepared_tasks {
1467                            task.scope = crate::state::task::TaskScope::Client(client_id);
1468                            let task_name = task.name.clone();
1469                            let task_id_num = self.state.add_task(task).await;
1470                            let _ = status_tx.send(format!(
1471                                "[TASK] Created task '{}' (ID: {}) for client #{}",
1472                                task_name,
1473                                task_id_num,
1474                                client_id.as_u32()
1475                            ));
1476                        }
1477                    }
1478                    Err(e) => {
1479                        // Connection failed
1480                        self.state
1481                            .update_client_status(client_id, ClientStatus::Error(e.to_string()))
1482                            .await;
1483                        let _ = status_tx.send(format!(
1484                            "[ERROR] Failed to connect {} client #{}: {}",
1485                            protocol,
1486                            client_id.as_u32(),
1487                            e
1488                        ));
1489                        return Err(e);
1490                    }
1491                }
1492
1493                let _ = status_tx.send("__UPDATE_UI__".to_string());
1494            }
1495            CommonAction::CloseClient { client_id } => {
1496                use crate::state::client::ClientStatus;
1497
1498                let cid = crate::state::ClientId::new(client_id);
1499
1500                // Mark client as Disconnected
1501                self.state
1502                    .update_client_status(cid, ClientStatus::Disconnected)
1503                    .await;
1504                let _ = status_tx.send(format!("[CLIENT] Closed client #{}", cid.as_u32()));
1505
1506                // Clean up tasks associated with this client
1507                self.state.cleanup_client_tasks(cid).await;
1508                let _ = status_tx.send(format!(
1509                    "[TASK] Cleaned up tasks for client #{}",
1510                    cid.as_u32()
1511                ));
1512
1513                let _ = status_tx.send("__UPDATE_UI__".to_string());
1514            }
1515            CommonAction::CloseAllClients => {
1516                use crate::state::client::ClientStatus;
1517
1518                let client_ids = self.state.get_all_client_ids().await;
1519
1520                for client_id in client_ids {
1521                    // Mark client as Disconnected
1522                    self.state
1523                        .update_client_status(client_id, ClientStatus::Disconnected)
1524                        .await;
1525                    let _ =
1526                        status_tx.send(format!("[CLIENT] Closed client #{}", client_id.as_u32()));
1527
1528                    // Clean up tasks associated with this client
1529                    self.state.cleanup_client_tasks(client_id).await;
1530                    let _ = status_tx.send(format!(
1531                        "[TASK] Cleaned up tasks for client #{}",
1532                        client_id.as_u32()
1533                    ));
1534                }
1535
1536                let _ = status_tx.send("__UPDATE_UI__".to_string());
1537            }
1538            CommonAction::CloseConnectionById { connection_id } => {
1539                use crate::server::connection::ConnectionId;
1540
1541                let conn_id = ConnectionId::new(connection_id);
1542                let all_servers = self.state.get_all_servers().await;
1543
1544                let mut found = false;
1545                for server in all_servers {
1546                    if server.connections.contains_key(&conn_id) {
1547                        self.state
1548                            .close_connection_on_server(server.id, conn_id)
1549                            .await;
1550                        let _ = status_tx.send(format!(
1551                            "[CONNECTION] Closed connection #{} on server #{}",
1552                            connection_id,
1553                            server.id.as_u32()
1554                        ));
1555                        found = true;
1556                        break;
1557                    }
1558                }
1559
1560                if !found {
1561                    let _ =
1562                        status_tx.send(format!("[ERROR] Connection #{} not found", connection_id));
1563                }
1564
1565                let _ = status_tx.send("__UPDATE_UI__".to_string());
1566            }
1567            CommonAction::ReconnectClient { client_id } => {
1568                let cid = crate::state::ClientId::new(client_id);
1569
1570                // Reconnect the client
1571                let llm_client = self.llm.clone();
1572                let status_tx_clone = status_tx.clone();
1573
1574                let _ =
1575                    status_tx.send(format!("[CLIENT] Reconnecting client #{}...", cid.as_u32()));
1576
1577                match crate::cli::client_startup::start_client_by_id(
1578                    &self.state,
1579                    cid,
1580                    &llm_client,
1581                    &status_tx_clone,
1582                )
1583                .await
1584                {
1585                    Ok(_) => {
1586                        let _ = status_tx
1587                            .send(format!("[CLIENT] Client #{} reconnected", cid.as_u32()));
1588                    }
1589                    Err(e) => {
1590                        let _ = status_tx.send(format!(
1591                            "[ERROR] Failed to reconnect client #{}: {}",
1592                            cid.as_u32(),
1593                            e
1594                        ));
1595                        return Err(e);
1596                    }
1597                }
1598
1599                let _ = status_tx.send("__UPDATE_UI__".to_string());
1600            }
1601            CommonAction::UpdateClientInstruction {
1602                client_id,
1603                instruction,
1604            } => {
1605                let cid = crate::state::ClientId::new(client_id);
1606
1607                // Update client instruction
1608                self.state
1609                    .set_instruction_for_client(cid, instruction.clone())
1610                    .await;
1611
1612                let _ = status_tx.send(format!(
1613                    "[CLIENT] Updated instruction for client #{}",
1614                    cid.as_u32()
1615                ));
1616                let _ = status_tx.send(format!("[CLIENT] New instruction: {}", instruction));
1617                let _ = status_tx.send("__UPDATE_UI__".to_string());
1618            }
1619            CommonAction::UpdateServer {
1620                server_id,
1621                port,
1622                host,
1623                interface,
1624                mac_address,
1625                send_first,
1626                instruction,
1627                initial_memory,
1628                startup_params,
1629                event_handlers,
1630                scheduled_tasks,
1631                feedback_instructions,
1632            } => {
1633                use crate::cli::management::{self, ServerForm};
1634                let form = ServerForm {
1635                    protocol: String::new(), // immutable; empty means "unchanged"
1636                    port,
1637                    host,
1638                    interface,
1639                    mac_address,
1640                    send_first,
1641                    instruction,
1642                    initial_memory,
1643                    startup_params,
1644                    event_handlers,
1645                    scheduled_tasks,
1646                    feedback_instructions,
1647                };
1648                match management::update_server(
1649                    &self.state,
1650                    crate::state::ServerId::new(server_id),
1651                    form,
1652                    status_tx.clone(),
1653                )
1654                .await
1655                {
1656                    Ok(outcome) => {
1657                        let _ = status_tx.send(format!("[INFO] {}", outcome.summary));
1658                    }
1659                    Err(e) => {
1660                        let _ = status_tx.send(format!("[ERROR] Failed to update server: {}", e));
1661                        return Err(crate::events::ActionExecutionError::Fatal(e));
1662                    }
1663                }
1664            }
1665            CommonAction::UpdateClient {
1666                client_id,
1667                remote_addr,
1668                instruction,
1669                initial_memory,
1670                startup_params,
1671                event_handlers,
1672                scheduled_tasks,
1673                feedback_instructions,
1674            } => {
1675                use crate::cli::management::{self, ClientForm};
1676                let form = ClientForm {
1677                    protocol: String::new(), // immutable; empty means "unchanged"
1678                    remote_addr,
1679                    instruction,
1680                    initial_memory,
1681                    startup_params,
1682                    event_handlers,
1683                    scheduled_tasks,
1684                    feedback_instructions,
1685                };
1686                match management::update_client(
1687                    &self.state,
1688                    crate::state::ClientId::new(client_id),
1689                    form,
1690                    self.llm.clone(),
1691                    status_tx.clone(),
1692                )
1693                .await
1694                {
1695                    Ok(outcome) => {
1696                        let _ = status_tx.send(format!("[INFO] {}", outcome.summary));
1697                    }
1698                    Err(e) => {
1699                        let _ = status_tx.send(format!("[ERROR] Failed to update client: {}", e));
1700                        return Err(crate::events::ActionExecutionError::Fatal(e));
1701                    }
1702                }
1703            }
1704
1705            #[cfg(feature = "sqlite")]
1706            CommonAction::CreateDatabase {
1707                name,
1708                is_memory,
1709                owner,
1710                schema_ddl,
1711            } => {
1712                use crate::state::DatabaseOwner;
1713
1714                // The name becomes a filesystem path (`./netget_db_<name>.db`) that
1715                // `delete_database` later removes, so a model-supplied `../` or absolute
1716                // path would write and destroy files outside the working directory.
1717                // Reject, never sanitise: a rewritten name would leave the model referring
1718                // to a database that does not exist under that name.
1719                if let Err(e) = crate::state::sqlite::validate_database_name(&name) {
1720                    let _ = status_tx.send(format!("[ERROR] {}", e));
1721                    return Ok(());
1722                }
1723
1724                // Construct path based on is_memory flag
1725                let db_path = if is_memory {
1726                    crate::state::sqlite::MEMORY_DATABASE_PATH.to_string()
1727                } else {
1728                    match crate::state::sqlite::database_file_path(&name) {
1729                        Ok(p) => p,
1730                        Err(e) => {
1731                            let _ = status_tx.send(format!("[ERROR] {}", e));
1732                            return Ok(());
1733                        }
1734                    }
1735                };
1736
1737                // Determine owner (default to global)
1738                let db_owner = if let Some(owner_str) = owner {
1739                    if owner_str == "global" {
1740                        DatabaseOwner::Global
1741                    } else if let Some(id_str) = owner_str.strip_prefix("server-") {
1742                        if let Ok(id) = id_str.parse::<u32>() {
1743                            DatabaseOwner::Server(crate::state::ServerId::new(id))
1744                        } else {
1745                            let _ = status_tx
1746                                .send(format!("[ERROR] Invalid server ID in owner: {}", owner_str));
1747                            return Ok(());
1748                        }
1749                    } else if let Some(id_str) = owner_str.strip_prefix("client-") {
1750                        if let Ok(id) = id_str.parse::<u32>() {
1751                            DatabaseOwner::Client(crate::state::ClientId::new(id))
1752                        } else {
1753                            let _ = status_tx
1754                                .send(format!("[ERROR] Invalid client ID in owner: {}", owner_str));
1755                            return Ok(());
1756                        }
1757                    } else {
1758                        let _ =
1759                            status_tx.send(format!("[ERROR] Invalid owner format: {}", owner_str));
1760                        return Ok(());
1761                    }
1762                } else {
1763                    // Default to first server or global
1764                    if let Some(sid) = self.state.get_first_server_id().await {
1765                        DatabaseOwner::Server(sid)
1766                    } else if let Some(cid) = self.state.get_first_client_id().await {
1767                        DatabaseOwner::Client(cid)
1768                    } else {
1769                        DatabaseOwner::Global
1770                    }
1771                };
1772
1773                // Create database
1774                match self
1775                    .state
1776                    .create_database(
1777                        name.clone(),
1778                        db_path.clone(),
1779                        db_owner.clone(),
1780                        schema_ddl.as_deref(),
1781                    )
1782                    .await
1783                {
1784                    Ok(db_id) => {
1785                        let _ = status_tx.send(format!(
1786                            "[DB] Created database '{}' ({}) at {} (owner: {})",
1787                            name, db_id, db_path, db_owner
1788                        ));
1789
1790                        // Show schema if provided
1791                        if let Some(db) = self.state.get_database(db_id).await {
1792                            if !db.tables.is_empty() {
1793                                let _ =
1794                                    status_tx.send(format!("[DB] Schema: {}", db.schema_summary()));
1795                            }
1796                        }
1797                    }
1798                    Err(e) => {
1799                        let _ = status_tx.send(format!("[ERROR] Failed to create database: {}", e));
1800                    }
1801                }
1802            }
1803
1804            #[cfg(feature = "sqlite")]
1805            CommonAction::DeleteDatabase { database_id } => {
1806                let db_id = crate::state::DatabaseId::new(database_id);
1807
1808                match self.state.delete_database(db_id).await {
1809                    Ok(_) => {
1810                        let _ = status_tx.send(format!("[DB] Deleted database {}", db_id));
1811                    }
1812                    Err(e) => {
1813                        let _ = status_tx.send(format!("[ERROR] Failed to delete database: {}", e));
1814                    }
1815                }
1816            }
1817
1818            CommonAction::ShowMessage { message } => {
1819                let _ = status_tx.send(format!("[CLIENT] {}", message));
1820            }
1821            CommonAction::ProvideFeedback { .. } => {
1822                // ProvideFeedback is handled by the action executor, not here
1823                // This match arm exists to satisfy exhaustiveness checking
1824            }
1825        }
1826
1827        Ok(())
1828    }
1829
1830    /// Parse event handlers from JSON array into EventHandlerConfig
1831    ///
1832    /// Static handlers are validated here rather than at the first packet:
1833    /// * every action name must exist in the action catalog of the protocol(s) that
1834    ///   declare an event matching the handler's `event_pattern` (see
1835    ///   [`action_catalog_for_pattern`]), and
1836    /// * every `{{event.…}}` reference must be well formed
1837    ///   ([`EventHandlerType::validate`]).
1838    ///
1839    /// Both used to fail silently at dispatch time — the peer got the protocol default,
1840    /// no error reached the caller, and the access log recorded the action as though it
1841    /// had run. Reporting them here means `start_server` / `open_server` rejects the
1842    /// configuration outright.
1843    pub fn parse_event_handlers(
1844        handlers_json: Vec<serde_json::Value>,
1845    ) -> Result<crate::scripting::EventHandlerConfig> {
1846        use crate::scripting::{EventHandler, EventHandlerConfig, EventHandlerType, EventPattern};
1847
1848        let mut config = EventHandlerConfig::new();
1849
1850        for handler_json in handlers_json {
1851            // Parse event_pattern field
1852            let event_pattern = if let Some(pattern_str) =
1853                handler_json.get("event_pattern").and_then(|v| v.as_str())
1854            {
1855                EventPattern::from(pattern_str)
1856            } else {
1857                // Default to wildcard if not specified
1858                EventPattern::wildcard()
1859            };
1860
1861            // Parse handler field
1862            let handler_type_json = handler_json.get("handler").ok_or_else(|| {
1863                anyhow::anyhow!("Missing 'handler' field in event handler configuration")
1864            })?;
1865
1866            let handler_type_str = handler_type_json
1867                .get("type")
1868                .and_then(|v| v.as_str())
1869                .ok_or_else(|| anyhow::anyhow!("Missing 'type' field in handler configuration"))?;
1870
1871            let handler_type = match handler_type_str {
1872                "llm" => {
1873                    let instruction = handler_type_json
1874                        .get("instruction")
1875                        .and_then(|v| v.as_str())
1876                        .ok_or_else(|| {
1877                            anyhow::anyhow!("Missing 'instruction' field for llm handler - instruction is required for LLM event handlers")
1878                        })?;
1879                    EventHandlerType::llm(instruction)
1880                }
1881                "script" => {
1882                    let language = handler_type_json
1883                        .get("language")
1884                        .and_then(|v| v.as_str())
1885                        .ok_or_else(|| {
1886                            anyhow::anyhow!("Missing 'language' field for script handler")
1887                        })?;
1888                    let code = handler_type_json
1889                        .get("code")
1890                        .and_then(|v| v.as_str())
1891                        .ok_or_else(|| {
1892                            anyhow::anyhow!("Missing 'code' field for script handler")
1893                        })?;
1894                    // Opt-in resident (persistent) mode: keeps one interpreter
1895                    // alive per scope so in-process state survives across events.
1896                    let resident = handler_type_json
1897                        .get("resident")
1898                        .and_then(|v| v.as_bool())
1899                        .unwrap_or(false);
1900                    if resident {
1901                        let scope = handler_type_json
1902                            .get("scope")
1903                            .and_then(|v| v.as_str())
1904                            .map(|s| s.to_string());
1905                        EventHandlerType::script_resident(language, code, scope)
1906                    } else {
1907                        EventHandlerType::script(language, code)
1908                    }
1909                }
1910                "static" => {
1911                    let actions = handler_type_json
1912                        .get("actions")
1913                        .and_then(|v| v.as_array())
1914                        .ok_or_else(|| {
1915                            anyhow::anyhow!("Missing or invalid 'actions' field for static handler")
1916                        })?;
1917                    EventHandlerType::static_response(actions.clone())
1918                }
1919                // A human answers each matched event at the dashboard; no answer
1920                // within the timeout fails closed.
1921                "manual" => {
1922                    let timeout_secs = match handler_type_json.get("timeout_secs") {
1923                        None => crate::scripting::DEFAULT_MANUAL_TIMEOUT_SECS,
1924                        Some(v) => v.as_u64().filter(|t| *t > 0).ok_or_else(|| {
1925                            anyhow::anyhow!(
1926                                "'timeout_secs' for a manual handler must be a positive integer \
1927                                 number of seconds"
1928                            )
1929                        })?,
1930                    };
1931                    EventHandlerType::manual(timeout_secs)
1932                }
1933                _ => anyhow::bail!("Unknown handler type: {}", handler_type_str),
1934            };
1935
1936            // Reject a malformed `{{event.…}}` reference now rather than at the first
1937            // packet. Only the shape is checkable without an event; whether a well-formed
1938            // reference resolves is decided by `interpolate_actions` at dispatch.
1939            //
1940            // The *field name* of a well-formed reference is deliberately NOT checked
1941            // here, even though a specific `event_pattern` resolves to an event type whose
1942            // `parameters` list looks like the right oracle. It is not one yet: several
1943            // protocols emit event data keys they never declare — `http_request` declares
1944            // `method`/`path`/`query_string`/`query`/`headers` but also emits `body`,
1945            // `body_bytes` and `body_is_binary` (`src/server/http/mod.rs`) — so rejecting
1946            // undeclared names would break handlers that work today. This becomes
1947            // checkable once every protocol's declared `parameters` are known to cover the
1948            // keys it actually emits.
1949            handler_type.validate().map_err(|e| {
1950                anyhow::anyhow!(
1951                    "Invalid event handler for {}: {}",
1952                    describe_pattern(&event_pattern),
1953                    e
1954                )
1955            })?;
1956
1957            // Reject action names the protocol cannot execute.
1958            if let EventHandlerType::Static { ref actions } = handler_type {
1959                validate_static_action_names(&event_pattern, actions)?;
1960            }
1961
1962            config.add_handler(EventHandler::new(event_pattern, handler_type));
1963        }
1964
1965        Ok(config)
1966    }
1967
1968    async fn handle_quit(&mut self, ui: &mut App) -> Result<()> {
1969        ui.add_llm_message("Quitting...".to_string());
1970        // The main event loop will handle the actual quit
1971        Ok(())
1972    }
1973
1974    async fn handle_stop_all(&mut self, ui: &mut App) -> Result<()> {
1975        use crate::state::client::ClientStatus;
1976        use crate::state::server::ServerStatus;
1977
1978        ui.add_llm_message("Stopping all servers, connections, and clients...".to_string());
1979
1980        // Stop all servers
1981        let server_ids: Vec<_> = self.state.get_all_server_ids().await;
1982        for server_id in server_ids {
1983            self.state
1984                .update_server_status(server_id, ServerStatus::Stopped)
1985                .await;
1986            self.state.cleanup_server_tasks(server_id).await;
1987            crate::scripting::ResidentScriptManager::shutdown_server(server_id.as_u32()).await;
1988            ui.add_llm_message(format!("[SERVER] Stopped server #{}", server_id.as_u32()));
1989        }
1990
1991        // Stop all clients
1992        let client_ids: Vec<_> = self.state.get_all_client_ids().await;
1993        for client_id in client_ids {
1994            self.state
1995                .update_client_status(client_id, ClientStatus::Disconnected)
1996                .await;
1997            self.state.cleanup_client_tasks(client_id).await;
1998            ui.add_llm_message(format!("[CLIENT] Stopped client #{}", client_id.as_u32()));
1999        }
2000
2001        ui.add_llm_message("All servers and clients stopped.".to_string());
2002        Ok(())
2003    }
2004
2005    async fn handle_stop_by_id(&mut self, id: u32, ui: &mut App) -> Result<()> {
2006        use crate::server::connection::ConnectionId;
2007        use crate::state::client::{ClientId, ClientStatus};
2008        use crate::state::server::{ServerId, ServerStatus};
2009
2010        // Try to find what type of entity this ID corresponds to
2011        let mut found = false;
2012
2013        // Check if it's a server
2014        let server_id = ServerId::new(id);
2015        if self.state.get_server(server_id).await.is_some() {
2016            self.state
2017                .update_server_status(server_id, ServerStatus::Stopped)
2018                .await;
2019            self.state.cleanup_server_tasks(server_id).await;
2020            ui.add_llm_message(format!("[SERVER] Stopped server #{}", id));
2021            found = true;
2022        }
2023
2024        // Check if it's a client
2025        let client_id = ClientId::new(id);
2026        if self.state.get_client(client_id).await.is_some() {
2027            self.state
2028                .update_client_status(client_id, ClientStatus::Disconnected)
2029                .await;
2030            self.state.cleanup_client_tasks(client_id).await;
2031            ui.add_llm_message(format!("[CLIENT] Stopped client #{}", id));
2032            found = true;
2033        }
2034
2035        // Check if it's a connection
2036        let connection_id = ConnectionId::new(id);
2037        let all_servers = self.state.get_all_servers().await;
2038        for server in all_servers {
2039            if server.connections.contains_key(&connection_id) {
2040                self.state
2041                    .close_connection_on_server(server.id, connection_id)
2042                    .await;
2043                ui.add_llm_message(format!(
2044                    "[CONNECTION] Closed connection #{} on server #{}",
2045                    id,
2046                    server.id.as_u32()
2047                ));
2048                found = true;
2049                break;
2050            }
2051        }
2052
2053        if !found {
2054            ui.add_llm_message(format!(
2055                "No server, client, or connection found with ID #{}",
2056                id
2057            ));
2058        }
2059
2060        Ok(())
2061    }
2062
2063    async fn handle_status(&mut self, ui: &mut App) -> Result<()> {
2064        let summary = self.state.get_summary().await;
2065        ui.add_llm_message(format!("Status: {summary}"));
2066
2067        // Show instruction for first server
2068        if let Some(server_id) = self.state.get_first_server_id().await {
2069            if let Some(instruction) = self.state.get_instruction(server_id).await {
2070                if !instruction.is_empty() {
2071                    ui.add_llm_message(format!(
2072                        "Server #{} instruction: {}",
2073                        server_id.as_u32(),
2074                        instruction
2075                    ));
2076                }
2077            }
2078        } else {
2079            ui.add_llm_message("No servers running".to_string());
2080        }
2081
2082        Ok(())
2083    }
2084
2085    async fn handle_show_model(&mut self, ui: &mut App) -> Result<()> {
2086        let current_model = self
2087            .state
2088            .get_ollama_model()
2089            .await
2090            .unwrap_or_else(|| "None".to_string());
2091
2092        ui.add_llm_message(format!("Current model: {current_model}"));
2093        ui.add_llm_message("".to_string());
2094        ui.add_llm_message("Fetching available models...".to_string());
2095
2096        // Fetch model list from Ollama
2097        match self.llm.list_models().await {
2098            Ok(models) => {
2099                if models.is_empty() {
2100                    ui.add_llm_message("No models found. Please pull a model first.".to_string());
2101                    ui.add_llm_message("Example: ollama pull llama3.2".to_string());
2102                } else {
2103                    ui.add_llm_message(format!("Available models ({}):", models.len()));
2104                    for model in &models {
2105                        if model == &current_model {
2106                            ui.add_llm_message(format!("  * {model} (current)"));
2107                        } else {
2108                            ui.add_llm_message(format!("    {model}"));
2109                        }
2110                    }
2111                    ui.add_llm_message("".to_string());
2112                    ui.add_llm_message("To change model, use: /model <name>".to_string());
2113                }
2114            }
2115            Err(e) => {
2116                ui.add_llm_message(format!("Failed to fetch models: {e}"));
2117                ui.add_llm_message("Make sure Ollama is running.".to_string());
2118            }
2119        }
2120
2121        Ok(())
2122    }
2123
2124    async fn handle_change_model(&mut self, model: String, ui: &mut App) -> Result<()> {
2125        // Validate model exists
2126        match self.llm.list_models().await {
2127            Ok(models) => {
2128                if models.contains(&model) {
2129                    self.state.set_ollama_model(Some(model.clone())).await;
2130                    ui.add_llm_message(format!("✓ Changed model to: {model}"));
2131                } else {
2132                    ui.add_llm_message(format!("✗ Model '{model}' not found"));
2133                    ui.add_llm_message("".to_string());
2134
2135                    if models.is_empty() {
2136                        ui.add_llm_message("No models available. Pull a model first:".to_string());
2137                        ui.add_llm_message("  ollama pull llama3.2".to_string());
2138                    } else {
2139                        ui.add_llm_message("Available models:".to_string());
2140                        for available_model in &models {
2141                            ui.add_llm_message(format!("  {available_model}"));
2142                        }
2143                        ui.add_llm_message("".to_string());
2144                        ui.add_llm_message("Or pull the model:".to_string());
2145                        ui.add_llm_message(format!("  ollama pull {model}"));
2146                    }
2147                }
2148            }
2149            Err(e) => {
2150                ui.add_llm_message(format!("Failed to validate model: {e}"));
2151                ui.add_llm_message("Make sure Ollama is running.".to_string());
2152            }
2153        }
2154
2155        Ok(())
2156    }
2157
2158    async fn handle_show_backend(&mut self, ui: &mut App) -> Result<()> {
2159        let backend_type = self.llm.backend_type();
2160        let backend_url = self.llm.backend_url();
2161        let current_model = self
2162            .state
2163            .get_ollama_model()
2164            .await
2165            .unwrap_or_else(|| "None".to_string());
2166
2167        ui.add_llm_message(format!("LLM Backend: {}", backend_type));
2168        ui.add_llm_message(format!("  URL: {}", backend_url));
2169        ui.add_llm_message(format!("  Model: {}", current_model));
2170        ui.add_llm_message("".to_string());
2171        ui.add_llm_message("To switch backend:".to_string());
2172        ui.add_llm_message("  /backend ollama [url]              - Switch to Ollama".to_string());
2173        ui.add_llm_message(
2174            "  /backend openai <url> [api-key]    - Switch to OpenAI-compatible".to_string(),
2175        );
2176        Ok(())
2177    }
2178
2179    #[cfg(not(target_arch = "wasm32"))]
2180    async fn handle_set_backend(&mut self, args: String, ui: &mut App) -> Result<()> {
2181        let parts: Vec<&str> = args.splitn(3, ' ').collect();
2182
2183        match parts.first().map(|s| s.to_lowercase()).as_deref() {
2184            Some("ollama") => {
2185                let url = parts.get(1).unwrap_or(&"http://localhost:11434");
2186                let new_client = crate::llm::OllamaClient::new(url.to_string())
2187                    .with_app_state(self.state.clone());
2188                self.llm = new_client.clone();
2189                self.state.set_llm_client(new_client).await;
2190                ui.add_llm_message(format!("✓ Switched to Ollama backend: {}", url));
2191                ui.add_llm_message("  Use /model to list and select a model.".to_string());
2192            }
2193            Some("openai") => {
2194                if parts.len() < 2 {
2195                    ui.add_llm_message("✗ Usage: /backend openai <url> [api-key]".to_string());
2196                    ui.add_llm_message("  The API key can also be set via NETGET_API_KEY or OPENAI_API_KEY env vars.".to_string());
2197                    return Ok(());
2198                }
2199                let url = parts[1];
2200                let api_key = if parts.len() >= 3 {
2201                    parts[2].to_string()
2202                } else {
2203                    // Try env vars
2204                    std::env::var("NETGET_API_KEY")
2205                        .or_else(|_| std::env::var("OPENAI_API_KEY"))
2206                        .unwrap_or_default()
2207                };
2208                if api_key.is_empty() {
2209                    ui.add_llm_message("✗ API key required. Provide as third argument or set NETGET_API_KEY/OPENAI_API_KEY env var.".to_string());
2210                    return Ok(());
2211                }
2212                let new_client = crate::llm::OllamaClient::new_openai(url, &api_key)
2213                    .with_app_state(self.state.clone());
2214                self.llm = new_client.clone();
2215                self.state.set_llm_client(new_client).await;
2216                ui.add_llm_message(format!("✓ Switched to OpenAI-compatible backend: {}", url));
2217                ui.add_llm_message("  Use /model <name> to set the model.".to_string());
2218            }
2219            _ => {
2220                ui.add_llm_message("✗ Unknown backend. Use: /backend ollama [url] or /backend openai <url> [api-key]".to_string());
2221            }
2222        }
2223        Ok(())
2224    }
2225
2226    /// The browser build has exactly one backend, the page's LLM bridge, so there is nothing
2227    /// to switch to.
2228    #[cfg(target_arch = "wasm32")]
2229    async fn handle_set_backend(&mut self, _args: String, ui: &mut App) -> Result<()> {
2230        ui.add_llm_message(
2231            "✗ The browser build talks to the page's model bridge; pick the model on the page."
2232                .to_string(),
2233        );
2234        Ok(())
2235    }
2236
2237    async fn handle_show_docs(&mut self, protocol: Option<String>, ui: &mut App) -> Result<()> {
2238        use crate::docs;
2239
2240        if let Some(protocol_name) = protocol {
2241            // Show detailed docs for specific protocol
2242            match docs::show_protocol_docs(&protocol_name) {
2243                Ok(docs_text) => {
2244                    // Split into lines and add each line to the UI
2245                    for line in docs_text.lines() {
2246                        ui.add_llm_message(line.to_string());
2247                    }
2248                }
2249                Err(err_msg) => {
2250                    ui.add_llm_message(err_msg);
2251                }
2252            }
2253        } else {
2254            // List all protocols
2255            let docs_text = docs::list_all_protocols();
2256            for line in docs_text.lines() {
2257                ui.add_llm_message(line.to_string());
2258            }
2259        }
2260
2261        Ok(())
2262    }
2263
2264    /// Handle show environment command - display environment information
2265    async fn handle_show_environment(&mut self, ui: &mut App) -> Result<()> {
2266        use crate::protocol::{registry, CLIENT_REGISTRY};
2267
2268        ui.add_llm_message("".to_string());
2269        ui.add_llm_message("=== NetGet Environment ===".to_string());
2270        ui.add_llm_message("".to_string());
2271
2272        // System information
2273        ui.add_llm_message("System Information:".to_string());
2274        ui.add_llm_message(format!("  OS: {}", std::env::consts::OS));
2275        ui.add_llm_message(format!("  Architecture: {}", std::env::consts::ARCH));
2276        ui.add_llm_message(format!(
2277            "  Rust Version: {}",
2278            env!("CARGO_PKG_RUST_VERSION", "unknown")
2279        ));
2280        ui.add_llm_message(format!("  NetGet Version: {}", env!("CARGO_PKG_VERSION")));
2281        ui.add_llm_message("".to_string());
2282
2283        // LLM configuration
2284        let model = self
2285            .state
2286            .get_ollama_model()
2287            .await
2288            .unwrap_or_else(|| "None".to_string());
2289        let web_search_mode = self.state.get_web_search_mode().await;
2290        let scripting_mode = self.state.get_selected_scripting_mode().await;
2291
2292        ui.add_llm_message("LLM Configuration:".to_string());
2293        ui.add_llm_message(format!("  Ollama Model: {}", model));
2294        ui.add_llm_message(format!("  Web Search: {}", web_search_mode.as_str()));
2295        ui.add_llm_message(format!("  Scripting: {}", scripting_mode.as_str()));
2296        ui.add_llm_message("".to_string());
2297
2298        // Get system capabilities
2299        let caps = self.state.get_system_capabilities().await;
2300
2301        // Check server protocols
2302        let server_excluded = registry().get_excluded_protocols(&caps);
2303        let server_available = registry().get_available_protocols(&caps);
2304
2305        // Check client protocols
2306        let client_excluded = CLIENT_REGISTRY.get_excluded_protocols(&caps);
2307        let client_available = CLIENT_REGISTRY.get_available_protocols(&caps);
2308
2309        // System capabilities summary
2310        ui.add_llm_message("System Capabilities:".to_string());
2311        ui.add_llm_message(format!(
2312            "  Root Access: {}",
2313            if caps.is_root { "Yes" } else { "No" }
2314        ));
2315        ui.add_llm_message(format!(
2316            "  Privileged Ports (<1024): {}",
2317            if caps.can_bind_privileged_ports {
2318                "Yes"
2319            } else {
2320                "No"
2321            }
2322        ));
2323        ui.add_llm_message(format!(
2324            "  Raw Socket Access (pcap): {}",
2325            if caps.has_raw_socket_access {
2326                "Yes"
2327            } else {
2328                "No"
2329            }
2330        ));
2331        ui.add_llm_message("".to_string());
2332
2333        // Summary
2334        let total_server_protocols = server_available.len() + server_excluded.len();
2335        let total_client_protocols = client_available.len() + client_excluded.len();
2336
2337        ui.add_llm_message(format!(
2338            "Server Protocols: {} available, {} excluded (total {})",
2339            server_available.len(),
2340            server_excluded.len(),
2341            total_server_protocols
2342        ));
2343        ui.add_llm_message(format!(
2344            "Client Protocols: {} available, {} excluded (total {})",
2345            client_available.len(),
2346            client_excluded.len(),
2347            total_client_protocols
2348        ));
2349        ui.add_llm_message("".to_string());
2350
2351        // Show excluded protocols if any
2352        if !server_excluded.is_empty() {
2353            ui.add_llm_message("Excluded Server Protocols:".to_string());
2354            let mut excluded_names: Vec<_> = server_excluded.keys().cloned().collect();
2355            excluded_names.sort();
2356
2357            for protocol_name in excluded_names {
2358                if let Some(missing_deps) = server_excluded.get(&protocol_name) {
2359                    ui.add_llm_message(format!("  {}", protocol_name));
2360                    for dep in missing_deps {
2361                        ui.add_llm_message(format!("    ✗ {}: {}", dep.name(), dep.description()));
2362                        ui.add_llm_message(format!("      → {}", dep.installation_hint()));
2363                    }
2364                }
2365            }
2366            ui.add_llm_message("".to_string());
2367        }
2368
2369        if !client_excluded.is_empty() {
2370            ui.add_llm_message("Excluded Client Protocols:".to_string());
2371            let mut excluded_names: Vec<_> = client_excluded.keys().cloned().collect();
2372            excluded_names.sort();
2373
2374            for protocol_name in excluded_names {
2375                if let Some(missing_deps) = client_excluded.get(&protocol_name) {
2376                    ui.add_llm_message(format!("  {}", protocol_name));
2377                    for dep in missing_deps {
2378                        ui.add_llm_message(format!("    ✗ {}: {}", dep.name(), dep.description()));
2379                        ui.add_llm_message(format!("      → {}", dep.installation_hint()));
2380                    }
2381                }
2382            }
2383            ui.add_llm_message("".to_string());
2384        }
2385
2386        if server_excluded.is_empty() && client_excluded.is_empty() {
2387            ui.add_llm_message("✓ All protocols are available!".to_string());
2388            ui.add_llm_message("".to_string());
2389        }
2390
2391        Ok(())
2392    }
2393
2394    async fn handle_save(&mut self, name: String, id: Option<u32>, ui: &mut App) -> Result<()> {
2395        use crate::state::client::ClientId;
2396        use crate::state::server::ServerId;
2397        use crate::utils::save_load;
2398
2399        let path = if let Some(id_val) = id {
2400            // Save specific server or client by ID
2401            // Try server first
2402            let server_id = ServerId::new(id_val);
2403            if self.state.get_server(server_id).await.is_some() {
2404                match save_load::save_server(&self.state, server_id, &name).await {
2405                    Ok(path) => {
2406                        ui.add_llm_message(format!(
2407                            "[SAVE] Saved server #{} to: {}",
2408                            id_val,
2409                            path.display()
2410                        ));
2411                        path
2412                    }
2413                    Err(e) => {
2414                        ui.add_llm_message(format!(
2415                            "[ERROR] Failed to save server #{}: {}",
2416                            id_val, e
2417                        ));
2418                        return Ok(());
2419                    }
2420                }
2421            } else {
2422                // Try client
2423                let client_id = ClientId::new(id_val);
2424                if self.state.get_client(client_id).await.is_some() {
2425                    match save_load::save_client(&self.state, client_id, &name).await {
2426                        Ok(path) => {
2427                            ui.add_llm_message(format!(
2428                                "[SAVE] Saved client #{} to: {}",
2429                                id_val,
2430                                path.display()
2431                            ));
2432                            path
2433                        }
2434                        Err(e) => {
2435                            ui.add_llm_message(format!(
2436                                "[ERROR] Failed to save client #{}: {}",
2437                                id_val, e
2438                            ));
2439                            return Ok(());
2440                        }
2441                    }
2442                } else {
2443                    ui.add_llm_message(format!(
2444                        "[ERROR] No server or client found with ID #{}",
2445                        id_val
2446                    ));
2447                    return Ok(());
2448                }
2449            }
2450        } else {
2451            // Save all servers and clients
2452            match save_load::save_all(&self.state, &name).await {
2453                Ok(path) => {
2454                    let servers = self.state.get_all_servers().await;
2455                    let clients = self.state.get_all_clients().await;
2456                    ui.add_llm_message(format!(
2457                        "[SAVE] Saved {} server(s) and {} client(s) to: {}",
2458                        servers.len(),
2459                        clients.len(),
2460                        path.display()
2461                    ));
2462                    path
2463                }
2464                Err(e) => {
2465                    ui.add_llm_message(format!("[ERROR] Failed to save configuration: {}", e));
2466                    return Ok(());
2467                }
2468            }
2469        };
2470
2471        ui.add_llm_message(format!(
2472            "[INFO] Use '/load {}' to restore this configuration",
2473            path.display()
2474        ));
2475        Ok(())
2476    }
2477
2478    async fn handle_load(&mut self, name: String, ui: &mut App) -> Result<()> {
2479        use crate::utils::save_load;
2480
2481        // Load actions from file
2482        let actions = match save_load::load_actions(&name).await {
2483            Ok(actions) => actions,
2484            Err(e) => {
2485                ui.add_llm_message(format!("[ERROR] Failed to load file '{}': {}", name, e));
2486                return Ok(());
2487            }
2488        };
2489
2490        if actions.is_empty() {
2491            ui.add_llm_message(format!("[WARN] File '{}' contains no actions", name));
2492            return Ok(());
2493        }
2494
2495        ui.add_llm_message(format!(
2496            "[LOAD] Loading {} action(s) from: {}",
2497            actions.len(),
2498            save_load::normalize_filename(&name)
2499        ));
2500
2501        // Execute each action
2502        for (i, action) in actions.iter().enumerate() {
2503            if action.get("type").and_then(serde_json::Value::as_str) == Some("restore_session") {
2504                match save_load::restore_session(&self.state, &self.llm, &action["session"]).await {
2505                    Ok(()) => ui.add_llm_message("[LOAD] Restored session relationships".into()),
2506                    Err(error) => {
2507                        ui.add_llm_message(format!("[ERROR] Session restoration failed: {error:#}"))
2508                    }
2509                }
2510                continue;
2511            }
2512
2513            // Try to parse as common action
2514            if let Ok(common_action) = crate::llm::actions::common::CommonAction::from_json(action)
2515            {
2516                use crate::llm::actions::common::CommonAction;
2517
2518                match common_action {
2519                    CommonAction::OpenServer {
2520                        mac_address,
2521                        interface,
2522                        host,
2523                        port,
2524                        protocol,
2525                        send_first,
2526                        initial_memory,
2527                        instruction,
2528                        startup_params,
2529                        event_handlers,
2530                        scheduled_tasks,
2531                        feedback_instructions,
2532                    } => {
2533                        // Create status channel for server startup messages
2534                        // Messages will be logged via tracing macros in the spawn method
2535                        let (status_tx, _status_rx) = mpsc::unbounded_channel();
2536
2537                        // Execute open_server action via server startup
2538                        match server_startup::start_server_from_action(
2539                            &self.state,
2540                            mac_address,
2541                            interface.clone(),
2542                            host,
2543                            port,
2544                            &protocol,
2545                            send_first,
2546                            initial_memory,
2547                            instruction.clone(),
2548                            startup_params,
2549                            event_handlers,
2550                            scheduled_tasks,
2551                            feedback_instructions,
2552                            status_tx,
2553                        )
2554                        .await
2555                        {
2556                            Ok(server_id) => {
2557                                let binding_desc = if let Some(iface) = &interface {
2558                                    format!("interface {} ({})", iface, protocol)
2559                                } else if let Some(p) = port {
2560                                    format!("port {} ({})", p, protocol)
2561                                } else {
2562                                    format!("({})", protocol)
2563                                };
2564                                ui.add_llm_message(format!(
2565                                    "[LOAD] Opened server #{} on {}",
2566                                    server_id.as_u32(),
2567                                    binding_desc
2568                                ));
2569                            }
2570                            Err(e) => {
2571                                ui.add_llm_message(format!(
2572                                    "[ERROR] Failed to open server (action {}): {}",
2573                                    i + 1,
2574                                    e
2575                                ));
2576                            }
2577                        }
2578                    }
2579                    CommonAction::OpenClient {
2580                        protocol,
2581                        remote_addr,
2582                        instruction,
2583                        startup_params,
2584                        initial_memory,
2585                        event_handlers,
2586                        scheduled_tasks,
2587                        feedback_instructions,
2588                    } => {
2589                        // Execute open_client action via client startup
2590                        use crate::cli::client_startup;
2591
2592                        match client_startup::start_client_from_action(
2593                            &self.state,
2594                            &protocol,
2595                            &remote_addr,
2596                            instruction.clone(),
2597                            startup_params,
2598                            initial_memory,
2599                            event_handlers,
2600                            scheduled_tasks,
2601                            feedback_instructions,
2602                            self.llm.clone(),
2603                            None, // status_tx: /load path, drain to tracing
2604                        )
2605                        .await
2606                        {
2607                            Ok(client_id) => {
2608                                ui.add_llm_message(format!(
2609                                    "[LOAD] Opened client #{} to {} ({})",
2610                                    client_id.as_u32(),
2611                                    remote_addr,
2612                                    protocol
2613                                ));
2614                            }
2615                            Err(e) => {
2616                                ui.add_llm_message(format!(
2617                                    "[ERROR] Failed to open client (action {}): {}",
2618                                    i + 1,
2619                                    e
2620                                ));
2621                            }
2622                        }
2623                    }
2624                    _ => {
2625                        ui.add_llm_message(format!(
2626                            "[WARN] Skipping unsupported action type (action {})",
2627                            i + 1
2628                        ));
2629                    }
2630                }
2631            } else {
2632                ui.add_llm_message(format!("[WARN] Skipping invalid action (action {})", i + 1));
2633            }
2634        }
2635
2636        ui.add_llm_message("[LOAD] Configuration loaded successfully".to_string());
2637        Ok(())
2638    }
2639
2640    #[cfg(feature = "sqlite")]
2641    async fn handle_sqlite(
2642        &mut self,
2643        db_id: Option<u32>,
2644        query: Option<String>,
2645        ui: &mut App,
2646    ) -> Result<()> {
2647        match (db_id, query) {
2648            (None, None) => {
2649                // List all databases
2650                let databases = self.state.get_all_databases().await;
2651                if databases.is_empty() {
2652                    ui.add_llm_message("[DB] No databases".to_string());
2653                } else {
2654                    ui.add_llm_message(format!("[DB] {} database(s):", databases.len()));
2655                    for db in databases {
2656                        ui.add_llm_message(format!("  {}", db.schema_summary()));
2657                    }
2658                }
2659            }
2660            (Some(id), None) => {
2661                // Show schema for specific database
2662                let db_id_obj = crate::state::DatabaseId::new(id);
2663                if let Some(db) = self.state.get_database(db_id_obj).await {
2664                    ui.add_llm_message(format!("[DB] Database {}:", db_id_obj));
2665                    ui.add_llm_message(db.schema_summary());
2666                } else {
2667                    ui.add_llm_message(format!("[ERROR] Database {} not found", db_id_obj));
2668                }
2669            }
2670            (Some(id), Some(sql)) => {
2671                // Execute query on specific database
2672                let db_id_obj = crate::state::DatabaseId::new(id);
2673                match self.state.execute_sql(db_id_obj, &sql).await {
2674                    Ok(result) => {
2675                        let formatted = result.format();
2676                        ui.add_llm_message(format!("[DB] Query result:\n{}", formatted));
2677                    }
2678                    Err(e) => {
2679                        ui.add_llm_message(format!("[ERROR] SQL error: {}", e));
2680                    }
2681                }
2682            }
2683            (None, Some(sql)) => {
2684                // Execute query on first database
2685                let databases = self.state.get_all_databases().await;
2686                if databases.is_empty() {
2687                    ui.add_llm_message("[ERROR] No databases available".to_string());
2688                } else {
2689                    let db_id = databases[0].id;
2690                    match self.state.execute_sql(db_id, &sql).await {
2691                        Ok(result) => {
2692                            let formatted = result.format();
2693                            ui.add_llm_message(format!("[DB] Query result:\n{}", formatted));
2694                        }
2695                        Err(e) => {
2696                            ui.add_llm_message(format!("[ERROR] SQL error: {}", e));
2697                        }
2698                    }
2699                }
2700            }
2701        }
2702        Ok(())
2703    }
2704}
2705
2706// ---------------------------------------------------------------------------
2707// Static event-handler validation
2708// ---------------------------------------------------------------------------
2709
2710/// Common action names the executor handles itself, before any protocol is consulted.
2711///
2712/// Mirrors the variants of [`crate::llm::CommonAction`]; an action naming one of these is
2713/// valid for every protocol, so it must never be rejected as unknown.
2714const COMMON_ACTION_NAMES: &[&str] = &[
2715    "show_message",
2716    "open_server",
2717    "close_server",
2718    "close_all_servers",
2719    "open_client",
2720    "close_client",
2721    "close_all_clients",
2722    "close_connection_by_id",
2723    "reconnect_client",
2724    "update_client_instruction",
2725    "update_instruction",
2726    "change_model",
2727    "set_memory",
2728    "append_memory",
2729    "append_to_log",
2730    "schedule_task",
2731    "cancel_task",
2732    "provide_feedback",
2733    #[cfg(feature = "sqlite")]
2734    "create_database",
2735    #[cfg(feature = "sqlite")]
2736    "delete_database",
2737];
2738
2739/// The action names every protocol accepts (the [`crate::llm::CommonAction`] variants), for
2740/// callers outside this module that validate a handler-shaped answer — the MCP
2741/// `answer_intercept` tool holds an operator's answer to the same rule a static handler is
2742/// held to here.
2743pub fn common_action_names() -> &'static [&'static str] {
2744    COMMON_ACTION_NAMES
2745}
2746
2747/// How many valid action names to spell out in an error before summarising the rest.
2748const MAX_LISTED_ACTIONS: usize = 40;
2749
2750/// The set of action names a handler for `pattern` is allowed to use, plus a label
2751/// describing where that set came from.
2752struct ActionCatalog {
2753    /// Valid action names, sorted
2754    names: std::collections::BTreeSet<String>,
2755    /// Human-readable scope, e.g. `TCP` or `TCP, HTTP`
2756    scope: String,
2757}
2758
2759/// Render an event pattern for an error message.
2760fn describe_pattern(pattern: &crate::scripting::EventPattern) -> String {
2761    use crate::scripting::EventPattern;
2762    match pattern {
2763        EventPattern::Specific(id) => format!("event '{}'", id),
2764        EventPattern::Wildcard => "the wildcard event pattern".to_string(),
2765    }
2766}
2767
2768/// Collect every action name a **server** can execute in response to an event matching
2769/// `pattern`, or `None` if the protocol declares no event matching it.
2770///
2771/// `get_sync_actions()` is the protocol-wide catalog for event responses; each matching
2772/// event type may additionally advertise its own actions, so both are unioned.
2773///
2774/// `get_async_actions()` is deliberately **not** part of it, and this is the asymmetry a
2775/// future reader will be tempted to "fix": a server has two LLM entry points, so its
2776/// async/sync split is a real statement about what may answer a network event. TCP's
2777/// `close_connection` takes a `connection_id` and is a user-driven action against the server;
2778/// the verb for hanging up on the peer whose event this is, is `close_this_connection`. SSH's
2779/// `ssh_auth` narrows to `ssh_auth_decision` for the same reason. A handler answering an event
2780/// is in the sync position, so it is held to the sync vocabulary.
2781///
2782/// Clients are the opposite case and go through
2783/// [`crate::llm::actions::client_trait::client_action_names_for_pattern`] — see the note there.
2784fn server_actions_for_pattern(
2785    protocol: &dyn crate::llm::actions::protocol_trait::Protocol,
2786    pattern: &crate::scripting::EventPattern,
2787) -> Option<Vec<String>> {
2788    let event_types = protocol.get_event_types();
2789    let matching: Vec<_> = event_types
2790        .iter()
2791        .filter(|et| pattern.matches(&et.id))
2792        .collect();
2793    if matching.is_empty() {
2794        return None;
2795    }
2796
2797    let mut names: Vec<String> = protocol
2798        .get_sync_actions()
2799        .into_iter()
2800        .map(|a| a.name)
2801        .collect();
2802    for event_type in matching {
2803        names.extend(event_type.actions.iter().map(|a| a.name.clone()));
2804    }
2805    Some(names)
2806}
2807
2808/// Build the action catalog for a handler's event pattern.
2809///
2810/// Protocols are matched by the events they declare: a pattern of `tcp_data_received`
2811/// resolves to TCP alone and yields TCP's catalog; a wildcard pattern matches every
2812/// protocol and yields the union. Both server and client registries are consulted because
2813/// Name the `open_server` / `open_client` parameter an event-handler parse error refers to.
2814///
2815/// The error surfaces to the model inside `InvalidActionParameters`, whose whole value is
2816/// telling it *which* field to correct. This mapping was written out inline at both call
2817/// sites; identical logic in two places drifts, and the two paths must name the same field
2818/// for the same failure or the model is taught that the two actions differ when they do not.
2819fn event_handler_parameter_name(error_msg: &str) -> &'static str {
2820    if error_msg.contains("instruction") {
2821        "event_handlers[].handler.instruction"
2822    } else if error_msg.contains("event_handlers") {
2823        "event_handlers"
2824    } else {
2825        "unknown"
2826    }
2827}
2828
2829/// The `AppState` used to enumerate a client's async actions while validating handlers.
2830///
2831/// `Protocol::get_async_actions` takes an `&AppState` and `parse_event_handlers` has none —
2832/// it is an associated function that validates a configuration, called before anything is
2833/// started. Building one per handler is out of the question: `AppState::new()` shells out to
2834/// `python3`, `node`, `go` and `perl` to detect the scripting environments and probes the
2835/// process's raw-socket capabilities, which is why `server_registry::register_protocols` also
2836/// builds a single shared dummy one. So this is built at most once per process, and only if a
2837/// static handler is actually validated.
2838///
2839/// Passing a state that is not the live one is sound *for enumerating names*: no client reads
2840/// it (all 100 `get_async_actions` implementations take `_state`), and a client whose
2841/// vocabulary varied with live state could not be validated ahead of start-up by any means.
2842fn catalog_state() -> &'static AppState {
2843    static CATALOG_STATE: std::sync::OnceLock<AppState> = std::sync::OnceLock::new();
2844    CATALOG_STATE.get_or_init(AppState::new)
2845}
2846
2847/// `parse_event_handlers` serves `start_server` and `start_client` alike.
2848///
2849/// Protocols are matched by the events they declare, so a specific pattern resolves to the
2850/// protocol(s) that raise that event and a wildcard pattern matches every protocol in both
2851/// registries and yields the union of their catalogs. The union is what makes a wildcard
2852/// handler usable at all — the caller starts one instance, of one protocol, and the pattern
2853/// says nothing about which — and it is why a wildcard catalog is necessarily broader than
2854/// any one protocol's: validation here rejects a name *no* candidate protocol could execute,
2855/// which is the typo case it exists for.
2856///
2857/// Server and client contributions are gathered by different rules, and the difference is
2858/// deliberate: see [`server_actions_for_pattern`] (narrowing) and
2859/// [`crate::llm::actions::client_trait::client_action_names_for_pattern`] (union). For a
2860/// wildcard that matches both a server and a client this means the server contributes its
2861/// sync vocabulary and the client its whole one, which is exactly what each of them would
2862/// accept when the event fires.
2863///
2864/// If no compiled protocol declares a matching event (an event id we do not know about),
2865/// the catalog is empty and validation is skipped rather than rejecting the handler.
2866fn action_catalog_for_pattern(pattern: &crate::scripting::EventPattern) -> ActionCatalog {
2867    let mut names: std::collections::BTreeSet<String> = COMMON_ACTION_NAMES
2868        .iter()
2869        .map(|s| (*s).to_string())
2870        .collect();
2871    let mut scopes: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2872
2873    for (_, protocol) in crate::protocol::server_registry::registry().all_protocols() {
2874        if let Some(actions) = server_actions_for_pattern(protocol.as_ref(), pattern) {
2875            scopes.insert(protocol.protocol_name().to_string());
2876            names.extend(actions);
2877        }
2878    }
2879
2880    for protocol in crate::protocol::client_registry::CLIENT_REGISTRY.get_all() {
2881        if let Some(actions) = crate::llm::actions::client_trait::client_action_names_for_pattern(
2882            protocol.as_ref(),
2883            catalog_state(),
2884            pattern,
2885        ) {
2886            scopes.insert(protocol.protocol_name().to_string());
2887            names.extend(actions);
2888        }
2889    }
2890
2891    if scopes.is_empty() {
2892        // Nothing declares this event: no catalog to validate against.
2893        return ActionCatalog {
2894            names: std::collections::BTreeSet::new(),
2895            scope: String::new(),
2896        };
2897    }
2898
2899    let scope = scopes.into_iter().collect::<Vec<_>>().join(", ");
2900    ActionCatalog { names, scope }
2901}
2902
2903/// Reject static handler actions whose `type` no matching protocol can execute.
2904///
2905/// Without this a typo'd action name is accepted by `start_server` and only surfaces when
2906/// the first packet arrives — where it produces no response at all.
2907fn validate_static_action_names(
2908    pattern: &crate::scripting::EventPattern,
2909    actions: &[serde_json::Value],
2910) -> Result<()> {
2911    let catalog = action_catalog_for_pattern(pattern);
2912    if catalog.names.is_empty() {
2913        // No protocol declares an event matching this pattern; nothing to check against.
2914        return Ok(());
2915    }
2916
2917    for action in actions {
2918        let Some(name) = action.get("type").and_then(|v| v.as_str()) else {
2919            anyhow::bail!(
2920                "Static handler action for {} has no \"type\" field: {}",
2921                describe_pattern(pattern),
2922                action
2923            );
2924        };
2925
2926        if catalog.names.contains(name) {
2927            continue;
2928        }
2929
2930        let listed: Vec<&str> = catalog
2931            .names
2932            .iter()
2933            .take(MAX_LISTED_ACTIONS)
2934            .map(|s| s.as_str())
2935            .collect();
2936        let overflow = catalog.names.len().saturating_sub(listed.len());
2937        let mut valid = listed.join(", ");
2938        if overflow > 0 {
2939            valid.push_str(&format!(", … ({} more)", overflow));
2940        }
2941
2942        anyhow::bail!(
2943            "Unknown action \"{}\" in the static handler for {}. \
2944             Valid actions for {}: {}",
2945            name,
2946            describe_pattern(pattern),
2947            catalog.scope,
2948            valid
2949        );
2950    }
2951
2952    Ok(())
2953}