use std::path::Path;
use serde_json::{json, Value};
use super::outcomes::{RequiredRead, ToolNextAction, ToolResultStatus};
use crate::errors::WinxError;
use crate::tool_registry::ToolKind;
pub(super) struct ErrorRecoveryPlan {
pub status: ToolResultStatus,
pub error_code: &'static str,
pub retryable: bool,
pub retry_after_ms: Option<u64>,
pub next_action: Option<ToolNextAction>,
pub required_reads: Vec<RequiredRead>,
}
impl ErrorRecoveryPlan {
fn terminal(status: ToolResultStatus, error_code: &'static str) -> Self {
Self {
status,
error_code,
retryable: false,
retry_after_ms: None,
next_action: None,
required_reads: Vec::new(),
}
}
fn read(
status: ToolResultStatus,
error_code: &'static str,
path: &Path,
ranges: Vec<String>,
arguments: Option<&Value>,
) -> Self {
let path = path.to_string_lossy().into_owned();
let next_action = read_action(arguments, &path, &ranges);
Self {
status,
error_code,
retryable: true,
retry_after_ms: None,
next_action,
required_reads: vec![RequiredRead { path, ranges }],
}
}
}
pub(super) fn classify(error: &WinxError, arguments: Option<&Value>) -> Option<ErrorRecoveryPlan> {
classify_with_path(error, arguments, None)
}
fn classify_with_path(
error: &WinxError,
arguments: Option<&Value>,
contextual_path: Option<&Path>,
) -> Option<ErrorRecoveryPlan> {
match error {
WinxError::MultiFilePlanError { path, source, .. } => {
classify_with_path(source, arguments, Some(path))
}
WinxError::FileReadRequired { path, ranges, .. } => Some(ErrorRecoveryPlan::read(
ToolResultStatus::NeedsRead,
"read_required",
contextual_path.unwrap_or(path),
ranges.clone(),
arguments,
)),
WinxError::FileChangedAfterEdit { path, .. } => Some(ErrorRecoveryPlan::read(
ToolResultStatus::Conflict,
"file_changed_after_edit",
contextual_path.unwrap_or(path),
Vec::new(),
arguments,
)),
WinxError::ConcurrentFileModification { path, .. } => {
let mut plan = ErrorRecoveryPlan::read(
ToolResultStatus::Conflict,
"file_changed_during_read",
contextual_path.unwrap_or(path),
Vec::new(),
arguments,
);
plan.retry_after_ms = Some(50);
Some(plan)
}
WinxError::FileRevisionMismatch { path, .. } => Some(ErrorRecoveryPlan::read(
ToolResultStatus::NeedsRead,
"revision_mismatch",
contextual_path.unwrap_or(path),
Vec::new(),
arguments,
)),
WinxError::SearchBlockNotFound(_) | WinxError::SearchBlockAmbiguous { .. } => {
let path = contextual_path
.map(Path::to_path_buf)
.or_else(|| string_argument(arguments, "file_path").map(Into::into))?;
Some(ErrorRecoveryPlan::read(
ToolResultStatus::Conflict,
if matches!(error, WinxError::SearchBlockAmbiguous { .. }) {
"search_block_ambiguous"
} else {
"search_block_not_found"
},
&path,
Vec::new(),
arguments,
))
}
WinxError::FileNotFound { .. } => {
Some(ErrorRecoveryPlan::terminal(ToolResultStatus::NotFound, "file_not_found"))
}
WinxError::UndoCheckpointNotFound { .. } => Some(ErrorRecoveryPlan::terminal(
ToolResultStatus::NotFound,
"undo_checkpoint_not_found",
)),
WinxError::FileOperationDenied { .. } => {
Some(ErrorRecoveryPlan::terminal(ToolResultStatus::Denied, "file_operation_denied"))
}
_ => None,
}
}
fn read_action(arguments: Option<&Value>, path: &str, ranges: &[String]) -> Option<ToolNextAction> {
if path.is_empty() {
return None;
}
let file_paths = if ranges.is_empty() {
vec![Value::String(path.to_string())]
} else {
ranges.iter().map(|range| Value::String(format!("{path}:{range}"))).collect()
};
let mut value = json!({"file_paths": file_paths});
copy_session_binding(arguments, &mut value);
Some(ToolNextAction {
tool: ToolKind::ReadFiles.as_str().to_string(),
instruction: "Call ReadFiles exactly as specified before another edit attempt. Bash/cat \
does not refresh the edit guard. Rebuild SEARCH from the returned current \
text, then make one corrected retry; never resend the failed edit unchanged."
.to_string(),
arguments: Some(value),
})
}
fn copy_session_binding(arguments: Option<&Value>, target: &mut Value) {
for key in ["thread_id", "workspace_root"] {
if let Some(value) = string_argument(arguments, key) {
target[key] = Value::String(value);
}
}
}
fn string_argument(arguments: Option<&Value>, key: &str) -> Option<String> {
arguments?.get(key)?.as_str().map(ToString::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::ReadRequirement;
#[test]
fn recovery_does_not_depend_on_human_message_text() -> crate::errors::Result<()> {
let error = WinxError::FileReadRequired {
path: "/workspace/src/lib.rs".into(),
reason: ReadRequirement::InsufficientCoverage,
ranges: vec!["20-40".into(), "90-".into()],
message: "wording may change freely".into(),
};
let arguments = json!({
"thread_id": "thread",
"workspace_root": "/workspace"
});
let plan = classify(&error, Some(&arguments)).ok_or_else(|| {
WinxError::ParseError("typed recovery plan was not produced".to_string())
})?;
assert_eq!(plan.status, ToolResultStatus::NeedsRead);
assert_eq!(plan.error_code, "read_required");
assert_eq!(plan.required_reads[0].ranges, ["20-40", "90-"]);
let action = plan.next_action.ok_or_else(|| {
WinxError::ParseError("recovery plan omitted next action".to_string())
})?;
let action_arguments = action.arguments.ok_or_else(|| {
WinxError::ParseError("recovery action omitted arguments".to_string())
})?;
assert_eq!(
action_arguments["file_paths"],
json!(["/workspace/src/lib.rs:20-40", "/workspace/src/lib.rs:90-"])
);
Ok(())
}
#[test]
fn multi_file_context_supplies_path_for_search_conflict() -> crate::errors::Result<()> {
let error = WinxError::MultiFilePlanError {
index: 2,
path: "/workspace/src/main.rs".into(),
source: Box::new(WinxError::SearchBlockNotFound("missing".into())),
};
let plan = classify(&error, None).ok_or_else(|| {
WinxError::ParseError("typed recovery plan was not produced".to_string())
})?;
assert_eq!(plan.error_code, "search_block_not_found");
assert_eq!(plan.required_reads[0].path, "/workspace/src/main.rs");
Ok(())
}
#[test]
fn generic_io_wording_is_not_promoted_to_a_recovery_transition() {
let error = WinxError::FileAccessError {
path: "/workspace/file".into(),
message: "hasn't been read but this is only an opaque OS message".into(),
};
assert!(classify(&error, None).is_none());
}
}