Skip to main content

netget/llm/actions/
executor.rs

1//! Action executor
2//!
3//! This module executes arrays of actions returned by the LLM.
4//! It handles both common actions and protocol-specific actions.
5
6use super::{
7    common::CommonAction,
8    protocol_trait::{ActionResult, Server},
9};
10use crate::protocol::log_action_result;
11use crate::state::app_state::AppState;
12use anyhow::Result;
13use tracing::{debug, error, warn};
14
15/// Prefix used for the synthetic `type` of a failed action in the access log.
16///
17/// The access-log viewer (`list_access_logs`) summarises an entry by the `type` of each
18/// recorded action, so the failure has to live in that field to be visible at a glance.
19/// A successful `send_tcp_data` is recorded as `send_tcp_data`; a failed one as
20/// `FAILED: send_tcp_data`.
21pub const FAILED_ACTION_TYPE_PREFIX: &str = "FAILED: ";
22
23/// Cap on a server's `memory` string.
24///
25/// Memory is injected verbatim into the "Current State" section of *every* prompt for that
26/// server, so it is paid for on every LLM call for the life of the server. `append_memory`
27/// had no bound at all: a protocol that appends a line per request grows the prompt without
28/// limit until the model's context is exhausted and every call starts failing.
29///
30/// 8000 characters is roughly 2k tokens — the same order as the conversation-history window,
31/// large enough for the running notes memory is meant to hold, and small enough that it
32/// cannot dominate the prompt.
33pub const MAX_SERVER_MEMORY_CHARS: usize = 8000;
34
35/// Notice prefixed to memory that had to be trimmed, so the model is not left believing it
36/// still remembers something that was dropped.
37const MEMORY_TRIMMED_NOTICE: &str = "[older memory dropped: over the size limit]";
38
39/// Bound a server's memory to [`MAX_SERVER_MEMORY_CHARS`], keeping the **newest** content.
40///
41/// Memory accumulates chronologically (`append_memory` joins with a newline), so the tail is
42/// the recent state and the head is the stalest. Whole lines are dropped from the front, and
43/// the result is marked so the model can tell that history was lost. A single line longer
44/// than the cap is truncated char-safely rather than dropped entirely.
45pub fn bound_server_memory(memory: String) -> String {
46    if memory.len() <= MAX_SERVER_MEMORY_CHARS {
47        return memory;
48    }
49
50    // Budget for the notice plus its newline.
51    let budget = MAX_SERVER_MEMORY_CHARS.saturating_sub(MEMORY_TRIMMED_NOTICE.len() + 1);
52
53    let mut kept: Vec<&str> = Vec::new();
54    let mut size = 0usize;
55    for line in memory.lines().rev() {
56        // +1 for the newline that will rejoin this line.
57        let cost = line.len() + 1;
58        if size + cost > budget {
59            break;
60        }
61        size += cost;
62        kept.push(line);
63    }
64    kept.reverse();
65
66    if kept.is_empty() {
67        // One oversized line: keep its tail, which is the newest text.
68        let start = memory.len().saturating_sub(budget);
69        let start = memory
70            .char_indices()
71            .map(|(i, _)| i)
72            .find(|i| *i >= start)
73            .unwrap_or(memory.len());
74        return format!("{}\n{}", MEMORY_TRIMMED_NOTICE, &memory[start..]);
75    }
76
77    format!("{}\n{}", MEMORY_TRIMMED_NOTICE, kept.join("\n"))
78}
79
80/// One action from a batch that could not be executed.
81///
82/// Recorded rather than discarded so the failure reaches the access log and the caller
83/// instead of being visible only as "the peer got the protocol default".
84#[derive(Clone, Debug, serde::Serialize)]
85pub struct ActionFailure {
86    /// Position of the action in the submitted batch (indexes `ExecutionResult::raw_actions`)
87    pub index: usize,
88    /// The action's `type` field, or `unknown` when it had none
89    pub action: String,
90    /// Why it failed, as reported by the protocol executor
91    pub error: String,
92}
93
94/// Result of executing all actions
95pub struct ExecutionResult {
96    /// Messages to display to the user
97    pub messages: Vec<String>,
98
99    /// Protocol-specific action results
100    pub protocol_results: Vec<ActionResult>,
101
102    /// The action name that produced each entry of `protocol_results`, index for index.
103    ///
104    /// A protocol that owes exactly one reply per request uses it to pick *the* reply when a
105    /// batch carries several (see [`ExecutionResult::chosen_reply`]).
106    pub protocol_result_actions: Vec<String>,
107
108    /// Raw action JSON (for protocols that need to manually process actions)
109    /// This is used by protocols like mDNS and NFS that have special manual processing
110    pub raw_actions: Vec<serde_json::Value>,
111
112    /// Actions in this batch that failed to execute, in submission order.
113    ///
114    /// Empty on a fully successful batch. A non-empty list does **not** mean the batch
115    /// was aborted: execution continues past a failed action (see `execute_actions`).
116    pub failures: Vec<ActionFailure>,
117}
118
119impl Default for ExecutionResult {
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125impl ExecutionResult {
126    pub fn new() -> Self {
127        Self {
128            messages: Vec::new(),
129            protocol_results: Vec::new(),
130            protocol_result_actions: Vec::new(),
131            raw_actions: Vec::new(),
132            failures: Vec::new(),
133        }
134    }
135
136    pub fn add_message(&mut self, message: String) {
137        self.messages.push(message);
138    }
139
140    pub fn add_protocol_result(&mut self, result: ActionResult) {
141        self.add_named_protocol_result(String::new(), result);
142    }
143
144    /// Record a protocol result together with the name of the action that produced it.
145    pub fn add_named_protocol_result(&mut self, action_name: String, result: ActionResult) {
146        self.protocol_results.push(result);
147        self.protocol_result_actions.push(action_name);
148    }
149
150    /// Which entry of `protocol_results` is *the* reply, for a protocol that owes exactly one
151    /// per request: the first that writes bytes and was produced by an action in `preferred`,
152    /// otherwise the first that writes bytes at all. `None` when nothing writes.
153    ///
154    /// Why not simply the first: a small model often answers with a generic acknowledgement
155    /// and *then* the answer the request needed - `+OK` then the `STAT` line, measured in the
156    /// real-model eval - and the specific answer is the one the client is waiting for.
157    pub fn chosen_reply(&self, preferred: &[&str]) -> Option<usize> {
158        let writes = |i: &usize| !self.protocol_results[*i].get_all_output().is_empty();
159        (0..self.protocol_results.len())
160            .filter(writes)
161            .find(|i| {
162                self.protocol_result_actions
163                    .get(*i)
164                    .is_some_and(|name| preferred.contains(&name.as_str()))
165            })
166            .or_else(|| (0..self.protocol_results.len()).find(writes))
167    }
168
169    /// Record an action that could not be executed.
170    pub fn add_failure(
171        &mut self,
172        index: usize,
173        action: impl Into<String>,
174        error: impl Into<String>,
175    ) {
176        self.failures.push(ActionFailure {
177            index,
178            action: action.into(),
179            error: error.into(),
180        });
181    }
182
183    /// Whether any action in the batch failed.
184    pub fn has_failures(&self) -> bool {
185        !self.failures.is_empty()
186    }
187
188    /// One-line summary of the failures, or `None` if the batch was clean.
189    pub fn failure_summary(&self) -> Option<String> {
190        if self.failures.is_empty() {
191            return None;
192        }
193        let parts: Vec<String> = self
194            .failures
195            .iter()
196            .map(|f| format!("{} ({})", f.action, f.error))
197            .collect();
198        Some(parts.join("; "))
199    }
200
201    /// The action array to record in the access log.
202    ///
203    /// Successful actions are recorded verbatim. A failed action keeps its original JSON
204    /// under `action` but is wrapped in an envelope whose `type` is
205    /// `FAILED: <action name>` and which carries the executor's error, so
206    /// `list_access_logs` cannot show a failed action as though it had run.
207    pub fn access_log_actions(&self) -> Vec<serde_json::Value> {
208        if self.failures.is_empty() {
209            return self.raw_actions.clone();
210        }
211        let mut out = self.raw_actions.clone();
212        for failure in &self.failures {
213            let original = out
214                .get(failure.index)
215                .cloned()
216                .unwrap_or(serde_json::Value::Null);
217            let envelope = serde_json::json!({
218                "type": format!("{}{}", FAILED_ACTION_TYPE_PREFIX, failure.action),
219                "error": failure.error,
220                "action": original,
221            });
222            if let Some(slot) = out.get_mut(failure.index) {
223                *slot = envelope;
224            } else {
225                out.push(envelope);
226            }
227        }
228        out
229    }
230}
231
232/// Execute an array of actions from LLM response
233///
234/// # Arguments
235/// * `actions` - Array of action JSON objects from LLM
236/// * `state` - Application state
237/// * `protocol` - Optional protocol for protocol-specific actions
238/// * `server_id` - Optional server ID for context (used for feedback, memory, etc.)
239/// * `client_id` - Optional client ID for context (used for client feedback)
240///
241/// # Failure handling
242///
243/// A failing action does **not** abort the batch. Each failure is logged at `error!`
244/// and recorded in `ExecutionResult::failures`, and execution continues with the next
245/// action. Aborting would suppress the *valid* actions that follow a bad one — dropping
246/// the `close_this_connection` that was meant to terminate a request whose body already
247/// went out, for example — and would convert a single malformed action into a lost
248/// connection in protocols that today recover by falling back to their default response.
249/// The defect this addresses is that failures were invisible, not that they were
250/// tolerated, so they are now surfaced (access log, `error!`) rather than made fatal.
251///
252/// # Returns
253/// * `Ok(ExecutionResult)` - Results of execution; check `failures` for per-action errors
254/// * `Err(_)` - If execution failed critically
255pub async fn execute_actions(
256    actions: Vec<serde_json::Value>,
257    state: &AppState,
258    protocol: Option<&dyn Server>,
259    server_id: Option<crate::state::ServerId>,
260    client_id: Option<crate::state::ClientId>,
261) -> Result<ExecutionResult> {
262    // Static interpolation and parsed JSON have budgets, but manual answers and
263    // direct callers can construct arbitrarily nested Values. Check the whole
264    // batch before cloning, logging, parsing or recursively destroying anything.
265    if !crate::utils::json_budget::within_values_budget(
266        &actions,
267        crate::scripting::event_handler::MAX_INTERPOLATION_BYTES,
268        crate::scripting::event_handler::MAX_INTERPOLATION_NODES,
269        crate::scripting::event_handler::MAX_INTERPOLATION_DEPTH,
270    ) {
271        for action in actions {
272            crate::utils::json_budget::drop_iteratively(action);
273        }
274        anyhow::bail!("action batch exceeds the shared JSON budget");
275    }
276    let mut result = ExecutionResult::new();
277
278    // Store raw actions for protocols that need manual processing (mDNS, NFS, etc.)
279    result.raw_actions = actions.clone();
280
281    for (i, action) in actions.iter().enumerate() {
282        debug!(
283            "Executing action {}: {:?}",
284            i,
285            crate::utils::redact::redact_sensitive(action)
286        );
287
288        let action_name = action
289            .get("type")
290            .and_then(|v| v.as_str())
291            .unwrap_or("unknown")
292            .to_string();
293
294        // Pipe wiring actions (create_pipe / remove_pipe): the LLM or an operator can
295        // wire one instance's events into another. Handled here rather than as a
296        // CommonAction variant so the enum and its many exhaustive matches stay
297        // untouched. The executor already holds `state` and the server context.
298        if action_name == "create_pipe" || action_name == "remove_pipe" {
299            if let Err(e) =
300                crate::pipe::execute_pipe_action(&action_name, action, state, server_id).await
301            {
302                warn!(
303                    "Pipe action '{}' failed: {} (action: {})",
304                    action_name, e, action
305                );
306                result.add_failure(i, action_name, e.to_string());
307            }
308            continue;
309        }
310
311        // Try to parse as common action first
312        if let Ok(common_action) = CommonAction::from_json(action) {
313            if let Err(e) =
314                execute_common_action(common_action, state, &mut result, server_id, client_id).await
315            {
316                error!(
317                    "Action {} '{}' failed: {} (action: {})",
318                    i, action_name, e, action
319                );
320                result.add_failure(i, action_name, e.to_string());
321            }
322            continue;
323        }
324
325        // Try protocol-specific action
326        if let Some(proto) = protocol {
327            match proto
328                .execute_action_with_state(action.clone(), state.clone(), server_id)
329                .await
330            {
331                Ok(action_result) => {
332                    // Find action definition to get log template
333                    let action_def = proto
334                        .get_sync_actions()
335                        .into_iter()
336                        .chain(proto.get_async_actions(state).into_iter())
337                        .find(|a| a.name == action_name);
338
339                    // Log action result with template if available
340                    log_action_result(
341                        &action_name,
342                        action,
343                        &action_result,
344                        action_def
345                            .as_ref()
346                            .and_then(|def| def.log_template.as_ref()),
347                        None, // No TUI output from executor (event-level logging handles TUI)
348                    );
349
350                    result.add_named_protocol_result(action_name.clone(), action_result);
351                    continue;
352                }
353                Err(e) => {
354                    // The peer will now receive the protocol's default (an empty 200 for
355                    // HTTP, nothing at all for TCP). Say so loudly and record it, rather
356                    // than letting the batch look successful.
357                    error!(
358                        "Action {} '{}' failed on protocol {}: {} — the peer receives the \
359                         protocol default instead (action: {})",
360                        i,
361                        action_name,
362                        proto.protocol_name(),
363                        e,
364                        action
365                    );
366                    result.add_failure(i, action_name, e.to_string());
367                    // Continue with the rest of the batch; see the fn-level docs.
368                    continue;
369                }
370            }
371        }
372
373        // Not a common action, and there is no protocol in context to try it against.
374        let detail = "unknown action type and no protocol in context".to_string();
375        error!(
376            "Action {} '{}' skipped: {} ({})",
377            i, action_name, detail, action
378        );
379        result.add_failure(i, action_name, detail);
380    }
381
382    if let Some(summary) = result.failure_summary() {
383        error!(
384            "{} of {} action(s) failed: {}",
385            result.failures.len(),
386            actions.len(),
387            summary
388        );
389    }
390
391    Ok(result)
392}
393
394/// Execute a common action
395async fn execute_common_action(
396    action: CommonAction,
397    state: &AppState,
398    _result: &mut ExecutionResult,
399    server_id: Option<crate::state::ServerId>,
400    client_id: Option<crate::state::ClientId>,
401) -> Result<()> {
402    match action {
403        CommonAction::ShowMessage { .. } => {
404            // ShowMessage is handled by the caller (event handler) to avoid duplicate output
405            // This match arm exists to satisfy exhaustiveness checking
406        }
407
408        CommonAction::OpenServer { .. } => {
409            // This should be handled by the caller (user command handler)
410            // because it requires spawning a new server task
411            warn!("open_server action cannot be executed by action executor - must be handled by caller");
412        }
413
414        CommonAction::CloseServer { .. } => {
415            // This should be handled by the caller
416            warn!("close_server action cannot be executed by action executor - must be handled by caller");
417        }
418
419        CommonAction::CloseAllServers => {
420            // This should be handled by the caller
421            warn!("close_all_servers action cannot be executed by action executor - must be handled by caller");
422        }
423
424        CommonAction::UpdateInstruction { .. } => {
425            // This should be handled by the caller
426            warn!("update_instruction action cannot be executed by action executor - must be handled by caller");
427        }
428
429        CommonAction::ChangeModel { .. } => {
430            // This should be handled by the caller
431            warn!("change_model action cannot be executed by action executor - must be handled by caller");
432        }
433
434        CommonAction::SetMemory { value } => {
435            // A client's memory belongs to that client. Falling through to
436            // `get_first_server_id_sync()` in a client context wrote it into whichever server
437            // happened to be first — an unrelated server's prompt, silently corrupted, while
438            // the client's own memory stayed empty.
439            if server_id.is_none() {
440                if let Some(cid) = client_id {
441                    let bounded = bound_server_memory(value);
442                    debug!(
443                        "Client #{} memory set ({} chars)",
444                        cid.as_u32(),
445                        bounded.len()
446                    );
447                    state
448                        .with_client_mut(cid, |client| client.memory = bounded)
449                        .await;
450                    return Ok(());
451                }
452            }
453            let sid = server_id.or_else(|| state.get_first_server_id_sync());
454            if let Some(server_id) = sid {
455                // Memory is injected into every prompt for this server, so it is bounded
456                // here rather than trusted to stay small.
457                let bounded = bound_server_memory(value);
458                debug!(
459                    "Server #{} memory set ({} chars)",
460                    server_id.as_u32(),
461                    bounded.len()
462                );
463                state.set_memory(server_id, bounded).await;
464            }
465        }
466
467        CommonAction::AppendMemory { value } => {
468            // See `SetMemory`: a client context must not fall through to the first server.
469            if server_id.is_none() {
470                if let Some(cid) = client_id {
471                    let bounded = state
472                        .with_client_mut(cid, |client| {
473                            let combined = if client.memory.is_empty() {
474                                value
475                            } else {
476                                format!("{}\n{}", client.memory, value)
477                            };
478                            client.memory = bound_server_memory(combined);
479                            client.memory.len()
480                        })
481                        .await;
482                    if let Some(len) = bounded {
483                        debug!("Client #{} memory appended ({} chars)", cid.as_u32(), len);
484                    }
485                    return Ok(());
486                }
487            }
488            let sid = server_id.or_else(|| state.get_first_server_id_sync());
489            if let Some(server_id) = sid {
490                let current = state.get_memory(server_id).await.unwrap_or_default();
491                let new_memory = if current.is_empty() {
492                    value
493                } else {
494                    format!("{}\n{}", current, value)
495                };
496                let bounded = bound_server_memory(new_memory);
497                debug!(
498                    "Server #{} memory appended ({} chars)",
499                    server_id.as_u32(),
500                    bounded.len()
501                );
502                state.set_memory(server_id, bounded).await;
503            }
504        }
505
506        CommonAction::AppendToLog {
507            output_name,
508            content,
509        } => {
510            let sid = server_id.or_else(|| state.get_first_server_id_sync());
511            if let Some(server_id) = sid {
512                // Get or create the log file path
513                let log_path = state
514                    .with_server_mut(server_id, |server| {
515                        server.get_or_create_log_path(&output_name)
516                    })
517                    .await;
518                let log_path = match log_path {
519                    Some(Ok(path)) => Some(path),
520                    Some(Err(e)) => {
521                        // Refused, not rewritten: the name is model-authored and would
522                        // have become part of a path.
523                        warn!("append_to_log refused: {e}");
524                        None
525                    }
526                    None => None,
527                };
528
529                if let Some(log_path) = log_path {
530                    // Append content to the log file
531                    match tokio::fs::OpenOptions::new()
532                        .create(true)
533                        .append(true)
534                        .open(&log_path)
535                        .await
536                    {
537                        Ok(mut file) => {
538                            use tokio::io::AsyncWriteExt;
539                            let log_line = format!("{}\n", content);
540                            if let Err(e) = file.write_all(log_line.as_bytes()).await {
541                                warn!("Failed to write to log file {:?}: {}", log_path, e);
542                            } else {
543                                debug!("Appended to log file {:?}", log_path);
544                            }
545                        }
546                        Err(e) => {
547                            warn!("Failed to open log file {:?}: {}", log_path, e);
548                        }
549                    }
550                } else {
551                    warn!("No server found to append log for");
552                }
553            }
554        }
555        CommonAction::ScheduleTask { .. } | CommonAction::CancelTask { .. } => {
556            // Task scheduling handled by event handler, not executor
557        }
558
559        CommonAction::OpenClient { .. }
560        | CommonAction::CloseClient { .. }
561        | CommonAction::CloseAllClients
562        | CommonAction::CloseConnectionById { .. }
563        | CommonAction::ReconnectClient { .. }
564        | CommonAction::UpdateClientInstruction { .. } => {
565            // Client and connection management handled by event handler, not executor
566        }
567
568        CommonAction::UpdateServer { .. } | CommonAction::UpdateClient { .. } => {
569            // Update handled by the event handler (execute_server_management_action),
570            // not the executor - it needs to spawn/restart instances.
571            warn!("update_server/update_client action must be handled by caller");
572        }
573
574        CommonAction::ProvideFeedback { feedback } => {
575            // Accumulate feedback for later processing (debounced + LLM invocation)
576            if let Some(sid) = server_id {
577                state
578                    .add_server_feedback(sid, feedback)
579                    .await
580                    .unwrap_or_else(|e| {
581                        warn!("Failed to add server feedback: {}", e);
582                    });
583                debug!("Server #{} feedback accumulated", sid.as_u32());
584            } else if let Some(cid) = client_id {
585                state
586                    .add_client_feedback(cid, feedback)
587                    .await
588                    .unwrap_or_else(|e| {
589                        warn!("Failed to add client feedback: {}", e);
590                    });
591                debug!("Client #{} feedback accumulated", cid.as_u32());
592            } else {
593                warn!("provide_feedback action called without server_id or client_id context");
594            }
595        }
596
597        #[cfg(feature = "sqlite")]
598        CommonAction::CreateDatabase { .. } | CommonAction::DeleteDatabase { .. } => {
599            // SQLite operations handled by event handler, not executor
600        }
601    }
602
603    Ok(())
604}
605
606/// Extract server management actions that need special handling
607///
608/// These actions cannot be executed directly by the executor and must
609/// be handled by the caller (usually the user command handler in main.rs)
610pub fn extract_server_management_actions(actions: &[serde_json::Value]) -> Vec<CommonAction> {
611    actions
612        .iter()
613        .filter_map(|action| {
614            if let Ok(common_action) = CommonAction::from_json(action) {
615                match common_action {
616                    CommonAction::OpenServer { .. }
617                    | CommonAction::CloseServer { .. }
618                    | CommonAction::UpdateInstruction { .. }
619                    | CommonAction::ChangeModel { .. } => Some(common_action),
620                    _ => None,
621                }
622            } else {
623                None
624            }
625        })
626        .collect()
627}