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}