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}