pub struct HintContext<'a> {
pub text: &'a str,
pub consecutive_errors: u32,
}
pub trait HintInjector: Send + Sync {
fn is_relevant(&self, ctx: &HintContext) -> bool;
fn hint(&self, ctx: &HintContext) -> String;
}
pub struct WritePathHint;
impl HintInjector for WritePathHint {
fn is_relevant(&self, ctx: &HintContext) -> bool {
ctx.text.contains("EISDIR") || ctx.text.contains("illegal operation on a directory")
}
fn hint(&self, ctx: &HintContext) -> String {
if ctx.consecutive_errors >= 3 {
"\n\n<CRITICAL>\n\
STOP using the Write tool — it has failed 3+ times with a directory path.\n\
You MUST use Bash instead: cat > ./path/to/file.ext << 'EOF'\n\
...content...\nEOF\n\
Write/Edit file_path MUST be a FILE (e.g. ./dir/file.txt), NEVER a directory (./dir).\n\
</CRITICAL>"
.to_string()
} else {
"\n\nHint: file_path is a directory, not a file. \
Use a full path like ./dir/file.txt (not ./dir). \
If Write keeps failing, use Bash: cat > file << 'EOF'"
.to_string()
}
}
}
pub struct FileNotFoundHint;
impl HintInjector for FileNotFoundHint {
fn is_relevant(&self, ctx: &HintContext) -> bool {
ctx.text.contains("ENOENT") || ctx.text.contains("No such file or directory")
}
fn hint(&self, ctx: &HintContext) -> String {
if ctx.consecutive_errors >= 3 {
"\n\n<CRITICAL>\n\
The file or directory does not exist. Create the parent directory first:\n\
mkdir -p ./parent/dir && cat > ./parent/dir/file.ext << 'EOF'\n\
...content...\nEOF\n\
</CRITICAL>"
.to_string()
} else {
"\n\nHint: File or directory not found. \
Create the parent directory first with: mkdir -p ./parent/dir"
.to_string()
}
}
}
pub struct ReadErrorHint;
impl HintInjector for ReadErrorHint {
fn is_relevant(&self, ctx: &HintContext) -> bool {
(ctx.text.contains("ENOENT") || ctx.text.contains("No such file"))
&& (ctx.text.contains("read") || ctx.text.contains("open"))
}
fn hint(&self, ctx: &HintContext) -> String {
if ctx.consecutive_errors >= 3 {
"\n\nHint: File does not exist. Check the path with: ls ./dir/ \
or find . -name 'filename'. Do NOT keep reading non-existent files."
.to_string()
} else {
"\n\nHint: File not found. Verify the path exists before reading.".to_string()
}
}
}
pub struct TaskDelegationHint;
impl HintInjector for TaskDelegationHint {
fn is_relevant(&self, ctx: &HintContext) -> bool {
ctx.text.contains("Unknown agent type")
|| ctx.text.contains("not a valid agent type")
|| ctx.text.contains("agent type")
}
fn hint(&self, ctx: &HintContext) -> String {
if ctx.consecutive_errors >= 2 {
"\n\nHint: STOP using the Task tool — use Bash, Write, Read, and Glob directly instead. \
Do NOT delegate to sub-agents."
.to_string()
} else {
"\n\nHint: Task delegation failed. Use direct tools (Bash, Write, Read) instead."
.to_string()
}
}
}
pub struct EditMismatchHint;
impl HintInjector for EditMismatchHint {
fn is_relevant(&self, ctx: &HintContext) -> bool {
ctx.text.contains("not found in file")
|| ctx.text.contains("old_string")
|| ctx.text.contains("not unique")
|| ctx.text.contains("No match found")
}
fn hint(&self, ctx: &HintContext) -> String {
if ctx.consecutive_errors >= 3 {
"\n\nHint: Edit keeps failing. Read the file first to see exact content, \
then retry with the correct old_string. Or use Write to replace the entire file."
.to_string()
} else {
"\n\nHint: Edit failed — old_string doesn't match file content. \
Read the file first to see the exact text."
.to_string()
}
}
}
pub struct NotInstalledHint;
impl HintInjector for NotInstalledHint {
fn is_relevant(&self, ctx: &HintContext) -> bool {
ctx.text.contains("command not found")
|| ctx.text.contains("Exit code 127")
|| ctx.text.contains(": not found")
|| ctx.text.contains("[tool error]\nExit code 127")
}
fn hint(&self, ctx: &HintContext) -> String {
if ctx.consecutive_errors >= 2 {
"\n\n<CRITICAL>\n\
STOP retrying. The command is NOT installed in this \
environment. Cosmetic variations (different mkdir, cd, \
flag order) will not change the outcome. Reply to the \
user about the missing dependency and ask whether to \
proceed differently. Do NOT call Bash with the same \
command again.\n\
</CRITICAL>"
.to_string()
} else {
"\n\nHint: that command is not installed. Do NOT retry \
with cosmetic variations (mkdir, cd, &&). Either tell \
the user the tool is missing, or use a different \
approach that doesn't require it."
.to_string()
}
}
}
pub struct GenericErrorHint;
fn pascal_error_prefix(text: &str) -> bool {
let t = text.trim_start();
let Some(first) = t.split(':').next() else {
return false;
};
if first.len() < 4
|| first.len() >= t.len()
|| !first.chars().all(|c| c.is_ascii_alphanumeric())
{
return false;
}
let caps = first.chars().filter(|c| c.is_ascii_uppercase()).count();
let has_lower = first.chars().any(|c| c.is_ascii_lowercase());
(has_lower && caps >= 2) || (!has_lower && caps == first.chars().count())
}
impl HintInjector for GenericErrorHint {
fn is_relevant(&self, ctx: &HintContext) -> bool {
ctx.text.starts_with("Error")
|| ctx.text.starts_with("error")
|| ctx.text.contains("Error:")
|| ctx.text.contains("error:")
|| ctx.text.contains("failed")
|| ctx.text.contains("Permission denied")
|| ctx.text.starts_with("BadResource")
|| pascal_error_prefix(ctx.text)
}
fn hint(&self, ctx: &HintContext) -> String {
if ctx.consecutive_errors >= 3 {
"\n\n<CRITICAL>\n\
This tool has failed 3+ times. STOP retrying the same approach.\n\
Use Bash as a fallback for file operations.\n\
Do NOT retry the exact same call — change your strategy.\n\
</CRITICAL>"
.to_string()
} else {
"\n\nHint: Tool call failed. If it keeps failing, \
try Bash as a fallback. Do NOT retry the exact same call."
.to_string()
}
}
}
pub fn inject_hints(text: &mut String, consecutive_errors: u32) {
let ctx = HintContext {
text,
consecutive_errors,
};
let injectors: &[&dyn HintInjector] = &[
&WritePathHint,
&ReadErrorHint,
&EditMismatchHint,
&TaskDelegationHint,
&FileNotFoundHint,
&NotInstalledHint,
&GenericErrorHint,
];
for injector in injectors {
if injector.is_relevant(&ctx) {
let hint = injector.hint(&ctx);
if !hint.is_empty() {
text.push_str(&hint);
return;
}
}
}
}
pub fn looks_like_error(text: &str) -> bool {
let ctx = HintContext {
text,
consecutive_errors: 0,
};
let injectors: &[&dyn HintInjector] = &[
&WritePathHint,
&ReadErrorHint,
&EditMismatchHint,
&TaskDelegationHint,
&FileNotFoundHint,
&NotInstalledHint,
&GenericErrorHint,
];
injectors.iter().any(|i| i.is_relevant(&ctx))
}
pub fn tool_call_is_productive(name: &str, args: &serde_json::Value) -> bool {
let n = name.to_ascii_lowercase();
if n.contains("write") || n.contains("edit") || n == "create" || n == "patch" {
return true;
}
if n.contains("bash") || n == "shell" || n == "run" || n.contains("exec") {
let cmd = args
.get("command")
.or_else(|| args.get("cmd"))
.or_else(|| args.get("script"))
.and_then(|v| v.as_str())
.unwrap_or("");
const WRITE_VERBS: &[&str] = &[
"cat >",
"cat>",
"tee ",
">>",
" > ",
"cargo build",
"cargo run",
"cargo test",
"rustc ",
"go build",
"go run",
"npm run",
"npm install",
"make ",
"python ",
"node ",
"touch ",
];
return WRITE_VERBS.iter().any(|v| cmd.contains(v));
}
false
}
pub fn bash_wander_hint(
total_tool_calls: usize,
productive_calls: usize,
enabled: bool,
) -> Option<String> {
if !enabled {
return None;
}
bash_wander_hint_inner(total_tool_calls, productive_calls)
}
fn bash_wander_hint_inner(total_tool_calls: usize, productive_calls: usize) -> Option<String> {
const MIN_CALLS: usize = 5;
if productive_calls > 0 || total_tool_calls < MIN_CALLS {
return None;
}
let n = total_tool_calls;
let body = if n >= 9 {
format!(
"<CRITICAL PROGRESS WATCHDOG>\n\
You have run {n} tool calls and have NOT written or edited a single file. \
Exploration will not complete the task. In your next message, call the write \
tool to create the required source file(s), then build and run to verify. \
Do not run any more read-only commands.\n</CRITICAL PROGRESS WATCHDOG>"
)
} else {
format!(
"<PROGRESS WATCHDOG>\n\
You have run {n} tool calls without writing or editing any file yet. If the \
task asks you to create or modify files, do that now with the write/edit tool \
instead of more exploration, then verify by building/running.\n</PROGRESS WATCHDOG>"
)
};
Some(format!("\n\n{body}"))
}
#[cfg(test)]
#[path = "hint_injector_tests.rs"]
mod hint_injector_tests;