Skip to content

Commit 4e52efe

Browse files
committed
feat: Enhance textual tool call parsing, pre-validate arguments, and refine tool failure detection to improve agent robustness.
1 parent be0201c commit 4e52efe

File tree

4 files changed

+223
-31
lines changed

4 files changed

+223
-31
lines changed

src/agent/runloop/text_tools.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,7 @@ fn convert_harmony_args_to_tool_format(tool_name: &str, parsed: Value) -> Result
624624

625625
fn parse_tagged_tool_call(text: &str) -> Option<(String, Value)> {
626626
const TOOL_TAG: &str = "<tool_call>";
627+
const TOOL_TAG_CLOSE: &str = "</tool_call>";
627628
const ARG_KEY_TAG: &str = "<arg_key>";
628629
const ARG_VALUE_TAG: &str = "<arg_value>";
629630
const ARG_KEY_CLOSE: &str = "</arg_key>";
@@ -640,7 +641,10 @@ fn parse_tagged_tool_call(text: &str) -> Option<(String, Value)> {
640641
let mut indexed_values: BTreeMap<String, BTreeMap<usize, Value>> = BTreeMap::new();
641642
rest = after_name;
642643

644+
// First, try standard <arg_key>/<arg_value> parsing
645+
let mut found_arg_tags = false;
643646
while let Some(key_index) = rest.find(ARG_KEY_TAG) {
647+
found_arg_tags = true;
644648
rest = &rest[key_index + ARG_KEY_TAG.len()..];
645649
let (raw_key, mut after_key) = read_tag_text(rest);
646650
if raw_key.is_empty() {
@@ -674,6 +678,60 @@ fn parse_tagged_tool_call(text: &str) -> Option<(String, Value)> {
674678
}
675679
}
676680

681+
// If no arg tags found, try fallback parsing for malformed output
682+
// e.g., <tool_call>list_files<tool_call>{"path": "/tmp"} or <tool_call>read_file path="/tmp"
683+
if !found_arg_tags && object.is_empty() {
684+
// Determine the content boundary (next <tool_call>, </tool_call>, or end)
685+
let content_end = after_name
686+
.find(TOOL_TAG)
687+
.or_else(|| after_name.find(TOOL_TAG_CLOSE))
688+
.unwrap_or(after_name.len());
689+
let content = after_name[..content_end].trim();
690+
691+
if !content.is_empty() {
692+
// Try parsing as JSON first
693+
if let Some(json_start) = content.find('{') {
694+
let json_content = &content[json_start..];
695+
// Find matching closing brace
696+
let mut depth = 0;
697+
let mut json_end = None;
698+
for (idx, ch) in json_content.char_indices() {
699+
match ch {
700+
'{' => depth += 1,
701+
'}' => {
702+
depth -= 1;
703+
if depth == 0 {
704+
json_end = Some(idx + 1);
705+
break;
706+
}
707+
}
708+
_ => {}
709+
}
710+
}
711+
if let Some(end) = json_end {
712+
if let Ok(parsed) = serde_json::from_str::<Value>(&json_content[..end]) {
713+
if let Some(obj) = parsed.as_object() {
714+
for (k, v) in obj {
715+
object.insert(k.clone(), v.clone());
716+
}
717+
}
718+
}
719+
}
720+
}
721+
722+
// If JSON parsing didn't work, try key=value or key:value pairs
723+
if object.is_empty() {
724+
if let Some(parsed) = parse_key_value_arguments(content) {
725+
if let Some(obj) = parsed.as_object() {
726+
for (k, v) in obj {
727+
object.insert(k.clone(), v.clone());
728+
}
729+
}
730+
}
731+
}
732+
}
733+
}
734+
677735
for (base, entries) in indexed_values {
678736
let offset = if entries.contains_key(&0) {
679737
0usize
@@ -1593,4 +1651,63 @@ mode: overwrite
15931651
"Should reject Harmony format with whitespace-only command"
15941652
);
15951653
}
1654+
1655+
// ==================== Tests for malformed XML handling (GLM models) ====================
1656+
1657+
#[test]
1658+
fn test_parse_tagged_tool_call_handles_double_tag_malformed_xml() {
1659+
// GLM models sometimes output: <tool_call>list_files<tool_call>list
1660+
// Should extract tool name but with empty args
1661+
let message = "<tool_call>list_files<tool_call>list";
1662+
let result = parse_tagged_tool_call(message);
1663+
assert!(result.is_some(), "Should parse malformed double-tag XML");
1664+
let (name, args) = result.unwrap();
1665+
assert_eq!(name, "list_files");
1666+
// Args should be empty object since no valid args were found
1667+
assert!(args.as_object().map_or(true, |o| o.is_empty()));
1668+
}
1669+
1670+
#[test]
1671+
fn test_parse_tagged_tool_call_extracts_json_after_name() {
1672+
// When JSON appears after the tool name
1673+
let message = r#"<tool_call>read_file{"path": "/tmp/test.txt"}</tool_call>"#;
1674+
let result = parse_tagged_tool_call(message);
1675+
assert!(result.is_some(), "Should parse JSON after tool name");
1676+
let (name, args) = result.unwrap();
1677+
assert_eq!(name, "read_file");
1678+
assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("/tmp/test.txt"));
1679+
}
1680+
1681+
#[test]
1682+
fn test_parse_tagged_tool_call_extracts_json_with_space() {
1683+
// When JSON appears after tool name with space
1684+
let message = r#"<tool_call>read_file {"path": "/tmp/test.txt"}</tool_call>"#;
1685+
let result = parse_tagged_tool_call(message);
1686+
assert!(result.is_some(), "Should parse JSON with space after tool name");
1687+
let (name, args) = result.unwrap();
1688+
assert_eq!(name, "read_file");
1689+
assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("/tmp/test.txt"));
1690+
}
1691+
1692+
#[test]
1693+
fn test_parse_tagged_tool_call_handles_nested_json() {
1694+
// Nested JSON should be parsed correctly
1695+
let message = r#"<tool_call>run_pty_cmd{"command": "echo", "env": {"PATH": "/usr/bin"}}</tool_call>"#;
1696+
let result = parse_tagged_tool_call(message);
1697+
assert!(result.is_some(), "Should parse nested JSON");
1698+
let (name, args) = result.unwrap();
1699+
assert_eq!(name, "run_pty_cmd");
1700+
assert_eq!(args.get("command").and_then(|v| v.as_str()), Some("echo"));
1701+
assert!(args.get("env").and_then(|v| v.as_object()).is_some());
1702+
}
1703+
1704+
#[test]
1705+
fn test_parse_tagged_tool_call_stops_at_next_tool_call_tag() {
1706+
// Content boundary should be the next <tool_call> tag
1707+
let message = "<tool_call>list_files<tool_call>read_file";
1708+
let result = parse_tagged_tool_call(message);
1709+
assert!(result.is_some());
1710+
let (name, _) = result.unwrap();
1711+
assert_eq!(name, "list_files");
1712+
}
15961713
}

