Skip to main content

netget/llm/actions/
mod.rs

1//! Action-based system for LLM interactions
2//!
3//! This module provides a unified action system where both user input
4//! and network events return arrays of actions to execute.
5
6pub mod client_trait;
7pub mod common;
8pub mod easy_trait;
9pub mod executor;
10pub mod protocol_trait;
11pub mod summary;
12pub mod tools;
13
14// Re-export commonly used functions and types
15pub use client_trait::{
16    audit_client_action_declarations, client_action_names_for_pattern, client_llm_action_set,
17    Client,
18};
19// Export the Client trait
20pub use common::{
21    generate_base_stack_documentation, get_network_event_common_actions,
22    get_user_input_common_actions,
23};
24pub use easy_trait::Easy;
25// Export the Easy trait
26pub use protocol_trait::{Protocol, Server};
27// Export StartupExamples for protocol implementations
28pub use summary::{summarize_action, summarize_actions};
29pub use tools::{
30    execute_tool, get_all_tool_actions, get_network_event_tool_actions, is_tool_name, ToolAction,
31    ToolResult, TOOL_ACTION_NAMES,
32};
33
34use crate::protocol::log_template::LogTemplate;
35use serde::{Deserialize, Serialize};
36
37/// Examples showing how to start a protocol with different handler modes
38///
39/// These examples are required for every protocol and are used in:
40/// - Protocol documentation (shown when user requests docs)
41/// - Prompt templates to guide LLM in creating servers/clients
42///
43/// Each example is a complete `open_server` action JSON that can be
44/// directly executed. The examples must be validated by tests.
45#[derive(Clone, Debug, Serialize)]
46pub struct StartupExamples {
47    /// Complete open_server action with LLM handler mode
48    /// Shows how to start the protocol with LLM-controlled responses
49    pub llm_mode: serde_json::Value,
50
51    /// Complete open_server action with script handler mode
52    /// Shows event_handlers with script type handlers
53    pub script_mode: serde_json::Value,
54
55    /// Complete open_server action with static handler mode
56    /// Shows event_handlers with static type handlers and predefined actions
57    pub static_mode: serde_json::Value,
58}
59
60impl StartupExamples {
61    /// Create new startup examples
62    pub fn new(
63        llm_mode: serde_json::Value,
64        script_mode: serde_json::Value,
65        static_mode: serde_json::Value,
66    ) -> Self {
67        Self {
68            llm_mode,
69            script_mode,
70            static_mode,
71        }
72    }
73
74    /// Validate that all examples are well-formed open_server actions
75    ///
76    /// Returns Ok(()) if valid, Err with description if invalid.
77    /// This is called by parameterized tests to ensure examples stay valid.
78    pub fn validate(&self, protocol_name: &str) -> Result<(), String> {
79        self.validate_example(&self.llm_mode, "llm_mode", protocol_name)?;
80        self.validate_example(&self.script_mode, "script_mode", protocol_name)?;
81        self.validate_example(&self.static_mode, "static_mode", protocol_name)?;
82        Ok(())
83    }
84
85    fn validate_example(
86        &self,
87        example: &serde_json::Value,
88        mode_name: &str,
89        protocol_name: &str,
90    ) -> Result<(), String> {
91        // Must be an object
92        let obj = example.as_object().ok_or_else(|| {
93            format!(
94                "Protocol {} {} example must be a JSON object",
95                protocol_name, mode_name
96            )
97        })?;
98
99        // Must have "type" field
100        let action_type = obj.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
101            format!(
102                "Protocol {} {} example missing 'type' field",
103                protocol_name, mode_name
104            )
105        })?;
106
107        // Type must be "open_server" or "open_client"
108        if action_type != "open_server" && action_type != "open_client" {
109            return Err(format!(
110                "Protocol {} {} example has type '{}', expected 'open_server' or 'open_client'",
111                protocol_name, mode_name, action_type
112            ));
113        }
114
115        // Must have "base_stack" field
116        if !obj.contains_key("base_stack") {
117            return Err(format!(
118                "Protocol {} {} example missing 'base_stack' field",
119                protocol_name, mode_name
120            ));
121        }
122
123        // For script_mode and static_mode, must have event_handlers
124        if mode_name == "script_mode" || mode_name == "static_mode" {
125            let handlers = obj.get("event_handlers").ok_or_else(|| {
126                format!(
127                    "Protocol {} {} example missing 'event_handlers' array",
128                    protocol_name, mode_name
129                )
130            })?;
131
132            if !handlers.is_array() {
133                return Err(format!(
134                    "Protocol {} {} example 'event_handlers' must be an array",
135                    protocol_name, mode_name
136                ));
137            }
138        }
139
140        // For llm_mode, should have instruction
141        if mode_name == "llm_mode" && !obj.contains_key("instruction") {
142            return Err(format!(
143                "Protocol {} llm_mode example missing 'instruction' field",
144                protocol_name
145            ));
146        }
147
148        Ok(())
149    }
150
151    /// Convert examples to prompt text for LLM documentation
152    pub fn to_prompt_text(&self) -> String {
153        let mut text = String::new();
154
155        text.push_str("### Starting this Protocol\n\n");
156
157        text.push_str("**LLM Mode** (LLM handles all responses intelligently):\n");
158        text.push_str("```json\n");
159        text.push_str(&serde_json::to_string_pretty(&self.llm_mode).unwrap_or_default());
160        text.push_str("\n```\n\n");
161
162        text.push_str("**Script Mode** (code-based deterministic responses):\n");
163        text.push_str("```json\n");
164        text.push_str(&serde_json::to_string_pretty(&self.script_mode).unwrap_or_default());
165        text.push_str("\n```\n\n");
166
167        text.push_str("**Static Mode** (fixed, unchanging responses):\n");
168        text.push_str("```json\n");
169        text.push_str(&serde_json::to_string_pretty(&self.static_mode).unwrap_or_default());
170        text.push_str("\n```\n\n");
171
172        text
173    }
174}
175
176/// Definition of a configuration parameter for prompt generation
177///
178/// This describes a startup parameter that a protocol accepts,
179/// including its name, type, description, and an example value.
180#[derive(Debug, Clone, Serialize)]
181pub struct ParameterDefinition {
182    /// Parameter name (e.g., "certificate_mode", "max_connections")
183    pub name: String,
184
185    /// Type hint for the LLM (e.g., "string", "number", "boolean", "object")
186    pub type_hint: String,
187
188    /// Human-readable description of what this parameter does
189    pub description: String,
190
191    /// Whether this parameter is required
192    pub required: bool,
193
194    /// JSON example showing a valid value for this parameter
195    pub example: serde_json::Value,
196
197    /// The value the protocol uses when the caller omits this parameter, when there is one.
198    ///
199    /// Declared where the code already falls back to a fixed value, so the operator can see
200    /// the value before choosing to change it: the dashboard's create form pre-fills it (and
201    /// does not submit it unless edited), and `get_protocol_docs` prints it. `None` means
202    /// omitting the parameter changes behaviour in a way no single value describes — a feature
203    /// switched off, a value derived from something else — or the parameter is required.
204    ///
205    /// Must parse as [`Self::type_hint`]; `tests/startup_param_defaults_test.rs` checks every
206    /// declaration. Point it at the constant the code falls back to
207    /// (`Some(json!(IDLE_TIMEOUT.as_secs()))`), never a literal repeating it, or the two drift.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub default: Option<serde_json::Value>,
210}
211
212impl ParameterDefinition {
213    /// Convert to prompt text format for LLM
214    pub fn to_prompt_text(&self) -> String {
215        let required = if self.required {
216            "required"
217        } else {
218            "optional"
219        };
220        let mut text = format!(
221            "\"{}\": {}  // {} ({})\nExample: {}",
222            self.name,
223            self.type_hint,
224            self.description,
225            required,
226            serde_json::to_string(&self.example).unwrap_or_default()
227        );
228        if let Some(default) = &self.default {
229            text.push_str(&format!(
230                "\nDefault when omitted: {}",
231                serde_json::to_string(default).unwrap_or_default()
232            ));
233        }
234        text
235    }
236}
237
238/// Controls how this tool/action behaves in conversations and which paths expose it
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum ToolCategory {
241    /// Information-gathering tool: results returned to LLM for further processing
242    /// Examples: read_file, web_search, read_documentation, list_models
243    InformationTool,
244    /// Action: executed with acknowledgment, LLM may continue
245    /// Examples: open_server, close_server, set_memory, show_message
246    Action,
247    /// Protocol-specific sync action: executed during network event handling
248    /// Examples: send_http_response, send_tcp_data, send_dns_a_response
249    ProtocolAction,
250}
251
252/// Definition of an action for prompt generation
253///
254/// This describes an action to the LLM, including its name,
255/// description, parameters, and an example.
256#[derive(Debug, Clone)]
257pub struct ActionDefinition {
258    /// Action type name (e.g., "send_tcp_data", "close_connection")
259    pub name: String,
260
261    /// Human-readable description of what this action does
262    pub description: String,
263
264    /// List of parameters this action accepts
265    pub parameters: Vec<Parameter>,
266
267    /// JSON example showing how to use this action
268    pub example: serde_json::Value,
269
270    /// Log template for action execution logging
271    /// Defines protocol-specific log formats at INFO/DEBUG/TRACE levels
272    pub log_template: Option<LogTemplate>,
273}
274
275impl ActionDefinition {
276    /// Check if this is a tool action (returns information and triggers LLM re-invocation)
277    ///
278    /// Delegates to [`tools::is_tool_name`] — the single source of truth shared with
279    /// [`ToolAction::is_tool_action`], which decides at runtime whether the model is
280    /// re-invoked. Classifying here independently is what previously rendered
281    /// `read_documentation` under the "Available Actions" heading, whose boilerplate
282    /// tells the model it will *not* be invoked again — the opposite of what happens.
283    pub fn is_tool(&self) -> bool {
284        tools::is_tool_name(&self.name)
285    }
286
287    /// Convert to prompt text format for LLM
288    pub fn to_prompt_text(&self) -> String {
289        let mut text = format!("{}\n\n{}\n", self.name, self.description);
290
291        // Only show schema if there are parameters
292        if !self.parameters.is_empty() {
293            text.push_str("\nParameters:\n");
294            for param in &self.parameters {
295                let required = if param.required {
296                    "required"
297                } else {
298                    "optional"
299                };
300                text.push_str(&format!(
301                    "- {}: {} ({}) - {}\n",
302                    param.name, param.type_hint, required, param.description
303                ));
304            }
305        }
306
307        text.push_str("\nExample:\n```json\n");
308        text.push_str(&serde_json::to_string_pretty(&self.example).unwrap_or_default());
309        text.push_str("\n```");
310        text
311    }
312
313    /// Add a log template to this action definition
314    ///
315    /// The log template defines protocol-specific log formats at INFO/DEBUG/TRACE levels.
316    /// This enables standardized, centralized logging without per-action logging code.
317    ///
318    /// # Example
319    /// ```rust,ignore
320    /// ActionDefinition {
321    ///     name: "send_http_response".to_string(),
322    ///     // ...other fields...
323    ///     log_template: None,
324    /// }
325    /// .with_log_template(
326    ///     LogTemplate::new()
327    ///         .with_info("HTTP {status}, {output_bytes}B")
328    ///         .with_debug("Response: status={status}, body_len={output_bytes}")
329    /// )
330    /// ```
331    pub fn with_log_template(mut self, template: LogTemplate) -> Self {
332        self.log_template = Some(template);
333        self
334    }
335
336    /// Derive the tool category from the action name
337    pub fn category(&self) -> ToolCategory {
338        if self.is_tool() {
339            ToolCategory::InformationTool
340        } else if self.is_common_action() {
341            ToolCategory::Action
342        } else {
343            ToolCategory::ProtocolAction
344        }
345    }
346
347    /// Whether this action should be exposed as an MCP tool in STDIO mode
348    pub fn mcp_visible(&self) -> bool {
349        match self.category() {
350            ToolCategory::InformationTool => {
351                // Expose information tools except generate_random (MCP clients can do this)
352                !matches!(
353                    self.name.as_str(),
354                    "generate_random" | "list_network_interfaces"
355                )
356            }
357            ToolCategory::Action => {
358                // Expose management actions, exclude internal-only ones
359                !matches!(
360                    self.name.as_str(),
361                    "show_message" | "append_to_log" | "provide_feedback"
362                )
363            }
364            ToolCategory::ProtocolAction => {
365                // Protocol-specific actions are not exposed via MCP
366                // (they are handled by the protocol server's internal LLM)
367                false
368            }
369        }
370    }
371
372    /// Check if this is a common (non-protocol-specific) action
373    fn is_common_action(&self) -> bool {
374        matches!(
375            self.name.as_str(),
376            "show_message"
377                | "open_server"
378                | "close_server"
379                | "close_all_servers"
380                | "open_client"
381                | "close_client"
382                | "close_all_clients"
383                | "reconnect_client"
384                | "update_client_instruction"
385                | "update_client"
386                | "update_server"
387                | "close_connection_by_id"
388                | "update_instruction"
389                | "set_memory"
390                | "append_memory"
391                | "append_to_log"
392                | "change_model"
393                | "schedule_task"
394                | "cancel_task"
395                | "provide_feedback"
396                | "create_database"
397                | "delete_database"
398        )
399    }
400
401    /// Convert to OpenAI/Ollama native tool calling schema
402    ///
403    /// Returns a JSON value in the format:
404    /// ```json
405    /// {
406    ///   "type": "function",
407    ///   "function": {
408    ///     "name": "action_name",
409    ///     "description": "What this action does",
410    ///     "parameters": {
411    ///       "type": "object",
412    ///       "properties": { ... },
413    ///       "required": [ ... ]
414    ///     }
415    ///   }
416    /// }
417    /// ```
418    pub fn to_tool_schema(&self) -> serde_json::Value {
419        let mut properties = serde_json::Map::new();
420        let mut required = Vec::new();
421
422        for param in &self.parameters {
423            let mut prop = serde_json::Map::new();
424            // Map type_hint to JSON Schema type
425            let json_type = match param.type_hint.as_str() {
426                "number" | "integer" => "number",
427                "boolean" | "bool" => "boolean",
428                "array" => "array",
429                "object" => "object",
430                _ => "string",
431            };
432            prop.insert(
433                "type".to_string(),
434                serde_json::Value::String(json_type.to_string()),
435            );
436            // A closed value set becomes a JSON-Schema enum, so native
437            // tool-calling backends constrain the model to the valid values.
438            if let Some(choices) = param.choices() {
439                prop.insert(
440                    "enum".to_string(),
441                    serde_json::Value::Array(
442                        choices.into_iter().map(serde_json::Value::String).collect(),
443                    ),
444                );
445            }
446            prop.insert(
447                "description".to_string(),
448                serde_json::Value::String(param.description.clone()),
449            );
450            properties.insert(param.name.clone(), serde_json::Value::Object(prop));
451            if param.required {
452                required.push(serde_json::Value::String(param.name.clone()));
453            }
454        }
455
456        serde_json::json!({
457            "type": "function",
458            "function": {
459                "name": self.name,
460                "description": self.description,
461                "parameters": {
462                    "type": "object",
463                    "properties": properties,
464                    "required": required
465                }
466            }
467        })
468    }
469
470    /// Convert to MCP tool schema for rmcp
471    ///
472    /// Returns a JSON value in the format:
473    /// ```json
474    /// {
475    ///   "name": "action_name",
476    ///   "description": "What this action does",
477    ///   "inputSchema": {
478    ///     "type": "object",
479    ///     "properties": { ... },
480    ///     "required": [ ... ]
481    ///   }
482    /// }
483    /// ```
484    pub fn to_mcp_tool_schema(&self) -> serde_json::Value {
485        let mut properties = serde_json::Map::new();
486        let mut required = Vec::new();
487
488        for param in &self.parameters {
489            let mut prop = serde_json::Map::new();
490            let json_type = match param.type_hint.as_str() {
491                "number" | "integer" => "number",
492                "boolean" | "bool" => "boolean",
493                "array" => "array",
494                "object" => "object",
495                _ => "string",
496            };
497            prop.insert(
498                "type".to_string(),
499                serde_json::Value::String(json_type.to_string()),
500            );
501            // Same as `to_tool_schema`: a closed value set is advertised as an enum.
502            if let Some(choices) = param.choices() {
503                prop.insert(
504                    "enum".to_string(),
505                    serde_json::Value::Array(
506                        choices.into_iter().map(serde_json::Value::String).collect(),
507                    ),
508                );
509            }
510            prop.insert(
511                "description".to_string(),
512                serde_json::Value::String(param.description.clone()),
513            );
514            properties.insert(param.name.clone(), serde_json::Value::Object(prop));
515            if param.required {
516                required.push(serde_json::Value::String(param.name.clone()));
517            }
518        }
519
520        serde_json::json!({
521            "name": self.name,
522            "description": self.description,
523            "inputSchema": {
524                "type": "object",
525                "properties": properties,
526                "required": required
527            }
528        })
529    }
530}
531
532/// Parameter definition for an action
533#[derive(Debug, Clone)]
534pub struct Parameter {
535    /// Parameter name (e.g., "output", "connection_id")
536    pub name: String,
537
538    /// Type hint for the LLM (e.g., "string", "number", "boolean").
539    ///
540    /// A parameter whose value comes from a **closed set** declares it here as a
541    /// quoted alternation — `"utf8" | "hex"` — normally written via
542    /// [`Parameter::with_choices`]. [`Parameter::choices`] parses it back out.
543    /// The spelling is deliberate: it reads as a TypeScript-style literal union
544    /// in the LLM prompt, maps to `"enum"` in the native tool-calling schemas,
545    /// and cannot collide with the *type* unions some protocols already use
546    /// (`"number | string"`, `"array | object"`), whose segments are unquoted.
547    pub type_hint: String,
548
549    /// Description of what this parameter does
550    pub description: String,
551
552    /// Whether this parameter is required
553    pub required: bool,
554}
555
556impl Parameter {
557    /// Declare the closed set of values this parameter accepts.
558    ///
559    /// Encodes the choices into `type_hint` as a quoted alternation
560    /// (`"utf8" | "hex"`), which is what [`Parameter::choices`] recognises.
561    /// The TUI composer renders such a parameter as a cycling selector instead
562    /// of a free-text field, and the native tool schemas advertise the set as a
563    /// JSON-Schema `enum`. The chosen value is always submitted as a plain
564    /// string.
565    ///
566    /// ```rust,ignore
567    /// Parameter { name: "encoding".into(), type_hint: "string".into(), … }
568    ///     .with_choices(["utf8", "hex"])
569    /// ```
570    pub fn with_choices<I, S>(mut self, choices: I) -> Self
571    where
572        I: IntoIterator<Item = S>,
573        S: Into<String>,
574    {
575        let rendered: Vec<String> = choices
576            .into_iter()
577            .map(|c| format!("\"{}\"", c.into()))
578            .collect();
579        if !rendered.is_empty() {
580            self.type_hint = rendered.join(" | ");
581        }
582        self
583    }
584
585    /// The closed set of values this parameter accepts, if it declares one.
586    ///
587    /// Recognises a `type_hint` that is a quoted alternation of two or more
588    /// literals (`"utf8" | "hex"`, as written by [`Parameter::with_choices`]).
589    /// Unquoted alternations — the *type* unions like `"number | string"` used
590    /// by jsonrpc and torrent_tracker — are deliberately not choices: they name
591    /// kinds of values, not the values themselves.
592    pub fn choices(&self) -> Option<Vec<String>> {
593        let segments: Vec<&str> = self.type_hint.split('|').map(str::trim).collect();
594        if segments.len() < 2 {
595            return None;
596        }
597        let mut values = Vec::with_capacity(segments.len());
598        for segment in segments {
599            let inner = segment.strip_prefix('"')?.strip_suffix('"')?;
600            if inner.is_empty() {
601                return None;
602            }
603            values.push(inner.to_string());
604        }
605        Some(values)
606    }
607}
608
609/// The first JSON value in `text`, starting the scan at `start`, that is shaped like an answer:
610/// an object, or an array whose items are all objects. Anything else (a bare string, a number,
611/// an array of words quoted in the model's prose) is skipped and the scan resumes at the next
612/// `{` or `[`.
613fn first_answer_shaped_value(text: &str, start: usize) -> Option<serde_json::Value> {
614    let mut from = start;
615    loop {
616        let candidate = &text[from..];
617        let value = serde_json::Deserializer::from_str(candidate)
618            .into_iter::<serde_json::Value>()
619            .next()
620            .and_then(|r| r.ok());
621        match value {
622            Some(v @ serde_json::Value::Object(_)) => return Some(v),
623            Some(serde_json::Value::Array(items)) if items.iter().all(|i| i.is_object()) => {
624                return Some(serde_json::Value::Array(items))
625            }
626            _ => {}
627        }
628        // Resume at the next opening bracket after this one.
629        let next = text[from + 1..]
630            .find(['{', '['])
631            .map(|offset| from + 1 + offset)?;
632        from = next;
633    }
634}
635
636/// Normalize an LLM-emitted action/tool-call object into the canonical flat
637/// `{"type": <name>, <params...>}` shape that the action/tool parsers expect.
638///
639/// Tolerates the common OpenAI/tool-calling variants that some models (notably
640/// MLX/gemma builds) emit instead of NetGet's format:
641///   - the action name under `type`, `function`, or `name`;
642///   - parameters nested under `args`, `arguments`, or `parameters` rather than flat.
643///
644/// A value with no recognizable name key, or a non-object, is returned unchanged.
645pub fn normalize_action_object(value: &serde_json::Value) -> serde_json::Value {
646    let obj = match value.as_object() {
647        Some(o) => o,
648        None => return value.clone(),
649    };
650
651    // `name` is BOTH a tool-calling spelling of the action name
652    // ({"name": "send_x", "arguments": {…}}) and a legitimate *parameter* of
653    // real actions — send_nntp_group's newsgroup, create_database's database,
654    // openid/oci_registry/dc fields. Consume it as the action name only when
655    // nothing else supplies one; otherwise it is data and must survive, or the
656    // executor sees the action with the field silently missing and fails it
657    // with "Missing 'name' field" against a model answer that had it.
658    let (name, name_was_the_action) = match obj.get("type").and_then(|v| v.as_str()) {
659        Some(t) => (t.to_string(), false),
660        None => match obj.get("function").and_then(|v| v.as_str()) {
661            Some(f) => (f.to_string(), false),
662            None => match obj.get("name").and_then(|v| v.as_str()) {
663                Some(n) => (n.to_string(), true),
664                None => return value.clone(),
665            },
666        },
667    };
668
669    let mut out = serde_json::Map::new();
670    out.insert("type".to_string(), serde_json::Value::String(name));
671
672    // Flat params: keep everything except wrapper keys and whichever key
673    // supplied the action name.
674    for (k, v) in obj {
675        let is_wrapper = matches!(
676            k.as_str(),
677            "type" | "function" | "args" | "arguments" | "parameters"
678        );
679        if is_wrapper || (k == "name" && name_was_the_action) {
680            continue;
681        }
682        out.insert(k.clone(), v.clone());
683    }
684    // Merge nested params up to the top level (flat params win on conflict).
685    for wrapper in ["args", "arguments", "parameters"] {
686        if let Some(serde_json::Value::Object(inner)) = obj.get(wrapper) {
687            for (k, v) in inner {
688                out.entry(k.clone()).or_insert_with(|| v.clone());
689            }
690        }
691    }
692
693    serde_json::Value::Object(out)
694}
695
696/// Response from LLM containing tools and/or actions
697#[derive(Debug, Clone, Deserialize, Serialize)]
698pub struct ActionResponse {
699    /// Array of protocol-specific actions to execute in order
700    #[serde(default)]
701    pub actions: Vec<serde_json::Value>,
702
703    /// Array of tool calls (read_file, web_search, generate_random, etc.)
704    /// Tools are executed before actions and their results feed back to the LLM
705    #[serde(default)]
706    pub tools: Vec<serde_json::Value>,
707}
708
709impl ActionResponse {
710    /// Parse from JSON string
711    #[allow(clippy::should_implement_trait)]
712    pub fn from_str(s: &str) -> anyhow::Result<Self> {
713        let trimmed = s.trim();
714
715        // Strip markdown code fences if present (```json ... ``` or ``` ... ```)
716        let json_str = if trimmed.starts_with("```") {
717            // Find the first newline after opening fence
718            let start = trimmed.find('\n').unwrap_or(3);
719            // Find the closing fence (must be after start)
720            let end = trimmed[start..]
721                .rfind("```")
722                .map(|pos| start + pos)
723                .unwrap_or(trimmed.len());
724            // Extract content between fences (ensure valid slice)
725            if start <= end {
726                trimmed[start..end].trim()
727            } else {
728                trimmed
729            }
730        } else {
731            trimmed
732        };
733
734        // Strip any extra characters before the JSON object or array.
735        // Sometimes LLMs add extra text like "Y{" instead of just "{". Take whichever
736        // of `{` or `[` comes FIRST — preferring `{` unconditionally would strip the
737        // leading `[` off a top-level array and corrupt it.
738        let json_start = match (json_str.find('{'), json_str.find('[')) {
739            (Some(b), Some(k)) => b.min(k),
740            (Some(b), None) => b,
741            (None, Some(k)) => k,
742            (None, None) => 0,
743        };
744        let clean_json = &json_str[json_start..];
745
746        // Take the FIRST complete JSON value and ignore whatever follows it.
747        //
748        // `serde_json::from_str` requires the *whole* string to be one value, and that single
749        // property discarded 29 of 30 answers in the first real-model eval
750        // (`EVAL_RESULTS.md`). Small models routinely append prose after the JSON:
751        //
752        //     {"actions": [{"type": "send_http_response", "status": 200, …}]}
753        //     Explanation: Since this is an HTTP request event, I'm emitting a …
754        //
755        // The model had named the right action with the right parameters *every time*, and
756        // NetGet threw the reply away with `Invalid JSON` — so the protocol behaved as though
757        // the backend had failed. Nothing in the suite could see it, because every mock
758        // returns exactly the JSON the test author wrote; the defect lives precisely in the
759        // gap between a mock and a model.
760        //
761        // The trailing-fence case already worked (the stripper above cuts at the closing
762        // ```), which is why this looked like a fenced-output problem rather than a parsing
763        // one.
764        //
765        // `StreamDeserializer` stops at the end of the first value instead of demanding it be
766        // the last thing in the string. A string that is entirely one value parses exactly as
767        // before, so this only ever widens what is accepted.
768        //
769        // The first value has to be something an answer can be: an object, or an array of
770        // objects. Prose often brackets a word before the JSON starts ("the containers
771        // ["eval-web"] are ..."), and taking that array made a bare string the model's
772        // "action". Such a value is skipped and the scan moves to the next `{` or `[`.
773        let first_value = first_answer_shaped_value(json_str, json_start);
774
775        if let Some(json_value) = first_value {
776            use crate::llm::actions::tools::ToolAction;
777            match json_value {
778                // Case 1: Full response object with tools and/or actions fields
779                serde_json::Value::Object(ref map)
780                    if map.contains_key("tools") || map.contains_key("actions") =>
781                {
782                    match serde_json::from_value::<ActionResponse>(json_value.clone()) {
783                        Ok(mut response) => {
784                            // Normalize each entry (flatten nested args, map function->type).
785                            for a in response.actions.iter_mut() {
786                                *a = normalize_action_object(a);
787                            }
788                            for t in response.tools.iter_mut() {
789                                *t = normalize_action_object(t);
790                            }
791                            // Separate tools and actions if mixed (support cross-contamination)
792                            Self::separate_tools_and_actions(&mut response);
793                            return Ok(response);
794                        }
795                        Err(e) => {
796                            anyhow::bail!(
797                                "Failed to parse action response\n\n❌ Expected format:\n{{\n  \"tools\": [...],\n  \"actions\": [...]\n}}\n\n❌ Actual response:\n{}\n\nError: {}",
798                                clean_json,
799                                e
800                            )
801                        }
802                    }
803                }
804
805                // Case 1b: OpenAI-style tool-call wrapper
806                // {"tool_calls": [{"function"/"name": "...", "args"/"arguments"/"parameters": {...}}]}
807                // Many models (e.g. MLX/gemma builds) emit this instead of the native format.
808                serde_json::Value::Object(ref map)
809                    if map.contains_key("tool_calls") || map.contains_key("tool_call") =>
810                {
811                    let mut response = ActionResponse::empty();
812                    let calls = map.get("tool_calls").or_else(|| map.get("tool_call"));
813                    let items: Vec<serde_json::Value> = match calls {
814                        Some(serde_json::Value::Array(a)) => a.clone(),
815                        Some(v @ serde_json::Value::Object(_)) => vec![v.clone()],
816                        _ => Vec::new(),
817                    };
818                    for item in &items {
819                        let norm = normalize_action_object(item);
820                        if ToolAction::is_tool_action(&norm) {
821                            response.tools.push(norm);
822                        } else {
823                            response.actions.push(norm);
824                        }
825                    }
826                    return Ok(response);
827                }
828
829                // Case 2: Single action/tool object at top level. Accept a name under
830                // `type`, `function`, or `name`; normalize flattens any nested args.
831                serde_json::Value::Object(ref map)
832                    if map.contains_key("type")
833                        || map.contains_key("function")
834                        || map.contains_key("name") =>
835                {
836                    let norm = normalize_action_object(&json_value);
837                    let mut response = ActionResponse::empty();
838                    if ToolAction::is_tool_action(&norm) {
839                        response.tools.push(norm);
840                    } else {
841                        response.actions.push(norm);
842                    }
843                    return Ok(response);
844                }
845
846                // Case 3: Top-level array [{"type": "..."}, ...]
847                serde_json::Value::Array(items) => {
848                    let mut response = ActionResponse::empty();
849
850                    for item in &items {
851                        let norm = normalize_action_object(item);
852                        if ToolAction::is_tool_action(&norm) {
853                            response.tools.push(norm);
854                        } else {
855                            response.actions.push(norm);
856                        }
857                    }
858                    return Ok(response);
859                }
860
861                // Case 4: Empty object {} - return empty response
862                serde_json::Value::Object(ref map) if map.is_empty() => {
863                    return Ok(ActionResponse::empty());
864                }
865
866                // Unrecognized format
867                _ => {
868                    anyhow::bail!(
869                        "Failed to parse action response: Unrecognized format\n\n❌ Expected one of:\n\
870                        1. {{\n     \"tools\": [...],\n     \"actions\": [...]\n   }}\n\
871                        2. {{\"type\": \"...\", ...}}\n\
872                        3. [{{\"type\": \"...\"}}, ...]\n\n❌ Actual response:\n{}",
873                        clean_json
874                    )
875                }
876            }
877        } else {
878            anyhow::bail!(
879                "Failed to parse action response: Invalid JSON\n\n❌ Actual response:\n{}",
880                clean_json
881            )
882        }
883    }
884
885    /// Separate tools and actions if they are mixed (cross-contamination support)
886    /// This handles cases where tools are in the actions array or vice versa
887    fn separate_tools_and_actions(response: &mut ActionResponse) {
888        use crate::llm::actions::tools::ToolAction;
889
890        // Check tools array for misplaced actions
891        if !response.tools.is_empty() {
892            let (actual_tools, misplaced_actions): (Vec<_>, Vec<_>) = response
893                .tools
894                .clone()
895                .into_iter()
896                .partition(|item| ToolAction::is_tool_action(item));
897
898            response.tools = actual_tools;
899            response.actions.extend(misplaced_actions);
900        }
901
902        // Check actions array for misplaced tools
903        if !response.actions.is_empty() {
904            let (misplaced_tools, actual_actions): (Vec<_>, Vec<_>) = response
905                .actions
906                .clone()
907                .into_iter()
908                .partition(|item| ToolAction::is_tool_action(item));
909
910            response.actions = actual_actions;
911            response.tools.extend(misplaced_tools);
912        }
913    }
914
915    /// Create empty action response
916    pub fn empty() -> Self {
917        Self {
918            actions: Vec::new(),
919            tools: Vec::new(),
920        }
921    }
922
923    /// Get all actions (combining tools and actions for backward compatibility)
924    pub fn all_actions(&self) -> Vec<serde_json::Value> {
925        let mut all = self.tools.clone();
926        all.extend(self.actions.clone());
927        all
928    }
929}
930
931impl std::str::FromStr for ActionResponse {
932    type Err = anyhow::Error;
933
934    fn from_str(s: &str) -> Result<Self, Self::Err> {
935        ActionResponse::from_str(s)
936    }
937}