use anyhow::Error;
use serde_json::{Value, json};
use vtcode_core::config::constants::tools as tool_names;
use vtcode_core::tools::tool_intent;
use vtcode_core::tools::validation::condensed_schema_hint;
use crate::agent::runloop::unified::request_user_input::normalize_request_user_input_fallback_args;
use crate::agent::runloop::unified::turn::context::TurnProcessingContext;
use crate::agent::runloop::unified::turn::tool_outcomes::execution_result::compact_model_tool_payload;
use crate::agent::runloop::unified::turn::tool_outcomes::handlers::PreparedToolCall;
fn trimmed_non_empty_string_field(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn public_exec_fallback(command: String) -> (String, Value) {
(
tool_names::EXEC_COMMAND.to_string(),
json!({
"cmd": command
}),
)
}
fn public_list_fallback(path: &str) -> (String, Value) {
public_exec_fallback(format!("find {} -maxdepth 1 -mindepth 1 -print", shell_single_quote(path)))
}
fn public_grep_fallback(args: &Value) -> Option<(String, Value)> {
let query = trimmed_non_empty_string_field(args, "query")?;
let path = trimmed_non_empty_string_field(args, "path").unwrap_or_else(|| ".".to_string());
Some(public_exec_fallback(format!(
"rg --line-number --column --color=never {} {}",
shell_single_quote(&query),
shell_single_quote(&path)
)))
}
pub(super) fn recovery_fallback_for_tool(tool_name: &str, args: &Value) -> Option<(String, Value)> {
match tool_name {
tool_names::CODE_SEARCH => public_grep_fallback(args),
"list files" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
Some(public_list_fallback(path))
}
"search text" => None,
tool_names::READ_FILE | "read file" | "repo_browser.read_file" => {
let parent_path = args
.get("path")
.and_then(|v| v.as_str())
.and_then(|path| std::path::Path::new(path).parent())
.and_then(|path| path.to_str())
.unwrap_or(".");
Some(public_list_fallback(parent_path))
}
tool_names::UNIFIED_FILE => {
let action = tool_intent::file_operation_action(args).unwrap_or("read");
if action.eq_ignore_ascii_case("write") {
file_operation_write_fallback(args)
} else {
let path = trimmed_non_empty_string_field(args, "path")
.or_else(|| trimmed_non_empty_string_field(args, "file_path"))
.or_else(|| trimmed_non_empty_string_field(args, "filepath"))
.or_else(|| trimmed_non_empty_string_field(args, "target_path"))
.or_else(|| trimmed_non_empty_string_field(args, "file"))
.or_else(|| trimmed_non_empty_string_field(args, "p"))
.unwrap_or_else(|| ".".to_string());
Some(public_list_fallback(&path))
}
}
_ => Some(public_list_fallback(".")),
}
}
fn file_operation_write_fallback(args: &Value) -> Option<(String, Value)> {
let path = args
.get("path")
.or_else(|| args.get("file_path"))
.or_else(|| args.get("filepath"))
.or_else(|| args.get("target_path"))
.or_else(|| args.get("file"))
.or_else(|| args.get("p"))
.and_then(Value::as_str)?;
let content = args.get("content").and_then(Value::as_str)?;
let delimiter = unique_heredoc_delimiter(content)?;
let command = format!("cat > {} << '{delimiter}'\n{content}\n{delimiter}", shell_single_quote(path));
Some(public_exec_fallback(command))
}
fn unique_heredoc_delimiter(content: &str) -> Option<&'static str> {
const CANDIDATES: &[&str] = &["__VT_WRITE_EOF__", "__VT_WRITE_EOF_2__", "__VT_WRITE_EOF_3__"];
CANDIDATES.iter().find(|d| !content.lines().any(|line| line == **d)).copied()
}
pub(super) fn build_validation_error_content_with_fallback(
error: String,
validation_stage: &'static str,
fallback_tool: Option<String>,
fallback_tool_args: Option<Value>,
) -> String {
build_validation_error_content_with_schema_hint(error, validation_stage, fallback_tool, fallback_tool_args, None)
}
pub(super) fn build_validation_error_content_with_schema_hint(
error: String,
validation_stage: &'static str,
fallback_tool: Option<String>,
fallback_tool_args: Option<Value>,
parameter_schema: Option<&Value>,
) -> String {
let is_recoverable = fallback_tool.is_some();
let loop_detected = validation_stage == "loop_detection";
let next_action = if is_recoverable {
"Retry with fallback_tool_args."
} else {
"Fix tool arguments to match the schema."
};
let mut payload = serde_json::json!({
"error": error,
"failure_kind": "validation",
"error_class": "invalid_arguments",
"validation_stage": validation_stage,
"retryable": false,
"is_recoverable": is_recoverable,
"loop_detected": loop_detected,
"next_action": next_action,
});
if let Some(obj) = payload.as_object_mut() {
if let Some(tool) = fallback_tool {
obj.insert("fallback_tool".to_string(), Value::String(tool));
}
if let Some(args) = fallback_tool_args {
obj.insert("fallback_tool_args".to_string(), args);
}
if let Some(schema) = parameter_schema.and_then(condensed_schema_hint) {
obj.insert("expected_schema".to_string(), schema);
}
}
compact_model_tool_payload(payload).to_string()
}
pub(super) fn preflight_validation_fallback(
tool_name: &str,
args_val: &Value,
error: &Error,
) -> Option<(String, Value)> {
let error_text = error.to_string();
let is_request_user_input = tool_name == tool_names::REQUEST_USER_INPUT
|| error_text.contains("tool 'request_user_input'")
|| error_text.contains("for 'request_user_input'");
if is_request_user_input {
let normalized = normalize_request_user_input_fallback_args(args_val)?;
if normalized == *args_val {
return None;
}
return Some((tool_names::REQUEST_USER_INPUT.to_string(), normalized));
}
let is_file_operation = tool_name == tool_names::UNIFIED_FILE
|| error_text.contains("tool 'file_operation'")
|| error_text.contains("for 'file_operation'");
if !is_file_operation {
return None;
}
if tool_intent::file_operation_action_is(args_val, "list") {
let path = trimmed_non_empty_string_field(args_val, "path").unwrap_or_else(|| ".".to_string());
return Some(public_list_fallback(&path));
}
tool_intent::remap_file_operation_command_args_to_command_session(args_val).and_then(|args| {
let command = args.get("command").and_then(Value::as_str).map(str::to_string)?;
let mut fallback_args = json!({ "cmd": command });
if let Some(cwd) = args.get("cwd").and_then(Value::as_str)
&& let Some(obj) = fallback_args.as_object_mut()
{
obj.insert("workdir".to_string(), Value::String(cwd.to_string()));
}
Some((tool_names::EXEC_COMMAND.to_string(), fallback_args))
})
}
pub(super) fn try_recover_preflight_with_fallback(
ctx: &mut TurnProcessingContext<'_>,
tool_name: &str,
args_val: &Value,
error: &Error,
) -> Option<PreparedToolCall> {
let (recovered_tool_name, recovered_args) = preflight_validation_fallback(tool_name, args_val, error)?;
match ctx.tool_registry.admit_public_tool_call(&recovered_tool_name, &recovered_args) {
Ok(prepared) => Some(prepared),
Err(recovery_err) => {
tracing::debug!(
tool = tool_name,
original_error = %error,
recovered_tool = %recovered_tool_name,
recovery_error = %recovery_err,
"Preflight recovery fallback failed"
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::trimmed_non_empty_string_field;
use serde_json::json;
use vtcode_core::config::constants::tools as tool_names;
#[test]
fn trimmed_non_empty_string_field_trims_and_rejects_blank() {
let value = json!({
"pattern": " todo ",
"blank": " "
});
assert_eq!(trimmed_non_empty_string_field(&value, "pattern"), Some("todo".to_string()));
assert_eq!(trimmed_non_empty_string_field(&value, "blank"), None);
assert_eq!(trimmed_non_empty_string_field(&value, "missing"), None);
}
#[test]
fn recovery_fallback_file_operation_write_returns_exec_command() {
let args = json!({
"action": "write",
"path": "docs/output.md",
"content": "# Large Document\n\nSome content here."
});
let (tool_name, fallback_args) = super::recovery_fallback_for_tool(tool_names::UNIFIED_FILE, &args)
.expect("file_operation write should have fallback");
assert_eq!(tool_name, tool_names::EXEC_COMMAND);
let command = fallback_args["cmd"].as_str().expect("command should be a string");
assert!(command.contains("'docs/output.md'"), "command should quote the path");
assert!(command.contains("# Large Document"), "command should include the content");
assert!(command.contains("__VT_WRITE_EOF__"), "command should use unique delimiter");
}
#[test]
fn recovery_fallback_file_operation_write_quotes_path_with_spaces() {
let args = json!({
"action": "write",
"path": "my files/output.md",
"content": "data"
});
let (tool_name, fallback_args) = super::recovery_fallback_for_tool(tool_names::UNIFIED_FILE, &args)
.expect("file_operation write should have fallback");
assert_eq!(tool_name, tool_names::EXEC_COMMAND);
let command = fallback_args["cmd"].as_str().expect("command");
assert!(command.contains("'my files/output.md'"), "path with spaces should be quoted");
}
#[test]
fn recovery_fallback_file_operation_write_infers_action_from_content() {
let args = json!({
"path": "output.txt",
"content": "Hello, world!"
});
let (tool_name, fallback_args) = super::recovery_fallback_for_tool(tool_names::UNIFIED_FILE, &args)
.expect("file_operation with content should have write fallback");
assert_eq!(tool_name, tool_names::EXEC_COMMAND);
assert!(fallback_args["cmd"].as_str().is_some());
}
#[test]
fn recovery_fallback_file_operation_read_preserves_path() {
let args = json!({
"action": "read",
"path": "src/main.rs"
});
let (tool_name, fallback_args) = super::recovery_fallback_for_tool(tool_names::UNIFIED_FILE, &args)
.expect("file_operation read should have fallback");
assert_eq!(tool_name, tool_names::EXEC_COMMAND);
assert!(fallback_args["cmd"].as_str().is_some_and(|cmd| cmd.contains("'src/main.rs'")));
}
#[test]
fn unique_heredoc_delimiter_avoids_content_collision() {
let content = "line 1\n__VT_WRITE_EOF__\nline 3";
let delimiter = super::unique_heredoc_delimiter(content).expect("should find a non-colliding delimiter");
assert_ne!(delimiter, "__VT_WRITE_EOF__");
assert!(!content.lines().any(|l| l == delimiter));
}
#[test]
fn unique_heredoc_delimiter_returns_none_when_all_collide() {
let content = "__VT_WRITE_EOF__\n__VT_WRITE_EOF_2__\n__VT_WRITE_EOF_3__";
assert!(super::unique_heredoc_delimiter(content).is_none());
}
}