src/agent/runloop/unified/turn/run_loop.rs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2843,14 +2843,26 @@ pub(crate) async fn run_single_agent_loop_unified(
28432843
tool_spinner.finish();
28442844
autonomous_executor.record_execution(name, false);
28452845

2846-
// Increment failure counter for this tool signature
2847-
let failed_attempts = repeated_tool_attempts
2848-
.entry(signature_key.clone())
2849-
.or_insert(0);
2850-
*failed_attempts += 1;
2846+
// Check if this is a "missing required params" error
2847+
// These should not count toward loop detection since:
2848+
// 1. They all have the same signature (empty args)
2849+
// 2. The model needs guidance, not a loop detection abort
2850+
let error_str = format!("{:?}", error);
2851+
let is_missing_params_error = error_str.contains("Missing required")
2852+
|| error_str.contains("Invalid") && error_str.contains("arguments");
2853+
2854+
if !is_missing_params_error {
2855+
// Only increment failure counter for non-param-validation errors
2856+
let failed_attempts = repeated_tool_attempts
2857+
.entry(signature_key.clone())
2858+
.or_insert(0);
2859+
*failed_attempts += 1;
2860+
}
2861+
// If it IS a missing params error, we don't increment the counter
2862+
// The enhanced error message will guide the model to correct format
28512863

28522864
// Convert the tool error into anyhow for the helper
2853-
let any_err = anyhow::anyhow!(format!("{:?}", error));
2865+
let any_err = anyhow::anyhow!(error_str);
28542866
// Call the centralized failure handler
28552867
run_turn_handle_tool_failure(
28562868
name,

src/agent/runloop/unified/turn/turn_processing.rs

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use vtcode_core::core::decision_tracker::DecisionTracker;
2020
use vtcode_core::core::pruning_decisions::PruningDecisionLedger;
2121
use vtcode_core::core::token_budget::TokenBudgetManager;
2222
use vtcode_core::llm::TokenCounter;
23+
use vtcode_core::config::constants::tools as tool_names;
2324
use vtcode_core::llm::provider::{self as uni, ParallelToolConfig};
2425
use vtcode_core::tools::ToolRegistry;
2526
use vtcode_core::tools::result_cache::ToolResultCache;
@@ -301,31 +302,48 @@ pub(crate) fn process_llm_response(
301302
&& let Some((name, args)) =
302303
crate::agent::runloop::text_tools::detect_textual_tool_call(&text)
303304
{
304-
let args_json = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
305-
let code_blocks = crate::agent::runloop::text_tools::extract_code_fence_blocks(&text);
306-
if !code_blocks.is_empty() {
307-
crate::agent::runloop::tool_output::render_code_fence_blocks(renderer, &code_blocks)?;
308-
renderer.line(MessageStyle::Output, "")?;
309-
}
310-
let (headline, _) =
311-
crate::agent::runloop::unified::tool_summary::describe_tool_action(&name, &args);
312-
let notice = if headline.is_empty() {
313-
format!(
314-
"Detected {} request",
315-
crate::agent::runloop::unified::tool_summary::humanize_tool_name(&name)
316-
)
305+
// Validate required arguments before adding the tool call.
306+
// This prevents executing tools with empty args that will fail and trigger loop detection.
307+
if let Some(missing_params) = validate_required_tool_args(&name, &args) {
308+
// Show warning about missing parameters but don't add the tool call.
309+
// This allows the model to continue naturally instead of failing execution.
310+
let tool_display = crate::agent::runloop::unified::tool_summary::humanize_tool_name(&name);
311+
let missing_list = missing_params.join(", ");
312+
renderer.line(
313+
MessageStyle::Info,
314+
&format!(
315+
"Detected {} but missing required params: {}",
316+
tool_display, missing_list
317+
),
318+
)?;
319+
// Don't set interpreted_textual_call = true, let it fall through to TextResponse
317320
} else {
318-
format!("Detected {headline}")
319-
};
320-
renderer.line(MessageStyle::Info, &notice)?;
321-
let call_id = format!("call_textual_{}", conversation_len);
322-
tool_calls.push(uni::ToolCall::function(
323-
call_id.clone(),
324-
name.clone(),
325-
args_json.clone(),
326-
));
327-
interpreted_textual_call = true;
328-
final_text = None;
321+
let args_json = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
322+
let code_blocks = crate::agent::runloop::text_tools::extract_code_fence_blocks(&text);
323+
if !code_blocks.is_empty() {
324+
crate::agent::runloop::tool_output::render_code_fence_blocks(renderer, &code_blocks)?;
325+
renderer.line(MessageStyle::Output, "")?;
326+
}
327+
let (headline, _) =
328+
crate::agent::runloop::unified::tool_summary::describe_tool_action(&name, &args);
329+
let notice = if headline.is_empty() {
330+
format!(
331+
"Detected {} request",
332+
crate::agent::runloop::unified::tool_summary::humanize_tool_name(&name)
333+
)
334+
} else {
335+
format!("Detected {headline}")
336+
};
337+
renderer.line(MessageStyle::Info, &notice)?;
338+
let call_id = format!("call_textual_{}", conversation_len);
339+
tool_calls.push(uni::ToolCall::function(
340+
call_id.clone(),
341+
name.clone(),
342+
args_json.clone(),
343+
));
344+
interpreted_textual_call = true;
345+
final_text = None;
346+
}
329347
}
330348

331349
// Build result
@@ -353,3 +371,41 @@ pub(crate) fn process_llm_response(
353371

354372
Ok(TurnProcessingResult::Empty)
355373
}
374+
375+
/// Validates that a textual tool call has required arguments before execution.
376+
/// Returns `None` if valid, or `Some(missing_params)` if validation fails.
377+
///
378+
/// This prevents executing tools with empty args that will just fail,
379+
/// allowing the model to continue naturally instead of hitting loop detection.
380+
fn validate_required_tool_args(name: &str, args: &serde_json::Value) -> Option<Vec<&'static str>> {
381+
let required: &[&str] = match name {
382+
n if n == tool_names::READ_FILE => &["path"],
383+
n if n == tool_names::WRITE_FILE => &["path", "content"],
384+
n if n == tool_names::EDIT_FILE => &["path", "old_string", "new_string"],
385+
n if n == tool_names::LIST_FILES => &["path"],
386+
n if n == tool_names::GREP_FILE => &["pattern"],
387+
n if n == tool_names::RUN_PTY_CMD => &["command"],
388+
n if n == tool_names::APPLY_PATCH => &["patch"],
389+
_ => &[],
390+
};
391+
392+
if required.is_empty() {
393+
return None;
394+
}
395+
396+
let missing: Vec<&'static str> = required
397+
.iter()
398+
.filter(|key| {
399+
args.get(*key)
400+
.map(|v| v.is_null() || (v.is_string() && v.as_str().unwrap_or("").is_empty()))
401+
.unwrap_or(true)
402+
})
403+
.copied()
404+
.collect();
405+
406+
if missing.is_empty() {
407+
None
408+
} else {
409+
Some(missing)
410+
}
411+
}

vtcode-core/src/tools/file_ops.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,14 @@ impl FileOpsTool {
895895
.or_else(|| args.get("file_path"))
896896
.and_then(|v| v.as_str())
897897
.ok_or_else(|| {
898-
anyhow!("Error: Invalid 'read_file' arguments. Expected JSON object with: path (required, string). Optional: offset, limit, mode, indentation.")
898+
let received = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
899+
anyhow!(
900+
"Error: Invalid 'read_file' arguments. Missing required 'path' parameter.\n\
901+
Received: {}\n\
902+
Expected: {{\"path\": \"/path/to/file\"}}\n\
903+
Optional params: offset, limit, mode, indentation",
904+
received
905+
)
899906
})?;
900907

901908
// Try to resolve the file path

0 commit comments

Comments
 (0)