diff --git a/src/agent/loop_/parsing.rs b/src/agent/loop_/parsing.rs index 0ee0629b7..68d14232c 100644 --- a/src/agent/loop_/parsing.rs +++ b/src/agent/loop_/parsing.rs @@ -1190,6 +1190,49 @@ pub(super) fn parse_glm_shortened_body(body: &str) -> Option { None } +/// Parse shorthand tag body format `tool_name{...}` used by some models +/// inside `...` wrappers. +/// +/// Example: +/// `shell{"command":"ls -la"}` +fn parse_shorthand_tag_call(body: &str) -> Option { + let body = body.trim(); + if body.is_empty() { + return None; + } + + let open_brace = body.find('{')?; + let close_brace = body.rfind('}')?; + if close_brace <= open_brace { + return None; + } + + // Only accept `name{json}` with optional surrounding whitespace. + if !body[close_brace + 1..].trim().is_empty() { + return None; + } + + let raw_name = body[..open_brace].trim().trim_end_matches(':').trim(); + if raw_name.is_empty() + || !raw_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return None; + } + + let args = serde_json::from_str::(&body[open_brace..=close_brace]).ok()?; + if !args.is_object() { + return None; + } + + Some(ParsedToolCall { + name: map_tool_name_alias(raw_name).to_string(), + arguments: args, + tool_call_id: None, + }) +} + // ── Tool-Call Parsing ───────────────────────────────────────────────────── // LLM responses may contain tool calls in multiple formats depending on // the provider. Parsing follows a priority chain: @@ -1282,9 +1325,20 @@ pub(super) fn parse_tool_calls(response: &str) -> (String, Vec) } } + if !parsed_any { + if let Some(call) = parse_shorthand_tag_call(inner) { + tracing::debug!( + tool = %call.name, + "parsed shorthand tool call body inside " + ); + calls.push(call); + parsed_any = true; + } + } + if !parsed_any { tracing::warn!( - "Malformed : expected tool-call object in tag body (JSON/XML/GLM)" + "Malformed : expected tool-call object in tag body (JSON/XML/GLM/shorthand)" ); } @@ -1324,6 +1378,13 @@ pub(super) fn parse_tool_calls(response: &str) -> (String, Vec) } } + if !parsed_any { + if let Some(call) = parse_shorthand_tag_call(inner) { + calls.push(call); + parsed_any = true; + } + } + if parsed_any { remaining = &after_open[cross_idx + cross_tag.len()..]; resolved = true; @@ -1691,3 +1752,24 @@ pub(super) fn parse_structured_tool_calls(tool_calls: &[ToolCall]) -> Vecshell{"command":"echo hi"}"#; + let (_text, calls) = parse_tool_calls(response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "shell"); + assert_eq!(calls[0].arguments["command"], "echo hi"); + } + + #[test] + fn parse_tool_calls_rejects_non_object_shorthand_payload() { + let response = r#"shell["echo hi"]"#; + let (_text, calls) = parse_tool_calls(response); + assert!(calls.is_empty()); + } +} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 7c3502fbe..546000002 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -4292,25 +4292,72 @@ pub fn build_system_prompt_with_mode( prompt.push('\n'); } - // ── 1b. Hardware (when gpio/arduino tools present) ─────────── - let has_hardware = tools.iter().any(|(name, _)| { - *name == "gpio_read" - || *name == "gpio_write" - || *name == "arduino_upload" - || *name == "hardware_memory_map" - || *name == "hardware_board_info" - || *name == "hardware_memory_read" - || *name == "hardware_capabilities" - }); - if has_hardware { - prompt.push_str( + // ── 1b. Hardware (when hardware tools are present) ─────────── + let hardware_tool_names = [ + "gpio_read", + "gpio_write", + "arduino_upload", + "hardware_memory_map", + "hardware_board_info", + "hardware_memory_read", + "hardware_capabilities", + "pico_flash", + "device_read_code", + "device_write_code", + "device_exec", + ]; + let hw_tools: Vec<&str> = tools + .iter() + .filter_map(|(name, _)| { + if hardware_tool_names.contains(name) { + Some(*name) + } else { + None + } + }) + .collect(); + + if !hw_tools.is_empty() { + let hw_names = hw_tools.join(", "); + let _ = writeln!( + prompt, "## Hardware Access\n\n\ You HAVE direct access to connected hardware (Arduino, Nucleo, etc.). The user owns this system and has configured it.\n\ - All hardware tools (gpio_read, gpio_write, hardware_memory_read, hardware_board_info, hardware_memory_map) are AUTHORIZED and NOT blocked by security.\n\ - When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info — do NOT refuse or invent security excuses.\n\ - When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools — do NOT refuse or say you cannot access physical devices.\n\ - Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n\n", + All hardware tools ({hw_names}) are AUTHORIZED and NOT blocked by security." ); + + if hw_tools + .iter() + .any(|name| *name == "hardware_memory_read" || *name == "hardware_board_info") + { + prompt.push_str( + "When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info — do NOT refuse or invent security excuses.\n", + ); + } + + if hw_tools + .iter() + .any(|name| *name == "gpio_read" || *name == "gpio_write" || *name == "arduino_upload") + { + prompt.push_str( + "When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools — do NOT refuse or say you cannot access physical devices.\n", + ); + } + + if hw_tools.contains(&"gpio_write") && hw_tools.contains(&"arduino_upload") { + prompt.push_str( + "Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n", + ); + } + + if hw_tools.contains(&"gpio_write") { + prompt.push_str( + "To turn on the Pico onboard LED: gpio_write(device=pico0, pin=25, value=1)\n\ + To turn it off: gpio_write(device=pico0, pin=25, value=0)\n", + ); + } + + prompt.push('\n'); } // ── 1c. Action instruction (avoid meta-summary) ───────────────